6433ab32
test: a down host says why on the row, and nothing on the wall
a73x 2026-08-30 10:58
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -170,8 +170,8 @@ done | |||
| 170 | # one of those and adds a convergence point would be pinning a fact every | 170 | # one of those and adds a convergence point would be pinning a fact every |
| 171 | # leg above already establishes. | 171 | # leg above already establishes. |
| 172 | 172 | ||
| 173 | [ "$OK_COUNT" = "91" ] || { | 173 | [ "$OK_COUNT" = "93" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 91 —" | 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 93 —" |
| 175 | echo " a scenario was added (update the pin) or silently lost" | 175 | echo " a scenario was added (update the pin) or silently lost" |
| 176 | exit 1 | 176 | exit 1 |
| 177 | } | 177 | } |
test/e2e_04_handoff.sh
| Old | New | ||
|---|---|---|---|
| @@ -180,7 +180,8 @@ pipe_detach "cold handoff client" | |||
| 180 | # The daemon the REMOTE started, by the pid its own up-line reported — the | 180 | # The daemon the REMOTE started, by the pid its own up-line reported — the |
| 181 | # only handle this suite has on a process that is nobody's child. The line | 181 | # only handle this suite has on a process that is nobody's child. The line |
| 182 | # is `mux d endpoint`'s now, and it reaches this file the way ssh's own | 182 | # is `mux d endpoint`'s now, and it reaches this file the way ssh's own |
| 183 | # stderr does: inherited, because the entry dial is not `quiet`. Same | 183 | # stderr does: RELAYED, byte for byte, because the entry dial is the one |
| 184 | # caller that `narrate`s the pipe it reads. Same | ||
| 184 | # accepted gap as the M13 blocks: a failure above leaves it untracked, and | 185 | # accepted gap as the M13 blocks: a failure above leaves it untracked, and |
| 185 | # the trap's stop-by-socket is what covers that. | 186 | # the trap's stop-by-socket is what covers that. |
| 186 | grep -q '^mux d endpoint: starting' "$OUT.h1.err" || { | 187 | grep -q '^mux d endpoint: starting' "$OUT.h1.err" || { |
| @@ -522,7 +523,7 @@ wait_for "$OUT.h5" "none-ok" 20 || { | |||
| 522 | HT3=$(date +%s%N) | 523 | HT3=$(date +%s%N) |
| 523 | HMS_E=$(( (HT3 - HT2) / 1000000 )) | 524 | HMS_E=$(( (HT3 - HT2) / 1000000 )) |
| 524 | pipe_detach "announce-none client" | 525 | pipe_detach "announce-none client" |
| 525 | # The remote said why, in one line, on the stderr ssh already carries. | 526 | # The remote said why, in one line, on the stderr mux relays from ssh. |
| 526 | grep -q '^mux d endpoint: .*staying on ssh' "$OUT.h5.err" || { | 527 | grep -q '^mux d endpoint: .*staying on ssh' "$OUT.h5.err" || { |
| 527 | echo "e2e FAIL: announce-none said nothing about why it stayed on ssh" | 528 | echo "e2e FAIL: announce-none said nothing about why it stayed on ssh" |
| 528 | cat "$OUT.h5.err"; exit 1; } | 529 | cat "$OUT.h5.err"; exit 1; } |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -1114,8 +1114,8 @@ NASKS_3B=$(grep -c -- '--start' "$NSHIMLOG" || true) | |||
| 1114 | 1114 | ||
| 1115 | # (4) A cold `mux HOST` entry, on the box the wall left alone. One daemon, | 1115 | # (4) A cold `mux HOST` entry, on the box the wall left alone. One daemon, |
| 1116 | # and the REMOTE said so in its own voice — `mux d endpoint --start`'s | 1116 | # and the REMOTE said so in its own voice — `mux d endpoint --start`'s |
| 1117 | # progress reaches the user's terminal over the same inherited stderr ssh's | 1117 | # progress reaches the user's terminal over the same relayed stderr ssh's |
| 1118 | # own diagnostics ride on. | 1118 | # own diagnostics ride on, `narrate` being the entry dial's alone. |
| 1119 | pipe_mux "$OUT.nsa" "$OUT.nsa.err" env SHELL=/bin/sh XDG_STATE_HOME="$NSTATE" \ | 1119 | pipe_mux "$OUT.nsa" "$OUT.nsa.err" env SHELL=/bin/sh XDG_STATE_HOME="$NSTATE" \ |
| 1120 | PATH="$NPATH" timeout 60 "$MUX" "nostart-a@127.0.0.1" | 1120 | PATH="$NPATH" timeout 60 "$MUX" "nostart-a@127.0.0.1" |
| 1121 | pipe_send 'printf "ns-cold-%%s\\n" ok\n' | 1121 | pipe_send 'printf "ns-cold-%%s\\n" ok\n' |
| @@ -1157,3 +1157,120 @@ NASKS_ALL=$(grep -c -- '--start' "$NSHIMLOG" || true) | |||
| 1157 | cat "$OUT.nsastop"; exit 1; } | 1157 | cat "$OUT.nsastop"; exit 1; } |
| 1158 | nostart_empty "$NRUNA" "$NSOCKA" "mux d stop" | 1158 | nostart_empty "$NRUNA" "$NSOCKA" "mux d stop" |
| 1159 | ok "reading a host leaves it alone; a picker Enter and a cold mux HOST each start one daemon" | 1159 | ok "reading a host leaves it alone; a picker Enter and a cold mux HOST each start one daemon" |
| 1160 | |||
| 1161 | # ---- ssh's stderr is mux's: no byte of it reaches the wall -------------- | ||
| 1162 | # | ||
| 1163 | # The bug, as the user met it: a hosts line naming a box that is down. The | ||
| 1164 | # wall polls it over ssh, ssh dies with `ssh: connect to host ... No route | ||
| 1165 | # to host`, and that line landed on the wall's ALTERNATE SCREEN every few | ||
| 1166 | # seconds — over tiles and rails, from a writer no repaint of ours can | ||
| 1167 | # reach, because the ssh child's stderr was inherited. | ||
| 1168 | # | ||
| 1169 | # It is a pipe mux reads now. The bytes go nowhere on their own; the last | ||
| 1170 | # line becomes the dial's reason, and the picker row — the one place that | ||
| 1171 | # was saying a bare `unreachable` — quotes it. | ||
| 1172 | # | ||
| 1173 | # The oracle for the regression is the client's own STDERR FILE, not the | ||
| 1174 | # capture. `ptyclient` requires `--err PATH` and gives the client fd 2 as | ||
| 1175 | # that file, so an inherited ssh writes there and never onto the pty — | ||
| 1176 | # the capture cannot see the original bug at all. The file is the | ||
| 1177 | # faithful stand-in precisely because it is the same fd: on a real | ||
| 1178 | # terminal fd 2 IS the screen mux is painting, under the alternate | ||
| 1179 | # screen, which is where those bytes landed. The capture is the second | ||
| 1180 | # pin, and a different claim — that every copy of the sentence ON SCREEN | ||
| 1181 | # is one the picker row painted. | ||
| 1182 | RSHIM="${TMPDIR:-/tmp}/mux-e2e-reason-shim-$$" | ||
| 1183 | defer_rm "$RSHIM" | ||
| 1184 | RSTATE="${TMPDIR:-/tmp}/mux-e2e-reason-state-$$" | ||
| 1185 | defer_rm "$RSTATE" | ||
| 1186 | mkdir -p "$RSHIM" "$RSTATE/mux" | ||
| 1187 | RSAID='ssh: connect to host 10.255.255.1 port 22: No route to host' | ||
| 1188 | # An ssh that fails the way an unplugged box fails: one line on stderr, | ||
| 1189 | # 255, and nothing on stdout at all. 10.255.255.1 is a literal in the | ||
| 1190 | # message, never dialled — the shim never reaches the network. | ||
| 1191 | ssh_shim_head "$RSHIM/ssh" | ||
| 1192 | cat >> "$RSHIM/ssh" <<SHIM | ||
| 1193 | printf '%s\n' '$RSAID' >&2 | ||
| 1194 | exit 255 | ||
| 1195 | SHIM | ||
| 1196 | chmod +x "$RSHIM/ssh" | ||
| 1197 | RPATH="$RSHIM:$(cd "$(dirname "$MUX")" && pwd):$PATH" | ||
| 1198 | printf 'noroute@127.0.0.1\n' > "$RSTATE/mux/hosts" | ||
| 1199 | |||
| 1200 | # The wall. One host, no sessions, so the picker opens by itself — which | ||
| 1201 | # is also the row this leg is about. The settle is for the paint, not for | ||
| 1202 | # a second cycle: a failed `.hand` poll answers `.pipe`, so `pollDelayMs` | ||
| 1203 | # stretches the next one to ten seconds and three of them see exactly one. | ||
| 1204 | # One is enough — the first cycle is where the inherited write happened. | ||
| 1205 | set +e | ||
| 1206 | SHELL=/bin/sh XDG_STATE_HOME="$RSTATE" PATH="$RPATH" timeout 60 "$PTYCLIENT" \ | ||
| 1207 | --cols 100 --rows 30 --out "$OUT.rcap" --err "$OUT.rcap.err" -- \ | ||
| 1208 | "$MUX" > "$OUT.rpc" 2>&1 <<'EOF' | ||
| 1209 | expect unreachable 25000 | ||
| 1210 | settle 3000 25000 | ||
| 1211 | send \x1b | ||
| 1212 | settle 500 15000 | ||
| 1213 | send \x1cd | ||
| 1214 | waitexit 15000 | ||
| 1215 | EOF | ||
| 1216 | RC=$? | ||
| 1217 | set -e | ||
| 1218 | [ "$RC" -eq 0 ] || { | ||
| 1219 | echo "e2e FAIL: reason: the wall leg exited $RC:" | ||
| 1220 | cat "$OUT.rpc"; echo "--- stderr ---"; cat "$OUT.rcap.err"; exit 1; } | ||
| 1221 | # THE regression. A wall's own stderr is the terminal it is painting — | ||
| 1222 | # under the alternate screen, over tiles and rails — and an INHERITED ssh | ||
| 1223 | # writes straight onto it. `ptyclient` gives the client a real stderr | ||
| 1224 | # FILE, so that file is where those bytes land here, and its emptiness of | ||
| 1225 | # them is the same claim the screen makes in production. Mutate | ||
| 1226 | # `spawnPipe`'s `.Pipe` back to `.Inherit` and this is what fires | ||
| 1227 | # (measured: one copy of the line, above `mux: left the wall`). | ||
| 1228 | grep -qF "$RSAID" "$OUT.rcap.err" && { | ||
| 1229 | echo "e2e FAIL: reason: ssh's own stderr reached the wall's own fd 2," | ||
| 1230 | echo " which on a terminal is the alternate screen:" | ||
| 1231 | cat "$OUT.rcap.err"; exit 1; } | ||
| 1232 | # And on the SCREEN, as a count rather than an absence: mux itself puts | ||
| 1233 | # ssh's sentence there now, inside the picker row, so "the bytes are not | ||
| 1234 | # there" is no longer the question. Every copy in the capture must be one | ||
| 1235 | # the row painted. | ||
| 1236 | # `wc -l` on the matches and never `grep -c`, which counts LINES: a pty | ||
| 1237 | # capture is escape bytes with almost no newlines in it, so five raw | ||
| 1238 | # copies and one row would both come back as 1. | ||
| 1239 | RALL=$(grep -aoF "$RSAID" "$OUT.rcap" | wc -l) | ||
| 1240 | RROW=$(grep -aoF "unreachable: $RSAID" "$OUT.rcap" | wc -l) | ||
| 1241 | [ "$RROW" -gt 0 ] || { | ||
| 1242 | echo "e2e FAIL: reason: the picker row never quoted ssh's line:" | ||
| 1243 | grep -aoF 'unreachable' "$OUT.rcap" | head -3; exit 1; } | ||
| 1244 | [ "$RALL" -eq "$RROW" ] || { | ||
| 1245 | echo "e2e FAIL: reason: ssh's own stderr reached the wall's screen —" | ||
| 1246 | echo " $RALL copies of its line, only $RROW of them in a row" | ||
| 1247 | exit 1; } | ||
| 1248 | ok "a down host's ssh says why on the picker row and nothing on the wall" | ||
| 1249 | |||
| 1250 | # ---- ...and the entry dial says it in mux's own failure line ------------ | ||
| 1251 | # | ||
| 1252 | # The other half of the rule: `narrate` is the entry dial's alone, so this | ||
| 1253 | # is the ONE caller that relays ssh's bytes onward as they come — there is | ||
| 1254 | # no wall yet, and the user is sitting in front of the wait. The failure | ||
| 1255 | # line then quotes the same sentence, because `UnterminatedLine` names | ||
| 1256 | # what mux observed while `No route to host` names what happened. | ||
| 1257 | set +e | ||
| 1258 | XDG_STATE_HOME="$RSTATE" PATH="$RPATH" timeout 40 "$MUX" "badhost@127.0.0.1" \ | ||
| 1259 | > "$OUT.rentry" 2> "$OUT.rentry.err" | ||
| 1260 | RC=$? | ||
| 1261 | set -e | ||
| 1262 | [ "$RC" -ne 0 ] || { | ||
| 1263 | echo "e2e FAIL: reason: a dial that never reached a daemon exited 0"; exit 1; } | ||
| 1264 | # Relayed, not swallowed: the bytes ssh wrote reached the user's stderr. | ||
| 1265 | grep -qF "$RSAID" "$OUT.rentry.err" || { | ||
| 1266 | echo "e2e FAIL: reason: the entry dial did not relay ssh's stderr:" | ||
| 1267 | cat "$OUT.rentry.err"; exit 1; } | ||
| 1268 | # The LAST mux line is the verdict, and it is ssh's sentence rather than | ||
| 1269 | # an error name only mux understands. | ||
| 1270 | RLAST=$(grep -a '^mux: ' "$OUT.rentry.err" | tail -1) | ||
| 1271 | [ "$RLAST" = "mux: badhost@127.0.0.1 over ssh: $RSAID" ] || { | ||
| 1272 | echo "e2e FAIL: reason: the entry dial's failure line is" | ||
| 1273 | echo " '$RLAST'" | ||
| 1274 | echo " want 'mux: badhost@127.0.0.1 over ssh: $RSAID'" | ||
| 1275 | cat "$OUT.rentry.err"; exit 1; } | ||
| 1276 | ok "the entry dial relays ssh's stderr and fails in ssh's own words" | ||