Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -457,6 +458,29 @@ async function ensureOffscreenDocument(): Promise<void> {
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).
Expand Down
8 changes: 8 additions & 0 deletions packages/extension/src/bridge-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions packages/extension/src/offscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
Expand Down