Skip to content

fix(truapi): bound every client request with a timeout - #411

Open
ryanleecode wants to merge 5 commits into
mainfrom
ryan-406
Open

fix(truapi): bound every client request with a timeout#411
ryanleecode wants to merge 5 commits into
mainfrom
ryan-406

Conversation

@ryanleecode

@ryanleecode ryanleecode commented Aug 14, 2026

Copy link
Copy Markdown

Closes #406.

Problem

A product awaiting client.account.getAccount() never got an answer when the host accepted the request and replied with nothing while the channel stayed open. The returned promise neither resolved nor rejected: client.ts's pending map only lost entries on a response, a transport close, or a synchronous send failure, so a silent peer left the entry live forever. In production that left a signed-in person looking at a permanently disabled button with no error to log.

What changed

Every request now carries a deadline, and the registry can no longer hold an entry that nothing will settle.

  • RequestTimeoutError (transport.ts) — extends Error, carries the bound it outlived on timeoutMs. A timeout is distinguishable from a transport close by type, not by message text; close still rejects with a plain Error.
  • A timer per request (client.ts) — armed before send, so a synchronous send failure still rejects with the close error rather than the timeout.
  • One removal pathtakePending(requestId) deletes the entry and clears the timer. Response dispatch, the close loop, and the timeout callback all go through it, so a settled request never leaves a live timer, and a reply arriving after the bound fired finds no entry: it is ignored rather than resolving or throwing.
  • resolveRequestTimeoutMs(requestFrameId, transportBound, perCallBound) — the precedence, in one testable place: a per-call timeoutMs wins outright; otherwise the larger of the configured bound and the method's floor.
  • requestTimeoutMs on CreateTransportOptions and timeoutMs on RequestParams, both validated by checkRequestTimeoutMs, which rejects the values setTimeout collapses into an immediate fire (0, Infinity, NaN, > 2^31-1). The per-call value throws at the call site rather than rejecting through the promise, matching createTransport.

The default and the floors

The default is 30_000 ms, justified against budgets already in this repo rather than chosen freely: the package waits HOST_PORT_TIMEOUT_MS = 20_000 for a host-injected message port, and the playground bounds prompt-backed protocol calls at 30s.

A flat 30s would abort answers the host is still allowed to send, so 24 request methods carry a floor keyed on the generated W.*.request id — codegen renumbering cannot silently re-bind one:

Floor Methods Why
190_000 ms account get, alias, and proof; VRF sign and ring-VRF register, list, and sign; every signing method; statement-store create-proof and create-proof-authorized clears the runtime's 180s remote-authority deadline (runtime.rs, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT)
420_000 ms request login; device and remote permission prompts; payment request and top-up a person answers and the host applies no deadline at all — request_device_permission / request_remote_permission ignore _cx and await check_or_prompt_*; request_login waits on an unbounded pairing loop
420_000 ms resource allocation; preimage submit; statement-store submit clears the 300s allocation and 360s preimage caps

The effective bound is max(configured, floor), so a product that deliberately configures a long bound keeps it and one that configures a short bound still cannot cut a host answer short. A per-call timeoutMs overrides both.

Verification

  • bun test in js/packages/truapi: 236 pass / 0 fail (225 at base). tsc -b clean.
  • Each new test was run against the mutant it exists to catch, not merely observed passing:
    • revert the timer arming → 5 cases fail
    • floor ?? configured instead of maxkeeps a configured bound that is longer than the method's floor fails
    • delete the per-call branch → 2 cases fail
    • drop one method from the classification sets → classifies every generated request method as floored or prompt-free fails
  • That last test is the drift gate: a generated request method that is neither floored nor listed prompt-free fails the suite instead of silently inheriting the 30s default.
  • No Rust files changed, so the cargo gates are unaffected.

Reviewer notes

  • REQUEST_TIMEOUT_FLOOR_MS and resolveRequestTimeoutMs are exported from client.ts for the tests but deliberately not re-exported from index.ts — the package's public surface gains only RequestTimeoutError, CreateTransportOptions.requestTimeoutMs, and RequestParams.timeoutMs.
  • Timers are ref'd on purpose. An unref'd timer's rejection was measured to be dropped on Node when the loop is otherwise empty, which would restore the original hang.
  • Prettier reports drift in transport.ts at line 249 (setHandler); that drift is present at the base commit and was left untouched.

Known limits

  • The floors restate Rust constants across a language boundary with no gate: a runtime bump from 180s would make the client abort valid answers with both suites green. The durable fix is emitting each method's host deadline into generated/wire-table.ts from rustdoc — a codegen change, outside this issue.
  • Generated stubs take (request) only, so the per-call timeoutMs is reachable through transport.request and not through a generated client method: a floored method cannot be shortened from Client. Documented rather than changed.
  • Subscription start is still unbounded — subscribeRaw arms no timer, so a host that accepts a start frame and never streams leaves the observer silent. Same shape as truAPI JS client requests never settle when the peer never replies #406, outside the request registry this change fixes.
  • A timed-out request is not withdrawn: RequestFrameIds has no cancel discriminant, so the host may still be executing. The README now tells callers of side-effecting methods to re-query state instead of resubmitting.

@ryanleecode
ryanleecode requested a review from a team August 14, 2026 19:27
Requests registered in the transport registry now carry a deadline. When it
fires, the pending entry is removed and the request rejects with
RequestTimeoutError, so a peer that accepts a frame and never replies no longer
leaves the promise unsettled and a late reply for that id is ignored.

The bound defaults to 30s and is set per transport with requestTimeoutMs or per
call with timeoutMs. Requests the host answers behind a remote authority or a
live allocation carry floors of 190s and 420s, above the runtime deadlines of
180s, 300s and 360s, so bounding them never aborts an answer still in flight.
The floor table missed five remote-authority methods (VRF sign, ring-VRF
register/list/sign, statement-store create-proof) and every method whose
host handler applies no deadline at all because it waits on a human -
login pairing, device and remote permission prompts, payment. All of
them inherited the 30s default and would abort answers the host is still
allowed to send.

Bound resolution moves into resolveRequestTimeoutMs so the floor/override
precedence is directly testable, and per-call validation now throws at
the call site instead of rejecting through the promise. A classification
test fails when codegen adds a request method that is neither floored nor
listed as prompt-free.
Records why a request registry with no timer never settles against a
silent host, and the two traps the fix walked into: a floor table keyed
on "host deadline exceeds the default" excludes the methods with no host
deadline at all, and ordering-only tests cannot tell max(configured,
floor) from floor ?? configured.

Seeds CONCEPTS.md with the vocabulary the learning leans on - Product,
Host, Action, remote authority, request timeout floor, prompt-backed
request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

truAPI JS client requests never settle when the peer never replies

1 participant