summaryrefslogtreecommitdiff
path: root/9ns/test/mntgen.sh
blob: 7dada535402b254054ccca249ccbd253032cf57b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
#!/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/<uid>/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 "<shell script>" — 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/<name> 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 ]