531494b3
feat(m7): reconnect held over the WAN — 10 tears, 10 delta resumes; the record
a73x 2026-08-08 14:08
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,16 @@ authoritatively in a daemon and replicated in the client — state sync | |||
| 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design | 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design |
| 6 | and `docs/decisions.md` for decisions made. | 6 | and `docs/decisions.md` for decisions made. |
| 7 | 7 | ||
| 8 | Status: **M6 — transport.** All handoff milestones complete; all kill | 8 | Status: **M7 — reconnect.** A dropped link is a non-event: the client |
| 9 | keeps its replica, rebuilds the transport, and re-attaches quoting what it | ||
| 10 | already holds, so the daemon can answer with a delta instead of a repaint. | ||
| 11 | Measured over a real WAN link: 10 consecutive transport kills against a | ||
| 12 | live session, all 10 resumed hands-off, all 10 delta-served (the daemon's | ||
| 13 | snapshot counter never moved), first frame back in ~254 ms — most of which | ||
| 14 | is the client's own 200 ms first retry. `mux HOST` attaches over ssh in | ||
| 15 | one word. | ||
| 16 | |||
| 17 | Previously, **M6 — transport.** All handoff milestones complete; all kill | ||
| 9 | criteria cleared. The prototype's two founding questions are both | 18 | criteria cleared. The prototype's two founding questions are both |
| 10 | answered yes: ghostty-vt serves as an authoritative headless grid in a | 19 | answered yes: ghostty-vt serves as an authoritative headless grid in a |
| 11 | daemon without forking it, and detach/reattach as state sync is correct | 20 | daemon without forking it, and detach/reattach as state sync is correct |
| @@ -30,8 +39,18 @@ toolchain, override with `make ZIG=...`. | |||
| 30 | 39 | ||
| 31 | Attach over a network: | 40 | Attach over a network: |
| 32 | 41 | ||
| 42 | ./zig-out/bin/mux HOST # sugar for the --via line below | ||
| 33 | ./zig-out/bin/mux --via "ssh host /path/muxd proxy --sock /path/muxd.sock" | 43 | ./zig-out/bin/mux --via "ssh host /path/muxd proxy --sock /path/muxd.sock" |
| 34 | 44 | ||
| 45 | `mux HOST` runs `ssh HOST muxd proxy`, so muxd must be on HOST's PATH; | ||
| 46 | anything more specific (a custom socket, a non-default path) is what | ||
| 47 | `--via` is for. `mux` itself does not parse HOST, so ssh's own config — | ||
| 48 | host aliases, ports, ProxyJump — keeps working. It is interpolated into a | ||
| 49 | shell command, though, exactly as the `--via` string is: **HOST must be | ||
| 50 | trusted**. Feeding it unreviewed from an inventory file or a cloud API is | ||
| 51 | handing that source a shell. If the link drops, the client reconnects on | ||
| 52 | its own and resumes the session; Ctrl-\ gives up waiting. | ||
| 53 | |||
| 35 | `--via` runs the protocol over any command that exposes the daemon's | 54 | `--via` runs the protocol over any command that exposes the daemon's |
| 36 | socket on its stdio — `muxd proxy` is one such command, and it is a | 55 | socket on its stdio — `muxd proxy` is one such command, and it is a |
| 37 | frame-agnostic byte pump that contains no protocol knowledge at all. | 56 | frame-agnostic byte pump that contains no protocol knowledge at all. |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -479,6 +479,131 @@ path, and it is transport work, which is the verdict restated. | |||
| 479 | cost measured here is what a predictor must beat to be worth its | 479 | cost measured here is what a predictor must beat to be worth its |
| 480 | complexity on a link this good. | 480 | complexity on a link this good. |
| 481 | 481 | ||
| 482 | ## 2026-08-07 (M7) | ||
| 483 | |||
| 484 | - **Kill criterion: PASSED on the real WAN box.** 10 consecutive transport | ||
| 485 | kills against a live session over ssh (~16.5ms round trip, via a jump | ||
| 486 | host): 10 of 10 resumed with zero manual action, and the remote daemon's | ||
| 487 | `snapshots` counter did not move across any of them (11 → 11) — every | ||
| 488 | resume was delta-served. First frame after a tear: min 254.0ms, med | ||
| 489 | 254.5ms, max 255.3ms (n=10). The M6 criteria still pass in the same run | ||
| 490 | (echo med 20.9ms against a 136.5ms budget; reattach protocol share | ||
| 491 | 22.0ms against 33.0ms). Re-confirmed on a second run after the | ||
| 492 | convergence clause below was narrowed and partly closed: 10/10 again, | ||
| 493 | snapshots 5 → 5, first frame med 256.4ms, state-survival check passed. | ||
| 494 | - **The criterion was NARROWED, and this is the record of it.** The plan | ||
| 495 | named four elements; the fourth — "replica converged after the last tear | ||
| 496 | (client render matches `muxd dump`)" — is **not** what the harness | ||
| 497 | checks, and the first version of this record restated the criterion | ||
| 498 | without saying so. What was substituted: each tear round-trips a marker | ||
| 499 | out to the remote shell and back through the rebuilt transport, and | ||
| 500 | (added when this was caught) after the last tear the **first** tear's | ||
| 501 | marker must still be in the daemon's grid — accumulated state survived | ||
| 502 | all ten resumes. Why: the client emits a stream of paints, not a grid, so | ||
| 503 | comparing its render to a dump means running a second VT parser inside | ||
| 504 | the measurement harness, whose own bugs would be indistinguishable from | ||
| 505 | the ones it exists to catch. That comparison IS made against a real | ||
| 506 | replica Engine in src/server.zig's tests — over a socket, not over the | ||
| 507 | WAN. The substitute is weaker than the plan's wording. **Full | ||
| 508 | convergence-over-the-WAN stays banked**, and the honest one-line summary | ||
| 509 | of M7 is "done, with the convergence clause narrowed on the record". | ||
| 510 | - **That 254ms is nearly all our own first backoff.** It decomposes as | ||
| 511 | ~200ms of client backoff + 36.4ms of ssh channel open (`viafloor`, | ||
| 512 | measured in the same run) + ~18ms of protocol. The wall clock is printed | ||
| 513 | and not gated for the same reason the M6 reattach ruling gives — gating | ||
| 514 | it would rule on ssh, not on this design — but the split is worth | ||
| 515 | keeping in view: **a shorter first retry would cut resume time roughly | ||
| 516 | fourfold on this link**, and nothing measured here argues against trying | ||
| 517 | it. That multiple is link-specific and must not be quoted as universal — | ||
| 518 | it is the ratio of a fixed 200ms backoff to a fast link's costs, so on | ||
| 519 | the +75ms netem profile the same change is worth roughly 2x, and on a | ||
| 520 | slow enough link it rounds to nothing. Banked rather than tuned, because | ||
| 521 | 200ms was chosen for a link that might be flapping and this run cannot | ||
| 522 | tell us how it behaves on one that is. | ||
| 523 | - **Transport death is a non-event; transport *absence* is not.** The | ||
| 524 | client keeps its replica, rebuilds the transport from the recipe it was | ||
| 525 | launched with, and re-attaches quoting `(have_seq, have_epoch)`; the | ||
| 526 | daemon's existing `sendResync` decides delta-or-snapshot. Retry is | ||
| 527 | uniform and generous with no cap, split on one thing only: **whether a | ||
| 528 | session was ever established** (`session_epoch != 0`). A transport that | ||
| 529 | dies before the first snapshot carried no session — a typo'd hostname, a | ||
| 530 | remote muxd that is not there — and exits immediately with the old | ||
| 531 | message. Retry policy is deliberately NOT split on transport type: a | ||
| 532 | dropped ssh link and a killed local daemon are the same event to a | ||
| 533 | client, and the abort key plus the indicator answer both. | ||
| 534 | - **Policy decisions, recorded because they are choices and not | ||
| 535 | consequences:** input typed while disconnected is **dropped**, not | ||
| 536 | queued (replaying a burst of stale keystrokes into a shell is worse than | ||
| 537 | losing them; prediction is the principled fix, and it is a later | ||
| 538 | milestone). A reconnect landing on a **different epoch repaints silently** | ||
| 539 | — new epoch means new session, and a fresh shell is self-evident without | ||
| 540 | a modal "session lost" state. An attach **refusal during a reconnect** is | ||
| 541 | retried for ~5s (the daemon may not have reaped our own dead | ||
| 542 | predecessor's slot yet); a refusal on first attach still exits loudly. | ||
| 543 | Ctrl-\ during a reconnect exits 0 with "detached while reconnecting" — | ||
| 544 | the user chose to leave and the session really is still running. | ||
| 545 | - **`session_epoch` graduated from parsed-but-unused**, closing the M6 | ||
| 546 | banked item: it is now the value that lets a reconnect resume a stream | ||
| 547 | instead of re-attaching from zero, and the value that makes "never | ||
| 548 | established" expressible at all. | ||
| 549 | - **`mux HOST` is sugar for `--via "ssh HOST muxd proxy"`** — the VM-hop | ||
| 550 | use case in one word. Nothing parses inside HOST, so ssh's own config | ||
| 551 | (aliases, ports, ProxyJump) keeps working. **It presumes a compatible | ||
| 552 | muxd on the remote PATH and there is no version negotiation anywhere in | ||
| 553 | this protocol: mismatched binaries are undefined behaviour, not a | ||
| 554 | handled error.** | ||
| 555 | |||
| 556 | ### Process rules this milestone paid for | ||
| 557 | |||
| 558 | - **build.zig test discovery is a LIVE hazard.** Tests only run for modules | ||
| 559 | listed in the `test` step's loop. `mux_main.zig` was not in it, so its | ||
| 560 | five new parser tests compiled and silently never ran — the suite | ||
| 561 | reported 79/79 green with them absent, and 84/84 once the module was | ||
| 562 | added. **`src/main.zig` (muxd's entry point) is still not in that list**, | ||
| 563 | so the first test anyone adds there will silently not run. Adding a test | ||
| 564 | file is not enough; check the module is in the loop, and check the test | ||
| 565 | count moved. | ||
| 566 | - **Counters are asserted because markers are blind to *how* a resume was | ||
| 567 | served.** A snapshot-served resume renders identically to a delta-served | ||
| 568 | one, so `snapshots` is the only witness — this is now proven three times | ||
| 569 | by mutation (a client reconnecting with `have_seq=0` passes every marker | ||
| 570 | assertion and fails only the counter). The restart scenario's mirror | ||
| 571 | assertion (`snapshots >= 1` on the *new* daemon) needs **both** the epoch | ||
| 572 | fence and the seq-range check removed before it fires, because | ||
| 573 | `buildDeltaSince` has no out-of-range guard of its own: a stale seq | ||
| 574 | yields a silent zero-row delta that renders fine. It is deliberate | ||
| 575 | defence in depth against those two guards ever being collapsed into one, | ||
| 576 | and it is recorded here so the next reviewer does not repeat the | ||
| 577 | "can't mutate it, is it dead?" cycle. | ||
| 578 | - **`pkill -f` is banned in this suite.** The client's own argv contains | ||
| 579 | the entire `--via` command string, so any pattern matching the proxy also | ||
| 580 | matches the client under test — which killed its own subject once here | ||
| 581 | before the rule existed. Select by pid: `comm == muxd` plus argv, or the | ||
| 582 | parent-pid of the client. The remote-side equivalent is the existing | ||
| 583 | bracketed-pattern trick in `wan.sh`. | ||
| 584 | - **The never-established gate has e2e-only coverage.** The property is | ||
| 585 | process-level (a client that must exit rather than loop, with no tty to | ||
| 586 | rescue it), so it cannot be unit-tested honestly. `make e2e` is therefore | ||
| 587 | a **permanently required gate**, not an optional one: with unit tests | ||
| 588 | alone, a regression to the infinite-retry behaviour ships green. | ||
| 589 | - **Mutation testing here has a stale-binary trap.** `make test` does not | ||
| 590 | rebuild the `mux`/`muxd` binaries, and a failed build leaves the previous | ||
| 591 | ones in place, so a mutation run can report a confident false green | ||
| 592 | against code that was never compiled. Always assert the build's exit | ||
| 593 | status and the binary's freshness before believing a mutation result. | ||
| 594 | |||
| 595 | - **Banked (M7 additions, carrying the M6 list forward intact):** a shorter | ||
| 596 | first reconnect backoff (see above); repeated `--via` is untested while | ||
| 597 | repeated `--sock` is covered — an asymmetry in the parser tests, not in | ||
| 598 | the parser; no version negotiation between `mux` and a remote `muxd` | ||
| 599 | (see above); systemd socket-activation packaging for the VMs (the unit | ||
| 600 | files exist in `contrib/`, an install doc does not); the reconnect loop | ||
| 601 | has no upper bound on total retry time by design, so a client left | ||
| 602 | attached to a permanently dead daemon retries until the user aborts. | ||
| 603 | - **Out of scope, in order:** QUIC/TLS transport (M8, next per user | ||
| 604 | direction), then prediction / local echo — which now has this | ||
| 605 | milestone's numbers to beat as well as M6's ~4ms protocol cost. | ||
| 606 | |||
| 482 | ## Open (owed by later milestones) | 607 | ## Open (owed by later milestones) |
| 483 | 608 | ||
| 484 | - Scrollback retention *tuning*. The policy itself was decided in M1 and | 609 | - Scrollback retention *tuning*. The policy itself was decided in M1 and |
docs/superpowers/plans/2026-08-07-m7-reconnect.md
| Old | New | ||
|---|---|---|---|
| @@ -25,7 +25,7 @@ | |||
| 25 | 25 | ||
| 26 | The via-child plumbing lives inline in `attach()` (src/client.zig:27-51) and the socket path right below it; neither can be re-established mid-session. Extract both into one struct. **No behavior change in this task** — same messages, same exit codes, same child cleanup order (stdin close → kill; the comment at client.zig:40-49 explains why — keep it). | 26 | The via-child plumbing lives inline in `attach()` (src/client.zig:27-51) and the socket path right below it; neither can be re-established mid-session. Extract both into one struct. **No behavior change in this task** — same messages, same exit codes, same child cleanup order (stdin close → kill; the comment at client.zig:40-49 explains why — keep it). |
| 27 | 27 | ||
| 28 | - [ ] **Step 1: Write the struct and constructor** | 28 | - [x] **Step 1: Write the struct and constructor** |
| 29 | 29 | ||
| 30 | ```zig | 30 | ```zig |
| 31 | /// One live connection to a muxd, however reached. Reopenable: the | 31 | /// One live connection to a muxd, however reached. Reopenable: the |
| @@ -62,13 +62,13 @@ const Transport = struct { | |||
| 62 | }; | 62 | }; |
| 63 | ``` | 63 | ``` |
| 64 | 64 | ||
| 65 | - [ ] **Step 2: Rewire `attach()` to use it, keeping today's error messages** | 65 | - [x] **Step 2: Rewire `attach()` to use it, keeping today's error messages** |
| 66 | 66 | ||
| 67 | `attach()` opens the first Transport, prints the same "cannot start --via command"/"cannot connect to {s} (is muxd running?)" messages on failure, and passes `sock_path`, `via`, and the Transport down to `session()` (signature grows; the recipe is needed there next task). | 67 | `attach()` opens the first Transport, prints the same "cannot start --via command"/"cannot connect to {s} (is muxd running?)" messages on failure, and passes `sock_path`, `via`, and the Transport down to `session()` (signature grows; the recipe is needed there next task). |
| 68 | 68 | ||
| 69 | - [ ] **Step 3: `make test && make e2e` — both green, zero behavior change** | 69 | - [x] **Step 3: `make test && make e2e` — both green, zero behavior change** |
| 70 | 70 | ||
| 71 | - [ ] **Step 4: Commit** `refactor: transport is a reopenable value, recipe travels with the session` | 71 | - [x] **Step 4: Commit** `refactor: transport is a reopenable value, recipe travels with the session` |
| 72 | 72 | ||
| 73 | --- | 73 | --- |
| 74 | 74 | ||
| @@ -77,15 +77,15 @@ const Transport = struct { | |||
| 77 | **Files:** | 77 | **Files:** |
| 78 | - Modify: `src/client.zig` (session loop) | 78 | - Modify: `src/client.zig` (session loop) |
| 79 | 79 | ||
| 80 | - [ ] **Step 1: Track `last_seq`** | 80 | - [x] **Step 1: Track `last_seq`** |
| 81 | 81 | ||
| 82 | In the session loop state (near `session_epoch`, client.zig:145): `var last_seq: u64 = 0;`. Update from both authoritative sources: `prefix.seq` in the snapshot arm, `composed.header.seq` in the delta arm. Fix the now-stale comment at client.zig:142-144 ("Nothing reads it yet") — something reads it now. | 82 | In the session loop state (near `session_epoch`, client.zig:145): `var last_seq: u64 = 0;`. Update from both authoritative sources: `prefix.seq` in the snapshot arm, `composed.header.seq` in the delta arm. Fix the now-stale comment at client.zig:142-144 ("Nothing reads it yet") — something reads it now. |
| 83 | 83 | ||
| 84 | - [ ] **Step 2: Route every transport-loss exit into `reconnect()`** | 84 | - [x] **Step 2: Route every transport-loss exit into `reconnect()`** |
| 85 | 85 | ||
| 86 | The three exit sites that today set `exit_msg = "mux: connection to muxd lost"` on a *dead transport* (readFrame null at client.zig:180; the input-write catch at :292; the winch-resize catch at :157, plus the scroll-request catches) instead do: `if (!try reconnect(...)) { exit_msg = ...; return 1; }` and `continue` the main loop. The malformed-delta re-attach (client.zig:217-224) is **not** one of these — the transport is alive there, and its `have_seq=0` is deliberate (untrusted replica must force a snapshot); leave it, but extend its comment to say why it differs from reconnect(). | 86 | The three exit sites that today set `exit_msg = "mux: connection to muxd lost"` on a *dead transport* (readFrame null at client.zig:180; the input-write catch at :292; the winch-resize catch at :157, plus the scroll-request catches) instead do: `if (!try reconnect(...)) { exit_msg = ...; return 1; }` and `continue` the main loop. The malformed-delta re-attach (client.zig:217-224) is **not** one of these — the transport is alive there, and its `have_seq=0` is deliberate (untrusted replica must force a snapshot); leave it, but extend its comment to say why it differs from reconnect(). |
| 87 | 87 | ||
| 88 | - [ ] **Step 3: Write `reconnect()`** | 88 | - [x] **Step 3: Write `reconnect()`** |
| 89 | 89 | ||
| 90 | ```zig | 90 | ```zig |
| 91 | /// The transport died but the session (daemon-side) very likely did not. | 91 | /// The transport died but the session (daemon-side) very likely did not. |
| @@ -124,13 +124,13 @@ fn reconnect( | |||
| 124 | 124 | ||
| 125 | Details the implementer owns: `paintBanner` (factor from `renderScrollback`'s marker paint, client.zig:394-398); `drainStdinForQuit` (poll stdin with the backoff as timeout; read; return true iff 0x1c seen — bytes are otherwise discarded); no unbounded retry cap on *transport* failures (the user has the abort key and the indicator; a laptop asleep for an hour should still resume on wake — that IS the use case), but see Step 4 for the refusal cap. | 125 | Details the implementer owns: `paintBanner` (factor from `renderScrollback`'s marker paint, client.zig:394-398); `drainStdinForQuit` (poll stdin with the backoff as timeout; read; return true iff 0x1c seen — bytes are otherwise discarded); no unbounded retry cap on *transport* failures (the user has the abort key and the indicator; a laptop asleep for an hour should still resume on wake — that IS the use case), but see Step 4 for the refusal cap. |
| 126 | 126 | ||
| 127 | - [ ] **Step 4: The refusal-during-reconnect grace** | 127 | - [x] **Step 4: The refusal-during-reconnect grace** |
| 128 | 128 | ||
| 129 | After a reconnect-attach, if the next frame is `exit_status` before any state arrives (the session-full refusal shape, client.zig:241-243), and we were reconnecting (a flag alongside `got_state`), treat it as retryable for up to ~5s total (ghost-slot race: cap=2 and the daemon may not have reaped our dead predecessor). Past the grace: exit with the existing refusal message. A *real* exit_status (after state) still exits normally — the shell exiting while we reconnect must not loop. | 129 | After a reconnect-attach, if the next frame is `exit_status` before any state arrives (the session-full refusal shape, client.zig:241-243), and we were reconnecting (a flag alongside `got_state`), treat it as retryable for up to ~5s total (ghost-slot race: cap=2 and the daemon may not have reaped our dead predecessor). Past the grace: exit with the existing refusal message. A *real* exit_status (after state) still exits normally — the shell exiting while we reconnect must not loop. |
| 130 | 130 | ||
| 131 | - [ ] **Step 5: `make test && make e2e && make bench` green** | 131 | - [x] **Step 5: `make test && make e2e && make bench` green** |
| 132 | 132 | ||
| 133 | - [ ] **Step 6: Commit** `feat: transport death is a non-event — reconnect with have_seq, resume by delta` | 133 | - [x] **Step 6: Commit** `feat: transport death is a non-event — reconnect with have_seq, resume by delta` |
| 134 | 134 | ||
| 135 | --- | 135 | --- |
| 136 | 136 | ||
| @@ -139,7 +139,7 @@ After a reconnect-attach, if the next frame is `exit_status` before any state ar | |||
| 139 | **Files:** | 139 | **Files:** |
| 140 | - Modify: `test/e2e.sh` | 140 | - Modify: `test/e2e.sh` |
| 141 | 141 | ||
| 142 | - [ ] **Step 1: Scenario A — proxy killed, delta resume** | 142 | - [x] **Step 1: Scenario A — proxy killed, delta resume** |
| 143 | 143 | ||
| 144 | Append after the M6 --via scenario. Shape (implementer adapts to the file's conventions — set -eu, cleanup traps, `|| true` on the piped client): | 144 | Append after the M6 --via scenario. Shape (implementer adapts to the file's conventions — set -eu, cleanup traps, `|| true` on the piped client): |
| 145 | 145 | ||
| @@ -165,13 +165,13 @@ SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/ | |||
| 165 | 165 | ||
| 166 | Timing note for the implementer: the pkill must land while the client is mid-`sleep 6` (attached, idle). If flaky, drive the tear from a marker in `$OUT.m7` instead of sleep. The pkill pattern must not match the daemon itself — verify with a comment and a `kill -0 $DPID` assertion right after. | 166 | Timing note for the implementer: the pkill must land while the client is mid-`sleep 6` (attached, idle). If flaky, drive the tear from a marker in `$OUT.m7` instead of sleep. The pkill pattern must not match the daemon itself — verify with a comment and a `kill -0 $DPID` assertion right after. |
| 167 | 167 | ||
| 168 | - [ ] **Step 2: Scenario B — daemon killed and restarted, snapshot resume with the new epoch** | 168 | - [x] **Step 2: Scenario B — daemon killed and restarted, snapshot resume with the new epoch** |
| 169 | 169 | ||
| 170 | Kill -9 the daemon under an attached client, restart it on the same socket path, and assert the client resumes into the NEW session: a marker typed after restart echoes back, and the pre-restart marker is absent from the new daemon's dump. (This is the epoch fence doing its job end-to-end: same path, different epoch, have_seq rightly refused, snapshot served.) Note: daemon restart on the same path relies on the stale-socket recovery (`ECONNREFUSED` → unlink) — coordinate with the socket-steal fix if it hasn't landed first. | 170 | Kill -9 the daemon under an attached client, restart it on the same socket path, and assert the client resumes into the NEW session: a marker typed after restart echoes back, and the pre-restart marker is absent from the new daemon's dump. (This is the epoch fence doing its job end-to-end: same path, different epoch, have_seq rightly refused, snapshot served.) Note: daemon restart on the same path relies on the stale-socket recovery (`ECONNREFUSED` → unlink) — coordinate with the socket-steal fix if it hasn't landed first. |
| 171 | 171 | ||
| 172 | - [ ] **Step 3: `make e2e` green, three runs (timing-sensitive scenarios must not flake)** | 172 | - [x] **Step 3: `make e2e` green, three runs (timing-sensitive scenarios must not flake)** |
| 173 | 173 | ||
| 174 | - [ ] **Step 4: Commit** `test: e2e proves resume-by-delta across a transport tear and resume-by-snapshot across a daemon restart` | 174 | - [x] **Step 4: Commit** `test: e2e proves resume-by-delta across a transport tear and resume-by-snapshot across a daemon restart` |
| 175 | 175 | ||
| 176 | --- | 176 | --- |
| 177 | 177 | ||
| @@ -182,17 +182,17 @@ Kill -9 the daemon under an attached client, restart it on the same socket path, | |||
| 182 | 182 | ||
| 183 | The use case is "attach to any of my VMs": `mux vm1`, `mux ubuntu@sandbox-9b70e9`. Sugar for `--via "ssh <host> muxd proxy"` (muxd must be on the remote PATH; document in the usage string). | 183 | The use case is "attach to any of my VMs": `mux vm1`, `mux ubuntu@sandbox-9b70e9`. Sugar for `--via "ssh <host> muxd proxy"` (muxd must be on the remote PATH; document in the usage string). |
| 184 | 184 | ||
| 185 | - [ ] **Step 1: Extract arg parsing into a testable function** | 185 | - [x] **Step 1: Extract arg parsing into a testable function** |
| 186 | 186 | ||
| 187 | `fn parseArgs(args: []const [:0]const u8) ParseResult` where ParseResult is a tagged union: `{ sock: ?[]const u8, via: ?[]const u8 } | usage_error | conflict`. A single positional arg (not starting with `-`) becomes `via = "ssh <arg> muxd proxy"` (allocPrint in main after parse, or return the host and let main format). Positional + --sock or --via = conflict, same as the existing pair. | 187 | `fn parseArgs(args: []const [:0]const u8) ParseResult` where ParseResult is a tagged union: `{ sock: ?[]const u8, via: ?[]const u8 } | usage_error | conflict`. A single positional arg (not starting with `-`) becomes `via = "ssh <arg> muxd proxy"` (allocPrint in main after parse, or return the host and let main format). Positional + --sock or --via = conflict, same as the existing pair. |
| 188 | 188 | ||
| 189 | - [ ] **Step 2: Tests for parseArgs** — plain, `--sock P`, `--via C`, `host`, `user@host`, `host --sock P` (conflict), `--sock P --via C` (conflict), unknown flag (usage). Mutation check: break the positional branch, the host tests must fail. | 189 | - [x] **Step 2: Tests for parseArgs** — plain, `--sock P`, `--via C`, `host`, `user@host`, `host --sock P` (conflict), `--sock P --via C` (conflict), unknown flag (usage). Mutation check: break the positional branch, the host tests must fail. |
| 190 | 190 | ||
| 191 | - [ ] **Step 3: Update usage string** to `usage: mux [HOST | --sock PATH | --via CMD]\n HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH)` | 191 | - [x] **Step 3: Update usage string** to `usage: mux [HOST | --sock PATH | --via CMD]\n HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH)` |
| 192 | 192 | ||
| 193 | - [ ] **Step 4: `make test && make e2e` green** | 193 | - [x] **Step 4: `make test && make e2e` green** |
| 194 | 194 | ||
| 195 | - [ ] **Step 5: Commit** `feat: mux HOST attaches over ssh — the VM hop is one word` | 195 | - [x] **Step 5: Commit** `feat: mux HOST attaches over ssh — the VM hop is one word` |
| 196 | 196 | ||
| 197 | --- | 197 | --- |
| 198 | 198 | ||
| @@ -202,17 +202,17 @@ The use case is "attach to any of my VMs": `mux vm1`, `mux ubuntu@sandbox-9b70e9 | |||
| 202 | - Modify: `test/wan.sh` (env-gated phase, same MUX_WAN_* convention) | 202 | - Modify: `test/wan.sh` (env-gated phase, same MUX_WAN_* convention) |
| 203 | - Modify: `docs/decisions.md` (M7 section) | 203 | - Modify: `docs/decisions.md` (M7 section) |
| 204 | 204 | ||
| 205 | - [ ] **Step 1: wan.sh reconnect phase** | 205 | - [x] **Step 1: wan.sh reconnect phase** |
| 206 | 206 | ||
| 207 | Attached over real ssh via, kill the local ssh child N=10 times in a loop; each iteration: assert resumption (marker echoes), record time-to-first-frame after respawn, assert daemon snapshots counter unchanged across all 10 (all delta-served). Print per-iteration and summary numbers labeled like the M6 phases. Gate: all 10 resumed hands-off AND delta-served. (Resume wall-clock will be dominated by ssh channel-open exactly as M6 measured for reattach — print it labeled, don't gate on it; the M6 ruling comment in wan.sh applies and should be referenced.) | 207 | Attached over real ssh via, kill the local ssh child N=10 times in a loop; each iteration: assert resumption (marker echoes), record time-to-first-frame after respawn, assert daemon snapshots counter unchanged across all 10 (all delta-served). Print per-iteration and summary numbers labeled like the M6 phases. Gate: all 10 resumed hands-off AND delta-served. (Resume wall-clock will be dominated by ssh channel-open exactly as M6 measured for reattach — print it labeled, don't gate on it; the M6 ruling comment in wan.sh applies and should be referenced.) |
| 208 | 208 | ||
| 209 | - [ ] **Step 2: Run it against the sandbox** (`MUX_WAN_SSH`/`MUX_WAN_SCP`/`MUX_WAN_HOST` as in mux-wan-box memory / wan.sh header comment), record the numbers. | 209 | - [x] **Step 2: Run it against the sandbox** (`MUX_WAN_SSH`/`MUX_WAN_SCP`/`MUX_WAN_HOST` as in mux-wan-box memory / wan.sh header comment), record the numbers. |
| 210 | 210 | ||
| 211 | - [ ] **Step 3: decisions.md M7 section** | 211 | - [x] **Step 3: decisions.md M7 section** |
| 212 | 212 | ||
| 213 | Record: kill criterion + measured result; the three policy decisions from the plan header (dropped input, silent epoch-crossing, refusal grace); `session_epoch` graduating from parsed-but-unused; `mux HOST` sugar; what stays banked (QUIC/TLS → M8 next per user, prediction after). | 213 | Record: kill criterion + measured result; the three policy decisions from the plan header (dropped input, silent epoch-crossing, refusal grace); `session_epoch` graduating from parsed-but-unused; `mux HOST` sugar; what stays banked (QUIC/TLS → M8 next per user, prediction after). |
| 214 | 214 | ||
| 215 | - [ ] **Step 4: Commit** `feat(m7): reconnect held over the WAN — 10 tears, 10 delta resumes; the record` | 215 | - [x] **Step 4: Commit** `feat(m7): reconnect held over the WAN — 10 tears, 10 delta resumes; the record` |
| 216 | 216 | ||
| 217 | --- | 217 | --- |
| 218 | 218 | ||
test/wan.sh
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,30 @@ | |||
| 15 | # check pre-kill session state is in that first paint | 15 | # check pre-kill session state is in that first paint |
| 16 | # hol echo latency while the session floods output (probes the | 16 | # hol echo latency while the session floods output (probes the |
| 17 | # proxy's known head-of-line blocking; recorded, not gated) | 17 | # proxy's known head-of-line blocking; recorded, not gated) |
| 18 | # reconnect M7: kill the ssh under a LIVE client N times; every tear must | ||
| 19 | # resume hands-off and be served by a delta (the remote | ||
| 20 | # daemon's snapshots counter must not move) | ||
| 21 | # | ||
| 22 | # M7 kill criterion (docs/superpowers/plans/2026-08-07-m7-reconnect.md): | ||
| 23 | # 10 consecutive transport kills against a live session, every one resumed | ||
| 24 | # with zero manual action, every one delta-served. The resume wall clock is | ||
| 25 | # printed, not gated: it necessarily includes the client's first backoff | ||
| 26 | # and an ssh channel open, so gating it would rule on ssh — the same | ||
| 27 | # reasoning as the M6 reattach ruling below. | ||
| 28 | # | ||
| 29 | # NARROWED, deliberately and on the record: the plan's criterion had a | ||
| 30 | # fourth element — "replica converged after the last tear (client render | ||
| 31 | # matches `muxd dump`)" — and this harness does NOT check that. The client | ||
| 32 | # emits a stream of paints, not a grid, so comparing it to a dump means | ||
| 33 | # parsing VT in the harness: a second terminal emulator, in Python, whose | ||
| 34 | # own bugs would then be indistinguishable from the ones it is meant to | ||
| 35 | # catch. (src/server.zig's tests do make that comparison, against a real | ||
| 36 | # replica Engine — over a socket, not over the WAN.) What is checked here | ||
| 37 | # instead: each tear round-trips a marker out to the remote shell and back | ||
| 38 | # through the rebuilt transport, and after the last tear the FIRST tear's | ||
| 39 | # marker must still be in the daemon's grid — accumulated state survived | ||
| 40 | # all ten resumes. That is the substitution, it is weaker than the plan's | ||
| 41 | # wording, and the full convergence check stays banked. | ||
| 18 | # | 42 | # |
| 19 | # Kill criterion (docs/superpowers/plans/2026-08-07-m6-transport.md): | 43 | # Kill criterion (docs/superpowers/plans/2026-08-07-m6-transport.md): |
| 20 | # median echo <= baseline median + 120ms, and reattach <= ~2x the link | 44 | # median echo <= baseline median + 120ms, and reattach <= ~2x the link |
| @@ -43,6 +67,7 @@ | |||
| 43 | # MUX_WAN_SCP matching scp command string (same ControlPath) | 67 | # MUX_WAN_SCP matching scp command string (same ControlPath) |
| 44 | # MUX_WAN_HOST scp target prefix, e.g. user@host | 68 | # MUX_WAN_HOST scp target prefix, e.g. user@host |
| 45 | # MUX_WAN_NETEM 1 to also measure under `netem delay 75ms loss 1%` | 69 | # MUX_WAN_NETEM 1 to also measure under `netem delay 75ms loss 1%` |
| 70 | # MUX_WAN_REPS_RECONNECT M7 tears (default 10 — that IS the criterion) | ||
| 46 | # ZIG cross-compiling zig 0.15.2 (the Makefile's default) | 71 | # ZIG cross-compiling zig 0.15.2 (the Makefile's default) |
| 47 | set -euo pipefail | 72 | set -euo pipefail |
| 48 | 73 | ||
| @@ -74,6 +99,9 @@ REPS_BASE="${MUX_WAN_REPS_BASE:-20}" | |||
| 74 | REPS_ATTACH="${MUX_WAN_REPS_ATTACH:-3}" | 99 | REPS_ATTACH="${MUX_WAN_REPS_ATTACH:-3}" |
| 75 | REPS_REATTACH="${MUX_WAN_REPS_REATTACH:-3}" | 100 | REPS_REATTACH="${MUX_WAN_REPS_REATTACH:-3}" |
| 76 | REPS_HOL="${MUX_WAN_REPS_HOL:-5}" | 101 | REPS_HOL="${MUX_WAN_REPS_HOL:-5}" |
| 102 | # M7: the kill criterion names 10 consecutive tears; the default is the | ||
| 103 | # criterion, not a convenience. | ||
| 104 | REPS_RECONNECT="${MUX_WAN_REPS_RECONNECT:-10}" | ||
| 77 | 105 | ||
| 78 | # Unique remote names: the box may be shared, and a crashed run must never | 106 | # Unique remote names: the box may be shared, and a crashed run must never |
| 79 | # leave a socket another run mistakes for its own. | 107 | # leave a socket another run mistakes for its own. |
| @@ -187,6 +215,7 @@ numbers are tens of milliseconds and must be taken around a single write(). | |||
| 187 | Each subcommand prints human-readable lines plus one '#RESULT <name> k=v...' | 215 | Each subcommand prints human-readable lines plus one '#RESULT <name> k=v...' |
| 188 | line that wan.sh banks for the summary.""" | 216 | line that wan.sh banks for the summary.""" |
| 189 | import os | 217 | import os |
| 218 | import re | ||
| 190 | import select | 219 | import select |
| 191 | import shlex | 220 | import shlex |
| 192 | import signal | 221 | import signal |
| @@ -496,6 +525,124 @@ def cmd_reattach(argv): | |||
| 496 | report("reattach", samples, marker_ok=marker_ok) | 525 | report("reattach", samples, marker_ok=marker_ok) |
| 497 | 526 | ||
| 498 | 527 | ||
| 528 | def transport_pid(client_pid): | ||
| 529 | """The transport process under a client. | ||
| 530 | |||
| 531 | `mux --via CMD` spawns /bin/sh -c CMD, which execs ssh, so the client's | ||
| 532 | only child IS the transport. Selected by PARENT PID, never by a | ||
| 533 | command-line pattern: the client's own argv contains the whole --via | ||
| 534 | string, so a pattern kill takes out the very client under test. (That | ||
| 535 | mistake has already been made once in this project's e2e work; it is a | ||
| 536 | rule now, not a preference.) | ||
| 537 | """ | ||
| 538 | out = subprocess.run(["ps", "--ppid", str(client_pid), "-o", "pid="], | ||
| 539 | capture_output=True, text=True).stdout.split() | ||
| 540 | return int(out[0]) if out else None | ||
| 541 | |||
| 542 | |||
| 543 | def remote_dump(ssh_cmd, rbin, rsock): | ||
| 544 | """The daemon's authoritative grid, as it sees it.""" | ||
| 545 | return subprocess.run(shlex.split(ssh_cmd) + ["%s dump --sock %s" % (rbin, rsock)], | ||
| 546 | capture_output=True, text=True, timeout=60).stdout | ||
| 547 | |||
| 548 | |||
| 549 | def remote_snapshots(ssh_cmd, rbin, rsock): | ||
| 550 | """The remote daemon's snapshots counter. | ||
| 551 | |||
| 552 | This is the whole M7 assertion: a resume served by snapshot renders | ||
| 553 | exactly like one served by delta, so the counter is the only witness to | ||
| 554 | which of the two actually happened. | ||
| 555 | """ | ||
| 556 | out = subprocess.run(shlex.split(ssh_cmd) + ["%s stats --sock %s" % (rbin, rsock)], | ||
| 557 | capture_output=True, text=True, timeout=60).stdout | ||
| 558 | m = re.search(r"\bsnapshots=(\d+)", out) | ||
| 559 | if m is None: | ||
| 560 | fail("reconnect: no snapshots counter in remote stats %r" % out.strip()) | ||
| 561 | return int(m.group(1)) | ||
| 562 | |||
| 563 | |||
| 564 | def cmd_reconnect(argv): | ||
| 565 | """M7: tear the transport under a live session N times over the real | ||
| 566 | link. Every tear must resume hands-off — nothing relaunches the client, | ||
| 567 | nothing retypes for it — and every resume must be served by a delta. | ||
| 568 | |||
| 569 | Timing note: the marker is typed only AFTER the resume is observed, not | ||
| 570 | before. Input typed while disconnected is dropped by policy, so typing | ||
| 571 | into the gap would measure the policy rather than the reconnect. | ||
| 572 | |||
| 573 | On the convergence check: the plan's criterion asked for the client's | ||
| 574 | render to match `muxd dump` after the last tear. That is NOT what this | ||
| 575 | measures — see the header note. What it does measure is that the FIRST | ||
| 576 | tear's marker is still in the daemon's grid after the LAST one, which | ||
| 577 | is the accumulated-state half of that claim and costs one ssh call. | ||
| 578 | """ | ||
| 579 | mux, via, reps, errlog = argv[0], argv[1], int(argv[2]), argv[3] | ||
| 580 | ssh_cmd, rbin, rsock = argv[4], argv[5], argv[6] | ||
| 581 | |||
| 582 | c = Client(mux, via, errlog) | ||
| 583 | samples = [] | ||
| 584 | resumed = 0 | ||
| 585 | snaps_before = snaps_after = -1 | ||
| 586 | first_marker = None | ||
| 587 | survived = 0 | ||
| 588 | try: | ||
| 589 | settle(c, "reconnect") | ||
| 590 | # Start the run on a cleared screen so all `reps` markers fit in one | ||
| 591 | # 24-row viewport (two rows each: the echoed command and its | ||
| 592 | # output). Without this the survival check below would be a coin | ||
| 593 | # flip on whether the first marker had scrolled out of the grid — | ||
| 594 | # `muxd dump` shows the viewport, not the scrollback. | ||
| 595 | c.send(b"\x15clear\n") | ||
| 596 | wait_idle(c, "reconnect") | ||
| 597 | # Taken after the session is up and quiet, so the first attach's own | ||
| 598 | # snapshot is already counted and anything after it is a resume. | ||
| 599 | snaps_before = remote_snapshots(ssh_cmd, rbin, rsock) | ||
| 600 | for i in range(reps): | ||
| 601 | child = transport_pid(c.p.pid) | ||
| 602 | if child is None: | ||
| 603 | fail("reconnect: rep %d found no transport to tear" % i) | ||
| 604 | before = c.total | ||
| 605 | t0 = now() | ||
| 606 | os.kill(child, signal.SIGKILL) | ||
| 607 | |||
| 608 | # First frame after the tear: the client rebuilt the transport, | ||
| 609 | # re-attached, and was answered. Hands-off from the kill to here. | ||
| 610 | if c.wait_for(lambda b, c=c, t=before: c.total > t) is None: | ||
| 611 | fail("reconnect: rep %d never resumed after the transport died" % i) | ||
| 612 | samples.append((now() - t0) * 1000.0) | ||
| 613 | resumed += 1 | ||
| 614 | |||
| 615 | # ...and the session is really usable again, not merely painted: | ||
| 616 | # a round trip through the new transport, out to bash and back. | ||
| 617 | nonce = "%d-%d" % (os.getpid(), i) | ||
| 618 | marker = ("wanre-" + nonce).encode() | ||
| 619 | if first_marker is None: | ||
| 620 | first_marker = marker | ||
| 621 | c.send(b'\x15printf "wanre-%s\\n" ' + nonce.encode() + b"\n") | ||
| 622 | if c.wait_for(lambda b, m=marker: m in b) is None: | ||
| 623 | fail("reconnect: rep %d resumed but the input path is dead" % i) | ||
| 624 | print(" reconnect rep %-2d first frame %7.1fms session usable" | ||
| 625 | % (i, samples[-1])) | ||
| 626 | wait_idle(c, "reconnect") | ||
| 627 | snaps_after = remote_snapshots(ssh_cmd, rbin, rsock) | ||
| 628 | |||
| 629 | # Accumulated state survived every resume: work from before the | ||
| 630 | # first tear is still on the daemon's grid after the last one. The | ||
| 631 | # per-rep markers cannot show this — each only proves the session | ||
| 632 | # was usable at that moment, not that nothing was lost along the way. | ||
| 633 | dump = remote_dump(ssh_cmd, rbin, rsock) | ||
| 634 | survived = 1 if first_marker.decode() in dump else 0 | ||
| 635 | if not survived: | ||
| 636 | print(" reconnect: FIRST TEAR'S MARKER %s IS GONE from the grid" | ||
| 637 | % first_marker.decode()) | ||
| 638 | finally: | ||
| 639 | c.detach() | ||
| 640 | report("reconnect", samples, resumed=resumed, reps=reps, | ||
| 641 | snaps_before=snaps_before, snaps_after=snaps_after, | ||
| 642 | delta_served=1 if snaps_after == snaps_before else 0, | ||
| 643 | first_survived=survived) | ||
| 644 | |||
| 645 | |||
| 499 | def cmd_hol(argv): | 646 | def cmd_hol(argv): |
| 500 | """Echo latency while the session floods output. The proxy is a byte | 647 | """Echo latency while the session floods output. The proxy is a byte |
| 501 | pump with no notion of priority, so a flood shares the pipe with the | 648 | pump with no notion of priority, so a flood shares the pipe with the |
| @@ -557,6 +704,7 @@ COMMANDS = { | |||
| 557 | "attach": cmd_attach, | 704 | "attach": cmd_attach, |
| 558 | "echo": cmd_echo, | 705 | "echo": cmd_echo, |
| 559 | "reattach": cmd_reattach, | 706 | "reattach": cmd_reattach, |
| 707 | "reconnect": cmd_reconnect, | ||
| 560 | "hol": cmd_hol, | 708 | "hol": cmd_hol, |
| 561 | } | 709 | } |
| 562 | 710 | ||
| @@ -604,6 +752,14 @@ measure clean echo "$MUX" "$VIA" "$REPS_ECHO" "$ERRLOG" | |||
| 604 | say "reattach after kill -9 ($REPS_REATTACH reps)" | 752 | say "reattach after kill -9 ($REPS_REATTACH reps)" |
| 605 | measure clean reattach "$MUX" "$VIA" "$REPS_REATTACH" "$ERRLOG" | 753 | measure clean reattach "$MUX" "$VIA" "$REPS_REATTACH" "$ERRLOG" |
| 606 | 754 | ||
| 755 | # M7. Measured on the clean link only: the criterion is about resuming at | ||
| 756 | # all, hands-off, and being served a delta while doing it — none of which | ||
| 757 | # netem's added delay makes more or less true, and each tear costs a real | ||
| 758 | # ssh channel open. | ||
| 759 | say "M7 reconnect: tear the transport under a live session ($REPS_RECONNECT tears)" | ||
| 760 | measure clean reconnect "$MUX" "$VIA" "$REPS_RECONNECT" "$ERRLOG" \ | ||
| 761 | "$MUX_WAN_SSH" "$RBIN" "$RSOCK" | ||
| 762 | |||
| 607 | # Every clean-link figure is taken before the box is degraded, so a netem | 763 | # Every clean-link figure is taken before the box is degraded, so a netem |
| 608 | # that failed to clear can never quietly relabel itself as the clean link. | 764 | # that failed to clear can never quietly relabel itself as the clean link. |
| 609 | say "head-of-line probe: echo while the session floods output ($REPS_HOL reps)" | 765 | say "head-of-line probe: echo while the session floods output ($REPS_HOL reps)" |
| @@ -704,16 +860,57 @@ phase_block() { | |||
| 704 | printf ' attach (fresh client, same path): med %s\n' "$attach_med" | 860 | printf ' attach (fresh client, same path): med %s\n' "$attach_med" |
| 705 | } | 861 | } |
| 706 | 862 | ||
| 863 | # M7's own criterion: resumption is pass/fail, and the resume kind is read | ||
| 864 | # off the daemon's counters. The wall-clock column is printed and not gated, | ||
| 865 | # for the same reason the M6 reattach ruling above gives — every resume pays | ||
| 866 | # an ssh channel open (`viafloor`) before a protocol byte can move, so gating | ||
| 867 | # it would rule on ssh, not on this design. | ||
| 868 | m7_block() { | ||
| 869 | local resumed reps snaps_b snaps_a delta_served verdict floor survived | ||
| 870 | resumed="$(val clean reconnect resumed)" | ||
| 871 | [ -n "$resumed" ] || return 0 | ||
| 872 | reps="$(val clean reconnect reps)" | ||
| 873 | snaps_b="$(val clean reconnect snaps_before)" | ||
| 874 | snaps_a="$(val clean reconnect snaps_after)" | ||
| 875 | delta_served="$(val clean reconnect delta_served)" | ||
| 876 | floor="$(val clean viafloor med)" | ||
| 877 | |||
| 878 | echo | ||
| 879 | echo "M7 reconnect (transport torn under a live session):" | ||
| 880 | printf ' %-34s %8s %8s %8s %5s\n' "" min med max n | ||
| 881 | printf ' %-34s %8s %8s %8s %5s\n' "first frame after tear" \ | ||
| 882 | "$(val clean reconnect min)" "$(val clean reconnect med)" \ | ||
| 883 | "$(val clean reconnect max)" "$(val clean reconnect n)" | ||
| 884 | echo " (milliseconds; includes the ~200ms first backoff and one ssh" | ||
| 885 | echo " channel open, measured just above as viafloor med ${floor}ms —" | ||
| 886 | echo " printed, not gated, per the reattach ruling above)" | ||
| 887 | printf ' resumed hands-off: %s of %s\n' "$resumed" "$reps" | ||
| 888 | printf ' daemon snapshots across all tears: %s -> %s (%s)\n' \ | ||
| 889 | "$snaps_b" "$snaps_a" \ | ||
| 890 | "$([ "$delta_served" = 1 ] && echo "unchanged: every resume delta-served" \ | ||
| 891 | || echo "CHANGED: a resume fell back to a full snapshot")" | ||
| 892 | survived="$(val clean reconnect first_survived)" | ||
| 893 | printf ' first tear'"'"'s marker still on the grid after the last: %s\n' \ | ||
| 894 | "$([ "$survived" = 1 ] && echo yes || echo NO)" | ||
| 895 | verdict=FAIL | ||
| 896 | [ "$resumed" = "$reps" ] && [ "$delta_served" = 1 ] && [ "$survived" = 1 ] && | ||
| 897 | verdict=PASS | ||
| 898 | printf ' M7 criterion: %s tears, all resumed, all delta-served, state kept -> %s\n' \ | ||
| 899 | "$reps" "$verdict" | ||
| 900 | [ "$verdict" = PASS ] || FAILED=1 | ||
| 901 | } | ||
| 902 | |||
| 707 | echo | 903 | echo |
| 708 | echo "=================== M6 WAN measurement summary ===================" | 904 | echo "=================== WAN measurement summary ======================" |
| 709 | echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ) host: (ephemeral, from MUX_WAN_SSH)" | 905 | echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ) host: (ephemeral, from MUX_WAN_SSH)" |
| 710 | phase_block clean "link as-is:" | 906 | phase_block clean "link as-is:" |
| 711 | phase_block netem "with netem delay 75ms loss 1%:" | 907 | phase_block netem "with netem delay 75ms loss 1%:" |
| 908 | m7_block | ||
| 712 | echo | 909 | echo |
| 713 | if [ "$FAILED" -eq 0 ]; then | 910 | if [ "$FAILED" -eq 0 ]; then |
| 714 | echo "M6 kill criterion: PASS" | 911 | echo "kill criteria (M6 + M7): PASS" |
| 715 | else | 912 | else |
| 716 | echo "M6 kill criterion: FAIL — report the numbers; do not tune the thresholds." | 913 | echo "kill criteria (M6 + M7): FAIL — report the numbers; do not tune the thresholds." |
| 717 | fi | 914 | fi |
| 718 | echo "==================================================================" | 915 | echo "==================================================================" |
| 719 | exit "$FAILED" | 916 | exit "$FAILED" |