Skip to content

test(spawn): speed up spawn-noread-leak.test.ts (27s -> 2s on Windows arm64) - #35427

Open
robobun wants to merge 3 commits into
mainfrom
farm/9e61383e/speedup-spawn-noread-leak
Open

test(spawn): speed up spawn-noread-leak.test.ts (27s -> 2s on Windows arm64)#35427
robobun wants to merge 3 commits into
mainfrom
farm/9e61383e/speedup-spawn-noread-leak

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Rewrite test/js/bun/spawn/spawn-noread-leak.test.ts so the Windows arm64 lane runs it in ~2 s instead of ~27 s, with a leak bound the release lanes can actually trip.

Why

This file was one of the slowest single-test files on Windows arm64 (build #79247: 29 s wall time). Almost all of that was process-creation cost: 3000 spawns of msys2 cat.exe at ~9 ms each. The RSS check at the end was a loose before + after < before * 3 ratio that on a debug/ASAN baseline of ~360 MB permitted ~1.8 GB of growth.

While tracing the allocation path I also found the previous form no longer exercised the reserve(16 * 1024) path on POSIX: it piped stderr, cat writes nothing to stderr, and PosixBufferedReader::read_with_fn now reads the first chunk into the shared event-loop stack buffer and returns on EOF without ever allocating _buffer. Instrumenting Subprocess::on_close_io confirms len=0 cap=0 for the stderr pipe there.

Changes

  • Fewer spawns: 3000 -> 750 (3 warmup + 12 measure batches of 50).
  • Native child: ["cmd", "/c", "type", payload] on Windows (~2.5 ms/spawn) and ["cat", payload] on POSIX, replacing the msys2 cat lookup on Windows (~9 ms/spawn).
  • 16 KB payload on the piped stdout so the per-reader buffer is allocated (POSIX falls past the stack-buffer fast path) and its pages are faulted in. With a tiny write only one page is touched and a retained buffer shows ~4 MB over 600 spawns on Linux (overcommit), which the previous ratio check and a 5 MB bound both miss.
  • Warmup + delta assertion: take RSS after a 150-spawn warmup, run 600 more, assert the delta is under 5 MB on release (100 MB under ASAN, where quarantine holds ~50 MB of freed buffers regardless of whether the path leaks).
  • Exit-code check: fold all child exit codes and assert 0 so a missing cmd/cat fails the test instead of silently changing the workload.
  • Drop the infinite timeout: the slowest lane now finishes in ~2 s so the default suffices.
  • isASAN only for the wide bound (not isASAN || isDebug): non-ASAN debug builds should use the tight bound so local bun bd on Windows/macOS x64 can detect the leak.

Verification

Fail-before: replacing taken.into_boxed_slice() with mem::forget(taken) in Subprocess::on_close_io (simulating the buffer being retained past child exit) and rebuilding release:

RSS: 46.3 MB -> 56.3 MB (+10.0 MB over 600 spawns)
error: expect(received).toBeLessThan(expected)
(fail) unread 'pipe' stdio does not leak the PipeReader buffer

The previous form of the test passes against the same build (before 37.4 MB, after 40.4 MB, well inside before * 3).

Pass-after timings (bun bd test / release bun test):

lane before after
Windows 11 arm64 release 27.1 s 1.9 s
Windows x64 release 5.8 s 0.9 s
Linux x64 debug+ASAN 6.1 s 3.3 s
Linux x64 release 0.7 s 0.3 s
RSS deltas (5+ runs each)
linux release:     +1.0  +1.0  +0.0  +1.0  +1.0                       (limit 5)
win x64 release:   +0.6  +0.1  +0.9  +0.5  +1.7                       (limit 5)
win arm64 release: +1.6  +1.8  +2.4  +1.7  +1.6                       (limit 5)
linux debug+asan: +51.0 +51.0 +51.0 +52.0 +51.0 +51.0 +50.0 +51.0     (limit 100)

no test proof · iteration 0 · Platform-specific test-only change; deferring to CI.

… arm64)

- Reduce total spawns 3000 -> 750 (3 warmup + 12 measure batches of 50).
- Spawn native `cmd /c echo x` on Windows instead of msys2 `cat`
  (~2.5 ms vs ~9 ms per spawn), and `echo x` instead of `cat` on POSIX.
- Pipe stdout with 2 bytes written instead of an empty stderr pipe. Since
  the PosixBufferedReader stack-buffer fast path landed, an empty pipe
  hits EOF in the shared stack buffer and never allocates the per-reader
  Vec, so the previous form was not exercising the reserve(16 KB) path on
  POSIX. Writing a byte makes it fall through to the allocating loop.
- Assert on post-warmup RSS delta with a per-build-type bound (5 MB
  release, 30 MB debug/ASAN) instead of the baseline-relative ratio.
  Tighter than the old `before * 3` check (~8.5 KB/spawn vs ~12 KB/spawn
  headroom) and stable under ASAN quarantine noise.
- Check child exit codes so a missing `cmd`/`echo` fails loudly.
- Drop the `0` (infinite) per-test timeout; the rewrite runs in ~2 s on
  the slowest lane so the default suffices.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 93c034a7-7123-4a22-813a-99e7bb050bce

📥 Commits

Reviewing files that changed from the base of the PR and between e43ba3e and 540c8ed.

📒 Files selected for processing (1)
  • test/js/bun/spawn/spawn-noread-leak.test.ts

Walkthrough

The spawn leak test now repeatedly launches children with unread piped stdout, performs GC-assisted RSS measurements, and verifies successful exits against an ASAN-adjusted memory delta limit.

Changes

Spawn pipe regression coverage

Layer / File(s) Summary
Spawn workload and platform command
test/js/bun/spawn/spawn-noread-leak.test.ts
The test creates a payload, selects cat or cmd /c type, and runs batched child processes with stdout piped and other stdio discarded.
Warm-up and RSS measurement
test/js/bun/spawn/spawn-noread-leak.test.ts
Warm-up batches and forced GC establish an RSS baseline before repeated measurement batches calculate the memory delta.
Exit and memory assertions
test/js/bun/spawn/spawn-noread-leak.test.ts
The test checks aggregated child exit codes and enforces an ASAN-adjusted RSS-delta limit.

Possibly related PRs

  • oven-sh/bun#34971: Updates lazy pipe-reader lifecycle behavior that affects unread pipe buffers after child exit.
  • oven-sh/bun#35267: Updates spawn pipe memory-leak regression tests and their GC/RSS measurements.
🚥 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 is concise, specific, and accurately summarizes the main change: speeding up spawn-noread-leak.test.ts on Windows arm64.
Description check ✅ Passed The description covers what changed, why, changes, and verification, which matches the template well despite different heading names.

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Verified locally: bun bd test test/js/bun/spawn/spawn-noread-leak.test.ts passes in 3.3 s (was 6.1 s), and release bun test on Windows arm64 passes in 1.9 s (was 27 s). Second commit addresses review: the child now writes 16 KB so a retained buffer actually shows in RSS (fail-before proven, see PR body), the Windows buffer-size comment is fixed, and the wide bound is gated on isASAN only.

CI build #79609: spawn-noread-leak passed on every test lane; 194 jobs passed, the only annotations are unrelated flakes that retried green, and the one red job is :darwin: 14 aarch64 - test-bun with step-failed-outside-runner (agent lost before the step ran, pre-existing). Ready for review.

@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 — test-only speedup that tightens the leak bound and follows the harness leak-test pattern (warmup, Bun.gc(true), RSS delta branched on isASAN/isDebug). The inline nit about the ~10 MB vs ~15 MB comment is worth fixing but doesn't affect the 30 MB limit's correctness.

Extended reasoning...

Overview

This PR rewrites a single leak-regression test file (test/js/bun/spawn/spawn-noread-leak.test.ts) to cut Windows arm64 wall time from ~27 s to ~2 s. It reduces spawn count from 3000 to 750, swaps msys2 cat for native cmd /c echo x / echo x, pipes stdout (with bytes written) instead of an always-empty stderr so the POSIX per-reader reserve() path is actually allocated, and replaces the loose before + after < before * 3 ratio with a warmup-then-delta assertion (5 MB release / 30 MB debug+ASAN). No production code is touched.

Security risks

None. Test-only change spawning echo/cmd /c echo x locally with no network, no filesystem writes, no untrusted input.

Level of scrutiny

Moderate — it rewrites an existing regression test's workload, which REVIEW.md flags for "keep asserting the property the original assertion protected". The PR does exactly that: it still bounds RSS growth over many unread-pipe spawns, and the author traces through PosixBufferedReader::read_with_fn to show the previous form had gone vacuous on POSIX (empty stderr never reached the reserve() allocation). The new delta assertion is strictly tighter than the old ratio on release lanes, and the ASAN/debug branch matches the CLAUDE.md leak-test guidance (branched threshold, well below the unfixed leak, measured after warmup). Measured RSS deltas across four lanes give ~3-10x headroom to the release limit and ~2x to the ASAN limit.

Other factors

  • Harness helpers (isASAN, isDebug, isWindows) and process.memoryUsage.rss() are all established patterns in the test suite.
  • Exit-code fold ensures a missing cmd/echo fails loudly rather than silently changing the workload.
  • The infinite per-test timeout is dropped now that the slowest lane finishes in ~2 s.
  • The one finding is a comment-accuracy nit ("~10 MB" vs the author's own measured ~15 MB for ASAN); the 30 MB limit is unaffected either way. This is a one-character-class fix that shouldn't block merge.

Comment thread test/js/bun/spawn/spawn-noread-leak.test.ts Outdated
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:58 AM PT - Jul 24th, 2026

@robobun, your commit 540c8ed has 1 failures in Build #79609 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35427

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

bun-35427 --bun

…ct it

Review feedback:

- The previous revision's 2-byte write only touched one page of the
  ~16 KB reserved buffer, so a regressed retention showed ~4 MB over
  600 spawns on Linux (overcommit) and slipped under the 5 MB bound.
  The child now writes 16 KB via `cat`/`cmd /c type` of a temp file so
  the whole buffer is faulted in; the regressed delta is ~10 MB
  (verified by replacing the into_boxed_slice() with mem::forget in
  on_close_io locally: +10.0 MB x3, test fails as intended).
- Drop `|| isDebug` from the bound selector: non-ASAN debug builds
  (native Windows/macOS-x64 `bun bd`, `--asan=off`) should use the
  tight bound so they can detect the leak locally. Matches the
  `isASAN ? ...` pattern in the other spawn leak tests.
- Fix the buffer-size comment: libuv's pipe alloc_cb passes 65536 on
  Windows, 16 KB is the POSIX hardcode.
- Bump the ASAN bound to 100 MB (observed ~50-52 MB with the 16 KB
  payload going through quarantine).

@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 `@test/js/bun/spawn/spawn-noread-leak.test.ts`:
- Around line 68-77: In the spawn leak test, move the expect(badExit).toBe(0)
assertion to after the deltaMB RSS-bound assertion, keeping the existing limitMB
calculation and memory assertion unchanged. Ensure the subprocess exit code is
asserted last.
🪄 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: e05d8b9d-56e7-4e71-8f41-434d8a8726af

📥 Commits

Reviewing files that changed from the base of the PR and between 1d46220 and e43ba3e.

📒 Files selected for processing (1)
  • test/js/bun/spawn/spawn-noread-leak.test.ts

Comment thread test/js/bun/spawn/spawn-noread-leak.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 follow-up commit addressed the comment-accuracy nit (ASAN delta now reads ~50 MB against the 100 MB bound, matching the updated measurements), and the byte-count remark is moot with the 16 KB payload.

What was reviewed:

  • Leak assertion is strictly tighter than before (5 MB delta vs the old before*3 ratio that permitted ~1.8 GB on debug baselines), with fail-before verified via mem::forget injection.
  • RSS bound branches on isASAN per REVIEW.md; release headroom (~1-2 MB observed vs 5 MB limit) and ASAN headroom (~51 MB vs 100 MB) both look safe against the 5+ run tables.
  • Windows cmd /c type <tempDir path> — exit codes are folded and asserted, so a path/quoting failure would surface as a test failure rather than a silently-different workload.
  • tempDir + using, Buffer.alloc(n, fill), dropped infinite timeout — all match harness conventions.
Extended reasoning...

Overview

Test-only rewrite of test/js/bun/spawn/spawn-noread-leak.test.ts (regression coverage for #18265 / PR #20102). No runtime code touched. The change reduces spawn count 3000→750, switches Windows to native cmd /c type instead of msys2 cat, writes a 16 KB payload to the piped stdout so the per-reader buffer is actually allocated and its pages faulted in, replaces the loose before + after < before * 3 ratio with a warmup+delta assertion (5 MB release / 100 MB ASAN), adds an exit-code fold so a missing child binary fails loudly, and drops the , 0 infinite timeout.

Security risks

None. Test-only; spawns cat/cmd /c type on a temp file the test itself creates, no network, no external input.

Level of scrutiny

Low-to-medium. This is a leak-regression test rewrite, and the main risks for this class of change are (a) weakening the property being asserted and (b) introducing flakiness via too-tight RSS bounds. Both are well-covered: the author demonstrated fail-before by injecting mem::forget(taken) in Subprocess::on_close_io on a release build (new test fails at +10 MB, old test passes), and provided 5-8 runs per lane showing release deltas of 0-2.4 MB against a 5 MB limit and ASAN deltas of 50-52 MB against a 100 MB limit. The old ratio check was effectively vacuous on high-baseline builds, so this is a strict tightening.

Other factors

  • My prior review flagged a comment/measurement mismatch on the ASAN bound; e43ba3e reworked the payload to 16 KB and rewrote the comment to match fresh measurements (~50 MB ASAN, 100 MB bound). The current diff and PR description are now consistent.
  • Harness conventions followed: tempDir with using for cleanup, Buffer.alloc(16*1024, "x").toString() (not .repeat), isASAN/isWindows from harness, no hardcoded sleeps.
  • The deliberate choice to gate the wide bound on isASAN only (not isASAN || isDebug) is explained in the description — non-ASAN debug lanes should be able to detect the leak with the tight bound. If a non-ASAN debug lane turns out noisier than expected, CI will surface it and the bound can be widened; that's the right direction to fail in.
  • The Windows cmd /c type <path> invocation passes an unquoted temp path; CI temp dirs don't contain spaces and the author verified on both Windows lanes, and the expect(badExit).toBe(0) guard means any future path issue fails the test rather than silently skipping the workload.

@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 — test-only speedup with fail-before proof; all prior review threads (comment/measurement mismatch, assertion order) are addressed.

What was reviewed:

  • The new assertion is strictly tighter than the old before + after < before * 3 ratio, and the stderr→stdout switch is justified (old form no longer allocated the per-reader buffer on POSIX).
  • RSS bounds vs. measured data: release 5 MB limit sits between healthy (~0-2.4 MB) and regressed (~10 MB); ASAN 100 MB vs. ~51 MB observed. isASAN-only gating (dropping isDebug) is intentional and stated.
  • Harness conventions: tempDir, Buffer.alloc(n, fill), exit-code asserted last, default timeout restored.
Extended reasoning...

Overview

Single-file rewrite of test/js/bun/spawn/spawn-noread-leak.test.ts, the regression guard for issue #18265 (unread "pipe" stdio retaining the PipeReader buffer past child exit). Changes: 3000→750 spawns, native cmd /c type on Windows instead of msys2 cat, 16 KB payload on piped stdout so a retained buffer actually faults pages in, warmup + delta-based RSS assertion replacing a loose ratio, exit-code fold, and default timeout instead of , 0). No production code touched.

Security risks

None. Test-only; spawns local cat/cmd against a temp file it creates.

Level of scrutiny

Moderate for a test rewrite: RSS-threshold leak tests are the class REVIEW.md singles out for headroom/flake scrutiny. The PR body supplies 5+ RSS-delta samples per lane (release max +2.4 MB vs. 5 MB limit; ASAN ~51 MB vs. 100 MB limit — both ~2x headroom) plus a fail-before demonstration (mem::forget on the buffer → +10 MB → assertion trips). The old assertion permitted ~1.8 GB of growth on debug baselines and passed against the same regressed build, so this is a tightening, not a weakening.

Other factors

  • All three prior review threads are resolved: my earlier comment/measurement-mismatch nit was mooted by the 16 KB payload rewrite in e43ba3e (new numbers now match the code comment), and CodeRabbit's assertion-order request landed in 540c8ed.
  • The stderr→stdout pipe change is explained (POSIX stack-buffer fast path means an empty stderr pipe never hits reserve()), so the test now exercises the allocation it claims to guard rather than being vacuous.
  • isASAN-only gating (dropping || isDebug) is a deliberate choice with a stated reason (non-ASAN debug lanes should still detect the leak); if a non-ASAN debug CI lane exceeds 5 MB it will surface immediately.
  • Harness idioms are followed: tempDir, Buffer.alloc(n, fill).toString(), exit code asserted last, no hardcoded sleep/timeout.

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