Skip to content

Follow finally() in the async stack of a native rejection - #43014

Open
robobun wants to merge 3 commits into
mainfrom
robobun/a33941c4/async-stack-through-finally
Open

robobun wants to merge 3 commits into
mainfrom
robobun/a33941c4/async-stack-through-finally

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • When a reaction's context is a JSSlimPromiseReaction record, the walk follows record->promise(), the promise finally() returned. JSC's own reaction walk (importPromiseGatesAsyncDependency) does the same.
  • The walk reads the context once per promise through JSPromise::asyncStackTraceContext(), which covers the inline reaction and the head of the list.
  • This has to land before the WebKit bump that carries JSC: propagate async context through PromiseFinallyAwaitJob and PromiseResolveWithoutHandlerJob WebKit#268. That change moves the second finally() phase to the same layout. Two of the new tests guard that phase.
  • Verified: test/js/node/fs/promises.test.js, 8 new tests, 6 fail on 1.4.3-canary. Also bun-file.test.ts and streams.test.js.

Background

  • A native rejection (fs, Bun.file, streams) has no JS call stack. Bun__attachAsyncStackFromPromise builds one from the async functions that await the promise.
  • A pending promise keeps its reactions inline or in a list. A reaction has a cell slot and a context slot. For await, the context is the async function's generator.
  • The finally() fast path registers an internal reaction whose context is a small record. The record holds the promise finally() returned.
Notes

Versions. The same probe (await readFile(missing).finally(() => {})): 1.4.0 prints at async frames, 1.4.1 and 1.4.3-canary c6b7fcb5b give e.stack === undefined, this branch prints the frames, with and without an AsyncLocalStorage context. oven-sh/WebKit#552 changed promiseProtoFuncFinally to register PromiseFinallyReactionJob with no cell.

Tests. The two tests named "the promise the callback returned rejects" pass on main. They are guards for oven-sh/WebKit#268. With that change linked in and without this fix, that phase loses its frames inside AsyncLocalStorage.run(). With this fix all 8 tests pass on it, also on its published preview build (autobuild-preview-pr-268-2cb7572a).

Not covered, on purpose. This PR follows finally() only. The walk still reads only the newest reaction of a promise, so a handler attached after the awaiting reaction (const done = p.finally(cb); p.catch(log); await done, or the same with a plain await) hides it. That is the same on 1.4.0. The walk also still stops at Promise.all / allSettled / any / race (the file header says so), at custom thenables and at Promise subclasses. Errors thrown from JS get their async stack from JSC's Interpreter::getAsyncStackTrace, not from this walk (#23760). Native sites that do not call reject_with_async_stack, and fetch aborts and timeouts, get no async stack with or without this PR.

Follow-up. JSPromise::forEachPendingReaction (in the fork since oven-sh/WebKit#552) reports every reaction with its task kind. A port of this walk to it removes the layout knowledge that broke here, and these lines with it.

Other open PRs on this file. #38074 changes Bun__attachAsyncStackFromPromise and does not overlap. #35685 rewrites the two lines where the walk reads the context. Whichever of the two lands second needs a small rebase in that loop.

Self-review. The review asked for the regression framing, the mid-chain test, the reaction-list test, assertions on the whole leading frame list and one read of the context per promise. All are in.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 6 failed, 5 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/fs/promises.test.js
bun test v1.4.3 (c6b7fcb5b)

test/js/node/fs/promises.test.js:
(pass) should exist [26.14ms]
(pass) should be enumerable [2.30ms]
(pass) access > should work [9.25ms]
(pass) access > should fail on non-existant files [23.51ms]
(skip) access > should fail on non-existant modes
(skip) access > should fail on object as the 2nd argument
(pass) open > should work [50.07ms]
(pass) open > should return an object [14.47ms]
(pass) open > should be closable [4.71ms]
(pass) more > is an object [44.37ms]
(pass) more > stat [58.99ms]
(skip) more > statfs
(skip) more > statfs bigint
(skip) more > 
(pass) writing to file in append mode works [66.12ms]
(pass) appendFile with flag 'ax' rejects with EEXIST on an existing file [24.54ms]
(pass) errors from fs.promises include async stack frames [19.84ms]
267 | 
268 |   test("the promise finally() was called on rejects", async () => {
269 |     async function finallyOnRejected() {
270 |       await readFile(missing).finally(() => {});
271 |     }
272 |     expect(await async
... (truncated)

release without fix: 6 failed, 5 skipped
bun test v1.4.3-canary.1 (c6b7fcb5b)

test/js/node/fs/promises.test.js:
(pass) should exist [0.05ms]
(pass) should be enumerable [0.02ms]
(pass) access > should work [0.20ms]
(pass) access > should fail on non-existant files [0.23ms]
(skip) access > should fail on non-existant modes
(skip) access > should fail on object as the 2nd argument
(pass) open > should work [0.78ms]
(pass) open > should return an object [0.18ms]
(pass) open > should be closable [0.08ms]
(pass) more > is an object [1.20ms]
(pass) more > stat [8.00ms]
(skip) more > statfs
(skip) more > statfs bigint
(skip) more > 
(pass) writing to file in append mode works [7.52ms]
(pass) appendFile with flag 'ax' rejects with EEXIST on an existing file [1.12ms]
(pass) errors from fs.promises include async stack frames [0.44ms]
267 | 
268 |   test("the promise finally() was called on rejects", async () => {
269 |     async function finallyOnRejected() {
270 |       await readFile(missing).finally(() => {});
271 |     }
272 |     expect(await asyncFramesOf(finallyOnRejected)).toEqual(["finallyOnRejected", "asyncFramesOf"]);
                                                         ^
error: expect(received).toEq
... (truncated)
passes on PR (with fix)
ASAN with fix: 5 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/fs/promises.test.js
bun test v1.4.3 (c6b7fcb5b)

test/js/node/fs/promises.test.js:
(pass) should exist [22.59ms]
(pass) should be enumerable [2.01ms]
(pass) access > should work [8.97ms]
(pass) access > should fail on non-existant files [21.30ms]
(skip) access > should fail on non-existant modes
(skip) access > should fail on object as the 2nd argument
(pass) open > should work [53.84ms]
(pass) open > should return an object [16.67ms]
(pass) open > should be closable [10.74ms]
(pass) more > is an object [34.24ms]
(pass) more > stat [74.62ms]
(skip) more > statfs
(skip) more > statfs bigint
(skip) more > 
(pass) writing to file in append mode works [72.11ms]
(pass) appendFile with flag 'ax' rejects with EEXIST on an existing file [16.70ms]
(pass) errors from fs.promises include async stack frames [23.31ms]
(pass) fs.promises async stack through finally(), no async context > the promise finally() was called on rejects [31.82ms]
(pass) fs.promises async stack through finally(), no async context > the promise the callback retur
... (truncated)

release with fix: 5 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1080ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[2/128] gen cpp.rs (cppbind)
[3/128] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 84 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[4/128] gen JS modules (bundle-modules)
Preprocess modules (11609ms)
Bundle modules (59ms)
Postprocesss modules (172ms)
Bundle Functions (607ms)
Generate Code (44ms)

[12.50s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/25] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�
... (truncated)
diff hotspot
src/jsc/bindings/AsyncStackTrace.cpp | 21 +++++++++++---
 test/js/node/fs/promises.test.js     | 56 ++++++++++++++++++++++++++++++++++++
 2 files changed, 73 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/jsc/bindings/AsyncStackTrace.cpp      3      5     24
test/js/node/fs/promises.test.js          2      2     23

Since v1.4.1 an error that Bun creates natively has no async frames when
the rejected promise is awaited through finally(): an fs.promises call
followed by .finally(cb) rejects with no stack at all, and finally()
further up the await chain cuts the stack after the first frame.

The walk over the pending reactions found the promise finally()
returned in the reaction's cell slot. oven-sh/WebKit#552 (#41190) put
the async context in that slot. The reaction's context is a
JSSlimPromiseReaction record whose promise() is that promise, so follow
the record, as JSC's own reaction walk in AbstractModuleRecord.cpp
does. Read the context once per promise through
JSPromise::asyncStackTraceContext(), which covers the inline reaction
and the head of the list.
@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green in CI. One unrelated test is red.

Repro: await fs.promises.readFile("/nonexistent").finally(() => {}) inside an async function rejects with e.stack === undefined on Bun 1.4.1 and on 1.4.3-canary c6b7fcb5b. Bun 1.4.0 and Node v26.3.0 print the at async frames. On this branch the frames are back.

Tests: USE_SYSTEM_BUN=1 bun test test/js/node/fs/promises.test.js -t "async stack through finally" gives 2 pass and 6 fail on the canary. bun bd test test/js/node/fs/promises.test.js passes (38 pass, 5 skip) on a debug ASAN build of this branch, and so do bun-file.test.ts and streams.test.js.

CI (build 116888): 178 jobs passed and the two darwin x64 test jobs still run. The one red job is the debian x64-asan shard with test/js/bun/spawn/spawn.test.ts ("an idle reader stopped at the highwater mark"). This diff does not touch it. The same test is red or retried in unrelated builds of the same hours (116891, 116882, 116861, 116887), and it is reported to main-break triage.

Order: this has to land before the WebKit bump that carries oven-sh/WebKit#268.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: de352cd7-d167-47ba-83ef-12992e19522f

📥 Commits

Reviewing files that changed from the base of the PR and between b31abc9 and 80d397b.

📒 Files selected for processing (1)
  • src/jsc/bindings/AsyncStackTrace.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The async stack tracer now follows finally() reaction contexts and returned promises. fs.promises tests cover rejected promises, rejecting finally() callbacks, await chains, existing rejection handlers, and AsyncLocalStorage contexts.

Changes

Async stack tracing

Layer / File(s) Summary
Finally reaction traversal
src/jsc/bindings/AsyncStackTrace.cpp
The tracer reads inline reaction contexts, unwraps generators, and follows returned promises from JSSlimPromiseReaction contexts. Heap reactions no longer extract generators from tryGetContext.
fs.promises finally coverage
test/js/node/fs/promises.test.js
Tests verify async stack frames across finally() rejection paths, await chains, existing rejection handlers, and plain or AsyncLocalStorage.run() execution.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 80d39

The change safely follows promises returned by finally handlers, and the added tests cover the stated rejection paths. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving async stack traversal through finally() for native promise rejections.
Description check ✅ Passed The description explains the problem, fix, scope, tests, verification results, and known limitations. It does not use the exact template headings, but it contains the required information and is subst…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/bindings/AsyncStackTrace.cpp
Comment thread src/jsc/bindings/AsyncStackTrace.cpp Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants