summaryrefslogtreecommitdiff
path: root/9player/test
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/test
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/test')
-rwxr-xr-x9player/test/adv_bridge_hostile.py487
-rwxr-xr-x9player/test/adv_bridge_hostile.sh181
-rwxr-xr-x9player/test/adv_bridge_semantics.sh205
-rwxr-xr-x9player/test/adv_bridge_stress.sh64
-rwxr-xr-x9player/test/adv_ns_process.sh202
-rwxr-xr-x9player/test/adversarial.sh15
-rwxr-xr-x9player/test/integration.sh160
7 files changed, 1314 insertions, 0 deletions
diff --git a/9player/test/adv_bridge_hostile.py b/9player/test/adv_bridge_hostile.py
new file mode 100755
index 0000000..b842a1a
--- /dev/null
+++ b/9player/test/adv_bridge_hostile.py
@@ -0,0 +1,487 @@
+#!/usr/bin/env python3
+"""A scriptable, hostile 9P2000 server on a Unix socket (stdlib only).
+
+Usage: adv_bridge_hostile.py SOCKET MODE
+
+Serves a tiny in-memory tree:
+ /f "hello world\\n"
+ /d/g "in d\\n"
+ /fids reading it returns the number of fids currently bound
+ /big 1 MiB of pseudo-random bytes
+plus create/write/remove/wstat so the scratch battery can run in `ok` mode.
+
+MODE selects one misbehaviour (see MODES below). Everything not covered by
+the mode behaves normally, so 9player gets through version/attach/stat(root).
+"""
+import os
+import random
+import socket
+import struct
+import sys
+import time
+
+NOTAG = 0xFFFF
+NOFID = 0xFFFFFFFF
+QTDIR = 0x80
+DMDIR = 0x80000000
+
+Tversion, Rversion = 100, 101
+Tauth, Rauth = 102, 103
+Tattach, Rattach = 104, 105
+Rerror = 107
+Tflush, Rflush = 108, 109
+Twalk, Rwalk = 110, 111
+Topen, Ropen = 112, 113
+Tcreate, Rcreate = 114, 115
+Tread, Rread = 116, 117
+Twrite, Rwrite = 118, 119
+Tclunk, Rclunk = 120, 121
+Tremove, Rremove = 122, 123
+Tstat, Rstat = 124, 125
+Twstat, Rwstat = 126, 127
+
+MODES = """
+ok behave (qid paths are recycled LIFO after remove, like many servers)
+trunc Rread on /f: send half the frame, then close
+short_frame Rread on /f: frame whose size field is 3
+huge_frame Rread on /f: frame whose size field is msize+1
+wrong_tag Rread on /f: reply carries tag+1
+wrong_type Tstat on /f: answer with an Rwalk
+rread_big Rread on /f: count = requested+1
+rwalk_many Twalk to f: nwqid = nwname+1
+rwalk_zero Twalk to nope: Rwalk nwqid=0 instead of Rerror
+rstat_garbage Tstat on /f: random bytes as the stat
+rstat_overlong Tstat on /f: inner stat size disagrees with outer
+dir_split Tread on /: a stat record split across two Rreads
+dir_forever Tread on /: ignore offset, always return the same records
+qid_collide every file and dir shares qid.path 7 (root keeps its own)
+qid_zero every qid.path is 0, including the root
+name_slash / has an entry "a/b"
+name_empty / has an entry ""
+name_huge / has an entry with a 60000-byte name
+name_dots / lists "." and ".." too
+rerror_big Twalk to nope: Rerror with 65535 bytes of text
+extra_reply Rread on /f: an unsolicited Rclunk (tag 9) precedes the real reply
+never Tread on /f: never reply (hang)
+close_mid Tread on /f: close the socket without replying
+renegotiate Tread on /f: an unsolicited Rversion precedes the real reply
+length_max Tstat on /f: length = 2**64-1
+iounit_one Ropen: iounit = 1
+rwrite_big Rwrite: count = requested+1
+msize_tiny Rversion msize = 64
+version_unknown Rversion "unknown"
+rename_fail Twstat with a new name always fails "file already exists"
+slow every reply delayed 20 ms (for interrupt tests)
+"""
+
+
+def s8(x): return struct.pack('<B', x)
+def s16(x): return struct.pack('<H', x)
+def s32(x): return struct.pack('<I', x)
+def s64(x): return struct.pack('<Q', x)
+def sstr(b):
+ if isinstance(b, str):
+ b = b.encode()
+ return s16(len(b)) + b
+
+
+class Node:
+ def __init__(self, name, isdir, path, content=b''):
+ self.name = name
+ self.isdir = isdir
+ self.path = path
+ self.content = bytearray(content)
+ self.children = {}
+ self.mode = 0o755 if isdir else 0o644
+ self.mtime = int(time.time())
+ self.removed = False
+
+ def qid(self, srv):
+ path = self.path
+ if srv.mode == 'qid_zero':
+ path = 0
+ elif srv.mode == 'qid_collide' and self is not srv.root:
+ path = 7
+ return s8(QTDIR if self.isdir else 0) + s32(1) + s64(path)
+
+ def stat_bytes(self, srv, name=None, length=None):
+ if name is None:
+ name = self.name
+ if length is None:
+ length = 0 if self.isdir else len(self.content)
+ body = (s16(0) + s32(0) + self.qid(srv) + s32((DMDIR if self.isdir else 0) | self.mode)
+ + s32(self.mtime) + s32(self.mtime) + s64(length)
+ + sstr(name) + sstr('u') + sstr('g') + sstr('u'))
+ return s16(len(body)) + body
+
+
+class Server:
+ def __init__(self, mode):
+ self.mode = mode
+ self.next_path = 100
+ self.free_paths = []
+ self.root = Node('', True, 1)
+ f = self.mk(self.root, 'f', False, b'hello world\n')
+ d = self.mk(self.root, 'd', True)
+ self.mk(d, 'g', False, b'in d\n')
+ self.mk(self.root, 'fids', False)
+ rnd = random.Random(4)
+ self.mk(self.root, 'big', False, bytes(rnd.getrandbits(8) for _ in range(1 << 20)))
+ self.fids = {}
+ self.msize = 8192
+ self.ops = 0
+
+ def alloc_path(self):
+ if self.free_paths:
+ return self.free_paths.pop()
+ self.next_path += 1
+ return self.next_path
+
+ def mk(self, parent, name, isdir, content=b''):
+ n = Node(name, isdir, self.alloc_path(), content)
+ parent.children[name] = n
+ return n
+
+ # -- framing ---------------------------------------------------------
+ def frame(self, typ, tag, body):
+ return s32(7 + len(body)) + s8(typ) + s16(tag) + body
+
+ def err(self, tag, text):
+ return self.frame(Rerror, tag, sstr(text))
+
+ def serve(self, conn):
+ buf = b''
+ while True:
+ if len(buf) >= 4:
+ n = struct.unpack('<I', buf[:4])[0]
+ if len(buf) >= n:
+ msg, buf = buf[:n], buf[n:]
+ out = self.handle(msg)
+ if out is None:
+ return # hang up / hang
+ if self.mode == 'slow':
+ time.sleep(0.02)
+ conn.sendall(out)
+ continue
+ data = conn.recv(65536)
+ if not data:
+ return
+ buf += data
+
+ def handle(self, msg):
+ typ = msg[4]
+ tag = struct.unpack('<H', msg[5:7])[0]
+ b = msg[7:]
+ self.ops += 1
+ r = Reader(b)
+ if typ == Tversion:
+ msize = r.u32()
+ ver = r.str()
+ self.msize = min(msize, 1 << 20)
+ self.fids = {}
+ if self.mode == 'msize_tiny':
+ self.msize = 64
+ if self.mode == 'version_unknown':
+ return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('unknown'))
+ return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('9P2000'))
+ if typ == Tauth:
+ return self.err(tag, 'authentication not required')
+ if typ == Tattach:
+ fid = r.u32()
+ r.u32()
+ r.str()
+ r.str()
+ if fid in self.fids:
+ return self.err(tag, 'fid in use')
+ self.fids[fid] = [self.root, False]
+ return self.frame(Rattach, tag, self.root.qid(self))
+ if typ == Tflush:
+ return self.frame(Rflush, tag, b'')
+ if typ == Twalk:
+ fid, newfid, nw = r.u32(), r.u32(), r.u16()
+ names = [r.str() for _ in range(nw)]
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ if newfid != fid and newfid in self.fids:
+ return self.err(tag, 'fid in use')
+ node = self.fids[fid][0]
+ qids = b''
+ n = 0
+ for name in names:
+ if not node.isdir:
+ break
+ if name == '..':
+ nxt = node # root's parent is itself; good enough
+ elif name in node.children:
+ nxt = node.children[name]
+ else:
+ break
+ node = nxt
+ qids += node.qid(self)
+ n += 1
+ if n < nw and n == 0:
+ if self.mode == 'rwalk_zero' and names == ['nope']:
+ return self.frame(Rwalk, tag, s16(0))
+ if self.mode == 'rerror_big' and names == ['nope']:
+ return self.err(tag, 'x' * 65535)
+ return self.err(tag, 'file does not exist')
+ if n == nw:
+ self.fids[newfid] = [node, False]
+ if self.mode == 'rwalk_many' and names == ['f']:
+ return self.frame(Rwalk, tag, s16(n + 1) + qids + node.qid(self))
+ return self.frame(Rwalk, tag, s16(n) + qids)
+ if typ == Topen:
+ fid, mode = r.u32(), r.u8()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node = self.fids[fid][0]
+ if node.isdir and (mode & 3) != 0:
+ return self.err(tag, 'is a directory')
+ if mode & 0x10 and not node.isdir:
+ node.content = bytearray()
+ self.fids[fid][1] = True
+ iounit = 1 if self.mode == 'iounit_one' else 0
+ return self.frame(Ropen, tag, node.qid(self) + s32(iounit))
+ if typ == Tcreate:
+ fid = r.u32()
+ name = r.str()
+ perm = r.u32()
+ mode = r.u8()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ parent = self.fids[fid][0]
+ if not parent.isdir:
+ return self.err(tag, 'not a directory')
+ if name in parent.children:
+ return self.err(tag, 'file already exists')
+ node = self.mk(parent, name, bool(perm & DMDIR))
+ node.mode = perm & 0o777
+ self.fids[fid] = [node, True]
+ return self.frame(Rcreate, tag, node.qid(self) + s32(0))
+ if typ == Tread:
+ fid, off, count = r.u32(), r.u64(), r.u32()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node, opened = self.fids[fid]
+ if not opened:
+ return self.err(tag, 'fid not open')
+ if node.isdir:
+ return self.readdir(tag, node, off, count)
+ if node.name == 'fids':
+ data = ('%d\n' % len(self.fids)).encode()
+ data = data[off:off + count]
+ return self.frame(Rread, tag, s32(len(data)) + data)
+ data = bytes(node.content[off:off + count])
+ if node is self.root.children.get('f'):
+ m = self.mode
+ if m == 'trunc':
+ fr = self.frame(Rread, tag, s32(len(data)) + data)
+ self.conn.sendall(fr[:len(fr) // 2])
+ return None
+ if m == 'short_frame':
+ return s32(3) + s8(Rread) + s16(tag)
+ if m == 'huge_frame':
+ return s32(self.msize + 1) + s8(Rread) + s16(tag) + s32(len(data)) + data
+ if m == 'wrong_tag':
+ return self.frame(Rread, (tag + 1) & 0xFFFF, s32(len(data)) + data)
+ if m == 'rread_big':
+ data = b'x' * (count + 1)
+ return self.frame(Rread, tag, s32(len(data)) + data)
+ if m == 'extra_reply':
+ return self.frame(Rclunk, 9, b'') + self.frame(Rread, tag, s32(len(data)) + data)
+ if m == 'never':
+ time.sleep(3600)
+ return None
+ if m == 'close_mid':
+ return None
+ if m == 'renegotiate':
+ return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('9P2000')) + self.frame(Rread, tag, s32(len(data)) + data)
+ return self.frame(Rread, tag, s32(len(data)) + data)
+ if typ == Twrite:
+ fid, off = r.u32(), r.u64()
+ data = r.data()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node, opened = self.fids[fid]
+ if not opened or node.isdir:
+ return self.err(tag, 'fid not open for write')
+ if node.name == 'fids':
+ return self.err(tag, 'permission denied')
+ if off > len(node.content):
+ node.content.extend(b'\0' * (off - len(node.content)))
+ node.content[off:off + len(data)] = data
+ node.mtime = int(time.time())
+ n = len(data) + 1 if self.mode == 'rwrite_big' else len(data)
+ return self.frame(Rwrite, tag, s32(n))
+ if typ == Tclunk:
+ fid = r.u32()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ del self.fids[fid]
+ return self.frame(Rclunk, tag, b'')
+ if typ == Tremove:
+ fid = r.u32()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node = self.fids[fid][0]
+ del self.fids[fid]
+ if node is self.root:
+ return self.err(tag, 'cannot remove root')
+ if node.isdir and node.children:
+ return self.err(tag, 'directory not empty')
+ parent = self.find_parent(self.root, node)
+ if parent is not None:
+ del parent.children[node.name]
+ self.free_paths.append(node.path)
+ node.removed = True
+ return self.frame(Rremove, tag, b'')
+ if typ == Tstat:
+ fid = r.u32()
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node = self.fids[fid][0]
+ if node is self.root.children.get('f'):
+ m = self.mode
+ if m == 'wrong_type':
+ return self.frame(Rwalk, tag, s16(0))
+ if m == 'rstat_garbage':
+ junk = bytes([0xAB] * 60)
+ return self.frame(Rstat, tag, s16(len(junk)) + junk)
+ if m == 'rstat_overlong':
+ st = node.stat_bytes(self)
+ inner = st[2:]
+ return self.frame(Rstat, tag, s16(len(inner) + 5) + inner)
+ if m == 'length_max':
+ st = node.stat_bytes(self, length=2 ** 64 - 1)
+ return self.frame(Rstat, tag, s16(len(st)) + st)
+ st = node.stat_bytes(self)
+ return self.frame(Rstat, tag, s16(len(st)) + st)
+ if typ == Twstat:
+ fid = r.u32()
+ r.u16()
+ st = r.bytes(r.u16())
+ if fid not in self.fids:
+ return self.err(tag, 'unknown fid')
+ node = self.fids[fid][0]
+ sr = Reader(st)
+ sr.u16(); sr.u32(); sr.bytes(13)
+ mode = sr.u32(); sr.u32(); mtime = sr.u32(); length = sr.u64()
+ name = sr.str()
+ if name and name != node.name:
+ if self.mode == 'rename_fail':
+ return self.err(tag, 'file already exists')
+ parent = self.find_parent(self.root, node)
+ if name in parent.children:
+ return self.err(tag, 'file already exists')
+ del parent.children[node.name]
+ node.name = name
+ parent.children[name] = node
+ if mode != 0xFFFFFFFF:
+ node.mode = mode & 0o777
+ if mtime != 0xFFFFFFFF:
+ node.mtime = mtime
+ if length != 0xFFFFFFFFFFFFFFFF and not node.isdir:
+ if length < len(node.content):
+ del node.content[length:]
+ else:
+ node.content.extend(b'\0' * (length - len(node.content)))
+ return self.frame(Rwstat, tag, b'')
+ return self.err(tag, 'unsupported message')
+
+ def find_parent(self, cur, node):
+ for c in cur.children.values():
+ if c is node:
+ return cur
+ if c.isdir:
+ p = self.find_parent(c, node)
+ if p is not None:
+ return p
+ return None
+
+ def readdir(self, tag, node, off, count):
+ recs = []
+ if node is self.root:
+ m = self.mode
+ if m == 'name_slash':
+ recs.append(node.stat_bytes(self, name='a/b'))
+ if m == 'name_empty':
+ recs.append(node.stat_bytes(self, name=''))
+ if m == 'name_huge':
+ recs.append(node.stat_bytes(self, name='h' * 60000))
+ if m == 'name_dots':
+ recs.append(node.stat_bytes(self, name='.'))
+ recs.append(node.stat_bytes(self, name='..'))
+ for c in node.children.values():
+ recs.append(c.stat_bytes(self))
+ blob = b''.join(recs)
+ if node is self.root and self.mode == 'dir_forever':
+ return self.frame(Rread, tag, s32(len(blob)) + blob)
+ if node is self.root and self.mode == 'dir_split':
+ # first read: up to the middle of the second record; second read: the rest
+ cut = len(recs[0]) + len(recs[1]) // 2
+ if off == 0:
+ data = blob[:cut]
+ elif off == cut:
+ data = blob[cut:]
+ else:
+ data = b''
+ return self.frame(Rread, tag, s32(len(data)) + data)
+ # 9P rule: offset 0 or previous offset+count; never split a record.
+ out = b''
+ pos = 0
+ for rec in recs:
+ if pos >= off and len(out) + len(rec) <= count:
+ out += rec
+ elif pos >= off:
+ break
+ pos += len(rec)
+ return self.frame(Rread, tag, s32(len(out)) + out)
+
+
+class Reader:
+ def __init__(self, b):
+ self.b = b
+ self.i = 0
+
+ def bytes(self, n):
+ v = self.b[self.i:self.i + n]
+ self.i += n
+ return v
+
+ def u8(self): return struct.unpack('<B', self.bytes(1))[0]
+ def u16(self): return struct.unpack('<H', self.bytes(2))[0]
+ def u32(self): return struct.unpack('<I', self.bytes(4))[0]
+ def u64(self): return struct.unpack('<Q', self.bytes(8))[0]
+ def str(self): return self.bytes(self.u16()).decode('utf-8', 'surrogateescape')
+ def data(self): return self.bytes(self.u32())
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__ + MODES)
+ sys.exit(2)
+ path, mode = sys.argv[1], sys.argv[2]
+ if mode not in [l.split()[0] for l in MODES.strip().splitlines()]:
+ print('unknown mode', mode)
+ sys.exit(2)
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+ ls = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ ls.bind(path)
+ ls.listen(8)
+ while True:
+ conn, _ = ls.accept()
+ srv = Server(mode)
+ srv.conn = conn
+ try:
+ srv.serve(conn)
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+ finally:
+ conn.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/9player/test/adv_bridge_hostile.sh b/9player/test/adv_bridge_hostile.sh
new file mode 100755
index 0000000..f1ee292
--- /dev/null
+++ b/9player/test/adv_bridge_hostile.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Hostile-server tests: 9player against 9player/test/adv_bridge_hostile.py in every
+# misbehaviour mode. 9player must never crash (panic/segfault) and must turn
+# each misbehaviour into an errno for the child.
+# Usage: bash 9player/test/adv_bridge_hostile.sh <9player> <introspect> (introspect unused; part of zig build 9player-adv)
+set -u
+PLAYER=$(realpath "${1:?path to 9player}")
+HERE=$(cd "$(dirname "$0")" && pwd)
+SRV=$HERE/adv_bridge_hostile.py
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-adv.XXXXXX")
+M=/mnt/9p
+FAILED=0
+PASSED=0
+SRVPID=
+
+cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; pkill -f "adv_bridge_hostile.py $TMP" 2>/dev/null; rm -rf "$TMP"; }
+trap cleanup EXIT
+
+if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi
+
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
+expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; }
+
+start_server() { # mode
+ [ -n "$SRVPID" ] && { kill "$SRVPID" 2>/dev/null; wait "$SRVPID" 2>/dev/null; }
+ SOCK=$TMP/$1.sock
+ rm -f "$SOCK"
+ python3 "$SRV" "$SOCK" "$1" >"$TMP/$1.srv.out" 2>&1 </dev/null &
+ SRVPID=$!
+ for _ in $(seq 1 100); do [ -S "$SOCK" ] && return 0; sleep 0.02; done
+ echo "server for $1 did not start"; cat "$TMP/$1.srv.out"; exit 1
+}
+
+# run MODE SCRIPT [extra 9player args...]: starts the server, runs 9player; sets OUT, RC, STDERR.
+run() {
+ local mode=$1 script=$2; shift 2
+ start_server "$mode"
+ OUT=$(timeout 30 "$PLAYER" --unix "$SOCK" "$@" -- sh -c "$script" 2>"$TMP/stderr")
+ RC=$?
+ STDERR=$(cat "$TMP/stderr")
+}
+
+# 9player must not die of a signal or panic. RC 124 = timeout(1) fired.
+no_crash() { # name
+ if [ "$RC" -ge 128 ] || [ "$RC" -eq 124 ]; then fail "$1: 9player exit $RC" "$STDERR"; return; fi
+ case "$STDERR" in *panic*|*"Segmentation"*|*"integer overflow"*|*"reached unreachable"*|*"index out of bounds"*) fail "$1: crash text in stderr" "$STDERR";; *) pass "$1: no crash (exit $RC)";; esac
+}
+
+echo "# sanity: the hostile server behaves in 'ok' mode"
+run ok "cat $M/f"; expect_eq "ok: cat f" "hello world" "$OUT"
+run ok "cat $M/d/g"; expect_eq "ok: nested" "in d" "$OUT"
+run ok "cat $M/nope 2>&1 | sed 's/.*: //'"; expect_eq "ok: ENOENT" "No such file or directory" "$OUT"
+run ok "head -c 1048576 $M/big | wc -c | grep -q 1048576 && echo yes"; expect_eq "ok: 1 MiB read matches" "yes" "$OUT"
+
+echo "# unlink + recreate with a recycled qid.path"
+run ok "echo 1 > $M/a; rm $M/a; echo 2 > $M/a; cat $M/a; rm $M/a"; expect_eq "qid reuse: new content, not stale" "2" "$OUT"
+run ok "echo 1 > $M/x; rm $M/x; mkdir $M/x; stat -c %F $M/x; rmdir $M/x"; expect_eq "qid reuse: file→dir on the same path" "directory" "$OUT"
+
+echo "# fids do not grow with the number of operations"
+loop='i=0; while [ $i -lt N ]; do echo hi > M/t; cat M/t >/dev/null; mkdir M/dd; rmdir M/dd; rm M/t; i=$((i+1)); done; cat M/fids'
+run ok "$(echo "$loop" | sed "s|N|20|; s|M/|$M/|g")"; a=$OUT
+run ok "$(echo "$loop" | sed "s|N|200|; s|M/|$M/|g")"; b=$OUT
+expect_eq "fids after 20 == after 200 iterations ($a)" "$a" "$b"
+floop='i=0; while [ $i -lt N ]; do cat M/nope 2>/dev/null; echo x > M/fids 2>/dev/null; mkdir M/f 2>/dev/null; rm M/d 2>/dev/null; mv M/f M/d 2>/dev/null; i=$((i+1)); done; cat M/fids'
+run ok "$(echo "$floop" | sed "s|N|20|; s|M/|$M/|g")"; a=$OUT
+run ok "$(echo "$floop" | sed "s|N|200|; s|M/|$M/|g")"; b=$OUT
+expect_eq "fids after 20 == after 200 failing iterations ($a)" "$a" "$b"
+
+echo "# rename over an existing file must not lose the target when the rename fails"
+run rename_fail "echo A > $M/a; echo B > $M/b; mv $M/a $M/b 2>/dev/null; echo mv=\$?; cat $M/b; cat $M/a"
+expect_contains "rename_fail: mv reports failure" "mv=1" "$OUT"
+expect_contains "rename_fail: target b still has its content" "B" "$OUT"
+expect_contains "rename_fail: source a still has its content" "A" "$OUT"
+
+echo "# protocol violations on a data read must yield an error, not a crash"
+for mode in trunc short_frame huge_frame wrong_tag rread_big extra_reply close_mid renegotiate rwrite_big; do
+ if [ "$mode" = rwrite_big ]; then script="dd if=/dev/zero of=$M/f bs=10 count=1 2>&1; echo status=\$?"; else script="cat $M/f 2>&1; echo status=\$?"; fi
+ run "$mode" "$script"
+ no_crash "$mode"
+ expect_contains "$mode: child sees an error" "status=1" "$OUT"
+ case "$OUT" in *"Input/output error"*|*"not connected"*) pass "$mode: EIO/ENOTCONN";; *) fail "$mode: errno text" "$OUT";; esac
+done
+
+echo "# protocol violations on lookup/stat"
+for mode in wrong_type rwalk_many rstat_garbage rstat_overlong; do
+ run "$mode" "stat -c %s $M/f 2>&1; echo status=\$?"
+ no_crash "$mode"
+ expect_contains "$mode: child sees an error" "status=1" "$OUT"
+done
+run rwalk_zero "cat $M/nope 2>&1; echo status=\$?; cat $M/f 2>&1"
+no_crash "rwalk_zero"
+expect_contains "rwalk_zero: child sees an error" "status=1" "$OUT"
+run rerror_big "cat $M/nope 2>&1; echo status=\$?; cat $M/f"
+no_crash "rerror_big"
+expect_contains "rerror_big: child sees an error" "status=1" "$OUT"
+expect_contains "rerror_big: session survives a 64 KiB Rerror" "hello world" "$OUT"
+
+echo "# hostile stat contents"
+run length_max "stat -c '%s %b' $M/f 2>&1; echo status=\$?"
+no_crash "length_max"
+expect_contains "length_max: stat succeeds with a saturated block count" "status=0" "$OUT"
+run iounit_one "cat $M/f; head -c 3000 $M/big | wc -c"
+no_crash "iounit_one"
+expect_contains "iounit_one: read still complete" "hello world" "$OUT"
+expect_contains "iounit_one: 3000 bytes" "3000" "$OUT"
+
+echo "# hostile directory listings"
+run dir_split "ls $M 2>&1; echo status=\$?; cat $M/f"
+no_crash "dir_split"
+expect_contains "dir_split: readdir fails with EIO" "Input/output error" "$OUT"
+expect_contains "dir_split: session survives" "hello world" "$OUT"
+run dir_forever "ls $M 2>&1 | tail -c 200; echo status=\$?; cat $M/f; grep VmRSS /proc/\$PPID/status"
+no_crash "dir_forever"
+expect_contains "dir_forever: infinite directory is cut off with EIO" "Input/output error" "$OUT"
+expect_contains "dir_forever: session survives" "hello world" "$OUT"
+for mode in name_slash name_empty name_huge name_dots; do
+ run "$mode" "ls -a $M | tr '\n' ' '; echo; cat $M/f"
+ no_crash "$mode"
+ expect_contains "$mode: listing still works" "big d f fids" "$OUT"
+ expect_contains "$mode: file readable" "hello world" "$OUT"
+ case "$mode" in
+ name_slash) expect_eq "$mode: slash entry dropped" "" "$(printf '%s' "$OUT" | grep -o 'a/b')";;
+ name_dots) expect_eq "$mode: exactly one . and one .." "1 1" "$(printf '%s %s' "$(printf '%s\n' "$OUT" | head -1 | tr ' ' '\n' | grep -c '^\.$')" "$(printf '%s\n' "$OUT" | head -1 | tr ' ' '\n' | grep -c '^\.\.$')")";;
+ esac
+done
+
+echo "# qid collisions"
+run qid_collide "cat $M/f; cat $M/d/g; ls $M/d; stat -c %i $M/f $M/d 2>&1; echo status=\$?"
+no_crash "qid_collide"
+expect_contains "qid_collide: reads work" "hello world" "$OUT"
+run qid_zero "cat $M/f; ls $M | tr '\n' ' '; echo; cat $M/d/g; echo status=\$?"
+no_crash "qid_zero"
+expect_contains "qid_zero: file with root's qid.path is still a readable file" "hello world" "$OUT"
+expect_contains "qid_zero: root still lists" "big d f fids" "$OUT"
+expect_contains "qid_zero: nested file readable" "in d" "$OUT"
+
+echo "# version negotiation"
+run version_unknown "echo ran"
+expect_eq "version_unknown: 9player refuses (125)" "125" "$RC"
+run msize_tiny "cat $M/f 2>&1; echo status=\$?"
+no_crash "msize_tiny"
+
+echo "# a server that never replies"
+start_server never
+# SIGTERM is forwarded to the child; once the child is gone 9player must leave the
+# pending 9P reply behind and exit even though the server stays silent.
+timeout -s TERM 3 "$PLAYER" --unix "$SOCK" -- sh -c "cat $M/f; echo unreachable" >"$TMP/never.out" 2>"$TMP/never.err" &
+TPID=$!
+sleep 4
+if kill -0 "$TPID" 2>/dev/null; then
+ fail "never: SIGTERM did not end 9player while a reply was outstanding"; kill -9 "$TPID"
+else
+ pass "never: SIGTERM ends 9player even while the server is silent"
+fi
+wait "$TPID" 2>/dev/null
+# Without a signal the mount hangs (documented v1 limitation) until the server dies.
+timeout 30 "$PLAYER" --unix "$SOCK" -- sh -c "cat $M/f; echo unreachable" >"$TMP/never.out" 2>"$TMP/never.err" &
+TPID=$!
+sleep 1.5
+if kill -0 "$TPID" 2>/dev/null; then
+ pass "never: mount hangs while the server is silent (documented v1 limitation)"
+ kill "$SRVPID"; wait "$SRVPID" 2>/dev/null; SRVPID=
+ for _ in $(seq 1 50); do kill -0 "$TPID" 2>/dev/null || break; sleep 0.1; done
+ if kill -0 "$TPID" 2>/dev/null; then fail "never: 9player still alive after its server died"; kill -9 "$TPID"; else pass "never: killing the server unblocks 9player"; fi
+else
+ wait "$TPID"; fail "never: 9player exited early ($?)" "$(cat "$TMP/never.err")"
+fi
+
+echo "# interrupting a slow read (INTERRUPT must not confuse reply matching)"
+run slow "cat $M/big > /dev/null; cat $M/f; cat $M/fids" --msize 8192
+base=$(printf '%s\n' "$OUT" | tail -1)
+run slow "(cat $M/big > /dev/null & sleep 0.3; kill -INT \$!; wait \$!) 2>/dev/null; cat $M/f; cat $M/fids" --msize 8192
+no_crash "slow"
+expect_contains "slow: read after interrupted read works" "hello world" "$OUT"
+expect_eq "slow: fids after an interrupted read == after a complete one ($base)" "$base" "$(printf '%s\n' "$OUT" | tail -1)"
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]
diff --git a/9player/test/adv_bridge_semantics.sh b/9player/test/adv_bridge_semantics.sh
new file mode 100755
index 0000000..f13fd57
--- /dev/null
+++ b/9player/test/adv_bridge_semantics.sh
@@ -0,0 +1,205 @@
+#!/usr/bin/env bash
+# FUSE semantics through the bridge against introspect's /scratch tree.
+# Usage: bash 9player/test/adv_bridge_semantics.sh <9player> <introspect> (part of zig build 9player-adv)
+set -u
+PLAYER=$(realpath "${1:?path to 9player}")
+INTROSPECT=$(realpath "${2:?path to introspect}")
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-sem.XXXXXX")
+M=/mnt/9p
+S=$M/scratch
+FAILED=0
+PASSED=0
+SRVPID=
+cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; rm -rf "$TMP"; }
+trap cleanup EXIT
+if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi
+
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
+expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; }
+
+SOCK=$TMP/i.sock
+"$INTROSPECT" --unix "$SOCK" >"$TMP/srv.out" 2>&1 &
+SRVPID=$!
+for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.02; done
+# Each run is a fresh session; state persists in the server, so tests clean up after themselves.
+run() { OUT=$(timeout 120 "$PLAYER" --unix "$SOCK" "${EXTRA[@]}" -- sh -c "$1" 2>"$TMP/stderr"); RC=$?; STDERR=$(cat "$TMP/stderr"); }
+EXTRA=()
+py() { run "python3 - <<'PYEOF'
+$1
+PYEOF"; }
+
+echo "# open/create flags"
+py "
+import os, errno
+p='$S/excl'
+fd=os.open(p, os.O_CREAT|os.O_WRONLY, 0o644); os.write(fd, b'x'); os.close(fd)
+try:
+ os.open(p, os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o644); print('no error')
+except OSError as e: print(errno.errorcode[e.errno])
+os.unlink(p)
+fd=os.open('$S/ro', os.O_CREAT|os.O_RDONLY, 0o644); print(os.read(fd, 10)); os.close(fd)
+print(os.path.exists('$S/ro')); os.unlink('$S/ro')
+"
+expect_eq "O_EXCL on an existing file is EEXIST" "EEXIST" "$(printf '%s\n' "$OUT" | sed -n 1p)"
+expect_eq "create with O_RDONLY works and reads empty" $'b\'\'\nTrue' "$(printf '%s\n' "$OUT" | sed -n 2,3p)"
+
+run "mkdir -m 700 $S/m7 && stat -c %a $S/m7; chmod 755 $S/m7 && stat -c %a $S/m7; rmdir $S/m7"
+expect_eq "mkdir -m 700 then chmod 755" $'700\n755' "$OUT"
+run "echo x > $S/c && chmod 600 $S/c && stat -c %a $S/c; chmod 444 $S/c && stat -c %a $S/c; rm -f $S/c"
+expect_eq "chmod on a file" $'600\n444' "$OUT"
+run "echo x > $S/t && touch -d @1000000000 $S/t && stat -c %Y $S/t; touch $S/t && [ \$(stat -c %Y $S/t) -gt 1000000000 ] && echo now; rm $S/t"
+expect_eq "utimes (explicit) and touch (now)" $'1000000000\nnow' "$OUT"
+run "echo abc > $S/tr && truncate -s 10 $S/tr && stat -c %s $S/tr && od -An -c $S/tr | tr -s ' ' | tr -d '\n'; echo; rm $S/tr"
+expect_eq "truncate to larger zero-fills" $'10\n a b c \\n \\0 \\0 \\0 \\0 \\0 \\0' "$OUT"
+run "echo a > $S/ap && echo b >> $S/ap && echo c >> $S/ap && cat $S/ap | tr '\n' ' '; rm $S/ap"
+expect_eq "shell append" "a b c " "$OUT"
+py "
+import os
+p='$S/ap2'
+f1=os.open(p, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0o644)
+f2=os.open(p, os.O_WRONLY|os.O_APPEND)
+os.write(f1, b'one '); os.write(f2, b'two '); os.write(f1, b'three')
+os.close(f1); os.close(f2)
+print(open(p).read()); os.unlink(p)
+"
+expect_eq "O_APPEND from two descriptors interleaves in order" "one two three" "$OUT"
+run "echo 0123456789 > $S/tt && (echo X > $S/tt) && cat $S/tt && stat -c %s $S/tt; rm $S/tt"
+expect_eq "O_TRUNC (atomic_o_trunc) truncates before write" $'X\n2' "$OUT"
+
+echo "# reads at the edges"
+run "printf hello > $S/e; dd if=$S/e bs=1 skip=100 count=5 2>/dev/null | wc -c; head -c 0 $S/e | wc -c; dd if=/dev/null of=$S/e bs=1 count=0 conv=notrunc 2>/dev/null; cat $S/e; echo; rm $S/e"
+expect_eq "read past EOF is 0 bytes; 0-byte read/write are no-ops" $'0\n0\nhello' "$OUT"
+head -c 4194304 /dev/urandom >"$TMP/four"
+SUM=$(sha256sum <"$TMP/four" | cut -d' ' -f1)
+run "dd if=$TMP/four of=$S/four bs=4M status=none && dd if=$S/four bs=4M status=none | sha256sum | cut -d' ' -f1; stat -c %s $S/four; rm $S/four"
+expect_eq "4 MiB single-request dd round trip" "$SUM"$'\n4194304' "$OUT"
+py "
+import os
+p='$S/lseek'
+open(p,'w').write('0123456789')
+f=open(p,'rb'); f.seek(0, 2); print(f.tell()); f.seek(-3, 2); print(f.read()); f.close()
+os.unlink(p)
+"
+expect_eq "lseek SEEK_END on a direct_io file" $'10\nb\'789\'' "$OUT"
+
+echo "# unlink of an open file"
+py "
+import os
+p='$S/unl'
+fd=os.open(p, os.O_CREAT|os.O_RDWR, 0o644)
+os.write(fd, b'before')
+os.unlink(p)
+print(os.path.exists(p))
+os.lseek(fd, 0, 0); print(os.read(fd, 100))
+os.write(fd, b'-after'); os.lseek(fd, 0, 0); print(os.read(fd, 100))
+os.close(fd)
+"
+expect_eq "read/write through the fd after unlink" $'False\nb\'before\'\nb\'before-after\'' "$OUT"
+
+echo "# rename"
+run "echo A > $S/ra; echo B > $S/rb; mv $S/ra $S/rb && cat $S/rb; ls $S | tr '\n' ' '; echo; rm $S/rb"
+expect_eq "rename over an existing file replaces it, no leftovers" $'A\nrb ' "$OUT"
+run "mkdir $S/rd1 && echo x > $S/rd1/f && mv $S/rd1 $S/rd2 && cat $S/rd2/f && ls $S/rd2; rm -r $S/rd2; ls $S | wc -l"
+expect_eq "rename of a directory" $'x\nf\n0' "$OUT"
+run "mkdir $S/e1 $S/e2 && mv -T $S/e1 $S/e2 && ls $S | tr '\n' ' '; rmdir $S/e2"
+expect_eq "rename dir over an empty dir" "e2 " "$OUT"
+run "mkdir $S/n1 $S/n2 && echo x > $S/n2/f && mv -T $S/n1 $S/n2 2>&1 | sed 's/.*: //'; rm -r $S/n1 $S/n2"
+expect_eq "rename dir over a non-empty dir is ENOTEMPTY" "Directory not empty" "$OUT"
+py "
+import os
+p='$S/same'; open(p,'w').write('x'); os.rename(p, p); print(open(p).read()); os.unlink(p)
+"
+expect_eq "rename onto itself is a no-op" "x" "$OUT"
+py "
+import os, ctypes, errno
+libc = ctypes.CDLL(None, use_errno=True)
+a, b = b'$S/nra', b'$S/nrb'
+open(a,'w').write('A'); open(b,'w').write('B')
+r = libc.renameat2(-100, a, -100, b, 1) # RENAME_NOREPLACE
+print('rc', r, errno.errorcode.get(ctypes.get_errno()))
+print(open(b).read())
+os.unlink(a); os.unlink(b)
+"
+expect_eq "RENAME_NOREPLACE keeps the target" $'rc -1 EEXIST\nB' "$OUT"
+
+echo "# directories"
+run "mkdir $S/many && cd $S/many && i=0; while [ \$i -lt 5000 ]; do : > f\$i; i=\$((i+1)); done; ls | wc -l; ls -l | wc -l; grep VmRSS /proc/\$PPID/status | awk '{print \$2}' > $TMP/rss1; rm -f $S/many/*; rmdir $S/many; ls | wc -l; grep VmRSS /proc/\$PPID/status | awk '{print \$2}' > $TMP/rss2"
+expect_eq "5000 entries: ls and ls -l" $'5000\n5001\n0' "$OUT"
+r1=$(cat "$TMP/rss1"); r2=$(cat "$TMP/rss2")
+if [ "$r2" -le $((r1 + 2048)) ]; then pass "RSS after cleanup ($r2 KiB) <= after listing ($r1 KiB)+2 MiB"; else fail "RSS grew after cleanup: $r1 -> $r2 KiB"; fi
+py "
+import os
+d='$S/chg'; os.mkdir(d)
+for i in range(50): open(f'{d}/a{i}','w').close()
+it = os.scandir(d); first = next(it).name
+for i in range(3000): open(f'{d}/b{i}','w').close()
+rest = [e.name for e in it]
+print(first[0], len(rest) >= 49, len(set(rest)) == len(rest))
+for n in os.listdir(d): os.unlink(f'{d}/{n}')
+os.rmdir(d)
+"
+expect_eq "readdir of a directory that changes mid-iteration" "a True True" "$OUT"
+run "ls $M/.. > /dev/null && echo ok; stat -c %i $M $M/. $M/scratch/..; cd $M/scratch && ls .. | grep -c scratch"
+expect_eq ".. of the root and of a subdir" $'ok\n1\n1\n1\n1' "$OUT"
+run "cd $M && find . -type d | wc -l && find . -type f | head -1 && find $S -type f | wc -l"
+expect_contains "find -type works" "./README" "$OUT"
+run "stat -f -c '%T %S %l' $M; df -P $M | tail -1 | awk '{print \$1}'; sync -f $M && echo synced; sync && echo synced2"
+expect_eq "statfs, df, syncfs, sync" $'fuse 4096 255\n9player\nsynced\nsynced2' "$OUT"
+
+echo "# server refusals keep their errno through the error path"
+run "echo x > $M/build/zig_version; a=\$?; mkdir $M/build/x 2>/dev/null; b=\$?; rm $M/README 2>/dev/null; c=\$?; rmdir $M/build 2>/dev/null; d=\$?; echo \$a\$b\$c\$d" 2>/dev/null
+expect_eq "open-for-write / mkdir / rm / rmdir on read-only nodes all fail (errno preserved through error path)" "1111" "$(printf '%s\n' "$OUT" | tail -1)"
+
+echo "# unsupported operations fail cleanly"
+run "echo x > $S/l1; ln $S/l1 $S/l2 2>&1 | sed 's/.*: //'; ln -s l1 $S/l3 2>&1 | sed 's/.*: //'; mkfifo $S/p 2>&1 | sed 's/.*: //'; ls $S | tr '\n' ' '; echo; rm $S/l1"
+# The kernel turns ENOSYS from LINK into EPERM (fuse_link); symlink/mknod keep ENOSYS.
+expect_eq "link/symlink/mknod fail cleanly" $'Operation not permitted\nFunction not implemented\nFunction not implemented\nl1 ' "$OUT"
+run "echo x > $S/x1; setfattr -n user.a -v 1 $S/x1 2>&1 | sed 's/.*: //'; getfattr -n user.a $S/x1 2>&1 | sed 's/.*: //'; getfattr -d $S/x1 2>&1 | sed 's/.*: //'; rm $S/x1"
+expect_eq "xattr ops are EOPNOTSUPP" $'Operation not supported\nOperation not supported\nOperation not supported' "$OUT"
+py "
+import os, fcntl, mmap, errno
+p='$S/mm'; open(p,'w').write('mapme')
+fd=os.open(p, os.O_RDWR)
+fcntl.flock(fd, fcntl.LOCK_EX); fcntl.flock(fd, fcntl.LOCK_UN); fcntl.lockf(fd, fcntl.LOCK_EX); fcntl.lockf(fd, fcntl.LOCK_UN); print('locks ok')
+try:
+ m = mmap.mmap(fd, 5); print('shared', bytes(m)); m.close()
+except OSError as e: print('shared', errno.errorcode[e.errno])
+try:
+ m = mmap.mmap(fd, 5, flags=mmap.MAP_PRIVATE, prot=mmap.PROT_READ); print('private', bytes(m)); m.close()
+except OSError as e: print('private', errno.errorcode[e.errno])
+os.close(fd); os.unlink(p)
+"
+expect_contains "flock/lockf work (local locks)" "locks ok" "$OUT"
+case "$OUT" in *"shared ENODEV"*|*"shared b'mapme'"*) pass "shared mmap: clean result ($(printf '%s\n' "$OUT" | sed -n 2p))";; *) fail "shared mmap" "$OUT";; esac
+expect_contains "private mmap reads the file" "private b'mapme'" "$OUT"
+
+echo "# tools"
+mkdir -p "$TMP/tree/sub/deeper"; echo one > "$TMP/tree/a"; echo two > "$TMP/tree/sub/b"; head -c 70000 /dev/urandom > "$TMP/tree/sub/deeper/blob"; chmod 640 "$TMP/tree/a"
+run "cp -a $TMP/tree $S/tree 2>&1; diff -r $TMP/tree $S/tree && echo same; stat -c %a $S/tree/a; cp -a $S/tree $TMP/back && diff -r $TMP/tree $TMP/back && echo back; rm -r $S/tree"
+expect_eq "cp -a there and back" $'same\n640\nback' "$OUT"
+run "cd $TMP && tar cf $S/t.tar tree && cd $S && mkdir tx && tar xf t.tar -C tx && diff -r $TMP/tree tx/tree && echo tar-ok; rm -r $S/tx $S/t.tar"
+expect_eq "tar into and out of the mount" "tar-ok" "$OUT"
+run "rsync -a $TMP/tree/ $S/rs/ && diff -r $TMP/tree $S/rs && echo rsync-ok; sleep 1.1; echo mod > $TMP/tree/a; rsync -a $TMP/tree/ $S/rs/ && cat $S/rs/a; rm -r $S/rs"
+expect_eq "rsync -a twice" $'rsync-ok\nmod' "$OUT"
+run "cd $S && mkdir repo && cd repo && git init -q . && git config user.email a@b && git config user.name n && echo hi > f && git add f && git commit -qm init && git log --oneline | wc -l && git status --porcelain | wc -l; cd $S && rm -rf repo; ls $S | wc -l"
+expect_eq "git init/add/commit inside the mount" $'1\n0\n0' "$OUT"
+
+echo "# --no-direct-io"
+EXTRA=(--no-direct-io)
+run "cp $TMP/four $S/nd && cmp $TMP/four $S/nd && echo same; stat -c %s $S/nd; rm $S/nd"
+expect_eq "no-direct-io: 4 MiB round trip" $'same\n4194304' "$OUT"
+[ "$RC" -eq 0 ] || echo " stderr: $STDERR"
+# Buffered writes are per-page without a writeback cache (kernel behaviour); the
+# point here is only that a large buffered write is delivered intact.
+EXTRA=(--no-direct-io)
+head -c 262144 /dev/urandom > "$TMP/w"
+WSUM=$(sha256sum <"$TMP/w" | cut -d' ' -f1)
+run "cp $TMP/w $S/w && sha256sum < $S/w | cut -d' ' -f1; stat -c %s $S/w; rm $S/w"
+expect_eq "no-direct-io: 256 KiB buffered write is intact" "$WSUM"$'\n262144' "$OUT"
+EXTRA=()
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]
diff --git a/9player/test/adv_bridge_stress.sh b/9player/test/adv_bridge_stress.sh
new file mode 100755
index 0000000..3d1203a
--- /dev/null
+++ b/9player/test/adv_bridge_stress.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Resource and concurrency stress through the bridge against introspect.
+# Usage: bash 9player/test/adv_bridge_stress.sh <9player> <introspect> (~1-2 min; part of zig build 9player-adv)
+set -u
+PLAYER=$(realpath "${1:?path to 9player}")
+INTROSPECT=$(realpath "${2:?path to introspect}")
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-stress.XXXXXX")
+M=/mnt/9p
+S=$M/scratch
+FAILED=0
+PASSED=0
+SRVPID=
+cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; rm -rf "$TMP"; }
+trap cleanup EXIT
+if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
+
+SOCK=$TMP/i.sock
+"$INTROSPECT" --unix "$SOCK" >"$TMP/srv.out" 2>&1 &
+SRVPID=$!
+for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.02; done
+run() { OUT=$(timeout 600 "$PLAYER" --unix "$SOCK" -- sh -c "$1" 2>"$TMP/stderr"); RC=$?; STDERR=$(cat "$TMP/stderr"); }
+
+echo "# 100k+ 9P operations in one session; RSS must plateau"
+# Each iteration: create+write+close, open+read+close, unlink, plus a failing lookup: ~15 RPCs.
+run "rss() { grep VmRSS /proc/\$PPID/status | awk '{print \$2}'; }
+i=0; while [ \$i -lt 8000 ]; do echo \$i > $S/s; cat $S/s > /dev/null; rm $S/s; cat $S/none 2>/dev/null; i=\$((i+1)); if [ \$i -eq 2000 ]; then rss; fi; done; rss; ls $S | wc -l"
+r1=$(printf '%s\n' "$OUT" | sed -n 1p); r2=$(printf '%s\n' "$OUT" | sed -n 2p); left=$(printf '%s\n' "$OUT" | sed -n 3p)
+expect_eq "scratch left clean" "0" "$left"
+if [ -n "$r1" ] && [ -n "$r2" ] && [ "$r2" -le $((r1 + 4096)) ]; then pass "RSS at 2000 iterations = $r1 KiB, at 8000 = $r2 KiB"; else fail "RSS grows: $r1 -> $r2 KiB" "$STDERR"; fi
+case "$STDERR" in *leak*) fail "allocator reported leaks" "$STDERR";; *) pass "no leak report from the debug allocator";; esac
+
+echo "# eight processes hammering the mount concurrently"
+run "mkdir $S/par; for p in 1 2 3 4 5 6 7 8; do (
+ d=$S/par/p\$p; mkdir \$d; i=0; bad=0
+ while [ \$i -lt 300 ]; do
+ printf '%s-%s' \$p \$i > \$d/f\$((i % 7)); v=\$(cat \$d/f\$((i % 7))); [ \"\$v\" = \"\$p-\$i\" ] || bad=\$((bad+1))
+ mkdir \$d/dd; rmdir \$d/dd; ls \$d > /dev/null; i=\$((i+1))
+ done; rm -r \$d; echo \$p:\$bad ) & done; wait; ls $S/par | wc -l; rmdir $S/par"
+expect_eq "all workers verified their own data" "1:0 2:0 3:0 4:0 5:0 6:0 7:0 8:0" "$(printf '%s\n' "$OUT" | grep ':' | sort | tr '\n' ' ' | sed 's/ $//')"
+expect_eq "parallel tree fully removed" "0" "$(printf '%s\n' "$OUT" | grep -v ':')"
+
+echo "# a process killed mid-read and mid-write"
+run "head -c 8000000 /dev/urandom > $S/kb; (cat $S/kb > /dev/null & sleep 0.05; kill -9 \$!; wait \$!) 2>/dev/null; (cat /dev/zero > $S/kw & sleep 0.05; kill -9 \$!; wait \$!) 2>/dev/null; sha256sum < $S/kb | cut -c1-8 > /dev/null && echo readable; [ -f $S/kw ] && echo written; rm $S/kb $S/kw; ls $S | wc -l"
+expect_eq "survives SIGKILL mid-read/mid-write" $'readable\nwritten\n0' "$OUT"
+
+echo "# many open handles at once (fh counter, fid table)"
+run "python3 - <<'EOF'
+import os
+d='$S/fh'; os.mkdir(d)
+fds=[]
+for i in range(1500):
+ fd=os.open(f'{d}/h{i%50}', os.O_CREAT|os.O_RDWR, 0o644); os.write(fd, b'z'); fds.append(fd)
+for fd in fds: os.close(fd)
+for i in range(50): os.unlink(f'{d}/h{i}')
+os.rmdir(d); print('ok')
+EOF"
+expect_eq "1500 simultaneous handles" "ok" "$OUT"
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]
diff --git a/9player/test/adv_ns_process.sh b/9player/test/adv_ns_process.sh
new file mode 100755
index 0000000..4ce0ae8
--- /dev/null
+++ b/9player/test/adv_ns_process.sh
@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# Adversarial regression tests for 9player/src/ns.zig and 9player/src/main.zig: process,
+# namespace, signal and CLI handling. Real namespaces, real FUSE.
+# Usage: bash 9player/test/adv_ns_process.sh <9player> <introspect> (part of zig build 9player-adv)
+# Exit 0 on success (or when the machine cannot run the tests), 1 on failure.
+set -u
+
+PLAYER=$(realpath "${1:?path to 9player}")
+INTROSPECT=$(realpath "${2:?path to introspect}")
+# Unix socket paths are limited to ~107 bytes; keep the temp dir short.
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9padv.XXXXXX")
+PIDS=()
+FAILED=0
+PASSED=0
+
+cleanup() {
+ for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done
+ rm -rf "$TMP"
+}
+trap cleanup EXIT
+
+if ! unshare -Urm true 2>/dev/null; then echo "SKIP: unprivileged user namespaces unavailable"; exit 0; fi
+if [ ! -c /dev/fuse ]; then echo "SKIP: /dev/fuse missing"; exit 0; fi
+
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; }
+expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; }
+
+SOCK=$TMP/s
+"$INTROSPECT" --unix "$SOCK" &
+PIDS+=($!)
+for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done
+[ -S "$SOCK" ] || { echo "introspect did not create $SOCK"; exit 1; }
+MI_BEFORE=$(grep -v " $TMP" /proc/self/mountinfo | sort)
+
+TIMEOUT=$(command -v timeout)
+run() { "$TIMEOUT" 60 "$PLAYER" --unix "$SOCK" "$@"; }
+
+echo "# CLI"
+expect_eq "--help goes to stdout, exit 0" "Usage: 9player" "$(run --help 2>/dev/null | head -1 | cut -c1-14; )"
+expect_eq "--help exit code" "0" "$("$PLAYER" --help >/dev/null 2>&1; echo $?)"
+expect_eq "--version on stdout" "9player" "$("$PLAYER" --version 2>/dev/null | cut -d' ' -f1)"
+expect_eq "single-dash typo is a usage error, not a program" "125" "$(run -mount /x -- true 2>/dev/null; echo $?)"
+expect_contains "single-dash typo message" "unknown option -mount" "$(run -mount /x -- true 2>&1)"
+expect_eq "--unix= empty is a usage error" "125" "$("$PLAYER" --unix= -- true 2>/dev/null; echo $?)"
+expect_contains "--unix= message" "socket path" "$("$PLAYER" --unix= -- true 2>&1)"
+expect_eq "--mount '' is a usage error" "125" "$(run --mount '' -- true 2>/dev/null; echo $?)"
+expect_eq "--msize huge rejected" "125" "$(run --msize 4294967295 -- true 2>/dev/null; echo $?)"
+expect_eq "--msize 16 MiB accepted" "ok" "$(run --msize 16777216 -- sh -c 'echo ok')"
+expect_contains "empty program name is reported" "empty program name" "$(run -- '' 2>&1)"
+expect_eq "empty program name exit" "125" "$(run -- '' 2>/dev/null; echo $?)"
+expect_eq "empty \$SHELL falls back to /bin/sh" "0" "$(SHELL= run -- </dev/null >/dev/null 2>&1; echo $?)"
+expect_eq "--fd with a closed descriptor fails early" "125" "$("$PLAYER" --fd 987 -- true 2>/dev/null; echo $?)"
+expect_contains "--fd bad descriptor message" "--fd 987: EBADF" "$("$PLAYER" --fd 987 -- true 2>&1)"
+
+echo "# exec failures"
+expect_eq "not found is 127" "127" "$(run -- no-such-program-9player 2>/dev/null; echo $?)"
+expect_eq "PATH element that is a file: still 127" "127" "$(PATH=/etc/passwd run -- true 2>/dev/null; echo $?)"
+expect_contains "PATH element that is a file: message" "exec true: E" "$(PATH=/etc/passwd run -- true 2>&1)"
+printf '#!/bin/sh\necho no\n' >"$TMP/nx"; chmod 644 "$TMP/nx"
+expect_eq "non-executable is 126" "126" "$(run -- "$TMP/nx" 2>/dev/null; echo $?)"
+mkdir -p "$TMP/p1" "$TMP/p2"; cp "$TMP/nx" "$TMP/p1/prog"; printf '#!/bin/sh\necho right\n' >"$TMP/p2/prog"; chmod 755 "$TMP/p2/prog"
+expect_eq "non-executable first in PATH, executable later" "right" "$(PATH=$TMP/p1:$TMP/p2 run -- prog)"
+expect_eq "non-executable only in PATH is 126" "126" "$(PATH=$TMP/p1 run -- prog 2>/dev/null; echo $?)"
+expect_eq "argv[0] preserved" "sh" "$(run -- sh -c 'echo $0')"
+expect_eq "PATH unset uses default" "ok" "$(env -u PATH "$PLAYER" --unix "$SOCK" -- sh -c 'echo ok')"
+
+echo "# fd hygiene"
+# 9player passes inherited descriptors through untouched, so compare with what a
+# plain child of this script sees (the runner may itself hold extra fds).
+FD_LIST='ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"'
+FD_BASE=$(sh -c "$FD_LIST")
+expect_eq "no extra fds in the program (unix)" "$FD_BASE" "$(run -- sh -c "$FD_LIST")"
+expect_eq "no extra fds in the program (spawn)" "$FD_BASE" "$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c "$FD_LIST")"
+expect_eq "--fd transport does not leak into the program" "0 1 2" "$(python3 - "$PLAYER" "$SOCK" <<'EOF'
+import socket, subprocess, sys, os
+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(sys.argv[2])
+r = subprocess.run([sys.argv[1], "--fd", str(s.fileno()), "--", "sh", "-c",
+ 'ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"'],
+ pass_fds=[s.fileno()], capture_output=True, text=True)
+print(r.stdout.strip())
+EOF
+)"
+
+echo "# signals"
+expect_eq "SIGTERM forwarded" "143" "$(run -- sh -c 'kill -TERM $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)"
+expect_eq "SIGHUP forwarded" "129" "$(run -- sh -c 'kill -HUP $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)"
+expect_eq "SIGINT to 9player is ignored while the child lives" "still-here" "$(run -- sh -c 'kill -INT $PPID; sleep 0.3; echo still-here')"
+# Ctrl-C from the tty must not kill the --spawn server (same process group).
+expect_eq "Ctrl-C on the tty leaves the --spawn server alive" "ok" "$(timeout 30 python3 - "$PLAYER" "$INTROSPECT" <<'EOF'
+import os, pty, sys, time, select
+P, I = sys.argv[1], sys.argv[2]
+prog = ["python3", "-c", """
+import os, signal, sys, time
+signal.signal(signal.SIGINT, lambda *a: None)
+m = os.environ['NINEPLAYER_MOUNT']
+open(m + '/build/optimize').read()
+sys.stdin.readline()
+try:
+ open(m + '/build/optimize').read(); print('ok', flush=True)
+except Exception as e:
+ print('mount dead:', e, flush=True)
+"""]
+pid, fd = pty.fork()
+if pid == 0:
+ os.execv(P, [P, "--spawn", I + " --stdio", "--"] + prog)
+out = b""
+def rd(t):
+ global out
+ end = time.time() + t
+ while time.time() < end:
+ r, _, _ = select.select([fd], [], [], 0.1)
+ if r:
+ try: d = os.read(fd, 4096)
+ except OSError: return
+ if not d: return
+ out += d
+rd(1.5); os.write(fd, b"\x03"); rd(0.7); os.write(fd, b"\n"); rd(3)
+os.waitpid(pid, 0)
+print(out.decode(errors="replace").replace("^C", "").strip().splitlines()[-1] if out.strip() else "no output")
+EOF
+)"
+# The --spawn server dying mid-session is reaped (no zombie) and does not end the session.
+OUT=$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c 'srv=$(cat $NINEPLAYER_MOUNT/runtime/pid); kill -TERM $srv; sleep 0.5; st=$(ps -o stat= -p $srv 2>/dev/null); echo "${st:-gone}"; exit 5' 2>/dev/null); RC=$?
+expect_eq "server death mid-session: exit status still the child's, server reaped (no zombie)" "5 gone" "$RC $OUT"
+# A server that never answers: once the child is dead, SIGTERM must end 9player.
+cat >"$TMP/hang.py" <<'EOF'
+import struct, os, sys, time
+def rd(n):
+ b = b""
+ while len(b) < n:
+ c = os.read(0, n - len(b))
+ if not c: sys.exit(0)
+ b += c
+ return b
+while True:
+ size, = struct.unpack("<I", rd(4)); body = rd(size - 4)
+ typ, tag = struct.unpack("<BH", body[:3])
+ if typ == 100:
+ msize, = struct.unpack("<I", body[3:7]); v = b"9P2000"
+ r = struct.pack("<BHI", 101, tag, msize) + struct.pack("<H", len(v)) + v
+ elif typ == 104:
+ r = struct.pack("<BH", 105, tag) + bytes([0x80]) + struct.pack("<IQ", 0, 0)
+ else:
+ time.sleep(3600)
+ os.write(1, struct.pack("<I", 4 + len(r)) + r)
+EOF
+"$PLAYER" --spawn "python3 $TMP/hang.py" -- true 2>/dev/null &
+HP=$!
+sleep 1; kill -TERM $HP
+START=$(date +%s)
+for _ in $(seq 1 100); do kill -0 $HP 2>/dev/null || break; sleep 0.1; done
+if kill -0 $HP 2>/dev/null; then kill -KILL $HP; RC=hung; else wait $HP; RC=$?; fi
+expect_eq "hung server: one SIGTERM ends 9player once the child is dead (watchdog)" "143" "$RC"
+expect_eq "hung server: exit was prompt" "yes" "$([ $(( $(date +%s) - START )) -lt 8 ] && echo yes)"
+pkill -f "$TMP/hang.py" 2>/dev/null
+
+echo "# child/parent protocol"
+if command -v strace >/dev/null 2>&1 && strace -qq -e trace=none true 2>/dev/null; then
+ expect_eq "child killed before handoff" "125" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)"
+ expect_contains "child killed before handoff: message" "child exited before reporting" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>&1)"
+ expect_eq "status handoff fails" "125" "$(strace -f -qq -e trace=sendmsg -e inject=sendmsg:error=EPIPE -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)"
+ # recvmsg skipped (returns 1 without the fd): the child must be killed, not exec'd onto a dead mount.
+ OUT=$(strace -f -qq -e trace=recvmsg -e inject=recvmsg:retval=1:when=1 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- sh -c 'echo child-ran' 2>&1; echo "rc=$?")
+ expect_contains "truncated fd handoff: child not exec'd" "rc=125" "$OUT"
+ expect_eq "truncated fd handoff: program never ran" "no" "$(case "$OUT" in *child-ran*) echo yes;; *) echo no;; esac)"
+ expect_contains "fuse mount failure is reported" "mount fuse: EPERM" "$(strace -f -qq -e trace=mount -e inject=mount:error=EPERM:when=2 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" --mount "$TMP/mp" -- true 2>&1)"
+else
+ echo "skip - strace unavailable (child failure injection)"
+fi
+expect_contains "fork failure is reported" "fork: E" "$(python3 -c "
+import resource, os
+resource.setrlimit(resource.RLIMIT_NPROC, (1, 1))
+os.execv('$PLAYER', ['$PLAYER', '--unix', '$SOCK', '--', 'true'])" 2>&1)"
+
+echo "# mountpoint policy"
+ln -s /nonexistent "$TMP/dangling"
+expect_contains "dangling symlink mountpoint" "dangling symlink" "$(run --mount "$TMP/dangling" -- true 2>&1)"
+expect_eq "refuse to shadow / via /proc/self/root" "125" "$(run --mount /proc/self/root/x9p -- true 2>/dev/null; echo $?)"
+expect_contains "refuse to shadow / via /proc/self/root: message" "refusing to shadow /" "$(run --mount /proc/self/root/x9p -- true 2>&1)"
+expect_eq "refuse to shadow under /proc" "125" "$(run --mount /proc/self/fd/x9p -- true 2>/dev/null; echo $?)"
+if [ "$(ls -A /usr/lib | wc -l)" -gt 4096 ]; then
+ expect_contains "parent with >4096 entries refused" "more than 4096 entries" "$(run --mount /usr/lib/x9p -- true 2>&1)"
+else
+ echo "skip - no root-owned directory with >4096 entries"
+fi
+expect_eq "shadowed /run keeps its entries" "$(ls -A /run | sort | tr '\n' ' ')" "$(run --mount /run/x9p -- sh -c 'ls -A /run | grep -v "^x9p$" | sort | tr "\n" " "')"
+expect_eq "mountpoint with spaces" "ok" "$(mkdir -p "$TMP/with space" && run --mount "$TMP/with space" -- sh -c '[ -f "$NINEPLAYER_MOUNT/README" ] && echo ok')"
+expect_eq "mountpoint is a file" "125" "$(run --mount "$TMP/nx" -- true 2>/dev/null; echo $?)"
+
+echo "# leaks"
+for i in $(seq 1 30); do run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null; done
+BG=(); for i in $(seq 1 10); do ( run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null ) & BG+=($!); done; wait "${BG[@]}" # not a bare wait: that would also wait for the server
+sleep 0.3
+expect_eq "no stray 9player processes" "" "$(pgrep -f "^$PLAYER " | tr '\n' ' ')"
+expect_eq "no stray --stdio servers" "" "$(pgrep -f "$INTROSPECT --stdio" | tr '\n' ' ')"
+expect_eq "host mount table untouched" "same" "$([ "$MI_BEFORE" = "$(grep -v " $TMP" /proc/self/mountinfo | sort)" ] && echo same || echo changed)"
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]
diff --git a/9player/test/adversarial.sh b/9player/test/adversarial.sh
new file mode 100755
index 0000000..857af14
--- /dev/null
+++ b/9player/test/adversarial.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Runs every 9player adversarial suite in sequence (hostile servers, FUSE
+# semantics, process/namespace edge cases, stress). The suites aimed at the
+# introspect server itself live in introspect/test (zig build introspect-adv).
+# Usage: bash 9player/test/adversarial.sh <9player> <introspect> (zig build 9player-adv)
+set -u
+PLAYER=${1:?path to 9player}
+INTROSPECT=${2:?path to introspect}
+HERE=$(cd "$(dirname "$0")" && pwd)
+status=0
+for suite in adv_ns_process adv_bridge_hostile adv_bridge_semantics adv_bridge_stress; do
+ echo "### $suite"
+ if bash "$HERE/$suite.sh" "$PLAYER" "$INTROSPECT"; then echo "### $suite: ok"; else echo "### $suite: FAILED"; status=1; fi
+done
+exit $status
diff --git a/9player/test/integration.sh b/9player/test/integration.sh
new file mode 100755
index 0000000..e12cb5f
--- /dev/null
+++ b/9player/test/integration.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Integration tests for 9player: real user+mount namespaces, real FUSE, real 9P servers.
+# Usage: bash 9player/test/integration.sh <9player> <introspect> (zig build 9player-itest)
+# Exit 0 on success (or when the machine cannot run the tests), 1 on failure.
+set -u
+
+PLAYER=$(realpath "${1:?path to 9player}")
+INTROSPECT=$(realpath "${2:?path to introspect}")
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-itest.XXXXXX")
+PIDS=()
+FAILED=0
+PASSED=0
+M=/mnt/9p
+
+cleanup() {
+ for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done
+ rm -rf "$TMP"
+}
+trap cleanup EXIT
+
+if ! unshare -Urm true 2>/dev/null; then
+ echo "SKIP: unprivileged user namespaces unavailable"; exit 0
+fi
+if [ ! -c /dev/fuse ]; then
+ echo "SKIP: /dev/fuse missing"; exit 0
+fi
+
+pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; }
+fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; }
+expect_eq() { # name expected actual
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi
+}
+expect_contains() { # name needle haystack
+ case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac
+}
+
+wait_socket() { # path
+ for _ in $(seq 1 100); do [ -S "$1" ] && return 0; sleep 0.05; done
+ return 1
+}
+
+# run_in "<shell script>" — run inside a namespace with the current transport ($TRANSPORT array).
+run_in() { timeout 60 "$PLAYER" "${TRANSPORT[@]}" -- sh -c "$1" 2>"$TMP/stderr"; }
+
+# --- scratch battery: works against any writable 9P tree rooted at $1 (relative to mount) ---
+scratch_battery() { # label scratchdir
+ local label=$1 S=$2
+
+ expect_eq "$label: create+append+read" $'hello\nworld' "$(run_in "echo hello > $M/$S/a && echo world >> $M/$S/a && cat $M/$S/a")"
+ expect_eq "$label: overwrite" "x" "$(run_in "echo x > $M/$S/a && cat $M/$S/a")"
+ expect_eq "$label: stat size after overwrite" "2" "$(run_in "stat -c %s $M/$S/a")"
+ expect_eq "$label: truncate" "0" "$(run_in "truncate -s 0 $M/$S/a && stat -c %s $M/$S/a")"
+ expect_eq "$label: truncate extend" "10" "$(run_in "truncate -s 10 $M/$S/a && stat -c %s $M/$S/a")"
+ expect_eq "$label: mkdir -p nested" "directory" "$(run_in "mkdir -p $M/$S/d1/d2/d3 && stat -c %F $M/$S/d1/d2/d3")"
+ expect_eq "$label: rename within dir" "moved" "$(run_in "echo moved > $M/$S/d1/d2/f && mv $M/$S/d1/d2/f $M/$S/d1/d2/g && cat $M/$S/d1/d2/g")"
+ # mv(1) silently falls back to copy+delete on EXDEV, so probe rename(2) directly.
+ expect_contains "$label: rename across dirs is EXDEV" "EXDEV" "$(run_in "python3 -c 'import os,errno
+try: os.rename(\"$M/$S/d1/d2/g\", \"$M/$S/d1/g\")
+except OSError as e: print(errno.errorcode[e.errno])
+'")"
+ expect_eq "$label: rm file" "gone" "$(run_in "rm $M/$S/d1/d2/g && [ ! -e $M/$S/d1/d2/g ] && echo gone")"
+ expect_eq "$label: rmdir non-empty fails" "1" "$(run_in "rmdir $M/$S/d1 2>/dev/null; echo \$?")"
+ expect_eq "$label: rmdir chain" "ok" "$(run_in "rmdir $M/$S/d1/d2/d3 $M/$S/d1/d2 $M/$S/d1 && echo ok")"
+ expect_eq "$label: ENOENT" "1" "$(run_in "cat $M/$S/nope 2>/dev/null; echo \$?")"
+ expect_eq "$label: ENOENT errno text" "No such file or directory" "$(run_in "cat $M/$S/nope 2>&1 | sed 's/.*: //'")"
+
+ head -c 1048576 /dev/urandom >"$TMP/rand"
+ local sum; sum=$(sha256sum <"$TMP/rand" | cut -d' ' -f1)
+ expect_eq "$label: 1 MiB round trip (cp)" "$sum" "$(run_in "cp $TMP/rand $M/$S/big && sha256sum < $M/$S/big | cut -d' ' -f1")"
+ expect_eq "$label: 1 MiB size" "1048576" "$(run_in "stat -c %s $M/$S/big")"
+ expect_eq "$label: odd block sizes (dd bs=1000)" "$sum" "$(run_in "dd if=$M/$S/big of=$M/$S/big2 bs=1000 status=none && sha256sum < $M/$S/big2 | cut -d' ' -f1")"
+ expect_eq "$label: partial read at offset" "$(tail -c 12345 "$TMP/rand" | sha256sum | cut -d' ' -f1)" "$(run_in "tail -c 12345 $M/$S/big | sha256sum | cut -d' ' -f1")"
+ expect_eq "$label: many small files" "200" "$(run_in "mkdir $M/$S/many && for i in \$(seq 1 200); do echo \$i > $M/$S/many/f\$i; done; ls $M/$S/many | wc -l")"
+ expect_eq "$label: find count" "201" "$(run_in "find $M/$S/many | wc -l")"
+ expect_eq "$label: readdir contents" "f1 f100 f200" "$(run_in "cd $M/$S/many && ls f1 f100 f200 | tr '\n' ' ' | sed 's/ \$//'")"
+ expect_eq "$label: cleanup many" "0" "$(run_in "rm -r $M/$S/many $M/$S/big $M/$S/big2 $M/$S/a; ls $M/$S | wc -l")"
+}
+
+# ============================================================================
+echo "# introspect over a Unix socket"
+SOCK=$TMP/introspect.sock
+"$INTROSPECT" --unix "$SOCK" &
+PIDS+=($!)
+wait_socket "$SOCK" || { echo "introspect did not create $SOCK"; exit 1; }
+TRANSPORT=(--unix "$SOCK")
+
+expect_eq "zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")"
+expect_eq "mount exported" "$M" "$(run_in 'echo $NINEPLAYER_MOUNT')"
+expect_contains "root listing" "build" "$(run_in "ls $M")"
+expect_contains "root listing has scratch" "scratch" "$(run_in "ls $M")"
+expect_eq "README size > 0" "yes" "$(run_in "[ \$(stat -c %s $M/README) -gt 0 ] && echo yes")"
+expect_eq "README readable" "yes" "$(run_in "[ -s $M/README ] && head -c 1 $M/README >/dev/null && echo yes")"
+expect_eq "fn/now numeric" "num" "$(run_in "cat $M/runtime/fn/now | grep -Eq '^[0-9]+\$' && echo num")"
+expect_eq "fn listing from comptime" "yes" "$(run_in "ls $M/runtime/fn | grep -q hostname && echo yes")"
+expect_eq "ctl round trip" "5" "$(run_in "echo 'add 2 3' > $M/runtime/ctl && cat $M/runtime/ctl")"
+expect_eq "ctl echo" "hi there" "$(run_in "echo 'echo hi there' > $M/runtime/ctl && cat $M/runtime/ctl")"
+expect_contains "comptime types" "Qid" "$(run_in "ls $M/comptime/types")"
+expect_eq "comptime size of Qid" "16" "$(run_in "cat $M/comptime/types/Qid/size")"
+expect_eq "runtime pid is server pid" "${PIDS[-1]}" "$(run_in "cat $M/runtime/pid")"
+expect_eq "exit status propagates" "7" "$(run_in 'exit 7'; echo $?)"
+expect_eq "mount is fuse" "yes" "$(run_in "grep -q \"^9player $M fuse\" /proc/mounts && echo yes")"
+expect_eq "host /mnt entries still visible" "$(ls -A /mnt | sort | tr '\n' ' ')" "$(run_in "ls -A /mnt | grep -v '^9p\$' | sort | tr '\n' ' '")"
+expect_eq "host mount table untouched" "no" "$(grep -q " $M " /proc/self/mountinfo && echo yes || echo no)"
+scratch_battery "introspect" scratch
+
+echo "# nested 9player"
+expect_eq "nested mount" "$(zig version)" "$(run_in "$PLAYER --unix $SOCK --mount $TMP/inner -- sh -c 'cat \$NINEPLAYER_MOUNT/build/zig_version'")"
+
+echo "# --mount variants"
+mkdir -p "$TMP/mnt"
+expect_eq "--mount existing dir" "ok" "$(timeout 60 "$PLAYER" --unix "$SOCK" --mount "$TMP/mnt" -- sh -c "[ -f $TMP/mnt/README ] && echo ok")"
+expect_eq "--mount relative" "ok" "$(cd "$TMP" && timeout 60 "$PLAYER" --unix "$SOCK" --mount rel -- sh -c "[ -f $TMP/rel/README ] && echo ok")"
+expect_eq "--mount missing under /" "125" "$(timeout 60 "$PLAYER" --unix "$SOCK" --mount /nonexistent-9player-dir -- true 2>/dev/null; echo $?)"
+
+echo "# lifecycle"
+START=$(date +%s)
+expect_eq "background grandchild does not block exit" "3" "$(run_in 'sleep 30 >/dev/null 2>&1 & exit 3'; echo $?)"
+expect_eq "exit was prompt" "yes" "$([ $(( $(date +%s) - START )) -lt 10 ] && echo yes)"
+expect_eq "SIGTERM forwarded" "143" "$(timeout 60 "$PLAYER" --unix "$SOCK" -- sh -c 'kill -TERM $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)"
+
+echo "# --spawn transport"
+TRANSPORT=(--spawn "$INTROSPECT --stdio")
+expect_eq "spawn: zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")"
+expect_eq "spawn: ctl" "7" "$(run_in "echo 'add 3 4' > $M/runtime/ctl && cat $M/runtime/ctl")"
+expect_eq "spawn: stateful sequence in one session" 'hello world 12 moved 0' "$(run_in "cd $M/scratch && echo hello > a && echo world >> a && cat a && stat -c %s a && mkdir d && echo moved > d/f && mv d/f d/g && cat d/g && rm d/g && rmdir d && rm a && ls | wc -l" | tr '\n' ' ' | sed 's/ $//')"
+
+echo "# --tcp transport"
+PORT=$(( 20000 + RANDOM % 20000 ))
+"$INTROSPECT" --tcp "127.0.0.1:$PORT" &
+PIDS+=($!)
+sleep 0.3
+TRANSPORT=(--tcp "127.0.0.1:$PORT")
+expect_eq "tcp: zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")"
+expect_eq "tcp: scratch" "tcp" "$(run_in "echo tcp > $M/scratch/t && cat $M/scratch/t && rm $M/scratch/t")"
+
+echo "# server death"
+"$INTROSPECT" --unix "$TMP/dying.sock" &
+DYING=$!
+wait_socket "$TMP/dying.sock"
+TRANSPORT=(--unix "$TMP/dying.sock")
+OUT=$(run_in "cat $M/build/optimize >/dev/null && kill $DYING && sleep 0.3; cat $M/build/optimize 2>&1 >/dev/null | sed 's/.*: //'; echo status=\$?")
+expect_contains "server death yields an error, not a hang" "status=0" "$OUT"
+expect_eq "server death errno text" "yes" "$(case "$OUT" in *"Input/output error"*|*"Transport endpoint is not connected"*) echo yes;; *) echo "no: $OUT";; esac)"
+
+echo "# plan9port ramfs (independent 9P2000 implementation)"
+if [ -x /usr/lib/plan9/bin/ramfs ]; then
+ mkdir -p "$TMP/p9ns"
+ NAMESPACE=$TMP/p9ns /usr/lib/plan9/bin/ramfs -s ramfs
+ wait_socket "$TMP/p9ns/ramfs" || echo "ramfs socket missing"
+ PIDS+=($(pgrep -f "9pserve -u unix!$TMP/p9ns/ramfs"))
+ TRANSPORT=(--unix "$TMP/p9ns/ramfs")
+ expect_eq "ramfs: mkdir scratch" "ok" "$(run_in "mkdir $M/scratch && echo ok")"
+ scratch_battery "ramfs" scratch
+else
+ echo "skip - plan9port ramfs not installed"
+fi
+
+echo
+echo "passed=$PASSED failed=$FAILED"
+[ "$FAILED" -eq 0 ]