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

+253
View File
@@ -467,6 +467,70 @@ const PROBE = `(function () {
/* the glass bar above plus the floating bars below, as a share of the screen */
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
/* ── the long session: what the app is still HOLDING ─────────────
Minutes is all a walk has ever been. These are the numbers that
decide what hours look like: the thumbnail map and its side
tables (from the store itself — they are module-private, so
artTally is the only way to see them), the catalog's ten
session maps, the queue, the transfer list, the DOM, and the
live listeners and timers.
The listener and timer counts come from a registry that a step
installs IN THE PAGE, not from here: they are -1 until it is
installed, which means every step before the session block, every
step after it puts itself away again, and the whole phone walk,
where it is never installed at all. They count what was
registered AFTER the install and is still ATTACHED — window,
document, or an element that is connected — so the absolute
number is small and depends on how much of the walk ran first;
it is the DELTA across the laps that means anything. Attached
rather than add-minus-remove because that counter is a liar in
this app: {#key nav.seq} replaces the .screen element on every
navigation, so each new screen gets its own scroll handler and
the old element is simply dropped.
grew* is the same number against whatever the block latched into
window.__ssBase, and -1 when nothing has. Every backslash here is
doubled and there is not one backtick, comment included — this
block lives inside a template literal. */
tallyArt: (function () {
/* one census per probe, kept in __ssLast for the fields below:
a field of an object literal cannot see its siblings, and
this insert is the only part of the probe this round owns.
All of it is a read; nothing here fetches or evicts. */
window.__ssCensus = function () {
var out = { art: 0, chars: 0, accents: 0, pending: 0, cat: 0, queue: 0, xfer: 0, nodes: 0, ears: -1, timers: -1, ticks: -1 };
try { var a = __ss.artTally(); out.art = a.art; out.chars = a.chars; out.accents = a.accents; out.pending = a.pending; /* waiting is a SUBSET of pending, never a second queue */ } catch (e) {}
try {
var c = __ss.catalog;
var ks = ['artists', 'albums', 'extras', 'profiles', 'seeds', 'albumInfo', 'popular', 'heroSeeds', 'overviews', 'listeners'];
for (var i = 0; i < ks.length; i++) out.cat += Object.keys(c[ks[i]] || {}).length;
} catch (e) {}
try { out.queue = __ss.player.queue.length; } catch (e) {}
try { out.xfer = Object.keys(__ss.transfers.items).length; } catch (e) {}
try { out.nodes = document.getElementsByTagName('*').length; } catch (e) {}
try { var k = window.__ssCount(); out.ears = k.listeners; out.timers = k.timeouts; out.ticks = k.intervals; } catch (e) {}
return out;
};
window.__ssLast = window.__ssCensus();
return window.__ssLast.art;
})(),
tallyChars: (function () { try { return window.__ssLast.chars; } catch (e) { return 0; } })(),
tallyAcc: (function () { try { return window.__ssLast.accents; } catch (e) { return 0; } })(),
tallyPend: (function () { try { return window.__ssLast.pending; } catch (e) { return 0; } })(),
tallyCat: (function () { try { return window.__ssLast.cat; } catch (e) { return 0; } })(),
tallyQueue: (function () { try { return window.__ssLast.queue; } catch (e) { return 0; } })(),
tallyXfer: (function () { try { return window.__ssLast.xfer; } catch (e) { return 0; } })(),
tallyNodes: (function () { try { return window.__ssLast.nodes; } catch (e) { return 0; } })(),
tallyEars: (function () { try { return window.__ssLast.ears; } catch (e) { return -1; } })(),
tallyTimers: (function () { try { return window.__ssLast.timers; } catch (e) { return -1; } })(),
tallyTicks: (function () { try { return window.__ssLast.ticks; } catch (e) { return -1; } })(),
grewArt: (function () { try { return window.__ssLast.art - window.__ssBase.art; } catch (e) { return -1; } })(),
grewCat: (function () { try { return window.__ssLast.cat - window.__ssBase.cat; } catch (e) { return -1; } })(),
grewEars: (function () { try { return window.__ssLast.ears - window.__ssBase.ears; } catch (e) { return -1; } })(),
grewTimers: (function () { try { return window.__ssLast.timers - window.__ssBase.timers; } catch (e) { return -1; } })(),
grewTicks: (function () { try { return window.__ssLast.ticks - window.__ssBase.ticks; } catch (e) { return -1; } })(),
grewNodes: (function () { try { return window.__ssLast.nodes - window.__ssBase.nodes; } catch (e) { return -1; } })(),
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
};
})()`;
@@ -1163,6 +1227,166 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 },
{ do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 },
/* ── the long session ─────────────────────────────────────────────
Every walk before this one was minutes long. A phone runs the app
for hours, and nothing in the port had ever been looked at for a
leak. This block is both the instrument and the assertion.
The registry is installed by a STEP rather than through
Page.addScriptToEvaluateOnNewDocument, and that is load-bearing: the
first-run block above reloads three times and reattach() opens a NEW
DevTools session, so a script registered on the old one is gone for
the rest of the run. Installed from here it covers exactly the laps
the assertion is about, and the last step takes it out again. It
wraps addEventListener/removeEventListener (holding a WeakRef to
anything that is not window or document) and the four timer
functions, and it counts only what was registered AFTER it went in
and is still ATTACHED. Attached, because the naive counter — add
minus remove — climbs about twelve a lap in this app and none of it
is a leak: {#key nav.seq} replaces the .screen element on every
navigation and the old element's scroll handler goes with it. After
the install, because nothing can be registered before it: the
absolute count is therefore small (two after the whole walk, eight
from a settled boot) and only the DELTA across the laps is the
measurement. A listener or an interval stranded once a lap shows as
+9 in one and +9 in the other; it was driven to check.
Then two laps to warm, a reading latched into __ssBase, nine more
laps, and a second reading. A lap is the five tabs, the Library, a
record, Play, the mini bar, all three faces of the sheet, the artwork
viewer, a row's half sheet, the artist page (a catalog lookup) and
back — about five seconds each, and fifty in all. The lap count is
what makes the assertion sharp: a one-per-lap leak shows as +9
against a headroom of four, and trimming it to save time blunts the
only thing this block is for.
Nothing here reaches the network or the disk beyond what any other
mock step does, and the last step puts the Library back on whichever
section the walk left it on. */
{ do: `
window.__ssLibTab = (function () { try { return __ss.nav.stacks.library[0].c.tab || 'albums'; } catch (e) { return 'albums'; } })();
window.__ssLapN = 0;
window.__ssLap = function (n) {
var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
var tap = function (sel, text) {
var all = document.querySelectorAll(sel);
for (var k = 0; k < all.length; k++) if (!text || (all[k].textContent || '').indexOf(text) >= 0) { all[k].click(); return true; }
return false;
};
/* the app's own back, never history.back(): walking off the
history mirror leaves file:// and the page is simply gone */
var peel = async function () {
for (var b = 0; b < 8; b++) {
var nv = __ss.nav;
if (!nv.timing && !nv.viewer && !nv.half && !nv.sheet && nv.stacks[nv.tab].length === 1) return;
__ss.back();
await nap(190);
}
};
return (async function () {
for (var i = 0; i < n; i++) {
/* the counter is global, not the loop's own: the laps
run in threes, and a lap that always opened the same
record would stop asking for anything new after the
first call and quietly measure nothing */
var which = window.__ssLapN++;
__ss.go('search'); await nap(90);
__ss.go('community'); await nap(90);
__ss.go('you'); await nap(90);
__ss.go('library'); await nap(200);
tap('.segtabs button', 'Albums'); await nap(260);
var rel = document.querySelectorAll('.grid2 .release');
if (rel.length) { rel[which % rel.length].click(); await nap(440); }
tap('.rec-acts .disc'); await nap(440);
tap('.mini'); await nap(380);
var fb = document.querySelectorAll('.fbtn');
for (var f = 0; f < fb.length; f++) { fb[f].click(); await nap(110); }
tap('.np-art'); await nap(240);
/* peeled one layer at a time and only if it opened: a
record with no cover never opens the viewer, and a
blind pair of backs would pop the album page instead
and take the rows below with it */
if (__ss.nav.viewer) { __ss.back(); await nap(220); }
if (__ss.nav.sheet) { __ss.back(); await nap(260); }
var trk = document.querySelector('.trk .r');
if (trk) { trk.click(); await nap(260); tap('.scrim'); await nap(220); }
tap('.rec-artist'); await nap(620);
await peel();
__ss.go('home'); await nap(160);
}
return n;
})();
};
(function () {
if (window.__ssCount) return 'already';
var recs = [], liveT = new Set(), liveI = new Set();
var ael = EventTarget.prototype.addEventListener, rel = EventTarget.prototype.removeEventListener;
EventTarget.prototype.addEventListener = function (t, f) {
try { recs.push({ t: t, f: f, w: (this === window || this === document) ? null : new WeakRef(this), dead: false }); } catch (e) {}
return ael.apply(this, arguments);
};
EventTarget.prototype.removeEventListener = function (t, f) {
try { for (var i = recs.length - 1; i >= 0; i--) if (!recs[i].dead && recs[i].t === t && recs[i].f === f) { recs[i].dead = true; break; } } catch (e) {}
return rel.apply(this, arguments);
};
var st = window.setTimeout, ct = window.clearTimeout, si = window.setInterval, ci = window.clearInterval;
window.setTimeout = function (fn, ms) {
var id, rest = [].slice.call(arguments, 2);
var w = typeof fn === 'function' ? function () { liveT.delete(id); return fn.apply(window, arguments); } : fn;
id = st.apply(window, [w, ms].concat(rest)); liveT.add(id); return id;
};
window.clearTimeout = function (id) { liveT.delete(id); return ct.call(window, id); };
window.setInterval = function () { var id = si.apply(window, arguments); liveI.add(id); return id; };
window.clearInterval = function (id) { liveI.delete(id); return ci.call(window, id); };
/* a listener is LIVE when its target is window, document, or an
element still in the tree; a timer is live until it fires or
is cleared */
window.__ssCount = function () {
var n = 0;
for (var i = 0; i < recs.length; i++) {
var r = recs[i];
if (r.dead) continue;
if (r.w === null) { n++; continue; }
var el = r.w.deref();
if (el && el.isConnected) n++;
}
return { listeners: n, timeouts: liveT.size, intervals: liveI.size };
};
/* and hands the page back exactly as it was found. A sibling
round appending after this block must not inherit a patched
EventTarget or a patched clock; __ssCount and __ssBase go
with them, so every later probe reads the -1 that means
'nothing has measured this' rather than a frozen number. */
window.__ssUninstall = function () {
EventTarget.prototype.addEventListener = ael;
EventTarget.prototype.removeEventListener = rel;
window.setTimeout = st; window.clearTimeout = ct;
window.setInterval = si; window.clearInterval = ci;
delete window.__ssCount; delete window.__ssBase; delete window.__ssUninstall;
return 'removed';
};
return 'installed';
})()
`, wait: 500 },
{ do: `__ssLap(2)`, name: "session-warm", wait: 1200 },
/* the baseline, taken after the warm laps have settled and their own
probe has run — __ssCensus is defined by the probe itself */
{ do: `window.__ssBase = window.__ssCensus(); true`, wait: 300 },
/* nine laps, in threes: cdp.send() gives a step forty-five seconds to
answer and a lap is about five, so one call of nine times out, the
walk records a failure it did not have, and the probe lands in the
middle of a lap instead of at Home */
{ do: `__ssLap(3)`, wait: 400 },
{ do: `__ssLap(3)`, wait: 400 },
{ do: `__ssLap(3)`, name: "session-late", wait: 1400 },
/* back to Home with nothing open, the Library on the section the walk
was using before this block, and the registry out of the page, so
whatever is appended after this finds the app — and the platform —
where the block above left them. This step's own probe therefore
reports -1 for every grew* field, which is the sentinel for
'nothing has latched a baseline': session-late is where the numbers
are. */
{ do: `__settle(); try { __ss.go('library'); __ss.patch({ tab: __ssLibTab }); __ss.go('home'); } catch (e) {} try { __ssUninstall(); } catch (e) {} true`, name: "session-clear", wait: 1000 },
];
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
@@ -1554,6 +1778,35 @@ const MOCK_EXPECT = {
"export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast,
"export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast),
"export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
/* the instrument itself, before anything is asked of it: the registry
is counting, the store is answering, and the walk is not sitting on a
toast from the block above. tallyTicks is only asked to ANSWER (-1 is
'no registry'), never to be non-zero: the app's own intervals were
all started at boot, before the registry went in, so the only live
one here belongs to the mock's playback ticker — requiring it would
tie this guard to the mock instead of to the registry. */
"session-warm": (i) => i.tallyEars > 0 && i.tallyTicks >= 0 && i.tallyArt > 0 && !i.toast,
/* Nine laps later. The listener and interval counts are the sharp ones —
both measured dead flat over the nine, twice — so their headroom is
small on purpose; grewEars allows four for a screen that happens to
be mounted at the second reading and not the first, and grewTicks is
one-sided because a ticker that simply stopped between the readings
is not a leak. Both were driven with a listener and an interval
deliberately stranded once a lap: +9 and +9, and this line failed.
tallyTimers is a CEILING rather than a delta, and nine of the
thirty-two are real: every track start schedules a 47-second
recents refresh (player.svelte.js, the desktop's own shape), so a
walk this fast keeps a dozen of them pending at once. That window is
bounded by its own length and does not grow with the session, while a
genuinely leaking timer climbs past this ceiling as laps are added.
Making it a delta would need a fifty-second quiet period between the
two readings, which is not worth the minute — said here rather than
tightening the number blind. tallyArt is the bound itself; grewArt's
headroom is for a sibling round that puts more records in a lap, and
is to be raised deliberately rather than deleted. */
"session-late": (i) => i.grewEars <= 4 && i.grewTicks <= 0 && i.tallyTimers <= 32
&& i.tallyArt <= 512 && i.grewArt <= 80 && i.grewCat <= 24 && i.grewNodes <= 200,
"session-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
};
/* the phone walk: a record by name, played for real */