From f2f7eab5d4a053cbd36c696cff37db086696422a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:14:04 +0000 Subject: [PATCH 01/10] test: a PATH lookup finds a command when no descriptor is free `Bun.which`, a spawn by name and a shell command all search PATH. The search asks a question about a path, so it must also work when the process has no file descriptor left. A spawn by name then fails on its pipes with EMFILE, as in Node. macOS opens each candidate to test it. These tests fail there until the probe stops opening the candidate: the spawn reports ENOENT, `Bun.which` returns null, and the shell prints "command not found". Linux passes, because its probe is a `stat`. --- test/js/bun/spawn/spawn-path.test.ts | 74 +++++++++++ .../spawn/spawn-sync-loop-init-fail.test.ts | 10 +- test/js/bun/util/which.test.ts | 117 +++++++++++++++++- 3 files changed, 195 insertions(+), 6 deletions(-) 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..10fa235ecaec 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,9 +13,9 @@ 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. +// argv[0] is a name, so the PATH lookup (which_for_spawn) runs before the event +// loop is created. The lookup needs no descriptor: the command is found, and +// the failure comes from us_create_loop. const fixture = /* js */ ` import * as fs from "node:fs"; // Warm anything lazily opened on first use so the fd fill below leaves zero @@ -27,7 +27,7 @@ const fixture = /* js */ ` let first; try { - Bun.spawnSync({ cmd: ["/bin/sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); + Bun.spawnSync({ cmd: ["sh", "-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,7 +40,7 @@ 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: ["sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); console.error("retry exit:", retry.exitCode); console.error("SURVIVED"); `; diff --git a/test/js/bun/util/which.test.ts b/test/js/bun/util/which.test.ts index 63b2de42ab2b..6a029699ce66 100644 --- a/test/js/bun/util/which.test.ts +++ b/test/js/bun/util/which.test.ts @@ -1,7 +1,8 @@ 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, 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 { basename, join } from "node:path"; @@ -409,3 +410,117 @@ 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 () => { + using dir = tempDir("which-no-descriptor", { + "tool": "#!/bin/sh\n", + "not-executable": "#!/bin/sh\n", + "folder/keep": "", + }); + 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 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 }), + }); + 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 })); + `; + const { stdout, stderr, exitCode } = await runWithDescriptorLimit(fixture, { TOOL_DIR: base }); + + 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, + }; + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ heldSome: true, withDescriptors: answers, withoutDescriptors: answers }); + expect(exitCode).toBe(0); +}); + +// 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); +}); From 12f968e1a4d664f9b16acd99a29d2f261f8ec992 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:14:05 +0000 Subject: [PATCH 02/10] test: temporary report on the macOS probe of a PATH candidate Compares open(O_EXEC) with getattrlist on the macOS CI machines: the answers for every file of every PATH directory, and the cost per call. The test fails on purpose so that CI prints the report. It is removed before the pull request is ready. [macos tests] --- test/js/bun/util/which-probe-measure.test.ts | 427 +++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 test/js/bun/util/which-probe-measure.test.ts diff --git a/test/js/bun/util/which-probe-measure.test.ts b/test/js/bun/util/which-probe-measure.test.ts new file mode 100644 index 000000000000..23498f902a33 --- /dev/null +++ b/test/js/bun/util/which-probe-measure.test.ts @@ -0,0 +1,427 @@ +// Temporary. It compares the old probe of a PATH candidate, open(O_EXEC) and close, with the new one, one getattrlist +// call, on the macOS CI machines: the answers for every file of every PATH directory, and the cost per call. The test +// fails on purpose so that CI prints the report. Remove this file before the PR is ready. +import { dlopen, ptr, read } from "bun:ffi"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { release, userInfo } from "node:os"; +import { dirname, join } from "node:path"; + +// The Linux column is a stand-in, to run the script where there is no Mac: O_PATH for O_EXEC, and no getattrlist. +const O_PROBE = isMacOS ? 0x40000000 | 0x01000000 | 0x0004 | 0x20000 : 0o10000000 | 0o2000000; +const AT_FDCWD = isMacOS ? -2 : -100; +const AT_EACCESS = isMacOS ? 0x0010 : 0x200; +const ATTR_CMN_OBJTYPE = 0x00000008; +const ATTR_CMN_USERACCESS = 0x00200000; +const VREG = 1; +const X_OK = 1; +const REPLY_SIZE = 12; + +const loadErrors: string[] = []; + +function setup() { + function tryLoad(symbols: Record): any { + try { + return dlopen(libcPathForDlopen(), symbols as any).symbols; + } catch (error) { + loadErrors.push(String(error)); + return {}; + } + } + + function load() { + const lib = { + ...dlopen(libcPathForDlopen(), { + open: { args: ["ptr", "i32"], returns: "i32" }, + close: { args: ["i32"], returns: "i32" }, + faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, + getpid: { args: [], returns: "i32" }, + [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, + } as any).symbols, + ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), + ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), + } as any; + // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. + const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; + const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as + ((path: number, out: number) => number) | undefined; + const errnoLocation = lib.__error ?? lib.__errno_location; + return { lib, stat, errno: () => read.i32(errnoLocation(), 0) }; + } + + const { lib, stat, errno } = load(); + + // Each view is made over its own ArrayBuffer before `ptr` reads its address: the first `.buffer` of a small typed + // array moves its bytes. + const request = new Uint8Array(new ArrayBuffer(24)); + { + const view = new DataView(request.buffer); + view.setUint16(0, 5, true); // ATTR_BIT_MAP_COUNT + view.setUint32(4, ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS, true); + } + const reply = new Uint32Array(new ArrayBuffer(12)); + const statBuffer = new Uint8Array(new ArrayBuffer(512)); + const statView = new DataView(statBuffer.buffer); + const requestPtr = ptr(request); + const replyPtr = ptr(reply); + const statPtr = ptr(statBuffer); + + const cString = (path: string) => { + const bytes = new Uint8Array(new ArrayBuffer(Buffer.byteLength(path) + 1)); + new TextEncoder().encodeInto(path, bytes); + return bytes; + }; + + type Answer = { ok: boolean; errno: number; detail?: string }; + + function openProbe(path: number): Answer { + const fd = lib.open(path, O_PROBE); + if (fd < 0) return { ok: false, errno: errno() }; + lib.close(fd); + return { ok: true, errno: 0 }; + } + + function attrProbe(path: number): Answer { + if (!isMacOS) return statProbe(path); + if (!lib.getattrlist) return { ok: false, errno: -1 }; + reply.fill(0); + const rc = lib.getattrlist(path, requestPtr, replyPtr, REPLY_SIZE, 0); + if (rc !== 0) return { ok: false, errno: errno() }; + const detail = `len=${reply[0]} type=${reply[1]} access=${reply[2]}`; + return { ok: reply[1] === VREG && (reply[2] & X_OK) !== 0, errno: 0, detail }; + } + + function statProbe(path: number): Answer { + if (!stat) return { ok: false, errno: -1 }; + statBuffer.fill(0); + if (stat(path, statPtr) !== 0) return { ok: false, errno: errno() }; + // st_mode: offset 4 in the 64-bit-inode layout of macOS, offset 24 on Linux x64, 16 on Linux arm64. + const mode = isMacOS ? statView.getUint16(4, true) : statView.getUint32(process.arch === "x64" ? 24 : 16, true); + if ((mode & 0o170000) !== 0o100000) return { ok: false, errno: 0, detail: "mode=" + mode.toString(8) }; + if (lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) !== 0) return { ok: false, errno: errno() }; + return { ok: true, errno: 0 }; + } + + function probeAll(path: string) { + const bytes = cString(path); + const p = ptr(bytes); + return { open: openProbe(p), attr: attrProbe(p), stat: statProbe(p) }; + } + + const show = (answer: Answer) => (answer.ok ? "yes" : answer.errno ? "no(errno " + answer.errno + ")" : "no"); + + // The instructions that the whole process retired, from the counter `/usr/bin/time -l` reads. + const rusage = new BigUint64Array(new ArrayBuffer(512)); + const rusagePtr = ptr(rusage); + function counters() { + if (!lib.proc_pid_rusage || lib.proc_pid_rusage(process.pid, 4, rusagePtr) !== 0) return null; + // rusage_info_v4: ri_instructions at byte 248, ri_cycles at byte 256. + return { instructions: Number(rusage[31]), cycles: Number(rusage[32]) }; + } + + function measure(fn: () => void, calls: number) { + const before = counters(); + const start = Bun.nanoseconds(); + for (let i = 0; i < calls; i++) fn(); + const ns = (Bun.nanoseconds() - start) / calls; + const after = counters(); + return { + ns, + instructions: before && after ? (after.instructions - before.instructions) / calls : null, + cycles: before && after ? (after.cycles - before.cycles) / calls : null, + }; + } + + return { lib, stat, cString, probeAll, show, measure, requestPtr, replyPtr, statPtr }; +} + +const median = (values: number[]) => values.toSorted((a, b) => a - b)[values.length >> 1]; +const spread = (values: number[]) => { + const sorted = values.toSorted((a, b) => a - b); + return sorted[0] === 0 ? 0 : ((sorted.at(-1)! - sorted[0]) / sorted[0]) * 100; +}; + +test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( + "report: open(O_EXEC) against getattrlist for a PATH candidate", + async () => { + const { lib, stat, cString, probeAll, show, measure, requestPtr, replyPtr, statPtr } = setup(); + const lines: string[] = []; + const log = (line: string) => lines.push("MEASURE " + line); + + const productVersion = isMacOS ? spawnSync("sw_vers", ["-productVersion"], { encoding: "utf8" }).stdout.trim() : ""; + log( + `system: macOS ${productVersion} darwin ${release()} ${process.arch} uid=${process.getuid!()} euid=${process.geteuid!()} user=${userInfo().username} bun=${Bun.version} ${Bun.revision.slice(0, 10)}`, + ); + + // 1. The reply for a known executable. + log(`reply for ${process.execPath}: ${JSON.stringify(probeAll(process.execPath))}`); + if (loadErrors.length) log("symbols that did not load: " + loadErrors.join("; ")); + + // 2. Every file of every PATH directory, and of some more directories. + const directories = [ + ...new Set([ + ...(process.env.PATH ?? "").split(":").filter(Boolean), + "/bin", + "/usr/bin", + "/sbin", + "/usr/sbin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/libexec", + "/usr/lib", + "/etc", + "/usr/share/zsh", + "/Applications", + "/System/Library/CoreServices", + dirname(process.execPath), + process.cwd(), + ]), + ]; + let total = 0; + const counts = { open: 0, attr: 0, stat: 0 }; + const differences: string[] = []; + const errnos = new Map(); + const scanned: string[] = []; + for (const directory of directories) { + let names: string[]; + try { + names = readdirSync(directory); + } catch { + continue; + } + let inDirectory = 0; + for (const name of names) { + const path = join(directory, name); + const answers = probeAll(path); + total++; + inDirectory++; + for (const key of ["open", "attr", "stat"] as const) { + if (answers[key].ok) counts[key]++; + else errnos.set(`${key}:${answers[key].errno}`, (errnos.get(`${key}:${answers[key].errno}`) ?? 0) + 1); + } + if (answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok) { + differences.push( + `${path} open=${show(answers.open)} attr=${show(answers.attr)}${answers.attr.detail ? "[" + answers.attr.detail + "]" : ""} stat=${show(answers.stat)}`, + ); + } + } + scanned.push(`${directory}=${inDirectory}`); + } + log(`scanned (directory=entries): ${scanned.join(" ")}`); + log( + `scan: ${total} paths; executable by open(O_EXEC) ${counts.open}, by getattrlist ${counts.attr}, by stat+faccessat ${counts.stat}; answers that differ: ${differences.length}`, + ); + log(`scan errnos of "no": ${JSON.stringify(Object.fromEntries([...errnos].sort()))}`); + for (const difference of differences.slice(0, 12)) log("differs: " + difference); + + // 3. Fixtures: one per kind of file and permission. + using dir = tempDir("which-probe-measure", { + "exec": "#!/bin/sh\n", + "empty-exec": "", + "plain": "#!/bin/sh\n", + "execute-only": "#!/bin/sh\n", + "owner-x": "#!/bin/sh\n", + "group-x": "#!/bin/sh\n", + "other-x": "#!/bin/sh\n", + "acl-deny-readattr": "#!/bin/sh\n", + "acl-deny-execute": "#!/bin/sh\n", + "acl-allow-execute": "#!/bin/sh\n", + "folder/inside": "#!/bin/sh\n", + "locked/inside": "#!/bin/sh\n", + }); + const base = String(dir); + const at = (name: string) => join(base, name); + chmodSync(at("exec"), 0o755); + chmodSync(at("empty-exec"), 0o755); + chmodSync(at("plain"), 0o644); + chmodSync(at("execute-only"), 0o111); + chmodSync(at("owner-x"), 0o100); + chmodSync(at("group-x"), 0o010); + chmodSync(at("other-x"), 0o001); + chmodSync(at("acl-deny-readattr"), 0o755); + chmodSync(at("acl-deny-execute"), 0o755); + chmodSync(at("acl-allow-execute"), 0o644); + chmodSync(at("folder/inside"), 0o755); + chmodSync(at("locked/inside"), 0o755); + symlinkSync("exec", at("link")); + symlinkSync("missing", at("dangling-link")); + symlinkSync("folder", at("link-to-folder")); + symlinkSync("plain", at("link-to-plain")); + mkfifo(at("fifo"), 0o755); + mkdirSync(at("app.app"), { mode: 0o755 }); + using listener = Bun.listen({ unix: at("socket"), socket: { data() {} } }); + chmodSync(at("socket"), 0o755); + const acl: string[] = []; + if (isMacOS) { + for (const [entry, name] of [ + ["everyone deny readattr", "acl-deny-readattr"], + ["everyone deny execute", "acl-deny-execute"], + [`${userInfo().username} allow execute`, "acl-allow-execute"], + ]) { + const { status, stderr } = spawnSync("/bin/chmod", ["+a", entry, at(name)], { encoding: "utf8" }); + acl.push(`${name}:${status === 0 ? "set" : "failed " + stderr.trim()}`); + } + } + log(`acl: ${acl.join(", ")}`); + chmodSync(at("locked"), 0o000); + const fixtures = [ + "exec", + "empty-exec", + "plain", + "execute-only", + "owner-x", + "group-x", + "other-x", + "acl-deny-readattr", + "acl-deny-execute", + "acl-allow-execute", + "folder", + "folder/inside", + "folder/inside/", + "exec/", + "locked/inside", + "link", + "dangling-link", + "link-to-folder", + "link-to-plain", + "fifo", + "socket", + "app.app", + "missing", + "", + ]; + let fixtureDifferences = 0; + for (const name of fixtures) { + const path = name === "" ? "" : at(name); + const answers = probeAll(path); + const differs = answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok; + if (differs) fixtureDifferences++; + let mode = ""; + try { + mode = (statSync(path).mode & 0o7777).toString(8); + } catch {} + log( + `fixture ${JSON.stringify(name)} mode=${mode}: open=${show(answers.open)} attr=${show(answers.attr)}${answers.attr.detail ? "[" + answers.attr.detail + "]" : ""} stat=${show(answers.stat)} which=${JSON.stringify(Bun.which(path, { PATH: "" }) !== null)}${differs ? " <-- DIFFERS" : ""}`, + ); + } + chmodSync(at("locked"), 0o755); + if (isMacOS) { + for (const name of ["acl-deny-readattr", "acl-deny-execute", "acl-allow-execute"]) { + spawnSync("/bin/chmod", ["-N", at(name)]); + } + } + log(`fixtures: ${fixtures.length}; answers that differ: ${fixtureDifferences}`); + + // 4. The same probes with no descriptor left. + writeFileSync( + at("limit.ts"), + ` + import { dlopen, ptr, read } from "bun:ffi"; + import { openSync, closeSync } from "node:fs"; + const isMacOS = process.platform === "darwin"; + const lib = dlopen(process.env.LIBC!, { + open: { args: ["ptr", "i32"], returns: "i32" }, + close: { args: ["i32"], returns: "i32" }, + [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, + ...(isMacOS ? { getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } } : {}), + } as any).symbols as any; + const errno = () => read.i32((lib.__error ?? lib.__errno_location)(), 0); + const request = new Uint8Array(new ArrayBuffer(24)); + new DataView(request.buffer).setUint16(0, 5, true); + new DataView(request.buffer).setUint32(4, ${ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS}, true); + const reply = new Uint32Array(new ArrayBuffer(12)); + const path = new Uint8Array(new ArrayBuffer(Buffer.byteLength(process.env.TARGET!) + 1)); + new TextEncoder().encodeInto(process.env.TARGET!, path); + const probes = () => { + const fd = lib.open(ptr(path), ${O_PROBE}); + const open = fd < 0 ? "no(errno " + errno() + ")" : (lib.close(fd), "yes"); + let attr = "n/a"; + if (isMacOS) { + reply.fill(0); + const rc = lib.getattrlist(ptr(path), ptr(request), ptr(reply), 12, 0); + attr = rc !== 0 ? "no(errno " + errno() + ")" : reply[1] === 1 && (reply[2] & 1) !== 0 ? "yes" : "no"; + } + return { open, attr, which: Bun.which(process.env.TARGET!, { PATH: "" }) !== null }; + }; + const free = probes(); + const held: number[] = []; + try { + for (;;) held.push(openSync("/dev/null", "r")); + } catch {} + const atLimit = probes(); + for (const fd of held) closeSync(fd); + console.log(JSON.stringify({ held: held.length, free, atLimit })); + `, + ); + { + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'ulimit -n 64 && exec "$@"', "sh", bunExe(), at("limit.ts")], + env: { ...bunEnv, LIBC: libcPathForDlopen(), TARGET: at("exec") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + log(`no descriptor left: ${stdout.trim()} exit=${exitCode} ${stderr.trim().slice(0, 300)}`); + } + + // 5. The cost of one probe: a hit (an executable) and a miss (a name that does not exist). + const hit = cString(process.execPath); + const miss = cString(join(dirname(process.execPath), "no-such-command-for-which-probe")); + const shapes: Record void> = { + "open+close": path => { + const fd = lib.open(path, O_PROBE); + if (fd >= 0) lib.close(fd); + }, + "getattrlist": path => { + if (lib.getattrlist) lib.getattrlist(path, requestPtr, replyPtr, REPLY_SIZE, 0); + }, + "stat+faccessat": path => { + if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); + }, + // What two and one calls through bun:ffi cost with no system call to speak of. + "ffi x2": () => { + lib.getpid(); + lib.getpid(); + }, + "ffi x1": () => { + lib.getpid(); + }, + }; + const calls = 20_000; + const rounds = 7; + for (const [label, bytes] of [ + ["hit", hit], + ["miss", miss], + ] as const) { + const p = ptr(bytes); + const samples: Record = {}; + for (const name of Object.keys(shapes)) samples[name] = { ns: [], instructions: [], cycles: [] }; + for (const name of Object.keys(shapes)) measure(() => shapes[name](p), 2_000); + for (let round = 0; round < rounds; round++) { + for (const name of Object.keys(shapes)) { + const result = measure(() => shapes[name](p), calls); + samples[name].ns.push(result.ns); + if (result.instructions !== null) samples[name].instructions.push(result.instructions); + if (result.cycles !== null) samples[name].cycles.push(result.cycles); + } + } + for (const name of Object.keys(shapes)) { + const { ns, instructions, cycles } = samples[name]; + log( + `cost ${label} ${name}: ` + + (instructions.length + ? `instructions/call median ${Math.round(median(instructions))} (spread ${spread(instructions).toFixed(1)}%), cycles/call median ${Math.round(median(cycles))}, ` + : "instructions n/a, ") + + `ns/call median ${Math.round(median(ns))} (spread ${spread(ns).toFixed(1)}%) [${rounds} rounds x ${calls} calls]`, + ); + } + } + + console.log(lines.join("\n")); + expect(lines.length).toBe(-1); + }, + 300_000, +); From b8cef3f19d3416bbdbc21dda02c4ecccd05406bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:39:10 +0000 Subject: [PATCH 03/10] test: widen the temporary report on the macOS probe Adds the cost of each way to ask for several kinds of target (system binaries, a script in a temporary directory, a node_modules/.bin symlink, a path ten directories down), the pieces of each call on their own, and two predicates that read only the mode bits. [macos tests] --- test/js/bun/util/which-probe-measure.test.ts | 366 +++++++++++-------- 1 file changed, 217 insertions(+), 149 deletions(-) diff --git a/test/js/bun/util/which-probe-measure.test.ts b/test/js/bun/util/which-probe-measure.test.ts index 23498f902a33..bf6842978fdf 100644 --- a/test/js/bun/util/which-probe-measure.test.ts +++ b/test/js/bun/util/which-probe-measure.test.ts @@ -1,12 +1,21 @@ -// Temporary. It compares the old probe of a PATH candidate, open(O_EXEC) and close, with the new one, one getattrlist -// call, on the macOS CI machines: the answers for every file of every PATH directory, and the cost per call. The test +// Temporary. It compares ways to ask "is this PATH candidate a file that I may execute" on the macOS CI machines: +// the answers for every file of every PATH directory, and the cost per call for several kinds of target. The test // fails on purpose so that CI prints the report. Remove this file before the PR is ready. import { dlopen, ptr, read } from "bun:ffi"; import { expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen, tempDir } from "harness"; import { mkfifo } from "mkfifo"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + realpathSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { release, userInfo } from "node:os"; import { dirname, join } from "node:path"; @@ -15,10 +24,13 @@ const O_PROBE = isMacOS ? 0x40000000 | 0x01000000 | 0x0004 | 0x20000 : 0o1000000 const AT_FDCWD = isMacOS ? -2 : -100; const AT_EACCESS = isMacOS ? 0x0010 : 0x200; const ATTR_CMN_OBJTYPE = 0x00000008; +const ATTR_CMN_OWNERID = 0x00008000; +const ATTR_CMN_GRPID = 0x00010000; +const ATTR_CMN_ACCESSMASK = 0x00020000; const ATTR_CMN_USERACCESS = 0x00200000; const VREG = 1; +const F_OK = 0; const X_OK = 1; -const REPLY_SIZE = 12; const loadErrors: string[] = []; @@ -32,46 +44,49 @@ function setup() { } } - function load() { - const lib = { - ...dlopen(libcPathForDlopen(), { - open: { args: ["ptr", "i32"], returns: "i32" }, - close: { args: ["i32"], returns: "i32" }, - faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, - getpid: { args: [], returns: "i32" }, - [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, - } as any).symbols, - ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), - ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), - } as any; - // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. - const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; - const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as - ((path: number, out: number) => number) | undefined; - const errnoLocation = lib.__error ?? lib.__errno_location; - return { lib, stat, errno: () => read.i32(errnoLocation(), 0) }; - } - - const { lib, stat, errno } = load(); + const lib = { + ...dlopen(libcPathForDlopen(), { + open: { args: ["ptr", "i32"], returns: "i32" }, + close: { args: ["i32"], returns: "i32" }, + access: { args: ["ptr", "i32"], returns: "i32" }, + faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, + getpid: { args: [], returns: "i32" }, + [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, + } as any).symbols, + ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), + ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), + } as any; + // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. + const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; + const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as + ((path: number, out: number) => number) | undefined; + const errnoLocation = lib.__error ?? lib.__errno_location; + const errno = () => read.i32(errnoLocation(), 0); // Each view is made over its own ArrayBuffer before `ptr` reads its address: the first `.buffer` of a small typed // array moves its bytes. - const request = new Uint8Array(new ArrayBuffer(24)); - { + const attrRequest = (attributes: number) => { + const request = new Uint8Array(new ArrayBuffer(24)); const view = new DataView(request.buffer); view.setUint16(0, 5, true); // ATTR_BIT_MAP_COUNT - view.setUint32(4, ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS, true); - } - const reply = new Uint32Array(new ArrayBuffer(12)); + view.setUint32(4, attributes, true); + return { request, address: ptr(request) }; + }; + const typeAndAccess = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS); + const typeOnly = attrRequest(ATTR_CMN_OBJTYPE); + const typeModeOwner = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK); + const reply = new Uint32Array(new ArrayBuffer(64)); + const replyPtr = ptr(reply); const statBuffer = new Uint8Array(new ArrayBuffer(512)); const statView = new DataView(statBuffer.buffer); - const requestPtr = ptr(request); - const replyPtr = ptr(reply); const statPtr = ptr(statBuffer); + // `ptr` gives an address and no reference, so every path stays in this list until the test ends. + const pinned: Uint8Array[] = []; const cString = (path: string) => { const bytes = new Uint8Array(new ArrayBuffer(Buffer.byteLength(path) + 1)); new TextEncoder().encodeInto(path, bytes); + pinned.push(bytes); return bytes; }; @@ -88,7 +103,7 @@ function setup() { if (!isMacOS) return statProbe(path); if (!lib.getattrlist) return { ok: false, errno: -1 }; reply.fill(0); - const rc = lib.getattrlist(path, requestPtr, replyPtr, REPLY_SIZE, 0); + const rc = lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); if (rc !== 0) return { ok: false, errno: errno() }; const detail = `len=${reply[0]} type=${reply[1]} access=${reply[2]}`; return { ok: reply[1] === VREG && (reply[2] & X_OK) !== 0, errno: 0, detail }; @@ -111,56 +126,95 @@ function setup() { return { open: openProbe(p), attr: attrProbe(p), stat: statProbe(p) }; } - const show = (answer: Answer) => (answer.ok ? "yes" : answer.errno ? "no(errno " + answer.errno + ")" : "no"); + // One call of each way to ask, for the cost table. + const shapes: Record void> = { + "open+close": path => { + const fd = lib.open(path, O_PROBE); + if (fd >= 0) lib.close(fd); + }, + "attr(type+access)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); + }, + "stat+eaccess(X)": path => { + if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); + }, + "stat": path => { + if (stat) stat(path, statPtr); + }, + "attr(type)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeOnly.address, replyPtr, 8, 0); + }, + "attr(type+mode+owner)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeModeOwner.address, replyPtr, 20, 0); + }, + "eaccess(F)": path => { + lib.faccessat(AT_FDCWD, path, F_OK, AT_EACCESS); + }, + "eaccess(X)": path => { + lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); + }, + "access(X)": path => { + lib.access(path, X_OK); + }, + "ffi": () => { + lib.getpid(); + }, + }; // The instructions that the whole process retired, from the counter `/usr/bin/time -l` reads. const rusage = new BigUint64Array(new ArrayBuffer(512)); const rusagePtr = ptr(rusage); - function counters() { + function instructions() { if (!lib.proc_pid_rusage || lib.proc_pid_rusage(process.pid, 4, rusagePtr) !== 0) return null; - // rusage_info_v4: ri_instructions at byte 248, ri_cycles at byte 256. - return { instructions: Number(rusage[31]), cycles: Number(rusage[32]) }; + // rusage_info_v4: ri_instructions at byte 248. + return Number(rusage[31]); } - function measure(fn: () => void, calls: number) { - const before = counters(); + function measure(fn: (path: number) => void, path: number, calls: number) { + const before = instructions(); const start = Bun.nanoseconds(); - for (let i = 0; i < calls; i++) fn(); + for (let i = 0; i < calls; i++) fn(path); const ns = (Bun.nanoseconds() - start) / calls; - const after = counters(); - return { - ns, - instructions: before && after ? (after.instructions - before.instructions) / calls : null, - cycles: before && after ? (after.cycles - before.cycles) / calls : null, - }; + const after = instructions(); + return { ns, instructions: before !== null && after !== null ? (after - before) / calls : null }; } - return { lib, stat, cString, probeAll, show, measure, requestPtr, replyPtr, statPtr }; + return { cString, probeAll, shapes, measure }; } const median = (values: number[]) => values.toSorted((a, b) => a - b)[values.length >> 1]; -const spread = (values: number[]) => { - const sorted = values.toSorted((a, b) => a - b); - return sorted[0] === 0 ? 0 : ((sorted.at(-1)! - sorted[0]) / sorted[0]) * 100; -}; +const yn = (ok: boolean) => (ok ? "Y" : "n"); test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( - "report: open(O_EXEC) against getattrlist for a PATH candidate", + "report: ways to ask whether a PATH candidate is executable", async () => { - const { lib, stat, cString, probeAll, show, measure, requestPtr, replyPtr, statPtr } = setup(); + const { cString, probeAll, shapes, measure } = setup(); const lines: string[] = []; const log = (line: string) => lines.push("MEASURE " + line); + const run = (cmd: string, args: string[]) => { + try { + const { stdout, stderr } = spawnSync(cmd, args, { encoding: "utf8" }); + return (stdout + stderr).trim().replace(/\s+/g, " ").slice(0, 260); + } catch (error) { + return String(error); + } + }; - const productVersion = isMacOS ? spawnSync("sw_vers", ["-productVersion"], { encoding: "utf8" }).stdout.trim() : ""; + const productVersion = isMacOS ? run("sw_vers", ["-productVersion"]) : ""; log( `system: macOS ${productVersion} darwin ${release()} ${process.arch} uid=${process.getuid!()} euid=${process.geteuid!()} user=${userInfo().username} bun=${Bun.version} ${Bun.revision.slice(0, 10)}`, ); - - // 1. The reply for a known executable. + if (isMacOS) { + log(`csrutil: ${run("/usr/bin/csrutil", ["status"])}`); + log(`system extensions: ${run("/usr/bin/systemextensionsctl", ["list"])}`); + } log(`reply for ${process.execPath}: ${JSON.stringify(probeAll(process.execPath))}`); if (loadErrors.length) log("symbols that did not load: " + loadErrors.join("; ")); - // 2. Every file of every PATH directory, and of some more directories. + // 1. Every file of every PATH directory, and of some more directories. Two more predicates read the mode bits + // only: the owner bit (what the Linux probe reads), and the bit of the class that the user is in. + const euid = process.geteuid!(); + const groups = new Set([process.getegid!(), ...process.getgroups!()]); const directories = [ ...new Set([ ...(process.env.PATH ?? "").split(":").filter(Boolean), @@ -181,10 +235,8 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( ]), ]; let total = 0; - const counts = { open: 0, attr: 0, stat: 0 }; - const differences: string[] = []; - const errnos = new Map(); - const scanned: string[] = []; + const yes = { open: 0, attr: 0, stat: 0, ownerBit: 0, classBit: 0 }; + const differs: Record = { attr: [], stat: [], ownerBit: [], classBit: [] }; for (const directory of directories) { let names: string[]; try { @@ -192,32 +244,43 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( } catch { continue; } - let inDirectory = 0; for (const name of names) { const path = join(directory, name); const answers = probeAll(path); + let ownerBit = false; + let classBit = false; + try { + const st = statSync(path); + if (st.isFile()) { + ownerBit = (st.mode & 0o100) !== 0; + classBit = + euid === 0 + ? (st.mode & 0o111) !== 0 + : st.uid === euid + ? (st.mode & 0o100) !== 0 + : groups.has(st.gid) + ? (st.mode & 0o010) !== 0 + : (st.mode & 0o001) !== 0; + } + } catch {} total++; - inDirectory++; - for (const key of ["open", "attr", "stat"] as const) { - if (answers[key].ok) counts[key]++; - else errnos.set(`${key}:${answers[key].errno}`, (errnos.get(`${key}:${answers[key].errno}`) ?? 0) + 1); - } - if (answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok) { - differences.push( - `${path} open=${show(answers.open)} attr=${show(answers.attr)}${answers.attr.detail ? "[" + answers.attr.detail + "]" : ""} stat=${show(answers.stat)}`, - ); + const all = { open: answers.open.ok, attr: answers.attr.ok, stat: answers.stat.ok, ownerBit, classBit }; + for (const key of Object.keys(yes) as (keyof typeof yes)[]) if (all[key]) yes[key]++; + for (const key of Object.keys(differs)) { + if (all[key as keyof typeof all] !== all.open) { + differs[key].push(`${path}(open=${yn(all.open)},errno=${answers.open.errno})`); + } } } - scanned.push(`${directory}=${inDirectory}`); } - log(`scanned (directory=entries): ${scanned.join(" ")}`); log( - `scan: ${total} paths; executable by open(O_EXEC) ${counts.open}, by getattrlist ${counts.attr}, by stat+faccessat ${counts.stat}; answers that differ: ${differences.length}`, + `scan: ${total} paths in ${directories.length} directories; executable by open(O_EXEC) ${yes.open}, getattrlist ${yes.attr}, stat+faccessat ${yes.stat}, owner bit ${yes.ownerBit}, class bit ${yes.classBit}`, ); - log(`scan errnos of "no": ${JSON.stringify(Object.fromEntries([...errnos].sort()))}`); - for (const difference of differences.slice(0, 12)) log("differs: " + difference); + for (const key of Object.keys(differs)) { + log(`scan: ${key} differs from open(O_EXEC) on ${differs[key].length}: ${differs[key].slice(0, 4).join(" ")}`); + } - // 3. Fixtures: one per kind of file and permission. + // 2. Fixtures: one per kind of file and permission. using dir = tempDir("which-probe-measure", { "exec": "#!/bin/sh\n", "empty-exec": "", @@ -231,25 +294,35 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( "acl-allow-execute": "#!/bin/sh\n", "folder/inside": "#!/bin/sh\n", "locked/inside": "#!/bin/sh\n", + "d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec": "#!/bin/sh\n", + "node_modules/pkg/bin/tool.js": "#!/usr/bin/env node\n", + "node_modules/.bin/keep": "", }); - const base = String(dir); + const base = realpathSync(String(dir)); const at = (name: string) => join(base, name); - chmodSync(at("exec"), 0o755); - chmodSync(at("empty-exec"), 0o755); - chmodSync(at("plain"), 0o644); - chmodSync(at("execute-only"), 0o111); - chmodSync(at("owner-x"), 0o100); - chmodSync(at("group-x"), 0o010); - chmodSync(at("other-x"), 0o001); - chmodSync(at("acl-deny-readattr"), 0o755); - chmodSync(at("acl-deny-execute"), 0o755); - chmodSync(at("acl-allow-execute"), 0o644); - chmodSync(at("folder/inside"), 0o755); - chmodSync(at("locked/inside"), 0o755); + for (const [name, mode] of [ + ["exec", 0o755], + ["empty-exec", 0o755], + ["plain", 0o644], + ["execute-only", 0o111], + ["owner-x", 0o100], + ["group-x", 0o010], + ["other-x", 0o001], + ["acl-deny-readattr", 0o755], + ["acl-deny-execute", 0o755], + ["acl-allow-execute", 0o644], + ["folder/inside", 0o755], + ["locked/inside", 0o755], + ["d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec", 0o755], + ["node_modules/pkg/bin/tool.js", 0o755], + ] as const) { + chmodSync(at(name), mode); + } symlinkSync("exec", at("link")); symlinkSync("missing", at("dangling-link")); symlinkSync("folder", at("link-to-folder")); symlinkSync("plain", at("link-to-plain")); + symlinkSync("../pkg/bin/tool.js", at("node_modules/.bin/tool")); mkfifo(at("fifo"), 0o755); mkdirSync(at("app.app"), { mode: 0o755 }); using listener = Bun.listen({ unix: at("socket"), socket: { data() {} } }); @@ -265,7 +338,6 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( acl.push(`${name}:${status === 0 ? "set" : "failed " + stderr.trim()}`); } } - log(`acl: ${acl.join(", ")}`); chmodSync(at("locked"), 0o000); const fixtures = [ "exec", @@ -287,25 +359,25 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( "dangling-link", "link-to-folder", "link-to-plain", + "node_modules/.bin/tool", "fifo", "socket", "app.app", "missing", "", ]; - let fixtureDifferences = 0; + const table: string[] = []; + const fixtureDiffers: string[] = []; for (const name of fixtures) { const path = name === "" ? "" : at(name); const answers = probeAll(path); - const differs = answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok; - if (differs) fixtureDifferences++; - let mode = ""; - try { - mode = (statSync(path).mode & 0o7777).toString(8); - } catch {} - log( - `fixture ${JSON.stringify(name)} mode=${mode}: open=${show(answers.open)} attr=${show(answers.attr)}${answers.attr.detail ? "[" + answers.attr.detail + "]" : ""} stat=${show(answers.stat)} which=${JSON.stringify(Bun.which(path, { PATH: "" }) !== null)}${differs ? " <-- DIFFERS" : ""}`, - ); + const which = Bun.which(path, { PATH: "" }) !== null; + table.push(`${name || '""'}=${yn(answers.open.ok)}${yn(answers.attr.ok)}${yn(answers.stat.ok)}${yn(which)}`); + if (answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok) { + fixtureDiffers.push( + `${name}: open=${yn(answers.open.ok)}(errno ${answers.open.errno}) attr=${yn(answers.attr.ok)}(errno ${answers.attr.errno}${answers.attr.detail ? " " + answers.attr.detail : ""}) stat=${yn(answers.stat.ok)}(errno ${answers.stat.errno})`, + ); + } } chmodSync(at("locked"), 0o755); if (isMacOS) { @@ -313,9 +385,11 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( spawnSync("/bin/chmod", ["-N", at(name)]); } } - log(`fixtures: ${fixtures.length}; answers that differ: ${fixtureDifferences}`); + log(`acl: ${acl.join(", ")}`); + log(`fixtures (open, getattrlist, stat+faccessat, Bun.which): ${table.join(" ")}`); + log(`fixtures: ${fixtures.length}; answers that differ: ${fixtureDiffers.length}: ${fixtureDiffers.join("; ")}`); - // 4. The same probes with no descriptor left. + // 3. The same probes with no descriptor left. writeFileSync( at("limit.ts"), ` @@ -325,6 +399,7 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( const lib = dlopen(process.env.LIBC!, { open: { args: ["ptr", "i32"], returns: "i32" }, close: { args: ["i32"], returns: "i32" }, + faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, ...(isMacOS ? { getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } } : {}), } as any).symbols as any; @@ -344,7 +419,9 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( const rc = lib.getattrlist(ptr(path), ptr(request), ptr(reply), 12, 0); attr = rc !== 0 ? "no(errno " + errno() + ")" : reply[1] === 1 && (reply[2] & 1) !== 0 ? "yes" : "no"; } - return { open, attr, which: Bun.which(process.env.TARGET!, { PATH: "" }) !== null }; + const eaccess = + lib.faccessat(${AT_FDCWD}, ptr(path), ${X_OK}, ${AT_EACCESS}) === 0 ? "yes" : "no(errno " + errno() + ")"; + return { open, attr, eaccess, which: Bun.which(process.env.TARGET!, { PATH: "" }) !== null }; }; const free = probes(); const held: number[] = []; @@ -367,58 +444,49 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( log(`no descriptor left: ${stdout.trim()} exit=${exitCode} ${stderr.trim().slice(0, 300)}`); } - // 5. The cost of one probe: a hit (an executable) and a miss (a name that does not exist). - const hit = cString(process.execPath); - const miss = cString(join(dirname(process.execPath), "no-such-command-for-which-probe")); - const shapes: Record void> = { - "open+close": path => { - const fd = lib.open(path, O_PROBE); - if (fd >= 0) lib.close(fd); - }, - "getattrlist": path => { - if (lib.getattrlist) lib.getattrlist(path, requestPtr, replyPtr, REPLY_SIZE, 0); - }, - "stat+faccessat": path => { - if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); - }, - // What two and one calls through bun:ffi cost with no system call to speak of. - "ffi x2": () => { - lib.getpid(); - lib.getpid(); - }, - "ffi x1": () => { - lib.getpid(); - }, - }; - const calls = 20_000; - const rounds = 7; - for (const [label, bytes] of [ - ["hit", hit], - ["miss", miss], - ] as const) { + // 4. The cost of one call of each way to ask, for several targets. + const homebrew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/opt/homebrew/bin/git", "/usr/local/bin/git"]; + const targets: [label: string, path: string][] = [ + ["hit /bin/ls", "/bin/ls"], + ["hit /usr/bin/true", "/usr/bin/true"], + ["hit /usr/bin/git", "/usr/bin/git"], + ["hit homebrew", homebrew.find(path => existsSync(path)) ?? ""], + ["hit tmp script", at("exec")], + ["hit tmp script 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec")], + ["hit node_modules/.bin symlink", at("node_modules/.bin/tool")], + ["hit bun itself", process.execPath], + ["miss /usr/bin", "/usr/bin/no-such-command-for-which-probe"], + ["miss tmp 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/no-such-command")], + ]; + const calls = 3_000; + const rounds = 5; + const names = Object.keys(shapes); + for (const [label, path] of targets) { + if (path === "" || (label.startsWith("hit") && !existsSync(path))) { + log(`cost ${label}: not on this machine`); + continue; + } + const bytes = cString(path); const p = ptr(bytes); - const samples: Record = {}; - for (const name of Object.keys(shapes)) samples[name] = { ns: [], instructions: [], cycles: [] }; - for (const name of Object.keys(shapes)) measure(() => shapes[name](p), 2_000); + const samples: Record = {}; + for (const name of names) { + samples[name] = { ns: [], instructions: [] }; + measure(shapes[name], p, 500); + } for (let round = 0; round < rounds; round++) { - for (const name of Object.keys(shapes)) { - const result = measure(() => shapes[name](p), calls); + for (const name of names) { + const result = measure(shapes[name], p, calls); samples[name].ns.push(result.ns); if (result.instructions !== null) samples[name].instructions.push(result.instructions); - if (result.cycles !== null) samples[name].cycles.push(result.cycles); } } - for (const name of Object.keys(shapes)) { - const { ns, instructions, cycles } = samples[name]; - log( - `cost ${label} ${name}: ` + - (instructions.length - ? `instructions/call median ${Math.round(median(instructions))} (spread ${spread(instructions).toFixed(1)}%), cycles/call median ${Math.round(median(cycles))}, ` - : "instructions n/a, ") + - `ns/call median ${Math.round(median(ns))} (spread ${spread(ns).toFixed(1)}%) [${rounds} rounds x ${calls} calls]`, - ); - } + const cells = names.map(name => { + const { ns, instructions } = samples[name]; + return `${name}=${instructions.length ? Math.round(median(instructions)) + "i/" : ""}${Math.round(median(ns))}ns`; + }); + log(`cost ${label} (${path.split("/").length - 1} components, ${path.length} bytes): ${cells.join(" ")}`); } + log(`cost: median of ${rounds} interleaved rounds of ${calls} calls; i = instructions retired by the process`); console.log(lines.join("\n")); expect(lines.length).toBe(-1); From 8aefc2cb5d71f5c9db5960633cefac9f51dcf5ed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:26:16 +0000 Subject: [PATCH 04/10] test: add what this build's Bun.which does to the temporary report The report now has Bun.which of this build beside the raw calls: its answers for every scanned path and fixture with descriptors free and with none left, its instructions and system calls per call, and the bytes of the probe in the Mach-O image. This commit has no source change, so its numbers are the baseline for the fix. --- .../bun/util/which-probe-measure-fixture.ts | 230 ++++++++ test/js/bun/util/which-probe-measure.test.ts | 510 +++++++++--------- 2 files changed, 476 insertions(+), 264 deletions(-) create mode 100644 test/js/bun/util/which-probe-measure-fixture.ts diff --git a/test/js/bun/util/which-probe-measure-fixture.ts b/test/js/bun/util/which-probe-measure-fixture.ts new file mode 100644 index 000000000000..fb1f64697bd4 --- /dev/null +++ b/test/js/bun/util/which-probe-measure-fixture.ts @@ -0,0 +1,230 @@ +// Temporary, with which-probe-measure.test.ts. The calls that the report compares, through bun:ffi. Run as a script +// it answers the same questions with descriptors free and with none left, and prints JSON. +import { dlopen, ptr, read } from "bun:ffi"; +import { closeSync, openSync } from "node:fs"; + +export const isMacOS = process.platform === "darwin"; +// The Linux column is a stand-in, to run the script where there is no Mac: O_PATH for O_EXEC, and no getattrlist. +export const O_PROBE = isMacOS ? 0x40000000 | 0x01000000 | 0x0004 | 0x20000 : 0o10000000 | 0o2000000; +const AT_FDCWD = isMacOS ? -2 : -100; +const AT_EACCESS = isMacOS ? 0x0010 : 0x200; +const ATTR_CMN_OBJTYPE = 0x00000008; +const ATTR_CMN_OWNERID = 0x00008000; +const ATTR_CMN_GRPID = 0x00010000; +const ATTR_CMN_ACCESSMASK = 0x00020000; +const ATTR_CMN_USERACCESS = 0x00200000; +const VREG = 1; +const F_OK = 0; +const X_OK = 1; + +export type Answer = { ok: boolean; errno: number; detail?: string }; +export const yn = (ok: boolean) => (ok ? "Y" : "n"); + +export function setup(libc: string) { + const loadErrors: string[] = []; + function tryLoad(symbols: Record): any { + try { + return dlopen(libc, symbols as any).symbols; + } catch (error) { + loadErrors.push(String(error)); + return {}; + } + } + + const lib = { + ...dlopen(libc, { + open: { args: ["ptr", "i32"], returns: "i32" }, + close: { args: ["i32"], returns: "i32" }, + access: { args: ["ptr", "i32"], returns: "i32" }, + faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, + getpid: { args: [], returns: "i32" }, + [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, + } as any).symbols, + ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), + ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), + ...(isMacOS ? tryLoad({ task_self_trap: { args: [], returns: "u32" } }) : {}), + ...(isMacOS ? tryLoad({ task_info: { args: ["u32", "i32", "ptr", "ptr"], returns: "i32" } }) : {}), + } as any; + // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. + const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; + const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as + ((path: number, out: number) => number) | undefined; + const errnoLocation = lib.__error ?? lib.__errno_location; + const errno = () => read.i32(errnoLocation(), 0); + + // Each view is made over its own ArrayBuffer before `ptr` reads its address: the first `.buffer` of a small typed + // array moves its bytes. + const attrRequest = (attributes: number) => { + const request = new Uint8Array(new ArrayBuffer(24)); + const view = new DataView(request.buffer); + view.setUint16(0, 5, true); // ATTR_BIT_MAP_COUNT + view.setUint32(4, attributes, true); + return { request, address: ptr(request) }; + }; + const typeAndAccess = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS); + const typeOnly = attrRequest(ATTR_CMN_OBJTYPE); + const typeModeOwner = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK); + const reply = new Uint32Array(new ArrayBuffer(64)); + const replyPtr = ptr(reply); + const statBuffer = new Uint8Array(new ArrayBuffer(512)); + const statView = new DataView(statBuffer.buffer); + const statPtr = ptr(statBuffer); + + // `ptr` gives an address and no reference, so every path stays in this list until the process ends. + const pinned: Uint8Array[] = []; + const cString = (path: string) => { + const bytes = new Uint8Array(new ArrayBuffer(Buffer.byteLength(path) + 1)); + new TextEncoder().encodeInto(path, bytes); + pinned.push(bytes); + return ptr(bytes); + }; + + function openProbe(path: number): Answer { + const fd = lib.open(path, O_PROBE); + if (fd < 0) return { ok: false, errno: errno() }; + lib.close(fd); + return { ok: true, errno: 0 }; + } + + function attrProbe(path: number): Answer { + if (!isMacOS) return statProbe(path); + if (!lib.getattrlist) return { ok: false, errno: -1 }; + reply.fill(0); + const rc = lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); + if (rc !== 0) return { ok: false, errno: errno() }; + const detail = `len=${reply[0]} type=${reply[1]} access=${reply[2]}`; + return { ok: reply[1] === VREG && (reply[2] & X_OK) !== 0, errno: 0, detail }; + } + + // What the fix asks when `open` has no descriptor: a regular file, and execute permission for the effective user. + function statProbe(path: number): Answer { + if (!stat) return { ok: false, errno: -1 }; + statBuffer.fill(0); + if (stat(path, statPtr) !== 0) return { ok: false, errno: errno() }; + // st_mode: offset 4 in the 64-bit-inode layout of macOS, offset 24 on Linux x64, 16 on Linux arm64. + const mode = isMacOS ? statView.getUint16(4, true) : statView.getUint32(process.arch === "x64" ? 24 : 16, true); + if ((mode & 0o170000) !== 0o100000) return { ok: false, errno: 0, detail: "mode=" + mode.toString(8) }; + if (lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) !== 0) return { ok: false, errno: errno() }; + return { ok: true, errno: 0 }; + } + + function probeAll(path: string) { + const p = cString(path); + return { + open: openProbe(p), + attr: attrProbe(p), + stat: statProbe(p), + which: Bun.which(path, { PATH: "" }) !== null, + }; + } + + // One call of each way to ask, for the cost table. + const shapes: Record void> = { + "Bun.which": (_, text) => { + Bun.which(text, EMPTY_PATH); + }, + "open+close": path => { + const fd = lib.open(path, O_PROBE); + if (fd >= 0) lib.close(fd); + }, + "attr(type+access)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); + }, + "stat+eaccess(X)": path => { + if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); + }, + "stat": path => { + if (stat) stat(path, statPtr); + }, + "attr(type)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeOnly.address, replyPtr, 8, 0); + }, + "attr(type+mode+owner)": path => { + if (lib.getattrlist) lib.getattrlist(path, typeModeOwner.address, replyPtr, 20, 0); + }, + "eaccess(F)": path => { + lib.faccessat(AT_FDCWD, path, F_OK, AT_EACCESS); + }, + "eaccess(X)": path => { + lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); + }, + "ffi": () => { + lib.getpid(); + }, + }; + const EMPTY_PATH = { PATH: "" }; + + // The instructions that the whole process retired, from the counter `/usr/bin/time -l` reads. A VM reports 0. + const rusage = new BigUint64Array(new ArrayBuffer(512)); + const rusagePtr = ptr(rusage); + function instructions() { + if (!lib.proc_pid_rusage || lib.proc_pid_rusage(process.pid, 4, rusagePtr) !== 0) return null; + // rusage_info_v4: ri_instructions at byte 248. + return Number(rusage[31]); + } + + // The BSD system calls that the whole process made: task_info(TASK_EVENTS_INFO).syscalls_unix, what `top` shows. + const events = new Int32Array(new ArrayBuffer(32)); + const eventsPtr = ptr(events); + const count = new Uint32Array(new ArrayBuffer(4)); + const countPtr = ptr(count); + const task = lib.task_self_trap ? lib.task_self_trap() : 0; + function syscalls() { + if (!lib.task_info || !task) return null; + count[0] = 8; + if (lib.task_info(task, 2, eventsPtr, countPtr) !== 0) return null; + return events[6]; + } + + function measure(fn: (path: number, text: string) => void, path: number, text: string, calls: number) { + const syscallsBefore = syscalls(); + const before = instructions(); + const start = Bun.nanoseconds(); + for (let i = 0; i < calls; i++) fn(path, text); + const ns = (Bun.nanoseconds() - start) / calls; + const after = instructions(); + const syscallsAfter = syscalls(); + return { + ns, + instructions: before !== null && after !== null ? (after - before) / calls : null, + syscalls: syscallsBefore !== null && syscallsAfter !== null ? (syscallsAfter - syscallsBefore) / calls : null, + }; + } + + return { loadErrors, cString, probeAll, shapes, measure, syscalls }; +} + +if (import.meta.main) { + const { probeAll, syscalls } = setup(process.env.LIBC!); + const fixtures: string[] = JSON.parse(process.env.FIXTURES!); + const base = process.env.BASE!; + const walkPath = process.env.WALK_PATH!; + const calls = 1000; + + const answers = () => + Object.fromEntries( + fixtures.map(name => { + const all = probeAll(name === "" ? "" : base + "/" + name); + return [name || '""', yn(all.open.ok) + yn(all.stat.ok) + yn(all.which)]; + }), + ); + // `Bun.which` of a name: the PATH has twelve directories, and the command is in the tenth. + const walk = (PATH: string) => { + const before = syscalls(); + let found = 0; + for (let i = 0; i < calls; i++) if (Bun.which("tool", { PATH }) !== null) found++; + const after = syscalls(); + return { found, syscallsPerCall: before !== null && after !== null ? (after - before) / calls : null }; + }; + const state = () => ({ answers: answers(), walk: walk(walkPath), emptyPathWalk: walk("") }); + + state(); + const free = state(); + const held: number[] = []; + try { + for (;;) held.push(openSync("/dev/null", "r")); + } catch {} + const atLimit = state(); + for (const fd of held) closeSync(fd); + console.log(JSON.stringify({ held: held.length, calls, free, atLimit })); +} diff --git a/test/js/bun/util/which-probe-measure.test.ts b/test/js/bun/util/which-probe-measure.test.ts index bf6842978fdf..25613364a2c6 100644 --- a/test/js/bun/util/which-probe-measure.test.ts +++ b/test/js/bun/util/which-probe-measure.test.ts @@ -1,200 +1,145 @@ // Temporary. It compares ways to ask "is this PATH candidate a file that I may execute" on the macOS CI machines: -// the answers for every file of every PATH directory, and the cost per call for several kinds of target. The test -// fails on purpose so that CI prints the report. Remove this file before the PR is ready. -import { dlopen, ptr, read } from "bun:ffi"; +// the answers for every file of every PATH directory, the cost per call for several kinds of target, and what +// `Bun.which` of this build does with descriptors free and with none left. The test fails on purpose so that CI +// prints the report. Remove this file and which-probe-measure-fixture.ts before the PR is ready. import { expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen, tempDir } from "harness"; import { mkfifo } from "mkfifo"; import { spawnSync } from "node:child_process"; import { chmodSync, + closeSync, existsSync, + fstatSync, mkdirSync, + openSync, readdirSync, + readSync, realpathSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; -import { release, userInfo } from "node:os"; +import { hostname, release, userInfo } from "node:os"; import { dirname, join } from "node:path"; +import { setup, yn } from "./which-probe-measure-fixture.ts"; -// The Linux column is a stand-in, to run the script where there is no Mac: O_PATH for O_EXEC, and no getattrlist. -const O_PROBE = isMacOS ? 0x40000000 | 0x01000000 | 0x0004 | 0x20000 : 0o10000000 | 0o2000000; -const AT_FDCWD = isMacOS ? -2 : -100; -const AT_EACCESS = isMacOS ? 0x0010 : 0x200; -const ATTR_CMN_OBJTYPE = 0x00000008; -const ATTR_CMN_OWNERID = 0x00008000; -const ATTR_CMN_GRPID = 0x00010000; -const ATTR_CMN_ACCESSMASK = 0x00020000; -const ATTR_CMN_USERACCESS = 0x00200000; -const VREG = 1; -const F_OK = 0; -const X_OK = 1; - -const loadErrors: string[] = []; +const median = (values: number[]) => values.toSorted((a, b) => a - b)[values.length >> 1]; +const spread = (values: number[]) => { + const sorted = values.toSorted((a, b) => a - b); + return sorted[0] > 0 ? (((sorted.at(-1)! - sorted[0]) / sorted[0]) * 100).toFixed(1) + "%" : "n/a"; +}; -function setup() { - function tryLoad(symbols: Record): any { - try { - return dlopen(libcPathForDlopen(), symbols as any).symbols; - } catch (error) { - loadErrors.push(String(error)); - return {}; +// The size of `__TEXT,__text`, and the bytes of the functions of the probe, read from the Mach-O image of this build. +function machOReport(path: string, log: (line: string) => void) { + const fd = openSync(path, "r"); + try { + const read = (length: number, position: number) => { + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, position); + return buffer; + }; + const head = read(32, 0); + if (head.readUInt32LE(0) !== 0xfeedfacf) { + log(`mach-o: ${path} is not a thin 64-bit image (magic ${head.readUInt32LE(0).toString(16)})`); + return; } - } - - const lib = { - ...dlopen(libcPathForDlopen(), { - open: { args: ["ptr", "i32"], returns: "i32" }, - close: { args: ["i32"], returns: "i32" }, - access: { args: ["ptr", "i32"], returns: "i32" }, - faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, - getpid: { args: [], returns: "i32" }, - [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, - } as any).symbols, - ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), - ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), - } as any; - // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. - const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; - const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as - ((path: number, out: number) => number) | undefined; - const errnoLocation = lib.__error ?? lib.__errno_location; - const errno = () => read.i32(errnoLocation(), 0); - - // Each view is made over its own ArrayBuffer before `ptr` reads its address: the first `.buffer` of a small typed - // array moves its bytes. - const attrRequest = (attributes: number) => { - const request = new Uint8Array(new ArrayBuffer(24)); - const view = new DataView(request.buffer); - view.setUint16(0, 5, true); // ATTR_BIT_MAP_COUNT - view.setUint32(4, attributes, true); - return { request, address: ptr(request) }; - }; - const typeAndAccess = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS); - const typeOnly = attrRequest(ATTR_CMN_OBJTYPE); - const typeModeOwner = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK); - const reply = new Uint32Array(new ArrayBuffer(64)); - const replyPtr = ptr(reply); - const statBuffer = new Uint8Array(new ArrayBuffer(512)); - const statView = new DataView(statBuffer.buffer); - const statPtr = ptr(statBuffer); - - // `ptr` gives an address and no reference, so every path stays in this list until the test ends. - const pinned: Uint8Array[] = []; - const cString = (path: string) => { - const bytes = new Uint8Array(new ArrayBuffer(Buffer.byteLength(path) + 1)); - new TextEncoder().encodeInto(path, bytes); - pinned.push(bytes); - return bytes; - }; - - type Answer = { ok: boolean; errno: number; detail?: string }; - - function openProbe(path: number): Answer { - const fd = lib.open(path, O_PROBE); - if (fd < 0) return { ok: false, errno: errno() }; - lib.close(fd); - return { ok: true, errno: 0 }; - } - - function attrProbe(path: number): Answer { - if (!isMacOS) return statProbe(path); - if (!lib.getattrlist) return { ok: false, errno: -1 }; - reply.fill(0); - const rc = lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); - if (rc !== 0) return { ok: false, errno: errno() }; - const detail = `len=${reply[0]} type=${reply[1]} access=${reply[2]}`; - return { ok: reply[1] === VREG && (reply[2] & X_OK) !== 0, errno: 0, detail }; - } - - function statProbe(path: number): Answer { - if (!stat) return { ok: false, errno: -1 }; - statBuffer.fill(0); - if (stat(path, statPtr) !== 0) return { ok: false, errno: errno() }; - // st_mode: offset 4 in the 64-bit-inode layout of macOS, offset 24 on Linux x64, 16 on Linux arm64. - const mode = isMacOS ? statView.getUint16(4, true) : statView.getUint32(process.arch === "x64" ? 24 : 16, true); - if ((mode & 0o170000) !== 0o100000) return { ok: false, errno: 0, detail: "mode=" + mode.toString(8) }; - if (lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) !== 0) return { ok: false, errno: errno() }; - return { ok: true, errno: 0 }; - } - - function probeAll(path: string) { - const bytes = cString(path); - const p = ptr(bytes); - return { open: openProbe(p), attr: attrProbe(p), stat: statProbe(p) }; - } - - // One call of each way to ask, for the cost table. - const shapes: Record void> = { - "open+close": path => { - const fd = lib.open(path, O_PROBE); - if (fd >= 0) lib.close(fd); - }, - "attr(type+access)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); - }, - "stat+eaccess(X)": path => { - if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); - }, - "stat": path => { - if (stat) stat(path, statPtr); - }, - "attr(type)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeOnly.address, replyPtr, 8, 0); - }, - "attr(type+mode+owner)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeModeOwner.address, replyPtr, 20, 0); - }, - "eaccess(F)": path => { - lib.faccessat(AT_FDCWD, path, F_OK, AT_EACCESS); - }, - "eaccess(X)": path => { - lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); - }, - "access(X)": path => { - lib.access(path, X_OK); - }, - "ffi": () => { - lib.getpid(); - }, - }; - - // The instructions that the whole process retired, from the counter `/usr/bin/time -l` reads. - const rusage = new BigUint64Array(new ArrayBuffer(512)); - const rusagePtr = ptr(rusage); - function instructions() { - if (!lib.proc_pid_rusage || lib.proc_pid_rusage(process.pid, 4, rusagePtr) !== 0) return null; - // rusage_info_v4: ri_instructions at byte 248. - return Number(rusage[31]); - } + const commandCount = head.readUInt32LE(16); + const commands = read(head.readUInt32LE(20), 32); + const name16 = (at: number) => commands.toString("latin1", at, at + 16).replace(/\0.*$/, ""); + const sections: { segment: string; section: string; address: number; size: number; offset: number }[] = []; + let symtab: { symoff: number; nsyms: number; stroff: number; strsize: number } | undefined; + for (let at = 0, i = 0; i < commandCount; i++) { + const command = commands.readUInt32LE(at); + if (command === 0x19) { + // LC_SEGMENT_64 + const count = commands.readUInt32LE(at + 64); + for (let s = 0; s < count; s++) { + const section = at + 72 + s * 80; + sections.push({ + section: name16(section), + segment: name16(section + 16), + address: Number(commands.readBigUInt64LE(section + 32)), + size: Number(commands.readBigUInt64LE(section + 40)), + offset: commands.readUInt32LE(section + 48), + }); + } + } else if (command === 0x2) { + // LC_SYMTAB + symtab = { + symoff: commands.readUInt32LE(at + 8), + nsyms: commands.readUInt32LE(at + 12), + stroff: commands.readUInt32LE(at + 16), + strsize: commands.readUInt32LE(at + 20), + }; + } + at += commands.readUInt32LE(at + 4); + } + const text = sections.find(s => s.segment === "__TEXT" && s.section === "__text"); + log(`mach-o: __TEXT,__text ${text?.size} bytes; file ${fstatSync(fd).size} bytes; ${symtab?.nsyms} symbols`); + if (!symtab || !text) return; - function measure(fn: (path: number) => void, path: number, calls: number) { - const before = instructions(); - const start = Bun.nanoseconds(); - for (let i = 0; i < calls; i++) fn(path); - const ns = (Bun.nanoseconds() - start) / calls; - const after = instructions(); - return { ns, instructions: before !== null && after !== null ? (after - before) / calls : null }; + const strings = read(symtab.strsize, symtab.stroff); + const symbols = read(symtab.nsyms * 16, symtab.symoff); + // The addresses of every symbol that a section defines, to find where a function ends. + const addresses = new Float64Array(symtab.nsyms); + let defined = 0; + for (let i = 0; i < symtab.nsyms; i++) { + const type = symbols[i * 16 + 4]; + if ((type & 0xe0) === 0 && (type & 0x0e) === 0x0e) { + addresses[defined++] = Number(symbols.readBigUInt64LE(i * 16 + 8)); + } + } + const sorted = addresses.subarray(0, defined).sort(); + const endOf = (address: number) => { + let low = 0; + let high = sorted.length; + while (low < high) { + const middle = (low + high) >> 1; + if (sorted[middle] <= address) low = middle + 1; + else high = middle; + } + return low < sorted.length ? sorted[low] : address; + }; + for (const [needle, limit] of [ + ["\0_is_executable_file\0", 1], + ["isExecutableFileByPath", 1], + ["is_executable_file_path", 3], + ] as const) { + const found: string[] = []; + for (let from = 0; found.length < limit;) { + const hit = strings.indexOf(needle, from, "latin1"); + if (hit === -1) break; + from = hit + needle.length; + const start = needle.startsWith("\0") ? hit + 1 : strings.lastIndexOf(0, hit) + 1; + const name = strings.toString("latin1", start, strings.indexOf(0, start)); + for (let i = 0; i < symtab.nsyms; i++) { + const type = symbols[i * 16 + 4]; + if (symbols.readUInt32LE(i * 16) !== start || (type & 0xe0) !== 0 || (type & 0x0e) !== 0x0e) continue; + const address = Number(symbols.readBigUInt64LE(i * 16 + 8)); + const size = endOf(address) - address; + const bytes = read(Math.min(size, 400), text.offset + (address - text.address)); + found.push(`${name} size=${size} bytes=${bytes.toString("hex")}`); + break; + } + } + log(`mach-o: ${JSON.stringify(needle.replaceAll("\0", ""))}: ${found.length ? found.join(" | ") : "no symbol"}`); + } + } finally { + closeSync(fd); } - - return { cString, probeAll, shapes, measure }; } -const median = (values: number[]) => values.toSorted((a, b) => a - b)[values.length >> 1]; -const yn = (ok: boolean) => (ok ? "Y" : "n"); - test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( "report: ways to ask whether a PATH candidate is executable", async () => { - const { cString, probeAll, shapes, measure } = setup(); + const { loadErrors, cString, probeAll, shapes, measure } = setup(libcPathForDlopen()); const lines: string[] = []; const log = (line: string) => lines.push("MEASURE " + line); const run = (cmd: string, args: string[]) => { try { const { stdout, stderr } = spawnSync(cmd, args, { encoding: "utf8" }); - return (stdout + stderr).trim().replace(/\s+/g, " ").slice(0, 260); + return (stdout + stderr).trim().replace(/\s+/g, " ").slice(0, 200); } catch (error) { return String(error); } @@ -202,14 +147,22 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( const productVersion = isMacOS ? run("sw_vers", ["-productVersion"]) : ""; log( - `system: macOS ${productVersion} darwin ${release()} ${process.arch} uid=${process.getuid!()} euid=${process.geteuid!()} user=${userInfo().username} bun=${Bun.version} ${Bun.revision.slice(0, 10)}`, + `system: macOS ${productVersion} darwin ${release()} ${process.arch} host=${hostname()} uid=${process.getuid!()} euid=${process.geteuid!()} user=${userInfo().username} bun=${Bun.version} ${Bun.revision.slice(0, 10)}`, ); if (isMacOS) { - log(`csrutil: ${run("/usr/bin/csrutil", ["status"])}`); - log(`system extensions: ${run("/usr/bin/systemextensionsctl", ["list"])}`); + log( + `csrutil: ${run("/usr/bin/csrutil", ["status"])}; system extensions: ${run("/usr/bin/systemextensionsctl", ["list"])}`, + ); } log(`reply for ${process.execPath}: ${JSON.stringify(probeAll(process.execPath))}`); if (loadErrors.length) log("symbols that did not load: " + loadErrors.join("; ")); + if (isMacOS) { + try { + machOReport(process.execPath, log); + } catch (error) { + log(`mach-o: failed: ${error}`); + } + } // 1. Every file of every PATH directory, and of some more directories. Two more predicates read the mode bits // only: the owner bit (what the Linux probe reads), and the bit of the class that the user is in. @@ -235,8 +188,8 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( ]), ]; let total = 0; - const yes = { open: 0, attr: 0, stat: 0, ownerBit: 0, classBit: 0 }; - const differs: Record = { attr: [], stat: [], ownerBit: [], classBit: [] }; + const yes = { open: 0, attr: 0, stat: 0, which: 0, ownerBit: 0, classBit: 0 }; + const differs: Record = { attr: [], stat: [], which: [], ownerBit: [], classBit: [] }; for (const directory of directories) { let names: string[]; try { @@ -264,7 +217,14 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( } } catch {} total++; - const all = { open: answers.open.ok, attr: answers.attr.ok, stat: answers.stat.ok, ownerBit, classBit }; + const all = { + open: answers.open.ok, + attr: answers.attr.ok, + stat: answers.stat.ok, + which: answers.which, + ownerBit, + classBit, + }; for (const key of Object.keys(yes) as (keyof typeof yes)[]) if (all[key]) yes[key]++; for (const key of Object.keys(differs)) { if (all[key as keyof typeof all] !== all.open) { @@ -274,11 +234,17 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( } } log( - `scan: ${total} paths in ${directories.length} directories; executable by open(O_EXEC) ${yes.open}, getattrlist ${yes.attr}, stat+faccessat ${yes.stat}, owner bit ${yes.ownerBit}, class bit ${yes.classBit}`, + `scan: ${total} paths in ${directories.length} directories; executable by open(O_EXEC) ${yes.open}, Bun.which ${yes.which}, getattrlist ${yes.attr}, stat+faccessat ${yes.stat}, owner bit ${yes.ownerBit}, class bit ${yes.classBit}`, + ); + log( + "scan: differs from open(O_EXEC): " + + Object.keys(differs) + .map( + key => + `${key} ${differs[key].length}${differs[key].length ? " [" + differs[key].slice(0, 3).join(" ") + "]" : ""}`, + ) + .join("; "), ); - for (const key of Object.keys(differs)) { - log(`scan: ${key} differs from open(O_EXEC) on ${differs[key].length}: ${differs[key].slice(0, 4).join(" ")}`); - } // 2. Fixtures: one per kind of file and permission. using dir = tempDir("which-probe-measure", { @@ -297,6 +263,7 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( "d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec": "#!/bin/sh\n", "node_modules/pkg/bin/tool.js": "#!/usr/bin/env node\n", "node_modules/.bin/keep": "", + "walk/p10/tool": "#!/bin/sh\n", }); const base = realpathSync(String(dir)); const at = (name: string) => join(base, name); @@ -315,9 +282,15 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( ["locked/inside", 0o755], ["d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec", 0o755], ["node_modules/pkg/bin/tool.js", 0o755], + ["walk/p10/tool", 0o755], ] as const) { chmodSync(at(name), mode); } + const walkPath: string[] = []; + for (let i = 1; i <= 12; i++) { + mkdirSync(at(`walk/p${i}`), { recursive: true }); + walkPath.push(at(`walk/p${i}`)); + } symlinkSync("exec", at("link")); symlinkSync("missing", at("dangling-link")); symlinkSync("folder", at("link-to-folder")); @@ -369,124 +342,133 @@ test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( const table: string[] = []; const fixtureDiffers: string[] = []; for (const name of fixtures) { - const path = name === "" ? "" : at(name); - const answers = probeAll(path); - const which = Bun.which(path, { PATH: "" }) !== null; - table.push(`${name || '""'}=${yn(answers.open.ok)}${yn(answers.attr.ok)}${yn(answers.stat.ok)}${yn(which)}`); - if (answers.open.ok !== answers.attr.ok || answers.open.ok !== answers.stat.ok) { + const answers = probeAll(name === "" ? "" : at(name)); + table.push( + `${name || '""'}=${yn(answers.open.ok)}${yn(answers.attr.ok)}${yn(answers.stat.ok)}${yn(answers.which)}`, + ); + if ( + answers.open.ok !== answers.attr.ok || + answers.open.ok !== answers.stat.ok || + answers.open.ok !== answers.which + ) { fixtureDiffers.push( - `${name}: open=${yn(answers.open.ok)}(errno ${answers.open.errno}) attr=${yn(answers.attr.ok)}(errno ${answers.attr.errno}${answers.attr.detail ? " " + answers.attr.detail : ""}) stat=${yn(answers.stat.ok)}(errno ${answers.stat.errno})`, + `${name}: open=${yn(answers.open.ok)}(errno ${answers.open.errno}) attr=${yn(answers.attr.ok)}(errno ${answers.attr.errno}) stat=${yn(answers.stat.ok)}(errno ${answers.stat.errno}) which=${yn(answers.which)}`, ); } } - chmodSync(at("locked"), 0o755); - if (isMacOS) { - for (const name of ["acl-deny-readattr", "acl-deny-execute", "acl-allow-execute"]) { - spawnSync("/bin/chmod", ["-N", at(name)]); - } - } log(`acl: ${acl.join(", ")}`); log(`fixtures (open, getattrlist, stat+faccessat, Bun.which): ${table.join(" ")}`); log(`fixtures: ${fixtures.length}; answers that differ: ${fixtureDiffers.length}: ${fixtureDiffers.join("; ")}`); - // 3. The same probes with no descriptor left. - writeFileSync( - at("limit.ts"), - ` - import { dlopen, ptr, read } from "bun:ffi"; - import { openSync, closeSync } from "node:fs"; - const isMacOS = process.platform === "darwin"; - const lib = dlopen(process.env.LIBC!, { - open: { args: ["ptr", "i32"], returns: "i32" }, - close: { args: ["i32"], returns: "i32" }, - faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, - [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, - ...(isMacOS ? { getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } } : {}), - } as any).symbols as any; - const errno = () => read.i32((lib.__error ?? lib.__errno_location)(), 0); - const request = new Uint8Array(new ArrayBuffer(24)); - new DataView(request.buffer).setUint16(0, 5, true); - new DataView(request.buffer).setUint32(4, ${ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS}, true); - const reply = new Uint32Array(new ArrayBuffer(12)); - const path = new Uint8Array(new ArrayBuffer(Buffer.byteLength(process.env.TARGET!) + 1)); - new TextEncoder().encodeInto(process.env.TARGET!, path); - const probes = () => { - const fd = lib.open(ptr(path), ${O_PROBE}); - const open = fd < 0 ? "no(errno " + errno() + ")" : (lib.close(fd), "yes"); - let attr = "n/a"; - if (isMacOS) { - reply.fill(0); - const rc = lib.getattrlist(ptr(path), ptr(request), ptr(reply), 12, 0); - attr = rc !== 0 ? "no(errno " + errno() + ")" : reply[1] === 1 && (reply[2] & 1) !== 0 ? "yes" : "no"; - } - const eaccess = - lib.faccessat(${AT_FDCWD}, ptr(path), ${X_OK}, ${AT_EACCESS}) === 0 ? "yes" : "no(errno " + errno() + ")"; - return { open, attr, eaccess, which: Bun.which(process.env.TARGET!, { PATH: "" }) !== null }; - }; - const free = probes(); - const held: number[] = []; - try { - for (;;) held.push(openSync("/dev/null", "r")); - } catch {} - const atLimit = probes(); - for (const fd of held) closeSync(fd); - console.log(JSON.stringify({ held: held.length, free, atLimit })); - `, - ); + // 3. The same questions with no descriptor left, and the system calls of one `Bun.which` of a name. { await using proc = Bun.spawn({ - cmd: ["/bin/sh", "-c", 'ulimit -n 64 && exec "$@"', "sh", bunExe(), at("limit.ts")], - env: { ...bunEnv, LIBC: libcPathForDlopen(), TARGET: at("exec") }, + cmd: [ + "/bin/sh", + "-c", + 'ulimit -n 64 && exec "$@"', + "sh", + bunExe(), + join(import.meta.dir, "which-probe-measure-fixture.ts"), + ], + env: { + ...bunEnv, + LIBC: libcPathForDlopen(), + BASE: base, + FIXTURES: JSON.stringify(fixtures), + WALK_PATH: walkPath.join(":"), + }, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - log(`no descriptor left: ${stdout.trim()} exit=${exitCode} ${stderr.trim().slice(0, 300)}`); + let parsed: any; + try { + parsed = JSON.parse(stdout); + } catch {} + if (!parsed) { + log( + `no descriptor left: exit=${exitCode} stdout=${stdout.trim().slice(0, 300)} stderr=${stderr.trim().slice(0, 300)}`, + ); + } else { + const { held, calls, free, atLimit } = parsed; + const show = (answers: Record) => + Object.entries(answers) + .map(([name, answer]) => `${name}=${answer}`) + .join(" "); + log(`limit: ${held} descriptors held; per fixture (open, stat+faccessat, Bun.which)`); + log(`limit: descriptors free: ${show(free.answers)}`); + log(`limit: no descriptor left: ${show(atLimit.answers)}`); + log( + `limit: Bun.which differs from its answer with descriptors free: ${ + Object.keys(free.answers) + .filter(name => free.answers[name][2] !== atLimit.answers[name][2]) + .join(", ") || "none" + }`, + ); + log( + `walk: Bun.which("tool") with the command in the 10th of 12 PATH directories, ${calls} calls: descriptors free: found ${free.walk.found}, ${free.walk.syscallsPerCall} system calls per call (empty PATH: ${free.emptyPathWalk.syscallsPerCall}); no descriptor left: found ${atLimit.walk.found}, ${atLimit.walk.syscallsPerCall} per call (empty PATH: ${atLimit.emptyPathWalk.syscallsPerCall})`, + ); + } + } + chmodSync(at("locked"), 0o755); + if (isMacOS) { + for (const name of ["acl-deny-readattr", "acl-deny-execute", "acl-allow-execute"]) { + spawnSync("/bin/chmod", ["-N", at(name)]); + } } // 4. The cost of one call of each way to ask, for several targets. const homebrew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/opt/homebrew/bin/git", "/usr/local/bin/git"]; - const targets: [label: string, path: string][] = [ - ["hit /bin/ls", "/bin/ls"], - ["hit /usr/bin/true", "/usr/bin/true"], - ["hit /usr/bin/git", "/usr/bin/git"], - ["hit homebrew", homebrew.find(path => existsSync(path)) ?? ""], - ["hit tmp script", at("exec")], - ["hit tmp script 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec")], - ["hit node_modules/.bin symlink", at("node_modules/.bin/tool")], - ["hit bun itself", process.execPath], - ["miss /usr/bin", "/usr/bin/no-such-command-for-which-probe"], - ["miss tmp 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/no-such-command")], - ]; - const calls = 3_000; - const rounds = 5; + const missing = join(dirname(process.execPath), "no-such-command-for-which-probe"); const names = Object.keys(shapes); - for (const [label, path] of targets) { + const costs = (label: string, path: string, shapeNames: string[], rounds: number, calls: number) => { if (path === "" || (label.startsWith("hit") && !existsSync(path))) { log(`cost ${label}: not on this machine`); - continue; + return; } - const bytes = cString(path); - const p = ptr(bytes); - const samples: Record = {}; - for (const name of names) { - samples[name] = { ns: [], instructions: [] }; - measure(shapes[name], p, 500); + const p = cString(path); + const samples: Record = {}; + for (const name of shapeNames) { + samples[name] = { ns: [], instructions: [], syscalls: [] }; + measure(shapes[name], p, path, 500); } for (let round = 0; round < rounds; round++) { - for (const name of names) { - const result = measure(shapes[name], p, calls); + for (const name of shapeNames) { + const result = measure(shapes[name], p, path, calls); samples[name].ns.push(result.ns); if (result.instructions !== null) samples[name].instructions.push(result.instructions); + if (result.syscalls !== null) samples[name].syscalls.push(result.syscalls); } } - const cells = names.map(name => { - const { ns, instructions } = samples[name]; - return `${name}=${instructions.length ? Math.round(median(instructions)) + "i/" : ""}${Math.round(median(ns))}ns`; + const cells = shapeNames.map(name => { + const { ns, instructions, syscalls } = samples[name]; + const counted = instructions.length && median(instructions) > 0; + return ( + `${name}=` + + (counted ? `${Math.round(median(instructions))}i(${spread(instructions)})/` : "") + + `${Math.round(median(ns))}ns(${spread(ns)})` + + (syscalls.length ? `/${median(syscalls).toFixed(2)}sc` : "") + ); }); - log(`cost ${label} (${path.split("/").length - 1} components, ${path.length} bytes): ${cells.join(" ")}`); + log(`cost ${label} (${path.split("/").length - 1} components, ${rounds}x${calls}): ${cells.join(" ")}`); + }; + // The bound for this build: `Bun.which` beside the raw calls, for a found command and for a missing one. + costs("bound hit bun itself", process.execPath, ["Bun.which", "open+close", "ffi"], 7, 20_000); + costs("bound miss beside bun", missing, ["Bun.which", "open+close", "ffi"], 7, 20_000); + for (const [label, path] of [ + ["hit /bin/ls", "/bin/ls"], + ["hit /usr/bin/git", "/usr/bin/git"], + ["hit homebrew", homebrew.find(path => existsSync(path)) ?? ""], + ["hit tmp script", at("exec")], + ["hit node_modules/.bin symlink", at("node_modules/.bin/tool")], + ["hit bun itself", process.execPath], + ["miss /usr/bin", "/usr/bin/no-such-command-for-which-probe"], + ["miss tmp 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/no-such-command")], + ] as const) { + costs(label, path, names, 5, 3_000); } - log(`cost: median of ${rounds} interleaved rounds of ${calls} calls; i = instructions retired by the process`); + log("cost: median over interleaved rounds (spread); i = instructions retired, sc = BSD system calls, per call"); console.log(lines.join("\n")); expect(lines.length).toBe(-1); From 303da3891b2849b24289e3cd019e1eee5dd9aff8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:27:49 +0000 Subject: [PATCH 05/10] [autofix.ci] apply automated fixes --- test/js/bun/util/which-probe-measure.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/bun/util/which-probe-measure.test.ts b/test/js/bun/util/which-probe-measure.test.ts index 25613364a2c6..71c681946f8c 100644 --- a/test/js/bun/util/which-probe-measure.test.ts +++ b/test/js/bun/util/which-probe-measure.test.ts @@ -18,7 +18,6 @@ import { realpathSync, statSync, symlinkSync, - writeFileSync, } from "node:fs"; import { hostname, release, userInfo } from "node:os"; import { dirname, join } from "node:path"; From 62bf3bb7c178a58e81e5e5c100c997c55ce070b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:26:58 +0000 Subject: [PATCH 06/10] which: answer from the path when open(O_EXEC) has no descriptor On macOS and FreeBSD the PATH probe opens each candidate with O_EXEC and reads every failure as "not executable". With no descriptor free the open fails with EMFILE, or with ENFILE when the system table is full, so every command is reported as missing: a spawn by name throws ENOENT, Bun.which returns null, and the shell prints "command not found". For those two errors the probe now asks about the path: stat for a regular file, then faccessat(X_OK, AT_EACCESS). A command that is found still costs one open and one close. The editor lookup tested its install locations with its own open. It now calls the same probe, so both halves of that lookup give the same answer when no descriptor is free. --- src/jsc/bindings/c-bindings.cpp | 16 +++++++++++++++- src/runtime/cli/open.rs | 16 +++++----------- src/sys/lib.rs | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index a5dc3ac28b1f..04e77ad21ffc 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) { + // No descriptor was free for the open, so its failure is not an answer about the path. + int error = errno; + 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..10d6d7f69c6b 100644 --- a/src/runtime/cli/open.rs +++ b/src/runtime/cli/open.rs @@ -92,17 +92,11 @@ impl Editor { } fn by_fallback_path_for_editor(editor: Editor) -> Option<&'static [u8]> { - for path in bin_path(editor)? { - match bun_sys::File::open_at(bun_sys::Fd::cwd(), path, bun_sys::O::RDONLY, 0) { - bun_sys::Result::Ok(opened) => { - let _ = opened.close(); // close error is non-actionable - return Some(path.as_bytes()); - } - bun_sys::Result::Err(_) => {} - } - } - - None + bin_path(editor)? + .iter() + .copied() + .find(|path| bun_sys::is_executable_file_path(path)) + .map(|path| path.as_bytes()) } /// Looks in `PATH` first, then in the usual install locations. 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 From 06156092c9864dd4e8f0e9f51ac3ff35596a3bc6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:15:09 +0000 Subject: [PATCH 07/10] test: keep the /bin/sh row, and pin the answer under an ACL spawn-sync-loop-init-fail: the `/bin/sh` row is back as it was. It reaches us_create_loop with no PATH lookup in front. The `sh` row is new and stands beside it. which: two macOS rows, for a file that an ACL refuses although it has the execute bits, and for one that an ACL grants although it has none. The owner bit says the opposite for both, so the rows fail if the lookup reads only the mode when it has no descriptor. --- .../spawn/spawn-sync-loop-init-fail.test.ts | 17 +++--- test/js/bun/util/which.test.ts | 61 +++++++++++++------ 2 files changed, 53 insertions(+), 25 deletions(-) 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 10fa235ecaec..20849975795d 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"; -// argv[0] is a name, so the PATH lookup (which_for_spawn) runs before the event -// loop is created. The lookup needs no descriptor: the command is found, and -// the failure comes from 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: ["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,15 +41,15 @@ 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: ["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], + cmd: ["/bin/sh", "-c", `ulimit -n 512 && exec "$1" --no-install -e "$2"`, "sh", bunExe(), fixture(command)], env: bunEnv, stdout: "pipe", stderr: "pipe", diff --git a/test/js/bun/util/which.test.ts b/test/js/bun/util/which.test.ts index 6a029699ce66..0503ec08e3ad 100644 --- a/test/js/bun/util/which.test.ts +++ b/test/js/bun/util/which.test.ts @@ -1,10 +1,10 @@ import { $, which } from "bun"; import { dlopen, ptr } from "bun:ffi"; import { expect, jest, test } from "bun:test"; -import { bunEnv, bunExe, 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(); @@ -427,16 +427,26 @@ async function runWithDescriptorLimit(fixture: string, env: Record { + // 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"); @@ -450,6 +460,10 @@ test.skipIf(isWindows)("which finds a command when the process has no descriptor 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 = []; @@ -461,21 +475,34 @@ test.skipIf(isWindows)("which finds a command when the process has no descriptor for (const fd of held) fs.closeSync(fd); console.log(JSON.stringify({ heldSome: held.length > 0, withDescriptors, withoutDescriptors })); `; - const { stdout, stderr, exitCode } = await runWithDescriptorLimit(fixture, { TOOL_DIR: base }); - - 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, - }; - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ heldSome: true, withDescriptors: answers, withoutDescriptors: answers }); - expect(exitCode).toBe(0); + 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 From dea021e9a2be36e5ada878fbde724d8e65a9e1df Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:52:38 +0000 Subject: [PATCH 08/10] test: remove the temporary report on the macOS probe The report did its work: it compared the ways to test a PATH candidate on four Macs, before and with the fix. The two rows that only it had, for files under an ACL, are in which.test.ts now. [macos tests] --- .../bun/util/which-probe-measure-fixture.ts | 230 --------- test/js/bun/util/which-probe-measure.test.ts | 476 ------------------ 2 files changed, 706 deletions(-) delete mode 100644 test/js/bun/util/which-probe-measure-fixture.ts delete mode 100644 test/js/bun/util/which-probe-measure.test.ts diff --git a/test/js/bun/util/which-probe-measure-fixture.ts b/test/js/bun/util/which-probe-measure-fixture.ts deleted file mode 100644 index fb1f64697bd4..000000000000 --- a/test/js/bun/util/which-probe-measure-fixture.ts +++ /dev/null @@ -1,230 +0,0 @@ -// Temporary, with which-probe-measure.test.ts. The calls that the report compares, through bun:ffi. Run as a script -// it answers the same questions with descriptors free and with none left, and prints JSON. -import { dlopen, ptr, read } from "bun:ffi"; -import { closeSync, openSync } from "node:fs"; - -export const isMacOS = process.platform === "darwin"; -// The Linux column is a stand-in, to run the script where there is no Mac: O_PATH for O_EXEC, and no getattrlist. -export const O_PROBE = isMacOS ? 0x40000000 | 0x01000000 | 0x0004 | 0x20000 : 0o10000000 | 0o2000000; -const AT_FDCWD = isMacOS ? -2 : -100; -const AT_EACCESS = isMacOS ? 0x0010 : 0x200; -const ATTR_CMN_OBJTYPE = 0x00000008; -const ATTR_CMN_OWNERID = 0x00008000; -const ATTR_CMN_GRPID = 0x00010000; -const ATTR_CMN_ACCESSMASK = 0x00020000; -const ATTR_CMN_USERACCESS = 0x00200000; -const VREG = 1; -const F_OK = 0; -const X_OK = 1; - -export type Answer = { ok: boolean; errno: number; detail?: string }; -export const yn = (ok: boolean) => (ok ? "Y" : "n"); - -export function setup(libc: string) { - const loadErrors: string[] = []; - function tryLoad(symbols: Record): any { - try { - return dlopen(libc, symbols as any).symbols; - } catch (error) { - loadErrors.push(String(error)); - return {}; - } - } - - const lib = { - ...dlopen(libc, { - open: { args: ["ptr", "i32"], returns: "i32" }, - close: { args: ["i32"], returns: "i32" }, - access: { args: ["ptr", "i32"], returns: "i32" }, - faccessat: { args: ["i32", "ptr", "i32", "i32"], returns: "i32" }, - getpid: { args: [], returns: "i32" }, - [isMacOS ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, - } as any).symbols, - ...(isMacOS ? tryLoad({ getattrlist: { args: ["ptr", "ptr", "ptr", "usize", "u32"], returns: "i32" } }) : {}), - ...(isMacOS ? tryLoad({ proc_pid_rusage: { args: ["i32", "i32", "ptr"], returns: "i32" } }) : {}), - ...(isMacOS ? tryLoad({ task_self_trap: { args: [], returns: "u32" } }) : {}), - ...(isMacOS ? tryLoad({ task_info: { args: ["u32", "i32", "ptr", "ptr"], returns: "i32" } }) : {}), - } as any; - // The 64-bit-inode layout: `stat` on arm64, `stat$INODE64` on x64. - const statName = isMacOS && process.arch === "x64" ? "stat$INODE64" : "stat"; - const stat = tryLoad({ [statName]: { args: ["ptr", "ptr"], returns: "i32" } })[statName] as - ((path: number, out: number) => number) | undefined; - const errnoLocation = lib.__error ?? lib.__errno_location; - const errno = () => read.i32(errnoLocation(), 0); - - // Each view is made over its own ArrayBuffer before `ptr` reads its address: the first `.buffer` of a small typed - // array moves its bytes. - const attrRequest = (attributes: number) => { - const request = new Uint8Array(new ArrayBuffer(24)); - const view = new DataView(request.buffer); - view.setUint16(0, 5, true); // ATTR_BIT_MAP_COUNT - view.setUint32(4, attributes, true); - return { request, address: ptr(request) }; - }; - const typeAndAccess = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_USERACCESS); - const typeOnly = attrRequest(ATTR_CMN_OBJTYPE); - const typeModeOwner = attrRequest(ATTR_CMN_OBJTYPE | ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK); - const reply = new Uint32Array(new ArrayBuffer(64)); - const replyPtr = ptr(reply); - const statBuffer = new Uint8Array(new ArrayBuffer(512)); - const statView = new DataView(statBuffer.buffer); - const statPtr = ptr(statBuffer); - - // `ptr` gives an address and no reference, so every path stays in this list until the process ends. - const pinned: Uint8Array[] = []; - const cString = (path: string) => { - const bytes = new Uint8Array(new ArrayBuffer(Buffer.byteLength(path) + 1)); - new TextEncoder().encodeInto(path, bytes); - pinned.push(bytes); - return ptr(bytes); - }; - - function openProbe(path: number): Answer { - const fd = lib.open(path, O_PROBE); - if (fd < 0) return { ok: false, errno: errno() }; - lib.close(fd); - return { ok: true, errno: 0 }; - } - - function attrProbe(path: number): Answer { - if (!isMacOS) return statProbe(path); - if (!lib.getattrlist) return { ok: false, errno: -1 }; - reply.fill(0); - const rc = lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); - if (rc !== 0) return { ok: false, errno: errno() }; - const detail = `len=${reply[0]} type=${reply[1]} access=${reply[2]}`; - return { ok: reply[1] === VREG && (reply[2] & X_OK) !== 0, errno: 0, detail }; - } - - // What the fix asks when `open` has no descriptor: a regular file, and execute permission for the effective user. - function statProbe(path: number): Answer { - if (!stat) return { ok: false, errno: -1 }; - statBuffer.fill(0); - if (stat(path, statPtr) !== 0) return { ok: false, errno: errno() }; - // st_mode: offset 4 in the 64-bit-inode layout of macOS, offset 24 on Linux x64, 16 on Linux arm64. - const mode = isMacOS ? statView.getUint16(4, true) : statView.getUint32(process.arch === "x64" ? 24 : 16, true); - if ((mode & 0o170000) !== 0o100000) return { ok: false, errno: 0, detail: "mode=" + mode.toString(8) }; - if (lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) !== 0) return { ok: false, errno: errno() }; - return { ok: true, errno: 0 }; - } - - function probeAll(path: string) { - const p = cString(path); - return { - open: openProbe(p), - attr: attrProbe(p), - stat: statProbe(p), - which: Bun.which(path, { PATH: "" }) !== null, - }; - } - - // One call of each way to ask, for the cost table. - const shapes: Record void> = { - "Bun.which": (_, text) => { - Bun.which(text, EMPTY_PATH); - }, - "open+close": path => { - const fd = lib.open(path, O_PROBE); - if (fd >= 0) lib.close(fd); - }, - "attr(type+access)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeAndAccess.address, replyPtr, 12, 0); - }, - "stat+eaccess(X)": path => { - if (stat && stat(path, statPtr) === 0) lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); - }, - "stat": path => { - if (stat) stat(path, statPtr); - }, - "attr(type)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeOnly.address, replyPtr, 8, 0); - }, - "attr(type+mode+owner)": path => { - if (lib.getattrlist) lib.getattrlist(path, typeModeOwner.address, replyPtr, 20, 0); - }, - "eaccess(F)": path => { - lib.faccessat(AT_FDCWD, path, F_OK, AT_EACCESS); - }, - "eaccess(X)": path => { - lib.faccessat(AT_FDCWD, path, X_OK, AT_EACCESS); - }, - "ffi": () => { - lib.getpid(); - }, - }; - const EMPTY_PATH = { PATH: "" }; - - // The instructions that the whole process retired, from the counter `/usr/bin/time -l` reads. A VM reports 0. - const rusage = new BigUint64Array(new ArrayBuffer(512)); - const rusagePtr = ptr(rusage); - function instructions() { - if (!lib.proc_pid_rusage || lib.proc_pid_rusage(process.pid, 4, rusagePtr) !== 0) return null; - // rusage_info_v4: ri_instructions at byte 248. - return Number(rusage[31]); - } - - // The BSD system calls that the whole process made: task_info(TASK_EVENTS_INFO).syscalls_unix, what `top` shows. - const events = new Int32Array(new ArrayBuffer(32)); - const eventsPtr = ptr(events); - const count = new Uint32Array(new ArrayBuffer(4)); - const countPtr = ptr(count); - const task = lib.task_self_trap ? lib.task_self_trap() : 0; - function syscalls() { - if (!lib.task_info || !task) return null; - count[0] = 8; - if (lib.task_info(task, 2, eventsPtr, countPtr) !== 0) return null; - return events[6]; - } - - function measure(fn: (path: number, text: string) => void, path: number, text: string, calls: number) { - const syscallsBefore = syscalls(); - const before = instructions(); - const start = Bun.nanoseconds(); - for (let i = 0; i < calls; i++) fn(path, text); - const ns = (Bun.nanoseconds() - start) / calls; - const after = instructions(); - const syscallsAfter = syscalls(); - return { - ns, - instructions: before !== null && after !== null ? (after - before) / calls : null, - syscalls: syscallsBefore !== null && syscallsAfter !== null ? (syscallsAfter - syscallsBefore) / calls : null, - }; - } - - return { loadErrors, cString, probeAll, shapes, measure, syscalls }; -} - -if (import.meta.main) { - const { probeAll, syscalls } = setup(process.env.LIBC!); - const fixtures: string[] = JSON.parse(process.env.FIXTURES!); - const base = process.env.BASE!; - const walkPath = process.env.WALK_PATH!; - const calls = 1000; - - const answers = () => - Object.fromEntries( - fixtures.map(name => { - const all = probeAll(name === "" ? "" : base + "/" + name); - return [name || '""', yn(all.open.ok) + yn(all.stat.ok) + yn(all.which)]; - }), - ); - // `Bun.which` of a name: the PATH has twelve directories, and the command is in the tenth. - const walk = (PATH: string) => { - const before = syscalls(); - let found = 0; - for (let i = 0; i < calls; i++) if (Bun.which("tool", { PATH }) !== null) found++; - const after = syscalls(); - return { found, syscallsPerCall: before !== null && after !== null ? (after - before) / calls : null }; - }; - const state = () => ({ answers: answers(), walk: walk(walkPath), emptyPathWalk: walk("") }); - - state(); - const free = state(); - const held: number[] = []; - try { - for (;;) held.push(openSync("/dev/null", "r")); - } catch {} - const atLimit = state(); - for (const fd of held) closeSync(fd); - console.log(JSON.stringify({ held: held.length, calls, free, atLimit })); -} diff --git a/test/js/bun/util/which-probe-measure.test.ts b/test/js/bun/util/which-probe-measure.test.ts deleted file mode 100644 index 71c681946f8c..000000000000 --- a/test/js/bun/util/which-probe-measure.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -// Temporary. It compares ways to ask "is this PATH candidate a file that I may execute" on the macOS CI machines: -// the answers for every file of every PATH directory, the cost per call for several kinds of target, and what -// `Bun.which` of this build does with descriptors free and with none left. The test fails on purpose so that CI -// prints the report. Remove this file and which-probe-measure-fixture.ts before the PR is ready. -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen, tempDir } from "harness"; -import { mkfifo } from "mkfifo"; -import { spawnSync } from "node:child_process"; -import { - chmodSync, - closeSync, - existsSync, - fstatSync, - mkdirSync, - openSync, - readdirSync, - readSync, - realpathSync, - statSync, - symlinkSync, -} from "node:fs"; -import { hostname, release, userInfo } from "node:os"; -import { dirname, join } from "node:path"; -import { setup, yn } from "./which-probe-measure-fixture.ts"; - -const median = (values: number[]) => values.toSorted((a, b) => a - b)[values.length >> 1]; -const spread = (values: number[]) => { - const sorted = values.toSorted((a, b) => a - b); - return sorted[0] > 0 ? (((sorted.at(-1)! - sorted[0]) / sorted[0]) * 100).toFixed(1) + "%" : "n/a"; -}; - -// The size of `__TEXT,__text`, and the bytes of the functions of the probe, read from the Mach-O image of this build. -function machOReport(path: string, log: (line: string) => void) { - const fd = openSync(path, "r"); - try { - const read = (length: number, position: number) => { - const buffer = Buffer.alloc(length); - readSync(fd, buffer, 0, length, position); - return buffer; - }; - const head = read(32, 0); - if (head.readUInt32LE(0) !== 0xfeedfacf) { - log(`mach-o: ${path} is not a thin 64-bit image (magic ${head.readUInt32LE(0).toString(16)})`); - return; - } - const commandCount = head.readUInt32LE(16); - const commands = read(head.readUInt32LE(20), 32); - const name16 = (at: number) => commands.toString("latin1", at, at + 16).replace(/\0.*$/, ""); - const sections: { segment: string; section: string; address: number; size: number; offset: number }[] = []; - let symtab: { symoff: number; nsyms: number; stroff: number; strsize: number } | undefined; - for (let at = 0, i = 0; i < commandCount; i++) { - const command = commands.readUInt32LE(at); - if (command === 0x19) { - // LC_SEGMENT_64 - const count = commands.readUInt32LE(at + 64); - for (let s = 0; s < count; s++) { - const section = at + 72 + s * 80; - sections.push({ - section: name16(section), - segment: name16(section + 16), - address: Number(commands.readBigUInt64LE(section + 32)), - size: Number(commands.readBigUInt64LE(section + 40)), - offset: commands.readUInt32LE(section + 48), - }); - } - } else if (command === 0x2) { - // LC_SYMTAB - symtab = { - symoff: commands.readUInt32LE(at + 8), - nsyms: commands.readUInt32LE(at + 12), - stroff: commands.readUInt32LE(at + 16), - strsize: commands.readUInt32LE(at + 20), - }; - } - at += commands.readUInt32LE(at + 4); - } - const text = sections.find(s => s.segment === "__TEXT" && s.section === "__text"); - log(`mach-o: __TEXT,__text ${text?.size} bytes; file ${fstatSync(fd).size} bytes; ${symtab?.nsyms} symbols`); - if (!symtab || !text) return; - - const strings = read(symtab.strsize, symtab.stroff); - const symbols = read(symtab.nsyms * 16, symtab.symoff); - // The addresses of every symbol that a section defines, to find where a function ends. - const addresses = new Float64Array(symtab.nsyms); - let defined = 0; - for (let i = 0; i < symtab.nsyms; i++) { - const type = symbols[i * 16 + 4]; - if ((type & 0xe0) === 0 && (type & 0x0e) === 0x0e) { - addresses[defined++] = Number(symbols.readBigUInt64LE(i * 16 + 8)); - } - } - const sorted = addresses.subarray(0, defined).sort(); - const endOf = (address: number) => { - let low = 0; - let high = sorted.length; - while (low < high) { - const middle = (low + high) >> 1; - if (sorted[middle] <= address) low = middle + 1; - else high = middle; - } - return low < sorted.length ? sorted[low] : address; - }; - for (const [needle, limit] of [ - ["\0_is_executable_file\0", 1], - ["isExecutableFileByPath", 1], - ["is_executable_file_path", 3], - ] as const) { - const found: string[] = []; - for (let from = 0; found.length < limit;) { - const hit = strings.indexOf(needle, from, "latin1"); - if (hit === -1) break; - from = hit + needle.length; - const start = needle.startsWith("\0") ? hit + 1 : strings.lastIndexOf(0, hit) + 1; - const name = strings.toString("latin1", start, strings.indexOf(0, start)); - for (let i = 0; i < symtab.nsyms; i++) { - const type = symbols[i * 16 + 4]; - if (symbols.readUInt32LE(i * 16) !== start || (type & 0xe0) !== 0 || (type & 0x0e) !== 0x0e) continue; - const address = Number(symbols.readBigUInt64LE(i * 16 + 8)); - const size = endOf(address) - address; - const bytes = read(Math.min(size, 400), text.offset + (address - text.address)); - found.push(`${name} size=${size} bytes=${bytes.toString("hex")}`); - break; - } - } - log(`mach-o: ${JSON.stringify(needle.replaceAll("\0", ""))}: ${found.length ? found.join(" | ") : "no symbol"}`); - } - } finally { - closeSync(fd); - } -} - -test.skipIf(!isMacOS && !(isLinux && process.env.WHICH_PROBE_MEASURE_ON_LINUX))( - "report: ways to ask whether a PATH candidate is executable", - async () => { - const { loadErrors, cString, probeAll, shapes, measure } = setup(libcPathForDlopen()); - const lines: string[] = []; - const log = (line: string) => lines.push("MEASURE " + line); - const run = (cmd: string, args: string[]) => { - try { - const { stdout, stderr } = spawnSync(cmd, args, { encoding: "utf8" }); - return (stdout + stderr).trim().replace(/\s+/g, " ").slice(0, 200); - } catch (error) { - return String(error); - } - }; - - const productVersion = isMacOS ? run("sw_vers", ["-productVersion"]) : ""; - log( - `system: macOS ${productVersion} darwin ${release()} ${process.arch} host=${hostname()} uid=${process.getuid!()} euid=${process.geteuid!()} user=${userInfo().username} bun=${Bun.version} ${Bun.revision.slice(0, 10)}`, - ); - if (isMacOS) { - log( - `csrutil: ${run("/usr/bin/csrutil", ["status"])}; system extensions: ${run("/usr/bin/systemextensionsctl", ["list"])}`, - ); - } - log(`reply for ${process.execPath}: ${JSON.stringify(probeAll(process.execPath))}`); - if (loadErrors.length) log("symbols that did not load: " + loadErrors.join("; ")); - if (isMacOS) { - try { - machOReport(process.execPath, log); - } catch (error) { - log(`mach-o: failed: ${error}`); - } - } - - // 1. Every file of every PATH directory, and of some more directories. Two more predicates read the mode bits - // only: the owner bit (what the Linux probe reads), and the bit of the class that the user is in. - const euid = process.geteuid!(); - const groups = new Set([process.getegid!(), ...process.getgroups!()]); - const directories = [ - ...new Set([ - ...(process.env.PATH ?? "").split(":").filter(Boolean), - "/bin", - "/usr/bin", - "/sbin", - "/usr/sbin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/usr/libexec", - "/usr/lib", - "/etc", - "/usr/share/zsh", - "/Applications", - "/System/Library/CoreServices", - dirname(process.execPath), - process.cwd(), - ]), - ]; - let total = 0; - const yes = { open: 0, attr: 0, stat: 0, which: 0, ownerBit: 0, classBit: 0 }; - const differs: Record = { attr: [], stat: [], which: [], ownerBit: [], classBit: [] }; - for (const directory of directories) { - let names: string[]; - try { - names = readdirSync(directory); - } catch { - continue; - } - for (const name of names) { - const path = join(directory, name); - const answers = probeAll(path); - let ownerBit = false; - let classBit = false; - try { - const st = statSync(path); - if (st.isFile()) { - ownerBit = (st.mode & 0o100) !== 0; - classBit = - euid === 0 - ? (st.mode & 0o111) !== 0 - : st.uid === euid - ? (st.mode & 0o100) !== 0 - : groups.has(st.gid) - ? (st.mode & 0o010) !== 0 - : (st.mode & 0o001) !== 0; - } - } catch {} - total++; - const all = { - open: answers.open.ok, - attr: answers.attr.ok, - stat: answers.stat.ok, - which: answers.which, - ownerBit, - classBit, - }; - for (const key of Object.keys(yes) as (keyof typeof yes)[]) if (all[key]) yes[key]++; - for (const key of Object.keys(differs)) { - if (all[key as keyof typeof all] !== all.open) { - differs[key].push(`${path}(open=${yn(all.open)},errno=${answers.open.errno})`); - } - } - } - } - log( - `scan: ${total} paths in ${directories.length} directories; executable by open(O_EXEC) ${yes.open}, Bun.which ${yes.which}, getattrlist ${yes.attr}, stat+faccessat ${yes.stat}, owner bit ${yes.ownerBit}, class bit ${yes.classBit}`, - ); - log( - "scan: differs from open(O_EXEC): " + - Object.keys(differs) - .map( - key => - `${key} ${differs[key].length}${differs[key].length ? " [" + differs[key].slice(0, 3).join(" ") + "]" : ""}`, - ) - .join("; "), - ); - - // 2. Fixtures: one per kind of file and permission. - using dir = tempDir("which-probe-measure", { - "exec": "#!/bin/sh\n", - "empty-exec": "", - "plain": "#!/bin/sh\n", - "execute-only": "#!/bin/sh\n", - "owner-x": "#!/bin/sh\n", - "group-x": "#!/bin/sh\n", - "other-x": "#!/bin/sh\n", - "acl-deny-readattr": "#!/bin/sh\n", - "acl-deny-execute": "#!/bin/sh\n", - "acl-allow-execute": "#!/bin/sh\n", - "folder/inside": "#!/bin/sh\n", - "locked/inside": "#!/bin/sh\n", - "d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec": "#!/bin/sh\n", - "node_modules/pkg/bin/tool.js": "#!/usr/bin/env node\n", - "node_modules/.bin/keep": "", - "walk/p10/tool": "#!/bin/sh\n", - }); - const base = realpathSync(String(dir)); - const at = (name: string) => join(base, name); - for (const [name, mode] of [ - ["exec", 0o755], - ["empty-exec", 0o755], - ["plain", 0o644], - ["execute-only", 0o111], - ["owner-x", 0o100], - ["group-x", 0o010], - ["other-x", 0o001], - ["acl-deny-readattr", 0o755], - ["acl-deny-execute", 0o755], - ["acl-allow-execute", 0o644], - ["folder/inside", 0o755], - ["locked/inside", 0o755], - ["d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/exec", 0o755], - ["node_modules/pkg/bin/tool.js", 0o755], - ["walk/p10/tool", 0o755], - ] as const) { - chmodSync(at(name), mode); - } - const walkPath: string[] = []; - for (let i = 1; i <= 12; i++) { - mkdirSync(at(`walk/p${i}`), { recursive: true }); - walkPath.push(at(`walk/p${i}`)); - } - symlinkSync("exec", at("link")); - symlinkSync("missing", at("dangling-link")); - symlinkSync("folder", at("link-to-folder")); - symlinkSync("plain", at("link-to-plain")); - symlinkSync("../pkg/bin/tool.js", at("node_modules/.bin/tool")); - mkfifo(at("fifo"), 0o755); - mkdirSync(at("app.app"), { mode: 0o755 }); - using listener = Bun.listen({ unix: at("socket"), socket: { data() {} } }); - chmodSync(at("socket"), 0o755); - const acl: string[] = []; - if (isMacOS) { - for (const [entry, name] of [ - ["everyone deny readattr", "acl-deny-readattr"], - ["everyone deny execute", "acl-deny-execute"], - [`${userInfo().username} allow execute`, "acl-allow-execute"], - ]) { - const { status, stderr } = spawnSync("/bin/chmod", ["+a", entry, at(name)], { encoding: "utf8" }); - acl.push(`${name}:${status === 0 ? "set" : "failed " + stderr.trim()}`); - } - } - chmodSync(at("locked"), 0o000); - const fixtures = [ - "exec", - "empty-exec", - "plain", - "execute-only", - "owner-x", - "group-x", - "other-x", - "acl-deny-readattr", - "acl-deny-execute", - "acl-allow-execute", - "folder", - "folder/inside", - "folder/inside/", - "exec/", - "locked/inside", - "link", - "dangling-link", - "link-to-folder", - "link-to-plain", - "node_modules/.bin/tool", - "fifo", - "socket", - "app.app", - "missing", - "", - ]; - const table: string[] = []; - const fixtureDiffers: string[] = []; - for (const name of fixtures) { - const answers = probeAll(name === "" ? "" : at(name)); - table.push( - `${name || '""'}=${yn(answers.open.ok)}${yn(answers.attr.ok)}${yn(answers.stat.ok)}${yn(answers.which)}`, - ); - if ( - answers.open.ok !== answers.attr.ok || - answers.open.ok !== answers.stat.ok || - answers.open.ok !== answers.which - ) { - fixtureDiffers.push( - `${name}: open=${yn(answers.open.ok)}(errno ${answers.open.errno}) attr=${yn(answers.attr.ok)}(errno ${answers.attr.errno}) stat=${yn(answers.stat.ok)}(errno ${answers.stat.errno}) which=${yn(answers.which)}`, - ); - } - } - log(`acl: ${acl.join(", ")}`); - log(`fixtures (open, getattrlist, stat+faccessat, Bun.which): ${table.join(" ")}`); - log(`fixtures: ${fixtures.length}; answers that differ: ${fixtureDiffers.length}: ${fixtureDiffers.join("; ")}`); - - // 3. The same questions with no descriptor left, and the system calls of one `Bun.which` of a name. - { - await using proc = Bun.spawn({ - cmd: [ - "/bin/sh", - "-c", - 'ulimit -n 64 && exec "$@"', - "sh", - bunExe(), - join(import.meta.dir, "which-probe-measure-fixture.ts"), - ], - env: { - ...bunEnv, - LIBC: libcPathForDlopen(), - BASE: base, - FIXTURES: JSON.stringify(fixtures), - WALK_PATH: walkPath.join(":"), - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - let parsed: any; - try { - parsed = JSON.parse(stdout); - } catch {} - if (!parsed) { - log( - `no descriptor left: exit=${exitCode} stdout=${stdout.trim().slice(0, 300)} stderr=${stderr.trim().slice(0, 300)}`, - ); - } else { - const { held, calls, free, atLimit } = parsed; - const show = (answers: Record) => - Object.entries(answers) - .map(([name, answer]) => `${name}=${answer}`) - .join(" "); - log(`limit: ${held} descriptors held; per fixture (open, stat+faccessat, Bun.which)`); - log(`limit: descriptors free: ${show(free.answers)}`); - log(`limit: no descriptor left: ${show(atLimit.answers)}`); - log( - `limit: Bun.which differs from its answer with descriptors free: ${ - Object.keys(free.answers) - .filter(name => free.answers[name][2] !== atLimit.answers[name][2]) - .join(", ") || "none" - }`, - ); - log( - `walk: Bun.which("tool") with the command in the 10th of 12 PATH directories, ${calls} calls: descriptors free: found ${free.walk.found}, ${free.walk.syscallsPerCall} system calls per call (empty PATH: ${free.emptyPathWalk.syscallsPerCall}); no descriptor left: found ${atLimit.walk.found}, ${atLimit.walk.syscallsPerCall} per call (empty PATH: ${atLimit.emptyPathWalk.syscallsPerCall})`, - ); - } - } - chmodSync(at("locked"), 0o755); - if (isMacOS) { - for (const name of ["acl-deny-readattr", "acl-deny-execute", "acl-allow-execute"]) { - spawnSync("/bin/chmod", ["-N", at(name)]); - } - } - - // 4. The cost of one call of each way to ask, for several targets. - const homebrew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/opt/homebrew/bin/git", "/usr/local/bin/git"]; - const missing = join(dirname(process.execPath), "no-such-command-for-which-probe"); - const names = Object.keys(shapes); - const costs = (label: string, path: string, shapeNames: string[], rounds: number, calls: number) => { - if (path === "" || (label.startsWith("hit") && !existsSync(path))) { - log(`cost ${label}: not on this machine`); - return; - } - const p = cString(path); - const samples: Record = {}; - for (const name of shapeNames) { - samples[name] = { ns: [], instructions: [], syscalls: [] }; - measure(shapes[name], p, path, 500); - } - for (let round = 0; round < rounds; round++) { - for (const name of shapeNames) { - const result = measure(shapes[name], p, path, calls); - samples[name].ns.push(result.ns); - if (result.instructions !== null) samples[name].instructions.push(result.instructions); - if (result.syscalls !== null) samples[name].syscalls.push(result.syscalls); - } - } - const cells = shapeNames.map(name => { - const { ns, instructions, syscalls } = samples[name]; - const counted = instructions.length && median(instructions) > 0; - return ( - `${name}=` + - (counted ? `${Math.round(median(instructions))}i(${spread(instructions)})/` : "") + - `${Math.round(median(ns))}ns(${spread(ns)})` + - (syscalls.length ? `/${median(syscalls).toFixed(2)}sc` : "") - ); - }); - log(`cost ${label} (${path.split("/").length - 1} components, ${rounds}x${calls}): ${cells.join(" ")}`); - }; - // The bound for this build: `Bun.which` beside the raw calls, for a found command and for a missing one. - costs("bound hit bun itself", process.execPath, ["Bun.which", "open+close", "ffi"], 7, 20_000); - costs("bound miss beside bun", missing, ["Bun.which", "open+close", "ffi"], 7, 20_000); - for (const [label, path] of [ - ["hit /bin/ls", "/bin/ls"], - ["hit /usr/bin/git", "/usr/bin/git"], - ["hit homebrew", homebrew.find(path => existsSync(path)) ?? ""], - ["hit tmp script", at("exec")], - ["hit node_modules/.bin symlink", at("node_modules/.bin/tool")], - ["hit bun itself", process.execPath], - ["miss /usr/bin", "/usr/bin/no-such-command-for-which-probe"], - ["miss tmp 10 directories down", at("d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/no-such-command")], - ] as const) { - costs(label, path, names, 5, 3_000); - } - log("cost: median over interleaved rounds (spread); i = instructions retired, sc = BSD system calls, per call"); - - console.log(lines.join("\n")); - expect(lines.length).toBe(-1); - }, - 300_000, -); From d6b198d3c60297196fd82951a68c6f6c00922271 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:12:12 +0000 Subject: [PATCH 09/10] open: keep the read probe of the editor lookup The install locations of an editor are tested with open(O_RDONLY) again, as before this branch. With a descriptor free the lookup gives the answers that it gave on main. Only when that open fails with EMFILE or ENFILE does it ask access(R_OK) about the path. c-bindings.cpp: the comment now sits on the branch that it describes, the two errors that say no descriptor was free. --- src/jsc/bindings/c-bindings.cpp | 2 +- src/runtime/cli/open.rs | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 04e77ad21ffc..7567451d2a9a 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -103,8 +103,8 @@ extern "C" bool is_executable_file(const char* path) // 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) { - // No descriptor was free for the open, so its failure is not an answer about the path. 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; diff --git a/src/runtime/cli/open.rs b/src/runtime/cli/open.rs index 10d6d7f69c6b..95f54216eb95 100644 --- a/src/runtime/cli/open.rs +++ b/src/runtime/cli/open.rs @@ -92,11 +92,24 @@ impl Editor { } fn by_fallback_path_for_editor(editor: Editor) -> Option<&'static [u8]> { - bin_path(editor)? - .iter() - .copied() - .find(|path| bun_sys::is_executable_file_path(path)) - .map(|path| path.as_bytes()) + for path in bin_path(editor)? { + match bun_sys::File::open_at(bun_sys::Fd::cwd(), path, bun_sys::O::RDONLY, 0) { + bun_sys::Result::Ok(opened) => { + 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(_) => {} + } + } + + None } /// Looks in `PATH` first, then in the usual install locations. From 613b0c46bdfcb23e1f5dc17ad019ca05f964153c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:12:12 +0000 Subject: [PATCH 10/10] test: give both rows of the loop-init test a fixed PATH The `sh` row found its command through the PATH of the test runner. Both rows now get PATH=/bin:/usr/bin, so the row depends only on the lookup that it tests. [macos tests] --- test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 20849975795d..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 @@ -50,7 +50,8 @@ describe.skipIf(!isPosix)("Bun.spawnSync event-loop creation under EMFILE", () = 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(command)], - env: bunEnv, + // 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", });