702b0416
docs: ssh's stderr is mux's, and its last line is the reason
a73x 2026-08-30 10:58
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -234,7 +234,12 @@ paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`: | |||
| 234 | rename either: a ≤15 daemon probes the candidate for `muxd <version>` and | 234 | rename either: a ≤15 daemon probes the candidate for `muxd <version>` and |
| 235 | this binary answers `mux <version>`, so that one migration is | 235 | this binary answers `mux <version>`, so that one migration is |
| 236 | `mux d stop` then `mux d start -d` — as is 16 to the next release, whose | 236 | `mux d stop` then `mux d start -d` — as is 16 to the next release, whose |
| 237 | daemon execs its candidate as `d run` and finds no such verb. | 237 | daemon execs its candidate as `d run` and finds no such verb. That ssh's |
| 238 | STDERR is a pipe mux reads, never a writer on somebody's screen: its last | ||
| 239 | line is the dial's `handoff.Reason` — quoted by the picker row | ||
| 240 | (`unreachable: <reason>`) and by the entry dial's failure line — and only | ||
| 241 | the entry dial (`HandoffTarget.narrate`) relays the bytes onward, to its | ||
| 242 | own fd 2, and only until the wall takes the screen. | ||
| 238 | - **The layout sidecar is derived convenience, not authored intent.** | 243 | - **The layout sidecar is derived convenience, not authored intent.** |
| 239 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from | 244 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from |
| 240 | every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall | 245 | every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -197,7 +197,11 @@ the `ssh HOST 'mux d start -d'` line above is optional after the first time. | |||
| 197 | Nothing else starts a daemon, because nothing else spells the flag: the | 197 | Nothing else starts a daemon, because nothing else spells the flag: the |
| 198 | wall's per-host poll, `mux hosts`, every reconnect and `mux --via 'ssh HOST | 198 | wall's per-host poll, `mux hosts`, every reconnect and `mux --via 'ssh HOST |
| 199 | mux d proxy'` run the bare `mux d endpoint`, report the box unreachable and | 199 | mux d proxy'` run the bare `mux d endpoint`, report the box unreachable and |
| 200 | leave it alone. Your | 200 | leave it alone. When that poll fails, the picker row says why in ssh's own |
| 201 | words — `unreachable: ssh: connect to host box port 22: No route to host` — | ||
| 202 | because mux reads that ssh's stderr rather than letting it print over the | ||
| 203 | wall; `mux HOST` relays the same bytes to your terminal as they come, being | ||
| 204 | the one dial you are sitting in front of. Your | ||
| 201 | ssh config (aliases, ports, | 205 | ssh config (aliases, ports, |
| 202 | ProxyJump) all keeps working, since `mux` never parses HOST. That ssh | 206 | ProxyJump) all keeps working, since `mux` never parses HOST. That ssh |
| 203 | fetches the daemon's QUIC port and key once and the session moves onto | 207 | fetches the daemon's QUIC port and key once and the session moves onto |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7546,3 +7546,24 @@ body-capable request that declared no length now closes its connection. | |||
| 7546 | - **The `use_pipe` rows are not vestigial.** Through `ssh -J gate box` the | 7546 | - **The `use_pipe` rows are not vestigial.** Through `ssh -J gate box` the |
| 7547 | announced port is unreachable by construction — the jump host is the only | 7547 | announced port is unreachable by construction — the jump host is the only |
| 7548 | route and it carries TCP — so the pipe is that host's only session. | 7548 | route and it carries TCP — so the pipe is that host's only session. |
| 7549 | |||
| 7550 | ## 2026-08-29 — the handoff ssh's stderr is a pipe mux reads | ||
| 7551 | |||
| 7552 | - **The bug.** A hosts line naming a box that is down: the wall polls it over | ||
| 7553 | ssh, ssh dies with `No route to host`, and that line landed on the wall's | ||
| 7554 | ALTERNATE SCREEN, from a writer no repaint of ours can reach — the child's | ||
| 7555 | stderr was inherited. `quiet` was the workaround (picker Enter only), and | ||
| 7556 | `.Ignore` threw the sentence away rather than keeping it. | ||
| 7557 | - **The rule.** The spawn always pipes; mux reads that fd in the announce poll | ||
| 7558 | and on the `Transport` after it, where the pump and the hub poll it beside | ||
| 7559 | the link. Unread it fills at 64k and ssh stops moving the session's bytes at | ||
| 7560 | all — asserted with a child that floods 1 MiB before it serves. | ||
| 7561 | - **`narrate` replaces `quiet`, inverted.** The entry dial alone relays, to its | ||
| 7562 | own fd 2, until the wall takes the screen; silence is the default, so a path | ||
| 7563 | that forgets the field cannot corrupt a paint. `--via` keeps `.Inherit`. | ||
| 7564 | - **What the line buys.** `handoff.Reason` survives fragmentation, keeps | ||
| 7565 | printable ASCII only (the row paints raw, and C1 has a two-byte UTF-8 | ||
| 7566 | spelling) and caps at 120. The row budgets it against its spare width, since | ||
| 7567 | `pickerRow` cuts the spelling and never the state. It beats both old failure | ||
| 7568 | lines: `UnterminatedLine` named what mux observed, this names what happened. | ||
| 7569 | - **Deferred.** ssh's PROMPTS (`SSH_ASKPASS`); the dead-host poll pacing. | ||