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 above moved it to the normal mixer; the phone walk samples the underrun
counters before and after and fails if they rose. 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) ## Prerequisites (home machine, done 2026-09-12)
- Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools, - Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools,
+2 -2
View File
@@ -40,7 +40,7 @@
import { lyr, loadLyrics } from "./lyrics.svelte.js"; import { lyr, loadLyrics } from "./lyrics.svelte.js";
import Stub from "./screens/Stub.svelte"; import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js"; 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 { 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 { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js"; import { loadPrefs } from "./prefs.svelte.js";
@@ -317,7 +317,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); })); requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason: /* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */ 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(); }; return () => { if (unlisten) unlisten(); };
}); });
</script> </script>
+88
View File
@@ -14,6 +14,79 @@ export const art = $state({});
export const DEFAULT_ACCENT = { accent: "#1f9ad6", hover: "#41b7ee" }; 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 pending = new Map(); // path -> { promise, resolve }
const waiting = []; // LIFO const waiting = []; // LIFO
let inFlight = 0; let inFlight = 0;
@@ -64,6 +137,8 @@ function pump() {
const w = pending.get(p); const w = pending.get(p);
pending.delete(p); pending.delete(p);
if (w) w.resolve(u); if (w) w.resolve(u);
/* bounded here, where it grows, and nowhere else */
trimArt(p);
}) })
.finally(() => { inFlight--; pump(); }); .finally(() => { inFlight--; pump(); });
} }
@@ -118,6 +193,7 @@ export function accentFor(path) {
.then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT)) .then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT))
.catch(() => DEFAULT_ACCENT), .catch(() => DEFAULT_ACCENT),
); );
while (accents.size > ART_MAX) accents.delete(accents.keys().next().value);
} }
return accents.get(path); return accents.get(path);
} }
@@ -129,3 +205,15 @@ export function applyAccent(c) {
s.setProperty("--accent", c.accent); s.setProperty("--accent", c.accent);
s.setProperty("--accent-hover", c.hover); 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); asking.add(k);
api.playlistCover(p.id, 360) api.playlistCover(p.id, 360)
.then((u) => { pls.covers[k] = u || null; }, () => { pls.covers[k] = null; }) .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)); .finally(() => asking.delete(k));
} }
return null; return null;
+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 */ /* 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); })(), 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), 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; })(), 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: `__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: `__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 }, { 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 = { const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"), 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-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-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"), "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 */ /* the phone walk: a record by name, played for real */