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),