a73x

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 proves resume-by-delta across a transport tear and resume-by-snapshot across a daemon restart

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"