Skip to content

process.binding('uv'): fix segfault when worker.terminate() lands inside getErrorMap() - #37441

Open
robobun wants to merge 1 commit into
mainfrom
farm/1739d11a/uv-geterrormap-termination-check
Open

process.binding('uv'): fix segfault when worker.terminate() lands inside getErrorMap()#37441
robobun wants to merge 1 commit into
mainfrom
farm/1739d11a/uv-geterrormap-termination-check

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Crash

Calling worker.terminate() while the worker is inside process.binding("uv").getErrorMap() segfaults the whole process. Reproduces on the current release build every time (20 of 20 runs with a single worker):

const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
  const w = new Worker(__filename);
  w.on("message", () => w.terminate());
  w.on("exit", code => console.log("worker exited", code));
} else {
  const uv = process.binding("uv");
  parentPort.postMessage("busy");
  for (;;) uv.getErrorMap();
}
panic: Segmentation fault at address 0x4

Under UBSan the first frame is ProcessBindingUV.cpp:162: member call on null pointer of type 'JSC::JSObject' in Bun::ProcessBindingUV::jsGetErrorMap. Besides direct process.binding("uv") users, internal/fs/watch.ts calls this binding on its error path.

Cause

jsGetErrorMap built each [name, message] entry in a void lambda:

auto arr = JSC::constructEmptyArray(globalObject, nullptr, 2);
// RETURN_IF_EXCEPTION
arr->putDirectIndex(globalObject, 0, ...);

constructEmptyArray returns null after its own RETURN_IF_EXCEPTION (or when it throws out of memory), and RETURN_IF_EXCEPTION also services VM traps (ExceptionScope.h goes through vm.hasExceptionsAfterHandlingTraps()), so the notifyNeedTermination() issued by terminate() materializes as a pending termination exception inside one of the ~85 constructEmptyArray calls per getErrorMap(). The lambda then dereferenced the null array. JSMap::set can throw too (it materializes and grows the storage) and was unchecked as well.

Fix

Replace the lambda with a constexpr table built from the same BUN_UV_ERRNO_MAP list and a plain loop in the host function, with a RETURN_IF_EXCEPTION after constructEmptyArray, each putDirectIndex and JSMap::set (the shape createPatternFilledArray in JSC and the other constructEmptyArray callers in this tree use; REVIEW.md also asks for no check macros inside lambdas). The termination now propagates like any other exception and the worker exits with code 1. The map contents and iteration order are byte-identical to before (compared the JSON of [...getErrorMap()] from the release build and this build, 85 entries).

Test

test/js/node/process-binding.test.ts gains a case that spawns bun, starts four workers that loop on getErrorMap(), terminates each on its first message and expects [1,1,1,1] with an empty stderr and exit code 0. Without the fix the child segfaults (10 of 10 runs on the release build); with the fix it passes on the debug build. BUN_JSC_validateExceptionChecks=1 is clean on the new loop, and test/js/node/test/parallel/test-uv-errmap.js and test/js/node/watch/fs.watch.test.ts still pass.

While auditing for the same shape I found two more hoisted-but-unchecked constructEmptyArray results (ProcessBindingHTTPParser.cpp methods/allMethods lazy builders, and JSC__JSValue__upsertBunStringArray in BunString.cpp). They have much narrower windows and are being handled separately; this PR only changes ProcessBindingUV.cpp.


[review] gate passed · iteration 0 · 2 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/js/node/process-binding.test.ts
bun test v1.4.0 (d1185a71e)

test/js/node/process-binding.test.ts:
(pass) process.binding > process.binding('constants') [12.84ms]
(pass) process.binding > process.binding('uv') [19.35ms]
66 |         stdout: "pipe",
67 |         stderr: "pipe",
68 |       });
69 | 
70 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
71 |       expect(stderr).toBe("");
                          ^
error: expect(received).toBe(expected)

- ""
+ "../../src/jsc/bindings/ProcessBindingUV.cpp:162:14: runtime error: member call on null pointer of type 'JSC::JSObject'
+ SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ../../src/jsc/bindings/ProcessBindingUV.cpp:162:14 
+ "

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/node/process-binding.test.ts:71:22)
(fail) process.binding > process.binding('uv').getErrorMap() survives worker.terminate() landing mid-call [3784.05ms]

 2 pass
 1 fail
 16 expect() calls
Ran 3 tests a
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)

test/js/node/process-binding.test.ts:
(pass) process.binding > process.binding('constants') [0.18ms]
(pass) process.binding > process.binding('uv') [0.23ms]
66 |         stdout: "pipe",
67 |         stderr: "pipe",
68 |       });
69 | 
70 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
71 |       expect(stderr).toBe("");
                          ^
error: expect(received).toBe(expected)

- ""
+ "============================================================
+ Bun Canary v1.4.0-canary.1 (9008ae7ab) Linux x64
+ Linux Kernel v6.17.0 | glibc v2.41
+ CPU: sse42 popcnt avx avx2 avx512
+ Args: "/workspace/bun/build/release/bun" "-e" "\n            const { Worker } = require(\"node:worker_threads\");\n            const source = `\n              const { parentPort } = require(\"node:worker_threads\");\n"...
+ Features: Bun.stderr(8) Bun.stdin(8) Bun.stdout(8) bunfig jsc tsconfig workers_spawned(4) 
+ Builtins: "bun:main" "node:worker_threads" 
+ 
+ Elapsed: 61ms | User: 160ms | Sys: 20ms
+ RSS: 60.31 MB | Peak: 55.82 MB | Commit: 120.98 MB | Faults: 0 | Machine: 34.36 GB

... (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/js/node/process-binding.test.ts
bun test v1.4.0 (d1185a71e)

test/js/node/process-binding.test.ts:
(pass) process.binding > process.binding('constants') [11.75ms]
(pass) process.binding > process.binding('uv') [38.40ms]
(pass) process.binding > process.binding('uv').getErrorMap() survives worker.terminate() landing mid-call [4600.59ms]

 3 pass
 0 fail
 18 expect() calls
Ran 3 tests across 1 file. [7.47s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 935ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/82] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/82] gen cpp.rs (cppbind)
[3/82] gen JS modules (bundle-modules)
Preprocess modules (12995ms)
Bundle modules (57ms)
Postprocesss modules (184ms)
Bundle Functions (769ms)
Generate Code (33ms)

[14.06s] Bundled "src/js" for production
  2610 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/70] 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_parsers v0.0.0 (/workspace/bun/src/parsers)
�[1m�[92m   Compiling�[0m bun_http v0.0.0 (/workspace/bun/src/http)
�[1m�[92m   Compiling�[0m bun_sourcemap v0.0.0 (/workspace/bun/src/sourcemap)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m 
... (truncated)
diff hotspot
src/jsc/bindings/ProcessBindingUV.cpp | 30 +++++++++++++++--------
 test/js/node/process-binding.test.ts  | 46 +++++++++++++++++++++++++++++++++++
 2 files changed, 66 insertions(+), 10 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/jsc/bindings/ProcessBindingUV.cpp      1      1      0
test/js/node/process-binding.test.ts       3      6      0

jsGetErrorMap built each [name, message] entry inside a void lambda that
dereferenced the result of constructEmptyArray without checking for an
exception (the check was left as a comment). constructEmptyArray returns
null once an exception is pending, and RETURN_IF_EXCEPTION also services
VM traps, so a worker.terminate() requested while a worker is inside
getErrorMap() turned into a null dereference that took down the whole
process ("Segmentation fault at address 0x4").

Build the map from a constexpr table in a plain loop instead, checking
the scope after every allocation, putDirectIndex and JSMap::set, so the
termination (or an out-of-memory error) propagates like any other
exception. The map contents and order are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b9605c63-034b-475b-9eb6-3f499e3d1af3

📥 Commits

Reviewing files that changed from the base of the PR and between 54d6d16 and d1185a7.

📒 Files selected for processing (2)
  • src/jsc/bindings/ProcessBindingUV.cpp
  • test/js/node/process-binding.test.ts

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:18 AM PT - Aug 11th, 2026

@robobun, your commit d1185a71e33d197eee2f7cc3eebe3180e39c2a6a passed in Build #92016! 🎉


🧪   To try this PR locally:

bunx bun-pr 37441

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

bun-37441 --bun

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting on CI.

Reproduced on the release build (1.4.0 canary 9008ae7) with the snippet in the description: a single worker looping on process.binding("uv").getErrorMap() and terminated on its first message crashed the process with panic: Segmentation fault at address 0x4 in 20 of 20 runs. The new case in test/js/node/process-binding.test.ts fails the same way on that build and passes on a debug build with this change; getErrorMap() output is unchanged (85 entries, same order).

The same unchecked constructEmptyArray shape in ProcessBindingHTTPParser.cpp and BunString.cpp is tracked separately and not part of this PR.

@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 — textbook exception-check fix for a real null-deref crash.

What was reviewed:

  • Verified RETURN_IF_EXCEPTION now guards constructEmptyArray, both putDirectIndex calls, and JSMap::set; the constexpr table is built from the same BUN_UV_ERRNO_MAP so map contents and order are unchanged.
  • Checked that JSMap::create(vm, ...) is left unchecked consistently with sibling call sites (JSNodePerformanceHooksHistogramPrototype.cpp) — not a regression.
  • Test drains stdout/stderr/exited concurrently, is event-driven (no sleeps), asserts exact output before exit code, and the 20s timeout is commented per convention.
Extended reasoning...

Overview

This PR fixes a segfault in jsGetErrorMap (src/jsc/bindings/ProcessBindingUV.cpp) that occurs when worker.terminate() fires the VM termination trap while the worker is inside process.binding('uv').getErrorMap(). The old code called constructEmptyArray inside a void lambda and dereferenced the result without checking for a pending exception; when the termination trap surfaced there, the array was null and the process crashed. The fix replaces the macro-expanded lambda calls with a static constexpr table (built from the same BUN_UV_ERRNO_MAP X-macro) and a plain loop under a DECLARE_THROW_SCOPE, with RETURN_IF_EXCEPTION after each of the four throwing calls. A regression test spawns four workers looping on getErrorMap(), terminates each on its first message, and asserts clean exit codes.

Security risks

None. No untrusted input is parsed; the change only adds exception propagation to an internal binding that builds a fixed error-name map. The constexpr table is derived from compile-time constants.

Level of scrutiny

Low-to-moderate. This is native C++ in the JSC bindings, so memory-safety review rules apply, but the change is a mechanical, well-understood pattern: hoist a throw scope, check after every throwing call, and avoid check macros inside lambdas — exactly what REVIEW.md's "Exception checks after every call that can enter JS" section prescribes. The data flow is identical to before (same macro, same iteration order, same values), so there is no behavior change on the success path. I confirmed the pattern matches other constructEmptyArray callers in the tree, and that leaving JSMap::create unchecked matches sibling sites.

Other factors

  • The test follows harness conventions cleanly: bunEnv/bunExe, subprocess isolation for a crash repro, concurrent pipe draining, event-driven synchronization via postMessage (no sleeps), stderr/stdout asserted before exit code, and a commented per-test timeout justified for debug/ASAN worker boot. The candidate concern about the explicit timeout was ruled out — it names the reason.
  • The added import { describe, expect, test } from "bun:test" at the top of the test file is a benign explicit import of what was previously global.
  • No CODEOWNERS cover these paths. No prior human or bot reviews to address.
  • The PR description documents that two sibling sites with the same shape (ProcessBindingHTTPParser.cpp, BunString.cpp) were identified and are being handled separately, which satisfies the "if a site is intentionally excluded, say so" rule.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review above has no action items, so nothing further is changing in this PR. Format and source lint checks are green; the Buildkite run (build 92016) is still in progress.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

FYI #37448 fixes the two other sites mentioned at the end of this description (BunString.cpp and ProcessBindingHTTPParser.cpp) and adds a source lint for the stored-result shape. For the lint to pass it carries this PR's ProcessBindingUV.cpp hunk unchanged, so the two merge cleanly in either order; the runtime test stays here.

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