#!/usr/bin/env bash # Integration tests for 9ns --mntgen: one FUSE mount whose synthetic root # lists the posted-9P registry ($XDG_RUNTIME_DIR/9p), servers dialed lazily # on the first walk into their name, one worker thread per server. # Registry entries that are directories are served like the root itself: # their sockets dial on walk and their directories recurse (depth-capped). # Usage: bash 9ns/test/mntgen.sh <9ns> <9proc-demo> (zig build 9ns-itest) # Exit 0 on success (or when the machine cannot run the tests), 1 on failure. set -u NS=$(realpath "${1:?path to 9ns}") PROC=$(realpath "${2:?path to 9proc-demo}") TMP=$(mktemp -d "${TMPDIR:-/tmp}/9ns-mntgen.XXXXXX") PIDS=() FAILED=0 PASSED=0 M=/mnt/9p RAMFS=/usr/lib/plan9/bin/ramfs cleanup() { # ramfs delegates to 9pserve, and tests may spawn servers inside the # namespace: catch anything holding a path under $TMP. for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done pkill -f "$TMP" 2>/dev/null 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 } # A scratch registry: the /srv translation is per-user tmpfs keyed by # XDG_RUNTIME_DIR; tests must never touch the real /run/user//9p. # mktemp paths are short enough for the 108-byte socket name budget. export XDG_RUNTIME_DIR="$TMP" mkdir -p "$TMP/9p" REG="$TMP/9p" # run_in "" — inside a namespace with the mntgen mount on $M. run_in() { timeout 60 "$NS" --mntgen --mount "$M" -- sh -c "$1" 2>"$TMP/stderr"; } # ============================================================================ echo "# two servers posted under two names" # 9proc-demo posts its socket directly in the registry directory: a socket # at $XDG_RUNTIME_DIR/9p/ IS a posted name (the /srv model). "$PROC" --unix "$REG/alpha" & ALPHA=$! PIDS+=($ALPHA) wait_socket "$REG/alpha" || { echo "9proc-demo did not post $REG/alpha"; exit 1; } # plan9port ramfs: an independent 9P2000 implementation; NAMESPACE points its # post9pservice at the registry (-S names the service; the default would # post under "ramfs"). HAVE_RAMFS=no if [ -x "$RAMFS" ]; then NAMESPACE="$REG" "$RAMFS" -S beta & PIDS+=($!) wait_socket "$REG/beta" && HAVE_RAMFS=yes fi echo "# synthetic root: posted names, no connection made for listing" OUT=$(run_in "ls $M") expect_contains "root lists alpha" "alpha" "$OUT" [ "$HAVE_RAMFS" = yes ] && expect_contains "root lists beta" "beta" "$OUT" # A plain file in the registry is listed but can never be dialed: this also # proves listing connects to nothing (there is nothing to connect to). touch "$REG/junk" expect_contains "root lists a non-socket entry" "junk" "$(run_in "ls $M")" echo "# lazy dial and per-server routing in one program run" OUT=$(run_in "ls $M; ls $M/alpha; cat $M/alpha/build/zig_version") expect_eq "alpha subtree served after lazy dial" "$(zig version)" "$(run_in "cat $M/alpha/build/zig_version")" expect_contains "root and subtree in one ls run" "build" "$OUT" expect_contains "root and subtree in one ls run (zig version)" "$(zig version)" "$OUT" if [ "$HAVE_RAMFS" = yes ]; then expect_eq "ramfs file round trip through its own mount" "hello-from-beta" \ "$(run_in "echo hello-from-beta > $M/beta/f && cat $M/beta/f")" # Both servers in one run: the dispatcher serves them through two workers. expect_eq "two subtrees in one run" "alpha ok beta ok" \ "$(run_in "[ -f $M/alpha/build/zig_version ] && echo alpha ok; [ -f $M/beta/f ] && echo beta ok" | tr '\n' ' ' | sed 's/ $//')" # Parallel readers on both mounts at once. expect_eq "parallel reads on both mounts" "ok" \ "$(run_in 'for i in 1 2 3 4 5 6 7 8; do cat '"$M"'/alpha/build/zig_version >/dev/null & cat '"$M"'/beta/f >/dev/null & done; wait; echo ok')" fi echo "# a post after the mount is visible (snapshot per opendir)" "$PROC" --unix "$REG/gamma" & PIDS+=($!) wait_socket "$REG/gamma" expect_contains "gamma appears without remounting" "gamma" "$(run_in "ls $M")" expect_eq "gamma dials on walk" "$(zig version)" "$(run_in "cat $M/gamma/build/zig_version")" echo "# walking a non-socket entry fails; the entry is never removed" expect_eq "walk into junk yields EIO, exit status" "1" "$(run_in "cat $M/junk 2>/dev/null; echo \$?")" expect_contains "junk still listed" "junk" "$(run_in "ls $M")" [ -S "$REG/junk" ] && fail "junk was turned into a socket" || pass "junk untouched" echo "# server death: EIO on the subtree, name still listed, re-dial on re-post" # SIGKILL, not SIGTERM: a graceful 9proc-demo unposts (the /srv model — a # clean exit removes the name), while the acceptance case is a server that # dies without cleaning up: the stale socket stays and must be listed, # yield EIO on walks, and be replaced by the next server that posts. # (the kill itself happens inside the child, at a deterministic point) # One 9ns process stays mounted through the whole cycle. The child shares # the host PID namespace, so the program itself kills the server and # re-posts a fresh one under the same name at deterministic points. DEATH_OUT=$(run_in ' cat '"$M"'/alpha/build/zig_version >/dev/null && echo dial=ok kill -9 '"$ALPHA"' 2>/dev/null sleep 0.4 MSG=$(cat '"$M"'/alpha/build/zig_version 2>&1 >/dev/null); echo "dead_status=$? dead_msg=$MSG" ls '"$M"' | grep -q "^alpha$" && echo listed=yes '"$PROC"' --unix '"$REG"'/alpha >/dev/null 2>&1 & NEW=$! for i in $(seq 1 50); do cat '"$M"'/alpha/build/zig_version >/dev/null 2>&1 && break; sleep 0.1; done cat '"$M"'/alpha/build/zig_version >/dev/null && echo redial=ok kill -9 "$NEW" 2>/dev/null; wait "$NEW" 2>/dev/null ') expect_contains "mount dialed the server first" "dial=ok" "$DEATH_OUT" expect_contains "dead server yields EIO, not a hang" "dead_status=1" "$DEATH_OUT" expect_contains "dead server errno is EIO" "Input/output error" "$DEATH_OUT" expect_contains "dead name still listed (stale socket, listing dials nothing)" "listed=yes" "$DEATH_OUT" expect_contains "re-dial on next walk after re-post" "redial=ok" "$DEATH_OUT" # The re-posted server was killed without cleanup inside the child, so a # fresh process walks a stale entry: EIO, again. expect_eq "stale name answers EIO in a fresh process" "1" "$(run_in "cat $M/alpha/build/zig_version 2>/dev/null; echo \$?")" # Leave a live alpha for the remaining sections. "$PROC" --unix "$REG/alpha" & ALPHA=$! PIDS+=($ALPHA) wait_socket "$REG/alpha" expect_eq "re-dial picks up the fresh post" "$(zig version)" "$(run_in "cat $M/alpha/build/zig_version")" echo "# mount defaults and environment" expect_eq "default mountpoint is /mnt/9p" "$M" "$(timeout 60 "$NS" --mntgen -- sh -c 'echo $NINE_MOUNT')" expect_contains "default mount is served" "alpha" "$(timeout 60 "$NS" --mntgen -- sh -c 'ls $NINE_MOUNT')" echo "# registry subdirectories are served like the root" mkdir -p "$REG/svc/deep" "$PROC" --unix "$REG/svc/delta" & PIDS+=($!) wait_socket "$REG/svc/delta" "$PROC" --unix "$REG/svc/deep/eps" & PIDS+=($!) wait_socket "$REG/svc/deep/eps" touch "$REG/svc/plainfile" expect_contains "root lists the subdirectory" "svc" "$(run_in "ls $M")" SVC_LS=$(run_in "ls $M/svc") expect_contains "subdirectory lists its sockets" "delta" "$SVC_LS" expect_contains "subdirectory lists nested directories" "deep" "$SVC_LS" expect_contains "subdirectory lists a plain file" "plainfile" "$SVC_LS" expect_eq "socket inside a directory dials" "$(zig version)" "$(run_in "cat $M/svc/delta/build/zig_version")" expect_eq "socket inside a nested directory dials" "$(zig version)" "$(run_in "cat $M/svc/deep/eps/build/zig_version")" expect_eq "walk into a plain file inside a directory is EIO, not a crash" "1" "$(run_in "cat $M/svc/plainfile 2>/dev/null; echo \$?")" expect_contains "the plain file stays listed" "plainfile" "$(run_in "ls $M/svc")" mkdir -p "$REG/a/b/c/d/e/f/g/h/i/j" expect_eq "eight levels of nesting serve" "ok" "$(run_in "[ -d $M/a/b/c/d/e/f/g/h ] && echo ok")" expect_eq "the ninth level answers EIO" "1" "$(run_in "[ -e $M/a/b/c/d/e/f/g/h/i/j ]; echo \$?")" expect_eq "\".\" inside a synthetic subdirectory is the subdirectory" "delta" \ "$(run_in "ls $M/svc/. | grep -x delta")" expect_eq "\"..\" from a synthetic subdirectory is the mount root" "svc" \ "$(run_in "ls $M/svc/.. | grep -x svc")" # A synthetic subdirectory holds one of 64 slots until the kernel forgets # its node. FUSE delivers those forgets in BATCH_FORGET messages whose # header nodeid is 0, so a dispatcher that routes a batch by that header # drops every entry in it and the slots never come back: subdirectories # served once then answer EIO forever. The forgets themselves arrive # lazily (the kernel frees dentries on its own schedule), so the check # retries rather than sampling once. if command -v python3 > /dev/null; then echo "# synthetic subdirectory slots come back after the kernel forgets them" for i in $(seq 1 65); do mkdir -p "$REG/s$(printf %02d "$i")"; done cat > "$TMP/slots.py" <<'PYEOF' import os, sys, time M = sys.argv[1] n = 64 def opendir(i): return os.open("%s/s%02d" % (M, i), os.O_RDONLY | os.O_DIRECTORY) fds = [opendir(i) for i in range(1, n + 1)] try: os.close(opendir(n + 1)) print("65th=opened") # the cap is not enforced except OSError: print("65th=refused") for fd in fds: os.close(fd) deadline, ok = time.time() + 10, 0 while True: ok = 0 for i in range(1, n + 1): try: os.close(opendir(i)) ok += 1 except OSError: pass if ok == n or time.time() > deadline: break time.sleep(0.2) print("recycled=%d" % ok) PYEOF SLOTS=$(run_in "python3 $TMP/slots.py $M") expect_contains "the 65th concurrent subdirectory is refused cleanly" "65th=refused" "$SLOTS" expect_contains "all 64 slots come back after a close burst" "recycled=64" "$SLOTS" rm -rf "$REG"/s[0-9][0-9] else echo "SKIP: synthetic-slot recycling check needs python3" fi rm -rf "$REG/svc" "$REG/a" expect_eq "mount is fuse" "yes" "$(run_in "grep -q \"^9ns $M fuse\" /proc/mounts && echo yes")" echo "# usage errors" expect_eq "--mntgen with --unix is a usage error" "125" "$(timeout 10 "$NS" --mntgen --unix /tmp/x -- true 2>/dev/null; echo $?)" expect_eq "--mntgen with --tcp is a usage error" "125" "$(timeout 10 "$NS" --mntgen --tcp 127.0.0.1:564 -- true 2>/dev/null; echo $?)" expect_eq "--mntgen with --fd is a usage error" "125" "$(timeout 10 "$NS" --mntgen --fd 3 -- true 2>/dev/null; echo $?)" expect_eq "--mntgen with --spawn is a usage error" "125" "$(timeout 10 "$NS" --spawn "true" --mntgen -- true 2>/dev/null; echo $?)" expect_eq "--mntgen with --name is a usage error" "125" "$(timeout 10 "$NS" --mntgen --name foo -- true 2>/dev/null; echo $?)" expect_eq "--mntgen=x is a usage error" "125" "$(timeout 10 "$NS" --mntgen=x -- true 2>/dev/null; echo $?)" expect_eq "missing XDG_RUNTIME_DIR is fatal" "125" "$(env -u XDG_RUNTIME_DIR timeout 10 "$NS" --mntgen -- true 2>/dev/null; echo $?)" expect_eq "exit status propagates" "7" "$(run_in 'exit 7'; echo $?)" echo "# xattr probes on the synthetic root read as unsupported, not EPERM" # llistxattr/lgetxattr (ACL and capability probes from ls -l and stat) used to # get EPERM from the root's read-only fallback, and coreutils blamed the # mount root itself: "ls: /mnt/9p: Operation not permitted". They must read # ENOSYS (like every other unimplemented op), which the kernel turns into a # silent "no xattrs here". XATTR_OUT=$(run_in "stat $M > /dev/null 2>&1; echo stat_rc=\$?; ls -ld $M > /dev/null 2>&1; echo lsld_rc=\$?") expect_contains "stat of the mount root is clean" "stat_rc=0" "$XATTR_OUT" expect_contains "ls -ld of the mount root is clean" "lsld_rc=0" "$XATTR_OUT" # junk (a plain registry file) makes full ls -l fail with EIO on that entry # (expected); what must never appear is EPERM blamed on the root. BAD=$(run_in "ls -l $M 2>&1 | grep -i 'not permitted' | head -1") expect_eq "no EPERM leak from ls -l" "" "$BAD" echo "# --debug and --no-direct-io reach the mntgen dispatcher" # runMntgen used to drop both flags from the options it handed serveMntgen; # --debug produced no trace at all. DEBUG_ERR=$(timeout 60 "$NS" --mntgen --debug -- sh -c "ls $M/alpha >/dev/null" 2>&1 >/dev/null) expect_contains "--debug traces the dispatcher" "lookup 'alpha'" "$DEBUG_ERR" echo "# a mute server costs only the walks into its own name" # A server that accepts the connection but never answers Tversion. The dial # runs on that name's worker, inside the walk that asked, so: every other # name (and the root) keeps answering; a signal releases the parked walker # at once (the dial is abandoned, EINTR); a fresh walk parks again and is # just as interruptible; and when the program exits with a walk still # parked, 9ns follows it out. Background jobs of a non-interactive sh ignore # SIGINT, so the parked walker is sent SIGTERM; the foreground `timeout -s # INT` case covers Ctrl-C. Pre-fix the dial ran on the dispatcher thread and # parked the whole mount, unkillably, until the program exited. if command -v python3 > /dev/null; then python3 - "$REG/mute" <<'MUTEEOF' & import socket, sys, os path = sys.argv[1] try: os.unlink(path) except FileNotFoundError: pass s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.bind(path) s.listen(8) while True: conn, _ = s.accept() # accept, then never say a word MUTEEOF MUTEPID=$! PIDS+=($MUTEPID) sleep 0.3 MUTE_OUT=$(timeout 60 "$NS" --mntgen -- sh -c ' stat '"$M"'/mute >/dev/null 2>&1 & W=$! sleep 0.3 echo "alpha=$(timeout 5 cat '"$M"'/alpha/build/zig_version)" echo "root=$(timeout 5 ls '"$M"' | grep -c .)" echo "readdir_alpha=$(timeout 5 ls '"$M"'/alpha | grep -c .)" s=$(date +%s%N); kill -TERM $W; wait $W; echo "term_rc=$? term_ms=$(( ($(date +%s%N) - s) / 1000000 ))" s=$(date +%s%N); timeout -s INT 2 stat '"$M"'/mute >/dev/null 2>&1; echo "int_rc=$? int_ms=$(( ($(date +%s%N) - s) / 1000000 ))" echo "alpha_again=$(timeout 5 cat '"$M"'/alpha/build/zig_version)" ' 2>"$TMP/mute.err") expect_eq "another name is served while a walk into mute is parked" "alpha=$(zig version)" "$(printf '%s\n' "$MUTE_OUT" | grep '^alpha=')" ROOT_N=$(printf '%s\n' "$MUTE_OUT" | sed -n 's/^root=//p') [ -n "$ROOT_N" ] && [ "$ROOT_N" -ge 2 ] && pass "the root lists while a walk into mute is parked ($ROOT_N entries)" || fail "the root listing did not answer" "$MUTE_OUT" READDIR_N=$(printf '%s\n' "$MUTE_OUT" | sed -n 's/^readdir_alpha=//p') [ -n "$READDIR_N" ] && [ "$READDIR_N" -ge 1 ] && pass "a readdir on another name is served meanwhile" || fail "readdir on alpha did not answer" "$MUTE_OUT" expect_eq "SIGTERM releases the parked walker (died of the signal)" "term_rc=143" "$(printf '%s\n' "$MUTE_OUT" | sed -n 's/^\(term_rc=[0-9]*\) .*/\1/p')" TERM_MS=$(printf '%s\n' "$MUTE_OUT" | sed -n 's/.*term_ms=//p') [ -n "$TERM_MS" ] && [ "$TERM_MS" -lt 1500 ] && pass "released promptly (${TERM_MS}ms)" || fail "release of the parked walker was slow or missing" "term_ms=$TERM_MS" expect_eq "a fresh walk into mute is interruptible (Ctrl-C after 2s)" "int_rc=124" "$(printf '%s\n' "$MUTE_OUT" | sed -n 's/^\(int_rc=[0-9]*\) .*/\1/p')" INT_MS=$(printf '%s\n' "$MUTE_OUT" | sed -n 's/.*int_ms=//p') [ -n "$INT_MS" ] && [ "$INT_MS" -ge 1900 ] && [ "$INT_MS" -lt 4000 ] && pass "the interrupted walk came back on the signal (${INT_MS}ms)" || fail "interrupted walk timing off" "int_ms=$INT_MS" expect_eq "alpha still served after all that" "alpha_again=$(zig version)" "$(printf '%s\n' "$MUTE_OUT" | grep '^alpha_again=')" HANG_START=$(date +%s) timeout 20 "$NS" --mntgen -- bash -c "(stat $M/mute >/dev/null 2>&1) & sleep 1" >/dev/null 2>&1 HANG_RC=$? HANG_SECONDS=$(( $(date +%s) - HANG_START )) if [ "$HANG_RC" -eq 124 ] || [ "$HANG_SECONDS" -ge 15 ]; then fail "9ns exits with a walk still parked in a dial" "rc=$HANG_RC after ${HANG_SECONDS}s (wedged)" else pass "9ns exits with a walk still parked in a dial (rc=$HANG_RC after ${HANG_SECONDS}s)" fi rm -f "$REG/mute" else echo "SKIP: mute-server checks need python3" fi echo echo "passed=$PASSED failed=$FAILED" [ "$FAILED" -eq 0 ]