fix(truapi): bound every client request with a timeout - #411
Open
ryanleecode wants to merge 5 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'spendingmap 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 ontimeoutMs. A timeout is distinguishable from a transport close by type, not by message text; close still rejects with a plainError.client.ts) — armed beforesend, so a synchronous send failure still rejects with the close error rather than the timeout.takePending(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-calltimeoutMswins outright; otherwise the larger of the configured bound and the method's floor.requestTimeoutMsonCreateTransportOptionsandtimeoutMsonRequestParams, both validated bycheckRequestTimeoutMs, which rejects the valuessetTimeoutcollapses 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, matchingcreateTransport.The default and the floors
The default is
30_000ms, justified against budgets already in this repo rather than chosen freely: the package waitsHOST_PORT_TIMEOUT_MS = 20_000for 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.*.requestid — codegen renumbering cannot silently re-bind one:190_000msruntime.rs,DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT)420_000msrequest_device_permission/request_remote_permissionignore_cxand awaitcheck_or_prompt_*;request_loginwaits on an unbounded pairing loop420_000msThe 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-calltimeoutMsoverrides both.Verification
bun testinjs/packages/truapi: 236 pass / 0 fail (225 at base).tsc -bclean.floor ?? configuredinstead ofmax→keeps a configured bound that is longer than the method's floorfailsclassifies every generated request method as floored or prompt-freefailsReviewer notes
REQUEST_TIMEOUT_FLOOR_MSandresolveRequestTimeoutMsare exported fromclient.tsfor the tests but deliberately not re-exported fromindex.ts— the package's public surface gains onlyRequestTimeoutError,CreateTransportOptions.requestTimeoutMs, andRequestParams.timeoutMs.transport.tsat line 249 (setHandler); that drift is present at the base commit and was left untouched.Known limits
generated/wire-table.tsfrom rustdoc — a codegen change, outside this issue.(request)only, so the per-calltimeoutMsis reachable throughtransport.requestand not through a generated client method: a floored method cannot be shortened fromClient. Documented rather than changed.subscribeRawarms 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.RequestFrameIdshas 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.