Skip to content

Report what object inspectors read as synthetic fields - #3016

Open
pyricau wants to merge 4 commits into
mainfrom
nexus/task-59d75ee8
Open

pyricau wants to merge 4 commits into
mainfrom
nexus/task-59d75ee8

Conversation

@pyricau

@pyricau pyricau commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

LeakCanary and Shark Dive described an object in two ways. LeakCanary's inspectors wrote labels, each spelling its values its own way: Thread name: 'main', Fragment.mTag=home, mRunning = true. Shark Dive read strings, threads, arrays and bitmaps itself, into an ObjectContent of its own. Now an inspector calls ObjectReporter.addSyntheticField(name, value) for each value it reads. A leak trace prints it like a field, Thread.name = "main", and Shark Dive prints the same line.

Four commits, meant to be read one at a time:

  1. Report what object inspectors read as synthetic fields.
    • SyntheticField(name, value), ObjectReporter.addSyntheticField() and ObjectReporter.syntheticFields, LeakTraceObject.syntheticFields.
    • Every built-in inspector that read a value reports a synthetic field. The inspector formats the value: a string quoted and escaped, another object as android.os.Handler@0x12c0a6b8, a constant by name, as in TYPE_TOAST (2005).
    • New STRING, ARRAY and BITMAP inspectors, in the default lists.
    • LeakCanary's two leak trace UIs print the synthetic fields before the labels.
    • LeakTraceObject's serialVersionUID is bumped.
  2. Draw Shark Dive's objects from the inspectors' synthetic fields. ObjectContent is gone, and nothing in Shark Dive picks a field out by name. The details panel and a path print one field per line. A row of a list, a leaking object and the card at the pointer join them on one line. The agent JSON has a syntheticFields object keyed by name.
  3. Link a binder stub to a page on fixing what it holds. The verdict reason is X is a binder stub. The label is Look for the leak in what X holds, see https://square.github.io/leakcanary/binder-stubs/, and docs/binder-stubs.md is that page.
  4. Say how far Message.when is from the heap dump in a larger unit too. Message.when is 7200000 ms (2 hours) before the heap dump.

A leak trace now ends like this, from shark/shark-android/src/test/resources/gcroot_unknown_object.hprof:

├─ androidx.fragment.app.Fragment[] array 0x13b398a8
│    Verdict: Expected (DvFragment↓ is expected)
│    length = 6
│    ↓ Fragment[3]
├─ com.jumio.qa.view.DvFragment instance 0x13aaa748
│    Verdict: Expected (Fragment.mLifecycleRegistry.state is CREATED)
│    Fragment.mTag = "android:switcher:2131296475:3"
│    ↓ DvFragment.mRoot
│                 ~~~~~
╰→ android.widget.ScrollView instance 0x13aaa868
​     Verdict: Stuck (ObjectWatcher was watching this)
​     Retaining 5.8 kB in 89 objects
​     KeyedWeakReference.key = "e4157a1a-b2ce-4775-9520-d5cfab82e6df"
​     KeyedWeakReference.watchDurationMillis = 75359
​     KeyedWeakReference.retainedDurationMillis = 70357
​     View.mWindowAttachCount = 1
​     View not part of a window view hierarchy
​     View.mAttachInfo is null (view detached)
​     mContext instance of com.jumio.qa.MainActivity with mDestroyed = false

API

  • LeakTraceObject has syntheticFields as a new last constructor parameter with a default. Its <init> and copy signatures change, and Kotlin source compiles unchanged. ⚠️ in the changelog.
  • Everything else in shark.api and shark-android.api is new API.

Left as they were

  • Verdict reasons are still strings.
  • How far Message.when is from the heap dump stays a label, next to the Message.when synthetic field.
  • The CONTEXT_FIELD lines, such as mContext instance of … with mDestroyed = false, stay labels.

🤖 Generated with Claude Code

@pyricau
pyricau force-pushed the nexus/task-59d75ee8 branch 2 times, most recently from 683c9c3 to e3d581b Compare October 16, 2026 09:23
@pyricau pyricau changed the title Report what object inspectors read as typed facts Report what object inspectors read as synthetic fields Oct 16, 2026
pyricau and others added 2 commits October 21, 2026 17:22
Inspectors described an object with labels only, and each one formatted the
values it read its own way: `Thread name: 'main'`, `Fragment.mTag=home`,
`mRunning = true`, `key = ...`. Shark Dive read strings, threads, arrays and
bitmaps itself instead of asking the inspectors.

An inspector now calls ObjectReporter.addSyntheticField(name, value). The name
is the class declaring the field and then the field, and the value is a
string. A leak trace prints each one as `name = value` before the labels, and
LeakTraceObject.syntheticFields carries them into the analysis. Labels stay for
sentences.

Every built-in inspector that read a value reports it as a synthetic field.
Three new inspectors report what Shark Dive used to read on its own: STRING,
ARRAY and BITMAP. Shark Dive passes the synthetic fields into the leak traces
it renders, so they stay identical to LeakCanary's.

LeakCanary stores LeakTraceObject in its database with Java serialization, so
its serialVersionUID and the database version change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shark Dive read a string's characters, a thread's name, an array's length and
a bitmap's size itself, into an ObjectContent of its own. The inspectors read
the same fields and the leak trace beside it printed them differently.

ObjectContent is gone. Every model type carries the inspectors' synthetic
fields, and every surface prints them as the leak trace does, without picking
any out by name. The details panel and a path list one per line. A row, a
leaking object and the card at the pointer join them on one line. The agent
answers with a `syntheticFields` object keyed by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@pyricau
pyricau force-pushed the nexus/task-59d75ee8 branch from e3d581b to d058eed Compare October 21, 2026 15:25
Comment thread docs/binder-stubs.md Outdated

The other process holds a proxy to the stub, and the stub stays in memory until that process garbage
collects the proxy. Your app has no say in when that happens. LeakCanary reports binder stubs as expected.
The objects a stub holds keep their own verdict, and the leak is usually one of them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LeakCanary reports binder stubs as expected. The objects a stub holds keep their own verdict, and the leak is usually one of them.

it's not clear that this word has special meaning. Also the second sentence isn't super clear. "The leak is usually one of them" is pointing to it a bit too much.

If a leaktrace contains a binder stub on its path, LeakCanary marks that binder stub as "Expected", i.e. binder stubs are expected to stay in memory potentially forever. Unfortunately, developers often don't realize that, and forget to clear out the fields of binder stub objects when they stop being used, which is often a cause of leak.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Rewrote it along the lines of your version: the stub stays in memory until the other process collects its proxy, potentially forever, and LeakCanary gives it the verdict Expected. A second paragraph says developers often forget to clear a stub's fields once it's no longer needed, and that this is a common cause of leaks. The changelog bullet said the same sentence, so I changed it too. Folded into the binder stub commit: d64715e

override fun inspect(
reporter: ObjectReporter
) {
reporter.whenInstanceOf(String::class) { instance ->

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc object inspectors have an abstract method to find leaking objects, which delegates to inspect when not implemented. I think here we want to override it and just return nothing, right? To avoid running this inspector which only does synthetic fields . Unless that's already the default behavior, which it probably is otherwise this would be a problem with other inspectors that don't set a verdict.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 That's already the default, so I left this inspector as it is. The hook is leakingObjectFilter, which is null unless an inspector overrides it (ObjectInspectors.kt#L162), and createLeakingObjectFilters drops the nulls with mapNotNull (L183-L187). THREAD, STRING and ARRAY have no filter, so leak finding never runs them. They only run on the objects of a path once a leak trace is being built.

pyricau and others added 2 commits October 21, 2026 17:42
The label on a binder stub was a 1,500 character essay, printed in every leak
trace that went through one. The verdict reason is now "X is a binder stub",
the label says to look for the leak in what the stub holds and links to
a new page, Leaks through a binder stub, with a real trace and the fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Message.when is 7200000 ms before the heap dump" makes the reader count digits
to learn the message has waited two hours. The label now follows the
milliseconds with the same duration in the largest unit that fits, to a tenth:
"7200000 ms (2 hours)". Under a second the label is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@pyricau
pyricau force-pushed the nexus/task-59d75ee8 branch from d058eed to 0dcad9e Compare October 21, 2026 15:42

This branch has not been deployed

No deployments
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