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:
1 parent
9bfbcfc96c
commit
33f32cb4c9
5 files changed
+454
-2
No files matched your search
+105
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user