android: the long session — what the app is still holding after an hour
Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.
Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.
The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
This commit is contained in:
1 parent
9bfbcfc96c
commit
33f32cb4c9
5 files changed
+454
-2
No files matched your search
+105
@@ -1131,6 +1131,111 @@ track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer
|
|||||||
above moved it to the normal mixer; the phone walk samples the underrun
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in new issue
Block a user