Skip to content

Commit 3aec8a0

Browse files
committed
wsd, docs: Report FUSE block counts
Populate FUSE stat block metadata so st_blocks consumers such as du see non-zero usage for files on the mount. The driver now preserves provider-supplied block fields and derives them for pending or buffered in-memory stats. Add regression coverage for persisted, empty, pending-create, and dirty buffered files. Document the setup traps around fuse-native builds, clean-checkout test builds, and privileged real-FUSE tests.
1 parent ddb2936 commit 3aec8a0

5 files changed

Lines changed: 215 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,60 @@ the file directly when the trigger applies:
2828
| [`capnweb`](.agents/skills/capnweb/SKILL.md) | Touching anything that crosses the RPC boundary: `packages/rpc`, `packages/workspace`, the `wsd` client, or the Durable Object server. |
2929
| [`cloudflare`](.agents/skills/cloudflare/SKILL.md) | Index of host-side Cloudflare skills — Workers, Durable Objects, wrangler, sandbox SDK, agents SDK. |
3030

31+
## Environment setup
32+
33+
A fresh container does not have everything the tests need. The traps
34+
below cost real time if you discover them one failure at a time.
35+
36+
**Native build tools.** `packages/wsd` depends on `fuse-native`, a
37+
native addon. Building it needs a C toolchain and the libfuse2 headers.
38+
On Debian or Ubuntu:
39+
40+
```bash
41+
apt-get install build-essential libfuse-dev
42+
```
43+
44+
If the `fuse-native` build fails, `npm install` aborts the whole
45+
install, not just that one package. When you only need the rest of the
46+
workspace, install with `npm install --ignore-scripts` to skip the
47+
native build.
48+
49+
**arm64 hosts.** `fuse-native` ships a prebuilt libfuse for x64 only.
50+
On a Linux arm64 host or container (including a Linux container on
51+
Apple Silicon, or arm64 CI) the link fails with `file in wrong
52+
format`. The path below is Debian or Ubuntu arm64; a native macOS host
53+
uses macFUSE instead and does not hit this. Replace the bundled library
54+
with the system one and rebuild:
55+
56+
```bash
57+
cp /usr/lib/aarch64-linux-gnu/libfuse.so.2 \
58+
node_modules/fuse-shared-library-linux/libfuse/lib/libfuse.so
59+
cd node_modules/fuse-native && npx node-gyp rebuild
60+
```
61+
62+
**Build before you test.** The test scripts don't build the sibling
63+
packages first. Several suites need build output that is absent in
64+
a clean checkout: `packages/wsd` imports the sibling `@cloudflare/dofs`
65+
and `@cloudflare/workspace-rpc` packages from their `dist/`
66+
directories, `packages/wsd`'s `src/cli/wsd.test.ts` spawns the bundled
67+
CLI at `dist/cli/wsd.cjs`, and `examples/think-compare-runtimes`
68+
imports `@cloudflare/workspace/backends/container`, which exists only
69+
after the `workspace` package is built. Run `npm run build` across the
70+
workspace before `npm test` on a clean checkout.
71+
72+
**Real FUSE needs privilege.** `packages/wsd`'s `src/cli/wsd.test.ts`
73+
runs its real-FUSE case only when `/dev/fuse` is reachable; otherwise
74+
it resolves to the shim and skips. The guard is a bare existence check,
75+
so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the
76+
skip and the mount then fails with `EPERM`, turning a clean skip into a
77+
hard failure. Leave the device absent unless the container is
78+
privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). The
79+
`src/exec/runner.fuse.test.ts` suite is separate: it skips unless both
80+
Docker and the prebuilt `wsd` binary are available, and runs `wsd`
81+
inside a privileged container. See the
82+
[`debugging-wsd-fuse`](.agents/skills/debugging-wsd-fuse/SKILL.md) skill
83+
for the privileged Docker setup.
84+
3185
## Checks before you finish
3286

3387
Run from the repo root:

PR.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Every file and directory on the `wsd` FUSE mount reports zero disk usage. Writing twelve bytes and asking `du` for the size returns `0`:
2+
3+
```sh
4+
printf 'hello world\n' > /workspace/du-repro.txt
5+
stat -c 'size=%s blocks=%b' /workspace/du-repro.txt
6+
# size=12 blocks=0
7+
du -B1 /workspace/du-repro.txt
8+
# 0 /workspace/du-repro.txt
9+
```
10+
11+
This is not a `du` bug. `du` reads `st_blocks` from `stat(2)`, not `st_size`, and the FUSE driver was leaving `st_blocks` empty. The `getattr` path built its stat result without the `blocks` and `blksize` fields, so the kernel saw zero allocated blocks for every inode on the mount.
12+
13+
The fix populates both fields wherever the driver builds a stat. `st_blocks` counts allocation in fixed 512-byte units, the unit POSIX defines for that field regardless of the filesystem's logical block size, so a 513-byte file occupies two blocks and an empty file occupies none. `st_blksize` is the preferred input/output size, a separate value that stays at `4096` to match what `statfs` already advertises and what the backing virtual filesystem reports. The backing filesystem already supplies both fields for files written to disk, so the driver passes those through and only derives the values for the in-memory cases: a freshly created file before its first flush, and a file whose buffered size has outrun the size on disk.
14+
15+
Reviewers with a privileged FUSE-capable container can verify the behavior against a real mount:
16+
17+
```sh
18+
printf 'hello world\n' > /workspace/du-repro.txt
19+
stat -c 'size=%s blocks=%b' /workspace/du-repro.txt
20+
# size=12 blocks=1
21+
du -B1 /workspace/du-repro.txt
22+
# 512 /workspace/du-repro.txt
23+
```
24+
25+
The regression tests cover the same block accounting without requiring a mount. They assert block counts for a 513-byte file, an empty file, a freshly created file that has not flushed, and a file whose buffered size has grown past a block boundary before flush. These tests fail against the old stat shape because `blocks` and `blksize` are missing, and pass with this change.
26+
27+
This also updates the setup documentation around running the tests from a clean container. `AGENTS.md` now calls out the native build tools `fuse-native` needs, the Linux arm64 libfuse swap needed when the package's bundled x64 library cannot link, the need to build sibling package output before running tests, and the different gates used by the two real-FUSE test suites. The `packages/wsd` README no longer claims that its test script builds first or uses Node's type stripping; it describes the Vitest command, the required build output, and the difference between the `/dev/fuse`-guarded CLI test and the Docker-backed real-FUSE runner test.

packages/wsd/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,17 @@ Caveats. The shim is dev-only:
135135

136136
## Tests
137137

138-
Tests live next to the source files and are written in TypeScript. The package test script builds first, then runs Node's experimental TypeScript stripping:
138+
Tests live next to the source files and are written in TypeScript. Vitest runs them directly:
139139

140140
```sh
141141
npm test --workspace=@cloudflare/workspace-wsd
142142
```
143143

144-
This package requires Node.js 22+ because `@platformatic/vfs` does, and because the test script uses `--experimental-strip-types`, which is only available on Node 22+ (unflagged on 23.6+).
144+
The test command does not build first. Some suites need build output that is not there in a clean checkout: the tests import the sibling `@cloudflare/dofs` and `@cloudflare/workspace-rpc` packages from their `dist/` directories, and `src/cli/wsd.test.ts` spawns the bundled CLI at `dist/cli/wsd.cjs`. Run `npm run build` across the workspace before `npm test`, or those tests fail to resolve the imports or exit early with no bundle to spawn.
145+
146+
This package requires Node.js 22+ because `@platformatic/vfs` does.
147+
148+
The two real-FUSE suites gate themselves differently. `src/cli/wsd.test.ts` runs its real-FUSE case only when `/dev/fuse` is reachable; otherwise auto-detection resolves to the shim and the case skips. The guard is a bare existence check, so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the skip and the mount then fails with `EPERM` — leave the device absent unless the container is privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). `src/exec/runner.fuse.test.ts` is separate: it skips unless both Docker and the prebuilt `wsd` binary are available, and runs `wsd` inside a privileged container, so the host's `/dev/fuse` does not matter. See the [`debugging-wsd-fuse`](../../.agents/skills/debugging-wsd-fuse/SKILL.md) skill for the privileged Docker setup.
145149

146150
## Standalone release artifacts
147151

packages/wsd/src/fuse/driver.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,3 +906,96 @@ test("FUSE getattr on a pending-create file returns a stable mtime", async () =>
906906
(first.result as { mtime: Date }).mtime.getTime(),
907907
);
908908
});
909+
910+
test("FUSE getattr reports st_blocks so du sees non-zero usage", async () => {
911+
// Regression: getattr omitted `blocks`/`blksize`, so the kernel
912+
// reported st_blocks=0 for every inode and `du`, which reads
913+
// st_blocks rather than st_size, reported zero usage across the
914+
// whole mount. POSIX st_blocks counts 512-byte units; a 513-byte
915+
// file occupies two of them.
916+
const { vfs } = await createNodeVirtualFileSystem();
917+
const ops = makeFUSEOps(vfs);
918+
919+
const create = await callback((cb) => ops.create("/du.txt", 0o644, cb));
920+
expect(create.errno).toBe(0);
921+
922+
const payload = Buffer.alloc(513, 0x61);
923+
expect(
924+
await status((cb) =>
925+
ops.write("/du.txt", create.result as number, payload, payload.length, 0, cb),
926+
),
927+
).toBe(payload.length);
928+
expect(await status((cb) => ops.flush("/du.txt", create.result as number, cb))).toBe(0);
929+
expect(await status((cb) => ops.release("/du.txt", create.result as number, cb))).toBe(0);
930+
931+
const stat = await callback((cb) => ops.getattr("/du.txt", cb));
932+
expect(stat.errno).toBe(0);
933+
expect(stat.result).toMatchObject({ size: 513, blksize: 4096, blocks: 2 });
934+
});
935+
936+
test("FUSE getattr reports zero blocks for an empty file", async () => {
937+
const { vfs } = await createNodeVirtualFileSystem();
938+
const ops = makeFUSEOps(vfs);
939+
940+
const create = await callback((cb) => ops.create("/empty.txt", 0o644, cb));
941+
expect(create.errno).toBe(0);
942+
expect(await status((cb) => ops.flush("/empty.txt", create.result as number, cb))).toBe(0);
943+
expect(await status((cb) => ops.release("/empty.txt", create.result as number, cb))).toBe(0);
944+
945+
const stat = await callback((cb) => ops.getattr("/empty.txt", cb));
946+
expect(stat.errno).toBe(0);
947+
expect(stat.result).toMatchObject({ size: 0, blksize: 4096, blocks: 0 });
948+
});
949+
950+
test("FUSE getattr on a pending-create file reports block metadata", async () => {
951+
// The pending-create window stats out of the in-memory buffer, not
952+
// the VFS. Its block accounting must match the buffered size so a
953+
// `du` before the first flush still sees the bytes.
954+
const { vfs } = await createNodeVirtualFileSystem();
955+
const ops = makeFUSEOps(vfs);
956+
957+
const create = await callback((cb) => ops.create("/pending.txt", 0o644, cb));
958+
expect(create.errno).toBe(0);
959+
960+
const payload = Buffer.alloc(1025, 0x62);
961+
expect(
962+
await status((cb) =>
963+
ops.write("/pending.txt", create.result as number, payload, payload.length, 0, cb),
964+
),
965+
).toBe(payload.length);
966+
967+
const stat = await callback((cb) => ops.getattr("/pending.txt", cb));
968+
expect(stat.errno).toBe(0);
969+
expect(stat.result).toMatchObject({ size: 1025, blksize: 4096, blocks: 3 });
970+
});
971+
972+
test("FUSE getattr block count tracks buffered size before flush", async () => {
973+
// With buffered writes the VFS inode still holds the old size while
974+
// the FileEntry carries the fresh bytes. getattr overrides size
975+
// with the buffered value; blocks must follow so a `du` before the
976+
// spill matches the size the kernel sees.
977+
const { vfs } = await createNodeVirtualFileSystem();
978+
const ops = makeFUSEOps(vfs);
979+
980+
const create = await callback((cb) => ops.create("/buf-blocks.txt", 0o644, cb));
981+
expect(create.errno).toBe(0);
982+
const seed = Buffer.from("seed");
983+
expect(
984+
await status((cb) =>
985+
ops.write("/buf-blocks.txt", create.result as number, seed, seed.length, 0, cb),
986+
),
987+
).toBe(seed.length);
988+
expect(await status((cb) => ops.flush("/buf-blocks.txt", create.result as number, cb))).toBe(0);
989+
990+
// Grow the buffer past a block boundary without flushing.
991+
const grow = Buffer.alloc(2000, 0x63);
992+
expect(
993+
await status((cb) =>
994+
ops.write("/buf-blocks.txt", create.result as number, grow, grow.length, 0, cb),
995+
),
996+
).toBe(grow.length);
997+
998+
const stat = await callback((cb) => ops.getattr("/buf-blocks.txt", cb));
999+
expect(stat.errno).toBe(0);
1000+
expect(stat.result).toMatchObject({ size: 2000, blksize: 4096, blocks: 4 });
1001+
});

packages/wsd/src/fuse/driver.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ const ERRNO = {
2424
// typical container memory limits.
2525
const MAX_FILE_BYTES = 256 * 1024 * 1024;
2626

27+
// POSIX st_blocks counts allocation in fixed 512-byte units regardless
28+
// of the filesystem's logical block size, so consumers like GNU `du`
29+
// (which reads st_blocks, not st_size) compute usage against this
30+
// constant. A getattr that omits blocks makes the kernel surface
31+
// st_blocks=0 and `du` reports zero usage for the whole mount.
32+
const STAT_BLOCK_SIZE = 512;
33+
34+
// st_blksize is the preferred I/O block size, not the st_blocks unit.
35+
// Match what statfs advertises (bsize: 4096) and what the backing VFS
36+
// reports so a fabricated stat stays consistent with a persisted one.
37+
const PREFERRED_IO_BLOCK_SIZE = 4096;
38+
39+
function blocksForSize(size: number): number {
40+
return size <= 0 ? 0 : Math.ceil(size / STAT_BLOCK_SIZE);
41+
}
42+
2743
type StatusCallback = (errnoOrBytes: number) => void;
2844
type ResultCallback<T> = (errno: number, result: T) => void;
2945
type NotImplementedOperation = (...args: unknown[]) => void;
@@ -97,6 +113,8 @@ export interface FuseStat {
97113
gid: number;
98114
nlink: number;
99115
ino: number;
116+
blksize: number;
117+
blocks: number;
100118
}
101119

102120
export interface FuseBufferStats {
@@ -308,6 +326,8 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
308326
gid: typeof process.getgid === "function" ? process.getgid() : 0,
309327
nlink: 1,
310328
ino: 0,
329+
blksize: PREFERRED_IO_BLOCK_SIZE,
330+
blocks: blocksForSize(entry.size),
311331
};
312332
};
313333
// Returns true on success, false if `needed` exceeds MAX_FILE_BYTES.
@@ -404,8 +424,14 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
404424
const entry = files.get(path);
405425
const stat =
406426
entry?.pendingCreate === true ? pendingStat(entry) : statNode(vfs.lstatSync(toVfs(path)));
407-
// File content lives outside the VFS, so prefer our size.
408-
if (entry !== undefined) stat.size = entry.size;
427+
// File content lives outside the VFS, so prefer our size. The
428+
// buffered size can outrun the persisted inode before a spill,
429+
// so recompute block accounting from it to keep st_blocks
430+
// coherent with the size the kernel sees.
431+
if (entry !== undefined) {
432+
stat.size = entry.size;
433+
stat.blocks = blocksForSize(entry.size);
434+
}
409435
const override = meta.get(path);
410436
if (override) {
411437
if (override.mode !== undefined) {
@@ -1021,6 +1047,8 @@ function statNode(stat: {
10211047
mode: number;
10221048
nlink?: number;
10231049
ino?: number;
1050+
blksize?: number;
1051+
blocks?: number;
10241052
isDirectory(): boolean;
10251053
}): FuseStat {
10261054
return {
@@ -1033,6 +1061,11 @@ function statNode(stat: {
10331061
gid: typeof process.getgid === "function" ? process.getgid() : 0,
10341062
nlink: stat.nlink ?? (stat.isDirectory() ? 2 : 1),
10351063
ino: stat.ino ?? 0,
1064+
// Prefer provider-supplied block accounting when the VFS exposes
1065+
// it; otherwise derive from size in 512-byte units so `du` and
1066+
// other st_blocks consumers see real usage.
1067+
blksize: stat.blksize ?? PREFERRED_IO_BLOCK_SIZE,
1068+
blocks: stat.blocks ?? blocksForSize(stat.size),
10361069
};
10371070
}
10381071

0 commit comments

Comments
 (0)