Skip to content

node: fix SEGV when worker termination races the first require("node:path") - #37296

Open
robobun wants to merge 10 commits into
mainfrom
farm/ee0764a8/path-binding-termination-check
Open

node: fix SEGV when worker termination races the first require("node:path")#37296
robobun wants to merge 10 commits into
mainfrom
farm/ee0764a8/path-binding-termination-check

Conversation

@robobun

@robobun robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Crash

Nested-worker fuzzing (release build with assertions and ASAN, Malloc=1) hit a SEGV on a worker thread when a termination request raced the worker's first require("node:path"):

SEGV on unknown address 0x000000000005 READ
#0 JSC::JSCell::isCustomGetterSetter JSCell.h:133
#3 JSC::JSObject::putDirectIndex JSObjectInlines.h:1580   ASSERT(!value.isCustomGetterSetterSlow())
#5 Bun::createNodePathBinding src/jsc/bindings/Path.cpp:144
#7 Bun::JS2Native::jsDollarLazy JS2Native.cpp:31
#15 Bun::generateModule InternalModuleRegistry.cpp:61  (node:path)
... WebWorker::spin web_worker.rs:843

Trigger shape: main spawns a middle worker which spawns K leaf workers whose first statement is require("node:path"); terminating the middle worker cascades termination requests into the leaves mid-require. Observed rate was about 1 hit per several thousand leaves that were mid-require when terminated.

Cause

RETURN_IF_EXCEPTION services VM traps (ExceptionScope.h calls vm.hasExceptionsAfterHandlingTraps()), so a pending worker.terminate() can materialize as the termination exception at any such check. Zig::createPath does constructEmptyObject(); RETURN_IF_EXCEPTION(scope, {}); (Path.cpp:83) and returns null in that case, after the allocation already succeeded. createNodePathBinding passed createPath(...) inline as the putDirectIndex argument, ahead of its own exception check, so putDirectIndex received the empty JSValue and dereferenced it in ASSERT(!value.isCustomGetterSetterSlow()) (the faulting address 0x5 is the m_type offset off a null cell). On builds without assertions the empty value would be written as an array hole and the next check unwinds, so this is a crash class on assert-enabled builds rather than memory corruption.

This is the case REVIEW.md calls out: the result of a throwing call was used before RETURN_IF_EXCEPTION.

Fix

Hoist each helper call into a local and check it before the put (the shape SQLClient.cpp already uses). An audit of the C++ compiled into bun for the same pattern (a null-on-exception helper consumed inline by putDirect*) found the same bug in more places, fixed the same way:

  • jsNodeVmModuleGetModuleRequests consuming NodeVMModuleRequest::toJS (same SEGV shape, reachable from vm.SourceTextModule in a terminating worker)
  • process.report.getReport() (BunProcess.cpp, BunProcessReportObjectWindows.cpp): constructEmptyArray inline in putDirect for the empty cpus/networkInterfaces/nativeStack/libuv/workers arrays; the 13 construct*() section-builder lambdas in constructReportObjectComplete, each of which returns an empty JSValue from an internal RETURN_IF_EXCEPTION; the Bun__Process__createExecArgv extern shim (its Rust side returns JSValue::ZERO when terminated) on both platforms; and the vm-first constructVersions/constructProcessReleaseObject, which carry their own scopes

CI on this branch then caught the adjacent flavor of the same bug at the Rust boundary: JSC__VM__ensureTerminationExceptionPending called handleTraps with no exception scope on the stack, so the simulated throw was still unchecked when the next native entry point declared a scope, and the exception-check validator aborted at JSC__JSModuleLoader__loadAndEvaluateModule (hit by worker-transfer-terminate-stress.test.ts on the x64-asan lane when terminate() raced worker startup). The function now declares a top exception scope and observes the throw before returning to Rust; the termination exception itself stays pending for the caller's next check point, which is the function's contract.

Test

The race itself cannot be made deterministic: the window is a single constructEmptyObject between two trap checks, and the termination bit is set by another thread. BUN_JSC_validateExceptionChecks=1 cannot see the use-before-check either, because putDirectIndex on a fresh ArrayWithUndecided array never constructs a ThrowScope, so the simulated throw from the helper's scope destruction is cleared by the too-late check with nothing in between to assert on (which is also why the exception-validation CI lane never flagged these sites).

Instead the regression test is a source lint, test/internal/source-lints/putdirect-unchecked-helper.test.ts, following the existing lints in that directory (empty-jsvalue-laundering.test.ts is the Rust flavor of the same invariant). It scans the C++ sources compiled into bun for the helper families that can return empty (create*/.toJS taking a global object first, including underscore-qualified extern shims, constructEmptyArray, and zero-argument construct*/create* scope-capturing helpers) consumed inline inside putDirect* argument lists. The comment/string stripping is a small lexical scan so literals containing // cannot mask an offender, and every rule is pinned by positive and negative fixtures. vm-first calls are deliberately not modeled: the common vm-first builders are infallible and the name alone cannot tell the rare fallible ones apart, so those were hoisted by hand and the exclusion is documented in the lint header. On the unfixed tree the lint reports 25 sites with accurate file:line and fails; on this branch it passes with zero suppressions.

Verification:

  • lint test fails on the base tree (25 offenders), passes on this branch
  • debug ASAN probe of the fuzzer's nested-terminate shape: 0 crashes and 0 hangs in 2040 iterations (about 18k leaf workers terminated mid-boot or mid-require) on the fixed build; the unfixed build also did not fire in the same budget, so the probe bounds regression risk rather than reproducing the race at debug speed
  • worker-transfer-terminate-stress.test.ts passes 6/6 consecutive runs under BUN_JSC_validateExceptionChecks=1 on the fixed build (the configuration that aborted on the x64-asan lane)
  • test/js/node/path/ 123 pass, test/js/node/vm/vm.test.ts 214 pass, the process.report test passes, and process.report.getReport() smoke-checked on Linux and Windows (branch compile-verified natively on Windows)
  • BUN_JSC_validateExceptionChecks=1 stays clean on require("node:path")

[review] gate passed · iteration 2 · 6 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/putdirect-unchecked-helper.test.ts
bun test v1.4.0 (cffee8290)

test/internal/source-lints/putdirect-unchecked-helper.test.ts:
(pass) detects create* taking the global object first [4.76ms]
(pass) detects a call spanning multiple lines [2.25ms]
(pass) detects method .toJS taking the global object [1.56ms]
(pass) detects constructEmptyArray [1.54ms]
(pass) detects a zero-arg section builder [1.31ms]
(pass) detects an offender after a string literal containing // [1.59ms]
(pass) detects an offender after a multi-line block comment, at its real line [1.62ms]
(pass) detects an underscore-qualified extern create* shim [1.98ms]
(pass) ignores the hoisted and checked fix shape [3.02ms]
(pass) ignores a vm-first helper, which cannot return empty [1.48ms]
(pass) ignores jsString(vm, ...) inline [1.10ms]
(pass) ignores a banned shape inside a comment [1.10ms]
(pass) ignores a banned shape inside a string literal [1.31ms]
(pass) ignores a vm-first construct* builder, which this lint does not model [1.51ms]
(pass) scans 
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (81592c47f)

test/internal/source-lints/putdirect-unchecked-helper.test.ts:
(pass) detects create* taking the global object first [0.13ms]
(pass) detects a call spanning multiple lines [0.01ms]
(pass) detects method .toJS taking the global object
(pass) detects constructEmptyArray [0.01ms]
(pass) detects a zero-arg section builder
(pass) detects an offender after a string literal containing //
(pass) detects an offender after a multi-line block comment, at its real line
(pass) detects an underscore-qualified extern create* shim
(pass) ignores the hoisted and checked fix shape [0.02ms]
(pass) ignores a vm-first helper, which cannot return empty
(pass) ignores jsString(vm, ...) inline
(pass) ignores a banned shape inside a comment
(pass) ignores a banned shape inside a string literal
(pass) ignores a vm-first construct* builder, which this lint does not model
(pass) scans a non-empty set of tracked C++ sources [0.01ms]
257 | test("scans a non-empty set of tracked C++ sources", () => {
258 |   expect(scanned).toBeGreaterThan(0);
259 | });
260 | 
261 | test("no throwing helper is consumed inline by putDirect* before its exception check", () => {
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/putdirect-unchecked-helper.test.ts
bun test v1.4.0 (cffee8290)

test/internal/source-lints/putdirect-unchecked-helper.test.ts:
(pass) detects create* taking the global object first [5.30ms]
(pass) detects a call spanning multiple lines [2.74ms]
(pass) detects method .toJS taking the global object [1.55ms]
(pass) detects constructEmptyArray [1.58ms]
(pass) detects a zero-arg section builder [1.31ms]
(pass) detects an offender after a string literal containing // [1.64ms]
(pass) detects an offender after a multi-line block comment, at its real line [2.05ms]
(pass) detects an underscore-qualified extern create* shim [1.68ms]
(pass) ignores the hoisted and checked fix shape [3.07ms]
(pass) ignores a vm-first helper, which cannot return empty [1.53ms]
(pass) ignores jsString(vm, ...) inline [1.10ms]
(pass) ignores a banned shape inside a comment [1.25ms]
(pass) ignores a banned shape inside a string literal [1.30ms]
(pass) ignores a vm-first construct* builder, which this lint does not model [1.38ms]
(pass) scans 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 636ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/10] gen cpp.rs (cppbind)
[1/10] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[
... (truncated)
diff hotspot
src/jsc/bindings/BunProcess.cpp                    |  54 +++--
 src/jsc/bindings/BunProcessReportObjectWindows.cpp |  20 +-
 src/jsc/bindings/NodeVMModule.cpp                  |   5 +-
 src/jsc/bindings/Path.cpp                          |  15 +-
 src/jsc/bindings/bindings.cpp                      |   2 +
 .../putdirect-unchecked-helper.test.ts             | 263 +++++++++++++++++++++
 6 files changed, 326 insertions(+), 33 deletions(-)

gate history · 3 passed · 2 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/BunProcess.cpp                               4      5      0
src/jsc/bindings/BunProcessReportObjectWindows.cpp            6      6      0
src/jsc/bindings/NodeVMModule.cpp                             2      3      0
src/jsc/bindings/Path.cpp                                     2      3      0
src/jsc/bindings/bindings.cpp                                 2      5      0
…nternal/source-lints/putdirect-unchecked-helper.test.ts      2      6      0

…ndex

Zig::createPath returns null with an exception pending when a VM trap
(such as a worker termination request) surfaces at its internal
RETURN_IF_EXCEPTION. createNodePathBinding passed the result inline to
putDirectIndex, which dereferences the empty JSValue in its
isCustomGetterSetterSlow assert before the caller's exception check
runs. Hoist each helper call into a local and check it before the put.

jsNodeVmModuleGetModuleRequests had the same pattern with
NodeVMModuleRequest::toJS; fixed the same way.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds pending-exception checks around JavaScript value conversion, process report construction, and array allocation in JSC bindings. It also scopes termination handling and adds a source-lint test for unchecked helper calls passed to putDirect APIs.

Changes

Binding exception safety

Layer / File(s) Summary
Checked binding conversions
src/jsc/bindings/NodeVMModule.cpp, src/jsc/bindings/Path.cpp
Module-request and path conversion results are stored and checked before array insertion.
Checked process report allocations
src/jsc/bindings/BunProcess.cpp, src/jsc/bindings/BunProcessReportObjectWindows.cpp
Process report values and arrays are allocated into local variables and checked before property insertion.
Termination handling and source-lint validation
src/jsc/bindings/bindings.cpp, test/internal/source-lints/putdirect-unchecked-helper.test.ts
Termination handling establishes an exception scope. The source-lint test scans tracked C++ sources for unchecked throwing helper calls passed to putDirect, putDirectIndex, or putDirectOffset.

Possibly related PRs

  • oven-sh/bun#35976: Both changes modify NodeVMModule.cpp exception handling.
  • oven-sh/bun#36342: Both changes modify termination-exception handling in bindings.cpp.
  • oven-sh/bun#36806: Both changes improve VM termination-exception handling in bindings.cpp.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the primary Node.js path termination race and resulting SEGV fix.
Description check ✅ Passed The description explains the crash, cause, fix, broader audit, and verification results with substantial technical detail.

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

…tion check

A helper that allocates under its own ThrowScope returns null with an
exception pending when a VM trap (worker termination, vm watchdog)
surfaces at one of its internal RETURN_IF_EXCEPTION checks. Consuming
such a call inline as the value argument of putDirect/putDirectIndex
stores or dereferences an empty JSValue before the caller's check runs.

The lint scans the C++ sources compiled into bun for the helper
families that can return empty (create*/.toJS taking a global object
first, and constructEmptyArray) inline inside putDirect* argument
lists. Fix the remaining instances it found: process.report's empty
cpus/networkInterfaces/nativeStack/libuv/workers arrays.
Comment thread src/jsc/bindings/Path.cpp Outdated
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:49 PM PT - Aug 9th, 2026

@robobun, your commit cffee829006d55fadcd462ac1cc30382b4d47bce passed in Build #91229! 🎉


🧪   To try this PR locally:

bunx bun-pr 37296

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

bun-37296 --bun

@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 — the hoist-and-check transformation is mechanical and correct, and the earlier comment-length nit was addressed in 793d1cd. The two remaining inline nits (pre-existing construct*() lambda sites one frame up, and line-number drift in the lint's diagnostic output) are follow-up material and don't affect the crash fix.

Extended reasoning...

Overview

This PR fixes a fuzzer-caught SEGV where helper functions that return null-on-exception (because RETURN_IF_EXCEPTION services VM traps and can materialize a pending worker termination) were consumed inline by putDirect* before the caller's exception check ran. The fix is the canonical hoist-then-check shape across four C++ binding files (Path.cpp, NodeVMModule.cpp, BunProcess.cpp, BunProcessReportObjectWindows.cpp) plus a new source-lint test that enforces the invariant going forward, modeled on the existing empty-jsvalue-laundering.test.ts in the same directory.

Security risks

None. This is exception-propagation ordering in JSC bindings; no user-controlled input handling, auth, or crypto is touched.

Level of scrutiny

JSC bindings are memory-safety-adjacent, but the transformation here is purely mechanical: obj->putDirect*(.., helper(g), ..)auto* v = helper(g); RETURN_IF_EXCEPTION(scope, {}); obj->putDirect*(.., v, ..). Each hunk is a strict improvement with no behavior change on the success path, and matches the shape already used elsewhere (e.g. SQLClient.cpp). The PR description demonstrates the root cause precisely (the 0x5 fault address is the m_type offset off a null cell in ASSERT(!value.isCustomGetterSetterSlow())), explains why validateExceptionChecks never caught it, and verifies the affected test suites still pass.

Other factors

  • My prior nit (three-line narrative comments) was addressed in 793d1cd — both comments are now one line keeping only the non-obvious trap-servicing fact.
  • The two new inline nits are non-blocking: the construct*() lambda sites at BunProcess.cpp:2658-2683 are pre-existing (each lambda already had internal RETURN_IF_EXCEPTION returning {} before this PR), so nothing regresses; and the lint's block-comment stripper only affects the reported file:line pointer in the failure message, not pass/fail.
  • The new lint follows the established test/internal/source-lints/ conventions (globAllSources, tracked-file filter, expect(offenders).toEqual([])) and passes with zero suppressions on this branch.

Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread test/internal/source-lints/putdirect-unchecked-helper.test.ts Outdated
robobun and others added 2 commits August 10, 2026 01:21
The construct* lambdas in constructReportObjectComplete each return an
empty JSValue from an internal RETURN_IF_EXCEPTION, so consuming them
inline in putDirect had the same use-before-check shape one frame up.
Widen the lint to catch zero-argument construct*/create* calls, and
blank comments instead of deleting them so its reported line numbers
stay accurate.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/internal/source-lints/putdirect-unchecked-helper.test.ts (1)

93-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add positive fixtures for every detector rule.

The test only asserts that the current tracked sources produce zero findings. A no-op scanner or an over-restrictive regex can therefore pass. Add table-driven fixtures for each unsafe helper pattern and assert the reported line and remediation. Include multiline calls and the //-inside-string case from Line 81.

As per coding guidelines, tests must prove they fail for the intended reason and must cover the complete relevant variant matrix.

🤖 Prompt for 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.

In `@test/internal/source-lints/putdirect-unchecked-helper.test.ts` around lines
93 - 99, Add table-driven positive fixtures for every unsafe-helper detector
rule in the putDirect* lint test, including multiline calls and the Line 81 case
where // appears inside a string. Assert each fixture reports the expected line
and remediation, while preserving the existing clean-source assertion and
covering the complete relevant pattern matrix.

Source: Coding guidelines

🤖 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/internal/source-lints/putdirect-unchecked-helper.test.ts`:
- Around line 80-81: Replace the regex-based stripping in the test’s content
preprocessing with a lexical scanner that removes line and block comments while
preserving normal and raw C++ string literals, including embedded // and /*
sequences. Keep non-comment characters and newlines intact so reported line
numbers remain accurate, and ensure putDirect calls following such literals are
still detected.

---

Outside diff comments:
In `@test/internal/source-lints/putdirect-unchecked-helper.test.ts`:
- Around line 93-99: Add table-driven positive fixtures for every unsafe-helper
detector rule in the putDirect* lint test, including multiline calls and the
Line 81 case where // appears inside a string. Assert each fixture reports the
expected line and remediation, while preserving the existing clean-source
assertion and covering the complete relevant pattern matrix.
🪄 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: 9b906960-812d-42bb-944e-ddfa08bae945

📥 Commits

Reviewing files that changed from the base of the PR and between 793d1cd and 3059e8a.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunProcess.cpp
  • test/internal/source-lints/putdirect-unchecked-helper.test.ts

Comment thread test/internal/source-lints/putdirect-unchecked-helper.test.ts Outdated
Replace the regex comment stripping with a small lexical scan that
blanks comments and string/char literal contents while preserving
newlines, so a literal containing // no longer truncates the line and
reported line numbers stay accurate. Add positive and negative fixtures
for every detector rule so a broken rule or no-op scan cannot pass.
Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread test/internal/source-lints/putdirect-unchecked-helper.test.ts Outdated
… report helpers

JSC__VM__ensureTerminationExceptionPending called handleTraps with no
exception scope on the stack, so under exception-check validation the
simulated throw was still unchecked when the next native entry point
declared a scope and aborted (seen at
JSC__JSModuleLoader__loadAndEvaluateModule when terminate() raced worker
startup in worker-transfer-terminate-stress). Declare a top exception
scope and observe the throw before returning; the termination exception
itself stays pending for the caller's next check.

Also hoist the remaining null-on-exception helpers consumed inline by
putDirect in the process.report header: the createExecArgv extern shim
(returns JSValue::ZERO when terminated) on both platforms, and the
vm-first constructVersions/constructProcessReleaseObject, which carry
their own scopes. Extend the lint to match underscore-qualified extern
create* shims and document why vm-first calls stay unmodeled.
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
50462cd was meant to remove one comment in bindings.cpp but its
commit captured reverted content for every binding file this branch
changes, leaving only the lint on the branch. Re-apply the Path,
NodeVMModule, process.report, and ensureTerminationExceptionPending
fixes exactly as reviewed.
Comment thread src/jsc/bindings/Path.cpp

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

I reviewed cffee82 and found no further issues — all three prior rounds of feedback are addressed and the accidental revert in 50462cd is fully restored. Given this touches JSC exception-scope handling at the Rust/C++ termination boundary (JSC__VM__ensureTerminationExceptionPending now carries a release assertion) and adds a new 260-line source-lint with a hand-rolled C++ lexer, a human look is still worthwhile.

What was reviewed:

  • The hoist-and-check transforms in Path.cpp, NodeVMModule.cpp, BunProcess.cpp, and BunProcessReportObjectWindows.cpp preserve property write order and add no new allocations.
  • DECLARE_TOP_EXCEPTION_SCOPE + releaseAssertNoExceptionExceptTermination() in bindings.cpp matches the pattern used across ~60 other binding files; checked that the only exception handleTraps(NeedTermination) can raise is the termination exception, so the release assert holds.
  • The lint's stripForScan lexer against raw strings, digit-separator ', and // inside literals — the fixture set pins each rule and the earlier line-number/string-literal issues are fixed.
Extended reasoning...

Overview

Six files: five C++ bindings applying the same mechanical transform (hoist a null-on-exception helper into a local, RETURN_IF_EXCEPTION, then putDirect*) at ~25 sites in Path.cpp, NodeVMModule.cpp, BunProcess.cpp, and BunProcessReportObjectWindows.cpp; a two-line addition to JSC__VM__ensureTerminationExceptionPending in bindings.cpp declaring a top exception scope so handleTraps' simulated throw is observed before returning to Rust; and a new 263-line source-lint test that scans tracked C++ for the inline-helper pattern.

Security risks

None identified. No user-controlled input parsing, no auth/crypto/permissions surface. The changes are exception-propagation ordering only; on non-assert builds the pre-fix behavior wrote an array hole and unwound at the next check, so this is a crash-class fix on assert builds rather than memory corruption.

Level of scrutiny

Moderate-to-high. The C++ hoists themselves are mechanical and low-risk, but two aspects warrant a maintainer's eye: (1) bindings.cpp adds scope.releaseAssertNoExceptionExceptTermination() at the Rust FFI boundary — a release-build assertion on a worker-termination path that runs in production; (2) the new lint is repo infrastructure (a hand-rolled C++ lexical scanner with four regex rules) that will gate future PRs, and its false-positive/negative envelope — particularly the documented vm-first exclusion — is a design choice a maintainer should sign off on.

Other factors

This PR has been through three prior review rounds from me with substantive findings each time (missed sibling sites at the outer constructHeader layer, block-comment newline handling, //-in-string-literal handling, vm-first constructVersions/constructProcessReleaseObject, and an accidental full revert in 50462cd). All were addressed and the threads are resolved. The bug-hunting system found nothing on the current head. The commit history was rocky (a stale-index commit reverted every fix, then cffee82 restored them), which by itself argues for a human confirming the final tree matches intent. Existing test coverage: test/js/node/path/, vm.test.ts, and the process.report test all pass per the PR body; the new lint fails on base (25 offenders) and passes on the branch.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

The process.report.getReport() part of this PR is reachable deterministically, which makes a runtime regression test possible alongside the lint. A worker that loops on getReport() is always inside one of the section builders when terminate() lands, so the termination is delivered at one of the builder's own exception checks and the empty section value reaches putDirect:

const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
  const w = new Worker(__filename);
  w.on("message", () => w.terminate());
  w.on("exit", () => console.log("survived"));
} else {
  parentPort.postMessage("busy");
  for (;;) process.report.getReport();
}

On a debug build of main this dies on the first worker, 10/10 runs (UBSan reports the null cell one frame before the SEGV the assertion would otherwise produce):

JSCJSValueCell.h:67:34: runtime error: member call on null pointer of type 'JSC::JSCell'
    #0 JSC::JSValue::isGetterSetter() const
    #1 JSC::JSValue::isGetterSetterSlow() const
    #2 JSC::JSObject::putDirect(JSC::VM&, JSC::PropertyName, JSC::JSValue, unsigned int) JSObject.h:1258
    #3 Bun::constructReportObjectComplete(JSC::VM&, Zig::GlobalObject*, WTF::String const&) BunProcess.cpp
    #4 Bun::Process_functionGetReport(JSC::JSGlobalObject*, JSC::CallFrame*)

A debug build of this branch at cffee82 survives it (20 terminated workers per run, 3 runs), so the hoisting here covers it.

db1d2ca on farm/8bfb2953/getreport-terminate-test packages that as a test in test/js/node/process/process.test.js (three workers terminated mid-getReport(), asserting the host sees all of them exit). It is red 5/5 against the main debug build and green 3/3 against the cffee82 build, about 2.9s per run on debug ASAN. It touches no file this PR changes, so it should cherry-pick cleanly if you want a runtime check for this part of the change in addition to the lint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants