From 9fa6435735f4997175bd6e5bc07fb97c597431a6 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 11:50:59 +0100 Subject: [PATCH 01/24] feat: wire smoldot lifecycle events to the loading bar --- apps/host/src/main.ts | 35 ++++++++++ apps/protocol/src/main.ts | 20 +++++- bun.lock | 6 +- package.json | 4 +- packages/resolver/src/resolve.ts | 3 +- packages/resolver/src/smoldot.ts | 109 +++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 7 deletions(-) diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 9622cee9..15adc24e 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -1181,6 +1181,41 @@ async function main(): Promise { advancePhase(0); showStatus(`Resolving ${label}.dot`); + // Advance the loading bar from smoldot's typed lifecycle stream instead of + // scraping log prose. `firstPeer` on the Asset Hub means a peer was + // discovered, so the sync band can start crawling. `bootstrapComplete` + // means the first finalized block landed, so the resolver can read + // storage. Events are emitted from the protocol iframe, which owns + // smoldot, and reach this listener over the postMessage bridge. The + // `statusToPhase` log-text path remains as a fallback. + if (chainBackend !== "rpc-gateway") { + window.addEventListener("message", (event: MessageEvent) => { + const data = event.data as Record | null; + if ( + data === null || + typeof data !== "object" || + data.namespace !== "dotli:protocol" || + data.kind !== "lifecycle" + ) { + return; + } + const chainName = typeof data.chainName === "string" ? data.chainName : ""; + const lifecycleKind = + typeof data.lifecycleKind === "string" ? data.lifecycleKind : ""; + log.warn( + `[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`, + ); + if (!chainName.includes("asset-hub")) { + return; + } + if (lifecycleKind === "firstPeer") { + advancePhase(2); + } else if (lifecycleKind === "bootstrapComplete") { + advancePhase(3); + } + }); + } + try { const cachedCid = cacheSettings.skipCidCache ? null diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index 3967c750..16d68b92 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -683,7 +683,7 @@ async function initDirectMode(): Promise { setResolverPeopleProvider, waitForPeopleFinalized, } = resolve; - const { terminateSmoldot, onSmoldotFatal } = smoldotMod; + const { terminateSmoldot, onSmoldotFatal, onLifecycle } = smoldotMod; // On a smoldot panic, broadcast a fatal envelope to the parent. Direct // mode has no SharedWorker in the loop, so we post straight up to the @@ -702,6 +702,24 @@ async function initDirectMode(): Promise { } }); + // Forward smoldot lifecycle events to the host shell so the loading bar can + // advance on real sync signals instead of on log-scraped prose. This iframe + // owns the smoldot instance. The host has no direct handle on it. + onLifecycle((event) => { + if (window.parent === window) { + return; + } + window.parent.postMessage( + { + namespace: "dotli:protocol", + kind: "lifecycle", + chainName: event.chainName, + lifecycleKind: event.kind, + }, + "*", + ); + }); + const engine = createEngine({ createChainProvider, isChainSupported, diff --git a/bun.lock b/bun.lock index 9a42dc95..b6cfb80f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "dotli", "dependencies": { - "smoldot": "3.3.2", + "smoldot": "3.3.3-dev-20260805.0", }, "devDependencies": { "prettier": "^3.8.4", @@ -326,7 +326,7 @@ "esbuild": "^0.28.1", "fast-uri": "3.1.4", "postcss": "^8.5.24", - "smoldot": "3.3.2", + "smoldot": "3.3.3-dev-20260805.0", }, "packages": { "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.7", "", { "dependencies": { "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw=="], @@ -1509,7 +1509,7 @@ "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], - "smoldot": ["smoldot@3.3.2", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-Zl4h/0gsw8cfTZzuJ7LV7mtR6QjxltwYjMY7MsVw0oXBXrLK8zyOS6DS9Vjsy57pX1vBMg6UVhHxjbH3W905zA=="], + "smoldot": ["smoldot@3.3.3-dev-20260805.0", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-qaZsRGg4gizRovTyOS1SHTS/3ckKOV5O+9VIjrmCR173kghfgkjs31bSI0ivk1J33N7P1Rt+poJh58RKqVCm8w=="], "sort-keys": ["sort-keys@5.1.0", "", { "dependencies": { "is-plain-obj": "^4.0.0" } }, "sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ=="], diff --git a/package.json b/package.json index bfca9521..297f653e 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "packages/*" ], "dependencies": { - "smoldot": "3.3.2" + "smoldot": "3.3.3-dev-20260805.0" }, "overrides": { "@parity/truapi": "0.5.1", "fast-uri": "3.1.4", - "smoldot": "3.3.2", + "smoldot": "3.3.3-dev-20260805.0", "esbuild": "^0.28.1", "brace-expansion": "^5.0.8", "postcss": "^8.5.24" diff --git a/packages/resolver/src/resolve.ts b/packages/resolver/src/resolve.ts index 6f83d7c3..d8d49bd3 100644 --- a/packages/resolver/src/resolve.ts +++ b/packages/resolver/src/resolve.ts @@ -46,7 +46,8 @@ export type { } from "./access-raw-storage"; export { statusToPhase } from "./access-raw-storage"; export { getSmoldot, getSmoldotDirect, getRelayChain } from "./smoldot"; -export { onConnectionIssue } from "./smoldot"; +export { onConnectionIssue, onLifecycle } from "./smoldot"; +export type { LifecycleEvent, LifecycleKind } from "./smoldot"; let clientInstance: SubstrateClient | null = null; let apiInstance: Api | null = null; diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 2d61800c..8ba95e70 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -58,6 +58,49 @@ export function onConnectionIssue(cb: ConnectionIssueCallback): () => void { }; } +/** Lifecycle events emitted by smoldot's per-chain broadcaster over the `lifecycle_unstable_follow` JSON-RPC subscription. */ +export type LifecycleKind = "firstPeer" | "bootstrapComplete"; +export interface LifecycleEvent { + chainName: string; + kind: LifecycleKind; +} + +type LifecycleCallback = (event: LifecycleEvent) => void; +const lifecycleListeners = new Set(); +const lifecycleHistory: LifecycleEvent[] = []; + +/** + * Subscribe to smoldot lifecycle events. Late subscribers receive the events + * emitted so far in the current session (snapshot-on-subscribe), then continue + * with live events. Returns an unsubscribe function. + */ +export function onLifecycle(cb: LifecycleCallback): () => void { + lifecycleListeners.add(cb); + for (const event of lifecycleHistory) { + try { + cb(event); + // eslint-disable-next-line no-restricted-syntax -- defensive replay: one buggy late subscriber must not block registration. + } catch { + /* listener threw during replay */ + } + } + return () => { + lifecycleListeners.delete(cb); + }; +} + +function emitLifecycle(event: LifecycleEvent): void { + lifecycleHistory.push(event); + for (const cb of lifecycleListeners) { + try { + cb(event); + // eslint-disable-next-line no-restricted-syntax -- defensive multicast: one buggy subscriber must not block the broadcast. + } catch { + /* listener threw */ + } + } +} + // Smoldot's WASM can panic (e.g., the "Option::unwrap() on a None value" // crash during relay-chain sync). A panic leaves every chain dead, and any // in-flight request would hang forever. The log callback catches the @@ -112,6 +155,11 @@ const CONNECTION_ISSUE_PATTERNS = [ "all bootnodes", ]; +// Reserved id used for our internal `lifecycle_unstable_follow` request. +// Chosen so it cannot collide with the numeric ids polkadot-api uses. +const LIFECYCLE_FOLLOW_REQUEST_ID = "__dotli_lifecycle_follow__"; + + function smoldotLogCallback( level: number, target: string, @@ -124,6 +172,16 @@ function smoldotLogCallback( log.debug(`[smoldot:${target}] ${message}`); } + // Lifecycle events flow through the chain's JSON-RPC pipe, which is owned by + // polkadot-api. Rather than wrap the chain and race for messages, we observe + // the payloads passing through smoldot's own debug log stream: every + // `json-rpc-` target emits the response JSON verbatim. We match + // the schema we ourselves define, so this is subscribing to a structured + // side-channel, not scraping human prose. + if (target.startsWith("json-rpc-") && message.includes("lifecycle_unstable_followEvent")) { + parseAndEmitLifecycle(target, message); + } + // A panic is terminal with no recovery. Smoldot's log message starts // with "Smoldot has panicked while executing task …". Surface as fatal. if ( @@ -150,6 +208,35 @@ function smoldotLogCallback( } } +function parseAndEmitLifecycle(target: string, message: string): void { + // Smoldot emits the response JSON verbatim after a literal `response=` + // prefix in the log line. + const responseStart = message.indexOf("response="); + if (responseStart === -1) { + return; + } + const jsonPart = message.slice(responseStart + "response=".length); + try { + const parsed = JSON.parse(jsonPart) as { + method?: string; + params?: { result?: { kind?: LifecycleKind } }; + }; + if (parsed.method !== "lifecycle_unstable_followEvent") { + return; + } + const kind = parsed.params?.result?.kind; + if (kind !== "firstPeer" && kind !== "bootstrapComplete") { + return; + } + // Strip the `json-rpc-` prefix to get the chain name for the event. + const chainName = target.slice("json-rpc-".length); + emitLifecycle({ chainName, kind }); + // eslint-disable-next-line no-restricted-syntax -- best-effort parse of a smoldot debug log line: any non-JSON or unexpected shape is expected and must not spam log.error. + } catch { + /* malformed response payload, skip */ + } +} + let smoldotInstance: SmoldotClient | null = null; let relayChainPromise: Promise | null = null; @@ -241,9 +328,31 @@ function attachPersistence( teardownPersistence(chainName); const tap = tapChain(underlying); schedulePersistence(chainName, tap); + // Fire the lifecycle subscription without wrapping the chain object. + // Polkadot-api consumes `jsonRpcResponses` iteratively and interposing on + // that path is racy. Notifications flow through smoldot's own json-rpc log + // stream, which is enough to prove the subscription is live for the demo. + attachLifecycleFollow(chainName, tap.chain); return tap.chain; } +function attachLifecycleFollow(chainName: string, chain: SmoldotChain): void { + try { + chain.sendJsonRpc( + JSON.stringify({ + jsonrpc: "2.0", + id: `${LIFECYCLE_FOLLOW_REQUEST_ID}:${chainName}`, + method: "lifecycle_unstable_follow", + params: [], + }), + ); + } catch (err: unknown) { + log.warn( + `[dot.li smoldot] lifecycle follow send failed for ${chainName}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + /** * Create smoldot using `start()`, which runs on the current thread. * From e4fd7df471d12cbbf2a162cfb9a359cf12ad4f29 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 12:03:25 +0100 Subject: [PATCH 02/24] Fix formatting --- apps/host/src/main.ts | 7 +++---- packages/resolver/src/smoldot.ts | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 3b83a186..32f2f2ce 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -1200,12 +1200,11 @@ async function main(): Promise { ) { return; } - const chainName = typeof data.chainName === "string" ? data.chainName : ""; + const chainName = + typeof data.chainName === "string" ? data.chainName : ""; const lifecycleKind = typeof data.lifecycleKind === "string" ? data.lifecycleKind : ""; - log.warn( - `[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`, - ); + log.warn(`[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`); if (!chainName.includes("asset-hub")) { return; } diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 8ba95e70..39b8afa7 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -159,7 +159,6 @@ const CONNECTION_ISSUE_PATTERNS = [ // Chosen so it cannot collide with the numeric ids polkadot-api uses. const LIFECYCLE_FOLLOW_REQUEST_ID = "__dotli_lifecycle_follow__"; - function smoldotLogCallback( level: number, target: string, @@ -178,7 +177,10 @@ function smoldotLogCallback( // `json-rpc-` target emits the response JSON verbatim. We match // the schema we ourselves define, so this is subscribing to a structured // side-channel, not scraping human prose. - if (target.startsWith("json-rpc-") && message.includes("lifecycle_unstable_followEvent")) { + if ( + target.startsWith("json-rpc-") && + message.includes("lifecycle_unstable_followEvent") + ) { parseAndEmitLifecycle(target, message); } From 57ab0e19a438ed7a94fb43662763651eefcae009 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 12:32:05 +0100 Subject: [PATCH 03/24] Document the maxLogLevel coupling, unit-test the lifecycle parser, and demote the host lifecycle log to debug --- apps/host/src/main.ts | 7 +- packages/resolver/src/smoldot.ts | 10 ++- packages/resolver/tests/smoldot.test.ts | 106 ++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 32f2f2ce..4261ccfa 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -1189,6 +1189,9 @@ async function main(): Promise { // storage. Events are emitted from the protocol iframe, which owns // smoldot, and reach this listener over the postMessage bridge. The // `statusToPhase` log-text path remains as a fallback. + // The loading bar tracks Asset Hub sync. Events from other chains are + // logged but never advance a phase. + const LIFECYCLE_PHASE_CHAIN = "asset-hub"; if (chainBackend !== "rpc-gateway") { window.addEventListener("message", (event: MessageEvent) => { const data = event.data as Record | null; @@ -1204,8 +1207,8 @@ async function main(): Promise { typeof data.chainName === "string" ? data.chainName : ""; const lifecycleKind = typeof data.lifecycleKind === "string" ? data.lifecycleKind : ""; - log.warn(`[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`); - if (!chainName.includes("asset-hub")) { + log.debug(`[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`); + if (!chainName.includes(LIFECYCLE_PHASE_CHAIN)) { return; } if (lifecycleKind === "firstPeer") { diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 39b8afa7..0f5d7e27 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -332,8 +332,8 @@ function attachPersistence( schedulePersistence(chainName, tap); // Fire the lifecycle subscription without wrapping the chain object. // Polkadot-api consumes `jsonRpcResponses` iteratively and interposing on - // that path is racy. Notifications flow through smoldot's own json-rpc log - // stream, which is enough to prove the subscription is live for the demo. + // that path is racy. Notifications are read back from smoldot's own + // json-rpc log stream instead (see parseAndEmitLifecycle). attachLifecycleFollow(chainName, tap.chain); return tap.chain; } @@ -368,6 +368,9 @@ export function getSmoldotDirect(): SmoldotClient { } log.warn("[dot.li smoldot] Creating smoldot via start() (current thread)"); smoldotInstance = startSmoldotDirect({ + // Lifecycle detection reads JSON-RPC payloads out of the debug log + // stream (parseAndEmitLifecycle). Lowering this level silently + // disables lifecycle events. maxLogLevel: 5, logCallback: smoldotLogCallback, // Smoldot's own auto-detection (no-auto-bytecode-browser.js) is buggy @@ -394,6 +397,9 @@ export function getSmoldot(): SmoldotClient { } log.warn("[dot.li smoldot] Creating smoldot via startFromWorker()"); smoldotInstance = startFromWorker(new SmWorker(), { + // Lifecycle detection reads JSON-RPC payloads out of the debug log + // stream (parseAndEmitLifecycle). Lowering this level silently + // disables lifecycle events. maxLogLevel: 5, logCallback: smoldotLogCallback, forbidNonLocalWs: true, diff --git a/packages/resolver/tests/smoldot.test.ts b/packages/resolver/tests/smoldot.test.ts index d53d1147..f8478b9f 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -49,12 +49,14 @@ vi.mock("@dotli/resolver/chain-specs", () => ({ let getSmoldot: typeof import("@dotli/resolver/smoldot").getSmoldot; let getRelayChain: typeof import("@dotli/resolver/smoldot").getRelayChain; +let onLifecycle: typeof import("@dotli/resolver/smoldot").onLifecycle; beforeEach(async () => { vi.resetModules(); const mod = await import("@dotli/resolver/smoldot"); getSmoldot = mod.getSmoldot; getRelayChain = mod.getRelayChain; + onLifecycle = mod.onLifecycle; }); describe("getSmoldot", () => { @@ -83,3 +85,107 @@ describe("getRelayChain", () => { expect(typeof chain.sendJsonRpc).toBe("function"); }); }); + +describe("onLifecycle", () => { + // Grab the logCallback the module hands to smoldot so tests can feed it + // synthetic json-rpc log lines. + async function captureLogCallback(): Promise< + (level: number, target: string, message: string) => void + > { + getSmoldot(); + const { startFromWorker } = + await import("polkadot-api/smoldot/from-worker"); + const options = vi.mocked(startFromWorker).mock.lastCall?.[1]; + if (options?.logCallback === undefined) { + throw new Error("logCallback was not passed to startFromWorker"); + } + return options.logCallback; + } + + function followEventLine(chain: string, kind: string): string { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "lifecycle_unstable_followEvent", + params: { subscription: "lf-1", result: { kind } }, + }); + return `chain=${chain} response=${payload}`; + } + + it("delivers events parsed from the json-rpc log stream", async () => { + const logCallback = await captureLogCallback(); + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + logCallback( + 4, + "json-rpc-asset-hub-paseo", + followEventLine("asset-hub-paseo", "firstPeer"), + ); + logCallback( + 4, + "json-rpc-asset-hub-paseo", + followEventLine("asset-hub-paseo", "bootstrapComplete"), + ); + + expect(events).toEqual([ + { chainName: "asset-hub-paseo", kind: "firstPeer" }, + { chainName: "asset-hub-paseo", kind: "bootstrapComplete" }, + ]); + }); + + it("replays earlier events to a late subscriber", async () => { + const logCallback = await captureLogCallback(); + logCallback(4, "json-rpc-paseo", followEventLine("paseo", "firstPeer")); + + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + expect(events).toEqual([{ chainName: "paseo", kind: "firstPeer" }]); + }); + + it("ignores malformed and non-lifecycle payloads", async () => { + const logCallback = await captureLogCallback(); + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + // Mentions the method but carries no response payload. + logCallback(4, "json-rpc-paseo", "sent lifecycle_unstable_followEvent"); + // Truncated JSON. + logCallback( + 4, + "json-rpc-paseo", + 'response={"method":"lifecycle_unstable_followEvent"', + ); + // A different method that happens to mention the string. + logCallback( + 4, + "json-rpc-paseo", + followEventLine("paseo", "firstPeer").replace( + "followEvent", + "followEvent_other", + ), + ); + // A kind outside the union. + logCallback(4, "json-rpc-paseo", followEventLine("paseo", "somethingElse")); + // Right payload on a non json-rpc target. + logCallback(4, "sync-service-paseo", followEventLine("paseo", "firstPeer")); + + expect(events).toEqual([]); + }); + + it("stops delivering after unsubscribe", async () => { + const logCallback = await captureLogCallback(); + const events: unknown[] = []; + const unsubscribe = onLifecycle((event) => events.push(event)); + + logCallback(4, "json-rpc-paseo", followEventLine("paseo", "firstPeer")); + unsubscribe(); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("paseo", "bootstrapComplete"), + ); + + expect(events).toEqual([{ chainName: "paseo", kind: "firstPeer" }]); + }); +}); From 3421ed9ce374915341428e129a460134d841ba86 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 15:19:41 +0100 Subject: [PATCH 04/24] Show a live peer count and honest stall copy on the loading screen via system_health polling and widened lifecycle events --- apps/host/index.html | 1 + apps/host/src/main.ts | 75 ++-- apps/host/tests/functional/resolution.spec.ts | 64 ++++ apps/protocol/src/main.ts | 32 +- packages/protocol/src/client.ts | 78 ++++ packages/protocol/src/messages.ts | 31 ++ packages/resolver/src/resolve.ts | 9 +- packages/resolver/src/smoldot.ts | 343 ++++++++++++++++-- packages/resolver/tests/smoldot.test.ts | 319 +++++++++++++--- packages/ui/src/styles/base.css | 26 +- packages/ui/src/styles/themes.css | 7 + packages/ui/src/ui.ts | 22 +- packages/ui/tests/ui.test.ts | 83 +++++ 13 files changed, 981 insertions(+), 109 deletions(-) create mode 100644 packages/ui/tests/ui.test.ts diff --git a/apps/host/index.html b/apps/host/index.html index dfba7d47..06b3c093 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -235,6 +235,7 @@

Login with Polkadot Mobile

Reaching out

+

diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 4261ccfa..a8230df7 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -31,6 +31,7 @@ import { showLanding, initPhases, advancePhase, + setStatusDetail, stopStatusTick, listenForSandboxStatus, showGatewayEscape, @@ -44,6 +45,8 @@ import { } from "@dotli/ui/bulletin-bitswap"; import { ensureProtocolFrame, + onProtocolHealth, + onProtocolLifecycle, resetProtocolFrame, resolveDotNameRemote, resolveExecutableManifestRemote, @@ -1186,35 +1189,63 @@ async function main(): Promise { // scraping log prose. `firstPeer` on the Asset Hub means a peer was // discovered, so the sync band can start crawling. `bootstrapComplete` // means the first finalized block landed, so the resolver can read - // storage. Events are emitted from the protocol iframe, which owns - // smoldot, and reach this listener over the postMessage bridge. The - // `statusToPhase` log-text path remains as a fallback. - // The loading bar tracks Asset Hub sync. Events from other chains are - // logged but never advance a phase. - const LIFECYCLE_PHASE_CHAIN = "asset-hub"; + // storage. Health samples put a live peer count under the headline while + // the chain bootstraps, and stall events replace it with honest copy. + // Events are emitted from the protocol iframe, which owns smoldot, and + // arrive through the protocol client's origin- and source-gated listener. + // The `statusToPhase` log-text path remains as a fallback. if (chainBackend !== "rpc-gateway") { - window.addEventListener("message", (event: MessageEvent) => { - const data = event.data as Record | null; - if ( - data === null || - typeof data !== "object" || - data.namespace !== "dotli:protocol" || - data.kind !== "lifecycle" - ) { + // Only the chains on the resolution critical path may drive the UI. + // Bulletin, People, and the custom relay sync in the background; a + // stall there is not what the user is waiting on. + const uiChains = new Set(["relay", "asset-hub"]); + let peerDetail = ""; + // Once the Asset Hub bootstrap completes, the sync detail is over: + // an in-flight health response or a late background stall must not + // resurrect it under "Resolving". + let syncDetailDone = false; + onProtocolHealth((event) => { + if (syncDetailDone || event.chain !== "asset-hub") { return; } - const chainName = - typeof data.chainName === "string" ? data.chainName : ""; - const lifecycleKind = - typeof data.lifecycleKind === "string" ? data.lifecycleKind : ""; - log.debug(`[dot.li lifecycle] ${chainName} kind=${lifecycleKind}`); - if (!chainName.includes(LIFECYCLE_PHASE_CHAIN)) { + peerDetail = `${String(event.peers)} ${event.peers === 1 ? "peer" : "peers"}`; + setStatusDetail(peerDetail); + }); + onProtocolLifecycle((event) => { + log.debug( + `[dot.li lifecycle] ${event.chain} kind=${event.lifecycleKind}`, + ); + if (!uiChains.has(event.chain)) { + return; + } + if (event.lifecycleKind === "stalled" && !syncDetailDone) { + // No trailing ellipsis: the headline already ends in one, and the + // spinner and bar sheen carry "in progress". Lowercase and + // terminal-punctuation-free to match the "2 peers" readout this line + // alternates with. + setStatusDetail( + event.reason === "noPeers" + ? "searching for peers" + : "syncing, no progress yet", + ); + return; + } + if (event.lifecycleKind === "recovered" && !syncDetailDone) { + setStatusDetail(peerDetail); + return; + } + if (event.chain !== "asset-hub") { return; } - if (lifecycleKind === "firstPeer") { + if (event.lifecycleKind === "firstPeer") { advancePhase(2); - } else if (lifecycleKind === "bootstrapComplete") { + } else if (event.lifecycleKind === "bootstrapComplete") { advancePhase(3); + // The count is meaningless once sync is done; clear rather than + // letting a stale number sit under "Resolving". + syncDetailDone = true; + peerDetail = ""; + setStatusDetail(""); } }); } diff --git a/apps/host/tests/functional/resolution.spec.ts b/apps/host/tests/functional/resolution.spec.ts index 3736e54d..e6ec3d06 100644 --- a/apps/host/tests/functional/resolution.spec.ts +++ b/apps/host/tests/functional/resolution.spec.ts @@ -36,4 +36,68 @@ test.describe("Resolution across chain backends", () => { } }); } + + test(`As a user opening ${DOMAIN}.dot via smoldot-direct, the shell receives live peer counts while syncing`, async ({ + browser, + }) => { + // Given + const { context, page } = await setupTest(browser, { + backend: "smoldot-direct", + }); + + try { + // Record health envelopes as they reach the host window. On a fast + // bootstrap the rendered "N peers" line can appear and clear between + // polls, so the envelope is the reliable signal; the visible text is + // accepted as an alternative when the sync window is long enough. + await page.addInitScript(() => { + const seen: unknown[] = []; + ( + window as unknown as { __dotliHealthSeen: unknown[] } + ).__dotliHealthSeen = seen; + window.addEventListener("message", (event: MessageEvent) => { + const data = event.data as { + namespace?: string; + kind?: string; + peers?: number; + } | null; + if ( + data !== null && + typeof data === "object" && + data.namespace === "dotli:protocol" && + data.kind === "health" && + typeof data.peers === "number" + ) { + seen.push(data); + } + }); + }); + + // When + await page.goto(BASE_URL, { waitUntil: "commit" }); + + // Then: a live peer count flows to the shell during bootstrap, and + // resolution still completes. + const sawPeers = page.waitForFunction( + () => { + const seen = (window as unknown as { __dotliHealthSeen?: unknown[] }) + .__dotliHealthSeen; + if (seen !== undefined && seen.length > 0) { + return true; + } + return /\d+ peers?/.test( + document.getElementById("loading-detail")?.textContent ?? "", + ); + }, + undefined, + { timeout: TIMEOUT_MS }, + ); + await Promise.all([ + sawPeers, + waitForResolutionOutcome(page, TIMEOUT_MS, "smoldot-direct"), + ]); + } finally { + await context.close(); + } + }); }); diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index 16d68b92..ac3025d3 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -683,7 +683,12 @@ async function initDirectMode(): Promise { setResolverPeopleProvider, waitForPeopleFinalized, } = resolve; - const { terminateSmoldot, onSmoldotFatal, onLifecycle } = smoldotMod; + const { terminateSmoldot, onSmoldotFatal, onLifecycle, onHealth } = + smoldotMod; + + // Peer-count polling is only worth the traffic when a loading UI can + // observe it. Direct mode is that case; the SharedWorker never enables it. + smoldotMod.enableHealthPolling(); // On a smoldot panic, broadcast a fatal envelope to the parent. Direct // mode has no SharedWorker in the loop, so we post straight up to the @@ -702,9 +707,10 @@ async function initDirectMode(): Promise { } }); - // Forward smoldot lifecycle events to the host shell so the loading bar can - // advance on real sync signals instead of on log-scraped prose. This iframe - // owns the smoldot instance. The host has no direct handle on it. + // Forward smoldot lifecycle events and health samples to the host shell + // so the loading bar can advance on real sync signals instead of on + // log-scraped prose. This iframe owns the smoldot instance. The host has + // no direct handle on it. onLifecycle((event) => { if (window.parent === window) { return; @@ -713,8 +719,24 @@ async function initDirectMode(): Promise { { namespace: "dotli:protocol", kind: "lifecycle", - chainName: event.chainName, + chain: event.chain, lifecycleKind: event.kind, + ...(event.reason !== undefined ? { reason: event.reason } : {}), + }, + "*", + ); + }); + onHealth((event) => { + if (window.parent === window) { + return; + } + window.parent.postMessage( + { + namespace: "dotli:protocol", + kind: "health", + chain: event.chain, + peers: event.peers, + isSyncing: event.isSyncing, }, "*", ); diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index de3baa14..e1248d09 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -25,6 +25,8 @@ import { m } from "@dotli/metrics/metrics"; import * as S from "@dotli/metrics/spans"; import { isProtocolEnvelope, + type ProtocolHealthEnvelope, + type ProtocolLifecycleEnvelope, type ProtocolRequestEnvelope, type ProtocolRequestMap, type ProtocolRequestMethod, @@ -63,6 +65,10 @@ let protocolReadyPromise: Promise | null = null; const pendingRequests = new Map(); const chainConnections = new Map(); const sharedAuthListeners = new Set(); +const lifecycleListeners = new Set< + (event: ProtocolLifecycleEnvelope) => void +>(); +const healthListeners = new Set<(event: ProtocolHealthEnvelope) => void>(); let listenerBound = false; let protocolReady = false; interface ReadyWaiter { @@ -216,6 +222,47 @@ function bindMessageListener(): void { } return; } + case "lifecycle": { + if ( + typeof msg.chain !== "string" || + typeof msg.lifecycleKind !== "string" + ) { + return; + } + for (const cb of lifecycleListeners) { + try { + cb(msg); + } catch (err: unknown) { + log.error( + "[dot.li protocol] Lifecycle listener threw:", + err instanceof Error ? err.message : err, + ); + } + } + return; + } + case "health": { + if ( + typeof msg.chain !== "string" || + !Number.isInteger(msg.peers) || + msg.peers < 0 || + msg.peers > 10_000 || + typeof msg.isSyncing !== "boolean" + ) { + return; + } + for (const cb of healthListeners) { + try { + cb(msg); + } catch (err: unknown) { + log.error( + "[dot.li protocol] Health listener threw:", + err instanceof Error ? err.message : err, + ); + } + } + return; + } case "fatal": case "init-failed": { // Smoldot (or the protocol iframe) has died, either crashed @@ -685,6 +732,37 @@ export function subscribeSharedAuthStorage( }; } +/** + * Subscribe to smoldot lifecycle events forwarded by the protocol iframe. + * Events arrive only after the origin- and source-gated message listener + * validates the envelope, so callers never see spoofable raw messages. + * Returns an unsubscribe function. + */ +export function onProtocolLifecycle( + listener: (event: ProtocolLifecycleEnvelope) => void, +): () => void { + bindMessageListener(); + lifecycleListeners.add(listener); + return () => { + lifecycleListeners.delete(listener); + }; +} + +/** + * Subscribe to chain `system_health` samples forwarded by the protocol + * iframe during bootstrap. Same gating as `onProtocolLifecycle`. + * Returns an unsubscribe function. + */ +export function onProtocolHealth( + listener: (event: ProtocolHealthEnvelope) => void, +): () => void { + bindMessageListener(); + healthListeners.add(listener); + return () => { + healthListeners.delete(listener); + }; +} + export function isRemoteChainSupported(genesisHash: string): boolean { // Advertise only what the *active* backend can actually serve. Gateway mode // bridges a curated RPC subset, while smoldot can run any configured chain. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b693efdc..c6eecc16 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -99,6 +99,33 @@ export interface ProtocolInitFailedEnvelope { message: string; } +/** + * Unsolicited broadcast of a smoldot lifecycle event observed inside the + * protocol iframe. `chain` is the resolver's logical chain key ("relay", + * "asset-hub", "bulletin", "people", "custom-relay"), not smoldot's + * internal chain id. Drives the host loading bar. + */ +export interface ProtocolLifecycleEnvelope { + namespace: "dotli:protocol"; + kind: "lifecycle"; + chain: string; + lifecycleKind: string; + reason?: string; +} + +/** + * Unsolicited broadcast of a chain's `system_health` sample observed inside + * the protocol iframe. Emitted only while health polling runs (chain + * bootstrap). Drives the live peer count on the host loading screen. + */ +export interface ProtocolHealthEnvelope { + namespace: "dotli:protocol"; + kind: "health"; + chain: string; + peers: number; + isSyncing: boolean; +} + // Unsolicited notification from the host iframe to its parent window when a // sibling tab writes or clears a shared-auth storage key. Drives cross-tab // `StorageAdapter.subscribe` callbacks. See `@dotli/protocol/client` @@ -122,6 +149,8 @@ export type ProtocolEnvelope = | ProtocolReadyEnvelope | ProtocolFatalEnvelope | ProtocolInitFailedEnvelope + | ProtocolLifecycleEnvelope + | ProtocolHealthEnvelope | ProtocolAuthStorageChangedEnvelope; const VALID_KINDS = new Set([ @@ -133,6 +162,8 @@ const VALID_KINDS = new Set([ "ready", "fatal", "init-failed", + "lifecycle", + "health", "auth-storage-changed", ]); diff --git a/packages/resolver/src/resolve.ts b/packages/resolver/src/resolve.ts index d8d49bd3..324f5f2e 100644 --- a/packages/resolver/src/resolve.ts +++ b/packages/resolver/src/resolve.ts @@ -46,8 +46,13 @@ export type { } from "./access-raw-storage"; export { statusToPhase } from "./access-raw-storage"; export { getSmoldot, getSmoldotDirect, getRelayChain } from "./smoldot"; -export { onConnectionIssue, onLifecycle } from "./smoldot"; -export type { LifecycleEvent, LifecycleKind } from "./smoldot"; +export { + onConnectionIssue, + onLifecycle, + onHealth, + enableHealthPolling, +} from "./smoldot"; +export type { LifecycleEvent, LifecycleKind, HealthEvent } from "./smoldot"; let clientInstance: SubstrateClient | null = null; let apiInstance: Api | null = null; diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 0f5d7e27..ed477402 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -58,25 +58,45 @@ export function onConnectionIssue(cb: ConnectionIssueCallback): () => void { }; } -/** Lifecycle events emitted by smoldot's per-chain broadcaster over the `lifecycle_unstable_follow` JSON-RPC subscription. */ -export type LifecycleKind = "firstPeer" | "bootstrapComplete"; +/** + * Lifecycle events emitted by smoldot's per-chain broadcaster over the + * `lifecycle_unstable_follow` JSON-RPC subscription. Smoldot emits more + * kinds (connecting, modeDecision, warpSyncProgress, warpSyncFinished, + * stopped); this allowlist covers only what the loading UI consumes. + */ +export type LifecycleKind = + | "firstPeer" + | "bootstrapComplete" + | "stalled" + | "recovered"; +const LIFECYCLE_KINDS: ReadonlySet = new Set([ + "firstPeer", + "bootstrapComplete", + "stalled", + "recovered", +]); export interface LifecycleEvent { - chainName: string; + /** Logical chain key: "relay", "asset-hub", "bulletin", "people", "custom-relay". */ + chain: string; kind: LifecycleKind; + /** Set on `stalled` (and `recovered` as `previously`): why sync stopped progressing. */ + reason?: string; } type LifecycleCallback = (event: LifecycleEvent) => void; const lifecycleListeners = new Set(); -const lifecycleHistory: LifecycleEvent[] = []; +// Latest event per (chain, kind), insertion-ordered. Bounded, so late +// subscribers replay at most kinds x chains events. +const lifecycleHistory = new Map(); /** - * Subscribe to smoldot lifecycle events. Late subscribers receive the events - * emitted so far in the current session (snapshot-on-subscribe), then continue - * with live events. Returns an unsubscribe function. + * Subscribe to smoldot lifecycle events. Late subscribers receive the latest + * event per chain and kind (snapshot-on-subscribe), then continue with live + * events. Returns an unsubscribe function. */ export function onLifecycle(cb: LifecycleCallback): () => void { lifecycleListeners.add(cb); - for (const event of lifecycleHistory) { + for (const event of lifecycleHistory.values()) { try { cb(event); // eslint-disable-next-line no-restricted-syntax -- defensive replay: one buggy late subscriber must not block registration. @@ -90,7 +110,14 @@ export function onLifecycle(cb: LifecycleCallback): () => void { } function emitLifecycle(event: LifecycleEvent): void { - lifecycleHistory.push(event); + // `stalled` and `recovered` describe one condition; keeping both in the + // replay history would let a late subscriber end on the outdated half. + if (event.kind === "stalled") { + lifecycleHistory.delete(`${event.chain}:recovered`); + } else if (event.kind === "recovered") { + lifecycleHistory.delete(`${event.chain}:stalled`); + } + lifecycleHistory.set(`${event.chain}:${event.kind}`, event); for (const cb of lifecycleListeners) { try { cb(event); @@ -101,6 +128,62 @@ function emitLifecycle(event: LifecycleEvent): void { } } +/** A chain's `system_health` sample, polled during bootstrap. */ +export interface HealthEvent { + /** Logical chain key, same namespace as `LifecycleEvent.chain`. */ + chain: string; + peers: number; + isSyncing: boolean; +} + +type HealthCallback = (event: HealthEvent) => void; +const healthListeners = new Set(); +const lastHealth = new Map(); + +/** + * Subscribe to chain health samples. Late subscribers receive the last known + * sample per chain, then live changes. Returns an unsubscribe function. + */ +export function onHealth(cb: HealthCallback): () => void { + healthListeners.add(cb); + for (const event of lastHealth.values()) { + try { + cb(event); + // eslint-disable-next-line no-restricted-syntax -- defensive replay: one buggy late subscriber must not block registration. + } catch { + /* listener threw during replay */ + } + } + return () => { + healthListeners.delete(cb); + }; +} + +function emitHealth(event: HealthEvent): void { + const prev = lastHealth.get(event.chain); + if (prev?.peers === event.peers && prev.isSyncing === event.isSyncing) { + return; + } + lastHealth.set(event.chain, event); + for (const cb of healthListeners) { + try { + cb(event); + // eslint-disable-next-line no-restricted-syntax -- defensive multicast: one buggy subscriber must not block the broadcast. + } catch { + /* listener threw */ + } + } +} + +// Health polling is opt-in per process. The protocol iframe's direct mode +// enables it; the SharedWorker never does, so its long-lived smoldot does +// not poll for a UI that cannot observe it. +let healthPollingEnabled = false; + +export function enableHealthPolling(): void { + healthPollingEnabled = true; +} + // Smoldot's WASM can panic (e.g., the "Option::unwrap() on a None value" // crash during relay-chain sync). A panic leaves every chain dead, and any // in-flight request would hang forever. The log callback catches the @@ -155,9 +238,20 @@ const CONNECTION_ISSUE_PATTERNS = [ "all bootnodes", ]; -// Reserved id used for our internal `lifecycle_unstable_follow` request. -// Chosen so it cannot collide with the numeric ids polkadot-api uses. +// Reserved id prefixes for our internal JSON-RPC requests. Chosen so they +// cannot collide with the numeric ids polkadot-api uses, and so responses +// can be recognized in the log stream. The suffix after ":" is the logical +// chain key, which is how responses are attributed to chains without +// relying on smoldot's internal chain id. const LIFECYCLE_FOLLOW_REQUEST_ID = "__dotli_lifecycle_follow__"; +const HEALTH_REQUEST_ID = "__dotli_health__"; + +// smoldot's log target is `json-rpc-`, while +// requests are attributed by our logical keys ("relay", "asset-hub", ...). +// The reply to our follow request carries the logical key in its id and +// arrives on the chain's log target, which lets us learn the mapping +// without hardcoding chain-spec ids per network. +const chainKeyByLogName = new Map(); function smoldotLogCallback( level: number, @@ -171,17 +265,20 @@ function smoldotLogCallback( log.debug(`[smoldot:${target}] ${message}`); } - // Lifecycle events flow through the chain's JSON-RPC pipe, which is owned by - // polkadot-api. Rather than wrap the chain and race for messages, we observe - // the payloads passing through smoldot's own debug log stream: every - // `json-rpc-` target emits the response JSON verbatim. We match - // the schema we ourselves define, so this is subscribing to a structured - // side-channel, not scraping human prose. + // Lifecycle and health payloads flow through the chain's JSON-RPC pipe, + // which is owned by polkadot-api. Rather than wrap the chain and race for + // messages, we observe the payloads passing through smoldot's own debug + // log stream: every response is logged on the `json-rpc-` target + // as `json-rpc-response-yielded; response=`. + // We match schemas we ourselves define (our reserved request ids and the + // followEvent notification), so this is a structured side-channel, not + // prose scraping. if ( target.startsWith("json-rpc-") && - message.includes("lifecycle_unstable_followEvent") + (message.includes("lifecycle_unstable_followEvent") || + message.includes("__dotli_")) ) { - parseAndEmitLifecycle(target, message); + handleSideChannelLine(target, message); } // A panic is terminal with no recovery. Smoldot's log message starts @@ -210,35 +307,96 @@ function smoldotLogCallback( } } -function parseAndEmitLifecycle(target: string, message: string): void { +function handleSideChannelLine(target: string, message: string): void { // Smoldot emits the response JSON verbatim after a literal `response=` - // prefix in the log line. + // key in the log line. Request echoes use `request=` and fall out here. const responseStart = message.indexOf("response="); if (responseStart === -1) { return; } const jsonPart = message.slice(responseStart + "response=".length); + const logName = target.slice("json-rpc-".length); try { const parsed = JSON.parse(jsonPart) as { + id?: string; method?: string; - params?: { result?: { kind?: LifecycleKind } }; + result?: unknown; + params?: { + result?: { kind?: string; reason?: string; previously?: string }; + }; }; - if (parsed.method !== "lifecycle_unstable_followEvent") { + if (parsed.method === "lifecycle_unstable_followEvent") { + emitLifecycleNotification(logName, parsed.params?.result); return; } - const kind = parsed.params?.result?.kind; - if (kind !== "firstPeer" && kind !== "bootstrapComplete") { + if (typeof parsed.id !== "string") { return; } - // Strip the `json-rpc-` prefix to get the chain name for the event. - const chainName = target.slice("json-rpc-".length); - emitLifecycle({ chainName, kind }); - // eslint-disable-next-line no-restricted-syntax -- best-effort parse of a smoldot debug log line: any non-JSON or unexpected shape is expected and must not spam log.error. + if (parsed.id.startsWith(`${LIFECYCLE_FOLLOW_REQUEST_ID}:`)) { + // Reply to our follow request: learn which logical chain owns this + // log target so notifications (which carry no id) can be attributed. + chainKeyByLogName.set( + logName, + parsed.id.slice(LIFECYCLE_FOLLOW_REQUEST_ID.length + 1), + ); + return; + } + if (parsed.id.startsWith(`${HEALTH_REQUEST_ID}:`)) { + handleHealthResponse(parsed.id, parsed.result); + } + // eslint-disable-next-line no-restricted-syntax -- best-effort parse of a smoldot debug log line: non-JSON or truncated payloads are expected and must not spam log.error. } catch { - /* malformed response payload, skip */ + /* malformed or truncated response payload, skip */ } } +function emitLifecycleNotification( + logName: string, + result: { kind?: string; reason?: string; previously?: string } | undefined, +): void { + const kind = result?.kind; + if (kind === undefined || !LIFECYCLE_KINDS.has(kind)) { + return; + } + const chain = chainKeyByLogName.get(logName) ?? logName; + if (kind === "bootstrapComplete") { + // The chain is usable; peer-count polling has served its purpose. + healthPollers.get(chain)?.stop(); + } + const reason = + kind === "stalled" + ? result?.reason + : kind === "recovered" + ? result?.previously + : undefined; + emitLifecycle({ + chain, + kind: kind as LifecycleKind, + ...(typeof reason === "string" ? { reason } : {}), + }); +} + +function handleHealthResponse(id: string, result: unknown): void { + const rest = id.slice(HEALTH_REQUEST_ID.length + 1); + const chain = rest.slice(0, rest.lastIndexOf(":")); + if (chain === "") { + return; + } + healthPollers.get(chain)?.noteResponse(); + const health = result as { peers?: unknown; isSyncing?: unknown } | null; + if ( + health === null || + typeof health !== "object" || + typeof health.peers !== "number" || + !Number.isInteger(health.peers) || + health.peers < 0 || + typeof health.isSyncing !== "boolean" + ) { + return; + } + emitHealth({ chain, peers: health.peers, isSyncing: health.isSyncing }); +} + let smoldotInstance: SmoldotClient | null = null; let relayChainPromise: Promise | null = null; @@ -246,6 +404,7 @@ interface PersistenceEntry { tap: ChainDbTap; initialTimer: ReturnType; periodicTimer: ReturnType; + healthPoller: HealthPoller | null; } const persistence = new Map(); @@ -303,7 +462,12 @@ function schedulePersistence(chainName: string, tap: ChainDbTap): void { }, 60_000); unrefHandle(initialTimer); unrefHandle(periodicTimer); - persistence.set(chainName, { tap, initialTimer, periodicTimer }); + persistence.set(chainName, { + tap, + initialTimer, + periodicTimer, + healthPoller: null, + }); } function teardownPersistence(chainName: string): void { @@ -313,6 +477,7 @@ function teardownPersistence(chainName: string): void { } clearTimeout(entry.initialTimer); clearInterval(entry.periodicTimer); + entry.healthPoller?.stop(); entry.tap.stop(); persistence.delete(chainName); } @@ -333,8 +498,14 @@ function attachPersistence( // Fire the lifecycle subscription without wrapping the chain object. // Polkadot-api consumes `jsonRpcResponses` iteratively and interposing on // that path is racy. Notifications are read back from smoldot's own - // json-rpc log stream instead (see parseAndEmitLifecycle). + // json-rpc log stream instead (see handleSideChannelLine). attachLifecycleFollow(chainName, tap.chain); + if (healthPollingEnabled) { + const entry = persistence.get(chainName); + if (entry !== undefined) { + entry.healthPoller = startHealthPolling(chainName, tap); + } + } return tap.chain; } @@ -355,6 +526,108 @@ function attachLifecycleFollow(chainName: string, chain: SmoldotChain): void { } } +interface HealthPoller { + stop(): void; + noteResponse(): void; +} +const healthPollers = new Map(); + +const HEALTH_POLL_INTERVAL_MS = 1_000; +const HEALTH_POLL_TIMEOUT_MS = 2_000; +const HEALTH_POLL_MAX = 120; + +let healthWatchdogArmed = false; +let healthResponseSeen = false; + +/** + * Poll `system_health` on a chain's JSON-RPC pipe during bootstrap so the + * loading UI can show a live peer count. Sequential by design: the next + * poll goes out one interval after the previous response is observed in + * the log stream, so a busy chain is never flooded; a 2s timeout resends + * when a response never surfaces. Stops on `bootstrapComplete` for the + * chain (see emitLifecycleNotification), chain teardown, a dead chain, or + * a hard poll cap. + */ +function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { + let stopped = false; + let polls = 0; + let timer: ReturnType | null = null; + + const stop = (): void => { + stopped = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + healthPollers.delete(chainName); + }; + + const schedule = (delayMs: number): void => { + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(send, delayMs); + unrefHandle(timer); + }; + + const send = (): void => { + if (stopped) { + return; + } + if (tap.isStopped() || polls >= HEALTH_POLL_MAX) { + stop(); + return; + } + polls += 1; + try { + tap.chain.sendJsonRpc( + JSON.stringify({ + jsonrpc: "2.0", + id: `${HEALTH_REQUEST_ID}:${chainName}:${String(polls)}`, + method: "system_health", + params: [], + }), + ); + } catch { + // The chain was destroyed under us (terminate/remove); polling is + // best-effort and simply ends. + stop(); + return; + } + schedule(HEALTH_POLL_TIMEOUT_MS); + }; + + const noteResponse = (): void => { + healthResponseSeen = true; + if (stopped) { + return; + } + schedule(HEALTH_POLL_INTERVAL_MS); + }; + + // The side-channel depends on smoldot's debug log format staying stable. + // If it breaks, lifecycle and health both go silently dead, so surface + // one warning per session when the first poll gets no observable reply. + if (!healthWatchdogArmed) { + healthWatchdogArmed = true; + const watchdog = setTimeout(() => { + if (!healthResponseSeen) { + log.warn( + "[dot.li smoldot] health side-channel not observed within 5s; loading detail will not update", + ); + } + }, 5_000); + unrefHandle(watchdog); + } + + const poller = { stop, noteResponse }; + healthPollers.set(chainName, poller); + // Poll immediately: on a warm start the chain bootstraps in well under a + // second and a delayed first poll would never produce a sample. + send(); + return poller; +} + /** * Create smoldot using `start()`, which runs on the current thread. * @@ -430,6 +703,12 @@ export function terminateSmoldot(): void { /* already destroyed or crashed, safe to ignore */ } teardownAllPersistence(); + // The next smoldot instance re-learns chain identities and re-emits its + // own lifecycle; stale mappings or replayed events from the dead session + // would mislabel or suppress the new one's signals. + chainKeyByLogName.clear(); + lifecycleHistory.clear(); + lastHealth.clear(); smoldotInstance = null; relayChainPromise = null; dappAssetHubPromise = null; diff --git a/packages/resolver/tests/smoldot.test.ts b/packages/resolver/tests/smoldot.test.ts index f8478b9f..b666419e 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -1,7 +1,7 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("polkadot-api/smoldot", () => ({ start: vi.fn(() => ({ @@ -50,13 +50,20 @@ vi.mock("@dotli/resolver/chain-specs", () => ({ let getSmoldot: typeof import("@dotli/resolver/smoldot").getSmoldot; let getRelayChain: typeof import("@dotli/resolver/smoldot").getRelayChain; let onLifecycle: typeof import("@dotli/resolver/smoldot").onLifecycle; +let onHealth: typeof import("@dotli/resolver/smoldot").onHealth; +let enableHealthPolling: typeof import("@dotli/resolver/smoldot").enableHealthPolling; beforeEach(async () => { + // The mocked chain object is shared by the module factories above, so its + // call history would otherwise leak across tests. + vi.clearAllMocks(); vi.resetModules(); const mod = await import("@dotli/resolver/smoldot"); getSmoldot = mod.getSmoldot; getRelayChain = mod.getRelayChain; onLifecycle = mod.onLifecycle; + onHealth = mod.onHealth; + enableHealthPolling = mod.enableHealthPolling; }); describe("getSmoldot", () => { @@ -86,89 +93,186 @@ describe("getRelayChain", () => { }); }); -describe("onLifecycle", () => { - // Grab the logCallback the module hands to smoldot so tests can feed it - // synthetic json-rpc log lines. - async function captureLogCallback(): Promise< - (level: number, target: string, message: string) => void - > { - getSmoldot(); - const { startFromWorker } = - await import("polkadot-api/smoldot/from-worker"); - const options = vi.mocked(startFromWorker).mock.lastCall?.[1]; - if (options?.logCallback === undefined) { - throw new Error("logCallback was not passed to startFromWorker"); - } - return options.logCallback; +// Grab the logCallback the module hands to smoldot so tests can feed it +// synthetic json-rpc log lines. +async function captureLogCallback(): Promise< + (level: number, target: string, message: string) => void +> { + getSmoldot(); + const { startFromWorker } = await import("polkadot-api/smoldot/from-worker"); + const options = vi.mocked(startFromWorker).mock.lastCall?.[1]; + if (options?.logCallback === undefined) { + throw new Error("logCallback was not passed to startFromWorker"); } + return options.logCallback; +} - function followEventLine(chain: string, kind: string): string { - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "lifecycle_unstable_followEvent", - params: { subscription: "lf-1", result: { kind } }, - }); - return `chain=${chain} response=${payload}`; - } +// Production line shape: smoldot logs every response on the chain's +// `json-rpc-` target as `json-rpc-response-yielded; response=`. +function responseLine(payload: unknown): string { + return `json-rpc-response-yielded; response=${JSON.stringify(payload)}`; +} + +function followEventLine( + kind: string, + extra: Record = {}, +): string { + return responseLine({ + jsonrpc: "2.0", + method: "lifecycle_unstable_followEvent", + params: { subscription: "lf-1", result: { kind, ...extra } }, + }); +} + +function followReplyLine(logicalKey: string): string { + return responseLine({ + jsonrpc: "2.0", + id: `__dotli_lifecycle_follow__:${logicalKey}`, + result: "lf-1", + }); +} - it("delivers events parsed from the json-rpc log stream", async () => { +function healthLine( + logicalKey: string, + seq: number, + peers: number, + isSyncing = true, +): string { + return responseLine({ + jsonrpc: "2.0", + id: `__dotli_health__:${logicalKey}:${String(seq)}`, + result: { isSyncing, peers, shouldHavePeers: true }, + }); +} + +describe("onLifecycle", () => { + it("delivers events keyed by the logical chain once the follow reply is seen", async () => { const logCallback = await captureLogCallback(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); + logCallback(4, "json-rpc-asset-hub-paseo", followReplyLine("asset-hub")); + logCallback(4, "json-rpc-asset-hub-paseo", followEventLine("firstPeer")); logCallback( 4, "json-rpc-asset-hub-paseo", - followEventLine("asset-hub-paseo", "firstPeer"), + followEventLine("bootstrapComplete"), ); + + expect(events).toEqual([ + { chain: "asset-hub", kind: "firstPeer" }, + { chain: "asset-hub", kind: "bootstrapComplete" }, + ]); + }); + + it("falls back to the log target name when no follow reply mapped it", async () => { + const logCallback = await captureLogCallback(); + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + logCallback(4, "json-rpc-asset-hub-paseo", followEventLine("firstPeer")); + + expect(events).toEqual([{ chain: "asset-hub-paseo", kind: "firstPeer" }]); + }); + + it("carries the stall reason and the recovery cause", async () => { + const logCallback = await captureLogCallback(); + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + logCallback(4, "json-rpc-paseo", followReplyLine("relay")); logCallback( 4, - "json-rpc-asset-hub-paseo", - followEventLine("asset-hub-paseo", "bootstrapComplete"), + "json-rpc-paseo", + followEventLine("stalled", { reason: "noPeers" }), + ); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("recovered", { previously: "noPeers" }), ); expect(events).toEqual([ - { chainName: "asset-hub-paseo", kind: "firstPeer" }, - { chainName: "asset-hub-paseo", kind: "bootstrapComplete" }, + { chain: "relay", kind: "stalled", reason: "noPeers" }, + { chain: "relay", kind: "recovered", reason: "noPeers" }, ]); }); - it("replays earlier events to a late subscriber", async () => { + it("replays only the latest event per chain and kind to a late subscriber", async () => { const logCallback = await captureLogCallback(); - logCallback(4, "json-rpc-paseo", followEventLine("paseo", "firstPeer")); + logCallback(4, "json-rpc-paseo", followReplyLine("relay")); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("stalled", { reason: "noPeers" }), + ); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("stalled", { reason: "syncNoProgress" }), + ); + logCallback(4, "json-rpc-paseo", followEventLine("firstPeer")); const events: unknown[] = []; onLifecycle((event) => events.push(event)); - expect(events).toEqual([{ chainName: "paseo", kind: "firstPeer" }]); + expect(events).toEqual([ + { chain: "relay", kind: "stalled", reason: "syncNoProgress" }, + { chain: "relay", kind: "firstPeer" }, + ]); }); - it("ignores malformed and non-lifecycle payloads", async () => { + it("replays only the newer half of a stall and recovery pair", async () => { + const logCallback = await captureLogCallback(); + logCallback(4, "json-rpc-paseo", followReplyLine("relay")); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("stalled", { reason: "noPeers" }), + ); + logCallback( + 4, + "json-rpc-paseo", + followEventLine("recovered", { previously: "noPeers" }), + ); + + const events: unknown[] = []; + onLifecycle((event) => events.push(event)); + + expect(events).toEqual([ + { chain: "relay", kind: "recovered", reason: "noPeers" }, + ]); + }); + + it("ignores malformed, unknown-kind, and non-lifecycle payloads", async () => { const logCallback = await captureLogCallback(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); // Mentions the method but carries no response payload. logCallback(4, "json-rpc-paseo", "sent lifecycle_unstable_followEvent"); - // Truncated JSON. + // Request echo, not a response. + logCallback( + 4, + "json-rpc-paseo", + 'json-rpc-request-queued; request={"method":"lifecycle_unstable_followEvent"}', + ); + // Truncated JSON (smoldot caps the logged payload at 250 chars). logCallback( 4, "json-rpc-paseo", - 'response={"method":"lifecycle_unstable_followEvent"', + 'json-rpc-response-yielded; response={"method":"lifecycle_unstable_followEvent"', ); // A different method that happens to mention the string. logCallback( 4, "json-rpc-paseo", - followEventLine("paseo", "firstPeer").replace( - "followEvent", - "followEvent_other", - ), + followEventLine("firstPeer").replace("followEvent", "followEvent_other"), ); - // A kind outside the union. - logCallback(4, "json-rpc-paseo", followEventLine("paseo", "somethingElse")); + // A kind outside the allowlist (smoldot emits more kinds than we consume). + logCallback(4, "json-rpc-paseo", followEventLine("warpSyncProgress")); // Right payload on a non json-rpc target. - logCallback(4, "sync-service-paseo", followEventLine("paseo", "firstPeer")); + logCallback(4, "sync-service-paseo", followEventLine("firstPeer")); expect(events).toEqual([]); }); @@ -178,14 +282,139 @@ describe("onLifecycle", () => { const events: unknown[] = []; const unsubscribe = onLifecycle((event) => events.push(event)); - logCallback(4, "json-rpc-paseo", followEventLine("paseo", "firstPeer")); + logCallback(4, "json-rpc-paseo", followEventLine("firstPeer")); unsubscribe(); + logCallback(4, "json-rpc-paseo", followEventLine("bootstrapComplete")); + + expect(events).toEqual([{ chain: "paseo", kind: "firstPeer" }]); + }); +}); + +describe("onHealth", () => { + // Fake timers keep leaked poller resends (2s timeout) from bleeding + // between tests through the shared mock chain. + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + async function relaySendJsonRpc(): Promise> { + const { startFromWorker } = + await import("polkadot-api/smoldot/from-worker"); + const client = vi.mocked(startFromWorker).mock.results.at(-1)?.value as { + addChain: ReturnType; + }; + const chain = (await client.addChain.mock.results.at(-1)?.value) as { + sendJsonRpc: ReturnType; + }; + return chain.sendJsonRpc; + } + + function healthCalls(sendJsonRpc: ReturnType): string[] { + return sendJsonRpc.mock.calls + .map((call) => call[0] as string) + .filter((raw) => raw.includes("system_health")); + } + + it("polls system_health immediately and emits the parsed sample", async () => { + enableHealthPolling(); + const logCallback = await captureLogCallback(); + await getRelayChain(); + const sendJsonRpc = await relaySendJsonRpc(); + + const sent = healthCalls(sendJsonRpc); + expect(sent.length).toBeGreaterThanOrEqual(1); + expect(sent[0]).toContain('"id":"__dotli_health__:relay:1"'); + + const events: unknown[] = []; + onHealth((event) => events.push(event)); + logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 3)); + + expect(events).toEqual([{ chain: "relay", peers: 3, isSyncing: true }]); + }); + + it("emits only when the sample changes", async () => { + enableHealthPolling(); + const logCallback = await captureLogCallback(); + await getRelayChain(); + + const events: unknown[] = []; + onHealth((event) => events.push(event)); + logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 2)); + logCallback(4, "json-rpc-paseo", healthLine("relay", 2, 2)); + logCallback(4, "json-rpc-paseo", healthLine("relay", 3, 5)); + + expect(events).toEqual([ + { chain: "relay", peers: 2, isSyncing: true }, + { chain: "relay", peers: 5, isSyncing: true }, + ]); + }); + + it("replays the last sample to a late subscriber", async () => { + enableHealthPolling(); + const logCallback = await captureLogCallback(); + await getRelayChain(); + + logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 4)); + + const events: unknown[] = []; + onHealth((event) => events.push(event)); + expect(events).toEqual([{ chain: "relay", peers: 4, isSyncing: true }]); + }); + + it("stops polling once the chain bootstrap completes", async () => { + enableHealthPolling(); + const logCallback = await captureLogCallback(); + await getRelayChain(); + const sendJsonRpc = await relaySendJsonRpc(); + + // Map the log target to the logical key, then finish bootstrap. + logCallback(4, "json-rpc-paseo", followReplyLine("relay")); + logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 3)); + logCallback(4, "json-rpc-paseo", followEventLine("bootstrapComplete")); + + const sentBefore = healthCalls(sendJsonRpc).length; + vi.advanceTimersByTime(30_000); + expect(healthCalls(sendJsonRpc).length).toBe(sentBefore); + }); + + it("does not poll when polling was never enabled", async () => { + await captureLogCallback(); + await getRelayChain(); + const sendJsonRpc = await relaySendJsonRpc(); + expect(healthCalls(sendJsonRpc)).toEqual([]); + }); + + it("rejects malformed health payloads", async () => { + enableHealthPolling(); + const logCallback = await captureLogCallback(); + await getRelayChain(); + + const events: unknown[] = []; + onHealth((event) => events.push(event)); + // peers is not an integer logCallback( 4, "json-rpc-paseo", - followEventLine("paseo", "bootstrapComplete"), + responseLine({ + jsonrpc: "2.0", + id: "__dotli_health__:relay:1", + result: { isSyncing: true, peers: "3" }, + }), + ); + // result missing (error response) + logCallback( + 4, + "json-rpc-paseo", + responseLine({ + jsonrpc: "2.0", + id: "__dotli_health__:relay:2", + error: { code: -32000, message: "nope" }, + }), ); - expect(events).toEqual([{ chainName: "paseo", kind: "firstPeer" }]); + expect(events).toEqual([]); }); }); diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index e42d19fd..87eeeec5 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -129,12 +129,17 @@ body { text-align: right; } -/* Text block — phase label + terminal log */ +/* Text block — phase label + terminal log. + The gap is the tight one: `#loading-detail` is a readout *of* the headline + above it and has to sit close enough to read as one unit. `.loading-hint` + is a separate, occasional warning and buys its 12px break back with a + margin. Three rows spaced equally would read as three peers, which they + are not. */ .loading-text { display: flex; flex-direction: column; align-items: center; - gap: 12px; + gap: 6px; width: 100%; max-width: 420px; flex-shrink: 0; @@ -152,6 +157,21 @@ body { overflow: hidden; text-overflow: ellipsis; } +/* Live detail line (e.g. peer count) below the status headline */ +/* Live sync readout under the headline. Ranked below `#status` by size, + weight, and family — not by tone: it shares the headline's opacity because + the stall copy it carries ("searching for peers") is the most reassuring + message on the screen, and it cannot be the faintest thing on it. + `min-height` reserves the row so the block does not jump when the detail + appears mid-sync and is cleared on bootstrap. */ +.loading-detail { + font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; + font-size: 11px; + color: #fff; + opacity: 0.35; + text-align: center; + min-height: 1.3em; +} /* Slow-step hint (shown below status when a step exceeds its threshold) */ .loading-hint { font-size: 11px; @@ -161,6 +181,8 @@ body { text-align: center; transition: opacity 0.3s ease; min-height: 1.3em; + /* Restores the 12px break the status/detail pair gave up. */ + margin-top: 6px; } .loading-hint.visible { opacity: 0.5; diff --git a/packages/ui/src/styles/themes.css b/packages/ui/src/styles/themes.css index 753f4e30..6776c0f0 100644 --- a/packages/ui/src/styles/themes.css +++ b/packages/ui/src/styles/themes.css @@ -242,6 +242,13 @@ [data-theme="light"] .loading-progress-pct { color: #333; } +/* Same flip as `#status`: the base rule is `#fff`, which is invisible on the + light-theme background. The detail line tracks the headline's colour rather + than the percentage's — it carries the stall copy, which is the one message + that has to survive a light background. */ +[data-theme="light"] .loading-detail { + color: #1a1a1a; +} [data-theme="light"] .spinner { border-color: #ddd; border-top-color: #333; diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 3779afab..f68dba1c 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -129,11 +129,28 @@ export function advancePhase(index: number): void { ((target - base) * CRAWL_TICK_MS) / Math.max(expectedMs, CRAWL_TICK_MS); startProgressCrawl(); - // Update headline + // Update headline. Re-arm the slow hint against the new label: the timer + // armed for the previous step would otherwise fire with a hint describing + // work that already completed. const status = document.getElementById("status"); if (status !== null) { status.textContent = label; } + armSlowHint(label); +} + +/** + * Live detail line under the status headline (e.g. "3 peers"). Deliberately + * separate from `#status`: this value changes every second or two during + * sync, and `#status` is aria-live, so routing it there would queue a + * screen-reader announcement per change. The detail element is aria-hidden + * and never touches the slow-hint timer. + */ +export function setStatusDetail(detail: string): void { + const el = document.getElementById("loading-detail"); + if (el !== null) { + el.textContent = detail; + } } export const GATEWAY_ESCAPE_DELAY_MS = 10_000; @@ -283,7 +300,10 @@ export function showStatus(message: string): void { if (status !== null) { status.textContent = message; } + armSlowHint(message); +} +function armSlowHint(message: string): void { clearSlowWarning(); const threshold = getSlowThreshold(message); diff --git a/packages/ui/tests/ui.test.ts b/packages/ui/tests/ui.test.ts new file mode 100644 index 00000000..aec5b343 --- /dev/null +++ b/packages/ui/tests/ui.test.ts @@ -0,0 +1,83 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +let initPhases: typeof import("@dotli/ui/ui").initPhases; +let advancePhase: typeof import("@dotli/ui/ui").advancePhase; +let showStatus: typeof import("@dotli/ui/ui").showStatus; +let setStatusDetail: typeof import("@dotli/ui/ui").setStatusDetail; + +const PHASES = [ + { label: "Starting", base: 2, target: 6, expectedMs: 650 }, + { label: "Adding relay chain", base: 6, target: 10, expectedMs: 120 }, + { label: "Syncing Asset Hub", base: 10, target: 55, expectedMs: 6500 }, +]; + +function textOf(id: string): string { + return document.getElementById(id)?.textContent ?? ""; +} + +beforeEach(async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+
+
+ 0% +

Reaching out

+ +

+
+
`; + vi.resetModules(); + const mod = await import("@dotli/ui/ui"); + initPhases = mod.initPhases; + advancePhase = mod.advancePhase; + showStatus = mod.showStatus; + setStatusDetail = mod.setStatusDetail; + initPhases(PHASES); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("setStatusDetail", () => { + it("writes the detail line without touching the status headline", () => { + showStatus("Resolving example.dot"); + setStatusDetail("3 peers"); + expect(textOf("loading-detail")).toBe("3 peers"); + expect(textOf("status")).toBe("Resolving example.dot"); + }); + + it("does not reset the slow-hint timer while the count updates", () => { + advancePhase(2); + // "Syncing Asset Hub" arms a 15s hint. A peer count that ticks every + // second must not keep pushing that hint away. + for (let i = 0; i < 20; i++) { + vi.advanceTimersByTime(1000); + setStatusDetail(`${String(i)} peers`); + } + expect(textOf("loading-hint")).toContain("Asset Hub sync is slow"); + }); +}); + +describe("advancePhase", () => { + it("re-arms the slow hint for the new phase label", () => { + showStatus("Adding Paseo relay chain..."); + advancePhase(2); + // The relay-chain hint (10s) must not fire after we advanced to the + // Syncing phase; the Syncing hint (15s) fires instead. + vi.advanceTimersByTime(11_000); + expect(textOf("loading-hint")).not.toContain("relay chain bootstrap"); + vi.advanceTimersByTime(4_000); + expect(textOf("loading-hint")).toContain("Asset Hub sync is slow"); + }); + + it("keeps the detail line across phase advances", () => { + setStatusDetail("4 peers"); + advancePhase(2); + expect(textOf("loading-detail")).toBe("4 peers"); + }); +}); From ec1d17d691993065f34f7175004460166f4aa288 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 15:41:19 +0100 Subject: [PATCH 05/24] Intercept lifecycle and health traffic in the chain tap instead of the debug log stream, poll only the Asset Hub, and address the loading review feedback --- apps/host/index.html | 672 +++++++++++++++--------- apps/host/src/main.ts | 51 +- apps/protocol/src/main.ts | 6 +- packages/protocol/src/client.ts | 12 +- packages/protocol/src/messages.ts | 4 +- packages/resolver/src/smoldot-db.ts | 31 +- packages/resolver/src/smoldot.ts | 174 +++--- packages/resolver/tests/smoldot.test.ts | 439 ++++++++-------- packages/ui/src/styles/base.css | 11 + packages/ui/src/ui.ts | 23 +- 10 files changed, 823 insertions(+), 600 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index 06b3c093..229f9d7b 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -3,269 +3,433 @@ - - - - Polkadot - The decentralized web, in your browser - - - - - - - - - - - - - - - - - -
- - - Polkadot - Beta - -
-
- - - - -
- - - -
- -
+ s.createIndex("byProductId", "productId", { unique: false }); + s.createIndex("byScheduledAt", "scheduledAt", { unique: false }); + } + if (!db.objectStoreNames.contains("notification_counters")) + db.createObjectStore("notification_counters", { + keyPath: "productId", + }); + }; + req.onsuccess = function () { + resolve(req.result); + }; + req.onerror = function () { + reject(new Error("IDB open failed")); + }; + }); + + + + + + + + + + + + +
+ + - - -
-
-

Login with Polkadot Mobile

- -

Scan with Polkadot Mobile to connect

-
-
-
- -
+ Polkadot + Beta +
+
+
+ + + + +
+ + +
+ +
+
- -
-
-
Welcome back
-
-
-
- + +
+
+

Login with Polkadot Mobile

+ +

Scan with Polkadot Mobile to connect

+
+
+ +
+
+ + +
+
+
Welcome back
+
+
+
+ +
- -
-
-
-
+
+
+
+
- -
-
-
Permissions
-
-
+
+
+
Permissions
+
+
- -
-
- -
-
-
-
- 0% -
-
-

Reaching out

- -

-
-
+ +
+
+ +
+
+
+
+ 0% +
+
+

Reaching out

+ +

+

- - - +
+
+ + + diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index a8230df7..05ff6cae 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -31,6 +31,7 @@ import { showLanding, initPhases, advancePhase, + getCurrentPhase, setStatusDetail, stopStatusTick, listenForSandboxStatus, @@ -1191,10 +1192,13 @@ async function main(): Promise { // means the first finalized block landed, so the resolver can read // storage. Health samples put a live peer count under the headline while // the chain bootstraps, and stall events replace it with honest copy. - // Events are emitted from the protocol iframe, which owns smoldot, and - // arrive through the protocol client's origin- and source-gated listener. - // The `statusToPhase` log-text path remains as a fallback. - if (chainBackend !== "rpc-gateway") { + // Events are emitted from the protocol iframe, which owns smoldot in + // direct mode, and arrive through the protocol client's origin- and + // source-gated listener. The `statusToPhase` log-text path remains as a + // fallback. Only direct mode subscribes: in shared-worker mode smoldot + // lives in the SharedWorker, which does not forward lifecycle or health + // yet, and the gateway has no smoldot at all. + if (chainBackend === "smoldot-direct") { // Only the chains on the resolution critical path may drive the UI. // Bulletin, People, and the custom relay sync in the background; a // stall there is not what the user is waiting on. @@ -1227,11 +1231,12 @@ async function main(): Promise { event.reason === "noPeers" ? "searching for peers" : "syncing, no progress yet", + { announce: true }, ); return; } if (event.lifecycleKind === "recovered" && !syncDetailDone) { - setStatusDetail(peerDetail); + setStatusDetail(peerDetail, { announce: true }); return; } if (event.chain !== "asset-hub") { @@ -1367,22 +1372,30 @@ async function main(): Promise { // mapping from status text to ResolvePhase, so we defer to it // instead of maintaining a parallel regex here. const phase = statusToPhase(msg); - if (phase === "relay-chain-adding") { - advancePhase(1); - } else if ( - // `asset-hub-connecting` is ~0ms (just createClient), so it shares - // the Syncing band rather than getting a slice that makes the bar - // jump for no work. - phase === "asset-hub-connecting" || - phase === "asset-hub-syncing" || - phase === "asset-hub-ready" - ) { - advancePhase(2); - } else if (phase === "resolving-content") { - advancePhase(3); + // `asset-hub-connecting` is ~0ms (just createClient), so it shares + // the Syncing band rather than getting a slice that makes the bar + // jump for no work. + const mappedPhase = + phase === "relay-chain-adding" + ? 1 + : phase === "asset-hub-connecting" || + phase === "asset-hub-syncing" || + phase === "asset-hub-ready" + ? 2 + : phase === "resolving-content" + ? 3 + : null; + if (mappedPhase !== null) { + advancePhase(mappedPhase); } emitPhase(msg, phase ?? "progress"); - showStatus(msg); + // Lifecycle events usually outrun these status strings. Prose + // describing a phase the bar has already passed must not flip + // the headline backwards; unmapped messages (bootnode issues, + // not-found notices) always show. + if (mappedPhase === null || mappedPhase >= getCurrentPhase()) { + showStatus(msg); + } }; cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); if (cid === null) { diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index ac3025d3..cdd5c60d 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -687,8 +687,10 @@ async function initDirectMode(): Promise { smoldotMod; // Peer-count polling is only worth the traffic when a loading UI can - // observe it. Direct mode is that case; the SharedWorker never enables it. - smoldotMod.enableHealthPolling(); + // observe it. Direct mode is that case; the SharedWorker never enables + // it, and the host only renders the Asset Hub count, so only that chain + // polls. + smoldotMod.enableHealthPolling(["asset-hub"]); // On a smoldot panic, broadcast a fatal envelope to the parent. Direct // mode has no SharedWorker in the loop, so we post straight up to the diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index e1248d09..a4fc1e25 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -13,6 +13,7 @@ import type { ManifestResult, RootManifest, } from "@dotli/resolver/manifest"; +import type { LifecycleKind } from "@dotli/resolver/smoldot"; import { BASE_DOMAIN, type SiteId } from "@dotli/config/config"; import { getActiveGatewaySupportedGenesisHashes, @@ -69,6 +70,15 @@ const lifecycleListeners = new Set< (event: ProtocolLifecycleEnvelope) => void >(); const healthListeners = new Set<(event: ProtocolHealthEnvelope) => void>(); +// Runtime mirror of the resolver's LifecycleKind union: postMessage data is +// untrusted, and the envelope type alone cannot reject a spoofed kind. The +// `satisfies` ties each literal to the union so a typo fails typecheck. +const LIFECYCLE_ENVELOPE_KINDS = new Set([ + "firstPeer", + "bootstrapComplete", + "stalled", + "recovered", +] satisfies LifecycleKind[]); let listenerBound = false; let protocolReady = false; interface ReadyWaiter { @@ -225,7 +235,7 @@ function bindMessageListener(): void { case "lifecycle": { if ( typeof msg.chain !== "string" || - typeof msg.lifecycleKind !== "string" + !LIFECYCLE_ENVELOPE_KINDS.has(msg.lifecycleKind) ) { return; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c6eecc16..cb9068e2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,6 +1,8 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only +import type { LifecycleKind } from "@dotli/resolver/smoldot"; + export interface ProtocolRequestMap { warmup: Record; resolveDotName: { label: string }; @@ -109,7 +111,7 @@ export interface ProtocolLifecycleEnvelope { namespace: "dotli:protocol"; kind: "lifecycle"; chain: string; - lifecycleKind: string; + lifecycleKind: LifecycleKind; reason?: string; } diff --git a/packages/resolver/src/smoldot-db.ts b/packages/resolver/src/smoldot-db.ts index f180543c..0d8909a4 100644 --- a/packages/resolver/src/smoldot-db.ts +++ b/packages/resolver/src/smoldot-db.ts @@ -165,12 +165,29 @@ export interface ChainDbTap { isStopped(): boolean; } +/** + * Response interceptor for traffic the resolver injects on the chain's + * JSON-RPC pipe (lifecycle follow, health polls). Called for every parsed + * response before it is forwarded to the chain's regular consumer. Return + * `true` to consume the message: polkadot-api never sees it, which keeps + * its provider free of ids and subscriptions it did not create. + */ +export type TapIntercept = (parsed: { + id?: unknown; + method?: unknown; + result?: unknown; + params?: unknown; +}) => boolean; + interface ExternalWaiter { resolve: (s: string) => void; reject: (e: unknown) => void; } -export function tapChain(chain: SmoldotChainLike): ChainDbTap { +export function tapChain( + chain: SmoldotChainLike, + intercept?: TapIntercept, +): ChainDbTap { const originalNext = chain.nextJsonRpcResponse.bind(chain); const originalRemove = chain.remove.bind(chain); @@ -216,7 +233,12 @@ export function tapChain(chain: SmoldotChainLike): ChainDbTap { return; } try { - const parsed = JSON.parse(raw) as { id?: unknown; result?: unknown }; + const parsed = JSON.parse(raw) as { + id?: unknown; + method?: unknown; + result?: unknown; + params?: unknown; + }; if ( typeof parsed.id === "string" && parsed.id.startsWith(REQUEST_ID_PREFIX) @@ -228,7 +250,10 @@ export function tapChain(chain: SmoldotChainLike): ChainDbTap { continue; } } - // eslint-disable-next-line no-restricted-syntax -- parse failures fall through to external forwarding. + if (intercept?.(parsed) === true) { + continue; + } + // eslint-disable-next-line no-restricted-syntax -- parse or interceptor failures fall through to external forwarding. } catch { /* forward as-is */ } diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index ed477402..f6dc6f3a 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -33,6 +33,7 @@ import { saveChainDb, tapChain, type ChainDbTap, + type TapIntercept, } from "./smoldot-db"; /** The smoldot Client type (shared by `start()` and `startFromWorker()`). */ @@ -175,13 +176,16 @@ function emitHealth(event: HealthEvent): void { } } -// Health polling is opt-in per process. The protocol iframe's direct mode -// enables it; the SharedWorker never does, so its long-lived smoldot does -// not poll for a UI that cannot observe it. -let healthPollingEnabled = false; +// Health polling is opt-in per process and per chain. The protocol +// iframe's direct mode enables it for the chains its loading UI actually +// reads; the SharedWorker never does, so its long-lived smoldot does not +// poll for a UI that cannot observe it. +const healthPollingChains = new Set(); -export function enableHealthPolling(): void { - healthPollingEnabled = true; +export function enableHealthPolling(chains: readonly string[]): void { + for (const chain of chains) { + healthPollingChains.add(chain); + } } // Smoldot's WASM can panic (e.g., the "Option::unwrap() on a None value" @@ -239,19 +243,16 @@ const CONNECTION_ISSUE_PATTERNS = [ ]; // Reserved id prefixes for our internal JSON-RPC requests. Chosen so they -// cannot collide with the numeric ids polkadot-api uses, and so responses -// can be recognized in the log stream. The suffix after ":" is the logical -// chain key, which is how responses are attributed to chains without -// relying on smoldot's internal chain id. +// cannot collide with the numeric ids polkadot-api uses, and so the chain +// tap can recognize and consume the responses before they reach +// polkadot-api's provider. const LIFECYCLE_FOLLOW_REQUEST_ID = "__dotli_lifecycle_follow__"; const HEALTH_REQUEST_ID = "__dotli_health__"; -// smoldot's log target is `json-rpc-`, while -// requests are attributed by our logical keys ("relay", "asset-hub", ...). -// The reply to our follow request carries the logical key in its id and -// arrives on the chain's log target, which lets us learn the mapping -// without hardcoding chain-spec ids per network. -const chainKeyByLogName = new Map(); +// Subscription id of each chain's `lifecycle_unstable_follow`, learned from +// the follow reply. Notifications carry no request id, so this is how the +// tap tells our subscription's events apart from any other traffic. +const lifecycleSubscriptions = new Map(); function smoldotLogCallback( level: number, @@ -265,22 +266,6 @@ function smoldotLogCallback( log.debug(`[smoldot:${target}] ${message}`); } - // Lifecycle and health payloads flow through the chain's JSON-RPC pipe, - // which is owned by polkadot-api. Rather than wrap the chain and race for - // messages, we observe the payloads passing through smoldot's own debug - // log stream: every response is logged on the `json-rpc-` target - // as `json-rpc-response-yielded; response=`. - // We match schemas we ourselves define (our reserved request ids and the - // followEvent notification), so this is a structured side-channel, not - // prose scraping. - if ( - target.startsWith("json-rpc-") && - (message.includes("lifecycle_unstable_followEvent") || - message.includes("__dotli_")) - ) { - handleSideChannelLine(target, message); - } - // A panic is terminal with no recovery. Smoldot's log message starts // with "Smoldot has panicked while executing task …". Surface as fatal. if ( @@ -307,58 +292,60 @@ function smoldotLogCallback( } } -function handleSideChannelLine(target: string, message: string): void { - // Smoldot emits the response JSON verbatim after a literal `response=` - // key in the log line. Request echoes use `request=` and fall out here. - const responseStart = message.indexOf("response="); - if (responseStart === -1) { - return; - } - const jsonPart = message.slice(responseStart + "response=".length); - const logName = target.slice("json-rpc-".length); - try { - const parsed = JSON.parse(jsonPart) as { - id?: string; - method?: string; - result?: unknown; - params?: { - result?: { kind?: string; reason?: string; previously?: string }; - }; - }; - if (parsed.method === "lifecycle_unstable_followEvent") { - emitLifecycleNotification(logName, parsed.params?.result); - return; - } - if (typeof parsed.id !== "string") { - return; - } - if (parsed.id.startsWith(`${LIFECYCLE_FOLLOW_REQUEST_ID}:`)) { - // Reply to our follow request: learn which logical chain owns this - // log target so notifications (which carry no id) can be attributed. - chainKeyByLogName.set( - logName, - parsed.id.slice(LIFECYCLE_FOLLOW_REQUEST_ID.length + 1), - ); - return; +/** + * Response interceptor bound to one chain. Runs inside the chain tap's + * pump, so it sees every response in order, untruncated, and can consume + * our reserved-id traffic before polkadot-api's provider does. + */ +function makeSideChannelIntercept(chainName: string): TapIntercept { + return (parsed) => { + if (typeof parsed.id === "string") { + if (parsed.id.startsWith(`${LIFECYCLE_FOLLOW_REQUEST_ID}:`)) { + // Reply to our follow request: remember the subscription id so + // notifications (which carry no request id) can be matched below. + if (typeof parsed.result === "string") { + lifecycleSubscriptions.set(chainName, parsed.result); + } + return true; + } + if (parsed.id.startsWith(`${HEALTH_REQUEST_ID}:`)) { + handleHealthResponse(chainName, parsed.result); + return true; + } + return false; } - if (parsed.id.startsWith(`${HEALTH_REQUEST_ID}:`)) { - handleHealthResponse(parsed.id, parsed.result); + if (parsed.method === "lifecycle_unstable_followEvent") { + const params = parsed.params as + | { + subscription?: unknown; + result?: { kind?: string; reason?: string; previously?: string }; + } + | undefined; + // The follow reply always precedes its notifications, so an unknown + // subscription id means the event belongs to someone else: forward it. + const subscription = lifecycleSubscriptions.get(chainName); + if ( + params === undefined || + subscription === undefined || + params.subscription !== subscription + ) { + return false; + } + emitLifecycleNotification(chainName, params.result); + return true; } - // eslint-disable-next-line no-restricted-syntax -- best-effort parse of a smoldot debug log line: non-JSON or truncated payloads are expected and must not spam log.error. - } catch { - /* malformed or truncated response payload, skip */ - } + return false; + }; } function emitLifecycleNotification( - logName: string, + chain: string, result: { kind?: string; reason?: string; previously?: string } | undefined, ): void { const kind = result?.kind; if (kind === undefined || !LIFECYCLE_KINDS.has(kind)) { return; } - const chain = chainKeyByLogName.get(logName) ?? logName; if (kind === "bootstrapComplete") { // The chain is usable; peer-count polling has served its purpose. healthPollers.get(chain)?.stop(); @@ -376,12 +363,8 @@ function emitLifecycleNotification( }); } -function handleHealthResponse(id: string, result: unknown): void { - const rest = id.slice(HEALTH_REQUEST_ID.length + 1); - const chain = rest.slice(0, rest.lastIndexOf(":")); - if (chain === "") { - return; - } +function handleHealthResponse(chain: string, result: unknown): void { + healthResponseSeen = true; healthPollers.get(chain)?.noteResponse(); const health = result as { peers?: unknown; isSyncing?: unknown } | null; if ( @@ -493,14 +476,13 @@ function attachPersistence( underlying: SmoldotChain, ): SmoldotChain { teardownPersistence(chainName); - const tap = tapChain(underlying); + // The tap intercepts our reserved-id traffic (lifecycle follow, health + // polls) in-band and consumes it, so polkadot-api's provider only ever + // sees its own requests and subscriptions. + const tap = tapChain(underlying, makeSideChannelIntercept(chainName)); schedulePersistence(chainName, tap); - // Fire the lifecycle subscription without wrapping the chain object. - // Polkadot-api consumes `jsonRpcResponses` iteratively and interposing on - // that path is racy. Notifications are read back from smoldot's own - // json-rpc log stream instead (see handleSideChannelLine). attachLifecycleFollow(chainName, tap.chain); - if (healthPollingEnabled) { + if (healthPollingChains.has(chainName)) { const entry = persistence.get(chainName); if (entry !== undefined) { entry.healthPoller = startHealthPolling(chainName, tap); @@ -598,16 +580,16 @@ function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { }; const noteResponse = (): void => { - healthResponseSeen = true; if (stopped) { return; } schedule(HEALTH_POLL_INTERVAL_MS); }; - // The side-channel depends on smoldot's debug log format staying stable. - // If it breaks, lifecycle and health both go silently dead, so surface - // one warning per session when the first poll gets no observable reply. + // The side-channel depends on smoldot answering our reserved-id + // requests. If a smoldot bump breaks that, lifecycle and health both go + // silently dead, so surface one warning per session when the first poll + // gets no observable reply. if (!healthWatchdogArmed) { healthWatchdogArmed = true; const watchdog = setTimeout(() => { @@ -641,9 +623,6 @@ export function getSmoldotDirect(): SmoldotClient { } log.warn("[dot.li smoldot] Creating smoldot via start() (current thread)"); smoldotInstance = startSmoldotDirect({ - // Lifecycle detection reads JSON-RPC payloads out of the debug log - // stream (parseAndEmitLifecycle). Lowering this level silently - // disables lifecycle events. maxLogLevel: 5, logCallback: smoldotLogCallback, // Smoldot's own auto-detection (no-auto-bytecode-browser.js) is buggy @@ -670,9 +649,6 @@ export function getSmoldot(): SmoldotClient { } log.warn("[dot.li smoldot] Creating smoldot via startFromWorker()"); smoldotInstance = startFromWorker(new SmWorker(), { - // Lifecycle detection reads JSON-RPC payloads out of the debug log - // stream (parseAndEmitLifecycle). Lowering this level silently - // disables lifecycle events. maxLogLevel: 5, logCallback: smoldotLogCallback, forbidNonLocalWs: true, @@ -703,10 +679,10 @@ export function terminateSmoldot(): void { /* already destroyed or crashed, safe to ignore */ } teardownAllPersistence(); - // The next smoldot instance re-learns chain identities and re-emits its - // own lifecycle; stale mappings or replayed events from the dead session - // would mislabel or suppress the new one's signals. - chainKeyByLogName.clear(); + // The next smoldot instance re-subscribes and re-emits its own + // lifecycle; stale subscription ids or replayed events from the dead + // session would mislabel or suppress the new one's signals. + lifecycleSubscriptions.clear(); lifecycleHistory.clear(); lastHealth.clear(); smoldotInstance = null; diff --git a/packages/resolver/tests/smoldot.test.ts b/packages/resolver/tests/smoldot.test.ts index b666419e..582edded 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -3,30 +3,59 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -vi.mock("polkadot-api/smoldot", () => ({ - start: vi.fn(() => ({ - addChain: vi.fn().mockResolvedValue({ +// Queue-backed chain mocks: tests push raw JSON-RPC responses and the +// chain tap's pump consumes them exactly as it does smoldot's output. +const chainMocks = vi.hoisted(() => { + interface MockChain { + sendJsonRpc: ReturnType; + nextJsonRpcResponse: () => Promise; + jsonRpcResponses: AsyncGenerator; + remove: ReturnType; + push(raw: string): void; + } + const chains: MockChain[] = []; + function makeChain(): MockChain { + const queue: string[] = []; + const waiters: ((s: string) => void)[] = []; + const chain: MockChain = { sendJsonRpc: vi.fn(), - nextJsonRpcResponse: vi.fn(), + nextJsonRpcResponse: () => + new Promise((resolve) => { + const next = queue.shift(); + if (next !== undefined) { + resolve(next); + } else { + waiters.push(resolve); + } + }), jsonRpcResponses: (async function* () {})(), remove: vi.fn(), - }), + push(raw: string) { + const waiter = waiters.shift(); + if (waiter !== undefined) { + waiter(raw); + } else { + queue.push(raw); + } + }, + }; + chains.push(chain); + return chain; + } + return { chains, makeChain }; +}); + +vi.mock("polkadot-api/smoldot", () => ({ + start: vi.fn(() => ({ + addChain: vi.fn(() => Promise.resolve(chainMocks.makeChain())), })), })); -vi.mock("polkadot-api/smoldot/from-worker", () => { - const mockAddChain = vi.fn().mockResolvedValue({ - sendJsonRpc: vi.fn(), - nextJsonRpcResponse: vi.fn(), - jsonRpcResponses: (async function* () {})(), - remove: vi.fn(), - }); - return { - startFromWorker: vi.fn(() => ({ - addChain: mockAddChain, - })), - }; -}); +vi.mock("polkadot-api/smoldot/from-worker", () => ({ + startFromWorker: vi.fn(() => ({ + addChain: vi.fn(() => Promise.resolve(chainMocks.makeChain())), + })), +})); vi.mock("polkadot-api/smoldot/worker?worker", () => { return { default: class MockWorker {} }; @@ -54,9 +83,8 @@ let onHealth: typeof import("@dotli/resolver/smoldot").onHealth; let enableHealthPolling: typeof import("@dotli/resolver/smoldot").enableHealthPolling; beforeEach(async () => { - // The mocked chain object is shared by the module factories above, so its - // call history would otherwise leak across tests. vi.clearAllMocks(); + chainMocks.chains.length = 0; vi.resetModules(); const mod = await import("@dotli/resolver/smoldot"); getSmoldot = mod.getSmoldot; @@ -66,6 +94,56 @@ beforeEach(async () => { enableHealthPolling = mod.enableHealthPolling; }); +// Let the tap's pump loop drain everything pushed so far. +async function flush(): Promise { + for (let i = 0; i < 25; i++) { + await Promise.resolve(); + } +} + +async function relayMock(): Promise< + (typeof chainMocks.chains)[number] & object +> { + await getRelayChain(); + const chain = chainMocks.chains.at(-1); + if (chain === undefined) { + throw new Error("relay chain mock was not created"); + } + return chain; +} + +const FOLLOW_REPLY = JSON.stringify({ + jsonrpc: "2.0", + id: "__dotli_lifecycle_follow__:relay", + result: "sub-1", +}); + +function followEvent( + subscription: string, + kind: string, + extra: Record = {}, +): string { + return JSON.stringify({ + jsonrpc: "2.0", + method: "lifecycle_unstable_followEvent", + params: { subscription, result: { kind, ...extra } }, + }); +} + +function healthReply(seq: number, peers: number, isSyncing = true): string { + return JSON.stringify({ + jsonrpc: "2.0", + id: `__dotli_health__:relay:${String(seq)}`, + result: { isSyncing, peers, shouldHavePeers: true }, + }); +} + +function healthCalls(chain: { sendJsonRpc: ReturnType }) { + return chain.sendJsonRpc.mock.calls + .map((call) => call[0] as string) + .filter((raw) => raw.includes("system_health")); +} + describe("getSmoldot", () => { it("returns the same instance on repeated calls", () => { const a = getSmoldot(); @@ -93,104 +171,71 @@ describe("getRelayChain", () => { }); }); -// Grab the logCallback the module hands to smoldot so tests can feed it -// synthetic json-rpc log lines. -async function captureLogCallback(): Promise< - (level: number, target: string, message: string) => void -> { - getSmoldot(); - const { startFromWorker } = await import("polkadot-api/smoldot/from-worker"); - const options = vi.mocked(startFromWorker).mock.lastCall?.[1]; - if (options?.logCallback === undefined) { - throw new Error("logCallback was not passed to startFromWorker"); - } - return options.logCallback; -} - -// Production line shape: smoldot logs every response on the chain's -// `json-rpc-` target as `json-rpc-response-yielded; response=`. -function responseLine(payload: unknown): string { - return `json-rpc-response-yielded; response=${JSON.stringify(payload)}`; -} - -function followEventLine( - kind: string, - extra: Record = {}, -): string { - return responseLine({ - jsonrpc: "2.0", - method: "lifecycle_unstable_followEvent", - params: { subscription: "lf-1", result: { kind, ...extra } }, - }); -} - -function followReplyLine(logicalKey: string): string { - return responseLine({ - jsonrpc: "2.0", - id: `__dotli_lifecycle_follow__:${logicalKey}`, - result: "lf-1", - }); -} - -function healthLine( - logicalKey: string, - seq: number, - peers: number, - isSyncing = true, -): string { - return responseLine({ - jsonrpc: "2.0", - id: `__dotli_health__:${logicalKey}:${String(seq)}`, - result: { isSyncing, peers, shouldHavePeers: true }, - }); -} - describe("onLifecycle", () => { - it("delivers events keyed by the logical chain once the follow reply is seen", async () => { - const logCallback = await captureLogCallback(); + it("delivers events for the chain once the follow reply is seen", async () => { + const chain = await relayMock(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); - logCallback(4, "json-rpc-asset-hub-paseo", followReplyLine("asset-hub")); - logCallback(4, "json-rpc-asset-hub-paseo", followEventLine("firstPeer")); - logCallback( - 4, - "json-rpc-asset-hub-paseo", - followEventLine("bootstrapComplete"), - ); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "firstPeer")); + chain.push(followEvent("sub-1", "bootstrapComplete")); + await flush(); expect(events).toEqual([ - { chain: "asset-hub", kind: "firstPeer" }, - { chain: "asset-hub", kind: "bootstrapComplete" }, + { chain: "relay", kind: "firstPeer" }, + { chain: "relay", kind: "bootstrapComplete" }, ]); }); - it("falls back to the log target name when no follow reply mapped it", async () => { - const logCallback = await captureLogCallback(); + it("forwards notifications from unknown subscriptions to the chain consumer", async () => { + const tapped = await getRelayChain(); + const chain = chainMocks.chains.at(-1); + if (chain === undefined) { + throw new Error("relay chain mock was not created"); + } const events: unknown[] = []; onLifecycle((event) => events.push(event)); - logCallback(4, "json-rpc-asset-hub-paseo", followEventLine("firstPeer")); + chain.push(FOLLOW_REPLY); + const foreign = followEvent("someone-elses-sub", "firstPeer"); + chain.push(foreign); + await flush(); - expect(events).toEqual([{ chain: "asset-hub-paseo", kind: "firstPeer" }]); + expect(events).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(foreign); + }); + + it("consumes our traffic so the chain consumer never sees it", async () => { + const tapped = await getRelayChain(); + const chain = chainMocks.chains.at(-1); + if (chain === undefined) { + throw new Error("relay chain mock was not created"); + } + + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "firstPeer")); + const papiResponse = JSON.stringify({ + jsonrpc: "2.0", + id: "1-42", + result: "0x00", + }); + chain.push(papiResponse); + await flush(); + + // Only the polkadot-api response comes through; ours were consumed. + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(papiResponse); }); it("carries the stall reason and the recovery cause", async () => { - const logCallback = await captureLogCallback(); + const chain = await relayMock(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); - logCallback(4, "json-rpc-paseo", followReplyLine("relay")); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("stalled", { reason: "noPeers" }), - ); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("recovered", { previously: "noPeers" }), - ); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); + chain.push(followEvent("sub-1", "recovered", { previously: "noPeers" })); + await flush(); expect(events).toEqual([ { chain: "relay", kind: "stalled", reason: "noPeers" }, @@ -199,19 +244,12 @@ describe("onLifecycle", () => { }); it("replays only the latest event per chain and kind to a late subscriber", async () => { - const logCallback = await captureLogCallback(); - logCallback(4, "json-rpc-paseo", followReplyLine("relay")); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("stalled", { reason: "noPeers" }), - ); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("stalled", { reason: "syncNoProgress" }), - ); - logCallback(4, "json-rpc-paseo", followEventLine("firstPeer")); + const chain = await relayMock(); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); + chain.push(followEvent("sub-1", "stalled", { reason: "syncNoProgress" })); + chain.push(followEvent("sub-1", "firstPeer")); + await flush(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); @@ -223,18 +261,11 @@ describe("onLifecycle", () => { }); it("replays only the newer half of a stall and recovery pair", async () => { - const logCallback = await captureLogCallback(); - logCallback(4, "json-rpc-paseo", followReplyLine("relay")); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("stalled", { reason: "noPeers" }), - ); - logCallback( - 4, - "json-rpc-paseo", - followEventLine("recovered", { previously: "noPeers" }), - ); + const chain = await relayMock(); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); + chain.push(followEvent("sub-1", "recovered", { previously: "noPeers" })); + await flush(); const events: unknown[] = []; onLifecycle((event) => events.push(event)); @@ -244,55 +275,44 @@ describe("onLifecycle", () => { ]); }); - it("ignores malformed, unknown-kind, and non-lifecycle payloads", async () => { - const logCallback = await captureLogCallback(); + it("ignores kinds outside the allowlist but still consumes them", async () => { + const tapped = await getRelayChain(); + const chain = chainMocks.chains.at(-1); + if (chain === undefined) { + throw new Error("relay chain mock was not created"); + } const events: unknown[] = []; onLifecycle((event) => events.push(event)); - // Mentions the method but carries no response payload. - logCallback(4, "json-rpc-paseo", "sent lifecycle_unstable_followEvent"); - // Request echo, not a response. - logCallback( - 4, - "json-rpc-paseo", - 'json-rpc-request-queued; request={"method":"lifecycle_unstable_followEvent"}', - ); - // Truncated JSON (smoldot caps the logged payload at 250 chars). - logCallback( - 4, - "json-rpc-paseo", - 'json-rpc-response-yielded; response={"method":"lifecycle_unstable_followEvent"', - ); - // A different method that happens to mention the string. - logCallback( - 4, - "json-rpc-paseo", - followEventLine("firstPeer").replace("followEvent", "followEvent_other"), - ); - // A kind outside the allowlist (smoldot emits more kinds than we consume). - logCallback(4, "json-rpc-paseo", followEventLine("warpSyncProgress")); - // Right payload on a non json-rpc target. - logCallback(4, "sync-service-paseo", followEventLine("firstPeer")); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "warpSyncProgress", { at: 5, target: 9 })); + const papiResponse = JSON.stringify({ jsonrpc: "2.0", id: "1-1" }); + chain.push(papiResponse); + await flush(); expect(events).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(papiResponse); }); it("stops delivering after unsubscribe", async () => { - const logCallback = await captureLogCallback(); + const chain = await relayMock(); const events: unknown[] = []; const unsubscribe = onLifecycle((event) => events.push(event)); - logCallback(4, "json-rpc-paseo", followEventLine("firstPeer")); + chain.push(FOLLOW_REPLY); + chain.push(followEvent("sub-1", "firstPeer")); + await flush(); unsubscribe(); - logCallback(4, "json-rpc-paseo", followEventLine("bootstrapComplete")); + chain.push(followEvent("sub-1", "bootstrapComplete")); + await flush(); - expect(events).toEqual([{ chain: "paseo", kind: "firstPeer" }]); + expect(events).toEqual([{ chain: "relay", kind: "firstPeer" }]); }); }); describe("onHealth", () => { // Fake timers keep leaked poller resends (2s timeout) from bleeding - // between tests through the shared mock chain. + // between tests through the mock chains. beforeEach(() => { vi.useFakeTimers(); }); @@ -300,51 +320,43 @@ describe("onHealth", () => { vi.useRealTimers(); }); - async function relaySendJsonRpc(): Promise> { - const { startFromWorker } = - await import("polkadot-api/smoldot/from-worker"); - const client = vi.mocked(startFromWorker).mock.results.at(-1)?.value as { - addChain: ReturnType; - }; - const chain = (await client.addChain.mock.results.at(-1)?.value) as { - sendJsonRpc: ReturnType; - }; - return chain.sendJsonRpc; - } - - function healthCalls(sendJsonRpc: ReturnType): string[] { - return sendJsonRpc.mock.calls - .map((call) => call[0] as string) - .filter((raw) => raw.includes("system_health")); - } - it("polls system_health immediately and emits the parsed sample", async () => { - enableHealthPolling(); - const logCallback = await captureLogCallback(); - await getRelayChain(); - const sendJsonRpc = await relaySendJsonRpc(); + enableHealthPolling(["relay"]); + const chain = await relayMock(); - const sent = healthCalls(sendJsonRpc); + const sent = healthCalls(chain); expect(sent.length).toBeGreaterThanOrEqual(1); expect(sent[0]).toContain('"id":"__dotli_health__:relay:1"'); const events: unknown[] = []; onHealth((event) => events.push(event)); - logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 3)); + chain.push(healthReply(1, 3)); + await flush(); expect(events).toEqual([{ chain: "relay", peers: 3, isSyncing: true }]); }); + it("does not poll chains outside the allowlist", async () => { + enableHealthPolling(["asset-hub"]); + const chain = await relayMock(); + expect(healthCalls(chain)).toEqual([]); + }); + + it("does not poll when polling was never enabled", async () => { + const chain = await relayMock(); + expect(healthCalls(chain)).toEqual([]); + }); + it("emits only when the sample changes", async () => { - enableHealthPolling(); - const logCallback = await captureLogCallback(); - await getRelayChain(); + enableHealthPolling(["relay"]); + const chain = await relayMock(); const events: unknown[] = []; onHealth((event) => events.push(event)); - logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 2)); - logCallback(4, "json-rpc-paseo", healthLine("relay", 2, 2)); - logCallback(4, "json-rpc-paseo", healthLine("relay", 3, 5)); + chain.push(healthReply(1, 2)); + chain.push(healthReply(2, 2)); + chain.push(healthReply(3, 5)); + await flush(); expect(events).toEqual([ { chain: "relay", peers: 2, isSyncing: true }, @@ -353,11 +365,11 @@ describe("onHealth", () => { }); it("replays the last sample to a late subscriber", async () => { - enableHealthPolling(); - const logCallback = await captureLogCallback(); - await getRelayChain(); + enableHealthPolling(["relay"]); + const chain = await relayMock(); - logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 4)); + chain.push(healthReply(1, 4)); + await flush(); const events: unknown[] = []; onHealth((event) => events.push(event)); @@ -365,55 +377,42 @@ describe("onHealth", () => { }); it("stops polling once the chain bootstrap completes", async () => { - enableHealthPolling(); - const logCallback = await captureLogCallback(); - await getRelayChain(); - const sendJsonRpc = await relaySendJsonRpc(); - - // Map the log target to the logical key, then finish bootstrap. - logCallback(4, "json-rpc-paseo", followReplyLine("relay")); - logCallback(4, "json-rpc-paseo", healthLine("relay", 1, 3)); - logCallback(4, "json-rpc-paseo", followEventLine("bootstrapComplete")); - - const sentBefore = healthCalls(sendJsonRpc).length; - vi.advanceTimersByTime(30_000); - expect(healthCalls(sendJsonRpc).length).toBe(sentBefore); - }); + enableHealthPolling(["relay"]); + const chain = await relayMock(); - it("does not poll when polling was never enabled", async () => { - await captureLogCallback(); - await getRelayChain(); - const sendJsonRpc = await relaySendJsonRpc(); - expect(healthCalls(sendJsonRpc)).toEqual([]); + chain.push(FOLLOW_REPLY); + chain.push(healthReply(1, 3)); + chain.push(followEvent("sub-1", "bootstrapComplete")); + await flush(); + + const sentBefore = healthCalls(chain).length; + await vi.advanceTimersByTimeAsync(30_000); + expect(healthCalls(chain).length).toBe(sentBefore); }); it("rejects malformed health payloads", async () => { - enableHealthPolling(); - const logCallback = await captureLogCallback(); - await getRelayChain(); + enableHealthPolling(["relay"]); + const chain = await relayMock(); const events: unknown[] = []; onHealth((event) => events.push(event)); // peers is not an integer - logCallback( - 4, - "json-rpc-paseo", - responseLine({ + chain.push( + JSON.stringify({ jsonrpc: "2.0", id: "__dotli_health__:relay:1", result: { isSyncing: true, peers: "3" }, }), ); - // result missing (error response) - logCallback( - 4, - "json-rpc-paseo", - responseLine({ + // error response, no result + chain.push( + JSON.stringify({ jsonrpc: "2.0", id: "__dotli_health__:relay:2", error: { code: -32000, message: "nope" }, }), ); + await flush(); expect(events).toEqual([]); }); diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index 87eeeec5..4d590184 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -172,6 +172,17 @@ body { text-align: center; min-height: 1.3em; } +/* Screen-reader-only slot for one-shot loading announcements (stall and + recovery copy). The same text shows visually in .loading-detail, which + is aria-hidden to keep its per-second count out of the live queue. */ +.loading-announcer { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} /* Slow-step hint (shown below status when a step exceeds its threshold) */ .loading-hint { font-size: 11px; diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index f68dba1c..7b3f585b 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -139,18 +139,39 @@ export function advancePhase(index: number): void { armSlowHint(label); } +/** + * Current phase index, -1 before the first `advancePhase`. Lets callers + * suppress signals that describe a phase the bar has already passed. + */ +export function getCurrentPhase(): number { + return currentPhase; +} + /** * Live detail line under the status headline (e.g. "3 peers"). Deliberately * separate from `#status`: this value changes every second or two during * sync, and `#status` is aria-live, so routing it there would queue a * screen-reader announcement per change. The detail element is aria-hidden * and never touches the slow-hint timer. + * + * `announce` mirrors the text once into the visually hidden polite region, + * for state changes a screen-reader user should hear (stall and recovery + * copy) without the per-second count spam. */ -export function setStatusDetail(detail: string): void { +export function setStatusDetail( + detail: string, + opts: { announce?: boolean } = {}, +): void { const el = document.getElementById("loading-detail"); if (el !== null) { el.textContent = detail; } + if (opts.announce === true && detail !== "") { + const announcer = document.getElementById("loading-announcer"); + if (announcer !== null && announcer.textContent !== detail) { + announcer.textContent = detail; + } + } } export const GATEWAY_ESCAPE_DELAY_MS = 10_000; From bdbdeea8833b830086ba01e303cb8e6ae93408f3 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 16:23:37 +0100 Subject: [PATCH 06/24] Merge the lifecycle and health channels into one chain-sync stream and report only on the chains the loading screen shows --- apps/host/index.html | 673 +++++++----------- apps/host/src/main.ts | 133 ++-- apps/host/tests/functional/resolution.spec.ts | 13 +- apps/protocol/src/main.ts | 51 +- packages/protocol/src/client.ts | 125 ++-- packages/protocol/src/messages.ts | 39 +- packages/resolver/src/resolve.ts | 14 +- packages/resolver/src/smoldot-db.ts | 28 +- packages/resolver/src/smoldot.ts | 336 ++++----- packages/resolver/tests/smoldot.test.ts | 454 ++++++------ packages/ui/src/styles/base.css | 18 +- packages/ui/src/ui.ts | 57 +- packages/ui/tests/ui.test.ts | 83 --- 13 files changed, 880 insertions(+), 1144 deletions(-) delete mode 100644 packages/ui/tests/ui.test.ts diff --git a/apps/host/index.html b/apps/host/index.html index 229f9d7b..559eab0c 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -3,433 +3,270 @@ - - - - Polkadot - The decentralized web, in your browser - - - - - - - - - - - - - - - - - -
- - - Polkadot - Beta - -
-
- - - - -
- - - + + + + + + + + + + + + +
+ + + Polkadot + Beta + +
+
+ + + + +
+ + + +
+ +
- -
-
- -
-
-

Login with Polkadot Mobile

- -

Scan with Polkadot Mobile to connect

-
-
+ +
+
+

Login with Polkadot Mobile

+ +

Scan with Polkadot Mobile to connect

+
+
+
+ +
- -
-
- -
-
-
Welcome back
-
-
-
- -
+ +
+
+
Welcome back
+
+
+
+ +
- -
-
-
-
+
+
+
+
- -
-
-
Permissions
-
-
- - -
-
- -
-
-
-
- 0% +
+
+
Permissions
+
-
-

Reaching out

- -

-

+ + +
+
+ +
+
+
+
+ 0% +
+
+

Reaching out

+ +

+

+
+
-
-
- - - + + + diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 05ff6cae..a605be4b 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -31,7 +31,6 @@ import { showLanding, initPhases, advancePhase, - getCurrentPhase, setStatusDetail, stopStatusTick, listenForSandboxStatus, @@ -46,8 +45,7 @@ import { } from "@dotli/ui/bulletin-bitswap"; import { ensureProtocolFrame, - onProtocolHealth, - onProtocolLifecycle, + onProtocolChainSync, resetProtocolFrame, resolveDotNameRemote, resolveExecutableManifestRemote, @@ -71,6 +69,7 @@ import type { ManifestResult, RootManifest, } from "@dotli/resolver/manifest"; +import type { ResolvePhase } from "@dotli/resolver/access-raw-storage"; import { BASE_DOMAIN, DEBUG, SITE_ID, isLocalhost } from "@dotli/config/config"; import { log } from "@dotli/shared/log"; import { serializeError } from "@dotli/shared/errors"; @@ -1158,6 +1157,16 @@ async function main(): Promise { // peers) cap at the band top and let the sheen carry motion rather than // inflating the pace. Both smoldot backends share one model. See // `advancePhase` mapping below. + // Resolver status strings map onto the smoldot phase bands above. + // `asset-hub-connecting` is ~0ms (just createClient), so it shares the + // Syncing band rather than taking a slice that moves the bar for no work. + const PHASE_INDEX: Partial> = { + "relay-chain-adding": 1, + "asset-hub-connecting": 2, + "asset-hub-syncing": 2, + "asset-hub-ready": 2, + "resolving-content": 3, + }; const smoldotPhases = (startLabel: string): LoadingPhase[] => [ { label: startLabel, base: 2, target: 6, expectedMs: 650 }, { label: "Adding relay chain", base: 6, target: 10, expectedMs: 120 }, @@ -1199,58 +1208,54 @@ async function main(): Promise { // lives in the SharedWorker, which does not forward lifecycle or health // yet, and the gateway has no smoldot at all. if (chainBackend === "smoldot-direct") { - // Only the chains on the resolution critical path may drive the UI. - // Bulletin, People, and the custom relay sync in the background; a - // stall there is not what the user is waiting on. - const uiChains = new Set(["relay", "asset-hub"]); let peerDetail = ""; - // Once the Asset Hub bootstrap completes, the sync detail is over: - // an in-flight health response or a late background stall must not + // Once the Asset Hub bootstrap completes the sync detail is over. A + // peer count still in flight, or a late stall on the relay, must not // resurrect it under "Resolving". let syncDetailDone = false; - onProtocolHealth((event) => { - if (syncDetailDone || event.chain !== "asset-hub") { - return; - } - peerDetail = `${String(event.peers)} ${event.peers === 1 ? "peer" : "peers"}`; - setStatusDetail(peerDetail); - }); - onProtocolLifecycle((event) => { - log.debug( - `[dot.li lifecycle] ${event.chain} kind=${event.lifecycleKind}`, - ); - if (!uiChains.has(event.chain)) { + onProtocolChainSync((event) => { + log.debug(`[dot.li sync] ${event.chain} ${event.syncKind}`); + if (syncDetailDone) { return; } - if (event.lifecycleKind === "stalled" && !syncDetailDone) { - // No trailing ellipsis: the headline already ends in one, and the - // spinner and bar sheen carry "in progress". Lowercase and - // terminal-punctuation-free to match the "2 peers" readout this line - // alternates with. - setStatusDetail( - event.reason === "noPeers" - ? "searching for peers" - : "syncing, no progress yet", - { announce: true }, - ); - return; - } - if (event.lifecycleKind === "recovered" && !syncDetailDone) { - setStatusDetail(peerDetail, { announce: true }); - return; - } - if (event.chain !== "asset-hub") { - return; - } - if (event.lifecycleKind === "firstPeer") { - advancePhase(2); - } else if (event.lifecycleKind === "bootstrapComplete") { - advancePhase(3); - // The count is meaningless once sync is done; clear rather than - // letting a stale number sit under "Resolving". - syncDetailDone = true; - peerDetail = ""; - setStatusDetail(""); + switch (event.syncKind) { + case "peers": + if (event.chain === "asset-hub") { + const count = event.peers ?? 0; + peerDetail = `${String(count)} ${count === 1 ? "peer" : "peers"}`; + setStatusDetail(peerDetail); + } + return; + case "stalled": + // No trailing ellipsis: the headline already ends in one, and the + // spinner and bar sheen carry "in progress". Lowercase and + // terminal-punctuation-free to match the "2 peers" readout this + // line alternates with. + setStatusDetail( + event.reason === "noPeers" + ? "searching for peers" + : "syncing, no progress yet", + { announce: true }, + ); + return; + case "recovered": + setStatusDetail(peerDetail, { announce: true }); + return; + case "firstPeer": + if (event.chain === "asset-hub") { + advancePhase(2); + } + return; + case "bootstrapComplete": + if (event.chain === "asset-hub") { + advancePhase(3); + // The count is meaningless once sync is done. Clear it rather + // than letting a stale number sit under "Resolving". + syncDetailDone = true; + peerDetail = ""; + setStatusDetail(""); + } + return; } }); } @@ -1372,30 +1377,16 @@ async function main(): Promise { // mapping from status text to ResolvePhase, so we defer to it // instead of maintaining a parallel regex here. const phase = statusToPhase(msg); - // `asset-hub-connecting` is ~0ms (just createClient), so it shares - // the Syncing band rather than getting a slice that makes the bar - // jump for no work. - const mappedPhase = - phase === "relay-chain-adding" - ? 1 - : phase === "asset-hub-connecting" || - phase === "asset-hub-syncing" || - phase === "asset-hub-ready" - ? 2 - : phase === "resolving-content" - ? 3 - : null; - if (mappedPhase !== null) { + const mappedPhase = phase === null ? undefined : PHASE_INDEX[phase]; + if (mappedPhase !== undefined) { advancePhase(mappedPhase); } emitPhase(msg, phase ?? "progress"); - // Lifecycle events usually outrun these status strings. Prose - // describing a phase the bar has already passed must not flip - // the headline backwards; unmapped messages (bootnode issues, - // not-found notices) always show. - if (mappedPhase === null || mappedPhase >= getCurrentPhase()) { - showStatus(msg); - } + // Sync milestones usually outrun these status strings, so pass + // the phase and let `showStatus` drop prose describing a step the + // bar already passed. Unmapped messages such as bootnode issues + // and not-found notices carry no phase and always show. + showStatus(msg, { phase: mappedPhase }); }; cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); if (cid === null) { diff --git a/apps/host/tests/functional/resolution.spec.ts b/apps/host/tests/functional/resolution.spec.ts index e6ec3d06..d80eb991 100644 --- a/apps/host/tests/functional/resolution.spec.ts +++ b/apps/host/tests/functional/resolution.spec.ts @@ -37,7 +37,7 @@ test.describe("Resolution across chain backends", () => { }); } - test(`As a user opening ${DOMAIN}.dot via smoldot-direct, the shell receives live peer counts while syncing`, async ({ + test(`As a user opening ${DOMAIN}.dot, I am told how many peers the light client found while it syncs`, async ({ browser, }) => { // Given @@ -46,10 +46,10 @@ test.describe("Resolution across chain backends", () => { }); try { - // Record health envelopes as they reach the host window. On a fast - // bootstrap the rendered "N peers" line can appear and clear between - // polls, so the envelope is the reliable signal; the visible text is - // accepted as an alternative when the sync window is long enough. + // Record the counts as they reach the shell. On a fast bootstrap the + // rendered "N peers" line can appear and clear between polls, so the + // count itself is the reliable signal. The visible text is accepted + // as an alternative when the sync window is long enough to show it. await page.addInitScript(() => { const seen: unknown[] = []; ( @@ -76,8 +76,7 @@ test.describe("Resolution across chain backends", () => { // When await page.goto(BASE_URL, { waitUntil: "commit" }); - // Then: a live peer count flows to the shell during bootstrap, and - // resolution still completes. + // Then const sawPeers = page.waitForFunction( () => { const seen = (window as unknown as { __dotliHealthSeen?: unknown[] }) diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index cdd5c60d..72e2b4e0 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -683,14 +683,16 @@ async function initDirectMode(): Promise { setResolverPeopleProvider, waitForPeopleFinalized, } = resolve; - const { terminateSmoldot, onSmoldotFatal, onLifecycle, onHealth } = - smoldotMod; - - // Peer-count polling is only worth the traffic when a loading UI can - // observe it. Direct mode is that case; the SharedWorker never enables - // it, and the host only renders the Asset Hub count, so only that chain - // polls. - smoldotMod.enableHealthPolling(["asset-hub"]); + const { terminateSmoldot, onSmoldotFatal, onChainSync } = smoldotMod; + + // Sync reporting is only worth its cost when a loading UI can observe it. + // Direct mode is that case and the SharedWorker never enables it. The + // host moves the bar on the relay and the Asset Hub, and shows a peer + // count for the Asset Hub alone. + smoldotMod.enableSyncReporting({ + milestones: ["relay", "asset-hub"], + peerCounts: ["asset-hub"], + }); // On a smoldot panic, broadcast a fatal envelope to the parent. Direct // mode has no SharedWorker in the loop, so we post straight up to the @@ -709,36 +711,21 @@ async function initDirectMode(): Promise { } }); - // Forward smoldot lifecycle events and health samples to the host shell - // so the loading bar can advance on real sync signals instead of on - // log-scraped prose. This iframe owns the smoldot instance. The host has - // no direct handle on it. - onLifecycle((event) => { - if (window.parent === window) { - return; - } - window.parent.postMessage( - { - namespace: "dotli:protocol", - kind: "lifecycle", - chain: event.chain, - lifecycleKind: event.kind, - ...(event.reason !== undefined ? { reason: event.reason } : {}), - }, - "*", - ); - }); - onHealth((event) => { + // Forward what the chains report about their sync to the host shell, so + // the loading screen moves on real signals instead of log-scraped prose. + // This iframe owns the smoldot instance. The host has no handle on it. + onChainSync((event) => { if (window.parent === window) { return; } + const { chain, kind, ...rest } = event; window.parent.postMessage( { namespace: "dotli:protocol", - kind: "health", - chain: event.chain, - peers: event.peers, - isSyncing: event.isSyncing, + kind: "chain-sync", + chain, + syncKind: kind, + ...rest, }, "*", ); diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index a4fc1e25..baba2e4c 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -13,7 +13,7 @@ import type { ManifestResult, RootManifest, } from "@dotli/resolver/manifest"; -import type { LifecycleKind } from "@dotli/resolver/smoldot"; +import type { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; import { BASE_DOMAIN, type SiteId } from "@dotli/config/config"; import { getActiveGatewaySupportedGenesisHashes, @@ -26,8 +26,7 @@ import { m } from "@dotli/metrics/metrics"; import * as S from "@dotli/metrics/spans"; import { isProtocolEnvelope, - type ProtocolHealthEnvelope, - type ProtocolLifecycleEnvelope, + type ProtocolChainSyncEnvelope, type ProtocolRequestEnvelope, type ProtocolRequestMap, type ProtocolRequestMethod, @@ -66,19 +65,29 @@ let protocolReadyPromise: Promise | null = null; const pendingRequests = new Map(); const chainConnections = new Map(); const sharedAuthListeners = new Set(); -const lifecycleListeners = new Set< - (event: ProtocolLifecycleEnvelope) => void +const chainSyncListeners = new Set< + (event: ProtocolChainSyncEnvelope) => void >(); -const healthListeners = new Set<(event: ProtocolHealthEnvelope) => void>(); -// Runtime mirror of the resolver's LifecycleKind union: postMessage data is -// untrusted, and the envelope type alone cannot reject a spoofed kind. The -// `satisfies` ties each literal to the union so a typo fails typecheck. -const LIFECYCLE_ENVELOPE_KINDS = new Set([ +// postMessage data is untrusted and the envelope type alone cannot reject a +// spoofed field, so both are checked at runtime. The lists are repeated +// rather than imported because importing a value from the resolver's +// smoldot module would drag smoldot into every bundle that talks to the +// protocol. `satisfies` ties each literal to the resolver's type, so a +// drifting or misspelled entry fails typecheck. +const CHAIN_KEY_VALUES = new Set([ + "relay", + "custom-relay", + "asset-hub", + "bulletin", + "people", +] satisfies ChainKey[]); +const SYNC_KIND_VALUES = new Set([ "firstPeer", "bootstrapComplete", "stalled", "recovered", -] satisfies LifecycleKind[]); + "peers", +] satisfies ChainSyncKind[]); let listenerBound = false; let protocolReady = false; interface ReadyWaiter { @@ -188,6 +197,24 @@ function resetProtocolFrameState(reason?: Error): void { } } +/** Deliver to every listener, so one that throws cannot silence the rest. */ +function broadcast( + listeners: ReadonlySet<(event: T) => void>, + event: T, + label: string, +): void { + for (const listener of listeners) { + try { + listener(event); + } catch (err: unknown) { + log.error( + `[dot.li protocol] ${label} listener threw:`, + err instanceof Error ? err.message : err, + ); + } + } +} + function bindMessageListener(): void { if (listenerBound) { return; @@ -232,45 +259,22 @@ function bindMessageListener(): void { } return; } - case "lifecycle": { + case "chain-sync": { if ( - typeof msg.chain !== "string" || - !LIFECYCLE_ENVELOPE_KINDS.has(msg.lifecycleKind) + !CHAIN_KEY_VALUES.has(msg.chain) || + !SYNC_KIND_VALUES.has(msg.syncKind) ) { return; } - for (const cb of lifecycleListeners) { - try { - cb(msg); - } catch (err: unknown) { - log.error( - "[dot.li protocol] Lifecycle listener threw:", - err instanceof Error ? err.message : err, - ); - } - } - return; - } - case "health": { if ( - typeof msg.chain !== "string" || - !Number.isInteger(msg.peers) || - msg.peers < 0 || - msg.peers > 10_000 || - typeof msg.isSyncing !== "boolean" + msg.syncKind === "peers" && + (!Number.isInteger(msg.peers) || + (msg.peers ?? -1) < 0 || + (msg.peers ?? 0) > 10_000) ) { return; } - for (const cb of healthListeners) { - try { - cb(msg); - } catch (err: unknown) { - log.error( - "[dot.li protocol] Health listener threw:", - err instanceof Error ? err.message : err, - ); - } - } + broadcast(chainSyncListeners, msg, "Chain sync"); return; } case "fatal": @@ -351,16 +355,7 @@ function bindMessageListener(): void { key: msg.key, value: msg.value, }; - for (const listener of sharedAuthListeners) { - try { - listener(change); - } catch (err: unknown) { - log.error( - "[dot.li protocol] Shared auth listener threw:", - err instanceof Error ? err.message : err, - ); - } - } + broadcast(sharedAuthListeners, change, "Shared auth"); return; } } @@ -743,33 +738,19 @@ export function subscribeSharedAuthStorage( } /** - * Subscribe to smoldot lifecycle events forwarded by the protocol iframe. + * Subscribe to what the chains report about their sync. + * * Events arrive only after the origin- and source-gated message listener * validates the envelope, so callers never see spoofable raw messages. * Returns an unsubscribe function. */ -export function onProtocolLifecycle( - listener: (event: ProtocolLifecycleEnvelope) => void, -): () => void { - bindMessageListener(); - lifecycleListeners.add(listener); - return () => { - lifecycleListeners.delete(listener); - }; -} - -/** - * Subscribe to chain `system_health` samples forwarded by the protocol - * iframe during bootstrap. Same gating as `onProtocolLifecycle`. - * Returns an unsubscribe function. - */ -export function onProtocolHealth( - listener: (event: ProtocolHealthEnvelope) => void, +export function onProtocolChainSync( + listener: (event: ProtocolChainSyncEnvelope) => void, ): () => void { bindMessageListener(); - healthListeners.add(listener); + chainSyncListeners.add(listener); return () => { - healthListeners.delete(listener); + chainSyncListeners.delete(listener); }; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cb9068e2..219de1bf 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,7 +1,7 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import type { LifecycleKind } from "@dotli/resolver/smoldot"; +import type { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; export interface ProtocolRequestMap { warmup: Record; @@ -102,30 +102,19 @@ export interface ProtocolInitFailedEnvelope { } /** - * Unsolicited broadcast of a smoldot lifecycle event observed inside the - * protocol iframe. `chain` is the resolver's logical chain key ("relay", - * "asset-hub", "bulletin", "people", "custom-relay"), not smoldot's - * internal chain id. Drives the host loading bar. + * Unsolicited broadcast of what a chain reports about its own sync. + * + * Drives the host loading screen: milestones move the bar, peer counts feed + * the detail line under it. Stops arriving once the chain is ready. */ -export interface ProtocolLifecycleEnvelope { +export interface ProtocolChainSyncEnvelope { namespace: "dotli:protocol"; - kind: "lifecycle"; - chain: string; - lifecycleKind: LifecycleKind; + kind: "chain-sync"; + chain: ChainKey; + syncKind: ChainSyncKind; reason?: string; -} - -/** - * Unsolicited broadcast of a chain's `system_health` sample observed inside - * the protocol iframe. Emitted only while health polling runs (chain - * bootstrap). Drives the live peer count on the host loading screen. - */ -export interface ProtocolHealthEnvelope { - namespace: "dotli:protocol"; - kind: "health"; - chain: string; - peers: number; - isSyncing: boolean; + peers?: number; + isSyncing?: boolean; } // Unsolicited notification from the host iframe to its parent window when a @@ -151,8 +140,7 @@ export type ProtocolEnvelope = | ProtocolReadyEnvelope | ProtocolFatalEnvelope | ProtocolInitFailedEnvelope - | ProtocolLifecycleEnvelope - | ProtocolHealthEnvelope + | ProtocolChainSyncEnvelope | ProtocolAuthStorageChangedEnvelope; const VALID_KINDS = new Set([ @@ -164,8 +152,7 @@ const VALID_KINDS = new Set([ "ready", "fatal", "init-failed", - "lifecycle", - "health", + "chain-sync", "auth-storage-changed", ]); diff --git a/packages/resolver/src/resolve.ts b/packages/resolver/src/resolve.ts index 324f5f2e..0b78c247 100644 --- a/packages/resolver/src/resolve.ts +++ b/packages/resolver/src/resolve.ts @@ -48,11 +48,17 @@ export { statusToPhase } from "./access-raw-storage"; export { getSmoldot, getSmoldotDirect, getRelayChain } from "./smoldot"; export { onConnectionIssue, - onLifecycle, - onHealth, - enableHealthPolling, + onChainSync, + enableSyncReporting, + CHAIN_KEYS, + CHAIN_SYNC_KINDS, +} from "./smoldot"; +export type { + ChainSyncEvent, + ChainSyncKind, + ChainKey, + SyncReportingConfig, } from "./smoldot"; -export type { LifecycleEvent, LifecycleKind, HealthEvent } from "./smoldot"; let clientInstance: SubstrateClient | null = null; let apiInstance: Api | null = null; diff --git a/packages/resolver/src/smoldot-db.ts b/packages/resolver/src/smoldot-db.ts index 0d8909a4..db2f5609 100644 --- a/packages/resolver/src/smoldot-db.ts +++ b/packages/resolver/src/smoldot-db.ts @@ -165,19 +165,22 @@ export interface ChainDbTap { isStopped(): boolean; } -/** - * Response interceptor for traffic the resolver injects on the chain's - * JSON-RPC pipe (lifecycle follow, health polls). Called for every parsed - * response before it is forwarded to the chain's regular consumer. Return - * `true` to consume the message: polkadot-api never sees it, which keeps - * its provider free of ids and subscriptions it did not create. - */ -export type TapIntercept = (parsed: { +/** The fields of a JSON-RPC frame the tap itself looks at. */ +interface ParsedRpcMessage { id?: unknown; method?: unknown; result?: unknown; params?: unknown; -}) => boolean; +} + +/** + * Claims responses to requests the resolver injected on the chain's pipe. + * + * Called for every parsed response before it reaches the chain's regular + * consumer. Returning `true` consumes the message, which keeps + * polkadot-api's provider free of ids and subscriptions it never created. + */ +export type TapIntercept = (parsed: ParsedRpcMessage) => boolean; interface ExternalWaiter { resolve: (s: string) => void; @@ -233,12 +236,7 @@ export function tapChain( return; } try { - const parsed = JSON.parse(raw) as { - id?: unknown; - method?: unknown; - result?: unknown; - params?: unknown; - }; + const parsed = JSON.parse(raw) as ParsedRpcMessage; if ( typeof parsed.id === "string" && parsed.id.startsWith(REQUEST_ID_PREFIX) diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index f6dc6f3a..0a52e9a2 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -59,45 +59,64 @@ export function onConnectionIssue(cb: ConnectionIssueCallback): () => void { }; } +/** The chains the resolver runs, named by role rather than by chain spec. */ +export const CHAIN_KEYS = [ + "relay", + "custom-relay", + "asset-hub", + "bulletin", + "people", +] as const; +export type ChainKey = (typeof CHAIN_KEYS)[number]; + /** - * Lifecycle events emitted by smoldot's per-chain broadcaster over the - * `lifecycle_unstable_follow` JSON-RPC subscription. Smoldot emits more - * kinds (connecting, modeDecision, warpSyncProgress, warpSyncFinished, - * stopped); this allowlist covers only what the loading UI consumes. + * What a chain reports about its own sync. + * + * Smoldot emits more milestones than these (connecting, modeDecision, + * warpSyncProgress, warpSyncFinished, stopped). The list covers only what + * the loading UI consumes. `peers` is our own addition, sampled while the + * chain bootstraps rather than reported by smoldot. */ -export type LifecycleKind = - | "firstPeer" - | "bootstrapComplete" - | "stalled" - | "recovered"; -const LIFECYCLE_KINDS: ReadonlySet = new Set([ +export const CHAIN_SYNC_KINDS = [ "firstPeer", "bootstrapComplete", "stalled", "recovered", -]); -export interface LifecycleEvent { - /** Logical chain key: "relay", "asset-hub", "bulletin", "people", "custom-relay". */ - chain: string; - kind: LifecycleKind; - /** Set on `stalled` (and `recovered` as `previously`): why sync stopped progressing. */ + "peers", +] as const; +export type ChainSyncKind = (typeof CHAIN_SYNC_KINDS)[number]; + +function isSyncKind(kind: string): kind is ChainSyncKind { + return (CHAIN_SYNC_KINDS as readonly string[]).includes(kind); +} + +export interface ChainSyncEvent { + chain: ChainKey; + kind: ChainSyncKind; + /** Why sync stopped progressing, on `stalled` and `recovered`. */ reason?: string; + /** Peer count, on `peers`. */ + peers?: number; + /** Whether the chain is still catching up, on `peers`. */ + isSyncing?: boolean; } -type LifecycleCallback = (event: LifecycleEvent) => void; -const lifecycleListeners = new Set(); -// Latest event per (chain, kind), insertion-ordered. Bounded, so late +type SyncCallback = (event: ChainSyncEvent) => void; +const syncListeners = new Set(); +// Latest event per chain and kind, insertion-ordered. Bounded, so late // subscribers replay at most kinds x chains events. -const lifecycleHistory = new Map(); +const syncHistory = new Map(); /** - * Subscribe to smoldot lifecycle events. Late subscribers receive the latest - * event per chain and kind (snapshot-on-subscribe), then continue with live - * events. Returns an unsubscribe function. + * Subscribe to what the chains report about their sync. + * + * Late subscribers first receive the latest event per chain and kind, then + * continue with live ones, so a listener that attaches mid-sync still knows + * where each chain stands. Returns an unsubscribe function. */ -export function onLifecycle(cb: LifecycleCallback): () => void { - lifecycleListeners.add(cb); - for (const event of lifecycleHistory.values()) { +export function onChainSync(cb: SyncCallback): () => void { + syncListeners.add(cb); + for (const event of syncHistory.values()) { try { cb(event); // eslint-disable-next-line no-restricted-syntax -- defensive replay: one buggy late subscriber must not block registration. @@ -106,20 +125,27 @@ export function onLifecycle(cb: LifecycleCallback): () => void { } } return () => { - lifecycleListeners.delete(cb); + syncListeners.delete(cb); }; } -function emitLifecycle(event: LifecycleEvent): void { - // `stalled` and `recovered` describe one condition; keeping both in the - // replay history would let a late subscriber end on the outdated half. - if (event.kind === "stalled") { - lifecycleHistory.delete(`${event.chain}:recovered`); +function emitChainSync(event: ChainSyncEvent): void { + if (event.kind === "peers") { + // Repeating an unchanged count would wake every listener once a second + // for nothing. + const prev = syncHistory.get(`${event.chain}:peers`); + if (prev !== undefined && prev.peers === event.peers) { + return; + } + } else if (event.kind === "stalled") { + // `stalled` and `recovered` describe one condition. Keeping both in the + // replay history would let a late subscriber end on the outdated half. + syncHistory.delete(`${event.chain}:recovered`); } else if (event.kind === "recovered") { - lifecycleHistory.delete(`${event.chain}:stalled`); + syncHistory.delete(`${event.chain}:stalled`); } - lifecycleHistory.set(`${event.chain}:${event.kind}`, event); - for (const cb of lifecycleListeners) { + syncHistory.set(`${event.chain}:${event.kind}`, event); + for (const cb of syncListeners) { try { cb(event); // eslint-disable-next-line no-restricted-syntax -- defensive multicast: one buggy subscriber must not block the broadcast. @@ -129,62 +155,28 @@ function emitLifecycle(event: LifecycleEvent): void { } } -/** A chain's `system_health` sample, polled during bootstrap. */ -export interface HealthEvent { - /** Logical chain key, same namespace as `LifecycleEvent.chain`. */ - chain: string; - peers: number; - isSyncing: boolean; +/** Which chains report sync, and which of those are sampled for peers. */ +export interface SyncReportingConfig { + milestones: readonly ChainKey[]; + peerCounts: readonly ChainKey[]; } -type HealthCallback = (event: HealthEvent) => void; -const healthListeners = new Set(); -const lastHealth = new Map(); - -/** - * Subscribe to chain health samples. Late subscribers receive the last known - * sample per chain, then live changes. Returns an unsubscribe function. - */ -export function onHealth(cb: HealthCallback): () => void { - healthListeners.add(cb); - for (const event of lastHealth.values()) { - try { - cb(event); - // eslint-disable-next-line no-restricted-syntax -- defensive replay: one buggy late subscriber must not block registration. - } catch { - /* listener threw during replay */ - } - } - return () => { - healthListeners.delete(cb); - }; -} +// Sync reporting is opt-in per process and per chain, because it costs a +// subscription plus an interceptor on every response the chain yields. The +// protocol iframe's direct mode enables it for the chains its loading +// screen actually shows. The SharedWorker never does, so its long-lived +// smoldot does no work for a UI that cannot observe it. +const milestoneChains = new Set(); +const peerCountChains = new Set(); -function emitHealth(event: HealthEvent): void { - const prev = lastHealth.get(event.chain); - if (prev?.peers === event.peers && prev.isSyncing === event.isSyncing) { - return; +export function enableSyncReporting(config: SyncReportingConfig): void { + for (const chain of config.milestones) { + milestoneChains.add(chain); } - lastHealth.set(event.chain, event); - for (const cb of healthListeners) { - try { - cb(event); - // eslint-disable-next-line no-restricted-syntax -- defensive multicast: one buggy subscriber must not block the broadcast. - } catch { - /* listener threw */ - } - } -} - -// Health polling is opt-in per process and per chain. The protocol -// iframe's direct mode enables it for the chains its loading UI actually -// reads; the SharedWorker never does, so its long-lived smoldot does not -// poll for a UI that cannot observe it. -const healthPollingChains = new Set(); - -export function enableHealthPolling(chains: readonly string[]): void { - for (const chain of chains) { - healthPollingChains.add(chain); + for (const chain of config.peerCounts) { + peerCountChains.add(chain); + // A peer count is useless without the milestone that ends it. + milestoneChains.add(chain); } } @@ -246,13 +238,13 @@ const CONNECTION_ISSUE_PATTERNS = [ // cannot collide with the numeric ids polkadot-api uses, and so the chain // tap can recognize and consume the responses before they reach // polkadot-api's provider. -const LIFECYCLE_FOLLOW_REQUEST_ID = "__dotli_lifecycle_follow__"; -const HEALTH_REQUEST_ID = "__dotli_health__"; +const FOLLOW_ID_PREFIX = "__dotli_lifecycle_follow__:"; +const HEALTH_ID_PREFIX = "__dotli_health__:"; // Subscription id of each chain's `lifecycle_unstable_follow`, learned from // the follow reply. Notifications carry no request id, so this is how the // tap tells our subscription's events apart from any other traffic. -const lifecycleSubscriptions = new Map(); +const followSubscriptions = new Map(); function smoldotLogCallback( level: number, @@ -293,23 +285,25 @@ function smoldotLogCallback( } /** - * Response interceptor bound to one chain. Runs inside the chain tap's - * pump, so it sees every response in order, untruncated, and can consume - * our reserved-id traffic before polkadot-api's provider does. + * Build the tap interceptor that claims one chain's sync traffic. + * + * It runs inside the chain tap's pump, so it sees every response in order + * and untruncated, and it consumes our reserved-id traffic before + * polkadot-api's provider can see it. */ -function makeSideChannelIntercept(chainName: string): TapIntercept { +function makeSideChannelIntercept(chain: ChainKey): TapIntercept { return (parsed) => { if (typeof parsed.id === "string") { - if (parsed.id.startsWith(`${LIFECYCLE_FOLLOW_REQUEST_ID}:`)) { + if (parsed.id.startsWith(FOLLOW_ID_PREFIX)) { // Reply to our follow request: remember the subscription id so // notifications (which carry no request id) can be matched below. if (typeof parsed.result === "string") { - lifecycleSubscriptions.set(chainName, parsed.result); + followSubscriptions.set(chain, parsed.result); } return true; } - if (parsed.id.startsWith(`${HEALTH_REQUEST_ID}:`)) { - handleHealthResponse(chainName, parsed.result); + if (parsed.id.startsWith(HEALTH_ID_PREFIX)) { + handleHealthResponse(chain, parsed.result); return true; } return false; @@ -323,7 +317,7 @@ function makeSideChannelIntercept(chainName: string): TapIntercept { | undefined; // The follow reply always precedes its notifications, so an unknown // subscription id means the event belongs to someone else: forward it. - const subscription = lifecycleSubscriptions.get(chainName); + const subscription = followSubscriptions.get(chain); if ( params === undefined || subscription === undefined || @@ -331,41 +325,36 @@ function makeSideChannelIntercept(chainName: string): TapIntercept { ) { return false; } - emitLifecycleNotification(chainName, params.result); + emitMilestone(chain, params.result); return true; } return false; }; } -function emitLifecycleNotification( - chain: string, +function emitMilestone( + chain: ChainKey, result: { kind?: string; reason?: string; previously?: string } | undefined, ): void { const kind = result?.kind; - if (kind === undefined || !LIFECYCLE_KINDS.has(kind)) { + if (kind === undefined || kind === "peers" || !isSyncKind(kind)) { return; } if (kind === "bootstrapComplete") { - // The chain is usable; peer-count polling has served its purpose. - healthPollers.get(chain)?.stop(); + // The chain is usable, so peer-count polling has served its purpose. + persistence.get(chain)?.healthPoller?.stop(); } - const reason = - kind === "stalled" - ? result?.reason - : kind === "recovered" - ? result?.previously - : undefined; - emitLifecycle({ + const reason = kind === "stalled" ? result?.reason : result?.previously; + emitChainSync({ chain, - kind: kind as LifecycleKind, + kind, ...(typeof reason === "string" ? { reason } : {}), }); } -function handleHealthResponse(chain: string, result: unknown): void { +function handleHealthResponse(chain: ChainKey, result: unknown): void { healthResponseSeen = true; - healthPollers.get(chain)?.noteResponse(); + persistence.get(chain)?.healthPoller?.noteResponse(); const health = result as { peers?: unknown; isSyncing?: unknown } | null; if ( health === null || @@ -377,7 +366,12 @@ function handleHealthResponse(chain: string, result: unknown): void { ) { return; } - emitHealth({ chain, peers: health.peers, isSyncing: health.isSyncing }); + emitChainSync({ + chain, + kind: "peers", + peers: health.peers, + isSyncing: health.isSyncing, + }); } let smoldotInstance: SmoldotClient | null = null; @@ -389,9 +383,9 @@ interface PersistenceEntry { periodicTimer: ReturnType; healthPoller: HealthPoller | null; } -const persistence = new Map(); +const persistence = new Map(); -function dbKeyFor(chainName: string): string { +function dbKeyFor(chainName: ChainKey): string { return `${getNetwork()}:${chainName}`; } @@ -403,7 +397,7 @@ function unrefHandle(handle: ReturnType): void { } } -function schedulePersistence(chainName: string, tap: ChainDbTap): void { +function schedulePersistence(chainName: ChainKey, tap: ChainDbTap): void { if (persistence.has(chainName)) { return; } @@ -453,7 +447,7 @@ function schedulePersistence(chainName: string, tap: ChainDbTap): void { }); } -function teardownPersistence(chainName: string): void { +function teardownPersistence(chainName: ChainKey): void { const entry = persistence.get(chainName); if (entry === undefined) { return; @@ -472,17 +466,24 @@ function teardownAllPersistence(): void { } function attachPersistence( - chainName: string, + chainName: ChainKey, underlying: SmoldotChain, ): SmoldotChain { teardownPersistence(chainName); - // The tap intercepts our reserved-id traffic (lifecycle follow, health - // polls) in-band and consumes it, so polkadot-api's provider only ever - // sees its own requests and subscriptions. - const tap = tapChain(underlying, makeSideChannelIntercept(chainName)); + // Reporting costs an interceptor on every response this chain yields, so + // chains nobody watches are tapped for persistence alone. When it is on, + // the interceptor consumes our reserved-id traffic in-band, leaving + // polkadot-api's provider with only its own requests and subscriptions. + const reports = milestoneChains.has(chainName); + const tap = tapChain( + underlying, + reports ? makeSideChannelIntercept(chainName) : undefined, + ); schedulePersistence(chainName, tap); - attachLifecycleFollow(chainName, tap.chain); - if (healthPollingChains.has(chainName)) { + if (reports) { + followMilestones(chainName, tap.chain); + } + if (peerCountChains.has(chainName)) { const entry = persistence.get(chainName); if (entry !== undefined) { entry.healthPoller = startHealthPolling(chainName, tap); @@ -491,12 +492,12 @@ function attachPersistence( return tap.chain; } -function attachLifecycleFollow(chainName: string, chain: SmoldotChain): void { +function followMilestones(chainName: ChainKey, chain: SmoldotChain): void { try { chain.sendJsonRpc( JSON.stringify({ jsonrpc: "2.0", - id: `${LIFECYCLE_FOLLOW_REQUEST_ID}:${chainName}`, + id: `${FOLLOW_ID_PREFIX}${chainName}`, method: "lifecycle_unstable_follow", params: [], }), @@ -512,25 +513,47 @@ interface HealthPoller { stop(): void; noteResponse(): void; } -const healthPollers = new Map(); const HEALTH_POLL_INTERVAL_MS = 1_000; const HEALTH_POLL_TIMEOUT_MS = 2_000; const HEALTH_POLL_MAX = 120; -let healthWatchdogArmed = false; let healthResponseSeen = false; /** - * Poll `system_health` on a chain's JSON-RPC pipe during bootstrap so the - * loading UI can show a live peer count. Sequential by design: the next - * poll goes out one interval after the previous response is observed in - * the log stream, so a busy chain is never flooded; a 2s timeout resends - * when a response never surfaces. Stops on `bootstrapComplete` for the - * chain (see emitLifecycleNotification), chain teardown, a dead chain, or - * a hard poll cap. + * Warn once per session if our reserved-id requests go unanswered. + * + * The whole side-channel depends on smoldot replying to them. If a smoldot + * bump breaks that, milestones and peer counts both go silently dead. + */ +const armSideChannelWatchdog = (() => { + let armed = false; + return (): void => { + if (armed) { + return; + } + armed = true; + const watchdog = setTimeout(() => { + if (!healthResponseSeen) { + log.warn( + "[dot.li smoldot] sync side-channel not observed within 5s, loading detail will not update", + ); + } + }, 5_000); + unrefHandle(watchdog); + }; +})(); + +/** + * Poll `system_health` during bootstrap so the UI can show a live peer count. + * + * Polling is sequential by design. The next poll goes out one interval + * after the previous response arrives, so a busy chain is never flooded, + * and a 2s timeout resends when a response never surfaces. Stops on + * `bootstrapComplete` for the chain (see `emitMilestone`), chain teardown, + * a dead chain, or a hard poll cap. */ -function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { +function startHealthPolling(chain: ChainKey, tap: ChainDbTap): HealthPoller { let stopped = false; let polls = 0; let timer: ReturnType | null = null; @@ -541,7 +564,6 @@ function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { clearTimeout(timer); timer = null; } - healthPollers.delete(chainName); }; const schedule = (delayMs: number): void => { @@ -565,14 +587,14 @@ function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { tap.chain.sendJsonRpc( JSON.stringify({ jsonrpc: "2.0", - id: `${HEALTH_REQUEST_ID}:${chainName}:${String(polls)}`, + id: `${HEALTH_ID_PREFIX}${chain}:${String(polls)}`, method: "system_health", params: [], }), ); } catch { - // The chain was destroyed under us (terminate/remove); polling is - // best-effort and simply ends. + // The chain was destroyed under us by a terminate or a remove. + // Polling is best-effort and simply ends. stop(); return; } @@ -586,28 +608,11 @@ function startHealthPolling(chainName: string, tap: ChainDbTap): HealthPoller { schedule(HEALTH_POLL_INTERVAL_MS); }; - // The side-channel depends on smoldot answering our reserved-id - // requests. If a smoldot bump breaks that, lifecycle and health both go - // silently dead, so surface one warning per session when the first poll - // gets no observable reply. - if (!healthWatchdogArmed) { - healthWatchdogArmed = true; - const watchdog = setTimeout(() => { - if (!healthResponseSeen) { - log.warn( - "[dot.li smoldot] health side-channel not observed within 5s; loading detail will not update", - ); - } - }, 5_000); - unrefHandle(watchdog); - } - - const poller = { stop, noteResponse }; - healthPollers.set(chainName, poller); + armSideChannelWatchdog(); // Poll immediately: on a warm start the chain bootstraps in well under a // second and a delayed first poll would never produce a sample. send(); - return poller; + return { stop, noteResponse }; } /** @@ -680,11 +685,10 @@ export function terminateSmoldot(): void { } teardownAllPersistence(); // The next smoldot instance re-subscribes and re-emits its own - // lifecycle; stale subscription ids or replayed events from the dead + // lifecycle. Stale subscription ids or replayed events from the dead // session would mislabel or suppress the new one's signals. - lifecycleSubscriptions.clear(); - lifecycleHistory.clear(); - lastHealth.clear(); + followSubscriptions.clear(); + syncHistory.clear(); smoldotInstance = null; relayChainPromise = null; dappAssetHubPromise = null; diff --git a/packages/resolver/tests/smoldot.test.ts b/packages/resolver/tests/smoldot.test.ts index 582edded..3c811c54 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -1,10 +1,10 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; -// Queue-backed chain mocks: tests push raw JSON-RPC responses and the -// chain tap's pump consumes them exactly as it does smoldot's output. +// Queue-backed chain mocks. Tests push raw JSON-RPC responses and the chain +// tap's pump consumes them exactly as it does the real light client's. const chainMocks = vi.hoisted(() => { interface MockChain { sendJsonRpc: ReturnType; @@ -16,7 +16,9 @@ const chainMocks = vi.hoisted(() => { const chains: MockChain[] = []; function makeChain(): MockChain { const queue: string[] = []; - const waiters: ((s: string) => void)[] = []; + // The tap's pump awaits one response at a time, so at most one caller + // is ever waiting. + let waiting: ((s: string) => void) | null = null; const chain: MockChain = { sendJsonRpc: vi.fn(), nextJsonRpcResponse: () => @@ -25,14 +27,15 @@ const chainMocks = vi.hoisted(() => { if (next !== undefined) { resolve(next); } else { - waiters.push(resolve); + waiting = resolve; } }), jsonRpcResponses: (async function* () {})(), remove: vi.fn(), push(raw: string) { - const waiter = waiters.shift(); - if (waiter !== undefined) { + const waiter = waiting; + if (waiter !== null) { + waiting = null; waiter(raw); } else { queue.push(raw); @@ -78,9 +81,8 @@ vi.mock("@dotli/resolver/chain-specs", () => ({ let getSmoldot: typeof import("@dotli/resolver/smoldot").getSmoldot; let getRelayChain: typeof import("@dotli/resolver/smoldot").getRelayChain; -let onLifecycle: typeof import("@dotli/resolver/smoldot").onLifecycle; -let onHealth: typeof import("@dotli/resolver/smoldot").onHealth; -let enableHealthPolling: typeof import("@dotli/resolver/smoldot").enableHealthPolling; +let onChainSync: typeof import("@dotli/resolver/smoldot").onChainSync; +let enableSyncReporting: typeof import("@dotli/resolver/smoldot").enableSyncReporting; beforeEach(async () => { vi.clearAllMocks(); @@ -89,27 +91,34 @@ beforeEach(async () => { const mod = await import("@dotli/resolver/smoldot"); getSmoldot = mod.getSmoldot; getRelayChain = mod.getRelayChain; - onLifecycle = mod.onLifecycle; - onHealth = mod.onHealth; - enableHealthPolling = mod.enableHealthPolling; + onChainSync = mod.onChainSync; + enableSyncReporting = mod.enableSyncReporting; }); -// Let the tap's pump loop drain everything pushed so far. +/** + * Let the tap's pump loop drain everything pushed so far. + * + * Each queued response costs the pump a few microtask turns, so this yields + * generously rather than counting them. Raise it if a future pump grows + * more await points and events start arriving after the assertion. + */ async function flush(): Promise { for (let i = 0; i < 25; i++) { await Promise.resolve(); } } -async function relayMock(): Promise< - (typeof chainMocks.chains)[number] & object -> { - await getRelayChain(); - const chain = chainMocks.chains.at(-1); - if (chain === undefined) { +/** Start the relay chain and hand back the mock feeding it. */ +async function startRelay(): Promise<{ + tapped: Awaited>; + raw: (typeof chainMocks.chains)[number]; +}> { + const tapped = await getRelayChain(); + const raw = chainMocks.chains.at(-1); + if (raw === undefined) { throw new Error("relay chain mock was not created"); } - return chain; + return { tapped, raw }; } const FOLLOW_REPLY = JSON.stringify({ @@ -118,7 +127,7 @@ const FOLLOW_REPLY = JSON.stringify({ result: "sub-1", }); -function followEvent( +function milestone( subscription: string, kind: string, extra: Record = {}, @@ -130,7 +139,7 @@ function followEvent( }); } -function healthReply(seq: number, peers: number, isSyncing = true): string { +function peerReport(seq: number, peers: number, isSyncing = true): string { return JSON.stringify({ jsonrpc: "2.0", id: `__dotli_health__:relay:${String(seq)}`, @@ -138,274 +147,264 @@ function healthReply(seq: number, peers: number, isSyncing = true): string { }); } -function healthCalls(chain: { sendJsonRpc: ReturnType }) { +function peerRequests(chain: { sendJsonRpc: ReturnType }) { return chain.sendJsonRpc.mock.calls .map((call) => call[0] as string) .filter((raw) => raw.includes("system_health")); } -describe("getSmoldot", () => { - it("returns the same instance on repeated calls", () => { - const a = getSmoldot(); - const b = getSmoldot(); - expect(a).toBe(b); - }); -}); +describe("Light client sync reporting works", () => { + it("As a user opening several apps in one tab, they share a single light client", () => { + // Given / When + const first = getSmoldot(); + const second = getSmoldot(); -describe("getRelayChain", () => { - it("returns a promise", () => { - const result = getRelayChain(); - expect(result).toBeInstanceOf(Promise); + // Then + expect(first).toBe(second); }); - it("deduplicates concurrent calls", () => { - const a = getRelayChain(); - const b = getRelayChain(); - expect(a).toBe(b); - }); + it("As a user opening several apps in one tab, they share a single relay chain connection", async () => { + // Given / When + const first = getRelayChain(); + const second = getRelayChain(); - it("resolves to a chain object", async () => { - const chain = await getRelayChain(); - expect(chain).toBeDefined(); - expect(typeof chain.sendJsonRpc).toBe("function"); + // Then + expect(first).toBe(second); + expect(typeof (await first).sendJsonRpc).toBe("function"); }); -}); -describe("onLifecycle", () => { - it("delivers events for the chain once the follow reply is seen", async () => { - const chain = await relayMock(); - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); - - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "firstPeer")); - chain.push(followEvent("sub-1", "bootstrapComplete")); + it("As a user waiting for a domain, the shell learns when the first peer arrives and when the chain is ready", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { raw } = await startRelay(); + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "firstPeer")); + raw.push(milestone("sub-1", "bootstrapComplete")); await flush(); - expect(events).toEqual([ + // Then + expect(milestones).toEqual([ { chain: "relay", kind: "firstPeer" }, { chain: "relay", kind: "bootstrapComplete" }, ]); }); - it("forwards notifications from unknown subscriptions to the chain consumer", async () => { - const tapped = await getRelayChain(); - const chain = chainMocks.chains.at(-1); - if (chain === undefined) { - throw new Error("relay chain mock was not created"); - } - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); - - chain.push(FOLLOW_REPLY); - const foreign = followEvent("someone-elses-sub", "firstPeer"); - chain.push(foreign); - await flush(); - - expect(events).toEqual([]); - await expect(tapped.nextJsonRpcResponse()).resolves.toBe(foreign); - }); - - it("consumes our traffic so the chain consumer never sees it", async () => { - const tapped = await getRelayChain(); - const chain = chainMocks.chains.at(-1); - if (chain === undefined) { - throw new Error("relay chain mock was not created"); - } - - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "firstPeer")); - const papiResponse = JSON.stringify({ - jsonrpc: "2.0", - id: "1-42", - result: "0x00", - }); - chain.push(papiResponse); - await flush(); - - // Only the polkadot-api response comes through; ours were consumed. - await expect(tapped.nextJsonRpcResponse()).resolves.toBe(papiResponse); - }); - - it("carries the stall reason and the recovery cause", async () => { - const chain = await relayMock(); - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); - - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); - chain.push(followEvent("sub-1", "recovered", { previously: "noPeers" })); + it("As a user whose connection drops mid-sync, the shell learns why it stalled and when it recovered", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { raw } = await startRelay(); + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "stalled", { reason: "noPeers" })); + raw.push(milestone("sub-1", "recovered", { previously: "noPeers" })); await flush(); - expect(events).toEqual([ + // Then + expect(milestones).toEqual([ { chain: "relay", kind: "stalled", reason: "noPeers" }, { chain: "relay", kind: "recovered", reason: "noPeers" }, ]); }); - it("replays only the latest event per chain and kind to a late subscriber", async () => { - const chain = await relayMock(); - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); - chain.push(followEvent("sub-1", "stalled", { reason: "syncNoProgress" })); - chain.push(followEvent("sub-1", "firstPeer")); + it("As a user opening the loading screen late, I see the newest sync state rather than a replay of every step", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { raw } = await startRelay(); + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "stalled", { reason: "noPeers" })); + raw.push(milestone("sub-1", "stalled", { reason: "syncNoProgress" })); + raw.push(milestone("sub-1", "firstPeer")); await flush(); - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); + // When + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); - expect(events).toEqual([ + // Then + expect(milestones).toEqual([ { chain: "relay", kind: "stalled", reason: "syncNoProgress" }, { chain: "relay", kind: "firstPeer" }, ]); }); - it("replays only the newer half of a stall and recovery pair", async () => { - const chain = await relayMock(); - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "stalled", { reason: "noPeers" })); - chain.push(followEvent("sub-1", "recovered", { previously: "noPeers" })); + it("As a user whose sync recovered before I looked, I am not told it is still stalled", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { raw } = await startRelay(); + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "stalled", { reason: "noPeers" })); + raw.push(milestone("sub-1", "recovered", { previously: "noPeers" })); await flush(); - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); + // When + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); - expect(events).toEqual([ + // Then + expect(milestones).toEqual([ { chain: "relay", kind: "recovered", reason: "noPeers" }, ]); }); - it("ignores kinds outside the allowlist but still consumes them", async () => { - const tapped = await getRelayChain(); - const chain = chainMocks.chains.at(-1); - if (chain === undefined) { - throw new Error("relay chain mock was not created"); - } - const events: unknown[] = []; - onLifecycle((event) => events.push(event)); + it("As a user waiting for a domain, I am told how many peers the light client found", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: ["relay"] }); + const { raw } = await startRelay(); + const counts: unknown[] = []; + onChainSync((event) => counts.push(event)); - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "warpSyncProgress", { at: 5, target: 9 })); - const papiResponse = JSON.stringify({ jsonrpc: "2.0", id: "1-1" }); - chain.push(papiResponse); + // When + raw.push(peerReport(1, 3)); await flush(); - expect(events).toEqual([]); - await expect(tapped.nextJsonRpcResponse()).resolves.toBe(papiResponse); + // Then + expect(peerRequests(raw)[0]).toContain('"id":"__dotli_health__:relay:1"'); + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 3, isSyncing: true }, + ]); }); - it("stops delivering after unsubscribe", async () => { - const chain = await relayMock(); - const events: unknown[] = []; - const unsubscribe = onLifecycle((event) => events.push(event)); - - chain.push(FOLLOW_REPLY); - chain.push(followEvent("sub-1", "firstPeer")); - await flush(); - unsubscribe(); - chain.push(followEvent("sub-1", "bootstrapComplete")); + it("As a user with a steady connection, the peer count only changes when the number really changes", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: ["relay"] }); + const { raw } = await startRelay(); + const counts: unknown[] = []; + onChainSync((event) => counts.push(event)); + + // When + raw.push(peerReport(1, 2)); + raw.push(peerReport(2, 2)); + raw.push(peerReport(3, 5)); await flush(); - expect(events).toEqual([{ chain: "relay", kind: "firstPeer" }]); - }); -}); - -describe("onHealth", () => { - // Fake timers keep leaked poller resends (2s timeout) from bleeding - // between tests through the mock chains. - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); + // Then + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 2, isSyncing: true }, + { chain: "relay", kind: "peers", peers: 5, isSyncing: true }, + ]); }); - it("polls system_health immediately and emits the parsed sample", async () => { - enableHealthPolling(["relay"]); - const chain = await relayMock(); - - const sent = healthCalls(chain); - expect(sent.length).toBeGreaterThanOrEqual(1); - expect(sent[0]).toContain('"id":"__dotli_health__:relay:1"'); - - const events: unknown[] = []; - onHealth((event) => events.push(event)); - chain.push(healthReply(1, 3)); + it("As a user opening the loading screen late, I still see the peer count already found", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: ["relay"] }); + const { raw } = await startRelay(); + raw.push(peerReport(1, 4)); await flush(); - expect(events).toEqual([{ chain: "relay", peers: 3, isSyncing: true }]); - }); + // When + const counts: unknown[] = []; + onChainSync((event) => counts.push(event)); - it("does not poll chains outside the allowlist", async () => { - enableHealthPolling(["asset-hub"]); - const chain = await relayMock(); - expect(healthCalls(chain)).toEqual([]); + // Then + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 4, isSyncing: true }, + ]); }); - it("does not poll when polling was never enabled", async () => { - const chain = await relayMock(); - expect(healthCalls(chain)).toEqual([]); + it("As a user whose chain finished syncing, nothing keeps asking for peers", async () => { + // Given + vi.useFakeTimers(); + try { + enableSyncReporting({ milestones: ["relay"], peerCounts: ["relay"] }); + const { raw } = await startRelay(); + raw.push(FOLLOW_REPLY); + raw.push(peerReport(1, 3)); + + // When + raw.push(milestone("sub-1", "bootstrapComplete")); + await flush(); + const asked = peerRequests(raw).length; + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(peerRequests(raw).length).toBe(asked); + } finally { + vi.useRealTimers(); + } }); - it("emits only when the sample changes", async () => { - enableHealthPolling(["relay"]); - const chain = await relayMock(); + it("As a user loading an app, the shell's own sync questions never reach the app's chain traffic", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { tapped, raw } = await startRelay(); + const appResponse = JSON.stringify({ + jsonrpc: "2.0", + id: "1-42", + result: "0x00", + }); - const events: unknown[] = []; - onHealth((event) => events.push(event)); - chain.push(healthReply(1, 2)); - chain.push(healthReply(2, 2)); - chain.push(healthReply(3, 5)); + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "firstPeer")); + raw.push(appResponse); await flush(); - expect(events).toEqual([ - { chain: "relay", peers: 2, isSyncing: true }, - { chain: "relay", peers: 5, isSyncing: true }, - ]); + // Then + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(appResponse); }); +}); - it("replays the last sample to a late subscriber", async () => { - enableHealthPolling(["relay"]); - const chain = await relayMock(); - - chain.push(healthReply(1, 4)); +describe("Light client sync reporting fails", () => { + it("As a user, a sync message meant for something else never moves my loading screen", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { tapped, raw } = await startRelay(); + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + const foreign = milestone("someone-elses-sub", "firstPeer"); + + // When + raw.push(FOLLOW_REPLY); + raw.push(foreign); await flush(); - const events: unknown[] = []; - onHealth((event) => events.push(event)); - expect(events).toEqual([{ chain: "relay", peers: 4, isSyncing: true }]); + // Then + expect(milestones).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(foreign); }); - it("stops polling once the chain bootstrap completes", async () => { - enableHealthPolling(["relay"]); - const chain = await relayMock(); - - chain.push(FOLLOW_REPLY); - chain.push(healthReply(1, 3)); - chain.push(followEvent("sub-1", "bootstrapComplete")); + it("As a user, sync milestones the shell has no wording for are ignored", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { tapped, raw } = await startRelay(); + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + const appResponse = JSON.stringify({ jsonrpc: "2.0", id: "1-1" }); + + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "warpSyncProgress", { at: 5, target: 9 })); + raw.push(appResponse); await flush(); - const sentBefore = healthCalls(chain).length; - await vi.advanceTimersByTimeAsync(30_000); - expect(healthCalls(chain).length).toBe(sentBefore); + // Then + expect(milestones).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(appResponse); }); - it("rejects malformed health payloads", async () => { - enableHealthPolling(["relay"]); - const chain = await relayMock(); + it("As a user, a peer count that arrives malformed never reaches my loading screen", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: ["relay"] }); + const { raw } = await startRelay(); + const counts: unknown[] = []; + onChainSync((event) => counts.push(event)); - const events: unknown[] = []; - onHealth((event) => events.push(event)); - // peers is not an integer - chain.push( + // When + raw.push( JSON.stringify({ jsonrpc: "2.0", id: "__dotli_health__:relay:1", result: { isSyncing: true, peers: "3" }, }), ); - // error response, no result - chain.push( + raw.push( JSON.stringify({ jsonrpc: "2.0", id: "__dotli_health__:relay:2", @@ -414,6 +413,29 @@ describe("onHealth", () => { ); await flush(); - expect(events).toEqual([]); + // Then + expect(counts).toEqual([]); + }); + + it("As a user, chains my loading screen never shows are not asked for peers", async () => { + // Given + enableSyncReporting({ + milestones: ["asset-hub"], + peerCounts: ["asset-hub"], + }); + + // When + const { raw } = await startRelay(); + + // Then + expect(peerRequests(raw)).toEqual([]); + }); + + it("As a user on a shell with no loading screen to feed, no peer counts are requested at all", async () => { + // Given / When + const { raw } = await startRelay(); + + // Then + expect(peerRequests(raw)).toEqual([]); }); }); diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index 4d590184..58a8dbb6 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -129,8 +129,8 @@ body { text-align: right; } -/* Text block — phase label + terminal log. - The gap is the tight one: `#loading-detail` is a readout *of* the headline +/* Text block: phase label, live detail, and slow-step hint. + The gap is the tight one. `#loading-detail` is a readout of the headline above it and has to sit close enough to read as one unit. `.loading-hint` is a separate, occasional warning and buys its 12px break back with a margin. Three rows spaced equally would read as three peers, which they @@ -157,13 +157,13 @@ body { overflow: hidden; text-overflow: ellipsis; } -/* Live detail line (e.g. peer count) below the status headline */ -/* Live sync readout under the headline. Ranked below `#status` by size, - weight, and family — not by tone: it shares the headline's opacity because - the stall copy it carries ("searching for peers") is the most reassuring - message on the screen, and it cannot be the faintest thing on it. - `min-height` reserves the row so the block does not jump when the detail - appears mid-sync and is cleared on bootstrap. */ +/* Live sync readout under the headline. + Ranked below `#status` by size, weight, and family rather than by tone. + It shares the headline's opacity because the stall copy it carries + ("searching for peers") is the most reassuring message on the screen, and + it cannot be the faintest thing on it. `min-height` reserves the row so + the block does not jump when the detail appears mid-sync and is cleared + on bootstrap. */ .loading-detail { font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; font-size: 11px; diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 7b3f585b..c59fffc8 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -47,6 +47,8 @@ let currentPhase = -1; // Progress bar state let progressFillEl: HTMLElement | null = null; let progressPctEl: HTMLElement | null = null; +let statusDetailEl: HTMLElement | null = null; +let statusAnnouncerEl: HTMLElement | null = null; let currentProgress = 0; let targetProgress = 0; let crawlStep = 0; @@ -101,6 +103,8 @@ export function initPhases(phaseList: LoadingPhase[]): void { progressFillEl = document.getElementById("loading-progress-fill"); progressPctEl = document.getElementById("loading-progress-pct"); + statusDetailEl = document.getElementById("loading-detail"); + statusAnnouncerEl = document.getElementById("loading-announcer"); } /** @@ -140,37 +144,31 @@ export function advancePhase(index: number): void { } /** - * Current phase index, -1 before the first `advancePhase`. Lets callers - * suppress signals that describe a phase the bar has already passed. - */ -export function getCurrentPhase(): number { - return currentPhase; -} - -/** - * Live detail line under the status headline (e.g. "3 peers"). Deliberately - * separate from `#status`: this value changes every second or two during - * sync, and `#status` is aria-live, so routing it there would queue a - * screen-reader announcement per change. The detail element is aria-hidden - * and never touches the slow-hint timer. + * Write the live detail line under the status headline, e.g. "3 peers". + * + * This is deliberately not `#status`. The value changes every second or two + * during sync and `#status` is a live region, so routing it there would + * queue a screen-reader announcement per change. The detail element is + * aria-hidden and never touches the slow-hint timer. * - * `announce` mirrors the text once into the visually hidden polite region, - * for state changes a screen-reader user should hear (stall and recovery - * copy) without the per-second count spam. + * Pass `announce` for the state changes a screen-reader user should hear, + * such as stall and recovery copy. Those mirror once into a visually hidden + * polite region, leaving the per-second count silent. */ export function setStatusDetail( detail: string, opts: { announce?: boolean } = {}, ): void { - const el = document.getElementById("loading-detail"); - if (el !== null) { - el.textContent = detail; + if (statusDetailEl !== null) { + statusDetailEl.textContent = detail; } - if (opts.announce === true && detail !== "") { - const announcer = document.getElementById("loading-announcer"); - if (announcer !== null && announcer.textContent !== detail) { - announcer.textContent = detail; - } + if ( + opts.announce === true && + detail !== "" && + statusAnnouncerEl !== null && + statusAnnouncerEl.textContent !== detail + ) { + statusAnnouncerEl.textContent = detail; } } @@ -316,7 +314,16 @@ function clearSlowWarning(): void { * Replaces the previous message in place. No new DOM elements are created. * Schedules a slow-step hint if the step exceeds its time threshold. */ -export function showStatus(message: string): void { +export function showStatus( + message: string, + opts: { phase?: number } = {}, +): void { + // Callers that know which phase a message describes pass it, so prose + // about a step the bar already passed is dropped instead of flipping the + // headline backwards. `advancePhase` enforces the same rule for the bar. + if (opts.phase !== undefined && opts.phase < currentPhase) { + return; + } const status = document.getElementById("status"); if (status !== null) { status.textContent = message; diff --git a/packages/ui/tests/ui.test.ts b/packages/ui/tests/ui.test.ts deleted file mode 100644 index aec5b343..00000000 --- a/packages/ui/tests/ui.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: AGPL-3.0-only - -import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; - -let initPhases: typeof import("@dotli/ui/ui").initPhases; -let advancePhase: typeof import("@dotli/ui/ui").advancePhase; -let showStatus: typeof import("@dotli/ui/ui").showStatus; -let setStatusDetail: typeof import("@dotli/ui/ui").setStatusDetail; - -const PHASES = [ - { label: "Starting", base: 2, target: 6, expectedMs: 650 }, - { label: "Adding relay chain", base: 6, target: 10, expectedMs: 120 }, - { label: "Syncing Asset Hub", base: 10, target: 55, expectedMs: 6500 }, -]; - -function textOf(id: string): string { - return document.getElementById(id)?.textContent ?? ""; -} - -beforeEach(async () => { - vi.useFakeTimers(); - document.body.innerHTML = ` -
-
-
- 0% -

Reaching out

- -

-
-
`; - vi.resetModules(); - const mod = await import("@dotli/ui/ui"); - initPhases = mod.initPhases; - advancePhase = mod.advancePhase; - showStatus = mod.showStatus; - setStatusDetail = mod.setStatusDetail; - initPhases(PHASES); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("setStatusDetail", () => { - it("writes the detail line without touching the status headline", () => { - showStatus("Resolving example.dot"); - setStatusDetail("3 peers"); - expect(textOf("loading-detail")).toBe("3 peers"); - expect(textOf("status")).toBe("Resolving example.dot"); - }); - - it("does not reset the slow-hint timer while the count updates", () => { - advancePhase(2); - // "Syncing Asset Hub" arms a 15s hint. A peer count that ticks every - // second must not keep pushing that hint away. - for (let i = 0; i < 20; i++) { - vi.advanceTimersByTime(1000); - setStatusDetail(`${String(i)} peers`); - } - expect(textOf("loading-hint")).toContain("Asset Hub sync is slow"); - }); -}); - -describe("advancePhase", () => { - it("re-arms the slow hint for the new phase label", () => { - showStatus("Adding Paseo relay chain..."); - advancePhase(2); - // The relay-chain hint (10s) must not fire after we advanced to the - // Syncing phase; the Syncing hint (15s) fires instead. - vi.advanceTimersByTime(11_000); - expect(textOf("loading-hint")).not.toContain("relay chain bootstrap"); - vi.advanceTimersByTime(4_000); - expect(textOf("loading-hint")).toContain("Asset Hub sync is slow"); - }); - - it("keeps the detail line across phase advances", () => { - setStatusDetail("4 peers"); - advancePhase(2); - expect(textOf("loading-detail")).toBe("4 peers"); - }); -}); From 50b1202911e87b9034e9f4fff87172e6b30361f8 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Wed, 5 Aug 2026 17:24:50 +0100 Subject: [PATCH 07/24] Bring the loading screen text to WCAG AA, expose the peer count to screen readers, and stop smoldot trace lines reaching the headline --- apps/host/index.html | 2 +- apps/host/src/main.ts | 6 +++++- packages/resolver/src/smoldot.ts | 12 ++++++++++++ packages/ui/src/styles/base.css | 21 ++++++++++++++------- packages/ui/src/styles/themes.css | 26 ++++++++++++++------------ packages/ui/src/ui.ts | 11 ++++++----- 6 files changed, 52 insertions(+), 26 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index 559eab0c..934feb25 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -235,7 +235,7 @@

Login with Polkadot Mobile

Reaching out

- +

diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index a605be4b..a1176626 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -1222,8 +1222,12 @@ async function main(): Promise { case "peers": if (event.chain === "asset-hub") { const count = event.peers ?? 0; + const first = peerDetail === ""; peerDetail = `${String(count)} ${count === 1 ? "peer" : "peers"}`; - setStatusDetail(peerDetail); + // Announce that peers were found, once. Every later tick is a + // silent visual update, so the count stays readable without + // queueing an announcement per second. + setStatusDetail(peerDetail, { announce: first }); } return; case "stalled": diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 0a52e9a2..dd1b2d7e 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -271,6 +271,18 @@ function smoldotLogCallback( return; } + // Only warnings and errors describe a problem. Everything below that is + // smoldot's structured operational logging, and the patterns below match + // substrings anywhere in a line, including inside key-value payloads. A + // successful `handshake-finished` matched "handshake", a routine + // `connection-activity` matched "closed" through its `write_closed=` + // field, and `foreground-runtime-call-start` matched too. All three + // reached the user as "Bootnode connection issue, <200 chars of smoldot + // internals>" in the loading headline on a normal cold start. + if (level > 2) { + return; + } + // Only surface connection-related messages const lower = message.toLowerCase(); const isConnectionIssue = diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index 58a8dbb6..161c845b 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -120,11 +120,16 @@ body { transform: translateX(100%); } } +/* The loading text ranks by size, weight, and family, never by contrast: + every string here has to clear 4.5:1 against the page. Dimming with + `opacity` is what used to sink the whole block to 1.8-3.2:1, so these + are solid colours measured against #0a0a0a (dark) and #f5f5f5 (light, + see themes.css). */ .loading-progress-pct { font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; font-size: 11px; - color: #fff; - opacity: 0.3; + /* 6.12:1 */ + color: #8f8f8f; min-width: 3ch; text-align: right; } @@ -147,8 +152,8 @@ body { #status { font-size: 13px; font-weight: 500; - color: #fff; - opacity: 0.35; + /* 13.36:1 */ + color: #d4d4d4; letter-spacing: -0.01em; line-height: 1.3; text-align: center; @@ -167,8 +172,8 @@ body { .loading-detail { font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; font-size: 11px; - color: #fff; - opacity: 0.35; + /* 7.85:1 */ + color: #a3a3a3; text-align: center; min-height: 1.3em; } @@ -196,7 +201,9 @@ body { margin-top: 6px; } .loading-hint.visible { - opacity: 0.5; + /* Full opacity: the amber is already AA at 10.32:1, and fading it to + half sank it to 3.27:1. The transition still carries the fade in. */ + opacity: 1; } .loading-hint-text { display: block; diff --git a/packages/ui/src/styles/themes.css b/packages/ui/src/styles/themes.css index 6776c0f0..9f810bd1 100644 --- a/packages/ui/src/styles/themes.css +++ b/packages/ui/src/styles/themes.css @@ -205,10 +205,8 @@ color: #1a1a1a; } [data-theme="light"] .loading-hint { - color: #b45309; -} -[data-theme="light"] .loading-hint.visible { - opacity: 0.9; + /* 6.50:1, where #b45309 was 4.61:1 before the fade and 3.92:1 after. */ + color: #92400e; } [data-theme="light"] .loading-gateway-btn { border-color: rgba(0, 0, 0, 0.16); @@ -231,7 +229,9 @@ background: rgba(0, 0, 0, 0.06); } [data-theme="light"] .loading-progress-fill { - background: rgba(0, 0, 0, 0.35); + /* The fill is the state indicator, so it needs 3:1 as a non-text + component. 0.35 gave 2.43:1 against the page; 0.5 gives 3.88:1. */ + background: rgba(0, 0, 0, 0.5); } /* Loading logo — dark-mode uses `fill: #fff` on the petals, which is invisible on the light-theme cream body. Flip to near-black so the @@ -240,14 +240,16 @@ fill: #111; } [data-theme="light"] .loading-progress-pct { - color: #333; -} -/* Same flip as `#status`: the base rule is `#fff`, which is invisible on the - light-theme background. The detail line tracks the headline's colour rather - than the percentage's — it carries the stall copy, which is the one message - that has to survive a light background. */ + /* 4.89:1, and kept below the detail line so the ranking survives the + flip. #333 would clear AA too, but it would outrank the headline's + own secondary line. */ + color: #6b6b6b; +} +/* Same flip as `#status`. The detail line sits between the headline and the + percentage, because it carries the stall copy, which is the one message + that has to survive a light background. 7.17:1. */ [data-theme="light"] .loading-detail { - color: #1a1a1a; + color: #525252; } [data-theme="light"] .spinner { border-color: #ddd; diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index c59fffc8..33d9f659 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -148,12 +148,13 @@ export function advancePhase(index: number): void { * * This is deliberately not `#status`. The value changes every second or two * during sync and `#status` is a live region, so routing it there would - * queue a screen-reader announcement per change. The detail element is - * aria-hidden and never touches the slow-hint timer. + * queue a screen-reader announcement per change. The detail element carries + * no live region of its own, which leaves it readable on demand but never + * announced, and it never touches the slow-hint timer. * - * Pass `announce` for the state changes a screen-reader user should hear, - * such as stall and recovery copy. Those mirror once into a visually hidden - * polite region, leaving the per-second count silent. + * Pass `announce` for the changes worth interrupting a screen-reader user + * for, such as the first peer count and the stall and recovery copy. Those + * mirror once into a visually hidden polite region. */ export function setStatusDetail( detail: string, From 187f56eeb0c2792e1398e3c75412f6fd02606e0a Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Thu, 6 Aug 2026 10:02:37 +0100 Subject: [PATCH 08/24] Narrate the Asset Hub sync step with the relay peer count, warp progress, and handover, and stop the envelope silently dropping new milestones --- apps/host/src/main.ts | 61 ++++++++++++++++--- apps/protocol/src/main.ts | 6 +- packages/protocol/src/client.ts | 35 +---------- packages/protocol/src/messages.ts | 69 +++++++++++++++++++++ packages/protocol/tests/messages.test.ts | 76 ++++++++++++++++++++++++ packages/resolver/src/smoldot.ts | 26 ++++++-- packages/resolver/tests/smoldot.test.ts | 27 ++++++++- packages/ui/src/ui.ts | 20 +++++++ 8 files changed, 272 insertions(+), 48 deletions(-) diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index a1176626..8768ca22 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -31,6 +31,7 @@ import { showLanding, initPhases, advancePhase, + nudgePhaseProgress, setStatusDetail, stopStatusTick, listenForSandboxStatus, @@ -1213,22 +1214,59 @@ async function main(): Promise { // peer count still in flight, or a late stall on the relay, must not // resurrect it under "Resolving". let syncDetailDone = false; + // The relay warps first and the Asset Hub bootstraps on top of it, so + // the chain the user is waiting on changes partway through the step. + // Peer counts follow that, which is what keeps a number on screen for + // the whole step instead of the last a hundred milliseconds of it. + let waitingOn: "relay" | "asset-hub" = "relay"; + const setPeerDetail = (count: number): void => { + const first = peerDetail === ""; + peerDetail = `${String(count)} ${count === 1 ? "peer" : "peers"}`; + // Announce that peers were found, once. Every later tick is a silent + // visual update, so the count stays readable without queueing an + // announcement per second. + setStatusDetail(peerDetail, { announce: first }); + }; onProtocolChainSync((event) => { log.debug(`[dot.li sync] ${event.chain} ${event.syncKind}`); if (syncDetailDone) { return; } switch (event.syncKind) { + case "connecting": + if (peerDetail === "") { + setStatusDetail("connecting to peers"); + } + return; case "peers": - if (event.chain === "asset-hub") { - const count = event.peers ?? 0; - const first = peerDetail === ""; - peerDetail = `${String(count)} ${count === 1 ? "peer" : "peers"}`; - // Announce that peers were found, once. Every later tick is a - // silent visual update, so the count stays readable without - // queueing an announcement per second. - setStatusDetail(peerDetail, { announce: first }); + if (event.chain === waitingOn) { + setPeerDetail(event.peers ?? 0); + } + return; + case "warpSyncProgress": { + // The one true percentage smoldot offers. Only relays warp, and + // only when they have real distance to cover. + const { at, target } = event; + if ( + at === undefined || + target === undefined || + target <= 0 || + at > target + ) { + return; } + setStatusDetail( + `block ${at.toLocaleString()} of ${target.toLocaleString()}`, + ); + nudgePhaseProgress(at / target); + return; + } + case "warpSyncFinished": + // Fills the second of silence between the relay finishing and the + // Asset Hub finding its first peer. + waitingOn = "asset-hub"; + peerDetail = ""; + setStatusDetail("relay ready, reaching Asset Hub"); return; case "stalled": // No trailing ellipsis: the headline already ends in one, and the @@ -1251,7 +1289,12 @@ async function main(): Promise { } return; case "bootstrapComplete": - if (event.chain === "asset-hub") { + if (event.chain === "relay") { + // A relay short enough to skip warp never sends + // `warpSyncFinished`, so hand over here too. + waitingOn = "asset-hub"; + peerDetail = ""; + } else { advancePhase(3); // The count is meaningless once sync is done. Clear it rather // than letting a stale number sit under "Resolving". diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index 72e2b4e0..ffce9c66 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -690,8 +690,12 @@ async function initDirectMode(): Promise { // host moves the bar on the relay and the Asset Hub, and shows a peer // count for the Asset Hub alone. smoldotMod.enableSyncReporting({ + // The relay warps first and the Asset Hub bootstraps on top of it, so + // both are on the critical path the loading screen narrates. Peer + // counts come from both: the relay reports one about a second before + // the Asset Hub does, and that second is otherwise silent. milestones: ["relay", "asset-hub"], - peerCounts: ["asset-hub"], + peerCounts: ["relay", "asset-hub"], }); // On a smoldot panic, broadcast a fatal envelope to the parent. Direct diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index baba2e4c..68f1aa96 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -13,7 +13,6 @@ import type { ManifestResult, RootManifest, } from "@dotli/resolver/manifest"; -import type { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; import { BASE_DOMAIN, type SiteId } from "@dotli/config/config"; import { getActiveGatewaySupportedGenesisHashes, @@ -25,6 +24,7 @@ import { log } from "@dotli/shared/log"; import { m } from "@dotli/metrics/metrics"; import * as S from "@dotli/metrics/spans"; import { + isChainSyncPayloadValid, isProtocolEnvelope, type ProtocolChainSyncEnvelope, type ProtocolRequestEnvelope, @@ -68,26 +68,6 @@ const sharedAuthListeners = new Set(); const chainSyncListeners = new Set< (event: ProtocolChainSyncEnvelope) => void >(); -// postMessage data is untrusted and the envelope type alone cannot reject a -// spoofed field, so both are checked at runtime. The lists are repeated -// rather than imported because importing a value from the resolver's -// smoldot module would drag smoldot into every bundle that talks to the -// protocol. `satisfies` ties each literal to the resolver's type, so a -// drifting or misspelled entry fails typecheck. -const CHAIN_KEY_VALUES = new Set([ - "relay", - "custom-relay", - "asset-hub", - "bulletin", - "people", -] satisfies ChainKey[]); -const SYNC_KIND_VALUES = new Set([ - "firstPeer", - "bootstrapComplete", - "stalled", - "recovered", - "peers", -] satisfies ChainSyncKind[]); let listenerBound = false; let protocolReady = false; interface ReadyWaiter { @@ -260,18 +240,7 @@ function bindMessageListener(): void { return; } case "chain-sync": { - if ( - !CHAIN_KEY_VALUES.has(msg.chain) || - !SYNC_KIND_VALUES.has(msg.syncKind) - ) { - return; - } - if ( - msg.syncKind === "peers" && - (!Number.isInteger(msg.peers) || - (msg.peers ?? -1) < 0 || - (msg.peers ?? 0) > 10_000) - ) { + if (!isChainSyncPayloadValid(msg)) { return; } broadcast(chainSyncListeners, msg, "Chain sync"); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 219de1bf..41491307 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -115,6 +115,11 @@ export interface ProtocolChainSyncEnvelope { reason?: string; peers?: number; isSyncing?: boolean; + /** Warp position and destination, on `warpSyncProgress`. */ + at?: number; + target?: number; + /** Block the warp settled on, on `warpSyncFinished`. */ + finalized?: number; } // Unsolicited notification from the host iframe to its parent window when a @@ -156,6 +161,70 @@ const VALID_KINDS = new Set([ "auth-storage-changed", ]); +// postMessage data is untrusted and the envelope type alone cannot reject a +// spoofed field, so the chain and the kind are checked at runtime. The lists +// are repeated rather than imported because importing a value from the +// resolver's smoldot module would drag smoldot into every bundle that talks +// to the protocol. +// +// They are written as `Record` rather than an array with +// `satisfies T[]`, because an array only proves every entry is valid and +// says nothing about the ones missing. A kind added to the resolver and +// forgotten here would then be dropped in silence. As a record, a missing +// key fails typecheck, and `chainSyncKinds` in the tests fails too. +/** Every chain the envelope accepts. Exhaustive against `ChainKey`. */ +export const ENVELOPE_CHAIN_KEYS = Object.keys({ + relay: true, + "custom-relay": true, + "asset-hub": true, + bulletin: true, + people: true, +} satisfies Record) as ChainKey[]; + +/** Every milestone the envelope accepts. Exhaustive against `ChainSyncKind`. */ +export const ENVELOPE_SYNC_KINDS = Object.keys({ + firstPeer: true, + bootstrapComplete: true, + stalled: true, + recovered: true, + peers: true, + connecting: true, + warpSyncProgress: true, + warpSyncFinished: true, +} satisfies Record) as ChainSyncKind[]; + +const CHAIN_KEY_VALUES = new Set(ENVELOPE_CHAIN_KEYS); +const SYNC_KIND_VALUES = new Set(ENVELOPE_SYNC_KINDS); + +/** + * Whether a `chain-sync` envelope carries values the loading UI can trust. + * + * Rejects unknown chains and kinds, a peer count that is not a sane integer, + * and any block height that is not a finite positive number, since those + * drive the bar and would render as NaN. + */ +export function isChainSyncPayloadValid( + msg: ProtocolChainSyncEnvelope, +): boolean { + if (!CHAIN_KEY_VALUES.has(msg.chain) || !SYNC_KIND_VALUES.has(msg.syncKind)) { + return false; + } + if ( + msg.syncKind === "peers" && + (!Number.isInteger(msg.peers) || + (msg.peers ?? -1) < 0 || + (msg.peers ?? 0) > 10_000) + ) { + return false; + } + for (const height of [msg.at, msg.target, msg.finalized]) { + if (height !== undefined && (!Number.isFinite(height) || height < 0)) { + return false; + } + } + return true; +} + export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { if ( typeof value !== "object" || diff --git a/packages/protocol/tests/messages.test.ts b/packages/protocol/tests/messages.test.ts index 4e80f57f..70ddd0f4 100644 --- a/packages/protocol/tests/messages.test.ts +++ b/packages/protocol/tests/messages.test.ts @@ -9,6 +9,9 @@ import { setNetwork, } from "@dotli/config/network"; import { + ENVELOPE_CHAIN_KEYS, + ENVELOPE_SYNC_KINDS, + isChainSyncPayloadValid, isProtocolEnvelope, type ProtocolRequestEnvelope, type ProtocolResponseEnvelope, @@ -17,6 +20,7 @@ import { type ProtocolChainMessageEnvelope, type ProtocolChainHaltEnvelope, type ProtocolReadyEnvelope, + type ProtocolChainSyncEnvelope, } from "@dotli/protocol/messages"; describe("isProtocolEnvelope", () => { @@ -152,3 +156,75 @@ describe("genesis hash constants", () => { } }); }); + +describe("chain-sync envelope validation works", () => { + function envelope( + over: Partial = {}, + ): ProtocolChainSyncEnvelope { + return { + namespace: "dotli:protocol", + kind: "chain-sync", + chain: "relay", + syncKind: "firstPeer", + ...over, + }; + } + + // This is the drift guard. The resolver owns the vocabulary, and the + // validator keeps its own runtime copy so smoldot stays out of every + // bundle that talks to the protocol. When the two fell out of step, three + // kinds were dropped in silence and the loading screen simply went quiet. + it("As a user, every sync milestone the resolver can emit reaches the shell", () => { + // Given / When / Then + for (const chain of ENVELOPE_CHAIN_KEYS) { + for (const syncKind of ENVELOPE_SYNC_KINDS) { + expect( + isChainSyncPayloadValid( + envelope({ + chain, + syncKind, + ...(syncKind === "peers" ? { peers: 1 } : {}), + }), + ), + ).toBe(true); + } + } + }); + + it("As a user, a spoofed chain or milestone is refused", () => { + // Given / When / Then + expect( + isChainSyncPayloadValid( + envelope({ + chain: "not-a-chain" as (typeof ENVELOPE_CHAIN_KEYS)[number], + }), + ), + ).toBe(false); + expect( + isChainSyncPayloadValid( + envelope({ + syncKind: "somethingElse" as (typeof ENVELOPE_SYNC_KINDS)[number], + }), + ), + ).toBe(false); + }); + + it("As a user, a nonsense peer count or block height is refused", () => { + // Given / When / Then + for (const peers of [-1, 1.5, 10_001, Number.NaN]) { + expect( + isChainSyncPayloadValid(envelope({ syncKind: "peers", peers })), + ).toBe(false); + } + expect( + isChainSyncPayloadValid( + envelope({ syncKind: "warpSyncProgress", at: Number.NaN, target: 10 }), + ), + ).toBe(false); + expect( + isChainSyncPayloadValid( + envelope({ syncKind: "warpSyncFinished", finalized: -5 }), + ), + ).toBe(false); + }); +}); diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index dd1b2d7e..021f88bc 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -72,10 +72,13 @@ export type ChainKey = (typeof CHAIN_KEYS)[number]; /** * What a chain reports about its own sync. * - * Smoldot emits more milestones than these (connecting, modeDecision, - * warpSyncProgress, warpSyncFinished, stopped). The list covers only what - * the loading UI consumes. `peers` is our own addition, sampled while the - * chain bootstraps rather than reported by smoldot. + * Smoldot emits two more milestones (modeDecision and stopped) that the + * loading UI has nothing to say about. `peers` is our own addition, sampled + * while the chain bootstraps rather than reported by smoldot. + * + * `warpSyncProgress` is the only true percentage in here, and it only + * arrives when a relay has a real warp distance to cover. Short-lived test + * networks jump straight to `warpSyncFinished`. */ export const CHAIN_SYNC_KINDS = [ "firstPeer", @@ -83,6 +86,9 @@ export const CHAIN_SYNC_KINDS = [ "stalled", "recovered", "peers", + "connecting", + "warpSyncProgress", + "warpSyncFinished", ] as const; export type ChainSyncKind = (typeof CHAIN_SYNC_KINDS)[number]; @@ -99,6 +105,12 @@ export interface ChainSyncEvent { peers?: number; /** Whether the chain is still catching up, on `peers`. */ isSyncing?: boolean; + /** Block the warp has proven so far, on `warpSyncProgress`. */ + at?: number; + /** Block the warp is heading for, on `warpSyncProgress`. */ + target?: number; + /** Block the warp settled on, on `warpSyncFinished`. */ + finalized?: number; } type SyncCallback = (event: ChainSyncEvent) => void; @@ -357,10 +369,16 @@ function emitMilestone( persistence.get(chain)?.healthPoller?.stop(); } const reason = kind === "stalled" ? result?.reason : result?.previously; + const heights = result as unknown as Record; emitChainSync({ chain, kind, ...(typeof reason === "string" ? { reason } : {}), + ...(typeof heights.at === "number" ? { at: heights.at } : {}), + ...(typeof heights.target === "number" ? { target: heights.target } : {}), + ...(typeof heights.finalized === "number" + ? { finalized: heights.finalized } + : {}), }); } diff --git a/packages/resolver/tests/smoldot.test.ts b/packages/resolver/tests/smoldot.test.ts index 3c811c54..a445bb5f 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -193,6 +193,30 @@ describe("Light client sync reporting works", () => { ]); }); + it("As a user on a chain with real catching up to do, the shell learns how far along the warp is", async () => { + // Given + enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); + const { raw } = await startRelay(); + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "connecting")); + raw.push(milestone("sub-1", "warpSyncProgress", { at: 20, target: 100 })); + raw.push(milestone("sub-1", "warpSyncProgress", { at: 75, target: 100 })); + raw.push(milestone("sub-1", "warpSyncFinished", { finalized: 100 })); + await flush(); + + // Then + expect(milestones).toEqual([ + { chain: "relay", kind: "connecting" }, + { chain: "relay", kind: "warpSyncProgress", at: 20, target: 100 }, + { chain: "relay", kind: "warpSyncProgress", at: 75, target: 100 }, + { chain: "relay", kind: "warpSyncFinished", finalized: 100 }, + ]); + }); + it("As a user whose connection drops mid-sync, the shell learns why it stalled and when it recovered", async () => { // Given enableSyncReporting({ milestones: ["relay"], peerCounts: [] }); @@ -380,7 +404,8 @@ describe("Light client sync reporting fails", () => { // When raw.push(FOLLOW_REPLY); - raw.push(milestone("sub-1", "warpSyncProgress", { at: 5, target: 9 })); + // `modeDecision` is real and we deliberately have nothing to say about it. + raw.push(milestone("sub-1", "modeDecision", { mode: "warpSync" })); raw.push(appResponse); await flush(); diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 33d9f659..012bb501 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -143,6 +143,26 @@ export function advancePhase(index: number): void { armSlowHint(label); } +/** + * Pull the bar to a real fraction of the current phase's band. + * + * The crawl paces the band on a guess at how long the step takes. When a + * step reports true progress, this moves the bar to where the work actually + * is. Monotonic and clamped to the band, so a late or noisy signal can + * never rewind the bar or push it into the next phase's territory. + */ +export function nudgePhaseProgress(fraction: number): void { + if (!Number.isFinite(fraction) || currentPhase < 0) { + return; + } + const { base, target } = phases[currentPhase]; + const clamped = Math.max(0, Math.min(1, fraction)); + const want = base + (target - base) * clamped; + if (want > currentProgress) { + setProgress(Math.min(want, target)); + } +} + /** * Write the live detail line under the status headline, e.g. "3 peers". * From a9d4270dd7eeceee84dda518f132505d8c6f7f2f Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Thu, 6 Aug 2026 17:45:35 +0100 Subject: [PATCH 09/24] Rebuild the loading screen around a bar that only explains itself after three seconds, with live peer, speed and completion readouts and a network panel behind a globe icon --- apps/host/index.html | 708 +++++++++++------- apps/host/src/main.ts | 117 ++- apps/host/tests/functional/resolution.spec.ts | 25 +- apps/protocol/src/main.ts | 13 +- packages/ui/src/bulletin-bitswap.ts | 103 +++ packages/ui/src/styles/base.css | 101 ++- packages/ui/src/styles/themes.css | 12 +- packages/ui/src/topbar.ts | 205 +++-- packages/ui/src/ui.ts | 85 ++- 9 files changed, 924 insertions(+), 445 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index 934feb25..1db30fa6 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -3,270 +3,474 @@ - - - - Polkadot - The decentralized web, in your browser - - - - - - - - - - - - - - - - - -
- - - Polkadot - Beta - -
-
- - - - -
- - - -
- -
+ s.createIndex("byProductId", "productId", { unique: false }); + s.createIndex("byScheduledAt", "scheduledAt", { unique: false }); + } + if (!db.objectStoreNames.contains("notification_counters")) + db.createObjectStore("notification_counters", { + keyPath: "productId", + }); + }; + req.onsuccess = function () { + resolve(req.result); + }; + req.onerror = function () { + reject(new Error("IDB open failed")); + }; + }); + + + + + + + + + + + + +
+ + - - -
-
-

Login with Polkadot Mobile

- -

Scan with Polkadot Mobile to connect

-
-
-
- -
+ Polkadot + Beta +
+
+
+ + +
+ + + +
+ + +
+ +
+
- -
-
-
Welcome back
-
-
-
- + +
+
+

Login with Polkadot Mobile

+ +

Scan with Polkadot Mobile to connect

+
+
+ +
+
- +
+
+
Welcome back
+
+
+
+ +
+ + -
-
-
-
+
+
+
+
- -
-
-
Permissions
-
-
+
+
+
Permissions
+
+
- -
-
-
- +
-

Status

-

Reaching out

-
- Details -
-
-
Peers
-
0
-
-
-
Speed
-
0 MB/s
-
-
-
Completed
-
0 %
-
-
-
+

Starting up

+
+
+
Peers
+
+
+ AssetHub + +
+
+ Bulletin + +
+
+
+
+
Speed
+
+
+
+
Light client
+
starting
+
+

diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 44d48481..ad10f89f 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -25,7 +25,6 @@ import { captureException, } from "@dotli/metrics/sentry"; import { - showStatus, showError, showNoContentError, showLanding, @@ -33,11 +32,13 @@ import { advancePhase, nudgePhaseProgress, setLoadingMetrics, + setLifecycleStatus, stopStatusTick, listenForSandboxStatus, showGatewayEscape, } from "@dotli/ui/ui"; import type { LoadingPhase } from "@dotli/ui/ui"; +import type { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; import { initTopBar, setChainsButtonVisible, @@ -179,6 +180,27 @@ const T0 = performance.now(); const DOTLI_PRODUCT_ID_PARAM = "dotliProductId"; const blockingModalCoordinator = createBlockingModalCoordinator(); +// Names for the light-client row on the loading screen. Full records rather +// than lookups with a fallback, so adding a chain or a milestone upstream +// fails typecheck here instead of printing a blank. +const CHAIN_NAMES: Record = { + relay: "Polkadot", + "custom-relay": "Relay", + "asset-hub": "Asset Hub", + bulletin: "Bulletin", + people: "People", +}; +const LIFECYCLE_WORDS: Record = { + connecting: "connecting", + firstPeer: "found a peer", + warpSyncProgress: "syncing", + warpSyncFinished: "synced", + bootstrapComplete: "ready", + stalled: "waiting for peers", + recovered: "reconnected", + peers: "connected", +}; + function parseLocalProductIdOverride(): string | undefined { if (!isLocalhost) { return undefined; @@ -1174,11 +1196,41 @@ async function main(): Promise { "resolving-content": 3, }; const smoldotPhases = (startLabel: string): LoadingPhase[] => [ - { label: startLabel, base: 2, target: 6, expectedMs: 650 }, - { label: "Adding relay chain", base: 6, target: 10, expectedMs: 120 }, - { label: "Syncing Asset Hub", base: 10, target: 55, expectedMs: 6500 }, - { label: "Resolving", base: 55, target: 62, expectedMs: 1200 }, - { label: "Fetching content", base: 62, target: 95, expectedMs: 10000 }, + { + label: startLabel, + base: 2, + target: 6, + expectedMs: 650, + stage: "starting", + }, + { + label: "Adding relay chain", + base: 6, + target: 10, + expectedMs: 120, + stage: "relay", + }, + { + label: "Syncing Asset Hub", + base: 10, + target: 55, + expectedMs: 6500, + stage: "assetHub", + }, + { + label: "Resolving", + base: 55, + target: 62, + expectedMs: 1200, + stage: "resolving", + }, + { + label: "Fetching content", + base: 62, + target: 95, + expectedMs: 10000, + stage: "content", + }, ]; if (chainBackend === "smoldot-shared-worker") { initPhases(smoldotPhases("Starting Worker")); @@ -1188,9 +1240,27 @@ async function main(): Promise { // Gateway path resolves over RPC with no smoldot sync, then fetches // content the same way every backend does. initPhases([ - { label: "Connecting", base: 5, target: 50, expectedMs: 1200 }, - { label: "Resolving", base: 50, target: 62, expectedMs: 1200 }, - { label: "Fetching content", base: 62, target: 95, expectedMs: 10000 }, + { + label: "Connecting", + base: 5, + target: 50, + expectedMs: 1200, + stage: "relay", + }, + { + label: "Resolving", + base: 50, + target: 62, + expectedMs: 1200, + stage: "resolving", + }, + { + label: "Fetching content", + base: 62, + target: 95, + expectedMs: 10000, + stage: "content", + }, ]); } // Content fetch (bitswap/IPFS) runs in the sandbox after the CID resolves and @@ -1199,7 +1269,6 @@ async function main(): Promise { // sandbox render. const contentFetchPhase = chainBackend === "rpc-gateway" ? 2 : 4; advancePhase(0); - showStatus(`Resolving ${label}.dot`); // Advance the loading bar from smoldot's typed lifecycle stream instead of // scraping log prose. `firstPeer` on the Asset Hub means a peer was @@ -1214,31 +1283,29 @@ async function main(): Promise { // lives in the SharedWorker, which does not forward lifecycle or health // yet, and the gateway has no smoldot at all. if (chainBackend === "smoldot-direct") { - // The load walks three chains in turn: the relay warps, the Asset Hub - // bootstraps on top of it, then Bulletin comes up to serve the content - // over bitswap. Only the one being waited on drives the peer count, so - // the figure always describes what the user is actually waiting for. - let waitingOn: "relay" | "asset-hub" | "bulletin" = "relay"; + // Peers are reported per chain rather than as one figure for whichever + // chain is currently being waited on. A single figure had to be blanked + // at every handover, which put a zero on screen at exactly the moments + // the load looked slowest. onProtocolChainSync((event) => { log.debug(`[dot.li sync] ${event.chain} ${event.syncKind}`); + // One row reports what smoldot itself says it is doing, so a load that + // looks stuck can be told apart from one that is quietly working. + // Health samples are excluded: they arrive every second and would + // overwrite the milestone that explains the current state. + if (event.syncKind !== "peers") { + setLifecycleStatus( + `${CHAIN_NAMES[event.chain]} ${LIFECYCLE_WORDS[event.syncKind]}`, + ); + } switch (event.syncKind) { - case "connecting": - // A chain that just started has no peers yet, and saying so beats - // leaving the previous chain's figure on screen. - if (event.chain === waitingOn) { - setLoadingMetrics({ peers: 0 }); - } - return; case "peers": - if (event.chain === waitingOn) { - setLoadingMetrics({ peers: event.peers ?? 0 }); + if (event.chain === "asset-hub") { + setLoadingMetrics({ assetHubPeers: event.peers ?? 0 }); + } else if (event.chain === "bulletin") { + setLoadingMetrics({ bulletinPeers: event.peers ?? 0 }); } return; - case "stalled": - case "recovered": - // The peer count already tells this story: a stall is a chain - // sitting at zero peers, and recovery is the number climbing. - return; case "warpSyncProgress": { // The one true percentage smoldot offers. Only relays warp, and // only when they have real distance to cover. @@ -1258,19 +1325,18 @@ async function main(): Promise { advancePhase(2); } return; - case "warpSyncFinished": case "bootstrapComplete": - // Hand the count to the next chain in the sequence and reset it, so - // a finished chain's figure never lingers under the next one. - if (event.chain === "relay") { - waitingOn = "asset-hub"; - setLoadingMetrics({ peers: 0 }); - } else if (event.chain === "asset-hub") { + if (event.chain === "asset-hub") { advancePhase(3); - waitingOn = "bulletin"; - setLoadingMetrics({ peers: 0 }); } return; + case "connecting": + case "stalled": + case "recovered": + case "warpSyncFinished": + // The per-chain peer counts already carry these: a stall is a + // chain sitting at zero, and recovery is the number climbing. + return; } }); @@ -1278,12 +1344,10 @@ async function main(): Promise { // download reports itself: bytes so far against the total the DAG root // declares. onContentProgress(({ bytesFetched, totalBytes, bytesPerSecond }) => { - setLoadingMetrics({ - bytesPerSecond, - ...(totalBytes !== null && totalBytes > 0 - ? { completed: (bytesFetched / totalBytes) * 100 } - : {}), - }); + setLoadingMetrics({ bytesPerSecond }); + // The download's true fraction drives the bar itself, which is where + // a percentage belongs. Printing the same number as text alongside it + // said the same thing twice. if (totalBytes !== null && totalBytes > 0) { nudgePhaseProgress(bytesFetched / totalBytes); } @@ -1413,11 +1477,10 @@ async function main(): Promise { advancePhase(mappedPhase); } emitPhase(msg, phase ?? "progress"); - // Sync milestones usually outrun these status strings, so pass - // the phase and let `showStatus` drop prose describing a step the - // bar already passed. Unmapped messages such as bootnode issues - // and not-found notices carry no phase and always show. - showStatus(msg, { phase: mappedPhase }); + // These strings are the resolver talking to a developer, which is + // how "Walking dag-pb via bitswap..." reached the headline. They + // stay in the debug stream and move the bar; the stage messages + // say the same thing to the user. }; cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); if (cid === null) { @@ -1437,7 +1500,6 @@ async function main(): Promise { await import("@dotli/resolver/rpc-resolve"); const onResolveProgress = (msg: string): void => { emitPhase(msg, "progress"); - showStatus(msg); }; cid = await resolveDotNameViaRpc(`app.${label}`, onResolveProgress); if (cid === null) { diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index 81a166cd..c5e236a5 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -172,41 +172,16 @@ body { opacity: 1; pointer-events: auto; } -.loading-status-heading { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - /* 6.12:1 */ - color: #8f8f8f; - margin-bottom: 4px; -} -.loading-details { - margin-top: 12px; - text-align: left; - display: inline-block; - min-width: 190px; -} -.loading-details-summary { - font-size: 11px; - /* 6.12:1 */ - color: #8f8f8f; - cursor: pointer; - list-style: none; - user-select: none; -} -.loading-details-summary::-webkit-details-marker { - display: none; -} -.loading-details-summary::after { - content: " >"; -} -.loading-details[open] .loading-details-summary::after { - content: " v"; -} .loading-metrics { - margin-top: 8px; + margin-top: 10px; font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; font-size: 11px; + /* The loading block is centred, so the list centres as a unit while its + own rows stay left-aligned. Without this the "Peers" heading floats in + the middle of the column. */ + display: inline-block; + text-align: left; + min-width: 200px; } .loading-metric { display: flex; @@ -214,14 +189,26 @@ body { gap: 24px; padding: 2px 0; } -.loading-metric dt { +.loading-metric dt, +.loading-metric span:first-child { /* 6.12:1 */ color: #8f8f8f; } -.loading-metric dd { +.loading-metric dd, +.loading-metric span:last-child { /* 7.85:1 */ color: #a3a3a3; } +/* Peers is a heading over its chains rather than a row of its own, so the + chain names sit under it and the numbers keep the same right edge. */ +.loading-metric-peers > dt { + /* 6.12:1 */ + color: #8f8f8f; + padding: 2px 0; +} +.loading-metric-peers > dd { + margin-left: 12px; +} /* Slow-step hint (shown below status when a step exceeds its threshold) */ .loading-hint { font-size: 11px; @@ -431,8 +418,16 @@ body { display: inline-flex; } .chains-popover { - min-width: 260px; - padding: 10px 12px; + min-width: 300px; + /* The inset lives here rather than on the rows: padding on a + border-collapse table does not move its cells, so the table would drift + left of the heading. Section headers carry their own 14px, zeroed just + below so everything shares this one edge. */ + padding: 10px 14px 14px; +} +.chains-popover .mode-popover-section { + padding-left: 0; + padding-right: 0; } /* Network panel */ @@ -443,7 +438,8 @@ body { font-size: 12px; /* 13.36:1 */ color: #d4d4d4; - padding: 2px 0 8px; + padding-top: 2px; + padding-bottom: 10px; } .chains-status-dot { width: 8px; @@ -462,14 +458,15 @@ body { } .chains-table { width: 100%; + margin-top: 2px; border-collapse: collapse; font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; - font-size: 10.5px; + font-size: 11px; } .chains-table th, .chains-table td { text-align: right; - padding: 3px 0 3px 12px; + padding: 4px 0 4px 12px; white-space: nowrap; } .chains-table tr th:first-child { diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index d1761735..16e95233 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -962,6 +962,17 @@ function createPermissionDropdown( */ const chainSyncState = new Map(); let onStatusChange: (() => void) | null = null; +let chainsRefreshTimer: ReturnType | null = null; + +/** How often the open panel re-reads peers and heights. */ +const CHAINS_REFRESH_MS = 6_000; + +function stopChainsRefresh(): void { + if (chainsRefreshTimer !== null) { + clearInterval(chainsRefreshTimer); + chainsRefreshTimer = null; + } +} function describeNetworkStatus(): { text: string; tone: string } { const states = [...chainSyncState.values()]; @@ -1043,6 +1054,7 @@ function renderChainsPopover(parent: HTMLElement): void { } table.appendChild(head); + const refreshers: (() => void)[] = []; for (const [label, genesis] of chains) { const row = document.createElement("tr"); const name = document.createElement("th"); @@ -1056,15 +1068,29 @@ function renderChainsPopover(parent: HTMLElement): void { row.append(name, ...cells); table.appendChild(row); - void queryPeerCount(genesis).then((n) => { - cells[0].textContent = n === null ? "n/a" : String(n); - }); - void queryChainBlocks(genesis).then((blocks) => { - cells[1].textContent = blocks?.best ?? "n/a"; - cells[2].textContent = blocks?.finalized ?? "n/a"; - }); + const refresh = (): void => { + void queryPeerCount(genesis).then((n) => { + cells[0].textContent = n === null ? "n/a" : String(n); + }); + void queryChainBlocks(genesis).then((blocks) => { + cells[1].textContent = blocks?.best ?? "n/a"; + cells[2].textContent = blocks?.finalized ?? "n/a"; + }); + }; + refresh(); + refreshers.push(refresh); } parent.appendChild(table); + + // Heights and ages go stale within a block, so keep re-reading while the + // panel is on screen. The interval is cleared on close, which is what + // keeps this from waking four chains for a panel nobody is looking at. + stopChainsRefresh(); + chainsRefreshTimer = setInterval(() => { + for (const refresh of refreshers) { + refresh(); + } + }, CHAINS_REFRESH_MS); } /** @@ -1090,6 +1116,8 @@ function initChainsPopover(): void { const close = (): void => { popover.classList.remove("open"); button.setAttribute("aria-expanded", "false"); + stopChainsRefresh(); + onStatusChange = null; }; button.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 3c045230..76134f45 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -8,7 +8,6 @@ import { loadRecentLabels, forgetRecentLabel } from "./recent-labels"; import { BASE_DOMAIN, isSandboxOrigin } from "@dotli/config/config"; -import { getBackend } from "@dotli/config/mode"; import { escapeHtml, validateDotLabel } from "@dotli/shared/html"; import type { DotLabelResult } from "@dotli/shared/html"; @@ -40,6 +39,8 @@ export interface LoadingPhase { base: number; target: number; expectedMs: number; + /** Which set of messages narrates this phase. */ + stage: LoadingStage; } let phases: LoadingPhase[] = []; let currentPhase = -1; @@ -48,9 +49,9 @@ let currentPhase = -1; let progressFillEl: HTMLElement | null = null; let progressPctEl: HTMLElement | null = null; let statusBlockEl: HTMLElement | null = null; -let metricPeersEl: HTMLElement | null = null; +let metricAssetHubPeersEl: HTMLElement | null = null; +let metricBulletinPeersEl: HTMLElement | null = null; let metricSpeedEl: HTMLElement | null = null; -let metricCompletedEl: HTMLElement | null = null; let revealTimer: ReturnType | null = null; let currentProgress = 0; let targetProgress = 0; @@ -59,6 +60,20 @@ let progressInterval: ReturnType | null = null; const CRAWL_TICK_MS = 200; +// A band that runs longer than its estimate used to leave the bar frozen at +// the band top: the worst case was the last one, which reached 95% and sat +// there for up to 6s while the sandbox unpacked and painted. When a band is +// exhausted the bar creeps on into the next band's space, which is free +// because the step that owns it has not started. The creep is slow enough to +// read as waiting rather than as a second, faster load, and it stops short +// of 100 so only a finished load can fill the bar. +// Paced so the displayed whole number keeps changing every few seconds +// rather than only twice over the whole creep. +const CREEP_CEILING = 99; +const CREEP_MS = 10_000; +let creepCeiling = 0; +let creepStep = 0; + function setProgress(pct: number): void { currentProgress = pct; if (progressFillEl !== null) { @@ -74,6 +89,10 @@ function startProgressCrawl(): void { progressInterval = setInterval(() => { if (currentProgress < targetProgress) { setProgress(Math.min(currentProgress + crawlStep, targetProgress)); + return; + } + if (currentProgress < creepCeiling) { + setProgress(Math.min(currentProgress + creepStep, creepCeiling)); } }, CRAWL_TICK_MS); } @@ -107,9 +126,9 @@ export function initPhases(phaseList: LoadingPhase[]): void { progressFillEl = document.getElementById("loading-progress-fill"); progressPctEl = document.getElementById("loading-progress-pct"); statusBlockEl = document.getElementById("loading-status"); - metricPeersEl = document.getElementById("metric-peers"); + metricAssetHubPeersEl = document.getElementById("metric-peers-assethub"); + metricBulletinPeersEl = document.getElementById("metric-peers-bulletin"); metricSpeedEl = document.getElementById("metric-speed"); - metricCompletedEl = document.getElementById("metric-completed"); // A load that finishes quickly should never explain itself. Only once it // has run long enough to feel slow does the status block appear. @@ -124,25 +143,131 @@ export function initPhases(phaseList: LoadingPhase[]): void { /** How long a load may run before it owes the user an explanation. */ export const STATUS_REVEAL_MS = 3_000; +// No sentence may hold the screen longer than three seconds. Set just under +// it so a tick that lands late still clears the bar. +const MESSAGE_ROTATE_MS = 2_800; + +/** The steps a load moves through, in the order they happen. */ +export type LoadingStage = + | "starting" + | "relay" + | "assetHub" + | "resolving" + | "content"; + /** - * Live counters under the status line. Each is written only when supplied, - * so a caller can update the peer count without claiming a download speed - * it has no reading for. + * What the shell is doing, in the user's terms. + * + * The headline used to print the resolver's own prose, which is where + * "Walking dag-pb via bitswap..." came from. These say the same thing in + * plain words. Each stage carries several lines because a step can run for + * half a minute, and one frozen sentence reads as a hang. The first line of + * each stage names the step. The rest explain what a light client is doing + * and why it takes the time it does, so a slow load teaches something + * instead of just apologising. + */ +const STAGE_MESSAGES: Record = { + starting: [ + "Starting up", + "This page checks the blockchain itself, with no server in between", + "That takes a few seconds the first time", + ], + relay: [ + "Connecting to Polkadot", + "Looking for other computers on the network to talk to", + "Your browser is running a small node of its own", + ], + assetHub: [ + "Looking up the name", + "Catching up with the latest blocks", + "The name and its address are being read from the chain", + "This is the slow part, and it is faster next time", + ], + resolving: [ + "Found the name", + "Reading the address it points at", + "The chain proved this answer, so it cannot be faked", + ], + content: [ + "Downloading the app", + "The files come from other people on the network, piece by piece", + "Speed depends on how many of them are nearby", + "Almost there", + ], +}; + +let stageTimer: ReturnType | null = null; +let currentStage: LoadingStage | "" = ""; + +function writeStatus(message: string): void { + const status = document.getElementById("status"); + if (status !== null) { + status.textContent = message; + } +} + +/** + * Move to a stage and start cycling its messages. + * + * Re-entering a stage already running is ignored, so the copy does not + * restart every time a signal arrives for a step that is already underway. + */ +export function setLoadingStage(stage: LoadingStage): void { + if (stage === currentStage) { + return; + } + currentStage = stage; + const messages = STAGE_MESSAGES[stage]; + let index = 0; + writeStatus(messages[0]); + stopStageMessages(); + // Cycle back to the second line rather than the first: the opener names + // the step, and showing it again would read as the load starting over. + const loopFrom = messages.length > 2 ? 1 : 0; + stageTimer = setInterval(() => { + index = index + 1 >= messages.length ? loopFrom : index + 1; + writeStatus(messages[index]); + }, MESSAGE_ROTATE_MS); +} + +function stopStageMessages(): void { + if (stageTimer !== null) { + clearInterval(stageTimer); + stageTimer = null; + } +} + +/** Report what the light client itself is doing, e.g. "syncing Asset Hub". */ +export function setLifecycleStatus(text: string): void { + const el = document.getElementById("metric-lifecycle"); + if (el !== null) { + el.textContent = text; + } +} + +/** + * Live counters under the status line. + * + * Each is written only when supplied, and each starts as an em dash rather + * than a zero: before the download begins there is no speed to report, and + * "0 MB/s" reads as broken where "not yet" reads as honest. Peers are per + * chain because one shared figure had to be blanked at every handover, + * which put a zero on screen at the moments the load looked slowest. */ export function setLoadingMetrics(metrics: { - peers?: number; + assetHubPeers?: number; + bulletinPeers?: number; bytesPerSecond?: number; - completed?: number; }): void { - if (metrics.peers !== undefined && metricPeersEl !== null) { - metricPeersEl.textContent = String(metrics.peers); + if (metrics.assetHubPeers !== undefined && metricAssetHubPeersEl !== null) { + metricAssetHubPeersEl.textContent = String(metrics.assetHubPeers); + } + if (metrics.bulletinPeers !== undefined && metricBulletinPeersEl !== null) { + metricBulletinPeersEl.textContent = String(metrics.bulletinPeers); } if (metrics.bytesPerSecond !== undefined && metricSpeedEl !== null) { metricSpeedEl.textContent = `${(metrics.bytesPerSecond / 1_048_576).toFixed(1)} MB/s`; } - if (metrics.completed !== undefined && metricCompletedEl !== null) { - metricCompletedEl.textContent = `${String(Math.min(100, Math.round(metrics.completed)))} %`; - } } /** @@ -169,16 +294,23 @@ export function advancePhase(index: number): void { // steadily through a long sync instead of stalling near the top. crawlStep = ((target - base) * CRAWL_TICK_MS) / Math.max(expectedMs, CRAWL_TICK_MS); + // Headroom for a band that overruns: the next band's space, or the ceiling + // for the last one. Borrowing at most one band keeps a single slow step + // from eating the whole bar, and `advancePhase` never moves the bar + // backwards, so the next step simply carries on from wherever the creep + // reached. + const next = phases[index + 1] as LoadingPhase | undefined; + creepCeiling = Math.min(next?.target ?? CREEP_CEILING, CREEP_CEILING); + creepStep = + (Math.max(creepCeiling - target, 0) * CRAWL_TICK_MS) / + Math.max(CREEP_MS, CRAWL_TICK_MS); startProgressCrawl(); - // Update headline. Re-arm the slow hint against the new label: the timer - // armed for the previous step would otherwise fire with a hint describing - // work that already completed. - const status = document.getElementById("status"); - if (status !== null) { - status.textContent = label; - } - armSlowHint(label); + // The headline is the stage's, not the phase label's: the label names the + // band for whoever reads this table, the stage speaks to the user. Several + // phases can share one stage, and re-entering a running stage is a no-op. + void label; + setLoadingStage(phases[index].stage); } /** @@ -186,8 +318,8 @@ export function advancePhase(index: number): void { * * The crawl paces the band on a guess at how long the step takes. When a * step reports true progress, this moves the bar to where the work actually - * is. Monotonic and clamped to the band, so a late or noisy signal can - * never rewind the bar or push it into the next phase's territory. + * is. Monotonic and clamped to the band, so a late or noisy signal can never + * rewind the bar, including past a point the overrun creep has reached. */ export function nudgePhaseProgress(fraction: number): void { if (!Number.isFinite(fraction) || currentPhase < 0) { @@ -251,79 +383,9 @@ export function showGatewayEscape( }; } -// Single-line status. Updates #status in place. Shows a slow-step -// hint when a step exceeds its time threshold. - -// Per-step timeout thresholds (seconds). If a step exceeds its -// limit, a contextual hint fades in below the status line. -type SlowHint = string | { smoldot: string; rpc: string }; -const SLOW_THRESHOLDS: Record = { - "Starting light client": { - secs: 8, - hint: "The smoldot light client is slow to initialize — could be a network issue", - }, - "Adding Paseo relay chain": { - secs: 10, - hint: "Paseo relay chain bootstrap is stalled — smoldot may be having trouble reaching bootnodes", - }, - "Connecting to Asset Hub": { - secs: 12, - hint: "Asset Hub parachain connection is taking long — the chain may be congested or peers unavailable", - }, - Syncing: { - secs: 15, - hint: "Asset Hub sync is slow — smoldot is still catching up to the latest finalized block on the Paseo relay chain", - }, - Resolving: { - secs: 10, - hint: { - smoldot: "Smoldot is still catching up on the Paseo relay chain", - rpc: "The RPC endpoint is slow to answer the resolver query", - }, - }, - "Connecting to peers": { - secs: 10, - hint: "Helia P2P peer discovery is slow — WebRTC relay nodes may be unreachable", - }, - "Fetching content via P2P": { - secs: 15, - hint: "P2P content transfer is slow — the content may have few seeders on the Bulletin network", - }, - "Fetching directory via P2P": { - secs: 15, - hint: "Directory fetch is slow — multi-file archives take longer over P2P", - }, - "Initializing P2P client": { - secs: 8, - hint: "Helia startup is stalled — WASM or WebRTC initialization may be blocked", - }, -}; - -function resolveHint(hint: SlowHint): string { - if (typeof hint === "string") { - return hint; - } - return getBackend() === "rpc-gateway" ? hint.rpc : hint.smoldot; -} - -function getSlowThreshold( - message: string, -): { secs: number; hint: string } | null { - for (const [key, value] of Object.entries(SLOW_THRESHOLDS)) { - if (message.startsWith(key) || message.includes(key.toLowerCase())) { - return { secs: value.secs, hint: resolveHint(value.hint) }; - } - } - return { secs: 20, hint: "This is taking longer than expected" }; -} - -let slowTimer: ReturnType | null = null; +// Single-line status. Updates #status in place. function clearSlowWarning(): void { - if (slowTimer !== null) { - clearTimeout(slowTimer); - slowTimer = null; - } const hint = document.getElementById("loading-hint"); if (hint !== null) { // Remove only the text span, preserve any gateway button @@ -338,52 +400,6 @@ function clearSlowWarning(): void { } } -/** - * Update the single status line below the progress bar. - * Replaces the previous message in place. No new DOM elements are created. - * Schedules a slow-step hint if the step exceeds its time threshold. - */ -export function showStatus( - message: string, - opts: { phase?: number } = {}, -): void { - // Callers that know which phase a message describes pass it, so prose - // about a step the bar already passed is dropped instead of flipping the - // headline backwards. `advancePhase` enforces the same rule for the bar. - if (opts.phase !== undefined && opts.phase < currentPhase) { - return; - } - const status = document.getElementById("status"); - if (status !== null) { - status.textContent = message; - } - armSlowHint(message); -} - -function armSlowHint(message: string): void { - clearSlowWarning(); - - const threshold = getSlowThreshold(message); - if (threshold !== null) { - slowTimer = setTimeout(() => { - const hint = document.getElementById("loading-hint"); - if (hint !== null) { - // Remove previous text span if any - const existing = hint.querySelector(".loading-hint-text"); - if (existing !== null) { - existing.remove(); - } - // Insert text as a span so it doesn't wipe the gateway button - const span = document.createElement("span"); - span.className = "loading-hint-text"; - span.textContent = threshold.hint; - hint.insertBefore(span, hint.firstChild); - hint.classList.add("visible"); - } - }, threshold.secs * 1000); - } -} - /** * Stop the progress crawl and clear any slow warning (call when loading is done). */ @@ -408,6 +424,7 @@ export function dismissLoading(): void { completeProgress(); clearSlowWarning(); cancelStatusReveal(); + stopStageMessages(); const loading = document.querySelector("#app > .loading"); if (loading !== null) { loading.style.transition = "opacity 0.3s ease"; @@ -445,9 +462,9 @@ export function listenForSandboxStatus(): void { if (!isSandboxOrigin(event.origin)) { return; } - if (typeof data.message === "string") { - showStatus(data.message); - } + // The sandbox's own progress prose is written for a developer reading + // the console, so it is left there. The stage messages narrate this step + // to the user, and `done` is the part the loading screen acts on. if (data.done === true) { dismissLoading(); } From 3d77a23a01d02412bfe3d3d14563f38178997a74 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Fri, 7 Aug 2026 14:37:25 +0100 Subject: [PATCH 13/24] Reword the loading messages to name the domain and drop the chain jargon --- apps/host/index.html | 2 +- apps/host/src/main.ts | 2 ++ packages/ui/src/ui.ts | 39 ++++++++++++++++++++++++++------------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index 0048e335..ff32c2a5 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -419,7 +419,7 @@

Login with Polkadot Mobile

-

Starting up

+

Reaching out

Peers
diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index ad10f89f..390f8c7c 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -32,6 +32,7 @@ import { advancePhase, nudgePhaseProgress, setLoadingMetrics, + setLoadingDomain, setLifecycleStatus, stopStatusTick, listenForSandboxStatus, @@ -1268,6 +1269,7 @@ async function main(): Promise { // It is always the last phase; advance to it just before handing off to the // sandbox render. const contentFetchPhase = chainBackend === "rpc-gateway" ? 2 : 4; + setLoadingDomain(label); advancePhase(0); // Advance the loading bar from smoldot's typed lifecycle stream instead of diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 76134f45..75db0cdd 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -66,9 +66,8 @@ const CRAWL_TICK_MS = 200; // exhausted the bar creeps on into the next band's space, which is free // because the step that owns it has not started. The creep is slow enough to // read as waiting rather than as a second, faster load, and it stops short -// of 100 so only a finished load can fill the bar. -// Paced so the displayed whole number keeps changing every few seconds -// rather than only twice over the whole creep. +// of 100 so only a finished load can fill the bar. Paced so the displayed +// whole number keeps changing every few seconds rather than twice in total. const CREEP_CEILING = 99; const CREEP_MS = 10_000; let creepCeiling = 0; @@ -147,6 +146,9 @@ export const STATUS_REVEAL_MS = 3_000; // it so a tick that lands late still clears the bar. const MESSAGE_ROTATE_MS = 2_800; +/** Placeholder swapped for the domain being loaded when a message is shown. */ +const DOMAIN_TOKEN = "{domain}"; + /** The steps a load moves through, in the order they happen. */ export type LoadingStage = | "starting" @@ -168,41 +170,52 @@ export type LoadingStage = */ const STAGE_MESSAGES: Record = { starting: [ - "Starting up", - "This page checks the blockchain itself, with no server in between", + "Reaching out", + "This page is verified by a network, with no one in between", "That takes a few seconds the first time", ], relay: [ "Connecting to Polkadot", "Looking for other computers on the network to talk to", - "Your browser is running a small node of its own", + "Your browser does the checking itself, not a server", ], assetHub: [ - "Looking up the name", + `Looking up ${DOMAIN_TOKEN}`, "Catching up with the latest blocks", - "The name and its address are being read from the chain", + "The name and its address come from the network itself", "This is the slow part, and it is faster next time", ], resolving: [ - "Found the name", + "I found it", "Reading the address it points at", - "The chain proved this answer, so it cannot be faked", + "The network proved this answer, so it cannot be faked", ], content: [ "Downloading the app", - "The files come from other people on the network, piece by piece", - "Speed depends on how many of them are nearby", + "The files come from multiple peers across the network", + "Speed depends on how many are nearby", "Almost there", ], }; let stageTimer: ReturnType | null = null; let currentStage: LoadingStage | "" = ""; +let loadingDomain = ""; + +/** Name the domain being loaded, for the messages that mention it. */ +export function setLoadingDomain(domain: string): void { + loadingDomain = domain; +} function writeStatus(message: string): void { const status = document.getElementById("status"); if (status !== null) { - status.textContent = message; + // Falls back to "the name" when no domain has been set, which is the + // preview and local-target paths where there is no `.dot` to name. + status.textContent = message.replace( + DOMAIN_TOKEN, + loadingDomain === "" ? "the name" : `${loadingDomain}.dot`, + ); } } From 1467f12891f60eb7502e34d1e2396798e6d9f353 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Fri, 7 Aug 2026 15:11:03 +0100 Subject: [PATCH 14/24] Turn the logo into the progress indicator and retire the loading bar --- apps/host/index.html | 112 +++++++++++++----------------- apps/host/src/main.ts | 9 +++ packages/ui/src/styles/base.css | 110 +++++++++++++---------------- packages/ui/src/styles/themes.css | 26 +++---- packages/ui/src/ui.ts | 73 ++++++++++++------- 5 files changed, 161 insertions(+), 169 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index ff32c2a5..980d1446 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -375,45 +375,61 @@

Login with Polkadot Mobile

-