From b4f7353ba3dcdc70312e3272cac93d5f354ebaa9 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 27 Aug 2026 17:03:32 +0530 Subject: [PATCH] feat(truapi-host): dev-gated worker dial to the debugger and core schema-hash reporting --- js/packages/truapi-host/README.md | 37 ++ js/packages/truapi-host/src/wasm-module.ts | 8 + .../src/wasm/web/truapi_server.d.ts | 1 + .../src/web/create-worker-host-runtime.ts | 92 ++++ .../src/web/worker-provider.test.ts | 1 + .../truapi-host/src/worker-protocol.ts | 13 +- .../truapi-host/src/worker-runtime.test.ts | 394 ++++++++++++++++ js/packages/truapi-host/src/worker-runtime.ts | 434 +++++++++++++++++- 8 files changed, 977 insertions(+), 3 deletions(-) create mode 100644 js/packages/truapi-host/src/worker-runtime.test.ts diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 717e26f92..429d044b2 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -241,6 +241,43 @@ await runtime.activateStoredSession().catch(() => {}); const provider = await runtime.createProvider({ productId: "first.dot" }); ``` +## Debugging (dev-only) + +The worker can stream every product↔core wire frame to the wire debugger. It is +off by default and enabled purely from the host page — the product needs no +changes. Two conditions must **both** hold or nothing dials, the core installs no +tap, and nothing is logged: + +1. **The host page is a dev build.** The `localStorage` read sits behind a hard + `import.meta.env.DEV` gate, which bundlers replace with a boolean literal: in + a production bundle it returns `null` unconditionally, so no stored key can + turn the tap on. A production build that shows no frames is this gate, not a + broken debugger. +2. **The host origin's `localStorage` carries a `ws://` loopback URL**, read on + the host page at runtime boot and forwarded to the worker in its `init` + message: + + ```js + localStorage.setItem("truapi:debugger", "ws://127.0.0.1:9231"); + ``` + +Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, +`127.0.0.1:9231`). On the next runtime boot the worker dials that URL and (via +the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. + +The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, +a LAN or public address, a non-loopback hostname — yields an inert link and a +`wire debugger URL rejected` console warning; there is no certificate or `wss` +path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the +gate, but it resolves `::1` first on macOS while the debugger binds `127.0.0.1` +alone, so the same URL handed to a native host (`truapi-server`'s `WsDebugSink` +dials the first resolved address) silently never connects. + +The debugger owns all decoding and decodes every frame it can, including signing +and payment payloads; its safety is the dev-build gate above, not redaction. See +`js/packages/truapi-debugger/README.md` for the tap, the envelope, and the +host-dials-debugger topology. + ## Publishing This package is published by the root `Release` workflow through diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 231fc81df..ed061a1ad 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -77,4 +77,12 @@ export interface WasmModuleShape { ) => Uint8Array; /** SS58 address for a product account public key, at the core's prefix. */ productAccountAddress: (publicKey: Uint8Array) => string; + /** + * The core's own `TRUAPI_WIRE_SCHEMA_HASH`, exported by `truapi-server`'s wasm + * bridge. Optional because `dist/wasm/web/` is gitignored and built by hand, so + * a stale bundle predating the export is a normal state to find at runtime; a + * core that cannot vouch for its table streams frames without a `schema` stamp + * and the debugger groups them without decoding. + */ + wireSchemaHash?: () => string; } diff --git a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts index 3ad50df95..3a55fad2e 100644 --- a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts +++ b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts @@ -15,3 +15,4 @@ export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"]; export const setLogLevel: (level: string) => void; export const deriveProductAccountPublicKey: WasmModuleShape["deriveProductAccountPublicKey"]; export const productAccountAddress: WasmModuleShape["productAccountAddress"]; +export const wireSchemaHash: () => string; diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 857d907bd..3af0b82f1 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -37,6 +37,16 @@ export type WebWorkerHostConfig = Omit< >; export interface WorkerPairingHostRuntime { + /** + * The encoding core's wire-schema hash, when the core reports one. + * + * An in-host debugger tap runs on this side of the worker boundary and has no + * other way to reach it, so without this it can only stamp frames with the + * page bundle's own constant — a different artifact from the core that + * actually encoded them. The debugger then refuses to decode, exactly as it + * should. Undefined for a core built before the export existed. + */ + readonly coreWireSchemaHash: string | undefined; createProvider(product: { productId: string; executionKind?: ProductExecutionKind; @@ -174,6 +184,7 @@ interface RuntimeState { logLevel: LogLevel; disposed: boolean; nextCoreId: number; + coreWireSchemaHash: string | undefined; } function debugLoggingEnabled(state: RuntimeState): boolean { @@ -201,6 +212,80 @@ function readPersistedLogLevel(): LogLevel | null { return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; } +// Dev-only, host-agnostic enablement for the wire debugger: in a DEV build, set +// `localStorage["truapi:debugger"] = "ws://:9231"` in the browser and the +// host worker dials that debugger and streams frames to it. Read here (host page) +// and forwarded to the worker in `init`; no cooperation from the embedding shell. +const DEV_DEBUGGER_URL_KEY = "truapi:debugger"; + +/** + * Why the wire debugger is (not) enabled, so a no-dial is never silent. + * + * `no-key` is the one that bites. The key is read on whichever origin creates the + * runtime - the shell in an embedded host like dot.li, but an iframe realm in + * another embedding - and `localStorage` is per-origin, so a key set anywhere else + * is invisible here. Naming the origin is the whole point: the tap then stays dark + * with nothing on screen to say why. + */ +type DebuggerEnablement = { + readonly url: string | null; + readonly reason: "enabled" | "production-build" | "no-key" | "no-storage"; +}; + +function readPersistedDebuggerUrl(): DebuggerEnablement { + // Hard dev-only gate, not a convention: bundlers (Vite) replace + // `import.meta.env.DEV` with a boolean literal, so in a PRODUCTION build this + // returns null unconditionally and the tap is inert - a stray localStorage key + // cannot turn the debugger on in prod. The wire debugger streams raw + // (now fully-decoded) frames and is strictly a development tool. + // + // The expression below must stay the *literal* `import.meta.env.DEV`, with no + // alias and no optional chaining. A bundler replaces that exact token; reading + // it through `const meta = import.meta` or as `import.meta.env?.DEV` does not + // match, so the expression survives into the bundle and is evaluated at runtime + // against an `import.meta.env` that a plain module does not have. That reads as + // `undefined`, and the gate then refuses in *every* bundled host rather than + // only production ones - which silently disables the standalone tap everywhere. + // The try/catch keeps it safe where `import.meta.env` genuinely does not exist + // (tsc output run under Node, unit tests), where the access throws. + let dev = false; + try { + dev = (import.meta as unknown as { env: { DEV?: boolean } }).env.DEV === true; + } catch { + dev = false; + } + if (!dev) return { url: null, reason: "production-build" }; + const storage = globalThis.localStorage; + if (storage === undefined) return { url: null, reason: "no-storage" }; + const url = storage.getItem(DEV_DEBUGGER_URL_KEY); + if (url === null || url === "") return { url: null, reason: "no-key" }; + return { url, reason: "enabled" }; +} + +/** + * Say once, in a dev build, whether the debugger will dial - and from which + * origin. Silence here used to be indistinguishable from a working tap: the + * debugger's own socket count still moves (its UI holds one), so "connected but + * no frames" reads as a debugger bug rather than a host that never dialled. + * Never logs in a production build, where the gate is closed by construction and + * the message would be noise. + */ +function reportDebuggerEnablement(e: DebuggerEnablement): void { + if (e.reason === "production-build") return; + const origin = globalThis.location?.origin ?? "(unknown origin)"; + if (e.reason === "enabled") { + console.info(`[truapi] wire debugger: dialling ${e.url} (origin ${origin})`); + return; + } + const why = + e.reason === "no-storage" + ? "no localStorage in this realm" + : `no "${DEV_DEBUGGER_URL_KEY}" key on origin ${origin} - localStorage is ` + + "per-origin, so set it on THIS origin (the realm that creates the host " + + "runtime), then reload. A key on another origin is invisible here"; + console.info(`[truapi] wire debugger: off (${why})`); +} + function persistLogLevel(level: LogLevel): void { globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); } @@ -665,6 +750,7 @@ export function createWebWorkerPairingHostRuntime( logLevel: devLogLevelOverride ?? options.logLevel ?? "off", disposed: false, nextCoreId: 0, + coreWireSchemaHash: undefined, }; let runtime: WorkerPairingHostRuntime | null = null; @@ -823,6 +909,9 @@ export function createWebWorkerPairingHostRuntime( notifyFault(new Error("worker message could not be deserialized")); }; + const debuggerEnablement = readPersistedDebuggerUrl(); + reportDebuggerEnablement(debuggerEnablement); + const onInitMessage = (ev: MessageEvent): void => { const msg = ev.data; if (msg.kind === "loaded") { @@ -834,8 +923,10 @@ export function createWebWorkerPairingHostRuntime( chat: host.chat !== undefined, permissionStatus: host.permissionStatus !== undefined, }, + debuggerUrl: debuggerEnablement.url, } satisfies MainToWorker); } else if (msg.kind === "ready") { + state.coreWireSchemaHash = msg.schema; cleanupInit(); worker.addEventListener("message", onMessage); worker.addEventListener("error", onRuntimeError); @@ -923,6 +1014,7 @@ function handleFrameError( function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { const runtime: WorkerPairingHostRuntime = { + coreWireSchemaHash: state.coreWireSchemaHash, createProvider(product): Promise { if (state.disposed) { return Promise.reject( diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index c7d14ab2c..80b02ea2c 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -221,6 +221,7 @@ describe("createWebWorkerPairingHostRuntime", () => { logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), capabilities: { chat: false, permissionStatus: false }, + debuggerUrl: null, }); worker.emit({ kind: "ready" }); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 06b721bed..e4b493e21 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -66,6 +66,9 @@ export type MainToWorker = * the boundary. */ capabilities: OptionalCapabilities; + // Dev-only: when set, the worker dials this debugger and streams tapped + // frames to it. Null in production, so the host tap stays inert. + debuggerUrl: string | null; } | { kind: "createCore"; coreId: number; product: unknown } | { kind: "disposeCore"; coreId: number } @@ -135,7 +138,15 @@ export type MainToWorker = */ export type WorkerToMain = | { kind: "loaded" } - | { kind: "ready" } + | { + kind: "ready"; + /** + * The encoding core's wire-schema hash, when it reports one. The page needs + * it to stamp an in-host debugger tap with the same identity a dialing host + * puts on a standalone envelope; without it a tap is grouped but not decoded. + */ + schema?: string; + } | { kind: "coreReady"; coreId: number } | { kind: "coreError"; coreId: number; error: string } | { kind: "fatalError"; error: string } diff --git a/js/packages/truapi-host/src/worker-runtime.test.ts b/js/packages/truapi-host/src/worker-runtime.test.ts new file mode 100644 index 000000000..7d378e711 --- /dev/null +++ b/js/packages/truapi-host/src/worker-runtime.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, test } from "bun:test"; + +import { + coreWireSchemaHash, + createDebuggerLink, + isLoopbackWsUrl, + type DebuggerSocket, +} from "./worker-runtime.js"; + +/** + * The gate mirrors the native sink's (`native_debug.rs`) three cases — loopback + * forms, bracket forms, and non-loopback rejection — and then covers the shapes + * a string-matching check is normally bypassed by. + * + * The one deliberate difference from the Rust side: `WsDebugSink::connect` + * *resolves* the host and requires every resolved address to be loopback, which + * closes the "validate one string, dial another" gap. A Web Worker has no + * resolver, so this checks the hostname the WHATWG parser normalized. That is + * sound here for the reason the Rust gap needed closing at all: the same `url` + * string is handed to `new WebSocket(url)`, so the browser resolves exactly what + * was validated — there is no second, unvalidated string. + */ +describe("isLoopbackWsUrl", () => { + test("accepts ws:// on every genuine loopback form", () => { + for (const url of [ + "ws://localhost:9231", + "ws://127.0.0.1:9231", + // 127.0.0.0/8 in full, not just 127.0.0.1. + "ws://127.5.6.7:9231", + "ws://[::1]:9231", + // Bracket forms: expanded and IPv4-mapped, both of which the URL parser + // normalizes to a different string than the one written. + "ws://[0:0:0:0:0:0:0:1]:9231", + "ws://[::ffff:127.0.0.1]:9231", + "ws://[::ffff:7f00:1]:9231", + // Scheme and host are case-insensitive; a path or query is irrelevant. + "WS://127.0.0.1:9231", + "ws://LOCALHOST:9231", + "ws://127.0.0.1:9231/path?q=1", + ]) { + expect(isLoopbackWsUrl(url)).toBe(true); + } + }); + + test("rejects wss:// — the tap is ws-only, matching the native sink", () => { + expect(isLoopbackWsUrl("wss://localhost:9231")).toBe(false); + expect(isLoopbackWsUrl("wss://127.0.0.1:9231")).toBe(false); + expect(isLoopbackWsUrl("wss://[::1]:9231")).toBe(false); + }); + + test("rejects every non-ws scheme, including ones that parse", () => { + for (const url of [ + "http://127.0.0.1:9231", + "https://127.0.0.1:9231", + "file://127.0.0.1", + "javascript:alert(1)", + "not a url", + "", + ]) { + expect(isLoopbackWsUrl(url)).toBe(false); + } + }); + + test("rejects non-loopback hosts, including the ones that look local", () => { + for (const url of [ + "ws://192.0.2.1:9231", + "ws://example.com:9231", + // A wildcard bind is not loopback: it is reachable from off-machine. + "ws://0.0.0.0:9231", + // Private and link-local ranges are still off-machine. + "ws://10.0.0.1:9231", + "ws://169.254.169.254:9231", + // IPv4-mapped *non*-loopback must not ride the ::ffff: prefix in. + "ws://[::ffff:192.0.2.1]:9231", + // A trailing dot is a distinct hostname and is not accepted. + "ws://localhost.:9231", + ]) { + expect(isLoopbackWsUrl(url)).toBe(false); + } + }); + + test("rejects hosts that merely embed a loopback-looking substring", () => { + for (const url of [ + "ws://localhost.evil.com:9231", + "ws://127.0.0.1.evil.com:9231", + // Path and userinfo are not the host: the dial target is `evil.com`. + "ws://evil.com/127.0.0.1", + "ws://user:pass@evil.com:9231", + "ws://127.0.0.1@evil.com:9231", + // A homoglyph digit does not normalize to an ASCII loopback literal. + "ws://➀27.0.0.1:9231", + ]) { + expect(isLoopbackWsUrl(url)).toBe(false); + } + }); + + test("accepts alternate integer spellings of 127.0.0.1 — they are loopback", () => { + // The WHATWG parser normalizes these to `127.0.0.1` before the check, and + // `new WebSocket(url)` normalizes identically, so accepting them is correct: + // the socket really does go to loopback. Pinned so a future hand-rolled + // hostname check cannot quietly start disagreeing with the parser. + expect(isLoopbackWsUrl("ws://2130706433:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://0177.0.0.1:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://0x7f.0.0.1:9231")).toBe(true); + // The same spellings for a non-loopback address stay rejected. + expect(isLoopbackWsUrl("ws://3221225985:9231")).toBe(false); + }); + + test("userinfo cannot smuggle a non-loopback dial target", () => { + // Mirror of the rejection case: here the *host* is loopback and the + // userinfo is the decoy, so the dial is loopback and the URL is accepted. + expect(isLoopbackWsUrl("ws://evil.com@127.0.0.1:9231")).toBe(true); + }); +}); + +/** + * A socket the tests drive: records what was sent, lets a test stall the peer by + * holding `bufferedAmount` high, and can fail a send the way a dead socket does. + */ +class FakeSocket implements DebuggerSocket { + sent: string[] = []; + bufferedAmount = 0; + failSends = false; + closed = false; + private listeners = new Map void)[]>(); + + send(data: string): void { + if (this.failSends) throw new Error("socket is dead"); + this.sent.push(data); + } + close(): void { + this.closed = true; + } + addEventListener(type: "open" | "close" | "error", listener: () => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + /** Drive the lifecycle the real socket would. */ + fire(type: "open" | "close" | "error"): void { + for (const l of this.listeners.get(type) ?? []) l(); + } + /** Every envelope sent so far, parsed. */ + envelopes(): Record[] { + return this.sent.map((s) => JSON.parse(s) as Record); + } +} + +/** A link wired to a fake socket and a manual clock. */ +function harness(options: { schema?: string } = {}) { + const sockets: FakeSocket[] = []; + const timers: { run: () => void; delayMs: number }[] = []; + const link = createDebuggerLink("ws://127.0.0.1:9231", { + ...options, + createSocket: () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + schedule: (run, delayMs) => timers.push({ run, delayMs }), + }); + return { + link, + sockets, + timers, + /** The socket currently in use. */ + live: () => sockets[sockets.length - 1]!, + /** Run every pending timer once, as the scheduler would. */ + tick: () => { + const due = timers.splice(0); + for (const t of due) t.run(); + }, + }; +} + +const FRAME = new Uint8Array([1, 2, 3]); + +describe("debugger link: envelope contents", () => { + test("stamps the core's schema only when the core vouched for one", () => { + const attested = harness({ schema: "deadbeefdeadbeef" }); + attested.live().fire("open"); + attested.link.emit("app.dot", "out", FRAME); + expect(attested.live().envelopes()[0]?.schema).toBe("deadbeefdeadbeef"); + + // No schema from the core: the envelope must carry none, so the debugger + // groups but refuses to decode rather than trusting a hash nobody vouched + // for. Fabricating one here is the silent mis-decode this exists to prevent. + const bare = harness(); + bare.live().fire("open"); + bare.link.emit("app.dot", "out", FRAME); + expect(bare.live().envelopes()[0]).not.toHaveProperty("schema"); + }); + + test("every frame carries the producer's own observation time", () => { + const h = harness(); + h.live().fire("open"); + const before = Date.now(); + h.link.emit("app.dot", "out", FRAME); + const observedAt = h.live().envelopes()[0]?.observedAt; + expect(typeof observedAt).toBe("number"); + expect(observedAt as number).toBeGreaterThanOrEqual(before); + }); + + test("frames queued while the socket is down replay marked as buffered", () => { + const h = harness(); + // Socket not open yet: these go to the queue. + h.link.emit("app.dot", "out", FRAME); + h.link.emit("app.dot", "in", FRAME); + expect(h.live().sent).toHaveLength(0); + + h.live().fire("open"); + const flushed = h.live().envelopes(); + expect(flushed).toHaveLength(2); + // Without the marker the debugger cannot tell a replayed backlog from a live + // stream, and every op in the flush lands in one retry-storm window. + expect(flushed.every((e) => e.buffered === true)).toBe(true); + }); + + test("a live frame is not marked buffered", () => { + const h = harness(); + h.live().fire("open"); + h.link.emit("app.dot", "out", FRAME); + expect(h.live().envelopes()[0]).not.toHaveProperty("buffered"); + }); +}); + +describe("debugger link: backpressure and drop accounting", () => { + test("sheds when the socket's own buffer is over the ceiling", () => { + const h = harness(); + h.live().fire("open"); + // Peer stopped reading: readyState stays OPEN while bufferedAmount grows, so + // handing frames over unchecked is unbounded buffering in the observed + // session's worker. + h.live().bufferedAmount = 9 * 1024 * 1024; + h.link.emit("app.dot", "out", FRAME); + expect(h.live().sent).toHaveLength(0); + + // The shed is counted and reported on the next frame that gets through. + h.live().bufferedAmount = 0; + h.link.emit("app.dot", "out", FRAME); + expect(h.live().envelopes()[0]?.dropped).toBe(1); + }); + + test("sheds a single over-cap message instead of killing the stream", () => { + const h = harness(); + h.live().fire("open"); + // One oversized frame on an IDLE socket: the cumulative ceiling never trips, + // but the debugger closes the connection on an over-cap message, so an + // unshed frame costs every later frame too. + h.link.emit("app.dot", "out", new Uint8Array(7 * 1024 * 1024)); + expect(h.live().sent).toHaveLength(0); + + h.link.emit("app.dot", "out", FRAME); + const envelopes = h.live().envelopes(); + expect(envelopes).toHaveLength(1); + expect(envelopes[0]?.dropped).toBe(1); + }); + + test("a failed send keeps the drop count instead of clearing it", () => { + const h = harness(); + h.live().fire("open"); + h.live().bufferedAmount = 9 * 1024 * 1024; + h.link.emit("app.dot", "out", FRAME); // shed, dropped = 1 + h.live().bufferedAmount = 0; + + h.live().failSends = true; + h.link.emit("app.dot", "out", FRAME); // send throws + h.live().failSends = false; + + h.link.emit("app.dot", "out", FRAME); + // EXACT, not >=1: the shed frame AND the frame whose send failed are both + // losses. `>=1` was satisfied by the seeded shed alone, so it could not see + // the frame that vanished on the failed send. + expect(h.live().envelopes()[0]?.dropped).toBe(2); + }); + + test("a failed backlog drain reports the gap instead of swallowing it", () => { + const h = harness(); + // Queue a backlog and shed past the cap, then fail every send on drain. The + // count was cleared before the loop, so a fully-failed flush reported a + // clean session while every frame in it was lost. + for (let i = 0; i < 1200; i++) h.link.emit("app.dot", "out", FRAME); + h.live().failSends = true; + h.live().fire("open"); + h.live().failSends = false; + + h.link.emit("app.dot", "out", FRAME); + const reported = h.live().envelopes().at(-1)?.dropped; + expect(typeof reported).toBe("number"); + expect(reported as number).toBeGreaterThan(1000); + }); + + test("the backlog drain respects the per-message cap", () => { + const h = harness(); + // Queued while the socket is down, drained after it opens. The cap is + // documented as applying to both paths; only the live one enforced it, so an + // over-cap message reached the debugger on reconnect and closed the stream. + h.link.emit("app.dot", "out", new Uint8Array(5 * 1024 * 1024)); + h.live().fire("open"); + for (const raw of h.live().sent) { + expect(raw.length).toBeLessThanOrEqual(6 * 1024 * 1024); + } + }); + + test("the backlog drain does not force-feed a wedged socket", () => { + const h = harness(); + for (let i = 0; i < 50; i++) h.link.emit("app.dot", "out", FRAME); + // Peer stopped reading before the drain begins. + h.live().bufferedAmount = 9 * 1024 * 1024; + h.live().fire("open"); + expect(h.live().sent).toHaveLength(0); + }); +}); + +describe("debugger link: reconnect", () => { + test("a dead link redials on a timer, not once per frame", () => { + const h = harness(); + h.live().fire("close"); + const dialsBefore = h.sockets.length; + + // Reconnect is scheduled lazily by the next emit. The property that matters + // is that N further frames do NOT produce N dials: before the backoff, a busy + // session with no debugger listening dialed loopback hundreds of times a + // second, each refused, each logging a console error. + for (let i = 0; i < 10; i++) h.link.emit("app.dot", "out", FRAME); + expect(h.sockets.length).toBe(dialsBefore); + expect(h.timers.length).toBe(1); + expect(h.timers[0]?.delayMs ?? 0).toBeGreaterThan(0); + + h.tick(); + expect(h.sockets.length).toBe(dialsBefore + 1); + }); + + test("the backoff grows across repeated failed dials", () => { + const h = harness(); + const delays: number[] = []; + for (let i = 0; i < 3; i++) { + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + delays.push(h.timers[0]?.delayMs ?? 0); + h.tick(); + } + expect(delays[0]).toBeGreaterThan(0); + expect(delays[1]).toBeGreaterThan(delays[0]!); + }); + + test("a dial that reaches the debugger earns the short delay back", () => { + const h = harness(); + // Fail twice so the backoff has grown. + for (let i = 0; i < 2; i++) { + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + h.tick(); + } + // Now a dial succeeds, then dies again: the next wait is the base delay, so a + // debugger that restarts is picked up promptly rather than after the cap. + h.live().fire("open"); + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + expect(h.timers[0]?.delayMs).toBe(200); + }); +}); + +describe("coreWireSchemaHash: attest only what the core vouched for", () => { + test("returns the core's hash when it reports one", () => { + expect(coreWireSchemaHash({ wireSchemaHash: () => "abc123abc123abc1" })).toBe( + "abc123abc123abc1", + ); + }); + + test("returns undefined for a core that does not report one", () => { + // `dist/wasm/web/` is gitignored and hand-built, so a stale bundle predating + // the export is a normal state to find at runtime. Inventing a hash here + // would attest to a table this core did not encode with — the debugger would + // then decode a foreign contract confidently, which is precisely the silent + // mis-decode the fingerprint exists to stop. Grouping without decode is the + // correct degradation. + expect(coreWireSchemaHash({})).toBeUndefined(); + }); + + test("returns undefined when the core's accessor throws or lies", () => { + expect( + coreWireSchemaHash({ + wireSchemaHash: () => { + throw new Error("stale bundle"); + }, + }), + ).toBeUndefined(); + // A non-string or empty answer is not an attestation either. + expect( + coreWireSchemaHash({ wireSchemaHash: () => "" as unknown as string }), + ).toBeUndefined(); + }); +}); diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 0f23d6786..9d7b20fb2 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -10,6 +10,7 @@ import type { WorkerToMain, } from "./worker-protocol.js"; import type { GenericError } from "@parity/truapi"; +import { TRUAPI_CODEC_VERSION } from "@parity/truapi"; import { createWorkerRawCallbacks, type CallbackName, @@ -174,8 +175,420 @@ function buildRawCallbacks(capabilities: OptionalCapabilities) { ); } -function buildCoreCallbacks(coreId: number) { +/** Encode raw frame bytes as base64 (JSON can't carry binary over the WS). */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +/** + * Envelope version stamped on each frame, mirroring the debugger's + * `WIRE_ENVELOPE_VERSION`. Kept in sync by hand (a value constant, not a shared + * dep, to avoid truapi-host depending on the debugger package). + */ +const WIRE_ENVELOPE_VERSION = 1; + +/** + * Is `url` a `ws://` URL on a loopback host? The debug tap forwards every frame + * verbatim, including payloads carrying key material: there is no denylist and + * nothing is redacted anywhere in this pipeline, so the loopback requirement is + * the whole confinement story - refuse to stream them off the local machine. + * `ws://` only, matching the native sink (`native_debug.rs`), also ws-only. + * + * Cleartext is the right call *because* the target is loopback-only. TLS defends + * against a party on the path, and a loopback socket has no path: the frames + * never reach an interface. `wss://` would instead require the debugger to + * present a certificate — unobtainable for `localhost` from a real CA, and + * self-signed on iOS costs the developer a CA install plus a manual enable under + * Settings → General → About → Certificate Trust Settings before a single frame + * arrives. So `wss://` buys no confidentiality here and costs setup, while adding + * a second protocol path and a trust surface to the gate. + * + * Confidentiality for the trace stream comes from the loopback check, not from + * the scheme: the frames never cross a network, so there is nothing on a network + * to encrypt. A *remote* debugger would need TLS **and** authentication **and** + * an explicit opt-in; none of that is a scheme this gate silently accepts today. + * + * Unlike `WsDebugSink::connect`, which resolves the host and requires every + * resolved address to be loopback, this matches the hostname the URL parser + * normalized. There is no resolver in a Web Worker, and none is needed: the same + * `url` string is passed to `new WebSocket(url)` below, so the browser resolves + * exactly what was validated. The Rust "validate one string, dial another" gap + * cannot open here because there is only ever one string. + */ +export function isLoopbackWsUrl(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol !== "ws:") return false; + const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + host === "localhost" || + host === "::1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || + // IPv4-mapped loopback: WHATWG serializes ::ffff:127.x.y.z as ::ffff:7fxx:yyyy. + /^::ffff:7f[0-9a-f]{2}:/.test(host) + ); + } catch { + return false; + } +} + +/** + * The wire-contract fingerprint of the core that *encodes* the frames, or + * `undefined` when this build of the core does not report one. + * + * The debugger decodes each frame against a `frameId → method` table, so the + * `schema` an envelope carries has to be the fingerprint of the table the bytes + * were encoded with. That is the WASM core's, not `@parity/truapi`'s: the client + * and the core are separate artifacts, and `dist/wasm/web/` is gitignored and + * built by hand (`make wasm`), so a stale core beside a fresh client is the + * everyday case rather than an exotic one. Stamping the client's hash there would + * make the debugger *confirm* identity on frames from a different table and decode + * them into the wrong methods and values, silently. + * + * When the core does not report a hash, the envelope carries none. The debugger + * treats an unstamped frame as unconfirmed: it still groups the op, but refuses + * to decode values. Losing decode until `make wasm` is rerun is the honest + * outcome; a confident wrong decode is not. + */ +export function coreWireSchemaHash(module: { + wireSchemaHash?: () => string; +}): string | undefined { + let hash: unknown; + try { + hash = module.wireSchemaHash?.(); + } catch { + hash = undefined; + } + if (typeof hash === "string" && hash.length > 0) return hash; + console.warn( + "[truapi] wire debugger: this WASM core does not report its wire-schema hash — frames will stream without a `schema` stamp and the debugger will group them but refuse to decode values (rebuild the core with `make wasm`)", + ); + return undefined; +} + +/** + * The socket surface the debugger link uses. A `WebSocket` satisfies it; tests + * substitute a fake to drive backpressure and reconnect timing without a network. + */ +export interface DebuggerSocket { + /** Bytes handed to the socket that it has not yet put on the wire. */ + readonly bufferedAmount: number; + send(data: string): void; + close(): void; + addEventListener(type: "open" | "close" | "error", listener: () => void): void; +} + +/** Construction options for {@link createDebuggerLink}. */ +export interface DebuggerLinkOptions { + /** + * The encoding core's wire-schema hash, from {@link coreWireSchemaHash}. When + * omitted, envelopes carry no `schema` and the debugger refuses value decode + * rather than trusting a hash the core never vouched for. + */ + schema?: string; + /** Socket factory. Defaults to a real `WebSocket`; tests inject a fake. */ + createSocket?: (url: string) => DebuggerSocket; + /** Deferred scheduler for reconnect backoff. Defaults to `setTimeout`. */ + schedule?: (run: () => void, delayMs: number) => void; +} + +/** Initial reconnect delay; doubles per failed dial up to {@link RECONNECT_MAX_MS}. */ +const RECONNECT_BASE_MS = 200; + +/** Cap on the reconnect backoff. Mirrors the native sink's `MAX_BACKOFF`. */ +const RECONNECT_MAX_MS = 5000; + +/** + * Ceiling on the socket's *own* unflushed send buffer before frames are shed. + * + * The queue caps below only bound what this module holds while the socket is + * down. A socket that is open but whose peer has stopped reading keeps + * `readyState === OPEN` while `bufferedAmount` grows without limit, and that + * growth is charged to the observed session's worker: handing frames to it + * unchecked is the same unbounded buffering the queue caps exist to prevent, one + * layer lower. Over this ceiling, frames are shed into the counted `dropped` + * instead. + */ +const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; + +/** + * Ceiling on a SINGLE encoded message, enforced on the live path and again when + * the backlog drains. + * + * `MAX_SOCKET_BUFFERED_BYTES` bounds the socket's cumulative backlog, which one + * oversized frame passes straight through on an otherwise idle socket. The + * debugger closes the connection on an over-cap message rather than dropping it + * (Bun: close 1006, "Received too big message"), so an unshed frame costs the + * whole stream. Base64 inflates 4/3, so this sits below the server's own limit + * with room for the envelope's other fields. + */ +const MAX_MESSAGE_BYTES = 6 * 1024 * 1024; + +/** + * Dev-only link to the debugger the host dials. Fire-and-forget by construction: + * it opens lazily, buffers a bounded backlog until the socket is up, retries a + * dropped connection with capped backoff, sheds frames (counted) rather than + * buffering without bound at either layer, and swallows every error - a slow, + * absent, or crashed debugger only loses the trace, it can never throw into the + * frame path. + */ +export function createDebuggerLink( + url: string, + options: DebuggerLinkOptions = {}, +): { + emit(channelId: string, dir: string, frame: Uint8Array): void; +} { + // Loopback-only, dev-only: a non-loopback (or non-ws://) debugger URL yields an + // inert link rather than streaming frames across the network. Warn so a + // mistyped value reads as "misconfigured", not "the debugger doesn't work". + if (!isLoopbackWsUrl(url)) { + console.warn( + `[truapi] wire debugger URL rejected (must be ws:// on a loopback host): ${url}`, + ); + return { emit() {} }; + } + const createSocket = + options.createSocket ?? ((target: string) => new WebSocket(target)); + const schedule = + options.schedule ?? + ((run: () => void, delayMs: number) => { + setTimeout(run, delayMs); + }); + const schema = options.schema; + let socket: DebuggerSocket | null = null; + let open = false; + const queue: string[] = []; + // Count *and* byte caps: each queued item is a base64 ProtocolMessage (storage + // writes, RPC responses - up to MBs each), so a count-only cap would let a slow + // or absent debugger buffer unbounded RSS on the observed session. Whichever + // ceiling hits first drops the frame (counted), never blocking the frame path. + const MAX_QUEUE = 1000; + const MAX_QUEUE_BYTES = 8 * 1024 * 1024; + let queuedBytes = 0; + let droppedSinceSend = 0; + let reconnectDelayMs = RECONNECT_BASE_MS; + let reconnectScheduled = false; + + /** + * Dial again after the current backoff, at most one dial in flight. + * + * Without the delay this ran once per frame: a busy session with no debugger + * listening dialed loopback hundreds of times a second (each refused + * immediately, each logging a console error), because every emit found + * `socket === null` and redialled. The native sink has always backed off; this + * mirrors it. + */ + function scheduleReconnect(): void { + if (socket !== null || reconnectScheduled) return; + reconnectScheduled = true; + const delayMs = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS); + try { + schedule(() => { + reconnectScheduled = false; + if (socket === null) connect(); + }, delayMs); + } catch { + // No timer available: fall back to redialling on the next emit. + reconnectScheduled = false; + } + } + + /** Drain the backlog onto a freshly opened socket. */ + function flush(): void { + const pending = queue.splice(0); + queuedBytes = 0; + // Deliver drops accumulated while disconnected by stamping the count on the + // first drained frame - a bare marker without channelId/dir/frame wouldn't + // parse server-side. Drops only happen once the queue is full, so when the + // count is nonzero there is always a pending frame to carry it; if not, it + // rides the next live emit. + // The count is only cleared once a frame carrying it is actually handed to + // the socket. Clearing it up front lost the whole gap whenever the drain + // failed - 1100 shed frames reported as a clean session. + let carried = 0; + if (pending.length > 0 && droppedSinceSend > 0) { + try { + const first = JSON.parse(pending[0]) as Record; + first.dropped = droppedSinceSend; + pending[0] = JSON.stringify(first); + carried = droppedSinceSend; + } catch { + // Leave the frame as-is; the count rides the next live emit. + } + } + for (const [index, message] of pending.entries()) { + // The drained path is subject to the same two ceilings as the live one: a + // wedged socket must not be force-fed the backlog, and an over-cap message + // closes the debugger's connection and costs every frame after it. + const open = socket; + if ( + open === null || + open.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES || + message.length > MAX_MESSAGE_BYTES + ) { + shed(); + continue; + } + if (send(message)) { + if (index === 0) droppedSinceSend -= carried; + } else { + shed(); + } + } + } + + function connect(): void { + let dialed: DebuggerSocket; + try { + dialed = createSocket(url); + } catch { + socket = null; + scheduleReconnect(); + return; + } + socket = dialed; + dialed.addEventListener("open", () => { + open = true; + // A dial that reached the debugger earns the short delay back, so a + // debugger that restarts is picked up promptly rather than after the cap. + reconnectDelayMs = RECONNECT_BASE_MS; + flush(); + }); + dialed.addEventListener("close", () => { + open = false; + if (socket === dialed) socket = null; + }); + dialed.addEventListener("error", () => { + // A socket that fired `error` is dead: close it explicitly (tidiness), then + // null it so the next emit schedules a redial. Without the null, a runtime + // that fires `error` without a following `close` would leave `socket` + // non-null and frames would buffer then drop. + open = false; + if (socket === dialed) socket = null; + try { + dialed.close(); + } catch { + // already closed / closing + } + }); + } + + function send(message: string): boolean { + // A null socket is NOT a success: returning true there would clear the drop + // count against a frame that went nowhere. Note the residual limit - per + // WHATWG, `WebSocket.send()` on a CLOSING/CLOSED socket discards silently + // without throwing, so a `true` here means "handed over", not "delivered". + const live = socket; + if (live === null) return false; + try { + live.send(message); + return true; + } catch { + // A dead socket must never break the frame path. The caller keeps its + // pending drop count rather than clearing it against a send that failed - + // otherwise a gap the host really did cause is reported as no gap at all. + return false; + } + } + + connect(); + + let warnedDrop = false; + /** Shed one frame into the counted backlog gap. */ + function shed(): void { + droppedSinceSend += 1; + if (!warnedDrop) { + // The link buffers a bounded backlog while the debugger is absent/slow, and + // stops handing frames to a socket that is not draining. Warn once so the + // gap is attributable to the link, not the host. + warnedDrop = true; + console.warn( + "[truapi] wire debugger link is not keeping up — dropping frames (counted in `dropped`) until it drains", + ); + } + } + return { + emit(channelId, dir, frame) { + // A debug tap must never throw into the observed frame path: toBase64 / + // JSON.stringify can raise on a pathological frame (btoa or V8 string-length + // limits), and only send() swallows its own errors. Losing a trace is fine; + // breaking dispatch is not. + try { + const live = open ? socket : null; + // Checked before encoding, so a shed frame costs no base64 either. + if (live !== null && live.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES) { + shed(); + return; + } + const base = { + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + // Only when the core vouched for it: see coreWireSchemaHash. + ...(schema !== undefined ? { schema } : {}), + channelId, + dir, + // The producer is the only party that knows when the frame crossed. The + // debugger's own clock is the flush instant for anything that waited in + // the queue below, which collapses every duration in a backlog to 0ms + // and pulls ops minutes apart into one retry-storm window. + observedAt: Date.now(), + frame: toBase64(frame), + }; + if (live !== null) { + // Piggyback any frames dropped while the link was down onto the next + // live frame, so the debugger attributes the gap to the link, not the + // host. + const message = + droppedSinceSend > 0 + ? JSON.stringify({ ...base, dropped: droppedSinceSend }) + : JSON.stringify(base); + // One over-cap message closes the debugger's socket, taking the whole + // stream with it. Shedding this frame keeps the rest. + if (message.length > MAX_MESSAGE_BYTES) { + shed(); + return; + } + if (send(message)) { + droppedSinceSend = 0; + } else { + // The frame just handed over is lost as well, not only the earlier + // ones: counting the prior gap but not this frame under-reports by + // exactly the frames whose send failed. + shed(); + } + return; + } + // Nothing leaves the queue except through flush(), so everything that + // enters it is by definition replayed rather than live: mark it here and + // the debugger can tell a backlog gap from a quiet session. Its + // `observedAt` above is already the real crossing time, so the marker is + // provenance, not a correction. + const message = JSON.stringify({ ...base, buffered: true }); + if ( + queue.length < MAX_QUEUE && + queuedBytes + message.length <= MAX_QUEUE_BYTES + ) { + queue.push(message); + queuedBytes += message.length; + } else { + shed(); + } + scheduleReconnect(); + } catch { + // Swallow: never let the tap disturb the frame path. + } + }, + }; +} + +let debuggerLink: ReturnType | null = null; + +function buildCoreCallbacks(coreId: number) { + const callbacks = { emitFrame(frame: Uint8Array): void { postToMain({ kind: "frame", coreId, bytes: frame }); }, @@ -183,6 +596,15 @@ function buildCoreCallbacks(coreId: number) { // Main thread owns lifecycle and disposes explicitly. }, }; + if (!debuggerLink) return callbacks; + // Adding `debugEmit` is what makes the Rust host install its debug sink; when + // no debugger is configured it is absent and the tap stays inert. + return { + ...callbacks, + debugEmit(channelId: string, dir: string, frame: Uint8Array): void { + debuggerLink?.emit(channelId, dir, frame); + }, + }; } let runtime: WorkerPairingHostRuntime | null = null; @@ -224,12 +646,20 @@ ctx.addEventListener("message", (ev: MessageEvent) => { break; } wasm.setLogLevel?.(msg.logLevel); + if (msg.debuggerUrl && !debuggerLink) { + // The hash comes from the core that will encode the frames, not from this + // package's client constant: they are separate artifacts and the WASM + // bundle is built by hand. + debuggerLink = createDebuggerLink(msg.debuggerUrl, { + schema: coreWireSchemaHash(wasm), + }); + } try { runtime = new wasm.WasmPairingHostRuntime( buildRawCallbacks(msg.capabilities), msg.hostConfig, ); - postToMain({ kind: "ready" }); + postToMain({ kind: "ready", schema: coreWireSchemaHash(wasm) }); } catch (err) { postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); }