Skip to content

Commit

Permalink
capture unhandled promise rejections in offscreen (#928)
Browse files Browse the repository at this point in the history
  • Loading branch information
turbocrime committed Apr 15, 2024
1 parent 9f4c112 commit 8f3c1c5
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 43 deletions.
57 changes: 37 additions & 20 deletions apps/extension/src/entry/offscreen-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>();
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;
Expand All @@ -28,24 +41,28 @@ chrome.runtime.onMessage.addListener((req, _sender, respond) => {
});

const spawnActionBuildWorker = (req: ActionBuildRequest) => {
const { promise, resolve, reject } = Promise.withResolvers<ActionBuildResponse>();

const worker = new Worker(new URL('../wasm-build-action.ts', import.meta.url));
return new Promise<ActionBuildResponse>((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;
};
54 changes: 31 additions & 23 deletions packages/services/src/offscreen-client.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -45,10 +45,11 @@ const releaseOffscreen = async () => {
if (!--active) await chrome.offscreen.closeDocument();
};

const sendOffscreenMessage = async <T extends OffscreenMessage>(
req: InternalRequest<T>,
): Promise<InternalResponse<T>> =>
chrome.runtime.sendMessage<InternalRequest<T>, InternalResponse<T>>(req);
const sendOffscreenMessage = async <T extends OffscreenMessage>(req: InternalRequest<T>) =>
chrome.runtime.sendMessage<InternalRequest<T>, InternalResponse<T>>(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.
Expand All @@ -61,29 +62,36 @@ const buildActions = (
fullViewingKey: FullViewingKey,
cancel: PromiseLike<never>,
): Promise<Action>[] => {
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<TransactionPlan>,
witness: witness.toJson() as Jsonified<WitnessData>,
fullViewingKey: fullViewingKey.toJson() as Jsonified<FullViewingKey>,
};

const buildTasks = transactionPlan.actions.map(async (_, actionPlanIndex) => {
await active;
const buildRes = await sendOffscreenMessage<ActionBuildMessage>({
const buildReq: InternalRequest<ActionBuildMessage> = {
type: 'BUILD_ACTION',
request: {
transactionPlan: transactionPlan.toJson() as Jsonified<TransactionPlan>,
witness: witness.toJson() as Jsonified<WitnessData>,
fullViewingKey: fullViewingKey.toJson() as Jsonified<FullViewingKey>,
...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;
Expand Down

0 comments on commit 8f3c1c5

Please sign in to comment.