android: the long session — what the app is still holding after an hour

Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.

Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.

The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
This commit is contained in:
KeelrLeek committed 2026-09-14 07:05:55 -05:00
1 parent 9bfbcfc96c
commit 33f32cb4c9
5 files changed
+454 -2

No files matched your search

+105
View File
@@ -1131,6 +1131,111 @@ 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)
- Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools,