From 33f32cb4c9f2431f83c9596d78dfd1f9a4fbb4f8 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:05:55 -0500 Subject: [PATCH] =?UTF-8?q?android:=20the=20long=20session=20=E2=80=94=20w?= =?UTF-8?q?hat=20the=20app=20is=20still=20holding=20after=20an=20hour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- ANDROID.md | 105 ++++++++++++++ src/mobile/Mobile.svelte | 4 +- src/mobile/art.svelte.js | 88 ++++++++++++ src/mobile/playlists.svelte.js | 6 + tests/mobile/smoke.mjs | 253 +++++++++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 2 deletions(-) diff --git a/ANDROID.md b/ANDROID.md index 0471c10..72ad644 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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 `` 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 `` — 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, diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 07a653c..cf50b31 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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(); }; }); diff --git a/src/mobile/art.svelte.js b/src/mobile/art.svelte.js index 096e628..0e3f1a6 100644 --- a/src/mobile/art.svelte.js +++ b/src/mobile/art.svelte.js @@ -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 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 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 }; +} diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js index 61acf39..cffeaa2 100644 --- a/src/mobile/playlists.svelte.js +++ b/src/mobile/playlists.svelte.js @@ -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; diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 0c0a69c..02cc350 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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 */