aa247b30
docs: M14 closed — ssh→QUIC handoff
a73x 2026-08-12 06:34
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -53,12 +53,18 @@ mux HOST # attach; Ctrl-\ detaches, running it again reattaches | |||
| 53 | `muxd start` spawns the daemon detached and waits until it answers, so it | 53 | `muxd start` spawns the daemon detached and waits until it answers, so it |
| 54 | is safe to run every time — if one is already up it says so and exits 0. | 54 | is safe to run every time — if one is already up it says so and exits 0. |
| 55 | 55 | ||
| 56 | `mux HOST` is sugar for `mux --via "ssh HOST muxd proxy"` — ssh config | 56 | `mux HOST` runs `ssh HOST muxd endpoint` — ssh config (aliases, ports, |
| 57 | (aliases, ports, ProxyJump) all keeps working, since `mux` never parses | 57 | ProxyJump) all keeps working, since `mux` never parses HOST. That ssh |
| 58 | HOST. It **is** interpolated into a shell command, so HOST must be trusted: | 58 | fetches the daemon's QUIC port and key once and the session moves onto |
| 59 | feeding it unreviewed from an inventory file or a cloud API is handing that | 59 | QUIC, leaving no ssh process behind; the coordinates are cached under |
| 60 | source a shell. `--via` accepts any command that exposes the daemon's | 60 | `~/.cache/mux/hosts/`, so later attaches dial QUIC straight away and skip |
| 61 | socket on stdio, for custom socket paths or transports. | 61 | ssh entirely. Where inbound UDP is blocked, the attach lands over ssh one |
| 62 | deadline (~2s) later, saying so in a single line, and the session is the | ||
| 63 | same session either way. HOST **is** interpolated into a shell command, so | ||
| 64 | it must be trusted: feeding it unreviewed from an inventory file or a | ||
| 65 | cloud API is handing that source a shell. `--via` accepts any command that | ||
| 66 | exposes the daemon's socket on stdio, for custom socket paths or | ||
| 67 | transports. | ||
| 62 | 68 | ||
| 63 | If the link drops, the client reconnects and resumes by itself; `Ctrl-\` | 69 | If the link drops, the client reconnects and resumes by itself; `Ctrl-\` |
| 64 | gives up waiting. | 70 | gives up waiting. |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -2203,6 +2203,287 @@ before and after; no product code was left mutated. | |||
| 2203 | mutation sweep, ASAN/valgrind over the QUIC tests, a `make gate` for | 2203 | mutation sweep, ASAN/valgrind over the QUIC tests, a `make gate` for |
| 2204 | loaded runs, and the M12 pty-scenario timeout notes. | 2204 | loaded runs, and the M12 pty-scenario timeout notes. |
| 2205 | 2205 | ||
| 2206 | ## 2026-08-11 (M14 — the ssh→QUIC handoff) | ||
| 2207 | |||
| 2208 | **Verdict: cleared, both legs.** `mux HOST` stopped being sugar for an | ||
| 2209 | ssh pipe and became a transport negotiation: one ssh to `muxd endpoint`, | ||
| 2210 | whose first stdout line is a mandatory announce of the daemon's QUIC port | ||
| 2211 | and key, then QUIC carries the session and the coordination ssh is | ||
| 2212 | reaped. The coordinates are cached, so the second attach spawns no ssh at | ||
| 2213 | all. Leg 1: the suite grew to **20 scenario checkpoints over 33 | ||
| 2214 | convergence points**, both pinned as literals, with `SOAK_N=10 make soak` | ||
| 2215 | at **10/10 on the code that ships** (d9b17d5). Leg 2: three | ||
| 2216 | resurrections, three catches, each legible at the check that predicted | ||
| 2217 | it. The kill criterion held on the real LAN box (`192.168.0.109`) with | ||
| 2218 | `muxd` on the remote PATH as the one manual step. Spec: | ||
| 2219 | superpowers/specs/2026-08-11-m14-ssh-quic-handoff-design.md. | ||
| 2220 | |||
| 2221 | ### The regrade table | ||
| 2222 | |||
| 2223 | One sitting, one tree, one set of binaries; `make build && make test && | ||
| 2224 | make e2e` green before, green after, `git status` clean between every | ||
| 2225 | row. Each mutant was required to **compile** — a build failure | ||
| 2226 | masquerading as a kill grades nothing. | ||
| 2227 | |||
| 2228 | | Resurrection | Predicted catch | Verdict | | ||
| 2229 | | --- | --- | --- | | ||
| 2230 | | `serviceObserver`'s `endpoint_req` arm deleted | unit: `Server: endpoint_req binds a listener lazily…` fails first and cheapest; e2e (a) fails too | **CAUGHT** (20s unit, 128s e2e) | | ||
| 2231 | | `endpointCmd`'s announce write deleted | e2e (a) fails at its bounded wait with the scenario's own message, the announce absent from the dump | **CAUGHT** (149s) | | ||
| 2232 | | `openHandoff`'s fallback stderr line deleted | e2e (d) fails its grep while (c)'s absent-grep control stays green in the same run | **CAUGHT** (156s) | | ||
| 2233 | |||
| 2234 | **The observer arm — the unit layer answered first, as predicted, and | ||
| 2235 | named the missing thing itself.** `make test` failed at | ||
| 2236 | `src/server.zig:4634` with `return error.NoEndpointReply`, which is the | ||
| 2237 | whole diagnosis in one identifier, 20 seconds after the mutation. The | ||
| 2238 | e2e corroboration landed too, but **not at the assertion the prediction | ||
| 2239 | named**: (a) died at `e2e FAIL: cold handoff: QUIC took over, so ssh | ||
| 2240 | must be gone: pid 2931354 is still running 2s later`, not at the later | ||
| 2241 | cache/QUIC assertion, because that is the earliest check in (a) the | ||
| 2242 | mutation reaches. The prediction was right about the scenario and wrong | ||
| 2243 | about the line — which is finding 3 below, at assertion granularity | ||
| 2244 | rather than scenario granularity. | ||
| 2245 | |||
| 2246 | **The announce write — the failure says why, not just that.** Both lines | ||
| 2247 | of the scenario's own message printed: | ||
| 2248 | |||
| 2249 | ``` | ||
| 2250 | e2e FAIL: cold handoff: no marker in 25s — the attach never converged. | ||
| 2251 | A client that blocks here read no announce line off the ssh pipe. | ||
| 2252 | muxd endpoint: starting… | ||
| 2253 | up (0.1s) pid=2940304 | ||
| 2254 | mux: aborted before attaching | ||
| 2255 | ``` | ||
| 2256 | |||
| 2257 | The diagnostic dump is what separates this kill from Task 7's | ||
| 2258 | stderr-routing kill, which produces the same first line: here `muxd | ||
| 2259 | endpoint`'s own preamble is present and the `endpoint …` announce is | ||
| 2260 | simply absent from it. The mutation as the plan first wrote it — moving | ||
| 2261 | the write below `return proxy.run(…)` — does not compile: Zig rejects | ||
| 2262 | unreachable code outright, so the sanctioned alternative was taken and | ||
| 2263 | the write deleted. Recorded because "each mutant must compile" and "make | ||
| 2264 | the announce unreachable" were, in this language, incompatible | ||
| 2265 | instructions. | ||
| 2266 | |||
| 2267 | **The fallback line — the control proved it was a control.** (d) failed | ||
| 2268 | with `e2e FAIL: key mismatch printed no fallback line in 15s; its | ||
| 2269 | stderr was:` and an empty capture, in a run whose immediately preceding | ||
| 2270 | line was `e2e OK: a stale cache self-heals: one refetch (2231ms), no | ||
| 2271 | fallback line, real port cached`. (c) asserts that line is *absent* and | ||
| 2272 | (d) asserts it is *present*; deleting the line flipped exactly one of | ||
| 2273 | them. A pair like that is the only way an absent-grep assertion can be | ||
| 2274 | shown to be load-bearing rather than vacuous. | ||
| 2275 | |||
| 2276 | ### The kill criterion, on the LAN box (2026-08-11) | ||
| 2277 | |||
| 2278 | `ubuntu@192.168.0.109`, a static musl `muxd` scp'd to the box and put on | ||
| 2279 | PATH — the one allowed manual step. Both ends zeroed first: remote | ||
| 2280 | daemon stopped, `~/.config/mux/key` removed, local cache entry removed, | ||
| 2281 | every local `mux` run under hermetic `XDG_*` so the developer's own key | ||
| 2282 | and cache were never touched. All three legs then ran with **zero | ||
| 2283 | further manual steps**. | ||
| 2284 | |||
| 2285 | | Leg | Wall | What was observed | | ||
| 2286 | | --- | --- | --- | | ||
| 2287 | | Cold | **244ms** attach → first marker | ssh child (`ssh ubuntu@192.168.0.109 muxd endpoint`, pid 2959493) gone by `kill -0` at the moment the marker painted, while `mux` stayed alive and a second marker sent 8s later still painted | | ||
| 2288 | | Warm | **13ms** attach → marker | **no ssh child at any point** — polled across the whole session, not just the attach | | ||
| 2289 | | Blocked, warm cache | **4264ms** | fallback line printed, session landed over ssh, exit 0 | | ||
| 2290 | | Blocked, cold cache | **2177ms** | same line, one deadline + 177ms | | ||
| 2291 | |||
| 2292 | The cold leg's announce was `endpoint 56407 8560…a307`, written to the | ||
| 2293 | cache verbatim; on the box `ss -uapn` showed `UNCONN 0.0.0.0:56407 | ||
| 2294 | users:(("muxd",pid=17958,fd=6))` and `muxd stats` reported `clients=1 | ||
| 2295 | deltas=3` — a session being served by deltas over a listener that did | ||
| 2296 | not exist before the attach asked for it. The blocked legs dropped | ||
| 2297 | inbound UDP with `iptables -A INPUT -p udp --dport 56407 -j DROP` and | ||
| 2298 | both printed the exact coordinates they had tried: `mux: | ||
| 2299 | quic://192.168.0.109:56407 unreachable, attaching over ssh`. The rule | ||
| 2300 | was removed on the way out and the removal proved by listing the chain | ||
| 2301 | (`-P INPUT ACCEPT`, nothing matching `--dport 56407`), not by trusting | ||
| 2302 | `iptables -D`'s exit status. Teardown was `muxd stop` over ssh: `muxd: | ||
| 2303 | stopped`, exit 0, then `pgrep -a muxd` empty, no UDP listener, socket | ||
| 2304 | file gone. | ||
| 2305 | |||
| 2306 | **The two blocked numbers are the interesting result, and they are not | ||
| 2307 | the same case.** From a **cold** cache the blackhole costs **one** | ||
| 2308 | deadline, because the ssh that carried the announce is still open and | ||
| 2309 | *is* the fallback transport. From a **warm** cache it costs **two**: the | ||
| 2310 | cached dial spends a deadline, then the refetch over ssh returns the same | ||
| 2311 | (still-blocked) port and spends another before falling back. The spec's | ||
| 2312 | criterion says "within one deadline" and the cold leg meets it as | ||
| 2313 | written; the warm leg is recorded at 4264ms rather than reconciled, | ||
| 2314 | because it is what a user on a UDP-blocked network actually pays on | ||
| 2315 | every attach after their first. It is the direct cost of refusing | ||
| 2316 | negative caching, which remains the right call — network state changes, | ||
| 2317 | and a cached "UDP is blocked" would strand a user on ssh after their | ||
| 2318 | firewall was fixed. | ||
| 2319 | |||
| 2320 | **A stale daemon from M10 had to be killed by pid, not stopped.** The | ||
| 2321 | box was still running the Aug-9 binary, which predates `stop_req` | ||
| 2322 | entirely, so `muxd stop` printed usage and exited 2. It was killed by | ||
| 2323 | its tracked pid and the absence confirmed with `pgrep`. Not part of the | ||
| 2324 | criterion — state zeroing, not attaching — but the shape is worth | ||
| 2325 | knowing: the stop verb is only as available as the binary on the far | ||
| 2326 | side, and a box that has been up across a milestone boundary may not | ||
| 2327 | have it. | ||
| 2328 | |||
| 2329 | ### What shipped | ||
| 2330 | |||
| 2331 | `endpoint_req = 0x08` / `endpoint_reply = 0x89` (`src/protocol.zig`), | ||
| 2332 | with an `encode`/`decode` pair and golden wire bytes pinned as literals, | ||
| 2333 | following the rule M10 extracted — assert the literal, never the | ||
| 2334 | constant the code under test reads. | ||
| 2335 | |||
| 2336 | The daemon's **lazy QUIC bind** (`src/server.zig`). `endpoint_req` is | ||
| 2337 | answered by binding a listener on demand if one is not already up, and | ||
| 2338 | the load-bearing arm is `serviceObserver` for M13's reason: `muxd | ||
| 2339 | endpoint` never attaches, so it is an observer for its whole life. A | ||
| 2340 | `quic_owned` flag records whether the listener was created by this path, | ||
| 2341 | so `deinit` hands back exactly what it took and a daemon started with an | ||
| 2342 | explicit `--quic` keeps ownership of its own. | ||
| 2343 | |||
| 2344 | `src/handoff.zig` — one grammar, one place. Cache read/write, announce | ||
| 2345 | `format`/`parse`, the dial-host strip and the deadline constant all live | ||
| 2346 | in a pure module with no sockets and no processes, so the entire surface | ||
| 2347 | tests without a daemon. **The announce is mandatory in both | ||
| 2348 | directions**: `endpoint none` is an explicit negative rather than | ||
| 2349 | silence. The spec was amended (dd6c511) to say so after the alternative | ||
| 2350 | was found to be unresolvable — at the reading end, "the remote has no | ||
| 2351 | QUIC to offer" and "ssh is being slow" are the same zero bytes, and the | ||
| 2352 | client blocks on one line either way. | ||
| 2353 | |||
| 2354 | `muxd endpoint` (`src/main.zig`) — `ensureForAttach`, then key | ||
| 2355 | resolution matching the daemon's exactly (`MUX_KEY_FILE`, then the | ||
| 2356 | default path, and only the default is ever created), then the announce | ||
| 2357 | as the **first** stdout bytes, then the byte-for-byte `muxd proxy` pump. | ||
| 2358 | Every unhappy path announces `none` and stays on ssh rather than | ||
| 2359 | panicking: a bug in a preamble must not take down a session ssh was | ||
| 2360 | about to carry perfectly well. | ||
| 2361 | |||
| 2362 | Client-side, the handoff sits at the **Transport** layer | ||
| 2363 | (`src/client.zig`), not above it, so a reconnect re-runs it rather than | ||
| 2364 | resurrecting a dead recipe. Cache first, ssh second. The fallback line | ||
| 2365 | is attach-only (`report_fallback`) so a flapping link cannot turn it | ||
| 2366 | into a log. `readAnnounceAbortable` has **no timeout by design** — it is | ||
| 2367 | bounded by ssh itself, the same trust `--via` already extends to the | ||
| 2368 | command it spawns — and it honors the carry contract, holding | ||
| 2369 | non-abort bytes typed during the wait rather than dropping them. | ||
| 2370 | |||
| 2371 | ### The deadline, pinned from measurement (Task 4, 2026-08-11) | ||
| 2372 | |||
| 2373 | **There is no fast-failure case.** A wrong PSK against a live listener | ||
| 2374 | ran 2037ms of a 2000ms budget, 3049 of 3000, 8031 of 8000: mutual auth | ||
| 2375 | means the listener does not answer a peer it cannot authenticate, so | ||
| 2376 | silence is all the client ever gets. A blackholed UDP port did the same | ||
| 2377 | (4001 of 4000, 8001 of 8000). So did an unbound port **on loopback**, | ||
| 2378 | 2051ms of 2000 — the ICMP refusal is real, but it lands on the `sendto` | ||
| 2379 | inside `Client.drain`, whose `catch return` discards it, so the | ||
| 2380 | `ConnectionRefused` branch in `readable` never sees it. Every failure | ||
| 2381 | runs the budget out, and the deadline is the only thing that ends any of | ||
| 2382 | them. | ||
| 2383 | |||
| 2384 | That fixes the shape of the trade, and 2000 stands. Below it: a real | ||
| 2385 | handshake is **4ms** local, **6.9ms** cold attach on the M8 LAN, and | ||
| 2386 | **234ms** at 75ms RTT — 3.1× RTT, because every fresh Initial costs a | ||
| 2387 | Retry round trip — so 2000 covers RTT to ~645ms, past any terrestrial | ||
| 2388 | link. Above it: with negative caching refused, every attach on a | ||
| 2389 | UDP-blocked network pays this in full, so it wants to be as low as the | ||
| 2390 | floor allows. 1000 would halve the tax and also halve the RTT ceiling to | ||
| 2391 | ~320ms — buying latency on a degraded network at the price of abandoning | ||
| 2392 | QUIC paths that would have worked. Wrong way round. | ||
| 2393 | |||
| 2394 | ### Findings | ||
| 2395 | |||
| 2396 | - **A wedged `zig test` step prints nothing at all.** Not a truncated | ||
| 2397 | report, not a partial list — nothing, for the whole timeout. So the | ||
| 2398 | ordering rule from M13 ("a regrade catch must be legible") needs a | ||
| 2399 | second clause: a legible catch must also be ordered **before** any | ||
| 2400 | test the same regression can hang. The ownership pin is the case that | ||
| 2401 | taught it. Left where it was, the mutation produced a 600-second empty | ||
| 2402 | wedge with no output to read; moved ahead of the test that hangs, the | ||
| 2403 | same mutation produces 8 named failures in 14 seconds. | ||
| 2404 | - **The suite was running the developer's login shell.** Hermetic | ||
| 2405 | `XDG_*` homes cannot make a suite hermetic while it still executes | ||
| 2406 | arbitrary user rc files: a zsh plugin manager cloning a repository at | ||
| 2407 | shell startup ate a convergence window and failed a scenario that had | ||
| 2408 | nothing to do with it. `SHELL` is now pinned suite-wide. The general | ||
| 2409 | form is that "hermetic" has to include the *code* the environment | ||
| 2410 | causes to run, not only the paths it points at. | ||
| 2411 | - **A mutation only proves the earliest scenario that catches it.** | ||
| 2412 | First-failure exit means every later scenario is untested against that | ||
| 2413 | break, and a campaign that reports "five kills" is silently reporting | ||
| 2414 | five *first* kills. Task 7's five left scenario (e) and (c)'s | ||
| 2415 | self-heal arm unproven until the accounting was audited scenario by | ||
| 2416 | scenario; re-running against each in turn brought the real total to | ||
| 2417 | eight. Kill counts are claims about ordering as much as about | ||
| 2418 | coverage. | ||
| 2419 | - **Review prescriptions remain fallible in both directions, and the | ||
| 2420 | pipeline caught both.** A spec reviewer prescribed a "complementary" | ||
| 2421 | post-hoc liveness check on the reaped ssh; it was **unfalsifiable** — | ||
| 2422 | dead processes do not resurrect, and a reused pid presents as alive — | ||
| 2423 | so it could only ever pass or lie, and an independent review proved | ||
| 2424 | that and deleted it. The same review corrected the lead's own `set | ||
| 2425 | +e` alignment. M13's rule holds: an implementer rejecting a bad | ||
| 2426 | prescription with evidence is the process working in the direction it | ||
| 2427 | is easier to forget it runs. | ||
| 2428 | - **`waitReady` answers the detach chord *during* a dial.** That is | ||
| 2429 | correct behavior — a user who presses Ctrl-\ while waiting asked to | ||
| 2430 | stop — but it means any scenario built to spend a deadline must hold | ||
| 2431 | its detach bytes out past the budget, or the client aborts early and | ||
| 2432 | the scenario passes having tested nothing. A timing assertion whose | ||
| 2433 | subject can be cancelled by its own fixture is not an assertion. | ||
| 2434 | - **A fixed port must dodge the kernel's ephemeral range.** Read | ||
| 2435 | `/proc/sys/net/ipv4/ip_local_port_range` and stay out of it: a port | ||
| 2436 | chosen for being dead can come alive mid-suite when something | ||
| 2437 | unrelated is handed it, and the scenario that depended on nothing | ||
| 2438 | listening there then fails for a reason no one will find by reading | ||
| 2439 | it. | ||
| 2440 | |||
| 2441 | ### Errata | ||
| 2442 | |||
| 2443 | The commit message of bde1af5 states `kill -0`'s polarity backwards. The | ||
| 2444 | code and its comments are correct; only the prose in that message is | ||
| 2445 | wrong. Recorded here rather than fixed by rewriting history, which is | ||
| 2446 | the standing preference — a wrong sentence with a correction beside it | ||
| 2447 | is more honest than a history that never contained it. | ||
| 2448 | |||
| 2449 | ### Process note | ||
| 2450 | |||
| 2451 | Subagent pipeline as M11–M13: one implementer and two reviewers per | ||
| 2452 | task, quality and spec, every new assertion written mutation-first. One | ||
| 2453 | new shape is worth recording. A spec reviewer was blocked by the | ||
| 2454 | permission classifier on `make e2e`, three times, situationally rather | ||
| 2455 | than by rule. It did not work around the block, invent an equivalent | ||
| 2456 | command, or report a verdict it had not earned: it stopped, reverted its | ||
| 2457 | own edit, and handed the exact change to the implementer to run. That is | ||
| 2458 | the correct response to a tool refusal, and it is recorded because the | ||
| 2459 | tempting alternatives all produce a green report that means nothing. | ||
| 2460 | |||
| 2461 | ### Banked by M14 | ||
| 2462 | |||
| 2463 | - **`drain()` swallows `ECONNREFUSED`** — the Task 4 incidental, and an | ||
| 2464 | M15 candidate. Fixing it would make unreachable ports fail in ~1 RTT | ||
| 2465 | instead of a full deadline. **Re-pin, do not delete:** e2e (d)'s | ||
| 2466 | `elapsed >= floor` bound and `client.zig`'s dead-coordinates test | ||
| 2467 | bound both currently lean on the quirk, and both would need re-timing | ||
| 2468 | in the same change. | ||
| 2469 | - **The transport recipe as a `union(enum)`** — from Task 6's quality | ||
| 2470 | review. `Transport.open` now takes four mutually-exclusive nullables, | ||
| 2471 | two of them sharing a type; `union(enum) { sock, via, quic, hand }` | ||
| 2472 | makes the invariant unstatable-wrong rather than documented. M15. | ||
| 2473 | - **Per-client keys, certificates and TOFU** — still parked, unchanged | ||
| 2474 | since M10. The handoff hands out the *same* shared key over ssh, which | ||
| 2475 | is exactly the trust model M10 chose and no worse; per-client | ||
| 2476 | credentials remain the thing that would change it. | ||
| 2477 | - **Negative caching of "UDP blocked"** — still refused, by design, and | ||
| 2478 | the LAN box's 4264ms warm-blocked attach is now the measured price of | ||
| 2479 | that refusal rather than an estimate of it. | ||
| 2480 | - **The WAN-box RTT on record disagrees with itself** — ~290ms in one | ||
| 2481 | place against the 15.7–16.5ms baseline at decisions.md:314. Verify | ||
| 2482 | against the live box before the next measurement leans on either. | ||
| 2483 | - **Scenario (e) has two unexercised assertions** — its absent-fallback | ||
| 2484 | grep and its no-budget bound were never driven red. Cheap candidates | ||
| 2485 | for the next regrade, and cheaper than the audit that found them. | ||
| 2486 | |||
| 2206 | ## Open (owed by later milestones) | 2487 | ## Open (owed by later milestones) |
| 2207 | 2488 | ||
| 2208 | - Scrollback retention *tuning*. The policy itself was decided in M1 and | 2489 | - Scrollback retention *tuning*. The policy itself was decided in M1 and |
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ The forward view, one item per line, ranked. History and evidence live in | |||
| 5 | this file at each milestone close and whenever the queue reorders; the | 5 | this file at each milestone close and whenever the queue reorders; the |
| 6 | queue's order is set by the user, not by this file. | 6 | queue's order is set by the user, not by this file. |
| 7 | 7 | ||
| 8 | **Now:** M1–M13 complete; `v0.0.1-2` published as a Linux tarball; in | 8 | **Now:** M1–M14 complete; `v0.0.1-2` published as a Linux tarball; in |
| 9 | field trial on real VMs, and the trial is now producing the queue. | 9 | field trial on real VMs, and the trial is now producing the queue. |
| 10 | Trial feedback outranks everything below — what actually hurts in use is | 10 | Trial feedback outranks everything below — what actually hurts in use is |
| 11 | better data than any of this ranking, and the proof is that the three | 11 | better data than any of this ranking, and the proof is that the three |
| @@ -117,18 +117,45 @@ truncation is reserved for `muxd start`; and three review prescriptions | |||
| 117 | were rejected with evidence, which is the adversarial flow running in | 117 | were rejected with evidence, which is the adversarial flow running in |
| 118 | the direction it is easier to forget. | 118 | the direction it is easier to forget. |
| 119 | 119 | ||
| 120 | ## M14 candidates — and still outranked by trial feedback | 120 | ## M14 — ssh→QUIC handoff — complete |
| 121 | 121 | ||
| 122 | The trial-friction bundle that used to head this section shipped as M13. | 122 | **Verdict: cleared, both legs.** `mux HOST` no longer chooses a |
| 123 | What remains is what was already ranked behind it, plus two items M13 | 123 | transport; it earns one. An ssh to `muxd endpoint` fetches the daemon's |
| 124 | created. Nothing here is a field finding; the next one that arrives | 124 | QUIC port and key as a mandatory one-line announce, the session moves |
| 125 | outranks all of it. | 125 | onto QUIC and the coordination ssh is reaped, and the coordinates are |
| 126 | 126 | cached so every later attach dials direct with no ssh process at all. | |
| 127 | - **ssh→QUIC handoff**: `muxd endpoint` prints port+key over ssh; | 127 | Leg 1 held at **20 scenario checkpoints over 33 convergence points**, |
| 128 | client caches both, tries QUIC first under a ~2s deadline, and the | 128 | both pinned as literals, with `SOAK_N=10 make soak` at **10/10 on the |
| 129 | coordination ssh stays alive as the fallback proxy. Full sketch — | 129 | code that ships** (d9b17d5). Leg 2 is three for three, each catch |
| 130 | including the key-outlives-the-daemon model and the stale-cache | 130 | legible at the check that predicted it. The kill criterion held on the |
| 131 | invariant — in the M10 spec's context section. | 131 | real LAN box with the one allowed manual step: cold attach **244ms** |
| 132 | with the ssh child observed dead while the session went on answering, | ||
| 133 | warm attach **13ms** with zero ssh spawned, and a blackholed UDP port | ||
| 134 | still landing the session over ssh, one deadline and one line later | ||
| 135 | (decisions.md, M14, which carries the numbers and the regrade table). | ||
| 136 | En route: the announce had to become **mandatory in both directions** | ||
| 137 | because silence and a slow ssh are indistinguishable at the reading end | ||
| 138 | — `endpoint none` is now an explicit negative — and the deadline was | ||
| 139 | pinned from measurement showing there is **no fast-failure case** at | ||
| 140 | all, not even a refused loopback port. | ||
| 141 | |||
| 142 | ## M15 candidates — and still outranked by trial feedback | ||
| 143 | |||
| 144 | What remains is what was already ranked behind M13 and M14, plus two | ||
| 145 | items M14 created. Nothing here is a field finding; the next one that | ||
| 146 | arrives outranks all of it. | ||
| 147 | |||
| 148 | - **`drain()` swallows `ECONNREFUSED`** — created by M14 and the | ||
| 149 | cheapest win on the list. On a connected UDP socket the queued ICMP | ||
| 150 | refusal is consumed by the `sendto` in `Client.drain`, whose `catch | ||
| 151 | return` discards it, so the `ConnectionRefused` branch never fires and | ||
| 152 | an unreachable port costs the full 2s deadline instead of ~1 RTT. | ||
| 153 | Fixing it re-times two existing bounds rather than deleting them | ||
| 154 | (decisions.md, M14). | ||
| 155 | - **The transport recipe as a `union(enum)`** — also from M14. | ||
| 156 | `Transport.open` takes four mutually-exclusive nullables, two of them | ||
| 157 | sharing a type; `union(enum) { sock, via, quic, hand }` would make the | ||
| 158 | invariant unstatable-wrong instead of merely documented. | ||
| 132 | - **First-backoff tuning**: a reconnect after a tear can pay a 200ms–2s | 159 | - **First-backoff tuning**: a reconnect after a tear can pay a 200ms–2s |
| 133 | backoff step; measured ~4x win available on fast links, ~2x on slow | 160 | backoff step; measured ~4x win available on fast links, ~2x on slow |
| 134 | (decisions.md, M7). | 161 | (decisions.md, M7). |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -2581,9 +2581,9 @@ HT0=$(date +%s%N) | |||
| 2581 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ | 2581 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ |
| 2582 | "$MUX" "127.0.0.1" > "$OUT.h4" 2> "$OUT.h4.err" & | 2582 | "$MUX" "127.0.0.1" > "$OUT.h4" 2> "$OUT.h4.err" & |
| 2583 | H4PID=$! | 2583 | H4PID=$! |
| 2584 | # Timed to the LINE rather than to the client's exit: the line is printed | 2584 | # Timed to the LINE rather than to the client's exit, so no session sleeps |
| 2585 | # the moment the budget runs out, so the elapsed time here IS the deadline | 2585 | # are folded in. The elapsed time is the dial's budget plus launch overhead |
| 2586 | # the dial spent, with no session sleeps folded into it. | 2586 | # (measured 26-129ms), which only inflates it — the floor is safe. |
| 2587 | # Only the stderr is printed on failure, deliberately: the complaint is | 2587 | # Only the stderr is printed on failure, deliberately: the complaint is |
| 2588 | # about a line that is missing from it, and the capture beside it is a | 2588 | # about a line that is missing from it, and the capture beside it is a |
| 2589 | # screenful of escape sequences that would bury the answer. | 2589 | # screenful of escape sequences that would bury the answer. |