Problem Statement
A product embedding the truAPI JS client awaits client.account.getAccount(). If the host accepts that request and never sends a reply — while the message channel stays nominally open — the returned promise never settles: it neither resolves nor rejects. The calling product cannot distinguish a slow host from a silent one, so its UI waits indefinitely with no error anywhere to log. In production this left a signed-in person looking at a permanently disabled button, because the account read never came back and nothing timed out.
Goal
Every request the JS client issues settles within a caller-observable bound — resolving, or rejecting with a timeout error distinguishable from a transport close — on every provider, including when the peer accepts the request and sends no reply while the channel remains open.
Evidence: the send path arms no timer
js/packages/truapi/src/client.ts:451-475
const promise = new Promise<ResultPayload<Ok, Err>>((resolve, reject) => {
if (closedError) { reject(closedError); return; }
const requestId = `p:${++idCounter}`;
pending.set(requestId, {
ids,
resolve: (response) => resolve(decodeResponse(response)),
reject,
});
try {
send({ requestId, payload: { id: ids.request, value: payload } });
} catch (error) { /* deletes the entry and rejects — only when send itself throws */ }
});
The entry is registered and the frame is sent. There is no setTimeout, no AbortSignal, and no deadline — in this function or anywhere else in js/packages/truapi/src/. The only setTimeout in the package polls for a webview port (sandbox.ts:91-103, bounded by HOST_PORT_TIMEOUT_MS); requests get nothing equivalent.
Evidence: the only other settle path is a close
js/packages/truapi/src/client.ts:200-211
function closeWithError(error: unknown) {
const nextError = toError(error);
if (closedError) { return; }
closedError = nextError;
for (const [requestId, entry] of pending) {
pending.delete(requestId);
entry.reject(nextError);
}
So a pending request resolves on a matching response frame, or rejects when the transport closes. A peer that keeps the channel open and simply does not answer hits neither.
Orientation
js/packages/truapi/src/client.ts — request registry, closeWithError, subscribeClose wiring at :225-227.
js/packages/truapi/src/transport.ts — the providers and the optional subscribeClose contract (:333); dispose() is what closes the iframe and MessagePort providers (:542-544, :610-612).
js/packages/truapi/src/sandbox.ts:91-103 — existing precedent in this package for a bounded wait with a named timeout constant.
- Existing tests:
js/packages/truapi/src/client.test.ts, transport.test.ts.
Non-Counting Outcomes
- A bound added to one provider (say the iframe transport) while the MessagePort, worker, and sandbox providers stay unbounded — the defect is in the request registry, not in one pipe.
- Rejecting with the same error shape
closeWithError produces, so callers still cannot tell "host went silent" from "channel is gone". Those need different remedies: retry versus re-establish.
- A default so large it is indistinguishable from hanging for a UI — matching the host shell's 240s frame-ready budget would satisfy the letter and reproduce the symptom.
- A test that asserts the timer fires under a fake clock but never asserts the
pending entry was deleted, leaving an id that a late reply can still resolve.
- Documenting a recommended caller-side timeout instead of bounding the client, which leaves every existing embedder broken.
Acceptance Criteria
Problem Statement
A product embedding the truAPI JS client awaits
client.account.getAccount(). If the host accepts that request and never sends a reply — while the message channel stays nominally open — the returned promise never settles: it neither resolves nor rejects. The calling product cannot distinguish a slow host from a silent one, so its UI waits indefinitely with no error anywhere to log. In production this left a signed-in person looking at a permanently disabled button, because the account read never came back and nothing timed out.Goal
Every request the JS client issues settles within a caller-observable bound — resolving, or rejecting with a timeout error distinguishable from a transport close — on every provider, including when the peer accepts the request and sends no reply while the channel remains open.
Evidence: the send path arms no timer
js/packages/truapi/src/client.ts:451-475The entry is registered and the frame is sent. There is no
setTimeout, noAbortSignal, and no deadline — in this function or anywhere else injs/packages/truapi/src/. The onlysetTimeoutin the package polls for a webview port (sandbox.ts:91-103, bounded byHOST_PORT_TIMEOUT_MS); requests get nothing equivalent.Evidence: the only other settle path is a close
js/packages/truapi/src/client.ts:200-211So a pending request resolves on a matching response frame, or rejects when the transport closes. A peer that keeps the channel open and simply does not answer hits neither.
Orientation
js/packages/truapi/src/client.ts— request registry,closeWithError,subscribeClosewiring at:225-227.js/packages/truapi/src/transport.ts— the providers and the optionalsubscribeClosecontract (:333);dispose()is what closes the iframe and MessagePort providers (:542-544,:610-612).js/packages/truapi/src/sandbox.ts:91-103— existing precedent in this package for a bounded wait with a named timeout constant.js/packages/truapi/src/client.test.ts,transport.test.ts.Non-Counting Outcomes
closeWithErrorproduces, so callers still cannot tell "host went silent" from "channel is gone". Those need different remedies: retry versus re-establish.pendingentry was deleted, leaving an id that a late reply can still resolve.Acceptance Criteria
js/packages/truapi/src/client.test.tsdrives a provider that accepts a request and never replies, asserts the returned promise rejects, and fails when the change is reverted.pnpm testinjs/packages/truapiexits 0.transport.ts; tests cover at least the iframe and MessagePort providers.pendingentry is removed, and a late reply carrying that request id is ignored rather than resolving or throwing.HOST_PORT_TIMEOUT_MS(sandbox.ts:93), and the primary embedder bounds protocol requests at 30s — rather than chosen freely.