Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions test/js/node/worker_threads/heap-snapshot-gc-race-fixture.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 39 additions & 23 deletions test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,47 @@ import { bunEnv, bunExe, isASAN, isDebug, isIntelMacOS, isWindows } from "harnes
import { join } from "node:path";

// The getHeapSnapshot() round-trip must never let the worker thread touch
// the parent VM's HandleSet. Before the fix this crashed with a segfault at
// 0x10 inside the "Sh" (Strong Handles) marking constraint — a parent-VM
// Strong<JSPromise> was captured by value in a lambda that ran on the worker
// thread, and Strong<T>'s copy/dtor mutated HandleSet::m_strongList without
// the parent VM's lock while the collector was iterating it.
// the parent VM's HandleSet. Before the fix (#30185) this corrupted
// HandleSet::m_strongList — a parent-VM Strong<JSPromise> was captured by
// value in a lambda that ran on the worker thread, and Strong<T>'s copy/dtor
// mutated the list without the parent VM's lock while the collector was
// iterating it, faulting at 0x10 inside the "Sh" marking constraint (or
// livelocking on the torn list).
//
// The race window is a handful of instructions after each snapshot
// completes, so no single run is guaranteed to hit it; we run the fixture
// repeatedly in release and fail if any attempt crashes. Debug and ASAN
// builds are several times slower per heap snapshot, so they get a reduced
// workload as a functional check — plain release CI is where this guards
// against regressions.
// Skipped on Windows and Intel (x64) macOS: this branch's always-on per-worker
// stdio path adds per-spawn overhead that a 15x300-snapshot stress exceeds on
// those builders. The race it guards is platform-agnostic and still covered on
// Linux and Apple-Silicon macOS.
// This test used to run 15x300 iterations in release as a probabilistic
// crash guard. That sizing dated from when the GC controller ran a
// collection every ~16ms of event-loop activity, which is what made the
// parent's strong-handle scans overlap the worker's task teardown; #35356
// removed those per-tick collections, and with them the overlap: the
// original bug, reintroduced, survives 15x300 with no crashes (0 detections
// in 18k iterations, vs ~60% per process before #35356). With no scheduling
// coincidence left to amplify, the loop is kept as a functional check of the
// cross-VM round-trip under concurrent processes and explicit parent GCs:
// promises settle, every stream delivers a non-empty payload (parsed as JSON
// once per process), workers terminate cleanly.
//
// The ASAN lane keeps a larger iteration count: ASAN can catch ordinary
// memory bugs in the round-trip/stream machinery that the plain-release
// lanes cannot.
//
// Skipped on Windows and Intel (x64) macOS: the always-on per-worker stdio
// path adds per-spawn overhead that this stress exceeds on those builders.
// The code path is platform-agnostic and still covered on Linux and
// Apple-Silicon macOS.
test.skipIf(isWindows || isIntelMacOS)(
"worker.getHeapSnapshot() does not race the parent VM's Strong Handles list under GC",
async () => {
const slow = isDebug || isASAN;
const attempts = slow ? 1 : 15;
const iters = isDebug ? "5" : slow ? "100" : "300";
const attempts = isDebug || isASAN ? 1 : 15;
const iters = isDebug ? 5 : isASAN ? 100 : 25;
const fixture = join(import.meta.dir, "heap-snapshot-gc-race-fixture.js");

// The attempts are independent processes with no shared state, so run them
// all concurrently; the race being guarded is intra-process.
// all concurrently; the behavior being exercised is intra-process.
const results = await Promise.all(
Array.from({ length: attempts }, async (_, i) => {
await using proc = Bun.spawn({
cmd: [bunExe(), fixture],
env: { ...bunEnv, ITERS: iters },
env: { ...bunEnv, ITERS: String(iters) },
stdout: "pipe",
stderr: "pipe",
});
Expand All @@ -42,15 +52,21 @@ test.skipIf(isWindows || isIntelMacOS)(
}),
);
for (const result of results) {
// One assertion per attempt so a crash shows stdout/stderr/signal together.
// One assertion per attempt so a failure shows stdout/stderr/signal
// together. The "ok <count>" stdout proves the fixture ran every
// iteration rather than exiting early.
expect(result).toEqual({
attempt: result.attempt,
stdout: "ok\n",
stdout: `ok ${iters}\n`,
stderr: "",
exitCode: 0,
signalCode: null,
});
}
},
isDebug || isASAN ? 60_000 : 120_000,
// One explicit ceiling for every lane: the debug/ASAN run needs more than
// the local 5s default (~20s), and a regression of the guarded race can
// present as a livelock, so the timeout is the time-to-red for hangs. The
// old 120s release arm was sized for the 15x300 workload.
60_000,
);