Skip to content

Say what holds an object once a verdict is set on the loop above it - #2961

Merged
pyricau merged 2 commits into
mainfrom
verdicts-on-a-loop
Aug 21, 2026
Merged

Say what holds an object once a verdict is set on the loop above it#2961
pyricau merged 2 commits into
mainfrom
verdicts-on-a-loop

Conversation

@pyricau

@pyricau pyricau commented Aug 20, 2026

Copy link
Copy Markdown
Member

Two fixes to what setting verdicts by hand does on leak_asynctask_o.hprof, found one behind the other: the first stops a conflict dialog nobody could make sense of, the second is why the verdicts that dialog was about still showed nothing.

A conflict reported the two objects the wrong way round

Marking 0x12d00c50 (AsyncTask$SerialExecutor$1) as Expected and then 0x12d00c30 (AsyncTask$3) as Stuck — the two verdicts that make the chain name the faulty reference, which is what setting them is for — showed a conflict, saying the runnable is held by the task. Every chain the window draws has it the other way round: SerialExecutor.mActive → the runnable → val$r → the task → the activity.

The conflict check asked HeapDominatorTreemap.reaches once, in the one direction the verdict pair implied, and read a yes as "this object is above that one". Those three objects are on a loop, so reaches answers yes whichever pair and whichever way round it is asked:

AsyncTask$SerialExecutor$1  --val$r-->              AsyncTask$3
AsyncTask$3                 --FutureTask.runner-->  Thread "AsyncTask #1"
Thread "AsyncTask #1"       --<Java Local>-->       AsyncTask$SerialExecutor$1

The last edge is the frame of the method the worker thread is inside of, which JavaLocalReferenceReader reports as a reference of the thread. RootPathSearch puts a frame off, which is why no chain on screen goes that way, and why the direction the dialog reported was the one nobody could see.

So isAbove asks reaches both ways: an object that is reached by the object it reaches is on a loop with it, and neither of the two is above the other — which of them a chain shows first is decided by where that chain enters the loop. A loop is no conflict, and a conflict that is reported has the direction the chains show. Nothing goes unsaid either: a chain that does put one of them above the other still records the disagreement, as a reason reading Conflicts with.

And then the chain left the reference those verdicts had just identified

With the dialog gone, setting the same two verdicts changed the chain to MainActivity from

AsyncTask.SERIAL_EXECUTOR → SerialExecutor$1 → AsyncTask$3 → MainActivity$2 → MainActivity

to

Thread → <local variable> → MainActivity$2 → MainActivity

RootPathSearch puts two kinds of referrer off until there is nothing else left to walk — a reference the reader marked low priority, a running method's stack frame among them, and an object that shouldn't be in memory — and both went on one queue with nothing to pick between them. So between two put off ways the shorter one won, and here that is the frame: two steps from the activity where the executor is six. That chain has no Expected step on it, so nothing crosses to Stuck and no reference is marked. The two verdicts that identify the leak were what hid it.

Nothing offered the executor back either: the ways a detour could have run are node disjoint paths, and the executor's route reaches MainActivity$2 through AsyncTask$3, which the frame's route already took.

The two kinds are two queues now, a leak above a low priority reference, because a leak is an answer and a stack frame is not — an object marked Stuck is a reader saying this is the thing to fix, while a frame answers "what holds this" with "a method is running".

Costs one more int array the size of the heap dump and a maxOf per referrer. A/B on the 38 MB dump: the 2000 chains to its largest 2000 objects, identical before and after at 1,713,405 steps, in 14.8–15.2 s ranked against 15.0–15.8 s unranked. The sweep of the explorer's leaks against LeakCanary's over the ten real dumps in this repo was re-run either side of the change and gives the same leak fingerprints on all ten.

Tests

A heap dump shaped like that AsyncTask — three objects each holding the other two, the destroyed activity under them, and a frame of the worker thread holding that activity as the real dump's frames do — and six cases on it in HeapLeakStatusTest: that the loop's objects each reach the other, that two verdicts on it conflict neither way round, that a verdict on the activity below the loop still conflicts with one on it, so this isn't a check switched off, that a Stuck verdict sends the chain through the task rather than through the frame, and that setting both verdicts marks AsyncTask$SerialExecutor$1.val$r as the faulty reference. The two "do not conflict" cases fail without the first change, and the last two without the second.

🤖 Generated with Claude Code

pyricau and others added 2 commits August 20, 2026 17:20
Setting a verdict by hand on an object asks which verdicts already set it
disagrees with, and answered that with one `reaches` call: a verdict of
`Stuck` disagrees with an `Expected` one below it, so the check asked
whether the object being set reaches the object already set.

Two objects of a heap dump reaching each other is ordinary rather than
exotic, and the sample app's own `AsyncTask` leak is an instance of it:
the task holds the thread running it through `FutureTask.runner`, that
thread's stack frame holds the runnable the serial executor wrapped the
task in, and that runnable holds the task. So on
`leak_asynctask_o.hprof`, marking the runnable `Expected` and then the
task `Stuck` — the two verdicts that make the chain name the faulty
reference, which is the whole point of setting them — reported a conflict
and said the runnable is held by the task, when every chain the window
draws has it the other way round.

Neither object on a loop is above the other: which of them a chain shows
first is decided by where that chain enters the loop. So `isAbove` asks
`reaches` both ways round and a loop is no conflict, which leaves the
direction in a conflict that is reported the direction the chains show.
Nothing goes unsaid: a chain that does put one of them above the other
still records the disagreement, as a reason reading `Conflicts with`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`RootPathSearch` puts two kinds of referrer off until there is nothing
else left to walk: a reference the reader marked low priority — a running
method's stack frame among them — and an object that shouldn't be in
memory. Both went on one queue, with nothing to pick between them, so
between two put off ways the shorter one won.

Which on `leak_asynctask_o.hprof` is the frame. Marking
`AsyncTask$SerialExecutor$1` `Expected` and `AsyncTask$3` `Stuck` — the
two verdicts that make the chain name the faulty reference — turned

    AsyncTask.SERIAL_EXECUTOR → SerialExecutor$1 → AsyncTask$3 →
    MainActivity$2 → MainActivity

into

    Thread → <local variable> → MainActivity$2 → MainActivity

the frame being two steps from the activity where the executor is six.
That chain has no `Expected` step on it, so nothing crosses to `Stuck`
and no reference is marked: the two verdicts that identify the leak were
what hid it. Nothing offered the executor back either — the ways a
detour could have run are node disjoint paths, and the executor's route
reaches `MainActivity$2` through `AsyncTask$3`, which the frame's route
already took.

So the two kinds are two queues now, a leak above a low priority
reference, because a leak is an answer and a stack frame is not: an
object marked `Stuck` is a reader saying this is the thing to fix, while
a frame answers "what holds this" with "a method is running".

Costs one more int array the size of the heap dump and a `maxOf` per
referrer. A/B on the 38 MB dump: the 2000 chains to its largest 2000
objects, identical before and after at 1,713,405 steps, in 14.8–15.2 s
ranked against 15.0–15.8 s unranked. The sweep of the explorer's leaks
against LeakCanary's over the ten real dumps here was re-run either side
and gives the same leak fingerprints on all ten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau pyricau changed the title Report no conflict between two verdicts on objects that hold each other Say what holds an object once a verdict is set on the loop above it Aug 21, 2026
@pyricau
pyricau merged commit e015007 into main Aug 21, 2026
14 checks passed
@pyricau
pyricau deleted the verdicts-on-a-loop branch August 21, 2026 15:16
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