diff --git a/src/jsc/bindings/ErrorStackTrace.cpp b/src/jsc/bindings/ErrorStackTrace.cpp index 7afd14d4b236..e6a76f29f206 100644 --- a/src/jsc/bindings/ErrorStackTrace.cpp +++ b/src/jsc/bindings/ErrorStackTrace.cpp @@ -524,6 +524,16 @@ String sourceURL(JSC::VM& vm, JSC::JSFunction* function) return Zig::sourceURL(function->jsExecutable()->source()); } +String functionNameForDisplay(JSC::VM& vm, String name) +{ + // Identity, not equality: a function that is actually named "starDefault" keeps its name. + if (name.impl() == vm.propertyNames->starDefaultPrivateName.impl()) { + return vm.propertyNames->defaultKeyword.string(); + } + + return name; +} + String functionName(JSC::VM& vm, JSC::CodeBlock* codeBlock) { auto codeType = codeBlock->codeType(); @@ -534,7 +544,7 @@ String functionName(JSC::VM& vm, JSC::CodeBlock* codeBlock) } if (codeType == JSC::FunctionCode) { - return uncheckedDowncast(executable)->ecmaName().string(); + return functionNameForDisplay(vm, uncheckedDowncast(executable)->ecmaName().string()); } return String(); @@ -590,7 +600,7 @@ String functionName(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC:: } } - return functionName; + return functionNameForDisplay(vm, WTF::move(functionName)); } String functionName(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, const JSC::StackFrame& frame, FinalizerSafety finalizerSafety, unsigned int* flags) @@ -649,7 +659,7 @@ String functionName(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, const auto str = function->nameWithoutGC(vm); if (str.isEmpty() && !function->isHostFunction()) { setTypeFlagsIfNecessary(); - return function->jsExecutable()->ecmaName().string(); + return functionNameForDisplay(vm, function->jsExecutable()->ecmaName().string()); } setTypeFlagsIfNecessary(); return str; diff --git a/src/jsc/bindings/ErrorStackTrace.h b/src/jsc/bindings/ErrorStackTrace.h index 17c9dc6822ea..4048424526e8 100644 --- a/src/jsc/bindings/ErrorStackTrace.h +++ b/src/jsc/bindings/ErrorStackTrace.h @@ -235,6 +235,9 @@ class FunctionNameFlags { static constexpr unsigned AddNewKeyword = 1 << 4; }; +// Renders an anonymous `export default`'s internal `*default*` binding as "default", like JSFunction::reifyName does for `.name`. +String functionNameForDisplay(JSC::VM& vm, String name); + String functionName(JSC::VM& vm, JSC::CodeBlock* codeBlock); String functionName(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSObject* callee); String functionName(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, const JSC::StackFrame& frame, FinalizerSafety, unsigned int* flags); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 40e19b3e571e..708ed59226d8 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -4957,7 +4957,7 @@ void JSC__JSValue__getNameProperty(JSC::EncodedJSValue JSValue0, JSC::JSGlobalOb return; } - actualName = function->jsExecutable()->name().string(); + actualName = Zig::functionNameForDisplay(vm, function->jsExecutable()->name().string()); *arg2 = Zig::toZigString(actualName); return; @@ -4981,7 +4981,7 @@ void JSC__JSValue__getNameProperty(JSC::EncodedJSValue JSValue0, JSC::JSGlobalOb auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); JSObject* object = value.getObject(); - auto displayName = JSC::getCalculatedDisplayName(vm, object); + auto displayName = Zig::functionNameForDisplay(vm, JSC::getCalculatedDisplayName(vm, object)); // JSC doesn't include @@toStringTag in calculated display name if (displayName.isEmpty()) { diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 63b28630a3f6..c265fad37923 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness"; import { join } from "node:path"; test("name property is used for function calls in Error.stack", () => { @@ -149,3 +149,120 @@ test("Async functions frame should be included in stack trace", async () => { at async (file:NN:NN)" `); }); + +// JSC binds an anonymous `export default` to the internal `*default*` identifier and only maps it +// to the observable name "default" when the `name` property is reified. Frame names must say +// "default" too (as node does), whether or not something has read `.name` yet. +describe("anonymous export default is named 'default'", () => { + const modules = { + "class.mjs": `export default class { constructor() { this.stack = new Error("class").stack; } }`, + "subclass.mjs": ` + class Base { constructor() { this.stack = new Error("subclass").stack; } } + export default class extends Base {} + `, + "arrow.mjs": `export default () => new Error("arrow").stack;`, + "expression.mjs": `export default (function () { return new Error("expression").stack; });`, + // A function really called starDefault keeps its name; only the internal binding is renamed. + "named-star-default.mjs": `export default function starDefault() { return new Error("starDefault").stack; }`, + "throws.mjs": `export default class { constructor() { throw new Error("thrown by an anonymous default export"); } }`, + "uncaught.mjs": ` + import Thrower from "./throws.mjs"; + new Thrower(); + `, + "unhandled-rejection.mjs": ` + import Thrower from "./throws.mjs"; + Promise.resolve().then(() => new Thrower()); + `, + "main.mjs": ` + import Class from "./class.mjs"; + import Subclass from "./subclass.mjs"; + import arrow from "./arrow.mjs"; + import expression from "./expression.mjs"; + import starDefault from "./named-star-default.mjs"; + + const frameNames = (stack, count) => + stack + .split("\\n") + .slice(1, 1 + count) + .map(line => line.trim().replace(/^at /, "").replace(/ \\(.*$/, "")); + + const topCallSite = getStack => { + const previous = Error.prepareStackTrace; + Error.prepareStackTrace = (_, callSites) => [callSites[0].getFunctionName(), callSites[0].isConstructor()]; + try { + return getStack(); + } finally { + Error.prepareStackTrace = previous; + } + }; + + const observe = () => ({ + class: frameNames(new Class().stack, 1), + subclass: frameNames(new Subclass().stack, 2), + arrow: frameNames(arrow(), 1), + expression: frameNames(expression(), 1), + starDefault: frameNames(starDefault(), 1), + callSites: [topCallSite(() => new Class().stack), topCallSite(arrow)], + }); + + const beforeReadingName = observe(); + const names = [Class.name, Subclass.name, arrow.name, expression.name, starDefault.name]; + const afterReadingName = observe(); + console.log(JSON.stringify({ beforeReadingName, names, afterReadingName })); + `, + }; + + const expected = { + class: ["new default"], + subclass: ["new Base", "new default"], + arrow: ["default"], + expression: ["default"], + starDefault: ["starDefault"], + callSites: [ + ["default", true], + ["default", false], + ], + }; + + test.concurrent("error.stack and CallSite#getFunctionName", async () => { + using dir = tempDir("export-default-name", modules); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ result: JSON.parse(stdout), stderr, exitCode }).toEqual({ + result: { + beforeReadingName: expected, + names: ["default", "default", "default", "default", "starDefault"], + afterReadingName: expected, + }, + stderr: "", + exitCode: 0, + }); + }); + + // Bun's own error printer names frames through a separate, GC-safe lookup from error.stack. + test.concurrent.each([ + ["uncaught.mjs", ["at new default (file:NN:NN)", "at /uncaught.mjs"]], + ["unhandled-rejection.mjs", ["at new default (file:NN:NN)", "at (file:NN:NN)"]], + ])("frames printed for an uncaught error: %s", async (entry, frames) => { + using dir = tempDir("export-default-name", modules); + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const printedFrames = normalizeBunSnapshot(stderr, String(dir)) + .split("\n") + .map(line => line.trim().replace(/:\d+:\d+$/, "")) + .filter(line => line.startsWith("at ")); + expect({ stdout, printedFrames, exitCode }).toEqual({ stdout: "", printedFrames: frames, exitCode: 1 }); + }); +}); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5c91f3dbe05f..58ed181c9531 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, normalizeBunSnapshot, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; +import { readdir } from "node:fs/promises"; +import { gzip } from "node:zlib"; import { join } from "path"; import util from "util"; it("prototype", () => { @@ -617,6 +619,52 @@ describe("console.logging class displays names and extends", async () => { } }); +// JSC names an anonymous `export default` after its internal `*default*` binding; `.name` is +// "default", and so is what node prints. +it("anonymous export default class and function are named 'default'", async () => { + using dir = tempDir("inspect-export-default", { + "class.mjs": `export default class {}`, + "subclass.mjs": ` + export class Base {} + export default class extends Base {} + `, + "arrow.mjs": `export default () => {};`, + "main.mjs": ` + import Class from "./class.mjs"; + import Subclass from "./subclass.mjs"; + import arrow from "./arrow.mjs"; + + const element = { $$typeof: Symbol.for("react.element"), type: Class, props: {}, key: null }; + console.log(JSON.stringify([Class, Subclass, arrow, element].map(value => Bun.inspect(value)))); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ inspected: JSON.parse(stdout), stderr, exitCode }).toEqual({ + inspected: ["[class default]", "[class default extends Base]", "[Function: default]", ""], + stderr: "", + exitCode: 0, + }); +}); + +// Bun's built-in modules are compiled as JSC builtins. zlib's convenience methods and fs.promises' +// wrappers are anonymous function expressions that get their `name` defined afterwards; the local +// variable each was assigned to inside the module (`fn`, `wrapped`) must not show up here. +it("functions from built-in modules do not inspect as their internal variable name", () => { + expect([gzip.name, Bun.inspect(gzip), readdir.name, Bun.inspect(readdir)]).toEqual([ + "gzip", + "[Function]", + "readdir", + "[AsyncFunction]", + ]); +}); + it("console.log on a Blob shows name", () => { const blob = new Blob(["foo"], { type: "text/plain" }); expect(Bun.inspect(blob)).toBe('Blob (3 bytes) {\n type: "text/plain;charset=utf-8"\n}');