Skip to content

test: shrink worker_heap_snapshot_gc release workload, assert fixture completion count - #36952

Open
robobun wants to merge 3 commits into
mainfrom
farm/1a91b9bc/worker-heap-snapshot-gc-test
Open

test: shrink worker_heap_snapshot_gc release workload, assert fixture completion count#36952
robobun wants to merge 3 commits into
mainfrom
farm/1a91b9bc/worker-heap-snapshot-gc-test

Conversation

@robobun

@robobun robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

worker_heap_snapshot_gc.test.ts takes ~34s on alpine 3.23 x64 (build 89119) and ~12s locally on a 12-core release build, because its release path runs 15 concurrent processes x 300 getHeapSnapshot() round-trips as a probabilistic crash guard for the #30185 cross-thread HandleSet race (panic: Segmentation fault at address 0x10 in the "Sh" Strong Handles marking constraint).

Before resizing anything I re-did the revert-and-count verification that sized this test (same method as the one documented in #35200: reintroduce a by-value Strong<JSPromise> capture in getHeapSnapshot's outer cross-thread lambda, build release, count detections). The result is qualitatively different from July:

build workload detections
main @ Jul 22 (per #35200's measurements) 15x300, 20 runs 20/20 runs, per-process crash rate ~60%
main @ b58cd46 (this PR's base) 15x300, 4 runs = 18,000 iterations 0

The race window itself is still real: with the reintroduced bug plus instrumentation that forces the worker-side handle teardown to overlap a parent GC, every process corrupts (mix of segfaults at 0x10 and livelocks on the torn SentinelLinkedList). What disappeared is the scheduling coincidence that let the unmodified workload hit the window: detection relied on the GC controller collecting every ~16ms of event-loop activity, and #35356 (merged Jul 29) removed those per-tick collections. The fixture's own Bun.gc(true) runs strictly between round-trips, so it almost never overlaps the worker's task teardown.

I tried to rebuild the overlap from JS before giving up on it: synchronous GC pumping during the round-trip, async Bun.gc(false) pumping, BUN_GC_TIMER_INTERVAL=1/16 idle collections, and stretching the strong-handle list with pools of in-flight cross-VM requests. Best case across ~30,000 bugged iterations was a single segfault; most shapes produced zero. The per-mutation window is a few instructions once per ~40ms round-trip, and nothing constructible from JS reliably lands a strong-handle scan inside it on current main.

So the 34s release workload no longer buys the detection it was sized for, on any lane. This PR:

  • resizes the release path from 15x300 to 15x25, keeping it as a functional check of the cross-VM round-trip under concurrent processes and explicit parent GCs (promises settle, streams drain intact, workers terminate cleanly). Debug stays 1x5; the ASAN lane stays at 100 iterations since ASAN catches ordinary memory bugs in the round-trip/stream machinery that plain release cannot.
  • makes the fixture validate ITERS and print ok <completed count>, and makes the test assert the exact count, so a fixture that silently exits early (for example ITERS lost in env plumbing, which previously made the loop a no-op that still printed ok) fails instead of passing. stderr and signal are asserted alongside, and a run against an instrumented binary that writes to stderr confirms the assertion fails the test.
  • rewrites the header comments so they describe what the test actually provides today instead of claiming a guard that measurably no longer fires.

A deterministic guard for this bug class (for example a debug assertion that HandleSet mutation happens on the owning thread, or thread-sanitizer coverage) is the right long-term replacement for scheduling-dependent crash probing, and is out of scope here.

Verification

Timing, local (16 visible cores / 12-core quota):

before after
release binary, bun test (15x300 -> 15x25) 12.3s wall (user 104s) 1.15-1.34s wall (3 runs)
bun bd test (debug+ASAN path, 1x5, unchanged) 19.6s 20.7s

Expected CI effect: the ~34s alpine/debian/ubuntu release lanes drop to a few seconds; debug and ASAN lanes unchanged.

Stability and assertion checks:

  • 10/10 consecutive green runs of the new sizing under the release binary.
  • fixture without ITERS: exits 1 (validation works).
  • test run against a binary that writes a line to stderr per iteration: fails on the stderr assertion as intended.
Full detection measurements on main @ b58cd46 with the bug reintroduced

All runs: 15 concurrent fixture processes under the bunEnv test environment, release build, per-process outcomes classified as completed / segfault / livelock (killed at deadline).

fixture shape iterations total detections
current fixture (serialized Bun.gc(true) after each await) 18,000 0
+ BUN_GC_TIMER_INTERVAL=16 3,000 1 segfault
+ BUN_GC_TIMER_INTERVAL=1 1,500 0
sync GC pump (Bun.gc(true) + setImmediate loop through drain) 2,250 0
async GC pump (Bun.gc(false)) 750 0
async GC on 4ms interval, parent otherwise idle 1,500 0
GC pump + 128-512 in-flight getHeapStatistics() strong handles 2,250 3 livelocks (128: 2, 256: 1, 512: 0)
forced overlap (instrumented binary: worker keeps mutating the parent's m_strongList for 4ms after posting) 5 procs x 50 5/5 (4 livelocks, 1 segfault); with GC-during-flight fixture: 5/5 segfault

The forced-overlap row is what shows the race itself is alive; everything above it shows the unmodified bug shape no longer meets a strong-handle scan under any JS-constructible GC schedule.


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

… completion count

The 15x300 release workload was sized as a probabilistic crash guard for
the #30185 cross-thread HandleSet race. That detection relied on the GC
controller collecting every ~16ms of event-loop activity, which made the
parent's strong-handle scans overlap the worker's task teardown. #35356
removed those per-tick collections, and the reintroduced bug now survives
the full 15x300 workload with zero detections in 18000 iterations (it was
caught at ~60% per process before). The loop is kept as a functional
check of the cross-VM round-trip at 15x25 in release; the ASAN lane keeps
100 iterations for memory-bug coverage.

The fixture now validates ITERS and reports the completed iteration
count, and the test asserts the exact count so a fixture that silently
exits early fails instead of passing.
@github-actions github-actions Bot added the claude label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The fixture now validates snapshot payloads and completed iterations. The test selects numeric iteration counts by build mode, checks the completion output, and uses a fixed 60-second timeout.

Changes

Heap snapshot GC validation

Layer / File(s) Summary
Fixture round-trip checks
test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js
The fixture validates positive integer iteration counts, checks snapshot streams, parses the first payload as JSON, tracks completed iterations, preserves worker recreation, and reports the count.
Test iteration and timeout configuration
test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts
The test uses numeric iteration counts for debug, ASAN, and release builds. It requires matching completion output and uses a fixed 60-second timeout.

Possibly related PRs

  • oven-sh/bun#33622: Modifies the same worker heap snapshot fixture and test, including iteration behavior and output validation.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the reduced release workload and added completion-count assertion.
Description check ✅ Passed The description explains the change, motivation, scope, verification results, and expected CI impact in sufficient detail.
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.

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

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

I didn't find any bugs — the fixture/test changes are internally consistent (ITERS validation, ok <count> assertion, retry path still yields completed === iters). Deferring to a human because this deliberately shrinks a memory-safety regression guard ~12x on the basis that it can no longer detect the reintroduced bug, with no replacement guard in this PR; that trade-off is a maintainer call.

What was reviewed:

  • Retry path (ERR_WORKER_NOT_RUNNINGi--; continue) still converges to completed === iters, so the new exact-count assertion won't false-fail on worker restarts.
  • iters is now a number and correctly String()-ed into env; the debug/ASAN/release branching (1x5 / 1x100 / 15x25) matches the PR description.
  • Number.isInteger guard rejects unset/NaN/non-integer ITERS and the fixture exits nonzero, so a lost env var now fails the test instead of passing vacuously.
Extended reasoning...

Overview

Test-only change to two files under test/js/node/worker_threads/. The release-lane workload for the #30185 cross-thread HandleSet race guard drops from 15×300 to 15×25 iterations (~34s → ~1.3s), the fixture now validates ITERS and prints ok <completed>, and the test asserts the exact count. Header comments are rewritten to state that the test is now a functional round-trip check rather than a probabilistic crash guard, because #35356 removed the per-tick GC collections that made the race observable from JS.

Security risks

None. No production code is touched; this is a test workload resize plus a strengthened assertion in a subprocess-spawning fixture.

Level of scrutiny

Moderate-to-high, despite being test-only. REVIEW.md is explicit: "Never silently weaken, skip, or delete an existing test or safety net." This PR is not silent — it comes with an unusually thorough empirical justification (bug reintroduced, 0 detections in 18k iterations across multiple GC-scheduling shapes, forced-overlap instrumentation confirming the race is still real). The argument is that the safety net already catches nothing on current main, so shrinking it loses no coverage. That reasoning is sound on its face, but accepting "this guard no longer works so shrink it" versus "make it work again (or add the deterministic HandleSet-thread-affinity assertion the PR itself suggests)" is a coverage-philosophy decision a maintainer should own, not a bot.

Other factors

  • The assertion is genuinely strengthened: the old fixture printed bare ok even if ITERS was unset (loop ran zero times); the new one throws on invalid ITERS and the test asserts the exact completed count.
  • I traced the ERR_WORKER_NOT_RUNNING retry: i--; continue re-runs the iteration without touching completed, so the final count still equals iters and the new assertion holds under worker restarts.
  • Debug (1×5) and ASAN (1×100) paths are unchanged in size; only plain release drops. Timeouts (60s slow / 120s release) are unchanged and remain generous for the smaller workload.
  • No prior human or claude[bot] reviews on the timeline; only a CodeRabbit rate-limit notice.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

The trade-off called out in the review is intentional, so to make the maintainer call easier:

The 12x reduction does not remove working coverage. The sizing existed to catch one specific race, and that detection is already gone: the reintroduced #30185 bug survives the full 15x300 workload on current main (0 detections in 18,000 iterations; full matrix in the PR body). Keeping the old size keeps the cost of the guard without its effect, on every release lane of every build.

A working replacement also cannot live in this test. The race window is a few instructions of worker-side handle teardown per 40ms round-trip, and since #35356 changed the GC cadence there is no JS-constructible schedule that reliably lands a strong-handle scan inside it (that is what the 6 fixture shapes in the measurement table were trying to do). The effective replacement is a deterministic detector in the engine, for example an assertion that HandleSet mutation happens on the owning thread, which would have caught #30185 at iteration 1 of the existing debug lane. That is a vendored-JSC change that deserves its own PR and review rather than riding along with a test resize.

If keeping 15x300 until such a detector exists is preferred, I can drop the resize from this PR and keep just the assertion strengthening; the measurements stand either way.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:58 AM PT - Aug 5th, 2026

@robobun, your commit 3f8663f has 1 failures in Build #89159 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36952

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

bun-36952 --bun

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 89151: 194/196 jobs passed. The two failed jobs are both the debian 13 x64-asan test shards, failing on two tests that also fail on main and are untouched by this change (test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts with an ExceptionScope SIGABRT, and test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts with RSA keygen leak reports); both are already reported for main triage. The resized worker_heap_snapshot_gc test passed on every lane that runs it, including alpine release and the ASAN lane. Remaining failures in the annotation list passed on retry.

… stale 120s timeout

Review follow-ups: the fixture header now describes the current reqId-map
design (#31216) instead of the superseded raw-pointer fix, the mid-loop
comment no longer claims a GC/teardown overlap this PR measured to be
gone, every iteration fails on an empty snapshot stream (one payload per
process is parsed as JSON), and the 120s release timeout arm sized for
the old 15x300 workload collapses into a single 60s ceiling.
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed review follow-ups in 2ca44ba:

Verification re-run after the changes: debug path passes in ~23s locally, release path 10/10 green runs at ~1.2s each (timing unchanged by the payload checks).

@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/node/worker_threads/heap-snapshot-gc-race-fixture.js`:
- Around line 31-32: Update the ITERS validation before makeWorker() to parse
the raw environment value, require a safe positive integer within a documented
maximum, and reject values such as 1e100 before creating the worker. Include the
accepted range and the original rejected value in the validation error message.
🪄 Autofix

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: 38f6e780-e8cb-48ac-b4c4-3246dbe42c07

📥 Commits

Reviewing files that changed from the base of the PR and between b58cd46 and 2ca44ba.

📒 Files selected for processing (2)
  • test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js
  • test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts

Comment thread test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js 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.

No bugs found, but this deliberately reduces a stress test's release workload 12x (15x300 → 15x25) on the basis that the probabilistic guard no longer fires post-#35356. The measurements are thorough and the assertion strengthening is a clear improvement, but the author explicitly flags the resize as a maintainer call (offering to keep 15x300 until a deterministic HandleSet-thread-affinity detector lands) — that trade-off should be signed off by a human.

What was reviewed:

  • Fixture correctness: ITERS validation, completed counting vs the i-- retry path, chunks?.push first-iteration-only collection, and the ok ${iters} stdout match — all consistent.
  • The iters string→number change flows correctly through String(iters) in env and the template-literal assertion.
  • Stream draining: once("end") without an error→reject wire is unchanged from the original fixture, not a regression here.
Extended reasoning...

Overview

Test-only change to test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts and its fixture. Three parts: (1) shrink the release-lane workload from 15 processes × 300 iterations to 15 × 25; (2) strengthen assertions — the fixture now validates ITERS, prints ok <count>, checks every stream is non-empty, and JSON-parses one payload per process, while the test asserts the exact completion count alongside stderr/exitCode/signalCode; (3) rewrite header comments to accurately describe what the test guards today, and unify the timeout to 60s.

Security risks

None. Test and fixture code only; no runtime, auth, crypto, or user-facing surface touched.

Level of scrutiny

Medium-high, specifically because REVIEW.md calls out "never silently weaken, skip, or delete an existing test or safety net" as a blocker. This PR is not silent — it provides extensive revert-and-count measurements showing the 15x300 workload detects the reintroduced #30185 bug 0 times in 18,000 iterations on current main (vs ~60% per-process before #35356 changed GC cadence), and documents six alternative fixture shapes that also fail to reproduce. The reasoning is sound: the guard's cost remains but its effect is already gone. However, the author explicitly frames this as a maintainer decision and offers to drop the resize if the preference is to keep the old size until a deterministic detector (e.g., a HandleSet thread-ownership assertion in JSC) exists. That is the right framing, and it is exactly the kind of coverage-vs-CI-cost trade-off a human maintainer should confirm rather than a bot.

Other factors

The mechanical changes look correct: iters moved from string to number and is re-stringified for env and interpolated in the stdout assertion; the ERR_WORKER_NOT_RUNNING retry path decrements i without incrementing completed, so the final count matches iters; the debug/ASAN precedence (isDebug ? 5 : isASAN ? 100 : 25) preserves the old ordering. CI (build 89151) passed on every lane that runs this test; the two ASAN failures are pre-existing on main and unrelated. The assertion-strengthening half of the PR is unambiguously good and could land on its own.

@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/node/worker_threads/heap-snapshot-gc-race-fixture.js`:
- Around line 30-33: The ITERS validation currently permits impractically large
safe integers; update the validation near the `iters` check to define and
enforce a named maximum of 300, while preserving positive integer values through
300. Update the error message to state the accepted range and keep the existing
5, 25, 100, and 300 iteration modes valid.
🪄 Autofix

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: bd113c96-260b-427c-9be4-2617316bc11c

📥 Commits

Reviewing files that changed from the base of the PR and between 2ca44ba and 3f8663f.

📒 Files selected for processing (1)
  • test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js

Comment thread test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js

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

No bugs found — the mechanical changes (ITERS validation before worker creation, ok <count> completion assertion, non-empty stream + first-payload JSON check, flat 60s timeout) are all strict improvements and look correct. Deferring because the 15×300 → 15×25 release resize is a coverage-vs-CI-time trade-off the author explicitly frames as a maintainer call ("if keeping 15x300 until such a detector exists is preferred, I can drop the resize"), and REVIEW.md treats weakening a safety net as something a human should sign off on even when well-justified.

What was reviewed:

  • Fixture: ITERS is validated with Number.isSafeInteger and > 0 before makeWorker(); the raw env value is echoed in the error — CodeRabbit's concern is addressed.
  • Stream handling: data handler is registered before awaiting end, so no chunks are dropped; chunks?.push correctly no-ops after the first payload is parsed.
  • Test: iters is now numeric and stringified only at the env boundary, so the ok ${iters} assertion matches the fixture's ok ${completed}; debug/ASAN paths keep their prior 5/100 counts and 60s ceiling.
Extended reasoning...

Overview

Test-only change to two files under test/js/node/worker_threads/. The fixture now validates ITERS as a positive safe integer before spawning the worker, counts completed round-trips, asserts every snapshot stream is non-empty, JSON-parses the first payload per process, and prints ok <count>. The test drops the release iteration count from 300 to 25 (debug 5 and ASAN 100 unchanged), asserts the exact completion count in stdout, and replaces the branched 60s/120s timeout with a flat 60s. Comments are rewritten to state that post-#35356 the workload no longer detects the #30185 race it was originally sized for.

Security risks

None. No production code is touched; the fixture reads only process.env.ITERS (set by the test itself) and the changes tighten validation rather than loosen it.

Level of scrutiny

Medium. The code changes themselves are simple and self-contained, and CI (build 89151) shows the resized test green on every lane including alpine release and ASAN. However, the substantive decision — dropping a 12× stress-test workload that was designed as a probabilistic crash guard — falls squarely under REVIEW.md's "never silently weaken an existing test or safety net" rule. The PR is anything but silent (the empirical case is thorough: 0 detections in 18k iterations with the bug reintroduced, and a full table of alternative fixture shapes tried), but the author's own follow-up comment explicitly presents this as a choice for a maintainer to make and offers to keep the assertion-strengthening without the resize. That's the right framing, and a human should be the one to accept it.

Other factors

  • The one CodeRabbit inline (validate ITERS before creating the worker, reject unsafe integers) is resolved in 3f8663f: validation now uses Number.isSafeInteger, runs before makeWorker(), and echoes the rejected raw value.
  • I checked that registering the data listener after Bun.gc(true) but before awaiting end cannot miss chunks — the stream is returned paused and the handler is attached synchronously before any await, so all data events are observed.
  • The iters type change from string to number is consistent end-to-end: String(iters) at the env boundary and ok ${iters} in the assertion both produce "25"/"100"/"5" matching the fixture's ok ${completed}.
  • The flat 60s timeout is safe for the unchanged debug/ASAN paths (previously also 60s) and generous for the ~1.3s release path; it also bounds livelock detection as the comment notes.

robobun added a commit that referenced this pull request Aug 5, 2026
…ctors

Review of the WebKit bump surfaced two gaps:

Bun__StrongRef__new/set/delete mutate the StrongRootBlock list that the
Srb marking constraint scans, with the same API-lock requirement as
JSC's HandleSet but no assertion. Add the mirror asserts, checking the
owner VM (the block's), so a thread holding some other VM's lock still
trips them. Strong.rs's existing TLS check covers only the delete path
and only proves the thread has a VM, not the owner VM's lock; the C++
Bun::StrongRef deleter bypasses it entirely.

Neither detector had a liveness test, which is how the previous
probabilistic guard for #30185 died silently (#35356, measured in
#36952). jscInternals gains a debug-only crossThreadStrongHandleMutation
hook that violates the contract from a spawned thread, and the new test
asserts the child aborts with each guard's message. If a future WebKit
bump or binding refactor drops an assertion, the test fails instead of
the coverage silently disappearing.
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