Skip to content
Draft
16 changes: 13 additions & 3 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,13 @@ export function initializeNextTickQueue(process: typeof globalThis.process, next
var drainMicrotasks = drainMicrotasksFn;

const { validateFunction } = require("internal/validators");
const asyncHooksTick = require("internal/async_hooks_tick");

var setup;
setup = () => {
const { FixedQueue } = require("internal/fixed_queue");
queue = new FixedQueue();
tickInitHooks = require("internal/async_hooks_tick").tickInitHooks;
tickInitHooks = asyncHooksTick.tickInitHooks;

function processTicksAndRejections() {
var tock;
Expand Down Expand Up @@ -373,7 +374,8 @@ export function initializeNextTickQueue(process: typeof globalThis.process, next
}

drainMicrotasks();
} while (!queue.isEmpty());
// With both queues empty, internal/process/after_tick_drain.ts (once something loaded it) runs one callback.
} while (!queue.isEmpty() || asyncHooksTick.runAfterTickDrainCallback?.());
Comment thread
robobun marked this conversation as resolved.
Resolved
// Without this, every checkpoint after the first tick calls this function to find an empty queue.
$putInternalField(nextTickQueue, 0, 0);
}
Expand All @@ -384,6 +386,15 @@ export function initializeNextTickQueue(process: typeof globalThis.process, next
setup = undefined;
};

// For internal/process/after_tick_drain.ts. JSNextTickQueue::drain only calls the tick loop while field 0 is set.
asyncHooksTick.ensureTickLoop = () => {
if (setup) {
setup();
process = globalThis.process;
}
$putInternalField(nextTickQueue, 0, 1);
};

function nextTick(cb, ...args) {
validateFunction(cb, "callback");
if (setup) {
Expand All @@ -402,7 +413,6 @@ export function initializeNextTickQueue(process: typeof globalThis.process, next
if (tickInitHooks.length !== 0) {
// node fires one TickObject init per process.nextTick() call, at
// construction time (before the callback runs).
const asyncHooksTick = require("internal/async_hooks_tick");
const asyncId = asyncHooksTick.newAsyncId();
// Snapshot: enable()/disable() from inside a hook must not affect the
// in-flight dispatch (node stages such mutations in tmp_array until
Expand Down
4 changes: 4 additions & 0 deletions src/js/internal/async_hooks_tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ export default {
newAsyncId() {
return ++nextAsyncId;
},
// For internal/process/after_tick_drain.ts. Set when process.nextTick is first read, which creates the tick queue.
ensureTickLoop: undefined as (() => void) | undefined,
// Set by internal/process/after_tick_drain.ts. The tick loop calls it each time both queues are empty.
runAfterTickDrainCallback: undefined as (() => boolean) | undefined,
};
41 changes: 41 additions & 0 deletions src/js/internal/process/after_tick_drain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Node empties the nextTick queue and the promise job queue between two libuv callbacks: https://github.com/nodejs/node/blob/v24.9.0/lib/internal/process/task_queues.js#L72-L109
const asyncHooksTick = require("internal/async_hooks_tick");
const Dequeue = require("internal/fifo");

type AfterTickDrainCallback = {
callback: (arg: any) => void;
arg: unknown;
frame: import("../../node/async_hooks").Frame | undefined;
};

const entries = new Dequeue<AfterTickDrainCallback>();

// Runs `callback` once both queues are empty, inside the drain that is running. One callback for each such point.
function runAfterTickDrain(callback: (arg: any) => void, arg?: unknown) {
let ensureTickLoop = asyncHooksTick.ensureTickLoop;
if (ensureTickLoop === undefined) {
// The first read of process.nextTick creates the tick queue. Fake timers read it before they replace it.
void process.nextTick;
ensureTickLoop = asyncHooksTick.ensureTickLoop;
// process.nextTick was overwritten before its first read: there is no tick loop to wait for.
if (ensureTickLoop === undefined) return process.nextTick(callback, arg);
}
ensureTickLoop();
entries.push({ callback, arg, frame: $getInternalField($asyncContext, 0) });
}

// The tick loop calls this each time both queues are empty. False: nothing waited.
function runAfterTickDrainCallback() {
const entry = entries.shift();
if (entry === undefined) return false;
const { callback, arg, frame } = entry;
const restore = $getInternalField($asyncContext, 0);
$putInternalField($asyncContext, 0, frame);
// No catch and no finally, as for a tick: JSNextTickQueue::drain reports the throw and calls the tick loop again.
callback(arg);
$putInternalField($asyncContext, 0, restore);
return true;
}
asyncHooksTick.runAfterTickDrainCallback = runAfterTickDrainCallback;

export default { runAfterTickDrain };
158 changes: 143 additions & 15 deletions src/js/internal/streams/native-readable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const transferToNativeReadable = $newCppFunction(
1,
);
const { errorOrDestroy } = require("internal/streams/destroy");
const { runAfterTickDrain } = require("internal/process/after_tick_drain");

const kRefCount = Symbol("refCount");
const kCloseState = Symbol("closeState");
Expand All @@ -20,6 +21,15 @@ const kHighWaterMark = Symbol("highWaterMark");
const kPendingRead = Symbol("pendingRead");
const kHasResized = Symbol("hasResized");
const kRemainingChunk = Symbol("remainingChunk");
const kSawEof = Symbol("sawEof");
const kReadEvents = Symbol("readEvents");
const kReadEventsHead = Symbol("readEventsHead");
const kReadEventQueued = Symbol("readEventQueued");

// What a read gave, in the order Node would report it: see `kReadEvents`.
const kChunkEvent = 0;
const kEofEvent = 1;
const kErrorEvent = 2;

const MIN_BUFFER_SIZE = 512;
let dynamicallyAdjustChunkSize = (_?) => (
Expand Down Expand Up @@ -49,6 +59,11 @@ interface NativeReadable extends NodeReadable {
[kHighWaterMark]: number;
[kHasResized]: boolean;
[kRemainingChunk]: Buffer | undefined;
[kSawEof]: boolean;
// "libuv-handle" only: (kind, value) pairs from `kReadEventsHead` on. At most the results of one `_read`.
[kReadEvents]: unknown[] | undefined;
[kReadEventsHead]: number;
[kReadEventQueued]: boolean;
debugId: number;
}

Expand All @@ -65,12 +80,19 @@ interface NativePtr {

let debugId = 0;

function constructNativeReadable(readableStream: ReadableStream, options): NativeReadable {
// `nodeSource`: what feeds the equivalent stream in Node. "libuv-handle" is child_process stdio, a net.Socket there.
function constructNativeReadable(
readableStream: ReadableStream,
options,
nodeSource: "libuv-handle" | "webstream",
): 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) as NativeReadable;
const isHandle = nodeSource === "libuv-handle";
// A net.Socket emits 'close' itself, from the close callback of its handle: see `destroy`.
const stream = new Readable(isHandle ? { ...options, emitClose: false } : options) as NativeReadable;
stream._read = read;
stream._destroy = destroy;

Expand All @@ -86,6 +108,10 @@ function constructNativeReadable(readableStream: ReadableStream, options): Nativ
stream[kPendingRead] = false;
stream[kHasResized] = !dynamicallyAdjustChunkSize();
stream[kCloseState] = [false];
stream[kSawEof] = false;
stream[kReadEvents] = isHandle ? [] : undefined;
stream[kReadEventsHead] = 0;
stream[kReadEventQueued] = false;

const highWaterMark = options.highWaterMark;
stream[kHighWaterMark] = typeof highWaterMark === "number" ? highWaterMark : 256 * 1024;
Expand Down Expand Up @@ -142,7 +168,12 @@ function read(this: NativeReadable, maxToRead: number) {
// not paused from a previous `push()===false` (readStart, like net.Socket).
// Runs even when a pull promise is outstanding so that promise can resolve.
if (ptr) ptr.setFlowing?.(true);
if (this[kPendingRead]) {
if (this[kPendingRead] || this[kSawEof]) {
return;
}
const events = this[kReadEvents];
// The events of the last read come first, and push() has not said yet if it wants more.
if (events !== undefined && events.length !== 0) {
return;
}
if (!ptr) {
Expand All @@ -158,17 +189,25 @@ function read(this: NativeReadable, maxToRead: number) {
this[kHighWaterMark] = Math.min(this[kHighWaterMark], result);
}
if ($isTypedArrayView(result) && result.byteLength > 0) {
pushAndCheck(this, result);
deliverChunk(this, result);
}
const drainResult = ptr.drain();
this[kConstructed] = true;
$debug(`[${this.debugId}] drain result: ${drainResult?.byteLength ?? "null"}`);
if ((drainResult?.byteLength ?? 0) > 0) {
pushAndCheck(this, drainResult);
deliverChunk(this, drainResult);
}
}
const chunk = getRemainingChunk(this, maxToRead);
var result = ptr.pull(chunk, this[kCloseState]);
var result;
try {
result = ptr.pull(chunk, this[kCloseState]);
} catch (error) {
if (events === undefined) throw error;
// The chunks that this read already got come before the error.
queueReadEvent(this, events, kErrorEvent, error);
return;
}
$assert(result !== undefined);
$debug(
`[${this.debugId}] pull ${chunk?.byteLength} bytes, result: ${$isPromise(result) ? "<pending>" : $isTypedArrayView(result) ? `<${result.byteLength} bytes>` : result}, closeState: ${this[kCloseState][0]}`,
Expand All @@ -184,7 +223,8 @@ function read(this: NativeReadable, maxToRead: number) {
this[kRemainingChunk] = handleResult(this, result, chunk, this[kCloseState][0]);
},
reason => {
errorOrDestroy(this, reason);
if (events === undefined) errorOrDestroy(this, reason);
else queueReadEvent(this, events, kErrorEvent, reason);
},
);
} else {
Expand All @@ -201,7 +241,7 @@ function handleResult(stream: NativeReadable, result: any, chunk: Buffer | undef
return handleNumberResult(stream, result, chunk, isClosed);
} else if (typeof result === "boolean") {
$debug(`[${stream.debugId}] handleResult(${result})`, chunk, isClosed);
process.nextTick(pushEof, stream);
deliverEof(stream);
return (chunk?.byteLength ?? 0) > 0 ? chunk : undefined;
} else if ($isTypedArrayView(result)) {
if (result.byteLength >= stream[kHighWaterMark] && !stream[kHasResized] && !isClosed) {
Expand All @@ -213,7 +253,7 @@ function handleResult(stream: NativeReadable, result: any, chunk: Buffer | undef
}
}

// EOF is pushed a tick after the last chunk. After a destroy() in between, Node emits 'close' without 'end'.
// After a destroy() before the EOF gets its turn, Node emits 'close' without 'end'.
function pushEof(stream: NativeReadable) {
if (!stream.destroyed) stream.push(null);
}
Expand All @@ -228,29 +268,102 @@ function pushAndCheck(stream: NativeReadable, chunk: any) {
}
}

function deliverChunk(stream: NativeReadable, chunk: any) {
const events = stream[kReadEvents];
// Nobody listens, so nobody sees an order, and a read() call gets the bytes now: Node has them buffered already.
if (
events === undefined ||
(events.length === 0 && stream.listenerCount("data") === 0 && stream.listenerCount("readable") === 0)
) {
pushAndCheck(stream, chunk);
} else {
queueReadEvent(stream, events, kChunkEvent, chunk);
}
}

// Node gets the EOF in a read of its own, after the nextTicks and promise jobs of the last chunk's listeners.
function deliverEof(stream: NativeReadable) {
stream[kSawEof] = true;
const events = stream[kReadEvents];
if (events === undefined) runAfterTickDrain(pushEof, stream);
else queueReadEvent(stream, events, kEofEvent, undefined);
}

function queueReadEvent(stream: NativeReadable, events: unknown[], kind: number, value: unknown) {
$arrayPush(events, kind);
$arrayPush(events, value);
queueReadEventDelivery(stream);
}

function queueReadEventDelivery(stream: NativeReadable) {
if (stream[kReadEventQueued]) return;
stream[kReadEventQueued] = true;
runAfterTickDrain(deliverReadEvent, stream);
}

function deliverReadEvent(stream: NativeReadable) {
stream[kReadEventQueued] = false;
const events = stream[kReadEvents]!;
// Node reports nothing more for a handle that is closed.
if (stream.destroyed) {
events.length = 0;
stream[kReadEventsHead] = 0;
return;
}
const head = stream[kReadEventsHead];
const kind = events[head];
const value = events[head + 1];
if (head + 2 === events.length) {
events.length = 0;
stream[kReadEventsHead] = 0;
} else {
stream[kReadEventsHead] = head + 2;
// Before the push, so that a listener that throws does not strand the events behind this one.
queueReadEventDelivery(stream);
}
if (kind === kEofEvent) {
stream.push(null);
} else if (kind === kErrorEvent) {
errorOrDestroy(stream, value);
} else {
let threw = true;
try {
pushAndCheck(stream, value);
threw = false;
} finally {
// The throw of a 'data' listener skipped the maybeReadMore() of push(). Node's handle reads on.
if (threw) process.nextTick(readAfterListenerThrew, stream);
}
}
}

function readAfterListenerThrew(stream: NativeReadable) {
if (!stream.destroyed) stream.read(0);
}

function handleNumberResult(stream: NativeReadable, result: number, chunk: any, isClosed: boolean) {
if (result > 0) {
const slice = chunk.subarray(0, result);
chunk = slice.byteLength < chunk.byteLength ? chunk.subarray(result) : undefined;
if (slice.byteLength > 0) {
pushAndCheck(stream, slice);
deliverChunk(stream, slice);
}
}

if (isClosed) {
process.nextTick(pushEof, stream);
deliverEof(stream);
}

return chunk;
}

function handleArrayBufferViewResult(stream: NativeReadable, result: any, chunk: any, isClosed: boolean) {
if (result.byteLength > 0) {
pushAndCheck(stream, result);
deliverChunk(stream, result);
}

if (isClosed) {
process.nextTick(pushEof, stream);
deliverEof(stream);
}

return chunk;
Expand All @@ -261,19 +374,34 @@ function adjustHighWaterMark(stream: NativeReadable) {
stream[kHasResized] = true;
}

function destroy(this: NativeReadable, error: any, cb: () => void) {
function destroy(this: NativeReadable, error: any, cb: (error?: any) => void) {
const ptr = this.$bunNativePtr;
if (ptr) {
ptr.cancel(error);
}
if (this[kReadEvents] !== undefined) {
// As net.Socket: 'close' comes from the handle's close callback. It is queued before cb(), which can throw.
runAfterTickDrain(error ? emitCloseAfterError : emitClose, this);

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.

🔴 A wrapper that exits with its child's code can now exit 0 after a fatal, unhandled read error on child.stdout, where main and Node exit 1. At native-readable.ts:384 the 'close' is queued as its own after-drain entry, independent of the tick that emits 'error'; when that emit throws because nobody listens, the entry still runs, 'close' fires, and child_process.ts:1657 #maybeClose emits the ChildProcess 'close' before the loop stops. Fix: no 'close' after an 'error' that went unhandled, as on main and in Node; e.g. arm the close from the tick that emits 'error' only once emit has returned. The PR's Downsides note calls this accepted; it reinstates the #34661 behavior. [also at: src/js/internal/streams/native-readable.ts:335 - A wrapper whose child.stdout 'data' listener throws with no uncaughtException handler can now exit 0 instead of 1 when it exits with its child's code.]

Why this was flagged

Trigger: a read of a piped child stdout or stderr fails (ptr.pull throws or rejects, e.g. EIO), or user/library code calls child.stdout.destroy(err), while the stream has no 'error' listener (exec/execFile attach only 'data' listeners). The handle-mode destroy at native-readable.ts:384 queues emitCloseAfterError via runAfterTickDrain and then cb(error); destroy.ts:104 queues emitErrorCloseNT on nextTick. In the tick loop emitErrorNT at destroy.ts:152 throws the error (no listener); JSNextTickQueue.cpp:93 calls processTicksAndRejections again, whose loop condition at ProcessObjectInternals.ts:378 runs runAfterTickDrainCallback, so stream.emit('close', true) at native-readable.ts:401 runs in the same drain. child_process.ts:1302 then calls #maybeClose and emits the ChildProcess 'close' at child_process.ts:1661; a handler doing process.exit(child.exitCode) exits 0. On the base branch emitErrorNT throwing skips emitCloseNT, so the ChildProcess never emits 'close', and the run ends with code 1. The existing safeguard, the uncaught-exception exit code, is defeated because the explicit process.exit in the close handler runs first.

Verification: Acknowledged in the PR's "Downsides" bullet. destroy() at native-readable.ts:382-386 queues the 'close' entry independently of the error emit; emitErrorNT at destroy.ts:152 throws with no listener, VirtualMachine.rs:2150-2153 only sets exit_code = 1 and keeps running, and the re-entered loop at ProcessObjectInternals.ts:378 emits 'close', so child_process.ts:1657-1662 emits ChildProcess 'close'. Base never emitted 'close' here and exited 1.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases are real. I measured them on Node v26.3.0, Bun 1.4.2, a debug build of main's src/ and this branch. The number is the exit status of a wrapper that exits with its child's code on 'close'.

  • child.stdout.destroy(err) that nothing handles: Node 1, main 1, Bun 1.4.2 0, this branch 0.
  • A 'data' listener throws after the child's 'exit', with the EOF behind the data: Node 1, main 1, Bun 1.4.2 1, this branch 0.

I do not add a guard at :384 or :335. At both lines the state of the stream is the same after a throw that an uncaughtException listener took and after a fatal one. errorEmitted is set before the emit, and the result of the report does not reach JS (Bun__reportUnhandledError drops the bool of uncaught_exception). With a listener Node emits 'close' after the error and reads on after the throw, and the throw rows pin the second. A guard on the state of the stream takes that away.

The stop belongs to the report. On main VirtualMachine::uncaught_exception counts the error and returns. The drain that runs goes on, and only the next turn of the loop is refused. The after-drain callbacks stand for that next turn, but they run inside the drain. Node exits at the report. #34661 makes a report that nothing handles end the main run.

I ran this branch with a stand-in for that: a preload that exits in uncaughtExceptionMonitor when no uncaughtException listener and no capture callback exists. Both cases exit 1 with no further event, and the case with a listener does not change.

So this PR must not merge before the report ends the run. The Downsides section and the Notes say so now. I leave this thread open until then.

if (cb) cb(error);
return;
}
dropReadAhead(this);
if (cb) {
// `_destroy` reports its error through the callback.
process.nextTick(cb, error);
}
}

// `_read()` pushes synchronously, so flow() stays one chunk ahead of the 'data' listener. Node's async sources do not.
// The argument is net.Socket's `hadError`: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L346-L358
function emitClose(stream: NativeReadable) {
stream.emit("close", false);
}

function emitCloseAfterError(stream: NativeReadable) {
stream.emit("close", true);
}

// `_read()` of a "webstream" source pushes synchronously, so flow() stays one chunk ahead of the 'data' listener. Node's async sources do not.
function dropReadAhead(stream: NativeReadable) {
const state = stream._readableState;
// Paused: Node has this buffered too, and a later read() returns it.
Expand Down
2 changes: 1 addition & 1 deletion src/js/internal/webstreams_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function tryTransferToNativeReadable(stream, options) {
if (!ptr || ptr === -1) {
return undefined;
}
return require("internal/streams/native-readable").constructNativeReadable(stream, options);
return require("internal/streams/native-readable").constructNativeReadable(stream, options, "webstream");
}

class ReadableFromWeb extends Readable {
Expand Down
Loading
Loading