diff --git a/apps/extension/src/entry/offscreen-handler.ts b/apps/extension/src/entry/offscreen-handler.ts index fec4af8ac9..14b9f1c122 100644 --- a/apps/extension/src/entry/offscreen-handler.ts +++ b/apps/extension/src/entry/offscreen-handler.ts @@ -13,13 +13,26 @@ chrome.runtime.onMessage.addListener((req, _sender, respond) => { if (isActionBuildRequest(request)) { void (async () => { try { - const data = await spawnActionBuildWorker(request); + // propagate errors that occur in unawaited promises + const unhandled = Promise.withResolvers(); + self.addEventListener('unhandledrejection', unhandled.reject, { + once: true, + }); + + const data = await Promise.race([ + spawnActionBuildWorker(request), + unhandled.promise, + ]).finally(() => self.removeEventListener('unhandledrejection', unhandled.reject)); + respond({ type, data }); } catch (e) { - respond({ - type, - error: errorToJson(ConnectError.from(e), undefined), - }); + const error = errorToJson( + // note that any given promise rejection event probably doesn't + // actually involve the specific request it ends up responding to. + ConnectError.from(e instanceof PromiseRejectionEvent ? e.reason : e), + undefined, + ); + respond({ type, error }); } })(); return true; @@ -28,24 +41,28 @@ chrome.runtime.onMessage.addListener((req, _sender, respond) => { }); const spawnActionBuildWorker = (req: ActionBuildRequest) => { + const { promise, resolve, reject } = Promise.withResolvers(); + const worker = new Worker(new URL('../wasm-build-action.ts', import.meta.url)); - return new Promise((resolve, reject) => { - const onWorkerMessage = (e: MessageEvent) => resolve(e.data as ActionBuildResponse); + void promise.finally(() => worker.terminate()); + + const onWorkerMessage = (e: MessageEvent) => resolve(e.data as ActionBuildResponse); + + const onWorkerError = ({ error, filename, lineno, colno, message }: ErrorEvent) => + reject( + error instanceof Error + ? error + : new Error(`Worker ErrorEvent ${filename}:${lineno}:${colno} ${message}`), + ); - const onWorkerError = ({ error, filename, lineno, colno, message }: ErrorEvent) => - reject( - error instanceof Error - ? error - : new Error(`Worker ErrorEvent ${filename}:${lineno}:${colno} ${message}`), - ); + const onWorkerMessageError = (ev: MessageEvent) => reject(ConnectError.from(ev.data ?? ev)); - const onWorkerMessageError = (ev: MessageEvent) => reject(ConnectError.from(ev.data ?? ev)); + worker.addEventListener('message', onWorkerMessage, { once: true }); + worker.addEventListener('error', onWorkerError, { once: true }); + worker.addEventListener('messageerror', onWorkerMessageError, { once: true }); - worker.addEventListener('message', onWorkerMessage, { once: true }); - worker.addEventListener('error', onWorkerError, { once: true }); - worker.addEventListener('messageerror', onWorkerMessageError, { once: true }); + // Send data to web worker + worker.postMessage(req); - // Send data to web worker - worker.postMessage(req); - }).finally(() => worker.terminate()); + return promise; }; diff --git a/packages/services/src/offscreen-client.ts b/packages/services/src/offscreen-client.ts index 9f435cf186..6eda2ff680 100644 --- a/packages/services/src/offscreen-client.ts +++ b/packages/services/src/offscreen-client.ts @@ -1,17 +1,17 @@ +import { FullViewingKey } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; import { Action, TransactionPlan, WitnessData, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb'; +import { ConnectError } from '@connectrpc/connect'; +import { errorFromJson } from '@connectrpc/connect/protocol-connect'; import { ActionBuildMessage, - ActionBuildRequest, OffscreenMessage, } from '@penumbra-zone/types/src/internal-msg/offscreen'; import { InternalRequest, InternalResponse } from '@penumbra-zone/types/src/internal-msg/shared'; -import { ConnectError } from '@connectrpc/connect'; import type { Jsonified } from '@penumbra-zone/types/src/jsonified'; -import { FullViewingKey } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; @@ -45,10 +45,11 @@ const releaseOffscreen = async () => { if (!--active) await chrome.offscreen.closeDocument(); }; -const sendOffscreenMessage = async ( - req: InternalRequest, -): Promise> => - chrome.runtime.sendMessage, InternalResponse>(req); +const sendOffscreenMessage = async (req: InternalRequest) => + chrome.runtime.sendMessage, InternalResponse>(req).then(res => { + if ('error' in res) throw errorFromJson(res.error, undefined, ConnectError.from(res)); + return res.data; + }); /** * Build actions in parallel, in an offscreen window where we can run wasm. @@ -61,29 +62,36 @@ const buildActions = ( fullViewingKey: FullViewingKey, cancel: PromiseLike, ): Promise[] => { - const active = activateOffscreen(); - // eslint-disable-next-line @typescript-eslint/no-floating-promises + const activation = activateOffscreen(); + + // this json serialization involves a lot of binary -> base64 which is slow, + // so just do it once and reuse + const partialRequest = { + transactionPlan: transactionPlan.toJson() as Jsonified, + witness: witness.toJson() as Jsonified, + fullViewingKey: fullViewingKey.toJson() as Jsonified, + }; + const buildTasks = transactionPlan.actions.map(async (_, actionPlanIndex) => { - await active; - const buildRes = await sendOffscreenMessage({ + const buildReq: InternalRequest = { type: 'BUILD_ACTION', request: { - transactionPlan: transactionPlan.toJson() as Jsonified, - witness: witness.toJson() as Jsonified, - fullViewingKey: fullViewingKey.toJson() as Jsonified, + ...partialRequest, actionPlanIndex, - } satisfies ActionBuildRequest, - }); - if ('error' in buildRes) throw ConnectError.from(buildRes.error); - return Action.fromJson(buildRes.data); + }, + }; + + // wait for offscreen to finish standing up + await activation; + + const buildRes = await sendOffscreenMessage(buildReq); + return Action.fromJson(buildRes); }); void Promise.race([Promise.all(buildTasks), cancel]) - .catch( - // if we don't suppress this, we log errors when a user denies approval. - // real failures are already conveyed by the individual promises. - () => null, - ) + // suppress 'unhandled promise' logs - real failures are already conveyed by the individual promises. + .catch() + // this build is done with offscreen. it may shut down .finally(() => void releaseOffscreen()); return buildTasks;