The audio side had its own lag: `dumpsys media.audio_flinger` showed the
track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer
above moved it to the normal mixer; the phone walk samples the underrun
counters before and after and fails if they rose.

### The long session

Every walk is minutes long; a phone runs the app for hours, and nothing in
the port had ever been looked at for a leak. It has now, by driving the real
bundle in headless Chrome for lap after lap of the whole app — five tabs,
the Library, a record, playback, the mini bar, all three faces of the sheet,
the artwork viewer, a row's half sheet, an artist page — and counting what
the app was still holding at the start and at the end.

**Listeners and timers do not leak, and the obvious counter says they do.**
With a registry installed at document start — an investigation, not what
ships — add minus remove climbs 23 → 119 over eight laps, about twelve a
lap, almost all of them `scroll`. None of it is a leak: `{#key nav.seq}`
replaces the `.screen` element on every navigation, so each new screen gets
its own handler and the old element is dropped whole. Counting only what is
still ATTACHED — `window`, `document`, or an element that is still
connected — gives 29 at the first lap and 29 at the eighth, key for key.
Intervals are the same story: two at boot (the media card's 30 s, the
player's 10 s session writer), three from the first lap on, and three at the
last; `Lane` and `Diagnostics` both clear theirs in their effect teardown,
and every `bind*()` in `src/mobile/` is guarded by a module-level `bound`
flag, so no Tauri subscription is ever registered twice.

**What the walk ships is narrower than that, and says so.** A script
registered with `Page.addScriptToEvaluateOnNewDocument` does not survive the
first-run block's reloads — the re-attach opens a new DevTools session — so
the registry the walk installs goes in as a STEP, after everything else, and
can only see what is registered from that moment on. Its absolute counts are
therefore small and depend on how much ran first: `tallyEars` reads 2 at the
end of the full mock walk and 8 from a settled boot, `tallyTicks` 1 in both
(the mock's own playback ticker). Those numbers are not a census of the app
and are not asserted as one. What is asserted is the DELTA across the laps,
which is where a per-navigation leak shows, and that was proved by planting
one: a listener and an interval stranded once a lap take `grewEars` 0 → 9
and `grewTicks` 0 → 9, and the step fails.

**The thumbnail map did leak, and is bounded now.** `src/mobile/art.svelte.js`
kept every cover it had ever fetched for the WebView's lifetime: one browse
of a 114-cover library took it from 5 keys to 114 and gave nothing back, and
a catalog crawl adds to the same map without limit. A 360 px cover comes
back from `album_art` as base64 JPEG — about 63 KB of string, measured
against this machine's own thumbnail cache — so that browse alone is roughly
7 MB retained. The bound is the desktop's, by its own numbers:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB from
`src/lib/state.svelte.js`, trimmed oldest first. Two rules the desktop does
not need are added, because the desktop's callers ask through `getArt(path)`
while the phone's templates INDEX the map (`art[path]` in Cover, Release,
Sheet, Viewer, Artist, Discography, Home and the media card): the entry that
has just arrived is never dropped, and neither is one a connected `<img>` is
painting — `Cover`'s `visible` action re-arms only when its path PROP
changes, so an evicted cover that is still mounted would stay a disc icon
for good. The few places that paint a cover as a `background-image` rather
than an `<img>` — the ground, the sheet's wash, the artist hero — are
covered twice over: each of them reads `art[path]` inside an effect that
calls `wantArt`, so an eviction under one re-fetches on its own. The cache
is therefore allowed to sit over its cap for as long as one screen needs all
of it, and comes down the moment that screen goes. Built with the cap forced
to 8 and scrolled through eighteen screens of a 140-card Library grid, the
map sat at 61 — every one of them spared for being painted — and not one
cover on screen lost its image at any point; the cards that had scrolled
away repaint cold and fill as they come back, which is what a cold first
visit already looks like. At the shipped cap of 512 a whole library walk
runs the trim zero times and the census is identical either way: on Kyle's
phone the change is inert, and it is the crawl past 512 covers that it
exists for. The accent table gets the same cap — an accent is a pure
function of its cover, so dropping one costs a recompute and nothing else.

**Playlist covers.** `pls.covers` is keyed `id:revision`, and an edit bumps
the revision, so every edit stranded one 360 px data URL under a key nothing
could ever read again. The new cover now drops that playlist's other
revisions as it lands — no cap and no policy, just the keys that are
unreachable by construction.

**Three things are unbounded on purpose.** `catalog.svelte.js`'s ten maps are
cached for the session by design, and evicting from them is not safe the way
the art map is: `Artist.svelte` calls `lookupArtist` inside `untrack`, so a
page whose entry went missing under it would never look it up again.
`lib.svelte.js`'s `tracksCache` is unbounded but finite — it is bounded by
the library. And `player.svelte.js`'s `scheduleRecents` leaves a 47-second
timeout per track start, which self-drains and is the desktop's own shape;
coalescing the two callers into one slot would cancel one of them, so it is
watched under a ceiling instead of changed.

The walk measures all of it. Every step prints `tallyArt`, `tallyChars`,
`tallyAcc`, `tallyPend`, `tallyCat`, `tallyQueue`, `tallyXfer` and
`tallyNodes` from the app itself (`artTally()` on the `__ss` handle, because
the map and its side tables are module-private), and `tallyEars`,
`tallyTimers`, `tallyTicks` plus the `grew*` deltas from a registry the
session block installs in the page. The registry is installed by a STEP, not
by `Page.addScriptToEvaluateOnNewDocument`: the first-run block reloads three
times and the re-attach opens a new DevTools session, so a script registered
on the old one would be gone. The block's last step takes it out again —
`EventTarget` and the four timer functions restored, `__ssCount` and the
baseline deleted — so nothing appended after it inherits a patched platform,
and every step after the block reads −1 for those fields, as does the whole
phone walk, where it is never installed at all. The assertion is a threshold
with headroom, not a stopwatch — the listener and interval counts are held
flat, the art map is held under its cap, and `tallyTimers` is a ceiling
rather than a delta because the recents window keeps a handful pending
during a walk this fast. Across eleven laps of the block as it ships, driven
against a real build of this tree, the listeners and the intervals moved by
0, the timers stood at 9 against a ceiling of 32, the art map grew 17
against a headroom of 80 and the catalog 5 against 24, and the DOM ended 10
nodes smaller than it started.

## Prerequisites (home machine, done 2026-09-12)
