From c8aa860989d34e98e2e62bacdc606f647708cd89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:23:41 +0000 Subject: [PATCH 1/6] Error.captureStackTrace: lazily compute .stack header on non-Error targets When the target of Error.captureStackTrace is not a native JSC ErrorInstance (a plain object, or a function-based Error subclass whose prototype is Object.create(Error.prototype), as used by jsonwebtoken), Bun eagerly formatted the stack string at capture time with a hardcoded "Error" header, ignoring the target's own .name/.message. V8 installs a lazy accessor and reads name/message at first .stack access, so setting them after capture is observable. This makes the non-ErrorInstance path match the existing ErrorInstance path: build the sourcemapped CallSite array at capture time, stash it under a private name on the target, and install a lazy custom getter that reads name/message (via Error.prototype.toString's algorithm) and consults Error.prepareStackTrace at first access. Also fixes formatStackTraceToJSValue's header to read .name instead of hardcoding "Error: ", so the header inside prepareStackTrace callbacks is correct for any target. Fixes the .stack property for the JsonWebTokenError pattern in #13904. --- src/js/builtins/BunBuiltinNames.h | 1 + src/jsc/bindings/FormatStackTraceForJS.cpp | 103 +++++++++++++++--- src/jsc/bindings/FormatStackTraceForJS.h | 1 + src/jsc/bindings/ZigGlobalObject.cpp | 5 + src/jsc/bindings/ZigGlobalObject.h | 1 + test/js/node/v8/capture-stack-trace.test.js | 113 +++++++++++++++++++- 6 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b6495c2eea2b..589dc7be0a0a 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -61,6 +61,7 @@ using namespace JSC; macro(byobRequest) \ macro(bytes) \ macro(cancel) \ + macro(capturedStackTrace) \ macro(checkBufferRead) \ macro(checks) \ macro(cloneArrayBuffer) \ diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..e295592dbbb5 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -41,21 +41,36 @@ static JSValue formatStackTraceToJSValue(JSC::VM& vm, Zig::GlobalObject* globalO WTF::StringBuilder sb; + WTF::String name = "Error"_s; + auto errorName = errorObject->getIfPropertyExists(lexicalGlobalObject, vm.propertyNames->name); + RETURN_IF_EXCEPTION(scope, {}); + if (errorName && !errorName.isUndefined()) { + auto* str = errorName.toString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto value = str->value(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + name = value.data; + } + + WTF::String message; auto errorMessage = errorObject->getIfPropertyExists(lexicalGlobalObject, vm.propertyNames->message); RETURN_IF_EXCEPTION(scope, {}); - if (errorMessage) { + if (errorMessage && !errorMessage.isUndefined()) { auto* str = errorMessage.toString(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, {}); - if (str->length() > 0) { - auto value = str->view(lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, {}); - sb.append("Error: "_s); - sb.append(value.data); - } else { - sb.append("Error"_s); - } + auto value = str->value(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + message = value.data; + } + + if (name.isEmpty()) { + sb.append(message); + } else if (message.isEmpty()) { + sb.append(name); } else { - sb.append("Error"_s); + sb.append(name); + sb.append(": "_s); + sb.append(message); } for (size_t i = 0; i < framesCount; i++) { @@ -428,7 +443,7 @@ static String computeErrorInfoWithoutPrepareStackTrace( return Bun::formatStackTrace(vm, globalObject, lexicalGlobalObject, name, message, line, column, sourceURL, stackTrace, errorInstance); } -static JSValue computeErrorInfoWithPrepareStackTrace(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSGlobalObject* lexicalGlobalObject, Vector& stackFrames, OrdinalNumber& line, OrdinalNumber& column, String& sourceURL, JSObject* errorObject, JSObject* prepareStackTrace) +static JSArray* buildSourceMappedCallSitesArray(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSGlobalObject* lexicalGlobalObject, Vector& stackFrames) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -513,6 +528,19 @@ static JSValue computeErrorInfoWithPrepareStackTrace(JSC::VM& vm, Zig::GlobalObj JSArray* callSitesArray = JSC::constructArray(globalObject, globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), callSites); RETURN_IF_EXCEPTION(scope, {}); + return callSitesArray; +} + +static JSValue computeErrorInfoWithPrepareStackTrace(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSGlobalObject* lexicalGlobalObject, Vector& stackFrames, OrdinalNumber& line, OrdinalNumber& column, String& sourceURL, JSObject* errorObject, JSObject* prepareStackTrace) +{ + UNUSED_PARAM(line); + UNUSED_PARAM(column); + UNUSED_PARAM(sourceURL); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSArray* callSitesArray = buildSourceMappedCallSitesArray(vm, globalObject, lexicalGlobalObject, stackFrames); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, formatStackTraceToJSValue(vm, globalObject, lexicalGlobalObject, errorObject, callSitesArray, prepareStackTrace)); } @@ -756,6 +784,42 @@ JSC_DEFINE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter, (JSGlobalObject * g return true; } +// Lazy .stack getter installed by Error.captureStackTrace on objects that are +// not JSC::ErrorInstance. The captured CallSite array is stashed under a +// private name on the target so the header (name/message) and +// Error.prepareStackTrace are read at first access, matching V8. +JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* errorObject = JSValue::decode(thisValue).getObject(); + if (!errorObject) [[unlikely]] + return JSValue::encode(jsUndefined()); + + const auto& privateName = WebCore::builtinNames(vm).capturedStackTracePrivateName(); + JSValue callSitesValue = errorObject->getDirect(vm, privateName); + auto* callSites = callSitesValue ? dynamicDowncast(callSitesValue) : nullptr; + if (!callSites) [[unlikely]] + return JSValue::encode(jsUndefined()); + + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + + JSValue result; + if (globalObject->isInsideErrorPrepareStackTraceCallback) { + result = formatStackTraceToJSValue(vm, globalObject, lexicalGlobalObject, errorObject, callSites); + } else { + globalObject->isInsideErrorPrepareStackTraceCallback = true; + result = formatStackTraceToJSValueWithoutPrepareStackTrace(vm, globalObject, lexicalGlobalObject, errorObject, callSites); + globalObject->isInsideErrorPrepareStackTraceCallback = false; + } + RETURN_IF_EXCEPTION(scope, {}); + + errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0); + errorObject->putDirect(vm, privateName, jsUndefined(), 0); + return JSValue::encode(result); +} + JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(lexicalGlobalObject); @@ -806,12 +870,19 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, globalObject->m_lazyStackCustomGetterSetter.get(globalObject), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor | 0); } } else { - OrdinalNumber line; - OrdinalNumber column; - String sourceURL; - JSValue result = computeErrorInfoToJSValue(vm, stackTrace, line, column, sourceURL, errorObject, nullptr); + JSArray* callSitesArray = buildSourceMappedCallSitesArray(vm, globalObject, lexicalGlobalObject, stackTrace); + RETURN_IF_EXCEPTION(scope, {}); + + { + const auto& propertyName = vm.propertyNames->stack; + VM::DeletePropertyModeScope deleteScope(vm, VM::DeletePropertyMode::IgnoreConfigurable); + DeletePropertySlot slot; + JSObject::deleteProperty(errorObject, globalObject, propertyName, slot); + } RETURN_IF_EXCEPTION(scope, {}); - errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0); + + errorObject->putDirect(vm, WebCore::builtinNames(vm).capturedStackTracePrivateName(), callSitesArray, 0); + errorObject->putDirectCustomAccessor(vm, vm.propertyNames->stack, globalObject->m_nonErrorLazyStackCustomGetterSetter.get(globalObject), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor | 0); } return JSC::JSValue::encode(JSC::jsUndefined()); diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index fc9515a4668b..810dfb099c1f 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -78,6 +78,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionDefaultErrorPrepareStackTrace); // JSC Custom Accessors - error.stack getter/setter JSC_DECLARE_CUSTOM_GETTER(errorInstanceLazyStackCustomGetter); JSC_DECLARE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter); +JSC_DECLARE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter); // Internal wrapper functions for JSC error info callbacks WTF::String computeErrorInfoWrapperToString(JSC::VM& vm, WTF::Vector& stackTrace, unsigned int& line_in, unsigned int& column_in, WTF::String& sourceURL, void* bunErrorData); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a45cf0016dfe..3137d13dcf78 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2112,6 +2112,11 @@ void GlobalObject::finishCreation(VM& vm) init.set(CustomGetterSetter::create(init.vm, errorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter)); }); + m_nonErrorLazyStackCustomGetterSetter.initLater( + [](const Initializer& init) { + init.set(CustomGetterSetter::create(init.vm, nonErrorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter)); + }); + m_JSDOMFileConstructor.initLater( [](const Initializer& init) { JSObject* fileConstructor = Bun::createJSDOMFileConstructor(init.vm, init.owner); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index ca759a74da9e..d47d5374c11a 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -659,6 +659,7 @@ class GlobalObject : public Bun::GlobalScope { V(public, LazyPropertyOfGlobalObject, m_performanceObject) \ V(public, LazyPropertyOfGlobalObject, m_processObject) \ V(public, LazyPropertyOfGlobalObject, m_lazyStackCustomGetterSetter) \ + V(public, LazyPropertyOfGlobalObject, m_nonErrorLazyStackCustomGetterSetter) \ V(public, LazyPropertyOfGlobalObject, m_ServerRouteListStructure) \ V(public, LazyPropertyOfGlobalObject, m_JSBunRequestStructure) \ V(public, LazyPropertyOfGlobalObject, m_JSBunRequestParamsPrototype) \ diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 6cd46a1ad90a..f34fd3c1693c 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -339,10 +339,12 @@ test("Error.captureStackTrace installs .stack as non-enumerable", () => { try { const o2 = {}; Error.captureStackTrace(o2); - expect(insidePrepare).toEqual({ keys: [], enumerable: false }); + // V8 invokes prepareStackTrace lazily on first .stack access, not at capture time. + expect(insidePrepare).toBeUndefined(); expect(Object.keys(o2)).toEqual([]); expectNonEnumerableStack(o2); expect(o2.stack).toBe("from-prepare"); + expect(insidePrepare).toEqual({ keys: [], enumerable: false }); } finally { Error.prepareStackTrace = origPrepareStackTrace; } @@ -1121,3 +1123,112 @@ test("lazy error-info materialization does not store an empty stack value when t }); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/13904 +test("captureStackTrace on a non-Error object reads name/message lazily for the stack header", () => { + // V8 installs a lazy accessor: the header is derived from .name/.message at + // first access, so setting them after capture is observable. This is the + // jsonwebtoken JsonWebTokenError shape. + function JsonWebTokenError(message) { + Error.call(this, message); + Error.captureStackTrace(this, this.constructor); + this.name = "JsonWebTokenError"; + this.message = message; + } + JsonWebTokenError.prototype = Object.create(Error.prototype); + JsonWebTokenError.prototype.constructor = JsonWebTokenError; + + function hello() { + return [new JsonWebTokenError("Hello world")]; + } + noInline(hello); + const [e] = hello(); + + expect(e.stack.split("\n")[0]).toBe("JsonWebTokenError: Hello world"); + expect(e.stack).toContain("at hello"); + expect(e instanceof Error).toBe(true); + + // plain object, name/message set after capture + const o = {}; + Error.captureStackTrace(o); + o.name = "CustomName"; + o.message = "custom msg"; + expect(o.stack.split("\n")[0]).toBe("CustomName: custom msg"); + + // lazy means "first access wins": later mutations are not reflected + o.name = "Changed"; + expect(o.stack.split("\n")[0]).toBe("CustomName: custom msg"); +}); + +test("captureStackTrace on a non-Error object installs a lazy accessor", () => { + const o = {}; + Error.captureStackTrace(o); + const d = Object.getOwnPropertyDescriptor(o, "stack"); + expect({ + hasGetter: typeof d.get, + hasSetter: typeof d.set, + value: d.value, + enumerable: d.enumerable, + configurable: d.configurable, + }).toEqual({ + hasGetter: "function", + hasSetter: "function", + value: undefined, + enumerable: false, + configurable: true, + }); + + // setter replaces the lazy accessor with the assigned value + o.stack = "overwritten"; + expect(o.stack).toBe("overwritten"); + + // captureStackTrace again re-installs the lazy accessor + Error.captureStackTrace(o); + expect(typeof Object.getOwnPropertyDescriptor(o, "stack").get).toBe("function"); + expect(typeof o.stack).toBe("string"); + expect(o.stack).toContain("at "); +}); + +test("captureStackTrace header on a non-Error object matches V8's Error.prototype.toString algorithm", () => { + const headerOf = obj => { + Error.captureStackTrace(obj); + return obj.stack.split("\n")[0]; + }; + // exact outputs verified against Node + expect(headerOf({})).toBe("Error"); + expect(headerOf({ name: "N" })).toBe("N"); + expect(headerOf({ message: "M" })).toBe("Error: M"); + expect(headerOf({ name: "N", message: "M" })).toBe("N: M"); + expect(headerOf({ name: "", message: "M" })).toBe("M"); + expect(headerOf({ name: "N", message: "" })).toBe("N"); + expect(headerOf({ name: "", message: "" })).toBe(""); + expect(headerOf({ name: undefined, message: "M" })).toBe("Error: M"); + expect(headerOf({ name: null, message: "M" })).toBe("null: M"); + expect(headerOf({ name: 42, message: "M" })).toBe("42: M"); + expect(headerOf(Object.create(Error.prototype))).toBe("Error"); +}); + +test("captureStackTrace on a non-Error object invokes Error.prepareStackTrace at access time", () => { + let callCount = 0; + let sawName; + const o = {}; + Error.captureStackTrace(o); + o.name = "LateName"; + Error.prepareStackTrace = (err, sites) => { + callCount++; + sawName = err.name; + return { err, sites }; + }; + const result = o.stack; + expect(callCount).toBe(1); + expect(sawName).toBe("LateName"); + expect(result.err).toBe(o); + expect(Array.isArray(result.sites)).toBe(true); + expect(result.sites.length).toBeGreaterThan(0); + expect(typeof result.sites[0].getFileName).toBe("function"); + + // cached after first access: prepareStackTrace not re-invoked + Error.prepareStackTrace = origPrepareStackTrace; + expect(o.stack).toBe(result); + expect(callCount).toBe(1); +}); From 51978f9b21499c75a7ca535afd5b96d6469b767c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:44:30 +0000 Subject: [PATCH 2/6] address review: async prefix in CallSite.formatAsString, detach before re-entry, proto-chain lookup, dedicated setter CallSite::formatAsString now emits "async " for await-chain frames, matching V8's CallSite.prototype.toString() and preserving the prefix that Bun::formatStackTrace already rendered on the pre-lazy path. The non-ErrorInstance lazy getter now clears the private CallSite slot before reading name/message so a getter that reads this.stack terminates at the !callSites guard instead of recursing to stack overflow, walks the prototype chain so Object.create(target).stack resolves, and is paired with a dedicated setter that releases the CallSite array when .stack is overwritten before first read. --- src/jsc/bindings/CallSite.cpp | 4 +++ src/jsc/bindings/FormatStackTraceForJS.cpp | 35 ++++++++++++++++--- src/jsc/bindings/FormatStackTraceForJS.h | 1 + src/jsc/bindings/ZigGlobalObject.cpp | 2 +- test/js/node/v8/capture-stack-trace.test.js | 38 +++++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/CallSite.cpp b/src/jsc/bindings/CallSite.cpp index dbfee33bc406..0a22d4078ea5 100644 --- a/src/jsc/bindings/CallSite.cpp +++ b/src/jsc/bindings/CallSite.cpp @@ -125,6 +125,10 @@ void CallSite::formatAsString(JSC::VM& vm, JSC::JSGlobalObject* globalObject, WT std::optional column = columnNumber().zeroBasedInt() >= 0 ? std::optional(columnNumber()) : std::nullopt; std::optional line = lineNumber().zeroBasedInt() >= 0 ? std::optional(lineNumber()) : std::nullopt; + if (isAsync()) { + sb.append("async "_s); + } + if (functionName.length() > 0) { if (isConstructor()) { diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index e295592dbbb5..510d52b52ba6 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -793,16 +793,32 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSObject* errorObject = JSValue::decode(thisValue).getObject(); - if (!errorObject) [[unlikely]] + JSObject* receiver = JSValue::decode(thisValue).getObject(); + if (!receiver) [[unlikely]] return JSValue::encode(jsUndefined()); + // The accessor may be reached via the prototype chain; the CallSite array + // lives on the object captureStackTrace was called with. const auto& privateName = WebCore::builtinNames(vm).capturedStackTracePrivateName(); - JSValue callSitesValue = errorObject->getDirect(vm, privateName); - auto* callSites = callSitesValue ? dynamicDowncast(callSitesValue) : nullptr; + JSObject* errorObject = nullptr; + JSC::JSArray* callSites = nullptr; + for (JSObject* o = receiver; o; ) { + JSValue v = o->getDirect(vm, privateName); + if (auto* arr = v ? dynamicDowncast(v) : nullptr) { + callSites = arr; + errorObject = o; + break; + } + JSValue proto = o->getPrototypeDirect(); + o = proto.isObject() ? asObject(proto) : nullptr; + } if (!callSites) [[unlikely]] return JSValue::encode(jsUndefined()); + // Detach before running any user code (name/message getters, prepareStackTrace) + // so a re-entrant .stack read terminates at the !callSites guard above. + errorObject->putDirect(vm, privateName, jsUndefined(), 0); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); JSValue result; @@ -816,10 +832,19 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject RETURN_IF_EXCEPTION(scope, {}); errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0); - errorObject->putDirect(vm, privateName, jsUndefined(), 0); return JSValue::encode(result); } +JSC_DEFINE_CUSTOM_SETTER(nonErrorInstanceLazyStackCustomSetter, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, PropertyName)) +{ + auto& vm = JSC::getVM(globalObject); + if (auto* object = JSValue::decode(thisValue).getObject()) { + object->putDirect(vm, vm.propertyNames->stack, JSValue::decode(value), JSC::PropertyAttribute::DontEnum | 0); + object->putDirect(vm, WebCore::builtinNames(vm).capturedStackTracePrivateName(), jsUndefined(), 0); + } + return true; +} + JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(lexicalGlobalObject); diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index 810dfb099c1f..47c734690057 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -79,6 +79,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionDefaultErrorPrepareStackTrace); JSC_DECLARE_CUSTOM_GETTER(errorInstanceLazyStackCustomGetter); JSC_DECLARE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter); JSC_DECLARE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter); +JSC_DECLARE_CUSTOM_SETTER(nonErrorInstanceLazyStackCustomSetter); // Internal wrapper functions for JSC error info callbacks WTF::String computeErrorInfoWrapperToString(JSC::VM& vm, WTF::Vector& stackTrace, unsigned int& line_in, unsigned int& column_in, WTF::String& sourceURL, void* bunErrorData); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 3137d13dcf78..21f819f5f8af 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2114,7 +2114,7 @@ void GlobalObject::finishCreation(VM& vm) m_nonErrorLazyStackCustomGetterSetter.initLater( [](const Initializer& init) { - init.set(CustomGetterSetter::create(init.vm, nonErrorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter)); + init.set(CustomGetterSetter::create(init.vm, nonErrorInstanceLazyStackCustomGetter, nonErrorInstanceLazyStackCustomSetter)); }); m_JSDOMFileConstructor.initLater( diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index f34fd3c1693c..5713df2e5816 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1208,6 +1208,44 @@ test("captureStackTrace header on a non-Error object matches V8's Error.prototyp expect(headerOf(Object.create(Error.prototype))).toBe("Error"); }); +test("captureStackTrace on a non-Error object preserves the async prefix on await-chain frames", async () => { + async function inner() { + await 1; + const o = {}; + Error.captureStackTrace(o); + return o; + } + noInline(inner); + async function outer() { + return await inner(); + } + noInline(outer); + const o = await outer(); + expect(o.stack).toContain("at async outer"); +}); + +test("captureStackTrace on a non-Error object terminates when a name/message getter reads .stack", () => { + const o = Object.create(null); + Object.defineProperty(o, "message", { + get() { + return String(this.stack); + }, + }); + Error.captureStackTrace(o); + expect(() => o.stack).not.toThrow(); + expect(typeof o.stack).toBe("string"); +}); + +test("captureStackTrace lazy .stack resolves when reached via the prototype chain", () => { + const parent = {}; + Error.captureStackTrace(parent); + const child = Object.create(parent); + expect(typeof child.stack).toBe("string"); + expect(child.stack.split("\n")[0]).toBe("Error"); + expect(child.stack).toContain("at "); + expect(parent.stack).toBe(child.stack); +}); + test("captureStackTrace on a non-Error object invokes Error.prepareStackTrace at access time", () => { let callCount = 0; let sawName; From 00191e3db7aef0e087f17ad5a85e8c93013bc233 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:46:31 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- src/jsc/bindings/FormatStackTraceForJS.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index 510d52b52ba6..694b2cc8a960 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -802,7 +802,7 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject const auto& privateName = WebCore::builtinNames(vm).capturedStackTracePrivateName(); JSObject* errorObject = nullptr; JSC::JSArray* callSites = nullptr; - for (JSObject* o = receiver; o; ) { + for (JSObject* o = receiver; o;) { JSValue v = o->getDirect(vm, privateName); if (auto* arr = v ? dynamicDowncast(v) : nullptr) { callSites = arr; From 9b1d1eb7f76bf53b05617a78767443224db6be8b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:42:58 +0000 Subject: [PATCH 4/6] restore captured frames when a name/message getter throws The sentinel that breaks re-entry is restored to the CallSite array if user code (name/message getter, prepareStackTrace) throws during formatting, so a later .stack read retries instead of returning undefined. Matches Node, which re-invokes the getter on the next access. Also trim the multi-line comments flagged by comment-cop. --- src/jsc/bindings/FormatStackTraceForJS.cpp | 19 +++++++++---------- test/js/node/v8/capture-stack-trace.test.js | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index 694b2cc8a960..1ae436eb2cfd 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -784,10 +784,6 @@ JSC_DEFINE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter, (JSGlobalObject * g return true; } -// Lazy .stack getter installed by Error.captureStackTrace on objects that are -// not JSC::ErrorInstance. The captured CallSite array is stashed under a -// private name on the target so the header (name/message) and -// Error.prepareStackTrace are read at first access, matching V8. JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { auto& vm = JSC::getVM(lexicalGlobalObject); @@ -797,8 +793,6 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject if (!receiver) [[unlikely]] return JSValue::encode(jsUndefined()); - // The accessor may be reached via the prototype chain; the CallSite array - // lives on the object captureStackTrace was called with. const auto& privateName = WebCore::builtinNames(vm).capturedStackTracePrivateName(); JSObject* errorObject = nullptr; JSC::JSArray* callSites = nullptr; @@ -815,9 +809,10 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject if (!callSites) [[unlikely]] return JSValue::encode(jsUndefined()); - // Detach before running any user code (name/message getters, prepareStackTrace) - // so a re-entrant .stack read terminates at the !callSites guard above. - errorObject->putDirect(vm, privateName, jsUndefined(), 0); + JSC::EnsureStillAliveScope keepCallSites(callSites); + + // Sentinel so re-entry through a name/message getter hits !callSites above; restored if the user code throws. + errorObject->putDirect(vm, privateName, jsNull(), 0); auto* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -829,9 +824,13 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject result = formatStackTraceToJSValueWithoutPrepareStackTrace(vm, globalObject, lexicalGlobalObject, errorObject, callSites); globalObject->isInsideErrorPrepareStackTraceCallback = false; } - RETURN_IF_EXCEPTION(scope, {}); + if (scope.exception()) [[unlikely]] { + errorObject->putDirect(vm, privateName, callSites, 0); + return {}; + } errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0); + errorObject->putDirect(vm, privateName, jsUndefined(), 0); return JSValue::encode(result); } diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 5713df2e5816..82462b8503d4 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1236,6 +1236,24 @@ test("captureStackTrace on a non-Error object terminates when a name/message get expect(typeof o.stack).toBe("string"); }); +test("captureStackTrace on a non-Error object keeps captured frames when a name getter throws", () => { + let calls = 0; + const o = {}; + Object.defineProperty(o, "name", { + get() { + calls++; + if (calls === 1) throw new Error("boom"); + return "Retry"; + }, + }); + Error.captureStackTrace(o); + expect(() => o.stack).toThrow("boom"); + expect(calls).toBe(1); + expect(o.stack.split("\n")[0]).toBe("Retry"); + expect(o.stack).toContain("at "); + expect(calls).toBe(2); +}); + test("captureStackTrace lazy .stack resolves when reached via the prototype chain", () => { const parent = {}; Error.captureStackTrace(parent); From 102103d6b502fb23685de9232aa5adb55c7718c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:43:19 +0000 Subject: [PATCH 5/6] stop proto-chain walk at the re-entry sentinel; only restore the CallSite array while the accessor is still installed The isNull sentinel now halts the prototype walk so a re-entrant read on a child cannot materialize an ancestor's capture. On the exception path, the CallSite array is restored only when .stack is still the custom accessor; if prepareStackTrace already replaced it with the default-formatted string, the array is released instead of being orphaned. --- src/js/internal/inspector/cdp.ts | 702 ++++++++++++++++++++ src/jsc/bindings/BunDebugger.h | 15 + src/jsc/bindings/FormatStackTraceForJS.cpp | 11 +- test/js/node/v8/capture-stack-trace.test.js | 2 + 4 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 src/js/internal/inspector/cdp.ts create mode 100644 src/jsc/bindings/BunDebugger.h diff --git a/src/js/internal/inspector/cdp.ts b/src/js/internal/inspector/cdp.ts new file mode 100644 index 000000000000..69729d3845eb --- /dev/null +++ b/src/js/internal/inspector/cdp.ts @@ -0,0 +1,702 @@ +// Translates between the V8 Chrome DevTools Protocol (CDP) spoken by clients +// of node:inspector (Chrome DevTools, vscode-js-debug, vitest --inspect, ...) +// and the JSC/WebKit inspector protocol spoken by Bun's inspector backend. +// +// One adapter instance serves one frontend connection. `handleClientMessage` +// receives raw CDP JSON from the client, `handleBackendMessage` receives raw +// JSC-protocol JSON from the backend connection. Command ids from the client +// are preserved by giving backend commands their own id space and correlating +// the responses. +const { pathToFileURL, fileURLToPath } = require("node:url"); +const { isAbsolute } = require("node:path"); + +const EXECUTION_CONTEXT_ID = 1; + +type AnyObject = Record; + +function toCdpUrl(url: string): string { + // V8 reports filesystem-backed scripts with file:// URLs; JSC script URLs + // are usually plain absolute paths. + if (url && isAbsolute(url)) { + try { + return pathToFileURL(url).href; + } catch { + return url; + } + } + return url; +} + +// Written without a regex literal: the builtin-module bundler's scanner cannot +// parse a character class that escapes both `]` and `\`. +const REGEX_SPECIAL_CHARACTERS = "\\^$.*+?()[]{}|"; +function escapeRegex(text: string): string { + let escaped = ""; + for (const character of text) { + escaped += REGEX_SPECIAL_CHARACTERS.includes(character) ? "\\" + character : character; + } + return escaped; +} + +// CDP clients address scripts by file:// URL while JSC usually knows them by +// plain path, so match a breakpoint URL against every spelling. +function breakpointUrlRegex(url: string): string { + const candidates = new Set([url]); + if (url.startsWith("file://")) { + try { + candidates.$add(fileURLToPath(url)); + } catch {} + } else if (isAbsolute(url)) { + try { + candidates.$add(pathToFileURL(url).href); + } catch {} + } + return Array.from(candidates, candidate => `^${escapeRegex(candidate)}$`).join("|"); +} + +const SCOPE_TYPE_MAP: Record = { + global: "global", + with: "with", + closure: "closure", + catch: "catch", + functionName: "local", + globalLexicalEnvironment: "script", + nestedLexical: "block", +}; + +// No "log" entry: JSC reports console.warn/error/info/debug as +// { type: "log", level: "warning"/"error"/... }, so a type-level match on "log" +// would mask the level. #translateConsoleMessage falls through to +// CONSOLE_LEVEL_MAP for those and for console.log itself. +const CONSOLE_TYPE_MAP: Record = { + dir: "dir", + dirxml: "dirxml", + table: "table", + trace: "trace", + clear: "clear", + startGroup: "startGroup", + startGroupCollapsed: "startGroupCollapsed", + endGroup: "endGroup", + assert: "assert", + timing: "timeEnd", + profile: "profile", + profileEnd: "profileEnd", +}; + +const CONSOLE_LEVEL_MAP: Record = { + log: "log", + info: "info", + warning: "warning", + error: "error", + debug: "debug", +}; + +class InspectorCDPAdapter { + #writeToBackend: (message: string) => void; + #writeToClient: (message: string) => void; + #nextBackendId = 1; + #nextExceptionId = 1; + #pending = new Map< + number, + { clientId: number | string | null; method: string; onResult?: (result: AnyObject, error?: AnyObject) => void } + >(); + #scripts = new Map(); + + constructor(writeToBackend: (message: string) => void, writeToClient: (message: string) => void) { + this.#writeToBackend = writeToBackend; + this.#writeToClient = writeToClient; + } + + handleClientMessage(message: string): void { + let parsed: AnyObject; + try { + parsed = JSON.parse(message); + } catch { + return; + } + if (parsed === null || typeof parsed !== "object") return; + const { id, method, params } = parsed; + if (typeof method !== "string") return; + try { + this.#dispatchClientCommand(id, method, params || {}); + } catch (error) { + this.#replyErrorToClient(id, -32000, `${error}`); + } + } + + handleBackendMessage(message: string): void { + let parsed: AnyObject; + try { + parsed = JSON.parse(message); + } catch { + return; + } + const { id, error, method } = parsed; + if (id !== undefined) { + const pending = this.#pending.$get(id); + if (!pending) return; + this.#pending.$delete(id); + const { clientId, onResult } = pending; + if (onResult) { + onResult(parsed.result || {}, error); + return; + } + if (clientId === null || clientId === undefined) return; + if (error) { + this.#replyErrorToClient(clientId, error.code ?? -32000, error.message ?? "Unknown error"); + return; + } + this.#replyToClient(clientId, this.#translateResult(pending.method, parsed.result || {})); + return; + } + if (typeof method === "string") { + this.#translateBackendEvent(method, parsed.params || {}); + } + } + + #replyToClient(id: number | string, result: AnyObject): void { + this.#writeToClient(JSON.stringify({ id, result })); + } + + #replyErrorToClient(id: number | string, code: number, message: string): void { + this.#writeToClient(JSON.stringify({ id, error: { code, message } })); + } + + #emitToClient(method: string, params: AnyObject): void { + this.#writeToClient(JSON.stringify({ method, params })); + } + + // `clientId` undefined/null marks an adapter-internal command whose response + // is dropped instead of being forwarded to the client. `onResult` intercepts + // the response for adapter-side chaining (e.g. Runtime.evaluate awaitPromise). + #sendToBackend( + method: string, + params?: AnyObject, + clientId: number | string | null = null, + clientMethod = method, + onResult?: (result: AnyObject, error?: AnyObject) => void, + ): void { + const id = this.#nextBackendId++; + this.#pending.$set(id, { clientId, method: clientMethod, onResult }); + this.#writeToBackend(JSON.stringify(params === undefined ? { id, method } : { id, method, params })); + } + + #dispatchClientCommand(id: number | string, method: string, params: AnyObject): void { + switch (method) { + // ── Runtime ────────────────────────────────────────────────────────── + case "Runtime.enable": + // JSGlobalObject inspection has a single execution context; CDP clients + // need at least one announced for the console and evaluation to work. + this.#emitToClient("Runtime.executionContextCreated", { + context: { + id: EXECUTION_CONTEXT_ID, + origin: "", + name: "Bun", + uniqueId: String(EXECUTION_CONTEXT_ID), + }, + }); + this.#sendToBackend("Runtime.enable"); + // Console output arrives as Console.messageAdded and is re-emitted as + // Runtime.consoleAPICalled. Answer the client from this one for the + // same reason as Debugger.enable below: a client that runs code once + // Runtime.enable resolves expects console events to be flowing. + this.#sendToBackend("Console.enable", undefined, id, method); + return; + + case "Runtime.disable": + this.#sendToBackend("Runtime.disable"); + // Runtime.enable also enabled the Console domain; mirror it here so a + // client that disables Runtime stops receiving consoleAPICalled. + this.#sendToBackend("Console.disable", undefined, id, method); + return; + + case "Runtime.runIfWaitingForDebugger": + // Inspector.initialized resolves Bun's wait-for-debugger state, which + // unblocks inspector.open(port, host, true) on the inspected thread. + this.#sendToBackend("Inspector.initialized"); + this.#replyToClient(id, {}); + return; + + case "Runtime.evaluate": { + // JSC's JSGlobalObjectRuntimeAgent rejects any contextId ("only one + // execution context"), so drop it even though CDP clients echo it. + const jscParams = { + expression: params.expression, + objectGroup: params.objectGroup, + includeCommandLineAPI: params.includeCommandLineAPI, + doNotPauseOnExceptionsAndMuteConsole: params.silent, + returnByValue: params.returnByValue, + generatePreview: params.generatePreview, + emulateUserGesture: params.userGesture, + }; + // JSC has no `awaitPromise` on Runtime.evaluate; emulate it by + // chaining Runtime.awaitPromise when the result is a promise. The + // initial evaluate must not use returnByValue (it would serialize the + // Promise itself instead of returning the objectId to await on). + if (params.awaitPromise === true) { + const firstStep = { ...jscParams, returnByValue: false }; + this.#sendToBackend("Runtime.evaluate", firstStep, null, method, (result, error) => { + if (error) { + this.#replyErrorToClient(id, error.code ?? -32000, error.message ?? "Unknown error"); + return; + } + const remote = result.result; + const objectId = remote?.objectId; + if (!result.wasThrown && remote?.type === "object" && objectId) { + // JSC's Runtime.awaitPromise resolves any thenable and returns + // non-thenable objects as-is, so no subtype check is needed. + this.#sendToBackend( + "Runtime.awaitPromise", + { + promiseObjectId: objectId, + returnByValue: params.returnByValue, + generatePreview: params.generatePreview, + saveResult: params.saveResult, + }, + id, + method, + ); + return; + } + // Primitive / thrown: nothing to await. Primitives already carry + // value regardless of returnByValue; a thrown non-primitive comes + // back as an objectId (the first step forced returnByValue:false), + // which DevTools/vscode-js-debug inspect via exceptionDetails, so + // we do not re-serialize it to honour the client's returnByValue. + this.#replyToClient(id, this.#translateResult(method, result)); + }); + return; + } + this.#sendToBackend("Runtime.evaluate", jscParams, id, method); + return; + } + + case "Runtime.getProperties": + if (params.accessorPropertiesOnly) { + // JSC has no accessor-only query; DevTools issues this in addition to + // the regular request, so an empty list keeps the merged view correct. + this.#replyToClient(id, { result: [] }); + return; + } + this.#sendToBackend( + "Runtime.getProperties", + { + objectId: params.objectId, + ownProperties: params.ownProperties, + generatePreview: params.generatePreview, + }, + id, + method, + ); + return; + + case "Runtime.callFunctionOn": { + const { objectId, executionContextId } = params; + const forward = (targetObjectId: unknown) => + this.#sendToBackend( + "Runtime.callFunctionOn", + { + objectId: targetObjectId, + functionDeclaration: params.functionDeclaration, + arguments: params.arguments, + doNotPauseOnExceptionsAndMuteConsole: params.silent, + returnByValue: params.returnByValue, + generatePreview: params.generatePreview, + emulateUserGesture: params.userGesture, + awaitPromise: params.awaitPromise, + }, + id, + method, + ); + if (objectId) { + forward(objectId); + return; + } + if (executionContextId === undefined) { + this.#replyErrorToClient(id, -32602, "Either objectId or executionContextId must be specified"); + return; + } + // CDP allows executionContextId-only (calls with this === globalThis); + // JSC requires an objectId, so fetch the global's first. JSC has a + // single execution context and rejects contextId, so omit it. Pass the + // client's objectGroup so its releaseObjectGroup reclaims this handle. + this.#sendToBackend( + "Runtime.evaluate", + { expression: "globalThis", objectGroup: params.objectGroup }, + null, + method, + (result, error) => { + const globalObjectId = result.result?.objectId; + if (error || !globalObjectId) { + this.#replyErrorToClient(id, error?.code ?? -32000, error?.message ?? "Failed to resolve global object"); + return; + } + forward(globalObjectId); + }, + ); + return; + } + + case "Runtime.releaseObject": + case "Runtime.releaseObjectGroup": + this.#sendToBackend(method, params, id, method); + return; + + case "Runtime.getIsolateId": + this.#replyToClient(id, { id: "bun" }); + return; + + case "Runtime.getHeapUsage": + this.#replyToClient(id, { usedSize: 0, totalSize: 0 }); + return; + + case "Runtime.compileScript": + this.#replyToClient(id, {}); + return; + + case "Runtime.globalLexicalScopeNames": + this.#replyToClient(id, { names: [] }); + return; + + // ── Debugger ───────────────────────────────────────────────────────── + case "Debugger.enable": + this.#sendToBackend("Debugger.enable"); + // V8's Debugger.enable activates breakpoints and pauses on `debugger;` + // by default; JSC requires explicit opt-in for both. A client may run + // code as soon as it sees the Debugger.enable response and expects + // pausing to already be armed, so answer it from the last of the three + // commands instead of the first: the backend replies in order, so that + // response is proof all three landed. #translateResult still builds + // V8's { debuggerId } shape from the clientMethod passed here. + this.#sendToBackend("Debugger.setBreakpointsActive", { active: true }); + this.#sendToBackend("Debugger.setPauseOnDebuggerStatements", { enabled: true }, id, method); + return; + + case "Debugger.disable": + case "Debugger.pause": + case "Debugger.resume": + case "Debugger.stepInto": + case "Debugger.stepOut": + case "Debugger.stepOver": + case "Debugger.setBreakpointsActive": + case "Debugger.removeBreakpoint": + case "Debugger.continueToLocation": + case "Debugger.getScriptSource": + this.#sendToBackend(method, params, id, method); + return; + + case "Debugger.setPauseOnExceptions": + this.#sendToBackend( + "Debugger.setPauseOnExceptions", + { state: params.state === "caught" ? "all" : params.state }, + id, + method, + ); + return; + + case "Debugger.setAsyncCallStackDepth": + this.#sendToBackend("Debugger.setAsyncStackTraceDepth", { depth: params.maxDepth ?? 0 }, id, method); + return; + + case "Debugger.setBreakpointByUrl": { + const { condition, urlRegex, url } = params; + const options: AnyObject = {}; + if (condition) options.condition = condition; + const jscParams: AnyObject = { + lineNumber: params.lineNumber, + columnNumber: params.columnNumber, + options, + }; + if (urlRegex) { + jscParams.urlRegex = urlRegex; + } else if (url) { + jscParams.urlRegex = breakpointUrlRegex(url); + } else if (params.scriptHash) { + // CDP also accepts scriptHash; JSC has no content-hash addressing + // (Debugger.scriptParsed carries no hash to match against). + this.#replyErrorToClient(id, -32000, "scriptHash breakpoints are not supported"); + return; + } else { + this.#replyErrorToClient(id, -32602, "Either url or urlRegex must be specified."); + return; + } + this.#sendToBackend("Debugger.setBreakpointByUrl", jscParams, id, method); + return; + } + + case "Debugger.setBreakpoint": { + const { condition } = params; + this.#sendToBackend( + "Debugger.setBreakpoint", + { + location: params.location, + options: condition ? { condition } : undefined, + }, + id, + method, + ); + return; + } + + case "Debugger.getPossibleBreakpoints": { + const start = params.start; + let end = params.end; + if (!end) { + const script = this.#scripts.$get(start?.scriptId); + end = { + scriptId: start?.scriptId, + lineNumber: script ? script.endLine : (start?.lineNumber ?? 0) + 1, + columnNumber: script ? script.endColumn : 0, + }; + } + this.#sendToBackend("Debugger.getBreakpointLocations", { start, end }, id, method); + return; + } + + case "Debugger.evaluateOnCallFrame": + this.#sendToBackend( + "Debugger.evaluateOnCallFrame", + { + callFrameId: params.callFrameId, + expression: params.expression, + objectGroup: params.objectGroup, + includeCommandLineAPI: params.includeCommandLineAPI, + doNotPauseOnExceptionsAndMuteConsole: params.silent, + returnByValue: params.returnByValue, + generatePreview: params.generatePreview, + }, + id, + method, + ); + return; + + case "HeapProfiler.collectGarbage": + this.#sendToBackend("Heap.gc", undefined, id, method); + return; + + case "Console.enable": + case "Console.disable": + case "Console.clearMessages": + case "Inspector.enable": + this.#sendToBackend(method, undefined, id, method); + return; + + // Accepted but inert: CDP features JSC's inspector does not implement and + // that do not affect core debugging. + case "Debugger.setSkipAllPauses": + case "Debugger.setBlackboxPatterns": + case "Debugger.setBlackboxExecutionContexts": + case "Debugger.setInstrumentationBreakpoint": + case "Debugger.removeInstrumentationBreakpoint": + case "Runtime.addBinding": + case "Runtime.removeBinding": + case "Runtime.setMaxCallStackSizeToCapture": + case "Runtime.discardConsoleEntries": + case "Runtime.setCustomObjectFormatterEnabled": + case "Runtime.setAsyncCallStackDepth": + case "Profiler.enable": + case "Profiler.disable": + case "HeapProfiler.enable": + case "HeapProfiler.disable": + case "Network.enable": + case "Network.disable": + case "Log.enable": + case "Log.disable": + case "Log.clear": + case "Page.enable": + case "Target.setAutoAttach": + case "Target.setDiscoverTargets": + case "Target.setRemoteLocations": + case "NodeWorker.enable": + case "NodeWorker.disable": + case "NodeRuntime.enable": + case "NodeRuntime.disable": + case "NodeRuntime.notifyWhenWaitingForDisconnect": + this.#replyToClient(id, {}); + return; + + default: + this.#replyErrorToClient(id, -32601, `'${method}' wasn't found`); + } + } + + #translateResult(method: string, result: AnyObject): AnyObject { + switch (method) { + case "Debugger.enable": + return { debuggerId: "(bun)", ...result }; + + case "Runtime.evaluate": + case "Runtime.callFunctionOn": + case "Debugger.evaluateOnCallFrame": { + const out: AnyObject = { result: result.result ?? { type: "undefined" } }; + if (result.wasThrown) { + out.exceptionDetails = { + exceptionId: this.#nextExceptionId++, + text: result.result?.description ?? "Uncaught", + lineNumber: 0, + columnNumber: 0, + exception: result.result, + }; + } + return out; + } + + case "Runtime.getProperties": { + const properties = (result.properties ?? []).map((property: AnyObject) => ({ + configurable: false, + enumerable: false, + ...property, + })); + const out: AnyObject = { result: properties }; + const { internalProperties } = result; + if (internalProperties) out.internalProperties = internalProperties; + return out; + } + + case "Debugger.getPossibleBreakpoints": + return { locations: result.locations ?? [] }; + + default: + return result; + } + } + + #translateBackendEvent(method: string, params: AnyObject): void { + switch (method) { + case "Debugger.scriptParsed": { + const url = params.sourceURL || params.url || ""; + const cdpUrl = toCdpUrl(url); + this.#scripts.$set(params.scriptId, { + cdpUrl, + endLine: params.endLine ?? 0, + endColumn: params.endColumn ?? 0, + }); + this.#emitToClient("Debugger.scriptParsed", { + scriptId: params.scriptId, + url: cdpUrl, + startLine: params.startLine ?? 0, + startColumn: params.startColumn ?? 0, + endLine: params.endLine ?? 0, + endColumn: params.endColumn ?? 0, + executionContextId: EXECUTION_CONTEXT_ID, + hash: "", + isModule: !!params.module, + sourceMapURL: params.sourceMapURL, + embedderName: cdpUrl, + scriptLanguage: "JavaScript", + }); + return; + } + + case "Debugger.paused": { + const callFrames = (params.callFrames ?? []).map((frame: AnyObject) => ({ + callFrameId: frame.callFrameId, + functionName: frame.functionName ?? "", + location: frame.location, + url: this.#scripts.$get(frame.location?.scriptId)?.cdpUrl ?? "", + scopeChain: (frame.scopeChain ?? []).map((scope: AnyObject) => ({ + type: SCOPE_TYPE_MAP[scope.type] ?? "closure", + object: scope.object, + name: scope.name, + })), + this: frame.this, + canBeRestarted: false, + })); + const { data, asyncStackTrace } = params; + const cdpParams: AnyObject = { callFrames, reason: "other", data }; + switch (params.reason) { + case "exception": + cdpParams.reason = "exception"; + break; + case "assert": + cdpParams.reason = "assert"; + break; + case "Breakpoint": + if (data?.breakpointId) cdpParams.hitBreakpoints = [data.breakpointId]; + break; + } + if (asyncStackTrace) cdpParams.asyncStackTrace = this.#translateStackTrace(asyncStackTrace); + this.#emitToClient("Debugger.paused", cdpParams); + return; + } + + case "Debugger.resumed": + this.#emitToClient("Debugger.resumed", {}); + return; + + case "Debugger.breakpointResolved": + this.#emitToClient("Debugger.breakpointResolved", { + breakpointId: params.breakpointId, + location: params.location, + }); + return; + + case "Debugger.globalObjectCleared": + this.#emitToClient("Runtime.executionContextsCleared", {}); + return; + + case "Console.messageAdded": + this.#translateConsoleMessage(params.message || {}); + return; + + default: + // JSC- and Bun-specific events have no CDP equivalent. + return; + } + } + + #translateStackTrace(stackTrace: AnyObject | undefined): AnyObject | undefined { + if (!stackTrace) return undefined; + const translated: AnyObject = { + callFrames: (stackTrace.callFrames ?? []).map((frame: AnyObject) => ({ + functionName: frame.functionName ?? "", + scriptId: frame.scriptId ?? "", + url: toCdpUrl(frame.url ?? ""), + lineNumber: frame.lineNumber ?? 0, + columnNumber: frame.columnNumber ?? 0, + })), + }; + const { parentStackTrace } = stackTrace; + if (parentStackTrace) { + translated.parent = this.#translateStackTrace(parentStackTrace); + } + return translated; + } + + #translateConsoleMessage(message: AnyObject): void { + const level = message.level ?? "log"; + const args = message.parameters?.length ? message.parameters : [{ type: "string", value: message.text ?? "" }]; + + if (message.source !== "console-api" && level === "error") { + this.#emitToClient("Runtime.exceptionThrown", { + timestamp: message.timestamp ?? Date.now(), + exceptionDetails: { + exceptionId: this.#nextExceptionId++, + text: message.text ?? "Uncaught", + lineNumber: Math.max((message.line ?? 1) - 1, 0), + columnNumber: Math.max((message.column ?? 1) - 1, 0), + url: toCdpUrl(message.url ?? ""), + stackTrace: this.#translateStackTrace(message.stackTrace), + }, + }); + return; + } + + const type = + message.type && CONSOLE_TYPE_MAP[message.type] + ? CONSOLE_TYPE_MAP[message.type] + : (CONSOLE_LEVEL_MAP[level] ?? "log"); + this.#emitToClient("Runtime.consoleAPICalled", { + type, + args, + executionContextId: EXECUTION_CONTEXT_ID, + timestamp: message.timestamp ?? Date.now(), + stackTrace: this.#translateStackTrace(message.stackTrace), + }); + } +} + +export default { + InspectorCDPAdapter, + EXECUTION_CONTEXT_ID, +}; diff --git a/src/jsc/bindings/BunDebugger.h b/src/jsc/bindings/BunDebugger.h new file mode 100644 index 000000000000..def5b8d7b9df --- /dev/null +++ b/src/jsc/bindings/BunDebugger.h @@ -0,0 +1,15 @@ +#pragma once + +#include "root.h" +#include + +namespace Bun { + +// node:inspector's inspector.open() / close() / waitForDebugger(), backed by +// the debugger-thread WebSocket server in src/js/internal/debugger.ts. +JSC_DECLARE_HOST_FUNCTION(jsFunction_openNodeInspector); +JSC_DECLARE_HOST_FUNCTION(jsFunction_waitForNodeInspectorConnection); +JSC_DECLARE_HOST_FUNCTION(jsFunction_postNodeInspectorControl); +JSC_DECLARE_HOST_FUNCTION(jsFunction_closeNodeInspector); + +} // namespace Bun diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index 1ae436eb2cfd..f4ca43136786 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -798,6 +798,8 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject JSC::JSArray* callSites = nullptr; for (JSObject* o = receiver; o;) { JSValue v = o->getDirect(vm, privateName); + if (v && v.isNull()) + return JSValue::encode(jsUndefined()); if (auto* arr = v ? dynamicDowncast(v) : nullptr) { callSites = arr; errorObject = o; @@ -811,7 +813,7 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject JSC::EnsureStillAliveScope keepCallSites(callSites); - // Sentinel so re-entry through a name/message getter hits !callSites above; restored if the user code throws. + // Sentinel so re-entry through a name/message getter hits the isNull check above; restored if the user code throws. errorObject->putDirect(vm, privateName, jsNull(), 0); auto* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -825,7 +827,12 @@ JSC_DEFINE_CUSTOM_GETTER(nonErrorInstanceLazyStackCustomGetter, (JSGlobalObject globalObject->isInsideErrorPrepareStackTraceCallback = false; } if (scope.exception()) [[unlikely]] { - errorObject->putDirect(vm, privateName, callSites, 0); + unsigned attrs = 0; + JSValue currentStack = errorObject->getDirect(vm, vm.propertyNames->stack, attrs); + if (currentStack && (attrs & JSC::PropertyAttribute::CustomAccessor)) + errorObject->putDirect(vm, privateName, callSites, 0); + else + errorObject->putDirect(vm, privateName, jsUndefined(), 0); return {}; } diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 82462b8503d4..f68085147760 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1254,6 +1254,8 @@ test("captureStackTrace on a non-Error object keeps captured frames when a name expect(calls).toBe(2); }); + + test("captureStackTrace lazy .stack resolves when reached via the prototype chain", () => { const parent = {}; Error.captureStackTrace(parent); From 644df87c1c37b34b1ef63f3321a2be528f1e1f37 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:45:30 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- src/js/internal/inspector/cdp.ts | 702 -------------------- src/jsc/bindings/BunDebugger.h | 15 - test/js/node/v8/capture-stack-trace.test.js | 2 - 3 files changed, 719 deletions(-) delete mode 100644 src/js/internal/inspector/cdp.ts delete mode 100644 src/jsc/bindings/BunDebugger.h diff --git a/src/js/internal/inspector/cdp.ts b/src/js/internal/inspector/cdp.ts deleted file mode 100644 index 69729d3845eb..000000000000 --- a/src/js/internal/inspector/cdp.ts +++ /dev/null @@ -1,702 +0,0 @@ -// Translates between the V8 Chrome DevTools Protocol (CDP) spoken by clients -// of node:inspector (Chrome DevTools, vscode-js-debug, vitest --inspect, ...) -// and the JSC/WebKit inspector protocol spoken by Bun's inspector backend. -// -// One adapter instance serves one frontend connection. `handleClientMessage` -// receives raw CDP JSON from the client, `handleBackendMessage` receives raw -// JSC-protocol JSON from the backend connection. Command ids from the client -// are preserved by giving backend commands their own id space and correlating -// the responses. -const { pathToFileURL, fileURLToPath } = require("node:url"); -const { isAbsolute } = require("node:path"); - -const EXECUTION_CONTEXT_ID = 1; - -type AnyObject = Record; - -function toCdpUrl(url: string): string { - // V8 reports filesystem-backed scripts with file:// URLs; JSC script URLs - // are usually plain absolute paths. - if (url && isAbsolute(url)) { - try { - return pathToFileURL(url).href; - } catch { - return url; - } - } - return url; -} - -// Written without a regex literal: the builtin-module bundler's scanner cannot -// parse a character class that escapes both `]` and `\`. -const REGEX_SPECIAL_CHARACTERS = "\\^$.*+?()[]{}|"; -function escapeRegex(text: string): string { - let escaped = ""; - for (const character of text) { - escaped += REGEX_SPECIAL_CHARACTERS.includes(character) ? "\\" + character : character; - } - return escaped; -} - -// CDP clients address scripts by file:// URL while JSC usually knows them by -// plain path, so match a breakpoint URL against every spelling. -function breakpointUrlRegex(url: string): string { - const candidates = new Set([url]); - if (url.startsWith("file://")) { - try { - candidates.$add(fileURLToPath(url)); - } catch {} - } else if (isAbsolute(url)) { - try { - candidates.$add(pathToFileURL(url).href); - } catch {} - } - return Array.from(candidates, candidate => `^${escapeRegex(candidate)}$`).join("|"); -} - -const SCOPE_TYPE_MAP: Record = { - global: "global", - with: "with", - closure: "closure", - catch: "catch", - functionName: "local", - globalLexicalEnvironment: "script", - nestedLexical: "block", -}; - -// No "log" entry: JSC reports console.warn/error/info/debug as -// { type: "log", level: "warning"/"error"/... }, so a type-level match on "log" -// would mask the level. #translateConsoleMessage falls through to -// CONSOLE_LEVEL_MAP for those and for console.log itself. -const CONSOLE_TYPE_MAP: Record = { - dir: "dir", - dirxml: "dirxml", - table: "table", - trace: "trace", - clear: "clear", - startGroup: "startGroup", - startGroupCollapsed: "startGroupCollapsed", - endGroup: "endGroup", - assert: "assert", - timing: "timeEnd", - profile: "profile", - profileEnd: "profileEnd", -}; - -const CONSOLE_LEVEL_MAP: Record = { - log: "log", - info: "info", - warning: "warning", - error: "error", - debug: "debug", -}; - -class InspectorCDPAdapter { - #writeToBackend: (message: string) => void; - #writeToClient: (message: string) => void; - #nextBackendId = 1; - #nextExceptionId = 1; - #pending = new Map< - number, - { clientId: number | string | null; method: string; onResult?: (result: AnyObject, error?: AnyObject) => void } - >(); - #scripts = new Map(); - - constructor(writeToBackend: (message: string) => void, writeToClient: (message: string) => void) { - this.#writeToBackend = writeToBackend; - this.#writeToClient = writeToClient; - } - - handleClientMessage(message: string): void { - let parsed: AnyObject; - try { - parsed = JSON.parse(message); - } catch { - return; - } - if (parsed === null || typeof parsed !== "object") return; - const { id, method, params } = parsed; - if (typeof method !== "string") return; - try { - this.#dispatchClientCommand(id, method, params || {}); - } catch (error) { - this.#replyErrorToClient(id, -32000, `${error}`); - } - } - - handleBackendMessage(message: string): void { - let parsed: AnyObject; - try { - parsed = JSON.parse(message); - } catch { - return; - } - const { id, error, method } = parsed; - if (id !== undefined) { - const pending = this.#pending.$get(id); - if (!pending) return; - this.#pending.$delete(id); - const { clientId, onResult } = pending; - if (onResult) { - onResult(parsed.result || {}, error); - return; - } - if (clientId === null || clientId === undefined) return; - if (error) { - this.#replyErrorToClient(clientId, error.code ?? -32000, error.message ?? "Unknown error"); - return; - } - this.#replyToClient(clientId, this.#translateResult(pending.method, parsed.result || {})); - return; - } - if (typeof method === "string") { - this.#translateBackendEvent(method, parsed.params || {}); - } - } - - #replyToClient(id: number | string, result: AnyObject): void { - this.#writeToClient(JSON.stringify({ id, result })); - } - - #replyErrorToClient(id: number | string, code: number, message: string): void { - this.#writeToClient(JSON.stringify({ id, error: { code, message } })); - } - - #emitToClient(method: string, params: AnyObject): void { - this.#writeToClient(JSON.stringify({ method, params })); - } - - // `clientId` undefined/null marks an adapter-internal command whose response - // is dropped instead of being forwarded to the client. `onResult` intercepts - // the response for adapter-side chaining (e.g. Runtime.evaluate awaitPromise). - #sendToBackend( - method: string, - params?: AnyObject, - clientId: number | string | null = null, - clientMethod = method, - onResult?: (result: AnyObject, error?: AnyObject) => void, - ): void { - const id = this.#nextBackendId++; - this.#pending.$set(id, { clientId, method: clientMethod, onResult }); - this.#writeToBackend(JSON.stringify(params === undefined ? { id, method } : { id, method, params })); - } - - #dispatchClientCommand(id: number | string, method: string, params: AnyObject): void { - switch (method) { - // ── Runtime ────────────────────────────────────────────────────────── - case "Runtime.enable": - // JSGlobalObject inspection has a single execution context; CDP clients - // need at least one announced for the console and evaluation to work. - this.#emitToClient("Runtime.executionContextCreated", { - context: { - id: EXECUTION_CONTEXT_ID, - origin: "", - name: "Bun", - uniqueId: String(EXECUTION_CONTEXT_ID), - }, - }); - this.#sendToBackend("Runtime.enable"); - // Console output arrives as Console.messageAdded and is re-emitted as - // Runtime.consoleAPICalled. Answer the client from this one for the - // same reason as Debugger.enable below: a client that runs code once - // Runtime.enable resolves expects console events to be flowing. - this.#sendToBackend("Console.enable", undefined, id, method); - return; - - case "Runtime.disable": - this.#sendToBackend("Runtime.disable"); - // Runtime.enable also enabled the Console domain; mirror it here so a - // client that disables Runtime stops receiving consoleAPICalled. - this.#sendToBackend("Console.disable", undefined, id, method); - return; - - case "Runtime.runIfWaitingForDebugger": - // Inspector.initialized resolves Bun's wait-for-debugger state, which - // unblocks inspector.open(port, host, true) on the inspected thread. - this.#sendToBackend("Inspector.initialized"); - this.#replyToClient(id, {}); - return; - - case "Runtime.evaluate": { - // JSC's JSGlobalObjectRuntimeAgent rejects any contextId ("only one - // execution context"), so drop it even though CDP clients echo it. - const jscParams = { - expression: params.expression, - objectGroup: params.objectGroup, - includeCommandLineAPI: params.includeCommandLineAPI, - doNotPauseOnExceptionsAndMuteConsole: params.silent, - returnByValue: params.returnByValue, - generatePreview: params.generatePreview, - emulateUserGesture: params.userGesture, - }; - // JSC has no `awaitPromise` on Runtime.evaluate; emulate it by - // chaining Runtime.awaitPromise when the result is a promise. The - // initial evaluate must not use returnByValue (it would serialize the - // Promise itself instead of returning the objectId to await on). - if (params.awaitPromise === true) { - const firstStep = { ...jscParams, returnByValue: false }; - this.#sendToBackend("Runtime.evaluate", firstStep, null, method, (result, error) => { - if (error) { - this.#replyErrorToClient(id, error.code ?? -32000, error.message ?? "Unknown error"); - return; - } - const remote = result.result; - const objectId = remote?.objectId; - if (!result.wasThrown && remote?.type === "object" && objectId) { - // JSC's Runtime.awaitPromise resolves any thenable and returns - // non-thenable objects as-is, so no subtype check is needed. - this.#sendToBackend( - "Runtime.awaitPromise", - { - promiseObjectId: objectId, - returnByValue: params.returnByValue, - generatePreview: params.generatePreview, - saveResult: params.saveResult, - }, - id, - method, - ); - return; - } - // Primitive / thrown: nothing to await. Primitives already carry - // value regardless of returnByValue; a thrown non-primitive comes - // back as an objectId (the first step forced returnByValue:false), - // which DevTools/vscode-js-debug inspect via exceptionDetails, so - // we do not re-serialize it to honour the client's returnByValue. - this.#replyToClient(id, this.#translateResult(method, result)); - }); - return; - } - this.#sendToBackend("Runtime.evaluate", jscParams, id, method); - return; - } - - case "Runtime.getProperties": - if (params.accessorPropertiesOnly) { - // JSC has no accessor-only query; DevTools issues this in addition to - // the regular request, so an empty list keeps the merged view correct. - this.#replyToClient(id, { result: [] }); - return; - } - this.#sendToBackend( - "Runtime.getProperties", - { - objectId: params.objectId, - ownProperties: params.ownProperties, - generatePreview: params.generatePreview, - }, - id, - method, - ); - return; - - case "Runtime.callFunctionOn": { - const { objectId, executionContextId } = params; - const forward = (targetObjectId: unknown) => - this.#sendToBackend( - "Runtime.callFunctionOn", - { - objectId: targetObjectId, - functionDeclaration: params.functionDeclaration, - arguments: params.arguments, - doNotPauseOnExceptionsAndMuteConsole: params.silent, - returnByValue: params.returnByValue, - generatePreview: params.generatePreview, - emulateUserGesture: params.userGesture, - awaitPromise: params.awaitPromise, - }, - id, - method, - ); - if (objectId) { - forward(objectId); - return; - } - if (executionContextId === undefined) { - this.#replyErrorToClient(id, -32602, "Either objectId or executionContextId must be specified"); - return; - } - // CDP allows executionContextId-only (calls with this === globalThis); - // JSC requires an objectId, so fetch the global's first. JSC has a - // single execution context and rejects contextId, so omit it. Pass the - // client's objectGroup so its releaseObjectGroup reclaims this handle. - this.#sendToBackend( - "Runtime.evaluate", - { expression: "globalThis", objectGroup: params.objectGroup }, - null, - method, - (result, error) => { - const globalObjectId = result.result?.objectId; - if (error || !globalObjectId) { - this.#replyErrorToClient(id, error?.code ?? -32000, error?.message ?? "Failed to resolve global object"); - return; - } - forward(globalObjectId); - }, - ); - return; - } - - case "Runtime.releaseObject": - case "Runtime.releaseObjectGroup": - this.#sendToBackend(method, params, id, method); - return; - - case "Runtime.getIsolateId": - this.#replyToClient(id, { id: "bun" }); - return; - - case "Runtime.getHeapUsage": - this.#replyToClient(id, { usedSize: 0, totalSize: 0 }); - return; - - case "Runtime.compileScript": - this.#replyToClient(id, {}); - return; - - case "Runtime.globalLexicalScopeNames": - this.#replyToClient(id, { names: [] }); - return; - - // ── Debugger ───────────────────────────────────────────────────────── - case "Debugger.enable": - this.#sendToBackend("Debugger.enable"); - // V8's Debugger.enable activates breakpoints and pauses on `debugger;` - // by default; JSC requires explicit opt-in for both. A client may run - // code as soon as it sees the Debugger.enable response and expects - // pausing to already be armed, so answer it from the last of the three - // commands instead of the first: the backend replies in order, so that - // response is proof all three landed. #translateResult still builds - // V8's { debuggerId } shape from the clientMethod passed here. - this.#sendToBackend("Debugger.setBreakpointsActive", { active: true }); - this.#sendToBackend("Debugger.setPauseOnDebuggerStatements", { enabled: true }, id, method); - return; - - case "Debugger.disable": - case "Debugger.pause": - case "Debugger.resume": - case "Debugger.stepInto": - case "Debugger.stepOut": - case "Debugger.stepOver": - case "Debugger.setBreakpointsActive": - case "Debugger.removeBreakpoint": - case "Debugger.continueToLocation": - case "Debugger.getScriptSource": - this.#sendToBackend(method, params, id, method); - return; - - case "Debugger.setPauseOnExceptions": - this.#sendToBackend( - "Debugger.setPauseOnExceptions", - { state: params.state === "caught" ? "all" : params.state }, - id, - method, - ); - return; - - case "Debugger.setAsyncCallStackDepth": - this.#sendToBackend("Debugger.setAsyncStackTraceDepth", { depth: params.maxDepth ?? 0 }, id, method); - return; - - case "Debugger.setBreakpointByUrl": { - const { condition, urlRegex, url } = params; - const options: AnyObject = {}; - if (condition) options.condition = condition; - const jscParams: AnyObject = { - lineNumber: params.lineNumber, - columnNumber: params.columnNumber, - options, - }; - if (urlRegex) { - jscParams.urlRegex = urlRegex; - } else if (url) { - jscParams.urlRegex = breakpointUrlRegex(url); - } else if (params.scriptHash) { - // CDP also accepts scriptHash; JSC has no content-hash addressing - // (Debugger.scriptParsed carries no hash to match against). - this.#replyErrorToClient(id, -32000, "scriptHash breakpoints are not supported"); - return; - } else { - this.#replyErrorToClient(id, -32602, "Either url or urlRegex must be specified."); - return; - } - this.#sendToBackend("Debugger.setBreakpointByUrl", jscParams, id, method); - return; - } - - case "Debugger.setBreakpoint": { - const { condition } = params; - this.#sendToBackend( - "Debugger.setBreakpoint", - { - location: params.location, - options: condition ? { condition } : undefined, - }, - id, - method, - ); - return; - } - - case "Debugger.getPossibleBreakpoints": { - const start = params.start; - let end = params.end; - if (!end) { - const script = this.#scripts.$get(start?.scriptId); - end = { - scriptId: start?.scriptId, - lineNumber: script ? script.endLine : (start?.lineNumber ?? 0) + 1, - columnNumber: script ? script.endColumn : 0, - }; - } - this.#sendToBackend("Debugger.getBreakpointLocations", { start, end }, id, method); - return; - } - - case "Debugger.evaluateOnCallFrame": - this.#sendToBackend( - "Debugger.evaluateOnCallFrame", - { - callFrameId: params.callFrameId, - expression: params.expression, - objectGroup: params.objectGroup, - includeCommandLineAPI: params.includeCommandLineAPI, - doNotPauseOnExceptionsAndMuteConsole: params.silent, - returnByValue: params.returnByValue, - generatePreview: params.generatePreview, - }, - id, - method, - ); - return; - - case "HeapProfiler.collectGarbage": - this.#sendToBackend("Heap.gc", undefined, id, method); - return; - - case "Console.enable": - case "Console.disable": - case "Console.clearMessages": - case "Inspector.enable": - this.#sendToBackend(method, undefined, id, method); - return; - - // Accepted but inert: CDP features JSC's inspector does not implement and - // that do not affect core debugging. - case "Debugger.setSkipAllPauses": - case "Debugger.setBlackboxPatterns": - case "Debugger.setBlackboxExecutionContexts": - case "Debugger.setInstrumentationBreakpoint": - case "Debugger.removeInstrumentationBreakpoint": - case "Runtime.addBinding": - case "Runtime.removeBinding": - case "Runtime.setMaxCallStackSizeToCapture": - case "Runtime.discardConsoleEntries": - case "Runtime.setCustomObjectFormatterEnabled": - case "Runtime.setAsyncCallStackDepth": - case "Profiler.enable": - case "Profiler.disable": - case "HeapProfiler.enable": - case "HeapProfiler.disable": - case "Network.enable": - case "Network.disable": - case "Log.enable": - case "Log.disable": - case "Log.clear": - case "Page.enable": - case "Target.setAutoAttach": - case "Target.setDiscoverTargets": - case "Target.setRemoteLocations": - case "NodeWorker.enable": - case "NodeWorker.disable": - case "NodeRuntime.enable": - case "NodeRuntime.disable": - case "NodeRuntime.notifyWhenWaitingForDisconnect": - this.#replyToClient(id, {}); - return; - - default: - this.#replyErrorToClient(id, -32601, `'${method}' wasn't found`); - } - } - - #translateResult(method: string, result: AnyObject): AnyObject { - switch (method) { - case "Debugger.enable": - return { debuggerId: "(bun)", ...result }; - - case "Runtime.evaluate": - case "Runtime.callFunctionOn": - case "Debugger.evaluateOnCallFrame": { - const out: AnyObject = { result: result.result ?? { type: "undefined" } }; - if (result.wasThrown) { - out.exceptionDetails = { - exceptionId: this.#nextExceptionId++, - text: result.result?.description ?? "Uncaught", - lineNumber: 0, - columnNumber: 0, - exception: result.result, - }; - } - return out; - } - - case "Runtime.getProperties": { - const properties = (result.properties ?? []).map((property: AnyObject) => ({ - configurable: false, - enumerable: false, - ...property, - })); - const out: AnyObject = { result: properties }; - const { internalProperties } = result; - if (internalProperties) out.internalProperties = internalProperties; - return out; - } - - case "Debugger.getPossibleBreakpoints": - return { locations: result.locations ?? [] }; - - default: - return result; - } - } - - #translateBackendEvent(method: string, params: AnyObject): void { - switch (method) { - case "Debugger.scriptParsed": { - const url = params.sourceURL || params.url || ""; - const cdpUrl = toCdpUrl(url); - this.#scripts.$set(params.scriptId, { - cdpUrl, - endLine: params.endLine ?? 0, - endColumn: params.endColumn ?? 0, - }); - this.#emitToClient("Debugger.scriptParsed", { - scriptId: params.scriptId, - url: cdpUrl, - startLine: params.startLine ?? 0, - startColumn: params.startColumn ?? 0, - endLine: params.endLine ?? 0, - endColumn: params.endColumn ?? 0, - executionContextId: EXECUTION_CONTEXT_ID, - hash: "", - isModule: !!params.module, - sourceMapURL: params.sourceMapURL, - embedderName: cdpUrl, - scriptLanguage: "JavaScript", - }); - return; - } - - case "Debugger.paused": { - const callFrames = (params.callFrames ?? []).map((frame: AnyObject) => ({ - callFrameId: frame.callFrameId, - functionName: frame.functionName ?? "", - location: frame.location, - url: this.#scripts.$get(frame.location?.scriptId)?.cdpUrl ?? "", - scopeChain: (frame.scopeChain ?? []).map((scope: AnyObject) => ({ - type: SCOPE_TYPE_MAP[scope.type] ?? "closure", - object: scope.object, - name: scope.name, - })), - this: frame.this, - canBeRestarted: false, - })); - const { data, asyncStackTrace } = params; - const cdpParams: AnyObject = { callFrames, reason: "other", data }; - switch (params.reason) { - case "exception": - cdpParams.reason = "exception"; - break; - case "assert": - cdpParams.reason = "assert"; - break; - case "Breakpoint": - if (data?.breakpointId) cdpParams.hitBreakpoints = [data.breakpointId]; - break; - } - if (asyncStackTrace) cdpParams.asyncStackTrace = this.#translateStackTrace(asyncStackTrace); - this.#emitToClient("Debugger.paused", cdpParams); - return; - } - - case "Debugger.resumed": - this.#emitToClient("Debugger.resumed", {}); - return; - - case "Debugger.breakpointResolved": - this.#emitToClient("Debugger.breakpointResolved", { - breakpointId: params.breakpointId, - location: params.location, - }); - return; - - case "Debugger.globalObjectCleared": - this.#emitToClient("Runtime.executionContextsCleared", {}); - return; - - case "Console.messageAdded": - this.#translateConsoleMessage(params.message || {}); - return; - - default: - // JSC- and Bun-specific events have no CDP equivalent. - return; - } - } - - #translateStackTrace(stackTrace: AnyObject | undefined): AnyObject | undefined { - if (!stackTrace) return undefined; - const translated: AnyObject = { - callFrames: (stackTrace.callFrames ?? []).map((frame: AnyObject) => ({ - functionName: frame.functionName ?? "", - scriptId: frame.scriptId ?? "", - url: toCdpUrl(frame.url ?? ""), - lineNumber: frame.lineNumber ?? 0, - columnNumber: frame.columnNumber ?? 0, - })), - }; - const { parentStackTrace } = stackTrace; - if (parentStackTrace) { - translated.parent = this.#translateStackTrace(parentStackTrace); - } - return translated; - } - - #translateConsoleMessage(message: AnyObject): void { - const level = message.level ?? "log"; - const args = message.parameters?.length ? message.parameters : [{ type: "string", value: message.text ?? "" }]; - - if (message.source !== "console-api" && level === "error") { - this.#emitToClient("Runtime.exceptionThrown", { - timestamp: message.timestamp ?? Date.now(), - exceptionDetails: { - exceptionId: this.#nextExceptionId++, - text: message.text ?? "Uncaught", - lineNumber: Math.max((message.line ?? 1) - 1, 0), - columnNumber: Math.max((message.column ?? 1) - 1, 0), - url: toCdpUrl(message.url ?? ""), - stackTrace: this.#translateStackTrace(message.stackTrace), - }, - }); - return; - } - - const type = - message.type && CONSOLE_TYPE_MAP[message.type] - ? CONSOLE_TYPE_MAP[message.type] - : (CONSOLE_LEVEL_MAP[level] ?? "log"); - this.#emitToClient("Runtime.consoleAPICalled", { - type, - args, - executionContextId: EXECUTION_CONTEXT_ID, - timestamp: message.timestamp ?? Date.now(), - stackTrace: this.#translateStackTrace(message.stackTrace), - }); - } -} - -export default { - InspectorCDPAdapter, - EXECUTION_CONTEXT_ID, -}; diff --git a/src/jsc/bindings/BunDebugger.h b/src/jsc/bindings/BunDebugger.h deleted file mode 100644 index def5b8d7b9df..000000000000 --- a/src/jsc/bindings/BunDebugger.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include "root.h" -#include - -namespace Bun { - -// node:inspector's inspector.open() / close() / waitForDebugger(), backed by -// the debugger-thread WebSocket server in src/js/internal/debugger.ts. -JSC_DECLARE_HOST_FUNCTION(jsFunction_openNodeInspector); -JSC_DECLARE_HOST_FUNCTION(jsFunction_waitForNodeInspectorConnection); -JSC_DECLARE_HOST_FUNCTION(jsFunction_postNodeInspectorControl); -JSC_DECLARE_HOST_FUNCTION(jsFunction_closeNodeInspector); - -} // namespace Bun diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index f68085147760..82462b8503d4 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1254,8 +1254,6 @@ test("captureStackTrace on a non-Error object keeps captured frames when a name expect(calls).toBe(2); }); - - test("captureStackTrace lazy .stack resolves when reached via the prototype chain", () => { const parent = {}; Error.captureStackTrace(parent);