From 9fa37339fcdf258181f1d12895554de3d3c7fc75 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Thu, 6 Aug 2026 20:43:53 +0200 Subject: [PATCH] fix(consent): hold a port open so the consent ask reaches the worker 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 --- packages/extension/src/background.ts | 24 ++++++++++++++++ packages/extension/src/bridge-protocol.ts | 8 ++++++ packages/extension/src/offscreen.ts | 35 +++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 7beeacd..86bec06 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -88,6 +88,7 @@ import { RUNTIME_REQUEST_TASK, RUNTIME_SIGN_TRUST_TASK, RUNTIME_TASK_CONSENT, + CONSENT_KEEPALIVE_PORT, RUNTIME_STEP_UP_CONSENT, RUNTIME_STEP_UP_VTA, RUNTIME_VERIFY_RP_DID, @@ -457,6 +458,29 @@ async function ensureOffscreenDocument(): Promise { await creatingOffscreen; } +// A port is the only reliable way for the offscreen document to reach this +// worker. `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 exactly how +// a consent request went missing — arriving, verifying, de-duplicating, being +// acked to the mediator, and then vanishing with no prompt and no error, while +// the identical message dispatched by hand from the console (with the worker +// already awake) prompted correctly. +// +// `chrome.runtime.connect` does start the worker, and an open port keeps it +// alive for the connection's lifetime — which also covers the second half of +// the problem: `requestTaskConsent` awaits a human decision that can take +// minutes, far past the ~30s idle teardown, with the resolver held in memory. +// +// The listener body is deliberately empty. Accepting the connection is the +// entire purpose; there is no protocol here. +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== CONSENT_KEEPALIVE_PORT) return; + port.onDisconnect.addListener(() => { + // Nothing to clean up — the port exists only to hold the worker awake. + }); +}); + // ─── Consent coordination ─── // A login request opens a consent popup and parks here until the popup // reports the user's decision (or is closed, which counts as a denial). diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 5a78a38..e7883c0 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -267,6 +267,14 @@ export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const; * enrolled executor is asking, not a site), and its approval is single-use so * there is nothing to remember. */ export const RUNTIME_TASK_CONSENT = "vta-wallet/task-consent" as const; + +/** Port the offscreen document opens before asking for a consent prompt. + * + * `chrome.runtime.sendMessage` from an offscreen document does not dependably + * START a terminated MV3 service worker, so the ask can resolve nowhere and + * hang with nothing thrown. `connect` does start it, and holding the port open + * keeps it alive across a decision that waits on a human. */ +export const CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive" as const; /** offscreen → background: a step-up approve-request has VERIFIED and the * human must now decide. Fired mid-flow — after the offscreen fetched the RP * `start` response and `verifyStepUpApproveRequest` passed, before anything diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 13384f7..fdfb66c 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -100,6 +100,7 @@ import { OFFSCREEN_VAULT_UPSERT, OFFSCREEN_VERIFY_DID, RUNTIME_TASK_CONSENT, + CONSENT_KEEPALIVE_PORT, RUNTIME_STEP_UP_CONSENT, RUNTIME_EMIT_WALLET_EVENT, type OffscreenDidcommLoginRequest, @@ -690,6 +691,22 @@ async function maybeRelayConsentLocally( } activeConsentDigests.add(outcome.payloadDigest); + // Open a port to the background BEFORE asking, and hold it for the whole + // interaction. `chrome.runtime.sendMessage` from an offscreen document does + // not dependably start a terminated MV3 service worker — the send resolves + // nowhere, nothing throws, and this await hangs forever. That is how a + // verified, de-duplicated, mediator-acked consent request went missing with + // no prompt and no error, while the same message sent by hand from this + // console (worker already awake) prompted correctly. + // + // `connect` does start the worker, and an open port keeps it alive — which + // also covers the decision itself: the prompt awaits a human, far past the + // ~30s idle teardown that would otherwise discard the resolver held in the + // worker's memory. + // + // Disconnected in `finally` so an answered, denied or failed prompt does not + // leave the worker pinned awake. + const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT }); try { const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, @@ -733,6 +750,7 @@ async function maybeRelayConsentLocally( conn.send(outer); console.info("[pnm consent relay] decision relayed over the worker session"); } finally { + keepAlive.disconnect(); activeConsentDigests.delete(outcome.payloadDigest); } } @@ -1895,6 +1913,22 @@ async function handleTaskConsent( } activeConsentDigests.add(parsed.request.payloadDigest); + // Open a port to the background BEFORE asking, and hold it for the whole + // interaction. `chrome.runtime.sendMessage` from an offscreen document does + // not dependably start a terminated MV3 service worker — the send resolves + // nowhere, nothing throws, and this await hangs forever. That is how a + // verified, de-duplicated, mediator-acked consent request went missing with + // no prompt and no error, while the same message sent by hand from this + // console (worker already awake) prompted correctly. + // + // `connect` does start the worker, and an open port keeps it alive — which + // also covers the decision itself: the prompt awaits a human, far past the + // ~30s idle teardown that would otherwise discard the resolver held in the + // worker's memory. + // + // Disconnected in `finally` so an answered, denied or failed prompt does not + // leave the worker pinned awake. + const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT }); try { const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, @@ -1934,6 +1968,7 @@ async function handleTaskConsent( } catch (e) { console.error("[pnm inbound] task-consent handling failed:", e); } finally { + keepAlive.disconnect(); activeConsentDigests.delete(parsed.request.payloadDigest); } }