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
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user