a73x

9bd46b17

test: 01_boot moves to a file of its own

a73x   2026-08-26 18:48

Commit message
test: 01_boot moves to a file of its own

--version, keygen, the key sources, both transports and `muxd start` — 10 scenarios, 965 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -94,973 +94,51 @@ done
94 echo " daemon can still report a command's real exit code" 94 echo " daemon can still report a command's real exit code"
95 exit 1; } 95 exit 1; }
96 96
97 # The helpers, the registry and the trap live in e2e_lib.sh; the scenarios 97 # Where the scenarios live. This file is the runner: it hands the group
98 # are still below. This file is the runner: it hands them a hermetic 98 # files a hermetic environment and an armed trap, sources them in order,
99 # environment and an armed trap, and owns the count pin at the bottom. 99 # and owns the count pin at the bottom. It is deliberately the only place
100 # that knows the order, because the suite is LINEAR and stateful — a group
101 # reads sessions and wall files an earlier group made.
100 E2E_DIR=$(dirname "$0") 102 E2E_DIR=$(dirname "$0")
101 . "$E2E_DIR/e2e_lib.sh" 103 . "$E2E_DIR/e2e_lib.sh"
102 104
103 # Second daemon, used only by the M7 abort scenario. 105 E2E_GROUPS='01_boot'
104 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" 106 E2E_NEEDS=""
105 defer_sock "$SOCK2" 107
106 # Third daemon, for the restart scenario: it gets killed and started again on 108 # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in
107 # the same path, so it cannot share the long-lived one. 109 # while it is being written; the gate is still the whole suite, which is
108 SOCK3="${TMPDIR:-/tmp}/muxd-e2e-restart-$$.sock" 110 # why this path never reaches the pin.
109 defer_sock "$SOCK3" 111 if [ -n "${E2E_ONLY:-}" ]; then
110 # Fourth daemon, for the M8 --quic scenario: it is the only one holding a UDP 112 _found=""
111 # port, so it gets its own path rather than sharing the long-lived one. 113 for _g in $E2E_GROUPS; do
112 SOCK4="${TMPDIR:-/tmp}/muxd-e2e-quic-$$.sock" 114 # An `if` rather than `[ ... ] && _found=1`, for wait_sock's reason:
113 defer_sock "$SOCK4" "$SOCK4.nokey" "$SOCK4.second" 115 # a false guard as the last command in a loop body is that body's
114 QKEY="${TMPDIR:-/tmp}/mux-e2e-key-$$" 116 # exit status, and under `set -e` the first name that did not match
115 defer_rm "$QKEY" "$QKEY.wrong" "$QKEY.bad" 117 # would end the run.
116 # A port out of the way of the ephemeral range, made per-run so two suites can 118 if [ "$_g" = "$E2E_ONLY" ]; then _found=1; fi
117 # overlap. Collisions surface as a loud bind failure, never as a silent pass. 119 done
118 QPORT=$(( 21000 + ($$ % 4000) )) 120 [ -n "$_found" ] || {
119 # M10 key-resolution daemons. Two, not one: the first scenario's client 121 echo "e2e FAIL: no such group '$E2E_ONLY'. The groups are:"
120 # detaches and its daemon is killed, and the second must not be sharing 122 # shellcheck disable=SC2086 # the list is words, and split is the point
121 # either. Ports in bands of their own so a concurrent suite cannot collide. 123 printf ' %s\n' $E2E_GROUPS
122 SOCK9="${TMPDIR:-/tmp}/muxd-e2e-envkey-$$.sock" 124 exit 1
123 defer_sock "$SOCK9"
124 SOCK10="${TMPDIR:-/tmp}/muxd-e2e-flagwins-$$.sock"
125 defer_sock "$SOCK10"
126 QPORT2=$(( 26000 + ($$ % 4000) ))
127 QPORT3=$(( 31000 + ($$ % 4000) ))
128 # M10 `muxd start`. These daemons are spawned DETACHED, so the suite never
129 # holds their pids as shell jobs — it reads them off the up-line and kills
130 # by that tracked pid, never by name.
131 SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"
132 defer_sock "$SOCK8"
133 SOCK8T="${TMPDIR:-/tmp}/muxd-e2e-trunc-$$.sock"
134 defer_sock "$SOCK8T"
135 SOCK11="${TMPDIR:-/tmp}/muxd-e2e-goal-$$.sock"
136 defer_sock "$SOCK11"
137 QPORT4=$(( 36000 + ($$ % 4000) ))
138
139 # --- M10: --version answers "did the scp land" without a daemon anywhere.
140 # No convergence: no daemon and no client, so there is no grid on either side.
141 "$MUXD" --version | grep -q '^muxd 0\.' || { echo "e2e FAIL: muxd --version"; exit 1; }
142 "$MUX" --version | grep -q '^mux 0\.' || { echo "e2e FAIL: mux --version"; exit 1; }
143 # Both must report the SAME version: the greps above pass just as happily for
144 # a binary that re-hardcoded a literal instead of reading build.zig's constant,
145 # which is exactly the drift --version exists to rule out.
146 [ "$("$MUXD" --version | cut -d' ' -f2)" = "$("$MUX" --version | cut -d' ' -f2)" ] || {
147 echo "e2e FAIL: version drift between binaries"; exit 1; }
148 ok "--version on both binaries"
149
150 # --- M10: keygen writes 0600, prints the path, refuses a second run.
151 # No convergence: keygen touches the filesystem, never a session.
152 KEYOUT=$("$MUXD" keygen)
153 [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || {
154 echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; }
155 PERMS=$(stat -c %a "$KEYOUT")
156 [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; }
157 DPERMS=$(stat -c %a "$XDG_CONFIG_HOME/mux")
158 [ "$DPERMS" = "700" ] || { echo "e2e FAIL: key dir perms $DPERMS, want 700"; exit 1; }
159 SUM1=$(sha256sum "$KEYOUT")
160 if "$MUXD" keygen > /dev/null 2>&1; then
161 echo "e2e FAIL: second keygen did not refuse"; exit 1
162 fi
163 SUM2=$(sha256sum "$KEYOUT")
164 [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; }
165 ok "keygen creates once, 0600 in a 0700 dir, refuses twice"
166
167 start_daemon "$SOCK" "$OUT.d1.d" "socket never appeared" --shell /bin/sh
168 D1PID=$DPID
169
170 # Client with piped stdio: types a command, waits for its output, detaches
171 # with the Ctrl-\ chord (prefix, then prefix again for detach).
172 pipe_mux "$OUT" "" timeout 30 "$MUX" --sock "$SOCK"
173 pipe_send 'printf "e2e-%%s\\n" works\n'
174 # 1. The client's rendered output must contain the command's result.
175 await_out "$OUT" "e2e-works" "client render missing output"
176 pipe_detach
177
178 # 2. The daemon kept the session; its grid must match.
179 "$MUXD" dump --sock "$SOCK" | grep -q "e2e-works" || {
180 echo "e2e FAIL: daemon grid missing output"; exit 1;
181 }
182
183 # 3. Detach left the daemon running.
184 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
185
186 # 4. The M11 claim itself: the screen the client painted equals the
187 # screen the daemon holds — same engine, same formatter, both formats.
188 assert_converged "$OUT" "$SOCK" "base attach"
189
190 # The control: a doctored stream must NOT converge. A convergence check
191 # that cannot fail proves nothing (the wan.sh rule, M9).
192 cp "$OUT" "$OUT.doctored"
193 printf '\033[12;1Hconvergence-control-glyphs' >> "$OUT.doctored"
194 if converged_quiet "$OUT.doctored" "$SOCK"; then
195 echo "e2e FAIL: convergence control did not fire on a doctored stream"; exit 1
196 fi
197 rm_swept "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \
198 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \
199 "$OUT.doctored.rvt" "$OUT.doctored.dvt"
200 ok "convergence control fires on a doctored stream"
201
202 # --- M11: a styled specimen, so the byte-exact leg has something to compare.
203 # The campaign's row 4 broke the delta paint's leading SGR reset and BOTH
204 # suites passed — not because the styled comparison is weak but because no
205 # scenario in the corpus had ever emitted a colour. A suppressed reset resets
206 # nothing when nothing upstream is styled, so the instrument was handed no
207 # specimen. This scenario is the specimen: two delta rows carrying a live
208 # attribute, followed by a plain prompt row that must come back unstyled.
209 #
210 # The escapes are doubled because they are written by the SESSION's shell,
211 # not by this one: what goes down the pipe is the literal text
212 # `printf "\033[1;31mstyled-%s\033[0m\n" red bold`.
213 pipe_mux "$OUT.st" "" timeout 30 "$MUX" --sock "$SOCK"
214 pipe_send 'printf "\\033[1;31mstyled-%%s\\033[0m\\n" red bold\n'
215 await_out "$OUT.st" "styled-bold" "the second styled row never reached the client"
216 pipe_detach
217 grep -q "styled-red" "$OUT.st" || {
218 echo "e2e FAIL: styled output never reached the client"; cat "$OUT.st"; exit 1; }
219 # The plain leg would pass on a bled attribute — same glyphs, wrong colours.
220 # The --vt leg inside assert_converged is the one that speaks here.
221 assert_converged "$OUT.st" "$SOCK" "styled content"
222 rm_swept "$OUT.st"
223 ok "styled content survives the paint path"
224
225 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
226 # No convergence on $OUT.kill: kill -9 truncates the stream mid-paint, possibly
227 # mid-escape-sequence, so a half-drawn capture is the expected shape here.
228 { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" &
229 CPID=$!
230 defer_kill "$CPID"
231 sleep 1
232 hardkill "$CPID"
233 sleep 1
234 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; }
235
236 { sleep 1; printf '\034\034'; } | timeout 30 "$MUX" --sock "$SOCK" > "$OUT.re"
237 grep -q "60" "$OUT.re" || {
238 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
239 }
240 assert_converged "$OUT.re" "$SOCK" "reattach after kill"
241 rm_swept "$OUT.kill" "$OUT.re"
242
243 # --- M5: two clients on one session. Output typed in A reaches both; then A
244 # detaches and B must still have a live input path (its own marker echoes back
245 # after A is gone), so one client's detach does not disturb the other.
246 { sleep 0.5; printf 'printf "m5-%%s\\n" both\n'; sleep 2.5; printf '\034\034'; } | \
247 "$MUX" --sock "$SOCK" > "$OUT.a" &
248 APID=$!
249 defer_kill "$APID"
250 { sleep 4; printf 'printf "m5-%%s\\n" after-a-left\n'; sleep 2.5; printf '\034\034'; } | \
251 "$MUX" --sock "$SOCK" > "$OUT.b" &
252 BPID=$!
253 defer_kill "$BPID"
254 wait "$APID" "$BPID"
255
256 # The marker text never appears in the echoed command line ("m5-%s" plus a
257 # separate argument), so a hit proves the shell ran it and the result came back.
258 grep -q "m5-both" "$OUT.a" || { echo "e2e FAIL: client A missing shared output"; exit 1; }
259 grep -q "m5-both" "$OUT.b" || { echo "e2e FAIL: client B missing shared output"; exit 1; }
260 grep -q "m5-after-a-left" "$OUT.b" || {
261 echo "e2e FAIL: client B lost its input path after A detached"; exit 1;
262 }
263 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; }
264 # B's capture, not A's: A detached before B's marker landed, so A's stream ends
265 # on a grid the daemon has since moved past. B saw both, and its convergence
266 # covers the same grid.
267 assert_converged "$OUT.b" "$SOCK" "two clients"
268 rm_swept "$OUT.a" "$OUT.b"
269
270 # --- M6: the same protocol over an arbitrary byte pipe. `muxd proxy` is a
271 # frame-agnostic stdio<->socket pump; if the session works through it, the
272 # transport really is a swap. The string is one shell word here and is split
273 # by the /bin/sh -c that mux spawns, so $MUXD must contain no spaces — it is
274 # the build tree's artifact path, which does not.
275 #
276 # XDG_RUNTIME_DIR is pointed at nothing so the test cannot pass by environment
277 # luck: if --via ever silently fell back to the default socket path, that path
278 # would resolve to a directory that does not exist and the session would fail
279 # instead of quietly attaching over the local socket.
280 pipe_mux "$OUT.via" "" env XDG_RUNTIME_DIR=/nonexistent-mux-e2e "$MUX" --via "$MUXD proxy --sock $SOCK"
281 pipe_send 'printf "m6-%%s\\n" via-pipe\n'
282 await_out "$OUT.via" "m6-via-pipe" "--via transport"
283 pipe_detach
284 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; }
285 assert_converged "$OUT.via" "$SOCK" "via transport"
286 rm_swept "$OUT.via"
287
288 # --- M10: a --via command that dies before the first frame stops claiming
289 # a connection existed. ssh's own stderr still passes through untouched.
290 # No convergence: no session was ever established, so there is no grid to match.
291 set +e
292 "$MUX" --via "sh -c 'exit 127'" > "$OUT.via" 2>&1
293 VRC=$?
294 set -e
295 [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; }
296 grep -q "transport command failed before a session started" "$OUT.via" || {
297 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; }
298 grep -q "connection to muxd lost" "$OUT.via" && {
299 echo "e2e FAIL: the old lie is still printed"; cat "$OUT.via"; exit 1; }
300 rm_swept "$OUT.via"
301 ok "--via failure says what happened"
302
303 # --- M7: a transport that dies before any session must exit, not retry. The
304 # reconnect loop resumes sessions; it must not turn a bad --via command into
305 # an unkillable client. With piped stdin there is no Ctrl-\ to rescue it, so
306 # a regression here hangs forever: exit 124 below is the timeout, and it is
307 # the failure this test exists to catch.
308 # No convergence: the transport died first, so no session and no grid.
309 set +e
310 timeout 10 "$MUX" --via "exit 7" < /dev/null > "$OUT.dead" 2>&1
311 RC=$?
312 set -e
313 [ "$RC" -eq 1 ] || {
314 echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)"
315 cat "$OUT.dead"; exit 1;
316 }
317 # M10 reworded this one: `--via "exit 7"` is the same shape as the scenario
318 # above — a transport command that died before carrying a frame — so it now
319 # gets the honest message. What this scenario is FOR is the exit code above
320 # (1, never 124); the diagnostic is asserted so the exit is not a silent one.
321 grep -q "transport command failed before a session started" "$OUT.dead" || {
322 echo "e2e FAIL: dead first transport lost its diagnostic; got:"; cat "$OUT.dead"; exit 1;
323 }
324 # The can-fail control for the M13 reword: a grep for the new wording passes
325 # just as well if BOTH lines are printed, so the old one must be absent from
326 # the very capture that just satisfied it. The dropped parenthetical guessed
327 # a cause the transport's own stderr had already named.
328 grep -q "is muxd installed on the host" "$OUT.dead" && {
329 echo "e2e FAIL: old lostMsg wording still emitted alongside the new pin"
330 cat "$OUT.dead"; exit 1; }
331 # Free ride on this capture, for a property with nothing to do with the exit
332 # code: this client had NON-TTY stdin and died before any frame, so it is
333 # the one place in the suite that proves mux does not push a title onto a
334 # terminal it never took over. An unmatched push is a title stack that only
335 # grows, and nothing later would pop it — the teardown never runs.
336 # -F is load-bearing, not decoration: without it `[22;0t` is a malformed
337 # bracket expression, grep EXITS 2 rather than 1, and `grep && { fail }`
338 # reads an error as "not found" — an assertion that can never fire. Caught
339 # by running this needle by hand against a capture known to contain it.
340 grep -qaF "$(printf '\033[22;0t')" "$OUT.dead" && {
341 echo "e2e FAIL: a client that entered nothing still pushed the title stack"
342 cat -v "$OUT.dead"; exit 1; }
343
344 # M13 spec amendment: a socket path past sun_path's 107 usable bytes is
345 # refused by name in both binaries, before either acts on it — the failure
346 # it replaced was a 2s poll ending in "daemon did not answer", a timeout
347 # story about a path that was doomed at parse.
348 LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock"
349 "$MUXD" dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: muxd accepted long sock"; exit 1; }
350 grep -q "muxd: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; }
351 "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; }
352 grep -q "mux: socket path too long" "$OUT.longc" || { echo "e2e FAIL:"; cat "$OUT.longc"; exit 1; }
353 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; }
354 "$MUXD" --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; }
355
356 rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc"
357
358 # --- M7: aborting a reconnect exits cleanly. The client establishes a real
359 # session (so reconnect is allowed), its daemon is then killed under it, and
360 # Ctrl-\ arrives while it is retrying. Exit 0 and the message; a double-close
361 # of the transport would abort here instead (SIGABRT = 134).
362 # No convergence: the client exits while reconnecting and its daemon is dead,
363 # so there is nothing left to dump against.
364 start_daemon "$SOCK2" "$OUT.d2.d" "second socket never appeared" --shell /bin/sh
365 D2PID=$DPID
366
367 set +e
368 { printf 'echo m7-abort-live\n'; sleep 3; printf '\034'; sleep 2; } | \
369 timeout 20 "$MUX" --sock "$SOCK2" > "$OUT.abort" 2>&1 &
370 CLIPID=$!
371 defer_kill "$CLIPID"
372 # Let the session establish, then take the daemon away mid-session.
373 sleep 2
374 hardkill "$D2PID"
375 D2PID=""
376 wait "$CLIPID"
377 RC=$?
378 set -e
379 [ "$RC" -eq 0 ] || {
380 echo "e2e FAIL: abort during reconnect exited $RC (want 0; 134 = panic, 124 = hung)"
381 cat "$OUT.abort"; exit 1;
382 }
383 grep -q "detached while reconnecting" "$OUT.abort" || {
384 echo "e2e FAIL: abort during reconnect lost its message; got:"; cat "$OUT.abort"; exit 1;
385 }
386 rm_swept "$OUT.abort" "$SOCK2"
387
388 # --- M7 Scenario A: kill the transport mid-session; the client must resume
389 # by DELTA. The proxy is the transport, so killing it stands in for an ssh
390 # drop while the daemon and its session carry on untouched.
391 #
392 # The counter is the real assertion, not the markers: a snapshot-served
393 # resume renders "m7-after" perfectly well, so only `snapshots` holding
394 # still across the tear proves the resume was a delta — which is the whole
395 # milestone claim.
396 pipe_mux "$OUT.m7" "$OUT.m7.err" env XDG_RUNTIME_DIR=/nonexistent-mux-e2e timeout 40 \
397 "$MUX" --via "$MUXD proxy --sock $SOCK"
398 pipe_send 'printf "m7-%%s\\n" before\n'
399
400 # Tear only once the session is provably live and idle: the marker is the
401 # shell's own output, so seeing it means attached and drained.
402 wait_for "$OUT.m7" "m7-before" 20 || {
403 echo "e2e FAIL: m7 pre-tear output never arrived"
404 cat "$OUT.m7" "$OUT.m7.err" 2>/dev/null; exit 1;
405 }
406 SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
407 PAINTS_BEFORE=$(repaints "$OUT.m7")
408 PPID_PROXY=$(proxy_pid "$SOCK")
409 [ -n "$PPID_PROXY" ] || { echo "e2e FAIL: could not find the proxy to kill"; exit 1; }
410 kill -9 "$PPID_PROXY"
411 # The tear must have hit the transport only; the daemon is what we are
412 # proving survives, so say so out loud rather than inferring it later.
413 kill -0 "$D1PID" || { echo "e2e FAIL: the tear killed the daemon, not the proxy"; exit 1; }
414
415 await_repaint "$OUT.m7" "$PAINTS_BEFORE" "m7 client never resumed after the transport was killed"
416 pipe_send 'printf "m7-%%s\\n" after\n'
417 await_out "$OUT.m7" "m7-after" "m7 client did not resume after the transport was killed"
418 pipe_detach "m7 client"
419 SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
420 [ -n "$SNAPS_BEFORE" ] && [ -n "$SNAPS_AFTER" ] || {
421 echo "e2e FAIL: could not read the snapshots counter"; exit 1;
422 }
423 [ "$SNAPS_BEFORE" = "$SNAPS_AFTER" ] || {
424 echo "e2e FAIL: reconnect was served a snapshot ($SNAPS_BEFORE -> $SNAPS_AFTER), not a delta"
425 exit 1;
426 }
427 assert_converged "$OUT.m7" "$SOCK" "delta resume"
428 rm_swept "$OUT.m7" "$OUT.m7.err"
429
430 # --- M7 Scenario B: kill the DAEMON under an attached client and start a new
431 # one on the same path. The seq the client holds belongs to a session that no
432 # longer exists, so the epoch fence must refuse it and serve a snapshot of the
433 # fresh session instead. Needs its own daemon, since this one gets killed.
434 start_daemon "$SOCK3" "$OUT.d3a.d" "restart-scenario socket never appeared" --shell /bin/sh
435 D3PID=$DPID
436
437 pipe_mux "$OUT.m7b" "$OUT.m7b.err" timeout 40 "$MUX" --sock "$SOCK3"
438 pipe_send 'printf "m7b-%%s\\n" pre-restart\n'
439 wait_for "$OUT.m7b" "m7b-pre-restart" 20 || {
440 echo "e2e FAIL: m7b pre-restart output never arrived"
441 cat "$OUT.m7b" "$OUT.m7b.err" 2>/dev/null; exit 1;
442 }
443
444 # SIGKILL leaves the socket file behind; the new daemon's stale-socket
445 # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here.
446 PAINTS_BEFORE=$(repaints "$OUT.m7b")
447 hardkill "$D3PID"
448 sleep 0.5
449 start_daemon "$SOCK3" "$OUT.d3b.d" "daemon did not rebind the stale socket" --shell /bin/sh
450 D3PID=$DPID
451
452 await_repaint "$OUT.m7b" "$PAINTS_BEFORE" "m7b client never resumed into the restarted daemon"
453 pipe_send 'printf "m7b-%%s\\n" post-restart\n'
454 await_out "$OUT.m7b" "m7b-post-restart" "m7b client did not resume into the restarted daemon"
455 pipe_detach "m7b client"
456 # The fresh session really is fresh: a new daemon means a new shell, so the
457 # pre-restart marker cannot be in its grid. If it were, we would be looking
458 # at a client that reconnected to something with the old session's history —
459 # i.e. the epoch fence letting a stale seq through.
460 if "$MUXD" dump --sock "$SOCK3" | grep -q "m7b-pre-restart"; then
461 echo "e2e FAIL: restarted daemon's grid still holds the pre-restart marker"
462 exit 1
463 fi
464 # Markers are blind to the kind of resume — a broken epoch fence handing back
465 # a garbage delta would render "m7b-post-restart" just as well. Only the
466 # counter sees it: the reconnecting client is this daemon's sole client, so a
467 # snapshot in its stats is proof it was resynced from scratch rather than
468 # resumed off a seq that belongs to a session that no longer exists.
469 SNAPS_NEW=$("$MUXD" stats --sock "$SOCK3" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
470 [ -n "$SNAPS_NEW" ] || { echo "e2e FAIL: could not read the restarted daemon's counters"; exit 1; }
471 [ "$SNAPS_NEW" -ge 1 ] || {
472 echo "e2e FAIL: restarted daemon served no snapshot ($SNAPS_NEW); the stale seq was honoured"
473 exit 1;
474 }
475 assert_converged "$OUT.m7b" "$SOCK3" "epoch resync"
476 rm_swept "$OUT.m7b" "$OUT.m7b.err"
477
478 # --- M8: the --quic flags. Every refusal must cost nothing — no session
479 # socket, no shell, no stack trace — and the accepted case must leave a
480 # daemon that is both listening on UDP and still an ordinary muxd.
481 #
482 # The client half of this (attaching over quic://) is Task 3; what is proven
483 # here is the daemon's side of the command line.
484
485 # A refusal that leaves a socket behind has already started a session, which
486 # is the failure this ordering exists to prevent.
487 # No convergence anywhere in refuse(): no daemon survives one, by construction.
488 # Takes the config home to run under as an explicit second argument, rather
489 # than relying on a `XDG_CONFIG_HOME=... refuse ...` prefix: POSIX says an
490 # assignment prefixing a FUNCTION call may outlive it, so on a dash /bin/sh
491 # that form could leak into every later scenario. Naming it per call site
492 # also makes each refusal say which key world it is refusing in, which is
493 # the thing that silently changes once a default key path exists.
494 refuse() {
495 _want="$1"; _cfg="$2"; shift 2
496 set +e
497 # Timed out rather than trusted to exit: every case here is a refusal, so
498 # a regression that ACCEPTS one would otherwise run a daemon forever and
499 # hang the suite instead of failing it. 124 is a distinguishable answer.
500 env XDG_CONFIG_HOME="$_cfg" timeout 10 "$MUXD" run --sock "$SOCK4" \
501 --shell /bin/sh "$@" > "$OUT.q" 2>&1
502 _rc=$?
503 set -e
504 [ "$_rc" -eq "$_want" ] || {
505 echo "e2e FAIL: muxd run $* exited $_rc (want $_want)"; cat "$OUT.q"; exit 1;
506 }
507 [ ! -e "$SOCK4" ] || {
508 echo "e2e FAIL: muxd run $* was refused but left $SOCK4 behind"; exit 1;
509 }
510 # One line of complaint plus the usage block, and not a stack trace: a
511 # Zig panic runs to dozens of lines and names a source file, which is
512 # what this is guarding against.
513 #
514 # Two bounds, because each misses what the other catches. The relative
515 # one is measured from the no-args usage path, which is NOT one of the
516 # paths it bounds: if usage printing ever ballooned, the bound would
517 # balloon with it and quietly stop catching a panic. The absolute one
518 # cannot drift, and 15 sits above any plausible complaint-plus-usage
519 # and below the ~20 lines of the smallest Debug panic.
520 _usage_lines=$("$MUXD" 2>&1 | wc -l)
521 _lines=$(wc -l < "$OUT.q")
522 { [ "$_lines" -le $((_usage_lines + 2)) ] && [ "$_lines" -le 15 ]; } || {
523 echo "e2e FAIL: muxd run $* answered with more than a message:"; cat "$OUT.q"; exit 1;
524 } 125 }
525 } 126 for _n in $E2E_NEEDS; do
526 127 case "$_n" in
527 head -c 32 /dev/urandom > "$QKEY" 128 "$E2E_ONLY":*)
528 chmod 600 "$QKEY" 129 echo "e2e FAIL: $E2E_ONLY cannot run alone — it uses daemons"
529 cp "$QKEY" "$QKEY.bad" 130 echo " ${_n#*:} starts. Run the whole suite."
530 chmod 644 "$QKEY.bad" 131 exit 1 ;;
531 132 esac
532 # Both or neither, and a usage mistake exits 2 like every other one. 133 done
533 # The no-key refusal must not find the suite's own keygen'd key, so it runs 134 . "$E2E_DIR/e2e_$E2E_ONLY.sh"
534 # against a config home that has never had one written to it. 135 echo "e2e OK ($OK_COUNT scenarios in $E2E_ONLY; the pin is the whole suite's)"
535 NOKEY_CFG="${TMPDIR:-/tmp}/mux-e2e-nokey-$$" 136 exit 0
536 defer_rm "$NOKEY_CFG" 137 fi
537 refuse 2 "$NOKEY_CFG" --quic "127.0.0.1:$QPORT"
538 refuse 2 "$XDG_CONFIG_HOME" --key "$QKEY"
539 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0
540 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon
541 # Refusals that are about the world rather than the spelling exit 1.
542 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:" --key "$QKEY"
543 refuse 1 "$XDG_CONFIG_HOME" --quic "localhost:$QPORT" --key "$QKEY"
544 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.bad"
545 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.missing"
546
547 # The key-missing message must name the way out, not just the absence.
548 env XDG_CONFIG_HOME="$NOKEY_CFG" "$MUXD" run --sock "$SOCK4.nokey" \
549 --quic "127.0.0.1:$QPORT" 2> "$OUT.nokey" || true
550 grep -q "muxd keygen" "$OUT.nokey" || {
551 echo "e2e FAIL: key-missing message does not name keygen"; cat "$OUT.nokey"; exit 1; }
552 rm -rf "$NOKEY_CFG"
553 ok "no key anywhere is refused, and says how to make one"
554
555 # The accepted case.
556 start_daemon "$SOCK4" "$OUT.d4.d" "--quic daemon never bound its session socket" --shell /bin/sh \
557 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000
558 D4PID=$DPID
559
560 # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is
561 # always there on the platform this daemon runs on, and needs no privileges.
562 # 127.0.0.1 is 0100007F in the little-endian hex the file uses.
563 QHEX=$(printf '0100007F:%04X' "$QPORT")
564 grep -qi " $QHEX " /proc/net/udp || {
565 echo "e2e FAIL: no UDP socket bound at 127.0.0.1:$QPORT ($QHEX)"
566 grep -i "0100007F" /proc/net/udp || true
567 exit 1
568 }
569
570 # A second daemon must NOT be able to take a share of that port. UDP with
571 # SO_REUSEADDR would let it bind alongside the first and the kernel would
572 # hand each datagram to one of them — two sessions splitting one port, with
573 # no error anywhere. This is the QUIC edition of the stale-socket story, and
574 # it needs two processes to test, which is why it lives here.
575 set +e
576 # Same reasoning as refuse(): if the second daemon ever succeeds it runs
577 # until killed, so the failure has to be a timeout rather than a hang.
578 timeout 10 "$MUXD" run --sock "$SOCK4.second" --shell /bin/sh \
579 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000 > "$OUT.q" 2>&1
580 RC=$?
581 set -e
582 [ "$RC" -eq 1 ] || {
583 echo "e2e FAIL: a second daemon took udp $QPORT (exit $RC, want 1; 124 means it bound and ran)"
584 cat "$OUT.q"; exit 1;
585 }
586 grep -q "already listening" "$OUT.q" || {
587 echo "e2e FAIL: second daemon refused, but not with the already-listening message:"
588 cat "$OUT.q"; exit 1;
589 }
590 [ ! -e "$SOCK4.second" ] || {
591 echo "e2e FAIL: refused second daemon left $SOCK4.second behind"; exit 1;
592 }
593
594 # ...and the daemon is still an ordinary daemon: the session runs and the
595 # unix-socket path is unaffected by the listener sharing its poll loop.
596 pipe_mux "$OUT.q" "" "$MUX" --sock "$SOCK4"
597 pipe_send 'printf "quic-%%s\\n" flags-ok\n'
598 await_out "$OUT.q" "quic-flags-ok" "quic-flags-ok never reached the client"
599 pipe_detach
600 grep -q "quic-flags-ok" "$OUT.q" || {
601 echo "e2e FAIL: --quic daemon did not serve an ordinary socket client"; cat "$OUT.q"; exit 1;
602 }
603 "$MUXD" dump --sock "$SOCK4" | grep -q "quic-flags-ok" || {
604 echo "e2e FAIL: --quic daemon's grid missing output"; exit 1;
605 }
606 kill -0 "$D4PID" || { echo "e2e FAIL: --quic daemon died"; exit 1; }
607 assert_converged "$OUT.q" "$SOCK4" "quic daemon serves sockets"
608
609 # --- M8 Task 3: the client speaks quic://. Same daemon, new transport.
610
611 # 1. Attach, run something, detach. The whole point of the milestone in one
612 # scenario: the wire protocol did not change, so this must behave exactly
613 # as the socket client does.
614 pipe_mux "$OUT.qc" "$OUT.qc.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
615 pipe_send 'printf "quic-%%s\\n" attach-ok\n'
616 await_out "$OUT.qc" "quic-attach-ok" "quic-attach-ok never reached the client"
617 pipe_detach "quic:// client"
618 grep -q "quic-attach-ok" "$OUT.qc" || {
619 echo "e2e FAIL: quic:// client render missing output"
620 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
621 }
622 "$MUXD" dump --sock "$SOCK4" | grep -q "quic-attach-ok" || {
623 echo "e2e FAIL: daemon grid missing the quic:// client's output"; exit 1;
624 }
625 assert_converged "$OUT.qc" "$SOCK4" "quic attach"
626
627 # 1b. Reattach. A detach leaves the session running, so coming back must
628 # find it — and must be served as a fresh attach (its own snapshot),
629 # because a client that detached deliberately holds nothing to resume
630 # from. The counter is what tells that apart from a delta.
631 SNAPS_RA=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
632 pipe_mux "$OUT.qc" "$OUT.qc.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
633 pipe_send 'printf "quic-%%s\\n" reattached\n'
634 await_out "$OUT.qc" "quic-reattached" "quic-reattached never reached the client"
635 pipe_detach "quic:// reattach"
636 # The earlier marker is still on the grid this client was handed, which is
637 # the session having survived the detach rather than a new shell.
638 grep -q "quic-attach-ok" "$OUT.qc" || {
639 echo "e2e FAIL: quic:// reattach did not land in the existing session"
640 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
641 }
642 grep -q "quic-reattached" "$OUT.qc" || {
643 echo "e2e FAIL: quic:// reattach could not run a command"
644 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
645 }
646 SNAPS_RA2=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
647 [ "$((SNAPS_RA2 - SNAPS_RA))" -eq 1 ] || {
648 echo "e2e FAIL: reattach served $((SNAPS_RA2 - SNAPS_RA)) snapshots (want exactly 1)"
649 exit 1;
650 }
651 # ...and the slot it held is free again now that it has gone.
652 CLIENTS=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*clients=\([0-9]*\).*/\1/p')
653 [ "$CLIENTS" = "0" ] || {
654 echo "e2e FAIL: $CLIENTS client slots still held after a clean detach (want 0)"; exit 1;
655 }
656 assert_converged "$OUT.qc" "$SOCK4" "quic reattach"
657
658 # 2. The key is checked, and a wrong one is refused loudly rather than
659 # retried forever. Nothing was ever established, so the reconnect loop
660 # must not engage — that is the never-established gate, over QUIC.
661 # No convergence: a refused client painted no grid.
662 head -c 32 /dev/urandom > "$QKEY.wrong"
663 chmod 600 "$QKEY.wrong"
664 set +e
665 : | timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
666 --quic-idle-ms 1500 > "$OUT.qc" 2>&1
667 RC=$?
668 set -e
669 [ "$RC" -eq 1 ] || {
670 echo "e2e FAIL: wrong-key quic client exited $RC (want 1; 124 means it retried)"
671 cat "$OUT.qc"; exit 1;
672 }
673 grep -q "did not answer" "$OUT.qc" || {
674 echo "e2e FAIL: wrong-key quic client said nothing useful:"; cat "$OUT.qc"; exit 1;
675 }
676
677 # 3. The abort key works DURING a handshake, not just after it. waitReady
678 # runs inside Transport.open, after drainStdinForQuit has returned, so
679 # watching only the socket left nothing looking for Ctrl-\ for as long as
680 # the handshake bound allows — and during a reconnect the terminal is in
681 # raw mode, where Ctrl-\ is the only way out.
682 #
683 # The target is the LIVE daemon dialled with the wrong key, and it has
684 # to be something silent like that: an unreachable port used to hold the
685 # handshake open for the whole bound, but since M15 a refused port is
686 # answered by an ICMP unreachable that kills the dial in ~2ms — far too
687 # fast to fit an abort inside, and this scenario would then be timing a
688 # failure rather than an abort. A listener that cannot authenticate us
689 # never answers (mutual PSK, same mechanism as case 2 above), so the
690 # dial runs its full budget and the window is real.
691 #
692 # No --quic-idle-ms: since M15 the dial is bounded by the client's
693 # 2000ms attach budget, which no flag on this command line moves, and
694 # spelling an idle timeout here would suggest otherwise.
695 # No convergence: the client aborts before it ever attaches, so it paints nothing.
696 # A fifo rather than a pipeline, so what is timed is the CLIENT's exit and
697 # not how long the writer happened to hang around afterwards.
698 QFIFO="${TMPDIR:-/tmp}/mux-e2e-abort-fifo-$$"
699 defer_rm "$QFIFO"
700 mkfifo "$QFIFO"
701 ( sleep 0.3; printf '\034'; sleep 20 ) > "$QFIFO" &
702 QWPID=$!
703 defer_kill "$QWPID"
704 QT0=$(date +%s%N)
705 set +e
706 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
707 < "$QFIFO" > "$OUT.qa" 2>&1
708 RC=$?
709 set -e
710 QT1=$(date +%s%N)
711 softkill "$QWPID" || true
712 rm -f "$QFIFO"
713 QMS=$(( (QT1 - QT0) / 1000000 ))
714 [ "$RC" -eq 0 ] || {
715 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)"
716 cat "$OUT.qa"; exit 1;
717 }
718 # The abort lands 300ms in and the budget ends at 2000ms, so this bound is
719 # what tells "answered the user" from "ran the budget out and reported a
720 # failure that happened to exit late" — the exit code catches the second
721 # too, but only this catches an abort that was merely slow. Measured at
722 # ~350ms; 1500 leaves room for a loaded machine and still clears 2000.
723 [ "$QMS" -lt 1500 ] || {
724 echo "e2e FAIL: abort during handshake took ${QMS}ms (want well inside the 2000ms budget)"
725 exit 1;
726 }
727
728 # 4. Reconnect over QUIC, with the resume kind asserted rather than assumed.
729 # The tear is a SIGSTOP held past the client's idle timeout: the daemon
730 # stops answering, the client declares the transport dead and reconnects.
731 # Deterministic, needs no privileges, and unlike kill -9 it leaves the
732 # session alive so the resume can be a DELTA rather than a fresh snapshot.
733 SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
734 [ -n "$SNAPS_BEFORE" ] || { echo "e2e FAIL: could not read snapshots before the tear"; exit 1; }
735
736 pipe_mux "$OUT.qr" "$OUT.qr.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" \
737 --quic-idle-ms 1500
738 pipe_send 'printf "quic-%%s\\n" pre-tear\n'
739 await_out "$OUT.qr" "quic-pre-tear" "quic reconnect client never got its pre-tear marker"
740
741 # Twice the 1500ms idle timeout: long enough that the client cannot mistake
742 # it for a slow moment, short enough not to pad the suite.
743 kill -STOP "$D4PID"
744 sleep 3
745 kill -CONT "$D4PID"
746
747 # The session is alive on the far side of the tear only if a NEW round trip
748 # completes: the pre-tear marker is already in the capture and proves
749 # nothing about the reconnect.
750 pipe_send 'printf "quic-%%s\\n" post-tear\n'
751 await_out "$OUT.qr" "quic-post-tear" "quic client lost its session across the tear"
752 pipe_detach "quic client"
753
754 # The counter is the only witness to HOW the resume was served: a snapshot
755 # renders identically to a delta, so markers cannot tell them apart. One
756 # more snapshot is the fresh attach at the start of this scenario; a second
757 # would mean the reconnect was resynced from scratch instead of resumed.
758 SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
759 [ -n "$SNAPS_AFTER" ] || { echo "e2e FAIL: could not read snapshots after the tear"; exit 1; }
760 [ "$((SNAPS_AFTER - SNAPS_BEFORE))" -eq 1 ] || {
761 echo "e2e FAIL: quic reconnect served $((SNAPS_AFTER - SNAPS_BEFORE)) snapshots (want 1:"
762 echo " the attach only; the resume itself must be delta-served)"
763 exit 1;
764 }
765 assert_converged "$OUT.qr" "$SOCK4" "quic delta resume"
766
767 # 5. The daemon is killed outright and started again on the SAME paths — unix
768 # socket and UDP port both. Two things are under test. The client must
769 # resume into a session that no longer exists, which can only be a
770 # snapshot under a new epoch (the stale-seq fence): a delta here would
771 # mean the daemon honoured a seq belonging to content that is gone. And
772 # the restarted daemon must be able to rebind the UDP port at all — there
773 # is no SO_REUSEADDR any more, so anything lingering from the killed
774 # process would show up as a bind failure rather than as silent sharing.
775 pipe_mux "$OUT.qk" "$OUT.qk.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" \
776 --quic-idle-ms 1500
777 pipe_send 'printf "quic-%%s\\n" pre-restart\n'
778 wait_for "$OUT.qk" "quic-pre-restart" 20 || {
779 echo "e2e FAIL: quic restart client never got its pre-restart marker"
780 cat "$OUT.qk" "$OUT.qk.err" 2>/dev/null; exit 1;
781 }
782 138
783 PAINTS_BEFORE=$(repaints "$OUT.qk") 139 for _g in $E2E_GROUPS; do
784 hardkill "$D4PID" 140 . "$E2E_DIR/e2e_$_g.sh"
785 D4PID=""
786 sleep 0.5
787 start_daemon "$SOCK4" "$OUT.q" "restarted --quic daemon never rebound its session socket" --shell /bin/sh \
788 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
789 D4PID=$DPID
790 # Nearly vacuous, and kept only because a MISSING file would still be worth
791 # saying out loud: kill -9 leaves the old socket file behind, so this
792 # passes on the dead daemon's leavings. What proves the unix socket serves
793 # again is further down, where `muxd dump` and `muxd stats` answer on it;
794 # what proves the daemon serves SESSIONS again is the post-restart marker
795 # the resumed client gets back.
796 # The UDP port really came back, and to THIS daemon. Without SO_REUSEADDR a
797 # bind that collided would have failed loudly instead.
798 #
799 # Polled rather than sampled once, and matched on the LOCAL address only
800 # ($2), because both halves of the old one-shot `grep " $QHEX "` were
801 # accidents. The socket wait above cannot gate it: kill -9 leaves the
802 # socket FILE behind, so that loop exits before the new daemon has done
803 # anything. And a bare grep also matched the reconnecting CLIENT's socket,
804 # whose rem_address is this port — which is what made an unbound instant
805 # read look bound. That client no longer holds a socket at this moment:
806 # since M15 a refused dial dies in ~1 RTT and the socket goes with it, so
807 # the gap between reconnect attempts is now genuinely empty. Both fixed
808 # here rather than one, since either alone still passes on an accident.
809 #
810 # One expression, used by the wait and the verdict: two spellings of the
811 # same match are two matches waiting to drift apart.
812 udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; }
813 i=0
814 # 50 x 0.1s, the same 5s every daemon wait in this file allows.
815 while ! udp_local_bound "$QHEX" && [ "$i" -lt 50 ]; do
816 sleep 0.1; i=$((i+1))
817 done 141 done
818 udp_local_bound "$QHEX" || {
819 echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in 5s"
820 echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)"
821 echo " /proc/net/udp lines mentioning the port:"
822 grep -i " $QHEX " /proc/net/udp || echo " (none)"
823 cat "$OUT.q"; exit 1;
824 }
825 kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; }
826
827 await_repaint "$OUT.qk" "$PAINTS_BEFORE" "quic client never resumed into the restarted daemon"
828 pipe_send 'printf "quic-%%s\\n" post-restart\n'
829 await_out "$OUT.qk" "quic-post-restart" "quic client did not resume into the restarted daemon"
830 pipe_detach "quic client"
831 # A new daemon means a new shell, so the old marker cannot be in its grid.
832 # If it were, we would be looking at a client that resumed off a seq
833 # belonging to a session that no longer exists.
834 if "$MUXD" dump --sock "$SOCK4" | grep -q "quic-pre-restart"; then
835 echo "e2e FAIL: restarted daemon's grid still holds the pre-restart marker"
836 exit 1
837 fi
838 # And the counter, because markers are blind to how a resume was served: the
839 # reconnecting client is this daemon's only client, so a snapshot in its
840 # stats is proof it was resynced from scratch rather than handed a delta off
841 # a stale seq.
842 SNAPS_NQ=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
843 [ -n "$SNAPS_NQ" ] || { echo "e2e FAIL: could not read the restarted daemon's counters"; exit 1; }
844 [ "$SNAPS_NQ" -ge 1 ] || {
845 echo "e2e FAIL: restarted daemon served no snapshot ($SNAPS_NQ); the stale seq was honoured"
846 exit 1;
847 }
848 assert_converged "$OUT.qk" "$SOCK4" "quic epoch resync"
849
850 softkill "$D4PID" || true
851 D4PID=""
852
853 # --- M10: the daemon's own key resolution, over a real handshake.
854 #
855 # 1. MUX_KEY_FILE with no --key anywhere. The daemon's env support had no
856 # automated coverage at all: envKey() reads the real environment, so
857 # only a daemon started with it set can prove the path works.
858 env MUX_KEY_FILE="$QKEY" "$MUXD" run --sock "$SOCK9" --shell /bin/sh \
859 --quic "127.0.0.1:$QPORT2" --quic-idle-ms 3000 > "$OUT.d9.d" 2>&1 &
860 D9PID=$!
861 defer_kill "$D9PID"
862 wait_sock "$SOCK9" "$OUT.d9.d" "MUX_KEY_FILE daemon never bound its session socket"
863
864 pipe_mux "$OUT.env1" "$OUT.env1.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT2" --key "$QKEY" --quic-idle-ms 15000
865 pipe_send 'printf "envkey-%%s\\n" ok\n'
866 await_out "$OUT.env1" "envkey-ok" "envkey-ok never reached the client"
867 pipe_detach "quic attach to a MUX_KEY_FILE daemon"
868 grep -q "envkey-ok" "$OUT.env1" || {
869 echo "e2e FAIL: MUX_KEY_FILE daemon served no session"
870 cat "$OUT.env1" "$OUT.env1.err" 2>/dev/null; exit 1; }
871 assert_converged "$OUT.env1" "$SOCK9" "env key"
872 softkill "$D9PID" || true
873 D9PID=""
874
875 # 2. --key AND MUX_KEY_FILE, naming different keys. The env names one the
876 # client does not have, so an attach that succeeds proves the FLAG won.
877 # This is the shape that catches the two being swapped at the call site
878 # in run() — pickKey's unit test pins the ordering inside the function
879 # and is blind to the order they are handed to it.
880 env MUX_KEY_FILE="$QKEY.wrong" "$MUXD" run --sock "$SOCK10" --shell /bin/sh \
881 --quic "127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 3000 > "$OUT.d10.d" 2>&1 &
882 D10PID=$!
883 defer_kill "$D10PID"
884 wait_sock "$SOCK10" "$OUT.d10.d" "--key-beats-env daemon never bound its session socket"
885
886 pipe_mux "$OUT.env2" "$OUT.env2.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 15000
887 pipe_send 'printf "flagwins-%%s\\n" ok\n'
888 await_out "$OUT.env2" "flagwins-ok" "flagwins-ok never reached the client"
889 pipe_detach "the --key attach (which must beat MUX_KEY_FILE)"
890 grep -q "flagwins-ok" "$OUT.env2" || {
891 echo "e2e FAIL: --key-beats-env daemon served no session"
892 cat "$OUT.env2" "$OUT.env2.err" 2>/dev/null; exit 1; }
893 assert_converged "$OUT.env2" "$SOCK10" "flag beats env"
894 softkill "$D10PID" || true
895 D10PID=""
896 ok "daemon honours MUX_KEY_FILE, and --key beats it"
897
898 rm_swept "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong" \
899 "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err"
900
901 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines.
902 #
903 # Every scenario below asserts a MARKER through the session, never just $?.
904 # A client that fails to authenticate exits 0 by way of "aborted before
905 # attaching", so an exit code cannot distinguish a working session from a
906 # key or transport regression — only bytes coming back out of the shell can.
907 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start"
908 grep -q '^muxd: starting' "$OUT.start" || {
909 echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; }
910 grep -q '^up (' "$OUT.start" || {
911 echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; }
912 # Known gap, accepted: between the spawn above and this capture the daemon
913 # is running with no pid the trap can reach, so a failure in the two
914 # assertions in that window leaks it. Narrow, only on an already-failing
915 # run, and closing it would mean parsing the pid before asserting the lines
916 # that prove the pid is there.
917 SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
918 defer_kill "$SPID"
919 [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; }
920 kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; }
921 # Non-tty stderr: exactly two lines, no dots.
922 [ "$(wc -l < "$OUT.start")" = "2" ] || {
923 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
924
925 # The daemon it started serves a session (marker in, marker in dump).
926 pipe_mux "$OUT.s8" "$OUT.s8.err" "$MUX" --sock "$SOCK8"
927 pipe_send 'printf "start-%%s\\n" works\n'
928 await_out "$OUT.s8" "start-works" "start-works never reached the client"
929 pipe_detach
930 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
931 echo "e2e FAIL: auto-started daemon lost the marker"
932 cat "$OUT.s8" "$OUT.s8.err" 2>/dev/null; exit 1; }
933 assert_converged "$OUT.s8" "$SOCK8" "started daemon"
934
935 # The log the failure path names really is there, and is no more readable
936 # than the key is: it carries whatever the daemon says about its own start.
937 MUXLOG="$XDG_STATE_HOME/mux/muxd.log"
938 [ -f "$MUXLOG" ] || { echo "e2e FAIL: no daemon log at $MUXLOG"; exit 1; }
939 LPERMS=$(stat -c %a "$MUXLOG")
940 [ "$LPERMS" = "600" ] || { echo "e2e FAIL: daemon log perms $LPERMS, want 600"; exit 1; }
941
942 # Rerun: silent no-op beyond the already-running line, exit 0, same daemon.
943 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start2"
944 grep -q "already running on $SOCK8 (stop it first with \`muxd stop --sock $SOCK8\`" "$OUT.start2" || {
945 echo "e2e FAIL: rerun did not say already running"; cat "$OUT.start2"; exit 1; }
946 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
947 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; }
948
949 # Truncated per spawn, not appended to: the log holds the CURRENT daemon's
950 # output, so a stale one must not be what an operator reads after a
951 # restart. Padded, then a real spawn on a fresh path must shrink it.
952 dd if=/dev/zero bs=1024 count=8 2>/dev/null >> "$MUXLOG"
953 PADDED=$(stat -c %s "$MUXLOG")
954 "$MUXD" start --sock "$SOCK8T" 2> "$OUT.start"
955 TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
956 defer_kill "$TPID"
957 [ -n "$TPID" ] || { echo "e2e FAIL: truncation spawn reported no pid"; exit 1; }
958 SHRUNK=$(stat -c %s "$MUXLOG")
959 [ "$SHRUNK" -lt "$PADDED" ] || {
960 echo "e2e FAIL: log not truncated on spawn ($PADDED -> $SHRUNK)"; exit 1; }
961 softkill "$TPID" || true
962 TPID=""
963
964 # Race: two concurrent starts, both exit 0, still one session (the marker
965 # survives — a second daemon on the path would have started a fresh shell).
966 softkill "$SPID" && wait_gone "$SOCK8"
967 SPID=""
968 "$MUXD" start --sock "$SOCK8" 2> "$OUT.ra" & RA=$!; defer_kill "$RA"
969 "$MUXD" start --sock "$SOCK8" 2> "$OUT.rb" & RB=$!; defer_kill "$RB"
970 set +e
971 wait "$RA"; RCA=$?
972 wait "$RB"; RCB=$?
973 set -e
974 { [ "$RCA" = "0" ] && [ "$RCB" = "0" ]; } || {
975 echo "e2e FAIL: race: exits $RCA/$RCB"; cat "$OUT.ra" "$OUT.rb"; exit 1; }
976 pipe_mux "$OUT.race" /dev/null "$MUX" --sock "$SOCK8"
977 pipe_send 'printf "race-%%s\\n" one\n'
978 await_out "$OUT.race" "race-one" "race-one never reached the client"
979 pipe_detach
980 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
981 echo "e2e FAIL: race: session unusable"; exit 1; }
982 SPID=$(cat "$OUT.ra" "$OUT.rb" | sed -n 's/.* pid=\([0-9]*\).*/\1/p' | while read -r p; do
983 kill -0 "$p" 2>/dev/null && echo "$p"; done | head -1)
984 defer_kill "$SPID"
985
986 # What actually makes two concurrent starts safe is that the LOSER exits
987 # instead of unlinking the winner's socket and binding over it. The race
988 # above cannot see that — both starts report success either way, and the
989 # marker comes back from whichever daemon owns the path — so it is pinned
990 # head-on: a second `muxd run` against a live socket must refuse.
991 set +e
992 timeout 10 "$MUXD" run --sock "$SOCK8" --shell /bin/sh > "$OUT.rb" 2>&1
993 RCS=$?
994 set -e
995 [ "$RCS" -eq 1 ] || {
996 echo "e2e FAIL: second muxd run on a live socket exited $RCS (want 1; 124 means it took the path)"
997 cat "$OUT.rb"; exit 1; }
998 grep -q "already running" "$OUT.rb" || {
999 echo "e2e FAIL: second muxd run refused, but not as already-running"; cat "$OUT.rb"; exit 1; }
1000 # ...and the session the winner was serving is undisturbed.
1001 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
1002 echo "e2e FAIL: the refused daemon disturbed the live session"; exit 1; }
1003 # The race client's own capture, checked here rather than above: the refused
1004 # `muxd run` between the two touches no session, so the grid has not moved.
1005 assert_converged "$OUT.race" "$SOCK8" "start race"
1006
1007 softkill "$SPID" || true
1008 SPID=""
1009 ok "muxd start — spawn, no-op rerun, log truncation, race"
1010
1011 # --- M10: a start whose daemon dies young REPORTS it. This is the first-run
1012 # mistake the failure line exists for — `ssh HOST 'muxd start --quic 0.0.0.0'`
1013 # before the key was ever scp'd — so it must be a message, not a panic. The
1014 # child exits on the missing key, the poll loop reaps it, and polling
1015 # continues to the deadline; a second waitpid there gets ECHILD, which the
1016 # stdlib answers with `unreachable`, i.e. exit 134 and a stack trace.
1017 # No convergence: the daemon under test is the one that died, so nothing to dump.
1018 DEADCFG="${TMPDIR:-/tmp}/mux-e2e-deadchild-$$"
1019 defer_rm "$DEADCFG"
1020 set +e
1021 env XDG_CONFIG_HOME="$DEADCFG" timeout 30 "$MUXD" start --sock "$SOCK8T" \
1022 --quic "127.0.0.1:1" --key /nonexistent > "$OUT.dead" 2>&1
1023 DRC=$?
1024 set -e
1025 [ "$DRC" -eq 1 ] || {
1026 echo "e2e FAIL: start with a doomed child exited $DRC (want 1; 134 is the waitpid panic)"
1027 cat "$OUT.dead"; exit 1; }
1028 grep -q "did not answer" "$OUT.dead" || {
1029 echo "e2e FAIL: doomed start printed no deadline line"; cat "$OUT.dead"; exit 1; }
1030 grep -q "log: .*muxd\.log" "$OUT.dead" || {
1031 echo "e2e FAIL: deadline line does not name the log"; cat "$OUT.dead"; exit 1; }
1032 # The failure says "daemon did not answer", never "muxd did not answer":
1033 # the prefix is already the program's name, and the same line serves the
1034 # mux-side caller when auto-start lands.
1035 grep -q "daemon did not answer" "$OUT.dead" || {
1036 echo "e2e FAIL: deadline line does not read 'daemon did not answer'"
1037 cat "$OUT.dead"; exit 1; }
1038 # Non-tty: exactly two lines, same as the success path. The newline that
1039 # terminates the dot line is tty-only, so nothing blank creeps in here.
1040 [ "$(wc -l < "$OUT.dead")" = "2" ] || {
1041 echo "e2e FAIL: non-tty failure not exactly two lines:"; cat "$OUT.dead"; exit 1; }
1042 rm -rf "$DEADCFG"
1043 ok "a start whose daemon dies young says so, with the log path"
1044
1045 # --- M10: the goal commands, minus ssh: keygen'd default key on both ends,
1046 # explicit loopback port (4433 on the suite machine is somebody's daemon).
1047 # No --key on either side — the key is the one `muxd keygen` wrote at the
1048 # hermetic default path near the top of this suite.
1049 "$MUXD" start --sock "$SOCK11" --quic "127.0.0.1:$QPORT4" 2> "$OUT.goal"
1050 GPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.goal")
1051 defer_kill "$GPID"
1052 [ -n "$GPID" ] || { echo "e2e FAIL: goal start reported no pid"; cat "$OUT.goal"; exit 1; }
1053 pipe_mux "$OUT.g9" "$OUT.g9.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT4"
1054 pipe_send 'printf "goal-%%s\\n" quic\n'
1055 await_out "$OUT.g9" "goal-quic" "goal-quic never reached the client"
1056 pipe_detach
1057 "$MUXD" dump --sock "$SOCK11" | grep -q "goal-quic" || {
1058 echo "e2e FAIL: no-key-flag QUIC attach did not reach the session"
1059 cat "$OUT.g9" "$OUT.g9.err" 2>/dev/null; exit 1; }
1060 assert_converged "$OUT.g9" "$SOCK11" "goal commands"
1061 softkill "$GPID" || true
1062 GPID=""
1063 ok "keygen + start --quic + mux quic:// with no --key anywhere"
1064 142
1065 # M9 prediction: each scenario needs a session whose LINE DISCIPLINE it 143 # M9 prediction: each scenario needs a session whose LINE DISCIPLINE it
1066 # controls, so they cannot share the long-lived /bin/sh daemon. 144 # controls, so they cannot share the long-lived /bin/sh daemon.
test/e2e_01_boot.sh
Old New
@@ -0,0 +1,965 @@
1 # shellcheck shell=sh
2 # e2e_01_boot.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may
4 # assume and what it must register.
5 # Second daemon, used only by the M7 abort scenario.
6 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock"
7 defer_sock "$SOCK2"
8 # Third daemon, for the restart scenario: it gets killed and started again on
9 # the same path, so it cannot share the long-lived one.
10 SOCK3="${TMPDIR:-/tmp}/muxd-e2e-restart-$$.sock"
11 defer_sock "$SOCK3"
12 # Fourth daemon, for the M8 --quic scenario: it is the only one holding a UDP
13 # port, so it gets its own path rather than sharing the long-lived one.
14 SOCK4="${TMPDIR:-/tmp}/muxd-e2e-quic-$$.sock"
15 defer_sock "$SOCK4" "$SOCK4.nokey" "$SOCK4.second"
16 QKEY="${TMPDIR:-/tmp}/mux-e2e-key-$$"
17 defer_rm "$QKEY" "$QKEY.wrong" "$QKEY.bad"
18 # A port out of the way of the ephemeral range, made per-run so two suites can
19 # overlap. Collisions surface as a loud bind failure, never as a silent pass.
20 QPORT=$(( 21000 + ($$ % 4000) ))
21 # M10 key-resolution daemons. Two, not one: the first scenario's client
22 # detaches and its daemon is killed, and the second must not be sharing
23 # either. Ports in bands of their own so a concurrent suite cannot collide.
24 SOCK9="${TMPDIR:-/tmp}/muxd-e2e-envkey-$$.sock"
25 defer_sock "$SOCK9"
26 SOCK10="${TMPDIR:-/tmp}/muxd-e2e-flagwins-$$.sock"
27 defer_sock "$SOCK10"
28 QPORT2=$(( 26000 + ($$ % 4000) ))
29 QPORT3=$(( 31000 + ($$ % 4000) ))
30 # M10 `muxd start`. These daemons are spawned DETACHED, so the suite never
31 # holds their pids as shell jobs — it reads them off the up-line and kills
32 # by that tracked pid, never by name.
33 SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"
34 defer_sock "$SOCK8"
35 SOCK8T="${TMPDIR:-/tmp}/muxd-e2e-trunc-$$.sock"
36 defer_sock "$SOCK8T"
37 SOCK11="${TMPDIR:-/tmp}/muxd-e2e-goal-$$.sock"
38 defer_sock "$SOCK11"
39 QPORT4=$(( 36000 + ($$ % 4000) ))
40
41 # --- M10: --version answers "did the scp land" without a daemon anywhere.
42 # No convergence: no daemon and no client, so there is no grid on either side.
43 "$MUXD" --version | grep -q '^muxd 0\.' || { echo "e2e FAIL: muxd --version"; exit 1; }
44 "$MUX" --version | grep -q '^mux 0\.' || { echo "e2e FAIL: mux --version"; exit 1; }
45 # Both must report the SAME version: the greps above pass just as happily for
46 # a binary that re-hardcoded a literal instead of reading build.zig's constant,
47 # which is exactly the drift --version exists to rule out.
48 [ "$("$MUXD" --version | cut -d' ' -f2)" = "$("$MUX" --version | cut -d' ' -f2)" ] || {
49 echo "e2e FAIL: version drift between binaries"; exit 1; }
50 ok "--version on both binaries"
51
52 # --- M10: keygen writes 0600, prints the path, refuses a second run.
53 # No convergence: keygen touches the filesystem, never a session.
54 KEYOUT=$("$MUXD" keygen)
55 [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || {
56 echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; }
57 PERMS=$(stat -c %a "$KEYOUT")
58 [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; }
59 DPERMS=$(stat -c %a "$XDG_CONFIG_HOME/mux")
60 [ "$DPERMS" = "700" ] || { echo "e2e FAIL: key dir perms $DPERMS, want 700"; exit 1; }
61 SUM1=$(sha256sum "$KEYOUT")
62 if "$MUXD" keygen > /dev/null 2>&1; then
63 echo "e2e FAIL: second keygen did not refuse"; exit 1
64 fi
65 SUM2=$(sha256sum "$KEYOUT")
66 [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; }
67 ok "keygen creates once, 0600 in a 0700 dir, refuses twice"
68
69 start_daemon "$SOCK" "$OUT.d1.d" "socket never appeared" --shell /bin/sh
70 D1PID=$DPID
71
72 # Client with piped stdio: types a command, waits for its output, detaches
73 # with the Ctrl-\ chord (prefix, then prefix again for detach).
74 pipe_mux "$OUT" "" timeout 30 "$MUX" --sock "$SOCK"
75 pipe_send 'printf "e2e-%%s\\n" works\n'
76 # 1. The client's rendered output must contain the command's result.
77 await_out "$OUT" "e2e-works" "client render missing output"
78 pipe_detach
79
80 # 2. The daemon kept the session; its grid must match.
81 "$MUXD" dump --sock "$SOCK" | grep -q "e2e-works" || {
82 echo "e2e FAIL: daemon grid missing output"; exit 1;
83 }
84
85 # 3. Detach left the daemon running.
86 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
87
88 # 4. The M11 claim itself: the screen the client painted equals the
89 # screen the daemon holds — same engine, same formatter, both formats.
90 assert_converged "$OUT" "$SOCK" "base attach"
91
92 # The control: a doctored stream must NOT converge. A convergence check
93 # that cannot fail proves nothing (the wan.sh rule, M9).
94 cp "$OUT" "$OUT.doctored"
95 printf '\033[12;1Hconvergence-control-glyphs' >> "$OUT.doctored"
96 if converged_quiet "$OUT.doctored" "$SOCK"; then
97 echo "e2e FAIL: convergence control did not fire on a doctored stream"; exit 1
98 fi
99 rm_swept "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \
100 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \
101 "$OUT.doctored.rvt" "$OUT.doctored.dvt"
102 ok "convergence control fires on a doctored stream"
103
104 # --- M11: a styled specimen, so the byte-exact leg has something to compare.
105 # The campaign's row 4 broke the delta paint's leading SGR reset and BOTH
106 # suites passed — not because the styled comparison is weak but because no
107 # scenario in the corpus had ever emitted a colour. A suppressed reset resets
108 # nothing when nothing upstream is styled, so the instrument was handed no
109 # specimen. This scenario is the specimen: two delta rows carrying a live
110 # attribute, followed by a plain prompt row that must come back unstyled.
111 #
112 # The escapes are doubled because they are written by the SESSION's shell,
113 # not by this one: what goes down the pipe is the literal text
114 # `printf "\033[1;31mstyled-%s\033[0m\n" red bold`.
115 pipe_mux "$OUT.st" "" timeout 30 "$MUX" --sock "$SOCK"
116 pipe_send 'printf "\\033[1;31mstyled-%%s\\033[0m\\n" red bold\n'
117 await_out "$OUT.st" "styled-bold" "the second styled row never reached the client"
118 pipe_detach
119 grep -q "styled-red" "$OUT.st" || {
120 echo "e2e FAIL: styled output never reached the client"; cat "$OUT.st"; exit 1; }
121 # The plain leg would pass on a bled attribute — same glyphs, wrong colours.
122 # The --vt leg inside assert_converged is the one that speaks here.
123 assert_converged "$OUT.st" "$SOCK" "styled content"
124 rm_swept "$OUT.st"
125 ok "styled content survives the paint path"
126
127 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
128 # No convergence on $OUT.kill: kill -9 truncates the stream mid-paint, possibly
129 # mid-escape-sequence, so a half-drawn capture is the expected shape here.
130 { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" &
131 CPID=$!
132 defer_kill "$CPID"
133 sleep 1
134 hardkill "$CPID"
135 sleep 1
136 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; }
137
138 { sleep 1; printf '\034\034'; } | timeout 30 "$MUX" --sock "$SOCK" > "$OUT.re"
139 grep -q "60" "$OUT.re" || {
140 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
141 }
142 assert_converged "$OUT.re" "$SOCK" "reattach after kill"
143 rm_swept "$OUT.kill" "$OUT.re"
144
145 # --- M5: two clients on one session. Output typed in A reaches both; then A
146 # detaches and B must still have a live input path (its own marker echoes back
147 # after A is gone), so one client's detach does not disturb the other.
148 { sleep 0.5; printf 'printf "m5-%%s\\n" both\n'; sleep 2.5; printf '\034\034'; } | \
149 "$MUX" --sock "$SOCK" > "$OUT.a" &
150 APID=$!
151 defer_kill "$APID"
152 { sleep 4; printf 'printf "m5-%%s\\n" after-a-left\n'; sleep 2.5; printf '\034\034'; } | \
153 "$MUX" --sock "$SOCK" > "$OUT.b" &
154 BPID=$!
155 defer_kill "$BPID"
156 wait "$APID" "$BPID"
157
158 # The marker text never appears in the echoed command line ("m5-%s" plus a
159 # separate argument), so a hit proves the shell ran it and the result came back.
160 grep -q "m5-both" "$OUT.a" || { echo "e2e FAIL: client A missing shared output"; exit 1; }
161 grep -q "m5-both" "$OUT.b" || { echo "e2e FAIL: client B missing shared output"; exit 1; }
162 grep -q "m5-after-a-left" "$OUT.b" || {
163 echo "e2e FAIL: client B lost its input path after A detached"; exit 1;
164 }
165 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; }
166 # B's capture, not A's: A detached before B's marker landed, so A's stream ends
167 # on a grid the daemon has since moved past. B saw both, and its convergence
168 # covers the same grid.
169 assert_converged "$OUT.b" "$SOCK" "two clients"
170 rm_swept "$OUT.a" "$OUT.b"
171
172 # --- M6: the same protocol over an arbitrary byte pipe. `muxd proxy` is a
173 # frame-agnostic stdio<->socket pump; if the session works through it, the
174 # transport really is a swap. The string is one shell word here and is split
175 # by the /bin/sh -c that mux spawns, so $MUXD must contain no spaces — it is
176 # the build tree's artifact path, which does not.
177 #
178 # XDG_RUNTIME_DIR is pointed at nothing so the test cannot pass by environment
179 # luck: if --via ever silently fell back to the default socket path, that path
180 # would resolve to a directory that does not exist and the session would fail
181 # instead of quietly attaching over the local socket.
182 pipe_mux "$OUT.via" "" env XDG_RUNTIME_DIR=/nonexistent-mux-e2e "$MUX" --via "$MUXD proxy --sock $SOCK"
183 pipe_send 'printf "m6-%%s\\n" via-pipe\n'
184 await_out "$OUT.via" "m6-via-pipe" "--via transport"
185 pipe_detach
186 kill -0 "$D1PID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; }
187 assert_converged "$OUT.via" "$SOCK" "via transport"
188 rm_swept "$OUT.via"
189
190 # --- M10: a --via command that dies before the first frame stops claiming
191 # a connection existed. ssh's own stderr still passes through untouched.
192 # No convergence: no session was ever established, so there is no grid to match.
193 set +e
194 "$MUX" --via "sh -c 'exit 127'" > "$OUT.via" 2>&1
195 VRC=$?
196 set -e
197 [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; }
198 grep -q "transport command failed before a session started" "$OUT.via" || {
199 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; }
200 grep -q "connection to muxd lost" "$OUT.via" && {
201 echo "e2e FAIL: the old lie is still printed"; cat "$OUT.via"; exit 1; }
202 rm_swept "$OUT.via"
203 ok "--via failure says what happened"
204
205 # --- M7: a transport that dies before any session must exit, not retry. The
206 # reconnect loop resumes sessions; it must not turn a bad --via command into
207 # an unkillable client. With piped stdin there is no Ctrl-\ to rescue it, so
208 # a regression here hangs forever: exit 124 below is the timeout, and it is
209 # the failure this test exists to catch.
210 # No convergence: the transport died first, so no session and no grid.
211 set +e
212 timeout 10 "$MUX" --via "exit 7" < /dev/null > "$OUT.dead" 2>&1
213 RC=$?
214 set -e
215 [ "$RC" -eq 1 ] || {
216 echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)"
217 cat "$OUT.dead"; exit 1;
218 }
219 # M10 reworded this one: `--via "exit 7"` is the same shape as the scenario
220 # above — a transport command that died before carrying a frame — so it now
221 # gets the honest message. What this scenario is FOR is the exit code above
222 # (1, never 124); the diagnostic is asserted so the exit is not a silent one.
223 grep -q "transport command failed before a session started" "$OUT.dead" || {
224 echo "e2e FAIL: dead first transport lost its diagnostic; got:"; cat "$OUT.dead"; exit 1;
225 }
226 # The can-fail control for the M13 reword: a grep for the new wording passes
227 # just as well if BOTH lines are printed, so the old one must be absent from
228 # the very capture that just satisfied it. The dropped parenthetical guessed
229 # a cause the transport's own stderr had already named.
230 grep -q "is muxd installed on the host" "$OUT.dead" && {
231 echo "e2e FAIL: old lostMsg wording still emitted alongside the new pin"
232 cat "$OUT.dead"; exit 1; }
233 # Free ride on this capture, for a property with nothing to do with the exit
234 # code: this client had NON-TTY stdin and died before any frame, so it is
235 # the one place in the suite that proves mux does not push a title onto a
236 # terminal it never took over. An unmatched push is a title stack that only
237 # grows, and nothing later would pop it — the teardown never runs.
238 # -F is load-bearing, not decoration: without it `[22;0t` is a malformed
239 # bracket expression, grep EXITS 2 rather than 1, and `grep && { fail }`
240 # reads an error as "not found" — an assertion that can never fire. Caught
241 # by running this needle by hand against a capture known to contain it.
242 grep -qaF "$(printf '\033[22;0t')" "$OUT.dead" && {
243 echo "e2e FAIL: a client that entered nothing still pushed the title stack"
244 cat -v "$OUT.dead"; exit 1; }
245
246 # M13 spec amendment: a socket path past sun_path's 107 usable bytes is
247 # refused by name in both binaries, before either acts on it — the failure
248 # it replaced was a 2s poll ending in "daemon did not answer", a timeout
249 # story about a path that was doomed at parse.
250 LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock"
251 "$MUXD" dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: muxd accepted long sock"; exit 1; }
252 grep -q "muxd: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; }
253 "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; }
254 grep -q "mux: socket path too long" "$OUT.longc" || { echo "e2e FAIL:"; cat "$OUT.longc"; exit 1; }
255 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; }
256 "$MUXD" --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; }
257
258 rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc"
259
260 # --- M7: aborting a reconnect exits cleanly. The client establishes a real
261 # session (so reconnect is allowed), its daemon is then killed under it, and
262 # Ctrl-\ arrives while it is retrying. Exit 0 and the message; a double-close
263 # of the transport would abort here instead (SIGABRT = 134).
264 # No convergence: the client exits while reconnecting and its daemon is dead,
265 # so there is nothing left to dump against.
266 start_daemon "$SOCK2" "$OUT.d2.d" "second socket never appeared" --shell /bin/sh
267 D2PID=$DPID
268
269 set +e
270 { printf 'echo m7-abort-live\n'; sleep 3; printf '\034'; sleep 2; } | \
271 timeout 20 "$MUX" --sock "$SOCK2" > "$OUT.abort" 2>&1 &
272 CLIPID=$!
273 defer_kill "$CLIPID"
274 # Let the session establish, then take the daemon away mid-session.
275 sleep 2
276 hardkill "$D2PID"
277 D2PID=""
278 wait "$CLIPID"
279 RC=$?
280 set -e
281 [ "$RC" -eq 0 ] || {
282 echo "e2e FAIL: abort during reconnect exited $RC (want 0; 134 = panic, 124 = hung)"
283 cat "$OUT.abort"; exit 1;
284 }
285 grep -q "detached while reconnecting" "$OUT.abort" || {
286 echo "e2e FAIL: abort during reconnect lost its message; got:"; cat "$OUT.abort"; exit 1;
287 }
288 rm_swept "$OUT.abort" "$SOCK2"
289
290 # --- M7 Scenario A: kill the transport mid-session; the client must resume
291 # by DELTA. The proxy is the transport, so killing it stands in for an ssh
292 # drop while the daemon and its session carry on untouched.
293 #
294 # The counter is the real assertion, not the markers: a snapshot-served
295 # resume renders "m7-after" perfectly well, so only `snapshots` holding
296 # still across the tear proves the resume was a delta — which is the whole
297 # milestone claim.
298 pipe_mux "$OUT.m7" "$OUT.m7.err" env XDG_RUNTIME_DIR=/nonexistent-mux-e2e timeout 40 \
299 "$MUX" --via "$MUXD proxy --sock $SOCK"
300 pipe_send 'printf "m7-%%s\\n" before\n'
301
302 # Tear only once the session is provably live and idle: the marker is the
303 # shell's own output, so seeing it means attached and drained.
304 wait_for "$OUT.m7" "m7-before" 20 || {
305 echo "e2e FAIL: m7 pre-tear output never arrived"
306 cat "$OUT.m7" "$OUT.m7.err" 2>/dev/null; exit 1;
307 }
308 SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
309 PAINTS_BEFORE=$(repaints "$OUT.m7")
310 PPID_PROXY=$(proxy_pid "$SOCK")
311 [ -n "$PPID_PROXY" ] || { echo "e2e FAIL: could not find the proxy to kill"; exit 1; }
312 kill -9 "$PPID_PROXY"
313 # The tear must have hit the transport only; the daemon is what we are
314 # proving survives, so say so out loud rather than inferring it later.
315 kill -0 "$D1PID" || { echo "e2e FAIL: the tear killed the daemon, not the proxy"; exit 1; }
316
317 await_repaint "$OUT.m7" "$PAINTS_BEFORE" "m7 client never resumed after the transport was killed"
318 pipe_send 'printf "m7-%%s\\n" after\n'
319 await_out "$OUT.m7" "m7-after" "m7 client did not resume after the transport was killed"
320 pipe_detach "m7 client"
321 SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
322 [ -n "$SNAPS_BEFORE" ] && [ -n "$SNAPS_AFTER" ] || {
323 echo "e2e FAIL: could not read the snapshots counter"; exit 1;
324 }
325 [ "$SNAPS_BEFORE" = "$SNAPS_AFTER" ] || {
326 echo "e2e FAIL: reconnect was served a snapshot ($SNAPS_BEFORE -> $SNAPS_AFTER), not a delta"
327 exit 1;
328 }
329 assert_converged "$OUT.m7" "$SOCK" "delta resume"
330 rm_swept "$OUT.m7" "$OUT.m7.err"
331
332 # --- M7 Scenario B: kill the DAEMON under an attached client and start a new
333 # one on the same path. The seq the client holds belongs to a session that no
334 # longer exists, so the epoch fence must refuse it and serve a snapshot of the
335 # fresh session instead. Needs its own daemon, since this one gets killed.
336 start_daemon "$SOCK3" "$OUT.d3a.d" "restart-scenario socket never appeared" --shell /bin/sh
337 D3PID=$DPID
338
339 pipe_mux "$OUT.m7b" "$OUT.m7b.err" timeout 40 "$MUX" --sock "$SOCK3"
340 pipe_send 'printf "m7b-%%s\\n" pre-restart\n'
341 wait_for "$OUT.m7b" "m7b-pre-restart" 20 || {
342 echo "e2e FAIL: m7b pre-restart output never arrived"
343 cat "$OUT.m7b" "$OUT.m7b.err" 2>/dev/null; exit 1;
344 }
345
346 # SIGKILL leaves the socket file behind; the new daemon's stale-socket
347 # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here.
348 PAINTS_BEFORE=$(repaints "$OUT.m7b")
349 hardkill "$D3PID"
350 sleep 0.5
351 start_daemon "$SOCK3" "$OUT.d3b.d" "daemon did not rebind the stale socket" --shell /bin/sh
352 D3PID=$DPID
353
354 await_repaint "$OUT.m7b" "$PAINTS_BEFORE" "m7b client never resumed into the restarted daemon"
355 pipe_send 'printf "m7b-%%s\\n" post-restart\n'
356 await_out "$OUT.m7b" "m7b-post-restart" "m7b client did not resume into the restarted daemon"
357 pipe_detach "m7b client"
358 # The fresh session really is fresh: a new daemon means a new shell, so the
359 # pre-restart marker cannot be in its grid. If it were, we would be looking
360 # at a client that reconnected to something with the old session's history —
361 # i.e. the epoch fence letting a stale seq through.
362 if "$MUXD" dump --sock "$SOCK3" | grep -q "m7b-pre-restart"; then
363 echo "e2e FAIL: restarted daemon's grid still holds the pre-restart marker"
364 exit 1
365 fi
366 # Markers are blind to the kind of resume — a broken epoch fence handing back
367 # a garbage delta would render "m7b-post-restart" just as well. Only the
368 # counter sees it: the reconnecting client is this daemon's sole client, so a
369 # snapshot in its stats is proof it was resynced from scratch rather than
370 # resumed off a seq that belongs to a session that no longer exists.
371 SNAPS_NEW=$("$MUXD" stats --sock "$SOCK3" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
372 [ -n "$SNAPS_NEW" ] || { echo "e2e FAIL: could not read the restarted daemon's counters"; exit 1; }
373 [ "$SNAPS_NEW" -ge 1 ] || {
374 echo "e2e FAIL: restarted daemon served no snapshot ($SNAPS_NEW); the stale seq was honoured"
375 exit 1;
376 }
377 assert_converged "$OUT.m7b" "$SOCK3" "epoch resync"
378 rm_swept "$OUT.m7b" "$OUT.m7b.err"
379
380 # --- M8: the --quic flags. Every refusal must cost nothing — no session
381 # socket, no shell, no stack trace — and the accepted case must leave a
382 # daemon that is both listening on UDP and still an ordinary muxd.
383 #
384 # The client half of this (attaching over quic://) is Task 3; what is proven
385 # here is the daemon's side of the command line.
386
387 # A refusal that leaves a socket behind has already started a session, which
388 # is the failure this ordering exists to prevent.
389 # No convergence anywhere in refuse(): no daemon survives one, by construction.
390 # Takes the config home to run under as an explicit second argument, rather
391 # than relying on a `XDG_CONFIG_HOME=... refuse ...` prefix: POSIX says an
392 # assignment prefixing a FUNCTION call may outlive it, so on a dash /bin/sh
393 # that form could leak into every later scenario. Naming it per call site
394 # also makes each refusal say which key world it is refusing in, which is
395 # the thing that silently changes once a default key path exists.
396 refuse() {
397 _want="$1"; _cfg="$2"; shift 2
398 set +e
399 # Timed out rather than trusted to exit: every case here is a refusal, so
400 # a regression that ACCEPTS one would otherwise run a daemon forever and
401 # hang the suite instead of failing it. 124 is a distinguishable answer.
402 env XDG_CONFIG_HOME="$_cfg" timeout 10 "$MUXD" run --sock "$SOCK4" \
403 --shell /bin/sh "$@" > "$OUT.q" 2>&1
404 _rc=$?
405 set -e
406 [ "$_rc" -eq "$_want" ] || {
407 echo "e2e FAIL: muxd run $* exited $_rc (want $_want)"; cat "$OUT.q"; exit 1;
408 }
409 [ ! -e "$SOCK4" ] || {
410 echo "e2e FAIL: muxd run $* was refused but left $SOCK4 behind"; exit 1;
411 }
412 # One line of complaint plus the usage block, and not a stack trace: a
413 # Zig panic runs to dozens of lines and names a source file, which is
414 # what this is guarding against.
415 #
416 # Two bounds, because each misses what the other catches. The relative
417 # one is measured from the no-args usage path, which is NOT one of the
418 # paths it bounds: if usage printing ever ballooned, the bound would
419 # balloon with it and quietly stop catching a panic. The absolute one
420 # cannot drift, and 15 sits above any plausible complaint-plus-usage
421 # and below the ~20 lines of the smallest Debug panic.
422 _usage_lines=$("$MUXD" 2>&1 | wc -l)
423 _lines=$(wc -l < "$OUT.q")
424 { [ "$_lines" -le $((_usage_lines + 2)) ] && [ "$_lines" -le 15 ]; } || {
425 echo "e2e FAIL: muxd run $* answered with more than a message:"; cat "$OUT.q"; exit 1;
426 }
427 }
428
429 head -c 32 /dev/urandom > "$QKEY"
430 chmod 600 "$QKEY"
431 cp "$QKEY" "$QKEY.bad"
432 chmod 644 "$QKEY.bad"
433
434 # Both or neither, and a usage mistake exits 2 like every other one.
435 # The no-key refusal must not find the suite's own keygen'd key, so it runs
436 # against a config home that has never had one written to it.
437 NOKEY_CFG="${TMPDIR:-/tmp}/mux-e2e-nokey-$$"
438 defer_rm "$NOKEY_CFG"
439 refuse 2 "$NOKEY_CFG" --quic "127.0.0.1:$QPORT"
440 refuse 2 "$XDG_CONFIG_HOME" --key "$QKEY"
441 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0
442 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon
443 # Refusals that are about the world rather than the spelling exit 1.
444 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:" --key "$QKEY"
445 refuse 1 "$XDG_CONFIG_HOME" --quic "localhost:$QPORT" --key "$QKEY"
446 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.bad"
447 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.missing"
448
449 # The key-missing message must name the way out, not just the absence.
450 env XDG_CONFIG_HOME="$NOKEY_CFG" "$MUXD" run --sock "$SOCK4.nokey" \
451 --quic "127.0.0.1:$QPORT" 2> "$OUT.nokey" || true
452 grep -q "muxd keygen" "$OUT.nokey" || {
453 echo "e2e FAIL: key-missing message does not name keygen"; cat "$OUT.nokey"; exit 1; }
454 rm -rf "$NOKEY_CFG"
455 ok "no key anywhere is refused, and says how to make one"
456
457 # The accepted case.
458 start_daemon "$SOCK4" "$OUT.d4.d" "--quic daemon never bound its session socket" --shell /bin/sh \
459 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000
460 D4PID=$DPID
461
462 # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is
463 # always there on the platform this daemon runs on, and needs no privileges.
464 # 127.0.0.1 is 0100007F in the little-endian hex the file uses.
465 QHEX=$(printf '0100007F:%04X' "$QPORT")
466 grep -qi " $QHEX " /proc/net/udp || {
467 echo "e2e FAIL: no UDP socket bound at 127.0.0.1:$QPORT ($QHEX)"
468 grep -i "0100007F" /proc/net/udp || true
469 exit 1
470 }
471
472 # A second daemon must NOT be able to take a share of that port. UDP with
473 # SO_REUSEADDR would let it bind alongside the first and the kernel would
474 # hand each datagram to one of them — two sessions splitting one port, with
475 # no error anywhere. This is the QUIC edition of the stale-socket story, and
476 # it needs two processes to test, which is why it lives here.
477 set +e
478 # Same reasoning as refuse(): if the second daemon ever succeeds it runs
479 # until killed, so the failure has to be a timeout rather than a hang.
480 timeout 10 "$MUXD" run --sock "$SOCK4.second" --shell /bin/sh \
481 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000 > "$OUT.q" 2>&1
482 RC=$?
483 set -e
484 [ "$RC" -eq 1 ] || {
485 echo "e2e FAIL: a second daemon took udp $QPORT (exit $RC, want 1; 124 means it bound and ran)"
486 cat "$OUT.q"; exit 1;
487 }
488 grep -q "already listening" "$OUT.q" || {
489 echo "e2e FAIL: second daemon refused, but not with the already-listening message:"
490 cat "$OUT.q"; exit 1;
491 }
492 [ ! -e "$SOCK4.second" ] || {
493 echo "e2e FAIL: refused second daemon left $SOCK4.second behind"; exit 1;
494 }
495
496 # ...and the daemon is still an ordinary daemon: the session runs and the
497 # unix-socket path is unaffected by the listener sharing its poll loop.
498 pipe_mux "$OUT.q" "" "$MUX" --sock "$SOCK4"
499 pipe_send 'printf "quic-%%s\\n" flags-ok\n'
500 await_out "$OUT.q" "quic-flags-ok" "quic-flags-ok never reached the client"
501 pipe_detach
502 grep -q "quic-flags-ok" "$OUT.q" || {
503 echo "e2e FAIL: --quic daemon did not serve an ordinary socket client"; cat "$OUT.q"; exit 1;
504 }
505 "$MUXD" dump --sock "$SOCK4" | grep -q "quic-flags-ok" || {
506 echo "e2e FAIL: --quic daemon's grid missing output"; exit 1;
507 }
508 kill -0 "$D4PID" || { echo "e2e FAIL: --quic daemon died"; exit 1; }
509 assert_converged "$OUT.q" "$SOCK4" "quic daemon serves sockets"
510
511 # --- M8 Task 3: the client speaks quic://. Same daemon, new transport.
512
513 # 1. Attach, run something, detach. The whole point of the milestone in one
514 # scenario: the wire protocol did not change, so this must behave exactly
515 # as the socket client does.
516 pipe_mux "$OUT.qc" "$OUT.qc.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
517 pipe_send 'printf "quic-%%s\\n" attach-ok\n'
518 await_out "$OUT.qc" "quic-attach-ok" "quic-attach-ok never reached the client"
519 pipe_detach "quic:// client"
520 grep -q "quic-attach-ok" "$OUT.qc" || {
521 echo "e2e FAIL: quic:// client render missing output"
522 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
523 }
524 "$MUXD" dump --sock "$SOCK4" | grep -q "quic-attach-ok" || {
525 echo "e2e FAIL: daemon grid missing the quic:// client's output"; exit 1;
526 }
527 assert_converged "$OUT.qc" "$SOCK4" "quic attach"
528
529 # 1b. Reattach. A detach leaves the session running, so coming back must
530 # find it — and must be served as a fresh attach (its own snapshot),
531 # because a client that detached deliberately holds nothing to resume
532 # from. The counter is what tells that apart from a delta.
533 SNAPS_RA=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
534 pipe_mux "$OUT.qc" "$OUT.qc.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
535 pipe_send 'printf "quic-%%s\\n" reattached\n'
536 await_out "$OUT.qc" "quic-reattached" "quic-reattached never reached the client"
537 pipe_detach "quic:// reattach"
538 # The earlier marker is still on the grid this client was handed, which is
539 # the session having survived the detach rather than a new shell.
540 grep -q "quic-attach-ok" "$OUT.qc" || {
541 echo "e2e FAIL: quic:// reattach did not land in the existing session"
542 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
543 }
544 grep -q "quic-reattached" "$OUT.qc" || {
545 echo "e2e FAIL: quic:// reattach could not run a command"
546 cat "$OUT.qc" "$OUT.qc.err" 2>/dev/null; exit 1;
547 }
548 SNAPS_RA2=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
549 [ "$((SNAPS_RA2 - SNAPS_RA))" -eq 1 ] || {
550 echo "e2e FAIL: reattach served $((SNAPS_RA2 - SNAPS_RA)) snapshots (want exactly 1)"
551 exit 1;
552 }
553 # ...and the slot it held is free again now that it has gone.
554 CLIENTS=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*clients=\([0-9]*\).*/\1/p')
555 [ "$CLIENTS" = "0" ] || {
556 echo "e2e FAIL: $CLIENTS client slots still held after a clean detach (want 0)"; exit 1;
557 }
558 assert_converged "$OUT.qc" "$SOCK4" "quic reattach"
559
560 # 2. The key is checked, and a wrong one is refused loudly rather than
561 # retried forever. Nothing was ever established, so the reconnect loop
562 # must not engage — that is the never-established gate, over QUIC.
563 # No convergence: a refused client painted no grid.
564 head -c 32 /dev/urandom > "$QKEY.wrong"
565 chmod 600 "$QKEY.wrong"
566 set +e
567 : | timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
568 --quic-idle-ms 1500 > "$OUT.qc" 2>&1
569 RC=$?
570 set -e
571 [ "$RC" -eq 1 ] || {
572 echo "e2e FAIL: wrong-key quic client exited $RC (want 1; 124 means it retried)"
573 cat "$OUT.qc"; exit 1;
574 }
575 grep -q "did not answer" "$OUT.qc" || {
576 echo "e2e FAIL: wrong-key quic client said nothing useful:"; cat "$OUT.qc"; exit 1;
577 }
578
579 # 3. The abort key works DURING a handshake, not just after it. waitReady
580 # runs inside Transport.open, after drainStdinForQuit has returned, so
581 # watching only the socket left nothing looking for Ctrl-\ for as long as
582 # the handshake bound allows — and during a reconnect the terminal is in
583 # raw mode, where Ctrl-\ is the only way out.
584 #
585 # The target is the LIVE daemon dialled with the wrong key, and it has
586 # to be something silent like that: an unreachable port used to hold the
587 # handshake open for the whole bound, but since M15 a refused port is
588 # answered by an ICMP unreachable that kills the dial in ~2ms — far too
589 # fast to fit an abort inside, and this scenario would then be timing a
590 # failure rather than an abort. A listener that cannot authenticate us
591 # never answers (mutual PSK, same mechanism as case 2 above), so the
592 # dial runs its full budget and the window is real.
593 #
594 # No --quic-idle-ms: since M15 the dial is bounded by the client's
595 # 2000ms attach budget, which no flag on this command line moves, and
596 # spelling an idle timeout here would suggest otherwise.
597 # No convergence: the client aborts before it ever attaches, so it paints nothing.
598 # A fifo rather than a pipeline, so what is timed is the CLIENT's exit and
599 # not how long the writer happened to hang around afterwards.
600 QFIFO="${TMPDIR:-/tmp}/mux-e2e-abort-fifo-$$"
601 defer_rm "$QFIFO"
602 mkfifo "$QFIFO"
603 ( sleep 0.3; printf '\034'; sleep 20 ) > "$QFIFO" &
604 QWPID=$!
605 defer_kill "$QWPID"
606 QT0=$(date +%s%N)
607 set +e
608 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
609 < "$QFIFO" > "$OUT.qa" 2>&1
610 RC=$?
611 set -e
612 QT1=$(date +%s%N)
613 softkill "$QWPID" || true
614 rm -f "$QFIFO"
615 QMS=$(( (QT1 - QT0) / 1000000 ))
616 [ "$RC" -eq 0 ] || {
617 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)"
618 cat "$OUT.qa"; exit 1;
619 }
620 # The abort lands 300ms in and the budget ends at 2000ms, so this bound is
621 # what tells "answered the user" from "ran the budget out and reported a
622 # failure that happened to exit late" — the exit code catches the second
623 # too, but only this catches an abort that was merely slow. Measured at
624 # ~350ms; 1500 leaves room for a loaded machine and still clears 2000.
625 [ "$QMS" -lt 1500 ] || {
626 echo "e2e FAIL: abort during handshake took ${QMS}ms (want well inside the 2000ms budget)"
627 exit 1;
628 }
629
630 # 4. Reconnect over QUIC, with the resume kind asserted rather than assumed.
631 # The tear is a SIGSTOP held past the client's idle timeout: the daemon
632 # stops answering, the client declares the transport dead and reconnects.
633 # Deterministic, needs no privileges, and unlike kill -9 it leaves the
634 # session alive so the resume can be a DELTA rather than a fresh snapshot.
635 SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
636 [ -n "$SNAPS_BEFORE" ] || { echo "e2e FAIL: could not read snapshots before the tear"; exit 1; }
637
638 pipe_mux "$OUT.qr" "$OUT.qr.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" \
639 --quic-idle-ms 1500
640 pipe_send 'printf "quic-%%s\\n" pre-tear\n'
641 await_out "$OUT.qr" "quic-pre-tear" "quic reconnect client never got its pre-tear marker"
642
643 # Twice the 1500ms idle timeout: long enough that the client cannot mistake
644 # it for a slow moment, short enough not to pad the suite.
645 kill -STOP "$D4PID"
646 sleep 3
647 kill -CONT "$D4PID"
648
649 # The session is alive on the far side of the tear only if a NEW round trip
650 # completes: the pre-tear marker is already in the capture and proves
651 # nothing about the reconnect.
652 pipe_send 'printf "quic-%%s\\n" post-tear\n'
653 await_out "$OUT.qr" "quic-post-tear" "quic client lost its session across the tear"
654 pipe_detach "quic client"
655
656 # The counter is the only witness to HOW the resume was served: a snapshot
657 # renders identically to a delta, so markers cannot tell them apart. One
658 # more snapshot is the fresh attach at the start of this scenario; a second
659 # would mean the reconnect was resynced from scratch instead of resumed.
660 SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
661 [ -n "$SNAPS_AFTER" ] || { echo "e2e FAIL: could not read snapshots after the tear"; exit 1; }
662 [ "$((SNAPS_AFTER - SNAPS_BEFORE))" -eq 1 ] || {
663 echo "e2e FAIL: quic reconnect served $((SNAPS_AFTER - SNAPS_BEFORE)) snapshots (want 1:"
664 echo " the attach only; the resume itself must be delta-served)"
665 exit 1;
666 }
667 assert_converged "$OUT.qr" "$SOCK4" "quic delta resume"
668
669 # 5. The daemon is killed outright and started again on the SAME paths — unix
670 # socket and UDP port both. Two things are under test. The client must
671 # resume into a session that no longer exists, which can only be a
672 # snapshot under a new epoch (the stale-seq fence): a delta here would
673 # mean the daemon honoured a seq belonging to content that is gone. And
674 # the restarted daemon must be able to rebind the UDP port at all — there
675 # is no SO_REUSEADDR any more, so anything lingering from the killed
676 # process would show up as a bind failure rather than as silent sharing.
677 pipe_mux "$OUT.qk" "$OUT.qk.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" \
678 --quic-idle-ms 1500
679 pipe_send 'printf "quic-%%s\\n" pre-restart\n'
680 wait_for "$OUT.qk" "quic-pre-restart" 20 || {
681 echo "e2e FAIL: quic restart client never got its pre-restart marker"
682 cat "$OUT.qk" "$OUT.qk.err" 2>/dev/null; exit 1;
683 }
684
685 PAINTS_BEFORE=$(repaints "$OUT.qk")
686 hardkill "$D4PID"
687 D4PID=""
688 sleep 0.5
689 start_daemon "$SOCK4" "$OUT.q" "restarted --quic daemon never rebound its session socket" --shell /bin/sh \
690 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 15000
691 D4PID=$DPID
692 # Nearly vacuous, and kept only because a MISSING file would still be worth
693 # saying out loud: kill -9 leaves the old socket file behind, so this
694 # passes on the dead daemon's leavings. What proves the unix socket serves
695 # again is further down, where `muxd dump` and `muxd stats` answer on it;
696 # what proves the daemon serves SESSIONS again is the post-restart marker
697 # the resumed client gets back.
698 # The UDP port really came back, and to THIS daemon. Without SO_REUSEADDR a
699 # bind that collided would have failed loudly instead.
700 #
701 # Polled rather than sampled once, and matched on the LOCAL address only
702 # ($2), because both halves of the old one-shot `grep " $QHEX "` were
703 # accidents. The socket wait above cannot gate it: kill -9 leaves the
704 # socket FILE behind, so that loop exits before the new daemon has done
705 # anything. And a bare grep also matched the reconnecting CLIENT's socket,
706 # whose rem_address is this port — which is what made an unbound instant
707 # read look bound. That client no longer holds a socket at this moment:
708 # since M15 a refused dial dies in ~1 RTT and the socket goes with it, so
709 # the gap between reconnect attempts is now genuinely empty. Both fixed
710 # here rather than one, since either alone still passes on an accident.
711 #
712 # One expression, used by the wait and the verdict: two spellings of the
713 # same match are two matches waiting to drift apart.
714 udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; }
715 i=0
716 # 50 x 0.1s, the same 5s every daemon wait in this file allows.
717 while ! udp_local_bound "$QHEX" && [ "$i" -lt 50 ]; do
718 sleep 0.1; i=$((i+1))
719 done
720 udp_local_bound "$QHEX" || {
721 echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in 5s"
722 echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)"
723 echo " /proc/net/udp lines mentioning the port:"
724 grep -i " $QHEX " /proc/net/udp || echo " (none)"
725 cat "$OUT.q"; exit 1;
726 }
727 kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; }
728
729 await_repaint "$OUT.qk" "$PAINTS_BEFORE" "quic client never resumed into the restarted daemon"
730 pipe_send 'printf "quic-%%s\\n" post-restart\n'
731 await_out "$OUT.qk" "quic-post-restart" "quic client did not resume into the restarted daemon"
732 pipe_detach "quic client"
733 # A new daemon means a new shell, so the old marker cannot be in its grid.
734 # If it were, we would be looking at a client that resumed off a seq
735 # belonging to a session that no longer exists.
736 if "$MUXD" dump --sock "$SOCK4" | grep -q "quic-pre-restart"; then
737 echo "e2e FAIL: restarted daemon's grid still holds the pre-restart marker"
738 exit 1
739 fi
740 # And the counter, because markers are blind to how a resume was served: the
741 # reconnecting client is this daemon's only client, so a snapshot in its
742 # stats is proof it was resynced from scratch rather than handed a delta off
743 # a stale seq.
744 SNAPS_NQ=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
745 [ -n "$SNAPS_NQ" ] || { echo "e2e FAIL: could not read the restarted daemon's counters"; exit 1; }
746 [ "$SNAPS_NQ" -ge 1 ] || {
747 echo "e2e FAIL: restarted daemon served no snapshot ($SNAPS_NQ); the stale seq was honoured"
748 exit 1;
749 }
750 assert_converged "$OUT.qk" "$SOCK4" "quic epoch resync"
751
752 softkill "$D4PID" || true
753 D4PID=""
754
755 # --- M10: the daemon's own key resolution, over a real handshake.
756 #
757 # 1. MUX_KEY_FILE with no --key anywhere. The daemon's env support had no
758 # automated coverage at all: envKey() reads the real environment, so
759 # only a daemon started with it set can prove the path works.
760 env MUX_KEY_FILE="$QKEY" "$MUXD" run --sock "$SOCK9" --shell /bin/sh \
761 --quic "127.0.0.1:$QPORT2" --quic-idle-ms 3000 > "$OUT.d9.d" 2>&1 &
762 D9PID=$!
763 defer_kill "$D9PID"
764 wait_sock "$SOCK9" "$OUT.d9.d" "MUX_KEY_FILE daemon never bound its session socket"
765
766 pipe_mux "$OUT.env1" "$OUT.env1.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT2" --key "$QKEY" --quic-idle-ms 15000
767 pipe_send 'printf "envkey-%%s\\n" ok\n'
768 await_out "$OUT.env1" "envkey-ok" "envkey-ok never reached the client"
769 pipe_detach "quic attach to a MUX_KEY_FILE daemon"
770 grep -q "envkey-ok" "$OUT.env1" || {
771 echo "e2e FAIL: MUX_KEY_FILE daemon served no session"
772 cat "$OUT.env1" "$OUT.env1.err" 2>/dev/null; exit 1; }
773 assert_converged "$OUT.env1" "$SOCK9" "env key"
774 softkill "$D9PID" || true
775 D9PID=""
776
777 # 2. --key AND MUX_KEY_FILE, naming different keys. The env names one the
778 # client does not have, so an attach that succeeds proves the FLAG won.
779 # This is the shape that catches the two being swapped at the call site
780 # in run() — pickKey's unit test pins the ordering inside the function
781 # and is blind to the order they are handed to it.
782 env MUX_KEY_FILE="$QKEY.wrong" "$MUXD" run --sock "$SOCK10" --shell /bin/sh \
783 --quic "127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 3000 > "$OUT.d10.d" 2>&1 &
784 D10PID=$!
785 defer_kill "$D10PID"
786 wait_sock "$SOCK10" "$OUT.d10.d" "--key-beats-env daemon never bound its session socket"
787
788 pipe_mux "$OUT.env2" "$OUT.env2.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 15000
789 pipe_send 'printf "flagwins-%%s\\n" ok\n'
790 await_out "$OUT.env2" "flagwins-ok" "flagwins-ok never reached the client"
791 pipe_detach "the --key attach (which must beat MUX_KEY_FILE)"
792 grep -q "flagwins-ok" "$OUT.env2" || {
793 echo "e2e FAIL: --key-beats-env daemon served no session"
794 cat "$OUT.env2" "$OUT.env2.err" 2>/dev/null; exit 1; }
795 assert_converged "$OUT.env2" "$SOCK10" "flag beats env"
796 softkill "$D10PID" || true
797 D10PID=""
798 ok "daemon honours MUX_KEY_FILE, and --key beats it"
799
800 rm_swept "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong" \
801 "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err"
802
803 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines.
804 #
805 # Every scenario below asserts a MARKER through the session, never just $?.
806 # A client that fails to authenticate exits 0 by way of "aborted before
807 # attaching", so an exit code cannot distinguish a working session from a
808 # key or transport regression — only bytes coming back out of the shell can.
809 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start"
810 grep -q '^muxd: starting' "$OUT.start" || {
811 echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; }
812 grep -q '^up (' "$OUT.start" || {
813 echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; }
814 # Known gap, accepted: between the spawn above and this capture the daemon
815 # is running with no pid the trap can reach, so a failure in the two
816 # assertions in that window leaks it. Narrow, only on an already-failing
817 # run, and closing it would mean parsing the pid before asserting the lines
818 # that prove the pid is there.
819 SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
820 defer_kill "$SPID"
821 [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; }
822 kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; }
823 # Non-tty stderr: exactly two lines, no dots.
824 [ "$(wc -l < "$OUT.start")" = "2" ] || {
825 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
826
827 # The daemon it started serves a session (marker in, marker in dump).
828 pipe_mux "$OUT.s8" "$OUT.s8.err" "$MUX" --sock "$SOCK8"
829 pipe_send 'printf "start-%%s\\n" works\n'
830 await_out "$OUT.s8" "start-works" "start-works never reached the client"
831 pipe_detach
832 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
833 echo "e2e FAIL: auto-started daemon lost the marker"
834 cat "$OUT.s8" "$OUT.s8.err" 2>/dev/null; exit 1; }
835 assert_converged "$OUT.s8" "$SOCK8" "started daemon"
836
837 # The log the failure path names really is there, and is no more readable
838 # than the key is: it carries whatever the daemon says about its own start.
839 MUXLOG="$XDG_STATE_HOME/mux/muxd.log"
840 [ -f "$MUXLOG" ] || { echo "e2e FAIL: no daemon log at $MUXLOG"; exit 1; }
841 LPERMS=$(stat -c %a "$MUXLOG")
842 [ "$LPERMS" = "600" ] || { echo "e2e FAIL: daemon log perms $LPERMS, want 600"; exit 1; }
843
844 # Rerun: silent no-op beyond the already-running line, exit 0, same daemon.
845 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start2"
846 grep -q "already running on $SOCK8 (stop it first with \`muxd stop --sock $SOCK8\`" "$OUT.start2" || {
847 echo "e2e FAIL: rerun did not say already running"; cat "$OUT.start2"; exit 1; }
848 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
849 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; }
850
851 # Truncated per spawn, not appended to: the log holds the CURRENT daemon's
852 # output, so a stale one must not be what an operator reads after a
853 # restart. Padded, then a real spawn on a fresh path must shrink it.
854 dd if=/dev/zero bs=1024 count=8 2>/dev/null >> "$MUXLOG"
855 PADDED=$(stat -c %s "$MUXLOG")
856 "$MUXD" start --sock "$SOCK8T" 2> "$OUT.start"
857 TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
858 defer_kill "$TPID"
859 [ -n "$TPID" ] || { echo "e2e FAIL: truncation spawn reported no pid"; exit 1; }
860 SHRUNK=$(stat -c %s "$MUXLOG")
861 [ "$SHRUNK" -lt "$PADDED" ] || {
862 echo "e2e FAIL: log not truncated on spawn ($PADDED -> $SHRUNK)"; exit 1; }
863 softkill "$TPID" || true
864 TPID=""
865
866 # Race: two concurrent starts, both exit 0, still one session (the marker
867 # survives — a second daemon on the path would have started a fresh shell).
868 softkill "$SPID" && wait_gone "$SOCK8"
869 SPID=""
870 "$MUXD" start --sock "$SOCK8" 2> "$OUT.ra" & RA=$!; defer_kill "$RA"
871 "$MUXD" start --sock "$SOCK8" 2> "$OUT.rb" & RB=$!; defer_kill "$RB"
872 set +e
873 wait "$RA"; RCA=$?
874 wait "$RB"; RCB=$?
875 set -e
876 { [ "$RCA" = "0" ] && [ "$RCB" = "0" ]; } || {
877 echo "e2e FAIL: race: exits $RCA/$RCB"; cat "$OUT.ra" "$OUT.rb"; exit 1; }
878 pipe_mux "$OUT.race" /dev/null "$MUX" --sock "$SOCK8"
879 pipe_send 'printf "race-%%s\\n" one\n'
880 await_out "$OUT.race" "race-one" "race-one never reached the client"
881 pipe_detach
882 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
883 echo "e2e FAIL: race: session unusable"; exit 1; }
884 SPID=$(cat "$OUT.ra" "$OUT.rb" | sed -n 's/.* pid=\([0-9]*\).*/\1/p' | while read -r p; do
885 kill -0 "$p" 2>/dev/null && echo "$p"; done | head -1)
886 defer_kill "$SPID"
887
888 # What actually makes two concurrent starts safe is that the LOSER exits
889 # instead of unlinking the winner's socket and binding over it. The race
890 # above cannot see that — both starts report success either way, and the
891 # marker comes back from whichever daemon owns the path — so it is pinned
892 # head-on: a second `muxd run` against a live socket must refuse.
893 set +e
894 timeout 10 "$MUXD" run --sock "$SOCK8" --shell /bin/sh > "$OUT.rb" 2>&1
895 RCS=$?
896 set -e
897 [ "$RCS" -eq 1 ] || {
898 echo "e2e FAIL: second muxd run on a live socket exited $RCS (want 1; 124 means it took the path)"
899 cat "$OUT.rb"; exit 1; }
900 grep -q "already running" "$OUT.rb" || {
901 echo "e2e FAIL: second muxd run refused, but not as already-running"; cat "$OUT.rb"; exit 1; }
902 # ...and the session the winner was serving is undisturbed.
903 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
904 echo "e2e FAIL: the refused daemon disturbed the live session"; exit 1; }
905 # The race client's own capture, checked here rather than above: the refused
906 # `muxd run` between the two touches no session, so the grid has not moved.
907 assert_converged "$OUT.race" "$SOCK8" "start race"
908
909 softkill "$SPID" || true
910 SPID=""
911 ok "muxd start — spawn, no-op rerun, log truncation, race"
912
913 # --- M10: a start whose daemon dies young REPORTS it. This is the first-run
914 # mistake the failure line exists for — `ssh HOST 'muxd start --quic 0.0.0.0'`
915 # before the key was ever scp'd — so it must be a message, not a panic. The
916 # child exits on the missing key, the poll loop reaps it, and polling
917 # continues to the deadline; a second waitpid there gets ECHILD, which the
918 # stdlib answers with `unreachable`, i.e. exit 134 and a stack trace.
919 # No convergence: the daemon under test is the one that died, so nothing to dump.
920 DEADCFG="${TMPDIR:-/tmp}/mux-e2e-deadchild-$$"
921 defer_rm "$DEADCFG"
922 set +e
923 env XDG_CONFIG_HOME="$DEADCFG" timeout 30 "$MUXD" start --sock "$SOCK8T" \
924 --quic "127.0.0.1:1" --key /nonexistent > "$OUT.dead" 2>&1
925 DRC=$?
926 set -e
927 [ "$DRC" -eq 1 ] || {
928 echo "e2e FAIL: start with a doomed child exited $DRC (want 1; 134 is the waitpid panic)"
929 cat "$OUT.dead"; exit 1; }
930 grep -q "did not answer" "$OUT.dead" || {
931 echo "e2e FAIL: doomed start printed no deadline line"; cat "$OUT.dead"; exit 1; }
932 grep -q "log: .*muxd\.log" "$OUT.dead" || {
933 echo "e2e FAIL: deadline line does not name the log"; cat "$OUT.dead"; exit 1; }
934 # The failure says "daemon did not answer", never "muxd did not answer":
935 # the prefix is already the program's name, and the same line serves the
936 # mux-side caller when auto-start lands.
937 grep -q "daemon did not answer" "$OUT.dead" || {
938 echo "e2e FAIL: deadline line does not read 'daemon did not answer'"
939 cat "$OUT.dead"; exit 1; }
940 # Non-tty: exactly two lines, same as the success path. The newline that
941 # terminates the dot line is tty-only, so nothing blank creeps in here.
942 [ "$(wc -l < "$OUT.dead")" = "2" ] || {
943 echo "e2e FAIL: non-tty failure not exactly two lines:"; cat "$OUT.dead"; exit 1; }
944 rm -rf "$DEADCFG"
945 ok "a start whose daemon dies young says so, with the log path"
946
947 # --- M10: the goal commands, minus ssh: keygen'd default key on both ends,
948 # explicit loopback port (4433 on the suite machine is somebody's daemon).
949 # No --key on either side — the key is the one `muxd keygen` wrote at the
950 # hermetic default path near the top of this suite.
951 "$MUXD" start --sock "$SOCK11" --quic "127.0.0.1:$QPORT4" 2> "$OUT.goal"
952 GPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.goal")
953 defer_kill "$GPID"
954 [ -n "$GPID" ] || { echo "e2e FAIL: goal start reported no pid"; cat "$OUT.goal"; exit 1; }
955 pipe_mux "$OUT.g9" "$OUT.g9.err" timeout 30 "$MUX" "quic://127.0.0.1:$QPORT4"
956 pipe_send 'printf "goal-%%s\\n" quic\n'
957 await_out "$OUT.g9" "goal-quic" "goal-quic never reached the client"
958 pipe_detach
959 "$MUXD" dump --sock "$SOCK11" | grep -q "goal-quic" || {
960 echo "e2e FAIL: no-key-flag QUIC attach did not reach the session"
961 cat "$OUT.g9" "$OUT.g9.err" 2>/dev/null; exit 1; }
962 assert_converged "$OUT.g9" "$SOCK11" "goal commands"
963 softkill "$GPID" || true
964 GPID=""
965 ok "keygen + start --quic + mux quic:// with no --key anywhere"