Skip to content

bun test: ignore a process.exitCode that test code leaves on a passing run - #47508

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/609ace2d/test-exit-code-from-results
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/609ace2d/test-exit-code-from-results

Conversation

@robobun

@robobun robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: Downsides asks a maintainer one question.

Problem

  • A passing bun test run exits with a process.exitCode that a test file stores, unless workers run the files: 2 pass, 0 fail, exit 5 or 0.
  • TestCommand::exec (src/runtime/cli/test_command.rs:2684) only raises the VM's exit byte to 1. A worker zeroes it (parallel/runner.rs:663). Nobody chose either (Notes).

Fix

  • A passing run exits with 0, or with the code that a preload's top level or beforeAll/afterAll hook stored.
  • The setter tells the runner when a preload stores. The tail of exec applies the rule and prints one note:.
  • Unchanged: failing runs, process.exit(), node:test runs, runs with workers.
  • Verified: test/cli/test/process-exitcode.test.ts (62 tests, 25 fail on main). Self-reviewed: 42 concerns raised, 39 addressed, 3 in part.

Background

Downsides

  • Exit N becomes 0 with the note for a code left by a test file, a file with no tests, or a preload's beforeEach/afterEach.
  • Question: a preload's code still follows the mode: N without workers, 0 with. Carry it from workers, or ignore it too?
  • Cost: +0 instructions per test callback, +2 per store outside bun test, +46 inside. .text +768 bytes.
Notes

Where this came from

An automated fuzz run found the split. No issue is open for it. #26435 (kjanat, January 2026) is the only report by a person: "This causes CI pipelines to fail despite all tests passing." It set the exit code to 0 at the tail of TestCommand.exec. A stale sweep closed it after the Rust rewrite removed its file, and no maintainer reviewed it. Its three cases are in the new test file, and its author is a co-author of the commit.

Repro

// a.test.ts
import { test } from "bun:test";
process.exitCode = 5;
test("a passing test", () => {});

// b.test.ts
import { test } from "bun:test";
test("a passing test", () => {});
command main this PR
bun test ./a.test.ts ./b.test.ts exit 5 exit 0, note
bun test --isolate ./a.test.ts ./b.test.ts exit 5 exit 0, note
bun test --parallel=1 ./a.test.ts ./b.test.ts exit 5 exit 0, note
bun test --parallel=2 ./a.test.ts (one file) exit 5 exit 0, note
bun test --parallel=2 ./a.test.ts ./b.test.ts exit 0 exit 0
bun test --parallel=2 --no-isolate ./a.test.ts ./b.test.ts exit 0 exit 0

Every run prints 2 pass, 0 fail. The note is one stderr line after the summary:

note: process.exitCode is 5 at the end of the run. bun test ignores it: no test failed, so the exit code is 0. To fail the run, call reportError(err) or throw an error.

When a preload stored 3 and a test file left 5, the run exits 3 and the note ends with so the exit code is 3, which a preload script set.

History

The rule

The setup and teardown of the run is a preload's module evaluation (vm.is_in_preload) and a beforeAll/afterAll hook that a preload registered. BunTest::is_running_preload_all_hook recognises the hook: Order::generate_all_order gives each such hook a group of one sequence with no test. The rule needs no new state in the execution loop and does not depend on timing.

The runner keeps one byte, TestRunner.preload_exit_code:

  • A store by the setup or teardown of the run sets the byte to the code.
  • Any other store of 0 clears the byte. So a test file still clears a preload's code, as on main.
  • Any other non-zero store leaves the byte alone.

At the end of a passing run with no Node exit semantics, a non-zero process.exitCode that differs from the byte is ignored: the note prints and the run exits with the byte. The arm never raises a 0.

who stores a non-zero code main, one process this PR, one process with workers (both)
a test file: top level, a test, a hook, a timer, a macro of the entry file N 0, note 0
a file that registers no test N 0, note 0
a preload's beforeEach/afterEach hook N 0, note 0
a function that a preload installed and a test calls N 0, note 0
a preload's top level N N 0
a preload's beforeAll/afterAll hook, also after an await N N 0
a preload afterAll guard that answers a test file's code N N 0
any file, in a run where a file or a preload calls a node:test API N N 0
any file, with BUN_TEST_DRAIN_EVENT_LOOP=1 N N 0

A failing run exits 1 in every row. process.exit() with no argument still exits with the stored code. bun test --watch does not exit and prints no note.

The question

A preload is two things in Bun. docs/test/lifecycle.mdx calls its hooks global setup and teardown. docs/guides/test/migrate-from-jest.mdx maps setupFiles and setupFilesAfterEnv to it. The reference runners treat the two differently (measured here, a stored 7, all tests pass):

runner where the store is exit
jest 30.5.2 -i globalTeardown 7
jest 30.5.2 -w 2 globalTeardown 7
vitest 4.1.12, forks and threads globalSetup teardown 7
node v26.3.0 --test, both isolation modes --test-global-setup teardown 7
jest 30.5.2 -i setupFilesAfterEnv afterAll 7
jest 30.5.2 -w 2 setupFilesAfterEnv afterAll 0
vitest 4.1.12, forks and threads setupFiles afterAll 0

The global teardown of those runners runs once, in the process that owns the exit code. A Bun preload runs in each worker, and for each file under --isolate.

This PR keeps today's result for a preload's top level and its beforeAll/afterAll hooks: N when one process runs the files, 0 with workers. The choices:

  1. Keep it, and carry it from workers. Every mode then gives N. A worker sends the byte to the coordinator at shutdown. Workers have no "last store", so the coordinator needs a rule for two workers that disagree. bun test --parallel: fail the run when a worker is killed or exits non-zero outside a test file #44431 already makes a worker's non-zero exit status fail the run, which is another way to carry it.
  2. Ignore it too. Every mode then gives 0 with the note. It deletes the call in storeExitCode (src/jsc/bindings/BunProcess.cpp:2518), then preload_exit_code, is_running_preload_all_hook and Bun__Jest__didStoreExitCode. About 25 native lines remain, all in test_command.rs. A preload guard then must call reportError(err), which fails a run in every mode today.
  3. Neither: keep exit N and only print why. If a change from N to 0 without a flag is not acceptable, the arm keeps the exit code and the note says why the run exits N. The exit code then still follows the mode.

Two smaller questions ride on the first:

  • Should a preload's beforeEach/afterEach hooks count as setup and teardown of the run? Here they count as test code, so their result does not depend on --concurrent. An earlier version of this branch kept them when the hook was on the stack. Between two concurrent tests, a code stored after await null was then kept and one stored after a setImmediate was not.
  • Should a file with no tests that stores a non-zero code keep it? jest and vitest fail such a file for having no tests. node --test fails it for the code. On main bun test exits N, and here 0 with the note.

Grounds for no flag: the exit code was 0 through 1.2.8 and the docs list 0 and 1, no run that exits 0 on 1.4.x changes, and the note prints in exactly the runs that change.

Other runners, test files

One passing file stores process.exitCode = 5 (measured here):

runner exit
jest 30.5.2 -i 5
jest 30.5.2 -w 2, first run 0
jest 30.5.2 -w 2, second run with the same cache (jest runs fast suites in band) 5
vitest 4.1.12, forks, threads, vmForks, vmThreads 0
node v26.3.0 --test 1, the file is reported as failed
node v26.3.0 --test --test-isolation=none 5

Why this layer

  • Zero the byte at the tail for every store (fix(test): reset exit code to 0 when all tests pass #26435 as written). It also drops a preload's code and the code of a node:test run, with no output.
  • Discard at the end of each file. Every preload store happens inside a file's window, so it needs the same store mark. Without --isolate it also changes what a later file and a preload's afterAll guard read.
  • Mark every test callback (a flag written in step_sequence_one). It costs a write per callback. In a group of concurrent tests the flag names the callback that started last, so timing decides the result.
  • Only when no preload is configured. An empty preload then brings exit N back.

Measurements

Release builds, linux x64: main f7607a3 and this diff on top of it, before the rebase over the WebKit upgrade.

  • Per test callback: +0 instructions. step_sequence (1206 bytes) and BunTest::run_test_callback (2078 bytes) differ only in addresses. Execution.rs is not in the diff.
  • process.exitCode = 5 outside bun test: 244 to 246 instructions from the entry of the setter to its return (gdb stepi count), no call into the runner.
  • The same store under bun test: 244 to 290 in a test body, 237 to 286 in a preload beforeAll hook, 244 to 258 at a preload's top level. One call of Bun__Jest__didStoreExitCode, no allocation.
  • End of a passing run that left no code: +8 instructions. write(2) calls: 7 and 7. A run that ignores a code makes one more, for the note.
  • Size: .text 108,798,697 to 108,799,465 bytes (+768). The file has 138,270,840 bytes in both. New export 225 bytes, TestCommand::exec +466, setProcessExitCodeInner +22.
  • TestRunner: 600 bytes in both (debug build). The new byte is at offset 596, in the tail padding. ExitHandler: 3 bytes, not changed.
  • Exit matrix: 61 fixtures in 5 modes, 305 cells. 69 cells change, all from N to 0 or to the preload's code, each with one note line. 0 cells go from 0 to non-zero. 0 cells of a run with workers change.

perf, valgrind, strace and bloaty are not installed in the container, so the counts are from gdb, llvm-nm, llvm-objdump and llvm-size.

Tests

test/cli/test/process-exitcode.test.ts, the path that #26435 used. 62 tests: 25 fail on a debug build of main, all pass here. The other 37 pass on main and pin what must not change. Deleting a clause of the fix fails tests (debug build):

deleted clause tests that fail
code == 0 || in the store hook 1
the !vm.is_main_thread guard 2
vm.is_in_preload || 11
the no-test check of the hook window 2
the AddedInPhase::Preload check 1
the Node gate of the tail 5
the kept byte in the tail 21

The tests are not in bun-test.test.ts because that file takes 42 s on a debug build and two of its tests run for 4.3 to 5.0 s there, at the default timeout of 5 s.

Other suites on the debug build of this branch: bun-test.test.ts 109 pass, isolation.test.ts 50 pass, node-test.test.ts 45 pass, pass-with-no-tests.test.ts, rerun-each.test.ts and parallel-startup-failure.test.ts pass. parallel.test.ts: 46 pass, 1 fails, each worker has a unique JEST_WORKER_ID. That test races the start of three debug workers: 20 manual runs of its fixture see three worker ids 5 times on the debug build of main and 14 times on this branch's.

Merge order

Not changed

  • A preload's code with workers (the question above).
  • Runs with workers print no note. Their exit code does not change here.
  • With --isolate, a later file reads process.exitCode as undefined, but its bare process.exit() exits with an earlier file's code. The byte crosses the swap of the global, as on main.
  • A run that calls a node:test API keeps Node's result for every file of the run, and prints no note. bun test --isolate, --parallel: emit 'exit' at the end of each node:test file, and fail the file on its exit code #47431 changes that for files that own their global.
  • file_generation() (src/runtime/test_runner/jest.rs) checks worker_ref(), which is also None in the VM of a macro on a transpiler thread. The new hook checks vm.is_main_thread for that reason.

Self-review

42 concerns. The three addressed in part:

  • "Split the change into two commits, so that the preload half is one revert." This repository squash-merges, so the seam is named above (choice 2) in place of a second commit.
  • "Switch file_generation() to the main-thread check too." It is a separate race that exists on main. It is named under "Not changed".
  • "Pin process.exitCode = undefined." Bun ignores that store today, and fix(process): allow clearing exitCode #42607 changes it.

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

fails on main (without fix)
ASAN without fix: 25 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/test/process-exitcode.test.ts
bun test v1.4.4 (367d939d9)

test/cli/test/process-exitcode.test.ts:
65 |   test.each(inTheMainProcess)("is not the exit code of a passing run: %j", async flags => {
66 |     expect(await run(files, [...flags, "a.test.ts", "b.test.ts"])).toEqual(passed("2 pass, 0 fail", 0, [ignored(5)]));
67 |   });
68 | 
69 |   test("is not the exit code when --parallel has one file, which the main process runs", async () => {
70 |     expect(await run(files, ["--parallel=2", "a.test.ts"])).toEqual(passed("1 pass, 0 fail", 0, [ignored(5)]));
                                                                 ^
error: expect(received).toEqual(expected)

  {
    "counts": "1 pass, 0 fail",
-   "exitCode": 0,
+   "exitCode": 5,
    "laterHookRan": false,
-   "notes": [
-     "note: process.exitCode is 5 at the end of the run. bun test ignores it: no test failed, so the exit code is 0. To fail the run, call reportError(err) or throw an error.",
-   ],
+   "notes": [],
  }

- Expected  - 4
+ Received  + 2

      at <anony
... (truncated)

release without fix: all passed
bun test v1.4.4-canary.1 (e2c049a41)

test/cli/test/process-exitcode.test.ts:
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: [] [52.15ms]
(pass) a process.exitCode that test code leaves > is not the exit code when --parallel has one file, which the main process runs [52.38ms]
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: ["--parallel=1"] [54.58ms]
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: ["--isolate"] [55.81ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-a-test.test.ts [45.13ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-several-tests.test.ts [45.10ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-after-each.test.ts [44.82ms]
(pass) a process.exitCode that test code leaves > is not the exit code with workers, which print no note: ["--parallel=2"] [60.09ms]
(pass) a process.exitCode that test code leaves > is not the exit code when three files store it: ["--isolate"] [45.20ms]
(pass) 
... (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/pr_gate.xml" test/cli/test/process-exitcode.test.ts
bun test v1.4.4 (367d939d9)

test/cli/test/process-exitcode.test.ts:
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: [] [329.06ms]
(pass) a process.exitCode that test code leaves > is not the exit code when --parallel has one file, which the main process runs [306.59ms]
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: ["--isolate"] [326.31ms]
(pass) a process.exitCode that test code leaves > is not the exit code of a passing run: ["--parallel=1"] [338.10ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-several-tests.test.ts [261.74ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-a-test.test.ts [281.86ms]
(pass) a process.exitCode that test code leaves > is not the exit code when stored as in inside-after-each.test.ts [263.29ms]
(pass) a process.exitCode that test code leaves > is not the exit code with workers, which pr
... (truncated)

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     a32fbbf6a1
  features     lto, baseline

21 deps, 432 codegen, 1228 objects in 738ms

ninja: Entering directory `/workspace/bun/build/release'
[1/2] fetch WebKit (prebuilt)
[WebKit] fetching https://github.com/oven-sh/WebKit/releases/download/autobuild-a7b59e61de2e2c4fae7c533e240ea85ca36fdddc/bun-webkit-linux-amd64-lto-aot.tar.gz
[WebKit] extracted to /root/.bun/build-cache/webkit-a7b59e61de2e2c4f-lto-aot (6.7s)
[1/2] reconfigure
[1/4] fetch WebKit (prebuilt)
[WebKit] up to date
[1/435] build.rs build_script_build
[2/435] gen generated_host_exports.rs
generated_host_exports.rs: 111 exports (lazy=4, generic=107, rust=0); 260 extern-C blocks audited
[3/434] gen AOTSchema.def
[4/433] gen cpp.rs (cppbind)
[5/433] rustc bun_safety 
[6/433] rustc bun_zlib_sys 
[7/433] rustc bun_output 
[8/433] rustc bun_perf 
[9/433] rustc bun_brotli 
[10/433] rustc bun_boringssl_sys 
[11/433] rustc bun_zstd 
[12/433] rustc bun_errno 
[13/433] rustc bun_ptr 
[14/433] rustc bun_picohttp 
[15/433] rustc bun_car
... (truncated)
diff hotspot
docs/test/lifecycle.mdx                |   2 +
 docs/test/parallel.mdx                 |   2 +
 docs/test/runtime-behavior.mdx         |  34 +++
 src/jsc/bindings/BunProcess.cpp        |  13 +-
 src/runtime/cli/test_command.rs        |  28 ++
 src/runtime/test_runner/bun_test.rs    |  16 ++
 src/runtime/test_runner/jest.rs        |  30 ++
 test/cli/test/process-exitcode.test.ts | 507 +++++++++++++++++++++++++++++++++
 8 files changed, 630 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
docs/test/lifecycle.mdx                     1      1     19
docs/test/parallel.mdx                      1      1     20
docs/test/runtime-behavior.mdx              3      1     21
src/jsc/bindings/BunProcess.cpp             3      2     16
src/runtime/cli/test_command.rs             5      5     18
src/runtime/test_runner/bun_test.rs         1      1     15
src/runtime/test_runner/jest.rs             4      4     17
test/cli/test/process-exitcode.test.ts      0      1     14

…g run

A `process.exitCode` that a test file stores was the exit code of a passing
run when the main process ran the files. `--parallel` workers dropped it.

The exit code of a passing run is now 0, or the code that the setup or
teardown of the run stored: a `--preload`'s top level and the `beforeAll`
and `afterAll` hooks it registers. `process.exitCode =` tells the runner
when such code stores, and the tail of `TestCommand::exec` applies the
rule and prints one note when it ignores a code. Runs that use `node:test`
or `BUN_TEST_DRAIN_EVENT_LOOP=1`, failing runs and runs with workers are
unchanged.

Co-authored-by: Kaj Kowalski <info@kajkowalski.nl>
@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (204d701, linux x64, debug and release builds) with two files:

// a.test.ts
import { test } from "bun:test";
process.exitCode = 5;
test("a passing test", () => {});

// b.test.ts
import { test } from "bun:test";
test("a passing test", () => {});

Every run prints 2 pass, 0 fail.

  • bun test ./a.test.ts ./b.test.ts exits 5.
  • bun test --isolate ./a.test.ts ./b.test.ts exits 5.
  • bun test --parallel=1 ./a.test.ts ./b.test.ts exits 5.
  • BUN_TEST_PARALLEL_SCALE_MS=0 bun test --parallel=2 ./a.test.ts ./b.test.ts exits 0.

Pull request: #47508

CI (build 135940)

test/cli/test/process-exitcode.test.ts passes on every lane: Linux glibc and musl, the ASAN lane, macOS x64 and arm64, Windows x64 and arm64. The build is red for two tests that this diff does not touch:

  • test/aot/engine/ir-6.test.ts. The build of main at the same base commit (build 135934, 204d701) is red for this test too.
  • test/aot/build-scaling/build-scaling-2.test.ts on macOS arm64. It measures a peak-memory ratio of 2.59 against a limit of 2.5.

This pull request is a draft. It waits for a maintainer's answer to the question in its description.

@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:10 PM PT - Oct 30th, 2026

❌ @robobun, your commit a32fbbf has 2 failures in Build #135940 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47508

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

bun-47508 --bun

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.

1 participant