Skip to content

ipc: reject non-serializable send() messages instead of closing the channel - #33383

Closed
robobun wants to merge 5 commits into
mainfrom
farm/98183dca/ipc-send-unserializable
Closed

robobun wants to merge 5 commits into
mainfrom
farm/98183dca/ipc-send-unserializable

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

// repro.mjs — exits 0 on node, 1 on Bun
import cp from "node:child_process";
import { fileURLToPath } from "node:url";
const selfPath = fileURLToPath(import.meta.url);

if (process.argv[2] === "child") {
  process.on("message", m => process.send({ echo: m }));
} else {
  const c = cp.fork(selfPath, ["child"]);
  let delivered = false;
  c.on("message", () => (delivered = true));

  try {
    c.send(() => {});            // node: throws ERR_INVALID_ARG_TYPE. bun: returns true
  } catch {}
  c.send({ ping: 1 });           // node: echoed. bun: never delivered

  setTimeout(() => { c.kill(); process.exit(delivered ? 0 : 1); }, 700);
}

subprocess.send() of a value JSON.stringify maps to undefined (a function, a
symbol, a toJSON() returning undefined) was accepted: it returned true and
called the send callback with a null error. The IPC channel was then torn down
asynchronously — 'error' (ERR_IPC_CHANNEL_CLOSED) and 'disconnect' fire, a
well-formed message sent immediately after (while connected === true) is never
delivered, and later sends fail. Node throws ERR_INVALID_ARG_TYPE synchronously and
the channel keeps working.

Same for process.send() in the child, in both json and advanced serialization.

Cause

Node's guard lives in target._send:

if (typeof message !== 'string' && typeof message !== 'object' &&
    typeof message !== 'number' && typeof message !== 'boolean') {
  throw new ERR_INVALID_ARG_TYPE('message', ['string', 'object', 'number', 'boolean'], message);
}

do_send mirrors it, but spells typeof message === "object" as
JSValue::is_object(). That is JSC's is a JSObject predicate, which is also
true for functions, so callables slip past the guard that exists to stop them.

json::serialize then calls JSON.stringify, which returns undefined for a
function. That reaches Rust as a null WTF::String, and Bun::toStringRef maps a
null string to the Empty tag — not Dead, which is the only tag the existing
guard checked. So the value was serialized to a zero-length JSON frame ("\n")
and written to the channel.

The peer's decoder rejects an empty payload as InvalidFormat and closes the
socket. With a node child on the other end, it dies outright:

SyntaxError: Unexpected end of JSON input
    at parseChannelMessages (node:internal/child_process/serialization:157:15)

Separately, when serialization throws (cyclic structure, throwing toJSON,
DataCloneError in advanced mode), serialize_and_send flattened the error to a
generic Failure, and do_send built a fresh TypeError: process.send() failed
on top of the still-pending exception. The observable result was send() throwing
the real error and the send callback firing with null, i.e. reporting success
for a message that was never sent.

Fix

  • do_send: exclude callables from the message type guard, restoring
    typeof-equivalence with node. Functions, symbols and bigints now all throw
    ERR_INVALID_ARG_TYPE synchronously, in both serialization modes and from both
    subprocess.send() and process.send().

  • json::serialize: treat an empty JSON.stringify result as a serialization
    failure. JSON.stringify never returns "" for a value it can represent, so an
    empty result always means the message is not serializable — writing it anyway is
    what poisons the channel.

  • serialize_and_send returns the IPCSerializationError instead of flattening it
    to Failure, so do_send can report each cause honestly:

    • a pending JS exception propagates unchanged, and the callback is not invoked
    • an OOM propagates as an OutOfMemoryError rather than a fake write error
    • a value the type guard accepts but JSON cannot represent (toJSON() returning
      undefined) throws ERR_INVALID_ARG_VALUE: The "message" argument could not be
      serialized to JSON

    With every variant now handled, the old TypeError: process.send() failed with
    syscall: "write" is dead and removed. It was never a transport error in the first
    place: a real write failure closes the socket inside continue_send and never
    returns through serialize_and_send.

Serialization already ran before anything was enqueued, so a rejected message leaves
the queue, the callback and the channel untouched.

Two adjacent fixes in the same error arm: node_cluster_binding's send helpers
register a StrongOptional callback keyed by seq before serializing, and nothing
removed it when the send failed (no ack arrives for a message that was never
enqueued), so they now release it; and the backoff flag is read after serialization,
which can run toJSON and re-enter.

Verification

test/js/node/child_process/child_process_ipc.test.js gains four tests covering
subprocess.send(), process.send() in the child, toJSON() returning undefined,
and the false-success callback on a cyclic message. Each asserts the exact error, and
that the channel still round-trips a message afterwards.

All four fail on main and pass with the fix:

USE_SYSTEM_BUN=1 bun test (before)
(fail) subprocess.send() of a function throws ERR_INVALID_ARG_TYPE
(fail) subprocess.send() of a value whose toJSON() returns undefined keeps the channel open
  error: IPC channel closed before a message arrived
(fail) subprocess.send() of a cyclic message rethrows and never reports success
  - []
  + [ null ]
(fail) process.send() of a function throws ERR_INVALID_ARG_TYPE in the child
  error: IPC channel closed before a message arrived
 1 pass
 4 fail
$ bun bd test test/js/node/child_process/child_process_ipc.test.js
 5 pass
 0 fail

The repro above now exits 0, matching node. test/js/node/child_process/,
test/js/bun/spawn/ IPC tests, test/js/node/cluster.test.ts and node's ported
test-child-process-send-*.js (including test-child-process-send-type-error.js)
are unchanged.

…hannel

`subprocess.send(() => {})` was accepted: it returned true, invoked the send
callback with a null error, and then tore the IPC channel down asynchronously.
Messages queued behind it were never delivered and later sends failed with
ERR_IPC_CHANNEL_CLOSED.

Node's guard in `target._send` rejects anything whose `typeof` is not
string/object/number/boolean. Bun spelled that guard with `JSValue::is_object()`,
which is JSC's "is a JSObject" and is also true for functions, so callables slipped
through. `JSON.stringify` then mapped the function to `undefined`, which reaches
Rust as a null WTF::String (the `Empty` tag, not `Dead`), and the existing `Dead`
check missed it. The result was a zero-length JSON frame on the wire, which the
peer rejects as malformed and answers by closing the socket. A node child in the
same position dies with `SyntaxError: Unexpected end of JSON input`.

- exclude callables from the message type guard, so `typeof`-equivalence holds
- treat an empty `JSON.stringify` result as a serialization failure: it can never
  be a value we can send, and writing it poisons the channel
- return the serialization error from `serialize_and_send` rather than flattening
  it to a generic transport failure, so `do_send` can throw ERR_INVALID_ARG_TYPE
  (node semantics) and propagate a pending JS exception instead of replacing it
  with `TypeError: process.send() failed` and then reporting success to the
  send callback
@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:47 PM PT - Jul 5th, 2026

❌ @robobun, your commit 6878f0d has 1 failures in Build #68621 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33383

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

bun-33383 --bun

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 1 minute

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: c58ea4b3-560a-44e6-875b-33a458aa4dc5

📥 Commits

Reviewing files that changed from the base of the PR and between 8c11a86 and 6878f0d.

📒 Files selected for processing (2)
  • src/jsc/ipc.rs
  • src/runtime/node/node_cluster_binding.rs

Walkthrough

This PR changes IPC serialization error handling in the JSC/runtime layer from an enum-based Failure variant to a propagated Result<SerializeAndSendResult, IPCSerializationError>, updates callers in ipc_host.rs and node_cluster_binding.rs to match on this result, adds message-sendability validation, and expands IPC test coverage for invalid payload scenarios.

Changes

IPC Send Error Handling Refactor

Layer / File(s) Summary
SerializeAndSendResult and IPCSerializationError contract changes
src/jsc/ipc.rs
Failure is removed from SerializeAndSendResult; IPCSerializationError gains as_js_error() and From<JsError>.
Serialization paths propagate errors via ?
src/jsc/ipc.rs
Advanced-mode and JSON-mode serialization now propagate JsError with ?, and JSON mode uses out.is_empty() to detect serialization failure.
ipc_host.rs message validation and error handling
src/runtime/ipc_host.rs
Adds is_sendable_message, preserves original_message, and branches on serialize_and_send’s Result for return and error handling.
node_cluster_binding.rs send helpers error handling
src/runtime/node/node_cluster_binding.rs
Both send helpers switch to match on serialize_and_send, clearing callbacks on Err and keeping existing success/fallback behavior.
IPC serialization edge case tests
test/js/node/child_process/child_process_ipc.test.js
Replaces the prior IPC snapshot test with fork-based concurrent cases for invalid payloads, undefined serialization, cyclic messages, and channel reuse.

Sequence Diagram(s)

sequenceDiagram
  participant do_send
  participant SendQueue
  do_send->>SendQueue: serialize_and_send(message)
  SendQueue-->>do_send: Err(IPCSerializationError) or Ok(SerializeAndSendResult)
  do_send->>do_send: validate message / map error
Loading

Related PRs: None identified.

Suggested labels: rust, ipc, tests, needs-review

Suggested reviewers: None identified.

Poem:
A rabbit hopped through IPC's maze,
Where Failure once stood, now Result stays.
It matched each error, pending or plain,
And sent the message safely again.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main IPC send-path fix.
Description check ✅ Passed The description covers the problem, fix, and verification, though its headings differ from the template.
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: 4

🤖 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 `@src/jsc/ipc.rs`:
- Around line 1378-1395: The backoff flag in SendQueue#serializeAndSend is
computed too early and can become stale because serialize(...) may run user JS
via toJSON and re-enter IPC. Recompute indicate_backoff only after
serialize(...) returns, immediately before start_message(...) and the final
result selection, so the return value reflects the current waiting_for_ack and
queue state. Keep the rest of the SendQueue#serializeAndSend flow unchanged,
just move the backoff check to the post-serialization point.
- Around line 286-295: The IPCSerializationError::pending_exception method is
dropping OutOfMemory by returning no pending exception, which causes later send
paths to replace it with a generic failure. Update the IPCSerializationError
handling so OutOfMemory is preserved either by returning JsError::OutOfMemory
from pending_exception or by treating it as a fatal OOM before the generic
process.send()/sendInternal() fallback allocates a new error. Make sure the
change is applied alongside the other IPCSerializationError variants in this
match so downstream callers keep the original OOM signal.

In `@src/runtime/ipc_host.rs`:
- Around line 74-77: The doc comment above the `JSValue::is_object()` check in
`ipc_host` is too long and must be trimmed to three lines. Shorten the comment
while preserving the key point about Node’s `target._send` type guard and
excluding callables because `JSValue::is_object()` includes functions. Keep the
wording concise and verify the final comment stays within the repo’s 3-line
limit.

In `@src/runtime/node/node_cluster_binding.rs`:
- Around line 121-138: The send failure path in node_cluster_binding’s helper
that calls serialize_and_send leaves the newly registered StrongOptional
callback entry behind, so clean up the just-inserted seq from the pending
callback map before returning on Err. Update the error arm around
serialize_and_send to mirror the ACK cleanup logic used with swap_remove,
ensuring the callback ref is removed and the entry does not remain unsettled;
apply this in the helper that builds the ex object and schedules
__jsc_host_impl_.
🪄 Autofix (Beta)

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: 1381bff8-38e2-4ba9-b27e-3b101152dc4f

📥 Commits

Reviewing files that changed from the base of the PR and between fb50cce and d895206.

📒 Files selected for processing (4)
  • src/jsc/ipc.rs
  • src/runtime/ipc_host.rs
  • src/runtime/node/node_cluster_binding.rs
  • test/js/node/child_process/child_process_ipc.test.js

Comment thread src/jsc/ipc.rs
Comment thread src/jsc/ipc.rs
Comment thread src/runtime/ipc_host.rs Outdated
Comment thread src/runtime/node/node_cluster_binding.rs
Comment thread src/runtime/ipc_host.rs Outdated
- propagate OOM from serialization as JsError::OutOfMemory rather than flattening it
  into `TypeError: process.send() failed` with `syscall: "write"`. With every
  IPCSerializationError variant now fully handled, that generic transport error is
  dead on this path, so delete it.
- report a message the type guard accepts but JSON cannot represent (toJSON()
  returning undefined) as ERR_INVALID_ARG_VALUE "could not be serialized to JSON"
  instead of reusing the type-guard message, which claimed an object was not an object.
- read the send queue state for the backoff result after serialization, which can run
  toJSON and re-enter.
- release the cluster callback entry when serialize_and_send fails: nothing was
  enqueued, so no ack would ever settle it and the Strong leaked.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all five review findings in e99d188. Each one held up against the code, so all five are fixed rather than waved off.

OOM was being flattened into a fake write error. IPCSerializationError::OutOfMemory now maps to JsError::OutOfMemory, which the host-fn shim turns into a thrown OutOfMemoryError. That also matches node, which throws synchronously out of send() when JSONStringify fails on an oversized message.

This had a knock-on effect worth calling out: with all four IPCSerializationError variants now fully handled, the generic TypeError: process.send() failed with syscall: "write" was dead on this path, so it is deleted. It was never a transport error to begin with — a real write failure closes the socket inside continue_send and never returns through serialize_and_send, so that error was only ever reachable from serialization, i.e. always misattributing. The PR's thesis ("never treat a serialization failure as a transport failure") now holds structurally.

The error for { toJSON: () => undefined } contradicted itself. It reused the type-guard message and told the user their object was not an object. Split into two shapes:

input error
function / symbol / bigint ERR_INVALID_ARG_TYPE (node's exact wording)
object JSON cannot represent ERR_INVALID_ARG_VALUE: The "message" argument could not be serialized to JSON

Used the centralized ERR_INVALID_ARG_VALUE code rather than a bare TypeError, per the guideline against hand-assigning .code. Node has no error here at all, so there is nothing to match. The test now asserts the exact code and message instead of just the code.

The cluster callback entry leaked on send failure. Confirmed: the only swap_remove is on the ack path, so an Err from serialize_and_send left the StrongOptional rooted forever with no ack ever coming. Both helpers now capture the pre-increment seq and swap_remove(&seq) in the error arm. Pre-existing, but this PR adds a new early return through that arm.

Backoff was read before serialization, which can run toJSON and re-enter. Moved to just before start_message.

Comment length. Trimmed, plus a second 4-line comment I had added in json::serialize.

Re-verified: child_process_ipc.test.js 5 pass, cluster.test.ts and the spawn/ IPC suites green, node's ported test-child-process-send-*.js exit 0, and the repro still exits 0 matching node.

Comment thread src/runtime/node/node_cluster_binding.rs Outdated
`serialize_and_send` runs `JSON.stringify`, which can invoke a user `toJSON` that
re-enters `sendHelperChild` and takes a second `&mut` to the same static. Cleaning up
the callback entry through the outer `singleton` binding kept that borrow live across
the call, which `child_singleton`'s aliasing contract forbids. Re-borrow in the error
arm so the outer borrow ends before serialization, as it did before.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 9a281a9, which fixes a real aliasing bug the previous round introduced, and documents why the red lane on build 68602 is not this PR.

The aliasing bug (fixed)

The callback-entry cleanup I added in e99d188 cleaned up through the outer singleton binding, which kept the &mut InternalMsgHolder from child_singleton() live across serialize_and_send. That call runs JSON.stringify → user toJSON, which can re-enter sendHelperChild and take a second &mut to the same static. child_singleton's own doc comment forbids exactly that:

Callers must be on the JS thread and must not hold the borrow across a re-entrant child_singleton() call.

Fixed by re-borrowing in the error arm, so the outer borrow's last use is back to singleton.seq = ...wrapping_add(1) and NLL ends it before serialization, as it did pre-PR:

Err(err) => {
    // Nothing was enqueued, so no ack will ever settle the callback registered above.
    child_singleton().callbacks.swap_remove(&seq);

send_helper_primary does not need the same treatment: there ipc_data is a &mut SendQueue that is the receiver of serialize_and_send, so it is live across the call by construction, and using it afterwards widens nothing.

Build 68602

Three annotations, one red lane:

annotation verdict
Failed to create agent for 2019 x64 - test-bun Azure agent provisioning, infra
test/bake/dev-and-prod.test.ts on 2019 x64-baseline (1 retry) flaky, passed on retry
test-worker-message-port-transfer-terminate.js SIGABRT on 13 x64-asan see below
ASSERTION FAILED: !scope.exception() || !hasSlot
JSCJSValuePropertyInlines.h(51) : JSValue JSC::JSValue::get(JSGlobalObject *, PropertyName, PropertySlot &) const

An exception-check violation: a JSValue::get() runs while the worker's termination exception is pending. The test spawns 10 workers and calls terminate() mid-postMessage, so it is a termination race by construction, and it is already listed in test/no-validate-leaksan.txt.

This PR cannot cause it. The test requires only worker_threads; my diff is confined to child_process IPC and node:cluster. src/jsc/web_worker.rs has zero ipc:: references, and a plain bun test process has no IPC channel, so SendQueue / do_send / the cluster helpers are never even instantiated. The only thing the worker path shares with my diff is JSValue::serialize (structured clone), which I did not touch — advanced::serialize only changed how it maps that function's error.

Locally the test is green 73/73 on a main-built ASAN binary (25 serial + 48 under 6-way contention) and 30/30 on this branch's build, which is what you would expect: the lane that caught it runs an ASAN-instrumented JSC, and the extra slowdown is what widens the termination window.

I did not spend the retrigger on it — this push re-runs CI anyway.

Verification on 9a281a9

$ bun bd test child_process_ipc.test.js cluster.test.ts
 10 pass / 0 fail

$ for t in send-cb send-after-close send-utf8 send-type-error; do bun bd test/js/node/test/parallel/test-child-process-$t.js; done
 all exit 0
$ bun bd test/js/node/test/parallel/test-cluster-send-deadlock.js
 exit 0

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

Caution

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

⚠️ Outside diff range comments (2)
src/jsc/ipc.rs (1)

196-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update stale .failure references after removing the variant.

SerializeAndSendResult::Failure is gone, but the OOM comments still say serializeAndSend returns .failure. Update both comments to describe the new Err(IPCSerializationError::OutOfMemory) path. As per coding guidelines, comments should carry durable, accurate non-obvious content.

Proposed comment update
-        // Propagate OOM so serializeAndSend
-        // returns `.failure` instead of silently discarding the Result.
+        // Propagate OOM as `Err(IPCSerializationError::OutOfMemory)`.

Also applies to: 443-447, 612-616

🤖 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 `@src/jsc/ipc.rs` around lines 196 - 199, Update the stale OOM comments that
still mention SerializeAndSendResult::Failure, since that variant was removed.
In the comments around serializeAndSend and the related IPC send paths
(including the references near SerializeAndSendResult and the later OOM handling
sites), replace any “returns .failure” wording with the current
Err(IPCSerializationError::OutOfMemory) behavior. Keep the comments accurate and
durable by describing the error-based path instead of the removed enum variant.

Source: Coding guidelines

src/runtime/node/node_cluster_binding.rs (1)

226-239: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Drop the ipc_data borrow before serialize_and_send

serialize_and_send can re-enter via user toJSON(), so this keeps a mutable borrow alive across a call that may invoke sendHelper again on the same subprocess. Re-fetch the queue in the error path, as send_helper_child does, instead of reusing the pre-call borrow.

🤖 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 `@src/runtime/node/node_cluster_binding.rs` around lines 226 - 239, The mutable
ipc_data borrow is still held across serialize_and_send in the send path, which
can re-enter through user toJSON() and conflict with a nested sendHelper call on
the same subprocess. Update the logic in node_cluster_binding.rs around the
serialize_and_send call so the queue borrow is dropped before invoking it,
following the pattern used by send_helper_child, and in the error path re-fetch
ipc_data.internal_msg_queue before removing the callback with seq instead of
reusing the pre-call borrow.
🤖 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.

Outside diff comments:
In `@src/jsc/ipc.rs`:
- Around line 196-199: Update the stale OOM comments that still mention
SerializeAndSendResult::Failure, since that variant was removed. In the comments
around serializeAndSend and the related IPC send paths (including the references
near SerializeAndSendResult and the later OOM handling sites), replace any
“returns .failure” wording with the current
Err(IPCSerializationError::OutOfMemory) behavior. Keep the comments accurate and
durable by describing the error-based path instead of the removed enum variant.

In `@src/runtime/node/node_cluster_binding.rs`:
- Around line 226-239: The mutable ipc_data borrow is still held across
serialize_and_send in the send path, which can re-enter through user toJSON()
and conflict with a nested sendHelper call on the same subprocess. Update the
logic in node_cluster_binding.rs around the serialize_and_send call so the queue
borrow is dropped before invoking it, following the pattern used by
send_helper_child, and in the error path re-fetch ipc_data.internal_msg_queue
before removing the callback with seq instead of reusing the pre-call borrow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d1ba771-a6c5-4df8-8a9c-9e766cbb6062

📥 Commits

Reviewing files that changed from the base of the PR and between d895206 and 8c11a86.

📒 Files selected for processing (4)
  • src/jsc/ipc.rs
  • src/runtime/ipc_host.rs
  • src/runtime/node/node_cluster_binding.rs
  • test/js/node/child_process/child_process_ipc.test.js

…nd_send

`send_helper_primary` had the same problem just fixed in `send_helper_child`: cleaning
up the callback entry through the pre-call `ipc_data` binding held the
`&mut SendQueue` across `serialize_and_send`, which runs `toJSON` and can re-enter
`sendHelper` on the same subprocess. `Subprocess::ipc`'s contract forbids exactly that
("callers do not hold the borrow across JS re-entry that touches `ipc_data` itself").
Re-borrow in the error arm so the outer borrow's last use is the send call again.

Also drop two comments still describing the removed `SerializeAndSendResult::Failure`.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed both findings in 6878f0d. The second one is a correction to something I got wrong last round.

send_helper_primary had the same borrow bug, and I said it didn't

When I fixed the child_singleton() borrow in 9a281a9, I told the other reviewer that send_helper_primary was unaffected because ipc_data is the receiver of serialize_and_send and is therefore live across the call "by construction". That reasoning was wrong in the way that matters.

Checking the pre-PR code settles it: ipc_data's last use was the serialize_and_send call itself. My callback cleanup added a use after it, which is exactly the extension I had just removed from the child helper. A nested subprocess.ipc() during user toJSON hands out a second &mut SendQueue to the same inline cell and pops the outer tag; the outer swap_remove then runs through it. Subprocess::ipc's own SAFETY comment spells out the invariant I broke:

callers do not hold the borrow across JS re-entry that touches ipc_data itself

Same fix as the child helper, so both now have the same shape:

Err(err) => {
    // Nothing was enqueued, so no ack will ever settle the callback registered above.
    // Re-borrow rather than holding `ipc_data` across `serialize_and_send`, which runs
    // `toJSON` and can re-enter (see `Subprocess::ipc`'s aliasing contract).
    if let Some(ipc_data) = subprocess.ipc() {
        ipc_data.internal_msg_queue.callbacks.swap_remove(&seq);
    }

ipc_data's last use is the send call again (line 52), and the cleanup re-borrows (line 59). The receiver's own &mut self during the call is inherent to the design and unchanged by this PR.

Stale comments

Dropped the two // Propagate OOM so serializeAndSend returns .failure`` comments, since that variant no longer exists.

Verification

$ bun bd test child_process_ipc.test.js cluster.test.ts
 10 pass / 0 fail
$ bun bd test/js/node/test/parallel/test-{child-process-send-cb,child-process-send-type-error,cluster-send-deadlock}.js
 all exit 0

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

Both prior findings are addressed and nothing new turned up this round; deferring to a human since this reworks the shared IPC send path (return-type change on serialize_and_send, removed Failure/process.send() failed fallback, re-entrant borrow handling in the cluster helpers) and that's more than I should rubber-stamp.

Extended reasoning...

Overview

Fixes subprocess.send() / process.send() accepting non-JSON-serializable values (functions, toJSON() → undefined) and then tearing down the IPC channel asynchronously. Changes span four files: src/jsc/ipc.rs (removes SerializeAndSendResult::Failure, adds IPCSerializationError::as_js_error() + From<JsError>, switches json::serialize to out.is_empty(), moves the backoff read after serialization, changes serialize_and_send to return Result<_, IPCSerializationError>), src/runtime/ipc_host.rs (adds is_sendable_message() with the !is_callable() fix, replaces the generic process.send() failed error with typed propagation and a distinct ERR_INVALID_ARG_VALUE for JSON-unrepresentable objects), src/runtime/node/node_cluster_binding.rs (both send helpers now match on Result, clean up the registered callback on Err, and re-borrow the singleton/subprocess IPC to avoid holding a &mut across re-entrant toJSON), and four new tests in child_process_ipc.test.js.

Security risks

None identified. This is input validation on the IPC send path — it tightens the guard (functions now rejected) and stops writing malformed frames to the peer. No auth/crypto/permissions code touched.

Level of scrutiny

Medium-high. The change is well-reasoned and thoroughly tested, but it's not mechanical: it changes a public function's return type, removes an enum variant and the associated process.send() failed fallback (on the argued basis that write errors never flow through this path), and touches the send queue used by child_process, Bun.spawn IPC, and node:cluster. The re-entrancy/aliasing handling in node_cluster_binding.rs needed two follow-up commits (9a281a9 for send_helper_child, 6878f0d for send_helper_primary) to keep the documented SAFETY contracts on child_singleton() / Subprocess::ipc() true — the final shape looks right, but that's exactly the kind of subtlety a maintainer should confirm.

Other factors

Both of my earlier inline findings (self-contradictory ERR_INVALID_ARG_TYPE message for { toJSON: () => undefined }; child_singleton() borrow extended across serialize_and_send) were addressed, and all CodeRabbit threads are resolved. Test coverage is solid: exact error code + message assertions for each case, plus a channel round-trip afterward proving the queue is untouched. The PR description documents USE_SYSTEM_BUN=1 failure and bun bd pass, and re-verified after each fix commit. No new findings from the bug-hunting system.

@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, CI is red on infra only. Needs a maintainer to merge.

Build 68621 on 6878f0d finished 283 of 287 jobs passed, zero test failures.

test-bun lanes 227 passed, 1 failed
x64-asan lanes 23 passed, 0 failed
binary size within 0.50 MB

The single failed job never ran a test:

:darwin: 26 aarch64 - test-bun
Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The two remaining annotations are also infra: Failed to create agent for 2019 x64 - test-bun (Azure provisioning), and grpc-js/test-pick-first.test.ts flagged flaky and green on retry.

Why none of this is the diff

Three builds, three different infra failures, and the one thing that looked real is now disproved:

build failure verdict
68602 test-worker-message-port-transfer-terminate.js SIGABRT on x64-asan flake — asan is 23/23 green here
68614 darwin aarch64 artifact download timeout ×2 infra, never ran a test
68621 darwin aarch64 artifact download timeout ×1 infra, never ran a test

The 68602 asan SIGABRT was an exception-check violation (!scope.exception() || !hasSlot) during worker termination. That test requires only worker_threads; this diff is confined to child_process IPC and node:cluster. web_worker.rs has zero ipc:: references and a plain test process has no IPC channel, so SendQueue / do_send / the cluster helpers are never instantiated. It is a pre-existing termination race — the test is already listed in test/no-validate-leaksan.txt — and the lane has been green on every build since.

I spent the one retrigger on 68614. Not pushing another; the darwin artifact download is failing on that lane regardless of what is in the branch.

What's in the PR

send(() => {}) was accepted (returned true, callback got null) and then tore the IPC channel down asynchronously. Root cause: do_send spelled node's typeof message === "object" guard as JSC's is_object(), which is true for functions, so JSON.stringify mapped the function to undefined → a null WTF::String → the Empty tag, which the existing Dead check missed → Bun wrote a zero-length JSON frame, and the peer closes the channel on a malformed frame. A node child on the other end dies with SyntaxError: Unexpected end of JSON input.

Fixed by excluding callables from the guard, failing serialization on an empty JSON.stringify result, and returning the IPCSerializationError from serialize_and_send so a serialization failure is never dressed up as a transport failure. That last one made TypeError: process.send() failed dead code (a real write error closes the socket inside continue_send and never returns through this path), so it is deleted.

Review caught three things beyond the original bug, all fixed and all worth a look since they touch shared state: OOM was being flattened into a fake syscall: "write" error; a cluster callback entry leaked on send failure; and my fix for that leak extended a &mut across serialize_and_send, which runs toJSON and can re-enter (once in each send helper — see 9a281a9 and 6878f0d, both restoring the documented aliasing contracts on child_singleton() and Subprocess::ipc()).

Verification

Four new tests in child_process_ipc.test.js, each asserting the exact error code and message and then round-tripping a message to prove the channel survived. All four fail on main, all pass with the fix. test/js/node/child_process/, test/js/bun/spawn/ IPC, test/js/node/cluster.test.ts, and node's ported test-child-process-send-*.js (including test-child-process-send-type-error.js) are unchanged.

Both review bots have signed off and every thread is resolved.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-05, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Reopened as #43005 against current main. ipc.rs moved to src/runtime/ after this PR was opened, and main has since started to rethrow a serializer exception, so the new PR is only the type check that lets a function through.

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.

1 participant