Skip to content
76 changes: 76 additions & 0 deletions src/js/internal/process/warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Default process warning printer, mirroring Node's lib/internal/process/warning.js
// onWarning. Invoked from BunProcess.cpp's doEmitWarning nextTick handler after
// the 'warning' event fires; it is not installed as a listener (so user
// listeners do not suppress the stderr report, matching Node where onWarning is
// just another listener alongside any user-installed ones).

const ErrorPrototypeToString = Error.prototype.toString;

let traceWarningHelperShown = false;

function hasExecFlag(flag: string): boolean {
const argv = process.execArgv;
for (let i = 0; i < argv.length; i++) if (argv[i] === flag) return true;
return false;
}

function lazyBasename(p: string): string {
let i = p.length;
while (i > 0) {
const c = p.charCodeAt(i - 1);
if (c === 47 /* / */ || c === 92 /* \ */) break;
i--;
}
let base = p.slice(i);
if (process.platform === "win32" && base.length > 4 && base.slice(-4).toLowerCase() === ".exe") {
base = base.slice(0, -4);
}
return base;
}
Comment thread
robobun marked this conversation as resolved.
Resolved

function writeOut(msg: string): void {
const stderr = process.stderr;
if (stderr && typeof stderr.write === "function") {
stderr.write(msg + "\n");
}
}

Check warning on line 36 in src/js/internal/process/warning.ts

View check run for this annotation

Claude / Claude Code Review

writeOut() diverges from Node: uses process.stderr.write instead of console.error + _rawDebug fallback

Nit: Node's `writeOut` routes through `console.error` (lazy-cached from `internal/console/global`) with a `process._rawDebug` fallback, not directly through `process.stderr.write`. Bytes on stderr are identical in the common case, but when `process.stderr`/.write is unavailable this port silently drops the message where Node falls back. Consider matching the reference since the file header claims to mirror it.
Comment thread
robobun marked this conversation as resolved.
Resolved

function onWarning(warning: Error): void {
if (!(warning instanceof Error)) return;
const isDeprecation = warning.name === "DeprecationWarning";
if (isDeprecation && process.noDeprecation) return;
const trace =
(process as any).traceProcessWarnings ||
hasExecFlag("--trace-warnings") ||
(isDeprecation && ((process as any).traceDeprecation || hasExecFlag("--trace-deprecation")));
let msg = `(${process.release?.name || "node"}:${process.pid}) `;
const code = (warning as any).code;
if (code) msg += `[${code}] `;
let stack: unknown;
if (trace && (stack = warning.stack)) {
msg += `${stack}`;
} else {
let body: string;
try {
body = typeof warning.toString === "function" ? `${warning.toString()}` : ErrorPrototypeToString.$call(warning);
} catch {
body = ErrorPrototypeToString.$call(warning);
}
msg += body;
}
const detail = (warning as any).detail;
if (typeof detail === "string") {
msg += `\n${detail}`;
}
if (!trace && !traceWarningHelperShown) {
const flag = isDeprecation ? "--trace-deprecation" : "--trace-warnings";
const argv0 = lazyBasename(process.argv0 || "node");
msg += `\n(Use \`${argv0} ${flag} ...\` to show where the warning was created)`;
traceWarningHelperShown = true;
}
writeOut(msg);
}

export default {
onWarning,
};
53 changes: 24 additions & 29 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1618,15 +1618,24 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_emitWarning, (JSC::JSGlobalObject * lexicalG
auto value = callFrame->argument(0);

auto ident = builtinNames(vm).warningPublicName();
if (process->wrapped().hasEventListeners(ident)) {
JSC::MarkedArgumentBuffer args;
args.append(value);
process->wrapped().emit(ident, args);
return JSValue::encode(jsUndefined());
} else if (!Bun__NODE_NO_WARNINGS()) {
auto jsArgs = JSValue::encode(value);
Bun__ConsoleObject__messageWithTypeAndLevel(reinterpret_cast<Bun::ConsoleObject*>(globalObject->consoleClient().get())->m_client, static_cast<uint32_t>(MessageType::Log), static_cast<uint32_t>(MessageLevel::Warning), globalObject, &jsArgs, 1);
JSC::MarkedArgumentBuffer args;
args.append(value);
process->wrapped().emit(ident, args);
RETURN_IF_EXCEPTION(scope, {});

// Node's default onWarning printer runs alongside any user 'warning'
// listeners; mirror that by always printing unless --no-warnings.
if (!Bun__NODE_NO_WARNINGS()) {
auto module = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::InternalProcessWarning);
RETURN_IF_EXCEPTION(scope, {});
auto onWarning = module.get(globalObject, Identifier::fromString(vm, "onWarning"_s));
RETURN_IF_EXCEPTION(scope, {});
if (onWarning.isCallable()) {
JSC::MarkedArgumentBuffer printArgs;
printArgs.append(value);
JSC::call(globalObject, onWarning, printArgs, "onWarning must be callable"_s);
RETURN_IF_EXCEPTION(scope, {});
}
Comment thread
robobun marked this conversation as resolved.
Resolved
}
return JSValue::encode(jsUndefined());
}
Expand Down Expand Up @@ -1995,33 +2004,19 @@ JSValue Process::emitWarning(JSC::JSGlobalObject* lexicalGlobalObject, JSValue w
auto s = warning.getString(globalObject);
errorInstance = createError(globalObject, !s.isEmpty() ? s : "Warning"_s);
errorInstance->putDirect(vm, vm.propertyNames->name, type, JSC::PropertyAttribute::DontEnum | 0);
if (!code.isUndefined()) errorInstance->putDirect(vm, builtinNames(vm).codePublicName(), code, JSC::PropertyAttribute::DontEnum | 0);
if (!detail.isUndefined()) errorInstance->putDirect(vm, vm.propertyNames->detail, detail, JSC::PropertyAttribute::DontEnum | 0);
// Re-capture past ctor (util.deprecate passes its wrapper). Without
// one, getFramesForCaller's framesToSkip=1 drops whichever native host
// function reached here (process.emitWarning, generateKeyPair, ...).
Bun::captureStackTraceForError(globalObject, errorInstance, ctor.isCallable() ? ctor : jsUndefined());
RETURN_IF_EXCEPTION(scope, {});
} else if (warning.isCell() && warning.asCell()->type() == ErrorInstanceType) {
errorInstance = warning.getObject();
} else {
return JSValue::decode(Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "warning"_s, "string or Error"_s, warning));
}

if (!code.isUndefined()) errorInstance->putDirect(vm, builtinNames(vm).codePublicName(), code, JSC::PropertyAttribute::DontEnum | 0);
if (!detail.isUndefined()) errorInstance->putDirect(vm, vm.propertyNames->detail, detail, JSC::PropertyAttribute::DontEnum | 0);

/*
// TODO: ErrorCaptureStackTrace(warning, ctor || process.emitWarning);
// This doesn't work, getStackTrace does not get any stack frames.
Vector<StackFrame> stackTrace;
const size_t framesToSkip = 1;
JSValue caller;
if (ctor.toBoolean(globalObject)) {
caller = ctor;
} else {
auto* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
auto* process = globalObject->processObject();
caller = process->get(globalObject, Identifier::fromString(vm, String("emitWarning"_s)));
RETURN_IF_EXCEPTION(scope, {});
}
vm.interpreter.getStackTrace(errorInstance, stackTrace, framesToSkip, globalObject->stackTraceLimit().value_or(0), caller.isCallable() ? caller.asCell() : nullptr);
errorInstance->putDirect(vm, vm.propertyNames->stack, jsString(vm, Interpreter::stackTraceAsString(vm, stackTrace)), static_cast<unsigned>(PropertyAttribute::DontEnum));
*/

RELEASE_AND_RETURN(scope, emitWarningErrorInstance(lexicalGlobalObject, errorInstance));
}

Expand Down
46 changes: 26 additions & 20 deletions src/jsc/bindings/FormatStackTraceForJS.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -756,43 +756,31 @@ JSC_DEFINE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter, (JSGlobalObject * g
return true;
}

JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
void captureStackTraceForError(Zig::GlobalObject* globalObject, JSC::JSObject* errorObject, JSC::JSValue caller)
{
Zig::GlobalObject* globalObject = static_cast<Zig::GlobalObject*>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

JSC::JSValue objectArg = callFrame->argument(0);
if (!objectArg.isObject()) {
return JSC::JSValue::encode(throwTypeError(lexicalGlobalObject, scope, "invalid_argument"_s));
}

JSC::JSObject* errorObject = objectArg.asCell()->getObject();
JSC::JSValue caller = callFrame->argument(1);

size_t stackTraceLimit = globalObject->stackTraceLimit().value_or(DEFAULT_ERROR_STACK_TRACE_LIMIT);
if (stackTraceLimit == 0) {
stackTraceLimit = DEFAULT_ERROR_STACK_TRACE_LIMIT;
}

WTF::Vector<JSC::StackFrame> stackTrace;
JSCStackTrace::getFramesForCaller(vm, callFrame, errorObject, caller, stackTrace, stackTraceLimit);
JSCStackTrace::getFramesForCaller(vm, nullptr, errorObject, caller, stackTrace, stackTraceLimit);

if (auto* instance = dynamicDowncast<JSC::ErrorInstance>(errorObject)) {
if (instance->hasMaterializedErrorInfo()) {
// Error info was already materialized (e.g. .stack was previously accessed).
// Don't call setStackFrames — it would leave m_errorInfoMaterialized=true with
// a non-null m_stackTrace, causing ASSERT(!m_errorInfoMaterialized) in
// computeErrorInfo when GC's finalizeUnconditionally finds unmarked frames.
// Eagerly compute and set the .stack property instead.
// Already materialized: setStackFrames here would later hit
// ASSERT(!m_errorInfoMaterialized) in computeErrorInfo during GC.
// Compute and write the .stack string eagerly instead.
OrdinalNumber line;
OrdinalNumber column;
String sourceURL;
JSValue result = computeErrorInfoToJSValue(vm, stackTrace, line, column, sourceURL, errorObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
RETURN_IF_EXCEPTION(scope, );
errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0);
} else {
// Not yet materialized — safe to install new frames with a lazy getter.
instance->setStackFrames(vm, WTF::move(stackTrace));

{
Expand All @@ -801,7 +789,7 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb
DeletePropertySlot slot;
JSObject::deleteProperty(instance, globalObject, propertyName, slot);
}
RETURN_IF_EXCEPTION(scope, {});
RETURN_IF_EXCEPTION(scope, );

instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, globalObject->m_lazyStackCustomGetterSetter.get(globalObject), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor | 0);
}
Expand All @@ -810,9 +798,27 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb
OrdinalNumber column;
String sourceURL;
JSValue result = computeErrorInfoToJSValue(vm, stackTrace, line, column, sourceURL, errorObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
RETURN_IF_EXCEPTION(scope, );
errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0);
}
}

JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
Zig::GlobalObject* globalObject = static_cast<Zig::GlobalObject*>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

JSC::JSValue objectArg = callFrame->argument(0);
if (!objectArg.isObject()) {
return JSC::JSValue::encode(throwTypeError(lexicalGlobalObject, scope, "invalid_argument"_s));
}

JSC::JSObject* errorObject = objectArg.asCell()->getObject();
JSC::JSValue caller = callFrame->argument(1);

captureStackTraceForError(globalObject, errorObject, caller);
RETURN_IF_EXCEPTION(scope, {});

return JSC::JSValue::encode(JSC::jsUndefined());
}
Expand Down
4 changes: 4 additions & 0 deletions src/jsc/bindings/FormatStackTraceForJS.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ WTF::String formatStackTrace(
WTF::Vector<JSC::StackFrame>& stackTrace,
JSC::JSObject* errorInstance);

// Shared body of Error.captureStackTrace: collect frames (filtered past
// `caller` when it is a function) and install them on `errorObject`.
void captureStackTraceForError(Zig::GlobalObject* globalObject, JSC::JSObject* errorObject, JSC::JSValue caller);

// JSC Host Functions - Error constructor methods
JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace);
JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncAppendStackTrace);
Expand Down
136 changes: 136 additions & 0 deletions test/js/node/process/process-warning-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

async function run(execArgv: string[], src: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...execArgv, "-e", src],
env: { ...bunEnv, NODE_NO_WARNINGS: undefined },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
Comment thread
robobun marked this conversation as resolved.
Resolved

describe("process.emitWarning default stderr report", () => {
test.concurrent("a user 'warning' listener does not suppress the stderr report", async () => {
const src = `
const util = require("node:util");
process.on("warning", () => {});
util.deprecate(() => 1, "dep msg A", "DEP0T1")();
setTimeout(() => {}, 30);
`;
const { stderr, exitCode } = await run([], src);
const first = stderr.split("\n")[0];
expect(first).toMatch(/^\(\w+:\d+\) \[DEP0T1\] DeprecationWarning: dep msg A$/);
expect(exitCode).toBe(0);
});

test.concurrent("default report uses Node's one-line (name:pid) [CODE] form", async () => {
const src = `
const util = require("node:util");
util.deprecate(() => 1, "dep msg B", "DEP0T2")();
`;
const { stderr, exitCode } = await run([], src);
const lines = stderr.split("\n");
expect(lines[0]).toMatch(/^\(\w+:\d+\) \[DEP0T2\] DeprecationWarning: dep msg B$/);
expect(lines[1]).toMatch(/--trace-deprecation/);
// No stack frames printed by default.
expect(stderr).not.toMatch(/^\s+at /m);
expect(exitCode).toBe(0);
});

test.concurrent("process.emitWarning with code and detail formats Node-style", async () => {
const src = `process.emitWarning("plain msg", { type: "CustomWarning", code: "CODE9", detail: "some detail" });`;
const { stderr, exitCode } = await run([], src);
const lines = stderr.split("\n");
expect(lines[0]).toMatch(/^\(\w+:\d+\) \[CODE9\] CustomWarning: plain msg$/);
expect(lines[1]).toBe("some detail");
expect(lines[2]).toMatch(/--trace-warnings/);
expect(exitCode).toBe(0);
});

test.concurrent("--throw-deprecation does not leak internal builtin source", async () => {
const src = `
const util = require("node:util");
util.deprecate(() => 1, "dep msg C", "DEP0T3")();
`;
const { stderr, exitCode } = await run(["--throw-deprecation"], src);
// The thrown DeprecationWarning's stack must start at the user's call
// site, not inside the internal util/deprecate emitter, so no builtin
// source snippet (with raw @-intrinsic syntax) should appear.
expect(stderr).not.toMatch(/getDeprecationWarningEmitter/);
expect(stderr).not.toMatch(/@undefined/);
expect(stderr).not.toMatch(/internal:util\/deprecate/);
expect(stderr).toContain("DeprecationWarning: dep msg C");
expect(exitCode).toBe(1);
});

test.concurrent("NODE_NO_WARNINGS=1 still delivers the event but suppresses stderr", async () => {
const src = `
process.on("warning", w => console.log("event:" + w.name + ":" + w.code));
process.emitWarning("hush", "DeprecationWarning", "DEP0T4");
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: { ...bunEnv, NODE_NO_WARNINGS: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toMatch(/DEP0T4|DeprecationWarning/);
expect(stdout).toBe("event:DeprecationWarning:DEP0T4\n");
expect(exitCode).toBe(0);
});

test.concurrent("--trace-deprecation prints the warning stack instead of the hint", async () => {
const src = `
const util = require("node:util");
util.deprecate(() => 1, "dep msg E", "DEP0T5")();
`;
const { stderr, exitCode } = await run(["--trace-deprecation"], src);
expect(stderr.split("\n")[0]).toMatch(/^\(\w+:\d+\) \[DEP0T5\] DeprecationWarning: dep msg E$/);
expect(stderr).toMatch(/^\s+at /m);
expect(stderr).not.toMatch(/internal:util\/deprecate/);
expect(stderr).not.toContain("--trace-deprecation ...");
expect(exitCode).toBe(0);
});

test.concurrent("passing an Error instance leaves it unmutated even when ctor/code/detail are given", async () => {
const src = `
function outer() {
const e = new Error("boom");
const orig = e.stack;
process.emitWarning(e, { ctor: outer, code: "X", detail: "Y" });
process.emitWarning(e, "Type", "CODE", outer);
console.log(JSON.stringify({ stack: e.stack === orig, code: e.code, detail: e.detail }));
}
outer();
`;
const { stdout, exitCode } = await run([], src);
expect(JSON.parse(stdout)).toEqual({ stack: true, code: undefined, detail: undefined });
expect(exitCode).toBe(0);
});

test.concurrent("--trace-warnings prints the warning stack for non-deprecation warnings", async () => {
const src = `process.emitWarning("trace me", "CustomWarning", "CODE10");`;
const { stderr, exitCode } = await run(["--trace-warnings"], src);
expect(stderr.split("\n")[0]).toMatch(/^\(\w+:\d+\) \[CODE10\] CustomWarning: trace me$/);
expect(stderr).toMatch(/^\s+at /m);
expect(stderr).not.toMatch(/at emitWarning/);
expect(stderr).not.toContain("--trace-warnings ...");
expect(exitCode).toBe(0);
});

test.concurrent("process.traceDeprecation = true enables stack printing at runtime", async () => {
const src = `
process.traceDeprecation = true;
require("node:util").deprecate(() => 1, "dep msg F", "DEP0T6")();
`;
const { stderr, exitCode } = await run([], src);
expect(stderr.split("\n")[0]).toMatch(/^\(\w+:\d+\) \[DEP0T6\] DeprecationWarning: dep msg F$/);
expect(stderr).toMatch(/^\s+at /m);
expect(stderr).not.toContain("--trace-deprecation ...");
expect(exitCode).toBe(0);
});
});
Loading