a73x

715f71b6

fix: a --via command that dies before connecting says so

a73x   2026-08-09 14:52

Commit message
fix: a --via command that dies before connecting says so

docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -1336,7 +1336,7 @@ git commit -m "feat: muxd start — the setsid-nohup incantation becomes a subco
1336 - Modify: `src/client.zig:508,518,573` (the three `exit_msg` sites inside `attach`) 1336 - Modify: `src/client.zig:508,518,573` (the three `exit_msg` sites inside `attach`)
1337 - Modify: `test/e2e.sh` 1337 - Modify: `test/e2e.sh`
1338 1338
1339 - [ ] **Step 1: Implement (the e2e test is this task's failing test — write it first)** 1339 - [x] **Step 1: Implement (the e2e test is this task's failing test — write it first)**
1340 1340
1341 `test/e2e.sh`: 1341 `test/e2e.sh`:
1342 1342
@@ -1357,7 +1357,7 @@ echo "e2e OK: --via failure says what happened"
1357 1357
1358 Run: `make build && make e2e` — MUST fail with the old message (failing state confirmed; this is also the mutation evidence — the old code IS the mutation). 1358 Run: `make build && make e2e` — MUST fail with the old message (failing state confirmed; this is also the mutation evidence — the old code IS the mutation).
1359 1359
1360 - [ ] **Step 2: Implement** 1360 - [x] **Step 2: Implement**
1361 1361
1362 `src/client.zig`, inside `attach` after the `transport` is opened (before the attach-frame write at ~504): 1362 `src/client.zig`, inside `attach` after the `transport` is opened (before the attach-frame write at ~504):
1363 1363
@@ -1380,12 +1380,12 @@ Replace the string at the three sites:
1380 1380
1381 Note `:573` is precisely "no frame ever arrived" (`session_epoch` is set by the first snapshot and never reset — the comment above it says so); after the first snapshot, reconnect logic keeps its own messages. Do not touch any other `exit_msg` site. 1381 Note `:573` is precisely "no frame ever arrived" (`session_epoch` is set by the first snapshot and never reset — the comment above it says so); after the first snapshot, reconnect logic keeps its own messages. Do not touch any other `exit_msg` site.
1382 1382
1383 - [ ] **Step 3: Run, expect pass** 1383 - [x] **Step 3: Run, expect pass**
1384 1384
1385 Run: `make build && make e2e && make test` 1385 Run: `make build && make e2e && make test`
1386 Expected: all pass. 1386 Expected: all pass.
1387 1387
1388 - [ ] **Step 4: Commit** 1388 - [x] **Step 4: Commit**
1389 1389
1390 ```bash 1390 ```bash
1391 git add src/client.zig test/e2e.sh 1391 git add src/client.zig test/e2e.sh
src/client.zig
Old New
@@ -50,6 +50,24 @@ const reconnect_grace_ms: i64 = 5000;
50 /// struct is that it can be closed and opened again from the same recipe 50 /// struct is that it can be closed and opened again from the same recipe
51 /// (`sock_path` or `via`), which is what lets a session outlive its 51 /// (`sock_path` or `via`), which is what lets a session outlive its
52 /// transport instead of exiting with it. 52 /// transport instead of exiting with it.
53 /// What to say when the link dies. A `--via` transport that died before a
54 /// single frame arrived never carried a connection at all — ssh refused, the
55 /// host is unreachable, or `muxd` is not on its PATH — and "connection to
56 /// muxd lost" claims one existed and blames the wrong end. Once any frame
57 /// has arrived the old wording is the true one, so it stays.
58 ///
59 /// `session_epoch` is the signal because it is set from the first snapshot
60 /// and never reset; callers before the first read pass 0, which is what it
61 /// is at that point by construction.
62 ///
63 /// Only `--via` gets the new wording: a unix socket or a `quic://` target
64 /// has no command to have failed, and saying so there would be its own lie.
65 fn lostMsg(via: ?[]const u8, session_epoch: u64) []const u8 {
66 if (via != null and session_epoch == 0)
67 return "mux: transport command failed before connecting (is muxd installed on the host?)";
68 return "mux: connection to muxd lost";
69 }
70
53 /// The client's name for the shared default; see `quic.default_idle_ms` 71 /// The client's name for the shared default; see `quic.default_idle_ms`
54 /// for what the number means and why it lives there. 72 /// for what the number means and why it lives there.
55 pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms; 73 pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms;
@@ -514,7 +532,8 @@ fn session(
514 .attach, 532 .attach,
515 &proto.encodeAttach(size.cols, size.rows, 0, 0), 533 &proto.encodeAttach(size.cols, size.rows, 0, 0),
516 ) catch { 534 ) catch {
517 exit_msg = "mux: connection to muxd lost"; 535 // Nothing has been read yet, so the epoch is 0 by construction.
536 exit_msg = lostMsg(via, 0);
518 return 1; 537 return 1;
519 }; 538 };
520 539
@@ -524,7 +543,7 @@ fn session(
524 // harness types. 543 // harness types.
525 if (carry.items.len > 0) { 544 if (carry.items.len > 0) {
526 transport.writeFrame(.input, carry.items) catch { 545 transport.writeFrame(.input, carry.items) catch {
527 exit_msg = "mux: connection to muxd lost"; 546 exit_msg = lostMsg(via, 0);
528 return 1; 547 return 1;
529 }; 548 };
530 carry.clearRetainingCapacity(); 549 carry.clearRetainingCapacity();
@@ -580,7 +599,7 @@ fn session(
580 // every re-attach, so mid-session it would send us down this 599 // every re-attach, so mid-session it would send us down this
581 // exit path exactly when resuming is what we want. 600 // exit path exactly when resuming is what we want.
582 if (session_epoch == 0) { 601 if (session_epoch == 0) {
583 exit_msg = "mux: connection to muxd lost"; 602 exit_msg = lostMsg(via, session_epoch);
584 return 1; 603 return 1;
585 } 604 }
586 // A resync repaints live state, so a history page would be 605 // A resync repaints live state, so a history page would be
@@ -1832,3 +1851,17 @@ test "parseQuicAddr: no port means 4433, explicit port wins" {
1832 // Unbracketed IPv6 stays ambiguous and refused, with or without ports. 1851 // Unbracketed IPv6 stays ambiguous and refused, with or without ports.
1833 try std.testing.expectError(error.MalformedAddress, parseQuicAddr("fe80::1:4433")); 1852 try std.testing.expectError(error.MalformedAddress, parseQuicAddr("fe80::1:4433"));
1834 } 1853 }
1854
1855 test "lostMsg: only a --via transport that never connected gets the new wording" {
1856 // The case the message exists for: a command that failed to start.
1857 try std.testing.expectEqualStrings(
1858 "mux: transport command failed before connecting (is muxd installed on the host?)",
1859 lostMsg("ssh box muxd proxy", 0),
1860 );
1861 // Same transport, but a session existed — there WAS a connection, and
1862 // saying otherwise would be the new lie in place of the old one.
1863 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg("ssh box muxd proxy", 7));
1864 // No command to have failed: a socket or quic:// target keeps the
1865 // original wording however early it dies.
1866 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg(null, 0));
1867 }
test/e2e.sh
Old New
@@ -252,6 +252,20 @@ grep -q "m6-via-pipe" "$OUT.via" || {
252 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; } 252 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; }
253 rm -f "$OUT.via" 253 rm -f "$OUT.via"
254 254
255 # --- M10: a --via command that dies before the first frame stops claiming
256 # a connection existed. ssh's own stderr still passes through untouched.
257 set +e
258 "$MUX" --via "sh -c 'exit 127'" > "$OUT.via" 2>&1
259 VRC=$?
260 set -e
261 [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; }
262 grep -q "transport command failed before connecting" "$OUT.via" || {
263 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; }
264 grep -q "connection to muxd lost" "$OUT.via" && {
265 echo "e2e FAIL: the old lie is still printed"; cat "$OUT.via"; exit 1; }
266 rm -f "$OUT.via"
267 echo "e2e OK: --via failure says what happened"
268
255 # --- M7: a transport that dies before any session must exit, not retry. The 269 # --- M7: a transport that dies before any session must exit, not retry. The
256 # reconnect loop resumes sessions; it must not turn a bad --via command into 270 # reconnect loop resumes sessions; it must not turn a bad --via command into
257 # an unkillable client. With piped stdin there is no Ctrl-\ to rescue it, so 271 # an unkillable client. With piped stdin there is no Ctrl-\ to rescue it, so
@@ -265,7 +279,11 @@ set -e
265 echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)" 279 echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)"
266 cat "$OUT.dead"; exit 1; 280 cat "$OUT.dead"; exit 1;
267 } 281 }
268 grep -q "connection to muxd lost" "$OUT.dead" || { 282 # M10 reworded this one: `--via "exit 7"` is the same shape as the scenario
283 # above — a transport command that died before carrying a frame — so it now
284 # gets the honest message. What this scenario is FOR is the exit code above
285 # (1, never 124); the diagnostic is asserted so the exit is not a silent one.
286 grep -q "transport command failed before connecting" "$OUT.dead" || {
269 echo "e2e FAIL: dead first transport lost its diagnostic; got:"; cat "$OUT.dead"; exit 1; 287 echo "e2e FAIL: dead first transport lost its diagnostic; got:"; cat "$OUT.dead"; exit 1;
270 } 288 }
271 rm -f "$OUT.dead" 289 rm -f "$OUT.dead"