diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 388c8866a96..0c83963317b 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -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"); + }; +} + export function getChannel() { const EventEmitter = require("node:events"); const setRef = $newZigFunction("node_cluster_binding.zig", "setRef", 1); diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6935f0cbbce..f1176a23ff7 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3944,6 +3944,22 @@ static JSValue constructProcessNextTickFn(VM& vm, JSObject* processObject) return uncheckedDowncast(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)); @@ -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 diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b47eca65954..3f968dabe84 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -689,7 +689,6 @@ describe.concurrent(() => { "_debugProcess", "_fatalException", "_linkedBinding", - "_rawDebug", "_startProfilerIdleNotifier", "_stopProfilerIdleNotifier", "_tickCallback", @@ -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); + }); + }); + it("dlopen args parsing", () => { const notFound = join(tmpdirSync(), "not-found.so"); expect(() => process.dlopen({ module: "42" }, notFound)).toThrow();