9bd46b17
test: 01_boot moves to a file of its own
a73x 2026-08-26 18:48
Commit message
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" | ||