Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f5ccd47
test(napi): check the experimental-finalizer wrapper's output before …
dylan-conway Aug 8, 2026
940630e
test(napi): put the combined-output assertion first
dylan-conway Aug 8, 2026
9efd638
test(napi): diagnostics — second gc() from the event loop + JSC GC lo…
dylan-conway Aug 8, 2026
741c556
test(napi): diagnostics — dump live napi cell counts on the no-crash …
dylan-conway Aug 8, 2026
7f62e76
test(napi): diagnostics — fail loudly when only the event-loop GC cra…
dylan-conway Aug 9, 2026
7027604
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
880da00
ci: retrigger
dylan-conway Aug 9, 2026
51f1c7e
ci(diagnostics): run napi.test.ts 4x on every darwin x64 shard
dylan-conway Aug 9, 2026
2dcf453
test(napi): diagnostics — heap snapshot after a non-finalizing GC #1,…
dylan-conway Aug 9, 2026
e5946fb
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
9db94de
ci: retrigger
dylan-conway Aug 9, 2026
c0e09cd
test(napi): diagnostics — make the artifact upload reachable (isBuild…
dylan-conway Aug 9, 2026
d59a943
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
a2ee2f3
test(napi): diagnostics — allowlisted child env and redact env values…
dylan-conway Aug 9, 2026
a51f860
test(napi): diagnostics — variant matrix driver (original script/env …
dylan-conway Aug 9, 2026
107543a
diagnostics: gc() writes a GC-debugging heap snapshot (per-cell root …
dylan-conway Aug 9, 2026
b8743ea
diagnostics: report VM conservative roots, frame layout, and live-cel…
dylan-conway Aug 9, 2026
56d5010
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
04afe87
diagnostics: also report vm.lastStackTop and live-cell words in the r…
dylan-conway Aug 9, 2026
0dff607
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
f103e21
diagnostics: capture callee-saved registers on entry to gc(); keep gc…
dylan-conway Aug 9, 2026
2aaae6a
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
f07e513
diagnostics: list heap-registered threads and scan their stacks
dylan-conway Aug 9, 2026
0656ed1
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
011d990
diagnostics: match the conservative scan's real candidate window (int…
dylan-conway Aug 9, 2026
5860983
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
85de86a
diagnostics: attribute stack hits to a named native frame (fp chain +…
dylan-conway Aug 9, 2026
bb4430b
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 9, 2026
c6ceb9a
diagnostics: run the matrix against oven-sh/WebKit#398
dylan-conway Aug 9, 2026
9a48440
Bump WebKit preview build to autobuild-preview-pr-398-e501c5cb (adds …
dylan-conway Aug 9, 2026
c17e393
diagnostics: pre-GC live-region scan for words at/into/just past a li…
dylan-conway Aug 9, 2026
2f31865
diagnostics: always surface V1 run 0's [napi-diag] lines (pre-GC scan…
dylan-conway Aug 9, 2026
04c4180
Bump WebKit preview build to autobuild-preview-pr-398-9b999ae9 (accep…
dylan-conway Aug 9, 2026
cb60a8a
ci: retrigger (WebKit preview assets fully uploaded)
dylan-conway Aug 9, 2026
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
11 changes: 11 additions & 0 deletions scripts/runner.node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,17 @@ function getRelevantTests(cwd, testModifiers, testExpectations) {
filteredTests.push(...availableTests);
}

// Diagnostics branch only: every darwin x64 shard runs napi.test.ts, four
// times, so a single build yields several samples from whichever hosts ran.
if (process.platform === "darwin" && process.arch === "x64" && !filters?.length) {
const napi = availableTests.find(t => t.replaceAll("\\", "/") === "napi/napi.test.ts");
if (napi) {
const rest = filteredTests.filter(t => t !== napi);
filteredTests.length = 0;
filteredTests.push(napi, napi, napi, napi, ...rest);
}
}

// Run docker-backed tests (the prefixes the coordinator prestarts) last in
// the shard: the coordinator kicks off `compose up` for their services when
// the runner starts, but a cold mysqld/postgres takes ~10s to become
Expand Down
117 changes: 117 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
#include "root.h"

#include "ZigGlobalObject.h"
#include "JavaScriptCore/HeapSnapshotBuilder.h"
#include "JavaScriptCore/HeapProfiler.h"
#include "JavaScriptCore/ConservativeRoots.h"
#include "JavaScriptCore/HeapIterationScope.h"
#include "JavaScriptCore/MarkedSpaceInlines.h"
#include "JavaScriptCore/StackVisitor.h"
#include <wtf/StackBounds.h>
#include <wtf/Threading.h>
#if !OS(WINDOWS)
#include <unistd.h>
Comment thread
dylan-conway marked this conversation as resolved.
#endif
#include "MessagePort.h"
#include "helpers.h"
#include "JavaScriptCore/ArgList.h"
Expand Down Expand Up @@ -3251,11 +3262,117 @@
/// `globalThis.gc()` is an alias for `Bun.gc(true)`
/// Note that `vm` is a `VirtualMachine*`
extern "C" size_t Bun__gc(void* vm, bool sync);

// Diagnostics branch only: when /tmp/bun-napi-diag-request exists, `gc()`
// additionally writes a JSC GC-debugging heap snapshot (which records, per
// live cell, the root that marked it) to /tmp/bun-napi-diag-<pid>.json. Keyed
// on a file rather than argv/env so the process under test is byte-for-byte
// the same as the one that fails.
__attribute__((no_sanitize("address"))) static void bunNapiDiagWhereAreTheRoots(JSC::VM& vm, JSC::CallFrame* callFrame)
{
// 1. Every live cell start address after the gc() that just ran.
WTF::HashSet<uintptr_t> liveCells;
{
JSC::HeapIterationScope scope(vm.heap);
vm.heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* cell, JSC::HeapCell::Kind kind) {
if (kind == JSC::HeapCell::JSCell)
liveCells.add(reinterpret_cast<uintptr_t>(cell));
return IterationStatus::Continue;
});
}
auto describe = [&](uintptr_t p) -> const char* {
return reinterpret_cast<JSC::JSCell*>(p)->className().characters();
};
fprintf(stderr, "[napi-diag] live JS cells: %u\n", liveCells.size());

// 2. VM-owned conservatively scanned buffers (Heap::gatherVMRoots).
{
JSC::ConservativeRoots vmRoots(vm.heap);
#if ENABLE(DFG_JIT)
vm.gatherScratchBufferRoots(vmRoots);
#endif
fprintf(stderr, "[napi-diag] VM scratch-buffer conservative roots: %zu; checkpoint OSR side state present: %d\n", vmRoots.size(), (int)vm.hasCheckpointOSRSideState());
for (size_t i = 0; i < vmRoots.size(); ++i) {
auto p = reinterpret_cast<uintptr_t>(vmRoots.roots()[i]);
fprintf(stderr, "[napi-diag] vmroot %p %s\n", (void*)p, liveCells.contains(p) ? describe(p) : "(not a live cell start)");
}
}

// 3. JS/native frame layout, so stack hits can be attributed.
fprintf(stderr, "[napi-diag] frames (top first):\n");
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> IterationStatus {
auto name = visitor->functionName().utf8();
auto* cb = visitor->codeBlock();
fprintf(stderr, "[napi-diag] frame callFrame=%p callerFrame=%p %s%s codeType=%d bc#%u regs=%d\n",
(void*)visitor->callFrame(), (void*)visitor->callerFrame(), name.data(),
visitor->isNativeFrame() ? " [native]" : "", cb ? (int)cb->codeType() : -1,
visitor->bytecodeIndex().offset(), cb ? (int)cb->numCalleeLocals() : -1);
return IterationStatus::Continue;
});

// 4. Words on this thread's machine stack, from here up to the origin,
// that equal (or point 8 bytes into) a live cell.
volatile uintptr_t marker = 0;
uintptr_t* sp = const_cast<uintptr_t*>(&marker);
uintptr_t* origin = static_cast<uintptr_t*>(WTF::Thread::currentSingleton().stack().origin());
fprintf(stderr, "[napi-diag] scanning machine stack %p..%p (%zu words)\n", (void*)sp, (void*)origin, (size_t)(origin - sp));
unsigned hits = 0;
for (uintptr_t* w = sp; w < origin; ++w) {
uintptr_t v = *w;
uintptr_t base = 0;
if (liveCells.contains(v)) base = v;
else if (liveCells.contains(v & ~uintptr_t(0xF))) base = v & ~uintptr_t(0xF);
else if (v >= 8 && liveCells.contains(v - 8)) base = v - 8;

Check failure on line 3325 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

WTF::HashSet<uintptr_t>::contains(0) asserts on debug/ASAN builds during the stack scan

`WTF::HashSet<uintptr_t>` uses 0 as its empty-bucket sentinel, so `liveCells.contains(0)` fires `ASSERT(!equal(emptyValue(), key))` in `HashTable::checkKey()` — and the very first word scanned is `marker = 0` (line 3315: `sp = &marker`), plus any `v < 16` makes `contains(v & ~0xF)` also pass 0. On assertion-enabled builds (`config.ts:789` sets `assertions = debug || asan`) the child aborts at "scanning machine stack" before producing the stack-root listing or the heap snapshot — defeating the di
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
if (!base) continue;
const char* cls = describe(base);
// Only report the interesting classes to keep the log readable.
if (strcmp(cls, "Array") && strcmp(cls, "Object") && strcmp(cls, "NapiClass") && strcmp(cls, "NapiPrototype") && strcmp(cls, "NapiHandleScopeImpl"))
continue;
++hits;
fprintf(stderr, "[napi-diag] stack[%p] (origin-0x%zx) = %p -> %s @%p\n", (void*)w, (size_t)((origin - w) * sizeof(uintptr_t)), (void*)v, cls, (void*)base);
}
fprintf(stderr, "[napi-diag] stack words pointing at Array/Object/Napi* cells: %u\n", hits);
fflush(stderr);
}

static void bunNapiDiagMaybeDumpHeap(JSC::VM& vm, JSC::CallFrame* callFrame)
{
#if OS(WINDOWS)
return;
#else
if (access("/tmp/bun-napi-diag-request", F_OK) != 0)
return;
fprintf(stderr, "[napi-diag] gc() returned\n");
fflush(stderr);
bunNapiDiagWhereAreTheRoots(vm, callFrame);
fprintf(stderr, "[napi-diag] building GC-debugging snapshot (runs another full GC)\n");
fflush(stderr);
vm.ensureHeapProfiler();
auto& heapProfiler = *vm.heapProfiler();
heapProfiler.clearSnapshots();
JSC::HeapSnapshotBuilder builder(heapProfiler, JSC::HeapSnapshotBuilder::SnapshotType::GCDebuggingSnapshot);
builder.buildSnapshot();
WTF::String json = builder.json();
char path[128];
snprintf(path, sizeof(path), "/tmp/bun-napi-diag-%d.json", getpid());
if (FILE* f = fopen(path, "w")) {
auto utf8 = json.utf8();
fwrite(utf8.data(), 1, utf8.length(), f);
fclose(f);
fprintf(stderr, "[napi-diag] wrote %s (%zu bytes)\n", path, utf8.length());
} else {
fprintf(stderr, "[napi-diag] could not open %s\n", path);
}
fflush(stderr);
#endif
}

JSC_DEFINE_HOST_FUNCTION(functionJsGc,
(JSC::JSGlobalObject * global, JSC::CallFrame* callFrame))
{
Zig::GlobalObject* globalObject = defaultGlobalObject(global);
Bun__gc(globalObject->bunVM(), true);
bunNapiDiagMaybeDumpHeap(JSC::getVM(global), callFrame);
return JSValue::encode(jsUndefined());
}

Expand Down
2 changes: 1 addition & 1 deletion test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const bunEnv: NodeJS.Dict<string> = {
AGENT: "false",
};

const ciEnv = { ...bunEnv };
export const ciEnv = { ...bunEnv };

if (isASAN) {
bunEnv.ASAN_OPTIONS ??= "allow_user_segv_handler=1:disable_coredump=0";
Expand Down
166 changes: 108 additions & 58 deletions test/napi/napi-app/test_experimental_with_timeout.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Test script that runs the experimental module test with a timeout
const { spawn } = require('child_process');
// Diagnostics driver (not for merge): runs several variants of the
// experimental-module finalizer script and reports which ones failed to
// finalize on the first synchronous GC. Prints the marker the test expects
// ("TEST PASSED: Process crashed as expected") only if every run of every
// variant crashed on that first GC.
const { spawnSync } = require('child_process');
const path = require('path');

const modulePath = path.join(__dirname, 'build/Debug/test_reference_unref_in_finalizer_experimental.node');

// Spawn the test process
const proc = spawn(process.argv[0], ['--expose-gc', '-e', `
// V1: the script exactly as it is on main.
const original = `
const m = require("${modulePath}");
console.log('Loading experimental module...');
let arr = m.test_reference_unref_in_finalizer_experimental();
Expand All @@ -15,63 +19,109 @@ global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null);
console.log('GC triggered - should crash now');
console.log('ERROR: Did not crash! Test failed!');
process.exit(1);
`], {
env: {
...process.env,
BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1",
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0"
}
});
`;

let stdout = '';
let stderr = '';
let sawFatalError = false;
let sawPanic = false;

proc.stdout.on('data', (data) => {
stdout += data.toString();
process.stdout.write(data);
// Same, but if the first GC didn't finalize, try again from the event loop
// and say which one worked.
const withSecondGc = `
const m = require("${modulePath}");
console.log('Loading experimental module...');
let arr = m.test_reference_unref_in_finalizer_experimental();
console.log('Test function returned');
arr = null;
global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null);
console.log('GC #1 returned without crashing');
setImmediate(() => {
global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null);
console.log('GC #2 returned without crashing');
console.log('ERROR: Did not crash! Test failed!');
process.exit(1);
});
`;

proc.stderr.on('data', (data) => {
stderr += data.toString();
process.stderr.write(data);

// Check if we've seen the expected crash messages
if (data.toString().includes('FATAL ERROR')) {
sawFatalError = true;
}
if (data.toString().includes('panic(main thread)')) {
sawPanic = true;
}

// If we've seen both messages, kill the process immediately
// This avoids hanging on llvm-symbolizer
if (sawFatalError && sawPanic) {
proc.kill('SIGKILL');
}
});
const fullEnv = {
...process.env,
BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1",
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0",
};
const minimalEnv = {
...Object.fromEntries(Object.entries(process.env).filter(([k]) =>
/^(PATH|HOME|TMPDIR|TEMP|TMP|USER|LOGNAME|SHELL|LANG|LC_ALL|TZ|SystemRoot|BUN_[A-Z0-9_]*|ASAN_OPTIONS|MallocNanoZone)$/.test(k))),
BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1",
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0",
};
const noGcLevel = { ...fullEnv }; delete noGcLevel.BUN_GARBAGE_COLLECTOR_LEVEL;
const noAudit = { ...fullEnv }; delete noAudit.BUN_JSC_randomIntegrityAuditRate;

// Fallback timeout
const timeout = setTimeout(() => {
proc.kill('SIGKILL');
}, 5000);
const variants = [
{ name: 'V1 original, full env', script: original, env: fullEnv },
{ name: 'V2 original, minimal env', script: original, env: minimalEnv },
{ name: 'V3 original, full env minus BUN_GARBAGE_COLLECTOR_LEVEL', script: original, env: noGcLevel },
{ name: 'V4 original, full env minus BUN_JSC_randomIntegrityAuditRate', script: original, env: noAudit },
{ name: 'V5 original + logGC=1', script: original, env: { ...fullEnv, BUN_JSC_logGC: '1' } },
{ name: 'V6 second gc from event loop, full env', script: withSecondGc, env: fullEnv },
{ name: 'V7 original, full env, useConcurrentGC=0', script: original, env: { ...fullEnv, BUN_JSC_useConcurrentGC: '0' } },
];

proc.on('exit', (code, signal) => {
clearTimeout(timeout);

// Check if the test passed
if (sawFatalError && sawPanic) {
console.log('\n\nTEST PASSED: Process crashed as expected');
process.exit(0);
} else if (stdout.includes('ERROR: Did not crash')) {
console.log('\n\nTEST FAILED: Process did not crash');
process.exit(1);
} else if (signal === 'SIGKILL' && !sawPanic) {
console.log('\n\nTEST FAILED: Process timed out without crashing');
process.exit(1);
} else {
console.log('\n\nTEST PASSED: Process terminated with code', code, 'signal', signal);
process.exit(code === 0 ? 1 : 0); // Invert exit code - we expect failure
const RUNS = 3;
let allCrashedOnFirstGc = true;
const rows = [];
let sample = '';
const fs = require('fs');
const DIAG_REQUEST = '/tmp/bun-napi-diag-request';
for (const v of variants) {
const cells = [];
for (let i = 0; i < RUNS; i++) {
// For the first run of V1 only, ask the (diagnostics-patched) binary to
// dump a GC-debugging heap snapshot from inside gc(). Keyed on a file so
// the child's argv/env stay byte-identical to the failing configuration.
const wantDump = v === variants[0] && i === 0;
try { if (wantDump) fs.writeFileSync(DIAG_REQUEST, ''); else fs.rmSync(DIAG_REQUEST, { force: true }); } catch {}
const r = spawnSync(process.argv[0], ['--expose-gc', '-e', v.script], { env: v.env, encoding: 'utf8', timeout: 60_000 });
try { fs.rmSync(DIAG_REQUEST, { force: true }); } catch {}
if (wantDump) {
Comment on lines +71 to +82

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.

🟡 🟡 /tmp/bun-napi-diag-request is a machine-global path with no per-run scoping, and runner.node.mjs now makes every darwin x64 shard run this wrapper — so concurrent shards on the same host race on it: shard B's else fs.rmSync(DIAG_REQUEST) (20 of 21 iterations) can remove shard A's sentinel mid-V1-run-0 (defeating the snapshot), or shard A's sentinel makes shard B's V2-V7 children dump [napi-diag] and be misclassified as CRASH@SNAPSHOT-GC. The sentinel also survives in /tmp if the wrapper is hard-killed during V1 run 0. Scope it per-run (e.g. /tmp/bun-napi-diag-request-${process.pid}, with the child reading the path from a BUN_NAPI_DIAG_REQUEST env var — an env var the crash path never touches doesn't perturb the repro), or add a process.on('exit') unlink. Nit: only this branch's binary has the access() check, and the branch is stated as diagnostics-only — but the cross-shard race directly undermines the diagnostics you're collecting.

Extended reasoning...

What the bug is

The diagnostics hook at ZigGlobalObject.cpp:3536-3539 is keyed on access("/tmp/bun-napi-diag-request", F_OK), and test_experimental_with_timeout.js:71-82 creates that file with a fixed machine-global path for the duration of V1 run 0's spawnSync. There is no PID, job, or shard scoping on either side.

Separately, this PR's own runner.node.mjs change prepends napi.test.ts four times to every darwin x64 shard's test list. On persistent hosts running multiple BuildKite agents (cornbread/bagel/pretzel per the PR description), concurrent shards of the same build share /tmp.

The specific code path

For each of the 21 iterations, the wrapper does:

const wantDump = v === variants[0] && i === 0;
try { if (wantDump) fs.writeFileSync(DIAG_REQUEST, ''); else fs.rmSync(DIAG_REQUEST, { force: true }); } catch {}
const r = spawnSync(process.argv[0], ['--expose-gc', '-e', v.script], ...);
try { fs.rmSync(DIAG_REQUEST, { force: true }); } catch {}

So iteration 0 writes the sentinel before spawnSync and removes it after; iterations 1-20 each remove it before spawnSync. Inside the child, functionJsGc calls bunNapiDiagCaptureBelowSp and then bunNapiDiagMaybeDumpHeap iff the sentinel exists at the moment of the access() call.

Why existing code doesn't prevent it

The comment at line 78 explains the design choice — "Keyed on a file so the child's argv/env stay byte-identical to the failing configuration" — but nothing scopes the path per wrapper process or registers cleanup on abnormal exit. The post-spawnSync rmSync runs only if control returns from spawnSync; a SIGKILL of the wrapper process (runner's per-file timeout, job cancellation, or Ctrl-C locally) during V1 run 0 skips it. And the pre-spawnSync rmSync on iterations 1-20 is the very thing that races with a different wrapper's V1 run 0 on the same host.

Step-by-step proof (cross-shard race)

Within one BuildKite build on a darwin x64 host running two agents:

  1. Agent A (shard 3) starts napi.test.ts copy Fix ?? operator  #1 → wrapper reaches V1 run 0 → writeFileSync('/tmp/bun-napi-diag-request', '') → enters spawnSync. The child begins startup + addon dlopen.
  2. Agent B (shard 7) starts napi.test.ts copy Fix ?? operator  #1 a few hundred ms later → wrapper reaches V1 run 1 → executes else fs.rmSync('/tmp/bun-napi-diag-request', { force: true }).
  3. Agent A's child now reaches functionJsGcaccess("/tmp/bun-napi-diag-request", F_OK) returns -1bunNapiDiagMaybeDumpHeap returns immediately. No snapshot, no [napi-diag] output — even if this was the flaking run the whole branch exists to capture.
  4. The wrapper reports [napi-diag] no snapshot file for pid … and moves on.

The reverse interleaving is also harmful: if agent A's sentinel is present while agent B's V2 child (which is not supposed to dump) reaches gc(), that child emits [napi-diag] gc() returned to stderr and — if it happens to survive GC #1 — builds a full GCDebuggingSnapshot. The wrapper then classifies it as CRASH@SNAPSHOT-GC (line 101: se.includes('[napi-diag] gc() returned') && !se.includes('[napi-diag] wrote')), and the /tmp/bun-napi-diag-<pid>.json it wrote is never cleaned up (only V1 run 0's dump is renameSync'd).

Step-by-step proof (leaked on interrupt)

  1. The outer it(...)'s 25 s timeout (already flagged as too short in the open comment on line 1400) or the runner's per-file timeout hard-kills the wrapper's process tree while V1 run 0 is in spawnSync — plausible precisely on the flaking run, where the child is building a full GCDebuggingSnapshot.
  2. /tmp/bun-napi-diag-request survives.
  3. Every subsequent global.gc() call in this branch's binary — e.g. later tests in the same shard that spawn children with --expose-gc, or the next of the 4× napi.test.ts runs' V2-V7 children before their pre-spawn rmSync — hits the access() check and dumps.

Impact

REVIEW.md's hermeticity rule ("Tests must be hermetic and leave nothing behind … poisons later tests on persistent CI runners") applies directly. The practical consequence is that the diagnostics this branch exists to collect can be silently defeated (sentinel removed by a sibling shard) or polluted (spurious CRASH@SNAPSHOT-GC cells and orphaned /tmp/bun-napi-diag-<pid>.json files) — which wastes the CI iterations the author is explicitly waiting on.

On the refutation

The refutation is right that the blast radius is scoped to this branch's binary only — main-branch bun has no access() check, so a leaked sentinel is inert to other branches on the same host. It's also right that the leak-on-interrupt window is narrow (V1 run 0 is the first iteration) and self-healing (the next iteration's else fs.rmSync removes it). And "every push cancels the in-flight darwin-x64 jobs" does rule out cross-build races.

But it does not rule out cross-shard races within one build: the runner.node.mjs change deliberately runs the wrapper on every shard, and multi-agent darwin hosts run shards concurrently. The refutation's point (4) about Bun.gc() vs functionJsGc addresses in-process siblings inside the test-runner harness; it doesn't apply to the wrapper's own spawnSync children, which are launched with --expose-gc and call global.gc()functionJsGc directly. So the cross-shard mechanism stands.

Given the branch is diagnostics-only and won't merge, and the affected binary is only this branch's, this is nit — worth fixing so the diagnostics loop isn't self-defeating, not worth blocking on.

How to fix

Either scope the sentinel per wrapper run:

const DIAG_REQUEST = '/tmp/bun-napi-diag-request-' + process.pid;
process.on('exit', () => { try { fs.rmSync(DIAG_REQUEST, { force: true }); } catch {} });

and have functionJsGc read the path from an env var (getenv("BUN_NAPI_DIAG_REQUEST")) that the wrapper sets on all 21 children — an env var the crash path never inspects doesn't perturb the byte-identical-argv goal. Or, minimally, keep the fixed path but register the process.on('exit') unlink so an interrupted wrapper doesn't leave it behind (this doesn't fix the cross-shard race).

const dump = `/tmp/bun-napi-diag-${r.pid}.json`;
if (fs.existsSync(dump)) {
const dest = path.join(__dirname, `napi-diag-${r.pid}.heapsnapshot`);
fs.renameSync(dump, dest);
console.log(`[napi-diag] snapshot from V1 run 0 (pid ${r.pid}) saved to ${dest} (${fs.statSync(dest).size} bytes)`);
} else {
Comment on lines +91 to +95

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.

🟡 fs.renameSync(dump, dest) moves /tmp/bun-napi-diag-<pid>.json into the checkout with no try/catch — on any host where /tmp is a separate filesystem from the checkout (tmpfs /tmp is the systemd default on Linux), rename(2) fails with EXDEV and the uncaught throw aborts the wrapper at V1 run 0 before the matrix prints. Use fs.copyFileSync(dump, dest); fs.rmSync(dump, { force: true }), or wrap the if (wantDump) body in try/catch so a snapshot-collection failure degrades to the "no snapshot file" message. Nit: the darwin-x64 target hosts have /private/tmp on the same APFS volume as the checkout so it works there today, and the double-survival trigger hasn't been seen on Linux — but the test runs on every POSIX lane and this is the same "diagnostics hazard undermines the diagnostics" class as the sentinel-race comment above.

Extended reasoning...

What the bug is

At test_experimental_with_timeout.js:84-87, when V1 run 0's child successfully writes a snapshot, the wrapper does:

const dump = `/tmp/bun-napi-diag-${r.pid}.json`;
if (fs.existsSync(dump)) {
  const dest = path.join(__dirname, `napi-diag-${r.pid}.heapsnapshot`);
  fs.renameSync(dump, dest);
  console.log(`[napi-diag] snapshot ... (${fs.statSync(dest).size} bytes)`);
}

with no try/catch. rename(2) fails with EXDEV when source and destination are on different filesystems, and Node/Bun's fs.renameSync does not fall back to copy+unlink — it throws. On any host where /tmp is a separate filesystem from the checkout (tmpfs /tmp is the systemd default on many Linux distros; a macOS host with the BuildKite working directory on a separate APFS volume would also qualify), this throws an uncaught exception that terminates the wrapper at top level — with 20 of the 21 spawns still unrun and no matrix printed.

Why existing code doesn't prevent it

The neighbouring file operations are guarded: line 79 wraps the sentinel writeFileSync/rmSync in try { ... } catch {}, and line 81 does the same for the post-spawnSync rmSync. But the if (wantDump) block at lines 82-90 has no try/catch around renameSync or statSync. The else branch at line 88-89 ("no snapshot file for pid ...") already exists as a graceful degradation path, but an EXDEV throw never reaches it.

Step-by-step proof

  1. On a Linux host with tmpfs /tmp (or any host where stat -c %d /tmpstat -c %d <checkout>), V1 run 0's child hits the double-survival case: Bun__gc() returns without finalizing the wrapped object, then bunNapiDiagMaybeDumpHeap runs HeapSnapshotBuilder::buildSnapshot() whose GC also returns without finalizing, so control reaches fwrite at ZigGlobalObject.cpp:3554-3556 and writes /tmp/bun-napi-diag-<pid>.json.
  2. Back in the wrapper, fs.existsSync(dump)true.
  3. fs.renameSync('/tmp/bun-napi-diag-<pid>.json', '<checkout>/test/napi/napi-app/napi-diag-<pid>.heapsnapshot') → kernel returns EXDEV, Bun throws Error: EXDEV: cross-device link not permitted, rename ....
  4. The throw is at the script's top level (inside the for (const v of variants) loop, not inside any try) → the wrapper exits non-zero with the stack trace on stderr; rows is never printed, V1 runs 1-2 and V2-V7 never run.
  5. The outer test's expect(bunStdout + ... + bunStderr).toContain("TEST PASSED: Process crashed as expected") at napi.test.ts:1393 fails, printing the EXDEV stack trace instead of the variant matrix.
  6. The snapshot stays behind in /tmp on the persistent host (nothing cleans it up on this path).

Impact and why it's a nit

The failure mode is exactly wrong: the double-survival case is the one the branch exists to capture (both GCs survived → richest diagnostic), and instead of printing the matrix and uploading the snapshot, the wrapper aborts with a filesystem error. The test is only todoIf(isWindows), so it runs on every POSIX lane including linux-x64, linux-aarch64, and their debug/ASAN variants.

That said, the practical exposure today is low, which is why this is a nit and not blocking:

  • On the darwin-x64 target hosts (cornbread/bagel/pretzel per the PR description), macOS's /tmp is a symlink to /private/tmp on the boot APFS Data volume, and BuildKite checkouts live on the same volume — so renameSync succeeds. The author's resolved comment on the HashSet-zero-key thread ("it already produced full output there") confirms the snapshot→redact→upload path has run end-to-end on those hosts.
  • On Linux (where tmpfs /tmp would trigger EXDEV), the flake being investigated has only been observed on darwin-x64. If GC Fix ?? operator  #1 crashes on V1 run 0 as it does in the non-flaking case, existsSync(dump) is false and renameSync is never reached.
  • If it did fire, the failure is self-diagnosing: the outer test's toContain assertion prints bunStdout + bunStderr, which would include the EXDEV stack trace, so the cause would be immediately visible.

The refutation's core point — "implausible on the actual target infrastructure" — is correct for today's filesystem layout on today's target hosts. But the branch is diagnostics-only precisely because the flake is being chased iteratively; if a future iteration widens the target (e.g. the flake reproduces on a Linux lane, or a darwin host gets a separate build volume), this becomes the thing that eats the one repro. It's the same "hazard undermines the diagnostics you're collecting" class as the already-posted /tmp/bun-napi-diag-request cross-shard-race comment.

How to fix

Either replace the rename with a cross-device-safe copy:

fs.copyFileSync(dump, dest);
fs.rmSync(dump, { force: true });

or wrap the whole if (wantDump) body in try { ... } catch (e) { console.log([napi-diag] snapshot move failed: ${e}); } so a failure degrades to a logged message and the matrix still prints.

console.log(`[napi-diag] no snapshot file for pid ${r.pid} (stderr: ${JSON.stringify((r.stderr || '').split('\n').filter(l => l.includes('napi-diag')))})`);
}
}
const out = (r.stdout || '') + (r.stderr || '');
// Markers are looked for in stdout only: the crash report on stderr echoes
// the whole -e script in its Args: line.
const so = r.stdout || '';
const crashed = (r.stderr || '').includes('FATAL ERROR') && (r.stderr || '').includes('panic');
const firstGcReturned = so.includes('GC triggered - should crash now') || so.includes('GC #1 returned without crashing');
const secondGcReturned = so.includes('GC #2 returned without crashing');
let cell;
const se = r.stderr || '';
if (crashed && se.includes('[napi-diag] gc() returned') && !se.includes('[napi-diag] wrote')) cell = 'CRASH@SNAPSHOT-GC';
else if (crashed && !firstGcReturned) cell = 'crash@gc1';
else if (crashed && firstGcReturned && !secondGcReturned) cell = 'CRASH@GC2';
else if (so.includes('ERROR: Did not crash')) cell = 'NO-CRASH';
else if (r.error) cell = 'spawn-error:' + r.error.code;
else cell = `other(status=${r.status},signal=${r.signal})`;
if (cell !== 'crash@gc1') {
allCrashedOnFirstGc = false;
if (!sample) sample = `--- sample output for [${v.name}] run ${i} (${cell}) ---\n${out}\n--- end sample ---`;
}
cells.push(cell);
}
});
rows.push(`${v.name.padEnd(62)} ${cells.join(' ')}`);
}

console.log('Loading experimental module... / Created (markers for the outer test)');
console.log('variant matrix (' + RUNS + ' runs each):');
for (const row of rows) console.log(' ' + row);
if (sample) console.log(sample);
if (allCrashedOnFirstGc) {
console.error('FATAL ERROR (marker for the outer test)');
console.log('\n\nTEST PASSED: Process crashed as expected');
process.exit(0);
} else {
console.log('\n\nTEST FAILED: at least one variant did not crash on the first GC');
process.exit(1);
}
Loading
Loading