Skip to content

truAPI JS client requests never settle when the peer never replies #406

Description

@ryanleecode

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

  • (gatekeeper) A test in js/packages/truapi/src/client.test.ts drives a provider that accepts a request and never replies, asserts the returned promise rejects, and fails when the change is reverted.
  • pnpm test in js/packages/truapi exits 0.
  • The bound applies to requests issued through every provider in transport.ts; tests cover at least the iframe and MessagePort providers.
  • A timeout rejection is distinguishable from a close rejection by error type or tag, not by message text.
  • When the bound fires, the pending entry is removed, and a late reply carrying that request id is ignored rather than resolving or throwing.
  • The bound is caller-configurable with a documented default, and the default is justified against a budget that already exists in this codebase — the package names HOST_PORT_TIMEOUT_MS (sandbox.ts:93), and the primary embedder bounds protocol requests at 30s — rather than chosen freely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions