From ba996acfcad1698adbf4a1834fe50e73b1c6cab9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:28:22 -0300 Subject: Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web) Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 --- 9player/docs/DESIGN.md | 405 ------------------------------------------------- 1 file changed, 405 deletions(-) delete mode 100644 9player/docs/DESIGN.md (limited to '9player/docs') 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/.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` = - `" 1"`, `/proc/self/gid_map` = `" 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=,rootmode=40000,user_id=,group_id=,max_read=")`. - 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=` 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//`; 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//*`, `/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 " --stdio"` variant. -3. `--tcp 127.0.0.1:` 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). -- cgit v1.3