cbd7007f
fix(agent.sh): type-visible JSON asserts; setup defects fail loudly; composed-narrative pin
a73x 2026-08-13 19:55
Commit message
test/agent.sh
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,17 @@ MUXA="${2:-$ROOT/zig-out/bin/muxa}" | |||
| 19 | [ -x "$MUXD" ] || { echo "agent FAIL: no muxd at $MUXD (run: zig build)"; exit 1; } | 19 | [ -x "$MUXD" ] || { echo "agent FAIL: no muxd at $MUXD (run: zig build)"; exit 1; } |
| 20 | [ -x "$MUXA" ] || { echo "agent FAIL: no muxa at $MUXA (run: zig build)"; exit 1; } | 20 | [ -x "$MUXA" ] || { echo "agent FAIL: no muxa at $MUXA (run: zig build)"; exit 1; } |
| 21 | 21 | ||
| 22 | # The two tools every scenario needs, checked here rather than per-scenario | ||
| 23 | # because a box without them cannot run ANY of this: python3 parses every | ||
| 24 | # assertion (see jget) and builds the relay, and `timeout` is what makes a hung | ||
| 25 | # muxa a failure instead of a wedged suite. Missing either is a refusal to run, | ||
| 26 | # not a skip — nine SKIP lines and exit 0 would be this suite reporting success | ||
| 27 | # for work it did not do. | ||
| 28 | for _tool in python3 timeout; do | ||
| 29 | command -v "$_tool" >/dev/null 2>&1 || | ||
| 30 | { echo "agent FAIL: no $_tool — this suite cannot assert or bound anything without it"; exit 1; } | ||
| 31 | done | ||
| 32 | |||
| 22 | # One directory for everything this run writes: sockets, keys, captures, the | 33 | # One directory for everything this run writes: sockets, keys, captures, the |
| 23 | # relay, the TUI's HOME. Removed by the trap, so a failing run leaves nothing | 34 | # relay, the TUI's HOME. Removed by the trap, so a failing run leaves nothing |
| 24 | # behind but its output. | 35 | # behind but its output. |
| @@ -81,8 +92,11 @@ SINK_PID="" | |||
| 81 | CLI_PID="" | 92 | CLI_PID="" |
| 82 | 93 | ||
| 83 | cleanup() { | 94 | cleanup() { |
| 84 | # `|| true` on every kill: without `set -e` here it is belt and braces, but | 95 | # Every pid this run started, including the ones already dead: a kill that |
| 85 | # this trap also runs on INT, and a half-run trap leaves a live daemon. | 96 | # finds nothing is not a problem here, which is why the status of each one |
| 97 | # is discarded rather than tested. The `return 0` at the bottom is the load- | ||
| 98 | # bearing part — without it the trap would exit with the status of whatever | ||
| 99 | # ran last, and a cleanup that fired on a PASSING run could fail the suite. | ||
| 86 | for p in "$D_MARKS" "$D_TUI" "$D_SETTLE" "$D_TEAR" "$D_QUIET" \ | 100 | for p in "$D_MARKS" "$D_TUI" "$D_SETTLE" "$D_TEAR" "$D_QUIET" \ |
| 87 | "$RELAY_PID" "$SINK_PID" "$CLI_PID"; do | 101 | "$RELAY_PID" "$SINK_PID" "$CLI_PID"; do |
| 88 | [ -n "$p" ] && kill "$p" 2>/dev/null | 102 | [ -n "$p" ] && kill "$p" 2>/dev/null |
| @@ -138,10 +152,17 @@ run_scenario() { | |||
| 138 | esac | 152 | esac |
| 139 | } | 153 | } |
| 140 | 154 | ||
| 141 | # One field out of a JSON object, printed flat: `null` for JSON null, `true` | 155 | # One field out of a JSON object, re-encoded as JSON: `null`, `true`, `0`, and |
| 142 | # /`false` for booleans (so an assertion reads as the wire spelling), and a | 156 | # `"marks"` WITH its quotes. The quotes are the point. muxa's contract is a |
| 143 | # loud sentinel for a missing key or a body that is not JSON at all. A muxa | 157 | # typed one — exit_code is a number, alt_screen a boolean, mechanism a string — |
| 144 | # that printed a stack trace fails here as `<unparseable>`, not as a mismatch. | 158 | # and a `str(v)` here would print all three the same way, so a daemon that |
| 159 | # started spelling exit_code as "0" or alt_screen as "true" would sail past | ||
| 160 | # every assertion below. Re-encoding makes the type part of the comparison, and | ||
| 161 | # the cost is that string expectations at the call sites carry their quotes too. | ||
| 162 | # | ||
| 163 | # The three sentinels cannot collide with any of that: a field whose value were | ||
| 164 | # literally the text `<missing>` re-encodes to `"<missing>"`, quotes and all. A | ||
| 165 | # muxa that printed a stack trace fails as `<unparseable>`, not as a mismatch. | ||
| 145 | jget() { | 166 | jget() { |
| 146 | python3 - "$1" "$2" <<'PY' | 167 | python3 - "$1" "$2" <<'PY' |
| 147 | import json, sys | 168 | import json, sys |
| @@ -153,13 +174,13 @@ if not isinstance(obj, dict): | |||
| 153 | print("<not-an-object>"); raise SystemExit(0) | 174 | print("<not-an-object>"); raise SystemExit(0) |
| 154 | if sys.argv[2] not in obj: | 175 | if sys.argv[2] not in obj: |
| 155 | print("<missing>"); raise SystemExit(0) | 176 | print("<missing>"); raise SystemExit(0) |
| 156 | v = obj[sys.argv[2]] | 177 | print(json.dumps(obj[sys.argv[2]])) |
| 157 | print("null" if v is None else ("true" if v is True else "false" if v is False else str(v))) | ||
| 158 | PY | 178 | PY |
| 159 | } | 179 | } |
| 160 | 180 | ||
| 161 | # want FILE FIELD VALUE — assert one field, naming the whole body on a miss so | 181 | # want FILE FIELD VALUE — assert one field, naming the whole body on a miss so |
| 162 | # a wrong answer is read in context rather than alone. | 182 | # a wrong answer is read in context rather than alone. VALUE is JSON: bare for |
| 183 | # null/true/false/numbers, quoted for strings. | ||
| 163 | want() { | 184 | want() { |
| 164 | _got=$(jget "$1" "$2") | 185 | _got=$(jget "$1" "$2") |
| 165 | [ "$_got" = "$3" ] && return 0 | 186 | [ "$_got" = "$3" ] && return 0 |
| @@ -218,13 +239,13 @@ scen_marks() { | |||
| 218 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'true' >"$TMP/m1" 2>&1 | 239 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'true' >"$TMP/m1" 2>&1 |
| 219 | _rc=$? | 240 | _rc=$? |
| 220 | [ "$_rc" -eq 0 ] || why "run 'true' exited $_rc [$(tr -d '\n' < "$TMP/m1")]" || return 1 | 241 | [ "$_rc" -eq 0 ] || why "run 'true' exited $_rc [$(tr -d '\n' < "$TMP/m1")]" || return 1 |
| 221 | want "$TMP/m1" reason returned || return 1 | 242 | want "$TMP/m1" reason '"returned"' || return 1 |
| 222 | want "$TMP/m1" mechanism marks || return 1 | 243 | want "$TMP/m1" mechanism '"marks"' || return 1 |
| 223 | want "$TMP/m1" exit_code 0 || return 1 | 244 | want "$TMP/m1" exit_code 0 || return 1 |
| 224 | 245 | ||
| 225 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'false' >"$TMP/m2" 2>&1 | 246 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'false' >"$TMP/m2" 2>&1 |
| 226 | want "$TMP/m2" reason returned || return 1 | 247 | want "$TMP/m2" reason '"returned"' || return 1 |
| 227 | want "$TMP/m2" mechanism marks || return 1 | 248 | want "$TMP/m2" mechanism '"marks"' || return 1 |
| 228 | # The command failed; muxa did not. A nonzero exit_code is an ANSWER, and | 249 | # The command failed; muxa did not. A nonzero exit_code is an ANSWER, and |
| 229 | # an agent that branches on muxa's own status must not see it as an error. | 250 | # an agent that branches on muxa's own status must not see it as an error. |
| 230 | want "$TMP/m2" exit_code 1 || return 1 | 251 | want "$TMP/m2" exit_code 1 || return 1 |
| @@ -233,13 +254,13 @@ scen_marks() { | |||
| 233 | # and one that is unique per run so a stale grid can never satisfy it. | 254 | # and one that is unique per run so a stale grid can never satisfy it. |
| 234 | _mark="out-$$" | 255 | _mark="out-$$" |
| 235 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 "echo $_mark" >"$TMP/m3" 2>&1 | 256 | timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 "echo $_mark" >"$TMP/m3" 2>&1 |
| 236 | want "$TMP/m3" reason returned || return 1 | 257 | want "$TMP/m3" reason '"returned"' || return 1 |
| 237 | want "$TMP/m3" mechanism marks || return 1 | 258 | want "$TMP/m3" mechanism '"marks"' || return 1 |
| 238 | want "$TMP/m3" exit_code 0 || return 1 | 259 | want "$TMP/m3" exit_code 0 || return 1 |
| 239 | # Exactly the output, not "contains": the span between the two marks is | 260 | # Exactly the output, not "contains": the span between the two marks is |
| 240 | # the command's transcript, and a prompt or an echoed command line leaking | 261 | # the command's transcript, and a prompt or an echoed command line leaking |
| 241 | # into it is the bug this equality is here to catch. | 262 | # into it is the bug this equality is here to catch. |
| 242 | want "$TMP/m3" output "$_mark" || return 1 | 263 | want "$TMP/m3" output "\"$_mark\"" || return 1 |
| 243 | 264 | ||
| 244 | "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1 | 265 | "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1 |
| 245 | D_MARKS="" | 266 | D_MARKS="" |
| @@ -253,34 +274,61 @@ run_scenario "marks: exit codes and output come back from a bash session" scen_m | |||
| 253 | # execs whatever path it is given, so a one-line wrapper carries the argument. | 274 | # execs whatever path it is given, so a one-line wrapper carries the argument. |
| 254 | # HOME points into the tmpdir for e2e.sh's $SHELL reason: ~/.lesskey and | 275 | # HOME points into the tmpdir for e2e.sh's $SHELL reason: ~/.lesskey and |
| 255 | # ~/.vimrc are arbitrary code on the session under test. | 276 | # ~/.vimrc are arbitrary code on the session under test. |
| 256 | TUI_KIND=none | 277 | # |
| 257 | TUI_CMD="" | 278 | # The outcome splits three ways, and the split is the point: no TUI on the box |
| 279 | # is the environment's business and skips, but a TUI that is here and whose | ||
| 280 | # daemon did not come up is a DEFECT and must fail. Blanking D_TUI for both | ||
| 281 | # would report a broken muxd as a skip — and would also lose the pid, orphaning | ||
| 282 | # a daemon that is merely unresponsive rather than dead. | ||
| 283 | TUI_BIN="" | ||
| 258 | TUI_QUIT="" | 284 | TUI_QUIT="" |
| 285 | TUI_SKIP="" | ||
| 286 | TUI_FAIL="" | ||
| 287 | TUI_OK="" | ||
| 259 | if command -v vi >/dev/null 2>&1; then | 288 | if command -v vi >/dev/null 2>&1; then |
| 260 | TUI_KIND=vi | 289 | TUI_BIN=$(command -v vi) |
| 261 | TUI_CMD="$(command -v vi) /etc/hostname" | ||
| 262 | TUI_QUIT=':q!\n' | 290 | TUI_QUIT=':q!\n' |
| 263 | elif command -v less >/dev/null 2>&1; then | 291 | elif command -v less >/dev/null 2>&1; then |
| 264 | TUI_KIND=less | 292 | TUI_BIN=$(command -v less) |
| 265 | TUI_CMD="$(command -v less) /etc/hostname" | ||
| 266 | TUI_QUIT='q' | 293 | TUI_QUIT='q' |
| 267 | fi | 294 | fi |
| 268 | if [ "$TUI_KIND" != none ]; then | 295 | if [ -z "$TUI_BIN" ]; then |
| 296 | TUI_SKIP="neither vi nor less on this box" | ||
| 297 | else | ||
| 269 | mkdir -p "$TMP/home" | 298 | mkdir -p "$TMP/home" |
| 299 | # Single-quoted in the generated script, both of them: $TMP contains $$ and | ||
| 300 | # is usually tame, but a TMPDIR with a space in it would otherwise split | ||
| 301 | # HOME in half and hand `exec` an argument it never meant to have. | ||
| 270 | { | 302 | { |
| 271 | echo '#!/bin/sh' | 303 | echo '#!/bin/sh' |
| 272 | echo "HOME=$TMP/home; export HOME" | 304 | echo "HOME='$TMP/home'; export HOME" |
| 273 | # less reads its own switches out of the environment; a developer with | 305 | # less reads its own switches out of the environment; a developer with |
| 274 | # -F exported would make the session exit before it was ever driven. | 306 | # -F exported would make the session exit before it was ever driven. |
| 275 | echo 'LESS=; export LESS' | 307 | echo 'LESS=; export LESS' |
| 276 | echo 'unset LESSOPEN LESSCLOSE' | 308 | echo 'unset LESSOPEN LESSCLOSE' |
| 277 | echo "exec $TUI_CMD" | 309 | echo "exec '$TUI_BIN' /etc/hostname" |
| 278 | } > "$TUISH" | 310 | } > "$TUISH" |
| 279 | chmod +x "$TUISH" | 311 | chmod +x "$TUISH" |
| 312 | # D_TUI keeps the pid whatever happens next, so cleanup can always reach a | ||
| 313 | # daemon that came up but never answered. | ||
| 280 | D_TUI=$(start_daemon "$TMP/tui.log" --sock "$SOCK_TUI" --shell "$TUISH") | 314 | D_TUI=$(start_daemon "$TMP/tui.log" --sock "$SOCK_TUI" --shell "$TUISH") |
| 281 | wait_ready "$SOCK_TUI" || D_TUI="" | 315 | if [ -z "$D_TUI" ]; then |
| 316 | TUI_FAIL="the TUI daemon printed no up-line [$(tr -d '\n' < "$TMP/tui.log")]" | ||
| 317 | elif wait_ready "$SOCK_TUI"; then | ||
| 318 | TUI_OK=1 | ||
| 319 | else | ||
| 320 | TUI_FAIL="the TUI daemon (pid $D_TUI) never answered on $SOCK_TUI" | ||
| 321 | fi | ||
| 282 | fi | 322 | fi |
| 283 | 323 | ||
| 324 | # The gate both TUI scenarios open with: 1 for a defect, 2 for a box that has | ||
| 325 | # no TUI to drive. `return $?` propagates whichever it was. | ||
| 326 | tui_gate() { | ||
| 327 | [ -z "$TUI_FAIL" ] || { WHY="$TUI_FAIL"; return 1; } | ||
| 328 | [ -n "$TUI_OK" ] || { WHY="$TUI_SKIP"; return 2; } | ||
| 329 | return 0 | ||
| 330 | } | ||
| 331 | |||
| 284 | # --- 2: the alt-screen guard ------------------------------------------------ | 332 | # --- 2: the alt-screen guard ------------------------------------------------ |
| 285 | # Ordered before the drive below because that one ENDS this session. Nothing | 333 | # Ordered before the drive below because that one ENDS this session. Nothing |
| 286 | # on a full-screen program's grid can mean "the command returned" — there are | 334 | # on a full-screen program's grid can mean "the command returned" — there are |
| @@ -289,10 +337,10 @@ fi | |||
| 289 | # worst failure in the surface: an agent would read an exit code that no | 337 | # worst failure in the surface: an agent would read an exit code that no |
| 290 | # command ever produced. | 338 | # command ever produced. |
| 291 | scen_altguard() { | 339 | scen_altguard() { |
| 292 | [ -n "$D_TUI" ] || { WHY="no TUI to drive (vi/less both absent or the daemon died)"; return 2; } | 340 | tui_gate || return $? |
| 293 | timeout 20 "$MUXA" run --sock "$SOCK_TUI" --timeout 1500 'true' >"$TMP/g1" 2>&1 | 341 | timeout 20 "$MUXA" run --sock "$SOCK_TUI" --timeout 1500 'true' >"$TMP/g1" 2>&1 |
| 294 | _rc=$? | 342 | _rc=$? |
| 295 | want "$TMP/g1" reason timeout || return 1 | 343 | want "$TMP/g1" reason '"timeout"' || return 1 |
| 296 | # Exit 3 is the whole point of having a code for it: `returned` and | 344 | # Exit 3 is the whole point of having a code for it: `returned` and |
| 297 | # `settled` are answers and exit 0, a timeout is a question still open. | 345 | # `settled` are answers and exit 0, a timeout is a question still open. |
| 298 | [ "$_rc" -eq 3 ] || why "exit $_rc, want 3 [$(tr -d '\n' < "$TMP/g1")]" || return 1 | 346 | [ "$_rc" -eq 3 ] || why "exit $_rc, want 3 [$(tr -d '\n' < "$TMP/g1")]" || return 1 |
| @@ -302,13 +350,13 @@ run_scenario "alt-screen: run times out rather than fabricating a return" scen_a | |||
| 302 | 350 | ||
| 303 | # --- 3: the ephemeral TUI, driven and quit --------------------------------- | 351 | # --- 3: the ephemeral TUI, driven and quit --------------------------------- |
| 304 | scen_tui() { | 352 | scen_tui() { |
| 305 | [ -n "$D_TUI" ] || { WHY="no TUI to drive (vi/less both absent or the daemon died)"; return 2; } | 353 | tui_gate || return $? |
| 306 | timeout 20 "$MUXA" status --sock "$SOCK_TUI" --timeout 5000 >"$TMP/t1" 2>&1 | 354 | timeout 20 "$MUXA" status --sock "$SOCK_TUI" --timeout 5000 >"$TMP/t1" 2>&1 |
| 307 | want "$TMP/t1" alt_screen true || return 1 | 355 | want "$TMP/t1" alt_screen true || return 1 |
| 308 | # Marks are a shell's doing. A program that is not a shell cannot have | 356 | # Marks are a shell's doing. A program that is not a shell cannot have |
| 309 | # them, and claiming otherwise is what scenario 2 would then read. | 357 | # them, and claiming otherwise is what scenario 2 would then read. |
| 310 | _mech=$(jget "$TMP/t1" mechanism) | 358 | _mech=$(jget "$TMP/t1" mechanism) |
| 311 | [ "$_mech" != marks ] || why "mechanism=marks on a TUI that no shell started" || return 1 | 359 | [ "$_mech" != '"marks"' ] || why "mechanism=marks on a TUI that no shell started" || return 1 |
| 312 | 360 | ||
| 313 | timeout 10 "$MUXA" send --sock "$SOCK_TUI" -- "$TUI_QUIT" >"$TMP/t2" 2>&1 | 361 | timeout 10 "$MUXA" send --sock "$SOCK_TUI" -- "$TUI_QUIT" >"$TMP/t2" 2>&1 |
| 314 | want "$TMP/t2" sent true || return 1 | 362 | want "$TMP/t2" sent true || return 1 |
| @@ -360,8 +408,8 @@ scen_settle() { | |||
| 360 | [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/s1")]" || return 1 | 408 | [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/s1")]" || return 1 |
| 361 | _reason=$(jget "$TMP/s1" reason) | 409 | _reason=$(jget "$TMP/s1" reason) |
| 362 | case "$_reason" in | 410 | case "$_reason" in |
| 363 | settled|returned) ;; | 411 | '"settled"'|'"returned"') ;; |
| 364 | *) why "reason=$_reason, want settled or returned [$(tr -d '\n' < "$TMP/s1")]" || return 1 ;; | 412 | *) why "reason=$_reason, want \"settled\" or \"returned\" [$(tr -d '\n' < "$TMP/s1")]" || return 1 ;; |
| 365 | esac | 413 | esac |
| 366 | # No marks means no exit code, and muxa says so with a null rather than a | 414 | # No marks means no exit code, and muxa says so with a null rather than a |
| 367 | # zero — an agent must never read "it worked" out of a mechanism that | 415 | # zero — an agent must never read "it worked" out of a mechanism that |
| @@ -430,18 +478,23 @@ while True: | |||
| 430 | front.sendto(data, addr) | 478 | front.sendto(data, addr) |
| 431 | PY | 479 | PY |
| 432 | 480 | ||
| 433 | QUIC_WHY="" | 481 | # Every way this setup can go wrong is a DEFECT, and every one of them fails |
| 434 | if ! command -v python3 >/dev/null 2>&1; then | 482 | # rather than skips. There is no environmental escape hatch left at this point: |
| 435 | QUIC_WHY="no python3 to run the UDP relay, and the tear cannot be made without one" | 483 | # python3 and `timeout` were made hard prerequisites at the top of the file, so |
| 436 | elif ! command -v timeout >/dev/null 2>&1; then | 484 | # what remains — keygen refusing, the key not landing where keygen said it did, |
| 437 | QUIC_WHY="no coreutils timeout to bound the backgrounded clients" | 485 | # the QUIC daemon not coming up, the relay not binding — is either muxd |
| 438 | elif ! "$MUXD" keygen >"$TMP/keygen.log" 2>&1; then | 486 | # misbehaving or this box handing out a port twice. A skip here would be the |
| 439 | QUIC_WHY="muxd keygen failed [$(tr -d '\n' < "$TMP/keygen.log")]" | 487 | # one door in the suite wide enough for a real regression to walk through |
| 440 | else | 488 | # wearing green: `muxd --quic` breaking outright would have reported "4 passed, |
| 441 | cp "$XDG_CONFIG_HOME/mux/key" "$KEY" 2>/dev/null || QUIC_WHY="keygen wrote no key where it said it did" | 489 | # 5 skipped" and exited 0, which is CI saying yes to a broken binary. |
| 490 | QUIC_FAIL="" | ||
| 491 | if ! "$MUXD" keygen >"$TMP/keygen.log" 2>&1; then | ||
| 492 | QUIC_FAIL="muxd keygen failed [$(tr -d '\n' < "$TMP/keygen.log")]" | ||
| 493 | elif ! cp "$XDG_CONFIG_HOME/mux/key" "$KEY" 2>/dev/null; then | ||
| 494 | QUIC_FAIL="keygen wrote no key where it said it did [$(tr -d '\n' < "$TMP/keygen.log")]" | ||
| 442 | fi | 495 | fi |
| 443 | 496 | ||
| 444 | if [ -z "$QUIC_WHY" ]; then | 497 | if [ -z "$QUIC_FAIL" ]; then |
| 445 | # The reduced idle is the schedule the tear scenarios wait for. It is a | 498 | # The reduced idle is the schedule the tear scenarios wait for. It is a |
| 446 | # transport parameter, so the NEGOTIATED value is the min of the two ends | 499 | # transport parameter, so the NEGOTIATED value is the min of the two ends |
| 447 | # and this daemon's 4s governs both — muxa has no idle flag of its own, | 500 | # and this daemon's 4s governs both — muxa has no idle flag of its own, |
| @@ -449,14 +502,25 @@ if [ -z "$QUIC_WHY" ]; then | |||
| 449 | # runtime. The quiet-await scenario below deliberately does not use it. | 502 | # runtime. The quiet-await scenario below deliberately does not use it. |
| 450 | D_TEAR=$(start_daemon "$TMP/tear.log" --sock "$SOCK_TEAR" --shell /bin/bash \ | 503 | D_TEAR=$(start_daemon "$TMP/tear.log" --sock "$SOCK_TEAR" --shell /bin/bash \ |
| 451 | --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --quic-idle-ms 4000) | 504 | --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --quic-idle-ms 4000) |
| 452 | [ -n "$D_TEAR" ] && wait_ready "$SOCK_TEAR" || QUIC_WHY="the QUIC daemon never came up [$(cat "$TMP/tear.log")]" | 505 | if [ -z "$D_TEAR" ]; then |
| 506 | QUIC_FAIL="the QUIC daemon printed no up-line [$(tr -d '\n' < "$TMP/tear.log")]" | ||
| 507 | elif ! wait_ready "$SOCK_TEAR"; then | ||
| 508 | QUIC_FAIL="the QUIC daemon (pid $D_TEAR) never answered on $SOCK_TEAR" | ||
| 509 | fi | ||
| 453 | fi | 510 | fi |
| 454 | if [ -z "$QUIC_WHY" ]; then | 511 | if [ -z "$QUIC_FAIL" ]; then |
| 455 | python3 "$RELAY" "$PORT_RELAY" "$PORT_TEAR" "$CTL_FLOW" "$CTL_ALL" >"$RELAY_LOG" 2>&1 & | 512 | python3 "$RELAY" "$PORT_RELAY" "$PORT_TEAR" "$CTL_FLOW" "$CTL_ALL" >"$RELAY_LOG" 2>&1 & |
| 456 | RELAY_PID=$! | 513 | RELAY_PID=$! |
| 457 | wait_for "$RELAY_LOG" "relay up" 5 || QUIC_WHY="the relay never bound $PORT_RELAY [$(cat "$RELAY_LOG")]" | 514 | wait_for "$RELAY_LOG" "relay up" 5 || QUIC_FAIL="the relay never bound $PORT_RELAY [$(tr -d '\n' < "$RELAY_LOG")]" |
| 458 | fi | 515 | fi |
| 459 | 516 | ||
| 517 | # The gate every QUIC scenario opens with. One outcome only — there is nothing | ||
| 518 | # left here that a box could legitimately be excused from. | ||
| 519 | quic_gate() { | ||
| 520 | [ -z "$QUIC_FAIL" ] || { WHY="$QUIC_FAIL"; return 1; } | ||
| 521 | return 0 | ||
| 522 | } | ||
| 523 | |||
| 460 | # --- 5: a tear mid-await heals, and the command still ran exactly once ------ | 524 | # --- 5: a tear mid-await heals, and the command still ran exactly once ------ |
| 461 | # The two claims an agent's whole reconnect story rests on. The reply that | 525 | # The two claims an agent's whole reconnect story rests on. The reply that |
| 462 | # arrives after the heal carries the ORIGINAL command's return — muxa re-issued | 526 | # arrives after the heal carries the ORIGINAL command's return — muxa re-issued |
| @@ -466,7 +530,7 @@ fi | |||
| 466 | # reconnect would have run it twice, and on anything but `sleep` that is a | 530 | # reconnect would have run it twice, and on anything but `sleep` that is a |
| 467 | # second deploy, not a second read. | 531 | # second deploy, not a second read. |
| 468 | scen_tear_heal() { | 532 | scen_tear_heal() { |
| 469 | [ -z "$QUIC_WHY" ] || { WHY="$QUIC_WHY"; return 2; } | 533 | quic_gate || return $? |
| 470 | _tally="$TMP/tally" | 534 | _tally="$TMP/tally" |
| 471 | rm -f "$_tally" | 535 | rm -f "$_tally" |
| 472 | # Counted in the FILESYSTEM, not in the grid: a grid count would also see | 536 | # Counted in the FILESYSTEM, not in the grid: a grid count would also see |
| @@ -485,8 +549,8 @@ scen_tear_heal() { | |||
| 485 | _rc=$? | 549 | _rc=$? |
| 486 | CLI_PID="" | 550 | CLI_PID="" |
| 487 | [ "$_rc" -eq 0 ] || why "run exited $_rc after the heal [$(tr -d '\n' < "$TMP/q1")]" || return 1 | 551 | [ "$_rc" -eq 0 ] || why "run exited $_rc after the heal [$(tr -d '\n' < "$TMP/q1")]" || return 1 |
| 488 | want "$TMP/q1" reason returned || return 1 | 552 | want "$TMP/q1" reason '"returned"' || return 1 |
| 489 | want "$TMP/q1" mechanism marks || return 1 | 553 | want "$TMP/q1" mechanism '"marks"' || return 1 |
| 490 | want "$TMP/q1" exit_code 0 || return 1 | 554 | want "$TMP/q1" exit_code 0 || return 1 |
| 491 | 555 | ||
| 492 | # A second flow through the relay is the reconnect, observed from outside | 556 | # A second flow through the relay is the reconnect, observed from outside |
| @@ -508,7 +572,7 @@ run_scenario "quic: a tear mid-await heals, and the command ran exactly once" sc | |||
| 508 | # `Timeout` goes and checks its own command; an agent told `connection lost; | 572 | # `Timeout` goes and checks its own command; an agent told `connection lost; |
| 509 | # reconnect failed: Timeout` knows to check the network. | 573 | # reconnect failed: Timeout` knows to check the network. |
| 510 | scen_tear_fatal() { | 574 | scen_tear_fatal() { |
| 511 | [ -z "$QUIC_WHY" ] || { WHY="$QUIC_WHY"; return 2; } | 575 | quic_gate || return $? |
| 512 | # A hang is the failure mode here, so the deadline is asserted twice: the | 576 | # A hang is the failure mode here, so the deadline is asserted twice: the |
| 513 | # outer `timeout` makes one impossible to sit through, and the wall clock | 577 | # outer `timeout` makes one impossible to sit through, and the wall clock |
| 514 | # below makes one impossible to pass with. | 578 | # below makes one impossible to pass with. |
| @@ -535,10 +599,16 @@ scen_tear_fatal() { | |||
| 535 | # --timeout plus muxa's 2s grace over the daemon's own window, plus room | 599 | # --timeout plus muxa's 2s grace over the daemon's own window, plus room |
| 536 | # for the box. Anything near 30s means the deadline was not honoured. | 600 | # for the box. Anything near 30s means the deadline was not honoured. |
| 537 | [ "$_spent" -lt 20000 ] || why "took ${_spent}ms for a 12000ms timeout" || return 1 | 601 | [ "$_spent" -lt 20000 ] || why "took ${_spent}ms for a 12000ms timeout" || return 1 |
| 602 | # The COMPOSED narrative, not merely a prefix of it. muxa has three endings | ||
| 603 | # for a lost connection and only one of them is honest here: this client | ||
| 604 | # redialled and the redial could not complete. A bare `connection lost` | ||
| 605 | # means nothing tried to redial, and `connection lost again, after the one | ||
| 606 | # reconnect` means the redial was already spent — both would be regressions | ||
| 607 | # in this setup, and a `"connection lost"*` glob would pass for either. | ||
| 538 | _detail=$(jget "$TMP/q2" detail) | 608 | _detail=$(jget "$TMP/q2" detail) |
| 539 | case "$_detail" in | 609 | case "$_detail" in |
| 540 | "connection lost"*) ;; | 610 | '"connection lost; reconnect failed: '*) ;; |
| 541 | *) why "detail=$_detail, want 'connection lost...' [$(tr -d '\n' < "$TMP/q2")]" || return 1 ;; | 611 | *) why "detail=$_detail, want '\"connection lost; reconnect failed: ...' [$(tr -d '\n' < "$TMP/q2")]" || return 1 ;; |
| 542 | esac | 612 | esac |
| 543 | return 0 | 613 | return 0 |
| 544 | } | 614 | } |
| @@ -551,7 +621,7 @@ run_scenario "quic: a tear with no path back fails with the whole story" scen_te | |||
| 551 | # connection — an agent would go looking for a network fault that never | 621 | # connection — an agent would go looking for a network fault that never |
| 552 | # happened, and the honest answer (still running) would have been one field. | 622 | # happened, and the honest answer (still running) would have been one field. |
| 553 | scen_keepalive() { | 623 | scen_keepalive() { |
| 554 | [ -z "$QUIC_WHY" ] || { WHY="$QUIC_WHY"; return 2; } | 624 | quic_gate || return $? |
| 555 | D_QUIET=$(start_daemon "$TMP/quiet.log" --sock "$SOCK_QUIET" --shell /bin/bash \ | 625 | D_QUIET=$(start_daemon "$TMP/quiet.log" --sock "$SOCK_QUIET" --shell /bin/bash \ |
| 556 | --quic "127.0.0.1:$PORT_QUIET" --key "$KEY") | 626 | --quic "127.0.0.1:$PORT_QUIET" --key "$KEY") |
| 557 | [ -n "$D_QUIET" ] || why "daemon never printed an up-line [$(cat "$TMP/quiet.log")]" || return 1 | 627 | [ -n "$D_QUIET" ] || why "daemon never printed an up-line [$(cat "$TMP/quiet.log")]" || return 1 |
| @@ -560,7 +630,7 @@ scen_keepalive() { | |||
| 560 | timeout 40 "$MUXA" await --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 20000 >"$TMP/q3" 2>&1 | 630 | timeout 40 "$MUXA" await --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 20000 >"$TMP/q3" 2>&1 |
| 561 | _rc=$? | 631 | _rc=$? |
| 562 | [ "$_rc" -eq 3 ] || why "await exited $_rc, want 3 [$(tr -d '\n' < "$TMP/q3")]" || return 1 | 632 | [ "$_rc" -eq 3 ] || why "await exited $_rc, want 3 [$(tr -d '\n' < "$TMP/q3")]" || return 1 |
| 563 | want "$TMP/q3" reason timeout || return 1 | 633 | want "$TMP/q3" reason '"timeout"' || return 1 |
| 564 | # The number is the assertion: 15000 would be the idle timeout wearing a | 634 | # The number is the assertion: 15000 would be the idle timeout wearing a |
| 565 | # timeout's clothes, and only a duration past it proves the keepalives ran. | 635 | # timeout's clothes, and only a duration past it proves the keepalives ran. |
| 566 | _dur=$(jget "$TMP/q3" duration_ms) | 636 | _dur=$(jget "$TMP/q3" duration_ms) |
| @@ -578,14 +648,27 @@ run_scenario "quic: a quiet 20s await outlives the 15s idle timeout" scen_keepal | |||
| 578 | # shell exited 5; the connection then closed BECAUSE it did. Reporting the | 648 | # shell exited 5; the connection then closed BECAUSE it did. Reporting the |
| 579 | # close is reporting the consequence and losing the cause, and the exit code | 649 | # close is reporting the consequence and losing the cause, and the exit code |
| 580 | # is the one thing the agent came for. | 650 | # is the one thing the agent came for. |
| 651 | # | ||
| 652 | # Coupling worth naming: this dials the tear daemon DIRECTLY, but it is the | ||
| 653 | # third scenario to drive that one bash session — 5 and 6 reached it through | ||
| 654 | # the relay. Scenario 6 leaves a `sleep 20` running there whether it passes or | ||
| 655 | # fails: killing the client does not kill what the session was already typed. | ||
| 656 | # What covers it is scenario 7, which spends 20s of its own in between, so the | ||
| 657 | # sleep is long finished before `exit 5` is ever sent. If 7 itself fails fast | ||
| 658 | # that margin narrows, hence the 12s bound rather than a snug one — the queued | ||
| 659 | # `exit 5` still lands, just late. Never a hang either way: the outer `timeout` | ||
| 660 | # is the backstop. | ||
| 661 | # | ||
| 662 | # Not given its own daemon because the session's death IS the assertion — this | ||
| 663 | # scenario destroys what it runs on, so it goes last among the three regardless. | ||
| 581 | scen_session_exit() { | 664 | scen_session_exit() { |
| 582 | [ -z "$QUIC_WHY" ] || { WHY="$QUIC_WHY"; return 2; } | 665 | quic_gate || return $? |
| 583 | timeout 20 "$MUXA" run --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --timeout 8000 \ | 666 | timeout 30 "$MUXA" run --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --timeout 12000 \ |
| 584 | 'exit 5' >"$TMP/q4" 2>&1 | 667 | 'exit 5' >"$TMP/q4" 2>&1 |
| 585 | _rc=$? | 668 | _rc=$? |
| 586 | # An ANSWER, not a failure: the command is over and this is how. | 669 | # An ANSWER, not a failure: the command is over and this is how. |
| 587 | [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/q4")]" || return 1 | 670 | [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/q4")]" || return 1 |
| 588 | want "$TMP/q4" reason session_ended || return 1 | 671 | want "$TMP/q4" reason '"session_ended"' || return 1 |
| 589 | want "$TMP/q4" exit_code 5 || return 1 | 672 | want "$TMP/q4" exit_code 5 || return 1 |
| 590 | return 0 | 673 | return 0 |
| 591 | } | 674 | } |
| @@ -600,7 +683,7 @@ D_TEAR="" | |||
| 600 | # and never replies makes the HANDSHAKE hang, and the only thing that can end | 683 | # and never replies makes the HANDSHAKE hang, and the only thing that can end |
| 601 | # it is muxa's own clock. | 684 | # it is muxa's own clock. |
| 602 | scen_blackhole() { | 685 | scen_blackhole() { |
| 603 | [ -z "$QUIC_WHY" ] || { WHY="$QUIC_WHY"; return 2; } | 686 | quic_gate || return $? |
| 604 | # The relay, blackholing from birth: its control file exists before it | 687 | # The relay, blackholing from birth: its control file exists before it |
| 605 | # starts, so it swallows the first packet it ever sees. | 688 | # starts, so it swallows the first packet it ever sees. |
| 606 | : > "$TMP/sink.all" | 689 | : > "$TMP/sink.all" |