Skip to content

fix(protocol): enforce a call-time request budget with phase attribution - #168

Open
ryanleecode wants to merge 18 commits into
mainfrom
fix/protocol-request-call-time-budget
Open

fix(protocol): enforce a call-time request budget with phase attribution#168
ryanleecode wants to merge 18 commits into
mainfrom
fix/protocol-request-call-time-budget

Conversation

@ryanleecode

@ryanleecode ryanleecode commented Aug 14, 2026

Copy link
Copy Markdown

Summary

postRequest previously advertised a per-method timeout contract (30s default, 90s for name/owner lookups) but only began counting after the shared protocol iframe finished loading and became ready. Because the frame bootstrap carries its own allowances (IFRAME_LOAD_TIMEOUT_MS = 30_000, IFRAME_READY_TIMEOUT_MS = 240_000), cold-start or stalled frame requests could block callers for up to ~5 minutes while promising a 30s deadline — without reporting whether time was spent booting the iframe or waiting on an RPC reply.

A unified request budget (startRequestBudget) is now armed at the call site and raced across each sequential lifecycle wait (load, ready, reply). Timeouts reject with a typed ProtocolRequestTimeoutError attributing the exact in-flight phase (load, ready, or reply). In addition, frame teardown now rejects in-flight requests immediately instead of orphaning them, message listener guards discard messages from non-frame sources, the host shell reports startup-phase timeouts accurately, the protocol test harness is refactored into a dual-driver domain DSL (DAppDriver and ProtocolFrame), and package testing doctrine is codified in packages/protocol/AGENTS.md.

Fixes #166

What Changed

  • Call-Time Request Budgeting: startRequestBudget initializes a single timer before awaiting frame setup. RequestBudget.guard(phase, work) tags the active lifecycle phase and races each step against the shared deadline.
  • Phase-Attributed Rejections: ProtocolRequestTimeoutError captures method, timeoutMs, and phase (load, ready, or reply), read directly when the timer fires so callers and telemetry distinguish exactly where time was spent.
  • Chain Provider Cold-Start Unstacking: createRemoteChainProvider invokes postRequest("chainConnect") directly instead of pre-awaiting ensureProtocolFrame(), eliminating redundant 30s + 240s + 30s (~300s) timeout cascades on cold starts while maintaining queueing and error flushes.
  • Teardown In-Flight Drain: resetProtocolFrameState now drains and rejects all pendingRequests immediately with "Protocol frame state reset before reply", eliminating orphaned promises on post-ready frame teardowns.
  • Source-Window Guard Hardening: bindMessageListener drops messages when !frameWindow || event.source !== frameWindow, ensuring post-teardown or spoofed window messages with valid origins are discarded.
  • Metric Latency Sample Integrity: Failed and timed-out requests no longer record misleading duration samples into protocol.request. Timeouts emit only counter samples tagged by phase, preventing partial-window residuals from skewing p95/p99 latency distributions.
  • Typed Error Matching in Host Shell: apps/host/src/errors.ts:describeError matches ProtocolRequestTimeoutError by type, and maps load and ready phase timeouts to HOST_ERRORS.SW_TIMED_OUT ("The light client timed out during startup.") instead of generic peer-loss copy.
  • Dual-Driver Protocol Test Architecture: Decomposed monolithic test files into domain-focused suites in packages/protocol/tests/ using a clean DSL (tests/support/{dapp,frame,rpc,broker,time,index}.ts), eliminating inline JSON parsing, raw wire inspection, and magic tick yields in test bodies.
  • Tautological Test Removal: Deleted packages/protocol/tests/errors.test.ts (constructor parameter echoing; thrown error properties are asserted on live rejection paths).
  • Package Testing Doctrine: Added packages/protocol/AGENTS.md specifying package testing doctrine in clean prose: dual-driver test doubles, domain getter assertions, virtual timer synchronization, and contract defense.

Design Decisions and Tradeoffs

Decision Rationale Alternatives Considered and Rejected
Single setTimeout raced per phase Monotonic timer queue ensures the deadline is respected across asynchronous hops without timer drift or cumulative budget expansion. Date.now() deadline arithmetic: Rejected because wall-clock shifts (NTP steps, system sleep/resume) distort time boundaries.
Budget timer armed before frame promise creation Under standard timer queue insertion order, equal-expiry timers fire in creation order. Arming the budget first ensures a 30s request on a hung load reports its own timeout rather than the frame's generic load failure, making the load phase reachable. Lowering IFRAME_LOAD_TIMEOUT_MS: Rejected because shrinking frame allowances globally breaks independent timeout contracts.
Phase read at timer fire time Reading spentOn inside the timer callback guarantees the rejection accurately attributes the exact wait currently in flight. Inferring phase from race winner: Rejected because shared promises across concurrent callers make race resolution order unreliable for attribution.
Omit duration samples on failure protocol.request is an attribute-less latency metric. Because the budget timer begins at the call while the request timer begins at the reply phase, recording a timeout would sample only the residual duration (e.g. 3s left out of 90s), artificially skewing latency downward. Recording residual duration: Rejected because mixing partial-window failures into completed roundtrip metrics misrepresents service health.
In-repo timer primitive over @std/async Lightweight, cancels timers cleanly on completion via release(), preserves custom phase attribution, and avoids external dependencies. @std/async (deadline / abortable): deadline() creates a new timer per call (reintroducing the reset bug); abortable() with AbortSignal.timeout leaks active timers on early resolution.

Review Notes: Behavioral Impact

  • Fail-Fast on Long Presync: If the SharedWorker presync takes longer than the method budget (e.g. 90s for resolveDotNameRemote), the request will fail at 90s instead of waiting up to ~5 minutes for presync to finish. Automatic retries are not performed on this path.
  • Storage Read Window Reduction: Shared-auth and shared-mode storage reads previously had an effective ~60s ceiling (30s load + 30s reply). They now share a single 30s default budget, surfacing failed session reads earlier on slow frames.

Test Plan

  • packages/protocol/tests/ (99 tests across 6 test files, all passing):
    • client-timeouts.test.ts: Parameterized matrix verifying request budgets across load, ready, and reply phases, two-sided timer boundary checks (remainingWaitMs - 1 still pending), timer cleanup on success, untimed warmup, and cross-tab storage change notification relays.
    • client-precedence.test.ts: Parameterized matrix verifying that explicit frame errors (ProtocolFatalError, frame resets before ready, frame resets after ready, iframe load errors) take precedence over budget timeouts when settling first.
    • client-chain-provider.test.ts: Connection lifecycle, request queuing before frame boot, chain response routing, chainSend failure propagation, clean disconnect(), chain-halt handling, and post-teardown message discard.
    • auth-storage.test.ts: Origin allowlist edge cases, key validation, and siteId constraints.
    • broker.test.ts: Broker request remapping, subscription token rewriting, early event buffering, statement fanout, and disconnect cleanup.
    • messages.test.ts: Protocol envelope serialization and validation.
  • Mutation battery verified:
    • Dropping timer disarm caught by client-timeouts.test.ts.
    • Dropping pending-entry cleanup caught by client-timeouts.test.ts.
    • Restoring unbudgeted provider wait caught by client-chain-provider.test.ts.
    • Arming budget after frame wait caught by client-timeouts.test.ts.
    • Giving warmup a budget caught by client-timeouts.test.ts.
    • Relabelling crash as timeout caught by client-precedence.test.ts.
    • Reverting post-ready teardown drain caught by client-precedence.test.ts.
  • bun run --cwd packages/protocol test (99 passed).
  • bun run --cwd packages/protocol typecheck (clean).
  • bun run --cwd packages/protocol lint (clean).
  • bunx --bun turbo run typecheck lint test (37/37 tasks successful monorepo-wide).

… wait

`postRequest` armed its per-method timer only after awaiting the shared
protocol frame, and that wait carries its own budget of up to 240 seconds.
A first request could therefore block for roughly four and a half minutes
while advertising a 30 second contract, and the eventual rejection did not
say whether the time went into booting the frame or waiting for a reply.

The per-method budget is now resolved before the frame wait and enforced by
a single timer armed at call time, raced against the frame wait and then
the reply wait. Rejections carry a typed error naming which wait spent the
budget: opening the host frame, waiting for the frame to report ready, or
waiting for a reply. A more specific frame failure still wins when it
settles first, so a torn-down frame or a dead chain reports its own cause.

No timeout constant changed. `warmup` stays exempt, so a legitimate cold
boot keeps its full window through the untimed warm-up and the direct
frame call that drive it.
The budget exposed `enterPhase` and `guard` as separate calls, so a caller
had to remember to announce the phase before the wait it belonged to. The
phase is now an argument to `guard`, which makes the pairing impossible to
get wrong and lets a rejection name only a wait the request was actually in.

Also drops the nullable request-id local. The pending entry is dropped in
the one place a reply can no longer arrive, so the outer block no longer
carries state whose only purpose was a conditional cleanup.
Three defects the review pass found in the first cut, plus the coverage that
proves each one.

A chain connection awaited the protocol frame outside any budget and only
then issued its own request, so a dApp connecting during a cold boot still
waited out the frame budgets before the connection's 30 second allowance even
started. The request already performs both frame waits, so the outer wait was
redundant as well as unbounded.

A failed request recorded a duration sample. The budget starts at the call
while that timer starts at the reply, so a timeout wrote the leftover budget
into a series with no outcome to filter on, and a 90 second failure could land
as a 3 second sample. Only a completed roundtrip is recorded now, matching
what the fatal and response-error paths already did.

The host mapped a protocol timeout to its user-facing copy by matching the
message text, which this branch reworded. It now matches on the error type,
keeping the text match for the foreign errors that still need it.

The suite was blind to two invariants: deleting the timer disarm or the
pending-entry cleanup left every test passing. Six mutations are now each
caught by a named scenario, including a crash relabelled as a timeout and
warmup handed a budget it must not have.

Intent moved out of comments and into names and structure: the untimed and
budgeted paths are separate functions rather than one branch with a warning,
and the request timer says what it records.
The defect generalises past this fix: a per-operation budget armed after an
unbounded setup wait advertises a bound it does not hold. Records the two
rejected alternatives with their reasons, and the mutation-probe result that
matters most - the suite was fully green with the timer disarm deleted and
again with the pending-entry cleanup deleted, so a green run was not evidence
either invariant was defended.

Seeds CONCEPTS.md for the protocol-frame area, including the host-frame
versus protocol-frame distinction that names two readiness stages of one
iframe rather than two elements.
@github-actions

Copy link
Copy Markdown
Contributor

⚡ Performance Report

⚠️ No baseline found on main. This PR's results are recorded but cannot be compared.
Merge to main to establish a baseline.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Report

Chunks over 500 KB:

File Raw Brotli Gzip
host/assets/paseo.smol-DboPaEh1.json 1.84 MB 941.7 KB 1019.4 KB
host/assets/paseo-people-next.smol.json 3.36 MB 1.68 MB 1.82 MB
host/assets/previewnet.smol.json 547.1 KB 92.2 KB 101.2 KB
host/assets/smoldot.js 3.05 MB 2.27 MB (-128 B) 2.28 MB
host/assets/smoldot_worker.js 3.02 MB 2.26 MB 2.27 MB
host/assets/wasm/web/truapi_server_bg.wasm 7.06 MB 5.25 MB 5.52 MB
Total 19.87 MB (+7.2 KB) 12.80 MB (+1.7 KB) (-36%) 13.34 MB (+2.0 KB)
All files
File Raw Brotli Gzip
host/.well-known/apple-app-site-association 1.0 KB 1.0 KB 1.0 KB
host/.well-known/assetlinks.json 2.1 KB 343 B 427 B
host/assets/bridge.js 69.5 KB 19.0 KB (-22 B) 21.9 KB
host/assets/browser.js 22.9 KB 7.6 KB (-1 B) 8.6 KB
host/assets/client.js 100.1 KB 29.4 KB (+26 B) 32.4 KB (-1 B)
host/assets/dist.js 39.0 KB 12.8 KB (-4 B) 14.5 KB (-1 B)
host/assets/dotli-debug-bus.js 710 B 710 B 710 B
host/assets/get-sync-provider.js 2.8 KB 1.1 KB (-2 B) 1.2 KB
host/assets/hex.js 154 B 154 B 154 B
host/assets/index.js 200.5 KB (+973 B) 50.1 KB (+349 B) 59.7 KB (+326 B)
host/assets/index.css 47.1 KB 7.4 KB 8.2 KB
host/assets/manifest.js 22.6 KB 7.2 KB (+2 B) 8.0 KB (-1 B)
host/assets/panel.js 76.2 KB 20.5 KB (-29 B) 23.2 KB (-1 B)
host/assets/paseo.smol-DboPaEh1.json 1.84 MB 941.7 KB 1019.4 KB
host/assets/paseo-people-next.smol.json 3.36 MB 1.68 MB 1.82 MB
host/assets/paseo.smol.json 23.4 KB 5.4 KB 6.2 KB
host/assets/previewnet.smol.json 547.1 KB 92.2 KB 101.2 KB
host/assets/resolve.js 156 B 156 B 156 B
host/assets/rpc-resolve.js 2.5 KB 1.0 KB (-1 B) 1.2 KB (-3 B)
host/assets/smoldot.js 3.05 MB 2.27 MB (-128 B) 2.28 MB
host/assets/smoldot_worker.js 3.02 MB 2.26 MB 2.27 MB
host/assets/spans.js 2.6 KB 1.1 KB 1.3 KB
host/assets/src.js 1.8 KB 858 B (+3 B) 947 B (+1 B)
host/assets/styles.css 15.3 KB 3.3 KB 3.8 KB
host/assets/wasm/web/README.md 11.9 KB 11.9 KB 11.9 KB
host/assets/wasm/web/package.json 371 B 371 B 371 B
host/assets/wasm/web/truapi_server.d.ts 8.1 KB 8.1 KB 8.1 KB
host/assets/wasm/web/truapi_server.js 38.2 KB 6.6 KB 7.6 KB
host/assets/wasm/web/truapi_server_bg.wasm 7.06 MB 5.25 MB 5.52 MB
host/assets/wasm/web/truapi_server_bg.wasm.d.ts 3.0 KB 3.0 KB 3.0 KB
host/assets/web.js 13.3 KB 3.6 KB (-2 B) 4.0 KB (+1 B)
host/assets/worker-runtime.js 6.4 KB (+6.3 KB) 1.7 KB (+1.5 KB) 1.8 KB (+1.7 KB)
host/assets/worker-runtime.js 106 B 106 B 106 B
host/assets/ws.js 23.1 KB 7.5 KB (-4 B) 8.2 KB (-1 B)
host/dotli.png 11.5 KB 11.5 KB 11.5 KB
host/favicon.svg 1.8 KB 1.8 KB 1.8 KB
host/host-sw.js 2.7 KB 1.1 KB (+1 B) 1.2 KB (-5 B)
host/icon-192.png 12.5 KB 12.5 KB 12.5 KB
host/icon-512.png 42.8 KB 42.8 KB 42.8 KB
host/index.html 24.0 KB 4.6 KB (-3 B) 5.7 KB (-4 B)
host/manifest.webmanifest 441 B 441 B 441 B
host/workbox.js 14.8 KB 4.6 KB 5.1 KB
sandbox/app-sw.js 9.7 KB 3.2 KB (-1 B) 3.6 KB (-2 B)
sandbox/assets/bitswap-bridge.js 840 B 840 B 840 B
sandbox/assets/fetch.js 3.4 KB 1.2 KB (-1 B) 1.4 KB (-3 B)
sandbox/assets/index.js 120.4 KB 34.3 KB (-22 B) 40.4 KB (+2 B)
sandbox/assets/index.css 47.1 KB 7.4 KB 8.2 KB
sandbox/favicon.svg 1.8 KB 1.8 KB 1.8 KB
sandbox/index.html 1.7 KB 582 B (+2 B) 786 B
Total 19.87 MB (+7.2 KB) 12.80 MB (+1.7 KB) (-36%) 13.34 MB (+2.0 KB)

Commit: 6334e5d

… DSL

Split monolithic client.test.ts into domain-focused suites (timeouts, error precedence, chain provider) using a reusable dual-driver harness (DAppDriver / ProtocolFrame) and Rpc factory. Expand parameterized test coverage and add package AGENTS.md.
@ryanleecode
ryanleecode force-pushed the fix/protocol-request-call-time-budget branch from 07194d7 to ecaab14 Compare August 14, 2026 22:29
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

E2E Product suite failed on 9c611d5be0cddb6c3ef012f67139abd7c3cef865 — 38 passed, 7 failed, 17 skipped.

Failed tests:

  • Get Product Account
  • Product Signer
  • Product Account Alias
  • All Allowances
  • As a product user, I can navigate within the current product
  • Query Balance
  • Sign Raw Message

Logs: https://github.com/paritytech/dotli-community/actions/runs/31856154758
Artifacts: e2e-product-results (uploaded above) — open the failed test's trace.zip with npx playwright show-trace.

… on frame teardown

resetProtocolFrameState now drains pendingRequests so a teardown between
request and reply rejects the caller immediately instead of orphaning it
until its budget timer fires; the fatal/init-failed handler reuses that
drain instead of duplicating the loop. bindMessageListener drops messages
whose source is not the mounted frame window, closing the gap where a
valid-origin message with no frame mounted passed the old null check.
A ProtocolRequestTimeoutError spent in the load or ready phase means the
light client timed out during startup (presync exceeded the request
budget), not that the host lost its peers. Map those phases to
SW_TIMED_OUT before the generic timeout branch.
createProviderHarness moves to tests/support/broker.ts so the routing
property-style suite and example tests share one harness. errors.test.ts
mirrored constructor parameters and could not fail on a plausible domain
bug; thrown error attributes are already asserted on live rejection paths.
AGENTS.md now states the testing doctrine in prose.
@ryanleecode ryanleecode changed the title fix(protocol): bound a request from the call, not the frame wait fix(protocol): enforce a call-time request budget with phase attribution Aug 14, 2026
…ad event

The source-window guard read `protocolIframe`, which was only assigned in
the iframe's load handler — several ticks after the frame was appended to
the DOM. A frame that posted `ready` while its document was still parsing
therefore had its own handshake discarded as untrusted, so the protocol
never became ready, resolution never settled, and the host rendered no
error page at all. Eight functional loading scenarios timed out waiting
for `.error-page-title`; the ones that survived did so only because their
mock retried the post twelve times with backoff.

Trust is now established when the frame is attached rather than when it
loads, and revoked on the load-timeout and error paths so a dead frame
never stays trusted. Post-teardown discarding is unchanged.
…guard

The previous test hardcoded `http://host.localhost:5173` as the message
origin, but the protocol origin resolves to port 3000. The origin check
rejected the event before it ever reached the source-window check, so the
test passed under both the strict and the permissive guard and defended
nothing — reverting the guard left the whole suite green.

It now calls `getProtocolOrigin()` and asserts the exploit the guard
exists to stop: an untrusted window forging a shared-auth broadcast into
every subscriber after teardown. Reverting the guard fails this test.
@ryanleecode
ryanleecode force-pushed the fix/protocol-request-call-time-budget branch from 8747e8c to 9c611d5 Compare August 15, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Protocol request timeout starts after the frame-ready wait, not at the call

1 participant