Skip to content

Say whether an object is stuck, and which reference made it so - #2952

Merged
pyricau merged 7 commits into
mainfrom
change-it-to-unknown-not-leaki
Aug 20, 2026
Merged

Say whether an object is stuck, and which reference made it so#2952
pyricau merged 7 commits into
mainfrom
change-it-to-unknown-not-leaki

Conversation

@pyricau

@pyricau pyricau commented Aug 10, 2026

Copy link
Copy Markdown
Member

Shark Explorer already worked out a LeakStatus for every object of every chain it drew. Two things were missing: the status of the object the tab is on, and any way to disagree with it.

The verdict on an object is the first thing "What it is" says

At the top of the details panel, under the object's name, above its size — a conclusion at the head of the column that holds the evidence for it — in the colours the chain beside it uses, with the reason under it, because half of these are about another object: an activity is red because its own mDestroyed is true, and the view under it is red because the activity is.

Loud for the two statuses that mean something, quiet for the third. Most of a heap dump is objects nothing knows either way about, so a shaded, bold answer on every one of them would be a line nobody reads by the time it says something. A glyph as well as a colour (✓ ? ✗), so it doesn't rest on the colour alone. Nothing at all on the tab a window opens with: the whole heap dump is no object of it.

The words are new, and no word built on "leak" is on an object. Leaking / Not leaking / Unknown became Stuck / Expected / Unknown, under a header saying Verdict — one word each, like the Retained and Shallow labels beside them, because a verdict is read a dozen times down one chain and once per row of the leaks screen.

A leak is one faulty reference that should have been cleared, and everything under it is retained by that single mistake — so Leaking or Leaked on twenty objects points a reader at the twenty rather than at the one thing to fix. Stuck says the object's situation without accusing it, and it is the only candidate that asks a question instead of closing one: something is holding this, what? Expected says its presence in memory is legitimate at this point in the app's life. The two aren't antonyms on purpose — an object in use can't be collected either, so the good side has to answer a different question than "can it leave".

No other analyser has a verdict like this to borrow words from, which is worth knowing before bikeshedding it. JProfiler classifies objects by reference type ("strongly referenced", "retained by soft references") and by age (Mark Heap, then "new" and "old"); YourKit by reachability scope (strong, softly, weakly reachable, unreachable, pending finalization); Eclipse MAT names places rather than objects (leak suspect, accumulation point, keep-alive path); dotMemory has key retention paths. None of them labels an object as leaking, because none has watched objects or framework inspectors to do it with. What they share is the frame, and JProfiler says it outright: whether objects "are still legitimately on the heap or if a faulty reference keeps them alive". That is now what this repo calls the culprit — the reference between the last Expected object and the first Stuck one, which is what LeakGroup.suspectPath starts at and what every row of the Leaks screen is named after.

The verdict means the same thing everywhere, including on a watched object nothing reaches any more: it was expected to be gone, and only the collector not having run keeps it here, so it reads Stuck like the rest. Where it sits on that scale is what the Leaks screen's Unreachable section is for; a fourth value would have made the verdict mean something different in one corner of the window.

One LeakStatus.statusText is where the words live, so the chain, the panel, the dialog, the checkbox that shades them over the map and the reasons propagated along a chain (Activity↓ is expected, Activity↑ is stuck) all say the same thing. The identifiers don't move: LeakStatus, LEAKING, leakStatusesOf stay Shark's names, because the code is where agreeing with shark.LeakTraceObject.LeakingStatus matters.

The chain marks the faulty reference

A verdict is about an object, and propagation paints every object under a leak as stuck — twenty rows of red for one mistake, which is the shape of the misdirection the words above were changed to avoid. So the chain marks the reference itself: Holder.activity · faulty reference, bold, in the red of the objects it left behind, on the one step that goes from an Expected object straight to a Stuck one. It is the one line of a chain that says where to go and change code, and where a leak is a single reference it is the same one the Leaks screen names that leak after, so a row there and the chain opened from it name one thing.

It is decided once, over the whole path — faultyReferenceIndexOrNull, called from withLeakStatuses — and carried on PathReference.isFaulty for the drawing to read. Deciding it in the window from the steps on screen looks equivalent and isn't: a pane draws stretches of a chain (stepsBelow, stepsAfter, a swapped-in way round), so the object that ends the stretch can sit above what it shows.

Nothing is marked unless one step crosses between the two verdicts. LeakCanary underlines the whole stretch between them, and suspectSubpath still names a leak after all of it — but a mark is a claim about one reference, and there are two shapes where the path doesn't support one. With objects nothing knows either way about in between, the fault is at one of those steps and the heap dump doesn't say which. With nothing Expected above the stuck object at all, what holds it may be something that should have let go too, so the fault can be further up than the path reaches — the first version of this marked the top of the stretch and put · faulty reference on the top reference of a chain of Cleaners with no verdict on any object of it, which is a reference named for being where the walk started. A guess drawn in the same bold red as an answer costs more than no mark, because being the one line to act on is the whole of what the mark is for. Overruling a verdict is what closes either gap: say what you know about an object in between, and the mark appears on the step that leaves.

A pencil overrules it

Left of the answer, where the eye already is, rather than a "Set by hand…" button after it. It takes one of the three statuses and a reason, and the reason is not optional: a status set by hand overrules the heap dump, so without the why it is an assertion the next reader — a colleague, an agent, the same person in a month — has no way to check. What the inspectors said is kept as the record of what was overruled, exactly the way a conflict between two inspectors is recorded, and set by hand — … marks it wherever it is read.

Overriding always wins, which is the one place this differs from two inspectors disagreeing. There the object still being needed wins, because two inspectors are two halves of one automated reading. A hand is not that, and a rule that weighed the two would mean a status that can't be set to the one an inspector already picked.

And what it contradicts is shown, not settled quietly

A status decides what the objects it holds or is held by are, so two hand-set statuses disagree exactly when one of the objects reaches the other. When the one being set does that, every status it disagrees with is listed by name, with the reason it was given and what it would become. Keep this and flip those keeps the new one and flips them, with what they said kept as part of the new reason; Undo leaves the heap dump exactly as it was. Nothing is written until one of the two is picked, and then it is one write of the lot.

Flipping always resolves it, which is why solving is one button: NOT_LEAKING propagates upwards only and LEAKING downwards only, so the pair that can disagree is always those two.

The leaks are read through them too

Setting a verdict changes which objects are leaks, not only how one of them reads. Mark an object as stuck halfway up a chain and it becomes a leak, while whatever it was holding drops off the list — that object is now only in memory because of this one, which is the rule foldedIntoWhatHoldsThem already applied to what the inspectors found. Mark a leak as expected and it leaves the list, and what it held can become a leak of its own.

So the candidate set is the dump's own minus everything set to anything but LEAKING plus everything set to it; RootPathSearch goes round what a hand marked exactly as it goes round what the inspectors did, or a leak would be grouped by a chain that disagrees with the statuses drawn on it; and the list is worked out again per set of statuses and kept until the next one, since a status is set by hand and this is seconds.

The price is that a LeakGroup.leakFingerprint matches the one LeakCanary prints for the same objects only while nothing is set by hand — the fingerprint hashes the stretch between the last expected object and the first stuck one, and moving that stretch is what setting a verdict is for.

Notes on the shape of it

  • A status set by hand is an argument to every read, never state on the tree. summarize, rootPathTo, independentPathsBetween, independentPathsFromRoots, findLeaks and isBelowLeakingObject all take a LeakStatusOverrides, and the window's effects are keyed on it. A value rather than state, because the tree is read from one thread while the window is composed on another: overrides in the tree would mean a chain drawn from one set of them and the panel beside it from another.
  • One tab separated file per heap dump in ~/.shark-explorer/leak-statuses, columns named at the top, reasons escaped, lines sorted by address — so it reads as evidence and can be diffed or pasted into an issue. A line that can't be read is skipped with a log line rather than thrown over, since it is hand editable on purpose.
  • Nothing is applied that wasn't written, and nothing is written before the file has been read: an empty set of statuses saved because the disk was slow is every status of that dump deleted.

Testing

LeakStatusTest for the propagation with statuses set by hand in it, HeapLeakStatusTest for the panel, the chain, reaches, the conflicts and the leaks list changing under a status — including a chain that stops going round an object once a hand says it is expected — LeakStatusFileTest for the round trip through disk, and LeakStatusSectionTest for the panel, the pencil, the reason being required, taking one back off, the conflict flow both ways, and the leaks screen following a status set before the window opened.

For the mark: HeapLeaksTest for it being the very reference the Leaks screen names the leak after, and for the three ways a chain carries none — nothing stuck on it, nothing Expected above what is stuck, and two references between the two verdicts; HeapLeakStatusTest for it appearing one step further down once a hand says the activity is expected; and LeakStatusSectionTest for the window drawing it and for it going away — the step still drawn — once the verdict under it is overruled. Two heap dumps in LeakHeapDumps.kt are new, an Application holding a destroyed activity directly and through a holder, since every dump there until now had objects nothing knows either way about above the leak.

./gradlew :shark:shark-explorer:shark-explorer-core:check :shark:shark-explorer:shark-explorer-app:check passes.

🤖 Generated with Claude Code

@pyricau pyricau changed the title Say whether an object is leaking, and let a hand overrule it Say whether an object is meant to be here, and let a hand overrule it Aug 11, 2026
@pyricau pyricau changed the title Say whether an object is meant to be here, and let a hand overrule it Say whether an object leaked, and let a hand overrule it Aug 11, 2026
@pyricau pyricau changed the title Say whether an object leaked, and let a hand overrule it Say whether an object is stuck, and let a hand overrule it Aug 20, 2026
@pyricau pyricau changed the title Say whether an object is stuck, and let a hand overrule it Say whether an object is stuck, and which reference made it so Aug 20, 2026
pyricau and others added 6 commits August 20, 2026 16:37
Someone reading a heap dump knows things the inspectors can't: a cache
that is meant to hold what it holds, a destroyed activity deliberately
kept for one more frame. So a status can be set on an object, and it
wins over what the inspectors made of it — with the reason kept, since a
status nobody can check makes every other status in the dump worth less,
and with what it overruled recorded after it.

Passed into every question whose answer it changes rather than held by
the tree, because the tree is read from one thread while a window is
composed on another: overrides in the tree would draw a chain from one
set of them and the row above it from another. findLeaks() deliberately
takes none, so that a leak's fingerprint stays comparable to the one
LeakCanary prints for the same objects.

Two statuses can contradict each other, since each decides what the
objects above or below it are, so leakStatusConflictsWith answers which
ones before any of them is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Under the title that says which object, in the colours a chain draws a
status in, since it is the same answer read in one place rather than a
dozen. Loud for the two statuses that mean something and quiet for the
third: most of a heap dump is objects nothing knows either way about, so
a shaded Unknown on every tab would be a banner nobody reads by the time
it says something.

"Set by hand…" asks for a status and the reason, and then, only when
there is one, for what the new status disagrees with: every status it
cannot be true alongside, with the reason each was given, because
whoever is about to overrule one is the only person who can weigh the
two. Keeping the new one flips them and says so in their reasons; undoing
leaves the heap dump as it was. Nothing is written until that is
answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status of the object a tab is on moves out of the title row and into
the top of "What it is", under a header, because it is a conclusion and
that panel is the evidence for it. Above the bitmap preview, so a tall
screenshot can't push it out of view, and the "Set by hand…" button is
now a pencil left of the answer, where the eye already is.

"Leaking" and "Not leaking" become "Shouldn't be here" and "Meant to be
here", "Unknown" becomes "Nobody knows". LeakCanary's leak traces print
`Leaking: YES / NO / UNKNOWN`, and matching them was the case for the old
words, but calling an object "leaking" reads as it leaking something as
easily as being the thing left behind — and a leak here is a reference,
not an object. The words the inspectors' answer deserves are the ones
this module's own prose already used. Only the display text moves:
LeakStatus, LEAKING and leakStatusesOf keep Shark's names.

And findLeaks is read through the statuses too, which it wasn't. Setting
one changes which objects are leaks, not only how one of them reads: mark
an object leaking halfway up a chain and it becomes a leak, while what it
holds drops off the list, being only in memory because of it. So the
candidate set follows the statuses, RootPathSearch goes round what a hand
marked exactly as it goes round what the inspectors found, and the list is
worked out again per set of statuses. The cost is that a leak fingerprint
matches LeakCanary's only while nothing is set by hand — which is the
stretch of chain a status is set to move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verdict on an object is read a dozen times down one chain and once
per row of the leaks screen, so it has to be a label rather than a
sentence: "Shouldn't be here" and "Meant to be here" were neither
skimmable there nor short enough for a 320dp panel.

"Leaked" keeps what the longer wording was for — this repo's rule is
that a leak is a reference and not an object, and "leaking" reads as
the object leaking something as easily as being the thing left behind,
which the past tense settles. "Needed" is what an inspector actually
recognizes about the other end. The panel's header carries the question
so the three labels don't have to, in one word like the Retained and
Shallow beside it, and the checkbox that shades them over the map says
"Leaked" too rather than being the one surface left calling it
something else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No word built on "leak" belongs on an object. A leak is one faulty
reference that should have been cleared, and everything under it is
retained by that single mistake — so "Leaked" on twenty objects points a
reader at the twenty rather than at the one thing to fix. "Stuck" says
the object's situation without accusing it, and it is the only candidate
that asks a question instead of closing one: something is holding this,
what? "Expected" says its presence in memory is legitimate at this point
in the app's life.

No other analyser has a verdict like this to borrow words from, checked
before choosing: JProfiler classifies objects by reference type and by
age, YourKit by reachability scope, MAT and dotMemory name places rather
than objects. What they share is the frame — JProfiler asks whether
objects "are still legitimately on the heap or if a faulty reference
keeps them alive" — which is where the name for the culprit edge comes
from, and it is now what the leaks screen and the KDoc call it.

An object nothing reaches any more stays Stuck like any other: it was
expected to be gone, and only the collector not having run keeps it
here. A fourth value would have made the verdict mean something
different in one corner of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau force-pushed the change-it-to-unknown-not-leaki branch from 0e13ae8 to 06cb6e0 Compare August 20, 2026 14:51
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>
@pyricau
pyricau force-pushed the change-it-to-unknown-not-leaki branch from 06cb6e0 to 45b05cb Compare August 20, 2026 14:55
@pyricau
pyricau merged commit 2c86388 into main Aug 20, 2026
14 checks passed
@pyricau
pyricau deleted the change-it-to-unknown-not-leaki branch August 20, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant