Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
945e842
docs(plans): plan the call-time budget for protocol requests
ryanleecode Aug 14, 2026
510a099
fix(protocol): measure a request timeout from the call, not the frame…
ryanleecode Aug 14, 2026
1774e50
refactor(protocol): bind the timeout phase to the wait it describes
ryanleecode Aug 14, 2026
83faf03
fix(protocol): extend the call-time budget to the chain provider
ryanleecode Aug 14, 2026
a6ced78
refactor(protocol): inline the single-caller frame wait
ryanleecode Aug 14, 2026
8df7c2c
docs(solutions): capture the call-time budget learning
ryanleecode Aug 14, 2026
55e9cdc
test(protocol): decompose protocol test suite into dual-driver domain…
ryanleecode Aug 14, 2026
ecaab14
chore: format
ryanleecode Aug 14, 2026
950534e
docs(solutions): refresh call-time budget learning with dual-driver a…
ryanleecode Aug 14, 2026
90ca159
chore: remove ephemeral plan file
ryanleecode Aug 14, 2026
496f6e8
fix(protocol): reject in-flight requests and discard spoofed messages…
ryanleecode Aug 14, 2026
040ac13
fix(host): report protocol startup timeout instead of generic peer loss
ryanleecode Aug 14, 2026
8f962c2
test(protocol): extract broker harness and drop tautological error test
ryanleecode Aug 14, 2026
21473d1
chore: format
ryanleecode Aug 14, 2026
f44f8bf
docs(solutions): update call-time budget learning with teardown drain…
ryanleecode Aug 14, 2026
55514ba
docs(solutions): rewrite call-time budget learning as durable archite…
ryanleecode Aug 14, 2026
c4e3a80
fix(protocol): accept the frame handshake posted before the iframe lo…
ryanleecode Aug 15, 2026
9c611d5
test(protocol): make the source-window guard test fail on a reverted …
ryanleecode Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Concepts

Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.

## The protocol frame

### Protocol frame

The hidden iframe, served from the protocol origin, that brokers all chain work for every product surface open in a tab.

There is exactly one per tab, and it is shared: product surfaces never talk to a chain directly, they post requests to it. It reaches usable state in two stages — loaded, then ready — and the distinction matters because different requests need different stages. Tearing it down rejects everything waiting on readiness and orphans requests already in flight.

### Host frame

The protocol frame in its first stage: the element has loaded, but its worker has not yet announced that it can serve chain work.

Requests that only read shared authentication or shared mode storage need this stage and no more, so they are usable well before chain work is. Anything touching a chain must wait for the ready signal.

### Ready signal

The protocol frame's announcement that it can serve chain work.

It is emitted once, after presync completes, and is what separates the host frame stage from a fully ready protocol frame. Callers waiting on it are rejected together if the frame is torn down first.

### Presync

The initial chain sync a protocol frame's worker performs before emitting its ready signal.

Its duration is what makes readiness slow on a cold start. The allowance for waiting on the ready signal is deliberately set above the worker's own allowance for presync, so the outer wait cannot expire while the inner sync is still legitimately progressing.

## Requests

### Request budget

The single time bound a protocol request promises its caller, measured from the moment the request is made and covering every wait it performs.

One budget spans waiting for the frame to load, waiting for readiness, and waiting for a reply — it is not a bound on the reply alone, and it is not added on top of the frame's own allowances. Some methods are deliberately exempt because they wait on chain sync rather than on a peer, and an exempt method is bounded only by the frame's allowances. A budget is disarmed the moment its request settles.

### Timeout phase

Which wait consumed a request budget: loading the frame, waiting for readiness, or waiting for a reply.

Recorded when the budget expires rather than inferred afterwards, so it names the wait actually in progress. A frame-level failure that surfaces before the budget expires is reported as itself, not as a phase — the phase describes a request that ran out of its own time.

## Chain access

### Remote chain connection

A JSON-RPC channel between a sandboxed app and a chain, brokered through the protocol frame.

Messages sent before the connection is established are queued rather than rejected, so a connection that never establishes looks unresponsive rather than failed until its budget expires. Each connection is independent and carries its own identity. Tearing down the protocol frame does not close them: a send on a connection that had established is answered with an error, while one that never established keeps queueing.

## Flagged ambiguities

- "Host frame" and "protocol frame" had been used interchangeably for the same iframe — they name two readiness stages of one element, not two elements. Use host frame for loaded-only and protocol frame for ready.
13 changes: 12 additions & 1 deletion apps/host/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
ProtocolFatalError,
ProtocolInitFailedError,
ProtocolRequestTimeoutError,
} from "@dotli/protocol/errors";

export const HOST_ERRORS = {
Expand Down Expand Up @@ -85,7 +86,17 @@ export function describeError(err: unknown, isP2p: boolean): ErrorDescription {
recovery: "switch-backend",
};
}
if (msg.includes("timed out") || msg.includes("Timed out")) {
if (
err instanceof ProtocolRequestTimeoutError &&
(err.phase === "load" || err.phase === "ready")
) {
return { message: HOST_ERRORS.SW_TIMED_OUT, recovery: "switch-backend" };
}
if (
err instanceof ProtocolRequestTimeoutError ||
msg.includes("timed out") ||
msg.includes("Timed out")
) {
return {
message: isP2p
? HOST_ERRORS.LIGHT_CLIENT_TIMEOUT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: Request timeout armed after asynchronous bootstrap creates unbounded wait
date: 2026-08-14
last_updated: 2026-08-14
category: logic-errors
module: protocol
problem_type: logic_error
component: service_object
symptoms:
- First request against a cold or wedged protocol frame blocked up to five minutes while advertising a 30-second timeout
- Rejections could not distinguish between a frame that failed to load, a frame stuck in presync, or a dropped RPC reply
- Downstream dApp chain connections stacked setup timeouts before the connection allowance even started
- Latency metrics for timed-out requests sampled only the residual budget, artificially improving reported p99 latency during outages
root_cause: async_timing
resolution_type: code_fix
severity: high
tags:
- timeout-budget
- async-timing
- postmessage-bridge
- metrics-integrity
- mutation-testing
- test-doubles
---

# Request timeout armed after asynchronous bootstrap creates unbounded wait

## Problem

When an asynchronous client advertises a per-operation timeout (e.g. 30s default, 90s for chain lookups) but arms its timer only *after* awaiting an underlying subsystem's readiness (such as a sandboxed host iframe, WebWorker, or chain presync), the advertised budget is violated. The total wait becomes the sum of the setup timeout plus the operation timeout (up to 5 minutes), while callers expect a strict 30-second bound.

Furthermore, when the timeout eventually fires, the rejection has lost context on where the time was spent, and metric histograms that record elapsed time on timeout sample only the residual fraction of the window, distorting service telemetry.

## Mechanism & Failure Modes

### 1. Cumulative Timeout Stacking
If setup carries an initial load allowance (30s) and a readiness allowance (240s), placing `await ensureReady()` before arming the per-request timer creates a sequential cascade:
$$\text{Max Latency} = T_{\text{load}} + T_{\text{ready}} + T_{\text{request}} \approx 300\text{s}$$
Callers programming to a 30-second deadline hang for 5 minutes during cold starts or worker stalls.

### 2. Loss of Phase Attribution
A single generic `TimeoutError` without phase metadata makes diagnosis impossible. A caller or telemetry consumer cannot tell whether:
- The host frame failed to load from network (`load` phase),
- The worker hung during chain presync (`ready` phase), or
- The remote chain RPC failed to answer (`reply` phase).

### 3. Metric Inversion on Failure
When a request budget starts at call time ($t=0$) but the latency stopwatch begins only when the message is dispatched to the frame ($t=t_{\text{ready}}$), measuring duration on timeout records only the *leftover* budget ($T_{\text{budget}} - t_{\text{ready}}$). A 90-second failure that spent 87 seconds waiting for readiness samples as a 3-second request, falsely pulling p95/p99 latency metrics downward during outages.

### 4. Teardown Orphan Leaks
If the underlying frame is reset or torn down while a request is awaiting a reply, failing to drain the pending request registry leaves caller promises hanging until their timeout timers expire, rather than failing fast with an explicit teardown error.

---

## Architectural Invariants

### 1. Unified Call-Time Budgeting
A single `setTimeout` must be armed at the public entry point before any setup or dispatch awaits occur. All subsequent asynchronous phases (`load`, `ready`, `reply`) are raced sequentially against that single deadline:

```ts
const budget = startRequestBudget(method, timeoutMs);
try {
await budget.guard("load", ensureHostFrame());
if (needsProtocolReady) {
await budget.guard("ready", ensureProtocolFrame());
}
const sent = sendRequest(frameWindow, method, payload, onProgress);
try {
const value = await budget.guard("reply", sent.reply);
recordRoundtrip();
return value;
} catch (error) {
pendingRequests.delete(sent.id);
throw error;
}
} finally {
budget.release();
}
```

### 2. Phase-Attributed Rejections
The timeout error must carry the exact phase in flight at the moment the timer fired (`load` | `ready` | `reply`), determined dynamically inside the timer callback rather than guessed from race winners.

### 3. Root-Cause Precedence
Explicit domain errors (such as peer crashes, frame load rejections, or session resets) must take precedence over budget expiration when settling first. `Promise.race` preserves the first settled rejection, preventing underlying crashes from being misattributed as client timeouts.

### 4. Metric Separation
Attribute-less latency metrics must record durations *only* for completed, successful roundtrips. Timeouts and failures must be emitted strictly as counter metrics tagged with the failure phase.

### 5. Immediate Teardown Drain
Any lifecycle transition that invalidates the underlying channel must synchronously drain and reject all in-flight pending requests with an explicit cancellation error.

---

## Verification & Prevention Rules

- **Enforce Two-Sided Timer Boundaries:** A timeout test must assert not just that a request fails at $T$, but that it remains pending and unresolved at $T - 1\text{ms}$.
- **Probe Cleanup Invariants with Mutation Gates:** A green test suite is not proof that cleanups work. Verify that removing `budget.release()` or `pendingRequests.delete(id)` causes a test to fail.
- **Dual-Driver Test Harness for Message Bridges:** Never mock DOM elements, iframes, or `postMessage` directly in test scenarios. Encapsulate the boundary into two domain drivers:
- A **Consumer Driver** (`DAppDriver`) that sends requests and collects responses.
- A **Peer Driver** (`ProtocolFrame`) that scripts frame state transitions (`open`, `ready`, `respond`, `fatal`).
- **Audit Unbudgeted Setup Awaits:** The code smell to grep for is an unbudgeted setup call preceding a budgeted operation:
```ts
// ❌ Defect: Setup is outside the budget
await ensureReady();
await withTimeout(op, 30_000);

// ✅ Invariant: Budget wraps setup and operation
await withTimeout(async () => {
await ensureReady();
return op();
}, 30_000);
```
10 changes: 7 additions & 3 deletions packages/metrics/src/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,13 @@ export const APP_RENDER = "app.render";
export const PROTOCOL_IFRAME_READY = "protocol.iframe_ready";

/**
* Protocol request roundtrip time. Timeouts emit
* `m.count(PROTOCOL_REQUEST, { outcome: "timeout", method })`; there is
* no separate `_TIMEOUT` constant.
* Protocol request roundtrip time, recorded only for a completed roundtrip.
*
* A request that fails records no duration, so this series stays comparable
* across releases. Timeouts arrive instead as
* `m.count(PROTOCOL_REQUEST, { outcome: "timeout", method, phase })`, where
* `phase` is `load`, `ready`, or `reply` and names which wait spent the
* request's call-time budget. There is no separate `_TIMEOUT` constant.
*/
export const PROTOCOL_REQUEST = "protocol.request";

Expand Down
19 changes: 19 additions & 0 deletions packages/protocol/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Protocol Package Instructions

## Testing Doctrine

1. **Dual-Driver Double**:
- Use `createTestDApp` and `installProtocolFrame` from `tests/support/`.
- Never mock DOM elements, iframes, or `window.postMessage` directly inside test files.

2. **Domain Getters**:
- Assert on parsed domain getters (`frame.sentRpcRequests()`, `frame.connectionId()`, `dApp.replies()`).
- Do not parse raw wire JSON strings inside test scenarios.

3. **Deterministic Virtual Time**:
- Synchronize using `settleWithin`, `until`, and `bootAndConnect`.
- Do not chain ad-hoc `elapse(1)` ticks or unanchored timeouts.

4. **Test Quality & Value**:
- Tests must defend observable system contracts and failure boundaries.
- Never write constructor-mirroring tests that assert properties passed directly into `new`.
Loading
Loading