diff --git a/apps/host/index.html b/apps/host/index.html index dfba7d47..2233f9b3 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -3,268 +3,495 @@ - - - - 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% +
+
+ +
+ + +

+
+
+
Status
+
starting
+
+
+
Peers
+
+
+ Relay + +
+
+ AssetHub + +
+
+ Bulletin + +
+
+
+
+
Speed
+
+
+
+
- - - +
+
+ + + diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 0200c880..a782d3c4 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -25,25 +25,37 @@ import { captureException, } from "@dotli/metrics/sentry"; import { - showStatus, showError, showNoContentError, showLanding, initPhases, advancePhase, + nudgePhaseProgress, + releasePhaseProgress, + setLoadingMetrics, + setLoadingDomain, + setLoadingStage, + setLifecycleStatus, stopStatusTick, listenForSandboxStatus, - showGatewayEscape, } from "@dotli/ui/ui"; import type { LoadingPhase } from "@dotli/ui/ui"; -import { initTopBar, wipeOriginState } from "@dotli/ui/topbar"; +import type { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; +import { + initTopBar, + setChainsButtonVisible, + wipeOriginState, +} from "@dotli/ui/topbar"; import { createBlockingModalCoordinator } from "@dotli/ui/blocking-modal-queue"; import { bitswapGet, listenForSandboxBitswap, + onContentProgress, } from "@dotli/ui/bulletin-bitswap"; import { ensureProtocolFrame, + onProtocolChainSync, + onProtocolNetBytes, resetProtocolFrame, resolveDotNameRemote, resolveExecutableManifestRemote, @@ -67,6 +79,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"; @@ -170,6 +183,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": "AssetHub", + 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; @@ -1154,12 +1188,61 @@ 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, + }; + // Only direct mode relays the sandbox's bitswap traffic through this window, + // so it is the only backend that can report a download percentage. + const countsContentBytes = chainBackend === "smoldot-direct"; 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", + // The download counts its own bytes against the total the DAG root + // declares, so this band is driven by that rather than by the clock. + // Only where something is actually counting: a band that waits for a + // percentage nobody will send would hold the indicator at 62 for the + // whole fetch. + reportsProgress: countsContentBytes, + }, ]; if (chainBackend === "smoldot-shared-worker") { initPhases(smoldotPhases("Starting Worker")); @@ -1169,9 +1252,30 @@ 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", + // No `reportsProgress` here. Gateway mode pulls the archive over HTTP + // from the sandbox, so nothing counts its bytes through this window + // and there would be no percentage for the indicator to wait on. + }, ]); } // Content fetch (bitswap/IPFS) runs in the sandbox after the CID resolves and @@ -1179,8 +1283,156 @@ 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); - 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. 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 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") { + // 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 "peers": + // The relay's count stops updating once it is bootstrapped, because + // the health poll stops with it, so that row is a snapshot of the + // peers it settled on rather than a live figure. + if (event.peers === undefined) { + return; + } + if (event.chain === "relay") { + setLoadingMetrics({ relayPeers: event.peers }); + } else if (event.chain === "asset-hub") { + setLoadingMetrics({ assetHubPeers: event.peers }); + } else if (event.chain === "bulletin") { + setLoadingMetrics({ bulletinPeers: event.peers }); + if (event.peers > 0) { + // The download can start, so the clock is a fair fallback from + // here. Holding is only honest while there is no peer to fetch + // from: an archive served from the sandbox's own cache never + // asks this window for a block, and would otherwise sit at the + // band base until the app painted. + releasePhaseProgress(); + } + } + 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 ( + (event.chain === "relay" || event.chain === "custom-relay") && + at !== undefined && + target !== undefined && + target > 0 && + at <= target + ) { + nudgePhaseProgress(at / target, "relay"); + } + return; + } + case "firstPeer": + if (event.chain === "asset-hub") { + advancePhase(2); + } + return; + case "bootstrapComplete": + if (event.chain === "asset-hub") { + advancePhase(3); + } + 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; + } + }); + + // Speed is the whole load's throughput, not one step's. The chain sync + // dominates the first half of a cold load and the archive download the + // second, so both are added up and the rate is taken over a short + // trailing window. Reporting only the archive left the readout at zero + // for the seconds the light client was working hardest. + let chainBytes = 0; + let contentBytes = 0; + // Seeded at the page's own start with nothing downloaded, which is true + // and means the first report from the protocol frame already has a second + // reading to be measured against. Without it the readout stayed blank + // until the frame's second message. + const samples: { at: number; total: number }[] = [{ at: 0, total: 0 }]; + const SPEED_WINDOW_MS = 3_000; + const reportSpeed = (): void => { + const now = performance.now(); + samples.push({ at: now, total: chainBytes + contentBytes }); + while (samples.length > 1 && now - samples[0].at > SPEED_WINDOW_MS) { + samples.shift(); + } + const oldest = samples[0]; + const span = now - oldest.at; + if (span > 0) { + setLoadingMetrics({ + bytesPerSecond: + ((chainBytes + contentBytes - oldest.total) / span) * 1000, + }); + } + }; + onProtocolNetBytes(({ received }) => { + chainBytes = received; + reportSpeed(); + }); + + // Every block the sandbox needs is fetched through this window, so the + // download reports itself: bytes so far against the total the DAG root + // declares. + onContentProgress(({ bytesFetched, totalBytes }) => { + contentBytes = bytesFetched; + reportSpeed(); + // 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) { + // Bytes are arriving but the DAG root declared no total, so there is + // no percentage to be had. Hand the indicator back to the clock. + releasePhaseProgress(); + } + if (totalBytes !== null && totalBytes > 0) { + nudgePhaseProgress(bytesFetched / totalBytes, "content"); + // The tail of the load is the sandbox unpacking the archive and + // painting, which used to hide behind download copy while the bar + // crept. Only blocks relayed for the sandbox are counted here, and + // the sandbox is mounted after the content phase begins, so this + // cannot fire while an earlier step is still on screen. + if (bytesFetched >= totalBytes) { + setLoadingStage("preparing"); + } + } + }); + } try { const cachedCid = cacheSettings.skipCidCache @@ -1202,6 +1454,7 @@ async function main(): Promise { // as `dotli.e2e.fast_path` alongside `dotli.e2e.slow_path`. await m.span(S.E2E_FAST, async () => { setShieldState(shieldState); + setChainsButtonVisible(true); const { renderAppSubdomain } = await renderChunkPromise; advancePhase(contentFetchPhase); await renderAppSubdomain(cachedCid, label); @@ -1283,48 +1536,29 @@ async function main(): Promise { log.warn( `[dot.li resolve] path=smoldot (trustless light-client) (${elapsed(T0)})`, ); - // After 10s of slow loading on the verified path, surface a one-click - // escape to the gateway backend. The user trades the light-client - // verification badge for a faster, trust-based load. - const cancelGatewayEscape = showGatewayEscape(() => { - m.count(S.GATEWAY_ESCAPE, { from_backend: chainBackend }); - switchBackendAndReload("rpc-gateway"); - }); - - try { - const { statusToPhase } = await import("@dotli/resolver/resolve"); - const onResolveProgress = (msg: string): void => { - // Progress events arrive as opaque strings across the iframe - // boundary. The resolver package owns the authoritative - // 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); - } - emitPhase(msg, phase ?? "progress"); - showStatus(msg); - }; - cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); - if (cid === null) { - cid = await resolveDotNameRemote(label, onResolveProgress); - log.warn( - `[dot.li resolve] fallback ${label}.dot contenthash -> ${cid ?? "null"}`, - ); + const { statusToPhase } = await import("@dotli/resolver/resolve"); + const onResolveProgress = (msg: string): void => { + // Progress events arrive as opaque strings across the iframe + // boundary. The resolver package owns the authoritative mapping from + // status text to ResolvePhase, so we defer to it instead of + // maintaining a parallel regex here. + const phase = statusToPhase(msg); + const mappedPhase = phase === null ? undefined : PHASE_INDEX[phase]; + if (mappedPhase !== undefined) { + advancePhase(mappedPhase); } - } finally { - cancelGatewayEscape(); + emitPhase(msg, phase ?? "progress"); + // 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) { + cid = await resolveDotNameRemote(label, onResolveProgress); + log.warn( + `[dot.li resolve] fallback ${label}.dot contenthash -> ${cid ?? "null"}`, + ); } } else { log.warn( @@ -1334,7 +1568,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) { @@ -1381,6 +1614,7 @@ async function main(): Promise { setShieldState(shieldState); + setChainsButtonVisible(true); const { renderAppSubdomain } = await renderChunkPromise; advancePhase(contentFetchPhase); await renderAppSubdomain(cid, label); diff --git a/apps/host/tests/functional/loading.spec.ts b/apps/host/tests/functional/loading.spec.ts index 99519def..bc604138 100644 --- a/apps/host/tests/functional/loading.spec.ts +++ b/apps/host/tests/functional/loading.spec.ts @@ -131,30 +131,6 @@ const successfulResolveResponse = (cid: string): string => ` }); `; -/** Rescale any setTimeout call whose delay matches `fromMs` down to `toMs`. */ -async function shrinkTimeout( - page: Page, - fromMs: number, - toMs: number, -): Promise { - await page.addInitScript( - ([from, to]) => { - const orig = window.setTimeout.bind(window); - window.setTimeout = (( - handler: TimerHandler, - ms?: number, - ...rest: unknown[] - ) => - orig( - handler, - ms === from ? to : ms, - ...rest, - )) as typeof window.setTimeout; - }, - [fromMs, toMs], - ); -} - test("As a user using smoldot directly, when the light client panics mid-resolution, I see the appropriate error and can switch backend", async ({ page, }) => { @@ -265,12 +241,11 @@ test("As a user using smoldot in shared worker, when the worker dies silently, I ); }); -test("As a user using smoldot directly, when loading is slow (>10s) I see a one-click gateway escape, and if it times out (>45s) I see the appropriate error and can switch backend", async ({ +test("As a user using smoldot directly, when the resolution times out I see the appropriate error and can switch backend", async ({ page, }) => { // Given await setBackend(page, "smoldot-direct"); - await shrinkTimeout(page, 10_000, 500); await mockProtocolIframe( page, errorResolveResponse( @@ -283,10 +258,6 @@ test("As a user using smoldot directly, when loading is slow (>10s) I see a one- await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); // Then - await expect(page.locator(".loading-gateway-btn")).toContainText( - "Use Trusted Provider", - { timeout: 5_000 }, - ); await expect(page.locator(".error-page-title")).toHaveText( "Domain can't be reached", { timeout: 10_000 }, @@ -302,12 +273,11 @@ test("As a user using smoldot directly, when loading is slow (>10s) I see a one- ); }); -test("As a user using smoldot in shared worker, when loading is slow (>10s) I see a one-click gateway escape, and if it times out (>45s) I see the appropriate error and can switch backend", async ({ +test("As a user using smoldot in shared worker, when the resolution times out I see the appropriate error and can switch backend", async ({ page, }) => { // Given await setBackend(page, "smoldot-shared-worker"); - await shrinkTimeout(page, 10_000, 500); await mockProtocolIframe( page, errorResolveResponse( @@ -320,10 +290,6 @@ test("As a user using smoldot in shared worker, when loading is slow (>10s) I se await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); // Then - await expect(page.locator(".loading-gateway-btn")).toContainText( - "Use Trusted Provider", - { timeout: 5_000 }, - ); await expect(page.locator(".error-page-title")).toHaveText( "Domain can't be reached", { timeout: 10_000 }, @@ -339,35 +305,6 @@ test("As a user using smoldot in shared worker, when loading is slow (>10s) I se ); }); -test("As a user using smoldot directly, when I click the gateway escape, the backend flips to rpc-gateway and the page reloads", async ({ - page, -}) => { - // Given - await setBackend(page, "smoldot-direct"); - await shrinkTimeout(page, 10_000, 500); - await mockProtocolIframe( - page, - errorResolveResponse("never resolves in test window", 30_000), - ); - - // When - await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); - const gatewayBtn = page.locator(".loading-gateway-btn"); - await expect(gatewayBtn).toContainText("Use Trusted Provider", { - timeout: 5_000, - }); - await Promise.all([ - page.waitForLoadState("domcontentloaded"), - gatewayBtn.click(), - ]); - - // Then - const backend = await page.evaluate(() => - localStorage.getItem("dotli:chain-backend"), - ); - expect(backend).toBe("rpc-gateway"); -}); - test("As a user, when the app chunks fail to load mid-session, I see the appropriate error with a reload button", async ({ page, }) => { diff --git a/apps/host/tests/functional/resolution.spec.ts b/apps/host/tests/functional/resolution.spec.ts index 3736e54d..b5b8649c 100644 --- a/apps/host/tests/functional/resolution.spec.ts +++ b/apps/host/tests/functional/resolution.spec.ts @@ -36,4 +36,71 @@ test.describe("Resolution across chain backends", () => { } }); } + + test(`As a user opening ${DOMAIN}.dot, I am shown how many peers the light client found`, async ({ + browser, + }) => { + // Given + const { context, page } = await setupTest(browser, { + backend: "smoldot-direct", + }); + + try { + // Record the counts as they reach the shell. The rendered figure can + // change faster than a poll can catch, so the envelope is the reliable + // signal and the visible readout is accepted as an alternative. + await page.addInitScript(() => { + const seen: unknown[] = []; + ( + window as unknown as { __dotliPeerCounts: unknown[] } + ).__dotliPeerCounts = seen; + window.addEventListener("message", (event: MessageEvent) => { + const data = event.data as { + namespace?: string; + kind?: string; + syncKind?: string; + peers?: number; + } | null; + if ( + data !== null && + typeof data === "object" && + data.namespace === "dotli:protocol" && + data.kind === "chain-sync" && + data.syncKind === "peers" && + typeof data.peers === "number" + ) { + seen.push(data); + } + }); + }); + + // When + await page.goto(BASE_URL, { waitUntil: "commit" }); + + // Then + const sawPeers = page.waitForFunction( + () => { + const seen = (window as unknown as { __dotliPeerCounts?: unknown[] }) + .__dotliPeerCounts; + if (seen !== undefined && seen.length > 0) { + return true; + } + return ["relay", "assethub", "bulletin"].some((chain) => + /[1-9]/.test( + document.getElementById(`metric-peers-${chain}`)?.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 3967c750..b2c2a2cd 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -14,6 +14,15 @@ import { installGlobalErrorHandlers, captureException, } from "@dotli/metrics/sentry"; +import { + chainBytesReceived, + installByteMeter, +} from "@dotli/resolver/byte-meter"; + +// Before anything opens a socket. smoldot's transports are the bulk of a +// cold load's traffic and are invisible to resource timing, so the loading +// screen's speed readout has no other source for them. +installByteMeter(); // Do NOT silently reload on chunk preload failure. The protocol iframe is // hidden and has no UI of its own, so it surfaces the failure to the parent @@ -683,7 +692,21 @@ async function initDirectMode(): Promise { setResolverPeopleProvider, waitForPeopleFinalized, } = resolve; - const { terminateSmoldot, onSmoldotFatal } = smoldotMod; + 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({ + // All three chains the load waits on, in the order it waits on them. + // The relay warps, the Asset Hub bootstraps on top of it, and Bulletin + // serves the content over bitswap. Bulletin is not even created until + // after the content phase begins, and takes roughly another second and + // a half to find a peer, which used to be silent. + milestones: ["relay", "asset-hub", "bulletin"], + peerCounts: ["relay", "asset-hub", "bulletin"], + }); // 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 +725,50 @@ async function initDirectMode(): Promise { } }); + // 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: "chain-sync", + chain, + syncKind: kind, + ...rest, + }, + "*", + ); + }); + + // Feed the host's speed readout. Cumulative totals on a fixed tick rather + // than a rate, so the host owns the averaging and a dropped message just + // widens one window. + if (window.parent !== window) { + const postBytes = (): void => { + window.parent.postMessage( + { + namespace: "dotli:protocol", + kind: "net-bytes", + received: chainBytesReceived(), + }, + "*", + ); + }; + // Send a baseline straight away. A rate needs two readings, so waiting a + // full tick for the first one delayed the whole readout by 500ms on top + // of the time this iframe took to boot. + postBytes(); + const reportBytes = setInterval(postBytes, 500); + window.addEventListener("pagehide", () => { + clearInterval(reportBytes); + }); + } + const engine = createEngine({ createChainProvider, isChainSupported, diff --git a/bun.lock b/bun.lock index 1a938eca..68ce2691 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "dotli", "dependencies": { - "smoldot": "3.3.2", + "smoldot": "3.4.1-dev-20260811.0", }, "devDependencies": { "prettier": "^3.8.4", @@ -296,6 +296,8 @@ "@parity/truapi": "0.7.0", "@parity/truapi-host": "0.4.0", "@polkadot-api/json-rpc-provider": "^0.2.0", + "@polkadot-api/substrate-bindings": "0.20.3", + "@polkadot-api/utils": "0.4.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.8", @@ -326,7 +328,7 @@ "esbuild": "^0.28.1", "fast-uri": "3.1.5", "postcss": "^8.5.24", - "smoldot": "3.3.2", + "smoldot": "3.4.1-dev-20260811.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 +1511,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.4.1-dev-20260811.0", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-W3hRMW2SfXjOInXXbO7uoyDRUbag77Mc7eEfk6pIjxKGctwRaVxxxl/LLPmFbA30+qGscFxuz136JTb2YIf7Cw=="], "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 18407a21..2fdc5560 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "packages/*" ], "dependencies": { - "smoldot": "3.3.2" + "smoldot": "3.4.1-dev-20260811.0" }, "overrides": { "@parity/truapi": "0.7.0", "fast-uri": "3.1.5", - "smoldot": "3.3.2", + "smoldot": "3.4.1-dev-20260811.0", "esbuild": "^0.28.1", "brace-expansion": "^5.0.9", "postcss": "^8.5.24" diff --git a/packages/metrics/src/spans.ts b/packages/metrics/src/spans.ts index e0c668d2..0851c7ba 100644 --- a/packages/metrics/src/spans.ts +++ b/packages/metrics/src/spans.ts @@ -158,13 +158,6 @@ export const AUTH_SESSION_RESTORE = "auth.session_restore"; /** WASM module load time (captured via PerformanceObserver) */ export const WASM_LOAD = "wasm.load"; -/** - * User clicked the "Use gateway instead" escape hatch on the loading - * screen. Tagged with `from_backend` so we can see which verified path - * (smoldot-direct vs smoldot-shared-worker) the user bailed out of. - */ -export const GATEWAY_ESCAPE = "loading.gateway_escape"; - /** * Shared-storage request rejected before it could touch `localStorage`: * bad siteId, malformed key, disallowed origin, or unrecognised value diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index de3baa14..e59304de 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -24,7 +24,10 @@ 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 ProtocolNetBytesEnvelope, type ProtocolRequestEnvelope, type ProtocolRequestMap, type ProtocolRequestMethod, @@ -63,6 +66,10 @@ let protocolReadyPromise: Promise | null = null; const pendingRequests = new Map(); const chainConnections = new Map(); const sharedAuthListeners = new Set(); +const chainSyncListeners = new Set< + (event: ProtocolChainSyncEnvelope) => void +>(); +const netBytesListeners = new Set<(event: ProtocolNetBytesEnvelope) => void>(); let listenerBound = false; let protocolReady = false; interface ReadyWaiter { @@ -172,6 +179,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; @@ -216,6 +241,22 @@ function bindMessageListener(): void { } return; } + case "chain-sync": { + if (!isChainSyncPayloadValid(msg)) { + return; + } + broadcast(chainSyncListeners, msg, "Chain sync"); + return; + } + case "net-bytes": { + // Cumulative and monotonic by construction, so anything else is + // spoofed traffic rather than a stale message. + if (!Number.isFinite(msg.received) || msg.received < 0) { + return; + } + broadcast(netBytesListeners, msg, "Net bytes"); + return; + } case "fatal": case "init-failed": { // Smoldot (or the protocol iframe) has died, either crashed @@ -294,16 +335,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; } } @@ -685,6 +717,34 @@ export function subscribeSharedAuthStorage( }; } +/** + * 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 onProtocolChainSync( + listener: (event: ProtocolChainSyncEnvelope) => void, +): () => void { + bindMessageListener(); + chainSyncListeners.add(listener); + return () => { + chainSyncListeners.delete(listener); + }; +} + +/** Subscribe to the light client's running byte total. */ +export function onProtocolNetBytes( + listener: (event: ProtocolNetBytesEnvelope) => void, +): () => void { + bindMessageListener(); + netBytesListeners.add(listener); + return () => { + netBytesListeners.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..70870c49 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 { ChainKey, ChainSyncKind } from "@dotli/resolver/smoldot"; + export interface ProtocolRequestMap { warmup: Record; resolveDotName: { label: string }; @@ -99,6 +101,39 @@ export interface ProtocolInitFailedEnvelope { message: string; } +/** + * 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 ProtocolChainSyncEnvelope { + namespace: "dotli:protocol"; + kind: "chain-sync"; + chain: ChainKey; + syncKind: ChainSyncKind; + 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; +} + +/** + * Running total of bytes the light client has pulled off the network. + * + * Cumulative rather than a rate, so a dropped message costs nothing and the + * host can pick whatever averaging window it wants. + */ +export interface ProtocolNetBytesEnvelope { + namespace: "dotli:protocol"; + kind: "net-bytes"; + received: number; +} + // 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 +157,8 @@ export type ProtocolEnvelope = | ProtocolReadyEnvelope | ProtocolFatalEnvelope | ProtocolInitFailedEnvelope + | ProtocolChainSyncEnvelope + | ProtocolNetBytesEnvelope | ProtocolAuthStorageChangedEnvelope; const VALID_KINDS = new Set([ @@ -133,9 +170,75 @@ const VALID_KINDS = new Set([ "ready", "fatal", "init-failed", + "chain-sync", + "net-bytes", "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/byte-meter.ts b/packages/resolver/src/byte-meter.ts new file mode 100644 index 00000000..baf23d4d --- /dev/null +++ b/packages/resolver/src/byte-meter.ts @@ -0,0 +1,82 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * Count every byte the light client pulls off the network. + * + * smoldot opens its own sockets from inside its own JS, and resource timing + * covers neither WebSocket nor WebRTC, so the constructor is the only place + * the bytes are visible. Install this before smoldot starts, or connections + * it already opened go uncounted. + */ + +let received = 0; +let installed = false; + +/** Total bytes received over the light client's transports so far. */ +export function chainBytesReceived(): number { + return received; +} + +function sizeOf(data: unknown): number { + if (typeof data === "string") { + // Frames are binary in practice; a text frame is counted as UTF-8 rather + // than as UTF-16 code units, which is what actually crossed the wire. + return new TextEncoder().encode(data).length; + } + if (data instanceof ArrayBuffer) { + return data.byteLength; + } + if (ArrayBuffer.isView(data)) { + return data.byteLength; + } + if (data instanceof Blob) { + return data.size; + } + return 0; +} + +/** + * Wrap `WebSocket` and `RTCDataChannel` so their inbound frames are tallied. + * + * Idempotent, and a no-op outside a browser. Listeners are added rather than + * replacing `onmessage`, so smoldot's own handler is untouched. + */ +export function installByteMeter(): void { + if (installed || typeof window === "undefined") { + return; + } + installed = true; + + // Subclassing rather than wrapping in a plain function: `WebSocket` is a + // real class, so a caller doing `new WebSocket(...)` needs a construct + // signature, and the statics and prototype come along for free. + class MeteredWebSocket extends window.WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + this.addEventListener("message", (event: MessageEvent) => { + received += sizeOf(event.data); + }); + } + } + window.WebSocket = MeteredWebSocket; + + // webrtc-direct bootnodes carry a real share of the sync on networks that + // publish them, so the data channels are counted the same way. + if (typeof RTCPeerConnection !== "undefined") { + // Taken off the prototype only to call it back with the original `this`. + // eslint-disable-next-line @typescript-eslint/unbound-method + const nativeCreate = RTCPeerConnection.prototype.createDataChannel; + RTCPeerConnection.prototype.createDataChannel = function ( + this: RTCPeerConnection, + label: string, + options?: RTCDataChannelInit, + ): RTCDataChannel { + const channel = nativeCreate.call(this, label, options); + channel.addEventListener("message", (event: MessageEvent) => { + received += sizeOf(event.data); + }); + return channel; + }; + } +} diff --git a/packages/resolver/src/resolve.ts b/packages/resolver/src/resolve.ts index 6f83d7c3..0b78c247 100644 --- a/packages/resolver/src/resolve.ts +++ b/packages/resolver/src/resolve.ts @@ -46,7 +46,19 @@ export type { } from "./access-raw-storage"; export { statusToPhase } from "./access-raw-storage"; export { getSmoldot, getSmoldotDirect, getRelayChain } from "./smoldot"; -export { onConnectionIssue } from "./smoldot"; +export { + onConnectionIssue, + onChainSync, + enableSyncReporting, + CHAIN_KEYS, + CHAIN_SYNC_KINDS, +} from "./smoldot"; +export type { + ChainSyncEvent, + ChainSyncKind, + ChainKey, + SyncReportingConfig, +} 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 f180543c..db2f5609 100644 --- a/packages/resolver/src/smoldot-db.ts +++ b/packages/resolver/src/smoldot-db.ts @@ -165,12 +165,32 @@ export interface ChainDbTap { isStopped(): boolean; } +/** The fields of a JSON-RPC frame the tap itself looks at. */ +interface ParsedRpcMessage { + id?: unknown; + method?: unknown; + result?: unknown; + params?: unknown; +} + +/** + * 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; 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 +236,7 @@ export function tapChain(chain: SmoldotChainLike): ChainDbTap { return; } try { - const parsed = JSON.parse(raw) as { id?: unknown; result?: unknown }; + const parsed = JSON.parse(raw) as ParsedRpcMessage; if ( typeof parsed.id === "string" && parsed.id.startsWith(REQUEST_ID_PREFIX) @@ -228,7 +248,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 2d61800c..11ce007c 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()`). */ @@ -58,6 +59,139 @@ 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]; + +/** + * What a chain reports about its own sync. + * + * 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", + "bootstrapComplete", + "stalled", + "recovered", + "peers", + "connecting", + "warpSyncProgress", + "warpSyncFinished", +] 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; + /** 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; +const syncListeners = new Set(); +// Latest event per chain and kind, insertion-ordered. Bounded, so late +// subscribers replay at most kinds x chains events. +const syncHistory = new Map(); + +/** + * 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 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. + } catch { + /* listener threw during replay */ + } + } + return () => { + syncListeners.delete(cb); + }; +} + +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") { + syncHistory.delete(`${event.chain}:stalled`); + } + 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. + } catch { + /* listener threw */ + } + } +} + +/** Which chains report sync, and which of those are sampled for peers. */ +export interface SyncReportingConfig { + milestones: readonly ChainKey[]; + peerCounts: readonly ChainKey[]; +} + +// 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(); + +export function enableSyncReporting(config: SyncReportingConfig): void { + for (const chain of config.milestones) { + milestoneChains.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); + } +} + // 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 +246,18 @@ const CONNECTION_ISSUE_PATTERNS = [ "all bootnodes", ]; +// Reserved id prefixes for our internal JSON-RPC requests. Chosen so they +// 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 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 followSubscriptions = new Map(); + function smoldotLogCallback( level: number, target: string, @@ -137,6 +283,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 = @@ -150,6 +308,110 @@ function smoldotLogCallback( } } +/** + * 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(chain: ChainKey): TapIntercept { + return (parsed) => { + if (typeof parsed.id === "string") { + 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") { + followSubscriptions.set(chain, parsed.result); + } + return true; + } + if (parsed.id.startsWith(HEALTH_ID_PREFIX)) { + handleHealthResponse(chain, parsed.result); + return true; + } + return false; + } + 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 = followSubscriptions.get(chain); + if ( + params === undefined || + subscription === undefined || + params.subscription !== subscription + ) { + return false; + } + emitMilestone(chain, params.result); + return true; + } + return false; + }; +} + +function emitMilestone( + chain: ChainKey, + result: + | { + kind?: string; + reason?: string; + previously?: string; + at?: number; + target?: number; + finalized?: number; + } + | undefined, +): void { + const kind = result?.kind; + if (kind === undefined || kind === "peers" || !isSyncKind(kind)) { + return; + } + if (kind === "bootstrapComplete") { + // The chain is usable, so peer-count polling has served its purpose. + persistence.get(chain)?.healthPoller?.stop(); + } + const reason = kind === "stalled" ? result?.reason : result?.previously; + emitChainSync({ + chain, + kind, + ...(typeof reason === "string" ? { reason } : {}), + ...(typeof result?.at === "number" ? { at: result.at } : {}), + ...(typeof result?.target === "number" ? { target: result.target } : {}), + ...(typeof result?.finalized === "number" + ? { finalized: result.finalized } + : {}), + }); +} + +function handleHealthResponse(chain: ChainKey, result: unknown): void { + healthResponseSeen = true; + persistence.get(chain)?.healthPoller?.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; + } + emitChainSync({ + chain, + kind: "peers", + peers: health.peers, + isSyncing: health.isSyncing, + }); +} + let smoldotInstance: SmoldotClient | null = null; let relayChainPromise: Promise | null = null; @@ -157,10 +419,11 @@ interface PersistenceEntry { tap: ChainDbTap; initialTimer: ReturnType; 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}`; } @@ -172,7 +435,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; } @@ -214,16 +477,22 @@ 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 { +function teardownPersistence(chainName: ChainKey): void { const entry = persistence.get(chainName); if (entry === undefined) { return; } clearTimeout(entry.initialTimer); clearInterval(entry.periodicTimer); + entry.healthPoller?.stop(); entry.tap.stop(); persistence.delete(chainName); } @@ -235,15 +504,155 @@ function teardownAllPersistence(): void { } function attachPersistence( - chainName: string, + chainName: ChainKey, underlying: SmoldotChain, ): SmoldotChain { teardownPersistence(chainName); - const tap = tapChain(underlying); + // 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); + if (reports) { + followMilestones(chainName, tap.chain); + } + if (peerCountChains.has(chainName)) { + const entry = persistence.get(chainName); + if (entry !== undefined) { + entry.healthPoller = startHealthPolling(chainName, tap); + } + } return tap.chain; } +function followMilestones(chainName: ChainKey, chain: SmoldotChain): void { + try { + chain.sendJsonRpc( + JSON.stringify({ + jsonrpc: "2.0", + id: `${FOLLOW_ID_PREFIX}${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)}`, + ); + } +} + +interface HealthPoller { + stop(): void; + noteResponse(): void; +} + +const HEALTH_POLL_INTERVAL_MS = 1_000; +const HEALTH_POLL_TIMEOUT_MS = 2_000; +const HEALTH_POLL_MAX = 120; + +let healthResponseSeen = false; + +/** + * 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(chain: ChainKey, 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; + } + }; + + 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_ID_PREFIX}${chain}:${String(polls)}`, + method: "system_health", + params: [], + }), + ); + } catch { + // The chain was destroyed under us by a terminate or a remove. + // Polling is best-effort and simply ends. + stop(); + return; + } + schedule(HEALTH_POLL_TIMEOUT_MS); + }; + + const noteResponse = (): void => { + if (stopped) { + return; + } + schedule(HEALTH_POLL_INTERVAL_MS); + }; + + 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 { stop, noteResponse }; +} + /** * Create smoldot using `start()`, which runs on the current thread. * @@ -313,6 +722,11 @@ export function terminateSmoldot(): void { /* already destroyed or crashed, safe to ignore */ } teardownAllPersistence(); + // 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. + 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 d53d1147..a445bb5f 100644 --- a/packages/resolver/tests/smoldot.test.ts +++ b/packages/resolver/tests/smoldot.test.ts @@ -3,30 +3,62 @@ import { describe, it, expect, vi, beforeEach } 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 the real light client's. +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[] = []; + // 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: vi.fn(), + nextJsonRpcResponse: () => + new Promise((resolve) => { + const next = queue.shift(); + if (next !== undefined) { + resolve(next); + } else { + waiting = resolve; + } + }), jsonRpcResponses: (async function* () {})(), remove: vi.fn(), - }), + push(raw: string) { + const waiter = waiting; + if (waiter !== null) { + waiting = null; + 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 {} }; @@ -49,37 +81,386 @@ vi.mock("@dotli/resolver/chain-specs", () => ({ let getSmoldot: typeof import("@dotli/resolver/smoldot").getSmoldot; let getRelayChain: typeof import("@dotli/resolver/smoldot").getRelayChain; +let onChainSync: typeof import("@dotli/resolver/smoldot").onChainSync; +let enableSyncReporting: typeof import("@dotli/resolver/smoldot").enableSyncReporting; beforeEach(async () => { + vi.clearAllMocks(); + chainMocks.chains.length = 0; vi.resetModules(); const mod = await import("@dotli/resolver/smoldot"); getSmoldot = mod.getSmoldot; getRelayChain = mod.getRelayChain; + onChainSync = mod.onChainSync; + enableSyncReporting = mod.enableSyncReporting; }); -describe("getSmoldot", () => { - it("returns the same instance on repeated calls", () => { - const a = getSmoldot(); - const b = getSmoldot(); - expect(a).toBe(b); +/** + * 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(); + } +} + +/** 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 { tapped, raw }; +} + +const FOLLOW_REPLY = JSON.stringify({ + jsonrpc: "2.0", + id: "__dotli_lifecycle_follow__:relay", + result: "sub-1", +}); + +function milestone( + subscription: string, + kind: string, + extra: Record = {}, +): string { + return JSON.stringify({ + jsonrpc: "2.0", + method: "lifecycle_unstable_followEvent", + params: { subscription, result: { kind, ...extra } }, + }); +} + +function peerReport(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 peerRequests(chain: { sendJsonRpc: ReturnType }) { + return chain.sendJsonRpc.mock.calls + .map((call) => call[0] as string) + .filter((raw) => raw.includes("system_health")); +} + +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(); + + // Then + expect(first).toBe(second); + }); + + 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(); + + // Then + expect(first).toBe(second); + expect(typeof (await first).sendJsonRpc).toBe("function"); + }); + + 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(); + + // Then + expect(milestones).toEqual([ + { chain: "relay", kind: "firstPeer" }, + { chain: "relay", kind: "bootstrapComplete" }, + ]); + }); + + 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: [] }); + 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(); + + // Then + expect(milestones).toEqual([ + { chain: "relay", kind: "stalled", reason: "noPeers" }, + { chain: "relay", kind: "recovered", reason: "noPeers" }, + ]); + }); + + 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(); + + // When + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + + // Then + expect(milestones).toEqual([ + { chain: "relay", kind: "stalled", reason: "syncNoProgress" }, + { chain: "relay", kind: "firstPeer" }, + ]); + }); + + 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(); + + // When + const milestones: unknown[] = []; + onChainSync((event) => milestones.push(event)); + + // Then + expect(milestones).toEqual([ + { chain: "relay", kind: "recovered", reason: "noPeers" }, + ]); + }); + + 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)); + + // When + raw.push(peerReport(1, 3)); + await flush(); + + // Then + expect(peerRequests(raw)[0]).toContain('"id":"__dotli_health__:relay:1"'); + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 3, isSyncing: true }, + ]); + }); + + 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(); + + // Then + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 2, isSyncing: true }, + { chain: "relay", kind: "peers", peers: 5, isSyncing: true }, + ]); + }); + + 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(); + + // When + const counts: unknown[] = []; + onChainSync((event) => counts.push(event)); + + // Then + expect(counts).toEqual([ + { chain: "relay", kind: "peers", peers: 4, isSyncing: true }, + ]); + }); + + 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("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", + }); + + // When + raw.push(FOLLOW_REPLY); + raw.push(milestone("sub-1", "firstPeer")); + raw.push(appResponse); + await flush(); + + // Then + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(appResponse); }); }); -describe("getRelayChain", () => { - it("returns a promise", () => { - const result = getRelayChain(); - expect(result).toBeInstanceOf(Promise); +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(); + + // Then + expect(milestones).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(foreign); + }); + + 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); + // `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(); + + // Then + expect(milestones).toEqual([]); + await expect(tapped.nextJsonRpcResponse()).resolves.toBe(appResponse); }); - it("deduplicates concurrent calls", () => { - const a = getRelayChain(); - const b = getRelayChain(); - expect(a).toBe(b); + 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)); + + // When + raw.push( + JSON.stringify({ + jsonrpc: "2.0", + id: "__dotli_health__:relay:1", + result: { isSyncing: true, peers: "3" }, + }), + ); + raw.push( + JSON.stringify({ + jsonrpc: "2.0", + id: "__dotli_health__:relay:2", + error: { code: -32000, message: "nope" }, + }), + ); + await flush(); + + // Then + expect(counts).toEqual([]); }); - it("resolves to a chain object", async () => { - const chain = await getRelayChain(); - expect(chain).toBeDefined(); - expect(typeof chain.sendJsonRpc).toBe("function"); + 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/package.json b/packages/ui/package.json index 426ed39b..b61b5425 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,6 +36,8 @@ "@parity/truapi": "0.7.0", "@parity/truapi-host": "0.4.0", "@polkadot-api/json-rpc-provider": "^0.2.0", + "@polkadot-api/substrate-bindings": "0.20.3", + "@polkadot-api/utils": "0.4.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.8", diff --git a/packages/ui/src/bulletin-bitswap.ts b/packages/ui/src/bulletin-bitswap.ts index 143d8e0b..ac3a52fa 100644 --- a/packages/ui/src/bulletin-bitswap.ts +++ b/packages/ui/src/bulletin-bitswap.ts @@ -24,6 +24,108 @@ const ERR_FAIL = -32810; const ERR_FAIL_RETRY = -32811; const ERR_FAIL_BACKOFF = -32812; +/** + * Byte accounting for the content download. + * + * Every block the sandbox needs is fetched here, so this is the one place + * that sees the whole transfer. The first dag-pb block is the DAG root, and + * its links carry `Tsize`, the cumulative size of each subtree. Summing them + * gives the total up front, which turns the download into a real percentage + * instead of a timer. + */ +export interface ContentProgress { + bytesFetched: number; + totalBytes: number | null; + bytesPerSecond: number; +} + +type ProgressCallback = (progress: ContentProgress) => void; +let progressCallback: ProgressCallback | null = null; +let bytesFetched = 0; +let totalBytes: number | null = null; +let firstBlockAt = 0; + +export function onContentProgress(cb: ProgressCallback): void { + progressCallback = cb; +} + +function readDagTotal(bytes: Uint8Array): number | null { + try { + // dag-pb links are field 2, each an embedded message carrying Hash (1), + // Name (2), and Tsize (3) as a varint. Reading Tsize directly keeps the + // 40kB `@ipld/dag-pb` decoder out of the eager host bundle. + let i = 0; + let total = 0; + let sawLink = false; + const readVarint = (): number => { + let result = 0; + let shift = 0; + while (i < bytes.length) { + const b = bytes[i]; + i += 1; + result += (b & 0x7f) * 2 ** shift; + if ((b & 0x80) === 0) { + break; + } + shift += 7; + } + return result; + }; + while (i < bytes.length) { + const key = readVarint(); + const field = key >> 3; + const wire = key & 0x7; + if (wire !== 2) { + return null; + } + const len = readVarint(); + if (field === 2) { + // A PBLink submessage. Walk it for Tsize (field 3, varint). + const end = i + len; + while (i < end) { + const lk = readVarint(); + const lf = lk >> 3; + const lw = lk & 0x7; + if (lw === 0) { + const v = readVarint(); + if (lf === 3) { + total += v; + sawLink = true; + } + } else if (lw === 2) { + // Read the length first: `i += readVarint()` would capture the + // old `i` before the call advanced it past the varint itself. + const skip = readVarint(); + i += skip; + } else { + return null; + } + } + i = end; + } else { + i += len; + } + } + return sawLink ? total : null; + } catch { + return null; + } +} + +function noteBlock(bytes: Uint8Array): void { + if (firstBlockAt === 0) { + firstBlockAt = performance.now(); + totalBytes = readDagTotal(bytes); + } + bytesFetched += bytes.length; + const elapsed = performance.now() - firstBlockAt; + progressCallback?.({ + bytesFetched, + totalBytes, + bytesPerSecond: elapsed > 0 ? (bytesFetched / elapsed) * 1000 : 0, + }); +} + const PER_CALL_TIMEOUT_MS = 60_000; const TOTAL_BUDGET_MS = 180_000; const BACKOFF_BASE_MS = 500; @@ -229,6 +331,7 @@ export function listenForSandboxBitswap(): void { } void bitswapGet(data.cid) .then((bytes) => { + noteBlock(bytes); const reply: BitswapResultOk = { type: "dotli:bitswap-result", id: data.id, diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index e42d19fd..0bb97ce4 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -120,30 +120,50 @@ 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; } -/* Text block — phase label + terminal log */ +/* Text block: the status block and the slow-step hint. Both are + conditional, so the block collapses to nothing on a fast load and the + logo sits alone. */ .loading-text { display: flex; flex-direction: column; align-items: center; - gap: 12px; + gap: 6px; width: 100%; max-width: 420px; flex-shrink: 0; } +/* Off-screen but still announced. `display: none` and `visibility: hidden` + would take it out of the accessibility tree too. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} #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; @@ -152,76 +172,67 @@ body { overflow: hidden; text-overflow: ellipsis; } -/* Slow-step hint (shown below status when a step exceeds its threshold) */ -.loading-hint { - font-size: 11px; - color: #eab308; +/* Status block: held back until the load is slow enough to explain itself. + Reserved height is not needed because nothing sits below it but the hint, + which is itself conditional. */ +.loading-status { opacity: 0; - font-style: italic; - text-align: center; - transition: opacity 0.3s ease; - min-height: 1.3em; -} -.loading-hint.visible { - opacity: 0.5; -} -.loading-hint-text { - display: block; -} - -.loading-gateway-btn { - display: inline-flex; - align-items: center; - gap: 10px; - margin: 20px auto 0; - padding: 10px 16px 10px 14px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.03); - color: rgba(255, 255, 255, 0.92); - font-family: inherit; - cursor: pointer; - line-height: 1.2; - transition: - border-color 0.2s ease, - background 0.2s ease, - color 0.2s ease, - transform 0.15s ease; -} -.loading-gateway-btn:hover { - border-color: rgba(255, 255, 255, 0.32); - background: rgba(255, 255, 255, 0.07); - color: #fff; -} -.loading-gateway-btn:active { - transform: translateY(1px); + transition: opacity 0.25s ease; + pointer-events: none; + width: 100%; } -.loading-gateway-btn-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 7px; - background: rgba(255, 255, 255, 0.06); - color: #fafafa; - flex-shrink: 0; +.loading-status.visible { + opacity: 1; + pointer-events: auto; } -.loading-gateway-btn-text { +.loading-metrics { + /* Matches `.loading-progress`'s bottom margin, so the headline sits an + equal distance from the bar above it and the readouts below it. */ + margin-top: 24px; + 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; + /* Fixed, not `min-width`. These readouts change while they are on screen, + and a box that grows with its content re-centres the whole block every + time a value gets longer. Sized to the widest row the readouts can + produce, which is "AssetHub waiting for peers" at 243px. */ + width: 248px; + max-width: 100%; +} +.loading-metric { display: flex; - flex-direction: column; - align-items: flex-start; - gap: 1px; + justify-content: space-between; + gap: 24px; + padding: 2px 0; +} +.loading-metric dt, +.loading-metric span:first-child { + /* 6.12:1 */ + color: #8f8f8f; +} +.loading-metric dd, +.loading-metric span:last-child { + /* 7.85:1 */ + color: #a3a3a3; + /* Last line of defence: a value wider than the row must clip rather than + wrap and shove the rows below it down. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.loading-gateway-btn-label { - font-size: 12.5px; - font-weight: 600; - letter-spacing: -0.01em; +/* 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-gateway-btn-sub { - font-size: 10.5px; - color: rgba(255, 255, 255, 0.5); - letter-spacing: -0.005em; +.loading-metric-peers > dd { + margin-left: 12px; } .accent { @@ -347,3 +358,77 @@ body { .error-page-domain-tld { color: rgba(250, 250, 250, 0.55); } + +/* Network button: appears once a product is on screen, so the loading view + stays as bare as the bar it shows. */ +.topbar-chains-btn { + display: none; +} +.topbar-chains-btn.visible { + display: inline-flex; +} +/* Settings owns the popover recipe: 6px on the shell from `.more-popover`, + and each child carries its own inset. This panel keeps that rather than + padding the shell, so the two popovers have identical edges. The children + below therefore repeat `.mode-popover-section`'s 14px. It has to be margin + on the table: padding on a `border-collapse` table does not move its + cells, so a padded table would sit left of the heading. */ +.chains-popover { + min-width: 300px; + padding-bottom: 8px; +} + +/* Network panel */ +.chains-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + /* 13.36:1 */ + color: #d4d4d4; + padding: 2px 14px 10px; +} +.chains-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.chains-status-dot.is-ok { + background: #22c55e; +} +.chains-status-dot.is-warn { + background: #eab308; +} +.chains-status-dot.is-idle { + background: #8f8f8f; +} +.chains-table { + width: calc(100% - 28px); + margin: 2px 14px 0; + border-collapse: collapse; + font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; + font-size: 11px; +} +.chains-table th, +.chains-table td { + text-align: right; + padding: 4px 0 4px 12px; + white-space: nowrap; +} +.chains-table tr th:first-child { + text-align: left; + padding-left: 0; + font-weight: 500; +} +.chains-table tr:first-child th { + /* 6.12:1 */ + color: #8f8f8f; + font-weight: 400; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.chains-table td, +.chains-table tr th:first-child { + /* 7.85:1 */ + color: #a3a3a3; +} diff --git a/packages/ui/src/styles/themes.css b/packages/ui/src/styles/themes.css index 753f4e30..74c0cb91 100644 --- a/packages/ui/src/styles/themes.css +++ b/packages/ui/src/styles/themes.css @@ -204,34 +204,13 @@ [data-theme="light"] #status { color: #1a1a1a; } -[data-theme="light"] .loading-hint { - color: #b45309; -} -[data-theme="light"] .loading-hint.visible { - opacity: 0.9; -} -[data-theme="light"] .loading-gateway-btn { - border-color: rgba(0, 0, 0, 0.16); - background: rgba(0, 0, 0, 0.03); - color: rgba(0, 0, 0, 0.85); -} -[data-theme="light"] .loading-gateway-btn:hover { - border-color: rgba(0, 0, 0, 0.32); - background: rgba(0, 0, 0, 0.06); - color: #111; -} -[data-theme="light"] .loading-gateway-btn-icon { - background: rgba(0, 0, 0, 0.06); - color: #0a0a0a; -} -[data-theme="light"] .loading-gateway-btn-sub { - color: rgba(0, 0, 0, 0.55); -} [data-theme="light"] .loading-progress-bar { 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,7 +219,36 @@ fill: #111; } [data-theme="light"] .loading-progress-pct { - color: #333; + /* 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; +} +[data-theme="light"] .loading-status-heading, +[data-theme="light"] .loading-details-summary, +[data-theme="light"] .loading-metric dt { + /* 4.89:1 */ + color: #6b6b6b; +} +[data-theme="light"] .chains-status { + color: #1a1a1a; +} +[data-theme="light"] .chains-table tr:first-child th { + /* 4.89:1 */ + color: #6b6b6b; + border-bottom-color: rgba(0, 0, 0, 0.1); +} +[data-theme="light"] .chains-table td, +[data-theme="light"] .chains-table tr th:first-child { + /* 7.17:1 */ + color: #525252; +} +[data-theme="light"] .chains-status-dot.is-idle { + background: #6b6b6b; +} +[data-theme="light"] .loading-metric dd { + /* 7.17:1 */ + color: #525252; } [data-theme="light"] .spinner { border-color: #ddd; diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index c3c4e3a6..bf06f253 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -17,6 +17,7 @@ import { import { createRemoteChainProvider, isRemoteChainSupported, + onProtocolChainSync, } from "@dotli/protocol/client"; import { getCacheSettings, @@ -275,6 +276,8 @@ export function initTopBar( // Mode toggle (P2P / Centralized) initModeToggle(); + initChainsPopover(); + watchChainSync(); // Permissions initPermissions(); @@ -949,6 +952,212 @@ function createPermissionDropdown( return wrap; } +/** + * Live connection state, fed by the chain-sync subscription. + * + * `lifecycle_unstable_follow` is never unfollowed, so `stalled` and + * `recovered` keep arriving long after the load finished. That makes the + * status line genuinely live, unlike the peer counts, whose polling stops + * once a chain is up and so have to be queried when the panel opens. + */ +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; +/** + * Budget for each step of a chain's read. + * + * Three steps run in sequence, so this is sized to keep the whole read inside + * the refresh interval and stop ticks stacking up on a slow chain. + */ +const CHAIN_QUERY_TIMEOUT_MS = 1_800; + +function stopChainsRefresh(): void { + if (chainsRefreshTimer !== null) { + clearInterval(chainsRefreshTimer); + chainsRefreshTimer = null; + } +} + +function describeNetworkStatus(): { text: string; tone: string } { + const states = [...chainSyncState.values()]; + if (states.length === 0) { + return { text: "Starting", tone: "idle" }; + } + if (states.includes("stalled")) { + return { text: "Reconnecting", tone: "warn" }; + } + const settled = states.every( + (k) => k === "bootstrapComplete" || k === "recovered", + ); + return settled + ? { text: "Your connection is good", tone: "ok" } + : { text: "Connecting", tone: "idle" }; +} + +function watchChainSync(): void { + onProtocolChainSync((event) => { + if (event.syncKind === "peers") { + return; + } + chainSyncState.set(event.chain, event.syncKind); + onStatusChange?.(); + }); +} + +/** + * Network popover: what each chain is doing right now. + * + * Heights and peer counts are read fresh on every open rather than polled, + * because this panel is the only thing that wants them and a background + * poll would keep four chains awake for something nobody has looked at. + */ +function renderChainsPopover(parent: HTMLElement): void { + parent.replaceChildren(); + const backend = getBackend(); + + appendSectionHeader(parent, "Network"); + const statusRow = document.createElement("div"); + statusRow.className = "chains-status"; + const dot = document.createElement("span"); + const text = document.createElement("span"); + statusRow.append(dot, text); + parent.appendChild(statusRow); + + if (backend === "rpc-gateway") { + onStatusChange = null; + dot.className = "chains-status-dot is-idle"; + text.textContent = "Trusted provider, no light client"; + return; + } + if (backend === "smoldot-shared-worker") { + onStatusChange = null; + dot.className = "chains-status-dot is-idle"; + text.textContent = "Light client in a shared worker"; + return; + } + + const paint = (): void => { + const { text: label, tone } = describeNetworkStatus(); + dot.className = `chains-status-dot is-${tone}`; + text.textContent = label; + }; + paint(); + // Keep the line honest while the panel is open, so a chain that stalls + // now says so without the user reopening it. + onStatusChange = paint; + + const cfg = getActiveServicesConfig(); + const chains: [string, string][] = [ + ["Relay", cfg.relay.genesis], + ["AssetHub", cfg.assethub.genesis], + ["Bulletin", cfg.bulletin.genesis], + ["People", cfg.people.genesis], + ]; + + const table = document.createElement("table"); + table.className = "chains-table"; + const head = document.createElement("tr"); + for (const label of ["", "Peers", "Best block", "Finalized"]) { + const th = document.createElement("th"); + th.textContent = label; + head.appendChild(th); + } + table.appendChild(head); + + const refreshers: (() => void)[] = []; + for (const [label, genesis] of chains) { + const row = document.createElement("tr"); + const name = document.createElement("th"); + name.scope = "row"; + name.textContent = label; + const cells = ["…", "…", "…"].map((v) => { + const td = document.createElement("td"); + td.textContent = v; + return td; + }); + row.append(name, ...cells); + table.appendChild(row); + + const refresh = (): void => { + void queryChainStatus(genesis).then(({ peers, best, finalized }) => { + cells[0].textContent = peers === null ? "n/a" : String(peers); + cells[1].textContent = best ?? "n/a"; + cells[2].textContent = 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); +} + +/** + * Reveal the network button. The host calls this once a product is on + * screen, so the icon appears with the app rather than during the load. + */ +export function setChainsButtonVisible(visible: boolean): void { + document + .getElementById("chains-button") + ?.classList.toggle("visible", visible); +} + +function initChainsPopover(): void { + const button = document.getElementById("chains-button"); + const popover = document.getElementById("chains-popover"); + if (button === null || popover === null) { + return; + } + button.setAttribute("aria-haspopup", "dialog"); + popover.setAttribute("role", "dialog"); + popover.setAttribute("aria-label", "Network"); + + const close = (): void => { + popover.classList.remove("open"); + button.setAttribute("aria-expanded", "false"); + stopChainsRefresh(); + onStatusChange = null; + }; + // No `stopPropagation`. The shared outside-click closer has to see this + // click to shut Settings, which sits at the same fixed position and would + // otherwise render on top of this panel. + button.addEventListener("click", () => { + if (popover.classList.contains("open")) { + close(); + return; + } + renderChainsPopover(popover); + popover.classList.add("open"); + button.setAttribute("aria-expanded", "true"); + }); + document.addEventListener("click", (e) => { + if ( + popover.classList.contains("open") && + !popover.contains(e.target as Node) && + e.target !== button + ) { + close(); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + close(); + } + }); +} + function initModeToggle(): void { modeButton = getElement("mode-button"); modePopover = getElement("mode-popover"); @@ -1647,49 +1856,10 @@ function renderDiagnostics(parent: HTMLElement): void { ); } - // Version is static and cheap. Block numbers are async so the rows start - // with an ellipsis placeholder and get swapped in when `chainConnect` - // rounds-trip back with a finalized-block header. When the user is on - // the RPC chain backend, smoldot isn't running, so hide the per-chain - // block rows entirely (the endpoints already appear under Chain) and - // keep only the smoldot version so the dependency is still visible. - const smoldotInfo: SmoldotInfo = { - version: buildSmoldotVersionLabel(), - blocks: { relay: "…", assetHub: "…", people: "…" }, - }; - const smoldotActive = getBackend() !== "rpc-gateway"; + // Version only. The per-chain block heights this section used to carry + // now live in the network popover, where they can be read live. appendSectionHeader(parent, "@smoldot"); - renderInfoRow(parent, "smoldot", smoldotInfo.version); - if (smoldotActive) { - const relayRow = renderInfoRow(parent, "Relay Chain", "…"); - const assetHubRow = renderInfoRow(parent, "Asset Hub", "…"); - const peopleRow = renderInfoRow(parent, "People Chain", "…"); - - // Fire all queries. They update their own rows and the shared snapshot - // (so the "Share diagnostic" button captures whatever resolved in time). - const cfg = getActiveServicesConfig(); - void queryFinalizedBlock(cfg.relay.genesis).then((n) => { - const v = formatBlock(n); - relayRow.update(v); - smoldotInfo.blocks.relay = v; - }); - void queryFinalizedBlock(cfg.assethub.genesis).then((n) => { - const v = formatBlock(n); - assetHubRow.update(v); - smoldotInfo.blocks.assetHub = v; - }); - void queryFinalizedBlock(cfg.people.genesis).then((n) => { - const v = formatBlock(n); - peopleRow.update(v); - smoldotInfo.blocks.people = v; - }); - } else { - // Keep the snapshot tagged as n/a so the Share-diagnostic report is - // coherent: smoldot wasn't consulted, don't claim a block height. - smoldotInfo.blocks.relay = "n/a"; - smoldotInfo.blocks.assetHub = "n/a"; - smoldotInfo.blocks.people = "n/a"; - } + renderInfoRow(parent, "smoldot", buildSmoldotVersionLabel()); // The unscoped `polkadot-api` package lives in the same visual section as // `@polkadot-api/*`. Same ecosystem, same release cadence, users expect @@ -1734,6 +1904,10 @@ function renderDiagnostics(parent: HTMLElement): void { "Open a new issue on paritytech/dotli pre-filled with these diagnostics"; shareBtn.addEventListener("click", () => { void (async () => { + // Block heights now live in the Network popover, so nothing has them + // cached. Query them here, where a report is actually being made, + // instead of keeping four chains awake for a panel nobody opened. + const smoldotInfo = await collectSmoldotInfo(); const report = await formatDiagnosticsReport( base, smoldotInfo, @@ -1793,10 +1967,10 @@ function isTruapiDebugEnabled(): boolean { * so the snapshot matches what's actually live right now. * 3. Permissions: per-product, omitted on landing where we don't have * a scoped label to query. - * 4. Packages: flat list of smoldot, polkadot-api, and @parity/truapi. The - * live block heights from the @smoldot popover section - * aren't included here because they're noise in a bug - * report. The popover already shows them live. */ + * 4. Packages: flat list of smoldot, polkadot-api, and @parity/truapi, + * with the block heights queried at share time. They are + * not rendered in this popover any more, they live in the + * network panel where they can be read live. */ async function formatDiagnosticsReport( base: [label: string, value: string][], smoldot: SmoldotInfo, @@ -1914,6 +2088,29 @@ function backendLabel(b: Backend): string { } } +/** Gather the smoldot readouts a diagnostic report quotes. */ +async function collectSmoldotInfo(): Promise { + const info: SmoldotInfo = { + version: buildSmoldotVersionLabel(), + blocks: { relay: "n/a", assetHub: "n/a", people: "n/a" }, + }; + if (getBackend() === "rpc-gateway") { + return info; + } + const cfg = getActiveServicesConfig(); + const [relay, assetHub, people] = await Promise.all([ + queryFinalizedBlock(cfg.relay.genesis), + queryFinalizedBlock(cfg.assethub.genesis), + queryFinalizedBlock(cfg.people.genesis), + ]); + info.blocks = { + relay: formatBlock(relay), + assetHub: formatBlock(assetHub), + people: formatBlock(people), + }; + return info; +} + interface SmoldotInfo { /** Human-facing version label, e.g. "3.0.0 (c33c647)". */ version: string; @@ -1946,6 +2143,155 @@ function buildSmoldotVersionLabel(): string { * stays dynamic so opening the popover is cheap when the user doesn't care * about blocks. */ +/** + * Read a block's own timestamp, so its age is measured by the chain's clock + * rather than by when we happened to hear about it. + * + * `Timestamp::Now` is a plain `u64` of milliseconds under a well-known key, + * so this needs no metadata: hash the pallet and item names and decode eight + * little-endian bytes. + */ +async function queryBlockAgeMs( + client: { + _request: (method: string, params: unknown[]) => Promise; + }, + blockHash: string, +): Promise { + try { + const [{ Twox128 }, { mergeUint8, toHex, fromHex }] = await Promise.all([ + import("@polkadot-api/substrate-bindings"), + import("@polkadot-api/utils"), + ]); + const enc = new TextEncoder(); + const key = toHex( + mergeUint8([ + Twox128(enc.encode("Timestamp")), + Twox128(enc.encode("Now")), + ]), + ); + // An empty storage slot comes back as null, which is normal on a chain + // whose block has not written the timestamp yet. + const raw = await client._request("state_getStorage", [ + key, + blockHash, + ]); + if (raw === null) { + return null; + } + const bytes = fromHex(raw); + if (bytes.length < 8) { + return null; + } + const millis = Number( + new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getBigUint64(0, true), + ); + const age = Date.now() - millis; + return age >= 0 ? age : 0; + } catch { + return null; + } +} + +/** "4s ago", "2m ago". Blank when the chain would not say. */ +/** How long ago a block landed, or null when its timestamp is unreadable. */ +function formatAge(ms: number | null): string | null { + if (ms === null) { + return null; + } + const secs = Math.round(ms / 1000); + if (secs < 60) { + return `${String(secs)}s ago`; + } + return `${String(Math.round(secs / 60))}m ago`; +} + +/** + * Query a chain's best and finalized block, each with the age its own clock + * reports. `getBestBlocks` returns the chain from best to finalized, so one + * call covers both ends. + */ +/** Reject after `ms`, and clear the timer as soon as the race settles. */ +async function withTimeout(work: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error("timeout")); + }, ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** + * Everything the network panel shows for one chain, over a single client. + * + * Peers and heights used to be read by two functions that each stood up their + * own client, so an open panel churned eight of them every refresh. The + * budget is under the refresh interval so ticks cannot overlap. + */ +async function queryChainStatus(genesisHash: string): Promise<{ + peers: number | null; + best: string | null; + finalized: string | null; +}> { + const empty = { peers: null, best: null, finalized: null }; + try { + if (!isRemoteChainSupported(genesisHash)) { + return empty; + } + const provider = createRemoteChainProvider(genesisHash); + if (provider === null) { + return empty; + } + const papi = await import("polkadot-api"); + const client = papi.createClient(provider); + try { + const health = await withTimeout( + client._request<{ peers?: number }>("system_health", []), + CHAIN_QUERY_TIMEOUT_MS, + ).catch(() => null); + const blocks = await withTimeout( + client.getBestBlocks(), + CHAIN_QUERY_TIMEOUT_MS, + ).catch(() => null); + const best = blocks?.at(0); + const finalized = blocks?.at(-1); + if (best === undefined || finalized === undefined) { + return { ...empty, peers: health?.peers ?? null }; + } + const [bestAge, finalizedAge] = await withTimeout( + Promise.all([ + queryBlockAgeMs(client, best.hash), + best.hash === finalized.hash + ? Promise.resolve(null) + : queryBlockAgeMs(client, finalized.hash), + ]), + CHAIN_QUERY_TIMEOUT_MS, + ).catch(() => [null, null]); + return { + peers: typeof health?.peers === "number" ? health.peers : null, + best: formatAge(bestAge), + finalized: formatAge( + best.hash === finalized.hash ? bestAge : finalizedAge, + ), + }; + } finally { + client.destroy(); + } + } catch { + return empty; + } +} + async function queryFinalizedBlock( genesisHash: string, ): Promise { diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index 3779afab..885c63ad 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,13 +39,30 @@ export interface LoadingPhase { base: number; target: number; expectedMs: number; + /** Which set of messages narrates this phase. */ + stage: LoadingStage; + /** + * This step publishes a true percentage, so the indicator waits for it. + * + * Without this the crawl guessed its way to 84% during the first seconds + * of a download and then had nowhere to go, because the real figure that + * followed was lower and the indicator never moves backwards. + */ + reportsProgress?: boolean; } let phases: LoadingPhase[] = []; let currentPhase = -1; -// Progress bar state +// Progress indicator state let progressFillEl: HTMLElement | null = null; let progressPctEl: HTMLElement | null = null; +let progressBarEl: HTMLElement | null = null; +let statusBlockEl: HTMLElement | null = null; +let metricRelayPeersEl: HTMLElement | null = null; +let metricAssetHubPeersEl: HTMLElement | null = null; +let metricBulletinPeersEl: HTMLElement | null = null; +let metricSpeedEl: HTMLElement | null = null; +let revealTimer: ReturnType | null = null; let currentProgress = 0; let targetProgress = 0; let crawlStep = 0; @@ -54,6 +70,15 @@ let progressInterval: ReturnType | null = null; const CRAWL_TICK_MS = 200; +// Where an exhausted band creeps on to, and how long it takes. Stops short +// of 100 so only a finished load can fill the indicator. +const CREEP_CEILING = 99; +const CREEP_MS = 10_000; +let creepCeiling = 0; +let creepStep = 0; +/** True while the current step owes the indicator a real percentage. */ +let phaseReportsProgress = false; + function setProgress(pct: number): void { currentProgress = pct; if (progressFillEl !== null) { @@ -62,13 +87,27 @@ function setProgress(pct: number): void { if (progressPctEl !== null) { progressPctEl.textContent = `${String(Math.round(pct))}%`; } + // The bar itself carries no value for a screen reader, so the wrapper does. + progressBarEl?.setAttribute("aria-valuenow", String(Math.round(pct))); } function startProgressCrawl(): void { stopProgressCrawl(); progressInterval = setInterval(() => { + // A step that reports a real percentage owns the indicator outright. The + // crawl and the creep are both guesses at how long a step takes, and on a + // download slower than the estimate they ran the logo to nearly full + // while the readout underneath still said 58%. Nothing that guesses may + // move the indicator past something that knows. + if (phaseReportsProgress) { + return; + } if (currentProgress < targetProgress) { setProgress(Math.min(currentProgress + crawlStep, targetProgress)); + return; + } + if (currentProgress < creepCeiling) { + setProgress(Math.min(currentProgress + creepStep, creepCeiling)); } }, CRAWL_TICK_MS); } @@ -90,23 +129,312 @@ export function completeProgress(): void { } /** - * Initialize the loading progress bar. + * Initialize the loading progress indicator. * Call once before resolution/fetching begins. */ export function initPhases(phaseList: LoadingPhase[]): void { phases = phaseList; currentPhase = -1; + currentStageIndex = -1; + openingLine = true; currentProgress = 0; targetProgress = 0; + phaseReportsProgress = false; progressFillEl = document.getElementById("loading-progress-fill"); progressPctEl = document.getElementById("loading-progress-pct"); + progressBarEl = document.getElementById("loading-progress"); + statusBlockEl = document.getElementById("loading-status"); + metricRelayPeersEl = document.getElementById("metric-peers-relay"); + metricAssetHubPeersEl = document.getElementById("metric-peers-assethub"); + metricBulletinPeersEl = document.getElementById("metric-peers-bulletin"); + metricSpeedEl = document.getElementById("metric-speed"); + + // A load that finishes quickly should never explain itself. Only once it + // has run long enough to feel slow does the status block appear. + if (revealTimer !== null) { + clearTimeout(revealTimer); + } + revealTimer = setTimeout(() => { + statusBlockEl?.classList.add("visible"); + }, STATUS_REVEAL_MS); + + // Not on the first `advancePhase`, which lands seconds later once the + // protocol frame is up. The markup already shows this stage's opening line, + // so the rotation clock has to start from when that line became visible. + setLoadingStage("starting"); +} + +/** How long a load may run before it owes the user an explanation. */ +export const STATUS_REVEAL_MS = 3_000; + +// How often the line turns over. Most of this window is the turnover +// animation, so the finished sentence itself is only still for the last ~2.7s. +const MESSAGE_ROTATE_MS = 6_500; + +/** Placeholder swapped for the domain being loaded when a message is shown. */ +const DOMAIN_TOKEN = "{domain}"; + +function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** The steps a load moves through, in the order they happen. */ +export const LOADING_STAGES = [ + "starting", + "relay", + "assetHub", + "resolving", + "content", + "preparing", +] as const; +export type LoadingStage = (typeof LOADING_STAGES)[number]; + +/** + * What the shell is doing, in the user's terms. + * + * The first line of each stage names the step. The rest explain what a light + * client is doing, and only a slow load reaches them. List lengths follow how + * long each step runs, so the long ones do not repeat. + */ +const STAGE_MESSAGES: Record = { + starting: [ + "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 does the checking itself, not a server", + ], + assetHub: [ + `Looking up ${DOMAIN_TOKEN}`, + "Catching up with the latest blocks", + "The name and its address come from the network itself", + "This is the slow part, and it is faster next time", + ], + resolving: [ + "I found it", + "Reading the address it points at", + "The network proved this answer, so it cannot be faked", + ], + content: [ + "Downloading the app", + "The files come from multiple peers across the network", + "Speed depends on how many are nearby", + "Every piece is checked against its fingerprint as it lands", + "No single machine is serving this, so there is nothing to take down", + "Bigger apps take longer the first time", + "Your browser keeps a copy, so the next visit is quick", + ], + preparing: [ + "Download complete. We are preparing your app.", + "Unpacking the files", + "Handing over to the app", + "Almost there", + ], +}; + +let stageTimer: ReturnType | null = null; +let currentStageIndex = -1; +/** True until the first stage turn, which the markup already painted. */ +let openingLine = true; +let loadingDomain = ""; + +/** Name the domain being loaded, for the messages that mention it. */ +export function setLoadingDomain(domain: string): void { + loadingDomain = domain; +} + +// A fixed budget rather than a per-character delay, so a long sentence +// animates at the same pace as a short one and always lands inside the +// rotation interval. +const ERASE_MS = 1_000; +const TYPE_MS = 2_800; +let typingFrame: number | null = null; +let pendingMessage: string | null = null; + +/** Slow at both ends, quickest in the middle. */ +function easeInOut(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; +} + +function cancelTyping(): void { + pendingMessage = null; + if (typingFrame !== null) { + cancelAnimationFrame(typingFrame); + typingFrame = null; + // An interrupted fade would otherwise leave the line stranded dim. + const status = document.getElementById("status"); + if (status !== null) { + status.style.opacity = "1"; + } + } +} + +function writeStatus(message: string): void { + const status = document.getElementById("status"); + if (status === null) { + return; + } + // 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. + const next = message.replace( + DOMAIN_TOKEN, + loadingDomain === "" ? "the name" : `${loadingDomain}.dot`, + ); + // Screen readers get the whole sentence once, from an element the typing + // never touches. + const announce = document.getElementById("status-sr"); + if (announce !== null) { + announce.textContent = next; + } + // A sentence already being typed is left to finish. Stages turn over faster + // than a line takes to render on a quick load, and cutting one off mid-word + // meant a step's opening line was never actually read: the screen went + // straight from "Reaching out" to the download copy. Only the newest + // waiting sentence is kept, so the queue can never fall behind by more + // than one. + if (typingFrame !== null) { + pendingMessage = next; + return; + } + const previous = status.textContent; + if (next === previous || prefersReducedMotion()) { + status.textContent = next; + return; + } + const start = performance.now(); + const step = (now: number): void => { + const elapsedMs = now - start; + if (elapsedMs < ERASE_MS) { + const gone = easeInOut(elapsedMs / ERASE_MS); + status.textContent = previous.slice( + 0, + Math.ceil(previous.length * (1 - gone)), + ); + // Dims as it empties and brightens as the new line arrives, so the + // turnover reads as one settling motion rather than a text scramble. + // Only a shallow dip: this block's contrast is built on solid colours + // precisely because opacity once sank it below AA, and 0.75 of #d4d4d4 + // is still 7.5:1 against the page. + status.style.opacity = String(1 - 0.25 * gone); + } else if (elapsedMs < ERASE_MS + TYPE_MS) { + const shown = easeInOut((elapsedMs - ERASE_MS) / TYPE_MS); + status.textContent = next.slice(0, Math.ceil(next.length * shown)); + status.style.opacity = String(0.75 + 0.25 * shown); + } else { + status.textContent = next; + status.style.opacity = "1"; + typingFrame = null; + if (pendingMessage !== null) { + const queued = pendingMessage; + pendingMessage = null; + writeStatus(queued); + } + return; + } + typingFrame = requestAnimationFrame(step); + }; + typingFrame = requestAnimationFrame(step); +} + +/** + * Move to a stage and start cycling its messages. + * + * Only ever moves forward. Re-entering the running stage is ignored so the + * copy does not restart on every signal for a step already underway, and an + * earlier stage is refused so a late event cannot walk the story backwards. + */ +export function setLoadingStage(stage: LoadingStage): void { + const stageIndex = LOADING_STAGES.indexOf(stage); + if (stageIndex <= currentStageIndex) { + return; + } + currentStageIndex = stageIndex; + const messages = STAGE_MESSAGES[stage]; + let line = 0; + // Only the rotation clock is stopped here. Cancelling the typing as well + // would kill the animation this very line was just queued behind and drop + // the queue with it, stranding the headline on a half-typed word. + stopStageTimer(); + writeStatus(messages[0]); + // 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; + // The opening line has been on screen since the page painted, so its turn + // is due relative to that, not to whenever this ran. Later turns get the + // full interval. + const firstDelay = openingLine + ? Math.max(500, MESSAGE_ROTATE_MS - performance.now()) + : MESSAGE_ROTATE_MS; + openingLine = false; + const turn = (): void => { + line = line + 1 >= messages.length ? loopFrom : line + 1; + writeStatus(messages[line]); + stageTimer = setTimeout(turn, MESSAGE_ROTATE_MS); + }; + stageTimer = setTimeout(turn, firstDelay); +} + +function stopStageTimer(): void { + if (stageTimer !== null) { + clearTimeout(stageTimer); + stageTimer = null; + } +} + +/** Stop narrating entirely: no more turns, and no line half-written. */ +function stopStageMessages(): void { + stopStageTimer(); + cancelTyping(); +} + +/** Report what the light client itself is doing, e.g. "AssetHub ready". */ +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, so a value stays blank until there is + * something true to put there. A zero would read as broken. + */ +export function setLoadingMetrics(metrics: { + relayPeers?: number; + assetHubPeers?: number; + bulletinPeers?: number; + bytesPerSecond?: number; +}): void { + if (metrics.relayPeers !== undefined && metricRelayPeersEl !== null) { + metricRelayPeersEl.textContent = String(metrics.relayPeers); + } + 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) { + // Chain sync runs at tens of kB/s, which rounded to "0.0 MB/s" and read + // as nothing happening. + const perSecond = metrics.bytesPerSecond; + metricSpeedEl.textContent = + perSecond < 1_048_576 + ? `${String(Math.round(perSecond / 1024))} kB/s` + : `${(perSecond / 1_048_576).toFixed(1)} MB/s`; + } } /** * Advance to a specific phase (0-indexed). - * Jumps the progress bar to the phase's base percentage and begins - * crawling toward its target. Updates the headline text. + * Jumps the indicator to the phase's base percentage and begins crawling + * toward its target. Updates the headline text. * No-ops if the phase is already active or past. */ export function advancePhase(index: number): void { @@ -116,7 +444,12 @@ export function advancePhase(index: number): void { currentPhase = index; // Update progress bar - const { base, target, label, expectedMs } = phases[index]; + const { base, target, label, expectedMs, reportsProgress } = phases[index]; + // Each step has to earn the indicator back: the previous step's real + // percentage says nothing about this one. A step that publishes its own + // figure holds the indicator at its band base until the figure arrives, + // rather than crawling somewhere the real number cannot then reach. + phaseReportsProgress = reportsProgress === true; if (base > currentProgress) { setProgress(base); } @@ -127,192 +460,96 @@ 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. A band that reports a real percentage lends nothing, + // since creeping into it would put the indicator above the figure that step + // is about to publish. + const next = phases[index + 1] as LoadingPhase | undefined; + const lentCeiling = + next === undefined + ? CREEP_CEILING + : next.reportsProgress === true + ? next.base + : next.target; + creepCeiling = Math.min(lentCeiling, CREEP_CEILING); + creepStep = + (Math.max(creepCeiling - target, 0) * CRAWL_TICK_MS) / + Math.max(CREEP_MS, CRAWL_TICK_MS); startProgressCrawl(); - // Update headline - const status = document.getElementById("status"); - if (status !== null) { - status.textContent = 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); } -export const GATEWAY_ESCAPE_DELAY_MS = 10_000; - /** - * One-click "Use Trusted Provider" escape hatch on the loading screen. - * Renders at most once per page lifetime after `delayMs` of slow loading. - * Returns a cancel function that clears the pending timer. + * Pull the indicator to a real fraction of the band `stage` owns. + * + * Takes the indicator over from the crawl for as long as that step has + * something to say. Monotonic and clamped to the band, so a late or noisy + * signal can never rewind it. + * + * The `stage` is checked against the running one, so a signal cannot drive a + * band it does not own. The relay's warp fraction arriving mid-sync used to + * both move the Asset Hub band and freeze its crawl. */ -export function showGatewayEscape( - onClick: () => void, - delayMs: number = GATEWAY_ESCAPE_DELAY_MS, -): () => void { - const timer = setTimeout(() => { - const hint = document.getElementById("loading-hint"); - if (hint === null) { - return; - } - if (hint.querySelector(".loading-gateway-btn") !== null) { - return; - } - const btn = document.createElement("button"); - btn.className = "loading-gateway-btn"; - btn.type = "button"; - const icon = document.createElement("span"); - icon.className = "loading-gateway-btn-icon"; - icon.setAttribute("aria-hidden", "true"); - icon.innerHTML = - '' + - ''; - const text = document.createElement("span"); - text.className = "loading-gateway-btn-text"; - const label = document.createElement("span"); - label.className = "loading-gateway-btn-label"; - label.textContent = "Use Trusted Provider"; - const sub = document.createElement("span"); - sub.className = "loading-gateway-btn-sub"; - sub.textContent = "Faster but no verification"; - text.append(label, sub); - btn.append(icon, text); - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - onClick(); - }); - hint.appendChild(btn); - hint.classList.add("visible"); - }, delayMs); - return () => { - clearTimeout(timer); - }; -} - -// 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; +export function nudgePhaseProgress( + fraction: number, + stage: LoadingStage, +): void { + if (!Number.isFinite(fraction) || currentPhase < 0) { + return; } - 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) }; - } + const phase = phases[currentPhase]; + if (phase.stage !== stage) { + return; } - return { secs: 20, hint: "This is taking longer than expected" }; -} - -let slowTimer: ReturnType | null = null; - -function clearSlowWarning(): void { - if (slowTimer !== null) { - clearTimeout(slowTimer); - slowTimer = null; + const { base, target } = phase; + const clamped = Math.max(0, Math.min(1, fraction)); + // Only a band that asked to be driven this way may suppress the crawl, and + // only until its own work is done. After that the creep carries the + // indicator through the tail, which nothing reports on. + if (phase.reportsProgress === true) { + phaseReportsProgress = clamped < 1; } - const hint = document.getElementById("loading-hint"); - if (hint !== null) { - // Remove only the text span, preserve any gateway button - const textSpan = hint.querySelector(".loading-hint-text"); - if (textSpan !== null) { - textSpan.remove(); - } - // Only hide if no gateway button is present - if (hint.querySelector(".loading-gateway-btn") === null) { - hint.classList.remove("visible"); - } + const want = base + (target - base) * clamped; + if (want > currentProgress) { + setProgress(Math.min(want, target)); } } /** - * 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. + * Give the indicator back to the clock. + * + * A step that declared `reportsProgress` holds the indicator until it can say + * where the work is. This is how it admits it never will. + * + * Deliberately not a timeout. A timeout fired whether or not anything was + * happening, so a load whose content chain never found a peer still crept to + * 99% and sat there claiming to be nearly done. */ -export function showStatus(message: string): void { - const status = document.getElementById("status"); - if (status !== null) { - status.textContent = message; - } - - 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); - } +export function releasePhaseProgress(): void { + phaseReportsProgress = false; } /** * Stop the progress crawl and clear any slow warning (call when loading is done). */ +/** Stop everything the loading screen has running. */ export function stopStatusTick(): void { stopProgressCrawl(); - clearSlowWarning(); + stopStageMessages(); + cancelStatusReveal(); +} + +/** Cancel the pending status reveal, for a load that finished in time. */ +function cancelStatusReveal(): void { + if (revealTimer !== null) { + clearTimeout(revealTimer); + revealTimer = null; + } } /** @@ -321,7 +558,8 @@ export function stopStatusTick(): void { */ export function dismissLoading(): void { completeProgress(); - clearSlowWarning(); + cancelStatusReveal(); + stopStageMessages(); const loading = document.querySelector("#app > .loading"); if (loading !== null) { loading.style.transition = "opacity 0.3s ease"; @@ -359,9 +597,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(); } @@ -389,6 +627,9 @@ export function showError( detail?: string, action?: ErrorAction | ErrorAction[] | (() => void), ): void { + // The markup below replaces the loading screen, so its timers have nothing + // left to write to. + stopStatusTick(); if (typeof action === "function") { action = { label: "Retry", onClick: action }; } diff --git a/packages/ui/tests/topbar.test.ts b/packages/ui/tests/topbar.test.ts index 247185a1..8f8ef5f6 100644 --- a/packages/ui/tests/topbar.test.ts +++ b/packages/ui/tests/topbar.test.ts @@ -33,6 +33,12 @@ vi.mock("@dotli/protocol/client", () => ({ sharedAuth.listeners.delete(listener); }; }, + // The topbar subscribes at init so its network panel can show a live + // status. No test drives chain events, so this just has to exist and + // hand back an unsubscribe. + onProtocolChainSync: () => () => { + /* no chain events in these tests */ + }, })); async function flushMicrotasks(): Promise {