715f71b6
fix: a --via command that dies before connecting says so
a73x 2026-08-09 14:52
Commit message
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" |