From 01bab007f732ee90f170171b07bf75d894b41e1a Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:38:15 -0500 Subject: [PATCH] android: the boot chain goes out in one round, and a census that holds it there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ANDROID.md | 82 ++++++++++++++++++++++++++++++++++++- src/lib/api.js | 6 ++- src/lib/boot.js | 53 ++++++++++++++++++++++++ src/lib/events.js | 5 ++- src/mobile/Mobile.svelte | 32 ++++++++++++++- src/mobile/Sheet.svelte | 1 - src/mobile/lib.svelte.js | 62 +++++++++++++++++++--------- src/mobile/player.svelte.js | 27 ++++++++---- src/mobile/slsk.svelte.js | 31 ++++++++++---- tests/mobile/smoke.mjs | 48 +++++++++++++++++++++- 10 files changed, 304 insertions(+), 43 deletions(-) create mode 100644 src/lib/boot.js diff --git a/ANDROID.md b/ANDROID.md index 4530446..6218c2e 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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 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`: +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 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 @@ -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 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, -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 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 diff --git a/src/lib/api.js b/src/lib/api.js index 64fe815..54d6d86 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -2,9 +2,13 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { listen } from "./events.js"; import { mockInvoke } from "./mockLibrary.js"; +import { countBoot } from "./boot.js"; // 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 // backend. Reject a mismatched response at the IPC boundary so a provider, diff --git a/src/lib/boot.js b/src/lib/boot.js new file mode 100644 index 0000000..a6dec8c --- /dev/null +++ b/src/lib/boot.js @@ -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 */ } +} diff --git a/src/lib/events.js b/src/lib/events.js index c876f12..664ac72 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -5,5 +5,8 @@ walked in a plain browser with a fake network behind them. */ import { listen as tauriListen } from "@tauri-apps/api/event"; 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); }; diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index f609ff1..7b49171 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -55,6 +55,7 @@ import { downloadsAllowed, cellular } from "./network.svelte.js"; import { contacts, loadContacts, display } from "./contacts.svelte.js"; import * as sources from "../lib/sources.js"; + import { boot, sealBoot } from "../lib/boot.js"; import { greeting, drag } from "./util.js"; let screenEl = $state(null); @@ -221,7 +222,23 @@ 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(() => { + /* 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(); bootNav(); let unlisten = null; @@ -246,9 +263,22 @@ edge, one decode at a time behind the tiles' gate, a moment after boot so the first screen and a restored track come first */ 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: 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(); }; }); diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte index 8ebde09..2c6c7c1 100644 --- a/src/mobile/Sheet.svelte +++ b/src/mobile/Sheet.svelte @@ -12,7 +12,6 @@ import Icon from "../components/Icon.svelte"; import Lane from "./Lane.svelte"; import Cover from "./Cover.svelte"; - import * as api from "../lib/api.js"; import { art } from "./art.svelte.js"; import { albumsBy } from "./lib.svelte.js"; import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js"; diff --git a/src/mobile/lib.svelte.js b/src/mobile/lib.svelte.js index a7f2307..ecac59f 100644 --- a/src/mobile/lib.svelte.js +++ b/src/mobile/lib.svelte.js @@ -101,29 +101,53 @@ export async function watchLibrary() { watching = true; let timer = null; try { - await listen("soulseek:library-changed", () => { - clearTimeout(timer); - timer = setTimeout(async () => { - try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } - await refreshRecents(); - void refreshStats(); - }, 600); - }); - /* a playlist mutation (the desktop's commands emit it) re-lists them */ - await listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }); + /* two subscriptions that never depended on one another. `listen` is + an invoke like any other, so one behind the other is two bridge + round trips at launch for nothing. */ + await Promise.all([ + listen("soulseek:library-changed", () => { + clearTimeout(timer); + timer = setTimeout(async () => { + try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } + await refreshRecents(); + 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 */ } } export async function loadLibrary() { - try { - const st = await api.appStatus(); - lib.version = st?.version ?? ""; - lib.username = st?.connected_as ?? null; - } catch (e) { lib.error = String(e); } - try { lib.albums = await api.libraryAlbums(); } catch (e) { lib.error = String(e); } - await refreshRecents(); - try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; } - try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } + /* Five reads that never depended on one another. Each is a + spawn_blocking SQLite read behind the library's single + `conn: Mutex`, so the queries still take their turn — + what is saved is five bridge round trips collapsing into one. + + Every call keeps the handling it had. The one thing that could have + moved is `lib.error`, which has two writers and used to be settled + by the order they were awaited in; the two messages are carried out + 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; void refreshStats(); } diff --git a/src/mobile/player.svelte.js b/src/mobile/player.svelte.js index 91b3d81..d471389 100644 --- a/src/mobile/player.svelte.js +++ b/src/mobile/player.svelte.js @@ -593,15 +593,18 @@ export async function bindEvents() { document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); }); window.addEventListener("pagehide", writeSession); setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000); - try { - await listen("soulseek:stream", (ev) => { - const p = ev.payload || {}; - if (!current()?.t?.stream) return; - 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); - }); - } catch { /* not a phone */ } - return await listen("soulseek:player", (ev) => { + /* Two subscriptions in one round, for the same reason as everywhere + else this round: `listen` is an invoke, so one behind the other is + two bridge round trips at launch. Both are in flight from here; they + are awaited together at the end, so neither can reject with nobody + listening. */ + const streamSub = listen("soulseek:stream", (ev) => { + const p = ev.payload || {}; + if (!current()?.t?.stream) return; + 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 || {}; switch (p.kind) { case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break; @@ -627,6 +630,12 @@ export async function bindEvents() { 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 ─────────────────────────────────────────────────────── */ diff --git a/src/mobile/slsk.svelte.js b/src/mobile/slsk.svelte.js index 8549df5..2e40d2a 100644 --- a/src/mobile/slsk.svelte.js +++ b/src/mobile/slsk.svelte.js @@ -142,8 +142,18 @@ export async function bindSlsk() { if (bound) return []; bound = true; const subs = []; - const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } }; - await on("soulseek:state", (ev) => { + const pending = []; + /* 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") { slsk.connection = "online"; slsk.username = ev.username || slsk.savedUser; @@ -154,17 +164,17 @@ export async function bindSlsk() { slsk.connection = "offline"; } }); - await on("soulseek:room-list", (rooms) => { + on("soulseek:room-list", (rooms) => { clearTimeout(roomsTimer); slsk.roomsLate = false; 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]; slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] }; }); - await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; }); - await on("soulseek:room-user", (p) => { + on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; }); + on("soulseek:room-user", (p) => { const room = slsk.joined[p.room]; if (!room) return; if (p.joined) { @@ -173,21 +183,24 @@ export async function bindSlsk() { 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]; if (!room) return; room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() }); trim(room.messages); }); - await on("soulseek:private-message", (p) => { + on("soulseek:private-message", (p) => { const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 }); chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() }); trim(chat.messages); 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] ??= {}); 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; } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index e970915..84e693c 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -251,6 +251,21 @@ const PROBE = `(function () { 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('|'), 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 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 @@ -904,7 +919,28 @@ const MOCK_EXPECT = { "landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn, "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, - 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), "cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice), "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 }, ]; 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-playing": (i) => i.mini && !i.toast, "lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),