Skip to content

Commit b68e924

Browse files
authored
fix(consent): hold a port open so the consent ask reaches the worker (#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>
1 parent 63668cf commit b68e924

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

packages/extension/src/background.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
RUNTIME_REQUEST_TASK,
8989
RUNTIME_SIGN_TRUST_TASK,
9090
RUNTIME_TASK_CONSENT,
91+
CONSENT_KEEPALIVE_PORT,
9192
RUNTIME_STEP_UP_CONSENT,
9293
RUNTIME_STEP_UP_VTA,
9394
RUNTIME_VERIFY_RP_DID,
@@ -457,6 +458,29 @@ async function ensureOffscreenDocument(): Promise<void> {
457458
await creatingOffscreen;
458459
}
459460

461+
// A port is the only reliable way for the offscreen document to reach this
462+
// worker. `chrome.runtime.sendMessage` from an offscreen document does not
463+
// dependably START a terminated MV3 service worker: the send resolves nowhere,
464+
// nothing is thrown, and the caller's await hangs forever. That is exactly how
465+
// a consent request went missing — arriving, verifying, de-duplicating, being
466+
// acked to the mediator, and then vanishing with no prompt and no error, while
467+
// the identical message dispatched by hand from the console (with the worker
468+
// already awake) prompted correctly.
469+
//
470+
// `chrome.runtime.connect` does start the worker, and an open port keeps it
471+
// alive for the connection's lifetime — which also covers the second half of
472+
// the problem: `requestTaskConsent` awaits a human decision that can take
473+
// minutes, far past the ~30s idle teardown, with the resolver held in memory.
474+
//
475+
// The listener body is deliberately empty. Accepting the connection is the
476+
// entire purpose; there is no protocol here.
477+
chrome.runtime.onConnect.addListener((port) => {
478+
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
479+
port.onDisconnect.addListener(() => {
480+
// Nothing to clean up — the port exists only to hold the worker awake.
481+
});
482+
});
483+
460484
// ─── Consent coordination ───
461485
// A login request opens a consent popup and parks here until the popup
462486
// reports the user's decision (or is closed, which counts as a denial).

packages/extension/src/bridge-protocol.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,14 @@ export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const;
267267
* enrolled executor is asking, not a site), and its approval is single-use so
268268
* there is nothing to remember. */
269269
export const RUNTIME_TASK_CONSENT = "vta-wallet/task-consent" as const;
270+
271+
/** Port the offscreen document opens before asking for a consent prompt.
272+
*
273+
* `chrome.runtime.sendMessage` from an offscreen document does not dependably
274+
* START a terminated MV3 service worker, so the ask can resolve nowhere and
275+
* hang with nothing thrown. `connect` does start it, and holding the port open
276+
* keeps it alive across a decision that waits on a human. */
277+
export const CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive" as const;
270278
/** offscreen → background: a step-up approve-request has VERIFIED and the
271279
* human must now decide. Fired mid-flow — after the offscreen fetched the RP
272280
* `start` response and `verifyStepUpApproveRequest` passed, before anything

packages/extension/src/offscreen.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
OFFSCREEN_VAULT_UPSERT,
101101
OFFSCREEN_VERIFY_DID,
102102
RUNTIME_TASK_CONSENT,
103+
CONSENT_KEEPALIVE_PORT,
103104
RUNTIME_STEP_UP_CONSENT,
104105
RUNTIME_EMIT_WALLET_EVENT,
105106
type OffscreenDidcommLoginRequest,
@@ -690,6 +691,22 @@ async function maybeRelayConsentLocally(
690691
}
691692

692693
activeConsentDigests.add(outcome.payloadDigest);
694+
// Open a port to the background BEFORE asking, and hold it for the whole
695+
// interaction. `chrome.runtime.sendMessage` from an offscreen document does
696+
// not dependably start a terminated MV3 service worker — the send resolves
697+
// nowhere, nothing throws, and this await hangs forever. That is how a
698+
// verified, de-duplicated, mediator-acked consent request went missing with
699+
// no prompt and no error, while the same message sent by hand from this
700+
// console (worker already awake) prompted correctly.
701+
//
702+
// `connect` does start the worker, and an open port keeps it alive — which
703+
// also covers the decision itself: the prompt awaits a human, far past the
704+
// ~30s idle teardown that would otherwise discard the resolver held in the
705+
// worker's memory.
706+
//
707+
// Disconnected in `finally` so an answered, denied or failed prompt does not
708+
// leave the worker pinned awake.
709+
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
693710
try {
694711
const result = (await chrome.runtime.sendMessage({
695712
type: RUNTIME_TASK_CONSENT,
@@ -733,6 +750,7 @@ async function maybeRelayConsentLocally(
733750
conn.send(outer);
734751
console.info("[pnm consent relay] decision relayed over the worker session");
735752
} finally {
753+
keepAlive.disconnect();
736754
activeConsentDigests.delete(outcome.payloadDigest);
737755
}
738756
}
@@ -1895,6 +1913,22 @@ async function handleTaskConsent(
18951913
}
18961914
activeConsentDigests.add(parsed.request.payloadDigest);
18971915

1916+
// Open a port to the background BEFORE asking, and hold it for the whole
1917+
// interaction. `chrome.runtime.sendMessage` from an offscreen document does
1918+
// not dependably start a terminated MV3 service worker — the send resolves
1919+
// nowhere, nothing throws, and this await hangs forever. That is how a
1920+
// verified, de-duplicated, mediator-acked consent request went missing with
1921+
// no prompt and no error, while the same message sent by hand from this
1922+
// console (worker already awake) prompted correctly.
1923+
//
1924+
// `connect` does start the worker, and an open port keeps it alive — which
1925+
// also covers the decision itself: the prompt awaits a human, far past the
1926+
// ~30s idle teardown that would otherwise discard the resolver held in the
1927+
// worker's memory.
1928+
//
1929+
// Disconnected in `finally` so an answered, denied or failed prompt does not
1930+
// leave the worker pinned awake.
1931+
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
18981932
try {
18991933
const result = (await chrome.runtime.sendMessage({
19001934
type: RUNTIME_TASK_CONSENT,
@@ -1934,6 +1968,7 @@ async function handleTaskConsent(
19341968
} catch (e) {
19351969
console.error("[pnm inbound] task-consent handling failed:", e);
19361970
} finally {
1971+
keepAlive.disconnect();
19371972
activeConsentDigests.delete(parsed.request.payloadDigest);
19381973
}
19391974
}

0 commit comments

Comments
 (0)