Skip to content

bun test: don't park the event loop after a test file's entry promise settles - #36453

Merged
Jarred-Sumner merged 6 commits into
mainfrom
farm/187bb123/test-runner-pending-timer-stall
Jul 31, 2026
Merged

bun test: don't park the event loop after a test file's entry promise settles#36453
Jarred-Sumner merged 6 commits into
mainfrom
farm/187bb123/test-runner-pending-timer-stall

Conversation

@robobun

@robobun robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #36450

Problem

In bun test, one pending ref'd timer (e.g. @tanstack/react-query's 5-minute gcTime timeout) made every subsequent test file that loads a module take a fixed extra ~15-100ms, linear in file count. A 20-file suite went from ~15ms to ~2s on the reporter's machine.

Repro

One file runs setTimeout(() => {}, 300_000) in a test; later files each import a small module and run a trivial test. Wall time grows ~100ms per file while CPU stays idle.

Cause

load_entry_point_for_test_runner ends with an unconditional auto_tick() after the entry promise has already settled. With a ref'd handle pending, the uws loop is active, so that tick parks in epoll/kqueue until the next timer deadline. The per-file perform_gc() arms JSC's incremental sweeper ~100ms out, so the park runs the full ~90ms, once per file. Files without an async-transpiled import dodge it only because the vm.wakeup() pre-arm before the entry-point load leaves an eventfd edge pending; a module load's async transpile round-trip consumes that edge first.

Fix

Pre-arm the waker before the final auto_tick, same as the existing wakeup() before the entry-point load: the tick still drains ready I/O and due timers but returns immediately instead of blocking, since the test runner continues synchronously either way.

Verification

New regression test measures the gap between module evaluation and the first test callback, paired against a control run without the pending timer. Unfixed the delta is ~15ms (release) / ~92ms (debug); fixed it is <2ms. Repro timing with the debug build (21 files): 2.53s before, 0.62s after (0.58s baseline without the pending timer).


[review] gate passed · iteration 1 · 2 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/regression/issue/36450.test.ts"
bun test v1.4.0 (0d7b612a4)

test/regression/issue/36450.test.ts:
70 |   const withTimer = await medianGap(true);
71 | 
72 |   // Unfixed, the pending timer pins every gap to the next JSC timer deadline
73 |   // (~15ms release, ~90ms debug on an idle machine); fixed, both runs behave
74 |   // identically.
75 |   expect(withTimer - withoutTimer).toBeLessThan(10);
                                        ^
error: expect(received).toBeLessThan(expected)

Expected: < 10
Received: 88.67999999999999

      at <anonymous> (/workspace/bun/test/regression/issue/36450.test.ts:75:36)
(fail) pending ref'd timer does not stall subsequent test files [1508.24ms]

 0 pass
 1 fail
 3 expect() calls
Ran 1 test across 1 file. [4.01s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (087b7b04a)

test/regression/issue/36450.test.ts:
(pass) pending ref'd timer does not stall subsequent test files [29.34ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [218.00ms]
__F:0:S:0
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/regression/issue/36450.test.ts"
bun test v1.4.0 (0d7b612a4)

test/regression/issue/36450.test.ts:
(pass) pending ref'd timer does not stall subsequent test files [1225.06ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 822ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
�[1m�[92m   Compiling�[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
�[1m�[92m   Compiling�[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
�[1m�[92m   Compiling�[0m bun_sourcemap_jsc v0.0.0 (/workspace/bun/src/sourcemap_jsc)
�[1m�[92m   Compiling�[0m bun_bundler_jsc v0.0.0 (/workspace/bun/src/bundler_jsc)
�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs           |  2 +
 test/regression/issue/36450.test.ts | 76 +++++++++++++++++++++++++++++++++++++
 2 files changed, 78 insertions(+)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                 reads  edits  tests
src/jsc/VirtualMachine.rs                4      5      0
test/regression/issue/36450.test.ts      2      4      0

root cause · written by the author bot

The test runner's pre-load wakeup pre-armed the event loop waker once, but an async-transpiled import consumed that eventfd edge before the trailing auto_tick() ran, so with a pending ref'd timer keeping the loop alive the tick had nothing to wake it and blocked until the poll's 100ms timeout, costing every subsequent module-loading test file that delay. The fix calls self.wakeup() after the test file's entry promise settles and before the final auto_tick(), re-arming the waker so the tick returns immediately instead of parking. This mirrors the existing pre-arm pattern used just before the…

… settles

With a ref'd handle pending (e.g. a user setTimeout left running by an
earlier test file), the final auto_tick in load_entry_point_for_test_runner
kept the loop active and parked in the poller until the next timer deadline,
typically JSC's incremental sweeper armed ~100ms out by the per-file GC.
Every subsequent test file that loaded a module paid that wait. Pre-arm the
waker so the tick drains I/O without blocking, matching the existing wakeup
before the entry-point load.

Fixes #36450
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The test-runner load path now pre-arms the event-loop waker before auto_tick(). A regression test compares generated test-file timing with and without a pending ref’d timer.

Changes

Test runner event-loop wakeup

Layer / File(s) Summary
Pre-arm the test-runner event loop
src/jsc/VirtualMachine.rs
The non-watch load path calls self.wakeup() after the load promise settles and before auto_tick().
Validate pending-timer behavior
test/regression/issue/36450.test.ts
Generated modules and test files run in leak and no-leak modes; median timing differences must remain below 10ms.

Possibly related PRs

  • oven-sh/bun#35891: Also changes test-runner event-loop draining around test-file completion.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The fix and regression test match #36450's requirement that a pending ref'd timer must not slow later module-loading test files.
Out of Scope Changes check ✅ Passed The diff stays focused on the runtime fix and its regression test, with no unrelated changes.
Title check ✅ Passed The title clearly summarizes the main fix: preventing bun test from parking the event loop after a test file settles.
Description check ✅ Passed The description covers the problem, cause, fix, and verification, though it doesn't use the template's exact headings.

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: 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/regression/issue/36450.test.ts`:
- Around line 4-11: In test/regression/issue/36450.test.ts, remove the
explanatory prose comments surrounding the regression test and retain only the
issue URL comment. Apply the same cleanup to the additional locations identified
by the review, preserving the test code unchanged.
- Around line 15-34: Strengthen the regression fixture in the subprocess setup
and generated plain modules: seed an expected-timer environment flag for each
subprocess, set an armed flag immediately after the setTimeout call in
leak.test.ts, and add a top-level guard in each plain module that fails when the
timer is expected but the armed flag is absent. Preserve the existing module
imports and test assertions while ensuring the setup precondition is validated
before plain-module evaluation.
🪄 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: 4fe77fd7-5654-419b-ad44-484787bde526

📥 Commits

Reviewing files that changed from the base of the PR and between c08f665 and 07ca26c.

📒 Files selected for processing (2)
  • src/jsc/VirtualMachine.rs
  • test/regression/issue/36450.test.ts

Comment thread test/regression/issue/36450.test.ts
Comment thread test/regression/issue/36450.test.ts
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated

@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 nit: I checked that the new self.wakeup() also runs on the watch-mode branch (it's placed after the if/else) — harmless there since wakeup() just signals the eventfd and the loop already exited with the promise settled. The pre-arm mirrors the existing one at test_command.rs:3268. Still worth a human look at whether this is the right layer vs. making the post-settle auto_tick() non-blocking or conditional.

Extended reasoning...

The runtime change is 2 lines in load_entry_point_for_test_runner and follows the exact precedent at test_command.rs:3268 (same vm.wakeup() before an auto_tick that shouldn't park). The regression test is well-designed (paired median against a control run, fixture guard proving the timer is armed). The one open question is architectural — REVIEW.md's "fix at the layer that owns the invariant" — since an unconditional auto_tick() after the promise has already settled arguably shouldn't be a blocking tick at all. That's a judgment call for someone who owns the event-loop draining semantics, not something I can settle from the diff.

Comment thread test/regression/issue/36450.test.ts Outdated

@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 stderr-surfacing feedback from the earlier pass is addressed in 0d7b612.

What was reviewed:

  • The self.wakeup() pre-arm mirrors the existing pattern at test_command.rs:3267-3268 (same rationale comment), and sits after the if/else so both watch and non-watch paths get it.
  • Regression test: paired-median design against a control run, fixture guard proves the timer is armed before measured files, pipes drained concurrently, combined {gapCount, exitCode, stderr} assertion so child failures self-diagnose.
  • Checked that wakeup() is a pure eventfd pre-arm (delegates to event_loop.wakeup()) with no side effects beyond making the subsequent auto_tick() non-blocking.
Extended reasoning...

Overview

Two-file change: a one-line self.wakeup() call (plus comment) inserted before the final auto_tick() in VirtualMachine::load_entry_point_for_test_runner, and a new regression test at test/regression/issue/36450.test.ts.

Security risks

None. The change only affects test-runner event-loop scheduling (whether a poll blocks or returns immediately). No user input, no parsing, no auth/crypto surface.

Level of scrutiny

Low-to-moderate. The runtime change is a single method call following a pattern already established two lines up the call stack — test_command.rs:3267-3268 calls vm.wakeup() immediately before load_entry_point_for_test_runner with the comment "need to wake up so autoTick() doesn't wait for 16-100ms after loading the entrypoint". This PR applies the same pre-arm before the trailing auto_tick(), which the original pre-arm no longer covers once an async-transpiled import consumes the eventfd edge. The root-cause analysis in the PR description is detailed and the mechgate evidence shows the test failing on the unfixed debug build with a 91.48ms delta (well above the 10ms threshold).

Other factors

  • All prior review threads are resolved: CodeRabbit's fixture-guard suggestion (7ad69c4), comment-cop's comment-length nags (22c255c, 1e9cecc), and my own stderr-surfacing note (0d7b612).
  • The test uses a paired median across 6 files with a control run, so machine speed cancels out and the 10ms threshold sits well below the ~15ms (release) / ~90ms (debug) unfixed signal — a reasonable design against timing flakiness.
  • Test follows harness conventions: tempDir, bunEnv spread, bunExe(), concurrent pipe drain via Promise.all, await using for the subprocess.
  • The bug-hunting system found no issues on the current revision.

@Jarred-Sumner
Jarred-Sumner merged commit b612f9e into main Jul 31, 2026
53 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/187bb123/test-runner-pending-timer-stall branch July 31, 2026 08:13
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.

bun test: one pending ref'd timer adds ~100ms to every subsequent test file that loads a module (1.4.0-canary.1, not in 1.3.14)

2 participants