feat(console-taming): `unhandledRejectionTrapping` after finalized
MODIFY
packages/ses/docs/lockdown.md
packages/ses/docs/lockdown.md
@@ -26,7 +26,8 @@ Each option is explained in its own section below.
| `localeTaming` | `'safe'` | `'unsafe'` | `toLocaleString` |
| `consoleTaming` | `'safe'` | `'unsafe'` | deep stacks |
| `errorTaming` | `'safe'` | `'unsafe'` | `errorInstance.stack` |
-| `errorTrapping` | `'platform'` | `'exit'` `'abort'` `'report'` | handling of uncaught exceptions |
+| `errorTrapping` | `'platform'` | `'exit'` `'abort'` `'report'` `'none'` | handling of uncaught exceptions |
+| `unhandledRejectionTrapping` | `'none'` | `'exit'` `'abort'` `'report'` | handling of finalized unhandled rejections |
| `evalTaming` | `'safeEval'` | `'unsafeEval'` `'noEval'` | `eval` and `Function` of the start compartment. |
| `stackFiltering` | `'concise'` | `'verbose'` | deep stacks signal/noise |
| `overrideTaming` | `'moderate'` | `'min'` or `'severe'` | override mistake antidote |
@@ -367,6 +368,52 @@ the container to exit explicitly, and we highly recommend setting
- `'none'`: do not install traps for uncaught exceptions. Errors are likely to
appear as `{}` when they are reported by the default trap.
+## `unhandledRejectionTrapping` Options
+
+**Background**: Same concerns as `errorTrapping`, but in addition, SES may be
+able to install platform-specific finalized (rather than just same-turn)
+unhandled rejection trapping.
+
+```js
+lockdown(); // defaults to 'none'
+// or
+lockdown({ unhandledRejectionTrapping: 'exit' }); // report and exit
+// or
+lockdown({ unhandledRejectionTrapping: 'abort' }); // report and drop a core dump
+// or
+lockdown({ unhandledRejectionTrapping: 'report' }); // just report
+// or
+lockdown({ unhandledRejectionTrapping: 'none' }); // explicitly no unhandled rejection traps
+```
+
+On the web, the `window` event emitter has a trap for `unhandledrejection` and
+`rejectionhandled` events. In the absence of a trap, the platform logs
+rejections that were not handled in the same turn in which they were created to
+the debugger console and continues. However, setting `errorTrapping` to
+`'exit'` or `'abort'` will cause the web equivalent of halting the page: the
+error will cause navigation to a blank page, immediately halting execution in
+the window.
+
+In Node.js, the `process` event emitter has a trap for `unhandledRejection` and
+`rejectionHandled`. In the absence of a trap, the platform logs rejections that
+were not handled in the same turn in which they were created, and potentially a
+scary warning that says unhandled rejections may cause the process to exit in a
+future release.
+
+By setting a non-`'none'` value for `unhandledRejectionTrapping`, the event
+handler will only be triggered by unhandled rejections when they are finalized.
+This enables the program to attach rejection handlers asynchronously without
+triggering the SES trap handler.
+
+- `'platform'`: same as `'report'` for web and Node.js
+- `'report'`: just report finalized unhandled rejections to the tamed console so stack traces appear.
+- `'exit'`: reports and exits on Node.js, reports and navigates away on the
+ web.
+- `'abort'`: reports and aborts a Node.js process, leaving a core dump for
+ postmortem analysis, reports and navigates away on the web.
+- `'none'`: do not install traps for finalized unhandled rejections. Errors are
+ likely to appear as `{}` when they are reported by the default trap.
+
## `evalTaming` Options
This option only affects the start compartment!

MODIFY
packages/ses/index.d.ts
packages/ses/index.d.ts
@@ -24,6 +24,12 @@ export interface LockdownOptions {
localeTaming?: 'safe' | 'unsafe';
consoleTaming?: 'safe' | 'unsafe';
errorTrapping?: 'platform' | 'exit' | 'abort' | 'report' | 'none';
+ unhandledRejectionTrapping?:
+ | 'platform'
+ | 'exit'
+ | 'abort'
+ | 'report'
+ | 'none';
errorTaming?: 'safe' | 'unsafe';
dateTaming?: 'safe' | 'unsafe'; // deprecated
mathTaming?: 'safe' | 'unsafe'; // deprecated

MODIFY
packages/ses/src/error/tame-console.js
packages/ses/src/error/tame-console.js
import { TypeError, globalThis } from '../commons.js';
import { loggedErrorHandler as defaultHandler } from './assert.js';
import { makeCausalConsole } from './console.js';
+import { makeRejectionHandlers } from './unhandled-rejection.js';
import './types.js';
import './internal-types.js';
@@ -17,11 +18,13 @@ const originalConsole = console;
*
* @param {"safe" | "unsafe"} consoleTaming
* @param {"platform" | "exit" | "abort" | "report" | "none"} [errorTrapping]
+ * @param {"platform" | "exit" | "abort" | "report" | "none"} [unhandledRejectionTrapping]
* @param {GetStackString=} optGetStackString
*/
export const tameConsole = (
consoleTaming = 'safe',
errorTrapping = 'platform',
+ unhandledRejectionTrapping = 'platform',
optGetStackString = undefined,
) => {
if (consoleTaming !== 'safe' && consoleTaming !== 'unsafe') {
@@ -53,34 +56,56 @@ export const tameConsole = (
// utter that. That unnecessary shim forces the whole bundle into sloppy mode,
// which in turn breaks SES's strict mode invariant.
+ // Disable the polymorphic check for the rest of this file. It's too noisy
+ // when dealing with platform APIs.
+ /* eslint-disable @endo/no-polymorphic-call */
+
// Node.js
if (errorTrapping !== 'none' && globalThis.process !== undefined) {
- // eslint-disable-next-line @endo/no-polymorphic-call
globalThis.process.on('uncaughtException', error => {
// causalConsole is born frozen so not vulnerable to method tampering.
- // eslint-disable-next-line @endo/no-polymorphic-call
causalConsole.error(error);
if (errorTrapping === 'platform' || errorTrapping === 'exit') {
- // eslint-disable-next-line @endo/no-polymorphic-call
globalThis.process.exit(globalThis.process.exitCode || -1);
} else if (errorTrapping === 'abort') {
- // eslint-disable-next-line @endo/no-polymorphic-call
globalThis.process.abort();
}
});
}
+ if (
+ unhandledRejectionTrapping !== 'none' &&
+ globalThis.process !== undefined
+ ) {
+ const handleRejection = reason => {
+ // 'platform' and 'report' just log the reason.
+ causalConsole.error('SES_UNHANDLED_REJECTION:', reason);
+ if (unhandledRejectionTrapping === 'exit') {
+ globalThis.process.exit(globalThis.process.exitCode || -1);
+ } else if (unhandledRejectionTrapping === 'abort') {
+ globalThis.process.abort();
+ }
+ };
+ // Maybe track unhandled promise rejections.
+ const h = makeRejectionHandlers(handleRejection);
+ if (h) {
+ // Rejection handlers are supported.
+ globalThis.process.on('unhandledRejection', h.unhandledRejectionHandler);
+ globalThis.process.on('rejectionHandled', h.rejectionHandledHandler);
+ globalThis.process.on('beforeExit', h.processTerminationHandler);
+ globalThis.process.on('exit', h.processTerminationHandler);
+ }
+ }
+
// Browser
if (
errorTrapping !== 'none' &&
globalThis.window !== undefined &&
globalThis.window.addEventListener !== undefined
) {
- // eslint-disable-next-line @endo/no-polymorphic-call
globalThis.window.addEventListener('error', event => {
- // eslint-disable-next-line @endo/no-polymorphic-call
event.preventDefault();
- // eslint-disable-next-line @endo/no-polymorphic-call
+ // 'platform' and 'report' just log the reason.
causalConsole.error(event.error);
if (errorTrapping === 'exit' || errorTrapping === 'abort') {
globalThis.window.location.href = `about:blank`;
@@ -88,5 +113,40 @@ export const tameConsole = (
});
}
+ if (
+ unhandledRejectionTrapping !== 'none' &&
+ globalThis.window !== undefined &&
+ globalThis.window.addEventListener !== undefined
+ ) {
+ const handleRejection = reason => {
+ causalConsole.error('SES_UNHANDLED_REJECTION:', reason);
+ if (
+ unhandledRejectionTrapping === 'exit' ||
+ unhandledRejectionTrapping === 'abort'
+ ) {
+ globalThis.window.location.href = `about:blank`;
+ }
+ };
+
+ const h = makeRejectionHandlers(handleRejection);
+ if (h) {
+ // Rejection handlers are supported.
+ globalThis.window.addEventListener('unhandledrejection', event => {
+ event.preventDefault();
+ h.unhandledRejectionHandler(event.reason, event.promise);
+ });
+
+ globalThis.window.addEventListener('rejectionhandled', event => {
+ event.preventDefault();
+ h.rejectionHandledHandler(event.promise);
+ });
+
+ globalThis.window.addEventListener('beforeunload', _event => {
+ h.processTerminationHandler();
+ });
+ }
+ }
+ /* eslint-enable @endo/no-polymorphic-call */
+
return { console: causalConsole };
};

ADD
packages/ses/src/error/unhandled-rejection.js
+// @ts-check
+import {
+ FinalizationRegistry,
+ Map,
+ mapGet,
+ mapDelete,
+ WeakMap,
+ mapSet,
+ finalizationRegistryRegister,
+ weakmapSet,
+ weakmapGet,
+ mapEntries,
+ mapHas,
+} from '../commons.js';
+
+/**
+ * Create rejection-tracking machinery compatible with Node.js and browsers.
+ *
+ * Note that modern browsers *prevent* access to the 'unhandledrejection' and
+ * 'rejectionhandled' events needed:
+ * - in cross-origin mode, like when served from file://
+ * - in the browser console (interactively typed-in code)
+ * - in the debugger
+ *
+ * Then, they just look like: `Uncaught (in promise) Error: ...` and don't
+ * implement the machinery.
+ *
+ * The solution is to serve your web page from an http:// or https:// web server
+ * and execute actual code.
+ *
+ * @param {(reason: unknown) => void} reportReason report the reason for an
+ * unhandled rejection.
+ */
+export const makeRejectionHandlers = reportReason => {
+ if (FinalizationRegistry === undefined) {
+ return undefined;
+ }
+
+ /** @typedef {number} ReasonId */
+ let lastReasonId = 0;
+
+ /** @type {Map<ReasonId, unknown>} */
+ const idToReason = new Map();
+
+ /** @type {(() => void) | undefined} */
+ let cancelChecking;
+
+ const removeReasonId = reasonId => {
+ mapDelete(idToReason, reasonId);
+ if (cancelChecking && idToReason.size === 0) {
+ // No more unhandled rejections to check, just cancel the check.
+ cancelChecking();
+ cancelChecking = undefined;
+ }
+ };
+
+ /** @type {WeakMap<Promise, ReasonId>} */
+ const promiseToReasonId = new WeakMap();
+
+ /**
+ * Clean up and report the reason for a GCed unhandled rejection.
+ *
+ * @param {ReasonId} heldReasonId
+ */
+ const finalizeDroppedPromise = heldReasonId => {
+ if (mapHas(idToReason)) {
+ const reason = mapGet(idToReason, heldReasonId);
+ removeReasonId(heldReasonId);
+ reportReason(reason);
+ }
+ };
+
+ /** @type {FinalizationRegistry<ReasonId>} */
+ const promiseToReason = new FinalizationRegistry(finalizeDroppedPromise);
+
+ /**
+ * Track a rejected promise and its corresponding reason if there is no
+ * rejection handler synchronously attached.
+ *
+ * @param {unknown} reason
+ * @param {Promise} pr
+ */
+ const unhandledRejectionHandler = (reason, pr) => {
+ lastReasonId += 1;
+ const reasonId = lastReasonId;
+
+ // Update bookkeeping.
+ mapSet(idToReason, reasonId, reason);
+ weakmapSet(promiseToReasonId, pr, reasonId);
+ finalizationRegistryRegister(promiseToReason, pr, reasonId, pr);
+ };
+
+ /**
+ * Deal with the addition of a handler to a previously rejected promise.
+ *
+ * Just remove it from our list. Let the FinalizationRegistry or
+ * processTermination report any GCed unhandled rejected promises.
+ *
+ * @param {Promise} pr
+ */
+ const rejectionHandledHandler = pr => {
+ const reasonId = weakmapGet(promiseToReasonId, pr);
+ removeReasonId(reasonId);
+ };
+
+ /**
+ * Report all the unhandled rejections, now that we are abruptly terminating
+ * the agent cluster.
+ */
+ const processTerminationHandler = () => {
+ for (const [reasonId, reason] of mapEntries(idToReason)) {
+ removeReasonId(reasonId);
+ reportReason(reason);
+ }
+ };
+
+ return {
+ rejectionHandledHandler,
+ unhandledRejectionHandler,
+ processTerminationHandler,
+ };
+};

MODIFY
packages/ses/src/lockdown-shim.js
packages/ses/src/lockdown-shim.js
@@ -160,6 +160,10 @@ export const repairIntrinsics = (options = {}) => {
const {
errorTaming = getenv('LOCKDOWN_ERROR_TAMING', 'safe'),
errorTrapping = getenv('LOCKDOWN_ERROR_TRAPPING', 'platform'),
+ unhandledRejectionTrapping = getenv(
+ 'LOCKDOWN_UNHANDLED_REJECTION_TRAPPING',
+ 'platform',
+ ),
regExpTaming = getenv('LOCKDOWN_REGEXP_TAMING', 'safe'),
localeTaming = getenv('LOCKDOWN_LOCALE_TAMING', 'safe'),
consoleTaming = getenv('LOCKDOWN_CONSOLE_TAMING', 'safe'),
@@ -286,18 +290,23 @@ export const repairIntrinsics = (options = {}) => {
const intrinsics = finalIntrinsics();
- // Wrap console unless suppressed.
- // At the moment, the console is considered a host power in the start
- // compartment, and not a primordial. Hence it is absent from the whilelist
- // and bypasses the intrinsicsCollector.
+ /**
+ * Wrap console unless suppressed.
+ * At the moment, the console is considered a host power in the start
+ * compartment, and not a primordial. Hence it is absent from the whilelist
+ * and bypasses the intrinsicsCollector.
+ *
+ * @type {((error: any) => string | undefined) | undefined}
+ */
let optGetStackString;
if (errorTaming !== 'unsafe') {
optGetStackString = intrinsics['%InitialGetStackString%'];
}
const consoleRecord = tameConsole(
- // @ts-ignore tameConsole does its own input validation
+ // @ts-expect-error2 tameConsole does its own input validation
consoleTaming,
errorTrapping,
+ unhandledRejectionTrapping,
optGetStackString,
);
globalThis.console = /** @type {Console} */ (consoleRecord.console);

feat(console-taming): `unhandledRejectionTrapping` after finalized