a73x

6433ab32

test: a down host says why on the row, and nothing on the wall

a73x   2026-08-30 10:58

Commit message
test: a down host says why on the row, and nothing on the wall

Two legs at the end of the hosts group, the bug's own shape: a hosts line
naming a box that is off, polled through an ssh stand-in that dies the
way an unplugged machine does.

The wall leg asserts on the client's own fd 2 — which on a terminal is
the alternate screen it is painting — and on the capture as a COUNT,
because mux now puts that sentence on the screen itself, inside the
picker row. Every copy must be one the row painted.

The entry-dial leg is the other half: `narrate` is its alone, so it
relays ssh's bytes as they come, and its last line quotes the same
sentence instead of an error name only mux understands.

The two handoff legs that read the remote's progress off the user's
stderr keep passing unchanged — they are entry dials, and their comments
now say relayed where they said inherited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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"