Skip to content

test: run process.on('exit') handlers in bun test (exposes vacuous tests) - #34443

Closed
cirospaciari wants to merge 3 commits into
mainfrom
claude/bun-test-exit-handlers
Closed

test: run process.on('exit') handlers in bun test (exposes vacuous tests)#34443
cirospaciari wants to merge 3 commits into
mainfrom
claude/bun-test-exit-handlers

Conversation

@cirospaciari

Copy link
Copy Markdown
Member

What

bun test never ran process.on('exit') handlers.

bun run calls vm.on_exit() before global_exit(), which dispatches the process 'exit' event and drains cleanup hooks. test_command.rs only set exit_code and went straight to global_exit(), so handlers registered under bun test never fired.

Why it matters

This silently weakened the vendored Node.js test suite. Node's common.mustCall(fn, N) — the most-used helper in that suite — verifies its counts from a process 'exit' handler (runCallChecks, test/js/node/test/common/index.js). With handlers never dispatched, every mustCall count went unchecked.

A file that calls a mustCall(3) callback once:

before node v26.3.0
exit code 0 1
output 1 pass, 0 fail Mismatched <anonymous> function calls. Expected exactly 3, actual 1.

After this change bun matches node exactly on both.

This is not hypothetical — it was masking real problems. Two vendored node:test files were passing while asserting nothing:

  • test-runner-xfail.js — spawns a child and matches its stdout against node's TAP reporter output, which bun test does not emit; the parent also finished before the child's close event, so the mustCall wrapping those assertions never ran. (Already removed separately.)
  • test-runner-plan.mjs — its run()-based assertions never fired; with this change it correctly reports Mismatched ... Expected exactly 1, actual 0.

Ordering: handlers run before the bun:test GC roots are released, since they are user JS and may touch still-live state.

Testing

Two tests in test/cli/test/bun-test.test.ts: one asserting an exit handler runs, one asserting an exit handler can set a failing exit code (the common.mustCall shape). Both fail with USE_SYSTEM_BUN=1 and pass with the built binary.

Ran the vendored test_runner suite against this change: 20/20 still pass, so no test depended on handlers being skipped. test/cli/test/bun-test.test.ts is 83/83. Also spot-checked expect.test.js (406), event-emitter.test.ts (67), mock-fn.test.js (77), node-test.test.ts (39) — all green.

Reviewers may want to consider the blast radius: this affects every bun test run, and any suite whose files register process.on('exit') handlers will now execute them (which is what node and jest both do).

`bun run` calls vm.on_exit() before global_exit(), which dispatches the
process 'exit' event and drains cleanup hooks. The test command only set
exit_code and went straight to global_exit(), so exit handlers never ran
under `bun test`.

This silently weakened the vendored Node.js test suite. node's
common.mustCall(fn, N) verifies its counts from a process 'exit' handler
(runCallChecks), so every mustCall count was unchecked: a file calling a
mustCall(3) callback once still reported success, where node exits 1 with
"Mismatched function calls. Expected exactly 3, actual 1."

Handlers run before the bun:test GC roots are released, since they are user
JS and may touch still-live state.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator
Updated 9:17 PM PT - Aug 3rd, 2026

@robobun, your commit 646e859 has 4 failures in Build #88573 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34443

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

bun-34443 --bun

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Test runner exit handlers

Layer / File(s) Summary
Invoke exit handlers during shutdown
src/runtime/cli/test_command.rs
The test command invokes VM exit handlers before marking the VM as shutting down and completing runtime teardown.
Cover exit handler outcomes
test/cli/test/bun-test.test.ts
CLI tests verify exit-handler output with exit code 0, and passing test output with a forced exit code 1.

Suggested reviewers: robobun, 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 describes the main change: running process.on('exit') handlers during bun test.
Description check ✅ Passed The description covers what changed and how it was verified, with enough detail despite using different section headings.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. process.on("exit") isn't triggered with bun test #8434 - Directly reports that process.on("exit") handlers are never triggered when running bun test, which is exactly what this PR fixes

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

Fixes #8434

🤖 Generated with Claude Code

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

Beyond the inline nits: the run_with_api_lock + raw-pointer reborrow is byte-identical to the two existing uses in this function (test_command.rs:2918-2923, 2963-2968), so no novel unsafe. global_exit() does not itself dispatch 'exit' or call on_exit(), so handlers run exactly once, and its debug_assert!(is_shutting_down()) is still satisfied via on_exit(). Leaving the blast-radius call (this now also runs cleanup hooks, profiler flush, and SQLite close under bun test) to a human as the author flagged.

Extended reasoning...

Checked the unsafe pattern against the two neighboring run_with_api_lock blocks in the same function — identical shape, so the SAFETY justification is copied convention rather than new reasoning. Confirmed global_exit() (VirtualMachine.rs:1528) does not dispatch the exit event or invoke on_exit(), ruling out double-dispatch. Confirmed on_exit() sets is_shutting_down before returning, so the debug_assert at the top of global_exit() remains satisfied even with the redundant line removed. Not approving because the author explicitly asks reviewers to weigh the blast radius, and on_exit() does more than fire 'exit' (profiler flush, Bun__closeAllSQLiteDatabasesForTermination, cleanup-hook drain) — all newly reachable from bun test.

Comment thread src/runtime/cli/test_command.rs Outdated
// unique mutable access on this single-threaded path.
vm.run_with_api_lock(|| unsafe { (*vm_ptr).on_exit() });
}
vm.is_shutting_down = true;

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.

🟡 nit: on_exit() already sets self.is_shutting_down = true (src/jsc/VirtualMachine.rs:1505), so this line is now a no-op re-assignment. Per REVIEW.md ("Delete dead code in the same PR that makes it dead … guards a new validator makes redundant"), this line can be dropped.

Extended reasoning...

What

The pre-existing line vm.is_shutting_down = true; at src/runtime/cli/test_command.rs:2950 is now dead. The new block immediately above it calls vm.on_exit(), and VirtualMachine::on_exit() unconditionally executes self.is_shutting_down = true; (src/jsc/VirtualMachine.rs:1505) after ExitHandler::dispatch_on_exit(self). So by the time control reaches line 2950, vm.is_shutting_down is already true, and this assignment re-writes the same value.

Code path

// test_command.rs:2943-2950
{
    let vm_ptr: *mut VirtualMachine = vm;
    vm.run_with_api_lock(|| unsafe { (*vm_ptr).on_exit() });  // sets is_shutting_down = true
}
vm.is_shutting_down = true;   // <-- already true; no-op

And in on_exit():

// VirtualMachine.rs:1483-1505
pub fn on_exit(&mut self) {
    // ... CPU/heap profiler flush ...
    ExitHandler::dispatch_on_exit(self);
    self.is_shutting_down = true;    // <-- unconditional
    // ... cleanup hooks ...
}

There is no early-return path in on_exit() that skips the assignment — the profiler branches are if let Some(...) guards that fall through, and dispatch_on_exit returns normally.

Why it wasn't dead before

Prior to this PR, test_command.rs never called on_exit(). The vm.is_shutting_down = true; line was load-bearing: it satisfied the debug_assert!(vm.is_shutting_down) in global_exit() and gated script_execution_status checks during teardown. This PR's on_exit() call now covers that responsibility, which is exactly the "guards a new validator makes redundant" case REVIEW.md calls out under Delete dead code in the same PR that makes it dead.

Step-by-step proof

  1. Execution reaches line 2948 → run_with_api_lock invokes the closure → (*vm_ptr).on_exit() runs.
  2. Inside on_exit(), after dispatch_on_exit, line 1505 executes self.is_shutting_down = true. self and *vm_ptr are the same VirtualMachine (same live &mut).
  3. on_exit() returns; the closure returns; run_with_api_lock returns.
  4. Line 2950 executes vm.is_shutting_down = true — the field is already true. No observable state changes.

Impact

Zero behavioral impact — a redundant boolean write. This is purely a cleanliness nit; nothing breaks if it merges as-is.

Fix

Delete line 2950 (vm.is_shutting_down = true;). The comment block above the GC-root release still reads correctly without it.

Comment on lines +2939 to +2942
// Run `process.on('exit')` handlers like `bun run` does. Node's test
// harness verifies mustCall() counts from one, so skipping them made
// those assertions silently pass. Must precede the GC-root release
// below: handlers are user JS and may touch still-live state.

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.

🟡 This comment is 4 lines, exceeding the repo's 3-line max (CLAUDE.md rule 13), and its middle sentence ("Node's test harness verifies mustCall() counts from one, so skipping them made those assertions silently pass") is bug-history rationale that REVIEW.md says belongs in the PR description — where it already lives verbatim. Dropping that sentence leaves the durable content (run exit handlers like bun run; must precede the GC-root release since handlers are user JS) fitting cleanly in ≤3 lines.

Extended reasoning...

What the finding is

The new comment at src/runtime/cli/test_command.rs:2939-2942 spans four lines:

// Run `process.on('exit')` handlers like `bun run` does. Node's test
// harness verifies mustCall() counts from one, so skipping them made
// those assertions silently pass. Must precede the GC-root release
// below: handlers are user JS and may touch still-live state.

Two repo rules apply here. CLAUDE.md rule 13 states: "Keep code comments to 3 lines max — Comments must be concise. If the code needs more explanation than that, it belongs in docs." And REVIEW.md's code-style section says: "Comments carry only durable non-obvious content: invariants, ownership/lifetime contracts, SAFETY justifications, deliberate deviations from upstream. No narrating what the code does, no bug history — that belongs in the PR description."

Why the middle sentence is the offender

The comment carries three distinct pieces of information:

  1. What — run process.on('exit') handlers like bun run does (durable: describes the behavior and its precedent).
  2. Why historically — "Node's test harness verifies mustCall() counts from one, so skipping them made those assertions silently pass" (bug history: describes the symptom that motivated the change).
  3. Invariant — must precede the GC-root release below because handlers are user JS and may touch still-live state (durable: an ordering constraint a future editor needs to preserve).

Piece (2) is precisely the kind of content REVIEW.md routes to the PR description — and indeed the PR description's "Why it matters" section already covers the common.mustCall / runCallChecks mechanism in far more detail than a one-line comment ever could. A reader who wants the motivation will git blame this block and find the PR; the in-source comment doesn't need to duplicate it.

Step-by-step: trimming to fit

Remove sentence (2) and re-wrap:

// Run `process.on('exit')` handlers like `bun run` does. Must precede
// the GC-root release below — handlers are user JS and may touch
// still-live state.

That is 3 lines, keeps both durable facts (the bun run parity and the ordering invariant), and drops only the bug-history rationale. Nothing a future maintainer needs at the call site is lost.

Impact and severity

This is purely a comment-style violation of two explicit repo rules — no runtime behavior is affected, so it is a nit, not a merge blocker. Note there is a pre-existing 5-line comment a few lines below (2951-2955) that also exceeds the limit, but that is untouched by this PR and doesn't excuse a new violation in freshly-added code.

Comment thread test/cli/test/bun-test.test.ts Outdated
stderr: "pipe",
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

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.

🟡 Both new tests pipe stdout and stderr but only drain one of them: the first test sets stderr: "pipe" yet awaits only proc.stdout.text(), and the second leaves stdout at its default "pipe" yet awaits only proc.stderr.text(). Per REVIEW.md's "Subprocess tests: drain pipes concurrently" rule — and matching the scanner tests at the bottom of this same file — use the three-way Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]). The undrained streams here are only a few hundred bytes so it won't actually deadlock, but it diverges from the file-local convention.

Extended reasoning...

What the issue is

REVIEW.md's harness rules include an explicit line item: "Subprocess tests: drain pipes concurrently. Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child." Both new tests in this PR pipe both stdout and stderr from the child but only read one of them before awaiting proc.exited.

The two sites

Test 1 — "runs process.on('exit') handlers" (test/cli/test/bun-test.test.ts:1476):

await using proc = Bun.spawn({
  cmd: [bunExe(), "test", "exit.test.ts"],
  ...
  stderr: "pipe",           // stderr piped
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

stderr is explicitly set to "pipe" (and stdout defaults to "pipe" in Bun.spawn), but only proc.stdout.text() is awaited. The child's stderr — which receives the bun test summary (1 pass / 0 fail / Ran 1 test...) — is never drained.

Test 2 — "an exit handler can fail the run..." (test/cli/test/bun-test.test.ts:1497):

await using proc = Bun.spawn({
  cmd: [bunExe(), "test", "exit-code.test.ts"],
  ...
  stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

Here stdout defaults to "pipe" but only proc.stderr.text() is awaited — stdout is never drained.

Why it's flagged even though nothing prevents it from passing

The child in each case runs a single trivial test and emits well under 1KB on either stream — nowhere near the ~64KB OS pipe buffer — so these tests will not actually deadlock. However:

  1. REVIEW.md lists the three-way Promise.all as an explicit harness convention ("Copy harness conventions exactly" / "Match the exact file's local conventions").
  2. The scanner tests at the bottom of this same file already follow the pattern verbatim:
    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
    so the new tests diverge from the file-local convention as well as the repo-wide one.

Step-by-step proof

  1. Bun.spawn defaults stdout to "pipe"; test 1 additionally sets stderr: "pipe" → both streams are open pipes.
  2. The child (bun test exit.test.ts) writes the test summary to stderr and console.log("exit handler ran") to stdout.
  3. The parent awaits Promise.all([proc.stdout.text(), proc.exited]) — nothing ever calls proc.stderr.text(), so the stderr pipe sits with unread bytes until the await using disposer closes it after the test body.
  4. Symmetrically in test 2, stdout is piped by default and never read.
  5. If either child ever grew to emit >~64KB on the undrained stream (e.g. debug-build noise, ASAN output on stderr, or a future change to bun test output), the child's write() would block on a full pipe and proc.exited would never resolve.

Fix

Match the neighboring scanner tests:

const [stdout, stderr, exitCode] = await Promise.all([
  proc.stdout.text(),
  proc.stderr.text(),
  proc.exited,
]);

in both tests (discarding whichever stream the assertions don't need). This is a two-line change per test with no behavioral risk.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Drain VM cleanup hooks in global_exit so bun test runs napi at-exit cleanup #32146 - Also fixes bun test exit paths skipping on_exit() / cleanup hooks in test_command.rs, but approaches it from the global_exit() side by extracting a run_cleanup_hooks() helper

🤖 Generated with Claude Code

@cirospaciari cirospaciari changed the title test: run process.on('exit') handlers in bun test test: run process.on('exit') handlers in bun test (exposes vacuous tests) Jul 17, 2026
@cirospaciari
cirospaciari marked this pull request as draft July 17, 2026 04:33
@cirospaciari

Copy link
Copy Markdown
Member Author

Moving this to draft — CI is red, and the failures are the point of the PR rather than regressions. Worth reading before this lands.

Every failure so far has the same signature: the test reports 1 pass 0 fail, then

Mismatched <anonymous> function calls. Expected at least 1, actual 0.

That is common.mustCall finally being checked. Each of these files has been green on main while asserting nothing.

Proof it's exposure, not regression — same file, same command, only the binary differs:

test-set-http-max-http-headers.js
bun without this change exit 0 — prints Module not found "test-http-max-http-headers.js" and passes anyway
bun with this change exit 1

The file it spawns isn't vendored, so it has never actually run. Same story for the other two.

What's red, and why

test cause fix
test-set-http-max-http-headers.js spawns test-http-max-http-headers.js, which isn't vendored already quarantined in #32631 with this exact diagnosis
test-fs-readdir-recursive.js callback-style test(name, (t, done) => ...); on main node:test doesn't await done, so the mustCall inside listen() never runs fixed by #32631 — verified passing with that branch's binary
test-worker-arraybuffer-zerofill.js w.on('message', mustCallAtLeast(...)) never fires still fails with #32631, so it's a real gap and needs its own investigation

I vendored test-http-max-http-headers.js to try to fix the first one properly rather than quarantine it. Bun accepts --max-http-header-size=1024 but doesn't appear to enforce it, so the child runs and the assertions fail. That's a separate compat gap.

What I'd suggest

This change is only landable together with the cleanup it forces. Two of the three are already fixed by #32631, so the natural move is to rebase this onto that branch and handle test-worker-arraybuffer-zerofill.js separately — but that ordering is your call, which is why it's a draft now rather than a red PR asking for a merge.

Note CI is still running (~98/286 jobs at the time of writing), so more Mismatched failures may appear. Each one is another file that has been passing without asserting anything, which I think argues for the change rather than against it.

@cirospaciari

Copy link
Copy Markdown
Member Author

Root-caused the third failure (test-worker-arraybuffer-zerofill.js), so all three are now diagnosed.

It isn't about workers. It's the runner's process lifetime after a synchronous test returns. That test does:

it('...should not affect zero-fill in other threads', () => {   // sync — returns immediately
  const w = new Worker(/* posts on setInterval */, { eval: true });
  w.on('message', common.mustCallAtLeast(...));                 // satisfied later, by the event loop
});

Node's runner drains the event loop before exiting, so the messages arrive and the mustCall counts are met. Bun's exits first. Minimal repro — a sync test that leaves a worker posting, counting messages in a process.on('exit') handler:

messages received at exit
node --test 5
bun test (with this PR) 0

So the test never had a chance to assert; this PR just makes that visible instead of printing 1 pass.

That's a deliberate-looking design difference (a runner that waits for stray handles can hang), so I'm not going to change it unilaterally. It does mean the three failures split cleanly:

test needs
test-fs-readdir-recursive.js #32631's await-the-done-callback fix — already resolved there
test-set-http-max-http-headers.js #32631's quarantine, or enforcing --max-http-header-size (bun accepts the flag but ignores it)
test-worker-arraybuffer-zerofill.js draining the event loop before exit, like node's runner — a design call

Two of three are handled by rebasing this onto #32631. The third needs your view on runner lifetime.

Worth stating plainly: every one of these was green before this change while asserting nothing, and I found two more the same way (test-runner-xfail.js, test-runner-plan.mjs) that I had vendored myself earlier today. The red CI here is the fix doing its job — but it's your call how much cleanup rides along with it, which is why this is a draft.

dylan-conway added a commit that referenced this pull request Jul 24, 2026
…#34444)

Implements `node:test`'s `run()` and raises the surrounding `node:test`
behavior to Node v26.3.0. Stacked on #34443 (its first commit), which is
landable on its own.

Every behavior below was diffed against the real `node v26.3.0` binary,
not read off the source.

## `run()`

- Node-exact option validation in Node's order (`runner.js:731-909`),
including the mutually-exclusive pairs: `forceExit`×`watch`,
`shard`×`watch`, `globPatterns`×`files`, `env`×`isolation:'none'`.
- `TestsStream`: a `Readable` in objectMode with Node's buffering,
emitting each message as both an event and a stream chunk.
- Each file runs in its own `bun test` child, spawned with
`NODE_TEST_CONTEXT` (Node's own variable — upstream tests branch on it
to tell parent from child). The child streams one JSON event per line;
the parent republishes, aggregates, and emits a per-file and a run-level
`test:summary`.
- Node's recursion guard, so a file calling `run()` on itself doesn't
fork forever.
- Options that can't be honored yet (`watch`, `coverage`, `shard`,
`isolation:'none'`, `globPatterns`, `globalSetupPath`) throw rather than
being silently ignored.

Event fidelity added in this round, each verified against Node:

- The **file-level test node** that process isolation reports:
`test:enqueue`/`test:dequeue` up front, `test:complete` at the end, and
`test:fail` with `failureType: 'testCodeFailure'` when the file itself
dies (a top-level throw, a missing file). When the file's *tests* fail
instead, the completion carries `subtestsFailed` and no `test:fail` —
which is what Node does.
- **Skip and todo directive events**, which bun never emitted at all:
`bun test` doesn't invoke those bodies, so nothing reported them.
- `details.type`, suites counted only in `suites`, and `failureType`
preserved across the child process boundary.

## `expectFailure` (xfail)

Node v26's `expectFailure` was missing entirely. Adds the option parser
(string label, function/RegExp validator, object form with
`label`/`match`, and the empty-object rejection), the inverted verdict —
a failing body is the expected outcome, a passing one fails with
`failureType: 'expectedFailure'` — and the `expectFailure` field on the
reported event.

## Two Node divergences reachable from plain `bun test`

Not `run()`-specific; these affect anyone using `node:test` under bun
today.

- **A skipped suite ran its callback.** Node never invokes it, so its
children are never declared and its side effects never happen. bun ran
the body and registered the children.
- **`{ skip: true, todo: true }` was treated as a todo.** Node checks
`skip` first, for both tests and suites.

## Test-runner harness

A vendored test that only *drives* `run()` is the parent of that run,
not a test file — Node executes it as a plain script, and under `bun
test` a file that registers no tests of its own exits before its `run()`
finishes, delivering zero events. The runner now picks `bun run` for
those, gated so that a file with any unindented registration of its own
keeps `bun test` rather than silently "passing" having tested nothing.
**4 of the 88 vendored `node:test` files change subcommand, all of them
added by this PR.**

## Coverage

Vendors 5 upstream tests — `test-runner-expect-error`,
`test-runner-expect-error-but-pass`, `test-runner-todo-skip-tests`,
`test-runner-filetest-location`, `test-runner-tags-experimental-warning`
— taking the upstream `test_runner` suite from **20 to 26 of 81**. bun's
own `node-test.test.ts` goes 40 → 44 (new fixtures cover `expectFailure`
and the suite-skip semantics). All 20 previously-vendored
`test-runner-*` files still pass.

## Still draft: the reporting seam

A test whose failure is decided **after** its body resolves is still
reported as passing by `run()`. Repro:
`test/fixtures/test-runner/plan/timeout-basic.mjs` — run directly under
`bun test` it is correctly 1 pass / 1 fail (bun's plan logic is fine),
but the JS layer has already returned by the time the native runner
attributes the async throw, so `run()` sees two passes.

I confirmed the JS layer cannot close this: bun's `bun test` intercepts
in-process throws natively and attributes them itself, so
`process.on('uncaughtException')` — the mechanism Node's own
`harness.js:255` uses — never fires for an in-test throw. A faithful
`run()` needs a **reporter hook in the native runner**, emitting where
bun actually decides pass/fail (`Execution::on_sequence_completed`,
`src/runtime/test_runner/Execution.rs`, already the single fan-out point
for the CLI reporter, the junit writer and the inspector agent).

That's a maintainer design call, which is why this stays draft. Happy to
implement it in this PR or a follow-up — it's also the unlock for
`--test`-style CLI emulation and `node:test/reporters`, which together
account for ~25 of the remaining 55 upstream test_runner files.


## Update (2026-07-17)

Three follow-up commits landed on this branch after review:

- **Event-loop drain for node tests** (`BUN_TEST_DRAIN_EVENT_LOOP=1`,
set by the vendored-test runner; also on for run() children): a node
test process only exits when its loop drains, and `common.mustCall()`
verifies counts in `'exit'` handlers. This fixes the five vendored tests
that started failing once exit handlers ran
(`test-worker-arraybuffer-zerofill`, `test-file-write-stream5`,
`test-tls-psk-alpn-callback-exception-handling`, and with the two fixes
below, `test-events-add-abort-listener` and
`test-net-server-async-dispose`). Off by default — bun suites keep
exit-after-tests.
- **`events.addAbortListener` now survives `stopImmediatePropagation`**:
it registers a native abort algorithm (runs in `runAbortSteps()` before
event dispatch) instead of an `'abort'` listener, since the native
EventTarget drops node's `[kResistStopPropagation]` option.
`node:events` had a duplicate inline implementation bypassing
`internal/abort_listener`; it now delegates.
- **`Server[Symbol.asyncDispose]` resolves when not listening** (node's
`lib/net.js` guard); a second dispose used to reject with
`ERR_SERVER_NOT_RUNNING` and re-emit `'close'`.

Each verified byte-identical to the node v26.3.0 binary on minimal
repros.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 26 · 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

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun added 2 commits August 3, 2026 20:17
…handlers

# Conflicts:
#	src/runtime/cli/test_command.rs
#	test/cli/test/bun-test.test.ts
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Closing: this change landed on main in #34444 (merged 2026-07-24), and the exit-listener behavior was since adjusted in #38442. The branch no longer differs from main (0 changed files), so there is nothing left to land here.

@robobun robobun closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants