Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ export function rawDebug() {
// The 'warning' listener itself is a native trampoline registered when `process` is created
// (BunProcess.cpp); this builds the printer it forwards to on the first warning.
export function createOnWarning(process, redirectPath, disabledArr) {
function noop() {}
const appendFileSync = redirectPath ? require("node:fs").appendFileSync : undefined;
// --disable-warning names/codes as a Set: matches Node's SafeSet lookup
// and avoids an FFI + utf8() encode per emit.
Expand All @@ -656,7 +657,38 @@ export function createOnWarning(process, redirectPath, disabledArr) {
// Runtime.consoleAPICalled, and a throwing listener there is surfaced via emitWarning,
// so console.error would re-enter and loop. Read process.stderr per call (like Node) so
// a reassigned process.stderr is honored and stderr is not materialized when redirected.
process.stderr.write(message + "\n");
const stream = process.stderr;
// Node's writeOut goes through console.error, whose kWriteToConsole (ported in
// ConsoleObject.ts) keeps a failing stderr from taking the process down: a sync throw
// (files, TTYs) is swallowed and an async 'error' (EPIPE on a pipe) gets a one-shot noop
// listener. Same here, or `bun x.js 2>&1 | head` dies on its first warning. The guard needs
// the Writable contract (a failed write calls back with the error, then emits 'error') and
// the listener API it arms and disarms; any other stand-in for process.stderr (a test's bare
// { write }, an EventEmitter with a write method, a spread copy of the real stream, which
// keeps _writableState but not the prototype's methods) gets the plain write it always got.
const guarded = stream._writableState !== undefined && typeof stream.removeListener === "function";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
if (guarded) {
if (stream.listenerCount("error") === 0) stream.once("error", noop);
stream.write(message + "\n", err => {
if (err !== null && !stream._writableState.errorEmitted && stream.listenerCount("error") === 0) {
stream.once("error", noop);
}
Comment thread
claude[bot] marked this conversation as resolved.
});
} else {
stream.write(message + "\n");
}
} catch (e) {
if (
e != null &&
typeof e === "object" &&
e.name === "RangeError" &&
e.message === "Maximum call stack size exceeded."
)
throw e;
} finally {
if (guarded) stream.removeListener("error", noop);
}
Comment thread
robobun marked this conversation as resolved.
}

return function onWarning(warning) {
Expand Down
129 changes: 129 additions & 0 deletions test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,135 @@ describe("default 'warning' listener is registered at startup", () => {
});
});

// Node's onWarning writes through console.error, which swallows a failing stderr
// (lib/internal/console/constructor.js kWriteToConsole). Verified against node
// v26.3.0: each of these prints "alive" / "0" and exits 0 there.
describe("default warning printer survives a failing stderr", () => {
const env = { ...bunEnv, NODE_NO_WARNINGS: undefined };

it.concurrent("stderr.write throwing synchronously", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.stderr.write = () => { throw new Error("boom"); };
process.emitWarning("w");
setImmediate(() => console.log("alive"));`,
],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "alive\n", stderr: "", exitCode: 0 });
});

it.concurrent("stderr pipe whose reader is gone (EPIPE)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.stdin.once("data", () => {
process.emitWarning("w");
setImmediate(() => console.log("alive"));
});`,
],
env,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Close our end of stderr before letting the child warn, so the print gets EPIPE.
await proc.stderr.cancel();
proc.stdin.write("go");
await proc.stdin.end();
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect({ stdout, exitCode }).toEqual({ stdout: "alive\n", exitCode: 0 });
});

it.concurrent("leaves no 'error' listener behind on a healthy stderr", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.emitWarning("one");
process.emitWarning("two");
setImmediate(() => console.log(process.stderr.listenerCount("error")));`,
],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toMatch(/Warning: one[\s\S]*Warning: two/);
expect({ stdout, exitCode }).toEqual({ stdout: "0\n", exitCode: 0 });
});

it.concurrent("process.stderr replaced by a bare { write } object", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.stderr = { write(s) { console.log("mock:" + s.trimEnd()); return true; } };
process.emitWarning("w");
setImmediate(() => console.log("alive"));`,
],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toMatch(/^mock:\(node:\d+\) Warning: w\nmock:\(Use `.*--trace-warnings .*\)\nalive\n$/);
expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 });
});

// An emitter that is not a Writable has no _writableState for the write callback to read,
// so the printer must not hand it one. 'beforeExit' only runs if nothing threw later.
it.concurrent("process.stderr replaced by an EventEmitter mock whose write() calls back", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { EventEmitter } = require("node:events");
process.stderr = Object.assign(new EventEmitter(), {
write(s, cb) { console.log("mock:" + s.trimEnd()); if (cb) setImmediate(cb); return true; },
});
process.emitWarning("w");
process.on("beforeExit", () => console.log("alive", process.stderr.listenerCount("error")));`,
],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toMatch(/^mock:\(node:\d+\) Warning: w\nmock:\(Use `.*--trace-warnings .*\)\nalive 0\n$/);
expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 });
});

// The opposite half-stream: spreading the real stderr keeps its own _writableState but none
// of the prototype's listener methods, so the printer must not arm the guard on it either.
it.concurrent("process.stderr replaced by a spread copy of the real stream", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.stderr = { ...process.stderr, write(s) { console.log("mock:" + s.trimEnd()); return true; } };
console.log(process.stderr._writableState !== undefined, typeof process.stderr.removeListener);
process.emitWarning("w");
process.on("beforeExit", () => console.log("alive"));`,
],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toMatch(
/^true undefined\nmock:\(node:\d+\) Warning: w\nmock:\(Use `.*--trace-warnings .*\)\nalive\n$/,
);
expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 });
});
});

it("--disable-warning suppresses print but not user 'warning' listeners", async () => {
await using proc = Bun.spawn({
cmd: [
Expand Down