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

+31 -1
View File
@@ -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(); };
});
</script>
-1
View File
@@ -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";
+43 -19
View File
@@ -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<Connection>`, 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();
}
+18 -9
View File
@@ -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 ─────────────────────────────────────────────────────── */
+22 -9
View File
@@ -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;
}