android: the boot chain goes out in one round, and a census that holds it there

Thirty rounds of onMount had never been read end to end, and read end to end
it was a queue: two dozen commands with a critical path ten deep, because
Tauri's `listen` is an invoke like any other. loadLibrary asked for five
independent SQLite reads one behind the other, bindSlsk registered eight
listeners one behind the other, and watchLibrary and the player's bindEvents
did two each. None of them depended on anything before them. They go out in
one round now — the same commands, critical path 10 to 3, and ten of them
used to leave in the shell's own first turn where twenty-three do now — and
in a rig that runs the real store modules with the bridge instrumented,
lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead
of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip
swept rather than measured; the phone's own numbers are unproven until the
walk prints bootMs and bootLibMs.

The behaviour is meant to be identical. loadLibrary's only movable part was
lib.error, which has two writers and used to be decided by the order they were
awaited in; the two messages are now carried out of the round and applied in
that same order, so a failing library still shows the library's message. Every
combination of which of the five reads fails, over six latency shapes, comes
out identical to the old code. bindSlsk still does not return until every
handler is up, so connectStored cannot let a fast connected state land with
nobody listening, and bindEvents still hands back the transport's unlisten and
still rejects only when the transport is the one that failed.

src/lib/boot.js is the project's first launch instrument — one counter every
invoke and every listen passes, sealed at the first frame the shell paints and
never re-opened, so a probe landing a second and a half later still reads the
launch. perf.mjs measures frames; nothing measured the launch. The mock walk's
Home step now holds a budget against it and both walks print bootCalls,
bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no
clock in it: a call that waits on the one before it can only issue its first
in the shell's own turn, so a chain put back in a queue falls under the floor
whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there
at all, which is the census itself being broken rather than the app.

One import line goes with it: Sheet.svelte's `import * as api`, the only one
of the ten dead imports this round turned up whose reason is the bundle at
all. Rollup already drops an unreferenced NAMED import, so a dead name on an
import line is hygiene rather than bytes; a namespace import is the one shape
tree-shaking cannot see through, and this one was dead. It frees nothing on
its own — twenty-nine other files in the phone's graph hold the same module
the same way, so api.js is pinned into the chunk whatever Sheet.svelte does —
but it is the only one of the ten that belongs to a round about weight. The
nine that are drift go out with the sweep in the round after this one.

Two things were looked at and left alone, both written down in ANDROID.md
rather than half-done. Home's per-artist catalog walk is the deepest chain in
the app, and that serial walk is the Deezer and MusicBrainz pacing, not an
oversight. And the follows really are read twice at launch, but the obvious
guard does not work: Home's effect calls loadHome with force the first time
they land, so the guard would never fire. One small local read, left for a
round that can do it properly.
This commit is contained in:
KeelrLeek committed 2026-09-14 01:38:15 -05:00
1 parent 052e15302b
commit 01bab007f7
10 files changed
+304 -43

No files matched your search

+81 -1
View File
@@ -782,6 +782,82 @@ switch (You → Appearance → Glass blur). Re-run the probe before adding any
backdrop blur to the phone; `--quick` runs the base variant only, and the backdrop blur to the phone; `--quick` runs the base variant only, and the
app must be on the phone's screen. app must be on the phone's screen.
The launch itself had never been measured. The bundle the phone loads is
`mobile.html` and three chunks: its own (about 300 KB), the shared one
(about 130 KB) and a small third (about 27 KB) — roughly 460 KB of JS and
57 KB of CSS, all of it eager. The static graph from `src/mobile-main.js`
outward is 64 modules and contains no dynamic import at all, so there is one
chunk and the phone evaluates every byte of it at every launch. Importing
that graph in headless Chrome costs 11.6 ms at full speed and 59.2 ms at a
6× CPU throttle.
The desktop is not riding along, which was checked rather than assumed: of
`state.svelte.js`'s 44 long string literals exactly four appear in the
chunks the phone loads, and all four are event names it legitimately uses.
`heroGpu.js`, `trace.js`, `ImportWindow.svelte`, `TitleBar` and
`DebugOverlay` contribute nothing; there is no shader source anywhere in the
bundle. The one passenger is the dev mock library
(`src/lib/mockLibrary.js`, about 32 KB of the shared chunk), which
`src/lib/api.js` and `src/lib/events.js` import unconditionally because
`mockInvoke` has to answer synchronously. It costs 6.6 ms of every launch at
6× throttle, only 1.4 ms of which is its top-level work — the rest is parse
and function definition, which only removing it from the bundle can recover.
`import.meta.env.DEV` cannot do that, because the mock walk builds
`dist-smoke/` with a plain production `vite build`; getting it out needs a
build-mode flag the walk itself would have to pass. Named here rather than
taken.
The boot chain was the expensive half. It is 26 commands the census can see,
plus two plugin listeners that register through `@tauri-apps` directly and go
past it. Until this round its critical path — the longest chain where each
call was issued only after the one before it had answered — was ten deep,
because `listen` is an invoke like any other: `loadLibrary` asked for five
independent SQLite reads one after another, `bindSlsk` registered eight
listeners one after another, and `watchLibrary` and the player's `bindEvents`
each did two. They go out in one round now, and the difference is countable
rather than felt: ten of those commands used to leave in the shell's own
first turn and twenty-three do now, and the critical path is three instead
of ten. Measured in a rig that runs the real store modules —
both trees, the same onMount script — with the bridge instrumented and the
backend's work modelled behind the library's single `conn: Mutex<Connection>`:
the same commands both ways, and `lib.loaded` 85 ms → 53 at a 6 ms bridge
round trip, 105 → 57 at 12 ms, 148 → 62 at 20 ms. Those are a desktop rig's
milliseconds with the round trip swept rather than measured; the structural
half holds anywhere, and the phone's own numbers arrive the first time the
walk prints `bootMs` and `bootLibMs`.
Three things were looked at and deliberately left alone. Home's per-artist
catalog walk is the deepest chain in the app — `catalog_artist` →
`catalog_artist_identity` → `catalog_related`, serially, per followed artist
— and that is the pacing, not an oversight: `crates/metadata/src/lib.rs`
holds a Deezer budget of 45 requests per 5 s and records the MusicBrainz
identity chain at 4–6 requests behind a mandatory 1 req/s gate, 3.7–8.5 s
per artist. The follows are read twice at launch (once by `loadLibrary`,
once by `loadHome`), and the obvious guard does not work, because Home's
effect calls `loadHome(true)` the first time the follows land — one small
local read, left for a round that can do it properly. And lazy-loading the
one-tab screens (Diagnostics, Import, Browse, Peer, Room, Thread and the
importer) is 9% of the source against a real risk of a blank pushed frame,
so it is worth about 6 ms at 6× throttle and is not worth a dynamic import
yet.
The instrument is `src/lib/boot.js`: one counter every `invoke` and every
`listen` passes, sealed at the first frame the shell paints and never
re-opened, so a probe landing a second and a half later still reads the
launch. The mock walk holds a budget against it at the Home step and both
walks print `bootCalls`, `bootSync`, `bootMs` and `bootLibMs` on every step.
`bootSync` is the clause with no clock in it: it is how many of the boot
commands left in the shell's own turn, so a chain put back in a queue can
only fall under its floor, whatever the machine is doing. Three things the
instrument does not do. It counts the app's own traffic, so the media card's
and the transfer notice's `addPluginListener` are invisible to it. It counts
commands, not bytes, so a later round that adds an import to a shared file
can move modules between chunks with nothing failing — that gap is open. And
the mock's exact count depends on how much of the first screen has asked for
its covers by the time the second frame lands, so the call ceiling is set to
catch a regression in kind (a per-artist walk or an index pulled to boot)
rather than a handful of calls.
The audio side had its own lag: `dumpsys media.audio_flinger` showed the The audio side had its own lag: `dumpsys media.audio_flinger` showed the
track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer 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
@@ -917,7 +993,11 @@ of theirs streamed → a buddy's sheet → You (every switch and chip saved
and read back from the config) → recent searches and a spelling and read back from the config) → recent searches and a spelling
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
record with a long sheet over it, an artist page, and the side insets faked, record with a long sheet over it, an artist page, and the side insets faked,
all at 915×411) and back, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on all at 915×411) and back, asserts what each step should show — the Home
step holds the launch to a budget as well, reading off `__ss.boot` how many
commands the app issued before its first frame, how many of those left in the
shell's own turn, how long it took, and requiring round 12's art prewarm to
still be absent from them — screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The block at the end of the mock walk is the any exception or console error. The block at the end of the mock walk is the
weather: a library index that will not read and its Try again, the client taken weather: a library index that will not read and its Try again, the client taken
offline, a refused password and a server that cannot be reached, Search with offline, a refused password and a server that cannot be reached, Search with
+5 -1
View File
@@ -2,9 +2,13 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen } from "./events.js"; import { listen } from "./events.js";
import { mockInvoke } from "./mockLibrary.js"; import { mockInvoke } from "./mockLibrary.js";
import { countBoot } from "./boot.js";
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1". // mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args); /* the boot census (src/lib/boot.js) counts what the app asks for before its
first frame and stops there, so this is one comparison for the rest of the
run. Inert on the desktop: nothing seals it there and nothing reads it. */
const invoke = (cmd, args) => { countBoot(cmd); return mockInvoke(cmd, args) ?? tauriInvoke(cmd, args); };
// Artist-page payloads are keyed by a Deezer id all the way through the // Artist-page payloads are keyed by a Deezer id all the way through the
// backend. Reject a mismatched response at the IPC boundary so a provider, // backend. Reject a mismatched response at the IPC boundary so a provider,
+53
View File
@@ -0,0 +1,53 @@
/* The boot census.
Every command the app issues passes one counter here — src/lib/api.js on
the way into `invoke`, and src/lib/events.js on the way into `listen`,
which is an invoke like any other (Tauri registers the subscription over
the same bridge). A plugin that registers its own listener through
@tauri-apps/api directly goes past both doors and is not counted — the
media card's and the transfer notice's `addPluginListener` are the two —
so what this measures is the app's own traffic. The question it answers
is the one nobody could answer before: how much of the world does the
app ask for before it has drawn anything?
It seals at the first frame the shell paints and never re-opens, so a
probe landing a second and a half later still reads the launch. That is
what tests/mobile/smoke.mjs holds a budget against — the point is to
catch a round that loads the world at boot, not to police milliseconds.
A plain object, deliberately: this module is shared with the desktop, it
imports nothing, and it must not throw. On the desktop nothing seals it
and nothing reads it. */
export const boot = {
/** the moment the chain starts — the shell's onMount sets it */
t0: 0,
/** every invoke and every listen, until the seal */
calls: 0,
/** how many of them left in the shell's own first turn, before any
* answer could have come back. A chain that waits on itself can only
* issue its first call there, so this is the one number that says
* whether the boot chain is still going out in one round; the shell
* latches it at the end of onMount. -1 until it does. */
sync: -1,
/** the first hundred and twenty of them, in the order they went out —
* past any budget the walk holds, so "is X at boot?" is never
* answered by a list that stopped short */
cmds: [],
/** onMount → the first frame the app could have drawn into */
frameMs: null,
/** onMount → lib.loaded, which is what Home waits for */
libMs: null,
sealed: false,
};
export function countBoot(cmd) {
if (boot.sealed) return;
boot.calls++;
if (boot.cmds.length < 120) boot.cmds.push(cmd);
}
export function sealBoot() {
if (boot.sealed) return;
boot.sealed = true;
try { boot.frameMs = Math.round(performance.now() - boot.t0); } catch { /* a webview without the timer */ }
}
+4 -1
View File
@@ -5,5 +5,8 @@
walked in a plain browser with a fake network behind them. */ walked in a plain browser with a fake network behind them. */
import { listen as tauriListen } from "@tauri-apps/api/event"; import { listen as tauriListen } from "@tauri-apps/api/event";
import { mockListen } from "./mockLibrary.js"; import { mockListen } from "./mockLibrary.js";
import { countBoot } from "./boot.js";
export const listen = (event, cb) => mockListen(event, cb) ?? tauriListen(event, cb); /* a listen is an invoke too — Tauri registers the subscription over the
same bridge — so the boot census counts it as one */
export const listen = (event, cb) => { countBoot("listen " + event); return mockListen(event, cb) ?? tauriListen(event, cb); };
+31 -1
View File
@@ -55,6 +55,7 @@
import { downloadsAllowed, cellular } from "./network.svelte.js"; import { downloadsAllowed, cellular } from "./network.svelte.js";
import { contacts, loadContacts, display } from "./contacts.svelte.js"; import { contacts, loadContacts, display } from "./contacts.svelte.js";
import * as sources from "../lib/sources.js"; import * as sources from "../lib/sources.js";
import { boot, sealBoot } from "../lib/boot.js";
import { greeting, drag } from "./util.js"; import { greeting, drag } from "./util.js";
let screenEl = $state(null); let screenEl = $state(null);
@@ -221,7 +222,23 @@
openSheet(miniArt); openSheet(miniArt);
} }
/* ── when the library landed, for the record ──────────────────────
Home gates its landing screen on lib.loaded, so this is the number a
launch is actually felt as, and it is the one the phone leg prints.
`boot` is a plain object and never a rune, so the write cannot feed
the effect back into itself; untrack says so out loud. */
$effect(() => {
if (!lib.loaded) return;
untrack(() => { if (boot.libMs == null) boot.libMs = Math.round(performance.now() - boot.t0); });
});
onMount(() => { onMount(() => {
/* the boot census starts here. Every command the app issues between
this line and the first frame is counted, and the mock walk holds
a budget against the count — the point is to catch a round that
loads the world at boot, not to police milliseconds. */
boot.t0 = performance.now();
try { performance.mark("ss:boot"); } catch { /* a webview without the entry types */ }
loadPrefs(); loadPrefs();
bootNav(); bootNav();
let unlisten = null; let unlisten = null;
@@ -246,9 +263,22 @@
edge, one decode at a time behind the tiles' gate, a moment after edge, one decode at a time behind the tiles' gate, a moment after
boot so the first screen and a restored track come first */ boot so the first screen and a restored track come first */
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* everything above went out in this one turn. A call that waits on
the one before it can only have issued its first here, so this is
the number that says the boot chain is still going out in one
round — 10 before this round, 23 after it — and the walk holds a
floor against it. */
boot.sync = boot.calls;
/* two frames: the first is the shell, the second is the first one
the app could have drawn anything into. A phone producing no
frames at all (launched straight into the background) seals on
the timer instead, well before the walk's first probe — the
census must never depend on something that might not happen. */
const sealTimer = setTimeout(sealBoot, 1200);
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, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, 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, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); }; return () => { if (unlisten) unlisten(); };
}); });
</script> </script>
-1
View File
@@ -12,7 +12,6 @@
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte"; import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte"; import Cover from "./Cover.svelte";
import * as api from "../lib/api.js";
import { art } from "./art.svelte.js"; import { art } from "./art.svelte.js";
import { albumsBy } from "./lib.svelte.js"; import { albumsBy } from "./lib.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js"; import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
+43 -19
View File
@@ -101,29 +101,53 @@ export async function watchLibrary() {
watching = true; watching = true;
let timer = null; let timer = null;
try { try {
await listen("soulseek:library-changed", () => { /* two subscriptions that never depended on one another. `listen` is
clearTimeout(timer); an invoke like any other, so one behind the other is two bridge
timer = setTimeout(async () => { round trips at launch for nothing. */
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } await Promise.all([
await refreshRecents(); listen("soulseek:library-changed", () => {
void refreshStats(); clearTimeout(timer);
}, 600); timer = setTimeout(async () => {
}); try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
/* a playlist mutation (the desktop's commands emit it) re-lists them */ await refreshRecents();
await listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }); void refreshStats();
}, 600);
}),
/* a playlist mutation (the desktop's commands emit it) re-lists them */
listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }),
]);
} catch { /* not a phone */ } } catch { /* not a phone */ }
} }
export async function loadLibrary() { export async function loadLibrary() {
try { /* Five reads that never depended on one another. Each is a
const st = await api.appStatus(); spawn_blocking SQLite read behind the library's single
lib.version = st?.version ?? ""; `conn: Mutex<Connection>`, so the queries still take their turn —
lib.username = st?.connected_as ?? null; what is saved is five bridge round trips collapsing into one.
} catch (e) { lib.error = String(e); }
try { lib.albums = await api.libraryAlbums(); } catch (e) { lib.error = String(e); } Every call keeps the handling it had. The one thing that could have
await refreshRecents(); moved is `lib.error`, which has two writers and used to be settled
try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; } by the order they were awaited in; the two messages are carried out
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } of the round and applied in that same order instead, so a failing
library still shows the library's message and not app_status's, no
matter which of them answers last. Nothing is dropped, nothing new
is asked for, and `lib.loaded` still goes up only once all five
have settled. */
const [statusErr, albumsErr, , follows, playlists] = await Promise.all([
api.appStatus().then((st) => {
lib.version = st?.version ?? "";
lib.username = st?.connected_as ?? null;
return null;
}, (e) => String(e)),
api.libraryAlbums().then((rows) => { lib.albums = rows; return null; }, (e) => String(e)),
refreshRecents(), // writes lib.recents itself, and swallows its own failure
api.followedArtists().then((rows) => rows || [], () => []),
api.listPlaylists().then((rows) => rows || [], () => []),
]);
if (statusErr) lib.error = statusErr;
if (albumsErr) lib.error = albumsErr;
lib.follows = follows;
lib.playlists = playlists;
lib.loaded = true; lib.loaded = true;
void refreshStats(); void refreshStats();
} }
+18 -9
View File
@@ -593,15 +593,18 @@ export async function bindEvents() {
document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); }); document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); });
window.addEventListener("pagehide", writeSession); window.addEventListener("pagehide", writeSession);
setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000); setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000);
try { /* Two subscriptions in one round, for the same reason as everywhere
await listen("soulseek:stream", (ev) => { else this round: `listen` is an invoke, so one behind the other is
const p = ev.payload || {}; two bridge round trips at launch. Both are in flight from here; they
if (!current()?.t?.stream) return; are awaited together at the end, so neither can reject with nobody
player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null }; listening. */
if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed); const streamSub = listen("soulseek:stream", (ev) => {
}); const p = ev.payload || {};
} catch { /* not a phone */ } if (!current()?.t?.stream) return;
return await listen("soulseek:player", (ev) => { player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null };
if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed);
});
const transportSub = listen("soulseek:player", (ev) => {
const p = ev.payload || {}; const p = ev.payload || {};
switch (p.kind) { switch (p.kind) {
case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break; case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break;
@@ -627,6 +630,12 @@ export async function bindEvents() {
break; break;
} }
}); });
/* the stream listener is still allowed to have nothing behind it (a
desktop build, the mock without the bus); the transport's failure is
still the one the shell shows, and the unlisten handed back is still
the transport's — the one onMount stores and calls on teardown. */
const [, unlisten] = await Promise.all([streamSub.catch(() => { /* not a phone */ }), transportSub]);
return unlisten;
} }
/* ── the session ─────────────────────────────────────────────────────── */ /* ── the session ─────────────────────────────────────────────────────── */
+22 -9
View File
@@ -142,8 +142,18 @@ export async function bindSlsk() {
if (bound) return []; if (bound) return [];
bound = true; bound = true;
const subs = []; const subs = [];
const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } }; const pending = [];
await on("soulseek:state", (ev) => { /* Tauri's `listen` is an invoke like any other, so eight of them one
behind the other is eight bridge round trips each waiting on the one
before. They never depended on one another: all eight go out at once
and the wait happens once, at the end. The ordering that does matter
is kept — bindSlsk still does not return until every handler is
registered, so connectStored() cannot let a fast `connected` land
with nobody listening. */
const on = (name, fn) => {
pending.push(listen(name, (ev) => fn(ev.payload)).then((u) => { subs.push(u); }, () => { /* not a phone */ }));
};
on("soulseek:state", (ev) => {
if (ev?.kind === "connected") { if (ev?.kind === "connected") {
slsk.connection = "online"; slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser; slsk.username = ev.username || slsk.savedUser;
@@ -154,17 +164,17 @@ export async function bindSlsk() {
slsk.connection = "offline"; slsk.connection = "offline";
} }
}); });
await on("soulseek:room-list", (rooms) => { on("soulseek:room-list", (rooms) => {
clearTimeout(roomsTimer); clearTimeout(roomsTimer);
slsk.roomsLate = false; slsk.roomsLate = false;
slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0)); slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));
}); });
await on("soulseek:room-joined", (p) => { on("soulseek:room-joined", (p) => {
const prev = slsk.joined[p.room]; const prev = slsk.joined[p.room];
slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] }; slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] };
}); });
await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; }); on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; });
await on("soulseek:room-user", (p) => { on("soulseek:room-user", (p) => {
const room = slsk.joined[p.room]; const room = slsk.joined[p.room];
if (!room) return; if (!room) return;
if (p.joined) { if (p.joined) {
@@ -173,21 +183,24 @@ export async function bindSlsk() {
room.users = room.users.filter((u) => u.username !== p.username); room.users = room.users.filter((u) => u.username !== p.username);
} }
}); });
await on("soulseek:room-message", (p) => { on("soulseek:room-message", (p) => {
const room = slsk.joined[p.room]; const room = slsk.joined[p.room];
if (!room) return; if (!room) return;
room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() }); room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() });
trim(room.messages); trim(room.messages);
}); });
await on("soulseek:private-message", (p) => { on("soulseek:private-message", (p) => {
const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 }); const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 });
chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() }); chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() });
trim(chat.messages); trim(chat.messages);
if (slsk.openChat !== p.username) chat.unread += 1; if (slsk.openChat !== p.username) chat.unread += 1;
}); });
await on("soulseek:user-state", (p) => { on("soulseek:user-state", (p) => {
const s = (slsk.buddyState[p.username] ??= {}); const s = (slsk.buddyState[p.username] ??= {});
Object.assign(s, p); Object.assign(s, p);
}); });
/* the one ordering that matters: connectStored() runs off this promise,
so a fast connected state must not land with nobody listening */
await Promise.all(pending);
return subs; return subs;
} }
+47 -1
View File
@@ -251,6 +251,21 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(), halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'), halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(), contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
/* the boot census, latched at the shell's first frame and never
re-opened: how many commands the app asked for before it had
drawn anything, how many of those left in the shell's own turn,
which kinds they were, and how long it took. A probe landing a
second and a half later still reads the launch. -1 is "the census
never sealed", -2 is "there is no census". bootCmds is the
distinct names, not the whole run of them — every step carries
it, and what is ever asked of it is whether a kind of command was
at boot at all. */
bootCalls: (function () { try { return __ss.boot.sealed ? __ss.boot.calls : -1; } catch (e) { return -2; } })(),
bootSync: (function () { try { return __ss.boot.sync; } catch (e) { return -2; } })(),
bootMs: (function () { try { return __ss.boot.frameMs == null ? -1 : __ss.boot.frameMs; } catch (e) { return -2; } })(),
bootLibMs: (function () { try { return __ss.boot.libMs == null ? -1 : __ss.boot.libMs; } catch (e) { return -2; } })(),
bootCmds: (function () { try { var c = __ss.boot.cmds, u = [], i; for (i = 0; i < c.length; i++) if (u.indexOf(c[i]) < 0) u.push(c[i]); return u.join(' '); } catch (e) { return ''; } })(),
navMs: (function () { try { var n = performance.getEntriesByType('navigation')[0]; return n ? Math.round(n.domContentLoadedEventEnd) : -1; } catch (e) { return -1; } })(),
/* ── the weather: what the screen says when a read fails, an answer /* ── the weather: what the screen says when a read fails, an answer
is empty, or nobody is signed in. Every one of these is a line is empty, or nobody is signed in. Every one of these is a line
this round put there; before it they were an empty box, a spinner this round put there; before it they were an empty box, a spinner
@@ -904,7 +919,28 @@ const MOCK_EXPECT = {
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn, "landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26, "landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24, "portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState
/* and the boot budget, read off __ss.boot. Each clause earns its
place: the census must have counted something (a census that
breaks and reports -1 or -2 would sail through a one-sided
test); it must not have counted the world; the boot chain must
still be going out in one round rather than waiting on itself,
which is the one clause that is not a clock at all — it was 10
before round 32 and is 23 after, so a chain put back in a queue
lands under the floor; the shell must have sealed on a frame
rather than on the 1200 ms fallback; the library must still land
inside the launch rather than behind something slow; and round
12's eight-second art prewarm must still be nowhere near boot.
The call ceiling is deliberately loose — the boot chain issues
about 25 commands and the first screen asks for its covers on
top, and the point there is to catch a round that pulls a
per-artist walk or an index to boot, not to police a handful of
calls. */
&& i.bootCalls >= 14 && i.bootCalls <= 60
&& i.bootSync >= 20
&& i.bootMs >= 0 && i.bootMs < 1200
&& i.bootLibMs >= 0 && i.bootLibMs < 600
&& !/library_art_prewarm/.test(i.bootCmds),
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText), "hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice), "cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"), "search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
@@ -1222,6 +1258,16 @@ const PHONE_STEPS = [
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
]; ];
const PHONE_EXPECT = { const PHONE_EXPECT = {
/* the phone's own launch, held wider on purpose: a real one signs in,
restores a session and may walk the follows, none of which the mock
does, and its library reads are real SQLite rather than an object in
memory. What is asserted here is the shape — a census that counted
something, sealed, still went out in one round, and did not drag the
art prewarm back to boot. The milliseconds are printed rather than
policed: every step's JSON carries bootMs and bootLibMs, so the
device's real figures land in the report the first time this walk
runs. */
home: (i) => i.bootCalls >= 14 && i.bootCalls <= 90 && i.bootSync >= 20 && i.bootMs >= 0 && !/library_art_prewarm/.test(i.bootCmds),
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost, album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost,
"album-playing": (i) => i.mini && !i.toast, "album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText), "lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),