Skip to content

ipc: reject a function passed to send() instead of writing an empty frame - #43005

Open
robobun wants to merge 1 commit into
mainfrom
robobun/98183dca/ipc-reject-unserializable-send
Open

robobun wants to merge 1 commit into
mainfrom
robobun/98183dca/ipc-reject-unserializable-send

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • send(fn) is accepted. In JSON mode the sender writes an empty line and reports success. A Bun receiver closes the channel on that line and loses later messages. A node receiver exits with SyntaxError: Unexpected end of JSON input. node v26.3.0 throws ERR_INVALID_ARG_TYPE.
  • The cause is the type check in do_send (src/runtime/ipc_host.rs:139). It tests typeof message === "object" with JSValue::is_object(), which is true for a function. JSON.stringify(fn) is undefined, which becomes the empty line.

Fix

  • The check now rejects a callable (function, class, callable Proxy). It throws ERR_INVALID_ARG_TYPE before anything is serialized, as in node (child_process.js#L780-L786).
  • subprocess.send(), process.send() and Bun.spawn's send() share do_send, in both serialization modes.
  • Verified: four new tests in test/js/node/child_process/child_process_ipc.test.js, all failing on main. Notes has the wire bytes and other suites.

Background

  • do_send is the native body of every IPC send(): it validates, serializes and queues the message.
  • JSON mode sends one message per line. A line the receiver cannot parse is fatal for the channel.
  • Considered an error in json::serialize when JSON.stringify returns undefined. That also rejects { toJSON: () => undefined }. node accepts it.

Downsides

  • Each send() of an object makes one more isCallable call, with no allocation or system call.
  • With advanced serialization a function threw DataCloneError. It now throws TypeError with code ERR_INVALID_ARG_TYPE.
  • { toJSON: () => undefined } still writes an empty line. node writes undefined there, and the receiver fails the same way.
Notes

This replaces #33383, which the stale-PR sweep closed unmerged after ipc.rs moved to src/runtime/. Since then main started to rethrow a serializer exception (a cyclic message no longer reports success to the callback), so this PR is only the type check.

Where the channel breaks. The sender does not close anything. It writes the empty line and continues, and process.connected stays true. The loss is on the receiving side: Bun's JSON decoder returns InvalidFormat for an empty line and closes the socket, and node's JSON.parse("") throws. #41791 is about how the Bun receiver reports such a line. This PR stops the sender from producing it for a function.

Bytes on the wire. A python peer holds the other end of the child's channel (NODE_CHANNEL_FD, JSON mode) and records every byte. The child sends {n:1}, a function, {n:2}, { toJSON() {} }, {n:3}.

sender send(fn) wire
bun main (1.4.3-canary, 367d939) returns true, callback null {"n":1}\n \n {"n":2}\n \n {"n":3}\n
this branch throws ERR_INVALID_ARG_TYPE, no callback {"n":1}\n {"n":2}\n \n {"n":3}\n
node v26.3.0 throws ERR_INVALID_ARG_TYPE, no callback {"n":1}\n {"n":2}\n undefined\n {"n":3}\n

The line that remains is the toJSON object. node puts undefined there and Bun puts nothing. Neither is JSON.

Why the sender did not notice. JSON.stringify returns undefined as a null WTF::String. Bun::toStringRef maps that to the Empty tag, and json::serialize (src/runtime/ipc.rs:561) only tests for Tag::Dead. That test cannot be true for this input. I did not change it here: an error there is the alternative in Background, and it changes the toJSON case.

Repro

const cp = require("child_process");
if (process.argv[2] === "child") { process.on("message", m => process.send({ echo: m })); return; }
const c = cp.fork(__filename, ["child"]);
let delivered = false; c.on("message", () => (delivered = true));
let threw = null; try { c.send(() => {}); } catch (e) { threw = e.code; }
c.send({ ping: 1 });
setTimeout(() => { console.log(`send(fn) threw=${threw} | follow-up ping delivered=${delivered}`); c.kill(); process.exit(delivered ? 0 : 1); }, 700);
output
node v26.3.0 send(fn) threw=ERR_INVALID_ARG_TYPE | follow-up ping delivered=true
bun 1.4.3-canary (c6b7fcb) send(fn) threw=null | follow-up ping delivered=false
this branch send(fn) threw=ERR_INVALID_ARG_TYPE | follow-up ping delivered=true

The full matrix, parent side, after this change (throw / callback / what happens next). Every row now matches node:

message json advanced
function throw ERR_INVALID_ARG_TYPE, no callback, channel works same
symbol, bigint throw ERR_INVALID_ARG_TYPE, no callback, channel works same
cyclic object throw TypeError, no callback, channel works sent
toJSON() that throws rethrown, no callback, channel works throw (not cloneable)
{ toJSON: () => undefined } no throw, callback null, the receiver closes the channel throw (not cloneable)

The last JSON row is node's behavior too: a node child exits with SyntaxError: "undefined" is not valid JSON. A throw there would validate more strictly than node does.

Message text. For a plain function and a class the message is the same as node's (Received function named, Received function A). For a callable Proxy and a bound function the shared determineSpecificType helper prints a different name than node (Received function and Received function b, node prints Received function target and Received function bound b). That helper serves every ERR_INVALID_ARG_TYPE, so the test pins only the code for the Proxy.

Suites run on the debug build: child_process_ipc.test.js, child_process_ipc_handle.test.ts, child_process_send_cb.test.js, spawn.ipc.test.ts, spawn.ipc.bun-node.test.ts, spawn.ipc.node-bun.test.ts, cluster.test.ts, and the ported test-child-process-send-*.js files, which include node's own test-child-process-send-type-error.js.

`subprocess.send(fn)` and `process.send(fn)` were accepted. With JSON serialization
`send()` returned true and the callback got null, then the channel closed and the
messages behind it were lost.

node rejects a message whose `typeof` is not string, object, number or boolean. `do_send`
tested "object" with `JSValue::is_object()`, which is true for every JSObject, functions
included. `JSON.stringify` maps a function to undefined, so an empty frame went on the
wire, and the peer closes the channel on a frame it cannot parse.

Exclude callable values in that check. A function now throws ERR_INVALID_ARG_TYPE before
anything is serialized, in both serialization modes, as in node.
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.3-canary (c6b7fcb) with the script in the PR notes: send(fn) threw=null | follow-up ping delivered=false, exit 1. node v26.3.0 prints threw=ERR_INVALID_ARG_TYPE | follow-up ping delivered=true and exits 0.
  • With this branch the same script prints node's line and exits 0.
  • The four new tests fail on main and pass on this branch.
  • This replaces ipc: reject non-serializable send() messages instead of closing the channel #33383, which the stale-PR sweep closed unmerged.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ca250f91-9a84-42b0-989b-d534cb920cc8


📥 Commits

Reviewing files that changed from the base of the PR and between b64b630 and 4e70a1d.


📒 Files selected for processing (2)
  • src/runtime/ipc_host.rs
  • test/js/node/child_process/child_process_ipc.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.



Walkthrough

IPC message validation now rejects callable objects. Node-compatible tests cover parent and child sends, callable proxies, error handling, channel continuity, and subsequent messages.

Changes

IPC callable validation

Layer / File(s) Summary
Reject callable IPC messages
src/runtime/ipc_host.rs
do_send no longer accepts callable objects as IPC messages.
Validate parent and child IPC behavior
test/js/node/child_process/child_process_ipc.test.js
Tests verify error messages, callback behavior, callable proxies, channel continuity, and successful subsequent messages.

Suggested reviewers: dylan-conway

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 4e70a

The IPC validation change has no identified merge-blocking issue.

Pre-merge checks | Passed 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 describes the main change: rejecting functions passed to IPC send() instead of writing an empty frame.
Description check Passed The description clearly explains the problem, fix, scope, behavior changes, and verification. It does not use the template headings exactly, but it provides the required information in equivalent sect…



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

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

Code review found no issues

No high-confidence issues detected in this change.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:27 PM PT - Sep 16th, 2026

✅ @robobun, your commit 4e70a1d45f0f046177214985701a3c8460cc9f54 passed in Build #116863! 🎉


🧪   To try this PR locally:

bunx bun-pr 43005

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

bun-43005 --bun

@robobun robobun changed the title ipc: reject a function passed to send() instead of closing the channel ipc: reject a function passed to send() instead of writing an empty frame Oct 10, 2026

This branch has not been deployed

No deployments
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