RuleStack

Configs

Stacks

Compare

Diff

RuleStack

Configs

Stacks

Compare

Diff

Read API

RuleStack

Configs

Stacks

Compare

Diff

Read API

Configs/square/leakcanary/diff

Two files, one repository

square/leakcanary ships 1 format across 2 indexed files. The question worth asking is whether the second one says anything the first does not.

A · AGENTS.md · 1374 wordsB · shark/shark-explorer/AGENTS.md · 5824 words
What each file covers, counted
DimensionSharedOnly in AOnly in BOverlap
Sections16214%
Commands1896%
Section tags62355%

What each file covers

Sections

1 shared · 6 only in A · 21 only in B
  • − LeakCanary — agent guide
  • − Layout
  • − Things that will bite you
  • − Changelog
  • − Conventions
  • − Scoped guides
  • + Shark Explorer — agent guide
  • + Modules
  • + Use `HeapDominatorTree`, not `ApproximateDominatorTree`
  • + The heap dump is read off the UI thread
  • + A heap dump can have `android.os.Build` and not the fields Shark reads off it
  • + Every run writes a log file
  • + This app has its own version line, and no number of it can mean "alpha"
  • + The update check reports and nothing else
  • + A `shark://` link never reaches a run from Gradle, and that is not a bug in the code
  • + Gradle facts that aren't visible from these build scripts
  • + The app icon is generated, and its macOS shape is baked in
  • + What macOS calls the run, as against what it calls a window
  • + The dock only reads a bundle's file name, so `runNamed` gives it one
  • + Reading these names without being able to see the screen
  • + The dock has no window while it is hidden, so a capture of where AX says the tile is comes back blank.
  • + Post mouse moves down to the bottom edge — one warp isn't enough, it takes an approach and a dwell —
  • + then ask AX for the tile again: a y that has moved up by the dock's height means it is on screen.
  • + Launch it. Paths are optional; without one, use the "Open heap dump…" button. One window per path,
  • + and one per heap dump opened from the button — see `notes/decisions.md`.
  • + Testing conventions
  • + Notes
  •   Build and test

Commands

1 shared · 8 only in A · 9 only in B
  • − ./gradlew build
  • − ./gradlew :shark:shark:test
  • − ./gradlew detekt
  • − ./gradlew updateKotlinAbi
  • − ./gradlew siteDokka
  • − gh pr checks <number> --watch --fail-fast && gh pr merge <number> --merge
  • − gh pr merge --auto
  • − gh pr checks
  • + ./gradlew :shark:shark-explorer:shark-explorer-app:createDistributable
  • + ./gradlew :shark:shark-explorer:shark-explorer-app:runNamed \
  • + ./gradlew :shark:shark-explorer:shark-explorer-core:test
  • + ./gradlew :shark:shark-explorer:shark-explorer-jdwp:test
  • + ./gradlew :shark:shark-explorer:shark-explorer-app:test
  • + ./gradlew :shark:shark-explorer:shark-explorer-app:check
  • + ./gradlew :shark:shark-explorer:shark-explorer-app:run \
  • + gradle.properties
  • + ./gradlew run
  •   gradle/libs.versions.toml

Section tags

6 shared · 2 only in A · 3 only in B
  • − git-pr
  • − docs
  • + testing-strategy
  • + ui
  • + do-not
  •   build
  •   test
  •   code-style
  •   architecture
  •   deployment
  •   agent-behaviour

Line diff

+472 added−107 removed36 unchanged7.1% identical
square/leakcanary · AGENTS.md
@@ −1 @@
1# LeakCanary — agent guide
2 
3A memory leak detection library for Android, plus Shark, the heap analyzer underneath it. Published
4to Maven Central and consumed by a very large number of apps, so **the public API and the bytecode
5level are contracts**, not implementation details.
6 
7Not equally, though. The `leakcanary*` modules are what apps depend on directly, and their public API
8is the contract that matters most: breaking backward compatibility there is a last resort. `shark*` is
9used far less, so a breaking change is on the table there when it buys a meaningful improvement. In
10both cases the ABI dump is what makes the break deliberate instead of a surprise, so propose it rather
11than assuming it's fine.
12 
13This file records what an agent would get wrong from reading the source alone. Anything derivable by
14reading the code belongs in the code, not here — please keep it that way when editing.
15 
16## Layout
 
 
 
 
17 
18| Directory | What's in it |
19| --- | --- |
20| `shark/` | Heap dump parsing and analysis. Plain JVM, no Android dependency, except `shark-android` which adds Android specific reference readers and matchers. |
21| `object-watcher/` | Watches objects for retention. The lowest layer, usable on its own. |
22| `leakcanary/` | The Android library, plus `leakcanary-app*`, a standalone UI app that is not part of the library. |
23| `plumber/` | Fixes for known Android framework leaks, installed automatically. |
24| `samples/` | Sample app. |
25| `docs/` | The [documentation site](https://square.github.io/leakcanary/) content. |
26 
27The group directories (`shark/`, `leakcanary/`, …) hold no code of their own — only modules do.
 
28 
29## Build and test
30 
31`docs/dev-env.md` is the contributor setup guide — code style, local deployment, examples of the
32synthetic heap dump DSL. Read it for anything this section doesn't cover.
 
33 
34Built with **Java 17**, targeting **Java 8 bytecode** repo wide. Both matter: consumers still on
35Java 8 have to be able to use the artifacts.
 
36 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37```bash
38./gradlew build # what CI runs
39./gradlew :shark:shark:test # one module's unit tests
40./gradlew detekt # static analysis, also run by the pre-push hook
41./gradlew updateKotlinAbi # after any public API change, see below
42./gradlew siteDokka # regenerate docs/api
 
 
 
43```
44 
45`docs/api/` — the `siteDokka` output — is git ignored, not committed. The release process
46regenerates it just before publishing the site (see `docs/releasing.md`), so a public API change
47means updating the ABI dump and nothing else. If you do run `siteDokka`, don't edit what it writes;
48fix the KDoc in the source.
49 
50Instrumentation tests need a device or emulator and only cover `leakcanary-android`,
51`leakcanary-android-core`, `leakcanary-android-instrumentation` and `leakcanary-android-test`. CI
52runs them on one emulator per major Android release, from the minSdk to the newest API level with a
53system image, so a change that only works on some API levels will fail there rather than locally.
54 
55## Things that will bite you
 
 
56 
57**Public API changes fail the build until the ABI dump is updated.** `checkKotlinAbi` compares the
58public ABI against the committed `api/*.api` files and runs as part of `check`, so `./gradlew build`
59catches it. When it fails, run `./gradlew updateKotlinAbi` and commit the changed `api/*.api` files —
60but read the diff first, because an unintended ABI change is exactly what this is meant to catch.
61Modules with no public API are exempt; they're listed in `modulesWithoutPublicApi` in the root
62`build.gradle.kts`.
63 
64**There are two ABI validation mechanisms, deliberately sharing task names** so that one command
65covers the whole repo: the Kotlin Gradle plugin's `abiValidation()` for JVM modules, and an
66equivalent pair of tasks hand-rolled in the root `build.gradle.kts` for Android library modules,
67which KGP doesn't support yet ([KT-83410](https://youtrack.jetbrains.com/issue/KT-83410)).
 
 
 
 
 
 
 
 
68 
69**So use `checkKotlinAbi`/`updateKotlinAbi`, never `checkLegacyAbi`/`updateLegacyAbi`.** The `Legacy`
70pair is what Kotlin's own ABI validation documentation calls these tasks, but here they come straight
71from KGP and therefore exist *only on the JVM modules* — they silently skip every Android library
72module, which is most of the published ones. A green `updateLegacyAbi` means less than half the repo
73was covered.
74 
75**Some dependency versions are deliberately old.** The `compileOnly` AndroidX versions in
76`gradle/libs.versions.toml` are pinned to the *lowest* version LeakCanary supports, so that apps
77resolve to their own newer version without needing a resolution strategy. The inline comments say
78which ones and why. Don't bump them to fix a warning.
 
 
 
79 
80**`HprofRetainedHeapPerfTest` and `HprofIOPerfTest` freeze exact numbers** — bytes read, and memory
81retained at each analysis step, within a margin. A change to how the analysis allocates or reads will
82fail them. That's the point: they exist to make memory and I/O regressions visible. Investigate
83before adjusting the expected values, and say in the PR why the new number is correct.
84 
85**detekt runs on pre-push and in CI**, config at `config/detekt-config.yml`. The hook installs itself
86via the `assemble` and `clean` tasks, so a fresh clone gets it after the first build. Run `detekt`
87before pushing rather than discovering it at push time.
 
88 
89**`gh pr merge --auto` does not wait for CI here — it merges on the spot.** Auto-merge is enabled on
90the repo, but `main` is deliberately left unprotected, so there are no required status checks for
91auto-merge to gate on. GitHub sees a mergeable pull request with nothing to wait for and merges
92immediately, exiting zero and printing nothing, which reads exactly like it armed. Nothing in the
93repo will stop a merge while CI is red — `main` is open on purpose — so waiting for green is your
94job, not the platform's. Wait explicitly and let the exit code decide:
95 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96```bash
97gh pr checks <number> --watch --fail-fast && gh pr merge <number> --merge
 
98```
99 
100`gh pr checks` exits zero only once every check has passed, so the `&&` is what makes this safe;
101`--fail-fast` returns as soon as one fails instead of sitting through the rest. A run takes 9 to 13
102minutes, nearly all of it the emulator matrix, so start that command detached — a foreground call
103that gives up at ten minutes will usually be killed just before the last emulator reports.
 
 
 
 
 
 
 
104 
105## Changelog
 
 
 
 
 
 
 
 
 
106 
107Entries go in `docs/changelog.md` under `## Unreleased`, each starting with one of the markers from
108the legend at the top of that file. Pick the marker from what the change *is*, not from how big it
109feels, and grep for a comparable existing entry rather than guessing.
110 
111**💥 means a crash fix here, not a breaking change** — the opposite of the
112[gitmoji](https://gitmoji.dev/) convention, and the mistake that convention trips people into.
113Reaching for 💥 because a change feels impactful tells readers a crash was fixed when nothing
114crashed. Breaking changes are ⚠️; when one needs more than a bullet, write it as a
115`### Breaking change: <summary>` heading with prose.
116 
117**The changelog is for changes that matter to the people consuming LeakCanary**, not a record of
118every diff. Refactors, internal cleanups and test-only changes usually don't need an entry.
 
119 
120## Conventions
 
 
121 
122- When a function's parameters don't fit on one line, put **each on its own line** — the existing
123 code is consistent about this and detekt won't tell you.
124- Commit subjects are imperative and describe the change, e.g. "Keep modules without a public API off
125 the documentation site". Explain *why* in the body when it isn't obvious.
126- Don't leave test-only or unused code in the committed tree. If scaffolding was needed to get
127 somewhere, remove it before the PR lands.
128- Test heap dumps are built with the `dump { }` DSL from `shark-hprof-test` (see `docs/dev-env.md`)
129 rather than committed as binary fixtures. Never hand-assemble hprof bytes. For a large realistic
130 dump, drive a real JVM via `HotSpotDiagnosticMXBean.dumpHeap`.
131- Tests use JUnit 4 and AssertJ. Instrumentation tests depend on `libs.assertjCore.android` rather
132 than `libs.assertjCore`, because AssertJ 3.16 and up can't load on API 24 — the catalog comment
133 says why. So an assertion that a unit test can use may not compile in an instrumentation test.
134 
135## Scoped guides
 
 
 
 
 
136 
137Subdirectories may carry their own `AGENTS.md`, and the closest one to the file being edited wins,
138like `.gitignore`. Prefer putting guidance in the narrowest place it applies over growing this file —
139a module's quirks belong next to the module.
 
140 
141Claude Code reads `CLAUDE.md` rather than `AGENTS.md`, so each `AGENTS.md` is paired with a
142`CLAUDE.md` containing `@AGENTS.md`. Add both when adding a scoped guide.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143 
square/leakcanary · shark/shark-explorer/AGENTS.md
@@ +1 @@
1# Shark Explorer — agent guide
2 
3A desktop app that renders a heap dump's dominator tree as a navigable treemap, as rings around a
4centre, or as a stack of rows the way a profiler draws a call tree. The long term goal is a YourKit-style
5heap explorer; these are the first surfaces.
6 
7This file is scoped to `shark/shark-explorer/`. It only records things an agent would get wrong by
8reading the source alone — everything else is in the code. Keep it that way.
 
 
 
9 
10## Modules
 
11 
12| Module | What it is | Constraints |
13| --- | --- | --- |
14| `shark-explorer-core` | Heap dump → dominator tree → layout model. Layout, hit testing, navigation state. | **No Compose dependency, Java 8 target.** Must stay reusable from the Android `leakcanary-app`. |
15| `shark-explorer-jdwp` | Attaches to a live app as a debugger to read the pixels of its bitmaps. | **Imports `com.sun.jdi`, so it needs a JDK and can't be loaded on Android.** That's the whole reason it isn't in `core`. |
16| `shark-explorer-app` | Compose Desktop UI: window, the canvas each shape draws into, details panel. | **Java 17 target** — see below. |
17 
18`shark/shark-explorer/` itself holds no code, matching how `shark/` and `leakcanary/` are grouping
19directories in this repo.
 
 
 
 
 
 
20 
21Put logic in `shark-explorer-core` by default. Anything in `shark-explorer-app` is hard to unit test
22and can't be shared with Android, so it should be limited to composables and wiring.
23 
24## Use `HeapDominatorTree`, not `ApproximateDominatorTree`
25 
26`shark.ApproximateDominatorTree` is the on device BFS approximation, and it is **known to be wrong**
27— a cross edge can be processed while the parent's dominator is still stale, so retained sizes get
28under-attributed. Don't build on it.
29 
30`shark.HeapDominatorTree` is the exact one, which is what `HeapExplorer` uses. See
31`notes/dominator-tree.md` for its memory profile and for the reference reader behaviour that makes a
32treemap read strangely until you know about it.
33 
34## The heap dump is read off the UI thread
35 
36A `HeapGraph` is read only and safe to read from several threads at once, so the reason everything that
37touches a `HeapExplorer` goes through `HeapDumpSession.read` is **latency, not safety**: labelling a
38rectangle is IO, and summarising a selection or walking up to the GC roots is seconds of it on a large
39dump. Doing any of that in a composable freezes the window.
40 
41What follows for the UI: a composable never holds a tree, only what was already computed from one. A
42laid out, labelled view is a `TreemapPresentation` or a `RadialPresentation`, and a selection is a
43`HeapObjectSummary`; both arrive a little after whatever asked for them changed.
44 
45The one thing that isn't thread safe is a `Sequence` a `HeapGraph` hands out — iterating one reads
46through it — so a thread reading `graph.objects` needs its own rather than a shared one.
47 
48**Cancelling the coroutine that asked for a read stops the read**, which is what a `LaunchedEffect`
49being relaunched does. Shark does the stopping, not us: the heap dump is opened with a `CancelSignal`
50asking whether the read in flight is still wanted, and it's asked on every record read, so the work
51gives up shortly after the question is withdrawn and comes back as a `CancellationException`. A read
52given up on while it was still queued never starts at all. So dragging a window edge costs the size it
53lands on and a little of each size it passed through, rather than all of them in full.
54 
55Two things follow. **A read is only cancellable at the granularity of what it reads** — a stretch that
56computes without reading, like a layout over an already-labelled tree, stops when it next reads — so the
57`HOVER_SETTLE_MILLIS` half of this, not starting work that isn't wanted yet, still earns its keep.
58And **anything a read mutates has to survive being abandoned half way**: today's reads are safe because
59the built-on-first-use indexes are `by lazy` initializers that build a whole object before assigning it
60(a cancelled build is simply retried, since `lazy` doesn't cache a failure), and the walks reuse arrays
61stamped with a generation per walk rather than cleared at the end.
62 
63**The pointer asks questions on that thread too**, because moving over a rectangle describes it. Which is
64why nothing is read until the pointer has been still for `HOVER_SETTLE_MILLIS`, and why what a hover asks
65for is index-backed: a chain from a GC root is one walk over `ReferrerIndex` and a read per step of what it
66found, and the search for every way an object is held runs for the object clicked and no other. A new
67question the panels ask has to be measured before it goes in the hover path — `notes/decisions.md` has the
68numbers on the biggest dump in the repo, including how long a chain gets there.
69 
70## A heap dump can have `android.os.Build` and not the fields Shark reads off it
71 
72`AndroidBuildMirror.fromHeapGraph` reads `MANUFACTURER`, `ID` and `VERSION.SDK_INT` with `!!`, and nearly
73every one of Shark's library leak patterns decides whether it applies by asking it. Those patterns are
74filtered against the graph when a `ReferenceReader` is *created*, which in the explorer is while the
75dominator tree is being built — so a dump that has the class and not the fields throws a bare NPE from
76under everything, and what you see is a window that spins for ever and never draws a tree.
77 
78Real dumps have all three. Synthetic ones written with the `dump { }` DSL have whatever the fixture wrote,
79which is why `ReferenceStrengthReader.recordsAndroidBuild` checks the fields rather than the class, and
80why the test fixtures that write an `android.os.Build` write `ID` too even though nothing in the window
81shows it.
82 
83## Every run writes a log file
84 
85`installLogging()` in `shark-explorer-app` points `SharkLog` at stdout **and** at
86`~/.shark-explorer/logs/shark-explorer-<when-it-started>.log`, one file per run, the newest
87`SessionLog.KEEP_SESSION_COUNT` kept and the rest deleted as a run starts.
88 
89**So ask for that file when someone reports something odd**, and read it before guessing. It holds the
90environment (JVM, OS, heap limit — a dump too large for the explorer runs out of exactly that), every
91step of opening the dump with its duration, and every read of it through `HeapDumpSession.read` with
92what was being read and how long it took. What that makes readable:
93 
94- A read logged as started and never as done is where the app was killed, hung, or ran out of memory.
95- The last line being `Shark Explorer closed` is how a session that ended cleanly is told from one that
96 didn't.
97- Everything the window does silently — a path zoomed out because a node left the tree, a click landing
98 on an object the tree has no node for, a list that came back empty — says so there rather than nowhere.
99- A run is every window of it, and a window is a heap dump, so the reads of several dumps interleave.
100 The `[heap-dump-<file name>]` a line was written from is which dump it is about; lines from the
101 window's own thread name the file instead.
102 
103Which is also the rule for new code here: **anything the UI swallows or falls back from gets a
104`SharkLog.d` line saying so.** The file is only worth reading if it's complete.
105 
106## This app has its own version line, and no number of it can mean "alpha"
107 
108`SHARK_EXPLORER_VERSION` in `gradle.properties`, not the repo wide `VERSION_NAME`, and the two are released
109independently — `shark-explorer-*` tags against `v*` tags. See `docs/releasing-shark-explorer.md`.
110 
111It isn't only a preference. Every installer format validates the version, and what they leave between them,
112each measured by building it, is **`MAJOR.MINOR.PATCH` with MAJOR from 1 to 255**, MINOR up to 255, PATCH up
113to 65535:
114 
115- `3.0-alpha-10` — `Illegal version for 'Dmg'`, and for `Msi`. No qualifiers, in any format.
116- `0.1.0` — fails `createDistributable`, and **not through the version validation the other two trip**:
117 the Compose plugin accepts it and jpackage then reports `Bundler Mac Application Image skipped because
118 of a configuration problem: The first number in an app-version cannot be zero or negative`. So the
119 message says "skipped", is only in `build/compose/logs/createDistributable/jpackage-*-err.txt`, and the
120 Gradle failure above it names no version at all.
121- `2026.8.0` and `256.0.0` — `Illegal version for 'Msi'`, whose fields cap at 255.255.65535.
122 
123So `0.x` is not available and neither is a calendar version, and "this is an alpha" is said by the release
124being a prerelease titled that way. Don't try to encode it in the number, and don't split it across
125`macOS.packageVersion` and `macOS.packageBuildVersion` either: the user visible one is
126`CFBundleShortVersionString`, which is also the field Munki compares for Managed Software Center updates,
127so pinning it would freeze updates for everyone who installed from there.
128 
129**The version reaches the app as a generated resource**, `shark-explorer-version.properties`, written by
130`writeVersionResource` and read by `SharkExplorerVersion`. Not the jar manifest: `run` and the tests put
131class directories on the classpath rather than the jar, so `Package.getImplementationVersion()` is null for
132every way this app is launched while being worked on, and the update check would only be exercisable from a
133packaged build. `SharkExplorerVersionTest` exists because a broken wiring here fails silently — the version
134becomes `unknown`, the check declines to run, and no window ever mentions an update.
135 
136## The update check reports and nothing else
137 
138`UpdateCheck` fetches one file and, if it names a later version, `UpdateNotice` puts a bar in every window
139of the run. Nothing downloads or installs. Three things about it that reading the code won't tell you:
140 
141- **`releases/latest` is the wrong release.** This repository publishes LeakCanary libraries on `v*` tags
142 and the explorer on `shark-explorer-*` tags, and GitHub has one "latest" pointer per repository, so that
143 endpoint answers with whichever line released last. Verified: it returns `v3.0-alpha-9`.
144- **The unauthenticated GitHub API allows 60 requests an hour per IP**, and Block's shared egress can
145 exhaust that on other people's runs. A release asset download is unmetered, which is why the manifest is
146 one.
147- **The manifest is written by `promote-shark-explorer.yml` and nothing else**, so publishing a release and
148 offering it to everyone are two separate acts. `UpdateCheckTest` pins the manifest format from this side,
149 because the workflow writes it in bash and nothing else keeps the two agreeing.
150 
151`UpdateNotice` is one per run rather than per window, so dismissing the bar in one window clears it in all
152of them.
153 
154## A `shark://` link never reaches a run from Gradle, and that is not a bug in the code
155 
156`DeepLink` is the URL, `DeepLinkScheme` is the OS end of it and `DeepLinkPeers` is how a link crosses
157from one run of this app to another. What reading them won't tell you is that **none of it can be tried
158from `run` or `runNamed`**, on any platform, and the reason is different on each:
159 
160- **macOS delivers to a bundle identity.** A JVM launched from a shell script — which is what `runNamed`'s
161 generated bundle is — registers with LaunchServices as `net.java.openjdk.java` whatever the wrapper's
162 `Info.plist` says. Measured with `lsappinfo find pid=<pid>`. Only a launcher `jpackage` built gets its
163 own `CFBundleIdentifier`, and the Apple Event carrying the URL goes to the identity, so a `runNamed`
164 process is handed nothing however well its plist declares the scheme.
165- **Windows and Linux are registered at runtime**, by `DeepLinkScheme.registerWithTheOs`, and it declines
166 for a run whose executable is `java` — registering that would tell the OS to open links with a JVM and
167 no classpath.
168 
169So the loop for anything about links is a package, not a compile:
170 
171```bash
172./gradlew :shark:shark-explorer:shark-explorer-app:createDistributable
173cp -R "shark/shark-explorer/shark-explorer-app/build/compose/binaries/main/app/Shark Explorer.app" \
174 ~/Applications/
175/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
176 -f ~/Applications/"Shark Explorer.app"
177open -a ~/Applications/"Shark Explorer.app" --args --title="Links" path/to/dump.hprof
178grep "Windows of this run" ~/.shark-explorer/logs/$(ls -t ~/.shark-explorer/logs | head -1)
179open "shark://<the id that printed>/leaks"
180```
181 
182**Read the result in the log rather than off the screen.** Following a link raises the app over whatever
183the person at the machine was doing, so a screenshot to check it worked costs them their window and shows
184you theirs. `The OS handed this run`, `A link asked window <id> for <place>` and `A link asked this window
185for <place>` are the three lines that say a link was delivered, routed and opened as a tab.
186 
187A run from source is still *reachable*: every run publishes a loopback port under `~/.shark-explorer/runs`,
188and the installed app hands on any link naming a window it doesn't have. That is what makes a link to a
189`./gradlew run` window work — the installed app is the courier, so there has to be one.
 
190 
191**Deliberately not single instance.** Several explorers open at once is how this app is used, so a run
192holding a link asks each of the others in turn rather than the second run handing its command line to the
193first and exiting.
194 
195## Gradle facts that aren't visible from these build scripts
 
 
 
 
 
196 
197- **`shark-explorer-app` is excluded by name** from the repo-wide Java 8 target in the root
198 `build.gradle.kts`, because Compose Multiplatform's artifacts aren't built for Java 8. If you
199 rename or move the module, update that exclusion list or the build breaks confusingly.
200- **All three modules are listed in `modulesWithoutPublicApi`** in the root `build.gradle.kts`. They
201 are not published to Maven Central, their ABI isn't tracked, and they're left out of the docs site.
202 So there is no `api/*.api` file to update and `updateKotlinAbi` doesn't apply.
203- **`jdk.jdi` is listed in the app's `nativeDistributions.modules`.** jlink includes only the JDK
204 modules it detects a use of, and it detects none through `Bootstrap.virtualMachineManager()`, so a
205 packaged build without that line attaches to nothing.
206- `compose` and `composeMultiplatform` in `gradle/libs.versions.toml` are **unrelated**: the first
207 is the Jetpack Compose version the Android app builds against, the second is Compose
208 Multiplatform for this desktop app.
209 
210## The app icon is generated, and its macOS shape is baked in
 
 
 
 
211 
212`shark-explorer-app/icons/shark-explorer-icon.svg` is the source, and
213`shark-explorer-icon-small.svg` beside it is the **same shark with the gills, teeth and brow left
214out**, for the sizes where those come out under a pixel. The `.icns`, the `.ico` and
215`src/main/resources/shark-explorer-icon.png` are **rendered from the two of them** by
216`icons/render-icons.sh`, which picks by size, so edit an SVG and re-run that script rather than
217touching a binary. It needs `rsvg-convert` (`brew install librsvg`), and `iconutil`, which is macOS
218only.
219 
220**The two drawings share one transform**, hard coded rather than fitted twice, so that the shark
221doesn't shift when the dock crosses between them. Changing the shape in one means changing it in the
222other and keeping that transform identical.
 
223 
224The SVG has the macOS app icon grid drawn into it — an 824x824 rounded body inside a 1024x1024
225canvas, with its own shadow — because jpackage ships an `.icns` and **nothing masks or insets that for
226us**, unlike an Android adaptive icon or a macOS 26 `.icon` bundle. So a redesign has to keep drawing
227the body and the padding, and the corners are a superellipse rather than a circular arc.
228 
229**The macOS dock icon of a `./gradlew run` needs no runtime code.** The Compose plugin turns
230`nativeDistributions.macOS.iconFile` into `-Xdock:icon` on the run task, verified by A/B: drop that
231one line from the build script and the flag is gone from the run's JVM arguments and the default Java
232icon is back. So `java.awt.Taskbar` has nothing to add here. `Window(icon = …)` does, but only for the
233Windows and Linux title bar — macOS ignores it.
 
234 
235**`-Xdock:icon` is what puts the icon on the tile, and a bundle around the JVM is not a substitute.**
236AWT sets the dock tile from that flag as it starts, and when the flag is absent it sets the tile to
237the Java icon — over whatever the bundle asked for. So `runNamed` passes the flag too, even though its
238generated bundle declares `CFBundleIconFile`. `CFBundleIconFile` is not ignored, it is just overwritten:
239`NSRunningApplication.icon` for a `runNamed` process without the flag hands back the shark, because
240that is the LaunchServices record, while the tile on screen is Duke. **Which is the trap** — every API
241an agent can read says the icon is right, and only a picture of the dock says otherwise.
242 
243## What macOS calls the run, as against what it calls a window
244 
245A run is one process and many windows, so the OS gets one name for all of them, and `main` sets it from
246`--title` before the first window: `apple.awt.application.name`. That reaches the menu bar next to the
247Apple logo, the app switcher, and every name macOS reports through an API. **It does not reach the
248dock** — nothing a process can do reaches the dock, see the next section. Three things about that line
249aren't visible from it.
250 
251**It is read once, as AWT starts**, and the process registers with macOS under whatever it said then.
252Setting it after a window is up changes nothing — measured, not assumed — so it belongs between parsing
253the command line and `application { }`, which is the only gap there is.
254 
255**It is the same name `-Xdock:name` sets.** That JVM argument only puts the name in an environment
256variable AWT reads at that same moment: a run given `-Xdock:name=X` and a run that sets the property to
257`X` in `main` produce LaunchServices records differing in nothing but their audit token and check-in
258time. So the run task passes no name and an IDE run configuration needs none either, and
259`java.awt.Taskbar` is no help — its API is icon, badge, menu and progress, and no name.
260 
261**A packaged app ignores the property and keeps its bundle's name.** The `.app` launched with
262`--title="Packaged with a title"` logs that title and is still called whatever `packageName` made the
263bundle, because jpackage gives it a real bundle. A run from Gradle has no bundle of its own — it is `/…/bin/java`,
264bundle id `net.java.openjdk.java` — which is why it is called after whatever launched it until
265something names it.
266 
267## The dock only reads a bundle's file name, so `runNamed` gives it one
268 
269`-Xdock:name` has not named the dock since around macOS 10.9 — [JDK-8173753][dock-bug], still open,
270where the reported symptom is exactly what you get: the name reaches the menu bar and the dock goes on
271saying `java`. Confirmed here, three explorer runs whose LaunchServices, `NSRunningApplication` and
272WindowServer names were all different showed three dock tiles all called `java`. **So don't spend time
273looking for the property or the API call that fixes this. There isn't one.**
274 
275What the dock reads is the file name of the bundle a process was launched from. Not `CFBundleName`:
276two bundles carrying the same `CFBundleName` and differing only in file name are two differently named
277tiles.
278 
279`runNamed` is `run` with a bundle around it, generated per launch and named after `--title`:
280 
281```bash
282./gradlew :shark:shark-explorer:shark-explorer-app:runNamed \
283 --args="--title=\"Hover previews\" shark/shark-android/src/test/resources/compose_leak.hprof"
284```
285 
286- **It is a launcher script and an `Info.plist` around the classes `run` would have run**, not a
287 `jpackage` build. Packaging is a minute of jlink per code change, which would be a minute per look;
288 this is a compile.
289- **The script `exec`s the JVM** rather than starting it as a child. The JVM has to end up being the
290 process macOS launched from the bundle or it is a process of its own again, and the dock is back to
291 calling it java.
292- **`open` gives it no terminal**, so stdout goes to `build/named/<title>.out` — which is where a run
293 that died before it could open a log file says why. Everything after that is in the usual place, see
294 the logging section.
295- **Relaunching a title while a window of that title is open** is the one thing to avoid: the bundle is
296 rewritten in place, and that window is reading it.
297 
298**And a Gradle build of any kind kills every explorer window already open.** `run` and `runNamed` both
299put the module jars on the classpath rather than a copy of them, and a JVM reads a jar's index once and
300then trusts it, so recompiling under a live window makes every class that window hasn't happened to load
301yet disappear. What that looks like is the window dying on the next pointer move with
302`NoClassDefFoundError: shark/explorer/TreemapPoint`, a `ClassNotFoundException` under it, and a stack
303trace through code nobody has touched — the class is in the source and in the jar, which is exactly what
304makes it read like a real bug in whatever was being worked on. Measured: a window launched at 16:42, a
305`shark-explorer-core:check` rewriting that jar at 16:46, and the first hover after it gone. So **launch
306the window you are handing over last**, after everything that builds — and when one dies this way, check
307the jar's mtime against the process start before believing the stack trace.
308 
309[dock-bug]: https://bugs.openjdk.org/browse/JDK-8173753
 
 
310 
311## Reading these names without being able to see the screen
 
 
 
 
312 
313```bash
314lsappinfo find pid=<pid> # ASN:0x0-0x2338336-"Hover previews":
315```
316 
317`NSRunningApplication.localizedName`, which the app switcher shows, and `kCGWindowOwnerName`, which the
318WindowServer holds, agree with it and are readable from `osascript -l JavaScript` through `ObjC.import`.
319None of them is what the dock displays, which is why all three can say one thing and the tile another.
320 
321The dock tile names, and an app's menu bar, are readable **only with the Accessibility permission**, and
322they are the ones worth reading, since they are what someone looking at the screen sees:
 
 
 
 
 
 
 
 
 
 
323 
324```bash
325osascript -e 'tell application "System Events" to tell process "Dock" \
326 to get name of UI elements of list 1'
327osascript -e 'tell application "System Events" to tell process "<the run>" \
328 to get name of menu bar items of menu bar 1' # Apple, <the run>
329```
330 
331Without that permission both fail with `-1719 not allowed assistive access`, and the only way to know
332what the dock says is to ask the person in front of it. It is granted per responsible process — for an
333agent, whichever app launched the session — in System Settings → Privacy & Security → Accessibility.
334Screen Recording is separate, and without it a screenshot of another process comes back as wallpaper.
335 
336**A tile's icon, though, only a screenshot of the dock will tell you.** `NSWorkspace.iconForFile` on a
337bundle and `NSRunningApplication.icon` on a pid both hand back a PNG an agent can open, but both read
338the LaunchServices record rather than the tile, so both are wrong the moment AWT overwrites it — see
339the `-Xdock:icon` section. With Screen Recording granted, this is the picture that settles it:
340 
341```bash
342# The dock has no window while it is hidden, so a capture of where AX says the tile is comes back blank.
343# Post mouse moves down to the bottom edge — one warp isn't enough, it takes an approach and a dwell —
344# then ask AX for the tile again: a y that has moved up by the dock's height means it is on screen.
345osascript -e 'tell application "System Events" to tell process "Dock" \
346 to get {position, size} of (first UI element of list 1 whose name is "<the run>")'
347screencapture -x -R <x>,<y>,<w>,<h> tile.png
348```
349 
350Put the cursor back where it was afterwards, since it is someone's cursor.
351 
352## Build and test
353 
354```bash
355./gradlew :shark:shark-explorer:shark-explorer-core:test
356./gradlew :shark:shark-explorer:shark-explorer-jdwp:test
357./gradlew :shark:shark-explorer:shark-explorer-app:test # UI tests, headless, no emulator
358./gradlew :shark:shark-explorer:shark-explorer-app:check # test + detekt
359 
360# Launch it. Paths are optional; without one, use the "Open heap dump…" button. One window per path,
361# and one per heap dump opened from the button — see `notes/decisions.md`.
362./gradlew :shark:shark-explorer:shark-explorer-app:run \
363 --args="--title=\"Hover previews\" shark/shark-android/src/test/resources/compose_leak.hprof"
364```
365 
366The repo has real Android heap dumps to try it on: `shark/shark-android/src/test/resources/*.hprof`
367and `leakcanary/leakcanary-android-instrumentation/src/androidTest/assets/large-dump.hprof` (39 MB,
368the biggest one). All of them are from API 25 or earlier, so every bitmap in them carries its pixels —
369anything about a modern dump has to be tried on one taken off a device. See `notes/bitmaps.md`.
370 
371**Always pass `--title`, and name the run after the piece of work it is for.** Several explorers end up
372open at once — one per task, often on the same heap dump — and a name is all the OS gives you to tell
373them apart. `--title` goes in front of the heap dump name in every window of that run, including windows
374opened from it later, so that two identical `large-dump.hprof` windows never end up on screen.
375`ExplorerArguments` is the whole command line, and it is strict: an unknown option is a message saying
376what to type, not a heap dump that can't be found.
377 
378**`run` while you work, `runNamed` when you hand a window over.** They take the same command line.
379`run` streams the log to the terminal and is a compile away, so it is the one for trying your own
380change — don't reach for `runNamed` for that. When the change is done and the app is being started for
381someone else to look at, use `runNamed`: it is the only one of the two the dock will name, and with
382several explorers open the dock is what they navigate by. See the dock section above.
383 
384`check` runs detekt (config at `config/detekt-config.yml`); CI and the pre-push hook both enforce
385it, so run it before pushing.
386 
387Anything that reaches a device — taking a heap dump, fetching bitmaps — can be tried for real with an
388emulator running and `leakcanary-android-sample` installed on it
389(`ANDROID_SERIAL=emulator-5554 ./gradlew :samples:leakcanary-android-sample:installDebug`). An emulator
390older than API 35 is what exercises `shark-explorer-jdwp`, since a newer one is asked through a heap dump
391instead.
392 
393**Two things let a process be dumped, and either is enough**: an app built debuggable, or a device whose
394whole build is — `ro.debuggable=1`, which is what a `userdebug` or `eng` image sets and what
395`ActivityManagerService.enforceDebuggable` skips its check on. So "only a debuggable app can be dumped"
396is right for a phone and wrong for a `userdebug` emulator, where every process on the device can be
397dumped and attached to. Measured on two emulators here: an API 36 `user` image refuses
398`am dumpheap` of `com.android.systemui` with `SecurityException: Process not debuggable` and lists one
399pid under `adb jdwp`; an API 29 `userdebug` one writes 16 MB of `com.android.permissioncontroller` and
400lists twenty. `AndroidDevice.dumpsAnyProcess` is that property, read from the `getprop` the explorer
401already runs.
402 
403**A modern emulator image is a `user` build**, so being an emulator is not what makes a device
404permissive — check `ro.debuggable` rather than assuming.
405 
406**None of that is covered by a test**, and it can't be: a JDI client talks to a real VM or to nothing.
407Drive it from a throwaway test against a running emulator, read the numbers, and delete the test — the
408numbers belong in `notes/bitmaps.md`.
409 
410## Testing conventions
411 
412- **UI tests are headless JVM tests**, not instrumentation tests. They live in `src/test/` and use
413 `androidx.compose.ui.test.v2.runComposeUiTest`. Import from the **`.v2` package** — the non-v2
414 `runComposeUiTest` is deprecated.
415- **A test of the whole window runs at the size a window opens at**, which is what `explorerUiTest` is
416 for. The default test window is smaller, and there the panes beside the view squeeze the controls above
417 it to zero width, so a test would be pressing a window nobody has.
418- **Hovering takes two moves.** A view describes what the pointer *moved* onto and ignores the enter that
419 comes with a pointer arriving, so a single injected `moveTo` reports nothing hovered. `hover()` in
420 `ExplorerUiTest.kt` moves twice; `notes/decisions.md` says why the views read events that way.
421- **An injected scroll only lands after a `waitForIdle()`.** `performMouseInput { scroll(n) }` on the
422 stack does scroll it, but the offset is still 0 in the same breath, because the scroll is animated and
423 the frame hasn't run — so reading it, or the callback it fires, right after the injection says nothing
424 happened. Which reads exactly like a wheel a headless test can't deliver, and cost an afternoon of
425 looking for one. **How far one notch scrolls is the platform's**, ten pixels with no AWT wheel event
426 behind the pointer event to say otherwise, so a test scrolls by notches and reads the pixels back off
427 `SemanticsProperties.VerticalScrollAxisRange` rather than asserting a number of its own.
428- **Each shape draws into a single `Canvas`, so there are no per-cell semantics nodes.** UI
429 tests can't find cells by tag, and **not by label either** — a cell's label is painted text, so no
430 assertion and no wait can reach it. Test layout and hit testing as pure functions in
431 `shark-explorer-core`, and have UI tests drive coordinates with `performMouseInput` and assert on what
432 is written outside the view: the chain pane and the details panel either side of it, and the card that
433 follows the pointer, whose text is real text and so can be found and its bounds read.
434- **A block naming an object is one semantics node**, because `Modifier.openable` merges its descendants,
435 so a step of the chain is found by any one of the three lines it prints. The same name is usually in the
436 window several times at once — a step of the chain, a tab, a button on the bar, the details panel — so
437 `onNodeWithText` failing with "found 2" is that, not a duplicated composable. **Tell them apart by the
438 role, not by `hasClickAction()`**, since all of them have one: `Role.Button` is the bar, `Role.Tab` is
439 the strip, and no role at all is a row that navigates, which is a link rather than a button. The
440 `isTab()` and `isButton()` matchers in the test files are that, and `openable` deliberately sets no role
441 so that the third case exists.
442- **A UI test knows the map is drawn through `waitForTheTree`**, which waits for the view's
443 `contentDescription` with nothing left spinning, because the drawn map itself adds no text to the
444 window. Where "the map *moved*" is the point rather than "the map is there", wait on the log line
445 every layout writes instead — `ExplorerAppTest.waitUntilZoomedIn`.
446- **A headless test can write a PNG of what Skia drew**, which is how an agent gets to look at this
447 UI at all: `onRoot().captureToImage().toAwtImage()` and `ImageIO.write`, after a
448 `performMouseInput`, renders the hover highlight and the path bar the same as a real window does.
449 Nothing outside the JVM can do this — macOS shows a process that lacks Screen Recording only the
450 desktop picture and its own windows, so a screenshot of a `./gradlew run` taken from any other
451 process comes back as wallpaper. Delete the capture again once it has been looked at; it's
452 scaffolding, not a test.
453- **The UI tests record `SharkLog` for every test**, not only for the ones asserting on it. A log
454 line is built from state — an index into a path, a node id — so a line built from the wrong state
455 should fail the test that reaches it rather than wait for a session nobody can read. The `RecordedLog`
456 rule does the recording, and is a rule rather than a `@Before` because putting the logger back is the
457 part that isn't optional: a test that leaves `SharkLog.logger` set breaks every test after it.
458- **What the log says is also how often something happened.** Every read of the heap dump is one line
459 through `HeapDumpSession.read`, so counting them is what holds the window to laying the tree out once
460 per view asked for, which is what `TreeLayoutTest` does. Those counts are sound because reads queue on
461 that one thread in order: anything queued behind the read a test waited for is already logged by then.
462- **A `Window` needs a display, so nothing inside `application { }` is covered headless.** Which
463 window a heap dump opens in is plain state in `ExplorerWindow.kt`, unit tested by
464 `ExplorerWindowTest`. `ExplorerApp` is one window's worth of app and takes the heap dump it shows
465 as a parameter, so a UI test drives one window and nothing else.
466- **A click is a fraction of the view, never of the window.** `ExplorerAppTest.viewBounds` measures the
467 view by its `contentDescription`, and every press helper is relative to that. Window fractions break
468 the moment anything above the view changes height, which is a change to the top bar away.
469- **A test about `java.lang.ref` strengths needs a dump of a real JVM, taken without collecting first.**
470 `JvmReferenceStrengthTest` writes one with `HotSpotDiagnosticMXBean.dumpHeap(path, live = false)`,
471 because the collection a heap dump normally begins with clears a weak referent nothing else holds and,
472 since JDK 9, a phantom one — so the strengths the test is about would be missing from a dump taken the
473 usual way. That leaves it fragile in a way its KDoc explains: anything allocated between its
474 `System.gc()` and the dump can trigger a collection that clears the lot. It doesn't replace the
475 `dump { }` cases either, ART's reference classes and the lists it keeps them on not being HotSpot's.
476- Build test heap dumps with the `hprofFile.dump { }` DSL from `shark-hprof-test` rather than
477 checking in binary fixtures or hand-writing hprof bytes. A dump with bitmaps in it is `BitmapDumps.kt`
478 in `shark-explorer-core`'s tests — the `"a.b.C" instance { }` shorthand declares a class per instance,
479 so two bitmaps built that way are two `android.graphics.Bitmap` classes, which no real dump has.
480- **A UI test that opens a note must pass an `ExplorerNotes` over a temporary directory.** `ExplorerApp`'s
481 default keeps notes in `~/.shark-explorer/notes`, so a test taking it writes into the notes of whoever is
482 running it. A window that never opens one only lists that directory to see which tabs to mark, which is
483 why the tests that don't touch notes need no directory of their own.
484- **A UI test must pass a `DeviceHeapDumps` built on a fake `Adb`.** `ExplorerApp`'s default shells out to
485 the machine's `adb`, so a test that takes it has whatever device is plugged in to answer for — and the
486 window can dump the heap of a real process. `FakeAdb` matches command prefixes, because the remote dump
487 path contains a timestamp.
488- **A synthetic Android class needs the fields the object inspectors read.** `HeapObjectSummary` runs
489 `AndroidObjectInspectors`, and those read fields with `!!` — an `android.view.View` without `mParent`,
490 `mWindowAttachCount`, `mAttachInfo` and `mContext` makes `summarize()` throw a bare
491 `NullPointerException` from inside shark-android, which reads like a bug in the explorer.
492 
493## Notes
494 
495Design decisions and findings, kept current as the work proceeds:
496 
497- `notes/decisions.md` — stack and structure decisions, with rationale
498- `notes/dominator-tree.md` — dominator algorithm findings, memory/perf numbers
499- `notes/treemap-rendering.md` — adaptive depth model, the two shapes, bugs in the existing Android
500 treemap
501- `notes/bitmaps.md` — which Android versions put a bitmap's pixels in the heap dump, and the two ways
502 the ones that don't are fetched off the device
503- `notes/dependency-injection.md` — what Dagger and Metro leave in a heap dump, why the owner rule is
504 about the provider rather than the component, and how to dump really generated code
505 
506Update these in the same change that makes them stale. They're for agents, so keep them short and
507skip anything derivable from the code.
508 
@@ −1 +1 @@
1−# LeakCanary — agent guide
1+# Shark Explorer — agent guide
22  
3−A memory leak detection library for Android, plus Shark, the heap analyzer underneath it. Published
4−to Maven Central and consumed by a very large number of apps, so **the public API and the bytecode
5−level are contracts**, not implementation details.
3+A desktop app that renders a heap dump's dominator tree as a navigable treemap, as rings around a
4+centre, or as a stack of rows the way a profiler draws a call tree. The long term goal is a YourKit-style
5+heap explorer; these are the first surfaces.
66  
7−Not equally, though. The `leakcanary*` modules are what apps depend on directly, and their public API
8−is the contract that matters most: breaking backward compatibility there is a last resort. `shark*` is
9−used far less, so a breaking change is on the table there when it buys a meaningful improvement. In
10−both cases the ABI dump is what makes the break deliberate instead of a surprise, so propose it rather
11−than assuming it's fine.
7+This file is scoped to `shark/shark-explorer/`. It only records things an agent would get wrong by
8+reading the source alone — everything else is in the code. Keep it that way.
129  
13−This file records what an agent would get wrong from reading the source alone. Anything derivable by
14−reading the code belongs in the code, not here — please keep it that way when editing.
10+## Modules
1511  
16−## Layout
12+| Module | What it is | Constraints |
13+| --- | --- | --- |
14+| `shark-explorer-core` | Heap dump → dominator tree → layout model. Layout, hit testing, navigation state. | **No Compose dependency, Java 8 target.** Must stay reusable from the Android `leakcanary-app`. |
15+| `shark-explorer-jdwp` | Attaches to a live app as a debugger to read the pixels of its bitmaps. | **Imports `com.sun.jdi`, so it needs a JDK and can't be loaded on Android.** That's the whole reason it isn't in `core`. |
16+| `shark-explorer-app` | Compose Desktop UI: window, the canvas each shape draws into, details panel. | **Java 17 target** — see below. |
1717  
18−| Directory | What's in it |
19−| --- | --- |
20−| `shark/` | Heap dump parsing and analysis. Plain JVM, no Android dependency, except `shark-android` which adds Android specific reference readers and matchers. |
21−| `object-watcher/` | Watches objects for retention. The lowest layer, usable on its own. |
22−| `leakcanary/` | The Android library, plus `leakcanary-app*`, a standalone UI app that is not part of the library. |
23−| `plumber/` | Fixes for known Android framework leaks, installed automatically. |
24−| `samples/` | Sample app. |
25−| `docs/` | The [documentation site](https://square.github.io/leakcanary/) content. |
18+`shark/shark-explorer/` itself holds no code, matching how `shark/` and `leakcanary/` are grouping
19+directories in this repo.
2620  
27−The group directories (`shark/`, `leakcanary/`, …) hold no code of their own — only modules do.
21+Put logic in `shark-explorer-core` by default. Anything in `shark-explorer-app` is hard to unit test
22+and can't be shared with Android, so it should be limited to composables and wiring.
2823  
29−## Build and test
24+## Use `HeapDominatorTree`, not `ApproximateDominatorTree`
3025  
31−`docs/dev-env.md` is the contributor setup guide — code style, local deployment, examples of the
32−synthetic heap dump DSL. Read it for anything this section doesn't cover.
26+`shark.ApproximateDominatorTree` is the on device BFS approximation, and it is **known to be wrong**
27+— a cross edge can be processed while the parent's dominator is still stale, so retained sizes get
28+under-attributed. Don't build on it.
3329  
34−Built with **Java 17**, targeting **Java 8 bytecode** repo wide. Both matter: consumers still on
35−Java 8 have to be able to use the artifacts.
30+`shark.HeapDominatorTree` is the exact one, which is what `HeapExplorer` uses. See
31+`notes/dominator-tree.md` for its memory profile and for the reference reader behaviour that makes a
32+treemap read strangely until you know about it.
3633  
34+## The heap dump is read off the UI thread
35+ 
36+A `HeapGraph` is read only and safe to read from several threads at once, so the reason everything that
37+touches a `HeapExplorer` goes through `HeapDumpSession.read` is **latency, not safety**: labelling a
38+rectangle is IO, and summarising a selection or walking up to the GC roots is seconds of it on a large
39+dump. Doing any of that in a composable freezes the window.
40+ 
41+What follows for the UI: a composable never holds a tree, only what was already computed from one. A
42+laid out, labelled view is a `TreemapPresentation` or a `RadialPresentation`, and a selection is a
43+`HeapObjectSummary`; both arrive a little after whatever asked for them changed.
44+ 
45+The one thing that isn't thread safe is a `Sequence` a `HeapGraph` hands out — iterating one reads
46+through it — so a thread reading `graph.objects` needs its own rather than a shared one.
47+ 
48+**Cancelling the coroutine that asked for a read stops the read**, which is what a `LaunchedEffect`
49+being relaunched does. Shark does the stopping, not us: the heap dump is opened with a `CancelSignal`
50+asking whether the read in flight is still wanted, and it's asked on every record read, so the work
51+gives up shortly after the question is withdrawn and comes back as a `CancellationException`. A read
52+given up on while it was still queued never starts at all. So dragging a window edge costs the size it
53+lands on and a little of each size it passed through, rather than all of them in full.
54+ 
55+Two things follow. **A read is only cancellable at the granularity of what it reads** — a stretch that
56+computes without reading, like a layout over an already-labelled tree, stops when it next reads — so the
57+`HOVER_SETTLE_MILLIS` half of this, not starting work that isn't wanted yet, still earns its keep.
58+And **anything a read mutates has to survive being abandoned half way**: today's reads are safe because
59+the built-on-first-use indexes are `by lazy` initializers that build a whole object before assigning it
60+(a cancelled build is simply retried, since `lazy` doesn't cache a failure), and the walks reuse arrays
61+stamped with a generation per walk rather than cleared at the end.
62+ 
63+**The pointer asks questions on that thread too**, because moving over a rectangle describes it. Which is
64+why nothing is read until the pointer has been still for `HOVER_SETTLE_MILLIS`, and why what a hover asks
65+for is index-backed: a chain from a GC root is one walk over `ReferrerIndex` and a read per step of what it
66+found, and the search for every way an object is held runs for the object clicked and no other. A new
67+question the panels ask has to be measured before it goes in the hover path — `notes/decisions.md` has the
68+numbers on the biggest dump in the repo, including how long a chain gets there.
69+ 
70+## A heap dump can have `android.os.Build` and not the fields Shark reads off it
71+ 
72+`AndroidBuildMirror.fromHeapGraph` reads `MANUFACTURER`, `ID` and `VERSION.SDK_INT` with `!!`, and nearly
73+every one of Shark's library leak patterns decides whether it applies by asking it. Those patterns are
74+filtered against the graph when a `ReferenceReader` is *created*, which in the explorer is while the
75+dominator tree is being built — so a dump that has the class and not the fields throws a bare NPE from
76+under everything, and what you see is a window that spins for ever and never draws a tree.
77+ 
78+Real dumps have all three. Synthetic ones written with the `dump { }` DSL have whatever the fixture wrote,
79+which is why `ReferenceStrengthReader.recordsAndroidBuild` checks the fields rather than the class, and
80+why the test fixtures that write an `android.os.Build` write `ID` too even though nothing in the window
81+shows it.
82+ 
83+## Every run writes a log file
84+ 
85+`installLogging()` in `shark-explorer-app` points `SharkLog` at stdout **and** at
86+`~/.shark-explorer/logs/shark-explorer-<when-it-started>.log`, one file per run, the newest
87+`SessionLog.KEEP_SESSION_COUNT` kept and the rest deleted as a run starts.
88+ 
89+**So ask for that file when someone reports something odd**, and read it before guessing. It holds the
90+environment (JVM, OS, heap limit — a dump too large for the explorer runs out of exactly that), every
91+step of opening the dump with its duration, and every read of it through `HeapDumpSession.read` with
92+what was being read and how long it took. What that makes readable:
93+ 
94+- A read logged as started and never as done is where the app was killed, hung, or ran out of memory.
95+- The last line being `Shark Explorer closed` is how a session that ended cleanly is told from one that
96+ didn't.
97+- Everything the window does silently — a path zoomed out because a node left the tree, a click landing
98+ on an object the tree has no node for, a list that came back empty — says so there rather than nowhere.
99+- A run is every window of it, and a window is a heap dump, so the reads of several dumps interleave.
100+ The `[heap-dump-<file name>]` a line was written from is which dump it is about; lines from the
101+ window's own thread name the file instead.
102+ 
103+Which is also the rule for new code here: **anything the UI swallows or falls back from gets a
104+`SharkLog.d` line saying so.** The file is only worth reading if it's complete.
105+ 
106+## This app has its own version line, and no number of it can mean "alpha"
107+ 
108+`SHARK_EXPLORER_VERSION` in `gradle.properties`, not the repo wide `VERSION_NAME`, and the two are released
109+independently — `shark-explorer-*` tags against `v*` tags. See `docs/releasing-shark-explorer.md`.
110+ 
111+It isn't only a preference. Every installer format validates the version, and what they leave between them,
112+each measured by building it, is **`MAJOR.MINOR.PATCH` with MAJOR from 1 to 255**, MINOR up to 255, PATCH up
113+to 65535:
114+ 
115+- `3.0-alpha-10` — `Illegal version for 'Dmg'`, and for `Msi`. No qualifiers, in any format.
116+- `0.1.0` — fails `createDistributable`, and **not through the version validation the other two trip**:
117+ the Compose plugin accepts it and jpackage then reports `Bundler Mac Application Image skipped because
118+ of a configuration problem: The first number in an app-version cannot be zero or negative`. So the
119+ message says "skipped", is only in `build/compose/logs/createDistributable/jpackage-*-err.txt`, and the
120+ Gradle failure above it names no version at all.
121+- `2026.8.0` and `256.0.0` — `Illegal version for 'Msi'`, whose fields cap at 255.255.65535.
122+ 
123+So `0.x` is not available and neither is a calendar version, and "this is an alpha" is said by the release
124+being a prerelease titled that way. Don't try to encode it in the number, and don't split it across
125+`macOS.packageVersion` and `macOS.packageBuildVersion` either: the user visible one is
126+`CFBundleShortVersionString`, which is also the field Munki compares for Managed Software Center updates,
127+so pinning it would freeze updates for everyone who installed from there.
128+ 
129+**The version reaches the app as a generated resource**, `shark-explorer-version.properties`, written by
130+`writeVersionResource` and read by `SharkExplorerVersion`. Not the jar manifest: `run` and the tests put
131+class directories on the classpath rather than the jar, so `Package.getImplementationVersion()` is null for
132+every way this app is launched while being worked on, and the update check would only be exercisable from a
133+packaged build. `SharkExplorerVersionTest` exists because a broken wiring here fails silently — the version
134+becomes `unknown`, the check declines to run, and no window ever mentions an update.
135+ 
136+## The update check reports and nothing else
137+ 
138+`UpdateCheck` fetches one file and, if it names a later version, `UpdateNotice` puts a bar in every window
139+of the run. Nothing downloads or installs. Three things about it that reading the code won't tell you:
140+ 
141+- **`releases/latest` is the wrong release.** This repository publishes LeakCanary libraries on `v*` tags
142+ and the explorer on `shark-explorer-*` tags, and GitHub has one "latest" pointer per repository, so that
143+ endpoint answers with whichever line released last. Verified: it returns `v3.0-alpha-9`.
144+- **The unauthenticated GitHub API allows 60 requests an hour per IP**, and Block's shared egress can
145+ exhaust that on other people's runs. A release asset download is unmetered, which is why the manifest is
146+ one.
147+- **The manifest is written by `promote-shark-explorer.yml` and nothing else**, so publishing a release and
148+ offering it to everyone are two separate acts. `UpdateCheckTest` pins the manifest format from this side,
149+ because the workflow writes it in bash and nothing else keeps the two agreeing.
150+ 
151+`UpdateNotice` is one per run rather than per window, so dismissing the bar in one window clears it in all
152+of them.
153+ 
154+## A `shark://` link never reaches a run from Gradle, and that is not a bug in the code
155+ 
156+`DeepLink` is the URL, `DeepLinkScheme` is the OS end of it and `DeepLinkPeers` is how a link crosses
157+from one run of this app to another. What reading them won't tell you is that **none of it can be tried
158+from `run` or `runNamed`**, on any platform, and the reason is different on each:
159+ 
160+- **macOS delivers to a bundle identity.** A JVM launched from a shell script — which is what `runNamed`'s
161+ generated bundle is — registers with LaunchServices as `net.java.openjdk.java` whatever the wrapper's
162+ `Info.plist` says. Measured with `lsappinfo find pid=<pid>`. Only a launcher `jpackage` built gets its
163+ own `CFBundleIdentifier`, and the Apple Event carrying the URL goes to the identity, so a `runNamed`
164+ process is handed nothing however well its plist declares the scheme.
165+- **Windows and Linux are registered at runtime**, by `DeepLinkScheme.registerWithTheOs`, and it declines
166+ for a run whose executable is `java` — registering that would tell the OS to open links with a JVM and
167+ no classpath.
168+ 
169+So the loop for anything about links is a package, not a compile:
170+ 
37171 ```bash
38−./gradlew build # what CI runs
39−./gradlew :shark:shark:test # one module's unit tests
40−./gradlew detekt # static analysis, also run by the pre-push hook
41−./gradlew updateKotlinAbi # after any public API change, see below
42−./gradlew siteDokka # regenerate docs/api
172+./gradlew :shark:shark-explorer:shark-explorer-app:createDistributable
173+cp -R "shark/shark-explorer/shark-explorer-app/build/compose/binaries/main/app/Shark Explorer.app" \
174+ ~/Applications/
175+/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
176+ -f ~/Applications/"Shark Explorer.app"
177+open -a ~/Applications/"Shark Explorer.app" --args --title="Links" path/to/dump.hprof
178+grep "Windows of this run" ~/.shark-explorer/logs/$(ls -t ~/.shark-explorer/logs | head -1)
179+open "shark://<the id that printed>/leaks"
43180 ```
44181  
45−`docs/api/` — the `siteDokka` output — is git ignored, not committed. The release process
46−regenerates it just before publishing the site (see `docs/releasing.md`), so a public API change
47−means updating the ABI dump and nothing else. If you do run `siteDokka`, don't edit what it writes;
48−fix the KDoc in the source.
182+**Read the result in the log rather than off the screen.** Following a link raises the app over whatever
183+the person at the machine was doing, so a screenshot to check it worked costs them their window and shows
184+you theirs. `The OS handed this run`, `A link asked window <id> for <place>` and `A link asked this window
185+for <place>` are the three lines that say a link was delivered, routed and opened as a tab.
49186  
50−Instrumentation tests need a device or emulator and only cover `leakcanary-android`,
51−`leakcanary-android-core`, `leakcanary-android-instrumentation` and `leakcanary-android-test`. CI
52−runs them on one emulator per major Android release, from the minSdk to the newest API level with a
53−system image, so a change that only works on some API levels will fail there rather than locally.
187+A run from source is still *reachable*: every run publishes a loopback port under `~/.shark-explorer/runs`,
188+and the installed app hands on any link naming a window it doesn't have. That is what makes a link to a
189+`./gradlew run` window work — the installed app is the courier, so there has to be one.
54190  
55−## Things that will bite you
191+**Deliberately not single instance.** Several explorers open at once is how this app is used, so a run
192+holding a link asks each of the others in turn rather than the second run handing its command line to the
193+first and exiting.
56194  
57−**Public API changes fail the build until the ABI dump is updated.** `checkKotlinAbi` compares the
58−public ABI against the committed `api/*.api` files and runs as part of `check`, so `./gradlew build`
59−catches it. When it fails, run `./gradlew updateKotlinAbi` and commit the changed `api/*.api` files —
60−but read the diff first, because an unintended ABI change is exactly what this is meant to catch.
61−Modules with no public API are exempt; they're listed in `modulesWithoutPublicApi` in the root
62−`build.gradle.kts`.
195+## Gradle facts that aren't visible from these build scripts
63196  
64−**There are two ABI validation mechanisms, deliberately sharing task names** so that one command
65−covers the whole repo: the Kotlin Gradle plugin's `abiValidation()` for JVM modules, and an
66−equivalent pair of tasks hand-rolled in the root `build.gradle.kts` for Android library modules,
67−which KGP doesn't support yet ([KT-83410](https://youtrack.jetbrains.com/issue/KT-83410)).
197+- **`shark-explorer-app` is excluded by name** from the repo-wide Java 8 target in the root
198+ `build.gradle.kts`, because Compose Multiplatform's artifacts aren't built for Java 8. If you
199+ rename or move the module, update that exclusion list or the build breaks confusingly.
200+- **All three modules are listed in `modulesWithoutPublicApi`** in the root `build.gradle.kts`. They
201+ are not published to Maven Central, their ABI isn't tracked, and they're left out of the docs site.
202+ So there is no `api/*.api` file to update and `updateKotlinAbi` doesn't apply.
203+- **`jdk.jdi` is listed in the app's `nativeDistributions.modules`.** jlink includes only the JDK
204+ modules it detects a use of, and it detects none through `Bootstrap.virtualMachineManager()`, so a
205+ packaged build without that line attaches to nothing.
206+- `compose` and `composeMultiplatform` in `gradle/libs.versions.toml` are **unrelated**: the first
207+ is the Jetpack Compose version the Android app builds against, the second is Compose
208+ Multiplatform for this desktop app.
68209  
69−**So use `checkKotlinAbi`/`updateKotlinAbi`, never `checkLegacyAbi`/`updateLegacyAbi`.** The `Legacy`
70−pair is what Kotlin's own ABI validation documentation calls these tasks, but here they come straight
71−from KGP and therefore exist *only on the JVM modules* — they silently skip every Android library
72−module, which is most of the published ones. A green `updateLegacyAbi` means less than half the repo
73−was covered.
210+## The app icon is generated, and its macOS shape is baked in
74211  
75−**Some dependency versions are deliberately old.** The `compileOnly` AndroidX versions in
76−`gradle/libs.versions.toml` are pinned to the *lowest* version LeakCanary supports, so that apps
77−resolve to their own newer version without needing a resolution strategy. The inline comments say
78−which ones and why. Don't bump them to fix a warning.
212+`shark-explorer-app/icons/shark-explorer-icon.svg` is the source, and
213+`shark-explorer-icon-small.svg` beside it is the **same shark with the gills, teeth and brow left
214+out**, for the sizes where those come out under a pixel. The `.icns`, the `.ico` and
215+`src/main/resources/shark-explorer-icon.png` are **rendered from the two of them** by
216+`icons/render-icons.sh`, which picks by size, so edit an SVG and re-run that script rather than
217+touching a binary. It needs `rsvg-convert` (`brew install librsvg`), and `iconutil`, which is macOS
218+only.
79219  
80−**`HprofRetainedHeapPerfTest` and `HprofIOPerfTest` freeze exact numbers** — bytes read, and memory
81−retained at each analysis step, within a margin. A change to how the analysis allocates or reads will
82−fail them. That's the point: they exist to make memory and I/O regressions visible. Investigate
83−before adjusting the expected values, and say in the PR why the new number is correct.
220+**The two drawings share one transform**, hard coded rather than fitted twice, so that the shark
221+doesn't shift when the dock crosses between them. Changing the shape in one means changing it in the
222+other and keeping that transform identical.
84223  
85−**detekt runs on pre-push and in CI**, config at `config/detekt-config.yml`. The hook installs itself
86−via the `assemble` and `clean` tasks, so a fresh clone gets it after the first build. Run `detekt`
87−before pushing rather than discovering it at push time.
224+The SVG has the macOS app icon grid drawn into it — an 824x824 rounded body inside a 1024x1024
225+canvas, with its own shadow — because jpackage ships an `.icns` and **nothing masks or insets that for
226+us**, unlike an Android adaptive icon or a macOS 26 `.icon` bundle. So a redesign has to keep drawing
227+the body and the padding, and the corners are a superellipse rather than a circular arc.
88228  
89−**`gh pr merge --auto` does not wait for CI here — it merges on the spot.** Auto-merge is enabled on
90−the repo, but `main` is deliberately left unprotected, so there are no required status checks for
91−auto-merge to gate on. GitHub sees a mergeable pull request with nothing to wait for and merges
92−immediately, exiting zero and printing nothing, which reads exactly like it armed. Nothing in the
93−repo will stop a merge while CI is red — `main` is open on purpose — so waiting for green is your
94−job, not the platform's. Wait explicitly and let the exit code decide:
229+**The macOS dock icon of a `./gradlew run` needs no runtime code.** The Compose plugin turns
230+`nativeDistributions.macOS.iconFile` into `-Xdock:icon` on the run task, verified by A/B: drop that
231+one line from the build script and the flag is gone from the run's JVM arguments and the default Java
232+icon is back. So `java.awt.Taskbar` has nothing to add here. `Window(icon = …)` does, but only for the
233+Windows and Linux title bar — macOS ignores it.
95234  
235+**`-Xdock:icon` is what puts the icon on the tile, and a bundle around the JVM is not a substitute.**
236+AWT sets the dock tile from that flag as it starts, and when the flag is absent it sets the tile to
237+the Java icon — over whatever the bundle asked for. So `runNamed` passes the flag too, even though its
238+generated bundle declares `CFBundleIconFile`. `CFBundleIconFile` is not ignored, it is just overwritten:
239+`NSRunningApplication.icon` for a `runNamed` process without the flag hands back the shark, because
240+that is the LaunchServices record, while the tile on screen is Duke. **Which is the trap** — every API
241+an agent can read says the icon is right, and only a picture of the dock says otherwise.
242+ 
243+## What macOS calls the run, as against what it calls a window
244+ 
245+A run is one process and many windows, so the OS gets one name for all of them, and `main` sets it from
246+`--title` before the first window: `apple.awt.application.name`. That reaches the menu bar next to the
247+Apple logo, the app switcher, and every name macOS reports through an API. **It does not reach the
248+dock** — nothing a process can do reaches the dock, see the next section. Three things about that line
249+aren't visible from it.
250+ 
251+**It is read once, as AWT starts**, and the process registers with macOS under whatever it said then.
252+Setting it after a window is up changes nothing — measured, not assumed — so it belongs between parsing
253+the command line and `application { }`, which is the only gap there is.
254+ 
255+**It is the same name `-Xdock:name` sets.** That JVM argument only puts the name in an environment
256+variable AWT reads at that same moment: a run given `-Xdock:name=X` and a run that sets the property to
257+`X` in `main` produce LaunchServices records differing in nothing but their audit token and check-in
258+time. So the run task passes no name and an IDE run configuration needs none either, and
259+`java.awt.Taskbar` is no help — its API is icon, badge, menu and progress, and no name.
260+ 
261+**A packaged app ignores the property and keeps its bundle's name.** The `.app` launched with
262+`--title="Packaged with a title"` logs that title and is still called whatever `packageName` made the
263+bundle, because jpackage gives it a real bundle. A run from Gradle has no bundle of its own — it is `/…/bin/java`,
264+bundle id `net.java.openjdk.java` — which is why it is called after whatever launched it until
265+something names it.
266+ 
267+## The dock only reads a bundle's file name, so `runNamed` gives it one
268+ 
269+`-Xdock:name` has not named the dock since around macOS 10.9 — [JDK-8173753][dock-bug], still open,
270+where the reported symptom is exactly what you get: the name reaches the menu bar and the dock goes on
271+saying `java`. Confirmed here, three explorer runs whose LaunchServices, `NSRunningApplication` and
272+WindowServer names were all different showed three dock tiles all called `java`. **So don't spend time
273+looking for the property or the API call that fixes this. There isn't one.**
274+ 
275+What the dock reads is the file name of the bundle a process was launched from. Not `CFBundleName`:
276+two bundles carrying the same `CFBundleName` and differing only in file name are two differently named
277+tiles.
278+ 
279+`runNamed` is `run` with a bundle around it, generated per launch and named after `--title`:
280+ 
96281 ```bash
97−gh pr checks <number> --watch --fail-fast && gh pr merge <number> --merge
282+./gradlew :shark:shark-explorer:shark-explorer-app:runNamed \
283+ --args="--title=\"Hover previews\" shark/shark-android/src/test/resources/compose_leak.hprof"
98284 ```
99285  
100−`gh pr checks` exits zero only once every check has passed, so the `&&` is what makes this safe;
101−`--fail-fast` returns as soon as one fails instead of sitting through the rest. A run takes 9 to 13
102−minutes, nearly all of it the emulator matrix, so start that command detached — a foreground call
103−that gives up at ten minutes will usually be killed just before the last emulator reports.
286+- **It is a launcher script and an `Info.plist` around the classes `run` would have run**, not a
287+ `jpackage` build. Packaging is a minute of jlink per code change, which would be a minute per look;
288+ this is a compile.
289+- **The script `exec`s the JVM** rather than starting it as a child. The JVM has to end up being the
290+ process macOS launched from the bundle or it is a process of its own again, and the dock is back to
291+ calling it java.
292+- **`open` gives it no terminal**, so stdout goes to `build/named/<title>.out` — which is where a run
293+ that died before it could open a log file says why. Everything after that is in the usual place, see
294+ the logging section.
295+- **Relaunching a title while a window of that title is open** is the one thing to avoid: the bundle is
296+ rewritten in place, and that window is reading it.
104297  
105−## Changelog
298+**And a Gradle build of any kind kills every explorer window already open.** `run` and `runNamed` both
299+put the module jars on the classpath rather than a copy of them, and a JVM reads a jar's index once and
300+then trusts it, so recompiling under a live window makes every class that window hasn't happened to load
301+yet disappear. What that looks like is the window dying on the next pointer move with
302+`NoClassDefFoundError: shark/explorer/TreemapPoint`, a `ClassNotFoundException` under it, and a stack
303+trace through code nobody has touched — the class is in the source and in the jar, which is exactly what
304+makes it read like a real bug in whatever was being worked on. Measured: a window launched at 16:42, a
305+`shark-explorer-core:check` rewriting that jar at 16:46, and the first hover after it gone. So **launch
306+the window you are handing over last**, after everything that builds — and when one dies this way, check
307+the jar's mtime against the process start before believing the stack trace.
106308  
107−Entries go in `docs/changelog.md` under `## Unreleased`, each starting with one of the markers from
108−the legend at the top of that file. Pick the marker from what the change *is*, not from how big it
109−feels, and grep for a comparable existing entry rather than guessing.
309+[dock-bug]: https://bugs.openjdk.org/browse/JDK-8173753
110310  
111−**💥 means a crash fix here, not a breaking change** — the opposite of the
112−[gitmoji](https://gitmoji.dev/) convention, and the mistake that convention trips people into.
113−Reaching for 💥 because a change feels impactful tells readers a crash was fixed when nothing
114−crashed. Breaking changes are ⚠️; when one needs more than a bullet, write it as a
115−`### Breaking change: <summary>` heading with prose.
311+## Reading these names without being able to see the screen
116312  
117−**The changelog is for changes that matter to the people consuming LeakCanary**, not a record of
118−every diff. Refactors, internal cleanups and test-only changes usually don't need an entry.
313+```bash
314+lsappinfo find pid=<pid> # ASN:0x0-0x2338336-"Hover previews":
315+```
119316  
120−## Conventions
317+`NSRunningApplication.localizedName`, which the app switcher shows, and `kCGWindowOwnerName`, which the
318+WindowServer holds, agree with it and are readable from `osascript -l JavaScript` through `ObjC.import`.
319+None of them is what the dock displays, which is why all three can say one thing and the tile another.
121320  
122−- When a function's parameters don't fit on one line, put **each on its own line** — the existing
123− code is consistent about this and detekt won't tell you.
124−- Commit subjects are imperative and describe the change, e.g. "Keep modules without a public API off
125− the documentation site". Explain *why* in the body when it isn't obvious.
126−- Don't leave test-only or unused code in the committed tree. If scaffolding was needed to get
127− somewhere, remove it before the PR lands.
128−- Test heap dumps are built with the `dump { }` DSL from `shark-hprof-test` (see `docs/dev-env.md`)
129− rather than committed as binary fixtures. Never hand-assemble hprof bytes. For a large realistic
130− dump, drive a real JVM via `HotSpotDiagnosticMXBean.dumpHeap`.
131−- Tests use JUnit 4 and AssertJ. Instrumentation tests depend on `libs.assertjCore.android` rather
132− than `libs.assertjCore`, because AssertJ 3.16 and up can't load on API 24 — the catalog comment
133− says why. So an assertion that a unit test can use may not compile in an instrumentation test.
321+The dock tile names, and an app's menu bar, are readable **only with the Accessibility permission**, and
322+they are the ones worth reading, since they are what someone looking at the screen sees:
134323  
135−## Scoped guides
324+```bash
325+osascript -e 'tell application "System Events" to tell process "Dock" \
326+ to get name of UI elements of list 1'
327+osascript -e 'tell application "System Events" to tell process "<the run>" \
328+ to get name of menu bar items of menu bar 1' # Apple, <the run>
329+```
136330  
137−Subdirectories may carry their own `AGENTS.md`, and the closest one to the file being edited wins,
138−like `.gitignore`. Prefer putting guidance in the narrowest place it applies over growing this file —
139−a module's quirks belong next to the module.
331+Without that permission both fail with `-1719 not allowed assistive access`, and the only way to know
332+what the dock says is to ask the person in front of it. It is granted per responsible process — for an
333+agent, whichever app launched the session — in System Settings → Privacy & Security → Accessibility.
334+Screen Recording is separate, and without it a screenshot of another process comes back as wallpaper.
140335  
141−Claude Code reads `CLAUDE.md` rather than `AGENTS.md`, so each `AGENTS.md` is paired with a
142−`CLAUDE.md` containing `@AGENTS.md`. Add both when adding a scoped guide.
336+**A tile's icon, though, only a screenshot of the dock will tell you.** `NSWorkspace.iconForFile` on a
337+bundle and `NSRunningApplication.icon` on a pid both hand back a PNG an agent can open, but both read
338+the LaunchServices record rather than the tile, so both are wrong the moment AWT overwrites it — see
339+the `-Xdock:icon` section. With Screen Recording granted, this is the picture that settles it:
340+ 
341+```bash
342+# The dock has no window while it is hidden, so a capture of where AX says the tile is comes back blank.
343+# Post mouse moves down to the bottom edge — one warp isn't enough, it takes an approach and a dwell —
344+# then ask AX for the tile again: a y that has moved up by the dock's height means it is on screen.
345+osascript -e 'tell application "System Events" to tell process "Dock" \
346+ to get {position, size} of (first UI element of list 1 whose name is "<the run>")'
347+screencapture -x -R <x>,<y>,<w>,<h> tile.png
348+```
349+ 
350+Put the cursor back where it was afterwards, since it is someone's cursor.
351+ 
352+## Build and test
353+ 
354+```bash
355+./gradlew :shark:shark-explorer:shark-explorer-core:test
356+./gradlew :shark:shark-explorer:shark-explorer-jdwp:test
357+./gradlew :shark:shark-explorer:shark-explorer-app:test # UI tests, headless, no emulator
358+./gradlew :shark:shark-explorer:shark-explorer-app:check # test + detekt
359+ 
360+# Launch it. Paths are optional; without one, use the "Open heap dump…" button. One window per path,
361+# and one per heap dump opened from the button — see `notes/decisions.md`.
362+./gradlew :shark:shark-explorer:shark-explorer-app:run \
363+ --args="--title=\"Hover previews\" shark/shark-android/src/test/resources/compose_leak.hprof"
364+```
365+ 
366+The repo has real Android heap dumps to try it on: `shark/shark-android/src/test/resources/*.hprof`
367+and `leakcanary/leakcanary-android-instrumentation/src/androidTest/assets/large-dump.hprof` (39 MB,
368+the biggest one). All of them are from API 25 or earlier, so every bitmap in them carries its pixels —
369+anything about a modern dump has to be tried on one taken off a device. See `notes/bitmaps.md`.
370+ 
371+**Always pass `--title`, and name the run after the piece of work it is for.** Several explorers end up
372+open at once — one per task, often on the same heap dump — and a name is all the OS gives you to tell
373+them apart. `--title` goes in front of the heap dump name in every window of that run, including windows
374+opened from it later, so that two identical `large-dump.hprof` windows never end up on screen.
375+`ExplorerArguments` is the whole command line, and it is strict: an unknown option is a message saying
376+what to type, not a heap dump that can't be found.
377+ 
378+**`run` while you work, `runNamed` when you hand a window over.** They take the same command line.
379+`run` streams the log to the terminal and is a compile away, so it is the one for trying your own
380+change — don't reach for `runNamed` for that. When the change is done and the app is being started for
381+someone else to look at, use `runNamed`: it is the only one of the two the dock will name, and with
382+several explorers open the dock is what they navigate by. See the dock section above.
383+ 
384+`check` runs detekt (config at `config/detekt-config.yml`); CI and the pre-push hook both enforce
385+it, so run it before pushing.
386+ 
387+Anything that reaches a device — taking a heap dump, fetching bitmaps — can be tried for real with an
388+emulator running and `leakcanary-android-sample` installed on it
389+(`ANDROID_SERIAL=emulator-5554 ./gradlew :samples:leakcanary-android-sample:installDebug`). An emulator
390+older than API 35 is what exercises `shark-explorer-jdwp`, since a newer one is asked through a heap dump
391+instead.
392+ 
393+**Two things let a process be dumped, and either is enough**: an app built debuggable, or a device whose
394+whole build is — `ro.debuggable=1`, which is what a `userdebug` or `eng` image sets and what
395+`ActivityManagerService.enforceDebuggable` skips its check on. So "only a debuggable app can be dumped"
396+is right for a phone and wrong for a `userdebug` emulator, where every process on the device can be
397+dumped and attached to. Measured on two emulators here: an API 36 `user` image refuses
398+`am dumpheap` of `com.android.systemui` with `SecurityException: Process not debuggable` and lists one
399+pid under `adb jdwp`; an API 29 `userdebug` one writes 16 MB of `com.android.permissioncontroller` and
400+lists twenty. `AndroidDevice.dumpsAnyProcess` is that property, read from the `getprop` the explorer
401+already runs.
402+ 
403+**A modern emulator image is a `user` build**, so being an emulator is not what makes a device
404+permissive — check `ro.debuggable` rather than assuming.
405+ 
406+**None of that is covered by a test**, and it can't be: a JDI client talks to a real VM or to nothing.
407+Drive it from a throwaway test against a running emulator, read the numbers, and delete the test — the
408+numbers belong in `notes/bitmaps.md`.
409+ 
410+## Testing conventions
411+ 
412+- **UI tests are headless JVM tests**, not instrumentation tests. They live in `src/test/` and use
413+ `androidx.compose.ui.test.v2.runComposeUiTest`. Import from the **`.v2` package** — the non-v2
414+ `runComposeUiTest` is deprecated.
415+- **A test of the whole window runs at the size a window opens at**, which is what `explorerUiTest` is
416+ for. The default test window is smaller, and there the panes beside the view squeeze the controls above
417+ it to zero width, so a test would be pressing a window nobody has.
418+- **Hovering takes two moves.** A view describes what the pointer *moved* onto and ignores the enter that
419+ comes with a pointer arriving, so a single injected `moveTo` reports nothing hovered. `hover()` in
420+ `ExplorerUiTest.kt` moves twice; `notes/decisions.md` says why the views read events that way.
421+- **An injected scroll only lands after a `waitForIdle()`.** `performMouseInput { scroll(n) }` on the
422+ stack does scroll it, but the offset is still 0 in the same breath, because the scroll is animated and
423+ the frame hasn't run — so reading it, or the callback it fires, right after the injection says nothing
424+ happened. Which reads exactly like a wheel a headless test can't deliver, and cost an afternoon of
425+ looking for one. **How far one notch scrolls is the platform's**, ten pixels with no AWT wheel event
426+ behind the pointer event to say otherwise, so a test scrolls by notches and reads the pixels back off
427+ `SemanticsProperties.VerticalScrollAxisRange` rather than asserting a number of its own.
428+- **Each shape draws into a single `Canvas`, so there are no per-cell semantics nodes.** UI
429+ tests can't find cells by tag, and **not by label either** — a cell's label is painted text, so no
430+ assertion and no wait can reach it. Test layout and hit testing as pure functions in
431+ `shark-explorer-core`, and have UI tests drive coordinates with `performMouseInput` and assert on what
432+ is written outside the view: the chain pane and the details panel either side of it, and the card that
433+ follows the pointer, whose text is real text and so can be found and its bounds read.
434+- **A block naming an object is one semantics node**, because `Modifier.openable` merges its descendants,
435+ so a step of the chain is found by any one of the three lines it prints. The same name is usually in the
436+ window several times at once — a step of the chain, a tab, a button on the bar, the details panel — so
437+ `onNodeWithText` failing with "found 2" is that, not a duplicated composable. **Tell them apart by the
438+ role, not by `hasClickAction()`**, since all of them have one: `Role.Button` is the bar, `Role.Tab` is
439+ the strip, and no role at all is a row that navigates, which is a link rather than a button. The
440+ `isTab()` and `isButton()` matchers in the test files are that, and `openable` deliberately sets no role
441+ so that the third case exists.
442+- **A UI test knows the map is drawn through `waitForTheTree`**, which waits for the view's
443+ `contentDescription` with nothing left spinning, because the drawn map itself adds no text to the
444+ window. Where "the map *moved*" is the point rather than "the map is there", wait on the log line
445+ every layout writes instead — `ExplorerAppTest.waitUntilZoomedIn`.
446+- **A headless test can write a PNG of what Skia drew**, which is how an agent gets to look at this
447+ UI at all: `onRoot().captureToImage().toAwtImage()` and `ImageIO.write`, after a
448+ `performMouseInput`, renders the hover highlight and the path bar the same as a real window does.
449+ Nothing outside the JVM can do this — macOS shows a process that lacks Screen Recording only the
450+ desktop picture and its own windows, so a screenshot of a `./gradlew run` taken from any other
451+ process comes back as wallpaper. Delete the capture again once it has been looked at; it's
452+ scaffolding, not a test.
453+- **The UI tests record `SharkLog` for every test**, not only for the ones asserting on it. A log
454+ line is built from state — an index into a path, a node id — so a line built from the wrong state
455+ should fail the test that reaches it rather than wait for a session nobody can read. The `RecordedLog`
456+ rule does the recording, and is a rule rather than a `@Before` because putting the logger back is the
457+ part that isn't optional: a test that leaves `SharkLog.logger` set breaks every test after it.
458+- **What the log says is also how often something happened.** Every read of the heap dump is one line
459+ through `HeapDumpSession.read`, so counting them is what holds the window to laying the tree out once
460+ per view asked for, which is what `TreeLayoutTest` does. Those counts are sound because reads queue on
461+ that one thread in order: anything queued behind the read a test waited for is already logged by then.
462+- **A `Window` needs a display, so nothing inside `application { }` is covered headless.** Which
463+ window a heap dump opens in is plain state in `ExplorerWindow.kt`, unit tested by
464+ `ExplorerWindowTest`. `ExplorerApp` is one window's worth of app and takes the heap dump it shows
465+ as a parameter, so a UI test drives one window and nothing else.
466+- **A click is a fraction of the view, never of the window.** `ExplorerAppTest.viewBounds` measures the
467+ view by its `contentDescription`, and every press helper is relative to that. Window fractions break
468+ the moment anything above the view changes height, which is a change to the top bar away.
469+- **A test about `java.lang.ref` strengths needs a dump of a real JVM, taken without collecting first.**
470+ `JvmReferenceStrengthTest` writes one with `HotSpotDiagnosticMXBean.dumpHeap(path, live = false)`,
471+ because the collection a heap dump normally begins with clears a weak referent nothing else holds and,
472+ since JDK 9, a phantom one — so the strengths the test is about would be missing from a dump taken the
473+ usual way. That leaves it fragile in a way its KDoc explains: anything allocated between its
474+ `System.gc()` and the dump can trigger a collection that clears the lot. It doesn't replace the
475+ `dump { }` cases either, ART's reference classes and the lists it keeps them on not being HotSpot's.
476+- Build test heap dumps with the `hprofFile.dump { }` DSL from `shark-hprof-test` rather than
477+ checking in binary fixtures or hand-writing hprof bytes. A dump with bitmaps in it is `BitmapDumps.kt`
478+ in `shark-explorer-core`'s tests — the `"a.b.C" instance { }` shorthand declares a class per instance,
479+ so two bitmaps built that way are two `android.graphics.Bitmap` classes, which no real dump has.
480+- **A UI test that opens a note must pass an `ExplorerNotes` over a temporary directory.** `ExplorerApp`'s
481+ default keeps notes in `~/.shark-explorer/notes`, so a test taking it writes into the notes of whoever is
482+ running it. A window that never opens one only lists that directory to see which tabs to mark, which is
483+ why the tests that don't touch notes need no directory of their own.
484+- **A UI test must pass a `DeviceHeapDumps` built on a fake `Adb`.** `ExplorerApp`'s default shells out to
485+ the machine's `adb`, so a test that takes it has whatever device is plugged in to answer for — and the
486+ window can dump the heap of a real process. `FakeAdb` matches command prefixes, because the remote dump
487+ path contains a timestamp.
488+- **A synthetic Android class needs the fields the object inspectors read.** `HeapObjectSummary` runs
489+ `AndroidObjectInspectors`, and those read fields with `!!` — an `android.view.View` without `mParent`,
490+ `mWindowAttachCount`, `mAttachInfo` and `mContext` makes `summarize()` throw a bare
491+ `NullPointerException` from inside shark-android, which reads like a bug in the explorer.
492+ 
493+## Notes
494+ 
495+Design decisions and findings, kept current as the work proceeds:
496+ 
497+- `notes/decisions.md` — stack and structure decisions, with rationale
498+- `notes/dominator-tree.md` — dominator algorithm findings, memory/perf numbers
499+- `notes/treemap-rendering.md` — adaptive depth model, the two shapes, bugs in the existing Android
500+ treemap
501+- `notes/bitmaps.md` — which Android versions put a bitmap's pixels in the heap dump, and the two ways
502+ the ones that don't are fetched off the device
503+- `notes/dependency-injection.md` — what Dagger and Metro leave in a heap dump, why the owner rule is
504+ about the provider rather than the component, and how to dump really generated code
505+ 
506+Update these in the same change that makes them stale. They're for agents, so keep them short and
507+skip anything derivable from the code.
143508  

Also from Kynth Studios

Built for the same person as RuleStack

ToolDrift

What the AI coding tools changed last night

tooldrift.kynth.studio

StillShipping

Which agent tools have stopped shipping

stillshipping.kynth.studio

BlockDex

Search inside every shadcn registry

blockdex.kynth.studio

The studio list

One product, taken apart, once a month

Kynth Studios pulls one shipped product open every month — what it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did. One email a month, nothing in between.

Double opt-in — we send one confirmation link and nothing else until you click it.

RuleStack

Built by

Kynth Studios

the studio behind ToolDrift, StillShipping and BlockDex

part of Toolproof, the measurement layer for AI agent tooling

Directory

Configs
Stacks
Compare formats
AGENTS.md vs CLAUDE.md
Cursor rules alternatives
Diff two configs
Best AGENTS.md examples
Best Cursor rules examples
What goes in a CLAUDE.md

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

© 2026 RuleStack. A Kynth Studios product. Changelog

RuleStack

The studio list

One product, taken apart, once a month

Kynth Studios pulls one shipped product open every month — what it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did. One email a month, nothing in between.

Double opt-in — we send one confirmation link and nothing else until you click it.

RuleStack

Built by

Kynth Studios

the studio behind ToolDrift, StillShipping and BlockDex

part of Toolproof, the measurement layer for AI agent tooling

Directory

Configs
Stacks
Compare formats
AGENTS.md vs CLAUDE.md
Cursor rules alternatives
Diff two configs
Best AGENTS.md examples
Best Cursor rules examples
What goes in a CLAUDE.md

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

© 2026 RuleStack. A Kynth Studios product. Changelog

RuleStack

The studio list

One product, taken apart, once a month

Kynth Studios pulls one shipped product open every month — what it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did. One email a month, nothing in between.

Double opt-in — we send one confirmation link and nothing else until you click it.

RuleStack

Built by

Kynth Studios

the studio behind ToolDrift, StillShipping and BlockDex

part of Toolproof, the measurement layer for AI agent tooling

Directory

Configs
Stacks
Compare formats
AGENTS.md vs CLAUDE.md
Cursor rules alternatives
Diff two configs
Best AGENTS.md examples
Best Cursor rules examples
What goes in a CLAUDE.md

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

© 2026 RuleStack. A Kynth Studios product. Changelog

RuleStack