diff --git a/README.md b/README.md index 1f51b6b..abc43ab 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ const { api, destroy: stopClient } = client(clientChannel, { auth }); const { message } = await api.greet({ name: "World" }); // input & output typed ``` -`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the failed call surfaces a typed error (`TIMEOUT` / `CHANNEL`) and the next call re-handshakes — the client never silently resends (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) and `abortPending()` cancel waiting without tearing anything down. +`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the first sent call to hit its reply-timeout surfaces `RPCAbortedError("TIMEOUT")` and resets the session — the next call re-handshakes lazily. A call that provably never left surfaces a plain `RPCError` and resets nothing: never-left is a transport event, the keys are fine, retrying is safe. The client never silently resends either way (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) cancels waiting without tearing anything down. Because `rpc` carries `Context`, procedures can be authored in any file with a fully-typed `ctx`, and `server()` requires a `context()` that returns the type your procedures expect. @@ -107,15 +107,19 @@ Built-in helpers cover Ed25519, ECDSA P-256, JWT, certificate-based, and multifa ## Errors ```typescript -import { RPCError, RemoteRPCError } from "@dotex/saferpc"; +import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc"; try { await api.greet({ name: "World" }); } catch (err) { if (err instanceof RemoteRPCError) { // The remote peer threw. err.code / err.message / err.data come from there. + } else if (err instanceof RPCAbortedError) { + // The request WAS sent but the outcome is unknown (TIMEOUT, ABORTED, + // SESSION) — it may have executed. Reconcile state before retrying. } else if (err instanceof RPCError) { - // Local failure: TIMEOUT, SESSION, HANDSHAKE, CLIENT, ... + // Local failure, the request never left the process: CHANNEL, ABORTED, + // SESSION, HANDSHAKE, CLIENT, ... — safe to retry as-is. // (validation errors like INPUT_VALIDATION run server-side and arrive as RemoteRPCError) } else { throw err; diff --git a/index.md b/index.md index f553330..95306ac 100644 --- a/index.md +++ b/index.md @@ -67,7 +67,7 @@ No HTTP. No JSON. No middleware stack. Encryption is the default because the des 2. On the first call, a handshake runs automatically. Ephemeral X25519 keys are exchanged and a session key is derived using the secret as HKDF salt. 3. The server proves it knows the secret with an HMAC over the transcript. The client proves it implicitly by producing valid ciphertext. 4. All calls are encrypted with XSalsa20-Poly1305 AEAD under the session key. -5. If the session drops, the client resets and re-handshakes lazily on the next call. The failed call surfaces a typed error (`TIMEOUT` / `CHANNEL`) — there is no silent resend; the caller decides whether to retry. +5. If the session drops, the first sent call that times out surfaces `RPCAbortedError("TIMEOUT")` and resets the session; the next call re-handshakes lazily. A call that never left surfaces a plain `RPCError("CHANNEL")` without touching the session. There is no silent resend; the caller decides whether to retry. No certificates. No token refresh. No auth middleware. A shared secret and a channel. diff --git a/spec-channel-lifecycle.md b/spec-channel-lifecycle.md index 039c645..c4bcda3 100644 --- a/spec-channel-lifecycle.md +++ b/spec-channel-lifecycle.md @@ -1,100 +1,90 @@ -# Spec: channel lifecycle — session survives transport death (saferpc client + channels module) - -Status: ready to implement. Target: `src/client.ts`, new `src/channels/`, -`package.json` exports, four spec docs, two test files. Ships within the -unreleased 0.7.0 (no published version ever carried the old `abortPending` -semantics, so no consumer-facing break). - -Decisions locked with CTO 2026-07-09: - -1. **Reconnect policy: lazy on the client core.** The client does not react to - channel recovery at all. Pending calls keep waiting for their reply under - their own timers; if the server kept the session the replies arrive over the - revived socket, if not the call times out and the existing - `timeout → reset → lazy re-handshake` path heals. No eager re-handshake on - reconnect — while `handshaking` the client drops TAG_MSG, which would kill - the very pending calls the kept session exists to serve. -2. **`abortPending` stays, in simple form** (rejects pending, no longer zeros - the session), **plus** a per-call `AbortSignal`, fetch-style, as an optional - argument on every generated method. -3. **The `Channel` interface does not change.** `{ send, receive }` as today. - The lifecycle policy is an *adapter contract*, stated in docs and shipped as - code in a new channels module: reopen your transport immediately when it - closes, hold it open as long as possible; while down, `send` must not throw — - queue frames that provably never left and drop any transport error inside - the adapter. - -This document is self-contained. +# Spec: channel lifecycle v2 — session survives transport death, core owns the send queue + +Status: ready to implement. Supersedes v1 (2026-07-09) after CTO review of +PR #2 (2026-07-10). Target: `src/client.ts`, `src/common.ts`, +`src/channels/`, spec docs, tests. Ships within the unreleased 0.7.0. + +Decisions locked with CTO 2026-07-10: + +1. **`abortPending` is removed entirely** (v1 kept it in simple form). The + per-call `AbortSignal` covers the use case: an app that wants "reject all + in-flight calls" holds one `AbortController` and passes its signal to every + call — `ctl.abort()` is `abortPending()` with none of the API surface. The + method was historically the transport hook (`ws.onclose → abortPending`), + which is exactly the antipattern this work kills; removing it removes the + footgun physically. +2. **New error class `RPCAbortedError extends RPCError`** — the caller must + know deterministically whether the request reached the wire. + `RPCAbortedError` = "the request was handed to a live transport; outcome + UNKNOWN — it may have executed, check before retrying". Plain `RPCError` + (local) = "the request provably never left this process; retry freely". + `RemoteRPCError` (unchanged) = "the handler ran and returned an error". + Details and the DX rationale in §5. +3. **Channels own no queue — queueing inside a transport adapter is an + antipattern.** The `Channel` contract flips: `send` MUST throw (or reject, + for async adapters) when it cannot hand the frame to a live transport + right now. The client core owns the outbound queue and retries unsent + frames until a new **`sendTimeout`** (default 10 000 ms) expires. The + channel's only jobs: move frames, and try to stay available + (auto-reconnect stays in `wsChannel`). +4. **Unchanged from v1:** lazy reconnect policy (the client core does not + react to channel recovery; pending calls wait under their own timers, a + lost server session heals via `timeout → reset → lazy re-handshake`); + the `Channel` interface shape `{ send, receive }`; the two-lever timeout + model (global `timeout` 30 s + per-call `AbortSignal.timeout(ms)`); no + auto-retry of frames that reached the wire (F1). + +This document is self-contained; v1 content still valid is restated. --- ## 1. The problem in one paragraph -Today channel death is handled by tearing the session down. The documented WS -wiring is `ws.onclose → abortPending()`, and `abortPending` rejects every -in-flight call **and zeros the session keys** (`src/client.ts`, `abortPending` -→ `zeroKeys(); state = "idle"`). That is wrong twice. First, it burns a session -that may still be perfectly valid on the server (the Enclave SessionDO case: -the DO and its session outlive any single socket) — a transient socket blip -costs a full re-handshake and rejects calls that could have completed. Second, -it makes the adapter responsible for calling into the client at the right -moment, which is exactly the API awkwardness the CTO flagged ("abortPending -неприятно юзать"). Meanwhile a `send` into a dead channel is adapter roulette: -browser `WebSocket.send()` on CLOSED silently drops (review F4), so the call -burns its whole timeout for a frame that never left, even if the socket comes -back 50 ms later. The fix: the channel owns its own liveness (reconnect -eagerly, queue never-left frames, swallow errors while down), and the client -core stops treating transport death as session death. +Two gaps remain after the v1 implementation (543d4f9). First, a caller +catching an error today cannot tell whether the request reached the server: +`TIMEOUT` is documented as unknown-outcome, `CHANNEL` as never-left, but the +send/queue split lived inside the adapter — a frame sitting in `wsChannel`'s +private queue when the call timed out was reported `TIMEOUT` (unknown) even +though it provably never left. The retry decision — the single most important +thing an RPC error must support — required reading docs and trusting adapter +internals. Second, the adapter-owned queue duplicates state the core already +has (the pending map bounds it, the call timers race it) and puts the +"never-left" bookkeeping in the one place the core cannot see. Moving the +queue into the core makes the sent/unsent boundary a fact the core *knows* +rather than infers, and shrinks the adapter contract to one line: send or +throw. ## 2. The property we want -> A Safe RPC session is bound to key material, not to a transport instance. The -> death of a socket must not, by itself, destroy the session or reject calls. -> A call's outcome is decided by exactly two events: a reply that decrypts, or -> the call's own timeout. - -Corollary for the send path: a frame that **provably never left** the client -(submitted while the transport was down) may be flushed after reconnect — this -is the `CHANNEL`-class "never left, safe to retry" case from the 0.7.0 -taxonomy, executed inside the adapter. A frame that was written to a live -socket that then died has UNKNOWN outcome and is **never** resent by anyone. -This keeps the no-auto-retry invariant (F1) intact: nothing above the adapter -retries, and the adapter only ever "retries" frames that never hit the wire. - -Payoff condition, stated honestly: keeping the session across a reconnect pays -off only when the server-side session outlives the socket (long-lived server -binding, e.g. Enclave's SessionDO, or any wiring where `server()` is not -per-connection). With the common `wss.on("connection", ws => server(...))` -wiring the kept client session is dead weight after a reconnect — the first -call times out, resets, and heals. That is the same cost as today's behavior, -minus the instant rejection. The design is strictly better or equal in every -wiring. - -## 3. Design part I — channels module (`src/channels/`) - -New source directory, exported as a subpath so the core stays dependency-free -and tree-shakeable: - -```jsonc -// package.json exports (add) -"./channels": { - "types": "./esm/channels/index.d.ts", - "import": "./esm/channels/index.js", - "require": "./cjs/channels/index.js" -} -``` +> A Safe RPC session is bound to key material, not to a transport instance. +> A call's outcome is decided by exactly two events: a reply that decrypts, +> or a terminal local event (timeout, abort, destroy). Every rejection states +> on which side of the wire the request died: `RPCAbortedError` = it left, +> outcome unknown; plain local `RPCError` = it never left, retry freely. + +The **sent boundary** is defined as: `channel.send(frame)` returned without +throwing (sync adapters) or its promise resolved (async adapters). Before +that point the core holds the only copy of the frame and can discard it with +certainty; after that point the frame's fate is unknowable and it is never +resent by any layer. -Files: `src/channels/index.ts` (re-exports), `src/channels/ws.ts`. The adapters -currently living as copy-paste snippets in `spec/integrations.md` migrate here -over time; WebSocket ships first because it is the one with the lifecycle trap. +## 3. Design part I — the channel contract (revised) and `src/channels/` -### 3.1 `wsChannel(source, opts?)` — reconnecting client adapter +### 3.1 Contract (goes into `common.ts` `Channel` jsdoc + integrations.md) + +- `send(frame)` MUST throw synchronously (or reject, if it returns a + promise) when it cannot hand the frame to a live transport **now**. No + internal queues, no buffering, no retry — a channel that accepts a frame it + cannot deliver lies to the core about the sent boundary. +- A channel SHOULD try to stay available: reopen its transport eagerly when + it dies, hold it open as long as possible. Availability is the channel's + job; delivery bookkeeping is the core's. +- `receive(cb)` unchanged: register a frame callback, return unsubscribe. + +### 3.2 `wsChannel(source, opts?)` — reconnecting client adapter (revised) ```ts export interface WsChannelOptions { - /** Max frames buffered while the socket is down. Default 256 - * (matches the client's default maxPending). Overflow: drop-oldest. */ - maxQueue?: number; /** Reconnect backoff. First retry is immediate; then exponential * from `backoffMin` (default 250) to `backoffMax` (default 5000) ms, * full jitter. Retries forever until close(). */ @@ -111,243 +101,294 @@ export function wsChannel( ): Channel & { close: () => void }; ``` -- `source` as a string uses `globalThis.WebSocket` (browser, Node ≥ 22); a - factory covers the `ws` package and custom construction. The adapter sets - `binaryType = "arraybuffer"` on every socket it creates. -- **Owns the socket lifecycle.** On `close` or `error` of the current socket: - notify `onDown`, immediately create a new socket via the factory, and keep - doing so with backoff until it opens or `close()` is called. Eager, not lazy — - the channel does not wait for the next `send` to notice death ("сразу - поднимать и держать открытым как можно дольше"). -- **`send` never throws while down.** If `readyState !== OPEN` (covers the F4 - browser silent-drop trap: CONNECTING throws, CLOSED drops — we do neither), - the frame goes into the queue. On `open`, the queue flushes in order, then - `onUp` fires. Queue overflow drops the **oldest** frame silently — the - affected call times out and heals; dropping oldest keeps the most recent - frames (a fresh hello beats a stale encrypted request). Only frames that were - queued (never left) are ever flushed; a frame passed to `ws.send` on an OPEN - socket is spent regardless of what happens to that socket afterwards. -- **Errors while down are dropped inside the adapter** (per CTO: "когда канал - закрыт любую ошибку дропать на канал"), surfaced only via `onDown` for - logging. A synchronous `ws.send` throw on an OPEN socket still propagates — - that is a real send failure, the client wraps it as `CHANNEL` and the - existing reset path applies. -- `receive(cb)` registers into a callback set that survives socket - replacement; the adapter re-attaches its single message handler to each new - socket. The returned unsubscribe removes only `cb`. -- `close()`: stop the reconnect loop, close the current socket, drop the - queue. After `close()`, `send` throws synchronously (client wraps as - `CHANNEL`). This is the hook for app shutdown, wired next to `destroy()`. - -### 3.2 `socketChannel(ws)` — plain single-socket adapter - -The old `wsChannel(ws)` from integrations.md, renamed. No lifecycle ownership: -one socket, no reconnect, no queue. This is what the **server** side uses in -`wss.on("connection", ...)` — a server cannot reconnect a client's socket, so -its channel is one-shot by nature and `ws.on("close", destroy)` stays correct. -No server-core changes anywhere in this spec. - -### 3.3 Interaction with the handshake - -A hello sent while the socket is down is queued like any frame. If the socket -opens within `handshakeTimeout` (default 5000 ms) the handshake proceeds -normally. If not, the client's hs timer fails the attempt (state → `idle`) and -the stale hello may still flush later — that is safe end to end: the server -processes it as a normal hello, and under make-before-break -(`spec-make-before-break.md`) it installs a *candidate* that expires without -touching any live session; the server's reply finds the client not in -`handshaking` and is dropped by the existing gate (`client.ts`, -`tag === TAG_HELLO && state === "handshaking"`). No new residual. (Queued-frame -TTL considered and rejected — adds a clock to the adapter for a case both state -machines already absorb.) - -## 4. Design part II — `abortPending` keeps the session - -New semantics (`src/client.ts`): +Diff against the v1 implementation: + +- **The queue is deleted** (`maxQueue` option gone, drop-oldest policy gone, + flush-on-open gone). `send` while `readyState !== OPEN` — or after + `close()` — **throws synchronously**. This also covers the F4 browser trap + (CLOSED silently drops, CONNECTING throws): the adapter checks readyState + itself and throws one typed error for every not-OPEN state. +- Everything else stays: owns the socket lifecycle, eager reconnect with + backoff until `close()`, `binaryType = "arraybuffer"`, `receive` callbacks + survive socket replacement, `onDown`/`onUp` observability, `close()` stops + the loop and closes the socket. + +### 3.3 `socketChannel(ws)` — plain single-socket adapter (revised) + +One socket, no reconnect (server side / caller-managed sockets). Change: +`send` must check `readyState` and throw when not OPEN instead of delegating +to `ws.send`'s platform-dependent behavior. `ws.on("close", ...)` server +wiring stays as is — the server core is untouched. + +### 3.4 Handshake frames + +The hello goes through the same core outbound queue (§4). If the channel is +down, the hello is retried like any frame; the attempt is bounded by +`handshakeTimeout` (5 s), which fires first and **removes the queued hello**. +This deletes v1's accepted residual (a stale hello flushing after handshake +timeout) — the core owns the queue, so it can revoke frames; an adapter +queue couldn't. + +## 4. Design part II — core outbound queue + `sendTimeout` + +New `ClientOptions` field: + +```ts +/** How long a frame may wait for a live channel before the call fails + * with a definite "never sent" error. Default 10_000 ms. */ +sendTimeout?: number; +``` + +Send path in `sendRequest` (and the hello path in `ensureHandshake`): + +1. Try `channel.send(encrypted)` immediately. Success (no throw / resolved + promise) → the call is **sent**: it waits for reply-or-timeout exactly as + today. +2. On sync throw or async rejection → the frame enters the **outbound + queue** with its call context. The call's global timer keeps running. +3. A retry tick (fixed 250 ms interval, running only while the queue is + non-empty) attempts queued frames **in order**; first throw stops the + tick's pass (head-of-line: if the channel is down for one frame it is + down for all). A frame that sends transitions its call to *sent*. +4. Terminal events on a **still-queued** frame remove it from the queue and + reject the call with a **plain** `RPCError` — the frame provably never + left: + - `sendTimeout` expiry (per-frame, counted from enqueue) → code + `CHANNEL`, message "not sent within sendTimeout". + - global `timeout` fires first (only possible when `timeout` is + configured below `sendTimeout`) → code `CHANNEL`, plain class — + same definite never-left code as sendTimeout expiry. + - per-call signal abort → code `ABORTED`, plain class. + - `destroy()` → code `SESSION`, plain class. + - session reset (epoch bump, §6) → code `CHANNEL`, plain class — the + frame is encrypted under zeroed keys and can never succeed. +5. Terminal events on a **sent** call reject with `RPCAbortedError` (§5). + +Bounding: the queue needs no own limit — `maxPending` (256) already bounds +in-flight calls, and at most one hello can be queued per handshake attempt. + +Defaults sanity: `sendTimeout` (10 s) < `timeout` (30 s), so with default +config an unsent frame normally fails via the sendTimeout expiry. But the +`CHANNEL` code does not depend on which timer fires first: if the global +`timeout` beats `sendTimeout` (custom config), the still-queued frame is +removed and rejected with the same plain `RPCError("CHANNEL")`. Plain +`TIMEOUT` therefore never occurs — the `TIMEOUT` code always means "sent, +no reply" and always rides the aborted class. + +The queue holds encrypted frames (encryption happens at call time, as +today); the epoch captured at call time (`sentEpoch`) identifies frames +staled by a reset. + +## 5. Design part III — error taxonomy: `RPCAbortedError` + +### 5.1 Shape ```ts -function abortPending(err?: RPCError): void { - if (state === "closed") return; - const e = err ?? new RPCError("ABORTED", "Pending calls aborted"); - rejectPending(e); // reject all in-flight calls, clear timers - if (state === "handshaking") { - failHandshake(e); // hello-waiters reject; attempt ephemerals - // zeroed; state → idle. Unchanged. - } - // state === "ready": session keys, encrypt/decrypt, state — ALL untouched. -} +// common.ts +export class RPCAbortedError extends RPCError {} ``` -Diff against 0.7.0: the `else { zeroKeys(); state = "idle"; }` branch is -deleted, and the default error code changes `CHANNEL → ABORTED` (the method's -role changed from "adapter reports transport death" to "application declares -waiting pointless" — e.g. user logged out, tab hiding). Failing an in-progress -handshake still zeros that attempt's ephemerals via `failHandshake` — those are -attempt state, not a live session. +No new fields. The class carries the one bit that decides retry safety; the +existing `code` string keeps naming the trigger. Constructor signature +identical to `RPCError` (code, message, data?, options?). + +### 5.2 The full local-error table -The adapter-driven use disappears from the docs: a `wsChannel` user wires -nothing on `onclose`. The method remains for the app itself. +| class | code | trigger | wire status | +|---|---|---|---| +| `RPCAbortedError` | `TIMEOUT` | global timeout, frame was sent | UNKNOWN — check, then retry | +| `RPCAbortedError` | `ABORTED` | signal fired, frame was sent | UNKNOWN — check, then retry | +| `RPCAbortedError` | `SESSION` | `destroy()`, frame was sent | UNKNOWN — check, then retry | +| `RPCError` | `CHANNEL` | sendTimeout or global timeout expired while still queued / channel closed / reset staled a queued frame | never left — retry freely | +| `RPCError` | `ABORTED` | signal fired before send (incl. during handshake wait, pre-aborted signal) | never left — retry freely | +| `RPCError` | `SESSION` | `destroy()` before send / call on closed client | never left | +| `RPCError` | `CLIENT` / `HANDSHAKE` | guardrails / handshake failure | never left | +| `RemoteRPCError` | server-defined | handler ran and threw | executed | -## 5. Design part III — per-call `AbortSignal` +Invariant, stated once in api.md and enforced by tests: **class = which side +of the wire the request died on; code = what killed it.** The same code can +appear in both classes (`ABORTED`, `SESSION`) — that is by design, the +trigger and the retry-safety are orthogonal axes. `TIMEOUT` appears only +on the aborted class: a still-queued frame that runs out of time fails +with the definite `CHANNEL` code, whichever timer fired. -Fetch-style optional argument on every generated method: +Caller-facing catch block, the whole point of the feature: ```ts -export interface CallOptions { - signal?: AbortSignal; +try { + await api.transfer(input, { signal }); +} catch (e) { + if (e instanceof RemoteRPCError) { + // executed, server said no + } else if (e instanceof RPCAbortedError) { + // may have executed — reconcile state before retrying + } else { + // never reached the server — safe to resend as-is + } } - -// ClientMethod gains a second optional parameter in all three branches: -type ClientMethod = unknown extends TInput - ? (input?: TInput, opts?: CallOptions) => Promise - : undefined extends TInput - ? (input?: TInput, opts?: CallOptions) => Promise - : (input: TInput, opts?: CallOptions) => Promise; ``` -Semantics (all rejections use -`new RPCError("ABORTED", "Call aborted: " + prop, undefined, { cause: signal.reason })`): - -- **Already aborted** at call time → reject immediately; nothing is sent, no - handshake is triggered. -- **Abort while awaiting the shared handshake** → reject *this call*; the - handshake itself continues (it is shared with other callers and with the next - call). Implementation: race the `ensureHandshake()` promise against an - abort promise; on abort, detach and reject. -- **Abort while pending** → delete the pending entry, `clearTimeout`, reject. - A reply arriving later finds no entry and is silently dropped (existing - behavior for unknown ids). -- **Session is never touched.** `ABORTED` must not trigger the auto-reset — the - proxy's reset predicate already whitelists only `TIMEOUT`/`CHANNEL`, so no - change needed there, but add the regression test (§9). -- **Outcome is UNKNOWN**, same as `TIMEOUT`: abort is client-local; the request - may have executed on the server. Document next to the timeout caveat in - api.md — abort does not cancel server-side execution. -- Listener hygiene: `signal.addEventListener("abort", h, { once: true })` and - remove it on every settle path (resolve, reject, timeout), or the pending map - leaks closures on long-lived signals reused across calls. - -Taxonomy addition: `ABORTED` = client-local, "caller gave up on purpose"; -retry-safety identical to `TIMEOUT` (unknown outcome). It joins `TIMEOUT`, -`SESSION`, `CLIENT`, `HANDSHAKE` as a genuinely client-local code. - -## 6. What deliberately does NOT change - -- **`Channel` interface** — `{ send, receive }`, untouched. The lifecycle - contract is prose in `common.ts` jsdoc + integrations.md, and code in - `src/channels/`. -- **Server core** — nothing. It gains `socketChannel` for convenience only. -- **Auto-reset on `TIMEOUT`/`CHANNEL` while ready** — stays exactly as in - 0.7.0. It is the heal path for the server-lost-session case and for dumb - adapters; with a reconnecting adapter `CHANNEL` simply fires rarely. -- **No auto-retry (F1)** — stays. The only "retry" anywhere is the adapter - flushing frames that provably never left. +### 5.3 DX rationale (variants considered, per review ask) + +- **Code-only (`code === "ABORTED"`), no subclass** — rejected: no + `instanceof` narrowing, and it conflates trigger with retry-safety + (timeout-after-send and timeout-before-send would need distinct invented + codes; stringly-typed checks spread through consumer code). +- **Boolean field on `RPCError` (`err.sent`)** — rejected: carries the bit + but is invisible at the catch site, not discoverable from types, easy to + ignore; a flag does not force the three-way decision the way the class + hierarchy does. +- **Subclass with a dedicated `reason` field and a single fixed code** — + rejected: `reason` duplicates the existing `code` machinery; two parallel + trigger vocabularies is worse than one. +- **Chosen: bare subclass, codes preserved across the boundary.** One + `instanceof` = the retry decision with type narrowing; `code`/`message`/ + `cause` = diagnostics, unchanged semantics; zero new fields; symmetric + codes make logs readable ("ABORTED before send" vs "ABORTED after send" is + the class name in the stack trace). + +`RemoteRPCError extends RPCError` already exists; `RPCAbortedError` is a +parallel branch. `instanceof RPCError` still catches everything — existing +consumer code keeps working. + +## 6. Auto-reset predicate (revised) + +Today: reset on `code === "TIMEOUT" || code === "CHANNEL"` while `ready`. +New predicate: **reset only on `RPCAbortedError` with code `TIMEOUT`** — +"the request went out and the server never answered" is the one signal that +the session may be desynced (server restarted, session dropped: the reply +can't come). Everything else must NOT reset: + +- plain `CHANNEL` (never left) — the transport was down; that is not a + session event, the keys are fine. Note this loses nothing: the old + CHANNEL-reset could only heal if a re-handshake could send, and if send + throws for 10 s the hello can't leave either; the first *sent* call that + times out still resets. The wedge case in the current comment (restarted + server silently dropping TAG_MSG over a sync transport) sends fine and + fails by reply-timeout → still resets. +- `ABORTED` (either class) — caller-local decision, existing rule. +- `SESSION`/`CLIENT`/`HANDSHAKE`/`RemoteRPCError` — existing rule. + +`reset()` itself gains one duty: reject **queued unsent** frames plain +`CHANNEL` (§4.4) — they are ciphertext under zeroed keys. Sent-pending calls +stay untouched, exactly as today (their replies can't decrypt post-reset; +they fail by reply-timeout → `RPCAbortedError("TIMEOUT")`, which is the +correct classification — they did leave). + +`destroy()` splits the pending map by the sent flag: sent → +`RPCAbortedError("SESSION", ...)`, queued → plain `RPCError("SESSION", ...)`. +Hello-waiters were never sent as calls → plain. + +## 7. What deliberately does NOT change + +- **`Channel` interface shape** — `{ send, receive }`. Only the prose + contract flips (§3.1). Async `send` stays supported (the core already + handles a returned promise). +- **Server core** — nothing. +- **No auto-retry of anything past the sent boundary (F1)** — the core + retries only frames it still exclusively owns; a frame written to a live + transport is spent. +- **Per-call `AbortSignal` semantics** (v1 §5) — all of it: reject-not- + cancel-handshake, listener hygiene, no reset on abort. Only the rejection + class now depends on the sent flag. - **Handshake/crypto/wire format** — untouched. - -## 7. Spec/doc changes - -- **integrations.md** - - Top contract section: add the adapter lifecycle contract (reopen - immediately on close, hold open as long as possible; while down `send` - must not throw — queue never-left frames, drop transport errors inside the - adapter; never resend a frame written to a live socket). - - §WebSocket: replace the inline adapter + `onclose → abortPending` guidance - with `import { wsChannel, socketChannel } from "@dotex/saferpc/channels"`; - keep one inline snippet as "what the adapter does for you". - - Fix any remaining "Safe RPC will time out and retry" phrasing (F2.3 - leftover check). -- **api.md** - - `abortPending`: new semantics (§4) — rejects pending, session survives, - default code `ABORTED`. - - `CallOptions` + `signal` on every method; UNKNOWN-outcome caveat shared - with `TIMEOUT`. - - Error table: add `ABORTED`; update the `CHANNEL` row (no longer "also the - default abortPending code"). - - Note under lifecycle: channel death no longer resets the session; what - decides a call is reply-or-timeout. -- **protocol.md** §Failure handling: transport death is not a session event; - describe the two-event outcome rule (§2) and the never-left flush exception. -- **assessment.md**: note the new residual — a stale queued hello flushing - after handshake timeout — and why it is absorbed (§3.3); note drop-oldest - overflow as an availability (not integrity) trade. -- **common.ts** `Channel` jsdoc: state the adapter contract in two sentences. - -## 8. Migration / breaking notes (vs pre-release 0.7.0 tree) - -- `abortPending` no longer returns the client to `idle` from `ready` and no - longer zeros keys; default error code `CHANNEL → ABORTED`. Anyone who used it - as "reset the session" should call nothing (channel death heals lazily) or - `destroy()` (terminal). -- `test/e2e/transport-lifecycle.test.ts` (F3 block) asserts the old semantics - — "rejects in-flight calls with CHANNEL and keeps the client usable" must be - updated: default code becomes `ABORTED`, and add the assertion that the next - call does **not** re-handshake (count TAG_HELLO frames — the session - survived). The F1 retry-semantics block stays green untouched. - -## 9. Tests - -`test/e2e/channel-lifecycle.test.ts` — in-memory channel with down/up fault -injection (extend `test/helpers/channels.ts` with a `faultChannel` that can -`goDown()`/`goUp()` and implements the §3 contract): +- **Two-lever timeout model** — global `timeout` + `AbortSignal.timeout()`. + `sendTimeout` is not a third caller lever; it is the definite/unknown + boundary inside the machine. + +## 8. Spec/doc changes + +- **api.md**: delete `abortPending` (§ returns, § failure handling); add + `RPCAbortedError` + the §5.2 table + the catch-block idiom; add + `sendTimeout` to `ClientOptions`; error-code table gets a "class" column; + migration note "abortPending → shared AbortController". +- **protocol.md**: delete the `### abortPending` section (it still describes + pre-0.7.0 zero-keys semantics — stale either way); §Failure handling: + state the sent-boundary rule (§2), the queue-in-core design, the revised + reset predicate; update the checklist line that still says abortPending + returns the client to `idle`. +- **integrations.md**: adapter contract section replaced by §3.1 (send or + throw, no queues, stay available); `wsChannel`/`socketChannel` docs lose + the queue paragraphs. +- **assessment.md**: remove the stale-hello residual (fixed by §3.4) and the + drop-oldest availability trade (queue no longer exists); add: head-of-line + blocking on the core retry tick is bounded by `sendTimeout`. +- **common.ts**: `Channel` jsdoc = §3.1 contract in three sentences; + `RPCAbortedError` jsdoc = the invariant sentence from §5.2. + +## 9. Migration / breaking notes (vs 543d4f9, all pre-release) + +- `abortPending` removed from the client return type. Replacement: shared + `AbortController` passed per call. +- `wsChannel` `maxQueue` option removed; `send` now throws while down. +- New `ClientOptions.sendTimeout` (default 10 000 ms). +- `ClientOptions.timeout` default raised from 10 000 to 30 000 ms. The old + short default doubled as a UX budget when a timeout auto-healed via + reset+retry; without auto-retry the per-call UX cap belongs to + `AbortSignal.timeout(ms)`. Pin `timeout: 10_000` to keep the old cadence. +- `CHANNEL` no longer triggers auto-reset; reply-timeout now surfaces as + `RPCAbortedError` (still `code === "TIMEOUT"`, so code-based consumer + checks keep working). +- Tests asserting `abortPending` (transport-lifecycle F3 block, + channel-lifecycle §4 case, session-continuity usage) are rewritten against + the shared-controller idiom or dropped. + +## 10. Tests + +`test/e2e/channel-lifecycle.test.ts` (rework; `faultChannel` helper now +implements the §3.1 contract — `send` throws while down): 1. **Call issued while channel is down completes after recovery, no - re-handshake.** Establish session, `goDown()`, issue call (frame queues), - `goUp()` within the call timeout. Assert: call resolves; total TAG_HELLO - count == 1. -2. **Channel death with server session intact: pending call survives.** Issue - call, `goDown()` *after* send, deliver the reply after `goUp()`. Assert - resolution. (Reply-or-timeout rule, reply branch.) -3. **Channel death with server session lost: lazy heal.** `goDown()`, replace - server with a fresh instance, `goUp()`. First call times out (`TIMEOUT`), - second call re-handshakes and succeeds; TAG_HELLO count == 2 total. -4. **abortPending keeps the session.** Establish, issue two calls, - `abortPending()`. Both reject with `ABORTED`; a third call succeeds with - TAG_HELLO count still == 1. -5. **AbortSignal**: (a) pre-aborted signal rejects immediately, nothing sent, - no handshake triggered on an idle client; (b) abort mid-flight rejects with - `ABORTED` + `cause`, the late reply is silently dropped, a subsequent call - succeeds without re-handshake (no reset on ABORTED — the regression for - §5's reset-predicate claim); (c) abort of one call during a shared handshake - rejects that call while a concurrent unaborted call completes the handshake - and resolves; (d) listener is removed after settle (assert via a stub - signal counting listeners, or `getEventListeners` under Node). - -`test/e2e/ws-channel.test.ts` — real `ws` server: - -6. **Reconnect + flush.** Kill the client socket server-side; adapter - reconnects; a call issued during the gap resolves after reconnect (server - session survives because the same `server()` is re-bound — use a helper - that re-attaches the server to the new connection, mirroring the SessionDO - wiring). Assert `onDown`/`onUp` fired once each. -7. **Queue overflow drops oldest.** `maxQueue: 2`, three sends while down; - after reconnect the receiving side saw the last two frames. -8. **close() is terminal.** After `close()`, `send` throws; client surfaces - `CHANNEL`. - -Keep neutral naming/comments (no attack vocabulary), as with -`session-continuity.test.ts`. - -## 10. Open questions for the implementer - -1. **Per-call `timeout` in the same `CallOptions` bag.** ~~Review F1.3 already - recommended a per-call timeout override as the mitigation for slow handlers - cascading resets; the options bag now exists, so the marginal cost is ~10 - lines. Recommended: include.~~ **Resolved 2026-07-10: REJECTED by CTO.** - The two-lever model covers all cases: a *global* timeout - (`ClientOptions.timeout`, already configurable — the safety net that heals - a dead session; default raised 10 s → 30 s as part of this decision) plus - a *local* abort (`AbortSignal.timeout(ms)` via the existing `signal` — - shorter single-call budget, `ABORTED`, no reset). A per-call field could - only add "extend past the global timer", and the answer to that case is - "raise the global". -2. Queue overflow policy is specced as drop-oldest (§3.1); if the CTO prefers - loud failure, the alternative is rejecting the *new* send with `CHANNEL` - ("never left" stays true). Drop-oldest is the current spec choice. -3. `wsChannel` factory typing across browser `WebSocket` and the `ws` package: - type `source` against a minimal structural interface (readyState, send, - close, addEventListener, binaryType) rather than the DOM type, so `ws` - users don't need casts. -4. Does anything else in the codebase or Enclave call `abortPending` expecting - the old zero-keys behavior? Grep consumers before release; the Enclave - WS-reconnect branch is the known caller and is the direct beneficiary — - coordinate the adapter swap there. + re-handshake.** Establish, `goDown()`, call (frame queues in core), + `goUp()` within `sendTimeout`. Resolves; TAG_HELLO count == 1. +2. **Pending call survives a gap after send.** Send, `goDown()`, `goUp()`, + deliver reply. Resolves. (Reply-or-timeout, reply branch.) +3. **Lazy heal on lost server session.** First call fails + `RPCAbortedError("TIMEOUT")`; second call re-handshakes and succeeds; + TAG_HELLO == 2. +4. **sendTimeout: definite failure.** `goDown()`, call, stay down past + `sendTimeout` → plain `RPCError("CHANNEL")`, NOT RPCAbortedError; session + not reset (next call after `goUp()` succeeds, TAG_HELLO still 1). +5. **Class split on abort.** (a) abort while frame queued → plain + `ABORTED`; (b) abort after send → `RPCAbortedError("ABORTED")` + cause; + late reply dropped; no reset either way. +6. **Class split on destroy.** One call sent, one queued (channel down); + `destroy()` → sent rejects `RPCAbortedError("SESSION")`, queued rejects + plain `RPCError("SESSION")`. +7. **Reset predicate regression.** Reply-timeout (sent) → next call + re-handshakes (reset happened). Plain CHANNEL (never sent) → next call + does NOT re-handshake. +8. **Stale hello is revoked.** Channel down, first call queues a hello, + `handshakeTimeout` fires → attempt fails; `goUp()` → queue does not flush + the dead hello (assert no TAG_HELLO frame from the failed attempt reaches + the server); next call sends a fresh hello. +9. **Shared-controller idiom replaces abortPending.** Two calls with one + controller's signal; `ctl.abort()` rejects both (`ABORTED`, class per + sent status); a third call succeeds without re-handshake. + +`test/e2e/ws-channel.test.ts` (rework): + +10. **Contract: send throws while down and after close().** Kill socket + server-side; `send` throws until reconnect completes; after `close()` + throws forever. +11. **Reconnect + core retry end-to-end.** Kill socket; call during the gap + resolves after the adapter reconnects (core tick re-sends); `onDown`/ + `onUp` fired once each; TAG_HELLO == 1. + +Keep neutral naming/comments, as with `session-continuity.test.ts`. + +## 11. Open questions for the implementer + +1. Retry tick granularity: fixed 250 ms is specced (not configurable — avoid + knob creep; `sendTimeout` is the caller-visible contract). Revisit only + if a sync-transport test shows the 250 ms floor hurting. +2. `RPCAbortedError` export surface: export from the package root alongside + `RPCError`/`RemoteRPCError` (it is part of the public catch idiom). +3. Grep Enclave for `abortPending` consumers before merging the removal — + the WS-reconnect branch there was the known caller; it migrates to + `wsChannel` + shared controller. +4. Async-send adapters: a rejection arriving *after* the sent boundary was + already counted (promise resolved) is impossible by contract; a rejection + is always pre-boundary and re-enqueues the frame at the head of the + queue. State this in the `Channel` jsdoc so adapter authors reject + rather than resolve-then-error. diff --git a/spec/api.md b/spec/api.md index bc025f0..bc0a4af 100644 --- a/spec/api.md +++ b/spec/api.md @@ -8,7 +8,7 @@ Reference for every exported symbol. End-to-end walkthrough lives in [Getting St // Root entry: everything import { saferpc, chain, server, client, - RPCError, RemoteRPCError, + RPCError, RPCAbortedError, RemoteRPCError, deriveSessionSecret, } from "@dotex/saferpc"; @@ -207,12 +207,11 @@ function client( options: ClientOptions, ): { api: Client; - abortPending: (err?: RPCError) => void; destroy: () => void; }; ``` -Returns synchronously. The handshake stays lazy: it starts on the first `api` call. `abortPending(err?)` lets the application reject all in-flight calls (and any in-progress handshake) at once when it knows further waiting is pointless — the session itself survives; see [Failure handling](#failure-handling-no-auto-retry). +Returns synchronously. The handshake stays lazy: it starts on the first `api` call. ### `ClientOptions` @@ -222,11 +221,14 @@ Returns synchronously. The handshake stays lazy: it starts on the first `api` ca | `timeout` | `number` (ms) | `30_000` | — | | `maxPending` | `number` | `256` | — | | `handshakeTimeout` | `number` (ms) | `5000` | — | +| `sendTimeout` | `number` (ms) | `10_000` | — | | `maxMessageBytes` | `number` | `1_048_576` | — | `maxPending` caps concurrent in-flight calls. Past the cap, calls reject with `RPCError("CLIENT", "Too many pending requests")`. -`timeout` applies to every call. On timeout the client throws `RPCError("TIMEOUT", "Timed out: ")` and resets the session (no auto-retry — the error surfaces and the caller decides whether to retry; the next call re-handshakes). Set it generously — it is the safety net, not a UX budget; shorten a single call with [`AbortSignal.timeout`](#calloptions--per-call-abort) instead. +`timeout` applies to every call. On timeout the client throws `RPCAbortedError("TIMEOUT", "Timed out: ")` if the frame had already been sent (outcome unknown — do not blind-resend; the session resets and the next call re-handshakes), or plain `RPCError("CHANNEL")` if the frame had not yet left the process (retry freely; no reset). Set `timeout` generously — it is the safety net, not a UX budget; shorten a single call with [`AbortSignal.timeout`](#calloptions--per-call-abort) instead. + +`sendTimeout` is the maximum time a frame spends in the core outbound queue waiting for a live channel before the call fails with a definite `RPCError("CHANNEL")` (never sent — retry freely). Default 10 000 ms. Not a caller-facing UX knob; it is the boundary between the definite and unknown failure paths. ### `Client` @@ -255,11 +257,11 @@ ac.abort(); // p rejects with RPCError("ABORTED"), signal.reason on .cause await api.getPrice(input, { signal: AbortSignal.timeout(500) }); ``` -Fetch-style. Aborting rejects **that call** with `RPCError("ABORTED")`: +Fetch-style. Aborting rejects **that call** with code `ABORTED`. The class depends on the sent boundary: -- An already-aborted signal rejects immediately — nothing is sent and no handshake is triggered. -- Aborting while the call waits on a shared handshake rejects the call only; the handshake keeps running for other callers and for the next call. -- Abort is client-local. Like a `TIMEOUT`, the outcome on the server is **UNKNOWN** — the request may still execute there. Abort does not cancel server-side execution; never blind-resend a non-idempotent call after aborting it. +- An already-aborted signal rejects immediately — nothing is sent and no handshake is triggered → plain `RPCError("ABORTED")`, retry freely. +- Aborting while the call waits on a shared handshake rejects the call only; the handshake keeps running for other callers and for the next call → plain `RPCError("ABORTED")`, retry freely. +- Aborting after the frame was already sent → `RPCAbortedError("ABORTED")`; `signal.reason` on `.cause`. Outcome on the server is **UNKNOWN** — the handler may have run. Do not blind-resend a non-idempotent call. - The session is never touched: `ABORTED` does not trigger the reset path, and a reply arriving after the abort is silently dropped. There is deliberately **no per-call `timeout` field**: the two-lever model is a *global* timeout (the client-level `timeout` option — the safety net that heals a dead session) plus a *local* abort (`AbortSignal.timeout(ms)` for a shorter single-call budget — gives `ABORTED`, session untouched). A signal can only shrink a call's budget, not extend it past the global timer; procedures slower than the global timeout mean the global value is too small — raise `ClientOptions.timeout` (this is also why the default is a generous 30 s), don't look for a per-call escape hatch. @@ -281,11 +283,11 @@ idle → handshaking → ready ## Failure handling (no auto-retry) -As of 0.7.0 a call that fails on a `ready` session with a local `TIMEOUT` or `CHANNEL` send error resets the session (zeros the key, returns to `idle`) but is **not** resent — the error surfaces so the caller, the only party that knows whether the procedure is idempotent, decides. Resending after a `TIMEOUT` (outcome unknown) would silently double-execute non-idempotent handlers. `RemoteRPCError` (server answered) and local guardrail errors (`CLIENT`, `ABORTED`) never reset the session. The reset alone keeps a desynced peer from wedging future calls: the next call re-handshakes lazily. Concurrent failures share one re-handshake via an epoch counter. Full state-machine and wire-level semantics in [Protocol § Failure handling](protocol.md#failure-handling-no-auto-retry). +As of 0.7.0 a call that fails as `RPCAbortedError("TIMEOUT")` — the frame was sent and no reply arrived — resets the session (zeros the key, returns to `idle`) but is **not** resent. The error surfaces so the caller, the only party that knows whether the procedure is idempotent, decides. Resending after a sent-frame timeout would silently double-execute non-idempotent handlers. A call whose frame was still in the core outbound queue when a terminal event fired (`RPCError("CHANNEL")` — either timer — or plain `RPCError("ABORTED")`) provably never left — the session is not reset. `RemoteRPCError` (server answered) and guardrail errors (`CLIENT`) never reset the session. The reset alone keeps a desynced peer from wedging future calls: the next call re-handshakes lazily. Concurrent failures share one re-handshake via an epoch counter. Full state-machine and wire-level semantics in [Protocol § Failure handling](protocol.md#failure-handling-no-auto-retry). -As of 0.7.0 **transport death is not a session event**. The session is bound to key material, not to a transport instance; when a socket dies, the client does nothing — keys are kept, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events: a reply that decrypts, or the call's own timeout. Channel liveness (reconnecting, queueing frames that never left) is the adapter's job — see [Integrations § adapter lifecycle contract](integrations.md) and the shipped `@dotex/saferpc/channels`. If the server lost its session with the socket, the first call after recovery times out and the reset above heals lazily. +As of 0.7.0 **transport death is not a session event**. The session is bound to key material, not to a transport instance; when a socket dies, the client does nothing — keys are kept, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events: a reply that decrypts, or the call's own timeout. If the adapter's `send` throws (transport down), the core holds the frame in its outbound queue and retries until `sendTimeout` — see [Integrations § adapter contract](integrations.md#adapter-contract-send-or-throw-no-queues-stay-available) and the shipped `@dotex/saferpc/channels`. If the server lost its session with the socket, the first sent call that times out triggers the reset above, and the next call re-handshakes lazily. -`abortPending(err?)` is the application-driven "stop waiting": it rejects every in-flight call and any hello-waiter immediately with `err ?? RPCError("ABORTED", "Pending calls aborted")` and fails an in-progress handshake. A `ready` session's keys and state are **untouched** — the next call reuses the session, no re-handshake. Use it when the app knows waiting is pointless (user logged out, tab hiding); use per-call `signal` for single-call aborts. +**Migration from 0.6.x:** `abortPending` is removed. Replace with a shared `AbortController` whose `signal` is passed to each call: `ctl.abort()` rejects every carrying call, equivalent to the old behavior with more precise class semantics (sent → `RPCAbortedError`, unsent → plain `RPCError`). Also note the `timeout` default rose from `10_000` to `30_000` ms: without auto-retry the short timeout no longer doubles as a UX budget — that moved to per-call `AbortSignal.timeout(ms)`. Code that relied on the 10 s reset-and-heal cadence should pin `timeout: 10_000` explicitly. ## Replay within a session @@ -308,7 +310,7 @@ The only transport contract. `receive` should return an unsubscribe function; re - Deliver each call to `cb` once, in any order - Allow `send` and `receive` to run concurrently -Dropping, duplicating, or reordering messages is allowed — Safe RPC will time out and surface a typed error (no auto-retry; the caller decides whether to resend). For transports that can die (sockets), the adapter owns liveness: reopen immediately, hold open, queue frames that provably never left while down — see [Integrations § adapter lifecycle](integrations.md#adapter-lifecycle-reopen-immediately-queue-what-never-left). Ready-made adapters live in [Integrations](integrations.md) and ship as code in `@dotex/saferpc/channels`. +Dropping, duplicating, or reordering messages is allowed — Safe RPC will time out and surface a typed error (no auto-retry; the caller decides whether to resend). For transports that can die (sockets), the adapter owns liveness: reopen immediately, hold open, throw from `send` while down so the core can queue and retry the frame — see [Integrations § adapter contract](integrations.md#adapter-contract-send-or-throw-no-queues-stay-available). Ready-made adapters live in [Integrations](integrations.md) and ship as code in `@dotex/saferpc/channels`. > Within a single session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that can reorder _across_ the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages. @@ -324,28 +326,32 @@ class RPCError extends Error { } class RemoteRPCError extends RPCError {} +class RPCAbortedError extends RPCError {} ``` -- `RPCError` is thrown for **local** failures: timeout, session destroyed, handshake failure, validation failure, channel error. -- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user. - -### Standard local error codes - -| Code | Thrown when | -| ------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `TIMEOUT` | RPC call exceeded `timeout` ms | -| `SESSION` | `destroy()` called or session closed | -| `CLIENT` | Client-side guardrail tripped (e.g., `maxPending` exceeded) | -| `CHANNEL` | `channel.send` failed (request provably never left). Original error on `.cause` | -| `ABORTED` | Per-call `signal` fired, or `abortPending()`; outcome on the server UNKNOWN (like `TIMEOUT`). `signal.reason` on `.cause` | -| `HANDSHAKE` | Handshake failed or timed out, auth payload malformed | -| `INPUT_VALIDATION` | `.input(schema)` rejected the input | -| `OUTPUT_VALIDATION` | `.output(schema)` rejected the handler output | -| `INVALID_DATA` | Wire-level data rejected by `sanitize()` | -| `INTERNAL` | Defensive: should not be reachable | -| `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) | - -`INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, and `NOT_FOUND` are raised **server-side**. The client never validates locally, so these always arrive wrapped as `RemoteRPCError` (the remote branch of the pattern below), never as a bare local `RPCError`. Only `TIMEOUT`, `SESSION`, `CLIENT`, `ABORTED`, and `HANDSHAKE` are genuinely client-local. +- `RPCError` is thrown for **local** failures where the request provably never left the process: `sendTimeout` expired, guardrail errors, session destroyed before send. +- `RPCAbortedError extends RPCError` is thrown for **local** failures where the frame **had already been sent** — the request may have executed on the server. **Invariant: class = which side of the wire the request died on; code = what killed it.** The same code (`ABORTED`, `SESSION`) can appear in both classes; `TIMEOUT` appears only on `RPCAbortedError` — a still-queued frame that runs out of time (either timer) fails with the definite `CHANNEL` code instead. +- `RemoteRPCError extends RPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user. + +### Standard error codes + +| Class | Code | Thrown when | +| ----------------- | ------------------- | ----------------------------------------------------------------------------------------------------- | +| `RPCAbortedError` | `TIMEOUT` | Global timeout; frame was already sent — outcome unknown | +| `RPCAbortedError` | `ABORTED` | Per-call signal fired; frame was already sent — outcome unknown. `signal.reason` on `.cause` | +| `RPCAbortedError` | `SESSION` | `destroy()` called; frame was already sent — outcome unknown | +| `RPCError` | `CHANNEL` | `sendTimeout` or global `timeout` expired while still queued, channel closed, or reset staled a queued frame — never left | +| `RPCError` | `ABORTED` | Signal fired before frame was sent (pre-aborted signal, or abort during handshake wait) — never left | +| `RPCError` | `SESSION` | `destroy()` before send, or call on a closed client — never left | +| `RPCError` | `CLIENT` | Client-side guardrail tripped (e.g., `maxPending` exceeded) | +| `RPCError` | `HANDSHAKE` | Handshake failed or timed out, auth payload malformed | +| `RemoteRPCError` | `INPUT_VALIDATION` | `.input(schema)` rejected the input (server-side) | +| `RemoteRPCError` | `OUTPUT_VALIDATION` | `.output(schema)` rejected the handler output (server-side) | +| `RPCError` | `INVALID_DATA` | Wire-level data rejected by `sanitize()` | +| `RPCError` | `INTERNAL` | Defensive: should not be reachable | +| `RPCError` | `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) | + +`INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, and `NOT_FOUND` are raised **server-side** and always arrive as `RemoteRPCError`. `CHANNEL` is purely client-local. `ABORTED` and `SESSION` appear in both `RPCAbortedError` (frame sent) and `RPCError` (frame unsent); `TIMEOUT` only in `RPCAbortedError` — the class is the retry-safety signal. Handlers may throw `RPCError(...)` with any code; those codes surface as `RemoteRPCError.code` on the client. @@ -356,9 +362,11 @@ try { await api.getProfile({ id: "u_1" }); } catch (err) { if (err instanceof RemoteRPCError) { - // handler threw on the other side: err.code, err.message, err.data + // handler ran and returned an error: err.code, err.message, err.data + } else if (err instanceof RPCAbortedError) { + // request left the process — outcome unknown, reconcile before retrying } else if (err instanceof RPCError) { - // local failure: TIMEOUT, SESSION, etc. + // request never reached the server — retry freely } else { throw err; } diff --git a/spec/assessment.md b/spec/assessment.md index 87495d7..2c10b7b 100644 --- a/spec/assessment.md +++ b/spec/assessment.md @@ -53,9 +53,9 @@ Residual by design (denial of service, out of threat model): each hello still co On any well-formed hello the server returns `proof = HMAC(HKDF(raw, salt=secret), …)` before the client authenticates. An attacker who completes an ECDH with their own ephemeral knows `raw` and receives the proof, leaving the `secret` as the only unknown — which they can then brute-force **offline**. Infeasible against a random 32-byte key (2²⁵⁶); a fast dictionary attack against a password-derived secret. This is a property of the scheme (PSK as HKDF salt, server-first proof), not a code defect, and is unchanged by make-before-break. Mitigation is documentation: the `secret` must be a CSPRNG key, not a passphrase — see [Security § The secret is a key, not a password](security.md#the-secret-is-a-key-not-a-password). -### 3c. Adapter frame queue — availability trade, by design (0.7.0) +### 3c. Core outbound queue — head-of-line blocking bounded by `sendTimeout` -The shipped reconnecting WS adapter (`@dotex/saferpc/channels`) queues frames submitted while the transport is down and flushes them on reconnect. Two accepted residuals, both availability-only (integrity is untouched — frames are ciphertext or hellos, and the receiving state machines already absorb staleness). First, queue overflow drops the **oldest** frame silently; the affected call times out and the session heals lazily — drop-oldest is chosen so a fresh hello outlives a stale request. Second, a hello queued during an outage can flush **after** the client's handshake attempt already timed out; the server processes it as a normal hello and, under make-before-break, installs a candidate that expires unconfirmed — the live session (if any) is untouched, and the server's reply is dropped by the client's `handshaking`-only gate. No queued-frame TTL is added for this: both endpoints already tolerate it. +The client core now owns the outbound queue; adapters have no internal queues. A frame that fails to send (the adapter threw) enters the core queue and is retried every 250 ms. The queue is head-of-line: if the channel is down, no later frame can pass a stuck earlier one. The bound is `sendTimeout` (default 10 s) — every queued frame fails with a definite `RPCError("CHANNEL")` if a live channel does not appear within that window. The stale-queued-hello residual (a timed-out hello flushing after handshake timeout) is gone: the core revokes queued hellos when the handshake attempt expires. ### 4. No server-side concurrency cap — documented, application concern diff --git a/spec/getting-started.md b/spec/getting-started.md index e64be71..fc87617 100644 --- a/spec/getting-started.md +++ b/spec/getting-started.md @@ -139,20 +139,24 @@ That is the whole loop. The handshake runs on the first call, every payload is X ## Error handling -Two error classes: +Three error classes: -- `RPCError`: local failure (timeout, session lost, validation error, handshake failure). +- `RPCError`: local failure where the request provably never left (frame expired in the outbound queue, validation error, handshake failure, session destroyed before send). Safe to retry. +- `RPCAbortedError` (subclass of `RPCError`): the request was sent and the outcome is unknown — it may have executed on the server. Retry only idempotent procedures. - `RemoteRPCError`: error returned from the remote peer (`code`, `message`, `data`). ```typescript -import { RPCError, RemoteRPCError } from "@dotex/saferpc"; +import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc"; try { await api.greet({ name: "World" }); } catch (err) { if (err instanceof RemoteRPCError) { if (err.code === "UNAUTHORIZED") await refreshCredentials(); + } else if (err instanceof RPCAbortedError) { + // Sent, no reply — may have executed. Retry only if idempotent. } else if (err instanceof RPCError) { + // Provably never left — retrying cannot double-execute. if (err.code === "HANDSHAKE") console.warn("auth mismatch?"); } else { throw err; diff --git a/spec/integrations.md b/spec/integrations.md index 6c2db36..6a0e73c 100644 --- a/spec/integrations.md +++ b/spec/integrations.md @@ -19,7 +19,7 @@ Bidirectional byte streams. Each connection maps to one Safe RPC session. The most common case: browser or service talking to a server over WS. Ships as code — `@dotex/saferpc/channels` — because WS is the one transport with a -lifecycle trap (see [the adapter lifecycle contract](#adapter-lifecycle-reopen-immediately-queue-what-never-left) below). +lifecycle trap (see [the adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below). ```typescript // Server (Node.js, ws package) @@ -54,12 +54,11 @@ const user = await api.getUser({ id: "123" }); `wsChannel(source, opts?)` **owns the socket lifecycle**: it connects immediately, and when the socket closes it reconnects at once and keeps the transport open as long as possible (exponential backoff, forever, until -`close()`). While the socket is down, `send` never throws — frames that -provably never left are queued (default cap 256, drop-oldest) and flushed in -order on reconnect; transport errors are swallowed and surfaced only through -the optional `onDown`/`onUp` observability hooks. `source` is a URL string -(uses the global `WebSocket`) or a factory `() => ws` for the `ws` package / -custom construction. +`close()`). While the socket is down `send` throws synchronously — the core +catches that, queues the frame, and retries on its own tick. Transport errors +are surfaced through the optional `onDown`/`onUp` observability hooks. +`source` is a URL string (uses the global `WebSocket`) or a factory `() => ws` +for the `ws` package / custom construction. `socketChannel(ws)` is the plain single-socket wrapper: no reconnect, no queue. The server uses it because a server cannot reconnect a client's @@ -444,40 +443,32 @@ The rules are the same as everywhere else: 1. `send` accepts `Uint8Array` and gets it to the other side. 2. `receive(cb)` calls `cb` with each incoming `Uint8Array`. It returns an unsubscribe function. 3. The transport is allowed to drop, duplicate, or reorder messages. Safe RPC will time out and surface a typed error (it does not auto-retry — the caller decides whether to resend). It will not behave correctly if your transport silently corrupts bytes. Wrap it in something that fails noisily if you cannot trust it. -4. If the transport can die (sockets), the adapter owns liveness — see the lifecycle contract below. +4. If the transport can die (sockets), the adapter owns liveness — see the [adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below. In brief: throw from `send` when the transport is down (the core queues and retries), reconnect eagerly, never queue inside the adapter. That is the whole API surface. Encryption, framing, key management: all on the Safe RPC side. Your adapter does not need to care. -### Adapter lifecycle: reopen immediately, queue what never left - -As of 0.7.0 the client core treats transport death as a non-event: the session -is bound to key material, not to a transport instance, so when a socket dies -the client keeps its keys and its pending calls keep waiting under their own -timers. A call's outcome is decided by exactly two events — a reply that -decrypts, or the call's own timeout. That works only if the adapter holds up -its half of the deal: - -- **Reopen immediately.** When the transport closes, reconnect at once and - keep it open as long as possible (backoff, forever). Don't wait for the - next `send` to notice. -- **While down, `send` must not throw.** Queue the frame — it provably never - left, so flushing it after reconnect is safe and is NOT an auto-retry — and - drop any transport error inside the adapter (log it through your own hook - if you want; don't surface it per-call). -- **Never resend a frame that was written to a live transport.** Its outcome - is unknown; resending is exactly the double-execution hazard the no-retry - rule exists to prevent. Only queued-while-down frames may be flushed. +### Adapter contract: send or throw, no queues, stay available + +The client core owns the outbound queue. The adapter's job is simpler: + +- **`send` MUST throw synchronously** (or reject, for async adapters) when it + cannot hand the frame to a live transport right now. No internal queues, no + buffering, no retry — accepting a frame the adapter cannot deliver lies to + the core about the sent boundary. +- **No queues inside adapters.** Queueing in a transport adapter is an + antipattern: it duplicates state the core already owns and moves the + "never-left" bookkeeping to a place the core cannot see. The core retries + frames it gets back via throw; it cannot know about frames an adapter + accepted and then lost. +- **Stay available.** When the transport closes, reconnect at once and keep + it open as long as possible (backoff, forever, until explicit `close()`). + The channel's job is availability; delivery bookkeeping is the core's. `wsChannel` in `@dotex/saferpc/channels` implements this contract; use it as the reference. Two WS-specific traps it absorbs, for anyone writing their own: browser `WebSocket.send()` on a **CLOSED** socket silently drops the -frame (it only throws while `CONNECTING`) — without a `readyState` check in -`send`, every failure degrades into a stacked RPC + handshake timeout (~35s -with defaults) surfacing as a misleading `HANDSHAKE "Handshake timeout"`; and -a socket can report OPEN before the reconnect flush has run — sending -directly then would reorder frames past the queued ones. - -A fail-fast adapter (throw from `send` when the transport is down, get an -immediate `CHANNEL` error) is still a legal, simpler choice — the failed call -rejects at once and the caller decides. What it costs you is exactly the new -property: calls no longer survive a socket blip. +frame (it only throws while `CONNECTING`) — without a `readyState` check, +every failure degrades into a stacked RPC + handshake timeout (~35 s with +defaults) surfacing as a misleading `HANDSHAKE "Handshake timeout"`; and a +socket in a transient non-OPEN state must be detected by checking +`readyState`, not by catching the send exception. diff --git a/spec/protocol.md b/spec/protocol.md index 03b3235..17d103a 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -41,7 +41,8 @@ All wire numbers are network-byte-order (big-endian) unless explicitly noted. | `MAX_AUTH_BYTES` | 32,768 | Max size of the optional `auth` payload | | `MAX_MSG_BYTES` | 1,048,576 | Max size of an encrypted RPC frame (configurable) | | `HANDSHAKE_TIMEOUT_MS` | 5,000 | Default timeout for completing the handshake | -| `RPC_TIMEOUT_MS` | 10,000 | Default per-call timeout (client side) | +| `RPC_TIMEOUT_MS` | 30,000 | Default per-call timeout (client side) | +| `SEND_TIMEOUT_MS` | 10,000 | Default outbound-queue deadline for an unsent frame (client side) | | `MAX_PENDING` | 256 | Default maximum in-flight RPCs per client | | `MAX_ID_LEN` | 64 | Max request `id` length accepted by the server | | `PROOF_LEN` | 32 | Length of the HMAC proof in the reply | @@ -407,38 +408,39 @@ destroy() ⇒ [destroyed], terminal │ reply OK + proof OK ▼ [ready] - │ call TIMEOUT / CHANNEL send error + │ RPCAbortedError(TIMEOUT) — sent-call reply timeout ▼ [idle] (auto-reset, NO resend; next call re-handshakes) -destroy() ⇒ [closed], terminal -abortPending() ⇒ [idle] (reject in-flight, client stays usable) +destroy() ⇒ [closed], terminal ``` The client uses an **epoch counter** to coordinate concurrent failure-and-reset. When multiple calls fail at once, only the first one increments the epoch and resets; the rest see the new epoch and join the in-progress handshake. ## Failure handling (no auto-retry) -As of 0.7.0 the client does **not** auto-retry. When a call fails with a local `TIMEOUT` or `CHANNEL` send error on a `ready` session — **and only then**: +As of 0.7.0 the client does **not** auto-retry. When a sent call times out with `RPCAbortedError("TIMEOUT")` on a `ready` session — **and only then**: 1. If `epoch === sentEpoch` (no other call has already reset), call `reset()`: zero the session key, drop encryptor/decryptor, state ← `idle`. The failed call is **not** resent. 2. The error surfaces to the caller with its typed code. The caller — the only party that knows whether the procedure is idempotent — decides whether to retry. -**Why no resend.** A `TIMEOUT` does not prove the server did not execute the request, only that no response arrived in time. Resending after a timeout can silently execute a non-idempotent procedure twice — a first-party double-execution mechanism for fund-moving handlers, layered on top of any attacker replay window. A `CHANNEL` send error means the request provably never left, so it is safe for the _caller_ to retry; the library still defers that choice to the caller so both cases follow one predictable rule. +**Sent boundary.** A call's retry safety is determined by whether its frame reached a live transport. `channel.send` returning without throwing (sync) or its promise resolving (async) is the sent boundary. Before that point, the core holds the only copy of the frame; terminal events (timeout, abort, destroy, `sendTimeout`) reject with a plain `RPCError` — the frame provably never left and the caller may retry freely. After the sent boundary, terminal events reject with `RPCAbortedError` — outcome unknown. -**Why still reset (without resending).** A desynced peer — e.g. a restarted server that silently drops `TAG_MSG` over a synchronous transport where no channel error is ever raised — would otherwise wedge every future call. Reset keeps healing lazy: the failed call fails, the _next_ call re-handshakes. The trade-off: `reset()` nulls `decrypt`, so replies to _other_ concurrent in-flight calls on the same session are dropped and those calls also fail; size the client-level `timeout` to the slowest procedure (it is the safety net, default 30 s) and shorten individual calls with `AbortSignal.timeout` — an abort never resets. +**Core outbound queue.** When `channel.send` throws, the frame enters the core outbound queue. A retry tick (every 250 ms, running only while the queue is non-empty) attempts queued frames in order; the first throw stops the pass (head-of-line: if the channel is down, no later frame bypasses a stuck one). A frame transitions to *sent* the first time `send` succeeds; it then waits for reply-or-timeout only. `sendTimeout` (default 10 000 ms, counted from enqueue) is the per-frame deadline; expiry rejects the call with plain `RPCError("CHANNEL")` — the frame provably never left. + +**Why no resend.** A sent-frame `TIMEOUT` does not prove the server did not execute the request, only that no response arrived in time. Resending would silently execute a non-idempotent handler twice. Unsent frames (`RPCError`) are safe to retry; the library defers that choice to the caller in both cases. + +**Why still reset (without resending).** A desynced peer — e.g. a restarted server that silently drops `TAG_MSG` over a synchronous transport — would otherwise wedge every future call. Reset keeps healing lazy: the failed call surfaces its error; the _next_ call re-handshakes. The trade-off: `reset()` nulls `decrypt`, so replies to other in-flight sent calls on the same session are dropped and those calls also fail; size `timeout` to the slowest procedure (default 30 s) and shorten individual calls with `AbortSignal.timeout` — an abort never resets. Calls that received a `RemoteRPCError` (the server responded with `ok: false`) are **not** reset or retried. The server is alive and gave a real answer. -**Transport death is not a session event (0.7.0).** The session is bound to key material, not to a transport instance. When the transport dies the client does nothing: keys are kept, state stays `ready`, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events — a reply that decrypts, or the call's own timeout. Transport liveness is the adapter's job (reconnect eagerly, hold the transport open); the one permitted "resend" anywhere in the stack is the adapter flushing frames that **provably never left** — frames submitted while the transport was down and queued. A frame written to a live transport has unknown outcome and is never resent by any layer. If the peer lost its session together with the transport, the encrypted frame fails to decrypt, the peer stays silent, the call hits `TIMEOUT`, and the reset path above heals lazily. Aborts (per-call `signal`, `abortPending`) are client-local and never touch the session either — `ABORTED` is outside the reset trigger set. +**Transport death is not a session event (0.7.0).** The session is bound to key material, not to a transport instance. When the transport dies the client does nothing: keys are kept, state stays `ready`, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events — a reply that decrypts, or the call's own timeout. Transport liveness is the adapter's job (reconnect eagerly, hold the transport open, throw from `send` while down); the core retries frames the adapter rejected until `sendTimeout` expires — a definite `RPCError("CHANNEL")` if the transport stays down. A frame written to a live transport has unknown outcome and is never resent by any layer. If the peer lost its session together with the transport, the call hits `RPCAbortedError("TIMEOUT")` and the reset path above heals lazily. Per-call `signal` aborts are client-local and never touch the session — `ABORTED` is outside the reset trigger set. -Local guardrail errors **must not** trigger the reset path either. The `CLIENT` backpressure error (`maxPending` exceeded), id-counter exhaustion, and any other error that does not indicate a dead session leave the session exactly as it was: resetting a healthy session on a guardrail error would tear down the encryption state for every in-flight call, force them into timeout, and re-execute their handlers — double execution with no attacker involved. The reset trigger set is exactly: local per-call `TIMEOUT`, `CHANNEL` send failure. Nothing else. +Local guardrail errors **must not** trigger the reset path either. The `CLIENT` backpressure error (`maxPending` exceeded), id-counter exhaustion, and any other error that does not indicate a dead session leave the session exactly as it was: resetting a healthy session on a guardrail error would tear down the encryption state for every in-flight call, force them into timeout, and re-execute their handlers — double execution with no attacker involved. The reset trigger set is exactly: `RPCAbortedError` with code `TIMEOUT` — a sent call whose reply never arrived. Nothing else. Concurrent failures share one re-handshake via the epoch counter, so there are no reset storms. -### abortPending -A transport adapter that learns the channel died from an event (`ws.onclose`) at a moment when no `send` is in progress calls `abortPending(err?)`. It rejects every in-flight call and any hello-waiter immediately with a retryable code (`err ?? RPCError("CHANNEL", "Channel down")`), fails an in-progress handshake so waiters do not hang until `handshakeTimeout`, zeros the keys, and returns the client to `idle` — **not** `closed`. The client object keeps its identity; the next call lazily re-handshakes over the revived transport. No-op once `closed`. ## Sanitization @@ -522,7 +524,7 @@ A new-language port that ticks every item is conformant: - [ ] The server's ephemeral pair `(s_priv, s_pub)` is generated **fresh per hello attempt** and never held at module/connection scope. This is load-bearing for make-before-break: it guarantees a duplicate hello derives a different candidate key than the live session, so replayed traffic can never decrypt under the candidate and force a promotion. - [ ] A validated attempt is installed as a **candidate**, not swapped into the live session. The live key is retired only when a `TAG_MSG` decrypts under the candidate key (make-before-break). Inbound frames are trial-decrypted live-first, then candidate. - [ ] The response-guard epoch is captured **after** promotion (not at frame arrival), so the reply to the confirming frame is not dropped by the guard. -- [ ] Epoch counter increments per handshake attempt on both sides, is echoed in the reply, is validated as a uint32 on the wire, and never wraps (exhaustion is a terminal client error). +- [ ] Client epoch increments per handshake attempt (and per session reset) and is echoed verbatim in the reply; it is validated as a uint32 on the wire and never wraps (exhaustion is a terminal client error). The server does NOT bump a single mirror counter — under make-before-break it keeps three internal counters: `attemptEpoch` (per incoming hello; invalidates older attempt coroutines), `candidateEpoch` (per candidate install; guards the confirmation timer), and `epoch` (per promotion; guards TAG_MSG responses). - [ ] The attempt counter is bumped for **every** incoming hello, including ones that arrive while a previous attempt is still suspended at an `await`. In-flight stale attempts detect themselves via the guard and abandon all writes. - [ ] Every `await` in the handshake path is followed by an attempt + destroyed guard before any session state is written. The candidate install happens under a final guard inside a single synchronous block. - [ ] A separate counter guards the candidate confirmation timer, bumped only when a candidate is installed — so a later hello that bumps the attempt counter but then fails validation cannot disarm an existing candidate's timeout. @@ -531,7 +533,7 @@ A new-language port that ticks every item is conformant: - [ ] The X25519 raw shared secret is zeroed in a try/finally so a thrown `psk()` does not leak it. - [ ] Ephemeral private keys captured for the duration of an `await` are owned by the in-flight attempt (copied, not aliased), so a concurrent reset that zeroes the live buffer does not corrupt the in-flight derivation. - [ ] Server accepts new hellos in any state (including `ready`). -- [ ] Client does **not** auto-retry. On a local `TIMEOUT` / `CHANNEL` send error while `ready` it resets the session (zeros the key, state → `idle`) **without resending**, then surfaces the error; the caller decides whether to retry. It never resets on `RemoteRPCError` or on local guardrail errors (`maxPending`, counter exhaustion) — those reject the call and leave the session intact. `abortPending()` rejects in-flight calls with a retryable code and returns the client to `idle`, not `closed`. +- [ ] Client does **not** auto-retry. On a sent-call reply timeout (`RPCAbortedError("TIMEOUT")`) while `ready` it resets the session (zeros the key, state → `idle`) **without resending**, then surfaces the error; the caller decides whether to retry. It never resets on `RemoteRPCError`, guardrail errors (`maxPending`, counter exhaustion), or unsent-frame failures — those reject the call and leave the session intact. - [ ] The application's secret buffer is never mutated or zeroed by the protocol; only protocol-owned copies and derived material are zeroed. - [ ] Request `id` is validated: non-empty string, ≤ `MAX_ID_LEN`; `p` non-empty string; violations are silent drops. - [ ] Absent call input omits the `i` key entirely (never nil). diff --git a/src/channels/ws.ts b/src/channels/ws.ts index ec9ac23..539ec36 100644 --- a/src/channels/ws.ts +++ b/src/channels/ws.ts @@ -6,15 +6,15 @@ * - `wsChannel(source)` — reconnecting client adapter. OWNS the socket * lifecycle: when the socket closes it immediately creates a new one and * keeps retrying (exponential backoff, forever) until `close()`. While the - * transport is down, `send` never throws — frames that provably never left - * are queued and flushed in order on reconnect, transport errors are - * swallowed (surfaced only via the `onDown` observability hook). A frame - * written to a live socket is spent — it is NEVER resent, so nothing here - * violates the client's no-auto-retry semantics. + * transport is down, `send` throws synchronously — the core outbound queue + * owns retry; the adapter's job is availability, not delivery bookkeeping. + * `onDown`/`onUp` hooks report transitions for observability only. * * - `socketChannel(ws)` — plain single-socket adapter, no lifecycle * ownership. This is the server-side wrapper (a server cannot reconnect a * client's socket) and the choice when the caller manages the socket. + * `send` checks `readyState` and throws when not OPEN (browser CLOSED + * silently drops — that silent drop is the trap this check closes). */ import type { Channel } from "../common.ts"; @@ -36,13 +36,6 @@ export interface WebSocketLike { } export interface WsChannelOptions { - /** - * Max frames buffered while the socket is down. Default 256 (matches the - * client's default `maxPending`). On overflow the OLDEST frame is dropped - * silently — the affected call times out and the session heals lazily; - * keeping the newest frames means a fresh hello beats a stale request. - */ - maxQueue?: number; /** * Reconnect backoff. The first retry is immediate; subsequent retries are * exponential from `backoffMin` (default 250 ms) to `backoffMax` @@ -78,10 +71,8 @@ export function wsChannel( source: string | (() => WebSocketLike), opts: WsChannelOptions = {}, ): Channel & { close: () => void } { - const maxQueue = opts.maxQueue !== undefined ? opts.maxQueue : 256; const backoffMin = opts.backoffMin !== undefined ? opts.backoffMin : 250; const backoffMax = opts.backoffMax !== undefined ? opts.backoffMax : 5000; - if (maxQueue < 1) throw new TypeError("wsChannel: maxQueue must be ≥ 1"); const factory: () => WebSocketLike = typeof source === "function" @@ -99,10 +90,9 @@ export function wsChannel( }; const callbacks = new Set<(data: Uint8Array) => void>(); - const queue: Uint8Array[] = []; let sock: WebSocketLike | null = null; let closed = false; - /** True between a completed flush (socket usable) and the next down. */ + /** True after onOpen (socket usable) until the next down event. */ let up = false; /** Consecutive failed attempts since the socket was last open. */ let attempts = 0; @@ -157,20 +147,6 @@ export function wsChannel( function onOpen(): void { if (closed || s !== sock) return; attempts = 0; - // Flush frames that provably never left, in order. A throw here means - // the socket died mid-flush; the frame is spent (unknown outcome — do - // not requeue), the remaining queue survives for the next reconnect, - // and the close event will schedule it. In that case the channel never - // went usably up — do NOT fire onUp (and onClose, seeing up === false, - // will not fire onDown), so consumers see no spurious up/down pair. - while (queue.length > 0) { - const frame = queue.shift() as Uint8Array; - try { - s.send(frame); - } catch { - return; - } - } up = true; if (opts.onUp !== undefined) { try { @@ -214,18 +190,10 @@ export function wsChannel( throw new Error("wsChannel: channel closed"); } const s = sock; - // `up` (set after flush) gates ordering: a socket can report OPEN - // before our open handler flushed the queue — sending directly then - // would reorder frames past the queued ones. `readyState` gates the - // browser trap: send() on a CLOSED socket silently drops. - if (s !== null && up && s.readyState === WS_OPEN) { - s.send(data); // a sync throw here is a real send failure — propagate - return; + if (s === null || !up || s.readyState !== WS_OPEN) { + throw new Error("wsChannel: no open socket"); } - // Transport down: never throw. Queue the frame (it provably never - // left); drop the oldest on overflow. - if (queue.length >= maxQueue) queue.shift(); - queue.push(data); + s.send(data); // a sync throw here is a real send failure — propagate }, receive(cb: (data: Uint8Array) => void): () => void { callbacks.add(cb); @@ -240,7 +208,6 @@ export function wsChannel( clearTimeout(retryTimer); retryTimer = null; } - queue.length = 0; callbacks.clear(); const s = sock; sock = null; @@ -257,11 +224,14 @@ export function wsChannel( } /** - * Plain single-socket channel. No reconnect, no queue — the caller owns the - * socket lifecycle. Use on the server side + * Plain single-socket channel. No reconnect — the caller owns the socket + * lifecycle. Use on the server side * (`wss.on("connection", sock => server(router, socketChannel(sock), ...))`, * with `sock.on("close", destroy)`) or anywhere the socket is managed * externally. + * + * `send` checks `readyState` and throws when not OPEN (browser CLOSED silently + * drops — that silent drop is the trap this check closes). */ export function socketChannel(ws: WebSocketLike): Channel { try { @@ -271,6 +241,9 @@ export function socketChannel(ws: WebSocketLike): Channel { } return { send(data: Uint8Array): void { + if (ws.readyState !== WS_OPEN) { + throw new Error("socketChannel: socket not open"); + } ws.send(data); }, receive(cb: (data: Uint8Array) => void): () => void { diff --git a/src/client.ts b/src/client.ts index fc43a39..f008592 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,20 +1,23 @@ /** * drpc/client — Lazy RPC client (no auto-retry) * - * LIFECYCLE: Handshake triggers lazily on first RPC call. On a local - * failure while `ready` (TIMEOUT or CHANNEL send error) the session is - * reset — but the failed call is NOT resent; the error surfaces to the + * LIFECYCLE: Handshake triggers lazily on first RPC call. The session + * auto-resets only on RPCAbortedError(TIMEOUT) — a sent request that got + * no reply — but the failed call is NOT resent; the error surfaces to the * caller, who alone knows whether the procedure is idempotent. The reset * only ensures the NEXT call lazily re-handshakes. Concurrent calls * coordinate via epoch to avoid redundant resets. * * Transport death is NOT a session event: the session is bound to key - * material, not to a transport instance. Channel liveness (reconnect, - * queueing frames that never left) is the adapter's job — see the Channel - * jsdoc in common.ts and the shipped adapters in channels/. A call's - * outcome is decided by exactly two events: a reply that decrypts, or the - * call's own timeout. Per-call AbortSignal and abortPending() cancel - * waiting without touching the session. + * material, not to a transport instance. Channel liveness (reconnect) is + * the adapter's job — see the Channel jsdoc in common.ts and the shipped + * adapters in channels/. Delivery bookkeeping is OURS: a frame whose + * `channel.send` throws enters the core outbound queue and is retried + * until `sendTimeout`; the sent boundary (send returned / promise + * resolved) decides every rejection's class — `RPCAbortedError` = the + * request left, outcome UNKNOWN; plain local `RPCError` = it provably + * never left, safe to resend. Per-call AbortSignal cancels waiting + * without touching the session. */ import { randomBytes } from "@noble/ciphers/utils.js"; @@ -46,6 +49,7 @@ import { buildHelloTranscript, buildReplyTranscript, RPCError, + RPCAbortedError, type Router, type Procedure, type Channel, @@ -57,6 +61,10 @@ import { const PROOF_LEN = 32; const MAX_PENDING = 256; const DEFAULT_TIMEOUT = 30_000; +const DEFAULT_SEND_TIMEOUT = 10_000; +// Outbound retry tick. Internal, not a caller lever — `sendTimeout` is the +// contract; the tick only bounds how fast a revived channel is noticed. +const SEND_RETRY_MS = 250; const MAX_KNOWN_PROCEDURES = 1024; // ─── Client types ───────────────────────────────────────── @@ -79,11 +87,12 @@ export class RemoteRPCError extends RPCError { */ export interface CallOptions { /** - * Abort THIS call. Rejects the call with `RPCError("ABORTED")` (the - * signal's reason on `.cause`). Client-local: like a TIMEOUT, the outcome - * on the server is UNKNOWN — the request may still execute there. The - * session is never touched; a shared in-progress handshake continues for - * other callers. + * Abort THIS call (code `ABORTED`, the signal's reason on `.cause`). + * Client-local: the session is never touched; a shared in-progress + * handshake continues for other callers. The rejection's class tells + * you whether the request had already left: `RPCAbortedError` = sent, + * outcome on the server UNKNOWN; plain `RPCError` = provably never + * sent, safe to resend. */ signal?: AbortSignal; } @@ -135,6 +144,15 @@ export interface ClientOptions { timeout?: number; /** Max concurrent pending RPC calls. Default: 256. */ maxPending?: number; + /** + * How long (ms) a frame may wait for a live channel before the call + * fails with a definite never-sent error (plain `RPCError("CHANNEL")`). + * A `send` that throws does not fail the call — the frame enters the + * core outbound queue and is retried until this expires. An unsent + * frame always fails with the definite `CHANNEL` code — even when the + * global `timeout` fires first. Default: 10000ms. + */ + sendTimeout?: number; /** * Max time (ms) to complete the handshake from when the client hello * is sent. Triggered lazily by the first RPC call, or on retry after @@ -151,7 +169,6 @@ export function client( opts: ClientOptions, ): { api: Client; - abortPending: (err?: RPCError) => void; destroy: () => void; } { if (typeof channel.send !== "function") { @@ -164,8 +181,24 @@ export function client( validateAuthConfig(opts.auth); const auth = opts.auth; const timeout = opts.timeout !== undefined ? opts.timeout : DEFAULT_TIMEOUT; + if ( + typeof timeout !== "number" || + !Number.isFinite(timeout) || + timeout <= 0 + ) { + throw new TypeError("client() timeout must be a number > 0 ms"); + } const maxPending = opts.maxPending !== undefined ? opts.maxPending : MAX_PENDING; + const sendTimeout = + opts.sendTimeout !== undefined ? opts.sendTimeout : DEFAULT_SEND_TIMEOUT; + if ( + typeof sendTimeout !== "number" || + !Number.isFinite(sendTimeout) || + sendTimeout < 0 + ) { + throw new TypeError("client() sendTimeout must be a number ≥ 0 ms"); + } const hsTimeout = opts.handshakeTimeout !== undefined ? opts.handshakeTimeout @@ -188,10 +221,12 @@ export function client( // closed: destroyed. All calls throw. // // On handshake failure, state → idle; the next call re-handshakes. - // AUTO-RESET: On a TIMEOUT or CHANNEL failure while ready, zeros crypto - // and goes idle WITHOUT resending. Other pending calls keep their - // timers; the next call re-handshakes lazily. Epoch prevents - // redundant resets. Guardrail (CLIENT) errors never reset. + // AUTO-RESET: Only an RPCAbortedError(TIMEOUT) — a sent request with no + // reply — while ready zeros crypto and goes idle WITHOUT resending. + // Plain RPCError (frame never left: CHANNEL, unsent TIMEOUT) never + // resets — transport failure is not a session event. Other pending + // calls keep their timers; the next call re-handshakes lazily. Epoch + // prevents redundant resets. Guardrail (CLIENT) errors never reset. let state: "idle" | "handshaking" | "ready" | "closed" = "idle"; // Ephemeral keys — regenerated per handshake attempt @@ -211,17 +246,233 @@ export function client( let handshakeReject: ((err: unknown) => void) | null = null; let hsTimer: ReturnType | null = null; - // Pending RPC responses - const pending = new Map< - string, - { - resolve: (v: unknown) => void; - reject: (e: unknown) => void; - timer: ReturnType; - } - >(); + // Pending RPC responses. `sent` is the wire boundary: true once + // `channel.send` returned without throwing (or its promise was handed + // off — optimistic, see enqueue comment). It decides the class of every + // terminal rejection: sent → RPCAbortedError (outcome UNKNOWN), unsent → + // plain RPCError (provably never left). + interface PendingEntry { + resolve: (v: unknown) => void; + reject: (e: unknown) => void; + timer: ReturnType; + sent: boolean; + } + const pending = new Map(); let counter = 0; + // ── Outbound queue: frames whose send failed, retried until sendTimeout ─ + // The channel contract (common.ts) forbids adapter queues — the core is + // the only owner of undelivered frames, so "never left" is a fact it + // knows, not an inference. Frames are already encrypted; `epoch` stales + // them on reset (ciphertext under a zeroed key can never succeed). + type OutboundEntry = + | { kind: "hello"; frame: Uint8Array; epoch: number } + | { + kind: "msg"; + frame: Uint8Array; + id: string; + prop: string; + epoch: number; + expiresAt: number; + cause?: unknown; + }; + const outbound: OutboundEntry[] = []; + let flushTimer: ReturnType | null = null; + // True while an async `send` of a queued frame is unresolved — blocks + // concurrent flush passes so order is preserved. + let flushInflight = false; + + function isThenable(v: unknown): v is Promise { + return ( + v !== null && + typeof v === "object" && + typeof (v as { then?: unknown }).then === "function" + ); + } + + function startFlushTimer(): void { + if (flushTimer === null) { + flushTimer = setInterval(flushOutbound, SEND_RETRY_MS); + } + } + + function stopFlushTimerIfIdle(): void { + if (flushTimer !== null && outbound.length === 0 && !flushInflight) { + clearInterval(flushTimer); + flushTimer = null; + } + } + + function enqueueMsg( + id: string, + prop: string, + frame: Uint8Array, + cause?: unknown, + ): void { + outbound.push({ + kind: "msg", + frame, + id, + prop, + epoch, + expiresAt: Date.now() + sendTimeout, + cause, + }); + startFlushTimer(); + } + + function removeOutboundMsg( + id: string, + ): (OutboundEntry & { kind: "msg" }) | undefined { + let removed: (OutboundEntry & { kind: "msg" }) | undefined; + for (let i = 0; i < outbound.length; i++) { + const e = outbound[i]; + if (e !== undefined && e.kind === "msg" && e.id === id) { + outbound.splice(i, 1); + removed = e; + break; + } + } + stopFlushTimerIfIdle(); + return removed; + } + + // Reject a still-queued call with a plain (never-left) error and drop + // its frame. Used by expiry, reset staling, and destroy. + function failQueued(e: OutboundEntry & { kind: "msg" }, err: RPCError): void { + const p = pending.get(e.id); + if (p === undefined) return; + pending.delete(e.id); + clearTimeout(p.timer); + p.reject(err); + } + + // Drop entries that can no longer be sent meaningfully: + // • hellos of a dead attempt (state/epoch mismatch) — silently; this is + // what revokes a stale hello after handshakeTimeout, no residual flush; + // • msgs whose call already settled — silently; + // • msgs staled by a reset (epoch mismatch) — plain CHANNEL (belt for + // reset()'s own purge); + // • msgs past their sendTimeout — plain CHANNEL, the definite failure. + function dropStaleAndExpired(): void { + const now = Date.now(); + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + if (e.kind === "hello") { + if (state !== "handshaking" || epoch !== e.epoch) { + outbound.splice(i, 1); + } + continue; + } + const p = pending.get(e.id); + if (p === undefined || p.sent) { + outbound.splice(i, 1); + continue; + } + if (epoch !== e.epoch) { + outbound.splice(i, 1); + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + continue; + } + if (now >= e.expiresAt) { + outbound.splice(i, 1); + failQueued( + e, + new RPCError( + "CHANNEL", + "Not sent within sendTimeout: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + } + } + } + + function markSent(e: OutboundEntry): void { + if (e.kind !== "msg") return; + const p = pending.get(e.id); + if (p !== undefined) p.sent = true; + } + + function flushOutbound(): void { + if (flushInflight) return; + dropStaleAndExpired(); + while (outbound.length > 0) { + const e = outbound[0]; + if (e === undefined) break; + let res: unknown; + try { + res = channel.send(e.frame) as unknown; + } catch (err: unknown) { + // Channel still down — head-of-line: if it can't take this frame + // it can't take the next. Keep the freshest cause for diagnostics. + if (e.kind === "msg") e.cause = err; + break; + } + if (isThenable(res)) { + // Optimistic sent: an unconfirmed frame counts as "left" — a false + // "unknown outcome" is safe, a false "never left" is not. Rolled + // back on rejection. + outbound.shift(); + markSent(e); + flushInflight = true; + res.then( + function onFlushSent() { + flushInflight = false; + flushOutbound(); + }, + function onFlushFail(err: unknown) { + flushInflight = false; + if (e.kind === "msg") { + const p = pending.get(e.id); + if (p !== undefined && p.sent) { + p.sent = false; + if (state === "ready" && epoch === e.epoch) { + e.cause = err; + outbound.unshift(e); + startFlushTimer(); + } else { + // The session was reset (or replaced) while this frame's + // async send was in flight. The rejection proves it never + // left, and its ciphertext is under the zeroed key — it + // can never succeed. Fail NOW (reset invalidates the + // queue); resurrecting it would retry dead ciphertext. + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: err }, + ), + ); + } + } + } else if (state === "handshaking" && epoch === e.epoch) { + outbound.unshift(e); + startFlushTimer(); + } + stopFlushTimerIfIdle(); + }, + ); + return; + } + outbound.shift(); + markSent(e); + } + stopFlushTimerIfIdle(); + } + const knownProcedures = new Set(); function clearHsTimer(): void { @@ -339,9 +590,16 @@ export function client( zero(helloPayload); try { await channel.send(hello); - } catch (err: unknown) { + } catch { if (state !== "handshaking" || epoch !== currentEpoch) return; - failHandshake(err); + // Channel down — not a handshake failure. Queue the hello and let + // the flush tick retry; the attempt stays bounded by hsTimer. If + // the attempt dies first, the queued hello is revoked by the + // state/epoch check in dropStaleAndExpired — it never flushes + // late. The send error is intentionally not surfaced: + // transport-down is the queue's normal input, not a failure. + outbound.push({ kind: "hello", frame: hello, epoch: currentEpoch }); + startFlushTimer(); } })().catch(function onProduceError(err: unknown) { if (state !== "handshaking" || epoch !== currentEpoch) return; @@ -641,6 +899,10 @@ export function client( ); } const id = String(++counter); + // The frame below is encrypted under THIS epoch's key. Captured so the + // async-send rollback can tell a live session from one that was reset + // (and possibly re-established) while the send promise was in flight. + const sendEpoch = epoch; // Omit `i` entirely for `undefined` input rather than encoding it — // msgpack has no `undefined` primitive and would round-trip it as // `null`, which a `.optional()` (as opposed to `.nullish()`) Zod schema @@ -661,13 +923,7 @@ export function client( } } - const timer = setTimeout(function onRpcTimeout() { - pending.delete(id); - settle(); - rej(new RPCError("TIMEOUT", "Timed out: " + prop)); - }, timeout); - - pending.set(id, { + const entry: PendingEntry = { resolve: function resolveSettled(v: unknown) { settle(); res(v); @@ -676,54 +932,106 @@ export function client( settle(); rej(e); }, - timer, - }); + timer: setTimeout(function onRpcTimeout() { + pending.delete(id); + settle(); + if (entry.sent) { + // The request left; a reply never came. Outcome UNKNOWN — + // this is the one signal that the session may be desynced, + // and the only trigger of the auto-reset (proxy catch). + rej(new RPCAbortedError("TIMEOUT", "Timed out: " + prop)); + } else { + // Global timer beat sendTimeout (timeout < sendTimeout + // config) while the frame was still queued — it never left, + // so the definite CHANNEL code applies regardless of which + // timer fired first. Plain TIMEOUT thus never occurs: the + // TIMEOUT code always means "sent, no reply" (aborted class). + const q = removeOutboundMsg(id); + rej( + new RPCError( + "CHANNEL", + "Not sent within timeout: " + prop, + undefined, + { cause: q !== undefined ? q.cause : undefined }, + ), + ); + } + }, timeout), + sent: false, + }; + pending.set(id, entry); if (signal !== undefined) { onAbort = function onCallAbort() { onAbort = null; // {once:true} already removed the listener - const entry = pending.get(id); - if (entry === undefined) return; + if (pending.get(id) !== entry) return; pending.delete(id); clearTimeout(entry.timer); - // The request may have executed on the server — same UNKNOWN - // outcome as a timeout. A late reply finds no pending entry and - // is silently dropped. The session is not touched. - rej(abortError(prop, signal.reason)); + if (entry.sent) { + // The request may have executed on the server — same UNKNOWN + // outcome as a timeout. A late reply finds no pending entry + // and is silently dropped. The session is not touched. + rej( + new RPCAbortedError( + "ABORTED", + "Call aborted: " + prop, + undefined, + { cause: signal.reason }, + ), + ); + } else { + // Still in the outbound queue — provably never left. + removeOutboundMsg(id); + rej(abortError(prop, signal.reason)); + } }; signal.addEventListener("abort", onAbort, { once: true }); } - // Give the send path a typed failure code. A raw adapter error (bare - // Error / DOMException) would slip past the caller's documented - // `instanceof RPCError` taxonomy; `CHANNEL` means "request provably - // never left", the retry predicate's clean trigger. The original error - // is preserved on `.cause` for debugging. Handles BOTH a synchronous - // throw (e.g. a closed MessagePort) and an async rejection (WS/queue - // adapters) from `channel.send`. - function onSendError(err: unknown): void { - pending.delete(id); - clearTimeout(timer); - settle(); - rej( - err instanceof RPCError - ? err - : new RPCError("CHANNEL", "Channel send failed", undefined, { - cause: err, - }), - ); - } - try { - const sent = channel.send(encrypted) as unknown; - if ( - sent !== null && - typeof sent === "object" && - typeof (sent as { then?: unknown }).then === "function" - ) { - (sent as Promise).catch(onSendError); + // Send now if the line is clear; queue behind earlier frames + // otherwise (a non-empty queue means the channel was down a tick + // ago — jumping it would reorder for no gain). A failed send does + // NOT reject the call: the frame provably never left, the core + // owns it and retries until sendTimeout. Async sends are counted + // sent optimistically (a false "unknown outcome" is safe, a false + // "never left" is not) and rolled back to the queue on rejection. + if (outbound.length > 0 || flushInflight) { + enqueueMsg(id, prop, encrypted); + } else { + try { + const sent = channel.send(encrypted) as unknown; + if (isThenable(sent)) { + entry.sent = true; + sent.catch(function onAsyncSendFail(err: unknown) { + if (pending.get(id) !== entry || !entry.sent) return; + entry.sent = false; + if (state === "ready" && epoch === sendEpoch) { + enqueueMsg(id, prop, encrypted, err); + } else { + // Reset (or reset + re-handshake) happened while the send + // promise was pending. `enqueueMsg` stamps the CURRENT + // epoch, which would smuggle old-key ciphertext past the + // staleness check — under a new session it can never + // decrypt. The rejection proves the frame never left: + // fail plain CHANNEL immediately. + pending.delete(id); + clearTimeout(entry.timer); + entry.reject( + new RPCError( + "CHANNEL", + "Session reset before send: " + prop, + undefined, + { cause: err }, + ), + ); + } + }); + } else { + entry.sent = true; + } + } catch (err: unknown) { + enqueueMsg(id, prop, encrypted, err); } - } catch (err: unknown) { - onSendError(err); } }); } @@ -767,25 +1075,28 @@ export function client( try { return await sendRequest(prop, input, signal); } catch (err: unknown) { - // No auto-retry. A TIMEOUT leaves the outcome UNKNOWN (the request - // may have executed — auto-resending it is a silent double-execution - // hazard for non-idempotent handlers); a CHANNEL error means the - // request provably never left. Either way the error surfaces and the - // CALLER decides whether to retry — it alone knows if the procedure - // is idempotent. + // No auto-retry. An RPCAbortedError leaves the outcome UNKNOWN + // (the request may have executed — auto-resending it is a silent + // double-execution hazard for non-idempotent handlers); a plain + // local error means the request provably never left. Either way + // the error surfaces and the CALLER decides whether to retry — + // it alone knows if the procedure is idempotent. // - // We still reset the session (but do NOT resend) on TIMEOUT/CHANNEL - // so the NEXT call lazily re-handshakes: a desynced peer (e.g. a - // restarted server that silently drops TAG_MSG over a sync transport) - // would otherwise wedge every future call. Guardrail (CLIENT) and - // SESSION errors must NOT reset — a healthy session has to survive - // local backpressure instead of tearing down its own good key. + // We still reset the session (but do NOT resend) on exactly one + // trigger: a reply-timeout on a SENT request — "it went out and + // the server never answered" is the one signal the session may + // be desynced (e.g. a restarted server silently dropping + // TAG_MSG over a live transport); without the reset every + // future call would wedge. A never-left CHANNEL failure is a + // transport event, not a session event — the keys are fine, + // and if send throws, a re-handshake's hello couldn't leave + // either. ABORTED is a caller-local decision; guardrail + // (CLIENT) and SESSION errors must not tear down a good key. if ( state === "ready" && epoch === sentEpoch && - err instanceof RPCError && - !(err instanceof RemoteRPCError) && - (err.code === "TIMEOUT" || err.code === "CHANNEL") + err instanceof RPCAbortedError && + err.code === "TIMEOUT" ) { reset(); } @@ -812,41 +1123,42 @@ export function client( }); // ── Auto-reset ───────────────────────────────────────────── - // Called when a call's sendRequest fails (timeout or send error). - // Only zeros crypto and returns to idle — pending calls are left - // untouched. They'll time out naturally and retry individually - // through the same catch → epoch-check → ensureHandshake path. - // If another call already reset (epoch advanced), latecomers skip - // this and just join the in-progress handshake. + // Called on a reply-timeout of a SENT request (the one desync signal). + // Zeros crypto and returns to idle. Sent pending calls are left + // untouched — they'll time out naturally and retry individually + // through the same catch → epoch-check → ensureHandshake path. Queued + // UNSENT frames are failed immediately: they are ciphertext under the + // zeroed key and can never succeed — plain CHANNEL, the definite + // never-left error. If another call already reset (epoch advanced), + // latecomers skip this and just join the in-progress handshake. function reset(): void { if (state !== "ready") return; zeroKeys(); state = "idle"; - } - - // ── abortPending ─────────────────────────────────────────── - // Application-driven "stop waiting": the app knows further waiting is - // pointless (user logged out, tab hiding) and rejects every in-flight - // call (and any hello-waiter) immediately instead of letting them stack - // up to their timeouts. The SESSION SURVIVES: a live session's keys and - // `ready` state are untouched — transport death is not a session event - // (channel liveness is the adapter's job; a lost server session heals - // lazily via timeout → reset). Only an in-progress handshake attempt is - // failed, which zeros that attempt's ephemerals — attempt state, not a - // live session. - - function abortPending(err?: RPCError): void { - if (state === "closed") return; - const e = err ?? new RPCError("ABORTED", "Pending calls aborted"); - // Reject all pending RPC calls (clears their timers). - rejectPending(e); - if (state === "handshaking") { - // failHandshake rejects the shared handshake promise (so hello-waiters - // don't hang until handshakeTimeout), zeros the attempt's ephemerals, - // and goes idle. A `ready` session is left fully intact. - failHandshake(e); + // Advance the epoch so anything encrypted under the zeroed key is + // epoch-stale from this point on. This is what arms the belt in + // dropStaleAndExpired for frames that were IN FLIGHT during the reset + // (shifted out of `outbound`, so the purge below can't see them) and + // get rolled back by an async-send rejection afterwards. + epoch++; + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + outbound.splice(i, 1); + if (e.kind === "msg") { + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + } } + stopFlushTimerIfIdle(); } // ── Destroy ─────────────────────────────────────────────── @@ -864,11 +1176,28 @@ export function client( handshakePromise = null; handshakeResolve = null; handshakeReject = null; + // Hello-waiters were never sent as calls — plain, retryable against + // a fresh client. rej(new RPCError("SESSION", "Session destroyed")); } - rejectPending(new RPCError("SESSION", "Session destroyed")); + // Split the pending map by the sent boundary: still-queued frames + // provably never left → plain; everything else was handed to the + // transport → outcome unknown → RPCAbortedError. + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + outbound.splice(i, 1); + if (e.kind === "msg") { + failQueued(e, new RPCError("SESSION", "Session destroyed")); + } + } + if (flushTimer !== null) { + clearInterval(flushTimer); + flushTimer = null; + } + rejectPending(new RPCAbortedError("SESSION", "Session destroyed")); } - return { api, abortPending, destroy }; + return { api, destroy }; } diff --git a/src/common.ts b/src/common.ts index da01fde..edf72bb 100644 --- a/src/common.ts +++ b/src/common.ts @@ -404,6 +404,19 @@ export class RPCError extends Error { } } +/** + * A local failure for a request that DID reach the wire — the outcome on + * the server is UNKNOWN: it may have executed, check before retrying. + * + * Invariant: the class says which side of the wire the request died on, + * `code` says what killed it. `RPCAbortedError` carries `TIMEOUT` (sent, + * no reply), `ABORTED` (signal fired after send) or `SESSION` (`destroy()` + * with the call in flight). The same codes on a plain local `RPCError` + * mean the request provably never left this process — safe to resend + * as-is. `RemoteRPCError` means the handler ran and returned an error. + */ +export class RPCAbortedError extends RPCError {} + // ─── Types ─────────────────────────────────────────────── export type Ctx = Record; @@ -471,14 +484,17 @@ export type RouterContext = UnionToIntersection< /** * The transport contract: move `Uint8Array` frames both ways. * - * Adapter lifecycle contract (for transports that can die, e.g. sockets): - * reopen the transport immediately when it closes and hold it open as long - * as possible; while it is down, `send` must not throw — queue frames that - * provably never left (flush them in order on reconnect) and drop transport - * errors inside the adapter. Never resend a frame that was written to a - * live transport — its outcome is unknown. A call's fate is then decided by - * exactly two events: a reply that decrypts, or the call's own timeout. - * Shipped reference implementation: `@dotex/saferpc/channels` (`wsChannel`). + * Adapter contract: `send` MUST throw synchronously (or reject, if it + * returns a promise) when it cannot hand the frame to a live transport + * right now — no internal queues, no buffering, no retry; a channel that + * accepts a frame it cannot deliver lies to the core about the sent + * boundary. A channel SHOULD try to stay available (reopen its transport + * eagerly when it dies, hold it open as long as possible) — availability + * is the channel's job, delivery bookkeeping is the core's. An async + * `send` must reject, never resolve-then-error: a resolved promise is + * counted as "frame left the process" and is never resent by any layer. + * Shipped reference implementations: `@dotex/saferpc/channels` + * (`wsChannel`, `socketChannel`). */ export interface Channel { send(data: Uint8Array): void | Promise; diff --git a/src/index.ts b/src/index.ts index 6aad80c..b3ee565 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export { EMPTY_SECRET, // Error RPCError, + RPCAbortedError, // Procedure builder + typed entry point saferpc, chain, diff --git a/src/server.ts b/src/server.ts index 250507f..8492911 100644 --- a/src/server.ts +++ b/src/server.ts @@ -79,7 +79,9 @@ export interface ServerOptionsBase { /** * Max time (ms) to complete a handshake AFTER a client hello arrives. * The server waits indefinitely for a client to connect — this timeout - * only governs the exchange once a hello is received. + * only governs the exchange once a hello is received. The budget covers + * the WHOLE attempt: the async auth callbacks (`verify`/`secret`/`sign`) + * and the wait for the first frame that decrypts under the candidate. * On timeout the unconfirmed candidate is dropped; the live session, * if any, keeps serving (make-before-break). Never destroys. * Default: 5000ms. @@ -97,8 +99,10 @@ export interface ServerOptionsBase { replayWindow?: number; /** * Called on handshake failures and non-fatal internal errors. - * The server does NOT destroy on handshake failure — it resets to - * waiting and accepts the next hello. Use this for logging/monitoring. + * The server does NOT destroy on handshake failure — a failed attempt + * simply installs no candidate; an established live session (if any) + * is untouched, and the next hello is accepted as usual. Use this for + * logging/monitoring. */ onError?: (err: unknown) => void; } @@ -454,6 +458,24 @@ export function server( attemptEpoch++; const myAttempt = attemptEpoch; + // The hsTimeout budget starts NOW, at hello receipt — not after the + // async auth callbacks. A slow/hung `auth.verify` / `auth.secret` / + // `auth.sign` must not stretch the attempt: on expiry the flag kills + // the attempt at its await guards (a pending await itself cannot be + // cancelled, but nothing past it can install a candidate or reply). + // Whatever budget validation leaves over goes to the candidate + // confirmation timer below, so hello → first-decrypted-frame is + // bounded by ONE hsTimeout total. + const attemptStart = Date.now(); + let attemptExpired = false; + const attemptTimer = setTimeout(function onAttemptTimeout() { + attemptExpired = true; + if (attemptEpoch !== myAttempt || destroyed) return; + if (onError !== null) { + onError(new RPCError("HANDSHAKE", "Handshake timeout")); + } + }, hsTimeout); + (async function handleHello() { // Attempt-local ephemeral pair — never published to module scope // until the final synchronous publish, so a failed attempt leaves @@ -522,7 +544,9 @@ export function server( nonce, ); const verifyResult = await auth.verify(helloAuth, transcript); - if (attemptEpoch !== myAttempt || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if (verifyResult && typeof verifyResult === "object") { const a = (verifyResult as { auth?: unknown }).auth; if (a !== undefined) { @@ -546,7 +570,9 @@ export function server( const secretBytes = auth.secret !== undefined ? await auth.secret() : EMPTY_SECRET; - if (attemptEpoch !== myAttempt || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if ( !(secretBytes instanceof Uint8Array) || @@ -573,8 +599,8 @@ export function server( // If `sign` is configured, sign over the canonical reply // transcript (which binds BOTH ephemeral pubs) so the client // can authenticate the server beyond what the secret alone provides. - // Computed BEFORE state transition so a failure here cleanly - // resets the handshake. + // Computed BEFORE the candidate install below, so a failure here + // installs nothing and leaves the live session untouched. if (auth.sign !== undefined) { const replyTranscript = buildReplyTranscript( clientEpoch, @@ -583,7 +609,9 @@ export function server( myPub, ); const signed = await auth.sign(replyTranscript); - if (attemptEpoch !== myAttempt || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if ( !(signed instanceof Uint8Array) || signed.length === 0 || @@ -600,7 +628,17 @@ export function server( // FINAL install guard. The block below is fully synchronous: it // installs the newly-proven session as a CANDIDATE without racing a // newer attempt or a request. - if (attemptEpoch !== myAttempt || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } + + // Validation finished within budget — the attempt timer's job is + // done; the candidate timer takes over with the REMAINING budget. + clearTimeout(attemptTimer); + const remainingBudget = Math.max( + 1, + hsTimeout - (Date.now() - attemptStart), + ); // Make-before-break: install as CANDIDATE, do NOT touch the live // session. The live key (if any) keeps serving until a frame @@ -628,7 +666,7 @@ export function server( if (onError !== null) { onError(new RPCError("HANDSHAKE", "Handshake timeout")); } - }, hsTimeout); + }, remainingBudget); const replyMsg: Record = { pub: myPub, @@ -650,6 +688,9 @@ export function server( // decrypts under the candidate to promote it. Total budget = // hsTimeout. } finally { + // Harmless double-clear on the success path; on every failure / + // stale-attempt path this stops a pending spurious timeout report. + clearTimeout(attemptTimer); if (rawShared !== null) zero(rawShared); if (localSessionKey !== null) zero(localSessionKey); if (localProof !== null) zero(localProof); diff --git a/test/e2e/channel-lifecycle.test.ts b/test/e2e/channel-lifecycle.test.ts index eec10f8..832262c 100644 --- a/test/e2e/channel-lifecycle.test.ts +++ b/test/e2e/channel-lifecycle.test.ts @@ -1,8 +1,8 @@ /** - * Channel lifecycle — 0.7.0: the session is bound to key material, not to a - * transport instance. Transport death alone must not destroy the session or - * reject calls; a call's outcome is decided by exactly two events — a reply - * that decrypts, or the call's own timeout. Plus per-call AbortSignal. + * Channel lifecycle v2 — the session is bound to key material, not to a + * transport instance. The core owns the outbound queue; channels implement + * the send-or-throw contract. RPCAbortedError vs plain RPCError reflects + * which side of the wire the request died on (spec §10, tests 1-9). */ import { describe, it, expect } from "vitest"; import { randomBytes } from "@noble/ciphers/utils.js"; @@ -10,6 +10,8 @@ import { chain, client, server, + RPCError, + RPCAbortedError, TAG_HELLO, type CallOptions, type Channel, @@ -30,14 +32,20 @@ function deferred(): { promise: Promise; resolve: (v: T) => void } { return { promise, resolve }; } -/** Wrap a channel to count outbound hello frames (handshake attempts). */ +/** + * Wrap a channel to count outbound hello frames that were successfully + * delivered (send() returned without throwing). Frames rejected by a down + * channel are not counted — they enter the core queue and may be retried. + */ function countingChannel(ch: Channel): { ch: Channel; hellos: () => number } { let n = 0; return { ch: { send(data) { + const result = ch.send(data) as void | Promise; + // Counted only when send did not throw (synchronous contract). if (data[0] === TAG_HELLO) n++; - return ch.send(data); + return result; }, receive: (cb) => ch.receive(cb), }, @@ -47,8 +55,9 @@ function countingChannel(ch: Channel): { ch: Channel; hellos: () => number } { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -describe("channel lifecycle / session survives transport death", () => { - it("a call issued while the channel is down completes after recovery, without a second handshake", async () => { +describe("channel lifecycle / core queue + send-or-throw contract", () => { + // ── Test 1: call issued while channel is down completes after recovery ── + it("a call issued while the channel is down completes after recovery, no re-handshake", async () => { const psk = randomBytes(32); const { a, b, link } = createFaultChannelPair(); const router: Router = { @@ -58,25 +67,28 @@ describe("channel lifecycle / session survives transport death", () => { const wrapped = countingChannel(a); const { api, destroy } = client(wrapped.ch, { auth: { secret: () => psk }, - timeout: 1000, + timeout: 2000, + sendTimeout: 500, }) as unknown as { api: LooseApi; destroy: () => void }; try { expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); link.goDown(); - const p = api.echo!("during-gap"); // frame queues at the sender - await sleep(50); - link.goUp(); + const p = api.echo!("during-gap"); // send throws → frame enters core queue + await sleep(30); + link.goUp(); // core retry tick re-sends within 250 ms expect(await p).toBe("during-gap"); - expect(wrapped.hellos()).toBe(1); // session survived, no re-handshake + expect(wrapped.hellos()).toBe(1); // session survived — no re-handshake } finally { destroy(); srv.destroy(); } }); - it("a reply produced while the channel is down is delivered after recovery", async () => { + // ── Test 2: pending call survives a gap that opens after send ── + it("a pending call survives a channel gap that opens after the frame is sent", async () => { const psk = randomBytes(32); const { a, b, link } = createFaultChannelPair(); const gate = deferred(); @@ -86,26 +98,27 @@ describe("channel lifecycle / session survives transport death", () => { const srv = server(router, b, { auth: { secret: () => psk } }); const { api, destroy } = client(a, { auth: { secret: () => psk }, - timeout: 1000, + timeout: 2000, }) as unknown as { api: LooseApi; destroy: () => void }; try { - const p = api.gated!(); // request delivered; handler awaiting the gate + const p = api.gated!(); // request delivered; handler awaiting gate + await sleep(30); + link.goDown(); // channel goes down after frame was sent await sleep(20); - link.goDown(); // reply will be produced into a dead link - gate.resolve("late"); - await sleep(50); - link.goUp(); // queued reply flushes + link.goUp(); // recover before handler resolves + gate.resolve("late"); // reply goes out on a live channel - expect(await p).toBe("late"); // reply-or-timeout rule, reply branch + expect(await p).toBe("late"); // reply-or-timeout, reply branch } finally { destroy(); srv.destroy(); } }); - it("a lost server session heals lazily: first call times out, second re-handshakes", async () => { + // ── Test 3: lazy heal on lost server session ── + it("a lost server session heals lazily: first call is RPCAbortedError(TIMEOUT), second re-handshakes", async () => { const psk = randomBytes(32); - const { a, b, link } = createFaultChannelPair(); + const { a, b } = createFaultChannelPair(); const router: Router = { echo: chain().handler(async ({ input }) => input), }; @@ -114,21 +127,21 @@ describe("channel lifecycle / session survives transport death", () => { const { api, destroy } = client(wrapped.ch, { auth: { secret: () => psk }, timeout: 300, + sendTimeout: 500, }) as unknown as { api: LooseApi; destroy: () => void }; try { expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); - link.goDown(); - srv.destroy(); // peer restarted: server session is gone + srv.destroy(); // server session gone srv = server(router, b, { auth: { secret: () => psk } }); - link.goUp(); - // Kept client session is dead weight now: the frame doesn't decrypt, - // the server stays silent, the call times out and resets the session. - await expect(api.echo!("stale")).rejects.toMatchObject({ - code: "TIMEOUT", - }); - // Next call lazily re-handshakes and succeeds. + // Frame is SENT but the new server cannot decrypt → no reply → TIMEOUT + const err = await api.echo!("stale").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCAbortedError); + expect((err as RPCAbortedError).code).toBe("TIMEOUT"); + + // Next call lazily re-handshakes and succeeds expect(await api.echo!("fresh")).toBe("fresh"); expect(wrapped.hellos()).toBe(2); } finally { @@ -137,7 +150,8 @@ describe("channel lifecycle / session survives transport death", () => { } }); - it("abortPending rejects pending calls but keeps the session", async () => { + // ── Test 4: sendTimeout → definite failure, session NOT reset ── + it("sendTimeout: definite failure is plain RPCError(CHANNEL), session not reset", async () => { const psk = randomBytes(32); const { a, b, link } = createFaultChannelPair(); const router: Router = { @@ -145,39 +159,36 @@ describe("channel lifecycle / session survives transport death", () => { }; const srv = server(router, b, { auth: { secret: () => psk } }); const wrapped = countingChannel(a); - const { api, abortPending, destroy } = client(wrapped.ch, { + const { api, destroy } = client(wrapped.ch, { auth: { secret: () => psk }, - timeout: 5000, - }) as unknown as { - api: LooseApi; - abortPending: () => void; - destroy: () => void; - }; + timeout: 2000, + sendTimeout: 150, // expires before global timeout + }) as unknown as { api: LooseApi; destroy: () => void }; try { expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + // Frame queues, retries until sendTimeout, expires → definite CHANNEL link.goDown(); - const p1 = api.echo!("one"); - const p2 = api.echo!("two"); - await sleep(20); // let both calls register as pending - abortPending(); - await expect(p1).rejects.toMatchObject({ code: "ABORTED" }); - await expect(p2).rejects.toMatchObject({ code: "ABORTED" }); - - link.goUp(); // stale queued requests flush; replies find no pending - expect(await api.echo!("three")).toBe("three"); - expect(wrapped.hellos()).toBe(1); // keys were never zeroed + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); // still only the original hello } finally { destroy(); srv.destroy(); } }); -}); -describe("channel lifecycle / per-call AbortSignal", () => { - it("a pre-aborted signal rejects immediately and triggers nothing", async () => { + // ── Test 4b: global timeout beats sendTimeout → still definite CHANNEL ── + it("global timeout on a still-queued frame is plain RPCError(CHANNEL), not the aborted class", async () => { const psk = randomBytes(32); - const { a, b } = createFaultChannelPair(); + const { a, b, link } = createFaultChannelPair(); const router: Router = { echo: chain().handler(async ({ input }) => input), }; @@ -185,154 +196,311 @@ describe("channel lifecycle / per-call AbortSignal", () => { const wrapped = countingChannel(a); const { api, destroy } = client(wrapped.ch, { auth: { secret: () => psk }, + timeout: 150, // fires while the frame is still queued + sendTimeout: 5000, // deliberately above timeout — misconfig-proof path }) as unknown as { api: LooseApi; destroy: () => void }; try { - const ac = new AbortController(); - ac.abort(); - await expect(api.echo!("x", { signal: ac.signal })).rejects.toMatchObject( - { code: "ABORTED" }, - ); - expect(wrapped.hellos()).toBe(0); // no handshake was even started + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Frame queues; the global timer expires first. The frame provably + // never left → same definite CHANNEL code as sendTimeout expiry, + // and plain class — TIMEOUT is reserved for sent-no-reply. + link.goDown(); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); } finally { destroy(); srv.destroy(); } }); - it("abort mid-flight rejects with ABORTED, drops the late reply, and never resets the session", async () => { + // ── Test 5: class split on abort ── + it("abort while frame queued → plain RPCError(ABORTED); abort after send → RPCAbortedError(ABORTED)", async () => { const psk = randomBytes(32); - const { a, b } = createFaultChannelPair(); + const { a, b, link } = createFaultChannelPair(); const gate = deferred(); const router: Router = { - gated: chain().handler(async () => gate.promise), echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), }; const srv = server(router, b, { auth: { secret: () => psk } }); - const wrapped = countingChannel(a); - const { api, destroy } = client(wrapped.ch, { + const { api, destroy } = client(a, { auth: { secret: () => psk }, timeout: 5000, + sendTimeout: 500, }) as unknown as { api: LooseApi; destroy: () => void }; try { expect(await api.echo!("warm")).toBe("warm"); - const ac = new AbortController(); - const p = api.gated!(undefined, { signal: ac.signal }); + // (a) abort while frame is still queued → plain RPCError("ABORTED") + link.goDown(); + const ac1 = new AbortController(); + const pA = api.echo!("queued", { signal: ac1.signal }); await sleep(20); - ac.abort(); - const err = (await p.then( - () => null, - (e: unknown) => e, - )) as { code: string; cause?: unknown }; - expect(err).not.toBeNull(); - expect(err.code).toBe("ABORTED"); - expect(err.cause).toBeDefined(); // signal.reason travels on .cause + ac1.abort("cancelled"); + const errA = (await pA.catch((e: unknown) => e)) as RPCError; + expect(errA).toBeInstanceOf(RPCError); + expect(errA).not.toBeInstanceOf(RPCAbortedError); + expect(errA.code).toBe("ABORTED"); + link.goUp(); + await sleep(10); + + // (b) abort after send → RPCAbortedError("ABORTED") + cause + const ac2 = new AbortController(); + const pB = api.gated!(undefined, { signal: ac2.signal }); + await sleep(30); // frame sent; handler is running on server + ac2.abort("user-cancelled"); + const errB = (await pB.catch((e: unknown) => e)) as RPCAbortedError; + expect(errB).toBeInstanceOf(RPCAbortedError); + expect(errB.code).toBe("ABORTED"); + expect(errB.cause).toBeDefined(); // signal.reason travels on .cause gate.resolve("late"); // late reply → no pending entry → silent drop await sleep(20); - // Session untouched: no reset predicate fired on ABORTED. + // Session untouched — no reset on ABORTED expect(await api.echo!("after")).toBe("after"); - expect(wrapped.hellos()).toBe(1); } finally { destroy(); srv.destroy(); } }); - it("aborting one call during a shared handshake leaves the handshake for others", async () => { + // ── Test 6: class split on destroy ── + it("destroy: sent call → RPCAbortedError(SESSION); queued call → plain RPCError(SESSION)", async () => { const psk = randomBytes(32); const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); const router: Router = { echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), }; const srv = server(router, b, { auth: { secret: () => psk } }); - const wrapped = countingChannel(a); - const { api, destroy } = client(wrapped.ch, { + let destroyed = false; + const { api, destroy } = client(a, { auth: { secret: () => psk }, - timeout: 2000, + timeout: 5000, + sendTimeout: 500, }) as unknown as { api: LooseApi; destroy: () => void }; try { - link.goDown(); // hello will queue → handshake stays in progress - const ac = new AbortController(); - const p1 = api.echo!("one", { signal: ac.signal }); - const p2 = api.echo!("two"); // joins the same handshake - await sleep(20); - ac.abort(); - await expect(p1).rejects.toMatchObject({ code: "ABORTED" }); + expect(await api.echo!("warm")).toBe("warm"); + + // Call 1: frame sent (gated handler keeps reply pending) + const p1 = api.gated!(); + await sleep(30); // frame is in-flight on server + + // Call 2: channel down → frame queues in core + link.goDown(); + const p2 = api.echo!("queued"); + await sleep(10); - link.goUp(); // hello flushes; the shared handshake completes - expect(await p2).toBe("two"); - expect(wrapped.hellos()).toBe(1); - } finally { destroy(); + destroyed = true; + + const [e1, e2] = await Promise.all([ + p1.catch((e: unknown) => e), + p2.catch((e: unknown) => e), + ]); + + // Sent call → outcome unknown → RPCAbortedError + expect(e1).toBeInstanceOf(RPCAbortedError); + expect((e1 as RPCAbortedError).code).toBe("SESSION"); + + // Queued call → provably never left → plain RPCError + expect(e2).toBeInstanceOf(RPCError); + expect(e2).not.toBeInstanceOf(RPCAbortedError); + expect((e2 as RPCError).code).toBe("SESSION"); + } finally { + if (!destroyed) destroy(); srv.destroy(); + gate.resolve("done"); } }); - it("AbortSignal.timeout gives one call a shorter budget than the client default", async () => { + // ── Test 7: reset predicate regression ── + it("reset predicate: RPCAbortedError(TIMEOUT) resets; plain RPCError(CHANNEL) does not", async () => { const psk = randomBytes(32); - const { a, b } = createFaultChannelPair(); - const gate = deferred(); const router: Router = { - gated: chain().handler(async () => gate.promise), echo: chain().handler(async ({ input }) => input), }; - const srv = server(router, b, { auth: { secret: () => psk } }); - const wrapped = countingChannel(a); - const { api, destroy } = client(wrapped.ch, { + + // (a) Reply-timeout on a SENT request → next call re-handshakes + { + const { a, b } = createFaultChannelPair(); + let srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 300, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Server gone → frame sent but no reply → RPCAbortedError(TIMEOUT) → reset + srv.destroy(); + srv = server(router, b, { auth: { secret: () => psk } }); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCAbortedError); + expect((err as RPCAbortedError).code).toBe("TIMEOUT"); + + // Reset happened: next call re-handshakes + expect(await api.echo!("fresh")).toBe("fresh"); + expect(wrapped.hellos()).toBe(2); + } finally { + destroy(); + srv.destroy(); + } + } + + // (b) Plain CHANNEL (frame never sent) → next call does NOT re-handshake + { + const { a, b, link } = createFaultChannelPair(); + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 150, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Channel down → frame queues → sendTimeout → plain RPCError(CHANNEL) → no reset + link.goDown(); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + } + }); + + // ── Test 8: stale hello is revoked after handshakeTimeout ── + it("stale hello is revoked after handshakeTimeout: goUp does not flush the dead hello", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + + // Count hellos that actually arrive at the server + let serverHellos = 0; + const serverB: Channel = { + send: (data) => b.send(data), + receive(cb) { + return b.receive((data) => { + if (data[0] === TAG_HELLO) serverHellos++; + cb(data); + }); + }, + }; + + const srv = server(router, serverB, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { auth: { secret: () => psk }, - timeout: 60_000, // client default deliberately huge + timeout: 2000, + sendTimeout: 500, + handshakeTimeout: 100, // minimum allowed; fires quickly }) as unknown as { api: LooseApi; destroy: () => void }; try { - expect(await api.echo!("warm")).toBe("warm"); + // Channel down before any call — hello queues in core instead of sending. + // Attach .catch() immediately so the rejection (at handshakeTimeout) is + // never unhandled during the sleep below. + link.goDown(); + const p = api.echo!("x").catch(() => {}); // triggers handshake; hello enters core queue - // The blessed per-call pattern: shorter budget via the platform's - // AbortSignal.timeout — rejects with ABORTED, session untouched. - const started = Date.now(); - await expect( - api.gated!(undefined, { signal: AbortSignal.timeout(100) }), - ).rejects.toMatchObject({ code: "ABORTED" }); - expect(Date.now() - started).toBeLessThan(2000); + // Wait beyond handshakeTimeout; the attempt is abandoned and epoch advances + await sleep(150); - gate.resolve("late"); // late reply → silently dropped - expect(await api.echo!("after")).toBe("after"); - expect(wrapped.hellos()).toBe(1); // no reset, no re-handshake + link.goUp(); // core retry tick fires; dropStaleAndExpired revokes the stale hello + await sleep(300); // one full retry tick (250 ms) plus margin + + // The dead hello must NOT have reached the server + expect(serverHellos).toBe(0); + await p; // catch already ran; just drain the promise + + // Fresh call: new handshake succeeds with a clean hello + expect(await api.echo!("fresh")).toBe("fresh"); + expect(serverHellos).toBe(1); // only the fresh hello } finally { destroy(); srv.destroy(); } }); - it("abort listeners are removed once the call settles", async () => { + // ── Test 9: shared-controller idiom replaces abortPending ── + it("shared AbortController aborts two calls (class per sent status); third call succeeds", async () => { const psk = randomBytes(32); - const { a, b } = createFaultChannelPair(); + const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); const router: Router = { echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), }; const srv = server(router, b, { auth: { secret: () => psk } }); - const { api, destroy } = client(a, { + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 500, }) as unknown as { api: LooseApi; destroy: () => void }; try { - // Stub signal that counts registered listeners — a long-lived signal - // reused across calls must not accumulate closures. - const listeners = new Set(); - const stub = { - aborted: false, - reason: undefined, - addEventListener(_t: string, cb: unknown) { - listeners.add(cb); - }, - removeEventListener(_t: string, cb: unknown) { - listeners.delete(cb); - }, - } as unknown as AbortSignal; - - expect(await api.echo!("a", { signal: stub })).toBe("a"); - expect(listeners.size).toBe(0); - expect(await api.echo!("b", { signal: stub })).toBe("b"); - expect(listeners.size).toBe(0); + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + const ctl = new AbortController(); + + // Call 1: gated → frame sent, awaiting reply + const p1 = api.gated!(undefined, { signal: ctl.signal }); + await sleep(30); // frame reaches server + + // Call 2: channel down → frame queues in core + link.goDown(); + const p2 = api.echo!("two", { signal: ctl.signal }); + await sleep(20); + + ctl.abort("bulk-cancel"); + + const [e1, e2] = await Promise.all([ + p1.catch((e: unknown) => e), + p2.catch((e: unknown) => e), + ]); + + // Call 1 was sent → RPCAbortedError("ABORTED") + expect(e1).toBeInstanceOf(RPCAbortedError); + expect((e1 as RPCAbortedError).code).toBe("ABORTED"); + + // Call 2 was queued → plain RPCError("ABORTED") + expect(e2).toBeInstanceOf(RPCError); + expect(e2).not.toBeInstanceOf(RPCAbortedError); + expect((e2 as RPCError).code).toBe("ABORTED"); + + gate.resolve("late"); // late reply silently dropped + link.goUp(); + await sleep(20); + + // Session untouched: third call succeeds without re-handshake + expect(await api.echo!("three")).toBe("three"); + expect(wrapped.hellos()).toBe(1); } finally { destroy(); srv.destroy(); diff --git a/test/e2e/reset-rollback.test.ts b/test/e2e/reset-rollback.test.ts new file mode 100644 index 0000000..abc2872 --- /dev/null +++ b/test/e2e/reset-rollback.test.ts @@ -0,0 +1,211 @@ +/** + * Optimistic-sent rollback × reset() interaction. + * + * An async Channel.send counts as "sent" optimistically and is rolled back + * to the outbound queue on rejection. If a reset() ran while the send + * promise was in flight, the rollback must NOT resurrect the frame: its + * ciphertext is under the zeroed key and can never succeed. The rejection + * proves the frame never left, so the call must fail immediately with a + * plain RPCError("CHANNEL") — not retry dead ciphertext, and never smuggle + * old-key bytes past the epoch staleness check. + * + * Covers both rollback paths: + * - direct-send path (sendRequest's onAsyncSendFail) + * - flush path (flushOutbound's onFlushFail) + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + RPCAbortedError, + type CallOptions, + type Channel, + type Router, +} from "../../src/index.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface Captured { + data: Uint8Array; + resolve: () => void; + reject: (e: unknown) => void; +} + +/** + * In-memory pair whose client→server side can switch behavior: + * - "sync" — deliver immediately (send returns undefined) + * - "throw" — throw synchronously (channel-down contract) + * - "capture" — return a pending Promise the test settles manually; + * the frame is NOT delivered (in-flight async send) + * The server→client side always delivers synchronously. + */ +function controllableChannelPair(): { + a: Channel; + b: Channel; + ctl: { + setMode: (m: "sync" | "throw" | "capture") => void; + captured: Captured[]; + sends: () => number; + }; +} { + let aCb: ((data: Uint8Array) => void) | null = null; + let bCb: ((data: Uint8Array) => void) | null = null; + let mode: "sync" | "throw" | "capture" = "sync"; + const captured: Captured[] = []; + let sends = 0; + + const a: Channel = { + send(data) { + sends++; + if (mode === "throw") throw new Error("channel down"); + if (mode === "capture") { + return new Promise((resolve, reject) => { + captured.push({ data: data.slice(), resolve, reject }); + }); + } + if (bCb) bCb(data); + }, + receive(cb) { + aCb = cb; + return () => { + aCb = null; + }; + }, + }; + const b: Channel = { + send(data) { + if (aCb) aCb(data); + }, + receive(cb) { + bCb = cb; + return () => { + bCb = null; + }; + }, + }; + + return { + a, + b, + ctl: { + setMode: (m) => { + mode = m; + }, + captured, + sends: () => sends, + }, + }; +} + +function makeRouter(): Router { + return { + echo: chain().handler(async ({ input }) => input), + // Never resolves — used to drive a reply-timeout → auto-reset. + gated: chain().handler(async () => new Promise(() => {})), + }; +} + +describe("reset() vs optimistic-sent rollback", () => { + it("direct async send rejected after reset fails plain CHANNEL immediately, frame is not retried", async () => { + const psk = randomBytes(32); + const { a, b, ctl } = controllableChannelPair(); + const srv = server(makeRouter(), b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 300, + sendTimeout: 5000, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + // Establish the session. + expect(await api["echo"]!("warm")).toBe("warm"); + + // Call A: delivered, server never replies → reply-timeout → reset. + const pA = api["gated"]!(); + await sleep(10); // let A's frame leave on the sync channel + + // Call B: direct async send, promise held pending by the test. + ctl.setMode("capture"); + const pB = api["echo"]!("b"); + + // A times out → RPCAbortedError(TIMEOUT) → auto-reset fires. + const errA = await pA.catch((e: unknown) => e); + expect(errA).toBeInstanceOf(RPCAbortedError); + expect((errA as RPCError).code).toBe("TIMEOUT"); + + // B's in-flight send now rejects — AFTER the reset. + expect(ctl.captured.length).toBe(1); + const sendsBefore = ctl.sends(); + ctl.captured[0]!.reject(new Error("socket died")); + + // B fails NOW with the definite never-left error: plain RPCError, + // not the aborted class, not a late TIMEOUT. + const errB = await pB.catch((e: unknown) => e); + expect(errB).toBeInstanceOf(RPCError); + expect(errB).not.toBeInstanceOf(RPCAbortedError); + expect((errB as RPCError).code).toBe("CHANNEL"); + expect((errB as RPCError).message).toContain("Session reset before send"); + + // The dead frame must not be resurrected: no retry ticks re-send it. + await sleep(600); + expect(ctl.sends()).toBe(sendsBefore); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("queued frame whose flush-send rejects after reset fails plain CHANNEL, not retried under the dead session", async () => { + const psk = randomBytes(32); + const { a, b, ctl } = controllableChannelPair(); + const srv = server(makeRouter(), b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 800, + sendTimeout: 5000, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api["echo"]!("warm")).toBe("warm"); + + // Call A: delivered, will reply-timeout at ~800 ms. + const pA = api["gated"]!(); + await sleep(10); // let A's frame leave on the sync channel + + // Call B: sync send throws → frame enters the core outbound queue. + ctl.setMode("throw"); + const pB = api["echo"]!("b"); + + // Flush tick (≤250 ms) picks B up as an in-flight async send. + ctl.setMode("capture"); + await sleep(400); + expect(ctl.captured.length).toBe(1); // B left the queue, promise pending + + // A times out → auto-reset. B is in flight: reset can't see it. + const errA = await pA.catch((e: unknown) => e); + expect(errA).toBeInstanceOf(RPCAbortedError); + expect((errA as RPCError).code).toBe("TIMEOUT"); + + const sendsBefore = ctl.sends(); + ctl.captured[0]!.reject(new Error("socket died")); + + const errB = await pB.catch((e: unknown) => e); + expect(errB).toBeInstanceOf(RPCError); + expect(errB).not.toBeInstanceOf(RPCAbortedError); + expect((errB as RPCError).code).toBe("CHANNEL"); + expect((errB as RPCError).message).toContain("Session reset before send"); + + await sleep(600); + expect(ctl.sends()).toBe(sendsBefore); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/e2e/transport-lifecycle.test.ts b/test/e2e/transport-lifecycle.test.ts index ea6b300..2774c68 100644 --- a/test/e2e/transport-lifecycle.test.ts +++ b/test/e2e/transport-lifecycle.test.ts @@ -1,12 +1,10 @@ /** - * Transport lifecycle — 0.7.0 retry semantics (F1 / Option A) + abortPending. + * Transport lifecycle — 0.7.0 retry semantics (F1 / Option A). * - * The client no longer auto-retries. A lost reply (TIMEOUT, outcome unknown) - * or a send failure (CHANNEL, request never left) surfaces to the caller with - * a typed code and resets the session — but is NOT resent. The caller, the - * only party that knows whether a procedure is idempotent, decides. This kills - * the silent double-execution hazard the old auto-retry created for - * fund-moving handlers. + * The client no longer auto-retries. A lost reply (TIMEOUT, outcome + * unknown) surfaces as RPCAbortedError and resets the session. A send + * failure (CHANNEL, request never left) surfaces as plain RPCError and + * does NOT reset the session. The caller decides whether to retry. */ import { describe, it, expect } from "vitest"; import { randomBytes } from "@noble/ciphers/utils.js"; @@ -15,6 +13,7 @@ import { client, server, RPCError, + RPCAbortedError, type Channel, type Router, } from "../../src/index.ts"; @@ -24,7 +23,7 @@ import { } from "../helpers/channels.ts"; describe("transport lifecycle / retry semantics (F1 Option A)", () => { - it("a lost reply surfaces TIMEOUT and the handler runs exactly once (no auto-resend)", async () => { + it("a lost reply surfaces RPCAbortedError(TIMEOUT) and the handler runs exactly once (no auto-resend)", async () => { const psk = randomBytes(32); const { a, b, mitm } = createMitmChannelPair(); let execCount = 0; @@ -57,7 +56,9 @@ describe("transport lifecycle / retry semantics (F1 Option A)", () => { return d; }); - await expect(api.ping({})).rejects.toMatchObject({ code: "TIMEOUT" }); + const timeoutErr = await api.ping({}).catch((e: unknown) => e); + expect(timeoutErr).toBeInstanceOf(RPCAbortedError); + expect((timeoutErr as RPCAbortedError).code).toBe("TIMEOUT"); // Handler executed once for the lost-reply call; there was NO resend. expect(execCount).toBe(2); } finally { @@ -66,7 +67,7 @@ describe("transport lifecycle / retry semantics (F1 Option A)", () => { } }); - it("a send failure surfaces CHANNEL and the request never executes", async () => { + it("a send failure queues and retries until sendTimeout, then surfaces plain RPCError(CHANNEL); session not reset", async () => { const psk = randomBytes(32); const { a, b } = createChannelPair(); let execCount = 0; @@ -80,25 +81,41 @@ describe("transport lifecycle / retry semantics (F1 Option A)", () => { // Wrap the client channel so send() throws for TAG_MSG once armed. let failSend = false; + let hellosSent = 0; const bFail: Channel = { send(data) { if (failSend && data[0] === 0x01) throw new Error("socket dead"); - return b.send(data); + const r = b.send(data); + // Count hellos only on successful delivery (never fails for 0x00) + if (data[0] === 0x00) hellosSent++; + return r; }, receive: (cb) => b.receive(cb), }; const { api, destroy } = client(bFail, { auth: { secret: () => psk }, - timeout: 500, + timeout: 2000, + sendTimeout: 150, // frame expires before global timeout }); try { expect(await api.ping({})).toBe("pong"); expect(execCount).toBe(1); + expect(hellosSent).toBe(1); + // With failSend=true, the frame is re-queued and retried by the core + // until sendTimeout (150 ms) expires → definite plain RPCError(CHANNEL). failSend = true; - await expect(api.ping({})).rejects.toMatchObject({ code: "CHANNEL" }); + const chanErr = await api.ping({}).catch((e: unknown) => e); + expect(chanErr).toBeInstanceOf(RPCError); + expect(chanErr).not.toBeInstanceOf(RPCAbortedError); + expect((chanErr as RPCError).code).toBe("CHANNEL"); // Request provably never left — handler count unchanged. expect(execCount).toBe(1); + + // Session NOT reset on CHANNEL: next call succeeds without re-handshake. + failSend = false; + expect(await api.ping({})).toBe("pong"); + expect(hellosSent).toBe(1); // still only the original hello } finally { destroy(); srv.destroy(); @@ -134,71 +151,3 @@ describe("transport lifecycle / retry semantics (F1 Option A)", () => { } }); }); - -describe("transport lifecycle / abortPending (F3, 0.7.0 semantics)", () => { - it("rejects in-flight calls with ABORTED and the session survives", async () => { - const psk = randomBytes(32); - const { a, b, mitm } = createMitmChannelPair(); - let execCount = 0; - const router: Router = { - slow: chain().handler(async () => { - execCount++; - return new Promise((r) => setTimeout(() => r("done"), 400)); - }), - }; - const srv = server(router, a, { auth: { secret: () => psk } }); - const { api, abortPending, destroy } = client(b, { - auth: { secret: () => psk }, - timeout: 5000, - }); - try { - const p1 = api.slow({}); - // Let the handshake + request go out. - await new Promise((r) => setTimeout(r, 60)); - abortPending(); - await expect(p1).rejects.toMatchObject({ code: "ABORTED" }); - - // The session SURVIVED: the next call succeeds without a second - // handshake (exactly one hello on the wire). - expect(await api.slow({})).toBe("done"); - const helloCount = mitm.state.captures.filter( - (c) => c.dir === "BtoA" && c.data[0] === 0x00, - ).length; - expect(helloCount).toBe(1); - expect(execCount).toBe(2); - } finally { - destroy(); - srv.destroy(); - } - }); - - it("uses a caller-supplied error and fails an in-progress handshake", async () => { - const psk = randomBytes(32); - // Black-hole server side: never answers, so the client stays handshaking. - const b: Channel = { send() {}, receive: () => () => {} }; - const { api, abortPending, destroy } = client(b, { - auth: { secret: () => psk }, - timeout: 5000, - handshakeTimeout: 5000, - }); - try { - const p = api.anything({}); - await new Promise((r) => setTimeout(r, 30)); - // A hello-waiter must not hang to handshakeTimeout. - abortPending(new RPCError("CHANNEL_DOWN", "dead")); - await expect(p).rejects.toMatchObject({ code: "CHANNEL_DOWN" }); - } finally { - destroy(); - } - }); - - it("is a no-op after destroy()", () => { - const psk = randomBytes(32); - const b: Channel = { send() {}, receive: () => () => {} }; - const { abortPending, destroy } = client(b, { - auth: { secret: () => psk }, - }); - destroy(); - expect(() => abortPending()).not.toThrow(); - }); -}); diff --git a/test/e2e/ws-channel.test.ts b/test/e2e/ws-channel.test.ts index f18022d..a485567 100644 --- a/test/e2e/ws-channel.test.ts +++ b/test/e2e/ws-channel.test.ts @@ -1,10 +1,9 @@ /** * E2E for the shipped reconnecting WebSocket adapter (src/channels/ws.ts) - * over a real socket on 127.0.0.1. Validates the adapter lifecycle contract: - * eager reconnect, queue-while-down with drop-oldest overflow, terminal - * close(). The server side mirrors a long-lived session binding (the same - * `server()` instance re-attached to each incoming connection), which is the - * wiring where a kept client session pays off. + * over a real socket on 127.0.0.1. Validates the v2 adapter contract: + * send() throws while the transport is down and after close(); eager + * reconnect surfaces via onDown/onUp; the core outbound queue re-sends + * during the gap so a call issued while disconnected still resolves. */ import { describe, it, expect } from "vitest"; import { randomBytes } from "@noble/ciphers/utils.js"; @@ -86,7 +85,71 @@ function startBridgedServer( } describe("ws channel / reconnecting adapter", () => { - it("reconnects after socket death and a call issued during the gap resolves without re-handshake", async () => { + // ── Test 10: contract ──────────────────────────────────────────────────── + + it("contract: send throws while down and after close()", async () => { + const port = pickPort(); + let ups = 0; + let downs = 0; + + // A blockable factory lets the test control when reconnects can land. + // When blockConnect is true the factory throws, which the adapter treats + // as a failed attempt and retries with backoff — keeping sock===null and + // up===false for the duration of the block. + let blockConnect = false; + const wss = new WebSocketServer({ host: "127.0.0.1", port }); + const ch = wsChannel( + () => { + if (blockConnect) throw new Error("connect blocked by test"); + return new WsClient( + `ws://127.0.0.1:${port}`, + ) as unknown as WebSocketLike; + }, + { + backoffMin: 20, + backoffMax: 200, + onDown: () => downs++, + onUp: () => ups++, + }, + ); + try { + await waitFor(() => ups === 1); + + // Block reconnects BEFORE killing the socket so the immediate retry + // (delay=0) hits a factory throw and the adapter stays reliably down. + blockConnect = true; + for (const s of wss.clients) s.terminate(); + await waitFor(() => downs === 1); + + // Factory throws on every retry → sock===null, up===false. + // The adapter MUST throw synchronously when it cannot hand the frame + // to a live transport now. + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: no open socket", + ); + + // Unblock: adapter reconnects on its own backoff schedule. + blockConnect = false; + await waitFor(() => ups === 2); + + // close() is terminal — throws forever regardless of socket state. + ch.close(); + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: channel closed", + ); + await sleep(50); + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: channel closed", + ); + } finally { + ch.close(); // idempotent if already closed + await new Promise((resolve) => wss.close(() => resolve())); + } + }); + + // ── Test 11: reconnect + core retry end-to-end ─────────────────────────── + + it("call issued during a gap resolves after the adapter reconnects; TAG_HELLO == 1", async () => { const psk = randomBytes(32); const port = pickPort(); const router: Router = { @@ -105,12 +168,14 @@ describe("ws channel / reconnecting adapter", () => { onUp: () => ups++, }, ); - // Count outbound hellos to prove the session survived the reconnect. + // Count hellos that actually left the adapter (count after a successful + // ch.send, not before — a throw means the frame never left). let hellos = 0; const counting: Channel = { send(d) { - if (d[0] === TAG_HELLO) hellos++; - return ch.send(d); + const result = ch.send(d); // throws if socket not open + if (d[0] === TAG_HELLO) hellos++; // only reached on success + return result; }, receive: (cb) => ch.receive(cb), }; @@ -128,11 +193,14 @@ describe("ws channel / reconnecting adapter", () => { srv.currentSock()!.terminate(); await waitFor(() => downs === 1); - const p = api.echo!("during-gap"); // queues; flushes on reconnect + // Call during gap (or right after the fast reconnect — either way the + // call resolves and the session is continuous: no second hello). + const p = api.echo!("during-gap"); expect(await p).toBe("during-gap"); + expect(downs).toBe(1); expect(ups).toBe(2); - expect(hellos).toBe(1); // same session across sockets + expect(hellos).toBe(1); // same session — no re-handshake } finally { destroy(); ch.close(); @@ -140,58 +208,9 @@ describe("ws channel / reconnecting adapter", () => { } }); - it("queue overflow drops the oldest frame", async () => { - const port = pickPort(); - const received: number[] = []; - let wss = new WebSocketServer({ host: "127.0.0.1", port }); - const record = (sock: import("ws").WebSocket): void => { - sock.on("message", (data) => { - received.push(toU8(data)[0] as number); - }); - }; - wss.on("connection", record); - - let downs = 0; - let ups = 0; - const ch = wsChannel( - () => new WsClient(`ws://127.0.0.1:${port}`) as unknown as WebSocketLike, - { - maxQueue: 2, - backoffMin: 20, - backoffMax: 100, - onDown: () => downs++, - onUp: () => ups++, - }, - ); - try { - await waitFor(() => ups === 1); - - // Take the listener fully away so the reconnect loop cannot succeed - // while we overflow the queue. - await new Promise((resolve) => { - for (const sock of wss.clients) sock.terminate(); - wss.close(() => resolve()); - }); - await waitFor(() => downs === 1); - - ch.send(new Uint8Array([1])); - ch.send(new Uint8Array([2])); - ch.send(new Uint8Array([3])); // overflow: frame [1] is dropped - - wss = new WebSocketServer({ host: "127.0.0.1", port }); - wss.on("connection", record); - await waitFor(() => received.length >= 2); - await sleep(50); // give a hypothetical third frame time to arrive - - expect(received).toEqual([2, 3]); - expect(ups).toBe(2); - } finally { - ch.close(); - await new Promise((resolve) => wss.close(() => resolve())); - } - }); + // ── Updated: close() is terminal ───────────────────────────────────────── - it("close() is terminal: send throws and the client surfaces CHANNEL", async () => { + it("close() is terminal: after close() the core cannot send and surfaces CHANNEL", async () => { const psk = randomBytes(32); const port = pickPort(); const router: Router = { @@ -203,9 +222,12 @@ describe("ws channel / reconnecting adapter", () => { () => new WsClient(`ws://127.0.0.1:${port}`) as unknown as WebSocketLike, { onUp: () => ups++ }, ); + // Small sendTimeout so the CHANNEL error arrives within one flush tick + // (~250 ms) rather than waiting the 10 s default. const { api, destroy } = client(ch, { auth: { secret: () => psk }, - timeout: 1000, + timeout: 2000, + sendTimeout: 100, }) as unknown as { api: Record Promise>; destroy: () => void; @@ -215,11 +237,13 @@ describe("ws channel / reconnecting adapter", () => { expect(await api.echo!("warm")).toBe("warm"); ch.close(); + // Adapter throws synchronously. The core queues the frame, retries for + // sendTimeout (100 ms), then surfaces plain RPCError("CHANNEL"). expect(() => ch.send(new Uint8Array([9]))).toThrow(); - // Through the client, the sync throw surfaces as a typed CHANNEL error. await expect(api.echo!("x")).rejects.toMatchObject({ code: "CHANNEL" }); } finally { destroy(); + ch.close(); // idempotent await srv.stop(); } }); diff --git a/test/helpers/channels.ts b/test/helpers/channels.ts index a6f8201..dcce29c 100644 --- a/test/helpers/channels.ts +++ b/test/helpers/channels.ts @@ -179,12 +179,12 @@ export interface FaultLink { } /** - * In-memory pair whose link can be taken down and brought back. Models a - * transport adapter that follows the lifecycle contract (common.ts - * `Channel` jsdoc): while the link is down, `send` never throws — frames - * queue at the sender (they provably never left) and flush in order on - * `goUp()`. Frames delivered while up are synchronous, like - * `createChannelPair`. + * In-memory pair whose link can be taken down and brought back. Implements + * the v2 Channel contract (common.ts `Channel` jsdoc): while the link is + * down, `send` THROWS synchronously — no internal queuing, no buffering. + * The core outbound queue is the sole owner of undelivered frames. On + * `goUp()` the channel becomes available again; the core retry tick will + * re-send any queued frames within its next tick (≤ 250 ms). */ export function createFaultChannelPair(): { a: Channel; @@ -194,15 +194,10 @@ export function createFaultChannelPair(): { let aCb: ((data: Uint8Array) => void) | null = null; let bCb: ((data: Uint8Array) => void) | null = null; let up = true; - const fromA: Uint8Array[] = []; - const fromB: Uint8Array[] = []; const a: Channel = { send(data) { - if (!up) { - fromA.push(data.slice()); - return; - } + if (!up) throw new Error("channel down"); if (bCb) bCb(data); }, receive(cb) { @@ -214,10 +209,7 @@ export function createFaultChannelPair(): { }; const b: Channel = { send(data) { - if (!up) { - fromB.push(data.slice()); - return; - } + if (!up) throw new Error("channel down"); if (aCb) aCb(data); }, receive(cb) { @@ -233,16 +225,7 @@ export function createFaultChannelPair(): { up = false; }, goUp() { - if (up) return; up = true; - while (fromA.length > 0) { - const frame = fromA.shift() as Uint8Array; - if (bCb) bCb(frame); - } - while (fromB.length > 0) { - const frame = fromB.shift() as Uint8Array; - if (aCb) aCb(frame); - } }, isUp: () => up, }; diff --git a/test/security/hung-auth-timeout.test.ts b/test/security/hung-auth-timeout.test.ts new file mode 100644 index 0000000..4941f58 --- /dev/null +++ b/test/security/hung-auth-timeout.test.ts @@ -0,0 +1,143 @@ +/** + * handshakeTimeout must bound the ENTIRE handshake attempt, starting at + * hello receipt — including the async auth phase (auth.verify / + * auth.secret / auth.sign). A slow or hung auth callback must not stretch + * the attempt: on expiry the server reports a handshake timeout, and a + * late-resolving callback must not install a candidate or send a reply. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + type CallOptions, + type Channel, + type Router, +} from "../../src/index.ts"; +import { createChannelPair } from "../helpers/channels.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const router: Router = { + ping: chain().handler(async () => "pong"), +}; + +/** Count outbound frames on a channel (server replies). */ +function countSends(ch: Channel): { ch: Channel; sends: () => number } { + let n = 0; + return { + ch: { + send(data) { + n++; + return ch.send(data); + }, + receive: (cb) => ch.receive(cb), + }, + sends: () => n, + }; +} + +describe("handshakeTimeout bounds the async auth phase", () => { + it("a slow auth.secret overrunning hsTimeout fails the attempt at hsTimeout; the late result installs nothing", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const wrapped = countSends(b); + const errors: unknown[] = []; + + const srv = server(router, wrapped.ch, { + auth: { + // Resolves at 300 ms — well past the 100 ms server budget. + secret: async () => { + await sleep(300); + return psk; + }, + }, + handshakeTimeout: 100, + onError: (e) => { + errors.push(e); + }, + }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + handshakeTimeout: 500, + timeout: 1000, + }) as unknown as { api: LooseApi; destroy: () => void }; + + try { + const pCall = api["ping"]!(); + pCall.catch(() => {}); // settled below; avoid unhandled rejection + + // At 200 ms: budget (100 ms) expired, auth.secret (300 ms) has NOT + // resolved yet — the timeout must have fired independently of it. + await sleep(200); + const timeoutErr = errors.find( + (e) => e instanceof RPCError && e.code === "HANDSHAKE", + ); + expect(timeoutErr).toBeDefined(); + expect((timeoutErr as RPCError).message).toMatch(/timeout/i); + expect(wrapped.sends()).toBe(0); // no reply went out + + // At 400 ms: auth.secret resolved at 300 ms — on a DEAD attempt. + // No candidate, no reply. + await sleep(200); + expect(wrapped.sends()).toBe(0); + + // The client's own handshakeTimeout (500 ms) fails the call. + const err = await pCall.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("HANDSHAKE"); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("an auth.secret that never resolves still times out the attempt", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const wrapped = countSends(b); + const errors: unknown[] = []; + + const srv = server(router, wrapped.ch, { + auth: { + secret: () => new Promise(() => {}), // hangs forever + }, + handshakeTimeout: 100, + onError: (e) => { + errors.push(e); + }, + }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + handshakeTimeout: 300, + timeout: 1000, + }) as unknown as { api: LooseApi; destroy: () => void }; + + try { + const pCall = api["ping"]!(); + pCall.catch(() => {}); + + await sleep(200); + const timeoutErr = errors.find( + (e) => e instanceof RPCError && e.code === "HANDSHAKE", + ); + expect(timeoutErr).toBeDefined(); + expect((timeoutErr as RPCError).message).toMatch(/timeout/i); + expect(wrapped.sends()).toBe(0); + + const err = await pCall.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("HANDSHAKE"); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/session-continuity.test.ts b/test/security/session-continuity.test.ts index 1ece5cc..2ea9c27 100644 --- a/test/security/session-continuity.test.ts +++ b/test/security/session-continuity.test.ts @@ -107,20 +107,26 @@ describe("security / session continuity (make-before-break)", () => { // Regression for the reqEpoch capture-timing bug: promotion advances // `epoch` while handling the confirming frame, so reqEpoch must be // captured AFTER promotion or this first post-rekey call times out. + // + // abortPending() is removed in v2. We force a genuine client reset via + // the only v2 reset trigger: a SENT call that times out + // (RPCAbortedError("TIMEOUT")) → auto-reset → idle → re-handshake. + // The mitm drops one server reply to produce that timeout. const psk = randomBytes(32); - const { a, b } = createMitmChannelPair(); + const { a, b, mitm } = createMitmChannelPair(); const router: Router = { ping: chain().handler(async () => "pong") }; const srv = server(router, a, { auth: { secret: () => psk } }); - const { api, abortPending, destroy } = client(b, { + const { api, destroy } = client(b, { auth: { secret: () => psk }, - timeout: 1000, + timeout: 200, // short for test speed }); try { expect(await api.ping({})).toBe("pong"); - // Force the client to drop its session and lazily re-handshake with a - // fresh ephemeral key on the next call. - abortPending(); + // Drop the next server reply → the call times out (frame was SENT) → + // RPCAbortedError("TIMEOUT") → auto-reset → client goes to idle. + mitm.dropNextAtoB(1); + await expect(api.ping({})).rejects.toMatchObject({ code: "TIMEOUT" }); // The FIRST call after the genuine rekey must resolve, not time out. expect(await api.ping({})).toBe("pong");