0c3c827ac9
* refactor(fuse): split the mount layer into MountCore plus a libfuse adapter MirageFS was one class doing two jobs: filesystem semantics and talking to libfuse. The FUSE-specific part was smeared across every method rather than sitting at a boundary, which is why it could not be reused by another kernel interface. MountCore (fuse/core.py, fuse/core.ts) now owns all semantics and imports nothing from mfusepy or fuse-native. MirageFS keeps only the callback signatures and error translation. The attr dicts stay as they were: st_mode and friends are POSIX stat field names, not FUSE ones. The load-bearing change is error handling. Python raised FuseOSError inline in ten methods with hand-rolled except chains while TypeScript already had a single classifyError. Both languages now share one classification table (fuse/errors.py, fuse/errors.ts), so the same backend failure reports the same errno on both sides. That fixes a real bug. Python's rmdir caught OSError before FileNotFoundError, and FileNotFoundError is an OSError subclass, so rmdir on a missing path returned ENOTEMPTY instead of ENOENT. No test covered it; test_errors.py now pins it. Behavior is otherwise unchanged: the pre-existing suites (test_fs.py 689 lines, fs.test.ts 43 tests) pass untouched apart from renaming private attribute reads onto .core. * feat(fuse): fskit mount backend, kext-free mounts on macOS Adds backend selection to a FUSE mount: Mount(..., fuse_backend="fskit") routes through macFUSE 5.x's FSKit shim, so the mount runs with no kernel extension loaded. Closes #82. Three rules are enforced at mount time rather than discovered at run time: - macOS only, raising elsewhere instead of silently dropping the option. - The mountpoint must live under /Volumes. FSKit refuses anything else, which is what made the reporter's /tmp attempt in #82 fail. The rule is owned by FuseManager, so no call site has to know it. - Every mounted resource must be able to size its files. FSKit has no direct_io, so reads are driven entirely by the reported size: a resource that stats as 0 before open would serve an empty file with exit code 0. Mirage refuses such a mount and names the offending prefixes. That last rule needs a new capability, SIZES_ALWAYS_KNOWN / sizesAlwaysKnown, default false. Opted in on the byte stores (ram, disk, redis, s3, gridfs) plus dev and history. History matters more than it looks: it is mounted into every workspace at /.bash_history, so leaving it false would block every root-scoped fskit mount. There is deliberately no "auto" value. Auto-selecting fskit would silently break every API-backed mount, and an option whose safe value is always the default is a trap. TypeScript cannot reach FSKit at all: @zkochan/fuse-native bundles a pre-macFUSE-5 dylib, so the option never arrives at a driver that understands it. checkPlatform throws rather than quietly mounting through the kext. The capability and guards are still mirrored so the semantics match. Documented as a known gap. Examples cover both sides of the limit: ram, redis and s3 mount and read under fskit; slack and gmail show the guard refusing and the supported workaround of scoping the mount to a byte-store subtree. * refactor(mount): one backend field, vfs by default Replaces the fuse boolean and the fuse_backend string with a single MountBackend StrEnum: vfs, fuse, fskit. VFS is the default and finally has a name, instead of being spelled fuse=False. Mount(resource, backend=MountBackend.FUSE) Mount(resource, backend=MountBackend.FSKIT, mountpoint="/Volumes/x") Three things this fixes. The default was previously defined by negation. Most mounts are not kernel-mounted at all, and that case is what mirage is mostly used for, so it deserves a name rather than a falsy value. The overloaded bool | str union is gone. fuse=True and fuse="/path" packed two decisions into one field; backend and mountpoint separate them. The enum no longer claims a name it does not own. fuse_backend="fskit" read as "use FSKit", but which delivery mechanism reaches FSKit (macFUSE 5's shim today, a native Swift module later) is mirage's business, not the caller's. One value covers both. MountBackend lives in mirage.types next to MountMode rather than in mirage.fuse, so the mount spec does not have to depend on the fuse package for a value that also covers the non-fuse case. resolve_backend rejects vfs: reaching it means a kernel mount was requested, and vfs registers nothing. Config, server routers, and the TypeScript mirror follow, including the YAML schema (backend: fuse plus mountpoint: <path>) and fuseMounts becoming kernelMounts. * fix(mount): migrate the CLI config path and integ fixtures to backend The backend rename missed three call-site shapes that a literal search for fuse=True / fuse: true does not catch: - YAML mountpoints written from shell variables (integ/cli_fuse.sh wrote `fuse: $dmnt`, five of them), which is the CLI's own end-to-end path. - Mount options passed a variable rather than a literal (integ/fuse.py and integ/fuse.ts both used `fuse=pinned`). - The TypeScript server config schema, which still declared `fuse?: boolean | string` and exported fuseMounts. It now mirrors Python: backend plus mountpoint in, kernelMounts out. The CLI itself has no fuse flag, so its whole surface is the workspace YAML. Both config loaders were checked against the exact document cli_fuse.sh writes and agree: /data and /logs resolve to (fuse, <path>), a backend with no mountpoint resolves to (fuse, None), and a mount with no backend key stays on vfs and is absent from the kernel mounts. CLAUDE.md documents the single backend field and the YAML keys. * refactor(mount): missing is vfs everywhere, and one guarded entry point Two related cleanups. Missing meant two different things. Mount.backend defaulted to VFS and an absent YAML key resolved to VFS, but resolve_backend(None) returned FUSE, reinterpreting an absent value as a request for a kernel mount. Now every absent value lands on VFS, and the mount entry points spell their own default: backend: str | MountBackend = MountBackend.FUSE. The intent is in the signature instead of hidden in the resolver. resolve_backend is pure coercion again; require_kernel_backend is the separate step that rejects VFS. The unknown-name error lists all three values rather than just the mountable two. The fskit guards were three separate calls at each mount path, so a new path could pick up fskit support and silently skip the macOS assert, the /Volumes rule, or the size check. prepare_backend now runs all of them, and every mount path routes through it: mount_background, mount, and FuseManager.setup. Tests pin that a linux platform raises through prepare_backend, not just through check_platform directly. Same shape in TypeScript: resolveBackend, requireKernelBackend, prepareBackend. * fix(fuse): drop the polynomial regex from unsizedMounts CodeQL js/polynomial-redos, two high-severity alerts on the same helper. `prefix.replace(/\/+$/, '')` backtracks on a string of many repeated slashes, and the input is a mount prefix, so it is library input. Uses core's loop-based rstripSlash instead, which is the existing sanctioned trim for exactly this. Python was never affected: str.rstrip is a C loop, not a regex. * fix(fskit): match the mount recipe and volume ownership verified in #82 Three corrections to the fskit path, all found by re-reading issue #82 against what we actually shipped. Mount options. #82's only reported working mount passes backend=fskit AND volname, and omits direct_io. We passed direct_io unconditionally and no volname, on my assumption that direct_io is simply inert on this path. That assumption contradicted the one person who has run it, and direct_io is a libfuse concept, so the shim needing it removed is not surprising. Now matched exactly, with a comment telling the next person not to restore it. Mountpoint ownership, which would have broken every fskit mount. /Volumes is drwxr-xr-x root:wheel, so tempfile.mkdtemp(dir="/Volumes") raises PermissionError for any non-root user. The reporter mounted at /Volumes/mirage-* as a normal user, so macFUSE creates the entry: an FSKit mount is a volume, not a directory we make. The fskit path now names its mountpoint and never creates it, skips makedirs for a pinned one, and never rmdirs a /Volumes entry on unmount. Readiness. The /Volumes entry does not exist until the volume is live, so bare existence is a real ready signal there, exactly as for WinFsp, and it does not depend on os.path.ismount recognizing the mount. These are the same three deviations WinFsp already needed, for the same underlying reason: the filesystem driver owns the mountpoint. Tests pin the option set and all three ownership rules, because nothing in CI can reach this path (macOS 15.4+, macFUSE 5.x, a GUI-enabled FSKit module). * fix(tests): pin the platform in the fskit manager tests These three passed on macOS and failed on the Linux runner: FuseManager routes through prepare_backend, whose check_platform rejects fskit off darwin. Patch sys.platform explicitly so the assertion under test is the mountpoint behavior, not the host, matching what test_backend.py already does in both directions. * ci: run the fskit backend for real on a macOS runner Until now fskit shipped on unit tests plus one user report, because no job could mount it. integ/fuse.py cannot cover it: its sizeless probe is refused by the fskit size guard by design, and its two-mount scenario needs something macOS forbids. So this adds integ/fskit.py, one RAM mount under /Volumes that reads, writes and stats through the kernel. Advisory like integ-fuse-windows, since whether a hosted runner enables the FSKit module without a GUI toggle is what the job measures. The truth file is JSON checked by value, not a txt file checked by substring: the old harness passes a result of 166 against a truth of 16. check_json.py is written to serve the remaining txt probes too. Only what mirage controls is asserted; the mountpoint, the raw mount row and whether a kext happens to be loaded are reported and left alone. * ci(fskit): report the mount state before reading it The first macOS run failed with a bare FileNotFoundError and told us nothing: every diagnostic in the probe was printed after the read that crashed. The volume entry appeared (readiness passed, the mount thread stayed alive) but served no tree, and there was no evidence of why. Print the mountpoint, its stat flags, the mount row and a listdir before touching a file, and add an always-run step dumping the mount table, /Volumes, the registered FSKit modules and the recent macfuse log. * fix(fskit): a stray /Volumes directory is not a live mount The macOS integ job reported exists=True isdir=True ismount=False with no row in the mount table: macFUSE creates the /Volumes entry while mounting and leaves the empty directory behind when the FSKit handoff fails. Bare existence as the ready signal accepted that, so a mount that never came up was reported live and failed with ENOENT on the first read. Require os.path.ismount for every POSIX backend and keep the existence shortcut for WinFsp only, where _prepare_mountpoint removes the directory first so its reappearance really does mean the filesystem is live. The backend argument is unused now, so it goes. _await_ready had no test at all; it has one now. Also register both macFUSE appexes on the runner: the installer registers only the -local module there, while a developer Mac has both. * ci(fskit): capture why the macFUSE mount never comes up Both modules register and enable now, and the mount still times out with no libfuse error, while the only module the system launches is msdos. So the mount request is not reaching macFUSE at all. Capture the kext device nodes, the system extension list and a macfuse-scoped log to tell an environment limit from a mirage bug. * docs(fskit): record where the /Volumes rule comes from The guard read as an arbitrary house rule. It is measured: issue #82 ran an fskit mount under /tmp, got 'mount_macfuse: the file system is not available (1)', and the same mount worked once it moved to /Volumes. * fskit: pin the real write surface, measured on a Mac The mount works: /Volumes/mirage-*, tagged fskit, tree served, reads exact (stat size == bytes read, so the size guard holds). ismount is true on a live FSKit volume, which is what the readiness fix now depends on. Writes are a different story. In-place writes and unlink work; create, mkdir and rename return ENOSYS. A failed create still applies: the syscall reports ENOSYS and the file exists in the resource and through the mount anyway. Tracing shows mirage's create succeeds and returns a handle, then the shim fails the syscall, so we cannot report this more accurately. Pin the whole matrix in the probe and say plainly in the docs that anything creating files will fail on an FSKit mount. * examples: one fskit example per language Five scattered files (ram, redis, s3, slack, gmail) collapse into examples/python/fuse/fskit.py, which shows the size guard refusing, a live mount with reads matching their stat size, and every write op with the errno it returns. Verified end to end on macFUSE 5.3.3. examples/typescript/fuse/fskit.ts cannot mount, since fuse-native bundles a pre-macFUSE-5 dylib, so it shows the refusal and both alternatives: backend fuse there, or the Python package for a kext-free mount. It typechecks but is not run here, because its fuse leg is a live kext mount. * feat(fskit): TypeScript serves fskit after all The 'fuse-native bundles a pre-macFUSE-5 dylib' claim was wrong: the libosxfuse.2.dylib it ships is a stub whose install name is /usr/local/lib/libfuse.2.dylib, and fuse.node links that absolute path, so Node loads the same macFUSE 5.x libfuse Python does. Verified with a live mount: /Volumes tagged fskit, cat/wc exact, and the same write surface as Python (in-place ok, create/mkdir/rename ENOSYS). Drop the unconditional throw from checkPlatform (macOS-only stays), generalize appendDirectIO into appendMountOptions, and give mount() the fskit branch: /Volumes named-not-created, backend=fskit + volname, no direct_io, ownsMountpoint false so nothing ever rmdirs a /Volumes entry. The example now mounts for real; docs flip from known-gap to supported-with-caveats. The caveat is earned: one of three example runs wedged on an append, and a dead FSKit volume blocks mount-table enumeration system-wide until the macFUSE appex is killed. Documented as read-mostly and experimental. * docs: example READMEs and a concise fskit story Both examples/ READMEs explain how to run, the naming convention, and why fskit exists: Apple has deprecated third-party kexts (reduced-security boot + approval on Apple Silicon already), FSKit is the supported userspace replacement, and macFUSE 5.x serves the same libfuse API through it. One mermaid flow shows the two paths differing only in the kernel-to-userspace hop. The two fuse.mdx warnings shrink to their load-bearing facts and the python page gains the same why + diagram. * ci(fskit): report the known hosted-runner limit as a skip Everything installs and enables headlessly, but the mount request never reaches macFUSE's FSKit module on a hosted runner, so every run ends in the same readiness TimeoutError and a permanently red advisory job is noise. Treat exactly that signature as a skip and drop continue-on-error: green now means environment-limited or verified, red means something new broke (a guard regression, a crash, or the runner starts mounting and diverges from integ/truth_fskit.json). * feat(fskit): full write surface via macFUSE's Darwin-only callbacks The read-mostly limitation was never FSKit's: the shim finalizes every created item through setattr_x and routes rename through renamex, both Darwin-only fuse_operations fields that mfusepy leaves as reserved NULL slots. libfuse answered those requests itself with ENOSYS, after our CREATE/MKDIR had already applied (wire trace: CREATE success, then SETATTR -78), which also explains why the failures were dirty and why rename never reached userspace. fuse/darwin.py replaces the 13 reserved slots with macFUSE's real Apple tail (same size, asserted) and marshals setattr_x, fsetattr_x and renamex; MirageFS decomposes setattr_x (size routes to truncate, other attributes follow the chmod/chown accept-if-exists semantics) and maps renamex flags (EXCL honored, SWAP refused as ENOTSUP). Installed once per process from _run_fuse, no-op off macOS, layout-guarded so an mfusepy upgrade degrades to the old behavior instead of corrupting the struct. touch, mkdir, mv, rm and a new-file write roundtrip now all pass on a real fskit mount; integ/truth_fskit.json pins the full matrix. Docs flip Python fskit from read-mostly to full-write; TS keeps the old surface because fuse-native's compiled op table cannot gain new C callbacks from JS. Remaining upstream caveats stay referenced in code comments: macfuse#1181 (exec until first read) and macfuse#1165 (root readdir cache invalidation). * test(fskit): skip the struct-extension test off macOS, settle yapf's import wrap mfusepy builds fuse_operations per platform, so the Darwin reserved tail the Apple fields replace does not exist in the Linux layout and install_macfuse_extensions correctly bails there; monkeypatching sys.platform cannot conjure the struct. yapf also disagreed with the committed import wrapping; both yapf and isort accept the new form.