20016508
test: e2e proves resume-by-delta across a transport tear and resume-by-snapshot across a daemon restart
a73x 2026-08-08 14:08
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -10,12 +10,40 @@ OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" | |||
| 10 | # trap below can reference them under `set -u` before they are ever started. | 10 | # trap below can reference them under `set -u` before they are ever started. |
| 11 | SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" | 11 | SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" |
| 12 | D2PID="" | 12 | D2PID="" |
| 13 | # Third daemon, for the restart scenario: it gets killed and started again on | ||
| 14 | # the same path, so it cannot share the long-lived one. | ||
| 15 | SOCK3="${TMPDIR:-/tmp}/muxd-e2e-restart-$$.sock" | ||
| 16 | D3PID="" | ||
| 17 | |||
| 18 | # Wait until PATTERN shows up in FILE (default 15s). Timing that keys off the | ||
| 19 | # session's own output instead of a fixed sleep: the marker is proof the | ||
| 20 | # client is attached and idle, which is exactly the state the tear needs. | ||
| 21 | wait_for() { | ||
| 22 | _file="$1"; _pat="$2"; _ticks=$(( ${3:-15} * 10 )); _i=0 | ||
| 23 | while [ "$_i" -lt "$_ticks" ]; do | ||
| 24 | if [ -f "$_file" ] && grep -q "$_pat" "$_file" 2>/dev/null; then return 0; fi | ||
| 25 | sleep 0.1; _i=$((_i+1)) | ||
| 26 | done | ||
| 27 | return 1 | ||
| 28 | } | ||
| 29 | |||
| 30 | # The transport child for a given socket: a process named muxd running the | ||
| 31 | # `proxy` subcommand on that path. NEVER `pkill -f proxy` — the client's own | ||
| 32 | # argv contains the --via command string, so a pattern kill takes out the | ||
| 33 | # very client under test. | ||
| 34 | proxy_pid() { | ||
| 35 | ps -eo pid,comm,args | awk -v s="$1" '$2=="muxd" && /proxy/ && index($0,s) {print $1}' | head -1 | ||
| 36 | } | ||
| 13 | 37 | ||
| 14 | cleanup() { | 38 | cleanup() { |
| 15 | kill "$DPID" 2>/dev/null || true | 39 | kill "$DPID" 2>/dev/null || true |
| 16 | [ -n "$D2PID" ] && kill "$D2PID" 2>/dev/null | 40 | # `|| true` on every one of these: under `set -e` a kill of an |
| 17 | rm -f "$SOCK" "$SOCK2" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.b" \ | 41 | # already-dead pid would abort the trap itself, skipping the rm below and |
| 18 | "$OUT.via" "$OUT.dead" "$OUT.abort" | 42 | # failing a suite that had actually passed. |
| 43 | [ -n "$D2PID" ] && kill "$D2PID" 2>/dev/null || true | ||
| 44 | [ -n "$D3PID" ] && kill "$D3PID" 2>/dev/null || true | ||
| 45 | rm -f "$SOCK" "$SOCK2" "$SOCK3" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ | ||
| 46 | "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" | ||
| 19 | } | 47 | } |
| 20 | trap cleanup EXIT INT TERM | 48 | trap cleanup EXIT INT TERM |
| 21 | 49 | ||
| @@ -145,4 +173,119 @@ grep -q "detached while reconnecting" "$OUT.abort" || { | |||
| 145 | } | 173 | } |
| 146 | rm -f "$OUT.abort" "$SOCK2" | 174 | rm -f "$OUT.abort" "$SOCK2" |
| 147 | 175 | ||
| 176 | # --- M7 Scenario A: kill the transport mid-session; the client must resume | ||
| 177 | # by DELTA. The proxy is the transport, so killing it stands in for an ssh | ||
| 178 | # drop while the daemon and its session carry on untouched. | ||
| 179 | # | ||
| 180 | # The counter is the real assertion, not the markers: a snapshot-served | ||
| 181 | # resume renders "m7-after" perfectly well, so only `snapshots` holding | ||
| 182 | # still across the tear proves the resume was a delta — which is the whole | ||
| 183 | # milestone claim. | ||
| 184 | set +e | ||
| 185 | { sleep 0.5; printf 'printf "m7-%%s\\n" before\n'; sleep 8; \ | ||
| 186 | printf 'printf "m7-%%s\\n" after\n'; sleep 2.5; printf '\034'; } | \ | ||
| 187 | XDG_RUNTIME_DIR=/nonexistent-mux-e2e timeout 40 \ | ||
| 188 | "$MUX" --via "$MUXD proxy --sock $SOCK" > "$OUT.m7" 2>&1 & | ||
| 189 | M7PID=$! | ||
| 190 | set -e | ||
| 191 | |||
| 192 | # Tear only once the session is provably live and idle: the marker is the | ||
| 193 | # shell's own output, so seeing it means attached, drained, mid-sleep. | ||
| 194 | wait_for "$OUT.m7" "m7-before" 20 || { | ||
| 195 | echo "e2e FAIL: m7 pre-tear output never arrived"; cat "$OUT.m7"; exit 1; | ||
| 196 | } | ||
| 197 | SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 198 | PPID_PROXY=$(proxy_pid "$SOCK") | ||
| 199 | [ -n "$PPID_PROXY" ] || { echo "e2e FAIL: could not find the proxy to kill"; exit 1; } | ||
| 200 | kill -9 "$PPID_PROXY" | ||
| 201 | # The tear must have hit the transport only; the daemon is what we are | ||
| 202 | # proving survives, so say so out loud rather than inferring it later. | ||
| 203 | kill -0 "$DPID" || { echo "e2e FAIL: the tear killed the daemon, not the proxy"; exit 1; } | ||
| 204 | |||
| 205 | set +e | ||
| 206 | wait "$M7PID" | ||
| 207 | RC=$? | ||
| 208 | set -e | ||
| 209 | [ "$RC" -eq 0 ] || { | ||
| 210 | echo "e2e FAIL: m7 client exited $RC after a transport tear (want 0)" | ||
| 211 | cat "$OUT.m7"; exit 1; | ||
| 212 | } | ||
| 213 | grep -q "m7-before" "$OUT.m7" || { echo "e2e FAIL: m7 pre-tear output missing"; exit 1; } | ||
| 214 | grep -q "m7-after" "$OUT.m7" || { | ||
| 215 | echo "e2e FAIL: m7 client did not resume after the transport was killed" | ||
| 216 | cat "$OUT.m7"; exit 1; | ||
| 217 | } | ||
| 218 | SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 219 | [ -n "$SNAPS_BEFORE" ] && [ -n "$SNAPS_AFTER" ] || { | ||
| 220 | echo "e2e FAIL: could not read the snapshots counter"; exit 1; | ||
| 221 | } | ||
| 222 | [ "$SNAPS_BEFORE" = "$SNAPS_AFTER" ] || { | ||
| 223 | echo "e2e FAIL: reconnect was served a snapshot ($SNAPS_BEFORE -> $SNAPS_AFTER), not a delta" | ||
| 224 | exit 1; | ||
| 225 | } | ||
| 226 | rm -f "$OUT.m7" | ||
| 227 | |||
| 228 | # --- M7 Scenario B: kill the DAEMON under an attached client and start a new | ||
| 229 | # one on the same path. The seq the client holds belongs to a session that no | ||
| 230 | # longer exists, so the epoch fence must refuse it and serve a snapshot of the | ||
| 231 | # fresh session instead. Needs its own daemon, since this one gets killed. | ||
| 232 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh & | ||
| 233 | D3PID=$! | ||
| 234 | i=0 | ||
| 235 | while [ ! -S "$SOCK3" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 236 | [ -S "$SOCK3" ] || { echo "e2e FAIL: restart-scenario socket never appeared"; exit 1; } | ||
| 237 | |||
| 238 | set +e | ||
| 239 | { sleep 0.5; printf 'printf "m7b-%%s\\n" pre-restart\n'; sleep 8; \ | ||
| 240 | printf 'printf "m7b-%%s\\n" post-restart\n'; sleep 3; printf '\034'; } | \ | ||
| 241 | timeout 40 "$MUX" --sock "$SOCK3" > "$OUT.m7b" 2>&1 & | ||
| 242 | M7BPID=$! | ||
| 243 | set -e | ||
| 244 | wait_for "$OUT.m7b" "m7b-pre-restart" 20 || { | ||
| 245 | echo "e2e FAIL: m7b pre-restart output never arrived"; cat "$OUT.m7b"; exit 1; | ||
| 246 | } | ||
| 247 | |||
| 248 | # SIGKILL leaves the socket file behind; the new daemon's stale-socket | ||
| 249 | # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here. | ||
| 250 | kill -9 "$D3PID" | ||
| 251 | sleep 0.5 | ||
| 252 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh & | ||
| 253 | D3PID=$! | ||
| 254 | i=0 | ||
| 255 | while [ ! -S "$SOCK3" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 256 | [ -S "$SOCK3" ] || { echo "e2e FAIL: daemon did not rebind the stale socket"; exit 1; } | ||
| 257 | |||
| 258 | set +e | ||
| 259 | wait "$M7BPID" | ||
| 260 | RC=$? | ||
| 261 | set -e | ||
| 262 | [ "$RC" -eq 0 ] || { | ||
| 263 | echo "e2e FAIL: m7b client exited $RC across a daemon restart (want 0)" | ||
| 264 | cat "$OUT.m7b"; exit 1; | ||
| 265 | } | ||
| 266 | grep -q "m7b-post-restart" "$OUT.m7b" || { | ||
| 267 | echo "e2e FAIL: m7b client did not resume into the restarted daemon" | ||
| 268 | cat "$OUT.m7b"; exit 1; | ||
| 269 | } | ||
| 270 | # The fresh session really is fresh: a new daemon means a new shell, so the | ||
| 271 | # pre-restart marker cannot be in its grid. If it were, we would be looking | ||
| 272 | # at a client that reconnected to something with the old session's history — | ||
| 273 | # i.e. the epoch fence letting a stale seq through. | ||
| 274 | if "$MUXD" dump --sock "$SOCK3" | grep -q "m7b-pre-restart"; then | ||
| 275 | echo "e2e FAIL: restarted daemon's grid still holds the pre-restart marker" | ||
| 276 | exit 1 | ||
| 277 | fi | ||
| 278 | # Markers are blind to the kind of resume — a broken epoch fence handing back | ||
| 279 | # a garbage delta would render "m7b-post-restart" just as well. Only the | ||
| 280 | # counter sees it: the reconnecting client is this daemon's sole client, so a | ||
| 281 | # snapshot in its stats is proof it was resynced from scratch rather than | ||
| 282 | # resumed off a seq that belongs to a session that no longer exists. | ||
| 283 | SNAPS_NEW=$("$MUXD" stats --sock "$SOCK3" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 284 | [ -n "$SNAPS_NEW" ] || { echo "e2e FAIL: could not read the restarted daemon's counters"; exit 1; } | ||
| 285 | [ "$SNAPS_NEW" -ge 1 ] || { | ||
| 286 | echo "e2e FAIL: restarted daemon served no snapshot ($SNAPS_NEW); the stale seq was honoured" | ||
| 287 | exit 1; | ||
| 288 | } | ||
| 289 | rm -f "$OUT.m7b" | ||
| 290 | |||
| 148 | echo "e2e OK" | 291 | echo "e2e OK" |