Skip to content
Closed
Show file tree
Hide file tree
Changes from 26 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
282 changes: 282 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
#include "root.h"

#include "ZigGlobalObject.h"
#include "JavaScriptCore/HeapSnapshotBuilder.h"
#include "JavaScriptCore/HeapProfiler.h"
#include "JavaScriptCore/ConservativeRoots.h"
#include "JavaScriptCore/MachineStackMarker.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 +3263,281 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm)
/// `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.
static constexpr size_t kBelowSpBytes = 256 * 1024;
static uintptr_t bunNapiDiagBelowSpCopy[kBelowSpBytes / sizeof(uintptr_t)];
static uintptr_t* bunNapiDiagBelowSpFrom = nullptr;
static void* bunNapiDiagLastStackTopBefore = nullptr;
// Callee-saved registers as they were on entry to gc()'s host function: these
// propagate unchanged into the collector and are part of the captured register
// state that the conservative scan visits.
static uintptr_t bunNapiDiagCalleeSaved[12];
static const char* const bunNapiDiagCalleeSavedNames[12] = {
#if CPU(X86_64)
"rbx", "rbp", "r12", "r13", "r14", "r15", "", "", "", "", "", ""
#elif CPU(ARM64)
"x19", "x20", "x21", "x22", "x23", "x24", "x25", "x26", "x27", "x28", "fp", ""
#else
"", "", "", "", "", "", "", "", "", "", "", ""
#endif
};
#if CPU(X86_64)
#define BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED() \
do { \
asm volatile("movq %%rbx, %0" : "=m"(bunNapiDiagCalleeSaved[0])); \
asm volatile("movq %%rbp, %0" : "=m"(bunNapiDiagCalleeSaved[1])); \
asm volatile("movq %%r12, %0" : "=m"(bunNapiDiagCalleeSaved[2])); \
asm volatile("movq %%r13, %0" : "=m"(bunNapiDiagCalleeSaved[3])); \
asm volatile("movq %%r14, %0" : "=m"(bunNapiDiagCalleeSaved[4])); \
asm volatile("movq %%r15, %0" : "=m"(bunNapiDiagCalleeSaved[5])); \
} while (0)
#elif CPU(ARM64)
#define BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED() \
do { \
asm volatile("str x19, %0" : "=m"(bunNapiDiagCalleeSaved[0])); \
asm volatile("str x20, %0" : "=m"(bunNapiDiagCalleeSaved[1])); \
asm volatile("str x21, %0" : "=m"(bunNapiDiagCalleeSaved[2])); \
asm volatile("str x22, %0" : "=m"(bunNapiDiagCalleeSaved[3])); \
asm volatile("str x23, %0" : "=m"(bunNapiDiagCalleeSaved[4])); \
asm volatile("str x24, %0" : "=m"(bunNapiDiagCalleeSaved[5])); \
asm volatile("str x25, %0" : "=m"(bunNapiDiagCalleeSaved[6])); \
asm volatile("str x26, %0" : "=m"(bunNapiDiagCalleeSaved[7])); \
asm volatile("str x27, %0" : "=m"(bunNapiDiagCalleeSaved[8])); \
asm volatile("str x28, %0" : "=m"(bunNapiDiagCalleeSaved[9])); \
asm volatile("str x29, %0" : "=m"(bunNapiDiagCalleeSaved[10])); \
} while (0)
#else
#define BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED() \
do { \
} while (0)
#endif

__attribute__((no_sanitize("address"), noinline)) 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());

// A word "hits" a cell if it points anywhere in [cell, cell+64): that covers
// interior pointers (ConservativeRoots cellAlign()s them) and, for our
// 16-byte JSArray, the [cell+16, cell+24] "butterfly just past the previous
// object" case that genericAddPointer maps back onto the cell to the left.
// The offset is reported so over-matches can be discounted by hand.
static size_t bunNapiDiagLastOff;
bunNapiDiagLastOff = 0;
auto interesting = [&](uintptr_t v, uintptr_t& base) -> const char* {
base = 0;
if (v < 4096 || v == UINTPTR_MAX)
return nullptr;
uintptr_t aligned = v & ~uintptr_t(7);
for (size_t off = 0; off <= 56; off += 8) {
if (aligned < off + 4096)
break;
if (liveCells.contains(aligned - off)) {
base = aligned - off;
bunNapiDiagLastOff = v - base;
break;
}
}
if (!base)
return nullptr;
const char* cls = describe(base);
if (strcmp(cls, "Array") && strcmp(cls, "Object") && strcmp(cls, "NapiClass") && strcmp(cls, "NapiPrototype") && strcmp(cls, "NapiHandleScopeImpl"))
return nullptr;
return cls;
};

// 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]);
if (p < 16 || p == UINTPTR_MAX) continue;
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 base;
const char* cls = interesting(*w, base);
if (!cls)
continue;
++hits;
fprintf(stderr, "[napi-diag] stack[%p] (origin-0x%zx) = %p -> %s @%p +%zu\n", (void*)w, (size_t)((origin - w) * sizeof(uintptr_t)), (void*)*w, cls, (void*)base, bunNapiDiagLastOff);
}
fprintf(stderr, "[napi-diag] stack words pointing at Array/Object/Napi* cells: %u\n", hits);

// 4a. Every thread registered with this heap's MachineThreads (each one is
// suspended and its registers + stack [sp, origin) conservatively
// scanned by every collection). Report them, and any word anywhere in
// their stack range that points at the interesting cells.
{
auto& machineThreads = vm.heap.machineThreads();
Locker locker { machineThreads.getLock() };
const auto& threads = machineThreads.threads(locker);
fprintf(stderr, "[napi-diag] threads registered with the heap: %u (current uid %u)\n", threads.size(), WTF::Thread::currentSingleton().uid());
for (auto& threadRef : threads) {
WTF::Thread& t = threadRef.get();
bool isCurrent = &t == &WTF::Thread::currentSingleton();
uintptr_t* lo = static_cast<uintptr_t*>(t.stack().end());
uintptr_t* hi = static_cast<uintptr_t*>(t.stack().origin());
fprintf(stderr, "[napi-diag] thread uid=%u%s stack=%p..%p\n", t.uid(), isCurrent ? " [current]" : "", (void*)lo, (void*)hi);
if (isCurrent)
continue;
unsigned th = 0;
for (uintptr_t* w = lo + 512; w < hi; ++w) { // skip the guard-ish bottom
uintptr_t base;
const char* cls = interesting(*w, base);
if (!cls) continue;
if (++th <= 40)
fprintf(stderr, "[napi-diag] other-thread stack[%p] (origin-0x%zx) = %p -> %s @%p +%zu\n", (void*)w, (size_t)((hi - w) * sizeof(uintptr_t)), (void*)*w, cls, (void*)base, bunNapiDiagLastOff);
}
fprintf(stderr, "[napi-diag] words on that thread's stack pointing at Array/Object/Napi* cells: %u\n", th);
}
}

// 4b. Callee-saved registers on entry to gc()'s host function.
for (int i = 0; i < 12; ++i) {
if (!bunNapiDiagCalleeSavedNames[i][0]) continue;
uintptr_t base;
const char* cls = interesting(bunNapiDiagCalleeSaved[i], base);
fprintf(stderr, "[napi-diag] callee-saved %s = %p%s%s +%zu\n", bunNapiDiagCalleeSavedNames[i], (void*)bunNapiDiagCalleeSaved[i], cls ? " -> " : "", cls ? cls : "", cls ? bunNapiDiagLastOff : (size_t)0);
}

// 5. The dead region below gc()'s frame as it was BEFORE the collection
// (this is what the collector's own frames were laid over), and vm.lastStackTop
// at that moment (sanitizeStackForVM only zeroes [lastStackTop, sp)).
fprintf(stderr, "[napi-diag] vm.lastStackTop before gc: %p (gc frame ~%p; %s)\n", bunNapiDiagLastStackTopBefore, (void*)sp,
(uintptr_t)bunNapiDiagLastStackTopBefore < (uintptr_t)sp ? "DEEPER than gc frame: sanitize could zero below" : "not deeper: sanitize zeroed nothing below gc frame");
if (bunNapiDiagBelowSpFrom) {
unsigned below = 0;
size_t n = kBelowSpBytes / sizeof(uintptr_t);
for (size_t i = 0; i < n; ++i) {
uintptr_t base;
const char* cls = interesting(bunNapiDiagBelowSpCopy[i], base);
if (!cls) continue;
++below;
uintptr_t* where = bunNapiDiagBelowSpFrom + i;
fprintf(stderr, "[napi-diag] pre-gc below-sp[%p] (gcframe-0x%zx) = %p -> %s @%p +%zu\n", (void*)where, (size_t)((sp - where) * sizeof(uintptr_t)), (void*)bunNapiDiagBelowSpCopy[i], cls, (void*)base, bunNapiDiagLastOff);
}
fprintf(stderr, "[napi-diag] pre-gc words BELOW gc()'s frame pointing at Array/Object/Napi* cells: %u (region %p..%p)\n", below, (void*)bunNapiDiagBelowSpFrom, (void*)(bunNapiDiagBelowSpFrom + n));
}
// 6. Same region now (what the collector left).
{
unsigned belowNow = 0;
uintptr_t* from = sp - kBelowSpBytes / sizeof(uintptr_t);
uintptr_t* limit = static_cast<uintptr_t*>(WTF::Thread::currentSingleton().stack().end());
if (from < limit + 4096) from = limit + 4096;
for (uintptr_t* w = from; w < sp; ++w) {
uintptr_t base;
const char* cls = interesting(*w, base);
if (!cls) continue;
++belowNow;
fprintf(stderr, "[napi-diag] post-gc below-sp[%p] (gcframe-0x%zx) = %p -> %s @%p +%zu\n", (void*)w, (size_t)((sp - w) * sizeof(uintptr_t)), (void*)*w, cls, (void*)base, bunNapiDiagLastOff);
}
fprintf(stderr, "[napi-diag] post-gc words BELOW gc()'s frame pointing at Array/Object/Napi* cells: %u\n", belowNow);
}
fflush(stderr);
}

__attribute__((noinline)) 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
}

// Diagnostics: copy of the dead stack region below gc()'s frame taken before
// the collection runs (see the statics above bunNapiDiagWhereAreTheRoots).
__attribute__((no_sanitize("address"), noinline)) static void bunNapiDiagCaptureBelowSp(JSC::VM& vm)
{
volatile uintptr_t marker = 0;
uintptr_t* sp = const_cast<uintptr_t*>(&marker);
uintptr_t* from = sp - kBelowSpBytes / sizeof(uintptr_t);
uintptr_t* limit = static_cast<uintptr_t*>(WTF::Thread::currentSingleton().stack().end());
if (from < limit + 4096)
from = limit + 4096;
bunNapiDiagBelowSpFrom = from;
for (size_t i = 0; from + i < sp && i < kBelowSpBytes / sizeof(uintptr_t); ++i)
bunNapiDiagBelowSpCopy[i] = from[i];
bunNapiDiagLastStackTopBefore = vm.lastStackTop();
}

JSC_DEFINE_HOST_FUNCTION(functionJsGc,
(JSC::JSGlobalObject * global, JSC::CallFrame* callFrame))
{
BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED();
Zig::GlobalObject* globalObject = defaultGlobalObject(global);
#if !OS(WINDOWS)
if (access("/tmp/bun-napi-diag-request", F_OK) == 0)
bunNapiDiagCaptureBelowSp(JSC::getVM(global));
Comment on lines +3630 to +3631

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.

🟡 c17e393 inserts bunNapiDiagPreGcLiveScan before bunNapiDiagCaptureBelowSp, so the 256KB region CaptureBelowSp then copies is the freshly-dead frames of PreGcLiveScan's own forEachLiveCell walk (whose lambda's HeapCell* cell parameter cycled through every live JSArray) — section 5's "pre-gc below-sp" hits are now the diagnostic's own residue, not what was there before gc() was entered. The same residue sits in the region Bun__gc's conservative scan reads next, so it may also seed a root on V1 run 0 (frame-layout dependent, unobserved yet — but check section 5 before trusting a V1-run-0-only CRASH@SNAPSHOT-GC/NO-CRASH; V1 runs 1-2 have no sentinel and stay uncontaminated). Swap the two calls so CaptureBelowSp runs first — that fixes section 5 outright; if V1 run 0 then diverges from runs 1-2 after this commit, the perturbation is self-evident from the matrix row.

Extended reasoning...

What the bug is

Commit c17e393 added bunNapiDiagPreGcLiveScan and inserted it at ZigGlobalObject.cpp:3630 so it runs before bunNapiDiagCaptureBelowSp at :3631 and before Bun__gc. Both are noinline siblings called from functionJsGc, so their frames occupy overlapping stack depth: CaptureBelowSp's &marker sits at roughly the same depth as PreGcLiveScan's frame top, and the 256KB it memcpy's below that point contains the freshly-dead frames of PreGcLiveScan's callees — HeapIterationScope ctor, MarkedSpace::forEachLiveCell, the block-walk loop, and the lambda body — every one of which held raw HeapCell* values (the lambda's cell parameter cycles through every live cell including the target JSArray, and arrays.add(cell) spills through HashSet::add).

Two consequences

(1) Section 5 is contaminated. bunNapiDiagWhereAreTheRoots section 5 ("pre-gc words BELOW gc()'s frame pointing at Array/Object/Napi* cells") reads bunNapiDiagBelowSpCopy and reports those addresses as if they were left there by whatever ran before gc() — the very hypothesis section 5 exists to test. Its own comment ("The dead region below gc()'s frame as it was BEFORE the collection") is now false: it's the dead region as it was after the diagnostic's own heap walk. Before c17e393, CaptureBelowSp was the only call inside the sentinel branch and captured the actual leftover state from module-load / addon-call frames.

(2) V1 run 0's crash bit may be perturbed. The same residue sits in [PreGcLiveScan-deepest, functionJsGc-sp), and Bun__gc's gatherFromCurrentThread scans [collector-sp, origin) — which spans that region. sanitizeStackForVM last ran at interpreter entry (before functionJsGc), not between the pre-scan and the collect, so it does not clear this residue. If any word survives un-overwritten by the collector's own frames, the conservative scan roots the JSArray, GC #1 does not finalize, and V1 run 0 is classified CRASH@SNAPSHOT-GCallCrashedOnFirstGc = false → the test fails on a run where the underlying flake did not reproduce.

Step-by-step proof (section-5 pollution)

  1. On V1 run 0, the sentinel exists → functionJsGc enters the access(...) == 0 branch.
  2. Line 3630: bunNapiDiagPreGcLiveScan(vm) runs. Its forEachLiveCell lambda receives every live cell as HeapCell* cell; for each JSArray it calls arrays.add(reinterpret_cast<uintptr_t>(cell)). The lambda invocation, its cell parameter, and HashSet::add's internals are all laid out below bunNapiDiagPreGcLiveScan's own frame — i.e., below functionJsGc's sp.
  3. If the stack-scan loop finds a hit, its 8-arg fprintf(stderr, "...PRE-GC stack...", ..., (void*)base, ...) passes base (the JSArray address) as a stack vararg (SysV x64 passes only 6 integer args in registers), guaranteeing the address is written below the frame.
  4. PreGcLiveScan returns. Its frame and callee frames are dead but not zeroed (no_sanitize("address") and noinline don't zero dead frames).
  5. Line 3631: bunNapiDiagCaptureBelowSp(vm) runs. Its &marker is at ~the same depth as step 2's frame top; the loop copies [sp - 256KB, sp) into bunNapiDiagBelowSpCopy — including the JSArray addresses from step 2-3.
  6. After Bun__gc returns without crashing, bunNapiDiagWhereAreTheRoots section 5 iterates bunNapiDiagBelowSpCopy, matches those addresses via interesting(), and prints them as pre-gc below-sp[...] -> Array @... — falsely attributing them to pre-gc() state.

Why existing code doesn't prevent it

CaptureBelowSp has no way to distinguish diagnostic-deposited words from genuine leftovers, and PreGcLiveScan doesn't wipe its callee stack before returning. The comment at test_experimental_with_timeout.js:78 ("Keyed on a file so the child's argv/env stay byte-identical") shows the author is deliberately avoiding perturbation — but this changes the child's stack contents right before both the capture and the conservative scan.

On the refutation of consequence (2)

One reviewer argued the GC-perturbation half is too speculative to file separately: everything in [collector-sp, functionJsGc-sp) is the collector's ~8-10-frame live call chain at capture time, so a specific cell address would have to land in an uninitialized hole of every frame; PreGcLiveScan's trailing unconditional fprintf+fflush is itself a deep libc/syscall chain that overwrites much of the shallower residue with non-cell values; and V1 runs 1-2 (no sentinel → no pre-scan) provide an immediate control — a run-0-only divergence is self-evident from the matrix row. All fair points, and consequence (2) is accordingly hedged as "may" / "frame-layout dependent" rather than a definite failure. But frames don't densely overwrite every slot (padding, unused callee-saved spill slots, uninitialized locals), the forEachLiveCell block-iteration chain plausibly goes deeper than fprintfwrite, and — critically — the section-5 half is not speculative: CaptureBelowSp deterministically copies whatever PreGcLiveScan left. The two share one root cause and one fix, so they're filed together; the actionable takeaway for (2) is "look at the section-5 output and the V1 run-1/2 columns before trusting a V1-run-0-only non-crash@gc1", not "this will definitely fire".

How to fix

Swap the two calls so bunNapiDiagCaptureBelowSp runs first:

if (access("/tmp/bun-napi-diag-request", F_OK) == 0) {
    bunNapiDiagCaptureBelowSp(JSC::getVM(global));
    bunNapiDiagPreGcLiveScan(JSC::getVM(global));
}

This restores section 5's meaning (CaptureBelowSp reads only [sp-256KB, sp) and vm.lastStackTop(), neither of which PreGcLiveScan needs pristine — PreGcLiveScan scans [sp, origin) upward). It does not address consequence (2) — PreGcLiveScan still runs immediately before Bun__gc — but that half is speculative and self-diagnosing via the matrix's run-1/2 control columns; if it turns out to matter, PreGcLiveScan could memset-zero [sp - kBelowSpBytes, sp) on return, or run from a fixed-depth trampoline whose depth exceeds the collector's.

Nit because this is diagnostics-only code that won't merge (per the author's own comments on this PR), section 5 is supplementary to the primary PRE-GC / section-4 / 4' output which is unaffected, and the author has already accepted similar methodology observations here as "will fix if another iteration is needed" — but it directly undermines section 5's signal on exactly the below-SP-garbage hypothesis being tested, so worth swapping before reading build #90799.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, and worth stating the direction of the bias: residue from the pre-GC walk can only add roots (make V1 run 0 survive), never remove them. The run in question showed the +24 word present pre-GC and the child then SIGABRT on GC #1 (finalized) — so if the walk contaminated anything, it did so against the result we observed. Section 5's below-sp numbers from that run are indeed not trustworthy; not relying on them. Leaving the order as is since this branch won't get further diagnostic rounds after the final repin.
🤖 Addressed by Claude Code

#endif
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
Loading