summaryrefslogtreecommitdiff
path: root/9player/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
committerGabriel Schneider <[email protected]>2026-09-19 21:26:05 -0300
commitb05abcba3ea09ea106ad28364c6e40a3ec31b890 (patch)
tree9170fac5e7e5d8bde108de34a182aaa9d6844117 /9player/docs
parentae310a207534b33b7321dd2b9f423a73b1969159 (diff)
downloadcloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.tar.gz
cloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.zip
Add 9player and introspect as programs beside the library
9player/: FUSE mount CLI that mounts a 9P2000 tree into a fresh user+mount namespace and runs a program in it (no root, no libfuse, no libc). introspect/: the 9P debug/introspection library (freestanding core, value renderers, Linux probe with threads/stacks/memory/breakpoints/panics) and its demo server. Each has its own build fragment; the root build.zig wires them behind -D9player/-Dintrospect with namespaced steps (9player-itest, introspect-check-freestanding, programs-test, ...) and exports the introspect module for dependents. This is the layout for related programs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9player/docs')
-rw-r--r--9player/docs/DESIGN.md405
1 files changed, 405 insertions, 0 deletions
diff --git a/9player/docs/DESIGN.md b/9player/docs/DESIGN.md
new file mode 100644
index 0000000..b9b2fbe
--- /dev/null
+++ b/9player/docs/DESIGN.md
@@ -0,0 +1,405 @@
+# 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).