Skip to content

worker_threads: drain port-backed stdio on process.exit() - #34340

Closed
robobun wants to merge 10 commits into
mainfrom
farm/e08659a7/worker-stdio-exit-drain
Closed

robobun wants to merge 10 commits into
mainfrom
farm/e08659a7/worker-stdio-exit-drain

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

A worker that writes to stdout/stderr and then exits synchronously loses every chunk buffered after the first writev batch. Exit code is clean; the loss is silent. Applies to process.exit(), an uncaught exception, and an unhandled rejection alike.

Since #31216 every node:worker_threads worker's stdio is port-backed (auto-piped to the parent's stdout/stderr when {stdout: true} is not set), so this hits the default path too:

// worker-log-exit.mjs
import { Worker, isMainThread } from "node:worker_threads";
import { fileURLToPath } from "node:url";
if (isMainThread) {
  const w = new Worker(fileURLToPath(import.meta.url));
  w.on("exit", c => console.error("[worker exit " + c + "]"));
} else {
  for (let i = 0; i < 300; i++) console.log("W" + i);
  process.exit(0); // or: throw new Error("...")
}
// node v26.3.0 ->  W0 .. W299, [worker exit 0]  (300/300)
// bun (before) ->  W0, [worker exit 0]          (1/300)

Same with captured stdio:

const { Worker } = require("worker_threads");
const w = new Worker('console.log("A");console.log("B");console.log("C");process.exit(0);',
                     { eval: true, stdout: true });
let out = "";
w.stdout.setEncoding("utf8").on("data", d => out += d);
w.on("exit", c => console.log(JSON.stringify(out), c));
// node v26.3.0  ->  "A\nB\nC\n" 0
// bun (before)  ->  "A\n" 0

Affects console.log, console.error, and raw process.stdout.write / process.stderr.write alike.

Why

makePortWritable's writev posts the payload over the MessagePort and parks its completion callback awaiting a reader ack. Inside a synchronous exit there are no event-loop turns left to deliver that ack, so the Writable never re-enters writev and everything queued in its internal buffer after the first batch is dropped. The existing process._exiting escape hatch inside writev is unreachable: by the time _exiting is true the stream is already blocked on the parked callback.

Node handles this in internal/bootstrap/switches/is_not_main_thread: when the worker stdio is created it registers process.on('exit', flushSync), which calls kStdioWantsMoreDataCallback on stdout and stderr. That completes the parked callback while _exiting is already true, so the stream flushes its buffered chunks and each subsequent writev posts and completes synchronously. The exit event fires on every worker exit path (explicit process.exit(), uncaught exception, unhandled rejection) with _exiting === true, so one handler covers all three.

Fix

Expose the ack-completion as flushInFlightForExit on the port-backed Writable, and register a process.on('exit') handler in setupWorkerStdio that calls it on both streams.

Verification

(pass) captured stdio drains on synchronous process.exit() > console.stdout output is not lost
(pass) captured stdio drains on synchronous process.exit() > console.stderr output is not lost
(pass) captured stdio drains on synchronous process.exit() > raw process.stdout.write output is not lost
(pass) captured stdio drains on synchronous process.exit() > raw process.stderr.write output is not lost
(pass) captured stdio drains on synchronous process.exit() > many writes before exit all reach the parent
(pass) captured stdio drains on synchronous process.exit() > writes from a user process.on('exit') handler reach the parent
(pass) auto-piped stdio drains on synchronous process.exit() > worker console.log lines all reach the parent's stdout
(pass) auto-piped stdio drains on synchronous process.exit() > without process.exit() all lines already arrive
(pass) auto-piped stdio drains when a worker dies from an uncaught error > uncaught exception after N logs
(pass) auto-piped stdio drains when a worker dies from an uncaught error > unhandled rejection after N logs

The six captured-stdio tests, the auto-piped process.exit() test, and both uncaught-error tests fail without the src/ change (first batch only, count: 1) and pass with it (count: 300). The existing backpressure tests (stdout write completion is withheld until the parent reads, large stdout survives writev batching and repeated acks) still pass, so the ack-gated flow control is intact.


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

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/worker-stdio-exit-drain.test.ts
bun test v1.4.0 (3ff7865b7)

test/js/node/worker_threads/worker-stdio-exit-drain.test.ts:
22 |     const worker = new Worker(
23 |       `console.${method}("A"); console.${method}("B"); console.${method}("C"); process.exit(0);`,
24 |       { eval: true, [stream]: true },
25 |     );
26 |     const { out, code } = await collect(worker, stream);
27 |     expect({ out, code }).toEqual({ out: "A\nB\nC\n", code: 0 });
                               ^
error: expect(received).toEqual(expected)

  {
    "code": 0,
    "out": 
  "A
- B
- C
  "
  ,
  }

- Expected  - 2
+ Received  + 0

      at <anonymous> (/workspace/bun/test/js/node/worker_threads/worker-stdio-exit-drain.test.ts:27:27)
(fail) captured stdio drains on synchronous process.exit() > console.stdout output is not lost [1846.96ms]
22 |     const worker = new Worker(
23 |       `console.${method}("A"); console.${method}("B"); console.${method}("C"); process.exit(0);`,
24 |       { eval: true, [stream]: true },
25 |     );
26 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (accf6bb7b)

test/js/node/worker_threads/worker-stdio-exit-drain.test.ts:
(pass) captured stdio drains on synchronous process.exit() > console.stdout output is not lost [31.52ms]
(pass) captured stdio drains on synchronous process.exit() > console.stderr output is not lost [29.45ms]
(pass) captured stdio drains on synchronous process.exit() > raw process.stderr.write output is not lost [28.87ms]
(pass) captured stdio drains on synchronous process.exit() > raw process.stdout.write output is not lost [29.31ms]
(pass) captured stdio drains on synchronous process.exit() > writes from a user process.on('exit') handler reach the parent [29.34ms]
(pass) captured stdio drains on synchronous process.exit() > many writes before exit all reach the parent [30.57ms]
(pass) auto-piped stdio drains on synchronous process.exit() > worker console.log lines all reach the parent's stdout [50.93ms]
(pass) auto-piped stdio drains when a worker dies from an uncaught error > uncaught exception after N logs [49.47ms]
(pass) auto-piped stdio drains when a worker dies from an uncaught error > unhandled rejection after N logs [48.97ms]
(pass) auto-piped stdio drains o
... (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/worker_threads/worker-stdio-exit-drain.test.ts
bun test v1.4.0 (3ff7865b7)

test/js/node/worker_threads/worker-stdio-exit-drain.test.ts:
(pass) captured stdio drains on synchronous process.exit() > console.stdout output is not lost [1846.71ms]
(pass) captured stdio drains on synchronous process.exit() > console.stderr output is not lost [1755.88ms]
(pass) captured stdio drains on synchronous process.exit() > raw process.stdout.write output is not lost [1807.38ms]
(pass) captured stdio drains on synchronous process.exit() > raw process.stderr.write output is not lost [1805.66ms]
(pass) captured stdio drains on synchronous process.exit() > many writes before exit all reach the parent [1929.76ms]
(pass) captured stdio drains on synchronous process.exit() > writes from a user process.on('exit') handler reach the parent [1715.16ms]
(pass) auto-piped stdio drains on synchronous process.exit() > worker console.log lines all reach the parent's stdout [3174.60ms]
(pass) auto-piped stdio drains when a worker dies from an uncaught er
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 660ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (8791ms)
Bundle modules (85ms)
Postprocesss modules (225ms)
Bundle Functions (767ms)
Generate Code (31ms)

[9.92s] Bundled "src/js" for production
  2569 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[1/8] 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
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      |  21 ++-
 .../worker_threads/worker-stdio-exit-drain.test.ts | 167 +++++++++++++++++++++
 2 files changed, 185 insertions(+), 3 deletions(-)

gate history · 4 passed · 3 rejected · iteration 8

evidence per changed file
file                                                      reads  edits  tests
src/js/node/worker_threads.ts                                14     25      0
…/js/node/worker_threads/worker-stdio-exit-drain.test.ts      1      2      0

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Worker stdio exit draining

Layer / File(s) Summary
Flush in-flight stdio writes during exit
src/js/node/worker_threads.ts
Writable stdio streams expose their acknowledgement handler, and the process exit handler flushes pending stdout and stderr writes.
Validate worker stdio delivery on exit
test/js/node/worker_threads/worker-stdio-exit-drain.test.ts
Tests verify complete console, direct-write, queued, and exit-handler output with exit code 0.

Suggested reviewers: cirospaciari

🚥 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 matches the main change: draining port-backed worker stdio on synchronous exit.
Description check ✅ Passed The description covers what changed and how it was verified, though the headings differ from the template.

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

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:36 AM PT - Jul 30th, 2026

✅ @robobun, your commit 3ff7865b719af551a0bab1a9184659e18bd8c101 passed in Build #85729! 🎉


🧪   To try this PR locally:

bunx bun-pr 34340

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

bun-34340 --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 — small, well-scoped Node compat fix mirroring Node's flushSync in is_not_main_thread.

What was reviewed:

  • makePortWritable/setupWorkerStdio: exposing onAck as flushInFlightForExit and the process.on('exit') flush; onAck is a no-op when nothing is parked, and port.unref() during exit is harmless.
  • Handler ordering: flushSync is registered in preload before user code, so user process.on('exit') handlers run after it with _exiting already true — covered by the last test.
  • Parent-side worker.stdin also gains the property via makePortWritable but nothing calls it — harmless.
Extended reasoning...

Overview

Two-file change: src/js/node/worker_threads.ts (~20 net lines) exposes the port-Writable's ack-completion callback as stream.flushInFlightForExit and registers a process.on('exit') handler in setupWorkerStdio that calls it on stdout/stderr. test/js/node/worker_threads/worker_threads.test.ts adds a 6-test describe covering console.log/error, raw process.std{out,err}.write, 100 writes, and writes from a user process.on('exit') handler.

Security risks

None. This is a worker-internal stdio flush hook; no untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. The mechanism is a direct port of Node's documented approach (internal/bootstrap/switches/is_not_main_thread → flushSync → kStdioWantsMoreDataCallback), and the diff is small and mechanical: capture the stream in a local, attach a property, register one exit listener. The existing _exiting branch inside writev was already designed for this — it just wasn't reachable without the exit-time nudge.

Other factors

  • onAck guards on pendingWriteCallback !== null, so calling it with nothing in flight is a safe no-op; the port.unref() it performs is irrelevant during exit.
  • setupWorkerStdio runs in the node:worker_threads preload before user code, so flushSync is the first exit listener; user exit-handler writes then take writev's synchronous _exiting path (verified by the last new test).
  • The property also lands on parent-side worker.stdin (also built via makePortWritable), but no one calls it there — inert.
  • Existing backpressure tests still pass per the PR, so the ack-gated flow control is intact for the non-exit path.
  • No CODEOWNERS entry for this path; no prior human review comments; bug hunt found nothing.

@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 — focused Node compat fix mirroring Node's flushSync in is_not_main_thread.

What was reviewed:

  • flushInFlightForExit is just the existing onAck; when no callback is parked it's a no-op, and when one is it unblocks the Writable so buffered chunks re-enter writev's _exiting synchronous branch.
  • The exit handler is registered once during worker preload (before user code), so user process.on('exit') handlers run after and their writes hit the _exiting fast path — covered by the last test.
  • makePortWritable is also used for parent-side worker.stdin; the extra property there is harmless (never called).
  • Normal-path backpressure/ack flow is unchanged; destroy() still nulls pendingWriteCallback and drops the ack listener.
Extended reasoning...

Overview

The PR fixes silent stdout/stderr data loss when a worker calls process.exit() synchronously after writing. It touches src/js/node/worker_threads.ts (~20 net lines) and adds a new test file with 6 cases covering console.log/error, raw writes, many-writes, and writes from a user 'exit' handler.

The fix exposes the existing onAck closure as stream.flushInFlightForExit and registers a single process.on('exit', flushSync) in setupWorkerStdio that calls it on stdout/stderr. Completing the parked writev callback while process._exiting is true lets the Writable re-enter writev, whose existing _exiting branch then posts each buffered batch and completes synchronously. This is a direct port of Node's mechanism in internal/bootstrap/switches/is_not_main_thread (kStdioWantsMoreDataCallback), and the PR description and code comments cite the upstream reference.

Security risks

None. This is Node.js worker stdio plumbing over an in-process MessageChannel; no auth, crypto, filesystem, or untrusted-input parsing is involved. The added process.on('exit') listener runs only inside worker threads (guarded by the existing setupWorkerStdio call site under !isMainThread && _isNodeWorker).

Level of scrutiny

Low-to-medium. The source change is small, mechanical, and additive: no existing control flow is altered — the Writable's writev/final/destroy are byte-identical, and the normal ack-gated backpressure path is untouched (the PR notes the existing backpressure tests still pass). onAck already guards on pendingWriteCallback !== null, so calling it when nothing is in flight is a no-op. The exit handler is registered exactly once per worker during preload, before user code, which is what makes the "writes from a user exit handler" test work (user handler fires after, and by then _exiting is true so its writes flush synchronously).

Other factors

  • The property is also attached on parent-side worker.stdin (same makePortWritable), but nothing calls it there — harmless.
  • Tests await real conditions (once(worker, 'exit') + once(stream, 'end')) and assert combined {out, code} objects, matching repo conventions. The PR verified all 6 fail without the src change and pass with it.
  • No CODEOWNERS entry covers this path, no outstanding human review comments, and no prior review from me on this PR.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI build #85722 (rebased onto main at c08f665): worker-stdio-exit-drain.test.ts passes on all lanes and the gate check is green (fail-before / pass-after verified on ASAN and release).

Remaining reds are unrelated to this diff:

  • test/js/node/test/parallel/test-fs-read-stream-pos.js timed out on Windows 2019 x64 (no worker_threads usage; reported for main-break triage)
  • 9 tests marked [flaky] (passed on retry): if-present, bun-test, update_interactive_formatting, spawn-streaming-stdout, webview-chrome, double-connect, happy-dom-vm-16277, setInterval, 08965

Ready for review.

@robobun
robobun force-pushed the farm/e08659a7/worker-stdio-exit-drain branch from d44297a to c7df31f Compare July 22, 2026 04:14

@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 — small, well-scoped Node compat fix that mirrors Node's own flushSync mechanism.

What was reviewed

  • Traced onAck → parked-cb completion → Writable re-enters writev under _exiting and drains synchronously; verified it's a no-op when nothing is parked or the stream was already destroyed.
  • Checked exit-handler ordering: flushSync registers at preload (before user handlers), so user process.on('exit') writes hit the _exiting fast path — matches the test expectation.
  • Confirmed makePortWritable is also used for parent-side worker.stdin; the new property is harmless there (nothing calls it), matching Node which only flushes worker stdout/stderr.
  • Tests await real events, drain subprocess pipes concurrently, and cover both captured and auto-piped paths plus a no-exit baseline.
Extended reasoning...

Overview

Two-file change: src/js/node/worker_threads.ts exposes the port-writable's ack handler as flushInFlightForExit and registers a process.on('exit') handler in setupWorkerStdio that calls it on stdout/stderr. A new test file adds 8 tests covering captured stdio (console + raw write, stdout + stderr, many writes, writes from a user exit handler) and auto-piped stdio via a spawned subprocess.

Security risks

None. This is worker-internal stdio plumbing over an existing MessagePort; no new inputs are parsed, no privilege boundaries are crossed. The added flushInFlightForExit string property on process.stdout is user-observable, but users can already replace process.stdout wholesale in a worker — not a boundary.

Level of scrutiny

Moderate. It's a Node.js compat behavioral fix in built-in JS (no native code), ~25 lines of src change, and directly mirrors Node's documented mechanism (internal/bootstrap/switches/is_not_main_thread → kStdioWantsMoreDataCallback). The tricky part is the synchronous re-entry cascade through Writable's buffer, which I traced by hand: completing the parked cb while _exiting is true lets each subsequent writev post-and-complete synchronously until the buffer is empty. The 100-line and 300-line tests exercise exactly this cascade.

Other factors

  • onAck is safe to call when nothing is parked (guards on pendingWriteCallback !== null) and after destroy() (which nulls the pending cb and removes the port listener), so flushSync cannot double-unref or fire into a destroyed stream.
  • Handler registration order: setupWorkerStdio runs during the node:worker_threads preload, so flushSync is the first exit listener; user exit-handler writes then take the _exiting synchronous branch directly — the test asserting before\nfrom-exit\ntail\n covers this.
  • Tests follow harness conventions: once() on real events, Promise.all for subprocess pipe draining, exact-value assertions on combined {out, code} objects, and a no-process.exit() baseline. PR description confirms fail-before / pass-after and that the existing backpressure tests still pass.

@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 — small, well-reasoned Node-compat fix that mirrors Node's own flushSync mechanism.

What was reviewed

  • Traced the flush path: onAck is idempotent (null-checks pendingWriteCallback), so calling it from the exit handler when nothing is parked or after destroy() is a safe no-op.
  • Checked exit-handler ordering: flushSync is registered at preload so it fires before user 'exit' handlers; subsequent writes hit writev's _exiting fast path, which the "writes from a user process.on('exit') handler" test covers.
  • Confirmed test coverage spans the variant matrix (console vs raw write, stdout vs stderr, captured vs auto-piped, many-writes, plus a no-exit control).
  • Ruled out the stderr exact-set assertion flaking under debug/ASAN — bunEnv quiets debug logs and the assertion is on filtered/sorted lines, not raw equality.
Extended reasoning...

Overview

This PR fixes silent data loss in node:worker_threads port-backed stdio when a worker calls process.exit() synchronously. The fix touches only src/js/node/worker_threads.ts (~20 lines): makePortWritable now exposes its ack-completion callback as flushInFlightForExit on the returned Writable, and setupWorkerStdio registers a process.on('exit') handler that invokes it on both stdout and stderr. This directly mirrors Node's internal/bootstrap/switches/is_not_main_thread flushSync, as cited in the code comments and PR description.

A new test file adds 8 tests covering captured stdio (console.log/error, raw process.stdout/stderr.write, 100 buffered writes, writes from a user exit handler) and auto-piped stdio (300 lines via a spawned subprocess, plus a control case without process.exit()).

Security risks

None. This is JS-side stream-completion plumbing inside a worker's own process; no untrusted input parsing, no fd/resource handling changes, no new API surface exposed to users beyond an internal expando property on the port-backed Writable (matching the existing endFromOwner pattern on the readable side).

Level of scrutiny

Medium. Worker stdio is user-visible behavior, but the change is small, mechanically follows Node's documented approach, and only adds a call to an already-existing idempotent function (onAck) at exit time. I traced the interaction with destroy() (which nulls pendingWriteCallback, making the flush a no-op), the _exiting fast path in writev (which makes post-flush writes complete synchronously), and exit-handler ordering (flushSync is registered during preload, so it runs before user handlers — and the tests confirm user-handler writes still land via the _exiting branch). The parent-side worker.stdin Writable is intentionally not flushed on parent exit, matching Node.

Other factors

The PR description confirms fail-before/pass-after for all new tests and that the existing backpressure tests (stdout write completion is withheld until the parent reads, large stdout survives writev batching and repeated acks) still pass, so ack-gated flow control is intact. The bug-hunting system found no issues; one candidate (exact stderr line-set assertion flaking under ASAN/debug) was examined and refuted — bunEnv sets BUN_DEBUG_QUIET_LOGS=1 and the assertion compares a filtered+sorted line array, not raw stderr. Tests follow harness conventions (bunEnv/bunExe, await using for spawn, concurrent pipe drain via Promise.all, once() for events).

@robobun
robobun force-pushed the farm/e08659a7/worker-stdio-exit-drain branch from ef42fb7 to 2fe9041 Compare July 30, 2026 10:17
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/js/node/worker_threads/worker-stdio-exit-drain.test.ts Outdated
robobun and others added 9 commits July 30, 2026 10:43
A worker that writes to stdout/stderr and then calls process.exit() would
lose every chunk buffered after the first writev batch: the in-flight
writev parks its completion callback awaiting a reader ack that can never
arrive inside a synchronous exit, so the Writable never re-enters writev
and its buffered chunks are dropped.

Node's is_not_main_thread bootstrap registers a process.on('exit')
listener that completes the parked callback (kStdioWantsMoreDataCallback),
letting the stream flush its buffer while _exiting is true so each
subsequent writev posts and completes synchronously. Mirror that here.
worker_threads.test.ts has pre-existing debug-mode timeouts in unrelated
subprocess-spawn tests; a dedicated file gives the new coverage a clean
pass/fail signal.
Since #31216 worker stdout/stderr is always port-backed, so the
process.exit() drop also hits workers created without {stdout: true};
a worker that logs N lines then exits surfaces only the first.
process.on('exit') fires with _exiting === true on both, so the same
flushSync drains the Writable; without it a worker that logs N lines
then throws surfaces only LINE-0 on the parent's stdout.
Each test creates its own Worker or subprocess with independent piped
stdio; running them concurrently cuts the ASAN wall-clock roughly 3x.
@robobun
robobun force-pushed the farm/e08659a7/worker-stdio-exit-drain branch from 6ae19c2 to accf6bb Compare July 30, 2026 10:45

@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 earlier describe.concurrent nit was applied, and the remaining inline nit (string-keyed flushInFlightForExit vs. a module-local Symbol) is worth addressing but mirrors the pre-existing endFromOwner pattern in the same file and doesn't affect correctness.

Extended reasoning...

Overview

~16 lines in src/js/node/worker_threads.ts: expose the parked-writev ack completion (onAck) on the port-backed Writable and register a process.on('exit', flushSync) in setupWorkerStdio that fires it for stdout/stderr. This is a direct port of Node's internal/bootstrap/switches/is_not_main_thread flushSync → kStdioWantsMoreDataCallback mechanism. Plus a 167-line test file covering captured stdio, auto-piped stdio, and the uncaught-exception / unhandled-rejection exit doors.

Security risks

None. Worker threads share the parent's address space and fd table; there is no privilege boundary here. The one tamper-resistance concern (string-keyed hook on process.stdout) is flagged inline as a nit — it only misbehaves if user code deliberately overwrites an undocumented property on its own stream, and the same shape already exists as stream.endFromOwner in makePortReadable.

Level of scrutiny

Medium. It's Node-compat runtime JS on a hot-ish path (every worker's stdio), but the change is small, the mechanism is well-understood (unblock the parked writev cb so the Writable re-enters writev while _exiting is true and each subsequent batch completes synchronously), and it mirrors the reference implementation line-for-line. I checked that onAck() is a no-op when pendingWriteCallback is null, that flushSync is registered before user code (via the node:worker_threads preload) so it fires first, and that later user exit handlers' writes still land because _exiting short-circuits the ack wait — the test at line 54 covers exactly that.

Other factors

  • CI green (Build #77437). The fail-before / pass-after evidence in the PR body is convincing (9 fail on ASAN main, all pass with fix; existing backpressure tests unaffected).
  • My prior describe.concurrent nit was applied (commit accf6bb / 6ae19c2); ASAN wall-clock dropped ~26s → ~7.5s.
  • The comment-cop warnings are resolved (the current diff has a one-line reference comment, not a paragraph).
  • Tests follow harness conventions: bunEnv/bunExe, await using proc, Promise.all over stdout/stderr/exited, exact-value assertions, error events wired into the awaited promise via once().

Comment thread src/js/node/worker_threads.ts Outdated
Matches Node's kStdioWantsMoreDataCallback and this file's existing
convention for internal hooks; avoids exposing a user-overridable
string-keyed property on process.stdout/stderr and worker.stdin.

@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 — both prior review notes are addressed (Symbol-keyed hook, describe.concurrent), and the fix now matches Node's flushSync in is_not_main_thread exactly.

What was reviewed:

  • Traced the flush mechanism: onAck() unparks the pending writev cb, after which _exiting === true makes subsequent writev calls complete synchronously — buffered chunks drain end-to-end.
  • Checked exit-listener ordering: flushSync is registered at preload before user code, and post-flush writes from a user exit handler hit the _exiting fast path (covered by test).
  • Confirmed the Symbol on parent-side worker.stdin is harmless (module-local, no reader) and onAck() is a no-op when nothing is in flight.
  • Tests cover captured + auto-piped stdio, both streams, raw writes, user exit-handler writes, and the uncaught-exception / unhandled-rejection paths; once() wires error to reject.
Extended reasoning...

Overview

Two-file change: ~19 lines in src/js/node/worker_threads.ts and a new 167-line test file. The runtime change adds a module-local kStdioWantsMoreDataCallback Symbol, attaches the port-writable's onAck to the stream under that key, and registers a process.on('exit', flushSync) handler in setupWorkerStdio that invokes it on stdout/stderr. This directly mirrors Node's internal/bootstrap/switches/is_not_main_thread and unblocks the parked writev callback so the Writable's internal buffer drains during synchronous exit (the pre-existing process._exiting fast path in writev then keeps subsequent batches synchronous).

Security risks

None. Worker threads are not a privilege boundary; the hook is keyed by a module-local Symbol so it is not user-reachable (my earlier tamper-resistance concern about the string-keyed property was addressed in 3ff7865). No untrusted input parsing, no auth/crypto/permission surface.

Level of scrutiny

Medium — Node compat behavior in a built-in JS module, but the change is small, mechanically follows the Node reference, and is backed by fail-before/pass-after evidence on both ASAN and release. The mechanism is straightforward: onAck was already the ack handler; this just calls it once at exit time so the Writable's own machinery re-enters writev. I traced that onAck no-ops when pendingWriteCallback is null, so calling it on a stream with nothing in flight is safe, and the ?. guards handle the stdout-only / stderr-only cases.

Other factors

  • Both prior review comments from me (test concurrency, Symbol key) are resolved with follow-up commits, and the comment-cop notes about verbose comments were also addressed.
  • CI build #85722 passed on all lanes for this test file per the robobun status; remaining reds were unrelated flakes.
  • Test coverage is thorough: 10 tests across captured stdio (console + raw write, both streams), auto-piped stdio, a no-exit control, and the uncaught-exception / unhandled-rejection exit paths — all subprocess tests drain stdout/stderr/exited concurrently and assert exact structured output.
  • The PR description states the existing backpressure tests still pass, so the ack-gated flow control on the normal (non-exiting) path is intact.

@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-30 and it conflicts with main. 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 closed this Sep 13, 2026
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