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
12 changes: 12 additions & 0 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,18 @@ export function windowsEnv(
});
}

export function getRawDebug() {
// process._rawDebug(...args) — undocumented-but-depended-on Node API used by
// Node's own test suite and libraries like pino/thread-stream. Behaves like
// `console.error(util.format(...args))` but writes synchronously to fd 2 so
// it works even while the event loop is blocked or during shutdown.
const { format } = require("node:util");
const { writeSync } = require("node:fs");
return function _rawDebug(...args) {
writeSync(2, format.$apply(null, args) + "\n");
};
Comment on lines +471 to +473
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.

🟡 Minor Node-compat divergence: Node's _rawDebug is backed by native FPrintF(stderr, ...), which silently no-ops when fd 2 is closed or the pipe is broken — but fs.writeSync(2, ...) will throw EBADF/EPIPE in those cases. Since this is meant as a last-resort logger for shutdown/crash paths where stderr may already be in a degraded state, consider wrapping the writeSync in try { ... } catch {} so a failed debug write can't mask the original error or change exit behavior.

Extended reasoning...

What the bug is

Node implements process._rawDebug in lib/internal/process/per_thread.js as a thin wrapper around the native binding _rawDebug (RawDebug() in src/node_process_methods.cc), which calls FPrintF(stderr, ...) followed by fflush(stderr). C stdio writes to a closed or broken FILE* set the stream's error indicator and return a negative value, but never propagate a JavaScript exception — the call simply does nothing visible from JS.

Bun's new implementation at src/js/builtins/ProcessObjectInternals.ts:471-473 uses fs.writeSync(2, ...):

return function _rawDebug(...args) {
  writeSync(2, format.$apply(null, args) + "\n");
};

fs.writeSync throws on any underlying write(2) error. So if fd 2 has been closed (EBADF) or stderr is a pipe whose reader has exited (EPIPE, since SIGPIPE is ignored), _rawDebug will throw where Node would silently continue.

Code path that triggers it

  1. Something puts stderr into a degraded state — e.g. user code calls fs.closeSync(2), or the process was launched as bun script.js 2>&1 | head and head exits after reading enough lines.
  2. A crash/shutdown path (or library like pino/thread-stream, per the PR description) calls process._rawDebug('diagnostic message').
  3. writeSync(2, ...) invokes write(2), which returns -1 with errno = EBADF or EPIPE.
  4. Bun's writeSync converts that into a thrown JS error, which propagates out of _rawDebug.

Why nothing prevents it

There is no try/catch around the writeSync call, and the constructRawDebug C++ wrapper in BunProcess.cpp only catches exceptions thrown while constructing the closure (the one-time getRawDebug() call), not exceptions thrown when the returned _rawDebug function is later invoked.

Step-by-step proof

const fs = require('fs');
fs.closeSync(2);
process._rawDebug('x');
console.log('survived');
  • Node: FPrintF(stderr, "x\n") writes to a closed fd, fprintf returns an error indicator, nothing is thrown into JS. 'survived' is printed to stdout. Exit code 0.
  • Bun (this PR): writeSync(2, "x\n")write(2, ...) = -1, errno=EBADF → throws Error: EBADF: bad file descriptor, write. The uncaught exception sets exit code 1 and 'survived' is never printed.

The EPIPE variant is more realistic in practice: bun -e "for(let i=0;i<1e5;i++) process._rawDebug(i)" 2>&1 | head -1 — once head exits, the next writeSync(2, ...) throws EPIPE in Bun but is silently dropped in Node.

Impact

The PR's own comment describes _rawDebug as something that "works even while the event loop is blocked or during shutdown" — i.e., it's a last-resort logger meant to be called from already-failing code paths. If it throws, it can:

  • Mask the original error being reported (the EBADF/EPIPE becomes the visible failure instead of whatever was being logged).
  • Change exit codes (an otherwise-clean shutdown that tries to log from beforeExit/exit now exits non-zero).
  • Re-enter uncaughtException handlers in surprising ways.

That said, this is a rare edge case: most programs never close fd 2, and stderr's reader dying mid-process is uncommon. The previous Bun behavior was a no-op stub, so this PR is still a strict improvement. And _rawDebug is an undocumented API.

Fix

One-line change — swallow write errors to match Node's C-stdio semantics:

return function _rawDebug(...args) {
  try { writeSync(2, format.$apply(null, args) + "\n"); } catch {}
};

}

export function getChannel() {
const EventEmitter = require("node:events");
const setRef = $newZigFunction("node_cluster_binding.zig", "setRef", 1);
Expand Down
18 changes: 17 additions & 1 deletion src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3944,6 +3944,22 @@ static JSValue constructProcessNextTickFn(VM& vm, JSObject* processObject)
return uncheckedDowncast<Process>(processObject)->constructNextTickFn(JSC::getVM(globalObject), globalObject);
}

static JSValue constructRawDebug(VM& vm, JSObject* processObject)
{
auto* globalObject = processObject->globalObject();
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
JSC::JSFunction* getRawDebug = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetRawDebugCodeGenerator(vm), globalObject);
JSC::MarkedArgumentBuffer args;
JSC::CallData callData = JSC::getCallData(getRawDebug);
auto result = JSC::profiledCall(globalObject, ProfilingReason::API, getRawDebug, callData, globalObject->globalThis(), args);
if (auto* exception = scope.exception()) {
(void)scope.tryClearException();
Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
return jsUndefined();
}
return result;
}

JSC_DEFINE_CUSTOM_GETTER(processNoDeprecation, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName name))
{
return JSValue::encode(jsBoolean(Bun__Node__ProcessNoDeprecation));
Expand Down Expand Up @@ -4271,7 +4287,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu
_kill Process_functionReallyKill Function 2
_linkedBinding Process_stubEmptyFunction Function 0
_preload_modules Process_stubEmptyArray PropertyCallback
_rawDebug Process_stubEmptyFunction Function 0
_rawDebug constructRawDebug PropertyCallback
_startProfilerIdleNotifier Process_stubEmptyFunction Function 0
_stopProfilerIdleNotifier Process_stubEmptyFunction Function 0
_tickCallback Process_stubEmptyFunction Function 0
Expand Down
67 changes: 66 additions & 1 deletion test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,6 @@ describe.concurrent(() => {
"_debugProcess",
"_fatalException",
"_linkedBinding",
"_rawDebug",
"_startProfilerIdleNotifier",
"_stopProfilerIdleNotifier",
"_tickCallback",
Expand Down Expand Up @@ -733,6 +732,72 @@ describe.concurrent(() => {
});
}

describe("process._rawDebug", () => {
it("writes util.format output + newline to stderr synchronously", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `process._rawDebug('hi', {a: 1}, [1, 2]);`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("");
expect(stderr).toBe("hi { a: 1 } [ 1, 2 ]\n");
expect(exitCode).toBe(0);
});

it("handles printf-style format specifiers via util.format", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `process._rawDebug('val: %d', 42); process._rawDebug('%s: %j', 'key', {a: 1});`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe('val: 42\nkey: {"a":1}\n');
expect(exitCode).toBe(0);
});

it("fires from a beforeExit handler during normal shutdown (issue #30934)", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `process.on('beforeExit', () => process._rawDebug('hi'));`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("hi\n");
expect(exitCode).toBe(0);
});

it("prints an empty line when called with no args", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `process._rawDebug();`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("\n");
expect(exitCode).toBe(0);
});

it("returns the same function across accesses (PropertyCallback cache)", async () => {
// The _rawDebug accessor is wired as a PropertyCallback that lazily
// constructs the function once and caches it on the process object.
// Matches Node: `process._rawDebug === process._rawDebug` is true.
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `console.log(process._rawDebug === process._rawDebug);`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("true\n");
expect(exitCode).toBe(0);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("dlopen args parsing", () => {
const notFound = join(tmpdirSync(), "not-found.so");
expect(() => process.dlopen({ module: "42" }, notFound)).toThrow();
Expand Down
Loading