Files
bun.sh/test/cli/run/glob-on-fuse.test.ts
mmitchellg5 85080f7949 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>
2026-01-22 13:44:49 -08:00

122 lines
4.2 KiB
TypeScript

/**
* 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);
});