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
@@ -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(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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 <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 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user