summaryrefslogtreecommitdiff
path: root/9player/docs/DESIGN.md
diff options
context:
space:
mode:
Diffstat (limited to '9player/docs/DESIGN.md')
-rw-r--r--9player/docs/DESIGN.md405
1 files changed, 0 insertions, 405 deletions
diff --git a/9player/docs/DESIGN.md b/9player/docs/DESIGN.md
deleted file mode 100644
index b9b2fbe..0000000
--- a/9player/docs/DESIGN.md
+++ /dev/null
@@ -1,405 +0,0 @@
-# 9player design
-
-`9player` mounts a 9P2000 file tree served over a Unix or TCP stream socket
-into a **fresh mount namespace** and runs a program inside it. The program
-(fish, bash, `claude`, anything) sees the 9P tree as ordinary files, without
-root and without touching the host's mount table.
-
-## Why FUSE
-
-The kernel's own `9p` filesystem is not mountable inside an unprivileged user
-namespace (it lacks `FS_USERNS_MOUNT`) and loading it needs root. FUSE has been
-user-namespace mountable since Linux 4.18, and `/dev/fuse` is world read/write.
-So 9player is a tiny FUSE server that speaks 9P2000 to the real server:
-
-```
- program (fish/bash/claude) 9player (parent) 9P server
- in new user+mount namespace │ (introspect,
- /mnt/9p ─── FUSE ───▶ kernel ──▶│ fuse.zig ──▶ bridge.zig ──▶ nine.zig ──▶ ramfs, ...)
- │ (framing) (translation) (cloud9 Client)
-```
-
-No libfuse: `src/fuse.zig` implements the small subset of the kernel FUSE
-protocol we need directly against `/usr/include/linux/fuse.h`.
-
-## Toolchain facts (Zig 0.16)
-
-* Zig 0.16.0 at `/usr/bin/zig`, std at `/usr/lib/zig/std`. **Grep the std
- tree before assuming an API exists**; 0.16 moved a lot of process/fs code
- behind `std.Io`. Raw Linux syscalls in `std.os.linux` (`fork`, `execve`,
- `mount`, `unshare`, `waitpid`, `pipe2`, `socketpair`, `poll`, `read`, `write`,
- `open`, `openat`, `getdents64`, `sigaction`, `kill`, `readlinkat`, `mkdirat`,
- `symlinkat`) are the intended low-level path. They return `usize`; decode
- with `std.os.linux.errno(rc)` (an `E` enum, `.SUCCESS` when ok).
-* `std.posix.poll`, `std.posix.sigaction`, `std.posix.read`, `std.posix.kill`
- exist. `std.posix.fork/execve/waitpid/pipe2/socketpair` do **not**.
-* `pub fn main() !void` and `pub fn main(init: std.process.Init) !void` are
- both supported. Prefer `main(init: std.process.Init)`; `init.gpa` is a
- general purpose allocator, `init.arena` an arena, `init.minimal.args` the
- argv (`toSlice(allocator)`), `init.minimal.environ.block` the envp block.
-* No libc is linked. Do not use `std.c.*`. Hostname lookups are therefore out
- of scope: `--tcp` takes IP literals only.
-* 9player lives in the cloud9 repository as `cloud9/9player/` and is built by
- the root `build.zig` through the fragment `9player/build.zig` (steps
- `9player`, `9player-test`, `9player-itest`, `9player-adv`; toggle
- `-D9player`). cloud9 itself is imported as module `cloud9`
- (`@import("cloud9")`). Read `../src/client.zig`, `Server.zig`, `wire.zig`
- and `../docs/design.md`. Its core is allocation-free and caller-driven: you
- push bytes in, take results out. The demo 9P server the tests mount is the
- sibling program `../introspect` (`zig build introspect`).
-* Standalone module tests while other files are missing (from the cloud9
- root):
- `zig test --dep cloud9 -Mroot=9player/src/<file>.zig -Mcloud9=src/root.zig`.
-* Format everything with `zig fmt`.
-
-## Process model
-
-```
-9player [options] -- PROGRAM [ARGS...]
-```
-
-1. Parent parses args, probes that `/dev/fuse` exists, connects to the 9P
- server, negotiates `version` and `attach`es (fid 0 = root). Connection
- failures are reported before anything is forked.
-2. Parent forks with a `socketpair` status channel. **Child**:
- 1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)`.
- 2. Writes `/proc/self/setgroups` = `deny`, `/proc/self/uid_map` =
- `"<uid> <uid> 1"`, `/proc/self/gid_map` = `"<gid> <gid> 1"` (same ids
- inside as outside; the child creating the namespace holds full
- capabilities in it until exec).
- 3. `mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL)` so nothing propagates.
- 4. Ensures the mountpoint exists (see below).
- 5. Opens `/dev/fuse` (`O_RDWR|O_CLOEXEC`). The kernel refuses to mount a
- fuse descriptor opened from a different user namespace than the mount
- ("wrong user namespace for fuse device"), so this must happen here, not
- in the parent.
- 6. `mount("9player", mountpoint, "fuse", MS_NOSUID|MS_NODEV,
- "fd=<fusefd>,rootmode=40000,user_id=<uid>,group_id=<gid>,max_read=<n>")`.
- 7. Sends the fuse fd to the parent over the status socket (`SCM_RIGHTS`).
- 8. `statx` of the mountpoint: this forces one GETATTR, which the parent
- serves. Without it the kernel keeps the root inode's initial uid 0
- (unmapped in the namespace) and every create in the root gets `EACCES`.
- 9. Sets `NINEPLAYER_MOUNT=<mountpoint>` in the environment.
- 10. `execve` of PROGRAM with PATH search (implemented by hand; no libc).
- Exec failures are reported through the `CLOEXEC` status socket
- (errno + message); the parent prints them after the serve loop ends.
-3. **Parent** receives the fuse fd, then runs the FUSE loop (`bridge.serve`)
- until either the child exits (SIGCHLD via self-pipe) or the FUSE fd reports
- `ENODEV` (last process in the namespace gone, mount destroyed). It then
- closes the fuse fd and exits with the child's status (`128+sig` if
- signalled). The self-pipe is also watched by the 9P session while a reply
- is outstanding (`Session.stop_fd` → `error.Stopped`), so a server that
- never answers cannot keep 9player alive after the child is gone; a 3 s
- watchdog armed from the SIGCHLD handler is the last resort.
-4. Signals in the parent: `SIGINT`/`SIGQUIT` ignored (the child owns the tty
- and gets them itself); `SIGTERM`/`SIGHUP` forwarded to the child;
- `SIGPIPE` ignored; `SIGCHLD` → self-pipe.
-
-The FUSE fd is shared with the child only until exec (CLOEXEC); the parent's
-copy keeps the connection alive.
-
-### Mountpoint policy
-
-Default mountpoint: `/mnt/9p`. A relative `--mount` is resolved against cwd.
-
-* If the path is a directory: use it.
-* Else try `mkdir`. If that fails with `EACCES`/`EPERM`/`EROFS` (the normal
- case for `/mnt/9p` as a plain user), **shadow the parent directory**:
- open an fd to the parent, mount a `tmpfs` over it, then recreate every
- existing entry inside the tmpfs: directories → `mkdir` + bind mount from
- `/proc/self/fd/<fd>/<name>`; symlinks → `readlinkat` + `symlink`; anything
- else → empty regular file + bind mount. Then `mkdir` the target inside.
- Refuse (with a clear message) if the parent has more than 4096 entries or
- is `/`. This only affects the new namespace.
-* Else fail with the errno and a hint to pass `--mount` an existing dir.
-
-## Module contracts
-
-### `src/fuse.zig` — kernel FUSE protocol (no policy)
-
-Extern structs mirroring `linux/fuse.h`, with `comptime` size asserts:
-`InHeader` (40), `OutHeader` (16), `Attr` (88), `EntryOut` (128),
-`AttrOut` (104), `GetattrIn` (16), `SetattrIn` (88), `OpenIn` (8),
-`OpenOut` (16), `ReleaseIn` (24), `FlushIn` (24), `ReadIn` (40),
-`WriteIn` (40), `WriteOut` (8), `CreateIn` (16), `MkdirIn` (8),
-`RenameIn` (8), `Rename2In` (16), `ForgetIn` (8), `BatchForgetIn` (8),
-`ForgetOne` (16), `FsyncIn` (16), `AccessIn` (8), `InterruptIn` (8),
-`Kstatfs` (80), `StatfsOut` (80), `InitIn` (64), `InitOut` (64),
-`Dirent` (24 header, name padded to 8), `LseekIn` (24).
-
-`pub const Opcode = enum(u32) { lookup = 1, forget = 2, getattr = 3, setattr = 4,
-readlink = 5, symlink = 6, mknod = 8, mkdir = 9, unlink = 10, rmdir = 11,
-rename = 12, link = 13, open = 14, read = 15, write = 16, statfs = 17,
-release = 18, fsync = 20, setxattr = 21, getxattr = 22, listxattr = 23,
-removexattr = 24, flush = 25, init = 26, opendir = 27, readdir = 28,
-releasedir = 29, fsyncdir = 30, getlk = 31, setlk = 32, setlkw = 33,
-access = 34, create = 35, interrupt = 36, bmap = 37, destroy = 38,
-ioctl = 39, poll = 40, notify_reply = 41, batch_forget = 42, fallocate = 43,
-readdirplus = 44, rename2 = 45, lseek = 46, copy_file_range = 47,
-setupmapping = 48, removemapping = 49, syncfs = 50, tmpfile = 51, statx = 52, _ }`
-
-Constants: `kernel_version = 7`, `kernel_minor = 31` (what we answer; the
-kernel adapts to the lower minor), `FOPEN_DIRECT_IO = 1`, `FOPEN_KEEP_CACHE = 2`,
-`FOPEN_NONSEEKABLE = 4`, `FUSE_ASYNC_READ = 1`, `FUSE_MAX_PAGES = 1<<22`,
-`FATTR_MODE=1, FATTR_UID=2, FATTR_GID=4, FATTR_SIZE=8, FATTR_ATIME=16,
-FATTR_MTIME=32, FATTR_FH=64, FATTR_ATIME_NOW=128, FATTR_MTIME_NOW=256,
-FATTR_LOCKOWNER=512, FATTR_CTIME=1024`. `root_id = 1`.
-
-I/O helpers (blocking fd, no allocation beyond the caller's buffer):
-
-```zig
-pub const Request = struct { header: InHeader, body: []const u8 };
-/// One kernel request. Returns null on ENODEV (unmounted). Retries EINTR/EAGAIN/ENOENT.
-pub fn readRequest(fd: i32, buf: []u8) !?Request;
-/// Success reply: header + concatenated payload slices, single writev.
-pub fn reply(fd: i32, unique: u64, payloads: []const []const u8) !void;
-/// Error reply: negative errno.
-pub fn replyError(fd: i32, unique: u64, err: std.os.linux.E) !void;
-/// Append a fuse_dirent (8-byte padded) to `buf`; returns false if it doesn't fit.
-pub fn addDirent(buf: []u8, used: *usize, ino: u64, off: u64, dtype: u32, name: []const u8) bool;
-pub fn body(comptime T: type, req: Request) !*const T; // aligned copy-free view, checks size
-pub fn nameAfter(comptime T: type, req: Request) ![]const u8; // NUL-terminated name after a struct
-```
-
-The request buffer must be ≥ `max_write + 4096`; 9player uses 1 MiB + 4 KiB.
-Requests with an unknown/unsupported opcode get `ENOSYS`.
-
-### `src/nine.zig` — synchronous 9P2000 session on a blocking fd
-
-Thin, synchronous RPC layer over `cloud9.Client` (which is push/take,
-non-blocking-agnostic). One outstanding request at a time (the FUSE loop is
-single-threaded). Fids are allocated from a free list.
-
-```zig
-pub const Address = union(enum) { unix: []const u8, tcp: struct { host: []const u8, port: u16 }, fd: i32 };
-pub const Session = struct {
- pub const Error = error{ Nine, Protocol, Io, Closed, TooLarge, OutOfMemory };
- /// After error.Nine, `ename` holds the server's Rerror text (copied, bounded).
- ename: [256]u8, ename_len: usize,
- msize: u32,
-
- pub fn connect(gpa: std.mem.Allocator, address: Address, msize: u32) !Session; // socket+connect, version
- pub fn deinit(s: *Session) void;
- pub fn attach(s: *Session, fid: u32, uname: []const u8, aname: []const u8) Error!cloud9.Qid;
- pub fn allocFid(s: *Session) u32;
- pub fn freeFid(s: *Session, fid: u32) void;
- /// Generic RPC. Result slices borrow the input buffer until the next call.
- pub fn rpc(s: *Session, req: cloud9.Client.Request) Error!cloud9.Client.Result;
- // Conveniences (all built on rpc):
- pub fn walk(s, fid: u32, newfid: u32, names: []const []const u8) Error!Walk; // Walk = { nwqid, wqid[16] }; partial walk → error.Nine with ename "not found"-ish
- pub fn clone(s, fid: u32) Error!u32; // allocFid + walk with no names
- pub fn open(s, fid: u32, mode: u8) Error!Open; // Open = { qid, iounit }
- pub fn create(s, fid: u32, name: []const u8, perm: u32, mode: u8) Error!Open;
- pub fn read(s, fid: u32, offset: u64, buf: []u8) Error!usize; // chunks by maxRead/iounit; stops at short read
- pub fn write(s, fid: u32, offset: u64, data: []const u8) Error!usize; // chunks; stops at short write
- pub fn stat(s, fid: u32) Error!cloud9.Stat; // strings borrow the input buffer
- pub fn wstat(s, fid: u32, st: cloud9.Stat) Error!void;
- pub fn clunk(s, fid: u32) Error!void; // frees the fid even on error
- pub fn remove(s, fid: u32) Error!void; // frees the fid even on error
- pub fn errno(s: *const Session) std.os.linux.E; // map ename → errno (see below)
-};
-pub const dontcare = cloud9.Stat{ .type = 0xFFFF, .dev = 0xFFFF_FFFF, .qid = .{ .type = 0xFF, .version = 0xFFFF_FFFF, .path = 0xFFFF_FFFF_FFFF_FFFF }, .mode = 0xFFFF_FFFF, .atime = 0xFFFF_FFFF, .mtime = 0xFFFF_FFFF, .length = 0xFFFF_FFFF_FFFF_FFFF, .name = "", .uid = "", .gid = "", .muid = "" };
-```
-
-`connect`: for `.unix` and `.tcp` create a blocking `SOCK_STREAM|SOCK_CLOEXEC`
-socket and connect (`TCP_NODELAY` on TCP); for `.fd` adopt it. Buffers of
-`msize` bytes for in/out are heap allocated. Then submit `.version`, drain
-output to the socket, read until `take()` yields the version result. The
-negotiated msize is `result.version.msize`; if the server answered
-`"unknown"`, fail with `error.Protocol`.
-
-`rpc`: submit, write all of `client.output()` (calling `wrote`), then loop:
-`take()`; if null, `read` from the fd into a temp buffer and `push` (push
-returns how much fit; the frame is at most msize so it always fits after a
-`take`). If the fd returns 0 → `error.Closed`. If the client dies →
-`error.Protocol`. A `.fail` result copies the ename and returns `error.Nine`.
-
-Rerror text → errno mapping (case-insensitive substring, in this order):
-`"not exist"`, `"not found"`, `"no such"` → `ENOENT`; `"exists"` → `EEXIST`;
-`"not empty"` → `ENOTEMPTY`; `"not a dir"` → `ENOTDIR`;
-`"is a dir"` → `EISDIR`; `"permission"`, `"denied"` → `EACCES`;
-`"read-only"`, `"read only"`, `"readonly"` → `EROFS`; `"no space"` → `ENOSPC`;
-`"not allowed"`, `"not permitted"`, `"cannot"` → `EPERM`;
-`"fid"` → `EBADF`; `"bad offset"`, `"invalid"`, `"bad "` → `EINVAL`;
-`"busy"`, `"in use"` → `EBUSY`; `"too long"` → `ENAMETOOLONG`;
-`"not supported"`, `"unsupported"` → `ENOTSUP`; otherwise `EIO`.
-
-### `src/bridge.zig` — FUSE ↔ 9P translation
-
-```zig
-pub const Options = struct {
- uid: u32, gid: u32, // reported owner of every file
- attr_timeout_ns: u64 = 1e9, // attr/entry cache validity (0 = none)
- direct_io: bool = true, // FOPEN_DIRECT_IO on every regular file
- debug: bool = false, // trace to stderr
-};
-/// Runs until the FUSE fd reports ENODEV or `stop_fd` becomes readable.
-pub fn serve(gpa: std.mem.Allocator, fuse_fd: i32, nine: *nine.Session, root_fid: u32, stop_fd: i32, opts: Options) !void;
-```
-
-State:
-
-* `inodes: AutoHashMap(u64 /*nodeid*/, Inode{ fid: u32, qid: Qid, nlookup: u64 })`.
- Node 1 is the root (`root_fid`, never forgotten).
-* `by_qid: AutoHashMap(u64 /*qid.path*/, u64 /*nodeid*/)` so that repeated
- lookups of the same file map to the same inode (the old fid is clunked and
- the fresh one kept). Dedupe only merges when the qid type (dir bit) also
- matches, so a server reusing a path across a file and a directory cannot
- poison an inode. `ino` in attrs is `qid.path` (root, or anything carrying
- the root's path: 1).
-* `handles: AutoHashMap(u64 /*fh*/, Handle{ fid: u32, dir: ?DirList })`.
- `DirList` is the entire directory read at first `READDIR` offset 0:
- `[]Entry{ name: []u8, ino: u64, dtype: u32 }` with synthetic `.` and `..`
- first. `READDIR` offsets are indices into that list; a `READDIR` at offset 0
- re-reads the directory (rewinddir).
-
-Op mapping (9P2000 has no symlinks, links, xattrs, locks, mknod):
-
-| FUSE | 9P |
-|---|---|
-| INIT | reply `InitOut{ major=7, minor=31, max_readahead=in.max_readahead, flags = FUSE_ASYNC_READ \| FUSE_ATOMIC_O_TRUNC \| FUSE_AUTO_INVAL_DATA \| FUSE_BIG_WRITES (plus FUSE_MAX_PAGES with max_pages=256 if offered), max_background=16, congestion_threshold=12, max_write=1 MiB, time_gran=1 }`. Atomic O_TRUNC matters: without it the kernel truncates via a separate SETATTR(size=0) that synthetic control files reject; with it `O_TRUNC` becomes 9P `OTRUNC` inside the open |
-| LOOKUP(parent,name) | `walk(parent.fid → newfid, [name])`; `stat(newfid)`; dedupe by qid; `EntryOut` |
-| FORGET / BATCH_FORGET | `nlookup -= n`; at 0 `clunk` and drop (no reply) |
-| GETATTR | `stat(inode.fid)` → `AttrOut` |
-| SETATTR | `stat` then `wstat` with a *dontcare* Stat: `FATTR_SIZE`→length; `FATTR_MODE`→`(old.mode & ~0o777) \| (mode & 0o777)`; `FATTR_MTIME`→mtime (`FATTR_MTIME_NOW` → now); `FATTR_ATIME` ignored; `FATTR_UID/GID` → `EPERM` unless unchanged; then `stat` again for the reply |
-| OPEN | `clone(inode.fid)` then `open(newfid, mode)`; mode from `O_ACCMODE` (`oread/owrite/ordwr`), `O_TRUNC` → `otrunc`; reply `OpenOut{ fh, open_flags = FOPEN_DIRECT_IO }`; on failure clunk |
-| OPENDIR | same with `oread`; `fh` with `dir = null` |
-| READ | `read(fh.fid, offset, buf[0..min(size, 1 MiB)])`; reply data |
-| WRITE | `write(fh.fid, offset, data)`; `WriteOut{ size = n }` |
-| READDIR | fill `Dirent`s from the `DirList` starting at `offset`, up to `size` bytes |
-| RELEASE / RELEASEDIR | `clunk(fh.fid)`; free DirList |
-| FLUSH / FSYNC / FSYNCDIR | ok (no-op) |
-| CREATE(parent,name,flags,mode) | `clone(parent)`; `create(fid, name, mode & 0o777, openmode)` → this fid is the **open** file; then `walk(parent → fid2, [name])` + `stat(fid2)` for the inode; reply `EntryOut ++ OpenOut` |
-| MKDIR | `clone(parent)`; `create(fid, name, DMDIR \| (mode & 0o777), oread)`; `clunk`; then lookup as above |
-| UNLINK / RMDIR | `walk(parent → tmp, [name])`; `remove(tmp)` |
-| RENAME / RENAME2 | if `newdir != parent` → `EXDEV`; else `walk(parent → tmp, [oldname])`, `wstat(tmp, dontcare with .name = newname)`, `clunk`. 9P rename never replaces, POSIX does: when the target exists (and `RENAME_NOREPLACE` is not set) a directory target is removed first; a file target is parked under a temporary name, the rename retried, and the parked file removed only after success (restored on failure) |
-| STATFS | constant `Kstatfs{ bsize = 4096, namelen = 255, frsize = 4096 }` |
-| ACCESS | `ENOSYS` (kernel stops asking; the server enforces permissions on open) |
-| READLINK, SYMLINK, LINK, MKNOD, *XATTR, *LK, IOCTL, POLL, BMAP, FALLOCATE, LSEEK, COPY_FILE_RANGE, TMPFILE, STATX | `ENOSYS` |
-| INTERRUPT | ignored (reply nothing) |
-| DESTROY | return from `serve` |
-
-Attr mapping from `cloud9.Stat`: `mode = (S_IFDIR if DMDIR else S_IFREG) |
-(st.mode & 0o777)`; `nlink = 1`; `size = length`; `blocks = (length+511)/512`;
-`blksize = 4096`; `atime/mtime/ctime = st.atime/st.mtime/st.mtime`;
-`uid/gid = opts.uid/gid`. `Dirent.type` = `DT_DIR` (4) / `DT_REG` (8).
-
-Errors: `nine.Session.Error.Nine` → `nine.errno()`; `Closed`/`Protocol`/`Io`
-→ `EIO` and, since the session is dead, `serve` returns `error.Closed` after
-replying so 9player can report "9P server went away".
-
-With `direct_io` the kernel never trusts `length` for reads: synthetic files
-that report length 0 (very common in 9P) still `cat` correctly, and reads run
-until the server returns a short read. With `--no-direct-io` the bridge forces
-`attr_timeout_ns = 0`, because a cached stale size truncates reads (observed
-data loss on a 4 MiB copy otherwise).
-
-Hostile-server rules: directory listings are capped at 64 MiB (a server that
-ignores read offsets otherwise loops forever); directory records with names
-containing `/`, NUL, empty, `.`/`..` or longer than `FUSE_NAME_MAX` are dropped
-rather than poisoning the whole READDIR reply; `length` near 2^64 is clamped
-to `i64` max; the errno of a failing 9P call is latched before any cleanup
-clunk overwrites the session's ename.
-
-### `src/ns.zig` — namespace and process plumbing
-
-```zig
-pub const Spawn = struct {
- argv: []const []const u8, // argv[0] is PATH-searched unless it contains '/'
- envp: [*:null]const ?[*:0]const u8, // inherited environment
- mountpoint: []const u8, // absolute
- fuse_fd: i32,
- uid: u32, gid: u32,
- max_read: u32,
-};
-pub const Child = struct { pid: i32 };
-/// fork; the child sets up the namespace, mounts, and execs. Returns once exec succeeded
-/// (status pipe closed) or fails with the child's error (message on stderr).
-pub fn spawn(gpa: std.mem.Allocator, s: Spawn) !Child;
-pub fn ensureMountpoint(path: [:0]const u8) !void; // the shadowing logic, testable alone
-pub fn resolveMountpoint(gpa, path: []const u8) ![:0]u8; // absolute, no trailing slash
-pub fn findInPath(gpa, envp, name) ![:0]u8;
-```
-
-Also exports the signal plumbing used by `main.zig`:
-`installSignals(child_pid_ptr: *i32) !i32` returning the SIGCHLD self-pipe
-read end (used as `stop_fd` for `bridge.serve`), and
-`waitChild(pid) !u8` → exit status (`128+sig` on signal death).
-
-### `src/main.zig` — CLI
-
-```
-Usage: 9player [options] -- PROGRAM [ARGS...]
-Transport (exactly one):
- --unix PATH Unix stream socket
- --tcp IP:PORT TCP (IPv4/IPv6 literal)
- --fd N already-connected inherited descriptor
- --spawn CMD run CMD (via /bin/sh -c) with a socketpair on its stdin/stdout
-Options:
- --mount PATH mountpoint inside the new namespace (default /mnt/9p)
- --uname NAME 9P user name (default $USER, else "none")
- --aname NAME 9P tree to attach (default "")
- --msize BYTES maximum 9P message size to request (default 131072, max 16 MiB)
- --cache SECONDS attr/entry cache validity, may be fractional (default 1)
- --no-direct-io let the kernel cache file pages (trusts stat length)
- --debug trace FUSE and 9P operations on stderr
- --help, --version
-PROGRAM defaults to $SHELL (else /bin/sh). The mountpoint is exported as $NINEPLAYER_MOUNT.
-```
-
-Exit codes: child's status; 125 for 9player's own failures (usage, connect,
-mount); 126/127 as usual for exec failures.
-
-### `../introspect/demo/main.zig` — demo 9P2000 server (binary `introspect`)
-
-The demo server is a separate program in this repository, built on the
-introspect library; see `../introspect/docs/LIBRARY.md` for the library
-contract (freestanding core, value renderers, Linux debug probe). The tree it serves keeps the paths the integration tests read
-(`/build/*`, `/comptime/types/<T>/*`, `/comptime/decls`, `/runtime/fn/*`,
-`/runtime/ctl`, `/runtime/{pid,ppid,uptime,argv,cwd,env,clients}`,
-`/scratch/`) and adds `/vars`, `/threads`, `/addr`, `/mem`, `/hex`,
-`/breakpoints` and `/panic`.
-
-## Integration test plan (`test/integration.sh`)
-
-Run by `zig build 9player-itest`; args: path to `9player`, path to `introspect`.
-Everything under a temp dir. Skips (exit 0 with a notice) when
-`unshare -Urm true` fails or `/dev/fuse` is missing.
-
-1. introspect on a Unix socket; `9player --unix … -- sh -c` scripts:
- `cat /mnt/9p/build/zig_version` == `zig version`; `ls` listings; `stat`
- sizes; `/runtime/fn/now` is numeric; `ctl` round trip; `/scratch`: create,
- append (`>>`), overwrite, truncate, `mkdir -p a/b/c`, rename within dir,
- `mv` across dirs fails with `EXDEV`-ish message, `rm`, `rmdir`, 1 MiB
- random file round trip compared with `sha256sum`, `dd` with odd block
- sizes, many small files, `find`, exit-status propagation (`exit 7` → 7),
- `$NINEPLAYER_MOUNT` set, nested `9player` inside `9player`.
-2. `--spawn "<introspect> --stdio"` variant.
-3. `--tcp 127.0.0.1:<port>` variant.
-4. If `/usr/lib/plan9/bin/ramfs` exists: `NAMESPACE=$tmp ramfs -s ramfs`
- creates `$tmp/ramfs`; run the scratch battery against it.
-5. `--mount` with an existing dir, with a relative path, and the default
- `/mnt/9p` (exercises parent shadowing; verify `/mnt`'s other entries are
- still visible inside).
-6. Kill tests: 9player exits when the child exits; server death during use
- yields `EIO`, not a hang.
-
-## Verification
-
-`zig build 9player-test` (unit), `zig build 9player-itest` (74 end-to-end
-checks against introspect over unix/tcp/socketpair and against plan9port's
-`ramfs`) and `zig build 9player-adv` (adversarial suites: a scriptable
-hostile 9P server with ~30 misbehaviour modes, FUSE semantics through the
-bridge, process/namespace/signal edge cases with 51 checks, and stress). The
-suites that attack the introspect server itself (a hostile raw-9P client with
-181 checks, the core, the Linux layer) moved with it to
-`../introspect/test` (`zig build introspect-adv`). All pass in Debug and
-ReleaseSafe.
-
-## Out of scope for v1 (documented, not hidden)
-
-* One 9P request in flight at a time: a 9P read that blocks (event files)
- stalls the whole mount until it returns (but not past the child's exit).
-* No 9P2000.u/.L: no symlinks, ownership, or extended attributes.
-* No PID namespace, no `/proc` remount. `--tcp` needs an IP literal.
-* Cross-directory rename returns `EXDEV` (9P2000 cannot move files).