Skip to content

ipc: run a send() callback in the async context of its send() call - #46370

Open
robobun wants to merge 1 commit into
mainfrom
robobun/745b18c1/ipc-send-callback-async-context
Open

robobun wants to merge 1 commit into
mainfrom
robobun/745b18c1/ipc-send-callback-async-context

Conversation

@robobun

@robobun robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A message can wait for the channel. Its send() callback then runs with no AsyncLocalStorage store, or in the store of a later send(). Node 26.3 uses the store of the call. Regression from more child-process #18688 (1.2.13).
  • CallbackList::call_next_tick (src/runtime/ipc.rs:757) queued the callback with process.nextTick at write completion. process.nextTick captures the context of that moment.
  • A callback of a Bun.ModuleGraph (a second program in the process) ran as the host.

Fix

  • SendQueue::serialize_and_send keeps the callback of a message still queued after send(), with the async context of the call.
  • Bun__Process__queueNextTick1InStoredContext queues its tick in that context, or in none.
  • A message taken inside send() queues its tick there, as before.
  • Verified: two fixtures under Node and Bun (24 rows fail on main), spawn.ipc.test.ts, child_process_send_cb.test.js, module-graph-callbacks.test.ts. Self-reviewed: 30 concerns raised, 27 addressed.

Background

  • SendQueue holds the messages the channel did not take yet, with their callbacks.
  • Node holds a send made while a handle awaits the peer's ACK, and sends it from its ACK handler. That callback gets no store of its own. Bun keeps this.
  • Considered capturing in every send(): every send pays. Considered process.nextTick accepting the wrapper: a send with no store gets a later store.

Downsides

  • A waiting send costs one capture call, and a 32 B cell under a store. Other sends cost nothing new. .text: +1,536 bytes.
  • A held send gets no store where Node gives the store of fork().
  • After dispose(), a waiting callback is dropped and one already queued runs. On Windows every send waits.
Notes

Origin. Found by comparison with Node v26.3.0. There is no issue and no user report, so this is normal priority. #46141 fixes the same class for node:net writes and lists the IPC callback as not covered.

Who is affected. Code that passes a callback to child.send(), cluster worker.send(), process.send() or Bun.spawn().send() and reads AsyncLocalStorage in it. The message must wait for the channel:

  • Windows: every send with a callback (a write there always completes from the event loop). Read from the code, not run.
  • Linux: one message of about 220 KB, about 93 sends of 1 KiB in one turn, or any send with a handle.
  • macOS: not measured.

An awaited or promisified send() was never affected, and neither was the error argument.

Rows against Node v26.3.0 (Linux x64). big is 1 MiB. queued is a small message sent right after it.

Callback of Node main this branch
a small send its store its store its store
a 1 MiB send its store no store its store
a send queued behind it, in another store its store no store its store
a send queued behind it, with no store no store no store no store
a send with no store that a later send() in store B completes no store B no store
a send with a handle its store no store its store
a send held behind a handle, fork() outside any store no store no store no store
a send held behind a handle, fork() inside a store the store of fork() no store no store
'uncaughtException' for a callback that throws the callback's store no store the callback's store

The rows are the same for child.send, process.send and Bun.spawn().send, and for json and advanced serialization.

What changes, by file.

  • ipc.rs: serialize_and_send reads the queue after the serializer ran (a getter can send). With an empty queue and no handle it queues the message with no callback and writes it. If the message is still queued, it keeps the callback with the context of the call (CallbackContext::OfSend). If not, it queues the tick in the call, as main does. A message behind other messages, or with a handle, keeps its callback at once. CallbackContext::BehindHandle is the rule for a send held behind a handle: the Bun.ModuleGraph of the sender, and no store.
  • BunProcess.cpp, JSValue.rs: Bun__Process__queueNextTick1InStoredContext makes the kept context current for the process.nextTick call and puts the previous one back. A bare function means no context, so a later send() that completes the message does not give it its store. This entry has one caller, the IPC send queue. It is not a general rule for kept callbacks.
  • child_process.ts, ipc_host.rs: ChildProcess#send gives the native layer the caller's callback. It wrapped every send in a function before, also a send with no callback. A send with no callback now queues no tick (Node: none) and keeps nothing. A send that fails is reported to a fifth argument that only node:child_process passes.

Measurements (linux-x64, release builds with ThinLTO of the merge base and of this branch).

  • Send that completes inside send(), with a callback, with or without a store, per send: 0 capture calls, 0 AsyncContextFrame cells and 1 tick on both builds. Protect and unprotect calls: 1 + 1 on main, 0 + 0 here (gdb breakpoint hit counts, slope over 1,000 to 2,000 sends).
  • child.send(msg) with no callback, per send: 1 tick and 1 + 1 protect calls on main, 0 and 0 + 0 here.
  • Instructions per send on the sender's JS thread (perf_event_open, JIT off, minimum of 7 runs, slope over 2,000 to 6,000 sends): child.send(msg, cb) 11,772 to 10,809, under a store 12,153 to 11,118. child.send(msg) 11,399 to 5,436. Bun.spawn().send(msg, cb) 19,818 to 19,311, under a store 19,708 to 19,521.
  • 1,000 sends of 1 KiB, each in its own store, to a child that is not reading: 93 complete inside send() and 907 wait, on both builds. Here the 907 make 907 capture calls and 907 cells (0 cells with no store). On main 907 callbacks run in no store.
  • The same 1,000 sends with no callback: 0 cells and 0 ticks here.
  • send() calls of libc (an LD_PRELOAD counter): in that loop 93 accepted and 907 refused on both builds. Ten runs end at 1,003 to 1,007 calls on both.
  • Sizes: SendHandle 72 B and CallbackList 16 B, as on main. .text 81,932,069 B to 81,933,605 B. Bun__Process__queueNextTick1InStoredContext is 692 B. No host function and no codegen entry is added.
  • A send behind other messages scans the queue for a handle. Messages with no handle merge into one item. The queue held at most 1 item in the 1,000-send burst and at most 3 in the handle fixture.

Bun.ModuleGraph. On main a waiting callback ran as the host: Bun.ModuleGraph.current was undefined in it, a throw skipped the graph's onError, and it ran after dispose(). Here it runs in the graph that called send(), also on a channel that another graph or the host owns. After dispose() it is dropped, through shouldDropCallbackOfStoppedModuleGraph, as other kept callbacks are. A tick that was queued inside send() before dispose() still runs, which is the rule for ticks a graph queued. So which callbacks a disposed graph still gets depends on what the channel took. This needs a yes from the owner of Bun.ModuleGraph.

Still different from Node on this channel, and not changed.

  • A send held behind a handle, with fork() called inside a store: Node runs the callback in the store of fork(). Bun runs it in no store, as on main. The stores of the channel would have to go under the Bun.ModuleGraph frame of the sender, and no helper does that today.
  • send(msg, handle, cb) calls back when the peer acknowledged the handle. Node calls back when the message is written. child_process_send_cb.test.js now pins what Bun does. On Windows the sender's handle must stay open until the peer imported it, so this needs a change of its own.
  • A send made inside the callback of a handle send is not held, because of the row above. It gets the store of that callback. Node holds it.
  • A send still queued when the channel closes is not called back (ipc: run the callbacks of sends still queued when the channel closes #43000).
  • Node runs each waiting callback in a callback scope of its own. Bun queues them as ticks.

Not part of this change. Other callbacks that complete later: node:net, node:tls and node:http writes (#46141), child.stdin writes, node:http2 stream writes and session.settings(cb). The callbacks of cluster's internal messages are not user callbacks, and Node also runs them from its message listener. The tick that closes a sent net.Socket is queued as before.

Open PRs that touch the same lines. #43081 adds has_unacked_handle() with the predicate that start_message has inline here. The one that lands second can call it. #43000 and #45662 edit SendQueue next to this change. #38662 types the callback of Subprocess#send, and the @ts-expect-error in spawn.ipc.test.ts goes when it lands.

Tests.

  • async-context-child_process-send.js and async-context-child_process-send-handle.js run under Node and Bun through AsyncLocalStorage-tracking.test.ts. On main they print 16 and 8 failed rows. The handle fixture does nothing on Windows, where a handle send can lose its ACK (cluster: fix two ways a Windows worker stops reacting (shared accept() race, early handle ack); fixes test-cluster-shared-leak timeouts #37815).
  • spawn.ipc.test.ts: both sides in both serialization modes, and a getter that sends while the outer send() serializes.
  • child_process_send_cb.test.js: ten sends that complete inside send() create no AsyncContextFrame and run before a tick queued after them. The order of callbacks in the existing snapshot is unchanged.
  • module-graph-callbacks.test.ts: a waiting callback runs in its graph, two graphs and the host on one channel, a held send, dispose(), and a throw that reaches onError.
  • Fourteen mutations of the change, each with one clause removed, were built. Each fails at least one of these tests.
  • Also ran, with no failure that the merge base does not show: 112 of Node's test-child-process-* and test-cluster-* tests and 9 sequential ones, test/js/node/child_process/, cluster.test.ts, the spawn.ipc* tests, test/js/node/async_hooks/, module-graph-io.test.ts, and rust:check-all for x86_64-pc-windows-msvc and aarch64-apple-darwin.
  • Not run: Windows and macOS.

Self-review, not changed. Three of the 30 concerns are listed above and not fixed here: the store of fork() for a held send, the time of the callback of a handle send, and the tick that a disposed graph queued before dispose().

The callback of child.send(), process.send() and Bun.spawn().send() was
queued with process.nextTick when the write completed. process.nextTick
captures the async context of that moment. A send that the channel did not
take inside the call lost its AsyncLocalStorage store, or got the store of a
later send() that completed it.

A message that is still queued when send() returns now keeps its callback
with the async context of the call, and the tick of a kept callback is
queued in that context. A message that the channel takes inside send()
queues its tick there, as before.

node:child_process gives the native layer the callback of its caller, and
no function when there is none. A send with no callback queues no tick.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How to reproduce. Save the script as ipc-send-cb.cjs. node ipc-send-cb.cjs exits 0. bun ipc-send-cb.cjs exits 1 on Bun 1.4.3 and on main (9634520).

const { fork } = require("node:child_process");
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();
const show = () => als.getStore() ?? "(no store)";
if (process.argv[2] === "child") {
  process.on("message", m => {
    if (m === "go") als.run("child send", () => process.send({ big: "x".repeat(1 << 20) }, () => process.send({ childCallbackStore: show() }, () => process.disconnect())));
  });
} else {
  const out = {};
  const child = als.run("fork", () => fork(__filename, ["child"]));
  child.on("message", m => { if (m.childCallbackStore) out["process.send(msg, cb) in the child"] = m.childCallbackStore; });
  child.on("exit", () => { console.log(JSON.stringify(out)); process.exit(Object.values(out).every(v => v.endsWith("send")) ? 0 : 1); });
  als.run("parent send", () => child.send({ big: "x".repeat(1 << 20) }, () => { out["child.send(msg, cb) in the parent"] = show(); child.send("go"); }));
}
  • Node v26.3.0: {"child.send(msg, cb) in the parent":"parent send","process.send(msg, cb) in the child":"child send"}
  • main: both values are "(no store)"
  • this branch: the same output as Node

The two fixtures that this PR adds to test/js/node/async_hooks/async-context/ print 16 and 8 failed rows on main and none under Node or this branch.

@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 4df31587-fe44-4783-8106-d7857a9c0cbd
📥 Commits

Reviewing files that changed from the base of the PR and between 9634520 and 6ddeff3.

📒 Files selected for processing: 12
  • src/js/node/child_process.ts
  • src/jsc/JSValue.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/runtime/ipc.rs
  • src/runtime/ipc_host.rs
  • test/js/bun/module-graph/module-graph-callbacks.test.ts
  • test/js/bun/spawn/spawn.ipc.test.ts
  • test/js/node/async_hooks/async-context/async-context-child_process-send-handle.js
  • test/js/node/async_hooks/async-context/async-context-child_process-send.js
  • test/js/node/child_process/child_process_send_cb.test.js
  • test/js/node/child_process/fixtures/child-process-send-cb-frames-fixture.js
  • test/js/node/child_process/fixtures/child-process-send-cb-handle-fixture.js
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit 6ddeff3 has some failures in Build #130236 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46370

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

bun-46370 --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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nit, I also checked the callback re-attach after continue_send in serialize_and_send (src/runtime/ipc.rs): on POSIX the synchronous write completion only queues nextTicks and close_socket leaves the queue in place, so the callback lands on its own item or is ticked from the call, not on another message's item. I also checked the async-context swap in Bun__Process__queueNextTick1InStoredContext (src/jsc/bindings/BunProcess.cpp): the previous context is restored unconditionally after queueNextTick, with the only early return happening before the swap.

Extended reasoning...

The change touches the IPC send queue in src/runtime/ipc.rs and ipc_host.rs, a new C++ nextTick entry in BunProcess.cpp that swaps the global async-context internal field, and ChildProcess#send in src/js/node/child_process.ts, with new tests for callback async-context across process.send, child.send and Bun.spawn().send. It touches no auth, crypto or injection surface. The one posted finding is a test-fixture cleanup nit; the runtime change is subtle enough (async-context swapping, callback ownership across a synchronous write, ModuleGraph drop semantics) that a human look is still needed.


if (process.argv[3] === "child") {
const pause = new Int32Array(new SharedArrayBuffer(4));
while (!fs.existsSync(process.argv[2])) Atomics.wait(pause, 0, 0, 1);

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.

🟡 nit (optional): when this test fails or times out, the forked grandchild keeps polling for the gate file forever and is left behind on the CI runner. The child spins in while (!fs.existsSync(process.argv[2])) Atomics.wait(pause, 0, 0, 1) with no deadline, and the gate lives in a tempDir that is removed when the test ends, so it can never appear once the parent is gone. Fix: give every gate poll loop a bounded deadline that throws, like waitFor in async-context-child_process-send.js:88-93, which covers the 2 sites. Same pattern at 2 sites (child-process-send-cb-handle-fixture.js:9, module-graph-callbacks.test.ts:315).

Why this was flagged

The handle test in test/js/node/child_process/child_process_send_cb.test.js:76-99 spawns the fixture, which fork()s itself as a child that blocks synchronously in the poll loop at test/js/node/child_process/fixtures/child-process-send-cb-handle-fixture.js:9 until the gate path exists. The gate is only written by the parent fixture inside setImmediate (line 24-26). If the parent fixture fails or the test times out, await using proc kills only the parent, using dir = tempDir(...) removes the directory, and the grandchild never processes the IPC disconnect because it never returns to the event loop; it polls at 1 ms intervals indefinitely. The same unbounded loop is in the held-child.mjs source at test/js/bun/module-graph/module-graph-callbacks.test.ts:315, though there the child is spawned directly and disposed by the test. The sibling fixture test/js/node/async_hooks/async-context/async-context-child_process-send.js:88-93 already bounds the same wait with a 30 s deadline. On the base branch none of these fixtures exist.

Verification: nit. Trigger: the parent fixture dies before its setImmediate runs. In test/js/node/child_process/fixtures/child-process-send-cb-handle-fixture.js line 9 while (!fs.existsSync(process.argv[2])) Atomics.wait(pause, 0, 0, 1); has no deadline. The gate lives in using dir = tempDir(...) and await using proc only kills the parent fixture pid, so the grandchild polls existsSync forever.

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.

1 participant