Skip to content

fix(extension): inbound-reconnect backoff + page-fetch timeouts (D8-F4 + F6) - #88

Merged
stormer78 merged 1 commit into
mainfrom
fix/extension-net-resilience
Jul 16, 2026
Merged

fix(extension): inbound-reconnect backoff + page-fetch timeouts (D8-F4 + F6)#88
stormer78 merged 1 commit into
mainfrom
fix/extension-net-resilience

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Two independent MV3 network-resilience fixes in the extension package (D8-F4,
D8-F6). Both are cases where a mediator/VTA outage silently degrades the wallet.

F4 — inbound listener survives a mediator outage (offscreen.ts, R1.5)

The inbox mediator session re-armed with a single 2s setTimeout from
onClose, and startInbound swallowed errors without rescheduling. So a
mediator outage longer than that one retry left the inbound listener dead
silently missing every consent/confirm request — until the MV3 worker happened
to reboot. Since consent prompts are security controls, a missed one is a gated
action that never got its human check.

  • New scheduleInboundReconnect(vtaDid): per-VTA exponential backoff (2s
    doubling to 60s), idempotent per VTA (no stacked timers), re-arming on
    failed attempts too, resetting to base on a successful (re)connect.
  • startInbound now returns success; reconcileInbound schedules a retry for
    any VTA that fails to come up and clears backoff for one that succeeds.
  • onClose fires only on an unexpected drop (not our own close()), and the
    forgotten-VTA close path calls clearInboundBackoff — so a deliberately-closed
    listener is never resurrected. A cancellation guard also covers the race where
    a VTA is forgotten while a retry's startInbound is in flight.

F6 — page-facing fetch can't hang the page (background.ts, R1.2)

handleApiGet / handleApiPost (the authenticated fetch the wallet proxies for
a page, using its host permissions) called fetch() with no AbortSignal,
so a blackholed VTA hung the page's await for minutes and stacked requests
pinned the MV3 worker awake.

  • Both now route through a shared proxyFetch applying
    AbortSignal.timeout(20s). A stall surfaces as a clean { ok: false, error }
    through the message dispatcher's existing .catch (with a descriptive
    "VTA unreachable or not responding" message on the timeout path).
  • One shared helper rather than two copies (R4.1).

Verification

Completes the locally-actionable part of D8. F5 (pnm-relay) remains — that repo
isn't checked out here.

…4, D8-F6)

F4 (offscreen.ts) — the inbound mediator listener re-armed with a single
2s setTimeout from onClose only, and startInbound swallowed errors without
rescheduling. A mediator outage longer than one retry left the listener
dead, silently missing every consent request until the MV3 worker happened
to reboot. Replace with a per-VTA exponential-backoff scheduler
(scheduleInboundReconnect): 2s doubling to 60s, re-arming on FAILED
attempts too, resetting on a successful (re)connect. startInbound now
returns success; reconcileInbound schedules a retry for any VTA that fails
to come up and clears backoff for one that succeeds. onClose fires only on
an unexpected drop, and the forgotten-VTA close path cancels any pending
retry, so a deliberately-closed listener isn't resurrected (a
cancellation guard also covers the in-flight-attempt race). (R1.5)

F6 (background.ts) — the page-facing authenticated fetch proxy
(handleApiGet/handleApiPost) called fetch() with no AbortSignal, so a
blackholed VTA hung the page's await for minutes and stacked requests
pinned the MV3 worker. Route both through a shared proxyFetch that applies
AbortSignal.timeout (20s); a stall now surfaces as a clean
{ ok: false, error } via the dispatcher's existing catch. (R1.2, R4.1
one shared helper rather than two copies)

Type-checks clean (tsc -b packages/extension). No version bump: the
extension is an unpublished artifact and recent fix(extension) PRs
(#82/#86) followed the same convention.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 9ecdfd4 into main Jul 16, 2026
1 check passed
@stormer78
stormer78 deleted the fix/extension-net-resilience branch July 16, 2026 04:30
stormer78 added a commit that referenced this pull request Jul 19, 2026
* fix: bound every outbound fetch with a timeout (R1.2)

CLAUDE.md names `handleApiGet`/`handleApiPost` as the unbounded fetch that
hangs a page against a blackholed VTA. That one was fixed in #88 and is now
the one call site in the repo that complies. Nine others never were.

They hid from a naive audit: a literal `grep "fetch("` finds a single call
site repo-wide. This package injects `fetch` for testability —

    const f = opts.fetch ?? fetch.bind(globalThis);

— so the real calls are spelled `f(...)`, `fetchFn(...)`,
`this.fetchImpl(...)`, and none of the six files containing them mentioned
`signal`, `AbortSignal` or `timeout`.

An unbounded fetch against a wedged peer does not fail slowly; it does not
fail at all. The page waits on a promise that never settles, and in MV3 the
unresolved `await` also pins the service worker awake and stacks later
requests behind it. Nothing surfaces an error, so nothing reports it.

Adds `withFetchTimeout`, applied at each INJECTION point rather than each call
site — that is what makes the coverage complete, and it matters most for
`getVtaBearer`, which runs before every REST request and is reached only
indirectly through `VtaAuthInputs`. Leaving it unbounded would have defeated a
timeout added anywhere downstream.

A caller-supplied `signal` is combined with the deadline rather than replaced:
silently disabling a caller's cancellation would be a worse bug than the one
being fixed. Where `AbortSignal.any` is unavailable the timeout survives, on
the principle that losing the combination is recoverable and losing the
deadline is the defect.

Adds `e.client.timeout` so "the VTA never answered" is distinguishable from
"the connection failed" by a stable code rather than message text (R3.7).
`isFetchTimeout` matches `DOMException.name`, the platform's own
machine-readable discriminant.

Sites bounded: vault/transport.ts (x2, the bearer handshake), vta/rest-channel,
vta/client, rp-login/step-up (x2), siop/login-client (x2),
device/register-gateway.

Verified: 10 new tests — deadline fires on a blackholed peer, fast responses
untouched, caller's abort still wins, timeout survives a non-aborting caller
signal, init fields preserved, global-fetch fallback bounded, plus a wiring
test asserting a real exported call path (`registerPushChannel`) hands a
signal to its injected fetch. Mutation-tested: with the timeout removed the
suite HANGS rather than failing, which is precisely the production symptom.
Full suite 190/190, clean build and lint.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

* test: hold the event loop open while a timeout test awaits its deadline

The new timeout tests passed locally and failed on CI with every test in the
file reported as "Promise resolution is still pending but the event loop has
already resolved".

Node unrefs the timer behind `AbortSignal.timeout`, so a pending abort does
not hold the event loop open. The blackhole stub's promise had nothing else
queued, so on CI the process drained and exited before the deadline fired.
Locally it survived only because other work happened to keep the loop alive —
the tests were depending on an accident.

A ref'd interval, cleared when the abort lands, removes the dependency.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

---------

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Jul 19, 2026
CLAUDE.md was untracked, so every contributor had their own copy or none.
Checks it in, and corrects the parts that had gone stale — its named defects
were the ones most likely to be trusted verbatim, and all three were fixed:

- R3.7's `consentRequiredFrom` example was fixed (it now matches
  `details.reason`). Replaced with the rule that actually bites now: a
  Response body reads once, so an already-parsed body must use
  `errorFromBody`, not a re-read of the spent Response (#99).
- R1.5's "one 2s retry from onClose" applied to the worker inbound path
  (fixed in #88) and then to the approver inbox (fixed in #97). Replaced with
  the invariant — cap the delay not the attempt count, re-arm on every
  failure including first-connect — and a pointer to `ReconnectScheduler`.
- R1.2's `handleApiGet`/`handleApiPost` example was fixed in #88 and is now
  the compliant reference. Replaced with the thing that actually hides these:
  fetch is injected, so `grep "fetch("` finds almost nothing and the timeout
  belongs at the injection point (#98).

Promotes the one genuinely open defect to its own section: R1.6
persist-before-ack, which is not fixable from this repo — vti-didcomm-js acks
before dispatching to `onMessage`, and the wallet persists only the message
id, so an offscreen teardown mid-prompt loses a task-consent request for good.

Adds a repo-mechanics section for the traps that cost time this week: build
`core` before typechecking dependents, lint is `tsc -b` (never `-b --noEmit`,
TS6310), cross-workspace imports need a `references` entry, what CI asserts,
stub with real `Response` objects, and Node unreffing the `AbortSignal.timeout`
timer (passes locally, fails in CI).

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
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.

1 participant