diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index a5dc3ac28b1f..7567451d2a9a 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -88,13 +88,27 @@ extern "C" int32_t set_process_priority(int32_t pid, int32_t priority) } #if !OS(WINDOWS) +#if defined(O_EXEC) && !OS(LINUX) +// Not the owner-bit test below: like open(O_EXEC), it asks the kernel whether the effective user may execute the file. +static NEVER_INLINE bool isExecutableFileByPath(const char* path) +{ + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode) && faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) == 0; +} +#endif // defined(O_EXEC) && !OS(LINUX) + extern "C" bool is_executable_file(const char* path) { #if defined(O_EXEC) && !OS(LINUX) // musl defines O_EXEC as O_PATH, which opens any file. int fd = open(path, O_EXEC | O_CLOEXEC | O_NONBLOCK | O_NOCTTY, 0); - if (fd < 0) + if (fd < 0) { + int error = errno; + // These two errors say that no descriptor was free. They are not an answer about the path. + if (error == EMFILE || error == ENFILE) [[unlikely]] + return isExecutableFileByPath(path); return false; + } close(fd); return true; #endif // defined(O_EXEC) && !OS(LINUX) diff --git a/src/runtime/cli/open.rs b/src/runtime/cli/open.rs index 567ea3f073ef..95f54216eb95 100644 --- a/src/runtime/cli/open.rs +++ b/src/runtime/cli/open.rs @@ -98,6 +98,13 @@ impl Editor { let _ = opened.close(); // close error is non-actionable return Some(path.as_bytes()); } + // No descriptor was free for the open, so ask about the path. + bun_sys::Result::Err(err) + if matches!(err.get_errno(), bun_sys::E::EMFILE | bun_sys::E::ENFILE) + && bun_sys::access(path, bun_sys::posix::R_OK).is_ok() => + { + return Some(path.as_bytes()); + } bun_sys::Result::Err(_) => {} } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 903d7ed2fadd..5d1c0df4ba45 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -3021,7 +3021,7 @@ mod posix_impl { unsafe { libc::faccessat(dir.native(), sub.as_ptr(), libc::F_OK, 0) == 0 } } } - /// Calls extern C `is_executable_file` (c-bindings.cpp:72-89) via FFI. + /// Whether a `$PATH` search takes `path` as the command. `is_executable_file` in c-bindings.cpp has the rule of each platform. It answers also when the process has no descriptor left. pub fn is_executable_file_path(path: &ZStr) -> bool { unsafe extern "C" { // `c_char`, not `i8` — `char` is unsigned on aarch64/arm/ppc, so diff --git a/test/js/bun/spawn/spawn-path.test.ts b/test/js/bun/spawn/spawn-path.test.ts index 768bee78456a..f887cef03cca 100644 --- a/test/js/bun/spawn/spawn-path.test.ts +++ b/test/js/bun/spawn/spawn-path.test.ts @@ -26,6 +26,80 @@ echo "hello from script"`, expect(status).toBe(0); }); +// The PATH search needs no descriptor, so with none left the command is still found. The spawn then fails when it +// makes the pipes, with EMFILE, as in Node. macOS opened each PATH candidate to test it, found nothing, and +// reported ENOENT: "Executable not found in $PATH". +test.skipIf(isWindows)("spawn finds a command on PATH when the process has no descriptor left", async () => { + using dir = tempDir("spawn-path-no-descriptor", { "tool": "#!/bin/sh\necho tool ran\n" }); + chmodSync(path.join(String(dir), "tool"), 0o755); + + const fixture = /* js */ ` + const cp = require("node:child_process"); + const fs = require("node:fs"); + const env = { PATH: process.env.TOOL_DIR }; + const code = fn => { + try { + fn(); + return "spawned"; + } catch (error) { + return error.code; + } + }; + const spawnEvent = name => + new Promise(resolve => { + const child = cp.spawn(name, [], { env, stdio: "pipe" }); + child.once("error", error => resolve(error.code)); + child.once("close", () => resolve("spawned")); + }); + + // A debug build reads its internal modules from disk on first use, and that needs a descriptor. + const warmUp = [ + cp.spawnSync("tool", [], { env, stdio: "pipe" }).status, + Bun.spawnSync({ cmd: ["tool"], env, stdout: "pipe", stderr: "pipe" }).exitCode, + await spawnEvent("tool"), + await spawnEvent("missing-tool"), + ]; + + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + const noDescriptorLeft = { + "child_process.spawnSync": cp.spawnSync("tool", [], { env, stdio: "pipe" }).error?.code, + "child_process.spawn": await spawnEvent("tool"), + "Bun.spawnSync": code(() => Bun.spawnSync({ cmd: ["tool"], env, stdout: "pipe", stderr: "pipe" })), + "Bun.spawn": code(() => Bun.spawn({ cmd: ["tool"], env, stdout: "pipe", stderr: "pipe" })), + }; + for (const fd of held) fs.closeSync(fd); + + const afterClose = cp.spawnSync("tool", [], { env, stdio: "pipe", encoding: "utf8" }).stdout; + console.log(JSON.stringify({ warmUp, heldSome: held.length > 0, noDescriptorLeft, afterClose })); + `; + + await using proc = Bun.spawn({ + // `ulimit -n` also sets the hard limit, so the runtime cannot raise it again. + cmd: ["/bin/sh", "-c", 'ulimit -n 128 && exec "$@"', "sh", bunExe(), "-e", fixture], + env: { ...bunEnv, TOOL_DIR: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + warmUp: [0, 0, "spawned", "ENOENT"], + heldSome: true, + noDescriptorLeft: { + "child_process.spawnSync": "EMFILE", + "child_process.spawn": "EMFILE", + "Bun.spawnSync": "EMFILE", + "Bun.spawn": "EMFILE", + }, + afterClose: "tool ran\n", + }); + expect(exitCode).toBe(0); +}); + // What a Windows environment has. bunEnv spells it `PATH`. test.skipIf(!isWindows)("spawn uses the PATH that env spells `Path`", async () => { using dir = tempDir("spawn-path-spelling", { "only-here.cmd": "@echo found\r\n" }); diff --git a/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts b/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts index dee0c8c0d73c..31f5e3ff3865 100644 --- a/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts +++ b/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts @@ -13,10 +13,11 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isPosix } from "harness"; -// Absolute argv[0] so PATH lookup (which_for_spawn) is skipped; on musl that -// lookup fails under EMFILE before the event loop is created and turns the -// failure into ENOENT instead of exercising us_create_loop. -const fixture = /* js */ ` +// `/bin/sh` skips the PATH lookup (which_for_spawn), so that row tests +// us_create_loop alone. `sh` runs the lookup before the event loop is created: +// the lookup must find the command with no descriptor free, and then the +// failure comes from us_create_loop all the same. +const fixture = (command: string) => /* js */ ` import * as fs from "node:fs"; // Warm anything lazily opened on first use so the fd fill below leaves zero // descriptors for us_create_loop itself (not for a module loader read). @@ -27,7 +28,7 @@ const fixture = /* js */ ` let first; try { - Bun.spawnSync({ cmd: ["/bin/sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); + Bun.spawnSync({ cmd: [${JSON.stringify(command)}, "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); first = { ok: false, msg: "UNEXPECTED: spawnSync succeeded" }; } catch (e) { first = { ok: true, code: e?.code, msg: String(e?.message ?? e) }; @@ -40,16 +41,17 @@ const fixture = /* js */ ` // Descriptors are free again: the isolated loop was not cached on failure, // so this call creates it successfully and runs the child. - const retry = Bun.spawnSync({ cmd: ["/bin/sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); + const retry = Bun.spawnSync({ cmd: [${JSON.stringify(command)}, "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); console.error("retry exit:", retry.exitCode); console.error("SURVIVED"); `; describe.skipIf(!isPosix)("Bun.spawnSync event-loop creation under EMFILE", () => { - test("throws instead of aborting the process", async () => { + test.concurrent.each(["/bin/sh", "sh"])("throws instead of aborting the process: %s", async command => { await using proc = Bun.spawn({ - cmd: ["/bin/sh", "-c", `ulimit -n 512 && exec "$1" --no-install -e "$2"`, "sh", bunExe(), fixture], - env: bunEnv, + cmd: ["/bin/sh", "-c", `ulimit -n 512 && exec "$1" --no-install -e "$2"`, "sh", bunExe(), fixture(command)], + // A fixed PATH, so that the `sh` row finds its command whatever PATH the test runner has. + env: { ...bunEnv, PATH: "/bin:/usr/bin" }, stdout: "pipe", stderr: "pipe", }); diff --git a/test/js/bun/util/which.test.ts b/test/js/bun/util/which.test.ts index 63b2de42ab2b..0503ec08e3ad 100644 --- a/test/js/bun/util/which.test.ts +++ b/test/js/bun/util/which.test.ts @@ -1,9 +1,10 @@ import { $, which } from "bun"; import { dlopen, ptr } from "bun:ffi"; import { expect, jest, test } from "bun:test"; -import { isArm64, isIntelMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isArm64, isIntelMacOS, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { mkfifo } from "mkfifo"; import { chmodSync, copyFileSync, existsSync, mkdirSync, realpathSync, rmdirSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { tmpdir, userInfo } from "node:os"; import { basename, join } from "node:path"; $.nothrow(); @@ -409,3 +410,144 @@ test("Bun.which can find executables in a non-ascii directory", async () => { process.chdir(cwd); } }); + +// Runs `fixture` with a low descriptor limit, so that it can use up every descriptor in a short loop. +// `ulimit -n` also sets the hard limit, so the runtime cannot raise it again. +async function runWithDescriptorLimit(fixture: string, env: Record) { + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'ulimit -n 128 && exec "$@"', "sh", bunExe(), "-e", fixture], + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// The lookup asks a question about a path, which needs no descriptor. macOS opened each candidate to ask it, so +// with no descriptor left every command was "not found". +test.skipIf(isWindows)("which finds a command when the process has no descriptor left", async () => { + // On macOS an ACL can refuse a file that has the execute bits, and grant one that has none. The owner bit says the + // opposite for both files, so these rows fail if the lookup reads only the mode when it has no descriptor. + const aclRows = isMacOS && process.getuid!() !== 0; + using dir = tempDir("which-no-descriptor", { + "tool": "#!/bin/sh\n", + "not-executable": "#!/bin/sh\n", + "folder/keep": "", + ...(aclRows ? { "acl-denied": "#!/bin/sh\n", "acl-allowed": "#!/bin/sh\n" } : {}), + }); + const base = String(dir); + chmodSync(join(base, "tool"), 0o755); + chmodSync(join(base, "not-executable"), 0o644); + symlinkSync("tool", join(base, "link")); + mkfifo(join(base, "fifo"), 0o755); + const acls: [file: string, mode: number, entry: string][] = aclRows + ? [ + ["acl-denied", 0o755, "everyone deny execute"], + ["acl-allowed", 0o644, `${userInfo().username} allow execute`], + ] + : []; + + const fixture = /* js */ ` + const fs = require("node:fs"); + const dir = process.env.TOOL_DIR; + const lookups = () => ({ + bare: Bun.which("tool", { PATH: dir }), + relative: Bun.which("./tool", { PATH: "", cwd: dir }), + absolute: Bun.which(dir + "/tool", { PATH: "" }), + symlink: Bun.which("link", { PATH: dir }), + notExecutable: Bun.which("not-executable", { PATH: dir }), + fifo: Bun.which("fifo", { PATH: dir }), + directory: Bun.which("folder", { PATH: dir }), + missing: Bun.which("missing", { PATH: dir }), + ...(process.env.ACL_ROWS && { + aclDenied: Bun.which("acl-denied", { PATH: dir }), + aclAllowed: Bun.which("acl-allowed", { PATH: dir }), + }), + }); + const withDescriptors = lookups(); + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + const withoutDescriptors = lookups(); + // LeakSanitizer reads /proc/self/task at exit, which needs a free descriptor. + for (const fd of held) fs.closeSync(fd); + console.log(JSON.stringify({ heldSome: held.length > 0, withDescriptors, withoutDescriptors })); + `; + try { + for (const [file, mode, entry] of acls) { + chmodSync(join(base, file), mode); + const { exitCode, stderr } = Bun.spawnSync(["/bin/chmod", "+a", entry, join(base, file)]); + expect({ file, exitCode, stderr: stderr.toString() }).toEqual({ file, exitCode: 0, stderr: "" }); + } + const { stdout, stderr, exitCode } = await runWithDescriptorLimit(fixture, { + TOOL_DIR: base, + ACL_ROWS: aclRows ? "1" : "", + }); + + const answers = { + bare: join(base, "tool"), + relative: join(base, "tool"), + absolute: join(base, "tool"), + symlink: join(base, "link"), + notExecutable: null, + fifo: null, + directory: null, + missing: null, + ...(aclRows && { aclDenied: null, aclAllowed: join(base, "acl-allowed") }), + }; + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ heldSome: true, withDescriptors: answers, withoutDescriptors: answers }); + expect(exitCode).toBe(0); + } finally { + for (const [file] of acls) Bun.spawnSync(["/bin/chmod", "-N", join(base, file)]); + } +}); + +// Each step has one more descriptor free than the step before. A step either fails for want of a descriptor or +// runs the command. On macOS the step that had descriptors for the shell but none for the lookup printed +// "bun: command not found: tool". +test.skipIf(isWindows)("the shell does not call a command missing when descriptors run out", async () => { + using dir = tempDir("which-shell-no-descriptor", { "tool": "#!/bin/sh\necho tool ran\n" }); + chmodSync(join(String(dir), "tool"), 0o755); + + const fixture = /* js */ ` + const fs = require("node:fs"); + Bun.$.env({ PATH: process.env.TOOL_DIR }); + async function run() { + try { + const { exitCode, stdout, stderr } = await Bun.$\`tool\`.nothrow().quiet(); + if (exitCode === 0 && stdout.toString() === "tool ran\\n") return "ran"; + if (exitCode !== 0 && stderr.toString().includes("Too many open files")) return "EMFILE"; + return { exitCode, stdout: stdout.toString(), stderr: stderr.toString() }; + } catch (error) { + return error.code ?? String(error); + } + } + // A debug build reads its internal modules from disk on first use, and that needs a descriptor. + const warmUp = await run(); + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + const steps = []; + while (held.length > 0 && steps.at(-1) !== "ran") { + steps.push(await run()); + fs.closeSync(held.pop()); + } + for (const fd of held) fs.closeSync(fd); + console.log(JSON.stringify({ warmUp, steps })); + `; + const { stdout, stderr, exitCode } = await runWithDescriptorLimit(fixture, { TOOL_DIR: String(dir) }); + + expect(stderr).toBe(""); + const { warmUp, steps } = JSON.parse(stdout); + expect({ + warmUp, + first: steps[0], + last: steps.at(-1), + neitherOutOfDescriptorsNorRan: steps.filter((step: unknown) => step !== "EMFILE" && step !== "ran"), + }).toEqual({ warmUp: "ran", first: "EMFILE", last: "ran", neitherOutOfDescriptorsNorRan: [] }); + expect(exitCode).toBe(0); +});