Skip to content

test(napi): check the experimental-finalizer wrapper's output before its exit code - #37214

Closed
dylan-conway wants to merge 34 commits into
mainfrom
claude/napi-experimental-test-diagnostics
Closed

test(napi): check the experimental-finalizer wrapper's output before its exit code#37214
dylan-conway wants to merge 34 commits into
mainfrom
claude/napi-experimental-test-diagnostics

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

What does this PR do?

napi > napi_reference_unref is blocked from finalizers in experimental modules has been failing on the darwin 14 x64 lane (e.g. builds 90590, 90595, 90602, 90622, 90629, 90644 — hosts cornbread/bagel/pretzel), and every failure reads only:

expect(bunExitCode).toBe(0)
Expected: 0
Received: 1
✗ napi > napi_reference_unref is blocked from finalizers in experimental modules [79.63ms]

because the exit-code assertion runs before the ones on the captured stdout/stderr. It does not reproduce outside the agent job on those same hosts (0/300+ runs of the wrapper, the whole file, and the CI runner itself, idle and loaded, with the CI binary and CI-built addons), so the next CI failure needs to say what happened. This moves the output assertions ahead of the exit-code check — same assertions, reordered — so a failure shows whether the child never crashed ("ERROR: Did not crash"), crashed without the expected text, or the wrapper failed.

How did you verify your code works?

Assertion reorder only; the test still passes locally (bun bd test test/napi/napi.test.ts -t "blocked from finalizers").

…its exit code

When this test fails on the darwin x64 lane the only thing in the log is
"Expected: 0 Received: 1", which doesn't say whether the child never
crashed, crashed without the expected messages, or the wrapper itself
failed. Assert on the captured output first so the failure carries it.

No-Verification-Needed: test-only assertion reorder
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The experimental N-API tests now perform a second garbage collection with diagnostics and validate crash markers before checking the wrapper exit code.

Changes

Experimental N-API tests

Layer / File(s) Summary
Add second GC and diagnostics
test/napi/napi-app/test_experimental_with_timeout.js
The timeout test performs a second asynchronous GC, logs N-API-related heap and protected-object counts, enables GC logging, and reports failure if the process remains alive.
Reorder crash test assertions
test/napi/napi.test.ts
The test reports diagnostics, rejects a non-crashing first GC, validates the crash-passed marker, and then checks the wrapper exit code.

Possibly related PRs

  • oven-sh/bun#36996: Modifies N-API lifetime and garbage-collection tests with related diagnostics.
  • oven-sh/bun#37166: Modifies the same experimental N-API GC and crash tests.
  • oven-sh/bun#37204: Covers the cleanup-hook and thread-safe-function teardown scenario validated by the crash tests.

Suggested reviewers: robobun, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the assertion reordering and its purpose for the experimental-finalizer wrapper.
Description check ✅ Passed The description includes both required sections and explains the problem, solution, and local verification result.
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.

@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/napi/napi.test.ts`:
- Around line 1336-1337: Move the bunExitCode assertion in the wrapper-script
test to after all stdout and stderr assertions, keeping the captured-output
checks for fatal errors, panics, and the success marker first. Preserve the
existing expect(bunExitCode).toBe(0) assertion unchanged, but ensure it is the
final assertion in the test.
🪄 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: fbf9e721-8be9-44b6-b20a-c1cff66bb290

📥 Commits

Reviewing files that changed from the base of the PR and between 9d519e8 and f5ccd47.

📒 Files selected for processing (1)
  • test/napi/napi.test.ts

Comment thread test/napi/napi.test.ts

@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 — pure assertion reorder for better CI failure diagnostics.

What was reviewed:

  • Confirmed the four output assertions and the exit-code assertion are unchanged, only reordered (exit code now last, matching the repo's own test guidance).
  • Checked the surrounding test — no ordering dependency between these assertions; all three streams are already awaited before any of them run.
  • The trailing not.toContain("ERROR: Did not crash") on stdout still runs after the exit-code check, which is fine since it's a negative assertion.
Extended reasoning...

Overview

This PR touches a single test file (test/napi/napi.test.ts) and reorders five expect calls in one test case: the four stdout/stderr content assertions now run before expect(bunExitCode).toBe(0) instead of after. No assertion is added, removed, or modified. The motivation is diagnostic: the test is flaking on the darwin 14 x64 CI lane with only "Expected: 0, Received: 1" as output, and moving the content assertions first will surface what the wrapper actually printed on the next failure.

Security risks

None. Test-only change, no production code touched, no new inputs or subprocess invocations.

Level of scrutiny

Minimal. This is a mechanical reorder of existing assertions with no semantic change to what the test accepts or rejects. It exactly matches the repo's own testing guidance in CLAUDE.md ("Assert the exit code last. This gives you a more useful error message on test failure.") and the REVIEW.md rule about asserting on a combined output object before exit code. There is no ordering dependency between the assertions — bunStdout, bunStderr, and bunExitCode are all resolved via Promise.all before any assertion runs.

Other factors

The PR description clearly explains this is a diagnostic aid for a non-reproducing CI flake, not a behavior fix. The test still passes locally per the author. The bug hunting system found no issues. No CODEOWNERS concerns for a test file. No outstanding reviewer comments.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ai slop #37166 - Makes the same reorder in the same napi.test.ts test, moving expect(bunExitCode).toBe(0) below the stdout/stderr assertions, plus fixes the underlying conservative-GC flake.

🤖 Generated with Claude Code

No-Verification-Needed: test-only assertion reorder

@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 — assertion reorder only, matching the repo's "assert output before exit code" convention so the next CI failure prints the wrapper's captured stdout/stderr.

What was reviewed

  • Confirmed the same assertions are present, just reordered; the first one now concatenates stdout + stderr so a failure surfaces both.
  • Checked that the trailing .not.toContain("ERROR: Did not crash") (stdout-only) is unaffected by the reorder.
  • CodeRabbit's "exit code last" note was addressed in 940630e and the thread is resolved.
Extended reasoning...

Overview

This PR touches a single test in test/napi/napi.test.ts — the napi_reference_unref is blocked from finalizers in experimental modules case. It reorders the existing assertions so the captured stdout/stderr checks run before expect(bunExitCode).toBe(0), and augments the first check to include both streams (bunStdout + "\n---- stderr ----\n" + bunStderr) so a failure message prints everything the wrapper and child wrote. No production code, no new assertions, no assertions removed.

Security risks

None. Test-only change with no new inputs, no new subprocess invocations, and no environment changes.

Level of scrutiny

Low. This is a diagnostic-quality-of-life change to an existing test, aligning it with the repo's own guidance in CLAUDE.md and REVIEW.md ("expect(stdout).toBe(...) BEFORE expect(exitCode).toBe(0)"). The pass/fail semantics of the test are unchanged — the same set of toContain / not.toContain / toBe(0) checks run; only their order and the failure-message payload of the first one differ.

Other factors

  • CodeRabbit's initial inline comment (which had the direction backwards relative to the PR's stated intent) is marked resolved after commit 940630e, and the final diff has the exit-code check after the output checks as intended.
  • The github-actions bot flagged possible overlap with #37166; that's a merge-order decision for maintainers and doesn't affect the correctness of this diff on its own.
  • The trailing negative assertion (expect(bunStdout).not.toContain("ERROR: Did not crash")) still sits after the exit-code check, which is fine: the goal is that a nonzero exit prints the captured output first, and the first assertion now guarantees that.

…g in the child

No-Verification-Needed: test-only diagnostics

@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 diagnostic change to surface CI failure output.

What was reviewed:

  • Assertion reorder in napi.test.ts: first check now embeds both stdout and stderr so a CI failure prints the full wrapper/child output; exit-code check moved after.
  • Fixture change: second gc() from a fresh event-loop turn plus BUN_JSC_logGC=1 — checked that the extra stderr lines don't collide with the FATAL ERROR/panic detection or the toContain assertions.
  • Confirmed the not.toContain("ERROR: Did not crash") guard still checks only stdout, so the panic report's Args echo of the -e script source in stderr can't false-positive it.
Extended reasoning...

Overview

Two test files changed. test/napi/napi.test.ts reorders the assertions in the "napi_reference_unref is blocked from finalizers in experimental modules" test so the combined stdout+stderr check runs first (with a ---- stderr ---- separator baked into the compared string), and the bunExitCode check runs after. test/napi/napi-app/test_experimental_with_timeout.js adds a second gc() from setImmediate (so the array isn't kept alive by a stale stack slot on the first synchronous GC) and sets BUN_JSC_logGC=1 on the child for per-cycle diagnostics.

Security risks

None. Test-only change with no production code touched; the added env var is a JSC diagnostic flag scoped to the spawned child.

Level of scrutiny

Low. This is a pure test-diagnostic change intended to make an unreproducible darwin-x64 CI flake print useful output on its next occurrence. The fixture's contract (child must crash; wrapper exits 0 on seeing FATAL ERROR + panic) is unchanged — the second GC only gives the finalizer another chance to fire, which does not weaken what the test asserts.

Other factors

Checked that BUN_JSC_logGC output on stderr doesn't interfere with the wrapper's FATAL ERROR / panic(main thread) substring checks or the outer expect(bunStderr).toContain("FATAL ERROR"). The negative assertion expect(bunStdout).not.toContain("ERROR: Did not crash") remains stdout-only, which matters because the debug panic report on stderr echoes the -e script source (including the literal error string). CodeRabbit's note about the exit-code assertion not being the very last line is cosmetic here — if bunExitCode is non-zero the preceding combined-output assertion will already have failed and printed everything, which is the whole point of the reorder. The duplicate-PR bot flagged #37166 as overlapping; that's a merge-coordination question, not a correctness issue for this diff.

…path

No-Verification-Needed: test-only diagnostics

@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 diagnostic change to surface why the darwin-x64 lane fails.

What was reviewed:

  • Assertion reorder in napi.test.ts — same checks, output-first so failures print the wrapper/child output; the trailing .not.toContain("ERROR: Did not crash") still checks stdout only, so the panic report echoing the -e source on stderr can't false-positive it.
  • Fixture wrapper: second GC from a fresh setImmediate turn plus heapStats()/BUN_JSC_logGC diagnostics on the no-crash path — child is always spawned via bunExe()process.argv[0], so bun:jsc is available; the wrapper's stdout.includes('ERROR: Did not crash') branch still fires because the marker is still console.log'd.
  • Checked that the extra GC-log stderr output doesn't collide with any toContain/not.toContain assertion.
Extended reasoning...

Overview

Two test-file changes aimed at making a darwin-14-x64 CI flake diagnosable:

  • test/napi/napi.test.ts: reorders the assertions in the "napi_reference_unref is blocked from finalizers in experimental modules" test so the combined stdout+stderr (with a ---- stderr ---- separator) is asserted first, then the individual markers, then bunExitCode. Same set of assertions; only the order and the first assertion's concatenation format changed.
  • test/napi/napi-app/test_experimental_with_timeout.js: on the no-crash path, the child now runs a second gc() from a fresh setImmediate turn, dumps napi-related heapStats().objectTypeCounts and protectedObjectTypeCounts, and only then prints ERROR: Did not crash and exits 1. Also sets BUN_JSC_logGC: "2" on the child so each GC cycle logs a line to stderr.

No production/runtime code is touched.

Security risks

None. Test fixture and assertion-order changes only; no new inputs, no network, no filesystem writes outside the existing spawn.

Level of scrutiny

Low. This is a diagnostics-only change to a single flaky test. The one behavioral shift — allowing the crash to occur on a second GC from a clean stack instead of only the first synchronous GC — does not weaken the invariant under test (experimental-module finalizers calling napi_reference_unref must abort); it just accounts for JSC's conservative stack scan keeping the array alive across the first collection. The success path is unchanged (crash during GC → wrapper sees FATAL ERROR + panic(main thread)TEST PASSED).

Other factors

  • Verified the child is always Bun (bunExe() → wrapper → process.argv[0] → child), so require("bun:jsc") and Bun.gc on the diagnostic path are safe.
  • The expect(bunStdout).not.toContain("ERROR: Did not crash") guard still holds: the marker is still emitted via console.log (stdout) and the assertion still targets stdout only, so the debug panic report echoing the enlarged -e source on stderr can't trip it.
  • BUN_JSC_logGC output goes to stderr and none of the stderr assertions are negative, so the extra lines can't cause spurious failures.
  • CodeRabbit's one inline comment (exit-code-last) is resolved by 940630e; the remaining .not.toContain after the exit-code check is redundant (a wrapper exit of 0 already implies it) and harmless.
  • A duplicate-PR bot flagged #37166 as overlapping; that's informational and doesn't affect correctness of this change.

Comment thread test/napi/napi.test.ts Outdated
dylan-conway and others added 5 commits August 8, 2026 17:27
No-Verification-Needed: diagnostics-only runner tweak on a non-merge branch
… uploaded as artifact

No-Verification-Needed: test-only diagnostics
Comment thread test/napi/napi.test.ts
dylan-conway and others added 4 commits August 8, 2026 18:19
…Kite + ciEnv), unlink snapshots

No-Verification-Needed: test-only diagnostics
… from the snapshot before the (public) upload

No-Verification-Needed: test-only diagnostics
…vs perturbations)

No-Verification-Needed: test-only diagnostics
Comment thread test/napi/napi.test.ts
…reason) when /tmp/bun-napi-diag-request exists; driver requests it for V1 run 0

Not for merge.

No-Verification-Needed: diagnostics-only branch
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
…l words on the machine stack after gc()

Not for merge.

No-Verification-Needed: diagnostics-only branch
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:19 AM PT - Aug 9th, 2026

@dylan-conway, your commit cb60a8a has 3 failures in Build #90812 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37214

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

bun-37214 --bun

dylan-conway and others added 2 commits August 8, 2026 19:20
…egion below gc()'s frame (pre- and post-collection)

Not for merge.

No-Verification-Needed: diagnostics-only branch
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread test/napi/napi.test.ts
Comment on lines +1399 to +1400
// The wrapper script should exit with 0 if the test passed
expect(bunExitCode).toBe(0);

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.

🟡 The wrapper went from a single async spawn (with a 5s SIGKILL fallback) to 7 variants × 3 runs = 21 sequential spawnSync calls — each a full bunExe() startup + addon dlopen + gc + panic report, with V1 run 0 additionally building a JSC GC-debugging heap snapshot — but the outer it(...) timeout stayed at 25_000. The test is only todoIf(isWindows), so it runs on the linux debug/ASAN lane too, where 21 debug+ASAN spawns can plausibly exceed 25s and time out for a reason unrelated to the flake. Either gate the variant matrix to darwin x64 (where the flake reproduces) or raise the timeout to match the new workload — each child's own spawnSync timeout: 60_000 is currently pre-empted by the outer 25s anyway.

Extended reasoning...

What the bug is

The variant-matrix rewrite of test_experimental_with_timeout.js increased the wrapper's workload roughly 21× without adjusting the outer per-test timeout at napi.test.ts:1408, which remains 25_000 ms.

The old wrapper spawned one child asynchronously, forwarded its output, and sent SIGKILL the moment it saw both FATAL ERROR and panic(main thread) on stderr (with a 5s fallback timer). The new wrapper runs 7 variants × 3 runs = 21 sequential spawnSync calls, each of which:

  • launches a full bunExe() child (process.argv[0]),
  • require()s the native addon,
  • runs a synchronous full GC,
  • panics inside the finalizer via NAPI_ABORT, emitting the crash handler's full metadata block (Args, features, argv, environment fingerprint),
  • and — because spawnSync has no early-kill path — waits for the child to fully exit before starting the next one.

Additionally, V1 run 0 writes /tmp/bun-napi-diag-request, so if that child's first GC doesn't crash, bunNapiDiagMaybeDumpHeap runs bunNapiDiagWhereAreTheRoots (a full-heap live-cell walk plus a word-by-word machine-stack scan) and then builds a full JSC GCDebuggingSnapshot — which itself runs another full GC and serializes every cell's root reason to JSON.

Where it can bite

The test is gated only by it.todoIf(isWindows) (napi.test.ts:1296-1299), so it runs on every POSIX lane — including linux-x64 debug and linux-x64 debug+ASAN, not just the darwin-x64 lane the diagnostics target. REVIEW.md's own numbers say "debug+ASAN runs 10-100x slower"; the PR description quotes ~80ms for the original single-spawn run on release darwin-x64, so a debug+ASAN spawn plausibly takes ~1-1.5s each. At ~1.2s per spawn, 21 sequential spawns already exceed 25s — before accounting for the crash handler's metadata output (which is longer on debug builds, 4096-char Args budget) or CI-agent load.

The per-child spawnSync timeout is 60_000 ms (test_experimental_with_timeout.js:80), which is meaningless because the outer 25s pre-empts it: a single slow child (e.g. V5 with BUN_JSC_logGC=1 writing GC log lines under debug+ASAN) can consume most of the budget on its own.

Why existing code doesn't prevent it

  • The old wrapper's early-SIGKILL-on-panic path is gone; spawnSync waits for the child's natural exit, so each child pays the full crash-handler cost.
  • describe.concurrent doesn't help — all 21 spawns are inside one it(...) body, sequential.
  • The 25_000ms value was sized for the previous wrapper (one spawn + 5s fallback), and nothing in this diff touched it.

Step-by-step proof

  1. On a linux-x64 debug+ASAN lane, the outer test spawns [bunExe(), "napi-app/test_experimental_with_timeout.js"].
  2. The wrapper enters its for (const v of variants) / for (let i = 0; i < 3; i++) nest → 21 iterations.
  3. Each iteration's spawnSync(process.argv[0], ['--expose-gc', '-e', v.script], { timeout: 60_000 }) launches a debug+ASAN bun, which starts up (~several hundred ms under ASAN), dlopens test_reference_unref_in_finalizer_experimental.node, calls gc(), panics via NAPI_ABORT, and writes the full crash-handler metadata to stderr.
  4. spawnSync blocks until the child fully exits — no early kill on seeing panic.
  5. Suppose each iteration averages ~1.3s (well within the 10-100× debug+ASAN multiplier over the ~80ms release baseline). 21 × 1.3s ≈ 27.3s.
  6. The outer test's Jest timer fires at 25_000ms → "thrown: exceeded 25000ms timeout" on a lane unrelated to the darwin-x64 flake being investigated.
  7. runner.node.mjs now runs napi.test.ts 4× back-to-back on darwin x64, so a marginal per-run duration compounds there too — though darwin-x64 release is the fast lane and the more likely victim is linux debug/ASAN.

Impact

Unrelated timeout noise on non-darwin lanes while iterating on this diagnostics branch. That's the exact opposite of what the branch wants: clean CI signal so a genuine TEST FAILED: at least one variant did not crash on the first GC stands out. Not a correctness/data-loss issue, and the author has stated the branch won't merge, so this is a nit.

How to fix

Either is fine (the second is closer to REVIEW.md's "shrink the workload" preference):

  • Raise the timeout to match the new workload, e.g. 21 * 60_000 or a conservative 180_000, so the per-child spawnSync timeout is the operative bound.
  • Gate the matrix to the target lane: wrap the variant loop (or the whole test) in if (process.platform === 'darwin' && process.arch === 'x64') (or it.todoIf(!isMacOS || !isIntelMacOS || isWindows)), so non-darwin lanes keep roughly the original single-spawn cost.

dylan-conway and others added 8 commits August 8, 2026 19:42
…()'s frame un-inflated; deeper below-sp capture

Not for merge.

No-Verification-Needed: diagnostics-only branch
Not for merge.

No-Verification-Needed: diagnostics-only branch
…erior + just-past-the-end) and print offsets

Not for merge.

No-Verification-Needed: diagnostics-only branch
… dladdr), show neighbours and pointee state

Not for merge.

No-Verification-Needed: diagnostics-only branch
Comment on lines +71 to +82
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) {

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

Comment on lines +3408 to +3428

// 4'. Native frame layout above (and including) this frame, via the frame
// pointer chain, with return addresses symbolized, so a hit can be
// attributed to the frame that owns the slot. Then re-list the hits with
// the owning frame and a few neighbouring words.
{
struct NativeFrame {
uintptr_t* fp;
const char* name;
uintptr_t ret;
};
WTF::Vector<NativeFrame, 128> chain;
uintptr_t* fp = static_cast<uintptr_t*>(__builtin_frame_address(0));
while (fp && fp < origin && chain.size() < 120) {
uintptr_t ret = fp[1];
Dl_info info;
const char* name = (dladdr(reinterpret_cast<void*>(ret), &info) && info.dli_sname) ? info.dli_sname : "?";
chain.append({ fp, name, ret });
uintptr_t* next = reinterpret_cast<uintptr_t*>(fp[0]);
if (next <= fp) break;
fp = next;

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.

🔴 Commit 85de86a added section 4' (native fp-chain) to bunNapiDiagWhereAreTheRoots, which uses Dl_info/dladdr() at lines 3423-3424 with no platform guard — but <dlfcn.h> is included only under #if !OS(WINDOWS) (lines 241-243), so on Windows these are undeclared identifiers and the TU fails to compile (robobun confirms 🪟 x64/aarch64 build-cpp are still red on bb4430b). This is the same class as the earlier <unistd.h> break fixed in b8743ea; wrap the bunNapiDiagWhereAreTheRoots body (and bunNapiDiagCaptureBelowSp / the BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED inline-asm) in #if !OS(WINDOWS) — they're only reached from the POSIX branch of bunNapiDiagMaybeDumpHeap anyway.

Extended reasoning...

What the bug is

Commit b8743ea fixed the earlier Windows break by guarding #include <unistd.h> and giving bunNapiDiagMaybeDumpHeap an early return on Windows. But commit 85de86a ("attribute stack hits to a named native frame (fp chain + dladdr)") landed after that fix and added section 4' to bunNapiDiagWhereAreTheRoots, which at ZigGlobalObject.cpp:3423-3424 does:

Dl_info info;
const char* name = (dladdr(reinterpret_cast<void*>(ret), &info) && info.dli_sname) ? info.dli_sname : "?";

Dl_info and dladdr are declared in <dlfcn.h>, which this file includes only under #if !OS(WINDOWS) at lines 241-243. root.h provides no shim. bunNapiDiagWhereAreTheRoots itself has no #if !OS(WINDOWS) around its body — it is defined at file scope on every platform.

Why the earlier fix doesn't cover it

b8743ea guarded only bunNapiDiagMaybeDumpHeap: on Windows that function returns immediately and never calls bunNapiDiagWhereAreTheRoots. But not being called doesn't help — C++ still compiles the callee's body as part of the translation unit. A static function with an undeclared identifier in its body is a compile error whether or not anything calls it. The same applies to bunNapiDiagCaptureBelowSp (though that one happens to use only WTF types), and to the BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED() macro's GCC-syntax asm volatile on the x86_64 branch if the Windows toolchain isn't clang-cl.

Step-by-step proof

  1. BuildKite spawns the 🪟 x64 - build-cpp job for commit bb4430b.
  2. The Windows compiler compiles src/jsc/bindings/ZigGlobalObject.cpp (as it does on main — the file's own #if OS(WINDOWS) blocks at lines 232/287/598/1437 prove it's part of the Windows TU set).
  3. The preprocessor reaches line 241: #if !OS(WINDOWS) is false → <dlfcn.h> is not included.
  4. Compilation reaches line 3423 inside bunNapiDiagWhereAreTheRoots: Dl_info info;Dl_info is undeclared → error: use of undeclared identifier 'Dl_info' (clang-cl) / C2065 (MSVC).
  5. Line 3424: dladdr(...) — also undeclared.
  6. Compilation stops; the lane goes red.

The robobun comment on this PR independently confirms both 🪟 x64 - build-cpp and 🪟 aarch64 - build-cpp are "build failed" on bb4430b (the latest commit, which is the autofix formatting pass on top of 85de86a).

Impact

Every push to this branch reds every Windows CI lane. The branch is stated as diagnostics-only and won't merge, but the diagnostics loop is pushing repeatedly and reading CI results — a Windows lane that's red for a compile error is noise to filter on every build, and blocks every downstream Windows test lane so it can't say whether Windows exhibits the flake. The author already fixed the identical-class <unistd.h> finding in b8743ea when it was flagged, so they evidently want Windows green during the diagnostics loop; this is a fresh regression of that same class from the very next commit.

How to fix

Wrap the whole bunNapiDiagWhereAreTheRoots body in #if !OS(WINDOWS) (matching bunNapiDiagMaybeDumpHeap), and do the same for bunNapiDiagCaptureBelowSp and the x86_64 asm volatile arm of BUN_NAPI_DIAG_CAPTURE_CALLEE_SAVED for good measure:

__attribute__((no_sanitize("address"), noinline)) static void bunNapiDiagWhereAreTheRoots(JSC::VM& vm, JSC::CallFrame* callFrame)
{
#if !OS(WINDOWS)
    // ... entire body ...
#endif
}

(or #if OS(DARWIN), since the diagnostics target darwin x64 exclusively). The functions are only ever reached from the POSIX #else branch of bunNapiDiagMaybeDumpHeap, so no call-site change is needed.

No-Verification-Needed: diagnostics-only branch
…the PreciseAllocation half of oven-sh/WebKit#398)

No-Verification-Needed: dependency pin bump; built in CI
…ve JSArray

Not for merge.

No-Verification-Needed: diagnostics-only branch
…) even when everything passes

No-Verification-Needed: diagnostics-only branch
Comment on lines +3630 to +3631
bunNapiDiagPreGcLiveScan(JSC::getVM(global));
bunNapiDiagCaptureBelowSp(JSC::getVM(global));

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

Comment on lines +84 to +88
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 {

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.

…t exact one-past-the-end for non-butterfly precise cells)

No-Verification-Needed: dependency pin bump; built in CI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants