Skip to content

bun:test: defer the AsyncContextFrame wrap past .length/.bind() for tests and hooks - #35334

Closed
robobun wants to merge 3 commits into
mainfrom
farm/f6141606/test-hook-als-length
Closed

bun:test: defer the AsyncContextFrame wrap past .length/.bind() for tests and hooks#35334
robobun wants to merge 3 commits into
mainfrom
farm/f6141606/test-hook-als-length

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

Registering a zero-arg test() or hook (beforeAll/afterAll/beforeEach/afterEach) while an AsyncLocalStorage context is active makes bun:test wait for a done() callback that was never declared, so the entry times out (or, on Windows before #34478, hangs the file). test.each() in the same context throws TypeError: bind() called on non-callable:

const { test } = require("bun:test");
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();

als.run({}, () => {
  test("passes instantly", () => {});
  test.each([[1], [2]])("n=%p", n => {});
});
(fail) passes instantly [5000.01ms]
  ^ this test timed out after 5000ms, before its done callback was called. If a done callback was not intended, remove the last parameter from the test callback function
TypeError: bind() called on non-callable

Why

parse_arguments() wrapped the callback with with_async_context_if_needed() so the captured ALS context is restored when the callback runs. An AsyncContextFrame has no .length (so JSC__JSValue__getLengthIfPropertyExistsInternal falls through to infinity, which get_length() clamps to (1<<51)-1, and has_done_parameter is always true) and is not itself callable via JSC::getCallData (so Bun__JSValue__bind rejects it).

Fix

Move the with_async_context_if_needed() call out of parse_arguments() and into the two storage points: enqueue_describe_or_test_callback() (after .length is read and, for test.each, after .bind()) and generic_hook_impl() (after .length is read). Both run synchronously inside the user's als.run(...) body, so the captured context frame is the same one parse_arguments() would have seen.

Found while investigating a Windows-only hang in #34515's node:test run-child mode, where describe() wrappers register the suite-completion afterAll from inside AsyncLocalStorage.run(); that PR's workaround of making the hook take an explicit done parameter is no longer needed with this fix.

Verification

bun bd test test/js/bun/test/als-hook-arity.test.ts

Passes on linux-x64 and windows-x64. Fails on the released bun with hook timed out before its done callback was called (hooks/test) and bind() called on non-callable (test.each).


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/als-hook-arity.test.ts
bun test v1.4.0 (ea0889bd9)

test/js/bun/test/als-hook-arity.test.ts:
16 |     stderr: "pipe",
17 |   });
18 | 
19 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
20 | 
21 |   expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
                                            ^
error: expect(received).toMatchInlineSnapshot(expected)

  
  "test/js/bun/test/als-hook-arity.fixture.ts:
- (pass) registered inside an active ALS context > zero-arg test
- (pass) registered inside an active ALS context > one-arg test still receives done
- (pass) registered inside an active ALS context > each 1
- (pass) registered inside an active ALS context > each 2
- (pass) registered inside an active ALS context > nested describe > passes
- (pass) hooks and tests registered inside an ALS context use the callback's real arity and restore the context
  
-  6 pass
-  0 fail
-  2 expect() calls
- Ran 6 tests across 1 file."
- 
+ # Unhandled error between
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/test/als-hook-arity.test.ts:
16 |     stderr: "pipe",
17 |   });
18 | 
19 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
20 | 
21 |   expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
                                            ^
error: expect(received).toMatchInlineSnapshot(expected)

  
  "test/js/bun/test/als-hook-arity.fixture.ts:
- (pass) registered inside an active ALS context  zero-arg test
- (pass) registered inside an active ALS context  one-arg test still receives done
- (pass) registered inside an active ALS context  each 1
- (pass) registered inside an active ALS context  each 2
- (pass) registered inside an active ALS context  nested describe  passes
- (pass) hooks and tests registered inside an ALS context use the callback's real arity and restore the context
  
-  6 pass
-  0 fail
-  2 expect() calls
- Ran 6 tests across 1 file."
- 
+ # Unhandled error between tests
+ -------------------------------
+ 38 |       mark("done test");
+ 39 |       expect(typeof done).toBe("function");
+ 40 |       setImmediate(done);
+ 41 |     })
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/als-hook-arity.test.ts
bun test v1.4.0 (ea0889bd9)

test/js/bun/test/als-hook-arity.test.ts:
(pass) tests and hooks registered inside an AsyncLocalStorage context detect done-callback arity correctly [2101.24ms]

 1 pass
 0 fail
 1 snapshots, 3 expect() calls
Ran 1 test across 1 file. [4.12s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ea0889bd94
  features     baseline

22 deps, 108 codegen, 1170 objects in 1168ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1233] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [17.00ms]
[2/1233] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [6.00ms]
[3/1233] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1233] gen bindgenv2
[5/1233] gen ErrorCode+*.h
[6/1233] fetch zlib
[zlib] up to date
[7/1233] fetch tinycc
[tinycc] up to date
[8/1233] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[9/1233] fetch picohttpparser
[picohttpparser] up to date
[10/1233] gen .bind.ts → GeneratedBindings.cpp
[11/1233] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from 
... (truncated)
diff hotspot
src/runtime/test_runner/ScopeFunctions.rs  | 11 +++-
 src/runtime/test_runner/bun_test.rs        | 14 +++--
 test/js/bun/test/als-hook-arity.fixture.ts | 83 ++++++++++++++++++++++++++++++
 test/js/bun/test/als-hook-arity.test.ts    | 37 +++++++++++++
 4 files changed, 141 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/runtime/test_runner/ScopeFunctions.rs       7      8      0
src/runtime/test_runner/bun_test.rs            12      8      0
test/js/bun/test/als-hook-arity.fixture.ts      1      4      0
test/js/bun/test/als-hook-arity.test.ts         4      4      0

parse_arguments() wraps a test/hook callback in an AsyncContextFrame when
an AsyncLocalStorage context is active, so the context is restored when the
callback runs. The callers then read .length from the returned callback to
decide whether to pass a done() parameter. AsyncContextFrame has no .length
property, so getLengthIfPropertyExistsInternal falls through to infinity,
which clamps to I52_MAX, and every zero-arg callback registered inside an
ALS context waits for a done() that is never called.

On Linux the hook times out after 5 seconds and the test file fails with
'hook timed out before its done callback was called' pointing at a callback
that has no parameter. On Windows prior to #34478 it hung forever.

Read .length from the user's function inside parse_arguments(), before
wrapping, and return it alongside the (possibly wrapped) callback.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

AsyncLocalStorage callback handling

Layer / File(s) Summary
Capture context after arity inspection
src/runtime/test_runner/ScopeFunctions.rs, src/runtime/test_runner/bun_test.rs
Callbacks are inspected in their raw form for .length and .bind(), then wrapped with the active AsyncLocalStorage context when enqueued or registered across test phases.
Validate hook and test callback arity
test/js/bun/test/als-hook-arity.fixture.ts, test/js/bun/test/als-hook-arity.test.ts
ALS-scoped hooks and tests verify zero-argument, done-callback, test.each, nested registration, execution order, and successful subprocess completion.

Suggested reviewers: jarred-sumner

🚥 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 summarizes the main fix: deferring AsyncContextFrame wrapping until after arity and bind checks.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses different headings than the template.

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

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:53 PM PT - Jul 23rd, 2026

@robobun, your commit ea0889b has 2 failures in Build #79053 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35334

That installs a local version of the PR into your bun-35334 executable, so you can run:

bun-35334 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. node:test async tests exceeding 5s timeout fail in Bun but pass in Node #27422 - node:test async tests exceeding 5s timeout fail with "before its done callback was called" error, exactly matching the arity misdetection bug this PR fixes

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #27422

🤖 Generated with Claude Code

Comment thread test/js/bun/test/als-hook-arity.test.ts Outdated
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
Move the with_async_context_if_needed() call out of parse_arguments()
and into the two storage points (enqueue_describe_or_test_callback and
generic_hook_impl), after the caller has read .length and, for
test.each, bound the per-row arguments. An AsyncContextFrame has no
.length and is not callable via getCallData, so wrapping first made
every zero-arg callback inside an ALS context wait for done() and made
test.each throw 'bind() called on non-callable'. The wrap still runs
synchronously inside the user's als.run() body, so it captures the same
context.
@robobun robobun changed the title bun:test: read callback arity before wrapping in AsyncContextFrame bun:test: defer the AsyncContextFrame wrap past .length/.bind() for tests and hooks Jul 24, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 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/bun/test/als-hook-arity.fixture.ts`:
- Around line 15-55: Add assertions that als.getStore()?.tag equals "collection"
inside at least one lifecycle hook and the nested describe callback in the
fixture. Keep the existing order checks and test callback assertions unchanged,
covering both hook context restoration and deferred nested-describe context
capture.
- Around line 4-8: Remove the implementation-history comment from
test/js/bun/test/als-hook-arity.fixture.ts lines 4-8 and
test/js/bun/test/als-hook-arity.test.ts lines 5-10, or replace each with the
relevant issue URL if available. Keep regression-test comments limited to
exactly the issue URL.
🪄 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: ad93a624-8f83-48e0-883e-12b629a2febb

📥 Commits

Reviewing files that changed from the base of the PR and between 43372bd and 8b4e33e.

📒 Files selected for processing (4)
  • src/runtime/test_runner/ScopeFunctions.rs
  • src/runtime/test_runner/bun_test.rs
  • test/js/bun/test/als-hook-arity.fixture.ts
  • test/js/bun/test/als-hook-arity.test.ts

Comment thread test/js/bun/test/als-hook-arity.fixture.ts
Comment thread test/js/bun/test/als-hook-arity.fixture.ts
The fix moved the AsyncContextFrame wrap to the storage points; record
als.getStore() in every callback (hooks, tests, test.each, nested
describe body) so the order check also proves the captured context is
restored on each path.

@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.

LGTM — the wrap point moves from parse_arguments() to the two storage sites (enqueue_describe_or_test_callback, generic_hook_impl), both still synchronous inside the user's als.run() body, so the captured frame is unchanged while .length/.bind() now see the bare function.

What was reviewed:

  • Confirmed parse_arguments() has exactly two callers and both now wrap at the storage point; no path stores an unwrapped callback.
  • test.each now binds the bare function, then wraps the resulting JSBoundFunctionbind() called on non-callable is fixed and covered by the fixture.
  • Fixture asserts :ctx on every hook/test/describe entry, so a dropped wrap on any path surfaces as :none in the toEqual diff.
  • Earlier findings (vacuous stdout assertion, .each sibling site) were addressed in 8b4e33e / ea0889b.
Extended reasoning...

Overview

This PR fixes a bug where registering a zero-arg test() or hook inside an active AsyncLocalStorage context caused bun:test to wait for a nonexistent done() callback (timing out), and test.each() to throw bind() called on non-callable. The root cause: parse_arguments() wrapped the callback in an AsyncContextFrame before the caller read .length (for done-param detection) or called .bind() (for test.each) — and AsyncContextFrame has no .length and is not callable via JSC::getCallData.

The fix moves the with_async_context_if_needed() call from parse_arguments() to the two storage points (enqueue_describe_or_test_callback() in ScopeFunctions.rs, generic_hook_impl() in bun_test.rs), both of which run synchronously in the same call stack, so the captured ALS frame is identical.

Security risks

None. This is test-runner-internal control flow with no untrusted-input parsing, no auth/crypto, no filesystem/network surface.

Level of scrutiny

Moderate. It touches the test runner's callback registration path, which affects every test()/describe()/hook call. However the change is mechanical (moving one call from one synchronous point to two later synchronous points in the same call stack), the invariant it preserves is clearly documented in the doc comment on ParseArgumentsResult.callback, and I verified via grep that parse_arguments() has exactly the two callers that now wrap. The fixture exercises every affected entry point (all four hook types, zero-arg test, done-arg test, test.each, nested describe) and asserts both correct arity detection and ALS context restoration at every one.

Other factors

I previously left two inline findings on earlier revisions of this PR: a vacuous stdout assertion (fixed in 8b4e33e by asserting toStartWith("bun test ")) and the .each sibling-site bug (fixed in 8b4e33e by moving the wrap to storage points so .bind() sees the bare function; test.each case added to fixture). CodeRabbit's request to assert ALS restoration in hooks and nested describe was addressed in ea0889b (every entry now records als.getStore()?.tag and the final toEqual asserts :ctx on all of them). All review threads are resolved. The bug hunting system found no issues on the current revision.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI on ea0889b: the new test (als-hook-arity.test.ts) passed on every lane. The hard failures are test/js/bun/spawn/spawn.test.ts (linux x64 release lanes) and test/cli/run/no-orphans.test.ts (darwin 26 aarch64), both of which fail identically on main builds 78969 and 79091 and are unrelated to this change (reported for main-break triage). The rest are retry-green flakes. Ready for review.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #38910 fixes JSValue::get_length returning 2^51 - 1 for objects without a length property (the clamp described in this PR's Why section). That change on its own breaks node:test, which registers its bun:test entries from inside AsyncLocalStorage.run() and so far only got its done callbacks because of that clamp, so #38910 carries this PR's ScopeFunctions.rs / bun_test.rs change and its als-hook-arity test as a second commit. Whichever lands first, the other rebases to its remaining part.

robobun added a commit that referenced this pull request Aug 15, 2026
Same change as #35334, carried here because get_length returning 0 for
the length-less wrapper depends on it: tests and hooks registered while an
AsyncLocalStorage context is active (node:test registers everything inside
a describe() body that way) had their .length read from the wrapper, and
only the 2^51 - 1 that get_length returned for it made their done
callbacks work. Read .length and .bind() from the function itself and wrap
at the point where the callback is stored, which is still synchronous
inside the caller's als.run() body.
robobun added a commit that referenced this pull request Aug 15, 2026
Same change as #35334, carried here because get_length returning 0 for
the length-less wrapper depends on it: tests and hooks registered while an
AsyncLocalStorage context is active (node:test registers everything inside
a describe() body that way) had their .length read from the wrapper, and
only the 2^51 - 1 that get_length returned for it made their done
callbacks work. Read .length and .bind() from the function itself and wrap
at the point where the callback is stored, which is still synchronous
inside the caller's als.run() body.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #38910, which carries this change rebased onto current main as its second commit (5cea179). The ScopeFunctions.rs / bun_test.rs change there is the same one as here (only the comments were reworded), and als-hook-arity.test.ts / als-hook-arity.fixture.ts are identical to this branch, so nothing from this PR is lost.

#38910 also changes JSValue::get_length to return 0 for objects without a length property. That is the 2^51 - 1 clamp described in the Why section above, and it is what currently hands node:test's (done) => runners their done callback by accident, so the relocation of the wrap here has to land together with that change rather than separately. This branch no longer merges cleanly against main (conflict in ScopeFunctions.rs), and the bug still reproduces on the current canary (bind() called on non-callable for test.each registered inside AsyncLocalStorage.run()), so #38910 is the one to review.

@robobun robobun closed this Aug 15, 2026
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.

1 participant