From 1f2c997e0e0e0cfbd74b6c2d659cc58566916e96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:03:59 +0000 Subject: [PATCH 1/6] node:fs: drop the error of fs.symlink() when its 4-argument callback is not callable fs.symlink(target, path, type, callback) passed a non-callable callback straight into .then(callback, callback). Promise machinery ignores non-callable handlers, so when the operation failed the error surfaced as an unhandled rejection and the process exited with 1. Node stores that callback on the request without validating it and skips a non-function one on completion, on failure as well as on success, so the operation's outcome is dropped either way. Attach a no-op in that branch instead. --- src/js/node/fs.ts | 9 +++++++-- test/js/node/fs/fs.test.ts | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index dae063b87fab..68f2cd76cbd9 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -59,6 +59,8 @@ function settleCallbackWithNull(promise: Promise, callback: Callback) { ); } +function discardResult() {} + function openAsBlob(path, options?) { return Promise.$resolve(Bun.file(path, options)); } @@ -430,9 +432,12 @@ var access = function access(path: PathLike, mode?: number | Callback, callback? callback = ensureCallback(type); type = undefined; } else if ($isCallable(callback)) { - // Not ensureCallback: node does not validate the 4-argument overload's - // callback, and a non-callable one must stay an ignored `.then` handler. callback = wrapFsCallback(callback); + } else { + // node does not validate the 4-argument overload's callback; a + // non-callable one is skipped on completion, on failure as well as on + // success. + callback = discardResult; } if ($isCallable(callback)) fs.symlinkCb(err => callback(err === null ? undefined : err), target, path, type); diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index b42366f8a1a3..2b58596b7a88 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -7572,13 +7572,36 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(exitCode).toBe(0); }); - it("leaves a non-callable symlink callback as an ignored handler, like node", async () => { - const { stdout, exitCode } = await runScript(` - require("fs").symlink(${file}, ${dirLit} + "/lnc", "file", "notafunc"); - setTimeout(() => console.log("quiet"), 50); - `); - expect(stdout).toBe("quiet"); - expect(exitCode).toBe(0); + // node does not validate the 4-argument overload's callback: it is stored on + // the request as-is and a non-function one is skipped when the operation + // completes, whether the link was created or the operation failed. The + // pending operation keeps the process alive, so "exited" is printed after + // it completed and anything it reported would precede it. + describe("ignores a non-callable 4-argument symlink callback, like node", () => { + function symlinkIgnoringCallback(link: string, callbackLiteral: string) { + return runScript(` + process.on("uncaughtException", e => console.log("UNCAUGHT:" + e.code)); + process.on("unhandledRejection", e => console.log("REJECTED:" + e.code)); + process.on("exit", () => console.log("exited")); + require("fs").symlink(${file}, ${JSON.stringify(link)}, "file", ${callbackLiteral}); + `); + } + + it("still creates the link", async () => { + const link = join(dir, "lnc-created"); + const { stdout, exitCode } = await symlinkIgnoringCallback(link, `"notafunc"`); + expect(stdout).toBe("exited"); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(exitCode).toBe(0); + }); + + it.each([`"notafunc"`, "null"])("drops the error of a failed operation (callback: %s)", async callbackLiteral => { + const link = join(dir, "missing-dir", "lnc"); + const { stdout, exitCode } = await symlinkIgnoringCallback(link, callbackLiteral); + expect(stdout).toBe("exited"); + expect(existsSync(link)).toBe(false); + expect(exitCode).toBe(0); + }); }); }); From 97c654719befefd2ef614813a55d82977658b626 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:50:45 +0000 Subject: [PATCH 2/6] test: run the symlink callback cases concurrently and pin the undefined-callback overload --- test/js/node/fs/fs.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 2b58596b7a88..9fddffe30e8e 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -7577,7 +7577,7 @@ describe("a throw from a node-style callback is an uncaughtException", () => { // completes, whether the link was created or the operation failed. The // pending operation keeps the process alive, so "exited" is printed after // it completed and anything it reported would precede it. - describe("ignores a non-callable 4-argument symlink callback, like node", () => { + describe.concurrent("ignores a non-callable 4-argument symlink callback, like node", () => { function symlinkIgnoringCallback(link: string, callbackLiteral: string) { return runScript(` process.on("uncaughtException", e => console.log("UNCAUGHT:" + e.code)); @@ -7602,6 +7602,17 @@ describe("a throw from a node-style callback is an uncaughtException", () => { expect(existsSync(link)).toBe(false); expect(exitCode).toBe(0); }); + + // An undefined callback selects the 3-argument overload, whose callback + // (here the string "file") node does validate. + it("still throws when the callback is undefined", () => { + const link = join(dir, "lnc-undefined"); + expect(() => fs.symlink(join(dir, "file.txt"), link, "file", undefined as any)).toThrowWithCode( + TypeError, + "ERR_INVALID_ARG_TYPE", + ); + expect(existsSync(link)).toBe(false); + }); }); }); From 09cf787dbe4de7da544044a33bef18d30cca5c42 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:04 +0000 Subject: [PATCH 3/6] node:fs: shorten the symlink non-callable callback comment --- src/js/node/fs.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 68f2cd76cbd9..43c657146a2f 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -434,9 +434,7 @@ var access = function access(path: PathLike, mode?: number | Callback, callback? } else if ($isCallable(callback)) { callback = wrapFsCallback(callback); } else { - // node does not validate the 4-argument overload's callback; a - // non-callable one is skipped on completion, on failure as well as on - // success. + // node does not validate this overload's callback: it skips a non-function one, on failure too callback = discardResult; } From d7efffcd64ebe8184c0c838d3e45766afd33fa46 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:38:09 +0000 Subject: [PATCH 4/6] node:fs: send a non-callable symlink callback to the callback arm Since #43728 a callable callback of fs.symlink() goes to the native callback arm (fs.symlinkCb), and only a non-callable one stayed on the promise arm. This branch already replaces a non-callable callback with a no-op, so the value is callable on every path and the promise arm is unreachable. Remove it: fs.symlink() now creates no promise at all. The no-op is an arrow in the branch that needs it, so loading node:fs allocates no new function. --- src/js/node/fs.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 43c657146a2f..616b5f97c374 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -59,8 +59,6 @@ function settleCallbackWithNull(promise: Promise, callback: Callback) { ); } -function discardResult() {} - function openAsBlob(path, options?) { return Promise.$resolve(Bun.file(path, options)); } @@ -435,11 +433,10 @@ var access = function access(path: PathLike, mode?: number | Callback, callback? callback = wrapFsCallback(callback); } else { // node does not validate this overload's callback: it skips a non-function one, on failure too - callback = discardResult; + callback = () => {}; } - if ($isCallable(callback)) fs.symlinkCb(err => callback(err === null ? undefined : err), target, path, type); - else fs.symlink(target, path, type).then(callback, callback); + fs.symlinkCb(err => callback(err === null ? undefined : err), target, path, type); }, truncate = function truncate(path, len, callback?) { if (typeof path === "number") { From 93cc425fd2637b7a38d2a63325b51bdd8482edc0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:02:31 +0000 Subject: [PATCH 5/6] test: prove the dropped symlink error is a real ENOENT with a callable control --- test/js/node/fs/fs.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 9fddffe30e8e..5bd53d2e48e8 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5674,7 +5674,7 @@ describe("fs.read/readv/write/writev pass the buffer to the callback on error", fs.write(fd, buffer, offset, length, position, (err, bytesWritten, buf) => { calls.push([err?.code, bytesWritten, buf === buffer]); if (err?.code === "EAGAIN") { - for (let left = filled; left > 0;) + for (let left = filled; left > 0; ) left -= fs.readSync(reader, scratch, 0, Math.min(left, scratch.length)); } cb(err, bytesWritten, buf); @@ -7578,12 +7578,17 @@ describe("a throw from a node-style callback is an uncaughtException", () => { // pending operation keeps the process alive, so "exited" is printed after // it completed and anything it reported would precede it. describe.concurrent("ignores a non-callable 4-argument symlink callback, like node", () => { - function symlinkIgnoringCallback(link: string, callbackLiteral: string) { + // With `control`, a callable callback runs the same operation on the same + // path and prints its error code, to prove the dropped error is real. + function symlinkIgnoringCallback(link: string, callbackLiteral: string, control = false) { + const target = `${file}, ${JSON.stringify(link)}, "file"`; return runScript(` process.on("uncaughtException", e => console.log("UNCAUGHT:" + e.code)); process.on("unhandledRejection", e => console.log("REJECTED:" + e.code)); process.on("exit", () => console.log("exited")); - require("fs").symlink(${file}, ${JSON.stringify(link)}, "file", ${callbackLiteral}); + const fs = require("fs"); + fs.symlink(${target}, ${callbackLiteral}); + ${control ? `fs.symlink(${target}, e => console.log("CONTROL:" + e.code));` : ""} `); } @@ -7597,9 +7602,8 @@ describe("a throw from a node-style callback is an uncaughtException", () => { it.each([`"notafunc"`, "null"])("drops the error of a failed operation (callback: %s)", async callbackLiteral => { const link = join(dir, "missing-dir", "lnc"); - const { stdout, exitCode } = await symlinkIgnoringCallback(link, callbackLiteral); - expect(stdout).toBe("exited"); - expect(existsSync(link)).toBe(false); + const { stdout, exitCode } = await symlinkIgnoringCallback(link, callbackLiteral, true); + expect(stdout.split("\n")).toEqual(["CONTROL:ENOENT", "exited"]); expect(exitCode).toBe(0); }); From 7376d567299c81aff8fd2a519d40d0a780b55251 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:03:54 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- test/js/node/fs/fs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 5bd53d2e48e8..83213f320ceb 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5674,7 +5674,7 @@ describe("fs.read/readv/write/writev pass the buffer to the callback on error", fs.write(fd, buffer, offset, length, position, (err, bytesWritten, buf) => { calls.push([err?.code, bytesWritten, buf === buffer]); if (err?.code === "EAGAIN") { - for (let left = filled; left > 0; ) + for (let left = filled; left > 0;) left -= fs.readSync(reader, scratch, 0, Math.min(left, scratch.length)); } cb(err, bytesWritten, buf);