Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dofs-readdir-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/dofs": patch
---

`SQLiteWorkspaceProvider.readdirEntriesSync(path)` lists a directory with each entry's type, permission bits and inode, as an `lstat` of it reports them, from one query.
5 changes: 5 additions & 0 deletions .changeset/readdir-entry-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computerd": patch
---

Directory listings on the mount now report each entry's type and inode, on synced and local-only paths alike. Before, every entry came back as `DT_UNKNOWN`, which `bun install` treats as nothing to link: with its cache under a local-only path such as `/workspace/.cache`, it left packages out of `node_modules` and failed with `failed to enqueue lifecycle scripts ...: ENOENT`. Tools that read `d_type`, such as `find` and `git`, no longer need an `lstat` per entry to learn it.
66 changes: 66 additions & 0 deletions packages/computerd/src/fuse/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1069,3 +1069,69 @@ test("FUSE ftruncate does not depend on fuse-native binding this", async () => {
expect(await status((cb) => ops.flush("/ftruncate-this.txt", fh, cb))).toBe(0);
expect(Buffer.from(vfs.readFileSync("/ftruncate-this.txt")).toString("utf8")).toBe("abc");
});

for (const listing of ["batched", "per entry"] as const) {
test(`readdir gives each entry the type and inode getattr reports (${listing})`, async () => {
// libfuse takes d_type from these stats. Without them every entry is
// DT_UNKNOWN, and readers that trust d_type skip it: bun's hardlink
// install walking a cache on the mount linked nothing.
const { vfs } = await createNodeVirtualFileSystem();
vfs.mkdirSync("/dir/sub", { recursive: true });
vfs.writeFileSync("/dir/file.txt", Buffer.from("x"));
vfs.symlinkSync("file.txt", "/dir/link");
// "per entry" is a VFS without the batched listing, which the driver
// stats name by name.
const source =
listing === "batched"
? vfs
: new Proxy(vfs, {
// Bound to the real object: the VFS keeps private fields,
// which a call with the proxy as `this` cannot reach.
get: (target, property) => {
if (property === "readdirEntriesSync") return undefined;
const value: unknown = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
},
});
const ops = makeFUSEOps(source);
expect(await status((cb) => ops.link("/dir/file.txt", "/dir/hard.txt", cb))).toBe(0);
expect(await status((cb) => ops.chmod("/dir/file.txt", 0o700, cb))).toBe(0);
// Created through the driver and not yet flushed, so only the driver
// knows about it.
expect((await callback((cb) => ops.create("/dir/pending.txt", 0o644, cb))).errno).toBe(0);

const result = await new Promise<{ errno: number; names: string[]; stats: unknown[] }>(
(resolve) =>
ops.readdir("/dir", (errno, names, stats = []) => resolve({ errno, names, stats })),
);
expect(result.errno).toBe(0);
expect([...result.names].sort()).toEqual([
"file.txt",
"hard.txt",
"link",
"pending.txt",
"sub",
]);
expect(result.stats).toHaveLength(result.names.length);

const S_IFMT = 0o170000;
const types: Record<string, number> = {};
for (const [i, name] of result.names.entries()) {
const fromListing = result.stats[i] as { mode: number; ino: number };
const fromGetattr = (await callback((cb) => ops.getattr(`/dir/${name}`, cb))).result as {
mode: number;
ino: number;
};
expect(fromListing.mode, name).toBe(fromGetattr.mode);
expect(fromListing.ino, name).toBe(fromGetattr.ino);
types[name] = fromListing.mode & S_IFMT;
}
expect(types).toEqual({
"file.txt": 0o100000,
"hard.txt": 0o100000,
link: 0o120000,
"pending.txt": 0o100000,
sub: 0o040000,
});
});
}
120 changes: 93 additions & 27 deletions packages/computerd/src/fuse/driver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { writeFileSync as nodeWriteFileSync } from "node:fs";
import { posix } from "node:path";

import type { WorkspaceDirentEntry } from "@cloudflare/dofs";
import type { FUSEBackend } from "./backend.js";
import { buildFuseOptionString } from "./options.js";
import {
Expand Down Expand Up @@ -41,19 +43,31 @@ const STAT_BLOCK_SIZE = 512;
// Match what statfs advertises (bsize: 4096) and what the backing VFS
// reports so a fabricated stat stays consistent with a persisted one.
const PREFERRED_IO_BLOCK_SIZE = 4096;
const DIRENT_TYPE_BITS: Record<WorkspaceDirentEntry["type"], number> = {
file: 0o100000,
dir: 0o040000,
symlink: 0o120000,
};
const EPOCH = new Date(0);

function blocksForSize(size: number): number {
return size <= 0 ? 0 : Math.ceil(size / STAT_BLOCK_SIZE);
}

type StatusCallback = (errnoOrBytes: number) => void;
type ResultCallback<T> = (errno: number, result: T) => void;
/**
* `stats`, when given, is one per name. libfuse takes each entry's type
* (d_type) and, under use_ino, its inode number from it; without it every
* entry is DT_UNKNOWN.
*/
export type ReaddirCallback = (errno: number, names: string[], stats?: FuseStat[]) => void;
type NotImplementedOperation = (...args: unknown[]) => void;

export interface FuseOps {
init(cb?: StatusCallback): void;
error: NotImplementedOperation;
readdir(path: string, cb: ResultCallback<string[]>): void;
readdir(path: string, cb: ReaddirCallback): void;
getattr(path: string, cb: ResultCallback<FuseStat | null>): void;
fgetattr(path: string, fh: number, cb: ResultCallback<FuseStat | null>): void;
open(path: string, flags: number, cb: ResultCallback<number>): void;
Expand Down Expand Up @@ -285,6 +299,9 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
const linkableVfs = vfs as NodeVirtualFileSystem & {
linkSync?: (existingPath: string, newPath: string) => void;
};
const listingVfs = vfs as NodeVirtualFileSystem & {
readdirEntriesSync?: (path: string) => WorkspaceDirentEntry[];
};
const markDirty = (entry: FileEntry, start: number, end: number): void => {
entry.dirty = true;
if (start < end) entry.dirtyRanges.push({ start, end });
Expand All @@ -294,6 +311,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
return parent === "." ? "/" : parent;
};
const baseName = (path: string): string => posix.basename(path);
const childPath = (parent: string, name: string): string => posix.join(parent, name);
const isPendingCreate = (path: string): boolean => files.get(path)?.pendingCreate === true;
const exists = (path: string): boolean => isPendingCreate(path) || vfs.existsSync(toVfs(path));
const getBufferStats = (): FuseBufferStats => {
Expand Down Expand Up @@ -460,6 +478,54 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
};
};

function statPath(path: string): FuseStat {
const entry = files.get(path);
const stat =
entry?.pendingCreate === true ? pendingStat(entry) : statNode(vfs.lstatSync(toVfs(path)));
// File content lives outside the VFS, so prefer our size. The
// buffered size can outrun the persisted inode before a spill,
// so recompute block accounting from it to keep st_blocks
// coherent with the size the kernel sees.
if (entry !== undefined) {
stat.size = entry.size;
stat.blocks = blocksForSize(entry.size);
}
const override = meta.get(path);
if (override) {
if (override.mode !== undefined) {
stat.mode = (stat.mode & 0o170000) | (override.mode & 0o7777);
}
if (override.uid !== undefined) stat.uid = override.uid;
if (override.gid !== undefined) stat.gid = override.gid;
if (override.atime) stat.atime = override.atime;
if (override.mtime) stat.mtime = override.mtime;
}
return stat;
}

// A listing entry's stat. libfuse puts only the type and the inode of a
// plain readdir entry into getdents(2) and caches none of it as
// attributes, so the remaining fields are placeholders. With readdirplus
// these would become cached attributes and need statPath's real values.
function direntStat(path: string, entry: WorkspaceDirentEntry): FuseStat {
let mode = DIRENT_TYPE_BITS[entry.type] | entry.mode;
const override = meta.get(path);
if (override?.mode !== undefined) mode = (mode & 0o170000) | (override.mode & 0o7777);
return {
mtime: EPOCH,
atime: EPOCH,
ctime: EPOCH,
size: 0,
mode,
uid: typeof process.getuid === "function" ? process.getuid() : 0,
gid: typeof process.getgid === "function" ? process.getgid() : 0,
nlink: 1,
ino: entry.inode,
blksize: PREFERRED_IO_BLOCK_SIZE,
blocks: 0,
Comment on lines +506 to +525

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Listing stats differ from lookup stats

The batched readdir path supplies placeholder attributes rather than the values returned by getattr. Plain readdir uses only type and inode, but readdirplus would cache those placeholders; the stated contract needs clarification.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

};
}

return {
init(cb) {
cb?.(0);
Expand All @@ -470,42 +536,42 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
error: notImplemented("error"),

readdir(path, cb) {
// Without a stat per entry, libfuse reports every entry as
// DT_UNKNOWN. POSIX allows that, but readers that trust d_type
// skip such entries: bun's hardlink install walks its cache that
// way and links nothing into node_modules.
try {
const entries = new Set(vfs.readdirSync(toVfs(path)));
const stats = new Map<string, FuseStat>();
const listed = listingVfs.readdirEntriesSync?.(toVfs(path));
if (listed !== undefined) {
for (const entry of listed) {
stats.set(entry.name, direntStat(childPath(path, entry.name), entry));
}
} else {
for (const name of vfs.readdirSync(toVfs(path)) as string[]) {
try {
stats.set(name, statPath(childPath(path, name)));
} catch {
// Listed but gone by the time it was stat'd.
}
}
}
// Created through this driver and not yet in the VFS, or staged
// over a committed name; either way getattr answers from here.
for (const [pendingPath, entry] of files) {
if (entry.pendingCreate && parentPath(pendingPath) === path)
entries.add(baseName(pendingPath));
if (entry.pendingCreate && parentPath(pendingPath) === path) {
stats.set(baseName(pendingPath), statPath(pendingPath));
}
}
cb(0, [...entries]);
cb(0, [...stats.keys()], [...stats.values()]);
} catch (error) {
cb(toErrno(error), []);
}
},

getattr(path, cb) {
try {
const entry = files.get(path);
const stat =
entry?.pendingCreate === true ? pendingStat(entry) : statNode(vfs.lstatSync(toVfs(path)));
// File content lives outside the VFS, so prefer our size. The
// buffered size can outrun the persisted inode before a spill,
// so recompute block accounting from it to keep st_blocks
// coherent with the size the kernel sees.
if (entry !== undefined) {
stat.size = entry.size;
stat.blocks = blocksForSize(entry.size);
}
const override = meta.get(path);
if (override) {
if (override.mode !== undefined) {
stat.mode = (stat.mode & 0o170000) | (override.mode & 0o7777);
}
if (override.uid !== undefined) stat.uid = override.uid;
if (override.gid !== undefined) stat.gid = override.gid;
if (override.atime) stat.atime = override.atime;
if (override.mtime) stat.mtime = override.mtime;
}
cb(0, stat);
cb(0, statPath(path));
} catch (error) {
cb(toErrno(error), null);
}
Expand Down
101 changes: 101 additions & 0 deletions packages/computerd/src/fuse/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,107 @@ describe("withLocalPassthrough: directory listing", () => {
});
});

describe("withLocalPassthrough: readdir entry types", () => {
// libfuse takes d_type, and under use_ino the inode, from the stat given
// with each name. Without one every entry is DT_UNKNOWN, which readers
// that trust d_type skip: bun's hardlink install walking a cache under a
// local-only path linked nothing.
let root: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "computerd-passthrough-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

const S_IFMT = 0o170000;
type EntryStat = { mode: number; ino: number };
// The listing as name -> stat, failing unless every name has one.
const list = (ops: FuseOps, path: string) => {
let out = { code: -1, names: [] as string[], byName: new Map<string, EntryStat>() };
ops.readdir(path, (code, names, stats = []) => {
expect(stats).toHaveLength(names.length);
out = { code, names, byName: new Map(names.map((name, i) => [name, stats[i] as EntryStat])) };
});
return out;
};
const typeOf = (stat: EntryStat | undefined): number | undefined =>
stat === undefined ? undefined : stat.mode & S_IFMT;

test("a local directory reports each entry's lstat type and inode", () => {
const { ops } = withLocalPassthrough(recordingOps().ops, {
root,
ignore: resolveMountIgnore(["**/.cache"], MOUNT),
mountPoint: MOUNT,
});
const dir = join(root, ".cache/bun/react@19");
mkdirSync(join(dir, "cjs"), { recursive: true });
writeFileSync(join(dir, "package.json"), "{}");
symlinkSync("package.json", join(dir, "link.json"));

const { code, names, byName } = list(ops, "/.cache/bun/react@19");
expect(code).toBe(0);
expect(typeOf(byName.get("cjs"))).toBe(constants.S_IFDIR);
expect(typeOf(byName.get("package.json"))).toBe(constants.S_IFREG);
expect(typeOf(byName.get("link.json"))).toBe(constants.S_IFLNK);
for (const name of names) {
expect(byName.get(name)?.ino, name).toBe(lstatSync(join(dir, name)).ino);
}
});

test("a synced directory keeps the VFS stats and adds local children's", () => {
const vfsStat = { mode: constants.S_IFREG | 0o644, ino: 42 };
const source = recordingOps();
const vfsOps = new Proxy(source.ops, {
get(target, property: string) {
if (property === "readdir") {
return (_path: string, cb: (code: number, names: string[], stats?: unknown[]) => void) =>
cb(0, ["src.ts"], [vfsStat]);
}
return Reflect.get(target, property);
},
});
const { ops } = withLocalPassthrough(vfsOps, {
root,
ignore: resolveMountIgnore(["**/node_modules"], MOUNT),
mountPoint: MOUNT,
});
mkdirSync(join(root, "app/node_modules"), { recursive: true });

const { code, byName } = list(ops, "/app");
expect(code).toBe(0);
expect(byName.get("src.ts")).toBe(vfsStat);
expect(typeOf(byName.get("node_modules"))).toBe(constants.S_IFDIR);
expect(byName.get("node_modules")?.ino).toBe(lstatSync(join(root, "app/node_modules")).ino);
});

test("a local-only child shadows a synced entry of the same name", () => {
// getattr routes the name to the local side, so the listing has to
// agree with it, including when stale synced content remains.
const source = recordingOps();
const vfsOps = new Proxy(source.ops, {
get(target, property: string) {
if (property === "readdir") {
return (_path: string, cb: (code: number, names: string[], stats?: unknown[]) => void) =>
cb(0, ["node_modules"], [{ mode: constants.S_IFREG | 0o644, ino: 7 }]);
}
return Reflect.get(target, property);
},
});
const { ops } = withLocalPassthrough(vfsOps, {
root,
ignore: resolveMountIgnore(["**/node_modules"], MOUNT),
mountPoint: MOUNT,
});
mkdirSync(join(root, "node_modules"), { recursive: true });

const { names, byName } = list(ops, "/");
expect(names).toEqual(["node_modules"]);
expect(typeOf(byName.get("node_modules"))).toBe(constants.S_IFDIR);
});
});

describe("withLocalPassthrough: symlinks", () => {
let root: string;

Expand Down
Loading
Loading