Skip to content

Commit 06cb6e0

Browse files
pyricauclaude
andcommitted
Mark the faulty reference on the chain
The chain already said which objects are stuck and which belong in memory. What it didn't say is which reference between them is the leak, which is the one thing to go and change: a reader following the statuses is being pointed at what the leak left behind rather than at the leak. So the one step that goes from an Expected object straight to a Stuck one reads "Holder.activity · faulty reference", bold, in the red of the objects under it, and the hover says why that reference and not another. Only that shape is marked. A stretch of several references between the two verdicts is a fault at one of those steps with nothing saying which, and a chain with nothing Expected above the stuck object can have its fault further up than the path reaches — a chain of Cleaners nothing recognizes would otherwise get its top reference marked for being where the walk started. So the mark is either the answer or absent, and setting a verdict by hand is what turns a stretch into one step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 67a3e45 commit 06cb6e0

14 files changed

Lines changed: 391 additions & 32 deletions

File tree

docs/shark-explorer-changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ uses, without the one for a newly recognized library leak:
3838
object as stuck makes it a leak and takes whatever it holds off the list. Kept between runs in
3939
`~/.shark-explorer/leak-statuses`, one file per heap dump.
4040
See [The verdict](shark-explorer.md#the-verdict).
41+
***The chain marks the faulty reference**: the one step going from an `Expected` object straight to a
42+
`Stuck` one reads `Holder.activity · faulty reference`, which is the leak itself rather than one of the
43+
objects it left behind, and the same reference the **Leaks** screen names that leak after. A chain whose
44+
two verdicts are further apart than one step carries no mark, since which reference in between is at fault
45+
is what isn't known — overrule a verdict in between and the mark appears.
46+
See [The verdict](shark-explorer.md#the-verdict).

docs/shark-explorer.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,20 @@ and only the garbage collector not having run keeps it here. The verdict means t
165165

166166
The reason is the rest of the answer, because half of these are about another object: an activity is red
167167
because its own `mDestroyed` is true, and the view under it is red because the activity is. `Activity↑ is
168-
stuck` is the chain saying so — and it is also how you find the faulty reference, which is the step between
169-
the last `Expected` object and the first `Stuck` one.
168+
stuck` is the chain saying so.
169+
170+
**The chain marks the faulty reference itself**: `Holder.activity · faulty reference`, in bold red, on the one
171+
step that goes from an `Expected` object straight to a `Stuck` one. It is the one line of a chain that says
172+
where to go and change code — the shades on the objects are what the leak left behind, this is the leak — and
173+
it is the same reference the Leaks screen names that leak after, so a row there and the chain you open from it
174+
name one thing.
175+
176+
**A chain with no such step carries no mark**, which is deliberate: what would be marked would be a guess
177+
drawn as an answer. With objects nothing knows either way about between the two verdicts, the fault is at one
178+
of those steps and nothing on the chain says which. With nothing `Expected` above the stuck object at all,
179+
what holds it may be something that should have let go of it too, so the fault can be further up than the
180+
chain reaches. Overruling a verdict is what closes either gap: say what you know about one object in between,
181+
and the mark appears on the step that leaves.
170182

171183
**The pencil beside it** overrules the verdict. Pick one of the three, type why, and **Set the verdict**:
172184

shark/shark-explorer/AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ deliberately avoid "leak" on an object, since a leak is one faulty reference and
7676
leaking points readers at the wrong thing. `LeakStatus.statusText` is the only place the two meet, so change
7777
a word there and nowhere else. `notes/decisions.md` has why each word won.
7878

79+
**Which reference the leak is, is decided once, over the whole path**`faultyReferenceIndexOrNull`, called
80+
from `withLeakStatuses` — and carried on `PathReference.isFaulty` for the drawing to read. Working it out in
81+
the window from the steps on screen looks equivalent and isn't: a pane draws stretches of a chain
82+
(`stepsBelow`, `stepsAfter`, a swapped-in `RootPathWay`), so the object that ends the stretch can be above
83+
what it shows.
84+
85+
**And it marks nothing unless one step crosses from `Expected` to `Stuck`.** `suspectReferenceIndexes`, the
86+
whole stretch between the two verdicts, is what a leak is *named* after — `suspectSubpath`, and Shark's leak
87+
fingerprint — so it is tempting to mark the top of it, which is what the first version did and what
88+
`notes/decisions.md` records as wrong: a chain whose objects all have no verdict got its top reference marked
89+
for being where the walk started. A longer stretch is a fault at one of several steps with nothing saying
90+
which, and nothing drawn is the answer for that.
91+
7992
Someone reading a heap dump can overrule what the inspectors made of an object, and the statuses they set
8093
are a `LeakStatusOverrides` **passed into every question whose answer they change**`summarize`,
8194
`rootPathTo`, `independentPathsBetween`, `independentPathsFromRoots`, `findLeaks`, `isBelowLeakingObject`

shark/shark-explorer/notes/decisions.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,8 +877,26 @@ window.
877877

878878
**"Faulty reference" is the name for the culprit**, the reference between the last `Expected` object and the
879879
first `Stuck` one, which is what `LeakGroup.suspectPath` starts at and what the leaks screen names each row
880-
after. Only prose so far — nothing on a chain marks that step yet, and marking it is the change that would
881-
actually put a reader's eye on the reference rather than on the objects.
880+
after. **And the chain marks it**: `Holder.activity · faulty reference`, bold, in the red of the objects it
881+
left behind, which is the change that actually puts a reader's eye on the reference rather than on the
882+
objects. `PathReference.isFaulty`, worked out in `withLeakStatuses`, and `suspectSubpath` names the leaks
883+
screen's rows off the same statuses — so where a leak is a single reference, a row there and the chain opened
884+
from it name one thing.
885+
886+
**Only a single step between the two verdicts is marked.** `faultyReferenceIndexOrNull` asks for an `Expected`
887+
object with a `Stuck` one directly under it, and marks nothing otherwise. The first attempt marked the top of
888+
the suspect stretch instead, the way LeakCanary underlines all of it, and it was wrong in the case that
889+
matters: a chain of `Cleaner`s with no verdict on any object of it had its top reference marked, which is a
890+
reference named for being where the walk started rather than for anything read off the heap dump. Two shapes
891+
make the stretch longer than a step and neither supports a mark — objects nothing knows either way about in
892+
between, where the fault is at one of those steps and nothing says which; and nothing `Expected` above the
893+
stuck object at all, where what holds it may be something that should have let go too, so the fault can be
894+
further up than the path reaches. A guess drawn in the same bold red as an answer costs more than no mark,
895+
because being the one line to act on is the whole of what the mark is for. Shortening the stretch is what
896+
setting a verdict by hand does, and the mark appears when it becomes one step.
897+
898+
**Nothing is marked on a chain with nothing stuck on it**, which is most chains in a heap dump. A leak is a
899+
reference the evidence points at, and there is no evidence until something below it is known not to belong.
882900

883901
**A pencil, left of the status, rather than a "Set by hand…" button.** It is what changes the answer, so it
884902
belongs where the eye already is, and a text button pushed the reason onto a second line of a 320dp panel.

shark/shark-explorer/shark-explorer-app/src/main/java/shark/explorer/app/LeaksScreen.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ private fun OnTheWayOutHeader(
218218
HorizontalDivider(thickness = SECTION_RULE_WIDTH, color = SECTION_RULE_COLOR)
219219
Column(
220220
Modifier.fillMaxWidth()
221-
.clickableRow(onToggle)
221+
.clickableRow(onClick = onToggle)
222222
.padding(start = 8.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
223223
verticalArrangement = Arrangement.spacedBy(2.dp)
224224
) {
@@ -622,7 +622,9 @@ internal const val NAME_HINT =
622622
"which is where to look on the chain to see it. They are one reference for most leaks. Everything " +
623623
"above the first is the app working as intended and everything below the last is what the leak is " +
624624
"holding, so neither is part of what makes this leak this leak. For an object on its way out it is " +
625-
"the one reference the collector hasn't cleared yet, which is the whole of why it is still here."
625+
"the one reference the collector hasn't cleared yet, which is the whole of why it is still here. Where " +
626+
"a leak is a single reference, the chain drawn for an object under this row marks it, so the row and " +
627+
"the step are one thing said twice."
626628

627629
internal const val LEAK_FINGERPRINT_HINT =
628630
"A hash of how this leak is held, which is the same for the same leak in the next heap dump of this app " +

shark/shark-explorer/shark-explorer-app/src/main/java/shark/explorer/app/PathDrawing.kt

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,12 @@ private fun ReferenceLine(reference: PathReference) {
457457
) {
458458
append(reference.displayName())
459459
}
460+
// The one line of a chain that says where to go and change code, so it is the one thing on a
461+
// chain drawn bold: the shades on the objects say what a leak left behind, and this says what the
462+
// leak is. See [shark.explorer.PathReference.isFaulty].
463+
if (reference.isFaulty) {
464+
withStyle(FAULTY_REFERENCE_SPAN) { append(" $FAULTY_REFERENCE") }
465+
}
460466
// Which is what makes a chain through a known leak readable as one: the objects below this
461467
// reference are held by code the app doesn't control, and this is the reference that does it.
462468
if (reference.libraryLeak != null) {
@@ -466,12 +472,16 @@ private fun ReferenceLine(reference: PathReference) {
466472
style = MaterialTheme.typography.bodySmall
467473
)
468474
}
469-
// What is known about the leak is a paragraph, which belongs on hover rather than in the chain.
470-
val description = reference.libraryLeak?.description?.takeIf { it.isNotEmpty() }
471-
if (description == null) {
475+
// Why this reference and not another, and what is known about a leak somebody else's code holds: both are
476+
// paragraphs, so both belong on hover rather than in the chain.
477+
val explanation = listOfNotNull(
478+
FAULTY_REFERENCE_HINT.takeIf { reference.isFaulty },
479+
reference.libraryLeak?.description?.takeIf { it.isNotEmpty() }
480+
)
481+
if (explanation.isEmpty()) {
472482
line()
473483
} else {
474-
Hint(description, line)
484+
Hint(explanation.joinToString("\n\n"), line)
475485
}
476486
}
477487

@@ -628,6 +638,20 @@ private const val LOCAL_VARIABLE = "<local variable>"
628638
/** What a reference Shark knows leaks in code the app doesn't control says about itself. */
629639
internal const val LIBRARY_LEAK = "· known library leak"
630640

641+
/**
642+
* And what the reference the leak is says about itself, which is the one thing on a chain to go and fix.
643+
*
644+
* Two words rather than a sentence, in the red of the objects it left behind: a chain is read as a column
645+
* of names, and this is the line to stop on.
646+
*/
647+
internal const val FAULTY_REFERENCE = "· faulty reference"
648+
649+
/** Why this reference of the chain and not another, which is a paragraph and so sits on hover. */
650+
internal const val FAULTY_REFERENCE_HINT =
651+
"The leak itself: what this reference is read on is expected to be in memory, what it points at should " +
652+
"have been gone, so this is the one reference that should have been cleared. Everything under it is " +
653+
"only still here because of it, and clearing this one is what would let all of it go."
654+
631655
/** Wide enough for the line, its arrow head and a ring to sit clear of the text beside it. */
632656
private val GUTTER_WIDTH = 26.dp
633657

@@ -721,6 +745,9 @@ private val MUTED_SPAN = SpanStyle(color = MUTED_TEXT, fontWeight = FontWeight.N
721745
/** And for the words saying a reference is a known library leak, in the red of the leaks it explains. */
722746
private val LIBRARY_LEAK_SPAN = SpanStyle(color = LEAKING_TEXT, fontWeight = FontWeight.Normal)
723747

748+
/** The same red for the reference the leak is, bold: nothing else on a chain is the thing to fix. */
749+
private val FAULTY_REFERENCE_SPAN = SpanStyle(color = LEAKING_TEXT, fontWeight = FontWeight.Bold)
750+
724751
/** The letter drawn in an object's circle, which is what kind of object it is. */
725752
private val HeapObjectKind.badgeLetter: String
726753
get() = when (this) {

shark/shark-explorer/shark-explorer-app/src/test/java/shark/explorer/app/LeakStatusSectionTest.kt

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ import shark.explorer.statusText
3939

4040
/**
4141
* Whether the object a tab is on is meant to be in memory, said at the top of the panel that says what the
42-
* object is, and changed by hand from there.
42+
* object is, changed by hand from there, and what the answer marks on the chain beside it.
4343
*
4444
* What the statuses mean and how two of them disagree is `LeakStatusTest` and `HeapLeakStatusTest` in
4545
* `shark-explorer-core`, and where they are kept is `LeakStatusFileTest`. What is only true here is that the
46-
* panel says what the heap dump says, that changing one asks for the reason before it writes anything, and
47-
* that a status which cannot be true alongside another is shown rather than settled quietly.
46+
* panel says what the heap dump says, that changing one asks for the reason before it writes anything, that
47+
* a status which cannot be true alongside another is shown rather than settled quietly, and that the chain
48+
* says which reference the leak is.
4849
*/
4950
@OptIn(ExperimentalTestApi::class)
5051
class LeakStatusSectionTest {
@@ -207,6 +208,34 @@ class LeakStatusSectionTest {
207208
}
208209
}
209210

211+
/**
212+
* What the verdicts are for: they are about objects, and the thing to go and fix is the one reference going
213+
* from an object that belongs in memory to one that doesn't.
214+
*
215+
* Both halves in one window, because they are one answer: the reference is marked from the verdicts either
216+
* side of it, so a verdict set by hand is what puts the mark on the chain and what takes it off again.
217+
*/
218+
@Test fun `the chain marks which reference the leak is, and a hand can take the mark off`() {
219+
explorerUiTest {
220+
// Set in a run before this one, and what leaves a single reference below it: with nothing on this
221+
// chain known to belong in memory, the fault is at either of its two steps and neither is marked.
222+
openHeapDump(setAlready = { holderIsExpected() }) { it.activityObjectId }
223+
224+
onNodeWithText("$FAULTY_STEP $FAULTY_REFERENCE").assertIsDisplayed()
225+
226+
changeStatus()
227+
choose(LeakStatus.NOT_LEAKING)
228+
write(TYPED_REASON)
229+
set()
230+
231+
// Nothing on this chain is stuck any more, so there is no reference to point at — and the step is
232+
// still drawn, which is the mark being about the leak rather than about the reference.
233+
waitUntilAtLeastOneExists(hasText(TYPED_REASON, substring = true), SAVE_TIMEOUT_MILLIS)
234+
onNodeWithText(FAULTY_REFERENCE, substring = true).assertDoesNotExist()
235+
onNodeWithText(FAULTY_STEP).assertIsDisplayed()
236+
}
237+
}
238+
210239
/**
211240
* The other half of setting a status: the leaks are read through them, so the list changes rather than
212241
* only the colour of one object. See [shark.explorer.HeapDominatorTreemap.findLeaks].
@@ -302,14 +331,22 @@ class LeakStatusSectionTest {
302331
}
303332

304333
/** A status set on the holder in a run before the one under test, which is the file being there. */
305-
private fun holderIsLeaking() {
334+
private fun holderIsLeaking() = holderWasSetTo(LeakStatus.LEAKING, HOLDER_REASON)
335+
336+
/** And the other way: a holder that belongs in memory, with the activity below it still stuck. */
337+
private fun holderIsExpected() = holderWasSetTo(LeakStatus.NOT_LEAKING, HOLDER_EXPECTED_REASON)
338+
339+
private fun holderWasSetTo(
340+
status: LeakStatus,
341+
reason: String
342+
) {
306343
statusFile().write(
307344
LeakStatusOverrides.of(
308345
listOf(
309346
LeakStatusOverride(
310347
objectId = heapDump.holderObjectId,
311-
status = LeakStatus.LEAKING,
312-
reason = HOLDER_REASON
348+
status = status,
349+
reason = reason
313350
)
314351
)
315352
)
@@ -352,9 +389,15 @@ class LeakStatusSectionTest {
352389
/** And what the object holding it is called, where the dialog names that one. */
353390
private const val HOLDER_NAME = "Holder instance"
354391

392+
/** The step of the chain that holds the destroyed activity, which is the reference to clear. */
393+
private const val FAULTY_STEP = "Holder.activity"
394+
355395
/** And what it was given as its reason, which the dialog has to show to be overruled. */
356396
private const val HOLDER_REASON = "this holder is the one to fix"
357397

398+
/** The reason for the other status a run before this one set on the holder. */
399+
private const val HOLDER_EXPECTED_REASON = "this holder is the app's own cache"
400+
358401
/** What the dialog says about a status set on an object that holds the one being changed. */
359402
private const val CONFLICT_ABOVE = "holds it"
360403

shark/shark-explorer/shark-explorer-core/src/main/java/shark/explorer/DominatorPaths.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,23 @@ data class PathReference(
125125
val ownerClassName: String,
126126
val locationType: ReferenceLocationType,
127127
/** Set for the references Shark knows leak in code an app doesn't control, null for the rest. */
128-
val libraryLeak: LibraryLeakPattern?
128+
val libraryLeak: LibraryLeakPattern?,
129+
/**
130+
* Whether this is the reference the leak *is*: the one step of the path that goes from an object expected
131+
* to be in memory to a stuck one.
132+
*
133+
* **The one thing on a chain that says where to go and change code.** A status is about an object, and
134+
* every object below this reference reads as stuck because of it — so a reader following the statuses is
135+
* being pointed at what a leak left behind, and this is being pointed at the leak. The same reference the
136+
* leaks screen names a leak after, wherever a leak is a single reference. See
137+
* [faultyReferenceIndexOrNull], which is where the rule and the three ways a path has no faulty reference
138+
* are, and [LeakGroup.suspectPath].
139+
*
140+
* False for every reference of most paths of a heap dump, since it takes the two verdicts either side of
141+
* one reference to be true. Worked out once the whole path is known, like [PathStep.leakStatus], for the
142+
* same reason: the statuses of the objects either side of it are what decide it.
143+
*/
144+
val isFaulty: Boolean = false
129145
)
130146

131147
/**

0 commit comments

Comments
 (0)