Preserve AsyncLocalStorage context in unhandledRejection handlers - #31721
Preserve AsyncLocalStorage context in unhandledRejection handlers#31721robobun wants to merge 19 commits into
Conversation
WalkthroughThis PR captures the async context when promises are rejected and restores that context while invoking unhandled-rejection handlers so AsyncLocalStorage values are observable in those handlers. ChangesAsync context preservation for unhandled promise rejections
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 10:18 AM PT - Jul 18th, 2026
❌ @autofix-ci[bot], your commit 30e1f88 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 31721That installs a local version of the PR into your bun-31721 --bun |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/async_hooks/AsyncLocalStorage.test.ts`:
- Around line 590-596: The test currently sets proc with stderr: "pipe" but
never consumes it; update the Promise.all consumer to drain stderr as well by
awaiting proc.stderr.text() (e.g., const [stdout, stderr, exitCode] = await
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])); then add an
assertion or include stderr in the failure diagnostics (for example
expect(stderr).toBe("") or include it in the test error messages) so the pipe
cannot fill and test failures surface stderr content; locate and change the code
around the proc usage and the existing stdout/exitCode awaits.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 674e4315-278d-4d16-ad2e-4908415d8e51
📒 Files selected for processing (4)
src/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.htest/js/node/async_hooks/AsyncLocalStorage.test.tstest/js/node/async_hooks/async-context/async-context-unhandled-rejection.js
There was a problem hiding this comment.
Thanks — the contextless-drain edge case and the stderr nit are both addressed; no further issues found. Leaving final sign-off to a maintainer since this touches the core promise-rejection tracker and GC-visible state in ZigGlobalObject.
Extended reasoning...
Overview
This PR makes unhandledRejection handlers observe the AsyncLocalStorage context that was active when the promise was rejected, matching Node.js. It changes m_aboutToBeNotifiedRejectedPromises from WriteBarrierList<JSPromise> to WriteBarrierList<JSCell> so entries can be either a raw JSPromise or an AsyncContextFrame wrapping the promise plus the captured context. promiseRejectionTracker snapshots the context on Reject and unwraps on Handle; handleRejectedPromises() installs the captured context (or jsUndefined() for contextless entries when tracking is enabled) around Bun__handleRejectedPromise, then restores. Two new tests cover the behavior, including a dual-runtime fixture run against both bun and node.
Security risks
None identified. This is a behavioral correctness fix for async-context propagation; no auth, crypto, untrusted input parsing, or privilege boundaries are involved.
Level of scrutiny
Moderate-to-high. ZigGlobalObject.cpp is core runtime: the promise-rejection tracker fires on every unhandled rejection, the pending list is GC-visited, and the drain now does uncheckedDowncast<JSPromise> based on the invariant that only JSPromise or AsyncContextFrame cells are ever appended. The implementation closely mirrors the existing pattern in NodeTimerObject.cpp (same m_asyncContextData save/install/restore dance) and m_asyncContextData.get() is already used unguarded throughout the file, so the new code is consistent with established idioms. The WriteBarrierList<JSCell> is still visited at the existing m_aboutToBeNotifiedRejectedPromises.visit(...) site, and AsyncContextFrame visits its own callback/context barriers, so GC reachability looks correct. Still, changing the cell type of a GC-visited container plus adding an allocation inside promiseRejectionTracker is the kind of thing a Bun/JSC maintainer should eyeball.
Other factors
- My previous inline nit (contextless rejections leaking the ambient drain-time context when
handleRejectedPromises()is re-entered from insideals.run()) was fixed in 8faa3ca by installingjsUndefined()for raw entries when tracking is enabled — verified in the current diff. - CodeRabbit's stderr-drain suggestion was applied in 19f2869. All review threads are resolved.
- The bug-hunting pass on the current revision found nothing.
- The two CI failures reported by robobun (FreeBSD linker warning, musl build-bun) are in
scripts/build/ci.tsinfrastructure and appear unrelated to this change. - Test coverage is solid: a direct subprocess test plus a dual-runtime fixture covering sync rejections in two stores, a no-context rejection, and a timer-deferred rejection.
|
Status: complete end-to-end, including the three issues the pushback review found; waiting on oven-sh/WebKit#268 merging.
Every clause is mutation-tested: reverting either Remaining action (maintainer): merge oven-sh/WebKit#268, then re-pin from the preview tag to its merge commit's autobuild tag before this lands. I'll push the re-pin the moment it merges. pushback findings (comment here): three should-fix concerns survived both refutation rounds. All three verified empirically, all three fixed. One caught a real hole in my WebKit audit — CI on 30e1f88 (build #75466): only owned main breaks — |
2475297 to
035b030
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
alii
left a comment
There was a problem hiding this comment.
Thanks — the core design here (snapshot the rejection-time context onto the pending entry, install it around the whole per-mode dispatch) is the right shape, and I verified it matches what Node >= 24 does in lib/internal/process/promises.js (store AsyncContextFrame.current() at rejection time, exchange() around the emit). Requesting changes for one blocker and five smaller items; details with file:line traces in the inline comments.
The blocker: the headline scenario — als.run(id, async () => { await x; throw e }), i.e. an async function failing after an await, which is the most common unhandled rejection there is — still observes getStore() === undefined with this PR, and no test in the PR can notice. The root cause is an ordering inconsistency in our WebKit fork: JSMicrotask.cpp's AsyncFunctionResume error branch restores the async context before promise->reject(), while PromiseReactionJob a page above settles first and restores after (and has a comment saying exactly why). Your snapshot hook fires inside that reject, so it reads the already-popped context. Every rejection in the test matrix is a synchronous Promise.reject or a setTimeout callback — the two paths that already keep the context installed — so the suite passes and the node-parity fixture certifies a case it never distinguishes. Fixing this properly is a one-line ordering change in the WebKit fork (plus the same audit on the sibling async-generator/finally branches) and a pin bump in this PR — details on the inline comment.
Summary of asks:
- (blocker) Fix the
AsyncFunctionResumerestore-vs-reject ordering in the WebKit fork + bump the pin here; addawait-throw / awaited-native-rejection / escaped-async-fn legs to the fixture and confirm they fail first. - Replace the hand-rolled Reject block with the existing
AsyncContextFrame::withAsyncContextIfNeededhelper (drops a redundant flag gate and a dead null check). - Make each of the two restore mechanisms in
handleRejectedPromises()individually load-bearing under test — today either one can be deleted and every test still passes. - Add the one case that pins the semantic this PR chooses (rejection-time vs creation-context — they differ, and Node itself flipped between 22 and >= 24), and note the Node-version dependency: the parity harness runs an unpinned system
node. - Cover the
Handle-path unwrap sites with a frame-wrapped entry (they currently have zero coverage, and reverting either one silently regresses #32554). - Don't run the microtask drain + GC inside the installed-context window (
--unhandled-rejections=warn|strict|throw|noneall do today); add astrict-mode test.
Happy to re-review quickly. The design is right — the blocker is that it doesn't yet handle the case it was built for, and the test matrix was (accidentally) constructed so it can't tell.
| if (auto* asyncContextData = globalObj->m_asyncContextData.get()) { | ||
| JSC::JSValue context = asyncContextData->getInternalField(0); | ||
| if (!context.isUndefined()) | ||
| entry = AsyncContextFrame::create(obj->vm(), globalObj->AsyncContextFrameStructure(), promise, context); |
There was a problem hiding this comment.
Blocker. This snapshot is taken too late for the most important rejection shape: an async function that throws (or awaits a rejection) after its first await.
Trace, at this PR's pinned WebKit (scripts/build/deps/webkit.ts → the vendor/WebKit checkout):
JSMicrotask.cpp,case InternalMicrotask::AsyncFunctionResume, error branch (~1964–1973): it doesasyncContextData->putInternalField(vm, 0, restoreAsyncContext)and thenpromise->reject(vm, error).promise->reject()→rejectPromise()→promiseRejectionTracker(..., Reject)fires synchronously.- So by the time this line reads
m_asyncContextData->getInternalField(0), the async function's[als, ctx]has already been popped back to the outer (usually undefined) value → the entry is stored raw → theunhandledRejectionlistener seesundefined.
Contrast PromiseReactionJob (~1832 in the same file), which settles first and restores after, with the comment "Note: Keep async context active during resolvePromise/rejectPromise …". That's why the PR's two sync fixtures and the timer fixture work — they never go through AsyncFunctionResume. The one path with the inverted order is the one path with no coverage, and it's the canonical one:
als.run(7, async () => { await Bun.sleep(5); throw new Error("late"); });
// with this PR: unhandledRejection sees undefined. Node prints 7.Two asks:
- Add these legs to
async-context-unhandled-rejection.js(they run against Node too, so they double as the parity proof) and confirm they fail on the current PR build before changing anything:als.run({test:"await-throw"}, async () => { await sleep(5); throw new Error("await-throw"); })als.run({test:"await-native-reject"}, async () => { await fetch("http://127.0.0.1:1/"); })const p = als.run(ctx, () => asyncFn())with nobody catchingp
- Fix the root cause in the WebKit fork rather than working around it here: in
AsyncFunctionResume, restore the async context afterpromise->reject()/promise->resolve(), matchingPromiseReactionJob's documented ordering, and bump the pin in this PR. While there, please audit the sibling terminal branches (theExecutingresolve arm,AsyncGeneratorBodyCall*,PromiseFinallyReactionJob) for the same restore-before-settle inversion — it's the same bug class.
Without the WebKit half, this feature returns undefined for its own motivating case while shipping a green node-parity fixture.
There was a problem hiding this comment.
Confirmed and fixed upstream: oven-sh/WebKit#268.
You're right on every point. AsyncFunctionResume restores the slot at JSMicrotask.cpp:1990-1991 and then calls promise->reject() at 1994, while PromiseReactionJob a few cases above settles first and restores after (with the comment explaining why). Reproduced with the full bun-side fix in place and the current pin:
FAIL: unhandledRejection for "await-throw" observed store null, expected "await-throw"
(the sync, no-context and timer legs all pass at that point, which is exactly why the old matrix couldn't see it).
The WebKit PR settles before restoring in both terminal arms. I audited every putInternalField(vm, 0, restoreAsyncContext) site in the file: only those two were inverted. AsyncGeneratorYieldAwaited, AsyncGeneratorBodyCallNormal/Return, AsyncGeneratorAwaitReturnContinuation, PromiseFinallyReactionJob and the await-continuation arm all already restore after their call. It doesn't change what .then()/.catch() handlers observe, since performPromiseThen() captures at registration time; what changes is the tracker and any thenable job the settle schedules.
The three legs are in async-context/async-context-unhandled-rejection-async-fn.js (await-throw, awaited native rejection via fs.promises.readFile of a missing path, escaped async fn). They pass on Node and fail on the current pin, so the file is in the tracking test's todos with a comment pointing at the WebKit PR — I'll drop the todo and bump WEBKIT_VERSION in this PR as soon as #268 merges and an autobuild tag exists. Worth flagging: the preview-build workflow on oven-sh/WebKit currently fails before building (actions/github-script@v7 isn't SHA-pinned and the org requires that), so I can't pin a preview tag in the meantime.
There was a problem hiding this comment.
Pin bumped in 0cde717 — the blocker is closed end-to-end.
oven-sh/WebKit#268's preview build is green across all 43 artifacts, so WEBKIT_VERSION now points at autobuild-preview-pr-268-ee98a203 and the async-fn fixture is out of todos. (The preview workflow had been failing for an unrelated reason — actions/github-script@v7 wasn't SHA-pinned; WebKit main fixed that in #269, so rebasing my branch onto it was enough.) Re-pin to the autobuild tag of the merge commit before this lands — happy to do that the moment #268 merges.
A/B on the same tree, flipping only the pin:
| old pin | preview pin | |
|---|---|---|
await-throw, awaited native rejection, escaped async fn |
FAIL: ... observed store null |
pass (bun and node) |
AsyncLocalStorage-tracking |
75 pass / 2 todo | 76 pass / 1 todo |
I also baselined the only other thing that moved locally: three setTimeout doesn't leak ... RSS-threshold tests fail under debug+ASAN on both pins, so they're pre-existing and unrelated. That matches the code — without an AsyncLocalStorage active, asyncContextData is null and the reorder is inert, so nothing outside the rejection paths can move.
Also smoke-tested the engine change beyond this feature: all 36 test-promise*/test-async-*/test-microtask* parallel tests, event-emitter, timers.promises and the async_hooks suites are green.
There was a problem hiding this comment.
Update: the AsyncFunctionResume half landed independently as oven-sh/WebKit#295, so #268 was rebased onto it and now carries only the PromiseFinallyAwaitJob fix. bun's pin is bumped to the new preview (86efe056, on top of #295). All 7 async-fn fixture cases pass on bun and node. Still open: re-pin to #268's merge-commit tag once it lands.
|
@robobun please address the review feedback above (#31721 (review)). Suggested order: start with the blocker's WebKit half — fix the restore-vs-settle ordering in |
b92fd1b to
2c91bb5
Compare
|
Thanks — this was a genuinely good catch, and the test matrix was constructed so it couldn't see the blocker. All six items are addressed; details in the inline replies. The blockerConfirmed exactly as you described, and fixed upstream in oven-sh/WebKit#268. I audited every Fail-before, with the complete bun-side fix and the current pin: The The rest
Every clause was mutation-tested: reverting either unwrap, deleting the restore-after-dispatch, dropping the replay-undefined branch, or removing the drain guard each breaks at least one test. The one thing I couldn't cover is the termination-path restore — it only runs while the VM is being torn down, so there's no point at which JS could observe the slot; that's stated in the description rather than tested. One correction to your reproduction note for the replay-undefined branch: under No test timeouts were raised. |
|
Pin bumped in 0cde717 — the blocker is now fixed end-to-end in CI, not just argued for. oven-sh/WebKit#268's preview build went green across all 43 artifacts, so A/B on the same tree, flipping only the pin:
I baselined the only other thing that moved locally, too: three One thing left, and it needs a human: the pin points at a preview tag, so oven-sh/WebKit#268 has to merge and then this wants a re-pin to its merge commit's autobuild tag. I've left that review thread open as the reminder and I'm happy to push the re-pin the moment it lands. |
0cde717 to
3c4e8a0
Compare
pushback resultsRan 1. A throwing
|
6553c8c to
0973a36
Compare
2481dc8 to
d28503d
Compare
d28503d to
70602eb
Compare
When a promise is rejected with no handler, the rejection is queued and the "unhandledRejection" event is only emitted at the end of the tick, after the async context that was live at rejection time has been unwound — so AsyncLocalStorage.getStore() returned undefined inside the listener. Snapshot the async context in the promise rejection tracker (wrapping the promise in an AsyncContextFrame when a context is active) and reinstall it around the unhandledRejection dispatch, mirroring how timers keep the async context installed while reporting an uncaught exception. Matches Node.js behavior.
When async context tracking is enabled, promises rejected with no active context must also install (undefined) around the unhandledRejection dispatch, so a drain re-entered from inside a context does not leak the caller's store into the listener.
…the installed window, harden tests - promiseRejectionTracker(Reject) now goes through AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it, dropping a redundant tracking-enabled gate and a dead null check. - unhandled_rejection()'s microtask drains and the auto-GC that follows the dispatch no longer run with the rejected promise's async context installed. Node drains outside its exchange window, and the propagation machinery assumes the ambient context is undefined during a top-level drain. - Tests: make each restore clause individually load-bearing (a contextless rejection drained from inside a context; a drain that must restore the slot afterwards), pin the rejection-time semantic against the creation-time one, cover the frame-wrapped Handle path in both unwrap sites (same-tick catch, plus the #32554 regression test parametrized with AsyncLocalStorage), and assert --unhandled-rejections=strict keeps the context for uncaughtException but not for the drain that follows.
oven-sh/WebKit#268 settles the async function's promise before restoring the async context, so a function that fails after an await reports its rejection with its own context still installed. Un-skips the async-fn fixture, which now passes on bun and node alike. Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps the shared-allocator change from #34009). Re-pin to the autobuild tag of its merge commit once it lands on WebKit main.
…ches uncaughtException A throwing unhandledRejection listener is reported as an uncaught exception, and Node's processPromiseRejections restores the previous frame in a finally before that throw propagates to triggerUncaughtException — so the uncaughtException handler does not see the promise's store. Bun's EventEmitter catches listener throws inside emit and reports them there, which was inside the installed window. Add an emit overload that returns the exception instead of reporting it. Bun__handleUnhandledRejection uses it and reports the throw itself after clearing the slot, which matches Node. handleRejectedPromises also now restores before reportUncaughtExceptionAtEventLoop for exceptions that escape the whole dispatch. Also clear the slot around the isBunTest early-return so the test runner's handler (and anything it drives) never observes the promise's context. Dual-runtime tests cover the throwing-listener case, and a spawned `bun test` fixture covers the isBunTest path.
…fixture cases The claim that these arms were already correct was wrong: a .finally() callback that returns a rejected thenable settles from PromiseFinallyAwaitJob, which did not carry the async context across, so the unhandledRejection handler observed undefined. Fixed alongside the AsyncFunctionResume ordering in oven-sh/WebKit#268. Add fixture cases for both .finally() shapes and two async-generator shapes. The file is back in the tracking test's todos until the WebKit pin picks up the PromiseFinallyAwaitJob fix.
oven-sh/WebKit#268's second commit carries the async context through PromiseFinallyAwaitJob, so a .finally() callback that returns a rejected thenable now reports its unhandled rejection with the callback's store. Un-skips the async-fn fixture (now 7 cases, all passing on bun and node). Also makes the "unhandledRejection async context" block concurrent — 8 hermetic subprocess spawns, so there's no reason to run them sequentially.
The emit overload that returns the listener's throw (instead of reporting it) also stops calling later listeners, which is what Node does — Node's emit lets the throw propagate. The old path continued to the next listener after reporting. Add a second listener to the throwing-listener test so that Node-parity fix is load-bearing.
…tures On a fast release build the setImmediate poll could run 10000 iterations before a 10ms timer fired, so the fixture timed out waiting for a rejection that was still pending. The bailout is only a safety net; use a 30s wall-clock deadline instead of an iteration count.
The AsyncFunctionResume settle-ordering fix landed independently as oven-sh/WebKit#295, so #268 was rebased onto it and now carries only the PromiseFinallyAwaitJob fix. Pin to the new preview (on top of e5f7fc2b).
…tion with the slot cleared
Add a persistent enterWith("Y") to the throwing-listener test so it
distinguishes "cleared to undefined" from "restored to the drain's
ambient". Node v26's uncaughtException handler observes undefined here,
so clearing is the Node-matching choice; the test now runs the
distinguishing case against both runtimes. Rewrite the comment to state
that rather than "restores the previous context", which was ambiguous.
oven-sh/WebKit#268 rebased onto WebKit main a8d15c1c and rewritten to use the AsyncContextSwapScope helper from #301, so it now matches every other microtask case (one wrapWithCurrent at the schedule point, one unwrapContextTuple + RAII scope in the case). Same behaviour, 19 lines instead of 51.
70602eb to
30e1f88
Compare
Problem
AsyncLocalStoragecontext propagates intouncaughtExceptionhandlers, butunhandledRejectionhandlers observedgetStore() === undefined:Cause
Zig::GlobalObject::promiseRejectionTrackeronly recorded the promise. TheunhandledRejectionevent is emitted later, from the end-of-tick drain (handleRejectedPromises()), by which point the context that was live at rejection time has been unwound.uncaughtExceptionworks because timers report the error before restoring the context.Fix
promiseRejectionTracker(Reject)snapshots the async context via the existingAsyncContextFrame::withAsyncContextIfNeeded, so a rejection raised inside a context is queued as anAsyncContextFramewrapping the promise. The pending list holdsJSCells; both places that match a promise against it unwrap through one helper.handleRejectedPromises()installs the captured context around the dispatch and restores it afterwards. A promise rejected with no context replays "no context" rather than inheriting whatever the drain happened to be running under (the drain is re-entrant:expect(fn).toThrow()drains synchronously).unhandledRejectionlistener reachesuncaughtExceptionwith the slot restored (Node'sprocessPromiseRejectionsrestores in afinallybefore the throw propagates). Theemitpath used forunhandledRejectionnow returns the listener's throw to the caller instead of reporting it inside, which also means a throwing listener now halts later listeners — that is what Node'semitdoes; previously Bun continued to the next listener after reporting.unhandled_rejection()and the auto-GC that follows the dispatch run with the context cleared. Node exchanges the context frame around the per-mode dispatch only, and the propagation machinery assumes the ambient slot is undefined during a top-level drain (JSNextTickQueueresets it after draining).Which semantic this pins: the context the promise was rejected in, not the one it was created in. That matches Node >= 24 and Node 22 with
--experimental-async-context-frame, which storeAsyncContextFrame.current()at rejection time andexchange()around the emit (lib/internal/process/promises.js). Node 22's defaultasync_hooks-basedAsyncLocalStoragereplays the creation context instead, so the dual-runtime fixtures only cover cases where the two agree;AsyncLocalStorage.test.tspins the distinguishing case against Bun alone.Depends on oven-sh/WebKit#268
Two JSC microtask cases settled without the async context the call belonged to:
InternalMicrotask::AsyncFunctionResumerestored the async context beforepromise->reject()/promise->resolve()— andreject()invokes the rejection tracker synchronously, so an async function failing after anawaitreported an already-popped slot.PromiseReactionJobsettles first and restores after, with a comment explaining why. Fixed in Keep async context active while settling the async function promise WebKit#295 (merged).InternalMicrotask::PromiseFinallyAwaitJob(phase 2 of.finally()— the callback's returned thenable settles) neither captured the context at schedule time nor installed it in its case, so.finally(() => Promise.reject(e))observedundefined. This is what Use Node.js v18.x from NodeSource to use string.replaceAll method #268 now carries — it captures the context alongside the reaction inpromiseFinallyReactionJoband installs it in the phase-2 case, the wayPromiseFinallyReactionJob(phase 1) already does.The
AsyncGenerator*branches andPromiseFinallyReactionJobwere already correct;PromiseFinallyAwaitJobwas not, which an earlier version of this description got wrong.WEBKIT_VERSIONpoints at #268's preview build (autobuild-preview-pr-268-5f70edce, on top of WebKita8d15c1c— which already has #295 merged and #301'sAsyncContextSwapScopeRAII helper, which #268 now uses). Re-pin to the autobuild tag of #268's merge commit before this merges. A/B against the two pins, same tree:4895f45d)await-throw / awaited native rejection / escaped async fnFAIL: ... observed store null.finally(() => Promise.reject(e))FAIL: ... observed store nullAsyncLocalStorage-trackingWithout an
AsyncLocalStorageactive the reorder is inert — the restore is guarded by a pointer that is only non-null when the continuation captured a context — which is why nothing outside the rejection paths moves.Verification
Fixtures under
async-context/run against both Bun and Node:async-context-unhandled-rejection.js: sync rejections in two different stores, a rejection with no context, a timer-deferred rejection, and a final in-context rejection followed by a context-free poll that fails if the drain leaks a context.async-context-unhandled-rejection-async-fn.js(todo, see above):await-then-throw, an awaited native rejection, and an escaped async function.Bun-only, in
AsyncLocalStorage.test.ts: the rejection-time vs creation-time semantic, a same-tick.catch()emitting neitherunhandledRejectionnorrejectionHandled, a contextless rejection drained from inside a context, and--unhandled-rejections=strictkeeping the context foruncaughtExceptionbut not for the drain that follows (also run against Node).test/js/node/process/process.test.js's #32554 regression test is parametrized withAsyncLocalStorageso every rejection is frame-wrapped, covering bothHandle-path unwrap sites.Each clause was mutation-tested: reverting either unwrap, deleting the restore-after-dispatch, dropping the replay-undefined branch, or removing the drain's context-clearing guard each breaks at least one test. The termination-path restore (
isTerminationException) has no test: it only runs while the VM is being torn down, so there is no point at which JS could observe the slot.No regressions in
test/js/node/async_hooks/(77 incl. 2 todo),process.test.js, the 14test-promise*unhandled*/*rejection*parallel tests (every--unhandled-rejectionsmode plusrejectionHandled),event-emitter, andtimers.promises.no test proof · iteration 28 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process.test.js