diff --git a/src/jsc/NodeCompileCache.rs b/src/jsc/NodeCompileCache.rs index e3b3c71f400c..e4bb253627aa 100644 --- a/src/jsc/NodeCompileCache.rs +++ b/src/jsc/NodeCompileCache.rs @@ -158,8 +158,11 @@ macro_rules! cclog { }; } +/// `ENOENT`, not the bare `NOENT` that `<&str>::from(E)` yields on Windows. fn errno_name(e: &sys::Error) -> &'static str { - <&'static str>::from(e.get_errno()) + e.get_error_code_tag_name() + .map(|(name, _)| name) + .unwrap_or("UNKNOWN") } /// Read-log tail for an I/O error; ENOENT uses Node's exact wording. diff --git a/src/paths/component_iterator.rs b/src/paths/component_iterator.rs index af82a17de28e..721c133254b9 100644 --- a/src/paths/component_iterator.rs +++ b/src/paths/component_iterator.rs @@ -183,8 +183,9 @@ pub enum MakePathStep { Created, /// Directory already exists (`EEXIST`). Walk advances forward. Exists, - /// A parent is missing (`ENOENT`). Walk steps back one component; - /// if there is no previous component the carried error is returned. + /// A parent is missing (`ENOENT`). Walk steps back one component; if + /// there is none, or the walk already advanced forward, the carried error + /// is returned. NotFound(E), } @@ -195,7 +196,9 @@ pub enum MakePathStep { /// Starts at `it.last()`; on `Created`/`Exists` advances via `next()` /// (returning `Ok(())` when there is none), on `NotFound(e)` steps back via /// `previous()` (returning `Err(e)` when there is none — i.e. the very first -/// component's parent does not exist). +/// component's parent does not exist). Once the walk has advanced, a +/// `NotFound(e)` is final and returns that component's `e`: its parent exists +/// but cannot hold children (a dangling symlink, procfs). /// /// `mkdir` is invoked with `component.path`: a borrowed prefix slice into the /// original input, never NUL-terminated. Callers that need a sentinel must @@ -207,14 +210,17 @@ pub fn make_path_with<'a, T: PathChar, E>( let Some(mut comp) = it.last() else { return Ok(()); }; + let mut advanced = false; loop { match mkdir(comp.path)? { MakePathStep::Created | MakePathStep::Exists => { + advanced = true; comp = match it.next() { Some(c) => c, None => return Ok(()), }; } + MakePathStep::NotFound(e) if advanced => return Err(e), MakePathStep::NotFound(e) => { comp = match it.previous() { Some(c) => c, @@ -376,4 +382,106 @@ mod tests { assert_eq!(it.next().unwrap().name, b"c"); assert!(it.next().is_none()); } + + #[test] + fn make_path_creates_missing_parents() { + // Only the cwd exists: the walk backs up to `a`, then creates forward. + let it = ComponentIterator::init(&b"a/b/c"[..], PathFormat::Posix).unwrap(); + let mut created: Vec<&[u8]> = vec![]; + let mut attempts: Vec<&[u8]> = vec![]; + let r = make_path_with::(it, |p| { + attempts.push(p); + let parent: &[u8] = match p { + b"a" => b"", + b"a/b" => b"a", + b"a/b/c" => b"a/b", + _ => unreachable!(), + }; + if parent.is_empty() || created.contains(&parent) { + created.push(p); + Ok(MakePathStep::Created) + } else { + Ok(MakePathStep::NotFound(())) + } + }); + assert_eq!(r, Ok(())); + assert_eq!( + attempts, + vec![ + &b"a/b/c"[..], + &b"a/b"[..], + &b"a"[..], + &b"a/b"[..], + &b"a/b/c"[..] + ] + ); + assert_eq!(created, vec![&b"a"[..], &b"a/b"[..], &b"a/b/c"[..]]); + } + + #[test] + fn make_path_stops_when_parent_exists_but_child_is_not_found() { + // `/a/b` is a dangling symlink: EEXIST for itself, ENOENT below it. + let it = ComponentIterator::init(&b"/a/b/c"[..], PathFormat::Posix).unwrap(); + let mut attempts: Vec<&[u8]> = vec![]; + let r = make_path_with(it, |p| { + attempts.push(p); + assert!(attempts.len() < 100, "runaway loop"); + match p { + b"/a" | b"/a/b" => Ok(MakePathStep::Exists), + b"/a/b/c" => Ok(MakePathStep::NotFound(p)), + _ => unreachable!(), + } + }); + assert_eq!(r, Err(&b"/a/b/c"[..])); + assert_eq!(attempts, vec![&b"/a/b/c"[..], &b"/a/b"[..], &b"/a/b/c"[..]]); + } + + #[test] + fn make_path_stops_on_a_middle_component_after_two_steps_back() { + // `/proc/nonexistent/out`: the target's parent is missing too, so the + // guard fires on the middle component and returns its error. + let it = ComponentIterator::init(&b"/a/b/c"[..], PathFormat::Posix).unwrap(); + let mut attempts: Vec<&[u8]> = vec![]; + let r = make_path_with(it, |p| { + attempts.push(p); + assert!(attempts.len() < 100, "runaway loop"); + match p { + b"/a" => Ok(MakePathStep::Exists), + b"/a/b" | b"/a/b/c" => Ok(MakePathStep::NotFound(p)), + _ => unreachable!(), + } + }); + assert_eq!(r, Err(&b"/a/b"[..])); + assert_eq!( + attempts, + vec![&b"/a/b/c"[..], &b"/a/b"[..], &b"/a"[..], &b"/a/b"[..]] + ); + } + + #[test] + fn make_path_stops_when_open_if_parent_succeeds_but_child_is_not_found() { + // `NtCreateFile(FILE_OPEN_IF)` reports `Created` for an existing + // directory too, so the guard must not depend on `Exists`. + let it = ComponentIterator::init(&b"x\\link\\out"[..], PathFormat::Windows).unwrap(); + let mut attempts = 0u32; + let r = make_path_with(it, |p| { + attempts += 1; + assert!(attempts < 100, "runaway loop"); + match p { + b"x" | b"x\\link" => Ok(MakePathStep::Created), + b"x\\link\\out" => Ok(MakePathStep::NotFound(())), + _ => unreachable!(), + } + }); + assert_eq!(r, Err(())); + assert_eq!(attempts, 3); + } + + #[test] + fn make_path_reports_missing_root_parent() { + // Nothing under the cwd exists: the first component's error comes back. + let it = ComponentIterator::init(&b"a/b"[..], PathFormat::Posix).unwrap(); + let r = make_path_with(it, |p| Ok(MakePathStep::NotFound(p))); + assert_eq!(r, Err(&b"a"[..])); + } } diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index a909f51c655c..8b4bc9f9b06f 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -668,6 +668,31 @@ describe("Bun.build", () => { Bun.gc(true); }); + test("outdir under a dangling symlink fails with ENOENT instead of never settling", async () => { + // mkdir on the link reports EEXIST, mkdir below it reports ENOENT. The + // recursive mkdir used to retry that pair forever and the build promise + // never settled. Run in a child so the spin cannot take the runner down. + using dir = tempDir("build-outdir-dangling", { + "index.js": `console.log("built");`, + "build.js": ` + const r = await Bun.build({ entrypoints: ["./index.js"], outdir: "./dangling/out", throw: false }); + console.log(r.success, r.logs.map(l => l.message).join("\\n")); + `, + }); + symlinkSync(join(String(dir), "does-not-exist"), join(String(dir), "dangling")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("false Failed to create output directory ENOENT"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test("BuildArtifact properties", async () => { Bun.gc(true); const outdir = tempDirWithFiles("build-artifact-properties", { diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index b0861c95ca98..a56f68b09ae3 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -443,6 +443,26 @@ test("--outdir build succeeds when the output directory already exists with prio expect(out).not.toContain("stale"); }); +// mkdir on a dangling symlink reports EEXIST, mkdir below it reports ENOENT. +// The recursive mkdir behind --outdir used to retry that pair forever. +test("--outdir fails with ENOENT when a parent of the output directory is a dangling symlink", async () => { + using dir = tempDir("build-outdir-dangling-symlink", { + "entry.ts": `console.log("built");`, + }); + fs.symlinkSync(path.join(String(dir), "does-not-exist"), path.join(String(dir), "dangling")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.ts", "--outdir", path.join("dangling", "out")], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("Failed to create output directory ENOENT"); + expect(exitCode).toBe(1); +}); + test("multi-entry build writes each entry point into the output directory", async () => { using dir = tempDir("build-multi-entry-outdir", { "a.ts": `export const a: number = 1;\nconsole.log("A" + a);`, diff --git a/test/cli/install/bun-install-cache-dir.test.ts b/test/cli/install/bun-install-cache-dir.test.ts new file mode 100644 index 000000000000..02c62a3ecf9c --- /dev/null +++ b/test/cli/install/bun-install-cache-dir.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { existsSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; + +// mkdir on a dangling symlink reports EEXIST, mkdir below it reports ENOENT. +// The recursive mkdir behind the install cache directory used to retry that +// pair forever, so `bun install` sat at 100% CPU before resolving anything. +// The folder dependency keeps the install off the network. +test("install completes when BUN_INSTALL_CACHE_DIR is under a dangling symlink", async () => { + using dir = tempDir("install-cache-dir-dangling", { + "proj/package.json": JSON.stringify({ + name: "x", + version: "1.0.0", + dependencies: { localdep: "file:./localdep" }, + }), + "proj/localdep/package.json": JSON.stringify({ name: "localdep", version: "1.2.3" }), + }); + symlinkSync(join(String(dir), "does-not-exist"), join(String(dir), "dangling")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: join(String(dir), "proj"), + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), "dangling", "cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const root = String(dir); + expect({ + stdout, + stderr, + installed: existsSync(join(root, "proj", "node_modules", "localdep", "package.json")), + // The install falls back to the cache directory inside node_modules. + fallbackCache: existsSync(join(root, "proj", "node_modules", ".cache")), + // Nothing is created through the link. + linkTarget: existsSync(join(root, "does-not-exist")), + }).toEqual({ + stdout: expect.stringContaining("1 package installed"), + stderr: expect.any(String), + installed: true, + fallbackCache: true, + linkTarget: false, + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index 7eef7eaf7420..1ec6e42bb67f 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -6,12 +6,13 @@ import { join } from "path"; // Minimal ustar tarball builder (pathnames must be <100 bytes). `name` accepts // a Buffer so tests can put raw, non-UTF-8 byte sequences into the name field. // `fields` overwrites the raw 8-byte mode / 12-byte mtime fields, for values the -// octal text form cannot hold (see `base256`). +// octal text form cannot hold (see `base256`). `linkname` is the target of a +// symlink ('2') entry. function ustarHeader( name: string | Buffer, size: number, typeflag: string = "0", - fields: { mode?: Uint8Array; mtime?: Uint8Array } = {}, + fields: { mode?: Uint8Array; mtime?: Uint8Array; linkname?: string } = {}, ): Buffer { const nameBytes = typeof name === "string" ? Buffer.from(name) : name; if (nameBytes.length > 99) throw new Error("ustar name too long: " + name); @@ -26,6 +27,7 @@ function ustarHeader( if (fields.mtime) h.set(fields.mtime.subarray(0, 12), 136); h.write(" ", 148); h.write(typeflag, 156); + if (fields.linkname) h.write(fields.linkname, 157, 99); h.write("ustar\0", 257); h.write("00", 263); let sum = 0; @@ -1894,6 +1896,45 @@ describe("Bun.Archive", () => { expect(count).toBe(0); }); + + // The glob path creates a symlink entry as soon as it reads it. mkdir on + // the dangling link reports EEXIST and mkdir below it reports ENOENT, and + // the recursive mkdir for the next entry's parents used to retry that pair + // forever. Windows does not extract symlink entries, so the link never + // exists there. + test.skipIf(isWindows)("skips an entry below a dangling symlink from the same archive", async () => { + const tarball = Buffer.concat([ + ustarHeader("link", 0, "2", { linkname: "nowhere" }), + ustarEntry("link/a/b.txt", Buffer.from("x")), + Buffer.alloc(1024), + ]); + + using dir = tempDir("archive-glob-dangling-symlink", { + "input.tar": tarball, + "extract.ts": ` + const fs = require("node:fs"); + const archive = new Bun.Archive(new Uint8Array(fs.readFileSync("input.tar"))); + const count = await archive.extract("out", { glob: "**" }); + console.log(JSON.stringify({ count, nowhere: fs.existsSync("out/nowhere") })); + `, + }); + + // In a child process: an extraction that never settles keeps a thread of + // the work pool spinning, which must not happen inside the test runner. + await using proc = Bun.spawn({ + cmd: [bunExe(), "extract.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Only the symlink counts. Nothing is created through it. + expect(stdout).toBe(JSON.stringify({ count: 1, nowhere: false }) + "\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); describe("concurrent operations", () => { diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index 368685185c7f..62f9a49633d3 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -1018,3 +1018,50 @@ console.log("survived", require("./late.js"));`, expect(exitCode).toBe(0); }); }); + +// Not in the concurrent block: when this test times out, its child spins until +// something kills it, and the runner only kills the children of a test that +// runs alone. +test("compile cache under a dangling symlink is reported as FAILED, not retried forever", async () => { + // mkdir on the link reports EEXIST, mkdir below it reports ENOENT. The + // recursive mkdir used to bounce between the two forever, so a bad + // NODE_COMPILE_CACHE hung every process before any user code ran. + using dir = tempDir("compile-cache-dangling", { + "main.js": ` + const Module = require("node:module"); + const r = Module.enableCompileCache(process.env.CACHE_DIR); + console.log(r.status === Module.constants.compileCacheStatus.FAILED, r.message); + `, + }); + const link = path.join(String(dir), "dangling"); + fs.symlinkSync(path.join(String(dir), "does-not-exist"), link); + const cacheDir = path.join(link, "cc"); + const env = { ...bunEnv }; + delete env.NODE_COMPILE_CACHE; + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('started')"], + env: { ...env, NODE_COMPILE_CACHE: cacheDir }, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("started\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + } + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: { ...env, CACHE_DIR: cacheDir }, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("true Cannot create cache directory: ENOENT\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + } +});