Skip to content

test: make worker_heap_snapshot_gc.test.ts race the parent's StrongSet instead of relying on volume - #41192

Open
robobun wants to merge 1 commit into
mainfrom
robobun/03b8d2b5/worker-heap-snapshot-gc-test
Open

robobun wants to merge 1 commit into
mainfrom
robobun/03b8d2b5/worker-heap-snapshot-gc-test

Conversation

@robobun

@robobun robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • worker_heap_snapshot_gc.test.ts takes 24s on alpine x64 (build 109310): 15 processes x 300 getHeapSnapshot() round-trips, a probabilistic guard for worker: fix cross-thread HandleSet race in getHeapSnapshot #30185.
  • That volume detects nothing: with the bug reintroduced (src/jsc/bindings/webcore/JSWorker.cpp:755, a by-value Strong<JSPromise> capture), 30 of 30 processes survive 300 round-trips. The parent idles during the snapshot, so the worker's unlocked StrongSet calls never overlap a parent-side one.

Fix

  • The fixture keeps the parent allocating and freeing Strong handles during each snapshot: convertTransferList makes a Strong<JSObject> per structuredClone() transfer entry before validation, so a plain-object list is pure allocate/free churn.
  • Bug reintroduced, release: 43 of 100 processes crash after 1 round-trip, 98 after 5, 100 after 10. The release lane now runs 5 processes of 10 round-trips. Fixed build: 230 of 230.
  • The fixture validates ITERS, rethrows worker errors, parses the first payload as a V8 heap snapshot with a non-zero node count, and prints ok snapshots=N workerExitCode=1, matched exactly.
  • Verified: bun bd test test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts, 22s to 25s, unchanged (5 debug snapshots of 3s). Release: 0.4s, was 9.4s.

Background

Notes

Bug reintroduction used for every measurement (local only, not in this PR): add JSC::Strong<JSC::JSPromise> bugStrong(vm, promise); before postTaskToWorkerGlobalScope in jsWorkerPrototypeFunction_getHeapSnapshotBody and capture bugStrong by value in both the worker-side lambda and the completion lambda it posts back.

Release build (local, non-LTO, 16 cores), 100 processes per row unless noted, 10 concurrent:

fixture round-trips per process crashed
old (idle parent, Bun.gc(true) per round-trip) 300 0 of 30
new 1 43
new 2 79
new 3 89
new 5 98
new 10 100

What remains after #39371 is allocator against allocator, which only a busy parent exposes. Failure modes seen, all on the parent thread inside structuredClone(): SIGSEGV in JSC::StrongSet::tryAllocateFromCurrent (bogus slot pointer, for example 0x300000B15DE0), SIGABRT from a RELEASE_ASSERT reached through JSC::StrongSet::didFreeSlot (the used count and block bookkeeping are release-checked in StrongBlock::decrementUsedCount and StrongSet::appendAvailable), and once a TypeError because a corrupted handle changed an unrelated cell.

Churn shapes compared at 5 round-trips, 40 processes each: a transfer list of fresh ArrayBuffers that clone cleanly crashed 15; a transfer list of plain objects (this PR) crashed 40. The clean clone spends most of each call serializing, so the parent is inside the allocator far less often. The plain-object shape depends on convertTransferList converting before SerializedScriptValue::create validates. The fixture header says so.

Timing detail: bun bd test 21.6s and 24.6s after, 20.9s and 24.0s before. Release bun test 0.41s to 0.47s after, 9.39s and 9.49s before (16 cores).

Debug+ASAN (bun bd) with the bug: 7 of 16 processes crashed at 5 round-trips, 3 of 4 at 10. A debug snapshot takes about 3s, so the debug lane stays at 1 process of 5 round-trips.

Fixed build, test file: 30 of 30 runs pass on 16 cores, 10 of 10 pinned to 2 cores (1.2s to 1.4s). Bugged build: 30 of 30 runs fail on 16 cores, 10 of 10 pinned to 2 cores. Pinned to 1 core the bugged build fails only 2 of 5 runs: without true parallelism the parent has to be preempted inside the few instructions of an allocate, which is rare. CI runners have more than one core (the old 4500 round-trips finished in 24s there).

The assertion changes were checked against a fixture edited to exit after 9 round-trips (snapshots=9 fails the match) and one that writes a line to stderr (the stderr: "" match fails).

Self-reviewed: 4 concerns raised, 4 addressed (assert attribution in both headers, scope and follow-up stated in the headers, the validation-order dependency documented, related PRs cited). Parameterizing the fixture over the other four cross-VM sites is a follow-up, not part of this PR.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts

…t instead of relying on volume

The release lane ran 15 processes of 300 getHeapSnapshot() round-trips
(24s on alpine x64) as a probabilistic guard for #30185. With the bug
reintroduced, that workload detects nothing: the parent VM idles while
the worker builds the snapshot, so the worker's unlocked allocate and
free on the parent's StrongSet never overlap a parent-side operation.

The fixture now keeps the parent allocating and freeing Strong handles
while each snapshot is in flight (structuredClone() converts its
transfer list into Strong<JSObject> handles before it validates the
entries). On a release build with the bug reintroduced, a round-trip
corrupts the parent VM about half of the time and 100 of 100 processes
crashed within 10 round-trips. The release lane now runs 5 processes
of 10 round-trips (0.4s locally, was 9.4s).

The fixture also validates ITERS, rethrows worker errors, parses the
first payload as a V8 heap snapshot with a non-zero node count, and
prints the completed count and the worker exit code, which the test
asserts exactly.
@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How the numbers were produced: the bug was reintroduced locally in jsWorkerPrototypeFunction_getHeapSnapshotBody (by-value Strong<JSPromise> capture in both cross-thread lambdas), a release build was made, and the old and new fixtures were run as described in the PR body. The fixed build was measured with the same release build and with bun bd (debug+ASAN).

Supersedes #36952 (closed).

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9a4126fb-a6bb-4011-bcdf-5fb50f952735

📥 Commits

Reviewing files that changed from the base of the PR and between d6af50f and 17b6205.

📒 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

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

Changes

The regression fixture now combines worker heap snapshots with parent-thread structured-clone transfer-list churn. It validates worker lifecycle, snapshot streams, V8 payloads, completion counts, and exit codes. The test harness uses separate debug and release workloads with a fixed 60-second timeout.

Heap snapshot GC race

Layer / File(s) Summary
Fixture setup and handle churn
test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js
The fixture validates ITERS, starts the worker, propagates errors, waits for startup, and requires transfer-list churn to fail with DataCloneError.
Snapshot execution and validation
test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js
Each snapshot runs while parent-thread handle churn continues. The fixture forces GC, validates non-empty V8 snapshot payloads, tracks completed snapshots, and reports the worker exit code.
Test workload and output assertions
test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts
The test uses separate debug and release workloads, updates regression documentation and expected output, and applies a fixed 60-second timeout.

Merge Risk: ⚪ Minimal · up to 17b62

This PR updates a targeted worker heap-snapshot race test and its assertions without changing production behavior. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 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 clearly identifies the main change: making the worker heap snapshot test race the parent VM's StrongSet instead of relying on test volume.
Description check ✅ Passed The description explains the problem, implementation, verification results, test configuration, and scope. It does not use the template headings exactly, but it contains the required content, includin…
Full details: Description check

Explanation

The description explains the problem, implementation, verification results, test configuration, and scope. It does not use the template headings exactly, but it contains the required content, including how the change was verified.


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

@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review above has no action items, so the diff is unchanged. Buildkite is still running. I will post the release-lane timing for this file (alpine x64 was 24s) once that build finishes.

@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 rewrite that trades volume for a targeted race window, with detection and timing measurements stated in the header comments.

What was reviewed:

  • Fixture control flow: settled is polled via setImmediate (no sleeps), stdout/stderr/exit are drained concurrently in the wrapper, and the combined .toEqual still asserts signalCode: null so a crash surfaces as a test failure rather than a timeout.
  • Churn mechanism: structuredClone(0, { transfer: [64 plain objects] }) requires only DataCloneError to escape the catch; anything else rethrows, so the fixture is not silently swallowing a behavior change there. The dependency on convertTransferList allocating before validation is called out in the header.
  • The explicit 60_000ms timeout is not new (the old test used 60_000/120_000) and is now tighter; the removed isASAN branch drops the workload from 1×100 to 5×10, which is smaller in total round-trips.
  • Changed files are not covered by CODEOWNERS.
Extended reasoning...

Overview

This PR touches two test files only: test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js and test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts. No production code changes. The fixture is rewritten to actively churn the parent VM's StrongSet (via structuredClone with an invalid transfer list of 64 plain objects) while each getHeapSnapshot() round-trip is in flight, replacing the previous idle-parent-plus-Bun.gc(true) approach. It adds strict ITERS validation, wires worker error/exit handlers, parses the first snapshot payload to assert V8-format structure (node_count × node_fields === nodes.length), asserts every stream is non-empty, and prints a summary line the wrapper matches exactly. The wrapper reduces the workload from 15×300 (release) to 5×10 (release/ASAN) or 1×5 (debug), drops the isASAN import, and tightens the timeout from 60_000/120_000 to a flat 60_000.

Security risks

None. This is a test fixture and test wrapper; no user-facing surface, no parsing of untrusted input, no network, no filesystem writes outside the harness, no dependency changes.

Level of scrutiny

Low-to-moderate. Test-only changes to an existing stress test warrant checking that the new test still fails for the right reason and follows harness conventions, but do not need the scrutiny of runtime code. The PR description provides empirical hit-rate measurements (0/30 detection on the old fixture with the bug reintroduced vs. 100/100 within 10 round-trips on the new one) and states the bun bd test / release timing. The fixture polls a settled flag via setImmediate rather than sleeping, wires failure events, and the wrapper drains pipes concurrently and asserts a single combined object per attempt — all matching the patterns REVIEW.md and test/CLAUDE.md ask for. The one documented fragility (the churn goes silent if convertTransferList ever validates before converting) is called out in the fixture header and framed as covered by the follow-up deterministic assert in #36958; since the test being replaced already detects nothing per the PR's measurements, this is a strict improvement.

Other factors

The explicit per-test timeout technically conflicts with test/CLAUDE.md's "no timeouts" rule, but it pre-exists this PR (the old test had 60_000/120_000), the new value is stricter, and the comment justifies it as the time-to-red for a hang-mode regression — root CLAUDE.md permits per-test timeouts for outliers. The worker.on("error", e => { throw e }) pattern in the fixture is acceptable in a spawned subprocess: an uncaught exception crashes the fixture with stderr output, which the wrapper's stderr: "" / exitCode: 0 assertion catches. CODEOWNERS does not cover these paths. No prior review comments or objections exist in the timeline. The bug hunt exited on dry_streak with no findings.

@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, nothing to change from that review. Buildkite build 109405 is at 161 of 181 jobs passed with the rest running. The per-lane timing for this file follows when it completes.

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