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

+2 -2
View File
@@ -40,7 +40,7 @@
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
import { art, wantArt, accentFor, applyAccent } from "./art.svelte.js";
import { art, artTally, wantArt, accentFor, applyAccent } from "./art.svelte.js";
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js";
@@ -317,7 +317,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
+88
View File
@@ -14,6 +14,79 @@ export const art = $state({});
export const DEFAULT_ACCENT = { accent: "#1f9ad6", hover: "#41b7ee" };
/* ── how much of this the phone may hold ────────────────────────────────
The desktop bounds the identical map with these two numbers
(src/lib/state.svelte.js: ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS,
trimmed in insertion order by trimResolvedCache). Kept exactly as they
are rather than invented here: `album_art` answers with base64 JPEG of a
360px thumbnail on both, so 512 covers is the same budget either side.
Until now nothing on the phone gave a byte of it back — one browse of a
114-cover library retains every one of them for the WebView's lifetime,
and a catalog crawl adds to the same map without limit. */
const ART_MAX = 512;
const ART_MAX_CHARS = 16 * 1024 * 1024;
/* The desktop may drop any cold entry, because every caller there asks
through getArt(path). The phone's templates INDEX this map — art[path] in
Cover, Release, Sheet, Viewer, Artist, Discography, Home and the media
card — so a key something is painting would blank on the spot, and
Cover's `visible` action re-arms only when its path PROP changes, so an
evicted cover that is still mounted would never come back at all. Hence
two rules the desktop does not need: never the entry that has just
landed, and never one that a connected <img> is showing. Oldest first
otherwise — Object.keys is insertion order here, because every key is a
file path, an https URL or a "stream:" string and never integer-like,
which would be hoisted to the front and go first. A remembered failure
("" so it is not retried) is evictable like anything else: past the cap
a retry is the cheaper of the two.
The map is allowed to sit OVER its cap for as long as one screen needs
all of it — a mounted grid of 600 covers loses none of them — and it
comes down the moment that screen goes.
Called from pump() and nowhere else, which is never inside an $effect:
Object.keys on a $state proxy reads the version signal, so an effect
that called this would re-run on every cover that lands. The two
writers that do not go through pump() — loadArt's data: short-circuit
and a stream's tag art in player.svelte.js — are therefore bounded at
the next cover that does, which on any screen worth the name is the
next one. Under the cap
it is a key count and a char sum and returns; the pass over the screen's
images is built once, lazily, and only when there is something to drop,
because reading a base64 src is a copy of the whole data URL. */
function trimArt(justAdded) {
const keys = Object.keys(art);
let chars = 0;
for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; }
let live = keys.length;
if (live <= ART_MAX && chars <= ART_MAX_CHARS) return;
let painted = null;
for (const k of keys) {
if (live <= ART_MAX && chars <= ART_MAX_CHARS) break;
if (k === justAdded) continue;
const v = art[k];
if (v) {
if (!painted) {
painted = new Set();
/* the attribute, not the .src property: for a data URL they
are the same string and the property re-serializes it.
The fallback is for an <img> whose src was set as a
property instead — none today, and a miss here is the one
way this could blank a cover. */
for (const img of document.images) {
if (!img.isConnected) continue;
const src = img.getAttribute("src") || img.src;
if (src) painted.add(src);
}
}
if (painted.has(v)) continue;
}
delete art[k];
if (typeof v === "string") chars -= v.length;
live--;
}
}
const pending = new Map(); // path -> { promise, resolve }
const waiting = []; // LIFO
let inFlight = 0;
@@ -64,6 +137,8 @@ function pump() {
const w = pending.get(p);
pending.delete(p);
if (w) w.resolve(u);
/* bounded here, where it grows, and nowhere else */
trimArt(p);
})
.finally(() => { inFlight--; pump(); });
}
@@ -118,6 +193,7 @@ export function accentFor(path) {
.then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT))
.catch(() => DEFAULT_ACCENT),
);
while (accents.size > ART_MAX) accents.delete(accents.keys().next().value);
}
return accents.get(path);
}
@@ -129,3 +205,15 @@ export function applyAccent(c) {
s.setProperty("--accent", c.accent);
s.setProperty("--accent-hover", c.hover);
}
/* ── what this module is holding ────────────────────────────────────────
The map and its three side tables are module-private, so nothing outside
this file can see whether they grow. The walk reads this before and after
a long session and asserts the difference. A pure read — it fetches
nothing, evicts nothing and is safe to call on Kyle's own phone. */
export function artTally() {
const keys = Object.keys(art);
let chars = 0;
for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; }
return { art: keys.length, chars, accents: accents.size, pending: pending.size, waiting: waiting.length, max: ART_MAX };
}
+6
View File
@@ -42,6 +42,12 @@ export function coverFor(p) {
asking.add(k);
api.playlistCover(p.id, 360)
.then((u) => { pls.covers[k] = u || null; }, () => { pls.covers[k] = null; })
/* the key is "id:revision" and an edit bumps the revision, so
every earlier revision of this playlist is unreachable by
construction — nothing looks a playlist's cover up by
anything but its current one. Dropped as the new one lands:
no cap, no policy, just the keys that are already dead. */
.then(() => { const mine = p.id + ":"; for (const old of Object.keys(pls.covers)) if (old !== k && old.startsWith(mine)) delete pls.covers[old]; })
.finally(() => asking.delete(k));
}
return null;