From d9490ee379199161e0f2b5760a340546f5def9b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:11:58 +0000 Subject: [PATCH 1/3] SystemError: install lazy .stack getter when the captured trace is empty Async node:fs errors (callback form and fs.promises consumed via .then()) are constructed from native code at the top of the event loop, where there are no JS frames on the stack. createError() then captures an empty stack trace, and ErrorInstance::materializeErrorInfoIfNeeded never installs a .stack own property for an empty trace. The result is err.stack === undefined, which breaks loggers that print ${err.stack} and any stack-based error reporter. Node.js returns at least "Error: ". Install Bun's existing lazy stack getter on the ErrorInstance when the captured trace is empty. That getter formats whatever stackTrace() holds at access time, so: - zero frames -> "Error: " (matches Node's header-only form) - frames later attached by Bun__attachAsyncStackFromPromise -> full trace - Error.prepareStackTrace is honored with an empty call-sites array The sync path is unchanged (always has JS frames on the stack). --- src/jsc/bindings/bindings.cpp | 17 ++++++++ test/js/node/fs/fs.test.ts | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 97fbbf7b30fd..22c828edc6d5 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2369,6 +2369,23 @@ JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::J JSC::JSObject* result = createError(globalObject, ErrorType::Error, message); + // When this is called from the top of the event loop (async fs / dns / socket + // threadpool callbacks) there are no JS frames on the stack, so createError() + // captures an empty stack trace. ErrorInstance::materializeErrorInfoIfNeeded + // then never installs a .stack property for an empty trace, leaving + // err.stack === undefined where Node.js always returns at least + // "Error: ". Install the lazy stack getter in that case; it formats + // whatever stackTrace() holds at access time (header-only, or the async frames + // later attached by Bun__attachAsyncStackFromPromise) and honors + // Error.prepareStackTrace. + if (auto* instance = dynamicDowncast(result)) { + auto* trace = instance->stackTrace(); + if (!trace || trace->isEmpty()) { + auto* zigGlobal = defaultGlobalObject(globalObject); + instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, zigGlobal->m_lazyStackCustomGetterSetter.get(zigGlobal), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor | 0); + } + } + auto clientData = WebCore::clientData(vm); if (err.code.tag != BunStringTag::Empty) { diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 16396f0dd211..915e8f9561c8 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5799,3 +5799,83 @@ describe("fs.close on stdio descriptors", () => { expect(exitCode).toBe(0); }); }); + +describe("async fs errors have a .stack string", () => { + const nope = path.join(tmpdir(), "__bun_fs_stack_nope", "deep", "f"); + const header = /^Error: ENOENT: no such file or directory/; + + it.each([ + ["open", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.open(nope, "r", cb)], + ["readFile", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.readFile(nope, cb)], + ["stat", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.stat(nope, cb)], + ["readdir", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.readdir(nope, cb)], + ["unlink", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.unlink(nope, cb)], + ["mkdir", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.mkdir(nope, cb)], + ["rm", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.rm(nope, cb)], + ["copyFile", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.copyFile(nope, nope + "2", cb)], + ["rename", (cb: (e: NodeJS.ErrnoException | null) => void) => fs.rename(nope, nope + "2", cb)], + ] as const)("fs.%s (callback)", async (_name, call) => { + const err = await new Promise(resolve => call(e => resolve(e!))); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("ENOENT"); + expect(typeof err.stack).toBe("string"); + expect(err.stack).toMatch(header); + }); + + it.each(["readFile", "stat", "readdir", "unlink", "mkdir", "rm"] as const)("fs.promises.%s", async name => { + let err: any; + try { + await (fs.promises as any)[name](nope); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("ENOENT"); + expect(typeof err.stack).toBe("string"); + expect(err.stack).toMatch(header); + }); + + it("fs.promises via .then() (no await chain)", async () => { + const err = await new Promise(resolve => { + fs.promises.readFile(nope).then( + () => resolve(undefined), + e => resolve(e), + ); + }); + expect(err).toBeInstanceOf(Error); + expect(typeof err.stack).toBe("string"); + expect(err.stack).toMatch(header); + }); + + it("createReadStream 'error' event", async () => { + const err = await new Promise(resolve => { + const s = fs.createReadStream(nope); + s.on("error", resolve); + }); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("ENOENT"); + expect(typeof err.stack).toBe("string"); + expect(err.stack).toMatch(header); + }); + + it("honors Error.prepareStackTrace for empty-trace errors", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + Error.prepareStackTrace = (err, frames) => "prep:" + frames.length + ":" + err.message; + require("node:fs").readFile(${JSON.stringify(nope)}, e => { + console.log(typeof e.stack === "string" && e.stack.startsWith("prep:") ? "ok" : "bad:" + e.stack); + }); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); + }); +}); From c99560d17edeb4f1c2813978998a64a972cfedf8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:17:16 +0000 Subject: [PATCH 2/3] ci: retrigger From 32d79c62f65f6fac9d710de91b0a44c05bb733be Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:24:17 +0000 Subject: [PATCH 3/3] drop comment per lint --- src/jsc/bindings/bindings.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 22c828edc6d5..e2f2444fbc46 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2369,15 +2369,6 @@ JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::J JSC::JSObject* result = createError(globalObject, ErrorType::Error, message); - // When this is called from the top of the event loop (async fs / dns / socket - // threadpool callbacks) there are no JS frames on the stack, so createError() - // captures an empty stack trace. ErrorInstance::materializeErrorInfoIfNeeded - // then never installs a .stack property for an empty trace, leaving - // err.stack === undefined where Node.js always returns at least - // "Error: ". Install the lazy stack getter in that case; it formats - // whatever stackTrace() holds at access time (header-only, or the async frames - // later attached by Bun__attachAsyncStackFromPromise) and honors - // Error.prepareStackTrace. if (auto* instance = dynamicDowncast(result)) { auto* trace = instance->stackTrace(); if (!trace || trace->isEmpty()) {