Skip to content

test_runner: gate event-loop drain and on_exit to node:test, drop BUN_TEST_DRAIN_EVENT_LOOP - #35401

Open
robobun wants to merge 5 commits into
mainfrom
farm/46ad8752/node-test-drain-event-loop
Open

test_runner: gate event-loop drain and on_exit to node:test, drop BUN_TEST_DRAIN_EVENT_LOOP#35401
robobun wants to merge 5 commits into
mainfrom
farm/46ad8752/node-test-drain-event-loop

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Removes BUN_TEST_DRAIN_EVENT_LOOP (added in 051952d) and replaces it with automatic detection: when node:test registers anything in the process, bun test drains the event loop before exit (single-file runs only) and dispatches process.on('exit') handlers at shutdown, matching how Node runs test files. bun:test-only runs keep the prior exit-after-tests behavior.

Why

051952d made bun test always call vm.on_exit() (dispatching process.on('exit') handlers) so Node's common.mustCall() exit-time checks would run. That is a behavior change for plain bun:test suites. This PR gates both the on_before_exit() drain and the shutdown on_exit() to processes that actually use node:test, and drops the env-var opt-in that the vendored-test runner and run() children set.

Also adds handle_rejected_promises() before on_exit() on the node:test path, matching bun run.

How

node:test already calls a native jsFileGeneration hook on every registration (via getRootNode()); that now sets TestRunner.node_test_module_used. At end of run, when the flag is set: drain (single-file only), flush pending rejections, dispatch 'exit'. The drain is once-per-process rather than per-file, and gated on summary.files <= 1, because on_before_exit() spins while is_event_loop_alive() and cannot tell one file's handles from another's; the vendored node suite and run() children each spawn one file per process, so this matches what the removed env var covered without letting a leaked handle from an earlier file wedge a mixed multi-file run.

Verification

$ bun bd test test/cli/test/bun-test.test.ts -t "process.on\('exit'\)|exit handler|multi-file"
(pass) bun test > bun:test does not run process.on('exit') handlers
(pass) bun test > node:test runs process.on('exit') handlers
(pass) bun test > node:test in a multi-file run does not hang on a handle an earlier bun:test file leaked
(pass) bun test > node:test: an exit handler can fail the run, like node's common.mustCall()

With src/ stashed (current main), bun:test does not run process.on('exit') handlers fails because main dispatches exit handlers unconditionally. test/js/node/test_runner/node-test.test.ts (45 tests) and test/js/node/test/parallel/test-file-write-stream5.js (node:test + common.mustCall()) still pass without the env var.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/test/bun-test.test.ts

…_TEST_DRAIN_EVENT_LOOP

The env var was an opt-in for the vendored node test runner and run()
children. Replace it with a TestRunner flag set the first time node:test
registers something in the process, and gate both the post-file
on_before_exit() drain and the shutdown on_exit() call on that flag.
bun:test-only runs revert to setting is_shutting_down directly, so
process.on('exit') handlers are not dispatched for them.

Also call handle_rejected_promises() before on_exit() on the node:test
path, matching bun run.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:44 AM PT - Jul 24th, 2026

@robobun, your commit 3b9df9f has 1 failures in Build #79508 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35401

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

bun-35401 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. process.on("exit") isn't triggered with bun test #8434 - PR now dispatches process.on('exit') handlers when node:test is used, directly addressing the reported missing exit callbacks in bun test
  2. bun test swallows unhandled rejections from a script file's async IIFE #34859 - The added handle_rejected_promises() call and event loop drain on the node:test path addresses silent swallowing of unhandled rejections in vendored Node test files

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

Fixes #8434
Fixes #34859

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The test runner removes BUN_TEST_DRAIN_EVENT_LOOP, tracks node:test usage, gates Node-style shutdown behavior on that state, updates child environments, and adds coverage for bun:test and node:test exit handling.

Node test exit lifecycle

Layer / File(s) Summary
Remove drain environment control
scripts/runner.node.mjs, src/bun_core/env_var.rs, src/js/node/test.ts
Removes the drain environment variable and adds explicit child-process color and quiet-log settings.
Track node:test usage
src/runtime/test_runner/jest.rs, src/runtime/cli/test_command.rs
Records node:test registration and initializes the corresponding reporter state.
Gate shutdown behavior and validate exit handling
src/runtime/cli/test_command.rs, test/cli/test/bun-test.test.ts
Runs Node-style exit processing only for node:test runs and verifies exit-handler behavior, multi-file execution, and failure codes.

Possibly related PRs

  • oven-sh/bun#34443: Changes test shutdown and lifecycle handling around vm.on_exit().
  • oven-sh/bun#34444: Adds environment-controlled Node test shutdown behavior that this change replaces.
  • oven-sh/bun#34515: Adjusts Node test child detection and event-loop draining in shared CLI test machinery.

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 removing the drain env var and gating test shutdown behavior on node:test.
Description check ✅ Passed The description covers the change, rationale, implementation, and verification, matching the template well enough.

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

@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: 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 `@src/runtime/cli/test_command.rs`:
- Around line 3049-3064: Update the early bail exit path in TestCommand::run
before the direct global_exit() call so node:test runs use the same
reporter.jest.node_test_module_used-gated shutdown routine as the shown block,
including handle_rejected_promises() and on_exit(). Preserve the existing
non-Node shutdown behavior and ensure global_exit() remains after the shared
shutdown processing.
🪄 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: 5a288423-9563-4578-9fd1-bdb1deed41d6

📥 Commits

Reviewing files that changed from the base of the PR and between df84f8d and 98b8c85.

📒 Files selected for processing (6)
  • scripts/runner.node.mjs
  • src/bun_core/env_var.rs
  • src/js/node/test.ts
  • src/runtime/cli/test_command.rs
  • src/runtime/test_runner/jest.rs
  • test/cli/test/bun-test.test.ts
💤 Files with no reviewable changes (2)
  • scripts/runner.node.mjs
  • src/bun_core/env_var.rs

Comment thread src/runtime/cli/test_command.rs
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Neither linked issue is fixed here:

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:test: run top-level tests registered from a macrotask after module evaluation #35137 - Both PRs gate event-loop drain to node:test only (not bun:test) by adding a flag set during node:test registration; node:test: run top-level tests registered from a macrotask after module evaluation #35137 uses node_test_drain on BunTest while this PR uses node_test_module_used on TestRunner

🤖 Generated with Claude Code

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #35137. That PR adds a per-file node_test_drain on BunTest to keep the per-file loop ticking so late-registered test() calls (from a setTimeout after collection) can run. This PR removes BUN_TEST_DRAIN_EVENT_LOOP and gates the existing on_before_exit()/on_exit() shutdown behavior on a process-wide TestRunner flag, which the shutdown path needs because it runs after all files. They touch adjacent lines in test_command.rs so whichever lands second will need a small rebase, but the changes are complementary.

Comment thread src/runtime/cli/test_command.rs Outdated
Comment thread src/js/node/test.ts
on_before_exit() spins while is_event_loop_alive(), which cannot tell one
file's handles from another's. Running it per file meant a handle leaked
by an earlier bun:test file would wedge a later node:test file's drain in
a multi-file run. Move the drain to the end-of-run node:test block and
gate it on single-file runs, matching what the removed env var covered:
the vendored node suite and run() children each spawn one file per
process.
Comment thread src/runtime/cli/test_command.rs
The end-of-run drain runs after active_file is cleared and after the
exit-code decision, so an uncaught throw surfaced there routes through
run_error_handler (print only) and left the run at exit 0. Re-check
unhandled_error_counter after the drain/handle_rejected_promises/on_exit
block and set exit_code = 1 when it grew.
Comment thread src/runtime/cli/test_command.rs Outdated
Comment thread test/cli/test/bun-test.test.ts Outdated
…ture

Set exit_code before on_exit() reads it so 'exit' listeners see the
propagated code (common.mustCall()'s checker early-returns on nonzero).
Keep throws from inside 'exit' listeners out of the check: on main those
print but do not fail the run, and the vendored test-sqlite-* files hit
EBUSY on Windows from tmpdir's exit-time cleanup with databases still
open, which otherwise turns a passing run into exit 1.

Arm the drain-throw fixture's setTimeout from inside the test body at
20ms instead of 500ms at module scope so it is due after the per-file
loop exits without spinning the drain for half a second.

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

No new issues found — all four earlier findings (multi-file drain hang, drain-throw exit-0, exit-handler seeing stale code, 500ms fixture) are addressed in 6621cb6. Deferring rather than approving because this reworks the bun test shutdown sequence (drain / handle_rejected_promises / on_exit / exit-code ordering) and reverts 051952d's unconditional on_exit() for bun:test-only runs, which is a user-visible behavior call worth a maintainer sign-off.

What was reviewed:

  • Shutdown ordering in test_command.rs: prev_unhandled capture → drain → handle_rejected_promises → exit-code propagation → on_exit, with is_shutting_down set on both branches before global_exit.
  • node_test_module_used write in js_file_generation: disjoint from the live &BunTestRoot borrow, JS-thread-only.
  • summary.files <= 1 gate covers the removed env var's callers (vendored runner, run() children); multi-file no-hang test guards it.
  • Acknowledged gap left as follow-up: run() children whose target never imports node:test no longer drain.
Extended reasoning...

Overview

Removes BUN_TEST_DRAIN_EVENT_LOOP and replaces it with a TestRunner.node_test_module_used flag set from node:test's existing js_file_generation hook. At end-of-run, when the flag is set: drain the event loop (single-file runs only), flush pending rejections, propagate any drain-surfaced uncaught error to the exit code, then dispatch process.on('exit'). bun:test-only runs take the else branch and just set is_shutting_down. Touches test_command.rs (shutdown sequence), jest.rs (flag + write site), env_var.rs / runner.node.mjs / node/test.ts (env-var removal), and adds/rewrites five tests in bun-test.test.ts.

Security risks

None identified. No untrusted-input parsing, no auth/crypto, no network. The unsafe blocks are pre-existing raw-pointer reborrow patterns on the single-threaded JS path; the one new unsafe write ((*runner).node_test_module_used = true) is a disjoint field projection with the aliasing invariant documented at the site.

Level of scrutiny

High. This is the bun test shutdown path — event-loop drain, exit-handler dispatch, exit-code computation, and GC-root release are all ordering-sensitive, and this PR went through three prior review rounds each of which surfaced a real ordering bug (multi-file hang, drain-throw exiting 0, exit handlers seeing a stale code). That history is exactly why I'm not auto-approving: the area has proven subtle enough that a maintainer should confirm the final shape.

Other factors

  • Behavior reversion: 051952d made bun test always call on_exit(); this PR reverts that for bun:test-only runs. Issue #8434 asks for the opposite. The author explicitly declined to fix #8434 here — that's a reasonable scope call, but a maintainer should confirm the direction.
  • Deliberately-preserved gap: throws from inside 'exit' listeners still print-but-exit-0 (the author cited Windows test-sqlite-* EBUSY as the reason not to widen the check). Documented in the code comment.
  • Acknowledged follow-up: run() children whose target file never imports node:test no longer drain (the NODE_TEST_CONTEXT env var is not consulted natively). Author marked this as low-value and left it.
  • Overlap with #35137: touches adjacent lines; whichever lands second needs a small rebase.
  • Test coverage is good for the paths implemented (both branches of the gate, multi-file no-hang, drain-throw exit code + handler-visible code, exit-handler-fails-run).

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

The fixes in 6621cb6 look right — the unhandled-counter check now sits between handle_rejected_promises() and on_exit() so exit handlers see the propagated code, and arming the 20ms timer from inside the test body keeps the drain-throw fixture fast without racing the per-file tick loop. Deferring for a maintainer to confirm the user-facing choice that plain bun:test runs revert to not dispatching process.on('exit') (scoping back 051952d), since #8434 requests the opposite.

Also checked this pass:

  • fileGeneration() is reached only from getRootNode() in src/js/node/test.ts, so bun:test-only files cannot set the flag.
  • reporter.summary().files <= 1 matches the removed env var's coverage (both former setters spawn one file per process); multi-file node:test runs still get on_exit() without the drain.
  • The raw-pointer write to node_test_module_used in js_file_generation is disjoint from the caller's live &BunTestRoot borrow.
Extended reasoning...

Overview

Replaces the BUN_TEST_DRAIN_EVENT_LOOP opt-in (added one commit earlier in 051952d) with automatic detection: a node_test_module_used flag on TestRunner, set the first time node:test's getRootNode() calls the native js_file_generation hook. At end-of-run, when the flag is set, bun test drains the event loop (single-file only), flushes pending rejections, propagates any drain-surfaced uncaught error to the exit code, and dispatches process.on('exit'). bun:test-only runs take the else branch and skip both the drain and on_exit(). Touches test_command.rs (shutdown sequence), jest.rs (flag + setter), src/js/node/test.ts and scripts/runner.node.mjs (drop the env var), env_var.rs (drop the declaration), and adds/rewrites five tests in bun-test.test.ts.

Security risks

None identified. No untrusted-input parsing, no auth/crypto/permissions surface. The two unsafe blocks are pre-existing patterns: test_command.rs reborrows the live &mut VirtualMachine through a raw pointer inside run_with_api_lock (single-threaded, same as the code it replaces), and jest.rs writes a disjoint bool field via runner_ptr() to avoid invalidating the caller's live &BunTestRoot — the SAFETY comment names the invariant and the field projection does not overlap.

Level of scrutiny

High. This is the test-runner shutdown path — event-loop drain, exit-handler dispatch, and exit-code propagation — and three prior review rounds on this PR each surfaced a real bug (multi-file hang from a leaked handle; drain-surfaced throw exiting 0; exit handlers seeing a stale code === 0). Each was fixed with a regression test. That history is exactly why I'm not auto-approving: the area is subtle enough that a maintainer should sign off on the final shape, particularly the summary.files <= 1 heuristic and the decision to keep bun:test runs off the on_exit() path.

Other factors

All prior inline findings are resolved and covered by new tests (bun:test does not run exit handlers, node:test runs exit handlers, multi-file no-hang, drain-throw fails with code === 1 seen by the handler, exit handler can fail the run). The one open design question is user-facing: 051952d made bun test fire process.on('exit') unconditionally; this PR scopes that back to node:test only, which is the pre-051952d75 behavior but is also what issue #8434 is asking to change. The author already stated on the thread that #8434 is intentionally out of scope here — that's reasonable, but it's a product call a human should confirm rather than a bot. The remaining acknowledged gap (a run() child whose target never imports node:test no longer drains) was left as a follow-up with stated reasoning; I agree it's not merge-blocking.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on the lanes it touches. Remaining failures across builds 79499 and 79508 are unrelated:

  • build 79499: worker_threads.test.ts SIGABRT !exception() on x64-asan mid-execution (bun:test file, my change is shutdown-only for node:test; gone on re-roll, 3 local ASAN runs pass 91/91)
  • build 79508: complex-workspace.test.ts on x64-asan cascades from its own bun install setup failing (install/migration test, nothing in this diff touches the package manager)
  • everything else is marked [flaky]

The 9 test-sqlite-* Windows failures from build 79488 are gone after 6621cb6 (the exit-code check is now scoped so a throw inside an 'exit' listener keeps main's print-but-exit-0 behavior). Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Delete it from runner too

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Already removed from both scripts/runner.node.mjs (the env object in runOneTest) and src/js/node/test.ts (runOneFile's spawn env); git grep BUN_TEST_DRAIN_EVENT_LOOP is now empty across the tree. Which runner did you mean?

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