Repository navigation
Conversation
馃 Changeset detectedLatest commit: de6822e The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| let entry = backingIds.get(key); | ||
| if (entry === undefined) { | ||
| const id = registerBackingFile(registrar, handle); | ||
| if (id === undefined) return fh; | ||
| entry = { id, refs: 0 }; | ||
| backingIds.set(key, entry); | ||
| } | ||
| entry.refs += 1; | ||
| handle.backingKey = key; | ||
| passthroughOpens += 1; | ||
| return { fd: fh, backingId: entry.id }; |
There was a problem hiding this comment.
馃敶 Shared backing file breaks mixed-mode opens
When two local-only handles use different flags, registerOpen shares the first handle's backing ID. The kernel then uses that backing file for both handles, so a writable handle opened after a read-only one cannot write.
Learn more
A backing ID refers to the descriptor passed to backingOpen, including its access and append flags. backingIds is keyed only by device and inode, so later opens reuse the first descriptor's ID even when their requested access differs. The open handler preserves each open's flags on its own descriptor, but the kernel's passthrough data operations use the shared backing ID instead. The existing concurrent-open tests use compatible modes and do not catch this mismatch.
Example: Open node_modules/data for reading, keep it open, then open it for writing. The second open succeeds but reuses the read-only backing ID; writing through that handle fails instead of modifying the file.
Recommended fix: Ensure backing registrations used by an inode support every open's requested access and append semantics. If a compatible backing ID cannot be shared safely, fall back to daemon-served I/O for the incompatible handle; exercise read-only/write-only and append/non-append simultaneous opens under real FUSE.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
| const previous = cacheMarks.get(path); | ||
| cacheMarks.set(path, mark); | ||
| return previous === mark; |
There was a problem hiding this comment.
馃敶 Renamed files can retain stale cached bytes
After rename replaces a synced file, resolveKeepCache can reuse the destination's old mark. When both files share size and mtime, reopening the destination retains the displaced file's cached bytes.
Learn more
cacheMarks records only the previous mtime and size for each pathname. rename replaces a destination without clearing its mark, and unlink also leaves a mark behind. The kernel's page cache can still contain pages from the old inode at that pathname, while the next open sets keepCache if the replacement happens to have the same metadata. A changed inode must force cache invalidation even when size and mtime match.
Example: Two synced files /workspace/a and /workspace/b both have size 4 and mtime 1000, but contain new! and old!. After /workspace/b is read and /workspace/a is renamed onto it, reopening /workspace/b can return old!.
Recommended fix: Track inode identity as part of the mark and invalidate marks on rename and unlink, including directory subtree renames. Add a real-FUSE regression with equal-size/equal-mtime replacement after warming the destination's page cache.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
commit: |
Replaces fuse-native (libfuse 2.9) with a vendored, patched fuse-napi 2.3.1 (MIT) built against libfuse 3, which is what can negotiate FOPEN_PASSTHROUGH. Backward compatibility with the 2.9 binding is not kept; there is one mount backend. Binding (vendor/fuse-napi, changes in PATCHES.md): - backingOpen(fd) / backingClose(id), the ioctl libfuse only exposes through its low-level API - backingId on the open/create result, written to info->backing_id - maxBackingStackDepth init config and Fuse.CAP_PASSTHROUGH - a backing id combined with keepCache or directIO is refused, because the kernel rejects that pairing with an opaque EIO from open() Mount options (options.ts) split in two, because libfuse 3 moved some of these into the init config: - buildFuseInitConfig() carries max_write, now an init field - big_writes is gone; libfuse 3 removed it and batching is the default - auto_cache and kernel_cache are gone, and are refused in EXTRA_OPTS Driver: - the _fuseOptions() monkey patch is deleted; options go through the constructor and the init callback - keepCache is decided per open for VFS-backed files, applying auto_cache's own rule (reuse when mtime and size are unchanged since the last open) because libfuse sets keep_cache after the open callback returns and does not check for passthrough - a created or recreated path never inherits a previous cache mark Verified on kernel 6.18 with libfuse 3.17.2: reads and writes through a passthrough handle never reached the JavaScript handlers, including two concurrent opens sharing one refcounted backing id.
Follows the libfuse 3 port. The option string no longer carries big_writes (removed upstream), max_write (an init config field now), or auto_cache / kernel_cache (incompatible with passthrough, and the keep-cache decision moved into the driver). - options.test.ts covers the new default profile, max_write arriving through buildFuseInitConfig, the backing stack depth request, and that a typo in EXTRA_OPTS naming a removed option is stripped rather than failing the mount - driver.test.ts reads open/create handles through a helper, since those now reply with a file-info object, and pins that a freshly created file never reuses the page cache
`build:bin` still read the fuse-native prebuild and its bundled libfuse.so.2, both gone since the move to the vendored fuse-napi binding, so it failed before producing anything. The addon is now compiled from the vendored source inside a node:22-trixie-slim container for linux/amd64. A container rather than the build host because the CI runners carry libfuse 3.14, older than the 3.17 that passthrough needs, and a developer machine may not be x64. The result is cached under dist/sea and keyed on the addon sources, since an emulated compile takes minutes. COMPUTERD_FUSE_ADDON points at a prebuilt fuse.node to skip the container entirely. The binary no longer embeds a copy of libfuse or prepends LD_LIBRARY_PATH. The addon links the system libfuse 3, so the image that runs it needs Debian trixie's fuse3 package or an equivalent; a missing library now fails with a message that says so. The macOS target is gone, since the vendored binding is only wired for Linux. The container is run detached and then waited on. An attached `docker run` against a remote daemon can lose its stream and return early, which looked like a build that produced no addon. The test harness image moves to debian:trixie-slim with fuse3 only, so the harness and the runner's real-FUSE test boot the new binary.
fuse-napi takes typed mount options, not a raw -o list. buildFuseOptionString becomes buildFuseMountOptions. COMPUTERD_FUSE_EXTRA_OPTS keeps the libfuse spelling operators know and is translated entry by entry. ac_attr_timeout is gone: it only tunes auto_cache, and the binding refuses it without auto_cache. A new test runs the profile through the binding's validator. The binding exposed neither use_ino nor negative_timeout, so the vendored copy gains both. use_ino is a fuse_config field in FUSE 3 that fuse_new() rejects on the command line. PATCHES.md records the change. toBindingOps adapts the op table. It drops `error`, `init` (mutually exclusive with initWithConfig), and getBufferStats, all of which the binding refuses. It also swaps utimens for utimensWithTimespec. Plain utimens answers EOPNOTSUPP for UTIME_NOW and UTIME_OMIT, which is what a bare `touch` sends. Omitted times are now read back and kept so `touch -a` and `touch -m` work. fsync and fsyncdir take (path, datasync, fd) in fuse-napi. With the old order the boolean landed where the handle should be, and fsync on a local-only file leaked into the VFS. listxattr must return an array of names rather than a buffer. libfuse 3 negotiates atomic O_TRUNC by default, so `: > f` arrives as open(O_TRUNC) with no separate truncate. open now honors the flag; the old bytes survived before.
An open or create of a local-only file now registers the file with the kernel and replies with its backing id. The kernel then serves reads, writes, and mmap for that handle straight from the backing file, and computerd is out of the data path. Writing 8 MiB and reading it back twice under an ignored path cost the daemon 16 writes and 64 reads before; it now costs none. The kernel allows one backing file per inode, and a second registration of the same file does not fail. It returns a new id, and the open that uses it fails with EIO. So the local-only layer keeps one id per file, keyed on dev:ino so a hard link shares it, counts the opens using it, and closes it on the last release. If registration fails, the file is served by computerd as before and the first failure is logged with a hint for the usual causes: EPERM without CAP_SYS_ADMIN, ELOOP on a stacked backing store, and ENOTSUP when the kernel did not offer passthrough. Fallbacks are counted rather than logged after that. A passthrough reply never carries keepCache or directIO, which the kernel refuses alongside a backing id. mountFuse passes the layer a late-bound registrar. The op table has to exist before the Fuse instance, so the closure reads the instance assigned after construction and answers ENOTSUP until init confirms the capability. The capability is only requested when there are local-only paths, so a mount without MOUNT_IGNORE negotiates what it did before, and COMPUTERD_FUSE_PASSTHROUGH=0 turns it off for debugging.
`fastPaths.passthrough` was a constant false with a reason naming libfuse 2.9. It now reports what the mount is doing, read on each request: `passthrough` is true once the kernel is serving local-only file data, and `passthroughOpens` and `passthroughFallbacks` count how opens were served. When passthrough is not active, `passthroughReason` says which of the cases applies, because each has a different fix: no local-only paths configured, no kernel FUSE mount running, turned off with COMPUTERD_FUSE_PASSTHROUGH, a kernel that never offered the capability, a kernel that offered it but refused every registration, and a mount that negotiated it but has not opened a local-only file yet. mountFuse exposes getPassthroughStatus on FuseMount for this, and the info object reads it through a getter. PASSTHROUGH_UNAVAILABLE_REASON is gone, along with the comments that still described the fuse-native behavior.
The unit tests drive the backing-id table through a fake registrar so they cannot show the kernel's side. passthrough.fuse.test.ts boots the binary in a privileged container, as runner.fuse.test.ts does, and counts the daemon's read and write callbacks with the op tracer. It checks three things. Writing 4 MiB under an ignored path and reading it back twice costs the daemon no reads or writes, and /__computerd/info reports passthrough active. Concurrent opens of one file, and of a hard link to it, all succeed; a second backing id for one inode would make the kernel fail the open with EIO. And a synced file still keeps its page cache across opens now that auto_cache is gone from the mount options. The passthrough cases skip on a kernel that does not offer the capability, since that is a property of the host. run-computerd.sh now forwards MOUNT_IGNORE, MOUNT_IGNORE_PATH, and COMPUTERD_FUSE_* into the container so a test can set them.
computerd now links the system libfuse 3 and needs 3.17 or newer, which means Debian trixie. The scripts and harnesses that run the freshly built binary drop libfuse2t64 and keep fuse3, and the soak harness image is renamed to match. The examples pin the published 0.4.1 image, which links libfuse 2, and the Version Packages pull request moves those pins to the new release in one step. Until then each example keeps libfuse2t64 next to fuse3, with a comment saying when it can go. The egress and mcp examples move from bookworm, whose libfuse 3.14 is too old for the new binary, to trixie. Both images were checked with the 0.4.1 binary and with a fresh build mounted over it. AGENTS.md describes the vendored addon, the libfuse 3 headers it builds against, the Docker step in build:bin, and the build:types step that --ignore-scripts skips, in place of the fuse-native and arm64 notes. The FUSE debugging skill gains a recipe for checking that passthrough is engaged.
The benchmarks ran the x64 binary, which on an arm64 machine means emulation and timings that say nothing about the mount. build:bin now takes COMPUTERD_BIN_TARGETS, so `linux-arm64` builds a native binary alongside or instead of the published linux-x64 one. Building one target no longer deletes another's binary. The npm bench gains a TARGETS list, a `large` scenario (TypeScript, Vite, webpack, ESLint, React; about 5,400 files and 88 MB), and a timed read of every installed file after each install. An install is mostly metadata, while the read-back is the data path that passthrough changes. IGNORE_NODE_MODULES=1 makes each run's node_modules local-only while its package.json stays synced, which is how MOUNT_IGNORE is meant to be used, and PLATFORM picks the Docker platform. run-fs-bench.sh gains LOCAL_ONLY=1, which runs the whole bench under a local-only directory. Both harnesses forward COMPUTERD_FUSE_PASSTHROUGH, so the same run can be repeated with passthrough off.
The docs described a binary that embedded libfuse 2.9 and a passthrough that could not exist. They now describe the libfuse 3 build: the runtime needs libfuse 3.17 or newer, which is Debian trixie, and no longer needs libfuse2t64 from the release after 0.4.1; the snippets that still pin 0.4.1 say so. No macOS binary is published. The local-only section of the computerd README replaces the libfuse 2.9 fastPaths example with the live block, and a table of every passthroughReason with its fix. docs/19_performance.md drops the "track the synced mount" guidance for local-only paths and gains fs-bench and npm install numbers for the synced mount, local-only paths with and without passthrough, and raw disk. Large-file I/O under a local-only path runs at 0.9 to 1.3 times raw disk, against 3 to 10 times on the synced mount, while small-file metadata still goes through computerd. The numbers come from a local arm64 virtual machine, and the page says so and how to reproduce them. The Cloudflare Containers cells stay empty until that install is run there. The computerd README also covers the runtime and build requirements, COMPUTERD_FUSE_PASSTHROUGH, the passthrough test suite, and the arm64 build target. bench-results.md marks its auto_cache section as a record of the libfuse 2.9 build. A changeset covers the libfuse 3 requirement, the dropped macOS binary, passthrough, and the three behavior changes on the synced mount.
a56651e to
fa693f8
Compare
The harness always ran a linux/amd64 container, so an arm64 binary could not be tested with it and an x64 one ran under emulation on an arm64 machine. COMPUTERD_HARNESS_PLATFORM now picks the platform, and the cached image is tagged per platform so the two do not overwrite each other. COMPUTERD_HARNESS_IMAGE boots a prepared image instead of the stock one, for instance one committed from a running container to stand in for a snapshot restore. MOUNT_NATIVE and COMPUTERD_DB are forwarded into the container along with the variables already passed through.
FUSE passthrough sends a local-only file's data straight to the kernel, but every lookup, stat, create, and listing under node_modules still reaches computerd's event loop, and for a dependency tree that is most of the work. MOUNT_NATIVE names a subset of the MOUNT_IGNORE patterns whose directories are bind-mounted onto the mount instead, so nothing under them reaches computerd at all. The source of each bind mount is the directory the local-only layer already serves the path from. That is what keeps the feature small: a mount only changes how fast a path is served, never what it contains, so a mount that lands late, fails, or is detached needs no handling beyond leaving the path to the FUSE layer. /proc/self/mountinfo is the only record of what is mounted, since anything under the mount point other than the FUSE mount itself was mounted here, and a lazy detach is always safe. There is no watcher. A native root's parent is synced, so the mkdir that creates one reaches the local-only layer, which reports it after replying; computerd then mounts it from a child process, because resolving a path under the mount from the event loop would wait on a request that same loop has to answer. A lookup that finds a root on disk but not mounted, as after a snapshot restore once the host has pushed the synced parent back, mounts it too, and startup mounts roots already present. A refused root is retried only when created again, and one detached on request is parked so a lookup cannot remount it under the rm that follows. Renames of a synced ancestor need nothing: the kernel moves the mount with the dentry. Native roots require negotiated passthrough, which shares their capability and kernel requirements and rules out a second page cache for files opened through FUSE before the mount landed. The one behavior change is that rmdir and rename of a root itself return EBUSY, as for a Docker volume, since the kernel never forwards them; POST /__computerd/native-roots/detach detaches roots on request. /__computerd/info reports the state under fastPaths.nativeRoots, and everything is detached before FUSE is unmounted on SIGTERM. MOUNT_IGNORE sets gain matches(), which tests a single path against the patterns rather than asking whether it lies under a match.
The fs and npm benches gain a NATIVE_ROOTS=1 knob, which lists the local-only path they already use in MOUNT_NATIVE as well, so a run measures work inside a bind-mounted native root. The fs bench prints the mount and the native-root counters at the end, so a run shows it measured what it meant to. The npm bench deletes each run's directory before the next, and a native root inside it is a mount point that rm cannot remove. It now detaches native roots through the daemon first when they are on, which is the same step a host has to take before removing one. docs/19_performance.md gains a native-roots section with fs-bench and npm install numbers beside the synced mount, local-only paths with passthrough, and raw disk. Every scenario under a native root lands at raw disk within noise, against 15 to 25 times raw for metadata-heavy work with passthrough alone; the front-end toolchain installs in 1,365 ms against 1,336 ms on raw disk and 2,165 ms with passthrough. The numbers come from a local arm64 virtual machine, and the page says so.
Brings in thirteen commits from main, among them the 0.6.0 release, the lazy getWorkspace client, and the isolate JavaScript changes. Two files conflicted. The linux-x64 image README takes main's 0.6.0 tag in its Dockerfile snippet and keeps this branch's trixie base without libfuse2t64. package-lock.json is main's, regenerated with `npm install --package-lock-only` to add the vendored fuse-napi in place of fuse-native.
The docs and example Dockerfiles said computerd 0.4.1 and earlier link libfuse 2, but 0.5.1 and 0.6.0 were released from main without the libfuse 3 binding and link libfuse 2 as well. The prose now says 0.6.0 and earlier. The Dockerfile comments explaining libfuse2t64 named the pinned version, which the release process rewrites on every release, so they went stale at the first bump. They now tie the package to the tag being 0.6.0 or earlier and say to drop it once the tag is past that, which stays true when the tag moves. The container-legacy example installed it without saying why and gains the same comment. The snippets in docs/README.md and the linux-x64 image README pin the 0.6.0 image on trixie but left libfuse2t64 out, so the binary they copy would not have found its library. They install it, with the same comment, until the pinned tag is a libfuse 3 release.
| detach: async (relativePath) => { | ||
| const all = owned(); | ||
| const targets = | ||
| relativePath === undefined | ||
| ? all | ||
| : all.filter((candidate) => candidate === targetOf(stripSlashes(relativePath))); | ||
| const detached = await detachTargets(targets); | ||
| for (const target of detached) parked.add(relOf(target)); | ||
| return detached; |
There was a problem hiding this comment.
馃煛 Immediate detach leaves root mounted
After a new native directory is created, detach() can return before its pending bind mount completes. owned() then finds nothing, and the mount lands afterward; the caller's removal fails with EBUSY.
Learn more
Creation calls ensure(), which starts mountOne() without waiting for it; the bind mount may not yet appear in mountinfo. The detach endpoint calls detach() and returns its answer to the caller. If it runs before the bind mount appears, owned() reports no target, so nothing is parked and the pending mount proceeds. The caller can then receive { detached: [] } yet get EBUSY when removing the directory. See ensure and handleNativeRootsDetach.
Example: A command creates /workspace/app/node_modules, immediately requests { "path": "app/node_modules" }, then removes it. The detach request returns before the asynchronous mount --bind finishes; rm -rf hits the newly mounted root and fails.
Recommended fix: Park the requested root before awaiting pending work, wait for its in-flight mountOne to settle, then detach any resulting mount. Apply the same ordering to detach-all during shutdown.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
MOUNT_NATIVE made native roots an opt-in subset of MOUNT_IGNORE, to keep the one behavior change, EBUSY when removing or renaming a mounted directory itself, away from existing MOUNT_IGNORE deployments. There are none yet, and a second list that has to repeat entries of the first is configuration nobody wants to write. MOUNT_IGNORE now implies native roots: every local-only directory whose parent is synced is bind-mounted when passthrough is negotiated, and a file pattern never is. COMPUTERD_FUSE_NATIVE_ROOTS=0 turns them off, with the same spellings as COMPUTERD_FUSE_PASSTHROUGH, for ruling them in or out while chasing a problem. The passthrough suites set it, so they keep testing the passthrough layer rather than a mount that may have landed first. MOUNT_NATIVE, its validation, and the matches() helper it needed are gone. /__computerd/info drops nativeRoots.patterns and reports the off switch in nativeRoots.reason. The benches measure native roots by default under LOCAL_ONLY and IGNORE_NODE_MODULES; the switch measures passthrough alone.
rmdir and rename of a mount point fail with EBUSY in the kernel's VFS before FUSE is consulted, so with native roots `rm -rf node_modules` emptied the directory and then failed on it, and so did fs.rm, rimraf, and anything else that removes or renames a dependency tree. computerd never saw the call. computerd now installs a seccomp filter on its JavaScript main thread, the one every command is spawned from, so each command and everything it starts inherits it. The filter pauses rmdir, the rename family, and unlinkat with AT_REMOVEDIR, which BPF can tell apart from a file deletion, and allows everything else. A thread in the vendored binding reads the path, and if it names a native root, lazily detaches the mount and lets the call continue; computerd then serves it through FUSE like any local-only path, so rm succeeds and mv of a root gets the EXDEV it gets without a mount. The detach is reported back so the root is parked and a lookup cannot remount it under the removal. The thread is started before the filter is installed, so it is never filtered itself, and it never waits on the JavaScript thread, whose own removals and renames are trapped too: paths are read through /proc and process_vm_readv, checked against an in-memory table, and a root is detached through a descriptor rather than its path under the FUSE mount, whose walk could need a lookup that thread has to answer. It answers every call with CONTINUE. This is a convenience rather than a boundary; a path changed after it was read costs at most a needless detach or the old EBUSY. The filter is installed without no_new_privs, so setuid binaries keep working; that needs CAP_SYS_ADMIN, which native roots need already. On Cloudflare Containers PID 1 has it and seccomp user notification with CONTINUE works. Each directory removal or rename costs about 15 碌s. Processes computerd did not start still get EBUSY and can use the detach endpoint, which the npm bench does. /__computerd/info reports the guard as nativeRoots.transparentRemoval with a reason.
The native-roots suite runs commands through computerd's exec RPC, as a host does, so the removal guard covers them, and checks the kernel's view with docker exec as before. With the guard, rm -rf of a root and of a directory holding roots succeeds, a program calling rmdir(2) itself does too, renaming a root gets EXDEV so mv copies, and a background process that outlives its command can still remove one. Ordinary removals and renames are unaffected, and a removed root is mounted again when created again. The existing EBUSY case now names what it covers: processes computerd did not start, which the guard cannot see, and which still need the detach endpoint.
| for (size_t i = 0; i < g_roots_len; i++) { | ||
| if (strcmp(g_roots[i].path, path) == 0) { | ||
| fd = g_roots[i].fd; | ||
| owned = g_roots[i].path; | ||
| g_roots[i] = g_roots[--g_roots_len]; | ||
| break; |
There was a problem hiding this comment.
馃煛 Renamed native roots resist removal
After renaming a synced parent, rm -rf of its native root returns EBUSY. The guard still matches its old pathname, although the kernel moved the mount with the parent.
Learn more
Native roots are bind mounts under synced directories. Renaming a synced parent moves its child mount to the new pathname, as native-root tests verify. The guard records the path when registerWithGuard opens the root, but nothing updates that record after the parent moves. An rm -rf using the new path misses the guard table and gets EBUSY at the mount point.
Example: Mount /workspace/pkgs/app/node_modules, rename /workspace/pkgs/app to /workspace/pkgs/web, then run rm -rf /workspace/pkgs/web/node_modules through shell exec. The bind mount moves to web, but the guard still knows app; removal fails.
Recommended fix: Resolve registered mounts by their current location or update guard entries when an ancestor is renamed. Cover rename followed by root removal in the real-FUSE suite.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
| if (read_target_string((pid_t) req->pid, paths[i], raw, sizeof(raw)) != 0) continue; | ||
| if (resolve((pid_t) req->pid, dirs[i], raw, absolute, sizeof(absolute)) != 0) continue; |
There was a problem hiding this comment.
馃煛 Changed-user commands cannot remove native roots
When a child changes UID without CAP_SYS_PTRACE, process_vm_readv cannot read its removal path. The guard skips detachment, so rm -rf of a native root returns EBUSY.
Learn more
The seccomp filter is installed on computerd's spawning thread, so commands inherit it even if they change UID. The guard reads each intercepted pathname from the command's memory in read_target_string. Linux applies ptrace credential checks to process_vm_readv; a daemon with CAP_SYS_ADMIN but without CAP_SYS_PTRACE cannot read a differently credentialed process. The guard then resumes the syscall without detaching the bind mount, so removal reaches EBUSY.
Example: With /workspace/node_modules mounted, spawn su nobody -s /bin/sh -c 'rm -rf /workspace/node_modules' from the shell RPC on a host granting computerd CAP_SYS_ADMIN but not CAP_SYS_PTRACE. The child's inherited filter pauses the removal, but the guard cannot read its pathname, and the command fails with EBUSY.
Recommended fix: Handle pathname resolution for UID-changing children without relying on cross-UID memory access, or explicitly disable native roots or transparent removal when the required capability is unavailable. Add an integration case using a different UID and the production capability set.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
| const nativeRootsWanted = nativeRootsRequested(process.env); | ||
| let removal: { active: boolean; reason: string } | undefined; | ||
| const nativeRoots: NativeRoots | undefined = | ||
| ignoreConfig.enabled && nativeRootsWanted && backend.kind !== "none" && backend.kind !== "shim" | ||
| ? createNativeRoots({ | ||
| root: ignoreConfig.root, | ||
| mountPoint, | ||
| ignore: ignoreConfig.ignore, | ||
| active: () => fuse?.getPassthroughStatus?.().negotiated === true, | ||
| }) | ||
| : undefined; |
There was a problem hiding this comment.
| for (int i = 0; i < count; i++) { | ||
| char raw[PATH_MAX]; | ||
| char absolute[PATH_MAX]; | ||
| if (read_target_string((pid_t) req->pid, paths[i], raw, sizeof(raw)) != 0) continue; | ||
| if (resolve((pid_t) req->pid, dirs[i], raw, absolute, sizeof(absolute)) != 0) continue; | ||
| /* The task may have died, or the call been interrupted, since. */ | ||
| if (ioctl(listener, SECCOMP_IOCTL_NOTIF_ID_VALID, &req->id) != 0) return; | ||
| detach_if_root(absolute); | ||
| } |
There was a problem hiding this comment.
A local-only backing directory can survive a container snapshot while the synced workspace parent becomes a symlink to a path outside the workspace. On restore, the path-based native-root bind follows that symlink and mounts the backing directory outside the FUSE tree. The mount is not counted as active there and normal cleanup cannot find it. Add a real-FUSE regression that plants the backing directory and outside victim, restores the snapshot, and checks the outside victim was neither covered nor changed. It fails against the current mount code. This commit records the vulnerability; it is not a fix and must not be deployed until the bind uses directory descriptors confined to the workspace and backing roots.
A restored workspace symlink can redirect a path-based privileged bind onto a directory outside /workspace. Resolve both mount endpoints under pinned directories in a native worker and attach a private clone by descriptor. If the helper is unavailable, leave the root on FUSE. Use confined opens and detaches for guard registration and cleanup as well. The focused unit and real-FUSE suites pass on arm64. Native x86_64 and Cloudflare Container behavior, deterministic path-swap tests, and a full-suite pass remain unverified. Keep native roots disabled in production until those checks and a security review are complete.
The native-root bind no longer calls util-linux mount, and the 0.7.0 image no longer links libfuse 2. Update failure hints, example packages, and debugging instructions to match. Clarify the mount ownership assumption and distinguish earlier benchmarks from mount setup time.
The removal guard is installed once and never unset. Capture that instance before opening a mount descriptor, rather than rechecking a state that cannot become undefined. State explicitly that mountinfo ownership assumes no unrelated mounts below the workspace.
The real-FUSE info test compares the whole response. Native-root status now includes removal guard availability and detach counts even when MOUNT_IGNORE is unset, so include those fields in the expected response rather than treating them as unexpected output.
A detach request could observe no mount while a bind was still in flight, return an empty result, and leave the root mounted after the caller tried to remove it. Park a requested root before waiting for its bind, then detach the mount that actually landed. Suspend new mount attempts while a detach-all operation waits for pending binds; startup cleanup still leaves roots available for its subsequent scan.
A local-only path avoids syncing dependency trees into the durable object, but FUSE still adds round trips for file data and metadata. This change moves computerd to libfuse 3 through a vendored binding so Linux 6.9+ can serve local-only file data through kernel FUSE passthrough. Synced paths continue through computerd.
With
MOUNT_IGNOREconfigured, eligible local-only directories also become native roots by default when passthrough and mount permissions are available. A bind mount serves both data and metadata from the container's disk. The mount helper resolves its source and target beneath pinned directory descriptors so workspace symlinks cannot redirect a privileged bind outside the mount. A refused bind falls back to FUSE;COMPUTERD_FUSE_NATIVE_ROOTS=0disables native roots. A removal guard detaches registered roots when commands launched by computerd remove or rename them.The binary needs system libfuse 3.17 or newer (Debian trixie), plus
/dev/fuse. Native roots also need negotiated passthrough, mount permissions, and kernel support for the mount helper. Performance measurements and their hardware limits are in the performance docs. Those native-root measurements predate the descriptor-confined helper and do not measure mount setup time.To verify locally, build the binary with
COMPUTERD_BIN_TARGETS=linux-arm64 npm run build:bin --workspace @cloudflare/computerdon an arm64 machine, then runnpx vitest run src/fuse/native-roots.fuse.test.ts src/fuse/passthrough.fuse.test.ts src/exec/runner.fuse.test.tsfrompackages/computerdwith privileged Docker and/dev/fuseavailable. The unit suite and real-FUSE regressions cover restored symlinks, fallback, mount cleanup, and ordinary operations. Native x86_64 hardware and Cloudflare Container behavior remain unverified; do not enable native roots for production until those checks and security review are complete. Do not create unrelated mounts under/workspace: the current cleanup treats every mount there as a native root.