Skip to content
18 changes: 3 additions & 15 deletions src/js/internal/fs/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,10 @@
if (fd == null) {
this[kFs] = customFs || fs;
this.fd = null;
// Internal $fastPath callers (writableFromFileSink) discard .path; do not
// resolve it - path.resolve("") needs process.cwd(), which throws when
// the cwd has been deleted (Node still spawns children in that state).
// Internal $fastPath callers discard .path; do not resolve it -
// path.resolve("") needs process.cwd(), which throws when the cwd has
// been deleted (Node still spawns children in that state).
Comment on lines +387 to +389

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

this.path = fastPath ? path : getValidatedPath(path);

Check warning on line 390 in src/js/internal/fs/streams.ts

View check run for this annotation

Claude / Claude Code Review

Dead fastPath ternary in WriteStream fd==null branch

Removing `writableFromFileSink` leaves no `$fastPath: true` caller that can reach the `fd == null` branch — the two remaining callers (ProcessObjectInternals.ts:55, tty.ts:116) both pass an `fd`. The `fastPath ? path : getValidatedPath(path)` ternary and its 3-line comment are now dead; per REVIEW.md "delete dead code in the same PR that makes it dead", drop them rather than rewording the comment (which also resolves the open comment-cop finding on line 389).
Comment on lines +387 to 390

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.

🟡 Removing writableFromFileSink leaves no $fastPath: true caller that can reach the fd == null branch — the two remaining callers (ProcessObjectInternals.ts:55, tty.ts:116) both pass an fd. The fastPath ? path : getValidatedPath(path) ternary and its 3-line comment are now dead; per REVIEW.md "delete dead code in the same PR that makes it dead", drop them rather than rewording the comment (which also resolves the open comment-cop finding on line 389).

Extended reasoning...

What the finding is

This PR removes writableFromFileSink, which was the only caller that constructed a WriteStream with $fastPath: true and no fd:

// removed by this PR
const w = new WriteStream("", { $fastPath: true });  // no fd → takes the fd == null branch

The two remaining $fastPath callers both pass an fd:

  • src/js/builtins/ProcessObjectInternals.ts:55 — new fs.WriteStream(null, { autoClose: false, fd, $fastPath: true })
  • src/js/node/tty.ts:116 — fs.WriteStream.$call(this, null, { fd, $fastPath: true, autoClose: false })

Both take the typeof options.fd === "number" branch at streams.ts:397, never the fd == null branch. $fastPath is a builtin-private name (registered in BunBuiltinNames.h), so user code cannot set it on an options object. That leaves the fastPath value inside the fd == null branch always undefined.

The specific dead code

if (fd == null) {
  this[kFs] = customFs || fs;
  this.fd = null;
  // Internal $fastPath callers discard .path; do not resolve it -
  // path.resolve("") needs process.cwd(), which throws when the cwd has
  // been deleted (Node still spawns children in that state).
  this.path = fastPath ? path : getValidatedPath(path);
  ...

The ternary at line 390 now always evaluates to getValidatedPath(path), and the 3-line comment (which this PR just reworded to drop the writableFromFileSink mention) describes a "child_process spawn from deleted cwd" scenario that no longer routes through this line. This PR touched exactly these lines to update the comment rather than delete it.

Note that fastPath is still live in the later if (fastPath) { this[kWriteStreamFastPath] = ... } block further down in WriteStream(), which the fd-bearing callers do reach — so only this ternary + comment are dead, not the whole $fastPath mechanism.

Step-by-step proof

  1. Grep confirms exactly two $fastPath sites remain in src/js/ after this PR: ProcessObjectInternals.ts:55 and tty.ts:116.
  2. ProcessObjectInternals.ts:55 is the process.stdout/stderr constructor; fd is asserted to be 1 or 2 before the call.
  3. tty.ts:116 is tty.WriteStream(fd); fd is the user's argument. If a user passed undefined, the code would reach the fd == null branch with fastPath truthy — but that's a degenerate user error Node rejects with ERR_INVALID_FD, and removing the ternary would just make Bun throw at construction (ERR_INVALID_ARG_TYPE for path=null) instead of deferring the failure to write time — arguably an improvement, and certainly not a case the comment's "spawn from deleted cwd" rationale covers.
  4. $fastPath compiles to a private symbol, so no external options object can set it.
  5. Therefore, on every path that reaches fd == null in WriteStream(), fastPath is undefined, and fastPath ? path : getValidatedPath(path) reduces to getValidatedPath(path).

Why it should change in this PR

REVIEW.md, Code style & idioms: "Delete dead code in the same PR that makes it dead." This PR's own diff (the writableFromFileSink removal) is what kills this branch, and the PR already touched these exact lines to reword the comment — so it's in scope.

This is also the root-cause fix for the still-open comment-cop finding on streams.ts:389 ("If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong"): the workaround the comment justifies has no caller, so the right fix is deletion, not shortening.

Impact

Zero functional impact — the ternary's dead arm is never taken, so behavior is unchanged. This is a nit: harmless but directly created by (and touched in) this PR.

Fix

Replace lines 387–390 with:

this.path = getValidatedPath(path);

const { flags, mode } = options;
this.flags = flags === undefined ? "w" : flags;
this.mode = mode === undefined ? 0o666 : mode;
Expand Down Expand Up @@ -796,20 +796,8 @@
}
}

function writableFromFileSink(fileSink: any) {
$assert(typeof fileSink === "object", "fileSink is not an object");
$assert(typeof fileSink.write === "function", "fileSink.write is not a function");
$assert(typeof fileSink.end === "function", "fileSink.end is not a function");
const w = new WriteStream("", { $fastPath: true });
$assert(w[kWriteStreamFastPath] === true, "fast path not enabled");
w[kWriteStreamFastPath] = fileSink;
w.path = undefined;
return w;
}

export default {
ReadStream,
WriteStream,
kWriteStreamFastPath,
writableFromFileSink,
};
12 changes: 6 additions & 6 deletions src/js/internal/streams/native-readable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@

let debugId = 0;

function constructNativeReadable(readableStream: ReadableStream, options): NativeReadable {
function constructNativeReadable(readableStream: ReadableStream, options, Base?): NativeReadable {
$assert(typeof readableStream === "object" && readableStream instanceof ReadableStream, "Invalid readable stream");
const bunNativePtr = (readableStream as any).$bunNativePtr;
$assert(typeof bunNativePtr === "object", "Invalid native ptr");

const stream = new Readable(options);
const stream = Base !== undefined ? new Base(options) : new Readable(options);
Comment thread
robobun marked this conversation as resolved.
stream._read = read;
stream._destroy = destroy;

Expand Down Expand Up @@ -260,20 +260,20 @@
}
}

function ref(this: NativeReadable) {
const ptr = this.$bunNativePtr;
if (ptr === undefined) return;
if (this[kRefCount]++ === 0) {
if (ptr !== undefined && this[kRefCount]++ === 0) {
ptr.updateRef(true);
}
return this;
}

Check warning on line 269 in src/js/internal/streams/native-readable.ts

View check run for this annotation

Claude / Claude Code Review

child.stdout.ref() is not idempotent — redundant ref() defeats a later unref()

`child.stdout.ref()` is not idempotent: 5299c8a clamped `unref()` at 0 but `ref()` still does `this[kRefCount]++` uncapped, so after spawn's eager ref (kRefCount=1) a redundant `ref()` bumps it to 2 and a subsequent `unref()` only brings it back to 1 — `updateRef(false)` never fires. Node documents `socket.ref()` as idempotent, and `child.stdin` already uses the fully-idempotent `kStdinUnrefed` flag, so the three stdio streams disagree. Cap `ref()` symmetrically (treat `kRefCount` as a 0/1 flag)
Comment on lines 263 to 269

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.

🟡 child.stdout.ref() is not idempotent: 5299c8a clamped unref() at 0 but ref() still does this[kRefCount]++ uncapped, so after spawn's eager ref (kRefCount=1) a redundant ref() bumps it to 2 and a subsequent unref() only brings it back to 1 — updateRef(false) never fires. Node documents socket.ref() as idempotent, and child.stdin already uses the fully-idempotent kStdinUnrefed flag, so the three stdio streams disagree. Cap ref() symmetrically (treat kRefCount as a 0/1 flag) or route stdout/stderr through the same flag path stdin uses.

Extended reasoning...

What the bug is

net.Socket.prototype.ref() is documented as idempotent: "If the socket is refed calling ref again will have no effect." Commit 5299c8a in this PR fixed the mirror case for unref() by clamping the decrement at 0 (this[kRefCount] > 0 && --this[kRefCount] === 0), but left ref() at native-readable.ts:263-269 unchanged:

function ref(this: NativeReadable) {
  const ptr = this.$bunNativePtr;
  if (ptr !== undefined && this[kRefCount]++ === 0) {
    ptr.updateRef(true);
  }
  return this;
}

The post-increment has no upper cap, so a redundant ref() on an already-refed stream silently drifts the counter above 1, and the next unref() fails to reach the 1→0 transition that fires updateRef(false).

Code path / step-by-step proof

For a live child.stdout pipe, constructNativeReadable installs the instance-level stream.ref = ref; stream.unref = unref; (native-readable.ts:82-83), which shadow StdoutSocket.prototype.ref/unref (the idempotent stdioSocketRef/Unref). Then:

  1. ChildProcess#spawn's eager loop for (let item of this.stdio) item?.ref?.(); calls native-readable's ref() → kRefCount 0→1 (post-increment returns 0, so updateRef(true) fires). ✓
  2. User (redundantly) calls child.stdout.ref() → kRefCount++ post-increment returns 1 (≠ 0), no updateRef; kRefCount = 2.
  3. User calls child.stdout.unref() → kRefCount > 0 && --kRefCount === 0 → 2→1, 1 ≠ 0, no updateRef(false).

Result: after ref(); unref() the pipe stays refed. In Node the same sequence leaves the socket unrefed.

Why existing code doesn't prevent it

The 5299c8a fix only touched the decrement side. Nothing caps the increment. And the fully-idempotent StdoutSocket.prototype.ref/unref (which use the kStdinUnrefed flag) are unreachable for live pipes because constructNativeReadable installs own-property ref/unref that shadow them — the comment in getStdoutSocket() ("Live pipes get instance _read/_destroy/ref/unref from constructNativeReadable") acknowledges this.

Internal inconsistency

Within this PR's own diff, the three stdio streams now disagree on ref/unref semantics: child.stdin uses the kStdinUnrefed boolean flag (fully idempotent both ways), while live child.stdout/stderr use a counter that's clamped only on decrement. REVIEW.md's "fix the whole class in the same PR" applies — this is the exact same pattern as the already-fixed unref() side, just on the increment.

Repro

const { spawn } = require('child_process');
const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{},1e6)'], { stdio: 'pipe' });
c.stdin.ref();  c.stdin.unref();   // stdin: unrefed (flag path)
c.stdout.ref(); c.stdout.unref();  // stdout: still refed (counter went 1→2→1)

Impact / severity

Nit. Triggering it requires a redundant ref() call on a stream that's already refed, which is uncommon (the motivating Nx use case only calls unref()), and pre-PR child.stdout had no ref/unref contract at all — so this is a gap in newly-added surface, not a regression. But it's a real Node-compat divergence on a stream this PR now advertises as instanceof net.Socket, and the asymmetry with stdin invites confusion.

Fix

Cap ref() symmetrically so kRefCount is effectively a 0/1 flag:

function ref(this: NativeReadable) {
  const ptr = this.$bunNativePtr;
  if (ptr !== undefined && this[kRefCount] === 0) {
    this[kRefCount] = 1;
    ptr.updateRef(true);
  }
  return this;
}

Or drop the instance-level ref/unref install when Base is supplied and let StdoutSocket.prototype.ref/unref (the kStdinUnrefed flag path) handle it, so all three stdio streams share one idempotent implementation.


function unref(this: NativeReadable) {
const ptr = this.$bunNativePtr;
if (ptr === undefined) return;
if (this[kRefCount]-- === 1) {
if (ptr !== undefined && this[kRefCount] > 0 && --this[kRefCount] === 0) {
ptr.updateRef(false);
}
return this;
}
Comment thread
robobun marked this conversation as resolved.

export default { constructNativeReadable };
226 changes: 184 additions & 42 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1195,42 +1195,13 @@ class ChildProcess extends EventEmitter {
switch (i) {
case 0: {
switch (io) {
case "pipe": {
const stdin = handle?.stdin;

if (!stdin) {
// This can happen if the process was already killed.
const Writable = require("internal/streams/writable");
const stream = new Writable({
write(chunk, encoding, callback) {
// Gracefully handle writes - stream acts as if it's ended
if (callback) callback();
return false;
},
});
// Mark as destroyed to indicate it's not usable
stream.destroy();
return stream;
}
const result = require("internal/fs/streams").writableFromFileSink(stdin);
result.readable = false;
return result;
}
case "pipe":
// handle.stdin can be undefined if the process was already killed.
return createStdinSocket(handle?.stdin);
case "inherit":
return null;
case "destroyed": {
const Writable = require("internal/streams/writable");
const stream = new Writable({
write(chunk, encoding, callback) {
// Gracefully handle writes - stream acts as if it's ended
if (callback) callback();
return false;
},
});
// Mark as destroyed to indicate it's not usable
stream.destroy();
return stream;
}
case "destroyed":
return createStdinSocket(undefined);
case "undefined":
return undefined;
default:
Expand All @@ -1242,25 +1213,22 @@ class ChildProcess extends EventEmitter {
switch (io) {
case "pipe": {
const value = handle?.[fdToStdioName(i as 1 | 2)!];
const Ctor = getStdoutSocket();
// This can happen if the process was already killed.
if (!value) {
const Readable = require("internal/streams/readable");
const stream = new Readable({ read() {} });
// Mark as destroyed to indicate it's not usable
const stream = new Ctor({ read() {} });
stream.destroy();
return stream;
}

const pipe = require("internal/streams/native-readable").constructNativeReadable(value, {});
const pipe = require("internal/streams/native-readable").constructNativeReadable(value, {}, Ctor);
this.#closesNeeded++;
pipe.once("close", () => this.#maybeClose());
if (autoResume) pipe.resume();
return pipe;
}
case "destroyed": {
const Readable = require("internal/streams/readable");
const stream = new Readable({ read() {} });
// Mark as destroyed to indicate it's not usable
const stream = new (getStdoutSocket())({ read() {} });
stream.destroy();
return stream;
}
Expand Down Expand Up @@ -1700,7 +1668,8 @@ function streamFdOf(item): number | undefined {

if (item.destroyed) return undefined;

const sink = item[require("internal/fs/streams").kWriteStreamFastPath];
// Another child's stdin socket, or a fs.WriteStream on its FileSink fast path.
const sink = item[kStdinSink] ?? item[require("internal/fs/streams").kWriteStreamFastPath];
if (sink && sink !== true) {
const fd = sink._getFd();
if (typeof fd === "number" && fd >= 0) return fd;
Expand Down Expand Up @@ -1776,6 +1745,179 @@ function fdToStdioName(fd: number) {
}
}

// Node's createSocket wraps each piped stdio in a net.Socket; subclass it for
// `instanceof` but route I/O to the FileSink / native readable that backs Bun's pipes.
Comment thread
robobun marked this conversation as resolved.
const kStdinSink = Symbol("kStdinSink");
const kStdinUnrefed = Symbol("kStdinUnrefed");
let StdinSocket;
let StdoutSocket;

function initStdioSocket(self, options) {
const Duplex = require("internal/streams/duplex");
Duplex.$call(self, options);
// net.Socket prototype methods read these; there is no native handle here.
self._handle = null;
self._parent = null;
self.connecting = false;
self.server = null;
self._server = null;
}

// Idempotent flag like net.Socket; FileSink's counter already starts at 1.
function stdioSocketRef(this: any) {
if (this[kStdinUnrefed]) {
this[kStdinUnrefed] = false;
this[kStdinSink]?.ref?.();
}
return this;
}

function stdioSocketUnref(this: any) {
if (!this[kStdinUnrefed]) {
this[kStdinUnrefed] = true;
this[kStdinSink]?.unref?.();
}
return this;
}
Comment thread
robobun marked this conversation as resolved.

function stdinSocketWrite(this: any, chunk: any, encoding: any, cb: any) {
const sink = this[kStdinSink];
if (!sink) {
cb($ERR_SOCKET_CLOSED());
return false;
}
try {
// FileSink treats string input as UTF-8; transcode other encodings here.
if (
typeof chunk === "string" &&
encoding !== undefined &&
encoding !== "utf8" &&
encoding !== "utf-8" &&
encoding !== "buffer"
) {
chunk = Buffer.from(chunk, encoding);
}
const result = sink.write(chunk);
if ($isPromise(result)) {
result.then(() => cb(), cb);
return false;
}
cb();
return true;
} catch (err) {
cb(err);
return false;
}
}

function stdinSocketWritev(this: any, chunks: any, cb: any) {
const buffers = new Array(chunks.length);
for (let i = 0; i < chunks.length; i++) {
const { chunk, encoding } = chunks[i];
buffers[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk;
}
stdinSocketWrite.$call(this, BufferConcat(buffers), "buffer", cb);
}

function stdinSocketFinal(this: any, cb: any) {
const sink = this[kStdinSink];
if (sink) {
this[kStdinSink] = undefined;
try {
const result = sink.end();
if ($isPromise(result)) {
result.then(() => cb(), cb);
return;
}
} catch (err) {
cb(err);
return;
}
}
cb();
}

function stdinSocketDestroy(this: any, err: any, cb: any) {
const sink = this[kStdinSink];
if (sink) {
this[kStdinSink] = undefined;
try {
const result = sink.end(err);
if ($isPromise(result)) {
result.then(
() => cb(err),
(e: any) => cb(e || err),
);
return;
}
} catch (e) {
cb(e || err);
return;
}
}
cb(err);
}

function getStdinSocket() {
if (StdinSocket === undefined) {
if (!NetModule) NetModule = require("node:net");
StdinSocket = function Socket(this: any) {
initStdioSocket(this, {
readable: false,
decodeStrings: false,
autoDestroy: true,
emitClose: true,
});
this[kStdinSink] = undefined;
this[kStdinUnrefed] = false;
};
$toClass(StdinSocket, "Socket", NetModule.Socket);
StdinSocket.prototype._write = stdinSocketWrite;
Comment thread
robobun marked this conversation as resolved.
StdinSocket.prototype._writev = stdinSocketWritev;
StdinSocket.prototype._final = stdinSocketFinal;
StdinSocket.prototype._destroy = stdinSocketDestroy;
StdinSocket.prototype.ref = stdioSocketRef;
StdinSocket.prototype.unref = stdioSocketUnref;
}
return StdinSocket;
}

function stdoutSocketDestroy(this: any, err: any, cb: any) {
cb(err);
}

function getStdoutSocket() {
if (StdoutSocket === undefined) {
if (!NetModule) NetModule = require("node:net");
StdoutSocket = function Socket(this: any, options: any) {
initStdioSocket(this, {
...options,
writable: false,
allowHalfOpen: false,
autoDestroy: true,
emitClose: true,
});
};
$toClass(StdoutSocket, "Socket", NetModule.Socket);
// Live pipes get instance _read/_destroy/ref/unref from constructNativeReadable.
StdoutSocket.prototype._destroy = stdoutSocketDestroy;
StdoutSocket.prototype.ref = stdioSocketRef;
StdoutSocket.prototype.unref = stdioSocketUnref;
}
return StdoutSocket;
}

function createStdinSocket(sink: any) {
const Ctor = getStdinSocket();
const stream = new Ctor();
if (sink) {
stream[kStdinSink] = sink;
} else {
stream.destroy();
}
return stream;
}

function getBunStdioFromOptions(stdio) {
const normalizedStdio = normalizeStdio(stdio);
if (normalizedStdio.filter(v => v === "ipc").length > 1) throw $ERR_IPC_ONE_PIPE();
Expand Down
Loading