Skip to content

fix(inbound): a throw while handling an inbound message is no longer silent - #108

Merged
stormer78 merged 1 commit into
mainfrom
fix/inbound-handling-swallows-throws
Aug 5, 2026
Merged

fix(inbound): a throw while handling an inbound message is no longer silent#108
stormer78 merged 1 commit into
mainfrom
fix/inbound-handling-swallows-throws

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

handleInbound wrapped dispatchInbound in try/finally with no catch, and the call site is void handleInbound(...).

So anything dispatch threw — rather than returned as a refusal — became an unhandled rejection: no log from this file, no prompt, no decision sent to the executor, and the message already acked to the mediator, so its queued copy was gone too.

How it presented

[pnm inbound] received (approver inbox) type=…/task-consent/request/0.1 followed by silence. No refusal, no dedup skip, no decision sent, no error. Indistinguishable from a request that was never sent — which is how it was read for several rounds of cross-service debugging.

What localised it: dispatching the identical message by hand from the offscreen console raised the consent window correctly. That proves the offscreen→background channel, the background listener, chrome.windows.create and consentWindowBounds are all fine, and narrows the fault to the path between arrival and sendMessage.

Every deliberate refusal in dispatchInbound logs and returns. Only a thrown error could vanish, and nothing was watching for one. On the task-consent path the candidates are enrolledExecutorDids() and parseTaskConsentRequest() — both awaited before any logging happens.

What this does and does not do

It does not stop the throw. The logged error is what will name the actual cause, and that is the point: a consent request can no longer disappear without trace after its mediator copy has been deleted.

The finally that releases the pending record is unchanged, so recovery semantics are untouched.

Lint clean, 223 tests pass.

…silent

handleInbound wrapped dispatchInbound in try/finally with no catch, and
the call site is `void handleInbound(...)`. So anything dispatch threw --
rather than returned as a refusal -- became an unhandled rejection: no log
from this file, no prompt, no decision sent to the executor, and the
message already acked to the mediator so its queued copy was gone.

That is indistinguishable from a request that never arrived, and it is
exactly what it looked like: `[pnm inbound] received` followed by silence.
Dispatching the identical message by hand from the offscreen console
raised the consent window correctly, which is what localised it -- the
channel, the background listener, window creation and bounds are all fine.

Every deliberate refusal in dispatchInbound already logs and returns; only
a thrown error could vanish, and nothing was watching for one. The
candidates on the task-consent path are enrolledExecutorDids() and
parseTaskConsentRequest(), both awaited before any logging.

Log it. This does not stop the throw -- the logged error is what will name
the actual cause -- but a consent request can no longer disappear without
trace after its mediator copy has been deleted.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 63668cf into main Aug 5, 2026
3 checks passed
@stormer78
stormer78 deleted the fix/inbound-handling-swallows-throws branch August 5, 2026 21:09
stormer78 added a commit that referenced this pull request Aug 7, 2026
…109)

chrome.runtime.sendMessage from an offscreen document does not dependably
START a terminated MV3 service worker. The send resolves nowhere, nothing
is thrown, and the caller's await hangs forever.

That is the remaining failure. A task-consent request arrived on the
approver inbox, verified, passed both dedup gates, and was acked to the
mediator -- deleting its queued copy -- and then nothing: no prompt, no
error, no decision. #108 proved it was not throwing; the same message sent
by hand from the offscreen console, with the worker already awake, raised
the window correctly. Inspect views showed "service worker (Inactive)".

chrome.runtime.connect does start the worker, and an open port keeps it
alive for the connection's lifetime. That also covers the second half:
requestTaskConsent awaits a human decision that can run minutes past the
~30s idle teardown, with the resolver held in the worker's memory --
so even a delivered ask could be discarded mid-decision.

Both offscreen consent sites open the port before asking and disconnect in
finally, so an answered, denied or failed prompt never leaves the worker
pinned awake. The background accepts the port and does nothing else;
accepting it is the entire purpose. The name lives in bridge-protocol.ts
with the message types it belongs beside.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Aug 7, 2026
…ndow (#110)

A prompt window that must appear at an arbitrary moment is the least
reliable thing this extension can attempt. It needs a live service worker,
a live offscreen document, and a promise chain spanning both, on a runtime
free to kill either at any time. Every failure chased in this series --
a create() that hung (#106), a swallowed throw (#108), a send that could
not start a terminated worker (#109) -- was a different edge of that one
design problem.

A badge has none of those dependencies. It is derived from the durable
record the inbound path already writes before it acks the mediator
(pending.ts), so it is correct after any teardown, and it gives the user a
way IN rather than depending on a window finding its way OUT.

refreshPendingBadge() counts approver-bound pending records and sets the
action badge. It runs on service-worker startup and when a consent port
opens or closes -- every wake is a chance to be correct. Idempotent and
cheap, so no single missed call can leave it lying, and a failure is
swallowed with a warn: a cosmetic surface must never break a wake path.

The window remains the fast path. This is the floor beneath it: the first
thing in this flow that does not depend on two ephemeral contexts being
alive simultaneously.

First step of the larger inversion -- making the durable record the spine
of the flow rather than crash recovery for it.

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