Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
99babd6
computerd: vendor fuse-napi and port the mount to libfuse 3
aron-cf Oct 1, 2026
758685a
computerd: update the mount option and driver tests for libfuse 3
aron-cf Oct 2, 2026
094f239
computerd: Keep oxlint and oxfmt out of the vendored fuse-napi tree
aron-cf Oct 14, 2026
b15e32e
computerd, computer: Build the binary against the system libfuse 3
aron-cf Oct 5, 2026
6fba45b
computerd: Make the libfuse 3 mount work with fuse-napi
aron-cf Oct 5, 2026
c5c6ba3
computerd: Hand local-only files to the kernel with FUSE passthrough
aron-cf Oct 5, 2026
8e148a7
computerd: Report live passthrough state on /__computerd/info
aron-cf Oct 5, 2026
6aa78c0
computerd: Test FUSE passthrough against a real mount
aron-cf Oct 5, 2026
0a68156
ci: Install libfuse 3 headers and drop libfuse 2
aron-cf Oct 5, 2026
b6f7887
chore: Move the FUSE images to libfuse 3
aron-cf Oct 5, 2026
016ddba
computerd: Benchmark local-only paths natively on arm64
aron-cf Oct 5, 2026
543da34
computerd: Document libfuse 3 and local-only passthrough
aron-cf Oct 5, 2026
fa693f8
computerd: cleanup code comments
aron-cf Oct 5, 2026
d48158d
computer: Let the computerd harness pick the platform and image
aron-cf Oct 22, 2026
51d2915
computerd: Bind-mount local-only directories named in MOUNT_NATIVE
aron-cf Oct 22, 2026
ac17125
computerd, docs: Benchmark native roots
aron-cf Oct 22, 2026
fb6b789
Merge main into computerd-passthrough
aron-cf Oct 22, 2026
a301073
docs, examples: Date libfuse 2 to 0.6.0 and earlier
aron-cf Oct 22, 2026
68414d1
computerd: Mount every local-only directory natively
aron-cf Oct 22, 2026
acf7700
computerd: Let commands remove and rename native roots
aron-cf Oct 22, 2026
f01e220
computerd: Test root removal through the guard under real FUSE
aron-cf Oct 22, 2026
c6ed27d
computerd: Reproduce restore-time bind escape
aron-cf Oct 23, 2026
c40b7aa
computerd: WIP bind native roots with pinned fds
aron-cf Oct 23, 2026
4f379a1
Merge origin/main into computerd-passthrough
aron-cf Oct 23, 2026
eef8037
computerd: Condense FUSE performance changeset
aron-cf Oct 23, 2026
49bef86
computerd, docs: Remove obsolete FUSE guidance
aron-cf Oct 23, 2026
7fc10f4
computerd: Simplify native-root guard handoff
aron-cf Oct 23, 2026
377e1ca
docs: Clarify native-root mount eligibility
aron-cf Oct 23, 2026
bf09921
computerd: Expect removal guard fields in FUSE info
aron-cf Oct 23, 2026
d562de8
computerd: Wait for pending native binds before detach
aron-cf Oct 23, 2026
de6822e
Merge origin/main into computerd-passthrough
aron-cf Oct 23, 2026
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
121 changes: 84 additions & 37 deletions .agents/skills/debugging-computerd-fuse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ capnweb endpoints from a plain Node script.
## When to reach for this

- A user report of "wrote a file via FUSE, RPC reads see 0 bytes."
- A user report of "exec hangs forever" or "computerd `/health` stops
- A report of "exec hangs forever" or "computerd `/health` stops
responding after the first shell call."
- Adding a new computerd op (FUSE callback, sync RPC, runner feature)
and you want a smoke test that doesn't depend on Docker-in-DO or
Expand Down Expand Up @@ -50,9 +50,12 @@ capnweb endpoints from a plain Node script.

There's already a recipe — `packages/computer/test-harness/run-computerd.sh`.
It picks a host port, runs the binary with `--privileged
--device /dev/fuse --cap-add SYS_ADMIN --cap-add MKNOD`, installs
fuse3 + libfuse2t64 from apt, waits for `/health`, prints the URL
on stdout and the container id on stderr.
--device /dev/fuse --cap-add SYS_ADMIN --cap-add MKNOD` on Debian
trixie with fuse3 from apt (computerd links the system libfuse 3 and
needs 3.17 or newer), waits for `/health`, prints the URL on stdout
and the container id on stderr. It forwards `MOUNT_IGNORE`,
`MOUNT_IGNORE_PATH`, and any `COMPUTERD_FUSE_*` variable from your
environment into the container.

```bash
# Start
Expand All @@ -63,7 +66,7 @@ COMPUTERD_HARNESS_PORT=18080 bash packages/computer/test-harness/run-computerd.s
# Verify
curl -sf http://127.0.0.1:18080/health
docker logs <CID> | tail
# should see: computerd listening on 0.0.0.0:8080 mount=/workspace backend=linux
# should see: computerd listening on 0.0.0.0:8080 mount=/workspace backend=fuse

# Tear down
docker kill <CID>
Expand Down Expand Up @@ -165,7 +168,7 @@ them. Check that:
- Your absolute paths start with `MOUNT_POINT` (default
`/workspace`). Writing to `/probe.txt` succeeds at the RPC
layer but FUSE only serves the `MOUNT_POINT` subtree.
- computerd actually logs `backend=linux` (not `backend=shim`).
- computerd actually logs `backend=fuse` (not `backend=shim`).
- You aren't reading the wrong container — `docker ps` after a
zombie `docker rm -f` can show stale entries.

Expand All @@ -179,19 +182,16 @@ running container:
docker exec <CID> sh -c 'echo container-write > /workspace/r2/hello.txt'
```

This is a real FUSE write: kernel → fuse-native → computerd's
`writeBuf` op → computerd's in-memory buffer for that file → on
This is a real FUSE write: kernel → fuse-napi → computerd's
`write` op → computerd's in-memory buffer for that file → on
`release`/`flush`/`fsync` the buffer spills into the backing VFS
(commit `68407fc`). On the host side you can then `pullOnce` and
see the new entry land in `applyChanges`.

**Avoid `client.shell.exec()` while debugging FUSE bugs in this
environment.** It calls `spawn` with `cwd=MOUNT_POINT` by default,
which can deadlock under real FUSE (see "Known deadlock patterns"
below). If you need exec-shaped behaviour from outside the
container, `docker exec` is closer to what wrangler's container
runtime would do anyway — computerd isn't the parent of the spawned
process.
Use `docker exec` when you need to isolate the FUSE mount from
computerd's command runner: computerd is not the parent of that
process. `client.shell.exec()` also works on current binaries; it
exercises the command runner and its removal guard as well.

## Known deadlock patterns

Expand All @@ -215,11 +215,11 @@ buffer-vs-VFS desync.

### computerd's event loop blocked in spawn

Symptom: `client.shell.exec` against a real-FUSE computerd hangs
forever. `/health` stops responding. `docker exec <CID> ls
/workspace` also hangs once the deadlock fires.
Historical failure: an older `Runner.exec` implementation made
`client.shell.exec` hang under real FUSE. `/health` stopped responding;
`docker exec <CID> ls /workspace` then hung too.

Mechanism: `Runner.exec` calls
Mechanism: it called
`spawn("/bin/sh", { cwd: "/workspace" })`. libuv's `uv_spawn`
forks and waits on a status pipe for the child to exec. The
child, between fork and exec, does `chdir("/workspace")`. The
Expand All @@ -230,23 +230,19 @@ read. Deadlock.
Diagnostic:

```js
// against a fresh computerd
const t = Date.now();
// Against a fresh computerd, both calls should return.
await client.shell.exec({ command: "echo hi", cwd: "/tmp", timeoutMs: 5000 });
// returns in <100ms — control case
await client.shell.exec({ command: "echo hi", cwd: "/workspace", timeoutMs: 5000 });
// hangs forever — under real FUSE
```

Same computerd, same client, same network — only `cwd` differs. If
that flips success→hang, you've reproduced the deadlock.
the second hangs, the old deadlock has regressed.

Fix: do the chdir inside the **shell**, not inside `spawn`'s
fork-and-exec dance. Prefix the command with `cd ... && exec ...`
so the chdir runs after the shell is up and computerd's event loop is
responsive again. Pre-flight existence via dofs's `stat(db, path)`
(reads SQLite directly, no FUSE callbacks) to preserve the
existing ENOENT-cwd error contract.
The runner now changes directory inside the **shell**, not in
`spawn`'s fork-and-exec path. It prefixes the command with `cd ... &&`
so FUSE lookups run after the shell starts and computerd's event loop is
responsive. It checks cwd existence through the VFS before spawning to
preserve the ENOENT error contract.

### Other event-loop blockers to suspect

Expand All @@ -261,9 +257,62 @@ goes through Node's `fs` against the mount point must be async

## Recipe library

### Is passthrough engaged?

With `MOUNT_IGNORE` set, an open of a local-only file should hand the
file to the kernel and keep computerd out of the data path. Two ways to
check, from inside the container:

```bash
# What the mount negotiated, and how opens were served.
curl -s localhost:8080/__computerd/info | jq .ignore.fastPaths

# Whether data still reaches the daemon. Boot with
# COMPUTERD_FUSE_TRACE=summary and COMPUTERD_FUSE_TRACE_FILE=/tmp/trace.json,
# do the I/O, then dump the counters. read and write should not move
# for local-only files.
kill -USR2 1 && jq '.ops[] | select(.op == "read" or .op == "write")' /tmp/trace.json
```

Keep the trace file outside the mount: a process serving a FUSE mount
deadlocks if it writes into that mount. `passthroughReason` names the
likely cause when passthrough is off, and `COMPUTERD_FUSE_PASSTHROUGH=0`
turns it off on purpose, to rule it in or out of a bug.

### Check native roots

With `MOUNT_IGNORE` configured, eligible local-only directories can be
mounted from the backing directory onto `/workspace`. Check the live
state and the kernel's mount table separately:

```bash
curl -s localhost:8080/__computerd/info | jq .ignore.fastPaths.nativeRoots
docker exec <CID> sh -c "grep ' /workspace/' /proc/self/mountinfo"
```

The info response shows `active`, `failures`, and `reason`; stats also
include `lastFailure`. A refused bind remains available through FUSE.
A symlink in either source or target path is refused by the native
helper, not followed by a shell mount command. Disable native roots
with `COMPUTERD_FUSE_NATIVE_ROOTS=0` when isolating a problem.

To remove a root before `rm -rf`, call the daemon's detach endpoint:

```bash
curl -X POST localhost:8080/__computerd/native-roots/detach \
-H 'content-type: application/json' \
-d '{"path":"node_modules"}'
```

If `RPC_CLIENT_SECRET` is set, include `Authorization: Bearer <secret>`.
Do not use a pathname-based `umount` on a workspace path that might be a
symlink. On shutdown and before mounting FUSE at startup, computerd
lazily detaches everything it finds below the workspace mount; do not
put unrelated mounts there.

### Reset between probes

A wedged computerd container can leave fuse-native zombie processes
A wedged computerd container can leave FUSE zombie processes
that `docker kill` refuses to reap. The cleanest fix:

```bash
Expand Down Expand Up @@ -341,11 +390,9 @@ writes to that subtree will propagate back via the next

### Reference: a full M3.5-style probe

A working end-to-end probe that stages the mount, pushes,
container-writes via `docker exec`, pulls, and asserts the
expected applied/skipped split lives at
`script/computerd-mount-probe.mjs` if it's still in the tree. Use it as
the template for new probes.
For the FUSE write/pull path, use `script/computerd-fuse-flush.mjs` as
the starting point for a probe that stages a mount and checks the
applied/skipped split.

## What this skill is NOT

Expand All @@ -369,7 +416,7 @@ your host) so the inspector port is reachable, or `docker run
through computerd's launch env.

If even that doesn't surface it, the wedge is probably below the
JS layer — in fuse-native or libuv. At that point reach for
JS layer — in the fuse-napi binding or libuv. At that point reach for
`strace -fp <computerd-pid>` inside the container; the FUSE callbacks
all surface as `read(/dev/fuse, ...)` and the per-call timing
will show you exactly which op deadlocks.
5 changes: 5 additions & 0 deletions .changeset/libfuse3-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computerd": minor
---

Improve local-only filesystem performance with FUSE passthrough and native roots; see the [performance docs](https://github.com/cloudflare/computer/blob/main/docs/19_performance.md).
8 changes: 8 additions & 0 deletions .github/actions/install/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ runs:
registry-url: ${{ inputs.registry-url }}
cache: npm

# npm ci compiles computerd's vendored FUSE addon against these.
- name: Install the libfuse 3 headers
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libfuse3-dev

- name: Install dependencies
shell: bash
run: npm ci
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,9 @@ jobs:
path: packages/computerd
needs-siblings: true
build-computerd-bin: true
# libfuse2 (libfuse.so.2) is what fuse-native binds to at
# runtime; fuse3 ships /dev/fuse helpers and the
# FUSE_MOUNT=shim tests need it for the userspace mount path.
# xz-utils extracts the target Node binary used by build:bin.
system-deps: "libfuse2t64 fuse3 xz-utils"
# fuse3 provides fusermount3 for the real-FUSE tests; xz-utils
# unpacks the Node binary build:bin injects into.
system-deps: "fuse3 xz-utils"
steps:
- uses: actions/checkout@v6
with:
Expand Down
6 changes: 1 addition & 5 deletions .github/workflows/next-computerd-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,6 @@ jobs:

- uses: ./.github/actions/install

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libfuse2t64 fuse3

# build-bin generates the SEA blob with the active Node executable and
# injects it into the pinned Node 22.23.4 target binary.
- name: Use the target Node version for the binary build
Expand All @@ -72,6 +67,7 @@ jobs:
- name: Build all workspaces
run: npm run build --workspaces --if-present

# Compiles the FUSE addon in a container, so no FUSE packages here.
- name: Build the computerd binary
run: npm run build:bin --workspace @cloudflare/computerd

Expand Down
8 changes: 3 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,12 @@ jobs:
registry-url: "https://registry.npmjs.org"

# @cloudflare/computer's prepublishOnly runs `npm run build && npm
# test`; its tests spawn the computerd binary, which dynamically links
# libfuse2 and uses fuse3 helpers for the FUSE_MOUNT=shim path. Without
# these the suite fails with `libfuse.so.2: cannot open shared object
# file`. Mirrors ci.yml.
# test`. fuse3 provides fusermount3 for the suites that mount.
# Mirrors ci.yml.
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libfuse2t64 fuse3
sudo apt-get install -y --no-install-recommends fuse3

# Build every workspace's dist/ up front. changesets doesn't run the
# build itself (changesets/changesets#860), and package builds/tests
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ worker-configuration.d.ts
# Local celld example state.
examples/celld/.celld-s3*/
examples/celld/.celld-state*/

# Vendored native addon: build output and its install tree
packages/computerd/vendor/*/build/
packages/computerd/vendor/*/node_modules/
packages/computerd/vendor/*/prebuilds/
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
],
"ignorePatterns": [
"**/node_modules",
"**/vendor",
"**/node_modules.host-backup.*",
"**/dist",
"/artifacts",
Expand Down
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"ignorePatterns": [
"**/node_modules",
"**/vendor",
"**/node_modules.host-backup.*",
"**/dist",
"/artifacts",
Expand Down
44 changes: 24 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,32 +34,35 @@ the file directly when the trigger applies:
A fresh container does not have everything the tests need. The traps
below cost real time if you discover them one failure at a time.

**Native build tools.** `packages/computerd` depends on `fuse-native`, a
native addon. Building it needs a C toolchain and the libfuse2 headers.
On Debian or Ubuntu:
**Native build tools.** `packages/computerd` depends on a vendored copy
of `fuse-napi` at `packages/computerd/vendor/fuse-napi`, a native addon
compiled from source on install. Building it needs a C toolchain and the
libfuse 3 headers. On Debian or Ubuntu:

```bash
apt-get install build-essential libfuse-dev
apt-get install build-essential pkg-config libfuse3-dev fuse3
```

If the `fuse-native` build fails, `npm install` aborts the whole
install, not just that one package. When you only need the rest of the
workspace, install with `npm install --ignore-scripts` to skip the
native build.
Any libfuse 3 compiles. Kernel passthrough for local-only paths needs
3.17 or newer (Debian trixie); against an older one the addon builds
without it and those paths are served by `computerd` instead.

**arm64 hosts.** `fuse-native` ships a prebuilt libfuse for x64 only.
On a Linux arm64 host or container (including a Linux container on
Apple Silicon, or arm64 CI) the link fails with `file in wrong
format`. The path below is Debian or Ubuntu arm64; a native macOS host
uses macFUSE instead and does not hit this. Replace the bundled library
with the system one and rebuild:
If the addon build fails, `npm install` aborts the whole install, not
just that one package. When you only need the rest of the workspace,
install with `npm install --ignore-scripts`. That also skips the root
`postinstall`, so run `npm run build:types` before building the
examples, and build the addon yourself when you need it:

```bash
cp /usr/lib/aarch64-linux-gnu/libfuse.so.2 \
node_modules/fuse-shared-library-linux/libfuse/lib/libfuse.so
cd node_modules/fuse-native && npx node-gyp rebuild
cd packages/computerd/vendor/fuse-napi && npx node-gyp rebuild
```

**The binary build uses Docker.** `npm run build:bin` compiles the addon
for linux-x64 inside a `node:22-trixie-slim` container, so it works the
same from an arm64 machine (through emulation, which is slow the first
time; the result is cached under `packages/computerd/dist/sea`). Set
`COMPUTERD_FUSE_ADDON` to a prebuilt `fuse.node` to skip that step.

**Build before you test.** The test scripts don't build the sibling
packages first. Several suites need build output that is absent in
a clean checkout: `packages/computerd` imports the sibling `@cloudflare/dofs`
Expand All @@ -77,9 +80,10 @@ so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the
skip and the mount then fails with `EPERM`, turning a clean skip into a
hard failure. Leave the device absent unless the container is
privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). The
`src/exec/runner.fuse.test.ts` suite is separate: it skips unless both
Docker and the prebuilt `computerd` binary are available, and runs `computerd`
inside a privileged container. See the
`src/exec/runner.fuse.test.ts` and `src/fuse/passthrough.fuse.test.ts`
suites are separate: they skip unless both Docker and the prebuilt
`computerd` binary are available, and run `computerd` inside a privileged
container. See the
[`debugging-computerd-fuse`](.agents/skills/debugging-computerd-fuse/SKILL.md) skill
for the privileged Docker setup.

Expand Down
Loading
Loading