078e9712
test: 02_predict moves to a file of its own
a73x 2026-08-26 18:48
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' | 105 | E2E_GROUPS='01_boot 02_predict' |
| 106 | E2E_NEEDS="" | 106 | E2E_NEEDS="" |
| 107 | 107 | ||
| 108 | # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in | 108 | # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in |
| @@ -140,806 +140,6 @@ for _g in $E2E_GROUPS; do | |||
| 140 | . "$E2E_DIR/e2e_$_g.sh" | 140 | . "$E2E_DIR/e2e_$_g.sh" |
| 141 | done | 141 | done |
| 142 | 142 | ||
| 143 | # M9 prediction: each scenario needs a session whose LINE DISCIPLINE it | ||
| 144 | # controls, so they cannot share the long-lived /bin/sh daemon. | ||
| 145 | SOCK5="${TMPDIR:-/tmp}/muxd-e2e-pred-$$.sock" | ||
| 146 | defer_sock "$SOCK5" | ||
| 147 | SOCK6="${TMPDIR:-/tmp}/muxd-e2e-pw-$$.sock" | ||
| 148 | defer_sock "$SOCK6" | ||
| 149 | SOCK7="${TMPDIR:-/tmp}/muxd-e2e-raw-$$.sock" | ||
| 150 | defer_sock "$SOCK7" | ||
| 151 | PWSH="${TMPDIR:-/tmp}/mux-e2e-pw-$$.sh" | ||
| 152 | defer_rm "$PWSH" | ||
| 153 | # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2 | ||
| 154 | # resizes the grid twice and tp1's session is a scrollback-generating | ||
| 155 | # wrapper, so neither can share the long-lived /bin/sh daemon. | ||
| 156 | SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock" | ||
| 157 | defer_sock "$SOCK12" | ||
| 158 | SOCK13="${TMPDIR:-/tmp}/muxd-e2e-tp1-$$.sock" | ||
| 159 | defer_sock "$SOCK13" | ||
| 160 | # tp1's session: a wrapper that fills the scrollback and then becomes cat. | ||
| 161 | TP1SH="${TMPDIR:-/tmp}/mux-e2e-tp1-$$.sh" | ||
| 162 | defer_rm "$TP1SH" | ||
| 163 | |||
| 164 | # ---- M9: prediction --------------------------------------------------- | ||
| 165 | # | ||
| 166 | # Every scenario here asserts COUNTERS, because the screen cannot tell a | ||
| 167 | # predicted glyph from an echoed one — that is the entire point of the | ||
| 168 | # feature — so a rendering test would pass just as happily with prediction | ||
| 169 | # switched off. | ||
| 170 | # | ||
| 171 | # The session shell is /bin/cat, not /bin/sh. An interactive bash prompt is | ||
| 172 | # icanon=0/echo=0 (readline echoes for itself), which is the ADAPTIVE tier, | ||
| 173 | # not the always-predict one; cat is a genuinely canonical reader and the | ||
| 174 | # only way to exercise `.always` here. That distinction cost a milestone's | ||
| 175 | # worth of confusion to learn and is why it is written down twice. | ||
| 176 | |||
| 177 | # Delay per direction. The round trip is twice this, and every assertion | ||
| 178 | # below about "before the daemon could have answered" is measured against | ||
| 179 | # it. Chosen against three constraints at once, which is why it is not a | ||
| 180 | # round number picked for looks: | ||
| 181 | # | ||
| 182 | # - Larger than delaypipe's built-in default of 150, and far enough from | ||
| 183 | # it to be told apart. The snapshot below is taken 450ms after the | ||
| 184 | # keystroke: the echo cannot have returned over this 600ms round trip, | ||
| 185 | # but WOULD have over the 300ms one the default gives. A DELAY_MS lookup | ||
| 186 | # that silently stopped working — leaving every scenario here measuring | ||
| 187 | # a path twice as fast as it claimed — fails that assertion rather than | ||
| 188 | # passing quietly. | ||
| 189 | # - Small enough that the round trip stays clear of the overlay's 1000ms | ||
| 190 | # expiry bound. This is a real ceiling, not a test artefact: delaypipe | ||
| 191 | # delays each chunk serially, so a burst's later keystrokes queue behind | ||
| 192 | # the earlier ones and age while they wait. At 400ms each way the burst | ||
| 193 | # scenario went to confirmed=1 — the predictions were expiring before | ||
| 194 | # their echo could arrive, exactly as the overlay is designed to do. | ||
| 195 | # - Leaving margin on both sides, so neither is a race under load. | ||
| 196 | PDELAY=300 | ||
| 197 | |||
| 198 | # Own file, not the client capture: the daemon's fd survives the client's truncation and would write into the replayed stream. | ||
| 199 | start_daemon "$SOCK5" "$OUT.p1.d" "prediction daemon never bound" --shell /bin/cat | ||
| 200 | D5PID=$DPID | ||
| 201 | |||
| 202 | # 1. Line mode: the glyph is on screen before the round trip could have | ||
| 203 | # delivered it. | ||
| 204 | set +e | ||
| 205 | { sleep 2; printf 'z'; sleep 4; printf '\034\034'; } | \ | ||
| 206 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 207 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 208 | > "$OUT.p1" 2> "$OUT.p1.err" & | ||
| 209 | P1PID=$! | ||
| 210 | defer_kill "$P1PID" | ||
| 211 | set -e | ||
| 212 | # Snapshot the client output 450ms after the keystroke: well | ||
| 213 | # before this 600ms round trip could bring the pty own echo back, and well | ||
| 214 | # AFTER the 300ms round trip delaypipe default of 150 would have given. | ||
| 215 | sleep 2.45 | ||
| 216 | cp "$OUT.p1" "$OUT.p1.early" 2>/dev/null || true | ||
| 217 | set +e | ||
| 218 | wait "$P1PID" | ||
| 219 | RC=$? | ||
| 220 | set -e | ||
| 221 | [ "$RC" -eq 0 ] || { | ||
| 222 | echo "e2e FAIL: line-mode prediction client exited $RC" | ||
| 223 | cat "$OUT.p1" "$OUT.p1.err" 2>/dev/null; exit 1; } | ||
| 224 | |||
| 225 | # The underlined glyph, in the snapshot taken before the echo could arrive. | ||
| 226 | # This is the whole claim of the milestone, as an effect rather than a | ||
| 227 | # counter: the character was on the screen while it was still in flight. | ||
| 228 | grep -q "$(printf '\033\[4mz')" "$OUT.p1.early" || { | ||
| 229 | echo "e2e FAIL: no predicted glyph 450ms after the keystroke (RTT is $((PDELAY * 2))ms)" | ||
| 230 | echo "--- early snapshot ---"; cat -v "$OUT.p1.early"; exit 1; | ||
| 231 | } | ||
| 232 | # ...and the daemon's own answer was NOT there yet, which is what makes the | ||
| 233 | # line above mean anything. A delta paints a row with ECH before its | ||
| 234 | # content; the prediction never does. | ||
| 235 | if grep -q "$(printf '\033\[80X\033\[0mz')" "$OUT.p1.early"; then | ||
| 236 | echo "e2e FAIL: the echo arrived within 450ms, so the path is not $((PDELAY * 2))ms;" | ||
| 237 | echo " either DELAY_MS was ignored (default is 150) or delaypipe is not delaying" | ||
| 238 | exit 1 | ||
| 239 | fi | ||
| 240 | want_stat "$OUT.p1.err" contradicted 0 "line mode" | ||
| 241 | CONF=$(predict_stat "$OUT.p1.err" confirmed) | ||
| 242 | [ -n "$CONF" ] && [ "$CONF" -ge 1 ] || { | ||
| 243 | echo "e2e FAIL: line mode: confirmed=$CONF, want >=1"; grep "^predict " "$OUT.p1.err"; exit 1; | ||
| 244 | } | ||
| 245 | # The session really received the keystroke: cat echoed it into the grid. | ||
| 246 | "$MUXD" dump --sock "$SOCK5" | grep -q "z" || { | ||
| 247 | echo "e2e FAIL: line mode: daemon grid never saw the keystroke"; exit 1; | ||
| 248 | } | ||
| 249 | assert_converged "$OUT.p1" "$SOCK5" "line-mode prediction" | ||
| 250 | |||
| 251 | # 2. A burst outrunning the round trip. This is reconcile v2's reason to | ||
| 252 | # exist: under the old rule every character after the first was judged by | ||
| 253 | # a frame built before it was typed, read as a contradiction, and the | ||
| 254 | # whole queue was flushed once per round trip. If anyone regresses the | ||
| 255 | # three-way judgment, this scenario is what says so. | ||
| 256 | set +e | ||
| 257 | { sleep 2; for c in b u r s t; do printf '%s' "$c"; sleep 0.25; done; \ | ||
| 258 | sleep 4; printf '\034\034'; } | \ | ||
| 259 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 260 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 261 | > "$OUT.pb" 2> "$OUT.pb.err" | ||
| 262 | RC=$? | ||
| 263 | set -e | ||
| 264 | [ "$RC" -eq 0 ] || { | ||
| 265 | echo "e2e FAIL: burst client exited $RC" | ||
| 266 | cat "$OUT.pb" "$OUT.pb.err" 2>/dev/null; exit 1; } | ||
| 267 | |||
| 268 | # Five keystrokes typed at 250ms into a 600ms round trip: three are always | ||
| 269 | # outstanding at once (at t=600, when the first is confirmed, the keys typed | ||
| 270 | # at 0, 250 and 500 are all in flight). `made` is asserted too, so two | ||
| 271 | # keystrokes arriving in one read (which would be suppressed as a multi-byte | ||
| 272 | # chunk, not predicted) fails loudly instead of quietly weakening the test. | ||
| 273 | # | ||
| 274 | # The interval is 250 and not 200 because of a bound that has to be COMPUTED | ||
| 275 | # rather than eyeballed. delaypipe delivers one chunk per DELAY_MS serially | ||
| 276 | # in EACH direction, so keystrokes closer together than DELAY_MS queue, and | ||
| 277 | # each one ages (DELAY_MS - interval) longer than the one before it: | ||
| 278 | # | ||
| 279 | # age at which keystroke k is confirmed = 2*DELAY_MS + k*(DELAY_MS - interval) | ||
| 280 | # | ||
| 281 | # At interval=200 the fifth keystroke lands at 600 + 4*100 = 1000ms, which is | ||
| 282 | # exactly predict.expire_after_ms: a coin flip, not a margin. It failed 4 runs | ||
| 283 | # in 20 on an idle machine. At 250 it is 600 + 4*50 = 800ms, measured at | ||
| 284 | # 798-799 across 12 runs. The same formula puts the SECOND keystroke at | ||
| 285 | # 1000ms when DELAY_MS=400, which is the confirmed=1 collapse noted above. | ||
| 286 | want_stat "$OUT.pb.err" made 5 "burst" | ||
| 287 | # `confirmed` is a LIVENESS counter and the only timing-dependent one here: | ||
| 288 | # the fifth confirmation can still be in flight when the detach byte lands, | ||
| 289 | # which is why it is a floor rather than an equality. Observed 1 run in 3 | ||
| 290 | # under load (review runs at 715f71b, 2026-08-09), and decisions.md records | ||
| 291 | # the same counter coming up one short on the M9 WAN burst (made=55 | ||
| 292 | # confirmed=54 abandoned=1, the mode-transition churn flush). | ||
| 293 | # | ||
| 294 | # `contradicted` and `expired` stay EXACT. They are the correctness claims — | ||
| 295 | # a prediction judged wrong, or one that aged out unjudged, is a defect | ||
| 296 | # whenever it happens, and nothing about timing makes either acceptable. | ||
| 297 | want_stat_ge "$OUT.pb.err" confirmed 4 "burst" | ||
| 298 | # `expired` first: an expiry runs through the same abandonAll tail as a | ||
| 299 | # refutation and increments BOTH counters, so contradicted >= expired always. | ||
| 300 | # Asserting contradicted first reports an age-out as "a prediction judged | ||
| 301 | # wrong" — a different defect with a different cause, and the reason this | ||
| 302 | # flake was first investigated as a reconcile bug. | ||
| 303 | want_stat "$OUT.pb.err" expired 0 "burst" | ||
| 304 | want_stat "$OUT.pb.err" contradicted 0 "burst" | ||
| 305 | "$MUXD" dump --sock "$SOCK5" | grep -q "burst" || { | ||
| 306 | echo "e2e FAIL: burst: daemon grid does not hold the typed text"; exit 1; | ||
| 307 | } | ||
| 308 | assert_converged "$OUT.pb" "$SOCK5" "burst" | ||
| 309 | |||
| 310 | softkill "$D5PID" || true | ||
| 311 | D5PID="" | ||
| 312 | |||
| 313 | # 3. A password prompt. The session is canonical with echo OFF from the | ||
| 314 | # moment it starts, so the whole run sits in the tier that predicts | ||
| 315 | # nothing at all. | ||
| 316 | cat > "$PWSH" <<'PWEOF' | ||
| 317 | #!/bin/sh | ||
| 318 | stty -echo | ||
| 319 | printf 'pw-ready\n' | ||
| 320 | read secret | ||
| 321 | printf 'pw-len-%s\n' "${#secret}" | ||
| 322 | # Blocks forever rather than exiting: the shell exiting would end the | ||
| 323 | # session, and the daemon would be gone before its grid could be read. | ||
| 324 | read _hold | ||
| 325 | PWEOF | ||
| 326 | chmod +x "$PWSH" | ||
| 327 | |||
| 328 | start_daemon "$SOCK6" "$OUT.pw.d" "password daemon never bound" --shell "$PWSH" | ||
| 329 | D6PID=$DPID | ||
| 330 | |||
| 331 | # One character per write, and NOT as a single `printf hunter2`. A whole | ||
| 332 | # word in one write reaches the client as a multi-byte chunk, which is | ||
| 333 | # refused for being multi-byte before the tier is ever consulted — so the | ||
| 334 | # scenario would pass with the password tier completely broken. It did: | ||
| 335 | # mapping echo-off canonical to the always-predict tier survived this | ||
| 336 | # scenario until the typing was split up. | ||
| 337 | set +e | ||
| 338 | { sleep 2; for c in h u n t e r 2; do printf '%s' "$c"; sleep 0.2; done; \ | ||
| 339 | printf '\n'; sleep 2; printf '\034\034'; } | \ | ||
| 340 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK6" > "$OUT.pw" 2> "$OUT.pw.err" | ||
| 341 | RC=$? | ||
| 342 | set -e | ||
| 343 | [ "$RC" -eq 0 ] || { | ||
| 344 | echo "e2e FAIL: password client exited $RC" | ||
| 345 | cat "$OUT.pw" "$OUT.pw.err" 2>/dev/null; exit 1; } | ||
| 346 | |||
| 347 | # Nothing was shown, and nothing was even attempted: echo-off canonical is | ||
| 348 | # the tier where a prediction must never be MADE, not merely never painted, | ||
| 349 | # because a made prediction sits in a buffer the overlay paints from. | ||
| 350 | want_stat "$OUT.pw.err" made 0 "password" | ||
| 351 | want_stat "$OUT.pw.err" displayed 0 "password" | ||
| 352 | # The effect, not the counter: the secret appears nowhere in the bytes the | ||
| 353 | # client wrote — either stream. The stats line cannot carry a secret, but | ||
| 354 | # checking both costs one word and the claim is about everything the client | ||
| 355 | # emitted, not about the half of it that happens to be the screen. | ||
| 356 | if grep -q "hunter2" "$OUT.pw" "$OUT.pw.err"; then | ||
| 357 | echo "e2e FAIL: the password appears in the client's terminal output"; exit 1 | ||
| 358 | fi | ||
| 359 | # ...and the run was not vacuous: the shell really received all seven | ||
| 360 | # characters, so the absence above is prediction declining rather than | ||
| 361 | # nothing having been typed. | ||
| 362 | "$MUXD" dump --sock "$SOCK6" | grep -q "pw-len-7" || { | ||
| 363 | echo "e2e FAIL: the password never reached the shell; the absence above proves nothing" | ||
| 364 | "$MUXD" dump --sock "$SOCK6"; exit 1; | ||
| 365 | } | ||
| 366 | assert_converged "$OUT.pw" "$SOCK6" "password" | ||
| 367 | |||
| 368 | softkill "$D6PID" || true | ||
| 369 | D6PID="" | ||
| 370 | |||
| 371 | # 4. Raw mode: display is earned, then lost to a keystroke the application | ||
| 372 | # swallows. rawmode echoes like an editor in insert mode until it is sent | ||
| 373 | # 0x00, after which it consumes input and prints nothing. | ||
| 374 | # | ||
| 375 | # Through delaypipe, like leg 1, and for the same reason leg 4b below is | ||
| 376 | # NOT: the overlay measures its own round trip and hides everything on a | ||
| 377 | # path faster than `predict.local_below_ms`. This leg is about the | ||
| 378 | # earn/lose policy, which only a remote path lets through to the screen. | ||
| 379 | # Keys are 1s apart so each confirm (600ms) lands before the next key — | ||
| 380 | # at 0.5s `c` would find only `a` confirmed and stay hidden. | ||
| 381 | start_daemon "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound" --shell "$RAWMODE" | ||
| 382 | D7PID=$DPID | ||
| 383 | |||
| 384 | set +e | ||
| 385 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \ | ||
| 386 | printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | ||
| 387 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 388 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK7 | $DELAYPIPE" \ | ||
| 389 | > "$OUT.rw" 2> "$OUT.rw.err" | ||
| 390 | RC=$? | ||
| 391 | set -e | ||
| 392 | [ "$RC" -eq 0 ] || { | ||
| 393 | echo "e2e FAIL: rawmode client exited $RC" | ||
| 394 | cat "$OUT.rw" "$OUT.rw.err" 2>/dev/null; exit 1; } | ||
| 395 | |||
| 396 | # Four printable keystrokes made predictions; the 0x00 that switched modes | ||
| 397 | # is not printable and was refused. | ||
| 398 | want_stat "$OUT.rw.err" made 4 "raw mode" | ||
| 399 | want_stat "$OUT.rw.err" suppressed 1 "raw mode" | ||
| 400 | # a and b are invisible while promotion is earned; c is the first painted, | ||
| 401 | # and j is painted because by then display had been earned. | ||
| 402 | want_stat "$OUT.rw.err" displayed 2 "raw mode" | ||
| 403 | want_stat "$OUT.rw.err" confirmed 3 "raw mode" | ||
| 404 | # j was swallowed: no frame ever answered the cell it was drawn in, so the | ||
| 405 | # expiry bound retired it rather than leaving a phantom glyph on screen for | ||
| 406 | # the rest of the session. | ||
| 407 | want_stat "$OUT.rw.err" expired 1 "raw mode" | ||
| 408 | want_stat "$OUT.rw.err" contradicted 1 "raw mode" | ||
| 409 | want_stat "$OUT.rw.err" abandoned 1 "raw mode" | ||
| 410 | |||
| 411 | # Every prediction accounted for: made = confirmed + abandoned + pending, | ||
| 412 | # and the queue is empty at exit, so nothing survived unexplained. | ||
| 413 | RW_MADE=$(predict_stat "$OUT.rw.err" made) | ||
| 414 | RW_CONF=$(predict_stat "$OUT.rw.err" confirmed) | ||
| 415 | RW_ABND=$(predict_stat "$OUT.rw.err" abandoned) | ||
| 416 | [ "$((RW_MADE - RW_CONF - RW_ABND))" -eq 0 ] || { | ||
| 417 | echo "e2e FAIL: raw mode: $((RW_MADE - RW_CONF - RW_ABND)) predictions unaccounted for" | ||
| 418 | grep "^predict " "$OUT.rw.err"; exit 1; | ||
| 419 | } | ||
| 420 | # The counters say `j` expired; this says the screen agrees. An expiry that | ||
| 421 | # retired the prediction in the book but left its underlined glyph painted is | ||
| 422 | # exactly the phantom the styled half of this check exists to catch. | ||
| 423 | assert_converged "$OUT.rw" "$SOCK7" "raw mode" | ||
| 424 | |||
| 425 | # 4b. The same keystrokes on the bare socket: a local daemon answers in | ||
| 426 | # about a millisecond, and that is the one path on which a prediction | ||
| 427 | # can only ever be SEEN when it is wrong. Display is still earned (the | ||
| 428 | # counters say so) and nothing is painted — `local` is where `c` and | ||
| 429 | # `j` went instead of `displayed`. `j` still expires: the queue keeps | ||
| 430 | # judging while hidden, which is how the gate would lift again on a | ||
| 431 | # box that got slow. The screen check is the point: no phantom `j`, | ||
| 432 | # because there was never an underlined one to leave behind. | ||
| 433 | # | ||
| 434 | # A fresh daemon: the rawmode above has taken its 0x00 and echoes | ||
| 435 | # nothing now, and the leg needs the insert-mode half back. | ||
| 436 | softkill "$D7PID" || true | ||
| 437 | wait "$D7PID" 2>/dev/null || true | ||
| 438 | rm -f "$SOCK7" | ||
| 439 | start_daemon "$SOCK7" "$OUT.rwl.d" "rawmode local daemon never bound" --shell "$RAWMODE" | ||
| 440 | D7PID=$DPID | ||
| 441 | |||
| 442 | set +e | ||
| 443 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \ | ||
| 444 | printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | ||
| 445 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rwl" 2> "$OUT.rwl.err" | ||
| 446 | RC=$? | ||
| 447 | set -e | ||
| 448 | [ "$RC" -eq 0 ] || { | ||
| 449 | echo "e2e FAIL: rawmode local client exited $RC" | ||
| 450 | cat "$OUT.rwl" "$OUT.rwl.err" 2>/dev/null; exit 1; } | ||
| 451 | want_stat "$OUT.rwl.err" made 4 "raw mode local" | ||
| 452 | want_stat "$OUT.rwl.err" confirmed 3 "raw mode local" | ||
| 453 | want_stat "$OUT.rwl.err" displayed 0 "raw mode local" | ||
| 454 | want_stat "$OUT.rwl.err" local 2 "raw mode local" | ||
| 455 | want_stat "$OUT.rwl.err" expired 1 "raw mode local" | ||
| 456 | grep -q "$(printf '\033\[4m')" "$OUT.rwl" && { | ||
| 457 | echo "e2e FAIL: raw mode local: an underlined prediction reached the screen" | ||
| 458 | cat -v "$OUT.rwl"; exit 1; | ||
| 459 | } | ||
| 460 | assert_converged "$OUT.rwl" "$SOCK7" "raw mode local" | ||
| 461 | |||
| 462 | softkill "$D7PID" || true | ||
| 463 | D7PID="" | ||
| 464 | |||
| 465 | # 5. A transport torn down with predictions outstanding. The overlay must | ||
| 466 | # come back empty: what was queued was predicted against a connection | ||
| 467 | # that no longer exists. | ||
| 468 | start_daemon "$SOCK5" "$OUT.pr.d" "reconnect daemon never bound" --shell /bin/cat | ||
| 469 | D5PID=$DPID | ||
| 470 | |||
| 471 | set +e | ||
| 472 | { sleep 2; printf 'p'; sleep 5; printf 'q'; sleep 4; printf '\034\034'; } | \ | ||
| 473 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 60 "$MUX" \ | ||
| 474 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 475 | > "$OUT.pr" 2> "$OUT.pr.err" & | ||
| 476 | PRPID=$! | ||
| 477 | defer_kill "$PRPID" | ||
| 478 | set -e | ||
| 479 | # Kill the transport a tenth of a second after the keystroke, while its | ||
| 480 | # prediction is still outstanding — the round trip is 600ms, so it cannot | ||
| 481 | # have been judged yet. By comm+pid: the client's own argv contains the | ||
| 482 | # --via string, so a pattern kill would take out the client under test. | ||
| 483 | sleep 2.1 | ||
| 484 | PP=$(proxy_pid "$SOCK5") | ||
| 485 | [ -n "$PP" ] || { | ||
| 486 | echo "e2e FAIL: no proxy to tear down" | ||
| 487 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 488 | kill -9 "$PP" | ||
| 489 | |||
| 490 | set +e | ||
| 491 | wait "$PRPID" | ||
| 492 | RC=$? | ||
| 493 | set -e | ||
| 494 | [ "$RC" -eq 0 ] || { | ||
| 495 | echo "e2e FAIL: reconnect client exited $RC" | ||
| 496 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 497 | |||
| 498 | # The queue is empty at exit, by attribution rather than by inspection: | ||
| 499 | # nothing predicted is still outstanding, and the flush that dropped the | ||
| 500 | # in-flight prediction was not counted as anybody being wrong. | ||
| 501 | PR_MADE=$(predict_stat "$OUT.pr.err" made) | ||
| 502 | PR_CONF=$(predict_stat "$OUT.pr.err" confirmed) | ||
| 503 | PR_ABND=$(predict_stat "$OUT.pr.err" abandoned) | ||
| 504 | [ -n "$PR_MADE" ] || { | ||
| 505 | echo "e2e FAIL: reconnect: no predict stats" | ||
| 506 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 507 | [ "$((PR_MADE - PR_CONF - PR_ABND))" -eq 0 ] || { | ||
| 508 | echo "e2e FAIL: reconnect: $((PR_MADE - PR_CONF - PR_ABND)) predictions outlived the tear" | ||
| 509 | grep "^predict " "$OUT.pr.err"; exit 1; | ||
| 510 | } | ||
| 511 | # And it was the reconnect that dropped it, not the expiry bound catching it | ||
| 512 | # a second later. Without this the scenario cannot tell the two apart: the | ||
| 513 | # resync after a reconnect flushes as well, so the outcome looks identical | ||
| 514 | # either way and removing the reconnect's own flush passes unnoticed. It | ||
| 515 | # did, until this line. | ||
| 516 | want_stat "$OUT.pr.err" expired 0 "reconnect" | ||
| 517 | [ -n "$PR_ABND" ] && [ "$PR_ABND" -ge 1 ] || { | ||
| 518 | echo "e2e FAIL: reconnect: nothing was abandoned ($PR_ABND), so no prediction was" | ||
| 519 | echo " outstanding when the transport died and the tear proves nothing" | ||
| 520 | grep "^predict " "$OUT.pr.err"; exit 1; | ||
| 521 | } | ||
| 522 | # The session survived and the client resumed into it: the keystroke typed | ||
| 523 | # after the tear reached the shell. | ||
| 524 | "$MUXD" dump --sock "$SOCK5" | grep -q "q" || { | ||
| 525 | echo "e2e FAIL: reconnect: the post-tear keystroke never reached the session" | ||
| 526 | "$MUXD" dump --sock "$SOCK5"; exit 1; | ||
| 527 | } | ||
| 528 | assert_converged "$OUT.pr" "$SOCK5" "reconnect flush" | ||
| 529 | |||
| 530 | softkill "$D5PID" || true | ||
| 531 | D5PID="" | ||
| 532 | |||
| 533 | # ---- M12: ptyclient fixture controls ---------------------------------- | ||
| 534 | # Before any scenario trusts the fixture, prove both directions: a | ||
| 535 | # roundtrip over plain /bin/cat (the pty line discipline's own echo | ||
| 536 | # answers — no mux anywhere, so a failure here is the FIXTURE'S), and an | ||
| 537 | # expect that cannot match, which must time out, exit nonzero, and say | ||
| 538 | # what it did see. A check that cannot fail proves nothing. | ||
| 539 | PCLOG="$OUT.pc.log" | ||
| 540 | set +e | ||
| 541 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF' | ||
| 542 | send hello\n | ||
| 543 | expect hello 10000 | ||
| 544 | send \x04 | ||
| 545 | waitexit 10000 | ||
| 546 | EOF | ||
| 547 | RC=$? | ||
| 548 | set -e | ||
| 549 | [ "$RC" -eq 0 ] || { | ||
| 550 | echo "e2e FAIL: ptyclient roundtrip over cat exited $RC:"; cat "$PCLOG"; exit 1; } | ||
| 551 | grep -q "hello" "$OUT.pc" || { | ||
| 552 | echo "e2e FAIL: ptyclient capture missing the pty echo"; cat -v "$OUT.pc"; exit 1; } | ||
| 553 | # The must-fail leg. 500ms: nothing is being waited FOR — the needle never | ||
| 554 | # arrives by construction — so the deadline only bounds the control's cost. | ||
| 555 | set +e | ||
| 556 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF' | ||
| 557 | expect never-going-to-match 500 | ||
| 558 | EOF | ||
| 559 | RC=$? | ||
| 560 | set -e | ||
| 561 | # The literal, not merely nonzero: 3 is EXIT_TIMEOUT, and the tp1/tp2 | ||
| 562 | # scenarios read that code to tell a missed needle apart from a client that | ||
| 563 | # died or a script the fixture refused. A bare -ne 0 would pass just as | ||
| 564 | # happily on a usage error that never ran the script at all. | ||
| 565 | [ "$RC" -eq 3 ] || { | ||
| 566 | echo "e2e FAIL: ptyclient expect control exited $RC, want 3 (EXIT_TIMEOUT)"; cat "$PCLOG.2"; exit 1; } | ||
| 567 | grep -q "did not arrive" "$PCLOG.2" || { | ||
| 568 | echo "e2e FAIL: ptyclient timeout fired but never said what it saw"; cat "$PCLOG.2"; exit 1; } | ||
| 569 | # The stderr split, end to end: the client's predict stats must land in the | ||
| 570 | # .err sibling and NOT in the capture the convergence machinery diffs. Only | ||
| 571 | # a child that writes to BOTH streams can tell a working split from a child | ||
| 572 | # that simply never wrote to stderr. | ||
| 573 | set +e | ||
| 574 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc3" --err "$OUT.pc3.err" -- \ | ||
| 575 | /bin/sh -c 'printf pc-stdout; printf pc-stderr 1>&2' > "$PCLOG.3" 2>&1 <<'EOF' | ||
| 576 | expect pc-stdout 10000 | ||
| 577 | waitexit 10000 | ||
| 578 | EOF | ||
| 579 | RC=$? | ||
| 580 | set -e | ||
| 581 | [ "$RC" -eq 0 ] || { | ||
| 582 | echo "e2e FAIL: ptyclient stderr-split leg exited $RC:"; cat "$PCLOG.3"; exit 1; } | ||
| 583 | grep -q "pc-stdout" "$OUT.pc3" || { | ||
| 584 | echo "e2e FAIL: ptyclient capture missing the child's stdout"; cat -v "$OUT.pc3"; exit 1; } | ||
| 585 | ! grep -q "pc-stderr" "$OUT.pc3" || { | ||
| 586 | echo "e2e FAIL: the child's stderr leaked into the capture"; cat -v "$OUT.pc3"; exit 1; } | ||
| 587 | grep -q "pc-stderr" "$OUT.pc3.err" || { | ||
| 588 | echo "e2e FAIL: the child's stderr reached neither the capture nor --err" | ||
| 589 | cat -v "$OUT.pc3.err"; exit 1; } | ||
| 590 | rm_swept "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \ | ||
| 591 | "$PCLOG" "$PCLOG.2" "$PCLOG.3" | ||
| 592 | ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture" | ||
| 593 | |||
| 594 | # ---- M12 tp2: resize under a real tty (campaign row 7's branch) -------- | ||
| 595 | # Two clients, two convergence points, because convergence is a scenario's | ||
| 596 | # LAST act after detach. | ||
| 597 | # | ||
| 598 | # tp2a is the suite's first attach at a size that is not the non-tty 80x24 | ||
| 599 | # default: it pins the size a real tty reports reaching the daemon, and | ||
| 600 | # convergence measured against that size rather than the default. It does | ||
| 601 | # NOT exercise the snapshot resize prefix — an attach applies the | ||
| 602 | # attacher's size to the grid BEFORE the answering resync, so the prefix | ||
| 603 | # always equals the size the replica already has and the client's | ||
| 604 | # resize-on-prefix guard never fires. Verified by probe, not by reading. | ||
| 605 | # | ||
| 606 | # tp2b is the leg that catches row 7 (the prefix ignored), and it only | ||
| 607 | # catches it because of WHERE the damage can show. DELTA STREAMING | ||
| 608 | # bypasses the replica: those rows go straight to the tty, absolutely | ||
| 609 | # addressed and clipped to the tty, so a stale replica cannot spoil them | ||
| 610 | # and a wrong row is healed by the next delta that re-addresses it. The | ||
| 611 | # damage therefore has to survive to a FULL REPAINT, which is the paint | ||
| 612 | # that reads the replica back out and clips at min(replica, tty) — still | ||
| 613 | # on screen by then, and in a region the stale geometry cannot reach. | ||
| 614 | # Hence: fill the screen, put a 95-wide row on it, then winch UP to | ||
| 615 | # 100x30. With the prefix ignored the replica stays 90x28 and the repaint | ||
| 616 | # answering the resize reproduces the OLD geometry. | ||
| 617 | start_daemon "$SOCK12" "$OUT.tp2.d" "tp2 daemon never bound" --shell /bin/sh | ||
| 618 | D12PID=$DPID | ||
| 619 | |||
| 620 | # Two rules govern every script below, and breaking either one produces a | ||
| 621 | # capture that is honestly SHORT rather than wrong — a divergence whose | ||
| 622 | # diff blames the product for a scenario's mistake: | ||
| 623 | # | ||
| 624 | # A marker must not appear in the command line that produces it. The | ||
| 625 | # shell echoes what is typed, so `echo tp2-claim` paints the needle | ||
| 626 | # before it has run anything; the expect then matches the echo and the | ||
| 627 | # script detaches while the OUTPUT is still in flight. `printf 'tp2-%s' | ||
| 628 | # claim` keeps the literal off the typed line, so a hit is proof the | ||
| 629 | # shell ran it — the same argument-splitting trick the M5 scenario uses. | ||
| 630 | # | ||
| 631 | # The LAST thing expected must be the prompt, not the output. A shell | ||
| 632 | # writes its result and its next prompt as two separate writes, so a | ||
| 633 | # script that detaches on the result can stop one frame short of the | ||
| 634 | # grid the daemon holds. PS1 is set to a marker here purely so that | ||
| 635 | # prompt is assertable: it is the quiesce signal that would otherwise | ||
| 636 | # have to be a sleep. | ||
| 637 | set +e | ||
| 638 | timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2a" --err "$OUT.tp2a.err" -- \ | ||
| 639 | "$MUX" --sock "$SOCK12" > "$OUT.tp2a.log" 2>&1 <<'EOF' | ||
| 640 | expect \x1b[?1049h 10000 | ||
| 641 | send PS1=tp2rdy@\n | ||
| 642 | # Twice, and the fixture's cursor is what makes that meaningful: the first | ||
| 643 | # hit is the ECHO of the assignment line, the second is the prompt the | ||
| 644 | # shell painted from it. Only the second proves PS1 took effect. | ||
| 645 | # | ||
| 646 | # Counting sentinel occurrences is safe HERE and nowhere else in tp2: this | ||
| 647 | # grid is nearly empty, so nothing scrolls, no repaint splits across | ||
| 648 | # deltas, and the two hits keep the order they were painted in. tp2b runs | ||
| 649 | # on a full screen and cannot assume any of that — see the hazard note | ||
| 650 | # there before copying this shape. | ||
| 651 | expect tp2rdy@ 10000 | ||
| 652 | expect tp2rdy@ 10000 | ||
| 653 | send printf 'tp2-%s\\n' claim\n | ||
| 654 | expect tp2-claim 10000 | ||
| 655 | expect tp2rdy@ 10000 | ||
| 656 | settle 500 15000 | ||
| 657 | send \x1c\x1c | ||
| 658 | waitexit 10000 | ||
| 659 | EOF | ||
| 660 | RC=$? | ||
| 661 | set -e | ||
| 662 | [ "$RC" -eq 0 ] || { | ||
| 663 | echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; } | ||
| 664 | assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30 | ||
| 665 | |||
| 666 | # Resize UP, not down: a replica LARGER than the grid is invisible (the | ||
| 667 | # paint clips at min(replica, tty) either way), so only a replica left | ||
| 668 | # too SMALL has anywhere to show. `seq 1 60` fills the screen so growing | ||
| 669 | # to 30 rows has to pull history down rather than append blanks, and the | ||
| 670 | # 95-wide row is wider than the stale 90 and narrower than the new 100. | ||
| 671 | # | ||
| 672 | # PS1 is still tp2rdy@ from tp2a: same daemon, same shell, and the | ||
| 673 | # assignment outlived the client that typed it. Note where the prompt | ||
| 674 | # expects sit — the sentinel is re-emitted whenever the prompt ROW is | ||
| 675 | # repainted, including by the echo of a command typed at it, so each | ||
| 676 | # trailing prompt is consumed BEFORE the resize. That leaves the expect | ||
| 677 | # after `resize` satisfiable only by the answering snapshot's repaint. | ||
| 678 | # | ||
| 679 | # THE HAZARD, and it is worth stating precisely because three shapes of | ||
| 680 | # this scenario died to it: on a FULL screen the output scrolls, and the | ||
| 681 | # daemon can flush the new prompt row in one delta and the scrolled rows | ||
| 682 | # in a LATER one. Prompt-row sentinels therefore have NO reliable order | ||
| 683 | # relative to the output rows around them, and counting their occurrences | ||
| 684 | # fails in both directions. Too late: the sentinel's bytes arrive before | ||
| 685 | # the output needle that precedes it in the script, the needle's match | ||
| 686 | # consumes the cursor past them, and the next sentinel expect waits on a | ||
| 687 | # shell that has gone idle — a hang. Too early: a post-resize sentinel | ||
| 688 | # matches bytes from BEFORE the resize and the client detaches mid-repaint, | ||
| 689 | # leaving a capture in the old geometry. Measured under load, that second | ||
| 690 | # one is the dangerous one: it produces a diff byte-identical to row 7's | ||
| 691 | # — missing rows and a still-wrapped wide row — so a healthy build fails | ||
| 692 | # wearing the bug's own signature. | ||
| 693 | # | ||
| 694 | # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS | ||
| 695 | # (`fill-done`, `00007`) for progress: each is printed once, by a command | ||
| 696 | # whose typed line cannot contain it, so its arrival is a fact about the | ||
| 697 | # session rather than about paint order. Occurrence-counting of a | ||
| 698 | # REPAINTED row — which is what a prompt sentinel is — is not a | ||
| 699 | # substitute, and is what the three earlier shapes of this leg died to. | ||
| 700 | # | ||
| 701 | # `expect 00007` earns its place as more than a marker: its arrival proves | ||
| 702 | # the daemon processed the wide row while the grid was still 90 columns, | ||
| 703 | # which is what puts the damage on the screen the resize will repaint from. | ||
| 704 | # | ||
| 705 | # After the resize the leg asserts TWO different things, and neither one | ||
| 706 | # implies the other: | ||
| 707 | # the 91-zero WITNESS proves the reflowed paint ARRIVED. 91 contiguous | ||
| 708 | # zeros cannot exist at width 90 — the row is painted wrapped there — so | ||
| 709 | # this needle is structurally incapable of firing early, whatever order | ||
| 710 | # the rows came in. It is also not the load risk an extra deadline looks | ||
| 711 | # like: this needle went 150/150 under 16-way load, at the same load that | ||
| 712 | # broke the two prompt-sentinel shapes 9 and 15 failures in 150. | ||
| 713 | # `settle` then proves nothing further is IN FLIGHT. The witness alone | ||
| 714 | # would permit a detach mid-repaint, with the rest of the screen still | ||
| 715 | # on its way. | ||
| 716 | # Dropping the witness and keeping only settle looks equivalent and is | ||
| 717 | # not: settle succeeds on 500ms of silence measured from its own start and | ||
| 718 | # never requires that a single byte arrived, so a healthy build whose | ||
| 719 | # repaint has not BEGUN inside that window satisfies it having observed | ||
| 720 | # nothing at all — leaving a pre-resize capture that diffs exactly like | ||
| 721 | # row 7. It would also be used outside its own precondition, which is that | ||
| 722 | # the session is already idle; straight after a resize the answering | ||
| 723 | # snapshot has not been generated yet. | ||
| 724 | set +e | ||
| 725 | timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ | ||
| 726 | "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' | ||
| 727 | expect \x1b[?1049h 10000 | ||
| 728 | send seq 1 60; printf 'fill-%s\\n' done\n | ||
| 729 | expect fill-done 10000 | ||
| 730 | send printf '%095d\\n' 7\n | ||
| 731 | expect 00007 10000 | ||
| 732 | resize 100 30 | ||
| 733 | expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000 | ||
| 734 | settle 500 15000 | ||
| 735 | send \x1c\x1c | ||
| 736 | waitexit 10000 | ||
| 737 | EOF | ||
| 738 | RC=$? | ||
| 739 | set -e | ||
| 740 | [ "$RC" -eq 0 ] || { | ||
| 741 | echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } | ||
| 742 | # Graded against the mutation (row 7: the prefix resize commented out) in | ||
| 743 | # this exact shape: 10 targeted runs, 10 red, plus a red full-suite run. What | ||
| 744 | # dies is the WITNESS, above — a replica still 90 wide cannot paint 91 | ||
| 745 | # contiguous zeros, so the leg fails on the fixture timeout at that verb | ||
| 746 | # ("expect ... did not arrive within 10000ms", exit 3) and the RC check | ||
| 747 | # reports it in ~10s. | ||
| 748 | # | ||
| 749 | # This assertion is the backstop rather than the tripwire, and it is kept | ||
| 750 | # because it sees what the witness does not: before the witness existed | ||
| 751 | # the same build reached here and diverged with both halves of the stale | ||
| 752 | # geometry in one diff — a 30-row grid coming back short its top rows, | ||
| 753 | # because a 28-row replica has only 28 rows to repaint from, and the | ||
| 754 | # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon | ||
| 755 | # had rejoined it into one row at 100. | ||
| 756 | assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 | ||
| 757 | rm_swept "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ | ||
| 758 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" | ||
| 759 | # Closed here like every other per-scenario daemon, not left to the trap: | ||
| 760 | # tp1 runs below and would otherwise share the box with a daemon nobody is | ||
| 761 | # watching, holding a session whose shell is still alive. | ||
| 762 | softkill "$D12PID" || true | ||
| 763 | D12PID="" | ||
| 764 | ok "a pty client resizes: snapshot prefix applied, winch follows the tty" | ||
| 765 | |||
| 766 | # ---- M12 tp1: reconnect while scrolled (row 18 + the 412f38f pin) ------ | ||
| 767 | # The session fills its scrollback BY ITSELF and then execs cat: no | ||
| 768 | # pre-tear typing means no pre-tear predictions, so the predict counters at | ||
| 769 | # exit belong entirely to the one post-resync keystroke — which is what | ||
| 770 | # lets them isolate the 412f38f fix. cat leaves the pty canonical and | ||
| 771 | # echoing, the `.always` tier, so a real keystroke predicts. | ||
| 772 | cat > "$TP1SH" <<'EOF' | ||
| 773 | #!/bin/sh | ||
| 774 | seq 1 100 | ||
| 775 | exec /bin/cat | ||
| 776 | EOF | ||
| 777 | chmod +x "$TP1SH" | ||
| 778 | start_daemon "$SOCK13" "$OUT.tp1.d" "tp1 daemon never bound" --shell "$TP1SH" | ||
| 779 | D13PID=$DPID | ||
| 780 | # Attach only after seq finished: the client must be served a snapshot of | ||
| 781 | # the TAIL, so the scrollback page it fetches later is content it has never | ||
| 782 | # been sent. Observed at 80x24: the attach snapshot carries rows 78..100 and | ||
| 783 | # the session goes quiet there (cat prints no prompt), history_rows is 77, | ||
| 784 | # and page 1 is rows 54..77. Every needle below is chosen against those | ||
| 785 | # numbers, which is why this wait is part of the scenario and not a nicety. | ||
| 786 | i=0 | ||
| 787 | until "$MUXD" dump --sock "$SOCK13" | grep -q "100"; do | ||
| 788 | i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: tp1 session never finished seq"; exit 1; } | ||
| 789 | sleep 0.1 | ||
| 790 | done | ||
| 791 | |||
| 792 | # MUX_PREDICT_STATS is exported into the FIXTURE's environment, not spelled | ||
| 793 | # as a `VAR=x cmd` prefix on the client: the fixture execve()s its child | ||
| 794 | # with an absolute path and no PATH search (src/pty.zig), so `env` would | ||
| 795 | # have to be an absolute path too, and the child inherits this environment | ||
| 796 | # anyway. Getting that wrong costs a client that exits 127 before the first | ||
| 797 | # verb, which the fixture reports as "closed the pty before ... matched". | ||
| 798 | # | ||
| 799 | # EXACTLY ONE keystroke, and that is the load-bearing choice in this script. | ||
| 800 | # offerKeystroke refuses any stdin chunk that is not one byte — an escape | ||
| 801 | # sequence, a multi-byte character, or a paste — and counts it suppressed. | ||
| 802 | # Consecutive `send`s have no barrier between them, so the client's next | ||
| 803 | # read can pick up two of them at once and predict neither: measured at | ||
| 804 | # idle, a three-keystroke version of this leg scored made=3 on one run and | ||
| 805 | # made=2/suppressed=1 on the next. Under load all three could coalesce, | ||
| 806 | # which would read as made=0 — the mutation's own signature. One send | ||
| 807 | # cannot coalesce with anything, so the counters below are exact. | ||
| 808 | # | ||
| 809 | # suppressed=0 therefore says more than "the keystroke predicted", and the | ||
| 810 | # rest is worth writing down because a future edit to the input path could | ||
| 811 | # take it away silently: Ctrl-\ is answered and returned on before | ||
| 812 | # offerKeystroke is ever reached (client.zig:874), so the detach chord | ||
| 813 | # cannot land in the counter; and Shift+PageUp has to arrive as ONE read, | ||
| 814 | # because the whole-chunk mem.eql at client.zig:882 is what recognizes it — | ||
| 815 | # split across two reads it matches nothing, falls through to the keystroke | ||
| 816 | # path, and is both counted suppressed and forwarded to the shell as input. | ||
| 817 | MUX_PREDICT_STATS=1 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 818 | --out "$OUT.tp1" --err "$OUT.tp1.err" -- \ | ||
| 819 | "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' & | ||
| 820 | expect \x1b[0m100 10000 | ||
| 821 | send \x1b[5;2~ | ||
| 822 | expect 60 10000 | ||
| 823 | expect reconnecting 20000 | ||
| 824 | expect \x1b[0m100 20000 | ||
| 825 | send t | ||
| 826 | expect \x1b[80X\x1b[0mt 20000 | ||
| 827 | settle 500 15000 | ||
| 828 | send \x1c\x1c | ||
| 829 | waitexit 15000 | ||
| 830 | EOF | ||
| 831 | TP1PID=$! | ||
| 832 | defer_kill "$TP1PID" | ||
| 833 | |||
| 834 | # The tear goes between verb 3 (the scroll view is on screen) and verb 4; | ||
| 835 | # the fixture's "done 3" line is the barrier. Every needle above is unique | ||
| 836 | # to its phase, which is what makes each expect sound under the no-counting | ||
| 837 | # rule: | ||
| 838 | # the first "100" is the attach snapshot's last row, and it is spelled | ||
| 839 | # with the SGR reset the paint puts in front of every row because a bare | ||
| 840 | # "100" is no longer unique to it: the client asks its own terminal for | ||
| 841 | # mouse reporting with `\x1b[?1000h`, which lands in this capture before | ||
| 842 | # any row does. Measured, not feared — that is the collision that made | ||
| 843 | # this scenario fail when the wheel landed; | ||
| 844 | # "60" is in the history page (54..77) and in NOTHING before it. The | ||
| 845 | # snapshot's rows are 78..100, and the escapes around them cannot spell | ||
| 846 | # it either — but that is a fact about THIS session, not about the paint | ||
| 847 | # code: both paint paths end with a CUP carrying the cursor's real | ||
| 848 | # column (client.zig:968, :991), and a cursor at column 60 would put a | ||
| 849 | # "60" in the stream. Here it cannot be: the session is cat in canonical | ||
| 850 | # mode with one keystroke typed after the tear, so that column is 1 or 2 | ||
| 851 | # for the whole scenario, and the row half of a CUP tops out at 24; | ||
| 852 | # "reconnecting" is the banner, painted once, on the tear; | ||
| 853 | # the SECOND "\x1b[0m100" can only be the post-resync repaint: the scroll view | ||
| 854 | # holds 54..77, and the banner carries no digits but its own position; | ||
| 855 | # the last needle is STRUCTURAL, and it has to be. In canonical mode the | ||
| 856 | # only thing a single keystroke produces is the line discipline's echo of | ||
| 857 | # the very glyph the prediction just painted, so no content needle can | ||
| 858 | # tell the daemon's answer from the client's own guess. `\x1b[80X` can: | ||
| 859 | # it is emitted by exactly one paint path in the client, the one that | ||
| 860 | # paints a DELTA (client.zig, paintDeltaClipped), so its arrival means a | ||
| 861 | # frame came back. The prediction paints the same glyph underlined and | ||
| 862 | # without an erase ("\x1b[24;1H\x1b[4mt\x1b[0m"), and the resync repaint | ||
| 863 | # erases the whole screen with \x1b[2J instead — neither can forge this. | ||
| 864 | # The two post-tear deadlines are 20000 because a reconnect is allowed to | ||
| 865 | # retry: the daemon may not have reaped the dead client's slot yet, and the | ||
| 866 | # client tolerates that for reconnect_grace_ms (5s) with a backoff that | ||
| 867 | # reaches 2s. That is a budget for a documented retry loop, not a deadline | ||
| 868 | # tuned until a flake stopped. | ||
| 869 | wait_for "$OUT.tp1.log" "done 3" 20 || { | ||
| 870 | echo "e2e FAIL: tp1 never reached the scroll view:"; cat "$OUT.tp1.log"; exit 1; } | ||
| 871 | TP1PROXY=$(proxy_pid "$SOCK13") | ||
| 872 | [ -n "$TP1PROXY" ] || { echo "e2e FAIL: tp1: no proxy to tear"; exit 1; } | ||
| 873 | kill -9 "$TP1PROXY" | ||
| 874 | # The daemon must still be SERVING, which is what `kill -0` cannot tell us: | ||
| 875 | # it is our own backgrounded child, so a dead one is a zombie until the | ||
| 876 | # shell reaps it, and signalling a zombie succeeds. A dump answers only if | ||
| 877 | # the process is alive and its accept loop is running. | ||
| 878 | "$MUXD" dump --sock "$SOCK13" >/dev/null 2>&1 || { | ||
| 879 | echo "e2e FAIL: tp1 tear killed the daemon, not the proxy"; exit 1; } | ||
| 880 | |||
| 881 | set +e | ||
| 882 | wait "$TP1PID" | ||
| 883 | RC=$? | ||
| 884 | TP1PID="" | ||
| 885 | set -e | ||
| 886 | [ "$RC" -eq 0 ] || { | ||
| 887 | echo "e2e FAIL: tp1 ptyclient exited $RC:"; cat "$OUT.tp1.log" | ||
| 888 | cat -v "$OUT.tp1.err" 2>/dev/null; exit 1; } | ||
| 889 | |||
| 890 | # Verb 5 is row 18's catch: a resync that fails to leave the scroll view | ||
| 891 | # suppresses every live paint, so the second "100" never arrives. | ||
| 892 | # | ||
| 893 | # The counters are the 412f38f pin, and they are EXACT rather than floors. | ||
| 894 | # One keystroke in the whole scenario, typed after the resync onto a blank | ||
| 895 | # cell at a live cursor, in a session whose termios never moves: nothing | ||
| 896 | # here is timing-dependent, so every number is a fact. | ||
| 897 | # made=1, suppressed=0 is the fix working. With it reverted the overlay | ||
| 898 | # still believes it is scrolled and predictAt refuses on that bit alone — | ||
| 899 | # made=0, suppressed=1, with nothing else in the run to hide it. | ||
| 900 | # displayed=1 says the glyph reached the SCREEN, which `made` does not: | ||
| 901 | # the overlay queues while unconfident and paints nothing, so a client | ||
| 902 | # that lost the `.always` tier would still count the prediction. | ||
| 903 | # confirmed=1 and contradicted=0 say the daemon's own echo agreed with | ||
| 904 | # the guess, which is what makes the prediction a speedup and not a lie. | ||
| 905 | want_stat "$OUT.tp1.err" made 1 "pty scroll reconnect" | ||
| 906 | want_stat "$OUT.tp1.err" displayed 1 "pty scroll reconnect" | ||
| 907 | want_stat "$OUT.tp1.err" confirmed 1 "pty scroll reconnect" | ||
| 908 | want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect" | ||
| 909 | want_stat "$OUT.tp1.err" suppressed 0 "pty scroll reconnect" | ||
| 910 | assert_converged "$OUT.tp1" "$SOCK13" "pty scroll reconnect" | ||
| 911 | |||
| 912 | # The doctored control, extended to a pty capture: this capture's byte shape | ||
| 913 | # (alt screen, banner paints, a history page) exists nowhere else in the | ||
| 914 | # corpus, so it earns its own cannot-fail check. | ||
| 915 | # | ||
| 916 | # It CANNOT be the plain append the non-pty control uses. render replays | ||
| 917 | # only up to the LAST alt-screen exit, and a tty client's capture ends with | ||
| 918 | # one — so appended bytes land after the grid under test and change nothing. | ||
| 919 | # Measured: the doctored capture rendered byte-identical to the original, | ||
| 920 | # i.e. a control that could never fire. Dropping that trailing 8-byte exit | ||
| 921 | # puts the append back INSIDE the alt screen (a stream with no alt-exit is | ||
| 922 | # replayed whole), which corrupts exactly one row of the real grid — row 10, | ||
| 923 | # "87" in this session. The tail is asserted rather than assumed, because if | ||
| 924 | # the client's teardown ever stops ending there, the truncation would silently | ||
| 925 | # go back to being a no-op appended after the grid. | ||
| 926 | [ "$(tail -c 8 "$OUT.tp1" | od -An -tx1 | tr -d ' \n')" = "1b5b3f313034396c" ] || { | ||
| 927 | echo "e2e FAIL: tp1 capture does not end with the alt-screen exit;" | ||
| 928 | echo " the doctored control below would not be doctoring the grid" | ||
| 929 | tail -c 16 "$OUT.tp1" | cat -v; exit 1; } | ||
| 930 | head -c "$(( $(wc -c < "$OUT.tp1") - 8 ))" "$OUT.tp1" > "$OUT.tp1.doc" | ||
| 931 | printf '\033[10;1Hpty-doctor-glyphs' >> "$OUT.tp1.doc" | ||
| 932 | if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then | ||
| 933 | echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1 | ||
| 934 | fi | ||
| 935 | rm_swept "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \ | ||
| 936 | "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \ | ||
| 937 | "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt" | ||
| 938 | softkill "$D13PID" || true | ||
| 939 | D13PID="" | ||
| 940 | rm_swept "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH" | ||
| 941 | ok "reconnect while scrolled: view restored, prediction resumed" | ||
| 942 | |||
| 943 | # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the | 143 | # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the |
| 944 | # script is mid-flight — so its pid is tracked and killed like a daemon's. | 144 | # script is mid-flight — so its pid is tracked and killed like a daemon's. |
| 945 | # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY | 145 | # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY |
test/e2e_02_predict.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,803 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # e2e_02_predict.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 | # M9 prediction: each scenario needs a session whose LINE DISCIPLINE it | ||
| 6 | # controls, so they cannot share the long-lived /bin/sh daemon. | ||
| 7 | SOCK5="${TMPDIR:-/tmp}/muxd-e2e-pred-$$.sock" | ||
| 8 | defer_sock "$SOCK5" | ||
| 9 | SOCK6="${TMPDIR:-/tmp}/muxd-e2e-pw-$$.sock" | ||
| 10 | defer_sock "$SOCK6" | ||
| 11 | SOCK7="${TMPDIR:-/tmp}/muxd-e2e-raw-$$.sock" | ||
| 12 | defer_sock "$SOCK7" | ||
| 13 | PWSH="${TMPDIR:-/tmp}/mux-e2e-pw-$$.sh" | ||
| 14 | defer_rm "$PWSH" | ||
| 15 | # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2 | ||
| 16 | # resizes the grid twice and tp1's session is a scrollback-generating | ||
| 17 | # wrapper, so neither can share the long-lived /bin/sh daemon. | ||
| 18 | SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock" | ||
| 19 | defer_sock "$SOCK12" | ||
| 20 | SOCK13="${TMPDIR:-/tmp}/muxd-e2e-tp1-$$.sock" | ||
| 21 | defer_sock "$SOCK13" | ||
| 22 | # tp1's session: a wrapper that fills the scrollback and then becomes cat. | ||
| 23 | TP1SH="${TMPDIR:-/tmp}/mux-e2e-tp1-$$.sh" | ||
| 24 | defer_rm "$TP1SH" | ||
| 25 | |||
| 26 | # ---- M9: prediction --------------------------------------------------- | ||
| 27 | # | ||
| 28 | # Every scenario here asserts COUNTERS, because the screen cannot tell a | ||
| 29 | # predicted glyph from an echoed one — that is the entire point of the | ||
| 30 | # feature — so a rendering test would pass just as happily with prediction | ||
| 31 | # switched off. | ||
| 32 | # | ||
| 33 | # The session shell is /bin/cat, not /bin/sh. An interactive bash prompt is | ||
| 34 | # icanon=0/echo=0 (readline echoes for itself), which is the ADAPTIVE tier, | ||
| 35 | # not the always-predict one; cat is a genuinely canonical reader and the | ||
| 36 | # only way to exercise `.always` here. That distinction cost a milestone's | ||
| 37 | # worth of confusion to learn and is why it is written down twice. | ||
| 38 | |||
| 39 | # Delay per direction. The round trip is twice this, and every assertion | ||
| 40 | # below about "before the daemon could have answered" is measured against | ||
| 41 | # it. Chosen against three constraints at once, which is why it is not a | ||
| 42 | # round number picked for looks: | ||
| 43 | # | ||
| 44 | # - Larger than delaypipe's built-in default of 150, and far enough from | ||
| 45 | # it to be told apart. The snapshot below is taken 450ms after the | ||
| 46 | # keystroke: the echo cannot have returned over this 600ms round trip, | ||
| 47 | # but WOULD have over the 300ms one the default gives. A DELAY_MS lookup | ||
| 48 | # that silently stopped working — leaving every scenario here measuring | ||
| 49 | # a path twice as fast as it claimed — fails that assertion rather than | ||
| 50 | # passing quietly. | ||
| 51 | # - Small enough that the round trip stays clear of the overlay's 1000ms | ||
| 52 | # expiry bound. This is a real ceiling, not a test artefact: delaypipe | ||
| 53 | # delays each chunk serially, so a burst's later keystrokes queue behind | ||
| 54 | # the earlier ones and age while they wait. At 400ms each way the burst | ||
| 55 | # scenario went to confirmed=1 — the predictions were expiring before | ||
| 56 | # their echo could arrive, exactly as the overlay is designed to do. | ||
| 57 | # - Leaving margin on both sides, so neither is a race under load. | ||
| 58 | PDELAY=300 | ||
| 59 | |||
| 60 | # Own file, not the client capture: the daemon's fd survives the client's truncation and would write into the replayed stream. | ||
| 61 | start_daemon "$SOCK5" "$OUT.p1.d" "prediction daemon never bound" --shell /bin/cat | ||
| 62 | D5PID=$DPID | ||
| 63 | |||
| 64 | # 1. Line mode: the glyph is on screen before the round trip could have | ||
| 65 | # delivered it. | ||
| 66 | set +e | ||
| 67 | { sleep 2; printf 'z'; sleep 4; printf '\034\034'; } | \ | ||
| 68 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 69 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 70 | > "$OUT.p1" 2> "$OUT.p1.err" & | ||
| 71 | P1PID=$! | ||
| 72 | defer_kill "$P1PID" | ||
| 73 | set -e | ||
| 74 | # Snapshot the client output 450ms after the keystroke: well | ||
| 75 | # before this 600ms round trip could bring the pty own echo back, and well | ||
| 76 | # AFTER the 300ms round trip delaypipe default of 150 would have given. | ||
| 77 | sleep 2.45 | ||
| 78 | cp "$OUT.p1" "$OUT.p1.early" 2>/dev/null || true | ||
| 79 | set +e | ||
| 80 | wait "$P1PID" | ||
| 81 | RC=$? | ||
| 82 | set -e | ||
| 83 | [ "$RC" -eq 0 ] || { | ||
| 84 | echo "e2e FAIL: line-mode prediction client exited $RC" | ||
| 85 | cat "$OUT.p1" "$OUT.p1.err" 2>/dev/null; exit 1; } | ||
| 86 | |||
| 87 | # The underlined glyph, in the snapshot taken before the echo could arrive. | ||
| 88 | # This is the whole claim of the milestone, as an effect rather than a | ||
| 89 | # counter: the character was on the screen while it was still in flight. | ||
| 90 | grep -q "$(printf '\033\[4mz')" "$OUT.p1.early" || { | ||
| 91 | echo "e2e FAIL: no predicted glyph 450ms after the keystroke (RTT is $((PDELAY * 2))ms)" | ||
| 92 | echo "--- early snapshot ---"; cat -v "$OUT.p1.early"; exit 1; | ||
| 93 | } | ||
| 94 | # ...and the daemon's own answer was NOT there yet, which is what makes the | ||
| 95 | # line above mean anything. A delta paints a row with ECH before its | ||
| 96 | # content; the prediction never does. | ||
| 97 | if grep -q "$(printf '\033\[80X\033\[0mz')" "$OUT.p1.early"; then | ||
| 98 | echo "e2e FAIL: the echo arrived within 450ms, so the path is not $((PDELAY * 2))ms;" | ||
| 99 | echo " either DELAY_MS was ignored (default is 150) or delaypipe is not delaying" | ||
| 100 | exit 1 | ||
| 101 | fi | ||
| 102 | want_stat "$OUT.p1.err" contradicted 0 "line mode" | ||
| 103 | CONF=$(predict_stat "$OUT.p1.err" confirmed) | ||
| 104 | [ -n "$CONF" ] && [ "$CONF" -ge 1 ] || { | ||
| 105 | echo "e2e FAIL: line mode: confirmed=$CONF, want >=1"; grep "^predict " "$OUT.p1.err"; exit 1; | ||
| 106 | } | ||
| 107 | # The session really received the keystroke: cat echoed it into the grid. | ||
| 108 | "$MUXD" dump --sock "$SOCK5" | grep -q "z" || { | ||
| 109 | echo "e2e FAIL: line mode: daemon grid never saw the keystroke"; exit 1; | ||
| 110 | } | ||
| 111 | assert_converged "$OUT.p1" "$SOCK5" "line-mode prediction" | ||
| 112 | |||
| 113 | # 2. A burst outrunning the round trip. This is reconcile v2's reason to | ||
| 114 | # exist: under the old rule every character after the first was judged by | ||
| 115 | # a frame built before it was typed, read as a contradiction, and the | ||
| 116 | # whole queue was flushed once per round trip. If anyone regresses the | ||
| 117 | # three-way judgment, this scenario is what says so. | ||
| 118 | set +e | ||
| 119 | { sleep 2; for c in b u r s t; do printf '%s' "$c"; sleep 0.25; done; \ | ||
| 120 | sleep 4; printf '\034\034'; } | \ | ||
| 121 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 122 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 123 | > "$OUT.pb" 2> "$OUT.pb.err" | ||
| 124 | RC=$? | ||
| 125 | set -e | ||
| 126 | [ "$RC" -eq 0 ] || { | ||
| 127 | echo "e2e FAIL: burst client exited $RC" | ||
| 128 | cat "$OUT.pb" "$OUT.pb.err" 2>/dev/null; exit 1; } | ||
| 129 | |||
| 130 | # Five keystrokes typed at 250ms into a 600ms round trip: three are always | ||
| 131 | # outstanding at once (at t=600, when the first is confirmed, the keys typed | ||
| 132 | # at 0, 250 and 500 are all in flight). `made` is asserted too, so two | ||
| 133 | # keystrokes arriving in one read (which would be suppressed as a multi-byte | ||
| 134 | # chunk, not predicted) fails loudly instead of quietly weakening the test. | ||
| 135 | # | ||
| 136 | # The interval is 250 and not 200 because of a bound that has to be COMPUTED | ||
| 137 | # rather than eyeballed. delaypipe delivers one chunk per DELAY_MS serially | ||
| 138 | # in EACH direction, so keystrokes closer together than DELAY_MS queue, and | ||
| 139 | # each one ages (DELAY_MS - interval) longer than the one before it: | ||
| 140 | # | ||
| 141 | # age at which keystroke k is confirmed = 2*DELAY_MS + k*(DELAY_MS - interval) | ||
| 142 | # | ||
| 143 | # At interval=200 the fifth keystroke lands at 600 + 4*100 = 1000ms, which is | ||
| 144 | # exactly predict.expire_after_ms: a coin flip, not a margin. It failed 4 runs | ||
| 145 | # in 20 on an idle machine. At 250 it is 600 + 4*50 = 800ms, measured at | ||
| 146 | # 798-799 across 12 runs. The same formula puts the SECOND keystroke at | ||
| 147 | # 1000ms when DELAY_MS=400, which is the confirmed=1 collapse noted above. | ||
| 148 | want_stat "$OUT.pb.err" made 5 "burst" | ||
| 149 | # `confirmed` is a LIVENESS counter and the only timing-dependent one here: | ||
| 150 | # the fifth confirmation can still be in flight when the detach byte lands, | ||
| 151 | # which is why it is a floor rather than an equality. Observed 1 run in 3 | ||
| 152 | # under load (review runs at 715f71b, 2026-08-09), and decisions.md records | ||
| 153 | # the same counter coming up one short on the M9 WAN burst (made=55 | ||
| 154 | # confirmed=54 abandoned=1, the mode-transition churn flush). | ||
| 155 | # | ||
| 156 | # `contradicted` and `expired` stay EXACT. They are the correctness claims — | ||
| 157 | # a prediction judged wrong, or one that aged out unjudged, is a defect | ||
| 158 | # whenever it happens, and nothing about timing makes either acceptable. | ||
| 159 | want_stat_ge "$OUT.pb.err" confirmed 4 "burst" | ||
| 160 | # `expired` first: an expiry runs through the same abandonAll tail as a | ||
| 161 | # refutation and increments BOTH counters, so contradicted >= expired always. | ||
| 162 | # Asserting contradicted first reports an age-out as "a prediction judged | ||
| 163 | # wrong" — a different defect with a different cause, and the reason this | ||
| 164 | # flake was first investigated as a reconcile bug. | ||
| 165 | want_stat "$OUT.pb.err" expired 0 "burst" | ||
| 166 | want_stat "$OUT.pb.err" contradicted 0 "burst" | ||
| 167 | "$MUXD" dump --sock "$SOCK5" | grep -q "burst" || { | ||
| 168 | echo "e2e FAIL: burst: daemon grid does not hold the typed text"; exit 1; | ||
| 169 | } | ||
| 170 | assert_converged "$OUT.pb" "$SOCK5" "burst" | ||
| 171 | |||
| 172 | softkill "$D5PID" || true | ||
| 173 | D5PID="" | ||
| 174 | |||
| 175 | # 3. A password prompt. The session is canonical with echo OFF from the | ||
| 176 | # moment it starts, so the whole run sits in the tier that predicts | ||
| 177 | # nothing at all. | ||
| 178 | cat > "$PWSH" <<'PWEOF' | ||
| 179 | #!/bin/sh | ||
| 180 | stty -echo | ||
| 181 | printf 'pw-ready\n' | ||
| 182 | read secret | ||
| 183 | printf 'pw-len-%s\n' "${#secret}" | ||
| 184 | # Blocks forever rather than exiting: the shell exiting would end the | ||
| 185 | # session, and the daemon would be gone before its grid could be read. | ||
| 186 | read _hold | ||
| 187 | PWEOF | ||
| 188 | chmod +x "$PWSH" | ||
| 189 | |||
| 190 | start_daemon "$SOCK6" "$OUT.pw.d" "password daemon never bound" --shell "$PWSH" | ||
| 191 | D6PID=$DPID | ||
| 192 | |||
| 193 | # One character per write, and NOT as a single `printf hunter2`. A whole | ||
| 194 | # word in one write reaches the client as a multi-byte chunk, which is | ||
| 195 | # refused for being multi-byte before the tier is ever consulted — so the | ||
| 196 | # scenario would pass with the password tier completely broken. It did: | ||
| 197 | # mapping echo-off canonical to the always-predict tier survived this | ||
| 198 | # scenario until the typing was split up. | ||
| 199 | set +e | ||
| 200 | { sleep 2; for c in h u n t e r 2; do printf '%s' "$c"; sleep 0.2; done; \ | ||
| 201 | printf '\n'; sleep 2; printf '\034\034'; } | \ | ||
| 202 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK6" > "$OUT.pw" 2> "$OUT.pw.err" | ||
| 203 | RC=$? | ||
| 204 | set -e | ||
| 205 | [ "$RC" -eq 0 ] || { | ||
| 206 | echo "e2e FAIL: password client exited $RC" | ||
| 207 | cat "$OUT.pw" "$OUT.pw.err" 2>/dev/null; exit 1; } | ||
| 208 | |||
| 209 | # Nothing was shown, and nothing was even attempted: echo-off canonical is | ||
| 210 | # the tier where a prediction must never be MADE, not merely never painted, | ||
| 211 | # because a made prediction sits in a buffer the overlay paints from. | ||
| 212 | want_stat "$OUT.pw.err" made 0 "password" | ||
| 213 | want_stat "$OUT.pw.err" displayed 0 "password" | ||
| 214 | # The effect, not the counter: the secret appears nowhere in the bytes the | ||
| 215 | # client wrote — either stream. The stats line cannot carry a secret, but | ||
| 216 | # checking both costs one word and the claim is about everything the client | ||
| 217 | # emitted, not about the half of it that happens to be the screen. | ||
| 218 | if grep -q "hunter2" "$OUT.pw" "$OUT.pw.err"; then | ||
| 219 | echo "e2e FAIL: the password appears in the client's terminal output"; exit 1 | ||
| 220 | fi | ||
| 221 | # ...and the run was not vacuous: the shell really received all seven | ||
| 222 | # characters, so the absence above is prediction declining rather than | ||
| 223 | # nothing having been typed. | ||
| 224 | "$MUXD" dump --sock "$SOCK6" | grep -q "pw-len-7" || { | ||
| 225 | echo "e2e FAIL: the password never reached the shell; the absence above proves nothing" | ||
| 226 | "$MUXD" dump --sock "$SOCK6"; exit 1; | ||
| 227 | } | ||
| 228 | assert_converged "$OUT.pw" "$SOCK6" "password" | ||
| 229 | |||
| 230 | softkill "$D6PID" || true | ||
| 231 | D6PID="" | ||
| 232 | |||
| 233 | # 4. Raw mode: display is earned, then lost to a keystroke the application | ||
| 234 | # swallows. rawmode echoes like an editor in insert mode until it is sent | ||
| 235 | # 0x00, after which it consumes input and prints nothing. | ||
| 236 | # | ||
| 237 | # Through delaypipe, like leg 1, and for the same reason leg 4b below is | ||
| 238 | # NOT: the overlay measures its own round trip and hides everything on a | ||
| 239 | # path faster than `predict.local_below_ms`. This leg is about the | ||
| 240 | # earn/lose policy, which only a remote path lets through to the screen. | ||
| 241 | # Keys are 1s apart so each confirm (600ms) lands before the next key — | ||
| 242 | # at 0.5s `c` would find only `a` confirmed and stay hidden. | ||
| 243 | start_daemon "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound" --shell "$RAWMODE" | ||
| 244 | D7PID=$DPID | ||
| 245 | |||
| 246 | set +e | ||
| 247 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \ | ||
| 248 | printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | ||
| 249 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | ||
| 250 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK7 | $DELAYPIPE" \ | ||
| 251 | > "$OUT.rw" 2> "$OUT.rw.err" | ||
| 252 | RC=$? | ||
| 253 | set -e | ||
| 254 | [ "$RC" -eq 0 ] || { | ||
| 255 | echo "e2e FAIL: rawmode client exited $RC" | ||
| 256 | cat "$OUT.rw" "$OUT.rw.err" 2>/dev/null; exit 1; } | ||
| 257 | |||
| 258 | # Four printable keystrokes made predictions; the 0x00 that switched modes | ||
| 259 | # is not printable and was refused. | ||
| 260 | want_stat "$OUT.rw.err" made 4 "raw mode" | ||
| 261 | want_stat "$OUT.rw.err" suppressed 1 "raw mode" | ||
| 262 | # a and b are invisible while promotion is earned; c is the first painted, | ||
| 263 | # and j is painted because by then display had been earned. | ||
| 264 | want_stat "$OUT.rw.err" displayed 2 "raw mode" | ||
| 265 | want_stat "$OUT.rw.err" confirmed 3 "raw mode" | ||
| 266 | # j was swallowed: no frame ever answered the cell it was drawn in, so the | ||
| 267 | # expiry bound retired it rather than leaving a phantom glyph on screen for | ||
| 268 | # the rest of the session. | ||
| 269 | want_stat "$OUT.rw.err" expired 1 "raw mode" | ||
| 270 | want_stat "$OUT.rw.err" contradicted 1 "raw mode" | ||
| 271 | want_stat "$OUT.rw.err" abandoned 1 "raw mode" | ||
| 272 | |||
| 273 | # Every prediction accounted for: made = confirmed + abandoned + pending, | ||
| 274 | # and the queue is empty at exit, so nothing survived unexplained. | ||
| 275 | RW_MADE=$(predict_stat "$OUT.rw.err" made) | ||
| 276 | RW_CONF=$(predict_stat "$OUT.rw.err" confirmed) | ||
| 277 | RW_ABND=$(predict_stat "$OUT.rw.err" abandoned) | ||
| 278 | [ "$((RW_MADE - RW_CONF - RW_ABND))" -eq 0 ] || { | ||
| 279 | echo "e2e FAIL: raw mode: $((RW_MADE - RW_CONF - RW_ABND)) predictions unaccounted for" | ||
| 280 | grep "^predict " "$OUT.rw.err"; exit 1; | ||
| 281 | } | ||
| 282 | # The counters say `j` expired; this says the screen agrees. An expiry that | ||
| 283 | # retired the prediction in the book but left its underlined glyph painted is | ||
| 284 | # exactly the phantom the styled half of this check exists to catch. | ||
| 285 | assert_converged "$OUT.rw" "$SOCK7" "raw mode" | ||
| 286 | |||
| 287 | # 4b. The same keystrokes on the bare socket: a local daemon answers in | ||
| 288 | # about a millisecond, and that is the one path on which a prediction | ||
| 289 | # can only ever be SEEN when it is wrong. Display is still earned (the | ||
| 290 | # counters say so) and nothing is painted — `local` is where `c` and | ||
| 291 | # `j` went instead of `displayed`. `j` still expires: the queue keeps | ||
| 292 | # judging while hidden, which is how the gate would lift again on a | ||
| 293 | # box that got slow. The screen check is the point: no phantom `j`, | ||
| 294 | # because there was never an underlined one to leave behind. | ||
| 295 | # | ||
| 296 | # A fresh daemon: the rawmode above has taken its 0x00 and echoes | ||
| 297 | # nothing now, and the leg needs the insert-mode half back. | ||
| 298 | softkill "$D7PID" || true | ||
| 299 | wait "$D7PID" 2>/dev/null || true | ||
| 300 | rm -f "$SOCK7" | ||
| 301 | start_daemon "$SOCK7" "$OUT.rwl.d" "rawmode local daemon never bound" --shell "$RAWMODE" | ||
| 302 | D7PID=$DPID | ||
| 303 | |||
| 304 | set +e | ||
| 305 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \ | ||
| 306 | printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | ||
| 307 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rwl" 2> "$OUT.rwl.err" | ||
| 308 | RC=$? | ||
| 309 | set -e | ||
| 310 | [ "$RC" -eq 0 ] || { | ||
| 311 | echo "e2e FAIL: rawmode local client exited $RC" | ||
| 312 | cat "$OUT.rwl" "$OUT.rwl.err" 2>/dev/null; exit 1; } | ||
| 313 | want_stat "$OUT.rwl.err" made 4 "raw mode local" | ||
| 314 | want_stat "$OUT.rwl.err" confirmed 3 "raw mode local" | ||
| 315 | want_stat "$OUT.rwl.err" displayed 0 "raw mode local" | ||
| 316 | want_stat "$OUT.rwl.err" local 2 "raw mode local" | ||
| 317 | want_stat "$OUT.rwl.err" expired 1 "raw mode local" | ||
| 318 | grep -q "$(printf '\033\[4m')" "$OUT.rwl" && { | ||
| 319 | echo "e2e FAIL: raw mode local: an underlined prediction reached the screen" | ||
| 320 | cat -v "$OUT.rwl"; exit 1; | ||
| 321 | } | ||
| 322 | assert_converged "$OUT.rwl" "$SOCK7" "raw mode local" | ||
| 323 | |||
| 324 | softkill "$D7PID" || true | ||
| 325 | D7PID="" | ||
| 326 | |||
| 327 | # 5. A transport torn down with predictions outstanding. The overlay must | ||
| 328 | # come back empty: what was queued was predicted against a connection | ||
| 329 | # that no longer exists. | ||
| 330 | start_daemon "$SOCK5" "$OUT.pr.d" "reconnect daemon never bound" --shell /bin/cat | ||
| 331 | D5PID=$DPID | ||
| 332 | |||
| 333 | set +e | ||
| 334 | { sleep 2; printf 'p'; sleep 5; printf 'q'; sleep 4; printf '\034\034'; } | \ | ||
| 335 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 60 "$MUX" \ | ||
| 336 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK5 | $DELAYPIPE" \ | ||
| 337 | > "$OUT.pr" 2> "$OUT.pr.err" & | ||
| 338 | PRPID=$! | ||
| 339 | defer_kill "$PRPID" | ||
| 340 | set -e | ||
| 341 | # Kill the transport a tenth of a second after the keystroke, while its | ||
| 342 | # prediction is still outstanding — the round trip is 600ms, so it cannot | ||
| 343 | # have been judged yet. By comm+pid: the client's own argv contains the | ||
| 344 | # --via string, so a pattern kill would take out the client under test. | ||
| 345 | sleep 2.1 | ||
| 346 | PP=$(proxy_pid "$SOCK5") | ||
| 347 | [ -n "$PP" ] || { | ||
| 348 | echo "e2e FAIL: no proxy to tear down" | ||
| 349 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 350 | kill -9 "$PP" | ||
| 351 | |||
| 352 | set +e | ||
| 353 | wait "$PRPID" | ||
| 354 | RC=$? | ||
| 355 | set -e | ||
| 356 | [ "$RC" -eq 0 ] || { | ||
| 357 | echo "e2e FAIL: reconnect client exited $RC" | ||
| 358 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 359 | |||
| 360 | # The queue is empty at exit, by attribution rather than by inspection: | ||
| 361 | # nothing predicted is still outstanding, and the flush that dropped the | ||
| 362 | # in-flight prediction was not counted as anybody being wrong. | ||
| 363 | PR_MADE=$(predict_stat "$OUT.pr.err" made) | ||
| 364 | PR_CONF=$(predict_stat "$OUT.pr.err" confirmed) | ||
| 365 | PR_ABND=$(predict_stat "$OUT.pr.err" abandoned) | ||
| 366 | [ -n "$PR_MADE" ] || { | ||
| 367 | echo "e2e FAIL: reconnect: no predict stats" | ||
| 368 | cat "$OUT.pr" "$OUT.pr.err" 2>/dev/null; exit 1; } | ||
| 369 | [ "$((PR_MADE - PR_CONF - PR_ABND))" -eq 0 ] || { | ||
| 370 | echo "e2e FAIL: reconnect: $((PR_MADE - PR_CONF - PR_ABND)) predictions outlived the tear" | ||
| 371 | grep "^predict " "$OUT.pr.err"; exit 1; | ||
| 372 | } | ||
| 373 | # And it was the reconnect that dropped it, not the expiry bound catching it | ||
| 374 | # a second later. Without this the scenario cannot tell the two apart: the | ||
| 375 | # resync after a reconnect flushes as well, so the outcome looks identical | ||
| 376 | # either way and removing the reconnect's own flush passes unnoticed. It | ||
| 377 | # did, until this line. | ||
| 378 | want_stat "$OUT.pr.err" expired 0 "reconnect" | ||
| 379 | [ -n "$PR_ABND" ] && [ "$PR_ABND" -ge 1 ] || { | ||
| 380 | echo "e2e FAIL: reconnect: nothing was abandoned ($PR_ABND), so no prediction was" | ||
| 381 | echo " outstanding when the transport died and the tear proves nothing" | ||
| 382 | grep "^predict " "$OUT.pr.err"; exit 1; | ||
| 383 | } | ||
| 384 | # The session survived and the client resumed into it: the keystroke typed | ||
| 385 | # after the tear reached the shell. | ||
| 386 | "$MUXD" dump --sock "$SOCK5" | grep -q "q" || { | ||
| 387 | echo "e2e FAIL: reconnect: the post-tear keystroke never reached the session" | ||
| 388 | "$MUXD" dump --sock "$SOCK5"; exit 1; | ||
| 389 | } | ||
| 390 | assert_converged "$OUT.pr" "$SOCK5" "reconnect flush" | ||
| 391 | |||
| 392 | softkill "$D5PID" || true | ||
| 393 | D5PID="" | ||
| 394 | |||
| 395 | # ---- M12: ptyclient fixture controls ---------------------------------- | ||
| 396 | # Before any scenario trusts the fixture, prove both directions: a | ||
| 397 | # roundtrip over plain /bin/cat (the pty line discipline's own echo | ||
| 398 | # answers — no mux anywhere, so a failure here is the FIXTURE'S), and an | ||
| 399 | # expect that cannot match, which must time out, exit nonzero, and say | ||
| 400 | # what it did see. A check that cannot fail proves nothing. | ||
| 401 | PCLOG="$OUT.pc.log" | ||
| 402 | set +e | ||
| 403 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF' | ||
| 404 | send hello\n | ||
| 405 | expect hello 10000 | ||
| 406 | send \x04 | ||
| 407 | waitexit 10000 | ||
| 408 | EOF | ||
| 409 | RC=$? | ||
| 410 | set -e | ||
| 411 | [ "$RC" -eq 0 ] || { | ||
| 412 | echo "e2e FAIL: ptyclient roundtrip over cat exited $RC:"; cat "$PCLOG"; exit 1; } | ||
| 413 | grep -q "hello" "$OUT.pc" || { | ||
| 414 | echo "e2e FAIL: ptyclient capture missing the pty echo"; cat -v "$OUT.pc"; exit 1; } | ||
| 415 | # The must-fail leg. 500ms: nothing is being waited FOR — the needle never | ||
| 416 | # arrives by construction — so the deadline only bounds the control's cost. | ||
| 417 | set +e | ||
| 418 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF' | ||
| 419 | expect never-going-to-match 500 | ||
| 420 | EOF | ||
| 421 | RC=$? | ||
| 422 | set -e | ||
| 423 | # The literal, not merely nonzero: 3 is EXIT_TIMEOUT, and the tp1/tp2 | ||
| 424 | # scenarios read that code to tell a missed needle apart from a client that | ||
| 425 | # died or a script the fixture refused. A bare -ne 0 would pass just as | ||
| 426 | # happily on a usage error that never ran the script at all. | ||
| 427 | [ "$RC" -eq 3 ] || { | ||
| 428 | echo "e2e FAIL: ptyclient expect control exited $RC, want 3 (EXIT_TIMEOUT)"; cat "$PCLOG.2"; exit 1; } | ||
| 429 | grep -q "did not arrive" "$PCLOG.2" || { | ||
| 430 | echo "e2e FAIL: ptyclient timeout fired but never said what it saw"; cat "$PCLOG.2"; exit 1; } | ||
| 431 | # The stderr split, end to end: the client's predict stats must land in the | ||
| 432 | # .err sibling and NOT in the capture the convergence machinery diffs. Only | ||
| 433 | # a child that writes to BOTH streams can tell a working split from a child | ||
| 434 | # that simply never wrote to stderr. | ||
| 435 | set +e | ||
| 436 | timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc3" --err "$OUT.pc3.err" -- \ | ||
| 437 | /bin/sh -c 'printf pc-stdout; printf pc-stderr 1>&2' > "$PCLOG.3" 2>&1 <<'EOF' | ||
| 438 | expect pc-stdout 10000 | ||
| 439 | waitexit 10000 | ||
| 440 | EOF | ||
| 441 | RC=$? | ||
| 442 | set -e | ||
| 443 | [ "$RC" -eq 0 ] || { | ||
| 444 | echo "e2e FAIL: ptyclient stderr-split leg exited $RC:"; cat "$PCLOG.3"; exit 1; } | ||
| 445 | grep -q "pc-stdout" "$OUT.pc3" || { | ||
| 446 | echo "e2e FAIL: ptyclient capture missing the child's stdout"; cat -v "$OUT.pc3"; exit 1; } | ||
| 447 | ! grep -q "pc-stderr" "$OUT.pc3" || { | ||
| 448 | echo "e2e FAIL: the child's stderr leaked into the capture"; cat -v "$OUT.pc3"; exit 1; } | ||
| 449 | grep -q "pc-stderr" "$OUT.pc3.err" || { | ||
| 450 | echo "e2e FAIL: the child's stderr reached neither the capture nor --err" | ||
| 451 | cat -v "$OUT.pc3.err"; exit 1; } | ||
| 452 | rm_swept "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \ | ||
| 453 | "$PCLOG" "$PCLOG.2" "$PCLOG.3" | ||
| 454 | ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture" | ||
| 455 | |||
| 456 | # ---- M12 tp2: resize under a real tty (campaign row 7's branch) -------- | ||
| 457 | # Two clients, two convergence points, because convergence is a scenario's | ||
| 458 | # LAST act after detach. | ||
| 459 | # | ||
| 460 | # tp2a is the suite's first attach at a size that is not the non-tty 80x24 | ||
| 461 | # default: it pins the size a real tty reports reaching the daemon, and | ||
| 462 | # convergence measured against that size rather than the default. It does | ||
| 463 | # NOT exercise the snapshot resize prefix — an attach applies the | ||
| 464 | # attacher's size to the grid BEFORE the answering resync, so the prefix | ||
| 465 | # always equals the size the replica already has and the client's | ||
| 466 | # resize-on-prefix guard never fires. Verified by probe, not by reading. | ||
| 467 | # | ||
| 468 | # tp2b is the leg that catches row 7 (the prefix ignored), and it only | ||
| 469 | # catches it because of WHERE the damage can show. DELTA STREAMING | ||
| 470 | # bypasses the replica: those rows go straight to the tty, absolutely | ||
| 471 | # addressed and clipped to the tty, so a stale replica cannot spoil them | ||
| 472 | # and a wrong row is healed by the next delta that re-addresses it. The | ||
| 473 | # damage therefore has to survive to a FULL REPAINT, which is the paint | ||
| 474 | # that reads the replica back out and clips at min(replica, tty) — still | ||
| 475 | # on screen by then, and in a region the stale geometry cannot reach. | ||
| 476 | # Hence: fill the screen, put a 95-wide row on it, then winch UP to | ||
| 477 | # 100x30. With the prefix ignored the replica stays 90x28 and the repaint | ||
| 478 | # answering the resize reproduces the OLD geometry. | ||
| 479 | start_daemon "$SOCK12" "$OUT.tp2.d" "tp2 daemon never bound" --shell /bin/sh | ||
| 480 | D12PID=$DPID | ||
| 481 | |||
| 482 | # Two rules govern every script below, and breaking either one produces a | ||
| 483 | # capture that is honestly SHORT rather than wrong — a divergence whose | ||
| 484 | # diff blames the product for a scenario's mistake: | ||
| 485 | # | ||
| 486 | # A marker must not appear in the command line that produces it. The | ||
| 487 | # shell echoes what is typed, so `echo tp2-claim` paints the needle | ||
| 488 | # before it has run anything; the expect then matches the echo and the | ||
| 489 | # script detaches while the OUTPUT is still in flight. `printf 'tp2-%s' | ||
| 490 | # claim` keeps the literal off the typed line, so a hit is proof the | ||
| 491 | # shell ran it — the same argument-splitting trick the M5 scenario uses. | ||
| 492 | # | ||
| 493 | # The LAST thing expected must be the prompt, not the output. A shell | ||
| 494 | # writes its result and its next prompt as two separate writes, so a | ||
| 495 | # script that detaches on the result can stop one frame short of the | ||
| 496 | # grid the daemon holds. PS1 is set to a marker here purely so that | ||
| 497 | # prompt is assertable: it is the quiesce signal that would otherwise | ||
| 498 | # have to be a sleep. | ||
| 499 | set +e | ||
| 500 | timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2a" --err "$OUT.tp2a.err" -- \ | ||
| 501 | "$MUX" --sock "$SOCK12" > "$OUT.tp2a.log" 2>&1 <<'EOF' | ||
| 502 | expect \x1b[?1049h 10000 | ||
| 503 | send PS1=tp2rdy@\n | ||
| 504 | # Twice, and the fixture's cursor is what makes that meaningful: the first | ||
| 505 | # hit is the ECHO of the assignment line, the second is the prompt the | ||
| 506 | # shell painted from it. Only the second proves PS1 took effect. | ||
| 507 | # | ||
| 508 | # Counting sentinel occurrences is safe HERE and nowhere else in tp2: this | ||
| 509 | # grid is nearly empty, so nothing scrolls, no repaint splits across | ||
| 510 | # deltas, and the two hits keep the order they were painted in. tp2b runs | ||
| 511 | # on a full screen and cannot assume any of that — see the hazard note | ||
| 512 | # there before copying this shape. | ||
| 513 | expect tp2rdy@ 10000 | ||
| 514 | expect tp2rdy@ 10000 | ||
| 515 | send printf 'tp2-%s\\n' claim\n | ||
| 516 | expect tp2-claim 10000 | ||
| 517 | expect tp2rdy@ 10000 | ||
| 518 | settle 500 15000 | ||
| 519 | send \x1c\x1c | ||
| 520 | waitexit 10000 | ||
| 521 | EOF | ||
| 522 | RC=$? | ||
| 523 | set -e | ||
| 524 | [ "$RC" -eq 0 ] || { | ||
| 525 | echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; } | ||
| 526 | assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30 | ||
| 527 | |||
| 528 | # Resize UP, not down: a replica LARGER than the grid is invisible (the | ||
| 529 | # paint clips at min(replica, tty) either way), so only a replica left | ||
| 530 | # too SMALL has anywhere to show. `seq 1 60` fills the screen so growing | ||
| 531 | # to 30 rows has to pull history down rather than append blanks, and the | ||
| 532 | # 95-wide row is wider than the stale 90 and narrower than the new 100. | ||
| 533 | # | ||
| 534 | # PS1 is still tp2rdy@ from tp2a: same daemon, same shell, and the | ||
| 535 | # assignment outlived the client that typed it. Note where the prompt | ||
| 536 | # expects sit — the sentinel is re-emitted whenever the prompt ROW is | ||
| 537 | # repainted, including by the echo of a command typed at it, so each | ||
| 538 | # trailing prompt is consumed BEFORE the resize. That leaves the expect | ||
| 539 | # after `resize` satisfiable only by the answering snapshot's repaint. | ||
| 540 | # | ||
| 541 | # THE HAZARD, and it is worth stating precisely because three shapes of | ||
| 542 | # this scenario died to it: on a FULL screen the output scrolls, and the | ||
| 543 | # daemon can flush the new prompt row in one delta and the scrolled rows | ||
| 544 | # in a LATER one. Prompt-row sentinels therefore have NO reliable order | ||
| 545 | # relative to the output rows around them, and counting their occurrences | ||
| 546 | # fails in both directions. Too late: the sentinel's bytes arrive before | ||
| 547 | # the output needle that precedes it in the script, the needle's match | ||
| 548 | # consumes the cursor past them, and the next sentinel expect waits on a | ||
| 549 | # shell that has gone idle — a hang. Too early: a post-resize sentinel | ||
| 550 | # matches bytes from BEFORE the resize and the client detaches mid-repaint, | ||
| 551 | # leaving a capture in the old geometry. Measured under load, that second | ||
| 552 | # one is the dangerous one: it produces a diff byte-identical to row 7's | ||
| 553 | # — missing rows and a still-wrapped wide row — so a healthy build fails | ||
| 554 | # wearing the bug's own signature. | ||
| 555 | # | ||
| 556 | # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS | ||
| 557 | # (`fill-done`, `00007`) for progress: each is printed once, by a command | ||
| 558 | # whose typed line cannot contain it, so its arrival is a fact about the | ||
| 559 | # session rather than about paint order. Occurrence-counting of a | ||
| 560 | # REPAINTED row — which is what a prompt sentinel is — is not a | ||
| 561 | # substitute, and is what the three earlier shapes of this leg died to. | ||
| 562 | # | ||
| 563 | # `expect 00007` earns its place as more than a marker: its arrival proves | ||
| 564 | # the daemon processed the wide row while the grid was still 90 columns, | ||
| 565 | # which is what puts the damage on the screen the resize will repaint from. | ||
| 566 | # | ||
| 567 | # After the resize the leg asserts TWO different things, and neither one | ||
| 568 | # implies the other: | ||
| 569 | # the 91-zero WITNESS proves the reflowed paint ARRIVED. 91 contiguous | ||
| 570 | # zeros cannot exist at width 90 — the row is painted wrapped there — so | ||
| 571 | # this needle is structurally incapable of firing early, whatever order | ||
| 572 | # the rows came in. It is also not the load risk an extra deadline looks | ||
| 573 | # like: this needle went 150/150 under 16-way load, at the same load that | ||
| 574 | # broke the two prompt-sentinel shapes 9 and 15 failures in 150. | ||
| 575 | # `settle` then proves nothing further is IN FLIGHT. The witness alone | ||
| 576 | # would permit a detach mid-repaint, with the rest of the screen still | ||
| 577 | # on its way. | ||
| 578 | # Dropping the witness and keeping only settle looks equivalent and is | ||
| 579 | # not: settle succeeds on 500ms of silence measured from its own start and | ||
| 580 | # never requires that a single byte arrived, so a healthy build whose | ||
| 581 | # repaint has not BEGUN inside that window satisfies it having observed | ||
| 582 | # nothing at all — leaving a pre-resize capture that diffs exactly like | ||
| 583 | # row 7. It would also be used outside its own precondition, which is that | ||
| 584 | # the session is already idle; straight after a resize the answering | ||
| 585 | # snapshot has not been generated yet. | ||
| 586 | set +e | ||
| 587 | timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ | ||
| 588 | "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' | ||
| 589 | expect \x1b[?1049h 10000 | ||
| 590 | send seq 1 60; printf 'fill-%s\\n' done\n | ||
| 591 | expect fill-done 10000 | ||
| 592 | send printf '%095d\\n' 7\n | ||
| 593 | expect 00007 10000 | ||
| 594 | resize 100 30 | ||
| 595 | expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000 | ||
| 596 | settle 500 15000 | ||
| 597 | send \x1c\x1c | ||
| 598 | waitexit 10000 | ||
| 599 | EOF | ||
| 600 | RC=$? | ||
| 601 | set -e | ||
| 602 | [ "$RC" -eq 0 ] || { | ||
| 603 | echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } | ||
| 604 | # Graded against the mutation (row 7: the prefix resize commented out) in | ||
| 605 | # this exact shape: 10 targeted runs, 10 red, plus a red full-suite run. What | ||
| 606 | # dies is the WITNESS, above — a replica still 90 wide cannot paint 91 | ||
| 607 | # contiguous zeros, so the leg fails on the fixture timeout at that verb | ||
| 608 | # ("expect ... did not arrive within 10000ms", exit 3) and the RC check | ||
| 609 | # reports it in ~10s. | ||
| 610 | # | ||
| 611 | # This assertion is the backstop rather than the tripwire, and it is kept | ||
| 612 | # because it sees what the witness does not: before the witness existed | ||
| 613 | # the same build reached here and diverged with both halves of the stale | ||
| 614 | # geometry in one diff — a 30-row grid coming back short its top rows, | ||
| 615 | # because a 28-row replica has only 28 rows to repaint from, and the | ||
| 616 | # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon | ||
| 617 | # had rejoined it into one row at 100. | ||
| 618 | assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 | ||
| 619 | rm_swept "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ | ||
| 620 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" | ||
| 621 | # Closed here like every other per-scenario daemon, not left to the trap: | ||
| 622 | # tp1 runs below and would otherwise share the box with a daemon nobody is | ||
| 623 | # watching, holding a session whose shell is still alive. | ||
| 624 | softkill "$D12PID" || true | ||
| 625 | D12PID="" | ||
| 626 | ok "a pty client resizes: snapshot prefix applied, winch follows the tty" | ||
| 627 | |||
| 628 | # ---- M12 tp1: reconnect while scrolled (row 18 + the 412f38f pin) ------ | ||
| 629 | # The session fills its scrollback BY ITSELF and then execs cat: no | ||
| 630 | # pre-tear typing means no pre-tear predictions, so the predict counters at | ||
| 631 | # exit belong entirely to the one post-resync keystroke — which is what | ||
| 632 | # lets them isolate the 412f38f fix. cat leaves the pty canonical and | ||
| 633 | # echoing, the `.always` tier, so a real keystroke predicts. | ||
| 634 | cat > "$TP1SH" <<'EOF' | ||
| 635 | #!/bin/sh | ||
| 636 | seq 1 100 | ||
| 637 | exec /bin/cat | ||
| 638 | EOF | ||
| 639 | chmod +x "$TP1SH" | ||
| 640 | start_daemon "$SOCK13" "$OUT.tp1.d" "tp1 daemon never bound" --shell "$TP1SH" | ||
| 641 | D13PID=$DPID | ||
| 642 | # Attach only after seq finished: the client must be served a snapshot of | ||
| 643 | # the TAIL, so the scrollback page it fetches later is content it has never | ||
| 644 | # been sent. Observed at 80x24: the attach snapshot carries rows 78..100 and | ||
| 645 | # the session goes quiet there (cat prints no prompt), history_rows is 77, | ||
| 646 | # and page 1 is rows 54..77. Every needle below is chosen against those | ||
| 647 | # numbers, which is why this wait is part of the scenario and not a nicety. | ||
| 648 | i=0 | ||
| 649 | until "$MUXD" dump --sock "$SOCK13" | grep -q "100"; do | ||
| 650 | i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: tp1 session never finished seq"; exit 1; } | ||
| 651 | sleep 0.1 | ||
| 652 | done | ||
| 653 | |||
| 654 | # MUX_PREDICT_STATS is exported into the FIXTURE's environment, not spelled | ||
| 655 | # as a `VAR=x cmd` prefix on the client: the fixture execve()s its child | ||
| 656 | # with an absolute path and no PATH search (src/pty.zig), so `env` would | ||
| 657 | # have to be an absolute path too, and the child inherits this environment | ||
| 658 | # anyway. Getting that wrong costs a client that exits 127 before the first | ||
| 659 | # verb, which the fixture reports as "closed the pty before ... matched". | ||
| 660 | # | ||
| 661 | # EXACTLY ONE keystroke, and that is the load-bearing choice in this script. | ||
| 662 | # offerKeystroke refuses any stdin chunk that is not one byte — an escape | ||
| 663 | # sequence, a multi-byte character, or a paste — and counts it suppressed. | ||
| 664 | # Consecutive `send`s have no barrier between them, so the client's next | ||
| 665 | # read can pick up two of them at once and predict neither: measured at | ||
| 666 | # idle, a three-keystroke version of this leg scored made=3 on one run and | ||
| 667 | # made=2/suppressed=1 on the next. Under load all three could coalesce, | ||
| 668 | # which would read as made=0 — the mutation's own signature. One send | ||
| 669 | # cannot coalesce with anything, so the counters below are exact. | ||
| 670 | # | ||
| 671 | # suppressed=0 therefore says more than "the keystroke predicted", and the | ||
| 672 | # rest is worth writing down because a future edit to the input path could | ||
| 673 | # take it away silently: Ctrl-\ is answered and returned on before | ||
| 674 | # offerKeystroke is ever reached (client.zig:874), so the detach chord | ||
| 675 | # cannot land in the counter; and Shift+PageUp has to arrive as ONE read, | ||
| 676 | # because the whole-chunk mem.eql at client.zig:882 is what recognizes it — | ||
| 677 | # split across two reads it matches nothing, falls through to the keystroke | ||
| 678 | # path, and is both counted suppressed and forwarded to the shell as input. | ||
| 679 | MUX_PREDICT_STATS=1 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 680 | --out "$OUT.tp1" --err "$OUT.tp1.err" -- \ | ||
| 681 | "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' & | ||
| 682 | expect \x1b[0m100 10000 | ||
| 683 | send \x1b[5;2~ | ||
| 684 | expect 60 10000 | ||
| 685 | expect reconnecting 20000 | ||
| 686 | expect \x1b[0m100 20000 | ||
| 687 | send t | ||
| 688 | expect \x1b[80X\x1b[0mt 20000 | ||
| 689 | settle 500 15000 | ||
| 690 | send \x1c\x1c | ||
| 691 | waitexit 15000 | ||
| 692 | EOF | ||
| 693 | TP1PID=$! | ||
| 694 | defer_kill "$TP1PID" | ||
| 695 | |||
| 696 | # The tear goes between verb 3 (the scroll view is on screen) and verb 4; | ||
| 697 | # the fixture's "done 3" line is the barrier. Every needle above is unique | ||
| 698 | # to its phase, which is what makes each expect sound under the no-counting | ||
| 699 | # rule: | ||
| 700 | # the first "100" is the attach snapshot's last row, and it is spelled | ||
| 701 | # with the SGR reset the paint puts in front of every row because a bare | ||
| 702 | # "100" is no longer unique to it: the client asks its own terminal for | ||
| 703 | # mouse reporting with `\x1b[?1000h`, which lands in this capture before | ||
| 704 | # any row does. Measured, not feared — that is the collision that made | ||
| 705 | # this scenario fail when the wheel landed; | ||
| 706 | # "60" is in the history page (54..77) and in NOTHING before it. The | ||
| 707 | # snapshot's rows are 78..100, and the escapes around them cannot spell | ||
| 708 | # it either — but that is a fact about THIS session, not about the paint | ||
| 709 | # code: both paint paths end with a CUP carrying the cursor's real | ||
| 710 | # column (client.zig:968, :991), and a cursor at column 60 would put a | ||
| 711 | # "60" in the stream. Here it cannot be: the session is cat in canonical | ||
| 712 | # mode with one keystroke typed after the tear, so that column is 1 or 2 | ||
| 713 | # for the whole scenario, and the row half of a CUP tops out at 24; | ||
| 714 | # "reconnecting" is the banner, painted once, on the tear; | ||
| 715 | # the SECOND "\x1b[0m100" can only be the post-resync repaint: the scroll view | ||
| 716 | # holds 54..77, and the banner carries no digits but its own position; | ||
| 717 | # the last needle is STRUCTURAL, and it has to be. In canonical mode the | ||
| 718 | # only thing a single keystroke produces is the line discipline's echo of | ||
| 719 | # the very glyph the prediction just painted, so no content needle can | ||
| 720 | # tell the daemon's answer from the client's own guess. `\x1b[80X` can: | ||
| 721 | # it is emitted by exactly one paint path in the client, the one that | ||
| 722 | # paints a DELTA (client.zig, paintDeltaClipped), so its arrival means a | ||
| 723 | # frame came back. The prediction paints the same glyph underlined and | ||
| 724 | # without an erase ("\x1b[24;1H\x1b[4mt\x1b[0m"), and the resync repaint | ||
| 725 | # erases the whole screen with \x1b[2J instead — neither can forge this. | ||
| 726 | # The two post-tear deadlines are 20000 because a reconnect is allowed to | ||
| 727 | # retry: the daemon may not have reaped the dead client's slot yet, and the | ||
| 728 | # client tolerates that for reconnect_grace_ms (5s) with a backoff that | ||
| 729 | # reaches 2s. That is a budget for a documented retry loop, not a deadline | ||
| 730 | # tuned until a flake stopped. | ||
| 731 | wait_for "$OUT.tp1.log" "done 3" 20 || { | ||
| 732 | echo "e2e FAIL: tp1 never reached the scroll view:"; cat "$OUT.tp1.log"; exit 1; } | ||
| 733 | TP1PROXY=$(proxy_pid "$SOCK13") | ||
| 734 | [ -n "$TP1PROXY" ] || { echo "e2e FAIL: tp1: no proxy to tear"; exit 1; } | ||
| 735 | kill -9 "$TP1PROXY" | ||
| 736 | # The daemon must still be SERVING, which is what `kill -0` cannot tell us: | ||
| 737 | # it is our own backgrounded child, so a dead one is a zombie until the | ||
| 738 | # shell reaps it, and signalling a zombie succeeds. A dump answers only if | ||
| 739 | # the process is alive and its accept loop is running. | ||
| 740 | "$MUXD" dump --sock "$SOCK13" >/dev/null 2>&1 || { | ||
| 741 | echo "e2e FAIL: tp1 tear killed the daemon, not the proxy"; exit 1; } | ||
| 742 | |||
| 743 | set +e | ||
| 744 | wait "$TP1PID" | ||
| 745 | RC=$? | ||
| 746 | TP1PID="" | ||
| 747 | set -e | ||
| 748 | [ "$RC" -eq 0 ] || { | ||
| 749 | echo "e2e FAIL: tp1 ptyclient exited $RC:"; cat "$OUT.tp1.log" | ||
| 750 | cat -v "$OUT.tp1.err" 2>/dev/null; exit 1; } | ||
| 751 | |||
| 752 | # Verb 5 is row 18's catch: a resync that fails to leave the scroll view | ||
| 753 | # suppresses every live paint, so the second "100" never arrives. | ||
| 754 | # | ||
| 755 | # The counters are the 412f38f pin, and they are EXACT rather than floors. | ||
| 756 | # One keystroke in the whole scenario, typed after the resync onto a blank | ||
| 757 | # cell at a live cursor, in a session whose termios never moves: nothing | ||
| 758 | # here is timing-dependent, so every number is a fact. | ||
| 759 | # made=1, suppressed=0 is the fix working. With it reverted the overlay | ||
| 760 | # still believes it is scrolled and predictAt refuses on that bit alone — | ||
| 761 | # made=0, suppressed=1, with nothing else in the run to hide it. | ||
| 762 | # displayed=1 says the glyph reached the SCREEN, which `made` does not: | ||
| 763 | # the overlay queues while unconfident and paints nothing, so a client | ||
| 764 | # that lost the `.always` tier would still count the prediction. | ||
| 765 | # confirmed=1 and contradicted=0 say the daemon's own echo agreed with | ||
| 766 | # the guess, which is what makes the prediction a speedup and not a lie. | ||
| 767 | want_stat "$OUT.tp1.err" made 1 "pty scroll reconnect" | ||
| 768 | want_stat "$OUT.tp1.err" displayed 1 "pty scroll reconnect" | ||
| 769 | want_stat "$OUT.tp1.err" confirmed 1 "pty scroll reconnect" | ||
| 770 | want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect" | ||
| 771 | want_stat "$OUT.tp1.err" suppressed 0 "pty scroll reconnect" | ||
| 772 | assert_converged "$OUT.tp1" "$SOCK13" "pty scroll reconnect" | ||
| 773 | |||
| 774 | # The doctored control, extended to a pty capture: this capture's byte shape | ||
| 775 | # (alt screen, banner paints, a history page) exists nowhere else in the | ||
| 776 | # corpus, so it earns its own cannot-fail check. | ||
| 777 | # | ||
| 778 | # It CANNOT be the plain append the non-pty control uses. render replays | ||
| 779 | # only up to the LAST alt-screen exit, and a tty client's capture ends with | ||
| 780 | # one — so appended bytes land after the grid under test and change nothing. | ||
| 781 | # Measured: the doctored capture rendered byte-identical to the original, | ||
| 782 | # i.e. a control that could never fire. Dropping that trailing 8-byte exit | ||
| 783 | # puts the append back INSIDE the alt screen (a stream with no alt-exit is | ||
| 784 | # replayed whole), which corrupts exactly one row of the real grid — row 10, | ||
| 785 | # "87" in this session. The tail is asserted rather than assumed, because if | ||
| 786 | # the client's teardown ever stops ending there, the truncation would silently | ||
| 787 | # go back to being a no-op appended after the grid. | ||
| 788 | [ "$(tail -c 8 "$OUT.tp1" | od -An -tx1 | tr -d ' \n')" = "1b5b3f313034396c" ] || { | ||
| 789 | echo "e2e FAIL: tp1 capture does not end with the alt-screen exit;" | ||
| 790 | echo " the doctored control below would not be doctoring the grid" | ||
| 791 | tail -c 16 "$OUT.tp1" | cat -v; exit 1; } | ||
| 792 | head -c "$(( $(wc -c < "$OUT.tp1") - 8 ))" "$OUT.tp1" > "$OUT.tp1.doc" | ||
| 793 | printf '\033[10;1Hpty-doctor-glyphs' >> "$OUT.tp1.doc" | ||
| 794 | if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then | ||
| 795 | echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1 | ||
| 796 | fi | ||
| 797 | rm_swept "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \ | ||
| 798 | "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \ | ||
| 799 | "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt" | ||
| 800 | softkill "$D13PID" || true | ||
| 801 | D13PID="" | ||
| 802 | rm_swept "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH" | ||
| 803 | ok "reconnect while scrolled: view restored, prediction resumed" | ||