a824b1a2
test: 14_upgrade moves to a file of its own
a73x 2026-08-26 19:18
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -102,7 +102,7 @@ done | |||
| 102 | E2E_DIR=$(dirname "$0") | 102 | E2E_DIR=$(dirname "$0") |
| 103 | . "$E2E_DIR/e2e_lib.sh" | 103 | . "$E2E_DIR/e2e_lib.sh" |
| 104 | 104 | ||
| 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes 13_birth' | 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes 13_birth 14_upgrade' |
| 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses | 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses |
| 107 | # these by name rather than running them: a group whose fixtures are | 107 | # these by name rather than running them: a group whose fixtures are |
| 108 | # missing does not fail, it passes having tested something else — and a | 108 | # missing does not fail, it passes having tested something else — and a |
| @@ -145,600 +145,6 @@ for _g in $E2E_GROUPS; do | |||
| 145 | . "$E2E_DIR/e2e_$_g.sh" | 145 | . "$E2E_DIR/e2e_$_g.sh" |
| 146 | done | 146 | done |
| 147 | 147 | ||
| 148 | # `muxd upgrade` — four daemons, because each one is a different daemon | ||
| 149 | # LIFECYCLE and no two can share a process: the same-binary leg's daemon | ||
| 150 | # ends up running a second image, the rollback leg's is born with the abort | ||
| 151 | # armed in its environment, the agent leg's holds an `-A` client, and the | ||
| 152 | # QUIC leg's owns a UDP socket. They run one after another, so the last of | ||
| 153 | # them is the only leg here that needs a port. | ||
| 154 | SOCK69="${TMPDIR:-/tmp}/muxd-e2e-upgrade-$$.sock" | ||
| 155 | defer_sock "$SOCK69" | ||
| 156 | SOCK70="${TMPDIR:-/tmp}/muxd-e2e-uproll-$$.sock" | ||
| 157 | defer_sock "$SOCK70" | ||
| 158 | SOCK71="${TMPDIR:-/tmp}/muxd-e2e-upagent-$$.sock" | ||
| 159 | defer_sock "$SOCK71" | ||
| 160 | SOCK72="${TMPDIR:-/tmp}/muxd-e2e-upquic-$$.sock" | ||
| 161 | defer_sock "$SOCK72" | ||
| 162 | SOCK73="${TMPDIR:-/tmp}/muxd-e2e-stopgone-$$.sock" | ||
| 163 | defer_sock "$SOCK73" | ||
| 164 | # The next 4000-wide band DOWN from the hydrate leg's 6000: every base from | ||
| 165 | # 11000 up is taken, and the 61000+ tail above the ephemeral range is spoken | ||
| 166 | # for by the two browser ports. A collision here reads as this leg's daemon | ||
| 167 | # failing to bind, the same verdict every other band gives. | ||
| 168 | UPQPORT=$(( 2000 + ($$ % 4000) )) | ||
| 169 | UPKEY="${TMPDIR:-/tmp}/mux-e2e-upkey-$$" | ||
| 170 | defer_rm "$UPKEY" | ||
| 171 | # A HOME of its own for the session shell. The title assertion below is | ||
| 172 | # about a title the SESSION set, and a distribution's rc file repaints the | ||
| 173 | # window title from PROMPT_COMMAND on every prompt (shellint.bash_init says | ||
| 174 | # so of Arch's) — read out of the developer's own dotfiles, this leg would | ||
| 175 | # assert on whatever their prompt happens to spell. | ||
| 176 | UPHOME="${TMPDIR:-/tmp}/mux-e2e-uphome-$$" | ||
| 177 | defer_rm "$UPHOME" | ||
| 178 | # The candidate binary the refusal leg offers: a copy, so its exec bit can | ||
| 179 | # be taken away without touching the one every other scenario runs. | ||
| 180 | UPBIN="${TMPDIR:-/tmp}/mux-e2e-upcand-$$" | ||
| 181 | defer_rm "$UPBIN" | ||
| 182 | UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock" | ||
| 183 | defer_rm "$UPAGENT" | ||
| 184 | UPAGKEY="${TMPDIR:-/tmp}/mux-e2e-upagkey-$$" | ||
| 185 | defer_rm "$UPAGKEY" "$UPAGKEY.pub" | ||
| 186 | |||
| 187 | # --- muxd upgrade: the daemon becomes the new binary, holding everything --- | ||
| 188 | # | ||
| 189 | # `muxd upgrade` execs the candidate OVER the running daemon: same pid, same | ||
| 190 | # children, same descriptors. Nothing on the wire can carry the proof — a | ||
| 191 | # client reconnecting to a freshly started daemon sees exactly the same | ||
| 192 | # snapshot — so the witness is the session shell's own pid, put on the grid | ||
| 193 | # before the exec and read off it after. A restart cannot fake that: a new | ||
| 194 | # daemon forks a new shell, and a new shell has a new pid. | ||
| 195 | # | ||
| 196 | # One daemon, one attached client, and the whole story in order: what the | ||
| 197 | # session had before, two refusals that must change nothing, the exec, and | ||
| 198 | # then every one of those things again on the far side. | ||
| 199 | # | ||
| 200 | # bash with marks on, because two of the claims are the mechanism's: | ||
| 201 | # `muxa run` answers under `marks` before the exec and must answer under | ||
| 202 | # `marks` after it. The FIRST one after is the assertion that matters — | ||
| 203 | # the manifest carries a return watermark, and one carried into the new | ||
| 204 | # seq space is a watermark from the future that no return can pass, which | ||
| 205 | # measured as the first await after every upgrade timing out at 30s while | ||
| 206 | # the shell had already answered. | ||
| 207 | UPVER=$("$MUXD" --version | awk '{print $2}') | ||
| 208 | case "$UPVER" in | ||
| 209 | ?*.?*) ;; | ||
| 210 | *) echo "e2e FAIL: upgrade: muxd --version gave no version to refuse with: [$UPVER]" | ||
| 211 | exit 1;; | ||
| 212 | esac | ||
| 213 | mkdir -p "$UPHOME" | ||
| 214 | MUX_SHELL_INTEGRATION=1 HOME="$UPHOME" "$MUXD" run --sock "$SOCK69" \ | ||
| 215 | --shell /bin/bash > "$OUT.up.d" 2>&1 & | ||
| 216 | D69PID=$! | ||
| 217 | defer_kill "$D69PID" | ||
| 218 | wait_sock "$SOCK69" "$OUT.up.d" "upgrade daemon never bound" | ||
| 219 | |||
| 220 | # Marks BEFORE the exec, so "marks after" is a comparison rather than a | ||
| 221 | # hope: a daemon that never had them would fail this line first. | ||
| 222 | timeout 20 "$MUXA" run --sock "$SOCK69" --timeout 8000 'echo up-marks-pre' > "$OUT.upm1" 2>&1 || { | ||
| 223 | echo "e2e FAIL: upgrade: muxa run failed before the upgrade even started:" | ||
| 224 | cat "$OUT.upm1"; exit 1; } | ||
| 225 | grep -qF '"mechanism":"marks"' "$OUT.upm1" || { | ||
| 226 | echo "e2e FAIL: upgrade: the session had no marks to lose:" | ||
| 227 | cat "$OUT.upm1"; exit 1; } | ||
| 228 | |||
| 229 | # The attached client, on a real pty, held across the exec. Its script | ||
| 230 | # stops on `expect up-resumed`, which nothing in the script produces — the | ||
| 231 | # suite's own post-upgrade `muxa run` does, below. That is the rendezvous: | ||
| 232 | # the fixture reads the master the whole time it waits, so the capture is | ||
| 233 | # continuous across the tear, and the needle can only arrive through the | ||
| 234 | # connection the client re-dialled. | ||
| 235 | # | ||
| 236 | # `unset PROMPT_COMMAND; PROMPT_COMMAND=_mux_precmd` keeps mux's own precmd | ||
| 237 | # (shellint's `precmd_fn`, which is where the marks come from) and drops | ||
| 238 | # every other member. A distribution rc appends a title-setting member | ||
| 239 | # there, and it repaints the title on every prompt — the title this leg | ||
| 240 | # sets would be gone by the next one. `unset` first because a scalar | ||
| 241 | # assignment onto bash's array spelling lands on element 0 and leaves the | ||
| 242 | # rest. | ||
| 243 | timeout 150 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 244 | --out "$OUT.upcap" --err "$OUT.upcap.err" -- \ | ||
| 245 | "$MUX" --sock "$SOCK69" > "$OUT.uppc" 2>&1 <<'EOF' & | ||
| 246 | settle 1000 25000 | ||
| 247 | send unset PROMPT_COMMAND; PROMPT_COMMAND=_mux_precmd\n | ||
| 248 | settle 800 25000 | ||
| 249 | send printf '\\033]0;uptitle\\007'\n | ||
| 250 | expect \x1b]0;uptitle\x07 25000 | ||
| 251 | send printf 'up-%s\n' pre\n | ||
| 252 | expect up-pre 25000 | ||
| 253 | send echo shpid=$$\n | ||
| 254 | expect shpid= 25000 | ||
| 255 | settle 800 25000 | ||
| 256 | expect \x1b]0;uptitle\x07 90000 | ||
| 257 | expect up-resumed 60000 | ||
| 258 | settle 800 25000 | ||
| 259 | send printf 'up-%s\n' typed\n | ||
| 260 | expect up-typed 25000 | ||
| 261 | send \x1cd | ||
| 262 | waitexit 15000 | ||
| 263 | EOF | ||
| 264 | UPPCPID=$! | ||
| 265 | defer_kill "$UPPCPID" | ||
| 266 | |||
| 267 | # Daemon truth for the pid, and the number this leg is built around. The | ||
| 268 | # echoed command line spells `$$`, so only the shell's own answer has | ||
| 269 | # digits after the `=` — grepping for a bare `shpid=` would match the echo. | ||
| 270 | wait_grid "$SOCK69" "shpid=[0-9]" "upgrade: the session shell never printed its pid" | ||
| 271 | UPSHPID=$(dump_session "$SOCK69" | sed -n 's/.*shpid=\([0-9][0-9]*\).*/\1/p' | head -1) | ||
| 272 | [ -n "$UPSHPID" ] || { | ||
| 273 | echo "e2e FAIL: upgrade: no shell pid read off the grid — the witness would be vacuous" | ||
| 274 | dump_session "$SOCK69"; exit 1; } | ||
| 275 | |||
| 276 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst1" 2>&1 || { | ||
| 277 | echo "e2e FAIL: upgrade: stats did not answer before the upgrade"; exit 1; } | ||
| 278 | UPATT1=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst1") | ||
| 279 | UPSNAP1=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst1") | ||
| 280 | [ -n "$UPATT1" ] && [ -n "$UPSNAP1" ] || { | ||
| 281 | echo "e2e FAIL: upgrade: no counters read out of stats; the carry pin would be vacuous:" | ||
| 282 | cat "$OUT.upst1"; exit 1; } | ||
| 283 | |||
| 284 | # 1. Refused: same version, no flag. The reason names BOTH versions, which | ||
| 285 | # is the difference between a policy an operator can act on and a "no". | ||
| 286 | set +e | ||
| 287 | "$MUXD" upgrade --sock "$SOCK69" > "$OUT.upref1" 2>&1 | ||
| 288 | UPRC=$? | ||
| 289 | set -e | ||
| 290 | [ "$UPRC" -eq 1 ] || { | ||
| 291 | echo "e2e FAIL: upgrade: a same-version upgrade exited $UPRC, want 1:" | ||
| 292 | cat "$OUT.upref1"; exit 1; } | ||
| 293 | grep -qF "refused: version: $UPVER is not newer than $UPVER" "$OUT.upref1" || { | ||
| 294 | echo "e2e FAIL: upgrade: the refusal did not name both versions:" | ||
| 295 | cat "$OUT.upref1"; exit 1; } | ||
| 296 | |||
| 297 | # 2. Refused: a candidate that is not executable. `muxd upgrade` offers its | ||
| 298 | # OWN path (/proc/self/exe), so the only way to spell this refusal is to | ||
| 299 | # take the exec bit off a copy AFTER it has started — and the ordering is | ||
| 300 | # made deterministic rather than hoped for. The daemon is held under | ||
| 301 | # SIGSTOP so it cannot read the request until the chmod has landed; | ||
| 302 | # `readlink /proc/PID/exe` is the witness that the copy has already | ||
| 303 | # exec'd, so the chmod cannot beat it and turn the leg into an exec | ||
| 304 | # failure that asserts nothing. | ||
| 305 | cp "$MUXD_ELF" "$UPBIN" | ||
| 306 | chmod 755 "$UPBIN" | ||
| 307 | kill -STOP "$D69PID" | ||
| 308 | "$UPBIN" upgrade --sock "$SOCK69" --allow-same-version > "$OUT.upref2" 2>&1 & | ||
| 309 | UPXPID=$! | ||
| 310 | defer_kill "$UPXPID" | ||
| 311 | _i=0 | ||
| 312 | while [ "$(readlink "/proc/$UPXPID/exe" 2>/dev/null)" != "$UPBIN" ]; do | ||
| 313 | _i=$((_i + 1)) | ||
| 314 | [ "$_i" -lt $(( 100 * TIME_SCALE )) ] || { | ||
| 315 | kill -CONT "$D69PID" 2>/dev/null || true | ||
| 316 | echo "e2e FAIL: upgrade: the candidate copy never exec'd; the refusal below" | ||
| 317 | echo " would have been about a binary that never asked" | ||
| 318 | exit 1; } | ||
| 319 | sleep 0.05 | ||
| 320 | done | ||
| 321 | chmod 000 "$UPBIN" | ||
| 322 | kill -CONT "$D69PID" | ||
| 323 | set +e | ||
| 324 | wait "$UPXPID" | ||
| 325 | UPRC=$? | ||
| 326 | set -e | ||
| 327 | chmod 755 "$UPBIN" | ||
| 328 | [ "$UPRC" -eq 1 ] || { | ||
| 329 | echo "e2e FAIL: upgrade: an unexecutable candidate exited $UPRC, want 1:" | ||
| 330 | cat "$OUT.upref2"; exit 1; } | ||
| 331 | grep -qF "refused: path: not executable" "$OUT.upref2" || { | ||
| 332 | echo "e2e FAIL: upgrade: the refusal did not name the path check:" | ||
| 333 | cat "$OUT.upref2"; exit 1; } | ||
| 334 | |||
| 335 | # ...and two refusals changed NOTHING. A refusal that had torn a client | ||
| 336 | # down, or written the manifest and left the fd table half open, would | ||
| 337 | # still have printed the words above. | ||
| 338 | timeout 20 "$MUXA" status --sock "$SOCK69" > "$OUT.upsta" 2>&1 || { | ||
| 339 | echo "e2e FAIL: upgrade: the daemon stopped answering after refusing:" | ||
| 340 | cat "$OUT.upsta"; exit 1; } | ||
| 341 | wait_grid "$SOCK69" "shpid=$UPSHPID" "upgrade: a refusal cost the session its shell" | ||
| 342 | |||
| 343 | # 3. The exec itself. | ||
| 344 | set +e | ||
| 345 | "$MUXD" upgrade --sock "$SOCK69" --allow-same-version > "$OUT.upok" 2>&1 | ||
| 346 | UPRC=$? | ||
| 347 | set -e | ||
| 348 | [ "$UPRC" -eq 0 ] || { | ||
| 349 | echo "e2e FAIL: upgrade: the upgrade exited $UPRC, want 0:" | ||
| 350 | cat "$OUT.upok" "$OUT.up.d"; exit 1; } | ||
| 351 | grep -qF "muxd: upgraded to $UPVER" "$OUT.upok" || { | ||
| 352 | echo "e2e FAIL: upgrade: no verdict naming the version it landed on:" | ||
| 353 | cat "$OUT.upok"; exit 1; } | ||
| 354 | |||
| 355 | # The pid is the feature. Not "a daemon is answering" — the same process, | ||
| 356 | # now running a different image, which /proc/PID/cmdline says in one word: | ||
| 357 | # a daemon that had restarted would carry the original `run --sock` argv. | ||
| 358 | kill -0 "$D69PID" 2>/dev/null || { | ||
| 359 | echo "e2e FAIL: upgrade: the daemon pid $D69PID is gone — that is a restart, not an upgrade" | ||
| 360 | cat "$OUT.up.d"; exit 1; } | ||
| 361 | tr '\0' ' ' < "/proc/$(real_pid "$D69PID")/cmdline" > "$OUT.upst2" 2>/dev/null || true | ||
| 362 | grep -qF -- "--resume-fd" "$OUT.upst2" || { | ||
| 363 | echo "e2e FAIL: upgrade: pid $D69PID is not running the resumed argv; it holds:" | ||
| 364 | cat "$OUT.upst2"; exit 1; } | ||
| 365 | |||
| 366 | # Counters CARRY, and it is asserted HERE rather than at the end for a | ||
| 367 | # reason about blame: the re-attach poll below counts `attaches` up from | ||
| 368 | # the reading taken before the exec, so a daemon that had zeroed its | ||
| 369 | # counters fails THAT poll — reported as a client that never came back, | ||
| 370 | # which is the wrong diagnosis of the wrong bug. `-ge`, because the | ||
| 371 | # re-attach this reading races has not necessarily landed yet. | ||
| 372 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst2" 2>&1 || { | ||
| 373 | echo "e2e FAIL: upgrade: stats did not answer after the exec"; cat "$OUT.up.d"; exit 1; } | ||
| 374 | UPATT2=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 375 | UPSNAP2=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 376 | [ -n "$UPATT2" ] && [ -n "$UPSNAP2" ] || { | ||
| 377 | echo "e2e FAIL: upgrade: no counters read out of stats after the exec:" | ||
| 378 | cat "$OUT.upst2"; exit 1; } | ||
| 379 | [ "$UPATT2" -ge "$UPATT1" ] || { | ||
| 380 | echo "e2e FAIL: upgrade: attaches went $UPATT1 -> $UPATT2 across the exec; the" | ||
| 381 | echo " counters were reset, not carried, and an upgrade now looks like" | ||
| 382 | echo " a restart to anything sampling this daemon" | ||
| 383 | exit 1; } | ||
| 384 | [ "$UPSNAP2" -ge "$UPSNAP1" ] || { | ||
| 385 | echo "e2e FAIL: upgrade: snapshots went $UPSNAP1 -> $UPSNAP2 across the exec; the" | ||
| 386 | echo " counters were reset, not carried" | ||
| 387 | exit 1; } | ||
| 388 | |||
| 389 | # The client came back on its own. Counted with `attaches=`, not with | ||
| 390 | # `clients=`: the client is re-dialled within a millisecond of the exec, so | ||
| 391 | # a poll that only looked for clients=1 would be satisfied by the reading it | ||
| 392 | # took before the tear and would pass on a daemon that never dropped anyone. | ||
| 393 | _i=0 | ||
| 394 | while : ; do | ||
| 395 | _upst=$("$MUXD" stats --sock "$SOCK69" 2>/dev/null || true) | ||
| 396 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 397 | case "$_upst" in | ||
| 398 | *"session 0 clients=1"*) | ||
| 399 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 400 | esac | ||
| 401 | _i=$((_i + 1)) | ||
| 402 | [ "$_i" -lt $(( 200 * TIME_SCALE )) ] || { | ||
| 403 | echo "e2e FAIL: upgrade: the attached client never re-attached after the exec;" | ||
| 404 | echo " stats holds: $_upst" | ||
| 405 | cat "$OUT.up.d"; exit 1; } | ||
| 406 | sleep 0.1 | ||
| 407 | done | ||
| 408 | |||
| 409 | # The grid crossed with the process: the marker the client typed, and the | ||
| 410 | # pid of the shell that is still running under the new image. | ||
| 411 | wait_grid "$SOCK69" "up-pre" "upgrade: the pre-upgrade marker did not survive the exec" | ||
| 412 | wait_grid "$SOCK69" "shpid=$UPSHPID" "upgrade: the session is a NEW shell, not the one that crossed" | ||
| 413 | |||
| 414 | # The FIRST muxa run after the exec, and it is also what releases the | ||
| 415 | # attached client's `expect up-resumed` above — one command, two claims. | ||
| 416 | set +e | ||
| 417 | timeout 40 "$MUXA" run --sock "$SOCK69" --timeout 20000 'echo up-resumed' > "$OUT.upm2" 2>&1 | ||
| 418 | UPRC=$? | ||
| 419 | set -e | ||
| 420 | [ "$UPRC" -eq 0 ] || { | ||
| 421 | echo "e2e FAIL: upgrade: the FIRST muxa run after the exec exited $UPRC — a carried" | ||
| 422 | echo " return watermark is a watermark from the future and no return passes it:" | ||
| 423 | cat "$OUT.upm2"; exit 1; } | ||
| 424 | grep -qF '"mechanism":"marks"' "$OUT.upm2" || { | ||
| 425 | echo "e2e FAIL: upgrade: the first await after the exec fell off marks:" | ||
| 426 | cat "$OUT.upm2"; exit 1; } | ||
| 427 | grep -qF '"exit_code":0' "$OUT.upm2" || { | ||
| 428 | echo "e2e FAIL: upgrade: no real exit code came back after the exec:" | ||
| 429 | cat "$OUT.upm2"; exit 1; } | ||
| 430 | # The client's own verdict: the title came back on the re-attach, the | ||
| 431 | # marker arrived through the new image, and a keystroke typed AFTER the | ||
| 432 | # exec reached the shell. | ||
| 433 | set +e | ||
| 434 | wait "$UPPCPID" | ||
| 435 | UPRC=$? | ||
| 436 | set -e | ||
| 437 | UPPCPID="" | ||
| 438 | [ "$UPRC" -eq 0 ] || { | ||
| 439 | echo "e2e FAIL: upgrade: the attached client exited $UPRC across the exec:" | ||
| 440 | cat "$OUT.uppc" "$OUT.upcap.err"; exit 1; } | ||
| 441 | |||
| 442 | # A NONZERO exit code too, because `"exit_code":0` is also what a mechanism | ||
| 443 | # that guesses would say. After the client has gone, not beside it: two | ||
| 444 | # writers typing into one shell's pty interleave, and the leg would be | ||
| 445 | # racing itself. Spelled as a CHILD, not as `exit 7`, which is the session | ||
| 446 | # shell's own exit — the session would end, the daemon with it, and the | ||
| 447 | # counter reading below would be taken from nothing. Not `(exit 7)` either: | ||
| 448 | # a subshell sets `_mux_ran` in the subshell, so shellint's `precmd_fn` | ||
| 449 | # sees an untouched prompt in the parent and emits no `D;` mark at all | ||
| 450 | # (measured: the await ran its whole budget out). | ||
| 451 | timeout 40 "$MUXA" run --sock "$SOCK69" --timeout 20000 'sh -c "exit 7"' > "$OUT.upm3" 2>&1 || true | ||
| 452 | grep -qF '"exit_code":7' "$OUT.upm3" || { | ||
| 453 | echo "e2e FAIL: upgrade: the shell's own exit code did not survive the exec:" | ||
| 454 | cat "$OUT.upm3"; exit 1; } | ||
| 455 | |||
| 456 | # ...and the re-attach was COUNTED onto them rather than replacing them. | ||
| 457 | # The carry was asserted above with `-ge`; this is the other half, taken | ||
| 458 | # once the client's own snapshot has certainly been served. | ||
| 459 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst2" 2>&1 | ||
| 460 | UPATT2=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 461 | UPSNAP2=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 462 | [ -n "$UPATT2" ] && [ -n "$UPSNAP2" ] || { | ||
| 463 | echo "e2e FAIL: upgrade: no counters read out of stats after the client left:" | ||
| 464 | cat "$OUT.upst2"; exit 1; } | ||
| 465 | [ "$UPATT2" -gt "$UPATT1" ] || { | ||
| 466 | echo "e2e FAIL: upgrade: attaches is still $UPATT2 — the re-attach after the exec" | ||
| 467 | echo " was never counted" | ||
| 468 | exit 1; } | ||
| 469 | [ "$UPSNAP2" -gt "$UPSNAP1" ] || { | ||
| 470 | echo "e2e FAIL: upgrade: snapshots is still $UPSNAP2 — the re-attach was served" | ||
| 471 | echo " without a snapshot, which no fresh epoch allows" | ||
| 472 | exit 1; } | ||
| 473 | |||
| 474 | assert_stopped "$SOCK69" "$D69PID" "upgrade" "$OUT.upstop" | ||
| 475 | D69PID="" | ||
| 476 | rm -f "$UPBIN" | ||
| 477 | rm -rf "$UPHOME" | ||
| 478 | ok "muxd upgrade keeps the shell, its pid, its title, its marks and its counters" | ||
| 479 | |||
| 480 | # --- ...and a candidate that cannot adopt hands the daemon back ----------- | ||
| 481 | # | ||
| 482 | # The rollback: the new image fails partway through adoption and execs the | ||
| 483 | # OLD binary — whose path the manifest carries — with the same manifest fd. | ||
| 484 | # Armed through the ENVIRONMENT rather than the flag it shares a name with, | ||
| 485 | # and that is not a convenience: `execUpgrade` builds a fixed argv | ||
| 486 | # (`run --resume-fd N`), so nothing typed here can put `--resume-fail-at` | ||
| 487 | # in front of the candidate. The daemon's environment is what crosses an | ||
| 488 | # exec, so the abort is armed on the daemon before any upgrade asks. | ||
| 489 | # | ||
| 490 | # The daemon's own log is the only thing that can say a rollback HAPPENED — | ||
| 491 | # `muxd upgrade` prints its verdict the moment the daemon accepts, and the | ||
| 492 | # process that would have corrected it is gone. Without that line every | ||
| 493 | # assertion below would pass just as well on an upgrade that simply worked. | ||
| 494 | mkdir -p "$UPHOME" | ||
| 495 | MUX_SHELL_INTEGRATION=1 MUX_RESUME_FAIL_AT=session HOME="$UPHOME" \ | ||
| 496 | start_daemon "$SOCK70" "$OUT.url.d" "rollback daemon never bound" --shell /bin/bash | ||
| 497 | D70PID=$DPID | ||
| 498 | |||
| 499 | timeout 20 "$MUXA" run --sock "$SOCK70" --timeout 8000 'echo rollpid=$$' > "$OUT.urlm1" 2>&1 || { | ||
| 500 | echo "e2e FAIL: rollback: muxa run failed before the upgrade:" | ||
| 501 | cat "$OUT.urlm1"; exit 1; } | ||
| 502 | wait_grid "$SOCK70" "rollpid=[0-9]" "rollback: the session shell never printed its pid" | ||
| 503 | UPROLLPID=$(dump_session "$SOCK70" | sed -n 's/.*rollpid=\([0-9][0-9]*\).*/\1/p' | head -1) | ||
| 504 | [ -n "$UPROLLPID" ] || { | ||
| 505 | echo "e2e FAIL: rollback: no shell pid read off the grid"; dump_session "$SOCK70"; exit 1; } | ||
| 506 | |||
| 507 | set +e | ||
| 508 | "$MUXD" upgrade --sock "$SOCK70" --allow-same-version > "$OUT.urlup" 2>&1 | ||
| 509 | UPRC=$? | ||
| 510 | set -e | ||
| 511 | [ "$UPRC" -eq 0 ] || { | ||
| 512 | echo "e2e FAIL: rollback: upgrade exited $UPRC — the rolled-back daemon must serve" | ||
| 513 | echo " well enough for the confirm to pass:" | ||
| 514 | cat "$OUT.urlup" "$OUT.url.d"; exit 1; } | ||
| 515 | wait_for "$OUT.url.d" "adoption failed at session" 15 || { | ||
| 516 | echo "e2e FAIL: rollback: the candidate adopted instead of aborting — the abort" | ||
| 517 | echo " was not armed, and nothing below tests a rollback:" | ||
| 518 | cat "$OUT.url.d"; exit 1; } | ||
| 519 | grep -qF "exec'ing" "$OUT.url.d" || { | ||
| 520 | echo "e2e FAIL: rollback: the abort never named the binary it went back to:" | ||
| 521 | cat "$OUT.url.d"; exit 1; } | ||
| 522 | |||
| 523 | kill -0 "$D70PID" 2>/dev/null || { | ||
| 524 | echo "e2e FAIL: rollback: the daemon pid $D70PID is gone; a failed adoption took" | ||
| 525 | echo " the sessions with it" | ||
| 526 | cat "$OUT.url.d"; exit 1; } | ||
| 527 | wait_grid "$SOCK70" "rollpid=$UPROLLPID" "rollback: the session lost its shell to a failed adoption" | ||
| 528 | timeout 40 "$MUXA" run --sock "$SOCK70" --timeout 20000 'echo roll-post' > "$OUT.urlm2" 2>&1 || { | ||
| 529 | echo "e2e FAIL: rollback: the daemon that came back cannot run a command:" | ||
| 530 | cat "$OUT.urlm2" "$OUT.url.d"; exit 1; } | ||
| 531 | grep -qF '"mechanism":"marks"' "$OUT.urlm2" || { | ||
| 532 | echo "e2e FAIL: rollback: the re-adopted session lost its marks:" | ||
| 533 | cat "$OUT.urlm2"; exit 1; } | ||
| 534 | |||
| 535 | assert_stopped "$SOCK70" "$D70PID" "rollback" "$OUT.urlstop" | ||
| 536 | D70PID="" | ||
| 537 | rm -rf "$UPHOME" | ||
| 538 | ok "a candidate that cannot adopt execs the old binary back, session and marks intact" | ||
| 539 | |||
| 540 | # --- ...and the session's agent socket crosses the exec ------------------- | ||
| 541 | # | ||
| 542 | # The per-session agent listener is a descriptor the daemon owns and the | ||
| 543 | # shell reaches by path ($SSH_AUTH_SOCK, named after the daemon's pid). It | ||
| 544 | # has to cross the exec on both counts: the fd by inheritance, the path | ||
| 545 | # because the pid does not change. Nothing else in this suite would notice | ||
| 546 | # if it did not — the agent channels themselves are deliberately NOT | ||
| 547 | # carried, so the only thing that can say the listener survived is a real | ||
| 548 | # `ssh-add -l` answering through a re-dialled `-A` client. | ||
| 549 | # | ||
| 550 | # Its own agent rather than the forwarding legs' AGENT48: that one is left | ||
| 551 | # under SIGSTOP by the mute-offerer leg, and a mute agent is exactly what | ||
| 552 | # this leg cannot tell from a broken listener. | ||
| 553 | start_daemon "$SOCK71" "$OUT.uag.d" "agent-upgrade daemon never bound" --shell /bin/sh | ||
| 554 | D71PID=$DPID | ||
| 555 | |||
| 556 | ssh-agent -a "$UPAGENT" > "$OUT.uagenv" 2>&1 | ||
| 557 | UPAGPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.uagenv") | ||
| 558 | defer_kill "$UPAGPID" | ||
| 559 | [ -n "$UPAGPID" ] || { | ||
| 560 | echo "e2e FAIL: agent-upgrade: ssh-agent printed no pid for the trap to hold it by:" | ||
| 561 | cat "$OUT.uagenv"; exit 1; } | ||
| 562 | ssh-keygen -q -t ed25519 -N '' -C mux-e2e-upgrade -f "$UPAGKEY" | ||
| 563 | SSH_AUTH_SOCK="$UPAGENT" ssh-add "$UPAGKEY" > /dev/null 2>&1 | ||
| 564 | UPFP=$(ssh-keygen -lf "$UPAGKEY" | awk '{print $2}') | ||
| 565 | case "$UPFP" in | ||
| 566 | SHA256:?*) ;; | ||
| 567 | *) echo "e2e FAIL: agent-upgrade: ssh-keygen -lf gave no fingerprint: [$UPFP]"; exit 1;; | ||
| 568 | esac | ||
| 569 | |||
| 570 | pipe_mux "$OUT.uag" "$OUT.uag.err" env SSH_AUTH_SOCK="$UPAGENT" timeout 90 \ | ||
| 571 | "$MUX" -A --sock "$SOCK71" | ||
| 572 | UPATT1=$("$MUXD" stats --sock "$SOCK71" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 573 | [ -n "$UPATT1" ] || { echo "e2e FAIL: agent-upgrade: no attaches counter to compare against"; exit 1; } | ||
| 574 | # `agpre=` rather than the fingerprint alone: the shell echoes what is | ||
| 575 | # typed, and `$?` is not expanded in the echo, so only ssh-add's own status | ||
| 576 | # can put a digit there. The fingerprint is asserted beside it because a | ||
| 577 | # status of 0 says an agent answered, not that it was the client's. | ||
| 578 | pipe_send 'ssh-add -l; echo agpre=$?\n' | ||
| 579 | await_out "$OUT.uag" "agpre=0" "agent-upgrade: ssh-add never answered before the upgrade" | ||
| 580 | # Counted by OCCURRENCE, not by matching line: this capture is an escape | ||
| 581 | # stream whose one "line" is the whole session, so `grep -c` would answer 1 | ||
| 582 | # for both answers and the comparison after the exec would be vacuous. | ||
| 583 | UPFP1=$(grep -aoF -- "$UPFP" "$OUT.uag" | wc -l) | ||
| 584 | [ "$UPFP1" -ge 1 ] || { | ||
| 585 | echo "e2e FAIL: agent-upgrade: the client's key never came back before the upgrade" | ||
| 586 | echo " (wanted $UPFP)"; exit 1; } | ||
| 587 | |||
| 588 | set +e | ||
| 589 | "$MUXD" upgrade --sock "$SOCK71" --allow-same-version > "$OUT.uagup" 2>&1 | ||
| 590 | UPRC=$? | ||
| 591 | set -e | ||
| 592 | [ "$UPRC" -eq 0 ] || { | ||
| 593 | echo "e2e FAIL: agent-upgrade: upgrade exited $UPRC:" | ||
| 594 | cat "$OUT.uagup" "$OUT.uag.d"; exit 1; } | ||
| 595 | _i=0 | ||
| 596 | while : ; do | ||
| 597 | _upst=$("$MUXD" stats --sock "$SOCK71" 2>/dev/null || true) | ||
| 598 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 599 | case "$_upst" in | ||
| 600 | *"session 0 clients=1"*) | ||
| 601 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 602 | esac | ||
| 603 | _i=$((_i + 1)) | ||
| 604 | [ "$_i" -lt $(( 200 * TIME_SCALE )) ] || { | ||
| 605 | echo "e2e FAIL: agent-upgrade: the -A client never re-attached; stats holds: $_upst" | ||
| 606 | cat "$OUT.uag.d"; exit 1; } | ||
| 607 | sleep 0.1 | ||
| 608 | done | ||
| 609 | |||
| 610 | # The same question a second time, and `agdone=` is a needle the first | ||
| 611 | # answer cannot satisfy. | ||
| 612 | pipe_send 'ssh-add -l; echo agdone=$?\n' | ||
| 613 | await_out "$OUT.uag" "agdone=0" "agent-upgrade: ssh-add stopped answering across the exec" | ||
| 614 | UPFP2=$(grep -aoF -- "$UPFP" "$OUT.uag" | wc -l) | ||
| 615 | [ "$UPFP2" -gt "$UPFP1" ] || { | ||
| 616 | echo "e2e FAIL: agent-upgrade: ssh-add exited 0 but the key came back $UPFP1 time(s)" | ||
| 617 | echo " before the exec and $UPFP2 after — no NEW answer crossed"; exit 1; } | ||
| 618 | pipe_detach "agent-upgrade client" | ||
| 619 | |||
| 620 | assert_stopped "$SOCK71" "$D71PID" "agent-upgrade" "$OUT.uagstop" | ||
| 621 | D71PID="" | ||
| 622 | softkill "$UPAGPID" || true | ||
| 623 | wait_pid_gone "$UPAGPID" "agent-upgrade: ssh-agent killed by tracked pid" | ||
| 624 | UPAGPID="" | ||
| 625 | ok "the session's agent socket crosses the exec: ssh-add -l answers again" | ||
| 626 | |||
| 627 | # --- ...and a QUIC client is serving again in a breath, not after a timeout | ||
| 628 | # | ||
| 629 | # Unix clients need no goodbye — their fds are close-on-exec and the exec is | ||
| 630 | # their EOF. A QUIC peer has no such thing: silence is indistinguishable from | ||
| 631 | # a bad network, and a client that waited it out would sit on a blank wall | ||
| 632 | # for the whole idle timeout. So the daemon sends CONNECTION_CLOSE to every | ||
| 633 | # connection before it execs, and what that buys is the number asserted | ||
| 634 | # here: back in service in a fraction of `--quic-idle-ms`, not after it. | ||
| 635 | head -c 32 /dev/urandom > "$UPKEY" | ||
| 636 | chmod 600 "$UPKEY" | ||
| 637 | start_daemon "$SOCK72" "$OUT.uqc.d" "quic-upgrade daemon never bound" --shell /bin/sh \ | ||
| 638 | --quic "127.0.0.1:$UPQPORT" --key "$UPKEY" --quic-idle-ms 15000 | ||
| 639 | D72PID=$DPID | ||
| 640 | |||
| 641 | pipe_mux "$OUT.uqc" "$OUT.uqc.err" timeout 90 "$MUX" "quic://127.0.0.1:$UPQPORT" \ | ||
| 642 | --key "$UPKEY" --quic-idle-ms 15000 | ||
| 643 | pipe_send 'printf "uq-%%s\\n" pre\n' | ||
| 644 | await_out "$OUT.uqc" "uq-pre" "quic-upgrade: the QUIC client never got its pre-upgrade marker" | ||
| 645 | |||
| 646 | UPATT1=$("$MUXD" stats --sock "$SOCK72" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 647 | [ -n "$UPATT1" ] || { echo "e2e FAIL: quic-upgrade: no attaches counter to compare against"; exit 1; } | ||
| 648 | UPT0=$(date +%s%N) | ||
| 649 | set +e | ||
| 650 | "$MUXD" upgrade --sock "$SOCK72" --allow-same-version > "$OUT.uqup" 2>&1 | ||
| 651 | UPRC=$? | ||
| 652 | set -e | ||
| 653 | [ "$UPRC" -eq 0 ] || { | ||
| 654 | echo "e2e FAIL: quic-upgrade: upgrade exited $UPRC:" | ||
| 655 | cat "$OUT.uqup" "$OUT.uqc.d"; exit 1; } | ||
| 656 | _i=0 | ||
| 657 | while : ; do | ||
| 658 | _upst=$("$MUXD" stats --sock "$SOCK72" 2>/dev/null || true) | ||
| 659 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 660 | case "$_upst" in | ||
| 661 | *"session 0 clients=1"*) | ||
| 662 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 663 | esac | ||
| 664 | _i=$((_i + 1)) | ||
| 665 | [ "$_i" -lt $(( 400 * TIME_SCALE )) ] || { | ||
| 666 | echo "e2e FAIL: quic-upgrade: the QUIC client never re-attached; stats holds: $_upst" | ||
| 667 | cat "$OUT.uqc.d"; exit 1; } | ||
| 668 | sleep 0.05 | ||
| 669 | done | ||
| 670 | UPT1=$(date +%s%N) | ||
| 671 | UPMS=$(( (UPT1 - UPT0) / 1000000 )) | ||
| 672 | # Half the idle timeout, which is the only bound that means anything here: | ||
| 673 | # a client that had waited the connection out would arrive at 15000ms and | ||
| 674 | # still pass a generous absolute budget. Measured at ~60ms. | ||
| 675 | [ "$UPMS" -lt $(( 7500 * TIME_SCALE )) ] || { | ||
| 676 | echo "e2e FAIL: quic-upgrade: the QUIC client took ${UPMS}ms to come back —" | ||
| 677 | echo " that is the idle timeout expiring, not a CONNECTION_CLOSE" | ||
| 678 | exit 1; } | ||
| 679 | |||
| 680 | # ...and it is a session, not just a connection: a round trip that did not | ||
| 681 | # exist before the exec. | ||
| 682 | pipe_send 'printf "uq-%%s\\n" post\n' | ||
| 683 | await_out "$OUT.uqc" "uq-post" "quic-upgrade: the reconnected QUIC client cannot reach its shell" | ||
| 684 | pipe_detach "quic-upgrade client" | ||
| 685 | |||
| 686 | assert_stopped "$SOCK72" "$D72PID" "quic-upgrade" "$OUT.uqstop" | ||
| 687 | D72PID="" | ||
| 688 | ok "a QUIC client is served again within a breath of the exec, not after the idle timeout" | ||
| 689 | |||
| 690 | # --- muxd stop returns when the PROCESS is gone, not when the path is | ||
| 691 | # | ||
| 692 | # The unlink is the first thing a stopping daemon does; reaping its shells | ||
| 693 | # and deleting its dirs come after. A stop that said "stopped" at the | ||
| 694 | # unlink handed a scripted `muxd start`, or a supervisor's "is it down", | ||
| 695 | # a daemon still running — `wait_pid_gone` after every stop in this suite | ||
| 696 | # was that gap, papered. Shells that ignore TERM make the window a real | ||
| 697 | # grace rather than a race the assertion could win by luck, and two | ||
| 698 | # sessions make it a table's grace, not one shell's. | ||
| 699 | printf '#!/bin/sh\ntrap "" HUP TERM\nwhile :; do sleep 1; done\n' > "$OUT.sg.sh" | ||
| 700 | chmod +x "$OUT.sg.sh" | ||
| 701 | start_daemon "$SOCK73" "$OUT.sg.d" "stop-gone daemon never bound" --shell "$OUT.sg.sh" | ||
| 702 | D73PID=$DPID | ||
| 703 | pipe_mux "$OUT.sg" "" timeout 60 "$MUX" --sock "$SOCK73" --session second | ||
| 704 | sleep 0.5 | ||
| 705 | pipe_detach "stop-gone: the second session's client" | ||
| 706 | [ "$("$MUXD" stats --sock "$SOCK73" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "2" ] || { | ||
| 707 | echo "e2e FAIL: stop-gone: wanted two sessions of stubborn shells"; exit 1; } | ||
| 708 | D73REAL=$(real_pid "$D73PID") | ||
| 709 | "$MUXD" stop --sock "$SOCK73" 2> "$OUT.sg.stop" || { | ||
| 710 | echo "e2e FAIL: stop-gone: stop failed"; cat "$OUT.sg.stop"; exit 1; } | ||
| 711 | # Asked of the OS the instant stop returns — no wait, no retry. | ||
| 712 | if kill -0 "$D73REAL" 2>/dev/null; then | ||
| 713 | echo "e2e FAIL: stop said stopped, but pid $D73PID is still running"; cat "$OUT.sg.stop"; exit 1 | ||
| 714 | fi | ||
| 715 | grep -q '^muxd: stopped' "$OUT.sg.stop" || { | ||
| 716 | echo "e2e FAIL: stop-gone: stop did not report stopped"; cat "$OUT.sg.stop"; exit 1; } | ||
| 717 | D73PID="" | ||
| 718 | ok "muxd stop returns when the process is gone, not when the socket is" | ||
| 719 | |||
| 720 | # The pins. Literals, not variables set from counting something else — | ||
| 721 | # "assert the literal, never the constant the code under test reads" | ||
| 722 | # (decisions.md, M10). The two figures live at the END of this file and | ||
| 723 | # nowhere else: a count repeated in prose is a count nothing gates, and | ||
| 724 | # both of the ones that used to stand here had drifted away from the pins | ||
| 725 | # they described. Adding a scenario means editing those two literals by | ||
| 726 | # hand, on purpose. | ||
| 727 | # | ||
| 728 | # Why most legs carry no convergence point. `assert_converged` replays one | ||
| 729 | # client capture into an engine and diffs it against one daemon grid, so it | ||
| 730 | # can only speak where BOTH exist and the grid is the subject. It says | ||
| 731 | # nothing about a side channel (OSC 52, a window title, pty modes, a | ||
| 732 | # fingerprint ssh printed, bytes counted rather than read), about a file an | ||
| 733 | # editor or the wall wrote, about an exit code or a status field, about | ||
| 734 | # WHICH of two sessions a keystroke reached, about scrollback the live grid | ||
| 735 | # does not hold, about the absence of bytes two agreeing empties would | ||
| 736 | # agree on anyway, or about a leg that never attaches at all. It also does | ||
| 737 | # not claim to reproduce a full-screen application's redraws byte for byte, | ||
| 738 | # which is why the alternate-screen legs stay out. A leg that asserts on | ||
| 739 | # one of those and adds a convergence point would be pinning a fact every | ||
| 740 | # leg above already establishes. | ||
| 741 | |||
| 742 | # The pins. Literals, not variables set from counting something else — | 148 | # The pins. Literals, not variables set from counting something else — |
| 743 | # "assert the literal, never the constant the code under test reads" | 149 | # "assert the literal, never the constant the code under test reads" |
| 744 | # (decisions.md, M10). The two figures live at the END of this file and | 150 | # (decisions.md, M10). The two figures live at the END of this file and |
test/e2e_14_upgrade.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,597 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # e2e_14_upgrade.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 | # `muxd upgrade` — four daemons, because each one is a different daemon | ||
| 6 | # LIFECYCLE and no two can share a process: the same-binary leg's daemon | ||
| 7 | # ends up running a second image, the rollback leg's is born with the abort | ||
| 8 | # armed in its environment, the agent leg's holds an `-A` client, and the | ||
| 9 | # QUIC leg's owns a UDP socket. They run one after another, so the last of | ||
| 10 | # them is the only leg here that needs a port. | ||
| 11 | SOCK69="${TMPDIR:-/tmp}/muxd-e2e-upgrade-$$.sock" | ||
| 12 | defer_sock "$SOCK69" | ||
| 13 | SOCK70="${TMPDIR:-/tmp}/muxd-e2e-uproll-$$.sock" | ||
| 14 | defer_sock "$SOCK70" | ||
| 15 | SOCK71="${TMPDIR:-/tmp}/muxd-e2e-upagent-$$.sock" | ||
| 16 | defer_sock "$SOCK71" | ||
| 17 | SOCK72="${TMPDIR:-/tmp}/muxd-e2e-upquic-$$.sock" | ||
| 18 | defer_sock "$SOCK72" | ||
| 19 | SOCK73="${TMPDIR:-/tmp}/muxd-e2e-stopgone-$$.sock" | ||
| 20 | defer_sock "$SOCK73" | ||
| 21 | # The next 4000-wide band DOWN from the hydrate leg's 6000: every base from | ||
| 22 | # 11000 up is taken, and the 61000+ tail above the ephemeral range is spoken | ||
| 23 | # for by the two browser ports. A collision here reads as this leg's daemon | ||
| 24 | # failing to bind, the same verdict every other band gives. | ||
| 25 | UPQPORT=$(( 2000 + ($$ % 4000) )) | ||
| 26 | UPKEY="${TMPDIR:-/tmp}/mux-e2e-upkey-$$" | ||
| 27 | defer_rm "$UPKEY" | ||
| 28 | # A HOME of its own for the session shell. The title assertion below is | ||
| 29 | # about a title the SESSION set, and a distribution's rc file repaints the | ||
| 30 | # window title from PROMPT_COMMAND on every prompt (shellint.bash_init says | ||
| 31 | # so of Arch's) — read out of the developer's own dotfiles, this leg would | ||
| 32 | # assert on whatever their prompt happens to spell. | ||
| 33 | UPHOME="${TMPDIR:-/tmp}/mux-e2e-uphome-$$" | ||
| 34 | defer_rm "$UPHOME" | ||
| 35 | # The candidate binary the refusal leg offers: a copy, so its exec bit can | ||
| 36 | # be taken away without touching the one every other scenario runs. | ||
| 37 | UPBIN="${TMPDIR:-/tmp}/mux-e2e-upcand-$$" | ||
| 38 | defer_rm "$UPBIN" | ||
| 39 | UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock" | ||
| 40 | defer_rm "$UPAGENT" | ||
| 41 | UPAGKEY="${TMPDIR:-/tmp}/mux-e2e-upagkey-$$" | ||
| 42 | defer_rm "$UPAGKEY" "$UPAGKEY.pub" | ||
| 43 | |||
| 44 | # --- muxd upgrade: the daemon becomes the new binary, holding everything --- | ||
| 45 | # | ||
| 46 | # `muxd upgrade` execs the candidate OVER the running daemon: same pid, same | ||
| 47 | # children, same descriptors. Nothing on the wire can carry the proof — a | ||
| 48 | # client reconnecting to a freshly started daemon sees exactly the same | ||
| 49 | # snapshot — so the witness is the session shell's own pid, put on the grid | ||
| 50 | # before the exec and read off it after. A restart cannot fake that: a new | ||
| 51 | # daemon forks a new shell, and a new shell has a new pid. | ||
| 52 | # | ||
| 53 | # One daemon, one attached client, and the whole story in order: what the | ||
| 54 | # session had before, two refusals that must change nothing, the exec, and | ||
| 55 | # then every one of those things again on the far side. | ||
| 56 | # | ||
| 57 | # bash with marks on, because two of the claims are the mechanism's: | ||
| 58 | # `muxa run` answers under `marks` before the exec and must answer under | ||
| 59 | # `marks` after it. The FIRST one after is the assertion that matters — | ||
| 60 | # the manifest carries a return watermark, and one carried into the new | ||
| 61 | # seq space is a watermark from the future that no return can pass, which | ||
| 62 | # measured as the first await after every upgrade timing out at 30s while | ||
| 63 | # the shell had already answered. | ||
| 64 | UPVER=$("$MUXD" --version | awk '{print $2}') | ||
| 65 | case "$UPVER" in | ||
| 66 | ?*.?*) ;; | ||
| 67 | *) echo "e2e FAIL: upgrade: muxd --version gave no version to refuse with: [$UPVER]" | ||
| 68 | exit 1;; | ||
| 69 | esac | ||
| 70 | mkdir -p "$UPHOME" | ||
| 71 | MUX_SHELL_INTEGRATION=1 HOME="$UPHOME" "$MUXD" run --sock "$SOCK69" \ | ||
| 72 | --shell /bin/bash > "$OUT.up.d" 2>&1 & | ||
| 73 | D69PID=$! | ||
| 74 | defer_kill "$D69PID" | ||
| 75 | wait_sock "$SOCK69" "$OUT.up.d" "upgrade daemon never bound" | ||
| 76 | |||
| 77 | # Marks BEFORE the exec, so "marks after" is a comparison rather than a | ||
| 78 | # hope: a daemon that never had them would fail this line first. | ||
| 79 | timeout 20 "$MUXA" run --sock "$SOCK69" --timeout 8000 'echo up-marks-pre' > "$OUT.upm1" 2>&1 || { | ||
| 80 | echo "e2e FAIL: upgrade: muxa run failed before the upgrade even started:" | ||
| 81 | cat "$OUT.upm1"; exit 1; } | ||
| 82 | grep -qF '"mechanism":"marks"' "$OUT.upm1" || { | ||
| 83 | echo "e2e FAIL: upgrade: the session had no marks to lose:" | ||
| 84 | cat "$OUT.upm1"; exit 1; } | ||
| 85 | |||
| 86 | # The attached client, on a real pty, held across the exec. Its script | ||
| 87 | # stops on `expect up-resumed`, which nothing in the script produces — the | ||
| 88 | # suite's own post-upgrade `muxa run` does, below. That is the rendezvous: | ||
| 89 | # the fixture reads the master the whole time it waits, so the capture is | ||
| 90 | # continuous across the tear, and the needle can only arrive through the | ||
| 91 | # connection the client re-dialled. | ||
| 92 | # | ||
| 93 | # `unset PROMPT_COMMAND; PROMPT_COMMAND=_mux_precmd` keeps mux's own precmd | ||
| 94 | # (shellint's `precmd_fn`, which is where the marks come from) and drops | ||
| 95 | # every other member. A distribution rc appends a title-setting member | ||
| 96 | # there, and it repaints the title on every prompt — the title this leg | ||
| 97 | # sets would be gone by the next one. `unset` first because a scalar | ||
| 98 | # assignment onto bash's array spelling lands on element 0 and leaves the | ||
| 99 | # rest. | ||
| 100 | timeout 150 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 101 | --out "$OUT.upcap" --err "$OUT.upcap.err" -- \ | ||
| 102 | "$MUX" --sock "$SOCK69" > "$OUT.uppc" 2>&1 <<'EOF' & | ||
| 103 | settle 1000 25000 | ||
| 104 | send unset PROMPT_COMMAND; PROMPT_COMMAND=_mux_precmd\n | ||
| 105 | settle 800 25000 | ||
| 106 | send printf '\\033]0;uptitle\\007'\n | ||
| 107 | expect \x1b]0;uptitle\x07 25000 | ||
| 108 | send printf 'up-%s\n' pre\n | ||
| 109 | expect up-pre 25000 | ||
| 110 | send echo shpid=$$\n | ||
| 111 | expect shpid= 25000 | ||
| 112 | settle 800 25000 | ||
| 113 | expect \x1b]0;uptitle\x07 90000 | ||
| 114 | expect up-resumed 60000 | ||
| 115 | settle 800 25000 | ||
| 116 | send printf 'up-%s\n' typed\n | ||
| 117 | expect up-typed 25000 | ||
| 118 | send \x1cd | ||
| 119 | waitexit 15000 | ||
| 120 | EOF | ||
| 121 | UPPCPID=$! | ||
| 122 | defer_kill "$UPPCPID" | ||
| 123 | |||
| 124 | # Daemon truth for the pid, and the number this leg is built around. The | ||
| 125 | # echoed command line spells `$$`, so only the shell's own answer has | ||
| 126 | # digits after the `=` — grepping for a bare `shpid=` would match the echo. | ||
| 127 | wait_grid "$SOCK69" "shpid=[0-9]" "upgrade: the session shell never printed its pid" | ||
| 128 | UPSHPID=$(dump_session "$SOCK69" | sed -n 's/.*shpid=\([0-9][0-9]*\).*/\1/p' | head -1) | ||
| 129 | [ -n "$UPSHPID" ] || { | ||
| 130 | echo "e2e FAIL: upgrade: no shell pid read off the grid — the witness would be vacuous" | ||
| 131 | dump_session "$SOCK69"; exit 1; } | ||
| 132 | |||
| 133 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst1" 2>&1 || { | ||
| 134 | echo "e2e FAIL: upgrade: stats did not answer before the upgrade"; exit 1; } | ||
| 135 | UPATT1=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst1") | ||
| 136 | UPSNAP1=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst1") | ||
| 137 | [ -n "$UPATT1" ] && [ -n "$UPSNAP1" ] || { | ||
| 138 | echo "e2e FAIL: upgrade: no counters read out of stats; the carry pin would be vacuous:" | ||
| 139 | cat "$OUT.upst1"; exit 1; } | ||
| 140 | |||
| 141 | # 1. Refused: same version, no flag. The reason names BOTH versions, which | ||
| 142 | # is the difference between a policy an operator can act on and a "no". | ||
| 143 | set +e | ||
| 144 | "$MUXD" upgrade --sock "$SOCK69" > "$OUT.upref1" 2>&1 | ||
| 145 | UPRC=$? | ||
| 146 | set -e | ||
| 147 | [ "$UPRC" -eq 1 ] || { | ||
| 148 | echo "e2e FAIL: upgrade: a same-version upgrade exited $UPRC, want 1:" | ||
| 149 | cat "$OUT.upref1"; exit 1; } | ||
| 150 | grep -qF "refused: version: $UPVER is not newer than $UPVER" "$OUT.upref1" || { | ||
| 151 | echo "e2e FAIL: upgrade: the refusal did not name both versions:" | ||
| 152 | cat "$OUT.upref1"; exit 1; } | ||
| 153 | |||
| 154 | # 2. Refused: a candidate that is not executable. `muxd upgrade` offers its | ||
| 155 | # OWN path (/proc/self/exe), so the only way to spell this refusal is to | ||
| 156 | # take the exec bit off a copy AFTER it has started — and the ordering is | ||
| 157 | # made deterministic rather than hoped for. The daemon is held under | ||
| 158 | # SIGSTOP so it cannot read the request until the chmod has landed; | ||
| 159 | # `readlink /proc/PID/exe` is the witness that the copy has already | ||
| 160 | # exec'd, so the chmod cannot beat it and turn the leg into an exec | ||
| 161 | # failure that asserts nothing. | ||
| 162 | cp "$MUXD_ELF" "$UPBIN" | ||
| 163 | chmod 755 "$UPBIN" | ||
| 164 | kill -STOP "$D69PID" | ||
| 165 | "$UPBIN" upgrade --sock "$SOCK69" --allow-same-version > "$OUT.upref2" 2>&1 & | ||
| 166 | UPXPID=$! | ||
| 167 | defer_kill "$UPXPID" | ||
| 168 | _i=0 | ||
| 169 | while [ "$(readlink "/proc/$UPXPID/exe" 2>/dev/null)" != "$UPBIN" ]; do | ||
| 170 | _i=$((_i + 1)) | ||
| 171 | [ "$_i" -lt $(( 100 * TIME_SCALE )) ] || { | ||
| 172 | kill -CONT "$D69PID" 2>/dev/null || true | ||
| 173 | echo "e2e FAIL: upgrade: the candidate copy never exec'd; the refusal below" | ||
| 174 | echo " would have been about a binary that never asked" | ||
| 175 | exit 1; } | ||
| 176 | sleep 0.05 | ||
| 177 | done | ||
| 178 | chmod 000 "$UPBIN" | ||
| 179 | kill -CONT "$D69PID" | ||
| 180 | set +e | ||
| 181 | wait "$UPXPID" | ||
| 182 | UPRC=$? | ||
| 183 | set -e | ||
| 184 | chmod 755 "$UPBIN" | ||
| 185 | [ "$UPRC" -eq 1 ] || { | ||
| 186 | echo "e2e FAIL: upgrade: an unexecutable candidate exited $UPRC, want 1:" | ||
| 187 | cat "$OUT.upref2"; exit 1; } | ||
| 188 | grep -qF "refused: path: not executable" "$OUT.upref2" || { | ||
| 189 | echo "e2e FAIL: upgrade: the refusal did not name the path check:" | ||
| 190 | cat "$OUT.upref2"; exit 1; } | ||
| 191 | |||
| 192 | # ...and two refusals changed NOTHING. A refusal that had torn a client | ||
| 193 | # down, or written the manifest and left the fd table half open, would | ||
| 194 | # still have printed the words above. | ||
| 195 | timeout 20 "$MUXA" status --sock "$SOCK69" > "$OUT.upsta" 2>&1 || { | ||
| 196 | echo "e2e FAIL: upgrade: the daemon stopped answering after refusing:" | ||
| 197 | cat "$OUT.upsta"; exit 1; } | ||
| 198 | wait_grid "$SOCK69" "shpid=$UPSHPID" "upgrade: a refusal cost the session its shell" | ||
| 199 | |||
| 200 | # 3. The exec itself. | ||
| 201 | set +e | ||
| 202 | "$MUXD" upgrade --sock "$SOCK69" --allow-same-version > "$OUT.upok" 2>&1 | ||
| 203 | UPRC=$? | ||
| 204 | set -e | ||
| 205 | [ "$UPRC" -eq 0 ] || { | ||
| 206 | echo "e2e FAIL: upgrade: the upgrade exited $UPRC, want 0:" | ||
| 207 | cat "$OUT.upok" "$OUT.up.d"; exit 1; } | ||
| 208 | grep -qF "muxd: upgraded to $UPVER" "$OUT.upok" || { | ||
| 209 | echo "e2e FAIL: upgrade: no verdict naming the version it landed on:" | ||
| 210 | cat "$OUT.upok"; exit 1; } | ||
| 211 | |||
| 212 | # The pid is the feature. Not "a daemon is answering" — the same process, | ||
| 213 | # now running a different image, which /proc/PID/cmdline says in one word: | ||
| 214 | # a daemon that had restarted would carry the original `run --sock` argv. | ||
| 215 | kill -0 "$D69PID" 2>/dev/null || { | ||
| 216 | echo "e2e FAIL: upgrade: the daemon pid $D69PID is gone — that is a restart, not an upgrade" | ||
| 217 | cat "$OUT.up.d"; exit 1; } | ||
| 218 | tr '\0' ' ' < "/proc/$(real_pid "$D69PID")/cmdline" > "$OUT.upst2" 2>/dev/null || true | ||
| 219 | grep -qF -- "--resume-fd" "$OUT.upst2" || { | ||
| 220 | echo "e2e FAIL: upgrade: pid $D69PID is not running the resumed argv; it holds:" | ||
| 221 | cat "$OUT.upst2"; exit 1; } | ||
| 222 | |||
| 223 | # Counters CARRY, and it is asserted HERE rather than at the end for a | ||
| 224 | # reason about blame: the re-attach poll below counts `attaches` up from | ||
| 225 | # the reading taken before the exec, so a daemon that had zeroed its | ||
| 226 | # counters fails THAT poll — reported as a client that never came back, | ||
| 227 | # which is the wrong diagnosis of the wrong bug. `-ge`, because the | ||
| 228 | # re-attach this reading races has not necessarily landed yet. | ||
| 229 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst2" 2>&1 || { | ||
| 230 | echo "e2e FAIL: upgrade: stats did not answer after the exec"; cat "$OUT.up.d"; exit 1; } | ||
| 231 | UPATT2=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 232 | UPSNAP2=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 233 | [ -n "$UPATT2" ] && [ -n "$UPSNAP2" ] || { | ||
| 234 | echo "e2e FAIL: upgrade: no counters read out of stats after the exec:" | ||
| 235 | cat "$OUT.upst2"; exit 1; } | ||
| 236 | [ "$UPATT2" -ge "$UPATT1" ] || { | ||
| 237 | echo "e2e FAIL: upgrade: attaches went $UPATT1 -> $UPATT2 across the exec; the" | ||
| 238 | echo " counters were reset, not carried, and an upgrade now looks like" | ||
| 239 | echo " a restart to anything sampling this daemon" | ||
| 240 | exit 1; } | ||
| 241 | [ "$UPSNAP2" -ge "$UPSNAP1" ] || { | ||
| 242 | echo "e2e FAIL: upgrade: snapshots went $UPSNAP1 -> $UPSNAP2 across the exec; the" | ||
| 243 | echo " counters were reset, not carried" | ||
| 244 | exit 1; } | ||
| 245 | |||
| 246 | # The client came back on its own. Counted with `attaches=`, not with | ||
| 247 | # `clients=`: the client is re-dialled within a millisecond of the exec, so | ||
| 248 | # a poll that only looked for clients=1 would be satisfied by the reading it | ||
| 249 | # took before the tear and would pass on a daemon that never dropped anyone. | ||
| 250 | _i=0 | ||
| 251 | while : ; do | ||
| 252 | _upst=$("$MUXD" stats --sock "$SOCK69" 2>/dev/null || true) | ||
| 253 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 254 | case "$_upst" in | ||
| 255 | *"session 0 clients=1"*) | ||
| 256 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 257 | esac | ||
| 258 | _i=$((_i + 1)) | ||
| 259 | [ "$_i" -lt $(( 200 * TIME_SCALE )) ] || { | ||
| 260 | echo "e2e FAIL: upgrade: the attached client never re-attached after the exec;" | ||
| 261 | echo " stats holds: $_upst" | ||
| 262 | cat "$OUT.up.d"; exit 1; } | ||
| 263 | sleep 0.1 | ||
| 264 | done | ||
| 265 | |||
| 266 | # The grid crossed with the process: the marker the client typed, and the | ||
| 267 | # pid of the shell that is still running under the new image. | ||
| 268 | wait_grid "$SOCK69" "up-pre" "upgrade: the pre-upgrade marker did not survive the exec" | ||
| 269 | wait_grid "$SOCK69" "shpid=$UPSHPID" "upgrade: the session is a NEW shell, not the one that crossed" | ||
| 270 | |||
| 271 | # The FIRST muxa run after the exec, and it is also what releases the | ||
| 272 | # attached client's `expect up-resumed` above — one command, two claims. | ||
| 273 | set +e | ||
| 274 | timeout 40 "$MUXA" run --sock "$SOCK69" --timeout 20000 'echo up-resumed' > "$OUT.upm2" 2>&1 | ||
| 275 | UPRC=$? | ||
| 276 | set -e | ||
| 277 | [ "$UPRC" -eq 0 ] || { | ||
| 278 | echo "e2e FAIL: upgrade: the FIRST muxa run after the exec exited $UPRC — a carried" | ||
| 279 | echo " return watermark is a watermark from the future and no return passes it:" | ||
| 280 | cat "$OUT.upm2"; exit 1; } | ||
| 281 | grep -qF '"mechanism":"marks"' "$OUT.upm2" || { | ||
| 282 | echo "e2e FAIL: upgrade: the first await after the exec fell off marks:" | ||
| 283 | cat "$OUT.upm2"; exit 1; } | ||
| 284 | grep -qF '"exit_code":0' "$OUT.upm2" || { | ||
| 285 | echo "e2e FAIL: upgrade: no real exit code came back after the exec:" | ||
| 286 | cat "$OUT.upm2"; exit 1; } | ||
| 287 | # The client's own verdict: the title came back on the re-attach, the | ||
| 288 | # marker arrived through the new image, and a keystroke typed AFTER the | ||
| 289 | # exec reached the shell. | ||
| 290 | set +e | ||
| 291 | wait "$UPPCPID" | ||
| 292 | UPRC=$? | ||
| 293 | set -e | ||
| 294 | UPPCPID="" | ||
| 295 | [ "$UPRC" -eq 0 ] || { | ||
| 296 | echo "e2e FAIL: upgrade: the attached client exited $UPRC across the exec:" | ||
| 297 | cat "$OUT.uppc" "$OUT.upcap.err"; exit 1; } | ||
| 298 | |||
| 299 | # A NONZERO exit code too, because `"exit_code":0` is also what a mechanism | ||
| 300 | # that guesses would say. After the client has gone, not beside it: two | ||
| 301 | # writers typing into one shell's pty interleave, and the leg would be | ||
| 302 | # racing itself. Spelled as a CHILD, not as `exit 7`, which is the session | ||
| 303 | # shell's own exit — the session would end, the daemon with it, and the | ||
| 304 | # counter reading below would be taken from nothing. Not `(exit 7)` either: | ||
| 305 | # a subshell sets `_mux_ran` in the subshell, so shellint's `precmd_fn` | ||
| 306 | # sees an untouched prompt in the parent and emits no `D;` mark at all | ||
| 307 | # (measured: the await ran its whole budget out). | ||
| 308 | timeout 40 "$MUXA" run --sock "$SOCK69" --timeout 20000 'sh -c "exit 7"' > "$OUT.upm3" 2>&1 || true | ||
| 309 | grep -qF '"exit_code":7' "$OUT.upm3" || { | ||
| 310 | echo "e2e FAIL: upgrade: the shell's own exit code did not survive the exec:" | ||
| 311 | cat "$OUT.upm3"; exit 1; } | ||
| 312 | |||
| 313 | # ...and the re-attach was COUNTED onto them rather than replacing them. | ||
| 314 | # The carry was asserted above with `-ge`; this is the other half, taken | ||
| 315 | # once the client's own snapshot has certainly been served. | ||
| 316 | "$MUXD" stats --sock "$SOCK69" > "$OUT.upst2" 2>&1 | ||
| 317 | UPATT2=$(sed -n 's/.*attaches=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 318 | UPSNAP2=$(sed -n 's/^snapshots=\([0-9]*\).*/\1/p' "$OUT.upst2") | ||
| 319 | [ -n "$UPATT2" ] && [ -n "$UPSNAP2" ] || { | ||
| 320 | echo "e2e FAIL: upgrade: no counters read out of stats after the client left:" | ||
| 321 | cat "$OUT.upst2"; exit 1; } | ||
| 322 | [ "$UPATT2" -gt "$UPATT1" ] || { | ||
| 323 | echo "e2e FAIL: upgrade: attaches is still $UPATT2 — the re-attach after the exec" | ||
| 324 | echo " was never counted" | ||
| 325 | exit 1; } | ||
| 326 | [ "$UPSNAP2" -gt "$UPSNAP1" ] || { | ||
| 327 | echo "e2e FAIL: upgrade: snapshots is still $UPSNAP2 — the re-attach was served" | ||
| 328 | echo " without a snapshot, which no fresh epoch allows" | ||
| 329 | exit 1; } | ||
| 330 | |||
| 331 | assert_stopped "$SOCK69" "$D69PID" "upgrade" "$OUT.upstop" | ||
| 332 | D69PID="" | ||
| 333 | rm -f "$UPBIN" | ||
| 334 | rm -rf "$UPHOME" | ||
| 335 | ok "muxd upgrade keeps the shell, its pid, its title, its marks and its counters" | ||
| 336 | |||
| 337 | # --- ...and a candidate that cannot adopt hands the daemon back ----------- | ||
| 338 | # | ||
| 339 | # The rollback: the new image fails partway through adoption and execs the | ||
| 340 | # OLD binary — whose path the manifest carries — with the same manifest fd. | ||
| 341 | # Armed through the ENVIRONMENT rather than the flag it shares a name with, | ||
| 342 | # and that is not a convenience: `execUpgrade` builds a fixed argv | ||
| 343 | # (`run --resume-fd N`), so nothing typed here can put `--resume-fail-at` | ||
| 344 | # in front of the candidate. The daemon's environment is what crosses an | ||
| 345 | # exec, so the abort is armed on the daemon before any upgrade asks. | ||
| 346 | # | ||
| 347 | # The daemon's own log is the only thing that can say a rollback HAPPENED — | ||
| 348 | # `muxd upgrade` prints its verdict the moment the daemon accepts, and the | ||
| 349 | # process that would have corrected it is gone. Without that line every | ||
| 350 | # assertion below would pass just as well on an upgrade that simply worked. | ||
| 351 | mkdir -p "$UPHOME" | ||
| 352 | MUX_SHELL_INTEGRATION=1 MUX_RESUME_FAIL_AT=session HOME="$UPHOME" \ | ||
| 353 | start_daemon "$SOCK70" "$OUT.url.d" "rollback daemon never bound" --shell /bin/bash | ||
| 354 | D70PID=$DPID | ||
| 355 | |||
| 356 | timeout 20 "$MUXA" run --sock "$SOCK70" --timeout 8000 'echo rollpid=$$' > "$OUT.urlm1" 2>&1 || { | ||
| 357 | echo "e2e FAIL: rollback: muxa run failed before the upgrade:" | ||
| 358 | cat "$OUT.urlm1"; exit 1; } | ||
| 359 | wait_grid "$SOCK70" "rollpid=[0-9]" "rollback: the session shell never printed its pid" | ||
| 360 | UPROLLPID=$(dump_session "$SOCK70" | sed -n 's/.*rollpid=\([0-9][0-9]*\).*/\1/p' | head -1) | ||
| 361 | [ -n "$UPROLLPID" ] || { | ||
| 362 | echo "e2e FAIL: rollback: no shell pid read off the grid"; dump_session "$SOCK70"; exit 1; } | ||
| 363 | |||
| 364 | set +e | ||
| 365 | "$MUXD" upgrade --sock "$SOCK70" --allow-same-version > "$OUT.urlup" 2>&1 | ||
| 366 | UPRC=$? | ||
| 367 | set -e | ||
| 368 | [ "$UPRC" -eq 0 ] || { | ||
| 369 | echo "e2e FAIL: rollback: upgrade exited $UPRC — the rolled-back daemon must serve" | ||
| 370 | echo " well enough for the confirm to pass:" | ||
| 371 | cat "$OUT.urlup" "$OUT.url.d"; exit 1; } | ||
| 372 | wait_for "$OUT.url.d" "adoption failed at session" 15 || { | ||
| 373 | echo "e2e FAIL: rollback: the candidate adopted instead of aborting — the abort" | ||
| 374 | echo " was not armed, and nothing below tests a rollback:" | ||
| 375 | cat "$OUT.url.d"; exit 1; } | ||
| 376 | grep -qF "exec'ing" "$OUT.url.d" || { | ||
| 377 | echo "e2e FAIL: rollback: the abort never named the binary it went back to:" | ||
| 378 | cat "$OUT.url.d"; exit 1; } | ||
| 379 | |||
| 380 | kill -0 "$D70PID" 2>/dev/null || { | ||
| 381 | echo "e2e FAIL: rollback: the daemon pid $D70PID is gone; a failed adoption took" | ||
| 382 | echo " the sessions with it" | ||
| 383 | cat "$OUT.url.d"; exit 1; } | ||
| 384 | wait_grid "$SOCK70" "rollpid=$UPROLLPID" "rollback: the session lost its shell to a failed adoption" | ||
| 385 | timeout 40 "$MUXA" run --sock "$SOCK70" --timeout 20000 'echo roll-post' > "$OUT.urlm2" 2>&1 || { | ||
| 386 | echo "e2e FAIL: rollback: the daemon that came back cannot run a command:" | ||
| 387 | cat "$OUT.urlm2" "$OUT.url.d"; exit 1; } | ||
| 388 | grep -qF '"mechanism":"marks"' "$OUT.urlm2" || { | ||
| 389 | echo "e2e FAIL: rollback: the re-adopted session lost its marks:" | ||
| 390 | cat "$OUT.urlm2"; exit 1; } | ||
| 391 | |||
| 392 | assert_stopped "$SOCK70" "$D70PID" "rollback" "$OUT.urlstop" | ||
| 393 | D70PID="" | ||
| 394 | rm -rf "$UPHOME" | ||
| 395 | ok "a candidate that cannot adopt execs the old binary back, session and marks intact" | ||
| 396 | |||
| 397 | # --- ...and the session's agent socket crosses the exec ------------------- | ||
| 398 | # | ||
| 399 | # The per-session agent listener is a descriptor the daemon owns and the | ||
| 400 | # shell reaches by path ($SSH_AUTH_SOCK, named after the daemon's pid). It | ||
| 401 | # has to cross the exec on both counts: the fd by inheritance, the path | ||
| 402 | # because the pid does not change. Nothing else in this suite would notice | ||
| 403 | # if it did not — the agent channels themselves are deliberately NOT | ||
| 404 | # carried, so the only thing that can say the listener survived is a real | ||
| 405 | # `ssh-add -l` answering through a re-dialled `-A` client. | ||
| 406 | # | ||
| 407 | # Its own agent rather than the forwarding legs' AGENT48: that one is left | ||
| 408 | # under SIGSTOP by the mute-offerer leg, and a mute agent is exactly what | ||
| 409 | # this leg cannot tell from a broken listener. | ||
| 410 | start_daemon "$SOCK71" "$OUT.uag.d" "agent-upgrade daemon never bound" --shell /bin/sh | ||
| 411 | D71PID=$DPID | ||
| 412 | |||
| 413 | ssh-agent -a "$UPAGENT" > "$OUT.uagenv" 2>&1 | ||
| 414 | UPAGPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.uagenv") | ||
| 415 | defer_kill "$UPAGPID" | ||
| 416 | [ -n "$UPAGPID" ] || { | ||
| 417 | echo "e2e FAIL: agent-upgrade: ssh-agent printed no pid for the trap to hold it by:" | ||
| 418 | cat "$OUT.uagenv"; exit 1; } | ||
| 419 | ssh-keygen -q -t ed25519 -N '' -C mux-e2e-upgrade -f "$UPAGKEY" | ||
| 420 | SSH_AUTH_SOCK="$UPAGENT" ssh-add "$UPAGKEY" > /dev/null 2>&1 | ||
| 421 | UPFP=$(ssh-keygen -lf "$UPAGKEY" | awk '{print $2}') | ||
| 422 | case "$UPFP" in | ||
| 423 | SHA256:?*) ;; | ||
| 424 | *) echo "e2e FAIL: agent-upgrade: ssh-keygen -lf gave no fingerprint: [$UPFP]"; exit 1;; | ||
| 425 | esac | ||
| 426 | |||
| 427 | pipe_mux "$OUT.uag" "$OUT.uag.err" env SSH_AUTH_SOCK="$UPAGENT" timeout 90 \ | ||
| 428 | "$MUX" -A --sock "$SOCK71" | ||
| 429 | UPATT1=$("$MUXD" stats --sock "$SOCK71" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 430 | [ -n "$UPATT1" ] || { echo "e2e FAIL: agent-upgrade: no attaches counter to compare against"; exit 1; } | ||
| 431 | # `agpre=` rather than the fingerprint alone: the shell echoes what is | ||
| 432 | # typed, and `$?` is not expanded in the echo, so only ssh-add's own status | ||
| 433 | # can put a digit there. The fingerprint is asserted beside it because a | ||
| 434 | # status of 0 says an agent answered, not that it was the client's. | ||
| 435 | pipe_send 'ssh-add -l; echo agpre=$?\n' | ||
| 436 | await_out "$OUT.uag" "agpre=0" "agent-upgrade: ssh-add never answered before the upgrade" | ||
| 437 | # Counted by OCCURRENCE, not by matching line: this capture is an escape | ||
| 438 | # stream whose one "line" is the whole session, so `grep -c` would answer 1 | ||
| 439 | # for both answers and the comparison after the exec would be vacuous. | ||
| 440 | UPFP1=$(grep -aoF -- "$UPFP" "$OUT.uag" | wc -l) | ||
| 441 | [ "$UPFP1" -ge 1 ] || { | ||
| 442 | echo "e2e FAIL: agent-upgrade: the client's key never came back before the upgrade" | ||
| 443 | echo " (wanted $UPFP)"; exit 1; } | ||
| 444 | |||
| 445 | set +e | ||
| 446 | "$MUXD" upgrade --sock "$SOCK71" --allow-same-version > "$OUT.uagup" 2>&1 | ||
| 447 | UPRC=$? | ||
| 448 | set -e | ||
| 449 | [ "$UPRC" -eq 0 ] || { | ||
| 450 | echo "e2e FAIL: agent-upgrade: upgrade exited $UPRC:" | ||
| 451 | cat "$OUT.uagup" "$OUT.uag.d"; exit 1; } | ||
| 452 | _i=0 | ||
| 453 | while : ; do | ||
| 454 | _upst=$("$MUXD" stats --sock "$SOCK71" 2>/dev/null || true) | ||
| 455 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 456 | case "$_upst" in | ||
| 457 | *"session 0 clients=1"*) | ||
| 458 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 459 | esac | ||
| 460 | _i=$((_i + 1)) | ||
| 461 | [ "$_i" -lt $(( 200 * TIME_SCALE )) ] || { | ||
| 462 | echo "e2e FAIL: agent-upgrade: the -A client never re-attached; stats holds: $_upst" | ||
| 463 | cat "$OUT.uag.d"; exit 1; } | ||
| 464 | sleep 0.1 | ||
| 465 | done | ||
| 466 | |||
| 467 | # The same question a second time, and `agdone=` is a needle the first | ||
| 468 | # answer cannot satisfy. | ||
| 469 | pipe_send 'ssh-add -l; echo agdone=$?\n' | ||
| 470 | await_out "$OUT.uag" "agdone=0" "agent-upgrade: ssh-add stopped answering across the exec" | ||
| 471 | UPFP2=$(grep -aoF -- "$UPFP" "$OUT.uag" | wc -l) | ||
| 472 | [ "$UPFP2" -gt "$UPFP1" ] || { | ||
| 473 | echo "e2e FAIL: agent-upgrade: ssh-add exited 0 but the key came back $UPFP1 time(s)" | ||
| 474 | echo " before the exec and $UPFP2 after — no NEW answer crossed"; exit 1; } | ||
| 475 | pipe_detach "agent-upgrade client" | ||
| 476 | |||
| 477 | assert_stopped "$SOCK71" "$D71PID" "agent-upgrade" "$OUT.uagstop" | ||
| 478 | D71PID="" | ||
| 479 | softkill "$UPAGPID" || true | ||
| 480 | wait_pid_gone "$UPAGPID" "agent-upgrade: ssh-agent killed by tracked pid" | ||
| 481 | UPAGPID="" | ||
| 482 | ok "the session's agent socket crosses the exec: ssh-add -l answers again" | ||
| 483 | |||
| 484 | # --- ...and a QUIC client is serving again in a breath, not after a timeout | ||
| 485 | # | ||
| 486 | # Unix clients need no goodbye — their fds are close-on-exec and the exec is | ||
| 487 | # their EOF. A QUIC peer has no such thing: silence is indistinguishable from | ||
| 488 | # a bad network, and a client that waited it out would sit on a blank wall | ||
| 489 | # for the whole idle timeout. So the daemon sends CONNECTION_CLOSE to every | ||
| 490 | # connection before it execs, and what that buys is the number asserted | ||
| 491 | # here: back in service in a fraction of `--quic-idle-ms`, not after it. | ||
| 492 | head -c 32 /dev/urandom > "$UPKEY" | ||
| 493 | chmod 600 "$UPKEY" | ||
| 494 | start_daemon "$SOCK72" "$OUT.uqc.d" "quic-upgrade daemon never bound" --shell /bin/sh \ | ||
| 495 | --quic "127.0.0.1:$UPQPORT" --key "$UPKEY" --quic-idle-ms 15000 | ||
| 496 | D72PID=$DPID | ||
| 497 | |||
| 498 | pipe_mux "$OUT.uqc" "$OUT.uqc.err" timeout 90 "$MUX" "quic://127.0.0.1:$UPQPORT" \ | ||
| 499 | --key "$UPKEY" --quic-idle-ms 15000 | ||
| 500 | pipe_send 'printf "uq-%%s\\n" pre\n' | ||
| 501 | await_out "$OUT.uqc" "uq-pre" "quic-upgrade: the QUIC client never got its pre-upgrade marker" | ||
| 502 | |||
| 503 | UPATT1=$("$MUXD" stats --sock "$SOCK72" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 504 | [ -n "$UPATT1" ] || { echo "e2e FAIL: quic-upgrade: no attaches counter to compare against"; exit 1; } | ||
| 505 | UPT0=$(date +%s%N) | ||
| 506 | set +e | ||
| 507 | "$MUXD" upgrade --sock "$SOCK72" --allow-same-version > "$OUT.uqup" 2>&1 | ||
| 508 | UPRC=$? | ||
| 509 | set -e | ||
| 510 | [ "$UPRC" -eq 0 ] || { | ||
| 511 | echo "e2e FAIL: quic-upgrade: upgrade exited $UPRC:" | ||
| 512 | cat "$OUT.uqup" "$OUT.uqc.d"; exit 1; } | ||
| 513 | _i=0 | ||
| 514 | while : ; do | ||
| 515 | _upst=$("$MUXD" stats --sock "$SOCK72" 2>/dev/null || true) | ||
| 516 | _upatt=$(printf '%s' "$_upst" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') | ||
| 517 | case "$_upst" in | ||
| 518 | *"session 0 clients=1"*) | ||
| 519 | if [ -n "$_upatt" ] && [ "$_upatt" -gt "$UPATT1" ]; then break; fi ;; | ||
| 520 | esac | ||
| 521 | _i=$((_i + 1)) | ||
| 522 | [ "$_i" -lt $(( 400 * TIME_SCALE )) ] || { | ||
| 523 | echo "e2e FAIL: quic-upgrade: the QUIC client never re-attached; stats holds: $_upst" | ||
| 524 | cat "$OUT.uqc.d"; exit 1; } | ||
| 525 | sleep 0.05 | ||
| 526 | done | ||
| 527 | UPT1=$(date +%s%N) | ||
| 528 | UPMS=$(( (UPT1 - UPT0) / 1000000 )) | ||
| 529 | # Half the idle timeout, which is the only bound that means anything here: | ||
| 530 | # a client that had waited the connection out would arrive at 15000ms and | ||
| 531 | # still pass a generous absolute budget. Measured at ~60ms. | ||
| 532 | [ "$UPMS" -lt $(( 7500 * TIME_SCALE )) ] || { | ||
| 533 | echo "e2e FAIL: quic-upgrade: the QUIC client took ${UPMS}ms to come back —" | ||
| 534 | echo " that is the idle timeout expiring, not a CONNECTION_CLOSE" | ||
| 535 | exit 1; } | ||
| 536 | |||
| 537 | # ...and it is a session, not just a connection: a round trip that did not | ||
| 538 | # exist before the exec. | ||
| 539 | pipe_send 'printf "uq-%%s\\n" post\n' | ||
| 540 | await_out "$OUT.uqc" "uq-post" "quic-upgrade: the reconnected QUIC client cannot reach its shell" | ||
| 541 | pipe_detach "quic-upgrade client" | ||
| 542 | |||
| 543 | assert_stopped "$SOCK72" "$D72PID" "quic-upgrade" "$OUT.uqstop" | ||
| 544 | D72PID="" | ||
| 545 | ok "a QUIC client is served again within a breath of the exec, not after the idle timeout" | ||
| 546 | |||
| 547 | # --- muxd stop returns when the PROCESS is gone, not when the path is | ||
| 548 | # | ||
| 549 | # The unlink is the first thing a stopping daemon does; reaping its shells | ||
| 550 | # and deleting its dirs come after. A stop that said "stopped" at the | ||
| 551 | # unlink handed a scripted `muxd start`, or a supervisor's "is it down", | ||
| 552 | # a daemon still running — `wait_pid_gone` after every stop in this suite | ||
| 553 | # was that gap, papered. Shells that ignore TERM make the window a real | ||
| 554 | # grace rather than a race the assertion could win by luck, and two | ||
| 555 | # sessions make it a table's grace, not one shell's. | ||
| 556 | printf '#!/bin/sh\ntrap "" HUP TERM\nwhile :; do sleep 1; done\n' > "$OUT.sg.sh" | ||
| 557 | chmod +x "$OUT.sg.sh" | ||
| 558 | start_daemon "$SOCK73" "$OUT.sg.d" "stop-gone daemon never bound" --shell "$OUT.sg.sh" | ||
| 559 | D73PID=$DPID | ||
| 560 | pipe_mux "$OUT.sg" "" timeout 60 "$MUX" --sock "$SOCK73" --session second | ||
| 561 | sleep 0.5 | ||
| 562 | pipe_detach "stop-gone: the second session's client" | ||
| 563 | [ "$("$MUXD" stats --sock "$SOCK73" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "2" ] || { | ||
| 564 | echo "e2e FAIL: stop-gone: wanted two sessions of stubborn shells"; exit 1; } | ||
| 565 | D73REAL=$(real_pid "$D73PID") | ||
| 566 | "$MUXD" stop --sock "$SOCK73" 2> "$OUT.sg.stop" || { | ||
| 567 | echo "e2e FAIL: stop-gone: stop failed"; cat "$OUT.sg.stop"; exit 1; } | ||
| 568 | # Asked of the OS the instant stop returns — no wait, no retry. | ||
| 569 | if kill -0 "$D73REAL" 2>/dev/null; then | ||
| 570 | echo "e2e FAIL: stop said stopped, but pid $D73PID is still running"; cat "$OUT.sg.stop"; exit 1 | ||
| 571 | fi | ||
| 572 | grep -q '^muxd: stopped' "$OUT.sg.stop" || { | ||
| 573 | echo "e2e FAIL: stop-gone: stop did not report stopped"; cat "$OUT.sg.stop"; exit 1; } | ||
| 574 | D73PID="" | ||
| 575 | ok "muxd stop returns when the process is gone, not when the socket is" | ||
| 576 | |||
| 577 | # The pins. Literals, not variables set from counting something else — | ||
| 578 | # "assert the literal, never the constant the code under test reads" | ||
| 579 | # (decisions.md, M10). The two figures live at the END of this file and | ||
| 580 | # nowhere else: a count repeated in prose is a count nothing gates, and | ||
| 581 | # both of the ones that used to stand here had drifted away from the pins | ||
| 582 | # they described. Adding a scenario means editing those two literals by | ||
| 583 | # hand, on purpose. | ||
| 584 | # | ||
| 585 | # Why most legs carry no convergence point. `assert_converged` replays one | ||
| 586 | # client capture into an engine and diffs it against one daemon grid, so it | ||
| 587 | # can only speak where BOTH exist and the grid is the subject. It says | ||
| 588 | # nothing about a side channel (OSC 52, a window title, pty modes, a | ||
| 589 | # fingerprint ssh printed, bytes counted rather than read), about a file an | ||
| 590 | # editor or the wall wrote, about an exit code or a status field, about | ||
| 591 | # WHICH of two sessions a keystroke reached, about scrollback the live grid | ||
| 592 | # does not hold, about the absence of bytes two agreeing empties would | ||
| 593 | # agree on anyway, or about a leg that never attaches at all. It also does | ||
| 594 | # not claim to reproduce a full-screen application's redraws byte for byte, | ||
| 595 | # which is why the alternate-screen legs stay out. A leg that asserts on | ||
| 596 | # one of those and adds a convergence point would be pinning a fact every | ||
| 597 | # leg above already establishes. | ||