mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
fix: handle DT_UNKNOWN in dir_iterator for bind-mounted filesystems (#25838)
### What does this PR do? Fixes #24007 Possibly fixes https://github.com/oven-sh/bun/issues/18902, https://github.com/oven-sh/bun/issues/7412 Some filesystems (bind mounts, FUSE, NFS) don't provide `d_type` in directory entries, returning `DT_UNKNOWN`. This caused glob and recursive readdir to skip entries entirely. ## Problem On Linux filesystems that don't populate `d_type` in directory entries (bind mounts, FUSE, NFS, some ext4 configurations), `readdir()` returns `DT_UNKNOWN` instead of the actual file type. This caused: - `Bun.Glob` to skip files/directories entirely - `fs.readdirSync(..., {recursive: true})` to not recurse into subdirectories - `fs.readdirSync(..., {withFileTypes: true})` to report incorrect types ## Solution Implemented a **lazy `lstatat()` fallback** when `d_type == DT_UNKNOWN`: - **`sys.zig`**: Added `lstatat()` function - same as `fstatat()` but with `AT_SYMLINK_NOFOLLOW` flag to correctly identify symlinks - **`GlobWalker.zig`**: When encountering `.unknown` entries, first check if filename matches pattern, then call `lstatat()` only if needed - **`node_fs.zig`**: Handle `.unknown` in both async and sync recursive readdir paths; propagate resolved kind to Dirent objects - **`dir_iterator.zig`**: Return `.unknown` for `DT_UNKNOWN` entries, letting callers handle lazy stat **Why `lstatat` instead of `fstatat`?** We use `AT_SYMLINK_NOFOLLOW` to preserve consistent behavior with normal filesystems - symlinks should be reported as symlinks, not as their target type. This matches [Node.js behavior](https://github.com/nodejs/node/blob/main/lib/internal/fs/utils.js#L251-L269) which uses `lstat()` for the DT_UNKNOWN fallback, and follows the lazy stat pattern established in PR #18172. ### How did you verify your code works? **Testing:** - Regression test: `test/regression/issue/24007.test.ts` - FUSE filesystem test: `test/cli/run/glob-on-fuse.test.ts` (reuses `fuse-fs.py` from PR #18172, includes symlink verification) - All existing glob/readdir tests pass - **Verified in Docker bind-mount environment:** - Official Bun: `0 files` - Patched Bun: `3 files` **Performance:** No impact on normal filesystems - the `.unknown` branch is only hit when `d_type == DT_UNKNOWN`. The lazy stat pattern avoids unnecessary syscalls by checking pattern match first. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
121
test/cli/run/glob-on-fuse.test.ts
Normal file
121
test/cli/run/glob-on-fuse.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Test that Bun.Glob and fs.globSync work correctly on FUSE filesystems
|
||||
* where d_type returns DT_UNKNOWN.
|
||||
*
|
||||
* Related to issue #24007 and PR #18172
|
||||
*/
|
||||
import { spawn, type ReadableSubprocess } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isLinux, tmpdirSync } from "harness";
|
||||
import fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
describe.skipIf(!isLinux)("glob on a FUSE mount", () => {
|
||||
async function withFuseMount<T>(fn: (mountpoint: string) => Promise<T>): Promise<T> {
|
||||
// Use tmpdirSync for empty mount point (tempDir requires file tree)
|
||||
const mountpoint = tmpdirSync();
|
||||
|
||||
let pythonProcess: ReadableSubprocess | undefined = undefined;
|
||||
let result: T;
|
||||
let originalError: Error | undefined;
|
||||
let cleanupError: Error | undefined;
|
||||
|
||||
try {
|
||||
// setup FUSE filesystem (uses fuse-fs.py which returns DT_UNKNOWN)
|
||||
pythonProcess = spawn({
|
||||
cmd: ["python3", "fuse-fs.py", "-f", mountpoint],
|
||||
cwd: __dirname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// wait for mount to be ready, also check if Python process exited early
|
||||
let tries = 0;
|
||||
while (!fs.existsSync(join(mountpoint, "main.js")) && tries < 250 && pythonProcess.exitCode === null) {
|
||||
tries++;
|
||||
await Bun.sleep(5);
|
||||
}
|
||||
if (pythonProcess.exitCode !== null && pythonProcess.exitCode !== 0) {
|
||||
throw new Error(`FUSE process exited early with code ${pythonProcess.exitCode}`);
|
||||
}
|
||||
expect(fs.existsSync(join(mountpoint, "main.js"))).toBeTrue();
|
||||
|
||||
result = await fn(mountpoint);
|
||||
} catch (e) {
|
||||
originalError = e instanceof Error ? e : new Error(String(e));
|
||||
} finally {
|
||||
if (pythonProcess) {
|
||||
try {
|
||||
// unmount
|
||||
const umount = spawn({ cmd: ["fusermount", "-u", mountpoint] });
|
||||
await umount.exited;
|
||||
// wait for graceful exit
|
||||
await Promise.race([pythonProcess.exited, Bun.sleep(1000)]);
|
||||
expect(pythonProcess.exitCode).toBe(0);
|
||||
} catch (e) {
|
||||
pythonProcess.kill("SIGKILL");
|
||||
console.error("python process errored:", await new Response(pythonProcess.stderr).text());
|
||||
// Capture cleanup error but don't throw inside finally
|
||||
if (!originalError) {
|
||||
cleanupError = e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw errors outside finally block
|
||||
if (originalError) {
|
||||
throw originalError;
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw cleanupError;
|
||||
}
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
// Set a long timeout so the test can clean up the filesystem mount itself
|
||||
// rather than getting interrupted by timeout (matches run-file-on-fuse.test.ts)
|
||||
test("Bun.Glob.scanSync finds files on FUSE mount", async () => {
|
||||
await withFuseMount(async mountpoint => {
|
||||
const glob = new Bun.Glob("*.js");
|
||||
const results = Array.from(glob.scanSync({ cwd: mountpoint }));
|
||||
|
||||
// fuse-fs.py provides main.js and main-symlink.js
|
||||
expect(results).toContain("main.js");
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
test("fs.globSync finds files on FUSE mount", async () => {
|
||||
await withFuseMount(async mountpoint => {
|
||||
const results = fs.globSync("*.js", { cwd: mountpoint });
|
||||
|
||||
expect(results).toContain("main.js");
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
test("fs.readdirSync works on FUSE mount", async () => {
|
||||
await withFuseMount(async mountpoint => {
|
||||
const results = fs.readdirSync(mountpoint);
|
||||
|
||||
expect(results).toContain("main.js");
|
||||
expect(results).toContain("main-symlink.js");
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
test("fs.readdirSync with withFileTypes returns correct types on FUSE mount", async () => {
|
||||
await withFuseMount(async mountpoint => {
|
||||
const results = fs.readdirSync(mountpoint, { withFileTypes: true });
|
||||
|
||||
const mainJs = results.find(d => d.name === "main.js");
|
||||
expect(mainJs).toBeDefined();
|
||||
expect(mainJs!.isFile()).toBe(true);
|
||||
|
||||
const symlink = results.find(d => d.name === "main-symlink.js");
|
||||
expect(symlink).toBeDefined();
|
||||
expect(symlink!.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
128
test/regression/issue/24007.test.ts
Normal file
128
test/regression/issue/24007.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Regression test for GitHub issue #24007
|
||||
* https://github.com/oven-sh/bun/issues/24007
|
||||
*
|
||||
* Issue: Bun's glob/readdir functionality failed on bind-mounted paths in Docker
|
||||
* because certain filesystems (sshfs, fuse, NFS, bind mounts) don't provide d_type
|
||||
* information in directory entries (returns DT_UNKNOWN).
|
||||
*
|
||||
* Fix: Added lstatat() fallback when d_type is unknown, following the lazy stat
|
||||
* pattern from PR #18172.
|
||||
*
|
||||
* See also: test/cli/run/glob-on-fuse.test.ts for FUSE filesystem testing.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tempDir } from "harness";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
describe.concurrent("issue #24007 - glob with recursive patterns", () => {
|
||||
test("recursive glob pattern **/*.ts finds nested files", () => {
|
||||
using dir = tempDir("issue-24007", {
|
||||
"server/api/health.get.ts": "export default () => 'ok';",
|
||||
"server/api/users/list.ts": "export default () => [];",
|
||||
"server/routes/index.ts": "export default {};",
|
||||
"server/routes/admin/dashboard.ts": "export default {};",
|
||||
"config.ts": "export default {};",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
|
||||
// Test recursive pattern with **
|
||||
const results = fs.globSync("**/*.ts", { cwd });
|
||||
|
||||
expect(results).toContain("config.ts");
|
||||
expect(results).toContain(path.join("server", "api", "health.get.ts"));
|
||||
expect(results).toContain(path.join("server", "api", "users", "list.ts"));
|
||||
expect(results).toContain(path.join("server", "routes", "index.ts"));
|
||||
expect(results).toContain(path.join("server", "routes", "admin", "dashboard.ts"));
|
||||
expect(results.length).toBe(5);
|
||||
});
|
||||
|
||||
test("recursive glob pattern server/**/*.ts finds files in subdirectory", () => {
|
||||
using dir = tempDir("issue-24007-subdir", {
|
||||
"server/api/health.get.ts": "x",
|
||||
"server/routes/status.ts": "x",
|
||||
"other/file.ts": "x",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
const results = fs.globSync("server/**/*.ts", { cwd });
|
||||
|
||||
expect(results).toContain(path.join("server", "api", "health.get.ts"));
|
||||
expect(results).toContain(path.join("server", "routes", "status.ts"));
|
||||
expect(results).not.toContain(path.join("other", "file.ts"));
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("top-level glob pattern server/*.ts finds direct children", () => {
|
||||
using dir = tempDir("issue-24007-toplevel", {
|
||||
"server/index.ts": "x",
|
||||
"server/config.ts": "x",
|
||||
"server/nested/deep.ts": "x",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
const results = fs.globSync("server/*.ts", { cwd });
|
||||
|
||||
expect(results).toContain(path.join("server", "index.ts"));
|
||||
expect(results).toContain(path.join("server", "config.ts"));
|
||||
expect(results).not.toContain(path.join("server", "nested", "deep.ts"));
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Bun.Glob recursive scan finds nested files", () => {
|
||||
using dir = tempDir("issue-24007-bun-glob", {
|
||||
"api/health.get.ts": "x",
|
||||
"api/users/index.ts": "x",
|
||||
"routes/home.ts": "x",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
const glob = new Bun.Glob("**/*.ts");
|
||||
const results = Array.from(glob.scanSync({ cwd }));
|
||||
|
||||
expect(results).toContain(path.join("api", "health.get.ts"));
|
||||
expect(results).toContain(path.join("api", "users", "index.ts"));
|
||||
expect(results).toContain(path.join("routes", "home.ts"));
|
||||
expect(results.length).toBe(3);
|
||||
});
|
||||
|
||||
test("fs.readdirSync with recursive option finds all files", () => {
|
||||
using dir = tempDir("issue-24007-readdir", {
|
||||
"a/b/c/file.txt": "content",
|
||||
"a/b/file.txt": "content",
|
||||
"a/file.txt": "content",
|
||||
"file.txt": "content",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
const results = fs.readdirSync(cwd, { recursive: true });
|
||||
|
||||
expect(results).toContain("file.txt");
|
||||
expect(results).toContain(path.join("a", "file.txt"));
|
||||
expect(results).toContain(path.join("a", "b", "file.txt"));
|
||||
expect(results).toContain(path.join("a", "b", "c", "file.txt"));
|
||||
});
|
||||
|
||||
test("fs.readdirSync with recursive and withFileTypes returns correct types", () => {
|
||||
using dir = tempDir("issue-24007-dirent", {
|
||||
"dir/subdir/file.txt": "content",
|
||||
"dir/another.txt": "content",
|
||||
});
|
||||
|
||||
const cwd = String(dir);
|
||||
const results = fs.readdirSync(cwd, { recursive: true, withFileTypes: true });
|
||||
|
||||
// Find the nested file in dir/subdir/
|
||||
const expectedParent = path.join(cwd, "dir", "subdir");
|
||||
const nestedFile = results.find(d => d.name === "file.txt" && d.parentPath === expectedParent);
|
||||
expect(nestedFile).toBeDefined();
|
||||
expect(nestedFile!.isFile()).toBe(true);
|
||||
|
||||
// Find a directory entry
|
||||
const dirEntry = results.find(d => d.name === "subdir");
|
||||
expect(dirEntry).toBeDefined();
|
||||
expect(dirEntry!.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user