Repository navigation
Conversation
…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>
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
Pull request: #47508 CI (build 135940)
This pull request is a draft. It waits for a maintainer's answer to the question in its description. |
Collaborator
Author
|
Updated 7:10 PM PT - Oct 30th, 2026
❌ @robobun, your commit a32fbbf has 2 failures in
🧪 To try this PR locally: bunx bun-pr 47508That installs a local version of the PR into your bun-47508 --bun |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft: Downsides asks a maintainer one question.
Problem
bun testrun exits with aprocess.exitCodethat 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
beforeAll/afterAllhook stored.execapplies the rule and prints onenote:.process.exit(),node:testruns, runs with workers.test/cli/test/process-exitcode.test.ts(62 tests, 25 fail on main). Self-reviewed: 42 concerns raised, 39 addressed, 3 in part.Background
--threads=falseoption isprocess.exitCodeaffected vitest-dev/vitest#3023, [Bug]: In Node 20, Jest exits with non-zero exit code ifprocess.exitCodeis set in the tested code jestjs/jest#14501). This PR revives fix(test): reset exit code to 0 when all tests pass #26435, which a stale sweep closed.setupFiles. vitest, and jest with workers, keep a code from the first and drop one from the second.node:testcodes), a mark per callback (timing decides the result).Downsides
beforeEach/afterEach.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
bun test ./a.test.ts ./b.test.tsbun test --isolate ./a.test.ts ./b.test.tsbun test --parallel=1 ./a.test.ts ./b.test.tsbun test --parallel=2 ./a.test.ts(one file)bun test --parallel=2 ./a.test.ts ./b.test.tsbun test --parallel=2 --no-isolate ./a.test.ts ./b.test.tsEvery run prints
2 pass, 0 fail. The note is one stderr line after the summary: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
bun testexited 0 for this repro in 1.0.0 through 1.2.8 and exits 5 since 1.2.9 (measured on the release binaries). The change is eee5d4f (node:worker_threads low-hanging fruit #18758): it sent the passing path throughglobalExit()so thatBUN_DESTRUCT_VM_ON_EXIT=1tears the VM down. The PR and its review do not mention exit codes.Global::exit(0). fba43af (Fix effectively every native-code memory leak in Bun #30875) routed it throughglobal_exit()and addedexit_code = 0to keep 0.docs/test/runtime-behavior.mdxlists only the exit codes 0 and 1.The rule
The setup and teardown of the run is a preload's module evaluation (
vm.is_in_preload) and abeforeAll/afterAllhook that a preload registered.BunTest::is_running_preload_all_hookrecognises the hook:Order::generate_all_ordergives 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:At the end of a passing run with no Node exit semantics, a non-zero
process.exitCodethat differs from the byte is ignored: the note prints and the run exits with the byte. The arm never raises a 0.beforeEach/afterEachhookbeforeAll/afterAllhook, also after anawaitafterAllguard that answers a test file's codenode:testAPIBUN_TEST_DRAIN_EVENT_LOOP=1A failing run exits 1 in every row.
process.exit()with no argument still exits with the stored code.bun test --watchdoes not exit and prints no note.The question
A preload is two things in Bun.
docs/test/lifecycle.mdxcalls its hooks global setup and teardown.docs/guides/test/migrate-from-jest.mdxmapssetupFilesandsetupFilesAfterEnvto it. The reference runners treat the two differently (measured here, a stored 7, all tests pass):-iglobalTeardown-w 2globalTeardownglobalSetupteardown--test, both isolation modes--test-global-setupteardown-isetupFilesAfterEnvafterAll-w 2setupFilesAfterEnvafterAllsetupFilesafterAllThe 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/afterAllhooks: N when one process runs the files, 0 with workers. The choices:storeExitCode(src/jsc/bindings/BunProcess.cpp:2518), thenpreload_exit_code,is_running_preload_all_hookandBun__Jest__didStoreExitCode. About 25 native lines remain, all intest_command.rs. A preload guard then must callreportError(err), which fails a run in every mode today.Two smaller questions ride on the first:
beforeEach/afterEachhooks 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 afterawait nullwas then kept and one stored after asetImmediatewas not.node --testfails it for the code. On mainbun testexits 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):-i-w 2, first run-w 2, second run with the same cache (jest runs fast suites in band)--test--test --test-isolation=none--threads=falseoption isprocess.exitCodeaffected vitest-dev/vitest#3023 (sheremet-va): "Theprocess.exitCodedoesn't affect the result of the test since it's possible that you check its value in your test."process.exitCodeis set in the tested code jestjs/jest#14501 (SimenB): "Happy to take a PR which makes sure to ignore anyprocess.exitCodeset by the user."node:testAPI keeps the code here, on the gate that bun test: only run process.on('exit') listeners when node:test APIs were used #38442 drew for'exit'listeners.Why this layer
node:testrun, with no output.--isolateit also changes what a later file and a preload'safterAllguard read.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.Measurements
Release builds, linux x64: main f7607a3 and this diff on top of it, before the rebase over the WebKit upgrade.
step_sequence(1206 bytes) andBunTest::run_test_callback(2078 bytes) differ only in addresses.Execution.rsis not in the diff.process.exitCode = 5outsidebun test: 244 to 246 instructions from the entry of the setter to its return (gdbstepicount), no call into the runner.bun test: 244 to 290 in a test body, 237 to 286 in a preloadbeforeAllhook, 244 to 258 at a preload's top level. One call ofBun__Jest__didStoreExitCode, no allocation.write(2)calls: 7 and 7. A run that ignores a code makes one more, for the note..text108,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.perf,valgrind,straceandbloatyare not installed in the container, so the counts are from gdb,llvm-nm,llvm-objdumpandllvm-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):code == 0 ||in the store hook!vm.is_main_threadguardvm.is_in_preload ||AddedInPhase::PreloadcheckThe tests are not in
bun-test.test.tsbecause 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.ts109 pass,isolation.test.ts50 pass,node-test.test.ts45 pass,pass-with-no-tests.test.ts,rerun-each.test.tsandparallel-startup-failure.test.tspass.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
JSMock__isInPreloadand renameson_stack_entry. This PR reads neither. If a maintainer wants one predicate,is_running_preload_all_hookis the narrower one: it is false for a preload'sbeforeEach/afterEach.node_test_usedanOptionfor each global.run_has_node_exit_semanticsthen reads.is_some().skip_exit_listenersis not changed here. Both PRs add text under "Exit Codes" indocs/test/runtime-behavior.mdx.process.exitCode = undefined. It must callstoreExitCode(), notBun__setExitCode, so that a clear also clears the kept byte.ifat the tail. This PR appends anelseto it.Not changed
--isolate, a later file readsprocess.exitCodeasundefined, but its bareprocess.exit()exits with an earlier file's code. The byte crosses the swap of the global, as on main.node:testAPI 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) checksworker_ref(), which is alsoNonein the VM of a macro on a transpiler thread. The new hook checksvm.is_main_threadfor that reason.Self-review
42 concerns. The three addressed in part:
file_generation()to the main-thread check too." It is a separate race that exists on main. It is named under "Not changed".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)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file