888e14a7
docs: the two worst narrators stop telling the story
a73x 2026-08-23 08:56
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -5434,3 +5434,115 @@ same file, executable, and it fails when the claim stops being true. Writing | |||
| 5434 | text to justify text (an inline "reviewed" marker, an accept-list of forgiven | 5434 | text to justify text (an inline "reviewed" marker, an accept-list of forgiven |
| 5435 | blocks) was considered and rejected: its existence should be justification | 5435 | blocks) was considered and rejected: its existence should be justification |
| 5436 | enough. | 5436 | enough. |
| 5437 | |||
| 5438 | ## 2026-08-23 — what the client's doc blocks were carrying | ||
| 5439 | |||
| 5440 | The tier-3 burn-down of `client.zig` moved these here rather than deleting | ||
| 5441 | them. Each was a paragraph in a doc block, and each is a fact about a past | ||
| 5442 | run or a past compile, which is this file's job and not the source's. | ||
| 5443 | |||
| 5444 | - **`quicTransport` is type-load-bearing on `error.UserAbort`.** It is the | ||
| 5445 | only source of that error either switch in `openHandoff` can see — | ||
| 5446 | `readAnnounceAbortable` is a second source on the handoff path overall, but | ||
| 5447 | its abort arrives before these are reached. Deleting the call narrows the | ||
| 5448 | inferred error set and the compiler refuses `error.UserAbort` as "not a | ||
| 5449 | member of destination error set". Verified by mutation, not assumed. | ||
| 5450 | - **Why the handshake wait lives inside `quicTransport` and not its caller.** | ||
| 5451 | `connect` only creates state; the first flight has not been answered. A | ||
| 5452 | transport returned before that makes "open succeeded" mean something weaker | ||
| 5453 | for QUIC than for every other transport, and the reconnect loop believes | ||
| 5454 | it — counting a dead attempt as live, firing an attach into a connection | ||
| 5455 | that never completes, and repeating on the next pass. Against a daemon that | ||
| 5456 | was merely paused this left a trail of half-open connections and duplicate | ||
| 5457 | attaches. | ||
| 5458 | - **`waitReady`'s bound is the only thing that ends a blackholed dial.** A | ||
| 5459 | refused port now ends the wait early: quic_client surfaces the ICMP refusal | ||
| 5460 | from whichever syscall the kernel hands it to. A blackholed one produces no | ||
| 5461 | error at all. | ||
| 5462 | - **`recordTile` has no tty check, deliberately.** A test fixture on a real | ||
| 5463 | pty is a human attach by the mechanical rule ("attaches at nonzero size"), | ||
| 5464 | which is fine because the suite runs under an isolated `XDG_STATE_HOME`. | ||
| 5465 | - **`recordOnState` replaced a "the dial succeeded" seam.** A first attach | ||
| 5466 | the daemon refused — its session table is full at four — exited 1 with the | ||
| 5467 | line stranded, naming a session that never existed; so did an error out of | ||
| 5468 | the pump before any state. Only a SWITCH's refusal had a fallback and so a | ||
| 5469 | place to undo the write. See also the `state_since_attach` entry above. | ||
| 5470 | |||
| 5471 | ## 2026-08-23 — what the wall's doc blocks were carrying | ||
| 5472 | |||
| 5473 | Same burn-down, `wallview.zig`. Each of these was a paragraph in a doc block | ||
| 5474 | and is a rule the tests already assert or a fact about a past run. | ||
| 5475 | |||
| 5476 | - **`wallMouse`: a plain click is `MouseDown1Pane -> select-pane`,** | ||
| 5477 | deliberately — it is what the user already has in their hands from tmux. A | ||
| 5478 | DRAG highlights the lines it crossed, inside the stripe it started in, and | ||
| 5479 | the highlight stands when the button comes up. Left button only: middle is | ||
| 5480 | the terminal's own paste and right its menu, and stealing either would be a | ||
| 5481 | surprise the wall has no answer for. The button word arrives from the filter | ||
| 5482 | verbatim, motion and modifier bits included, so the low two bits name the | ||
| 5483 | button. The keyboard never PAINTS the highlight — it writes the drag under | ||
| 5484 | `paint_mu` and rings, and the pumps draw it inside the stripe paints they | ||
| 5485 | were doing anyway; anything drawn from the key loop would be overwritten by | ||
| 5486 | the next frame from the session. | ||
| 5487 | - **`tilePaintBegin` is the only enforcement point, and used to be four.** | ||
| 5488 | The promote repaint, the frame repaint, the expiry repaint and the | ||
| 5489 | speculation each spelled the zoom test themselves, and any one of them | ||
| 5490 | forgetting it was a prediction glyph painted onto whichever session the | ||
| 5491 | zoom had moved to. A demoted tile's Core now cannot write a grid byte at | ||
| 5492 | all: no rows, no overlay, nothing. | ||
| 5493 | - **`endWith` ringing was measured, not reasoned about.** It hung a piped | ||
| 5494 | `mux` whose shell had exited: the keyboard's test for a finished tile is | ||
| 5495 | `!alive`, so a ring before that store wakes the keyboard, which finds the | ||
| 5496 | tile still alive, drains the bell and sleeps again — and the news never | ||
| 5497 | arrives. | ||
| 5498 | - **`sendKeys`: a mouse report CUT by a read boundary is the one uncovered | ||
| 5499 | corner.** It is the same split class `interact.MouseFilter` admits to at its | ||
| 5500 | own edge. The wall holds the head; `setZoom` drops it (`WallInput.reset`), | ||
| 5501 | since the bytes describe a screen that has changed hands; the next read | ||
| 5502 | arrives zoomed and its tail reaches `sendKeys` as keystrokes, so a shell can | ||
| 5503 | be handed `0;3;2M`. It needs a terminal that splits one report across two | ||
| 5504 | writes AND a zoom landing in the gap. Named rather than defended against: | ||
| 5505 | the alternative is holding a partial report across the transition, which is | ||
| 5506 | state about a screen nobody is looking at any more. | ||
| 5507 | - **`zoomChord`: `w` unzooms, `d` detaches, and they used to be one key.** | ||
| 5508 | While the zoom was still a child-spawn's replacement, `d` meant "come back | ||
| 5509 | from this tile". Now that `mux TARGET` is itself a wall entered zoomed, `d` | ||
| 5510 | has to keep meaning what it means to every user's fingers — detach and | ||
| 5511 | leave mux — or the muscle memory the convergence is judged by is the first | ||
| 5512 | thing it breaks. `n`/`p`/`c` are NOT tile motion: they move the zoom around | ||
| 5513 | the DAEMON's session ring, the plain client's discipline moved a layer out, | ||
| 5514 | while the wall's selector (`j`/`k`/digits) still walks tiles. `Ctrl-\ l` | ||
| 5515 | with nowhere to go back to unzooms rather than guessing, which is also the | ||
| 5516 | answer a tile that has since been FORGOTTEN gets — hence `present` is | ||
| 5517 | consulted and not just the length — and the answer `l` aimed at the tile | ||
| 5518 | already zoomed gets, as tmux's `prefix-l` does: "go where I was" cannot mean | ||
| 5519 | "stay here". | ||
| 5520 | - **`forgetTile` on a tile that is not in the wall file** — a spelling named | ||
| 5521 | on `mux wall`'s own command line — forgets it from the VIEW just the same, | ||
| 5522 | silently: the file had nothing to remove and the screen is the answer either | ||
| 5523 | way. A file error is remembered rather than printed, because this terminal | ||
| 5524 | is on the alternate screen and a stray line would corrupt the paint, and is | ||
| 5525 | said once on the way out. | ||
| 5526 | - **`showsSelf` drops a self-tile only because the list was auto-built** — | ||
| 5527 | the saved wall, restored on an unzoom nobody spelled out — so a name the | ||
| 5528 | user never typed can leave without contradicting anything they said. `mux | ||
| 5529 | wall HOST#a` refuses the WHOLE wall instead (`mux_main`), for the opposite | ||
| 5530 | reason: those tiles are what was asked for, and silently omitting one would | ||
| 5531 | be a wall lying about what it shows. The predicate is the true | ||
| 5532 | socket-and-session pair, unix sockets only, and an emptied variable counts | ||
| 5533 | as unset — `MUX_SESSION=` is the documented way to override the refusal. | ||
| 5534 | - **`endAction`'s first half:** a session that ends under the zoom ends MUX | ||
| 5535 | when it was the wall's only tile — which is every plain `mux TARGET`, so the | ||
| 5536 | shell's exit code propagates as it always did — and drops to the wall when | ||
| 5537 | it was not, because there is still something to look at. | ||
| 5538 | - **`awaitingSession` was `state != .up` once.** That cost a zoom into a | ||
| 5539 | refused tile its only way out: the `w` of `\x1cw` never arrived, because the | ||
| 5540 | `\x1c` had already been spent quitting. | ||
| 5541 | - **`runAttach`: a first contact with a host reaches ssh through a shell,** | ||
| 5542 | which can want the tty for a hostkey prompt or a password; the abort key has | ||
| 5543 | to work while it waits; and a dial that never came up owes the user the | ||
| 5544 | sentence and exit code the plain client always gave (`client.openFailure`). | ||
| 5545 | None of that is a pump's to do. | ||
| 5546 | - **`relayout` failing keeps the old geometry.** Forgetting a tile can only | ||
| 5547 | give the survivors MORE rows, so `TooSmall` there is unreachable except for | ||
| 5548 | the empty wall, which is handled before it. | ||
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 1 | client_core_wasm_check.zig 0 | 1 | client_core_wasm_check.zig 0 |
| 2 | client_core.zig 756 | 2 | client_core.zig 756 |
| 3 | client.zig 16274 | 3 | client.zig 0 |
| 4 | cmd.zig 117 | 4 | cmd.zig 117 |
| 5 | delta.zig 1659 | 5 | delta.zig 1659 |
| 6 | docscheck.zig 209 | 6 | docscheck.zig 209 |
| @@ -26,7 +26,7 @@ shellint.zig 2945 | |||
| 26 | sockpath.zig 1119 | 26 | sockpath.zig 1119 |
| 27 | spawn.zig 2471 | 27 | spawn.zig 2471 |
| 28 | testtmp.zig 0 | 28 | testtmp.zig 0 |
| 29 | wallview.zig 27094 | 29 | wallview.zig 0 |
| 30 | wall.zig 3731 | 30 | wall.zig 3731 |
| 31 | wasm_core.zig 2841 | 31 | wasm_core.zig 2841 |
| 32 | webhub_main.zig 496 | 32 | webhub_main.zig 496 |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,26 +1,17 @@ | |||
| 1 | //! The LINK, and nothing above it. | 1 | //! The LINK, and nothing above it. |
| 2 | //! | 2 | //! |
| 3 | //! What this file owns is how a `mux` reaches a muxd and what it says when | 3 | //! How a `mux` reaches a muxd and what it says on arrival: the `Target` |
| 4 | //! it gets there: the `Target` grammar, the dial (unix socket, `--via` | 4 | //! grammar, the dial (unix socket, `--via` pipe, direct QUIC, the ssh→QUIC |
| 5 | //! pipe, direct QUIC, the ssh→QUIC handoff), the failure vocabulary a dial | 5 | //! handoff), the vocabulary a dial that never came up exits with, the attach |
| 6 | //! that did not come up exits with, the attach frame, the reconnect | 6 | //! frame, the reconnect backoff, the wall-file record an attach earns, and |
| 7 | //! backoff schedule, the wall-file record an attach earns, and the pure | 7 | //! the pure answers a `Ctrl-\ c`/`n`/`p` needs out of a `sessions_reply`. |
| 8 | //! answers a `Ctrl-\ c`/`n`/`p` needs out of a `sessions_reply`. | ||
| 9 | //! | 8 | //! |
| 10 | //! What it no longer owns is a SESSION. The wall-home-screen work | 9 | //! Not a SESSION. There is ONE interaction loop and it is wallview.zig's |
| 11 | //! converged the two interaction loops that existed here and in | 10 | //! tile pump, driving the entry tile exactly as every other; a converged |
| 12 | //! wallview.zig into one: `mux [TARGET]` is now a wall of one tile, entered | 11 | //! binary with a second copy of that loop linked here is not converged. What |
| 13 | //! zoomed, so the loop that drives an `interact.Core` over a link — the | 12 | //! crosses the seam is `Transport` — opened by whoever holds the tty, |
| 14 | //! chord meanings, the reconnect, the terminal's lifetime, the exit code — | 13 | //! `adopt`ed by the thread that will own it — plus the pure helpers the pump |
| 15 | //! is a tile pump in wallview.zig, for the entry tile exactly as for every | 14 | //! asks for by name. Everything at the terminal is interact.zig's. |
| 16 | //! other. There is deliberately no second copy of it here: a converged | ||
| 17 | //! binary with the old loop still linked is not converged. | ||
| 18 | //! | ||
| 19 | //! The seam that remains is `Transport` (opened by whoever has the tty, | ||
| 20 | //! `adopt`ed by the thread that will own it) plus the small pure helpers | ||
| 21 | //! the pump asks for by name. Everything at the terminal — the replica, | ||
| 22 | //! the paints, prediction, the chord and mouse filters — was always | ||
| 23 | //! interact.zig's and still is. | ||
| 24 | const std = @import("std"); | 15 | const std = @import("std"); |
| 25 | const Replica = @import("replica").Replica; | 16 | const Replica = @import("replica").Replica; |
| 26 | // For `interact.detach_key` only — the abort byte both opening waits watch | 17 | // For `interact.detach_key` only — the abort byte both opening waits watch |
| @@ -54,19 +45,8 @@ pub const SessionName = struct { | |||
| 54 | } | 45 | } |
| 55 | }; | 46 | }; |
| 56 | 47 | ||
| 57 | /// The name a chord (`c`/`n`/`p`) reached, or null for "stay put". | 48 | /// `SessionName.of` memcpys with no bound of its own, and ring names arrive |
| 58 | /// | 49 | /// in a peer's `sessions_reply` — so the guard sits where `of` is reached. |
| 59 | /// The validation is the point. `SessionName.of` memcpys into a | ||
| 60 | /// `session_name_max` buffer with no bound of its own, so a name longer | ||
| 61 | /// than that is a write past the array — and the ring's names come | ||
| 62 | /// straight out of a peer's `sessions_reply` payload, which this client | ||
| 63 | /// did not author and cannot vouch for. `nextFreeName` can only produce a | ||
| 64 | /// valid name, but the guard sits on the path all three share rather than | ||
| 65 | /// on each producer: the unsafe step is `of`, and everything reaching it | ||
| 66 | /// should have been checked once, here. | ||
| 67 | /// | ||
| 68 | /// An unusable pick stays put, the same answer a step with nowhere to go | ||
| 69 | /// gives — a peer talking nonsense is not a reason to move the zoom. | ||
| 70 | pub fn validPick(pick: ?[]const u8) ?SessionName { | 50 | pub fn validPick(pick: ?[]const u8) ?SessionName { |
| 71 | const name = pick orelse return null; | 51 | const name = pick orelse return null; |
| 72 | if (!proto.validSessionName(name)) return null; | 52 | if (!proto.validSessionName(name)) return null; |
| @@ -105,26 +85,18 @@ pub fn nextFreeName(out: *[proto.session_name_max]u8, list: []const u8) []const | |||
| 105 | /// Which way `Ctrl-\ n` / `Ctrl-\ p` step around the ring. | 85 | /// Which way `Ctrl-\ n` / `Ctrl-\ p` step around the ring. |
| 106 | pub const Ring = enum { next, prev }; | 86 | pub const Ring = enum { next, prev }; |
| 107 | 87 | ||
| 108 | /// The name a step lands on: the neighbour of `current` in `list` (a | 88 | /// The neighbour of `current` in a `sessions_reply` payload (names |
| 109 | /// `sessions_reply` payload, names '\n'-separated in the daemon's slot | 89 | /// '\n'-separated, daemon slot order), wrapping at both ends. Slot order is |
| 110 | /// order), wrapping at both ends. Slot order is what makes this a ring | 90 | /// stable across replies, so `n` then `p` returns you where you started. |
| 111 | /// rather than a shuffle: the daemon reports it the same way every time, | ||
| 112 | /// so `n` then `p` returns you to where you started. | ||
| 113 | /// | 91 | /// |
| 114 | /// `current` is the name this client attached under, which for the default | 92 | /// `current` is the name this client attached under — the empty string for |
| 115 | /// session is the empty string — the wire's spelling of it — while the | 93 | /// the default session — while the list carries the resolved name; resolving |
| 116 | /// list always carries the resolved name. Resolving here rather than at | 94 | /// here keeps the empty case off every call site. |
| 117 | /// the call site keeps the empty case from being a rule the caller has to | ||
| 118 | /// remember. | ||
| 119 | /// | 95 | /// |
| 120 | /// Null means "nowhere to go", and the caller must treat it as a no-op | 96 | /// Null is "nowhere to go", never "switch to self": re-attaching to the |
| 121 | /// rather than a switch to self: detaching and re-attaching to the session | 97 | /// session on screen repaints the same grid after tearing it down. A |
| 122 | /// already on screen tears the terminal down and paints the same grid back, | 98 | /// `current` the list does not name gets null too — it died between request |
| 123 | /// which is a visible flicker in exchange for nothing. Two ways to get it: | 99 | /// and reply, and a neighbour of a gone position is arbitrary. |
| 124 | /// a one-session daemon, and a `current` the list does not name at all — | ||
| 125 | /// which means the session died between the request and the reply, and | ||
| 126 | /// guessing a neighbour for a position that no longer exists would move | ||
| 127 | /// the user somewhere arbitrary. | ||
| 128 | pub fn ringNeighbour(list: []const u8, current: []const u8, dir: Ring) ?[]const u8 { | 100 | pub fn ringNeighbour(list: []const u8, current: []const u8, dir: Ring) ?[]const u8 { |
| 129 | const want = proto.resolveName(current); | 101 | const want = proto.resolveName(current); |
| 130 | var count: usize = 0; | 102 | var count: usize = 0; |
| @@ -189,9 +161,8 @@ pub const PendingSwitch = struct { | |||
| 189 | return self.intent; | 161 | return self.intent; |
| 190 | } | 162 | } |
| 191 | 163 | ||
| 192 | /// True exactly once per armed intent — the deadline passed unanswered, | 164 | /// True exactly once per armed intent — self-clearing, so a poll loop |
| 193 | /// and the caller owes the user a word about it. Self-clearing so the | 165 | /// cannot report the same silence twice. |
| 194 | /// caller cannot report the same silence on every poll. | ||
| 195 | pub fn expired(self: *PendingSwitch, now: i64) bool { | 166 | pub fn expired(self: *PendingSwitch, now: i64) bool { |
| 196 | if (self.intent == .none or now < self.until) return false; | 167 | if (self.intent == .none or now < self.until) return false; |
| 197 | self.intent = .none; | 168 | self.intent = .none; |
| @@ -205,15 +176,9 @@ pub const PendingSwitch = struct { | |||
| 205 | /// blindness is the point: transport is a swap, not a redesign. | 176 | /// blindness is the point: transport is a swap, not a redesign. |
| 206 | const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t }; | 177 | const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t }; |
| 207 | 178 | ||
| 208 | /// What came off the transport. Three outcomes, not two, and the third is | 179 | /// Three outcomes, not two: QUIC's socket goes readable for acks and half |
| 209 | /// the reason this is a union rather than an optional. | 180 | /// frames, so `null` cannot keep the socket path's meaning of "peer gone" |
| 210 | /// | 181 | /// without making every partial frame reconnect. |
| 211 | /// A socket or a `--via` pipe is readable exactly when a frame's bytes are | ||
| 212 | /// there, so "nothing to read" has always meant "the peer is gone". A QUIC | ||
| 213 | /// transport breaks that: its UDP socket goes readable for acks, for | ||
| 214 | /// handshake flights, for a packet carrying half a frame — traffic that is | ||
| 215 | /// the transport working, not ending. Collapsing that into the same `null` | ||
| 216 | /// the socket path uses would make every partial frame trigger a reconnect. | ||
| 217 | pub const Incoming = union(enum) { | 182 | pub const Incoming = union(enum) { |
| 218 | frame: proto.Frame, | 183 | frame: proto.Frame, |
| 219 | /// The transport is healthy; there is simply no whole frame yet. | 184 | /// The transport is healthy; there is simply no whole frame yet. |
| @@ -222,26 +187,9 @@ pub const Incoming = union(enum) { | |||
| 222 | closed, | 187 | closed, |
| 223 | }; | 188 | }; |
| 224 | 189 | ||
| 225 | /// What to say when the link dies. A `--via` transport that died before a | 190 | /// A `--via` that died before the first frame carried no connection, and the |
| 226 | /// single frame arrived never carried a connection at all — ssh refused, the | 191 | /// cause is already on that command's stderr. `session_epoch` tells the two |
| 227 | /// host is unreachable, or `muxd` is not on its PATH — and "connection to | 192 | /// apart: set from the first snapshot, never reset. |
| 228 | /// muxd lost" claims one existed and blames the wrong end. Once any frame | ||
| 229 | /// has arrived the old wording is the true one, so it stays. | ||
| 230 | /// | ||
| 231 | /// Which of those causes it was is not this line's to say: the transport | ||
| 232 | /// command's own stderr passes through untouched and has already named it. | ||
| 233 | /// So the message states only what is certain — the command failed, no | ||
| 234 | /// session ever started — and leaves the cause to the layer that saw it. | ||
| 235 | /// | ||
| 236 | /// `session_epoch` is the signal because it is set from the first snapshot | ||
| 237 | /// and never reset; callers before the first read pass 0, which is what it | ||
| 238 | /// is at that point by construction. | ||
| 239 | /// | ||
| 240 | /// Only `--via` gets the new wording: a unix socket or a `quic://` target | ||
| 241 | /// has no command to have failed, and saying so there would be its own lie. | ||
| 242 | /// The recipe is passed whole for the caller's convenience, but only its tag | ||
| 243 | /// is read — the command string is the transport's own stderr to report, not | ||
| 244 | /// this line's. | ||
| 245 | pub fn lostMsg(target: Target, session_epoch: u64) []const u8 { | 193 | pub fn lostMsg(target: Target, session_epoch: u64) []const u8 { |
| 246 | if (target == .via and session_epoch == 0) | 194 | if (target == .via and session_epoch == 0) |
| 247 | return "mux: transport command failed before a session started"; | 195 | return "mux: transport command failed before a session started"; |
| @@ -300,10 +248,8 @@ pub const HandoffTarget = struct { | |||
| 300 | report_fallback: bool = true, | 248 | report_fallback: bool = true, |
| 301 | }; | 249 | }; |
| 302 | 250 | ||
| 303 | /// What the user asked for — the recipe a (re)connect runs. A union rather | 251 | /// A union, not four nullable fields — "exactly one is set" stops being a |
| 304 | /// than four nullable parameters because "exactly one of them is set" was a | 252 | /// rule a caller must remember. |
| 305 | /// rule a comment asked callers to keep; here it is the only shape the type | ||
| 306 | /// can hold. | ||
| 307 | pub const Target = union(enum) { | 253 | pub const Target = union(enum) { |
| 308 | sock: []const u8, | 254 | sock: []const u8, |
| 309 | via: []const u8, | 255 | via: []const u8, |
| @@ -364,28 +310,9 @@ pub const Transport = struct { | |||
| 364 | }; | 310 | }; |
| 365 | } | 311 | } |
| 366 | 312 | ||
| 367 | /// One QUIC dial: connect, wait out the handshake, wrap. | 313 | /// `connect` only creates state, so the handshake wait belongs here: |
| 368 | /// | 314 | /// "open succeeded" means the same on every transport. `budget_ms` is |
| 369 | /// The wait belongs in here rather than in the caller. `connect` only | 315 | /// the attach budget, not `idle_ms`. |
| 370 | /// creates state — the first flight has not been answered yet — so | ||
| 371 | /// returning without it would make "open succeeded" mean something | ||
| 372 | /// different for QUIC than for every other transport, and the reconnect | ||
| 373 | /// loop believes it: it would count a dead attempt as a live one, fire | ||
| 374 | /// an attach into a connection that never completes, and do it again on | ||
| 375 | /// the next pass. Against a daemon that was merely paused, that left a | ||
| 376 | /// trail of half-open connections and duplicate attaches. | ||
| 377 | /// | ||
| 378 | /// It is also the only source of `error.UserAbort` either switch can | ||
| 379 | /// see — `readAnnounceAbortable` is a second source on the handoff path | ||
| 380 | /// overall, but its abort arrives before these ones are reached — which | ||
| 381 | /// makes the abort arms in `openHandoff`'s two switches | ||
| 382 | /// type-load-bearing: deleting this call narrows the inferred error set | ||
| 383 | /// and the compiler refuses `error.UserAbort` as "not a member of | ||
| 384 | /// destination error set". Verified by mutation, not assumed. | ||
| 385 | /// | ||
| 386 | /// `budget_ms` is the attach budget and is deliberately not `idle_ms`: | ||
| 387 | /// a 2s attach budget must not become a 2s idle timeout on the session | ||
| 388 | /// it opens. | ||
| 389 | fn quicTransport( | 316 | fn quicTransport( |
| 390 | alloc: std.mem.Allocator, | 317 | alloc: std.mem.Allocator, |
| 391 | addr: std.net.Address, | 318 | addr: std.net.Address, |
| @@ -442,12 +369,8 @@ pub const Transport = struct { | |||
| 442 | /// for why one file can now have two writers. | 369 | /// for why one file can now have two writers. |
| 443 | var cache_write_mu: std.Thread.Mutex = .{}; | 370 | var cache_write_mu: std.Thread.Mutex = .{}; |
| 444 | 371 | ||
| 445 | /// The handoff, in transport terms. Warm: cached coordinates, dial | 372 | /// The pipe that carried the announce IS the QUIC fallback, so a |
| 446 | /// direct, no ssh process at all. Cold: one ssh child whose first stdout | 373 | /// UDP-blocked network costs one deadline, not two. |
| 447 | /// line is the mandatory announce; QUIC success kills it, QUIC failure | ||
| 448 | /// keeps it — the pipe that carried the announce IS the fallback | ||
| 449 | /// transport, which is what makes a UDP-blocked network cost one | ||
| 450 | /// deadline instead of two. | ||
| 451 | pub fn openHandoff( | 374 | pub fn openHandoff( |
| 452 | alloc: std.mem.Allocator, | 375 | alloc: std.mem.Allocator, |
| 453 | h: HandoffTarget, | 376 | h: HandoffTarget, |
| @@ -540,9 +463,8 @@ pub const Transport = struct { | |||
| 540 | } | 463 | } |
| 541 | } | 464 | } |
| 542 | 465 | ||
| 543 | /// The `quic://` open, from announce coordinates instead of a key file. | ||
| 544 | /// The dial's budget is the handoff deadline while the CONNECTION keeps | 466 | /// The dial's budget is the handoff deadline while the CONNECTION keeps |
| 545 | /// the ordinary idle_ms; `quicTransport` is where that split is spelled. | 467 | /// the ordinary `idle_ms`; `quicTransport` spells that split. |
| 546 | pub fn openQuicEndpoint( | 468 | pub fn openQuicEndpoint( |
| 547 | alloc: std.mem.Allocator, | 469 | alloc: std.mem.Allocator, |
| 548 | h: HandoffTarget, | 470 | h: HandoffTarget, |
| @@ -555,31 +477,14 @@ pub const Transport = struct { | |||
| 555 | return quicTransport(alloc, addr, key, h.idle_ms, h.deadline_ms, carry, abort_fd); | 477 | return quicTransport(alloc, addr, key, h.idle_ms, h.deadline_ms, carry, abort_fd); |
| 556 | } | 478 | } |
| 557 | 479 | ||
| 558 | /// The descriptor to wait on for readability. | 480 | /// Not where frames come from: QUIC polls UDP, reads frames above it. |
| 559 | /// | ||
| 560 | /// For a socket and for `--via` this is also where frames are read | ||
| 561 | /// from, which is exactly the assumption that stops being true for | ||
| 562 | /// QUIC: there the pollable thing is the UDP socket while frames come | ||
| 563 | /// out of the stream layer above it. Separating "what to poll" from | ||
| 564 | /// "where frames come from" is the whole of this seam. | ||
| 565 | pub fn pollFd(self: *const Transport) std.posix.fd_t { | 481 | pub fn pollFd(self: *const Transport) std.posix.fd_t { |
| 566 | return self.conn.r; | 482 | return self.conn.r; |
| 567 | } | 483 | } |
| 568 | 484 | ||
| 569 | /// Hand this transport to the thread that owns it from now on. | 485 | /// One owning thread per `Transport`, but the ENTRY dial runs on main — |
| 570 | /// | 486 | /// ssh may need the tty for a password. `qout` is what would otherwise |
| 571 | /// A `Transport` has exactly one owning thread (wallview's module header | 487 | /// cross: two threads on one non-thread-safe arena. |
| 572 | /// says why), but the ENTRY dial happens on the main thread — it needs | ||
| 573 | /// the tty, so ssh can ask for a password — and the tile pump that | ||
| 574 | /// inherits it runs on its own, with its own allocator. The one thing | ||
| 575 | /// that would cross that line is `qout`, the QUIC out-queue: left | ||
| 576 | /// pointing at the dialling thread's allocator, two threads would be | ||
| 577 | /// allocating from one non-thread-safe arena for the link's whole life. | ||
| 578 | /// | ||
| 579 | /// Legal only while the queue is EMPTY, which is exactly the moment | ||
| 580 | /// after `open` and before the first frame. Asserted rather than | ||
| 581 | /// assumed: a non-empty queue would mean moving bytes between | ||
| 582 | /// allocators, and there is no such moment on any path here. | ||
| 583 | pub fn adopt(self: *Transport, alloc: std.mem.Allocator) void { | 488 | pub fn adopt(self: *Transport, alloc: std.mem.Allocator) void { |
| 584 | if (self.link != .quic) return; | 489 | if (self.link != .quic) return; |
| 585 | std.debug.assert(self.qout.items.len == 0); | 490 | std.debug.assert(self.qout.items.len == 0); |
| @@ -601,15 +506,8 @@ pub const Transport = struct { | |||
| 601 | } | 506 | } |
| 602 | } | 507 | } |
| 603 | 508 | ||
| 604 | /// One service pass, called every loop iteration whether or not the | 509 | /// Unconditional: a QUIC connection's timers are the only thing that |
| 605 | /// socket was readable. | 510 | /// notices a peer which stopped answering. |
| 606 | /// | ||
| 607 | /// It has to be unconditional, and finding out why cost an e2e run: a | ||
| 608 | /// QUIC connection's timers are the ONLY thing that notices a peer which | ||
| 609 | /// has stopped answering. Servicing it just from the readable path means | ||
| 610 | /// a transport that goes quiet — a wrong key, a daemon that stopped — | ||
| 611 | /// never runs its idle timer, never declares itself dead, and the client | ||
| 612 | /// polls a corpse forever instead of exiting or reconnecting. | ||
| 613 | pub fn service(self: *Transport) void { | 511 | pub fn service(self: *Transport) void { |
| 614 | switch (self.link) { | 512 | switch (self.link) { |
| 615 | .quic => |cl| { | 513 | .quic => |cl| { |
| @@ -620,7 +518,6 @@ pub const Transport = struct { | |||
| 620 | } | 518 | } |
| 621 | } | 519 | } |
| 622 | 520 | ||
| 623 | /// How long the caller may sleep before this transport needs attention. | ||
| 624 | /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts | 521 | /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts |
| 625 | /// happen on time without a second timer. | 522 | /// happen on time without a second timer. |
| 626 | pub fn timeoutMs(self: *Transport, cap_ms: i32) i32 { | 523 | pub fn timeoutMs(self: *Transport, cap_ms: i32) i32 { |
| @@ -715,38 +612,17 @@ pub const Transport = struct { | |||
| 715 | } | 612 | } |
| 716 | }; | 613 | }; |
| 717 | 614 | ||
| 718 | /// Dial THIS machine's ssh-agent, for one forwarded channel. | 615 | /// The path is a parameter, never the environment, so tests stay |
| 719 | /// | 616 | /// environment-free. Null, never an error: "no agent" must be an answer the |
| 720 | /// The path is a parameter and never read from the environment here: the | 617 | /// pump can turn into an `agent_close`. |
| 721 | /// caller passes `std.posix.getenv(proto.agent_sock_env)`, which keeps the | ||
| 722 | /// tests environment-free (`spawn.parseArgs`' discipline). | ||
| 723 | /// | ||
| 724 | /// Null, never an error, on every failure — no agent, a stale path, a | ||
| 725 | /// refused connect. The daemon's fallback for a client that cannot serve is | ||
| 726 | /// an `agent_close` reply, and the pump can only send that if "no agent" is | ||
| 727 | /// an answer rather than something to bubble out of the tile. | ||
| 728 | /// | ||
| 729 | /// Blocking connect: the agent is a unix socket on this box and answers in | ||
| 730 | /// microseconds, so there is nothing here worth a poll loop. `CLOEXEC` comes | ||
| 731 | /// from `connectUnixSocket`, same as the `.sock` transport above — a | ||
| 732 | /// forwarded channel must not leak into a `--via` child. | ||
| 733 | pub fn connectAgent(path: []const u8) ?std.posix.fd_t { | 618 | pub fn connectAgent(path: []const u8) ?std.posix.fd_t { |
| 734 | if (path.len == 0) return null; | 619 | if (path.len == 0) return null; |
| 735 | const stream = std.net.connectUnixSocket(path) catch return null; | 620 | const stream = std.net.connectUnixSocket(path) catch return null; |
| 736 | return stream.handle; | 621 | return stream.handle; |
| 737 | } | 622 | } |
| 738 | 623 | ||
| 739 | /// Drive a fresh connection until it can carry bytes, or give up. | 624 | /// Bounded by the attach budget, not the idle timeout: the time given a |
| 740 | /// | 625 | /// handshake is not the time given a quiet session. |
| 741 | /// Bounded by the attach budget, not by the connection's idle timeout: the | ||
| 742 | /// time we give a handshake is not the time we give a quiet session. Every | ||
| 743 | /// caller passes the target's `deadline_ms`, which defaults to | ||
| 744 | /// `handoff.deadline_ms`; tests shrink it. | ||
| 745 | /// | ||
| 746 | /// A refused port now ends this wait early — quic_client surfaces the ICMP | ||
| 747 | /// refusal from whichever syscall the kernel hands it to. A blackholed one | ||
| 748 | /// still produces no error at all, and there this bound is the only thing | ||
| 749 | /// that ends the wait. | ||
| 750 | fn waitReady( | 626 | fn waitReady( |
| 751 | cl: *quic_client.Client, | 627 | cl: *quic_client.Client, |
| 752 | budget_ms: u32, | 628 | budget_ms: u32, |
| @@ -805,37 +681,8 @@ fn waitReady( | |||
| 805 | return error.QuicHandshakeFailed; | 681 | return error.QuicHandshakeFailed; |
| 806 | } | 682 | } |
| 807 | 683 | ||
| 808 | /// The announce line, waited for without giving up the abort key. | 684 | /// Deliberately NO deadline: a timer here races a cold `muxd endpoint` |
| 809 | /// | 685 | /// spawn, and the abort key already covers a hung ssh. |
| 810 | /// `handoff.readLine` sits in read(2) watching nothing else, which is right | ||
| 811 | /// for a pure module and wrong here. This wait runs INSIDE Transport.open, | ||
| 812 | /// where for its whole length nothing | ||
| 813 | /// else is looking for Ctrl-\ — and on a re-dial the terminal is | ||
| 814 | /// in raw mode, where Ctrl-\ is the only way out. An ssh whose TCP connect | ||
| 815 | /// hangs, which is exactly what a network that has just died produces, | ||
| 816 | /// would otherwise pin every retry for the kernel's connect timeout with no | ||
| 817 | /// way to stop it but killing mux from another terminal. waitReady makes | ||
| 818 | /// this same argument for the QUIC handshake and cites 14.6s measured; the | ||
| 819 | /// ssh half is worse, and this is it. | ||
| 820 | /// | ||
| 821 | /// Deliberately NO deadline, and that is not an oversight. The read is | ||
| 822 | /// bounded by trust in ssh, as the spec's amendment says it must be: any | ||
| 823 | /// timer here races a cold remote spawn — `muxd endpoint` may be starting a | ||
| 824 | /// daemon — and would tax every honest attach to bound a case the abort key | ||
| 825 | /// already covers. The abort key is user-driven and orthogonal to a clock. | ||
| 826 | /// | ||
| 827 | /// One byte at a time off the pipe, exactly as `handoff.readLine` does it | ||
| 828 | /// and for its reason: the frame stream begins at the byte after the | ||
| 829 | /// newline, so a buffered read would swallow its first bytes into a buffer | ||
| 830 | /// nobody looks at again. Only the WAIT changes here, never the read size — | ||
| 831 | /// and the grammar is still handoff's, through `parseAnnounce`, with the | ||
| 832 | /// buffer sized from the same `announce_max_len`. | ||
| 833 | /// | ||
| 834 | /// It lives here rather than in handoff.zig because everything it adds is | ||
| 835 | /// client policy: which byte aborts, and whether the bytes that are not | ||
| 836 | /// that byte are owed to the shell or dropped. handoff.zig owns no | ||
| 837 | /// processes, no descriptors' meaning and no input policy, and handing it a | ||
| 838 | /// carry pointer would be the first crack in that. | ||
| 839 | fn readAnnounceAbortable( | 686 | fn readAnnounceAbortable( |
| 840 | fd: std.posix.fd_t, | 687 | fd: std.posix.fd_t, |
| 841 | alloc: std.mem.Allocator, | 688 | alloc: std.mem.Allocator, |
| @@ -886,8 +733,10 @@ fn readAnnounceAbortable( | |||
| 886 | } | 733 | } |
| 887 | 734 | ||
| 888 | if (fds[0].revents == 0) continue; | 735 | if (fds[0].revents == 0) continue; |
| 889 | // Polled readable, so this one byte does not block. EOF and the | 736 | // One byte, because the frame stream begins at the byte after the |
| 890 | // over-long line are handoff's errors, told in handoff's words. | 737 | // newline and a buffered read would swallow its first bytes. Polled |
| 738 | // readable, so it does not block; EOF and the over-long line are | ||
| 739 | // handoff's errors, told in handoff's words. | ||
| 891 | var one: [1]u8 = undefined; | 740 | var one: [1]u8 = undefined; |
| 892 | if (try std.posix.read(fd, &one) == 0) return error.UnterminatedLine; | 741 | if (try std.posix.read(fd, &one) == 0) return error.UnterminatedLine; |
| 893 | if (one[0] == '\n') return handoff.parseAnnounce(buf[0..n]); | 742 | if (one[0] == '\n') return handoff.parseAnnounce(buf[0..n]); |
| @@ -896,15 +745,9 @@ fn readAnnounceAbortable( | |||
| 896 | } | 745 | } |
| 897 | } | 746 | } |
| 898 | 747 | ||
| 899 | /// Whether a failed handoff failed at the announce — meaning ssh itself | 748 | /// Did ssh work and the announce not, or did ssh not work? Exhaustive by |
| 900 | /// worked and what came back was not the line we needed — rather than | 749 | /// reflection over handoff.zig's error sets: a hand-kept list would let a |
| 901 | /// before it. | 750 | /// new member fall through to "cannot reach", the lie this prevents. |
| 902 | /// | ||
| 903 | /// Exhaustive by construction rather than by a hand-kept list: it reflects | ||
| 904 | /// over the two error sets handoff.zig declares, so a member added there is | ||
| 905 | /// classified correctly the day it appears. A list would instead let the | ||
| 906 | /// new member fall through to "cannot reach", which is the lie this | ||
| 907 | /// function exists to prevent. | ||
| 908 | fn announceFailed(err: anyerror) bool { | 751 | fn announceFailed(err: anyerror) bool { |
| 909 | const Announce = handoff.ParseError || handoff.ReadLineError; | 752 | const Announce = handoff.ParseError || handoff.ReadLineError; |
| 910 | inline for (@typeInfo(Announce).error_set.?) |e| { | 753 | inline for (@typeInfo(Announce).error_set.?) |e| { |
| @@ -943,14 +786,9 @@ pub const OpenFailure = struct { | |||
| 943 | /// the message clips instead of anything going wrong. | 786 | /// the message clips instead of anything going wrong. |
| 944 | pub const open_err_len = 8192; | 787 | pub const open_err_len = 8192; |
| 945 | 788 | ||
| 946 | /// A message and the failure exit that goes with it, formatted into the | 789 | /// Truncating, not failing: this is the user's only account of why the |
| 947 | /// caller's buffer. | 790 | /// attach did not happen, so a clipped line beats none. The `\n` goes last, |
| 948 | /// | 791 | /// so it is the first thing a clip loses. |
| 949 | /// Truncating rather than failing is deliberate: this line is the user's | ||
| 950 | /// only account of why the attach did not happen, so a clipped one beats | ||
| 951 | /// none. What the user sees in that case is a line that stops mid-operand | ||
| 952 | /// with no trailing newline — the `\n` is the last thing every one of | ||
| 953 | /// these messages writes, so it is the first thing a clip loses. | ||
| 954 | fn failedMsg(buf: []u8, comptime fmt: []const u8, args: anytype) OpenFailure { | 792 | fn failedMsg(buf: []u8, comptime fmt: []const u8, args: anytype) OpenFailure { |
| 955 | var w: std.Io.Writer = .fixed(buf); | 793 | var w: std.Io.Writer = .fixed(buf); |
| 956 | w.print(fmt, args) catch {}; | 794 | w.print(fmt, args) catch {}; |
| @@ -1099,45 +937,28 @@ pub fn spellingCap(target: Target) usize { | |||
| 1099 | return "--sock ".len + operand + 1 + proto.session_name_max; | 937 | return "--sock ".len + operand + 1 + proto.session_name_max; |
| 1100 | } | 938 | } |
| 1101 | 939 | ||
| 1102 | /// One warning line per TILE, whatever went wrong and however many times. | 940 | /// One line per TILE, however often it fails: a wall file that cannot be |
| 1103 | /// A tile that reconnects comes back through the writer, and a wall file | 941 | /// written cannot be written the second time either, and repeating the |
| 1104 | /// that cannot be written cannot be written the second time either — | 942 | /// sentence scrolls a working session. |
| 1105 | /// repeating the sentence would scroll a working session for a record | ||
| 1106 | /// nobody is reading. | ||
| 1107 | pub fn warnWall(warned: *bool, what: []const u8, err: anyerror) void { | 943 | pub fn warnWall(warned: *bool, what: []const u8, err: anyerror) void { |
| 1108 | if (warned.*) return; | 944 | if (warned.*) return; |
| 1109 | warned.* = true; | 945 | warned.* = true; |
| 1110 | std.debug.print("mux: wall not updated ({s}): {s}\n", .{ what, @errorName(err) }); | 946 | std.debug.print("mux: wall not updated ({s}): {s}\n", .{ what, @errorName(err) }); |
| 1111 | } | 947 | } |
| 1112 | 948 | ||
| 1113 | /// The wall file is attach HISTORY: an attach that claims the grid writes | 949 | /// The wall file is attach HISTORY: an attach that claims the grid records |
| 1114 | /// its tile there (the wall-home-screen spec). | 950 | /// its tile. |
| 1115 | /// | 951 | /// |
| 1116 | /// The rule the spec states is mechanical — "attaches at nonzero size", | 952 | /// "Claims the grid" is enforced by which tiles CALL this, not by a size test |
| 1117 | /// not "is a human" — and the CALLER is the enforcement, by which tiles ask | 953 | /// here, and not by a tty check (decisions.md). A stripe attaches at 0x0 and |
| 1118 | /// for it rather than by a size test here. Exactly the tiles that claim the | 954 | /// never calls; muxa does not link this module. |
| 1119 | /// grid ask: the one a `mux TARGET` attached to, and the ones a zoom chord | ||
| 1120 | /// added (`Tile.record`). A stripe attaches at 0x0 and never calls this, | ||
| 1121 | /// and muxa does not link this module at all. A tty check is | ||
| 1122 | /// deliberately NOT added — a test fixture on a real pty is a human attach | ||
| 1123 | /// by this rule, which is fine because the suite runs under an isolated | ||
| 1124 | /// `XDG_STATE_HOME`. | ||
| 1125 | /// | 955 | /// |
| 1126 | /// Why in this module and not in `mux_main`: the chord switches | 956 | /// Here rather than in `mux_main`: the chord switches grow their tiles inside |
| 1127 | /// (`Ctrl-\ c`/`n`/`p`) grow their tiles from inside the wall and never | 957 | /// the wall and never return through main, so a seam up there would record |
| 1128 | /// return through main, so a seam up there would record the session the | 958 | /// only the session the user started at. |
| 1129 | /// user started at and none of the ones they actually visited. The spec | ||
| 1130 | /// wants every visited session to earn its tile — "the wall grows by the | ||
| 1131 | /// truth". | ||
| 1132 | /// | 959 | /// |
| 1133 | /// Best effort, always: the attach is the act, the tile is the record. An | 960 | /// KNOWN GAP: a running `mux wall` or muxweb reads the file once at startup. |
| 1134 | /// unwritable wall file costs one warning line and nothing else — never a | 961 | /// The file is state, not a channel (decisions.md). |
| 1135 | /// blocked attach, never an exit code. | ||
| 1136 | /// | ||
| 1137 | /// KNOWN GAP, deliberate: a `mux wall` or muxweb that is ALREADY RUNNING | ||
| 1138 | /// reads the file once at startup, so it does not see a line added here | ||
| 1139 | /// until it restarts. The file is state, not a channel; a change feed is | ||
| 1140 | /// out of scope for now (decisions.md). | ||
| 1141 | pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, warned: *bool) void { | 962 | pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, warned: *bool) void { |
| 1142 | const buf = alloc.alloc(u8, spellingCap(target)) catch return; | 963 | const buf = alloc.alloc(u8, spellingCap(target)) catch return; |
| 1143 | defer alloc.free(buf); | 964 | defer alloc.free(buf); |
| @@ -1154,29 +975,8 @@ pub fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, wa | |||
| 1154 | _ = wall.record(alloc, path, spelling) catch |err| warnWall(warned, path, err); | 975 | _ = wall.record(alloc, path, spelling) catch |err| warnWall(warned, path, err); |
| 1155 | } | 976 | } |
| 1156 | 977 | ||
| 1157 | /// WHEN an attach becomes history: the first moment state arrives under | 978 | /// A dial that succeeds is not an attach that landed; keying off |
| 1158 | /// it, and exactly once per tile-pump run (`wallview.pumpTile`). | 979 | /// `state_since_attach` means a refusal cannot record. |
| 1159 | /// | ||
| 1160 | /// The seam was "the dial succeeded" until review reproduced what that | ||
| 1161 | /// costs. A dial that succeeds is not an attach that landed: the daemon | ||
| 1162 | /// can still refuse (its session table is full at four), and only a | ||
| 1163 | /// SWITCH's refusal had somewhere to fall back to and so a place to undo | ||
| 1164 | /// the write. A FIRST attach refused the same way exited 1 with the line | ||
| 1165 | /// stranded — a tile naming a session that never existed. So did an error | ||
| 1166 | /// out of the pump before any state. | ||
| 1167 | /// | ||
| 1168 | /// `state_since_attach` is the daemon's own answer to "did this attach | ||
| 1169 | /// land", and it is the very flag both refusal paths read to decide there | ||
| 1170 | /// was none. Keying the write off it means a refusal cannot record, on any | ||
| 1171 | /// path, present or future — no case analysis to keep exhaustive, and | ||
| 1172 | /// nothing left to take back, which is why there is no `unrecordTile`. | ||
| 1173 | /// "Visible while attached" survives intact: the snapshot is milliseconds | ||
| 1174 | /// behind the dial, not a session's lifetime. | ||
| 1175 | /// | ||
| 1176 | /// A reconnect clears the flag and it turns true again; `done` is what | ||
| 1177 | /// stops that being a second write. The write would be a no-op anyway | ||
| 1178 | /// (dedup), but a resync should not pay for a read-modify-write to learn | ||
| 1179 | /// that. | ||
| 1180 | pub fn recordOnState( | 980 | pub fn recordOnState( |
| 1181 | done: *bool, | 981 | done: *bool, |
| 1182 | rep: *const Replica, | 982 | rep: *const Replica, |
| @@ -1190,18 +990,7 @@ pub fn recordOnState( | |||
| 1190 | recordTile(alloc, target, name, warned); | 990 | recordTile(alloc, target, name, warned); |
| 1191 | } | 991 | } |
| 1192 | 992 | ||
| 1193 | /// The re-dial pacing, measured: iteration zero waits not at all, then | 993 | /// Zero first — a link that just died usually reconnects now. No retry cap. |
| 1194 | /// 200ms doubling to a 2s cap — pacing a flapping link without ever giving | ||
| 1195 | /// up. Zero first because a transport that died a moment ago is | ||
| 1196 | /// overwhelmingly likely to accept a new connection right now (daemon | ||
| 1197 | /// restarts, link blips, killed proxies), and a WAN resume measured at | ||
| 1198 | /// ~255ms of which ~200ms was this backoff and only ~54ms was the link: | ||
| 1199 | /// the wait was nearly the whole cost and bought nothing. | ||
| 1200 | /// | ||
| 1201 | /// Pure so every dialler runs the SAME schedule — the wall's tile pumps and | ||
| 1202 | /// the muxweb hub both — and the deliberate no-retry-cap is part of the | ||
| 1203 | /// contract: the user has an abort key and a visible indicator, and a | ||
| 1204 | /// laptop asleep for an hour resuming on wake is the use case. | ||
| 1205 | pub fn nextBackoffMs(prev: u64) u64 { | 994 | pub fn nextBackoffMs(prev: u64) u64 { |
| 1206 | return if (prev == 0) 200 else @min(prev * 2, 2000); | 995 | return if (prev == 0) 200 else @min(prev * 2, 2000); |
| 1207 | } | 996 | } |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,96 +1,22 @@ | |||
| 1 | //! The CLI wall (POC): `mux wall` — one terminal showing N sessions at | 1 | //! The CLI wall: N sessions in one terminal, the multiattach the browser hub |
| 2 | //! once, the same multiattach the browser hub gives, without a browser. | 2 | //! gives without a browser, over the same wall file and grammar (wall.zig). |
| 3 | //! Targets are the wall grammar's spellings (wall.zig), so `mux wall` with | ||
| 4 | //! no arguments shows the SAME wall the browser built — one state file, | ||
| 5 | //! one grammar. An UNZOOMED tile attaches at 0x0 (join, never claim the | ||
| 6 | //! grid, never create a session — muxa's discipline); a zoomed one claims | ||
| 7 | //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or | ||
| 8 | //! Ctrl-\ leaves. | ||
| 9 | //! | 3 | //! |
| 10 | //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump, or | 4 | //! ZOOM IS A LENS, NOT A MODE (CLAUDE.md): zooming PROMOTES a tile's |
| 11 | //! click one); its label bar carries a `> ` marker. `Enter` ZOOMS it, in | 5 | //! existing connection and unzooming DEMOTES it, at no round trip. `mux |
| 12 | //! place. | 6 | //! [TARGET]` is a wall of one tile entered zoomed — the only loop here. |
| 13 | //! | 7 | //! |
| 14 | //! `x` FORGETS the selected tile: its line leaves the wall file, its pump | 8 | //! Tiles are never compacted: pump threads hold pointers into the tile array, |
| 15 | //! ends (closing the transport, freeing the daemon slot), and the stripes | 9 | //! so a forgotten tile is a hole motion steps over (`selectKey`) and nothing |
| 16 | //! are re-cut over what is left. The SESSION is untouched — "remove is | 10 | //! may paint (`paintModeLocked`). |
| 17 | //! detach", the dynamic-wall doctrine. Tiles are never compacted, because | ||
| 18 | //! the pump threads hold pointers into the tile array: a forgotten tile | ||
| 19 | //! becomes a hole that every motion steps over (`selectKey`) and that | ||
| 20 | //! nothing may paint (`paintModeLocked`). Forget them all and the wall | ||
| 21 | //! says so rather than going blank. | ||
| 22 | //! | 11 | //! |
| 23 | //! ZOOM IS A LENS, NOT A MODE. An unzoomed tile claims nothing; zooming | 12 | //! One thread per tile, owning its transport BOTH directions. A `Transport` |
| 24 | //! PROMOTES that tile's existing connection and unzooming DEMOTES it. | 13 | //! is single-threaded — `service()` and `writeFrame` share QUIC state, and a |
| 14 | //! reconnect swaps the struct out from under the pump — so the keyboard | ||
| 15 | //! hands typed bytes over a mailbox and doorbell pipe. Threads, not one fair | ||
| 16 | //! loop: readFrame blocks and userspace buffering is invisible to poll. | ||
| 25 | //! | 17 | //! |
| 26 | //! * Promote resizes the tile's attach from 0x0 to this terminal's size | 18 | //! Horizontal stripes, not a column grid: the terminal does the clipping, |
| 27 | //! and starts forwarding keystrokes down it. Latest-wins already makes | 19 | //! where a grid needs VT-safe truncation of dumpVtRow that nothing has. |
| 28 | //! a resize the legitimate claim on a grid, and the resize goes out | ||
| 29 | //! BEFORE the first keystroke — so the client that types is a | ||
| 30 | //! full-size one and the passivity rule keeps no exception. The daemon | ||
| 31 | //! changes not at all: no new attach, no dial, no snapshot round trip. | ||
| 32 | //! The replica was hot the whole time the tile was a stripe, so the | ||
| 33 | //! zoom paints full-screen from it immediately. Moving the zoom | ||
| 34 | //! between tiles is therefore a local repaint, at zero round trips. | ||
| 35 | //! * Demote is CLIENT-LOCAL and sends nothing at all. The slot keeps its | ||
| 36 | //! promoted size at the daemon deliberately: `applySize` refuses | ||
| 37 | //! sub-minimum resizes, so there is no 0x0 to hand back, and a size | ||
| 38 | //! nobody types at claims nothing under latest-wins. "Claims nothing" | ||
| 39 | //! is kept by the wall NEVER queueing a byte for a tile it is not | ||
| 40 | //! zoomed into — not by the slot reading 0x0. The session keeps its | ||
| 41 | //! full-size grid (no resize storm) and the stripe crops it, which is | ||
| 42 | //! what stripes already do. | ||
| 43 | //! | ||
| 44 | //! Zoomed, the terminal belongs to the session: `Ctrl-\` is its command | ||
| 45 | //! prefix, read out of interact.zig's PrefixFilter so there is one chord | ||
| 46 | //! table and not a twin. `w` UNZOOMS and `d` DETACHES and leaves mux — | ||
| 47 | //! two different keys, and `zoomChord` is where the difference lives. | ||
| 48 | //! `n`/`p`/`c` move the zoom around the DAEMON's session ring rather than | ||
| 49 | //! around the wall's tiles, `l` skips back to the last tile zoomed — and | ||
| 50 | //! unzooms when there is none. Everything else the | ||
| 51 | //! prefix takes is swallowed, exactly as in a client. | ||
| 52 | //! | ||
| 53 | //! And BELOW that chord layer a zoomed tile is a client: every tile owns an | ||
| 54 | //! `interact.Core` from birth, and a promoted one drives it with the same | ||
| 55 | //! calls client.zig's session loop used to make. So the wheel scrolls a | ||
| 56 | //! zoomed session's history, an application that asks for the mouse gets | ||
| 57 | //! every byte of it, and the prediction overlay is the client's own — none | ||
| 58 | //! of which the wall could offer while it hand-rolled a subset of that path. | ||
| 59 | //! What a tile keeps to itself is the STRIPE: a crop of the same replica, | ||
| 60 | //! painted by the wall while the Core is not allowed to paint at all. | ||
| 61 | //! | ||
| 62 | //! Layout is horizontal stripes, not a column grid: a stripe gets the full | ||
| 63 | //! terminal width, so long rows clip at the terminal's right edge (DECAWM | ||
| 64 | //! off) — the terminal does the clipping. A column grid would need VT-safe | ||
| 65 | //! truncation of dumpVtRow output, which nothing here does. Each stripe is | ||
| 66 | //! one inverse label bar plus the BOTTOM rows of that session's grid, | ||
| 67 | //! because prompts live at the bottom. | ||
| 68 | //! | ||
| 69 | //! The ZOOM follows the tty and the stripes do not, and the asymmetry is | ||
| 70 | //! the point: a zoomed tile is a full-screen client, so `mux` resizing with | ||
| 71 | //! its terminal is behaviour a user would miss, while a wall is a thing you | ||
| 72 | //! open, read and leave. So the promoted pump answers the SIGWINCH (the | ||
| 73 | //! flag is process-wide, and it owns the screen) and records the new size | ||
| 74 | //! for everyone; the stripes are re-cut at the next unzoom, not on the | ||
| 75 | //! signal, because a live re-layout has to stop every pump mid-paint. Until | ||
| 76 | //! that unzoom the bars keep their old width — a POC's accepted limit. | ||
| 77 | //! | ||
| 78 | //! One thread per tile, the hub's lesson verbatim (webhub.zig pumpTile): | ||
| 79 | //! a `.fd`/`.pipe` readFrame blocks, and bytes buffered in userspace are | ||
| 80 | //! invisible to poll — a single-threaded fair loop over N transports is | ||
| 81 | //! exactly the bug the hub already paid for. Stripe paints serialize on | ||
| 82 | //! one mutex; each paint is one synchronized-update write. | ||
| 83 | //! | ||
| 84 | //! That thread also OWNS its transport, both directions. The keyboard runs | ||
| 85 | //! on the main thread and hands typed bytes to the pump through a mailbox | ||
| 86 | //! and a doorbell pipe rather than writing them itself: a `Transport` is | ||
| 87 | //! single-threaded (QUIC's `service()` and `writeFrame` share state, and a | ||
| 88 | //! reconnect swaps the whole struct out from under the pump), so one | ||
| 89 | //! thread per transport is a rule and not a preference. | ||
| 90 | //! | ||
| 91 | //! Spelling → Target resolution mirrors the hub's resolveTile (webhub.zig) | ||
| 92 | //! rather than sharing it: both are layer 4, so neither may import the | ||
| 93 | //! other. Third copy wants a shared home below client — noted, not built. | ||
| 94 | const std = @import("std"); | 20 | const std = @import("std"); |
| 95 | const proto = @import("protocol"); | 21 | const proto = @import("protocol"); |
| 96 | const client = @import("client"); | 22 | const client = @import("client"); |
| @@ -124,8 +50,7 @@ pub const Resolved = struct { | |||
| 124 | 50 | ||
| 125 | pub const ResolveError = wall.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; | 51 | pub const ResolveError = wall.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; |
| 126 | 52 | ||
| 127 | /// Spelling → client.Target. Slices in the result borrow `spelling` or | 53 | /// Result slices borrow `spelling` or live in `alloc`; keep both alive. |
| 128 | /// live in `alloc`; the caller keeps both alive for the wall's lifetime. | ||
| 129 | pub fn resolveSpelling( | 54 | pub fn resolveSpelling( |
| 130 | alloc: std.mem.Allocator, | 55 | alloc: std.mem.Allocator, |
| 131 | spelling: []const u8, | 56 | spelling: []const u8, |
| @@ -175,8 +100,7 @@ pub fn resolveSpelling( | |||
| 175 | /// the label bar). | 100 | /// the label bar). |
| 176 | pub const Stripe = struct { top: u16, rows: u16 }; | 101 | pub const Stripe = struct { top: u16, rows: u16 }; |
| 177 | 102 | ||
| 178 | /// Split `tty_rows` into n stripes, remainder rows to the top stripes. A | 103 | /// Remainder to the top. Under 2 rows a stripe shows nothing: refused. |
| 179 | /// stripe below 2 rows (label + one content row) shows nothing — refused. | ||
| 180 | pub fn layoutStripes( | 104 | pub fn layoutStripes( |
| 181 | alloc: std.mem.Allocator, | 105 | alloc: std.mem.Allocator, |
| 182 | n: usize, | 106 | n: usize, |
| @@ -506,22 +430,14 @@ const Tile = struct { | |||
| 506 | } | 430 | } |
| 507 | }; | 431 | }; |
| 508 | 432 | ||
| 509 | /// How far down a stripe's CONTENT a zero-based terminal row lands, or null | 433 | /// Null for the label bar as well as outside the stripe — the bar is where a |
| 510 | /// when that row names no session line at all. | 434 | /// hand rests between stripes, and the nearest line is not what was clicked. |
| 511 | /// | ||
| 512 | /// Two rows are null on purpose: the label bar (`Stripe.top` itself, since | ||
| 513 | /// `Stripe` counts from zero and the bar is its first row) and anything | ||
| 514 | /// outside the stripe. A click there points at nothing, and answering with | ||
| 515 | /// the nearest line would move a selection the user did not ask to move — | ||
| 516 | /// the bar is exactly where a hand rests between two stripes. | ||
| 517 | fn stripeContentRow(s: Stripe, row: u16) ?u16 { | 435 | fn stripeContentRow(s: Stripe, row: u16) ?u16 { |
| 518 | if (row <= s.top) return null; | 436 | if (row <= s.top) return null; |
| 519 | const off = row - s.top - 1; | 437 | const off = row - s.top - 1; |
| 520 | return if (off < s.rows -| 1) off else null; | 438 | return if (off < s.rows -| 1) off else null; |
| 521 | } | 439 | } |
| 522 | 440 | ||
| 523 | /// Take this tile's pending selection request, if the keyboard left one. | ||
| 524 | /// | ||
| 525 | /// A swap and not a read: one posted question is one question asked, the | 441 | /// A swap and not a read: one posted question is one question asked, the |
| 526 | /// discipline `ask` already keeps. | 442 | /// discipline `ask` already keeps. |
| 527 | fn takeSelectionReq(t: *Tile) ?select.Range { | 443 | fn takeSelectionReq(t: *Tile) ?select.Range { |
| @@ -612,28 +528,8 @@ const WallDrain = struct { | |||
| 612 | } | 528 | } |
| 613 | }; | 529 | }; |
| 614 | 530 | ||
| 615 | /// What a mouse report does at an UNZOOMED wall. | 531 | /// Selection follows the RELEASE, so a press that becomes a drag does not |
| 616 | /// | 532 | /// flicker the marker past. |
| 617 | /// A plain click — press and release with the pointer never leaving the | ||
| 618 | /// cell — moves the selection to the stripe under it. That is tmux's | ||
| 619 | /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the | ||
| 620 | /// user already has in their hands. A DRAG highlights the lines it | ||
| 621 | /// crossed, inside the stripe it started in, and leaves that highlight | ||
| 622 | /// standing when the button comes up. | ||
| 623 | /// | ||
| 624 | /// The selection follows the RELEASE and not the press, so a press that | ||
| 625 | /// turns out to be the start of a drag never flickers the marker on its | ||
| 626 | /// way past. | ||
| 627 | /// | ||
| 628 | /// Left button only. Middle is the terminal's own paste and right its menu; | ||
| 629 | /// stealing either would be a surprise the wall has no answer for. The | ||
| 630 | /// button word arrives from the filter verbatim, modifier bits and the | ||
| 631 | /// motion bit included, so it is the low two bits that name the button. | ||
| 632 | /// | ||
| 633 | /// The keyboard never PAINTS the highlight. It writes the drag under | ||
| 634 | /// `paint_mu` and rings, and the pumps draw it inside the stripe paints | ||
| 635 | /// they were doing anyway (`paintStripe`) — anything drawn from here | ||
| 636 | /// would be overwritten by the next frame from the session. | ||
| 637 | fn wallMouse( | 533 | fn wallMouse( |
| 638 | tiles: []Tile, | 534 | tiles: []Tile, |
| 639 | present: []const bool, | 535 | present: []const bool, |
| @@ -718,10 +614,9 @@ const WallInput = struct { | |||
| 718 | /// UNZOOMED, which the wall's own modes are what produce. | 614 | /// UNZOOMED, which the wall's own modes are what produce. |
| 719 | mouse: interact.MouseFilter = .{}, | 615 | mouse: interact.MouseFilter = .{}, |
| 720 | 616 | ||
| 721 | /// The drag itself lives in `Shared` — the pumps have to read it to | 617 | /// The drag lives in `Shared` — the pumps read it to paint it — but is |
| 722 | /// paint it — but it is dropped HERE, with the rest, because it is | 618 | /// dropped HERE for the same reason as the rest: the screen it describes |
| 723 | /// dropped for the same reason: the screen it describes has stopped | 619 | /// has stopped existing, and its anchor names a stripe about to belong to |
| 724 | /// existing, and its anchor names a stripe that is about to belong to | ||
| 725 | /// nobody. | 620 | /// nobody. |
| 726 | fn reset(self: *WallInput, shared: *Shared) void { | 621 | fn reset(self: *WallInput, shared: *Shared) void { |
| 727 | self.prefix = .{}; | 622 | self.prefix = .{}; |
| @@ -807,11 +702,8 @@ fn paintModeLocked(t: *const Tile) PaintMode { | |||
| 807 | return if (z == t.idx) .full else .none; | 702 | return if (z == t.idx) .full else .none; |
| 808 | } | 703 | } |
| 809 | 704 | ||
| 810 | /// Narrate a new state on this tile's bar. The state is remembered on the | 705 | /// The state is remembered on the tile: the keyboard repaints this bar with |
| 811 | /// tile because the keyboard repaints the same bar when the selection | 706 | /// no frame in hand, and a zoom leaves no bars at all to hold it. |
| 812 | /// moves, with no frame in hand to tell it what to say — and because while | ||
| 813 | /// a zoom is up there are no bars at all, so the state has to survive | ||
| 814 | /// somewhere until one comes back. | ||
| 815 | fn paintLabel(t: *Tile, state: State) void { | 707 | fn paintLabel(t: *Tile, state: State) void { |
| 816 | t.shared.paint_mu.lock(); | 708 | t.shared.paint_mu.lock(); |
| 817 | defer t.shared.paint_mu.unlock(); | 709 | defer t.shared.paint_mu.unlock(); |
| @@ -819,15 +711,7 @@ fn paintLabel(t: *Tile, state: State) void { | |||
| 819 | if (paintModeLocked(t) == .stripe) paintLabelLocked(t); | 711 | if (paintModeLocked(t) == .stripe) paintLabelLocked(t); |
| 820 | } | 712 | } |
| 821 | 713 | ||
| 822 | /// One bar's text, byte-truncated to fit BOTH the terminal and `buf`. | 714 | /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns. |
| 823 | /// Plain text only, so byte truncation cannot split an escape sequence. | ||
| 824 | /// | ||
| 825 | /// Split out of the paint so a very wide terminal can be asserted without | ||
| 826 | /// one. The two bounds are not the same bound: truncating by `cols` alone | ||
| 827 | /// left the format overrunning a fixed `buf` somewhere past 250 columns, | ||
| 828 | /// where `bufPrint` fails and the fallback is the buffer's undefined bytes | ||
| 829 | /// — losing the state word this truncation exists to preserve, at exactly | ||
| 830 | /// the width nobody tests on. | ||
| 831 | pub fn labelText( | 715 | pub fn labelText( |
| 832 | buf: []u8, | 716 | buf: []u8, |
| 833 | cols: u16, | 717 | cols: u16, |
| @@ -935,15 +819,8 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u | |||
| 935 | return true; | 819 | return true; |
| 936 | } | 820 | } |
| 937 | 821 | ||
| 938 | /// Say one sentence on the wall's own terminal, from a pump that may not | 822 | /// `Core.banner` refuses when demoted, and this has to be said either way. |
| 939 | /// hold the zoom. | 823 | /// The next bar repaint erases it — the right lifetime for a line read once. |
| 940 | /// | ||
| 941 | /// `Core.banner` is the zoomed spelling and refuses when demoted — its sink | ||
| 942 | /// IS the zoom test — and a copy refused for its size has to be said | ||
| 943 | /// whether the tile is a stripe or the whole screen. Top-right, which at an | ||
| 944 | /// unzoomed wall is the first tile's label bar: it is written over by the | ||
| 945 | /// next repaint of that bar, which is the right lifetime for something the | ||
| 946 | /// user needs to read once. | ||
| 947 | fn wallBanner(shared: *Shared, text: []const u8) void { | 824 | fn wallBanner(shared: *Shared, text: []const u8) void { |
| 948 | if (!shared.is_tty) return; | 825 | if (!shared.is_tty) return; |
| 949 | shared.paint_mu.lock(); | 826 | shared.paint_mu.lock(); |
| @@ -951,18 +828,8 @@ fn wallBanner(shared: *Shared, text: []const u8) void { | |||
| 951 | paint.paintBanner(shared.out_fd, shared.size, text); | 828 | paint.paintBanner(shared.out_fd, shared.size, text); |
| 952 | } | 829 | } |
| 953 | 830 | ||
| 954 | /// Put a finished selection's text on the HOST terminal's clipboard. | 831 | /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the |
| 955 | /// | 832 | /// paint after it. |
| 956 | /// Which drag the answer has to still match is which screen this tile is | ||
| 957 | /// on: zoomed, the Core read the mouse and holds its own; unzoomed, the | ||
| 958 | /// wall's keyboard did and the drag is `Shared`'s. Nothing else separates | ||
| 959 | /// the two paths — the tile index is part of a `select.Range`, so a drag | ||
| 960 | /// begun over the neighbour cannot match a request made from here. | ||
| 961 | /// | ||
| 962 | /// Under `paint_mu` for `interact.semanticFrame`'s reason: an OSC 52 | ||
| 963 | /// payload runs to 64 KiB and leaves as a write loop, and another thread's | ||
| 964 | /// screen clear spliced into the middle of it leaves the terminal hunting | ||
| 965 | /// for a string terminator and eating everything painted after. | ||
| 966 | fn copySelection( | 833 | fn copySelection( |
| 967 | t: *Tile, | 834 | t: *Tile, |
| 968 | alloc: std.mem.Allocator, | 835 | alloc: std.mem.Allocator, |
| @@ -993,21 +860,9 @@ fn copySelection( | |||
| 993 | if (answer == .too_large) wallBanner(t.shared, "[selection too large to copy]"); | 860 | if (answer == .too_large) wallBanner(t.shared, "[selection too large to copy]"); |
| 994 | } | 861 | } |
| 995 | 862 | ||
| 996 | /// The wall's answer to a Core asking whether it may paint: only while this | 863 | /// Only while this tile IS the zoom, `paint_mu` held for the whole paint |
| 997 | /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes | 864 | /// (`interact.Sink`): a zoom that moved between decision and bytes puts one |
| 998 | /// (see `interact.Sink`). | 865 | /// session's rows on another's screen. |
| 999 | /// | ||
| 1000 | /// This is the enforcement point, and now the only one. It used to be | ||
| 1001 | /// spelled at four sites — the promote repaint, the frame repaint, the | ||
| 1002 | /// expiry repaint and the speculation — and any one of them forgetting it | ||
| 1003 | /// was a prediction glyph painted onto whichever session the zoom had moved | ||
| 1004 | /// to. Now a demoted tile's Core cannot write a grid byte at all: no rows, | ||
| 1005 | /// no overlay, nothing. | ||
| 1006 | /// | ||
| 1007 | /// The lock is what makes the answer stay true for the length of the paint. | ||
| 1008 | /// A zoom that moved between the decision and the bytes would put one | ||
| 1009 | /// session's rows on another session's screen — the same reason `setZoom` | ||
| 1010 | /// does its whole transition under one hold. | ||
| 1011 | fn tilePaintBegin(ctx: ?*anyopaque) bool { | 866 | fn tilePaintBegin(ctx: ?*anyopaque) bool { |
| 1012 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | 867 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); |
| 1013 | t.shared.paint_mu.lock(); | 868 | t.shared.paint_mu.lock(); |
| @@ -1045,22 +900,18 @@ fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) ! | |||
| 1045 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); | 900 | if (t.r.agent) try tr.writeFrame(.agent_offer, ""); |
| 1046 | } | 901 | } |
| 1047 | 902 | ||
| 1048 | /// One byte on this tile's doorbell. Never blocks and never reports: a bell | 903 | /// Never blocks and never reports: a full pipe is a bell already ringing. |
| 1049 | /// that is already ringing needs no second ring, which is precisely what a | ||
| 1050 | /// full pipe means here. | ||
| 1051 | fn ring(t: *const Tile) void { | 904 | fn ring(t: *const Tile) void { |
| 1052 | _ = std.posix.write(t.wake_w, "\x00") catch {}; | 905 | _ = std.posix.write(t.wake_w, "\x00") catch {}; |
| 1053 | } | 906 | } |
| 1054 | 907 | ||
| 1055 | /// One byte on the KEYBOARD's doorbell, from a pump. Same discipline as | 908 | /// `ring` in the other direction: never blocks, never reports. |
| 1056 | /// `ring`, in the other direction: never blocks, never reports. | ||
| 1057 | fn ringKeyboard(shared: *const Shared) void { | 909 | fn ringKeyboard(shared: *const Shared) void { |
| 1058 | _ = std.posix.write(shared.kb_w, "\x00") catch {}; | 910 | _ = std.posix.write(shared.kb_w, "\x00") catch {}; |
| 1059 | } | 911 | } |
| 1060 | 912 | ||
| 1061 | /// Empty a doorbell so the next ring is visible to poll. Both ends of both | 913 | /// Both ends of both bells are non-blocking, which is what makes this safe |
| 1062 | /// bells are non-blocking, which is what makes this safe to call on a pipe | 914 | /// on a pipe nobody has rung. |
| 1063 | /// nobody has rung. | ||
| 1064 | fn drainBell(fd: std.posix.fd_t) void { | 915 | fn drainBell(fd: std.posix.fd_t) void { |
| 1065 | var sink: [64]u8 = undefined; | 916 | var sink: [64]u8 = undefined; |
| 1066 | while (std.posix.read(fd, &sink)) |n| { | 917 | while (std.posix.read(fd, &sink)) |n| { |
| @@ -1068,20 +919,16 @@ fn drainBell(fd: std.posix.fd_t) void { | |||
| 1068 | } else |_| {} | 919 | } else |_| {} |
| 1069 | } | 920 | } |
| 1070 | 921 | ||
| 1071 | /// The terminal is a different shape than it was. Written by the one | 922 | /// Written by the one promoted pump that answered the SIGWINCH, so the next |
| 1072 | /// promoted pump that answered the SIGWINCH, so the next re-cut of the | 923 | /// re-cut and promote use the size that is true now. |
| 1073 | /// stripes and the next tile to be promoted use the size that is true now | ||
| 1074 | /// rather than the one measured at startup. | ||
| 1075 | fn setWallSize(shared: *Shared, size: proto.Size) void { | 924 | fn setWallSize(shared: *Shared, size: proto.Size) void { |
| 1076 | shared.paint_mu.lock(); | 925 | shared.paint_mu.lock(); |
| 1077 | defer shared.paint_mu.unlock(); | 926 | defer shared.paint_mu.unlock(); |
| 1078 | shared.size = size; | 927 | shared.size = size; |
| 1079 | } | 928 | } |
| 1080 | 929 | ||
| 1081 | /// Hand the zoomed tile's prediction counters out to the thread that will | 930 | /// PROMOTED pumps only: a stripe's counters are all zero, and publishing |
| 1082 | /// print them. Only a PROMOTED pump calls this: a stripe forwards nothing | 931 | /// them would clobber the tile the user typed at. |
| 1083 | /// and its counters are all zero, so letting one publish would clobber the | ||
| 1084 | /// numbers of the tile the user was actually typing at. | ||
| 1085 | fn publishStats(shared: *Shared, c: interact.PredictCounters) void { | 932 | fn publishStats(shared: *Shared, c: interact.PredictCounters) void { |
| 1086 | shared.paint_mu.lock(); | 933 | shared.paint_mu.lock(); |
| 1087 | defer shared.paint_mu.unlock(); | 934 | defer shared.paint_mu.unlock(); |
| @@ -1111,12 +958,9 @@ fn takeNotice(shared: *Shared, out: []u8) []const u8 { | |||
| 1111 | return out[0..n]; | 958 | return out[0..n]; |
| 1112 | } | 959 | } |
| 1113 | 960 | ||
| 1114 | /// The name a zoom chord landed on, for the keyboard to zoom to (or grow a | 961 | /// Validated first: these bytes came out of a peer's `sessions_reply` and |
| 1115 | /// tile for). Validated first: these bytes came out of a peer's | 962 | /// `SessionName.of` memcpys with no bound of its own (`client.validPick`). |
| 1116 | /// `sessions_reply` payload, which this client did not author, and | 963 | /// A name it refuses is a name nobody is moved to. |
| 1117 | /// `SessionName.of` memcpys into a fixed buffer with no bound of its own — | ||
| 1118 | /// so the guard sits on the one path every producer shares | ||
| 1119 | /// (`client.validPick`). A name it refuses is a name nobody is moved to. | ||
| 1120 | fn postAnswer(t: *Tile, pick: []const u8) void { | 964 | fn postAnswer(t: *Tile, pick: []const u8) void { |
| 1121 | const name = client.validPick(pick) orelse return; | 965 | const name = client.validPick(pick) orelse return; |
| 1122 | { | 966 | { |
| @@ -1128,67 +972,21 @@ fn postAnswer(t: *Tile, pick: []const u8) void { | |||
| 1128 | ringKeyboard(t.shared); | 972 | ringKeyboard(t.shared); |
| 1129 | } | 973 | } |
| 1130 | 974 | ||
| 1131 | /// This tile's pump is finished, and why. Written before `alive` clears | 975 | /// Written before `alive` clears, so no dead tile is ever seen without a |
| 1132 | /// (the defer at the top of `pumpTile`) so the keyboard can never see a | 976 | /// reason. Rings NOTHING — the keyboard's test is `!alive`. |
| 1133 | /// dead tile without a reason for it. | ||
| 1134 | /// | ||
| 1135 | /// It rings NOTHING. The bell belongs with the `alive` store and not with | ||
| 1136 | /// this, because the keyboard's test for a finished tile is `!alive` — ring | ||
| 1137 | /// here and the keyboard wakes, finds the tile still alive, drains the bell | ||
| 1138 | /// and sleeps again, and the news never arrives. Measured, not reasoned | ||
| 1139 | /// about: it hung a piped `mux` whose shell had exited. | ||
| 1140 | fn endWith(t: *Tile, reason: EndReason, code: u8) void { | 977 | fn endWith(t: *Tile, reason: EndReason, code: u8) void { |
| 1141 | t.code.store(code, .release); | 978 | t.code.store(code, .release); |
| 1142 | t.end.store(@intFromEnum(reason), .release); | 979 | t.end.store(@intFromEnum(reason), .release); |
| 1143 | } | 980 | } |
| 1144 | 981 | ||
| 1145 | /// Hand typed bytes to this tile's pump. | 982 | /// Called ONLY from the keyboard loop and ONLY while `t` is the zoomed tile: |
| 1146 | /// | 983 | /// that restriction IS the enforcement of "an unzoomed tile claims nothing". |
| 1147 | /// Called ONLY from the keyboard loop and ONLY while `t` is the zoomed | ||
| 1148 | /// tile. That restriction is not an optimisation — it is the whole | ||
| 1149 | /// enforcement of "an unzoomed tile claims nothing". There is no other | ||
| 1150 | /// caller and there must not be one. | ||
| 1151 | /// | ||
| 1152 | /// Mouse reports travel this path too, unsplit. The splitting is the | ||
| 1153 | /// PUMP's, because everything it needs is the pump's: whether the session's | ||
| 1154 | /// own application asked for the mouse, where this tile's scrollback view | ||
| 1155 | /// sits, and the transport a wheel notch turns into a `fetch_scrollback`. | ||
| 1156 | /// So the mailbox carries whatever the terminal wrote, minus the chord, and | ||
| 1157 | /// `interact.Core.forward` takes it apart on the other side. | ||
| 1158 | /// | ||
| 1159 | /// Nothing arriving here is an UNZOOMED wall's mouse report, and that is a | ||
| 1160 | /// routing fact rather than an absence now that there are such reports. The | ||
| 1161 | /// wall holds mouse modes of its own for its whole life | ||
| 1162 | /// (`interact.wall_mouse_claim`) and reads what they produce itself, in the | ||
| 1163 | /// key loop, through a `MouseFilter` of its own. This function is reached | ||
| 1164 | /// only on the zoomed branch of that loop, so a report that gets here | ||
| 1165 | /// belongs to a session by construction. | ||
| 1166 | /// | ||
| 1167 | /// A WHOLE report in flight across the boundary lands on whichever side the | ||
| 1168 | /// zoom was on when the read returned: the wall's filter eats it, or the | ||
| 1169 | /// session's does. That is what keeps a wall drag from becoming tile input. | ||
| 1170 | /// | 984 | /// |
| 1171 | /// A report CUT by a read boundary is the one corner that is not covered, | 985 | /// Mouse reports travel it unsplit — the split needs the pump's state — and |
| 1172 | /// and it is the same split class `interact.MouseFilter` admits to at its | 986 | /// an unzoomed wall's never arrive, since this is the zoomed branch only. |
| 1173 | /// own edge. The wall holds the head; `setZoom` drops it (`WallInput.reset` | ||
| 1174 | /// — the bytes describe a screen that has changed hands); the next read | ||
| 1175 | /// arrives zoomed and its tail comes here as keystrokes, so a shell can be | ||
| 1176 | /// handed `0;3;2M`. It needs a terminal that splits one report across two | ||
| 1177 | /// writes AND a zoom landing in the gap, which is why it is named rather | ||
| 1178 | /// than defended against: the alternative is holding a partial report | ||
| 1179 | /// across the transition, which is state about a screen nobody is looking | ||
| 1180 | /// at any more. | ||
| 1181 | /// | 987 | /// |
| 1182 | /// A chunk that does not fit is dropped WHOLE. The obvious alternative — | 988 | /// A chunk that does not fit is dropped WHOLE: copying what fits splices two |
| 1183 | /// copy what fits — splices: the head of one read lands in the mailbox, the | 989 | /// reads into a command nobody typed; blocking wedges the wall. |
| 1184 | /// middle of the stream is lost, and the next read appends to it, so a | ||
| 1185 | /// shell can be handed a command nobody typed (the front of a paste, then | ||
| 1186 | /// bytes from somewhere else, then a newline that runs the result). Losing | ||
| 1187 | /// a whole read is a keystroke that did not arrive, which is a thing users | ||
| 1188 | /// understand and retry; a spliced one is a command they never wrote. | ||
| 1189 | /// Blocking instead is not on offer either — a full mailbox means the pump | ||
| 1190 | /// is not reading (a dead session, a dial in progress) and a wedged | ||
| 1191 | /// keyboard would take the whole wall down with it. | ||
| 1192 | fn sendKeys(t: *Tile, keys: []const u8) void { | 990 | fn sendKeys(t: *Tile, keys: []const u8) void { |
| 1193 | { | 991 | { |
| 1194 | t.in_mu.lock(); | 992 | t.in_mu.lock(); |
| @@ -1207,18 +1005,9 @@ fn sendKeys(t: *Tile, keys: []const u8) void { | |||
| 1207 | ring(t); | 1005 | ring(t); |
| 1208 | } | 1006 | } |
| 1209 | 1007 | ||
| 1210 | /// Take everything the keyboard left, in order. Pump thread only. | 1008 | /// Whole-mailbox chunking, so `offerKeystroke` (one-byte chunks only) counts |
| 1211 | /// | 1009 | /// keystrokes that arrive between polls as suppressed: a wall predicts a |
| 1212 | /// Whole-mailbox chunking, which has one visible consequence worth naming | 1010 | /// little less. Splitting would speculate against a stale replica. |
| 1213 | /// before somebody reads `MUX_PREDICT_STATS` from a wall and panics: | ||
| 1214 | /// `offerKeystroke` speculates only on a chunk of exactly one byte, so two | ||
| 1215 | /// keystrokes that arrive between one poll and the next are handed over | ||
| 1216 | /// together and BOTH counted as suppressed. A zoomed tile therefore | ||
| 1217 | /// predicts a little less than a plain client, whose stdin read is its own | ||
| 1218 | /// chunk boundary. Left as it is deliberately: the alternative is offering | ||
| 1219 | /// the chunk byte by byte, which would speculate against a replica that | ||
| 1220 | /// has not seen the earlier bytes yet — a guess about a screen that does | ||
| 1221 | /// not exist, which is exactly what predict.zig refuses to make. | ||
| 1222 | fn takeKeys(t: *Tile, out: []u8) []u8 { | 1011 | fn takeKeys(t: *Tile, out: []u8) []u8 { |
| 1223 | t.in_mu.lock(); | 1012 | t.in_mu.lock(); |
| 1224 | defer t.in_mu.unlock(); | 1013 | defer t.in_mu.unlock(); |
| @@ -1260,11 +1049,8 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Trans | |||
| 1260 | /// why these helpers take the table as a slice rather than reaching for one. | 1049 | /// why these helpers take the table as a slice rather than reaching for one. |
| 1261 | const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; | 1050 | const AgentLocal = struct { id: u32, fd: std.posix.fd_t }; |
| 1262 | 1051 | ||
| 1263 | /// First free slot, or null for a full table. Fixed size and no allocator: | 1052 | /// Fixed at `proto.agent_chans_max`, which is what the daemon opens anyway: |
| 1264 | /// the size is `proto.agent_chans_max`, which is what the daemon will open | 1053 | /// a full table costs one failed lookup, not the pump's hot path an alloc. |
| 1265 | /// anyway, and the refusal a full table earns costs the far side one failed | ||
| 1266 | /// agent lookup rather than the tile a heap allocation on the pump's hot | ||
| 1267 | /// path. | ||
| 1268 | fn storeLocal(locals: []?AgentLocal, id: u32, fd: std.posix.fd_t) ?usize { | 1054 | fn storeLocal(locals: []?AgentLocal, id: u32, fd: std.posix.fd_t) ?usize { |
| 1269 | for (locals, 0..) |c, s| { | 1055 | for (locals, 0..) |c, s| { |
| 1270 | if (c != null) continue; | 1056 | if (c != null) continue; |
| @@ -1292,13 +1078,7 @@ fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) | |||
| 1292 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; | 1078 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; |
| 1293 | } | 1079 | } |
| 1294 | 1080 | ||
| 1295 | /// Open one channel to this machine's agent, or refuse. False is the whole | 1081 | /// Lifted out of the pump so the `offered` gate has a seam a test can watch. |
| 1296 | /// refusal: the caller answers every reason with the same `agent_close`. | ||
| 1297 | /// | ||
| 1298 | /// Lifted out of the pump for the first line of it. `offered` is the tile's | ||
| 1299 | /// own `-A`, and a gate that only ever ran inside a live pump is a gate no | ||
| 1300 | /// test can watch: flip it open and the suite still passes, because a | ||
| 1301 | /// client with no agent refuses at the dial for a different reason. | ||
| 1302 | fn openAgentChan( | 1082 | fn openAgentChan( |
| 1303 | locals: []?AgentLocal, | 1083 | locals: []?AgentLocal, |
| 1304 | id: u32, | 1084 | id: u32, |
| @@ -1321,20 +1101,7 @@ fn openAgentChan( | |||
| 1321 | return true; | 1101 | return true; |
| 1322 | } | 1102 | } |
| 1323 | 1103 | ||
| 1324 | /// One `agent_data` payload onto the channel it names. Lifted out of the | 1104 | /// Lifted out of the pump so the length cap has a seam a test can reach. |
| 1325 | /// pump so the cap has a seam a test can reach: everything else in that | ||
| 1326 | /// loop needs a live daemon, and the length rule is the one thing here that | ||
| 1327 | /// answers a peer rather than the daemon we chose to trust. | ||
| 1328 | /// | ||
| 1329 | /// An id naming no channel of ours is dropped in silence — the daemon's own | ||
| 1330 | /// stance for the same frame, and here it is simply a channel we closed a | ||
| 1331 | /// moment before this arrived. Oversize hangs the channel up instead: the | ||
| 1332 | /// write below is blocking, and a truncated agent reply is worse than none. | ||
| 1333 | /// | ||
| 1334 | /// False means the payload was too short to name a channel at all, and the | ||
| 1335 | /// caller abandons the batch on it — the stance its sibling `agent_open` | ||
| 1336 | /// and `agent_close` arms take for the same malformation, and the one this | ||
| 1337 | /// arm took before it was lifted out of the pump. | ||
| 1338 | fn deliverAgentData( | 1105 | fn deliverAgentData( |
| 1339 | locals: []?AgentLocal, | 1106 | locals: []?AgentLocal, |
| 1340 | payload: []const u8, | 1107 | payload: []const u8, |
| @@ -1355,10 +1122,8 @@ fn deliverAgentData( | |||
| 1355 | return true; | 1122 | return true; |
| 1356 | } | 1123 | } |
| 1357 | 1124 | ||
| 1358 | /// Every channel, silently. For the redial only: these channels belonged to | 1125 | /// Redial only: these belonged to the dead connection, whose `dropClient` |
| 1359 | /// the daemon CONNECTION that just died, whose `dropClient` has already | 1126 | /// already reaped the server side. |
| 1360 | /// reaped the server side — so there is nobody to tell, and the ids the next | ||
| 1361 | /// connection hands out will mean something else entirely. | ||
| 1362 | fn dropLocals(locals: []?AgentLocal) void { | 1127 | fn dropLocals(locals: []?AgentLocal) void { |
| 1363 | for (locals, 0..) |c, s| if (c) |ch| { | 1128 | for (locals, 0..) |c, s| if (c) |ch| { |
| 1364 | locals[s] = null; | 1129 | locals[s] = null; |
| @@ -1366,15 +1131,12 @@ fn dropLocals(locals: []?AgentLocal) void { | |||
| 1366 | }; | 1131 | }; |
| 1367 | } | 1132 | } |
| 1368 | 1133 | ||
| 1369 | /// The transport died, or the dial has to be redone: rebuild it on the | 1134 | /// The transport died, or the dial has to be redone: rebuild it on the CLI's |
| 1370 | /// CLI's backoff schedule and re-attach quoting what this tile already | 1135 | /// backoff and re-attach quoting what this tile holds. False means the pump |
| 1371 | /// holds. False means the pump is finished — the wall quit, or the tile was | 1136 | /// is finished — the wall quit, or the tile was forgotten while retrying. |
| 1372 | /// forgotten while it was retrying. | ||
| 1373 | /// | 1137 | /// |
| 1374 | /// One function for what were four copies of the same five steps. They had | 1138 | /// One function for what were four copies of five steps, which had begun to |
| 1375 | /// begun to differ: only some of them dropped a scroll view that a resync | 1139 | /// differ: only some dropped a scroll view a resync was about to invalidate. |
| 1376 | /// was about to make meaningless, and none of them told the overlay that | ||
| 1377 | /// what it was predicting against no longer existed. | ||
| 1378 | fn redial( | 1140 | fn redial( |
| 1379 | t: *Tile, | 1141 | t: *Tile, |
| 1380 | alloc: std.mem.Allocator, | 1142 | alloc: std.mem.Allocator, |
| @@ -1425,20 +1187,8 @@ fn redial( | |||
| 1425 | return true; | 1187 | return true; |
| 1426 | } | 1188 | } |
| 1427 | 1189 | ||
| 1428 | /// Drop the WALL's highlight, if it is over this tile. | 1190 | /// Absolute rows count from the oldest row the daemon keeps, and a resync |
| 1429 | /// | 1191 | /// renames that space: a kept highlight inverts rows nobody selected. |
| 1430 | /// The pump's own clear, and the third of three: the keyboard's two are | ||
| 1431 | /// about the screen changing shape (`WallInput.reset`, `relayout`), and | ||
| 1432 | /// this one is about the coordinates changing meaning. Absolute rows count | ||
| 1433 | /// from the oldest row the daemon still retains, and the resync a | ||
| 1434 | /// reconnect brings renames that space outright — a highlight kept across | ||
| 1435 | /// it inverts rows nobody selected. | ||
| 1436 | /// | ||
| 1437 | /// Only this tile's. A neighbour reconnecting says nothing about a | ||
| 1438 | /// selection over a session that never went away. | ||
| 1439 | /// | ||
| 1440 | /// Written from the pump because the pump is where a reconnect is noticed, | ||
| 1441 | /// under the same lock the keyboard writes the drag with. | ||
| 1442 | fn dropDragOver(t: *Tile) void { | 1192 | fn dropDragOver(t: *Tile) void { |
| 1443 | t.shared.paint_mu.lock(); | 1193 | t.shared.paint_mu.lock(); |
| 1444 | defer t.shared.paint_mu.unlock(); | 1194 | defer t.shared.paint_mu.unlock(); |
| @@ -2084,36 +1834,7 @@ pub const ZoomMove = union(enum) { | |||
| 2084 | to: usize, | 1834 | to: usize, |
| 2085 | }; | 1835 | }; |
| 2086 | 1836 | ||
| 2087 | /// The zoomed tile's chord table, resolved against the wall. Pure, so what | 1837 | /// Pure, so what a chord MEANS is assertable without a terminal or a daemon. |
| 2088 | /// a chord MEANS can be asserted without a terminal, two daemons and a | ||
| 2089 | /// pty: `selectKey`'s reason, one layer up. | ||
| 2090 | /// | ||
| 2091 | /// `w` UNZOOMS and `d` DETACHES; they used to be the same key. | ||
| 2092 | /// While the zoom was still a child-spawn's replacement, `d` | ||
| 2093 | /// meant "come back from this tile" because that is what it had meant to | ||
| 2094 | /// the child; now that `mux TARGET` is itself a wall entered zoomed, `d` | ||
| 2095 | /// has to keep meaning what it means to every user's fingers — detach the | ||
| 2096 | /// session and leave mux — or the muscle memory the whole convergence is | ||
| 2097 | /// judged by is the first thing it breaks. `w` is the model's own key: a | ||
| 2098 | /// wall is what unzooming shows, and it is the only way out of a zoom that | ||
| 2099 | /// stays inside mux. | ||
| 2100 | /// | ||
| 2101 | /// `n`/`p`/`c` are NOT tile motion here, and that is the spec's rule rather | ||
| 2102 | /// than an implementation convenience: they move the zoom around the | ||
| 2103 | /// DAEMON's session ring. A sibling already on the wall is an instant zoom | ||
| 2104 | /// move; a sibling without a tile gets one. Which sessions exist is the | ||
| 2105 | /// daemon's to say, so all three become one question and the answer decides | ||
| 2106 | /// — the plain client's discipline exactly, moved a layer out. The wall's | ||
| 2107 | /// SELECTOR (`j`/`k`/digits, unzoomed) is what still walks tiles. | ||
| 2108 | /// | ||
| 2109 | /// `last` is the tile the zoom last came from, or null when it has not | ||
| 2110 | /// moved yet. `Ctrl-\ l` with nothing to go back to unzooms rather than | ||
| 2111 | /// guessing — the spec's chosen fallback, and the same answer a tile that | ||
| 2112 | /// has since been FORGOTTEN gets, which is why `present` is consulted and | ||
| 2113 | /// not just the length. So does `l` aimed at the tile already zoomed, | ||
| 2114 | /// which tmux's `prefix-l` treats the same way: "go where I was" cannot | ||
| 2115 | /// mean "stay here", and re-zooming in place would clear the screen and | ||
| 2116 | /// repaint it to no visible effect. | ||
| 2117 | pub fn zoomChord( | 1838 | pub fn zoomChord( |
| 2118 | action: interact.PrefixFilter.Action, | 1839 | action: interact.PrefixFilter.Action, |
| 2119 | cur: usize, | 1840 | cur: usize, |
| @@ -2303,18 +2024,8 @@ fn paintEmptyWallLocked(shared: *Shared) void { | |||
| 2303 | proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {}; | 2024 | proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {}; |
| 2304 | } | 2025 | } |
| 2305 | 2026 | ||
| 2306 | /// Re-cut the stripes over the tiles that are LEFT, and put the whole wall | 2027 | /// One `paint_mu` hold: no window where a pump paints rows that just |
| 2307 | /// back on the screen. | 2028 | /// changed owner. |
| 2308 | /// | ||
| 2309 | /// One hold of `paint_mu` for the same reason `setZoom` takes it: between | ||
| 2310 | /// the clear and the new geometry there must be no window in which a pump | ||
| 2311 | /// paints a stripe at rows that have just changed owner. `t.stripe` is | ||
| 2312 | /// written here and read only under this lock (`paintStripe`, | ||
| 2313 | /// `paintLabelLocked`), which is what makes moving it safe at all. | ||
| 2314 | /// | ||
| 2315 | /// A failed re-layout keeps the old geometry: forgetting a tile can only | ||
| 2316 | /// give the survivors MORE rows, so `TooSmall` here is unreachable except | ||
| 2317 | /// for the empty wall, which is handled before it. | ||
| 2318 | fn relayout( | 2029 | fn relayout( |
| 2319 | alloc: std.mem.Allocator, | 2030 | alloc: std.mem.Allocator, |
| 2320 | tiles: []Tile, | 2031 | tiles: []Tile, |
| @@ -2364,20 +2075,8 @@ fn relayout( | |||
| 2364 | } | 2075 | } |
| 2365 | } | 2076 | } |
| 2366 | 2077 | ||
| 2367 | /// `x`: forget the selected tile. Removes its line from the wall file, | 2078 | /// `x`: removes the tile's line from the wall file and ends its pump. It |
| 2368 | /// ends its pump (which closes the transport and frees the daemon slot), | 2079 | /// NEVER kills the session — "remove is detach". |
| 2369 | /// and re-cuts the wall around the hole. | ||
| 2370 | /// | ||
| 2371 | /// It NEVER kills the session — "remove is detach", the dynamic-wall | ||
| 2372 | /// doctrine the home-screen spec keeps. Nothing at all is said to the | ||
| 2373 | /// daemon beyond the connection closing. | ||
| 2374 | /// | ||
| 2375 | /// A tile that is not in the wall file (a spelling named on `mux wall`'s | ||
| 2376 | /// own command line) is forgotten from the VIEW just the same, silently: | ||
| 2377 | /// the file had nothing to remove, and the screen is the answer either | ||
| 2378 | /// way. A file error is remembered rather than printed — this terminal is | ||
| 2379 | /// on the alternate screen and a stray line would corrupt the paint — and | ||
| 2380 | /// said once on the way out. | ||
| 2381 | fn forgetTile( | 2080 | fn forgetTile( |
| 2382 | alloc: std.mem.Allocator, | 2081 | alloc: std.mem.Allocator, |
| 2383 | tiles: []Tile, | 2082 | tiles: []Tile, |
| @@ -2407,23 +2106,8 @@ fn forgetTile( | |||
| 2407 | relayout(alloc, tiles, present, shared, stepPresent(present, sel, true) orelse sel); | 2106 | relayout(alloc, tiles, present, shared, stepPresent(present, sel, true) orelse sel); |
| 2408 | } | 2107 | } |
| 2409 | 2108 | ||
| 2410 | /// Would this tile be the session the shell running the wall is standing | 2109 | /// A stripe of the shell's own session paints into the grid it reads. |
| 2411 | /// in? A stripe of that session paints into the grid it is reading, which | 2110 | /// Mirrors `mux_main.insideThisSession`. |
| 2412 | /// is the attach-to-self feedback loop with a nicer name. | ||
| 2413 | /// | ||
| 2414 | /// Dropping such a tile is honest ONLY because the list it came from was | ||
| 2415 | /// auto-built — the saved wall, restored on an unzoom nobody spelled out — | ||
| 2416 | /// so a name the user never typed can leave without contradicting anything | ||
| 2417 | /// they said. `mux wall HOST#a` keeps refusing the WHOLE wall instead | ||
| 2418 | /// (mux_main), for the opposite reason: those tiles are what was asked for, | ||
| 2419 | /// and silently omitting one would be a wall lying about what it shows. | ||
| 2420 | /// | ||
| 2421 | /// The predicate mirrors `mux_main.insideThisSession` and must keep | ||
| 2422 | /// mirroring it: the true socket-and-session pair only, unix sockets only | ||
| 2423 | /// (a host or `quic://` target is a different daemon whatever its sessions | ||
| 2424 | /// are called), and an emptied variable counts as unset — `MUX_SESSION=` is | ||
| 2425 | /// how a shell overrides an export it cannot unset, and that is documented | ||
| 2426 | /// as the way to override the refusal. | ||
| 2427 | pub fn showsSelf( | 2111 | pub fn showsSelf( |
| 2428 | target: client.Target, | 2112 | target: client.Target, |
| 2429 | name: []const u8, | 2113 | name: []const u8, |
| @@ -2455,11 +2139,8 @@ fn sameTarget(a: client.Target, b: client.Target) bool { | |||
| 2455 | }; | 2139 | }; |
| 2456 | } | 2140 | } |
| 2457 | 2141 | ||
| 2458 | /// `layoutStripes`' own predicate, asked BEFORE a tile is added rather than | 2142 | /// Asked BEFORE the add, because a failed re-layout keeps the old geometry: |
| 2459 | /// after: a re-layout that fails keeps the old geometry, which is right for | 2143 | /// right for `x`, wrong here — the new tile would point at other rows. |
| 2460 | /// `x` (forgetting only ever gives the survivors more rows) and wrong for | ||
| 2461 | /// an add, where it would leave the new tile pointing at somebody else's | ||
| 2462 | /// rows. So a wall that cannot show another stripe refuses to grow one. | ||
| 2463 | fn wallFits(rows: u16, n: usize) bool { | 2144 | fn wallFits(rows: u16, n: usize) bool { |
| 2464 | // Guards the cast below, as in `layoutStripes` — not the wall. | 2145 | // Guards the cast below, as in `layoutStripes` — not the wall. |
| 2465 | if (n == 0 or n > 65535) return false; | 2146 | if (n == 0 or n > 65535) return false; |
| @@ -2532,11 +2213,8 @@ const ZoomTo = union(enum) { | |||
| 2532 | stay, | 2213 | stay, |
| 2533 | }; | 2214 | }; |
| 2534 | 2215 | ||
| 2535 | /// The attach-adds rule, as a zoom move: a sibling session of the tile the | 2216 | /// A sibling is zoomed if it has a tile and GETS one if not — otherwise a |
| 2536 | /// user is standing on is zoomed if it already has a tile, and GETS one if | 2217 | /// tile labelled S would paint T. |
| 2537 | /// it does not. Growing the wall by the truth costs the one dial and | ||
| 2538 | /// snapshot the old client's switch cost; not growing it would leave a tile | ||
| 2539 | /// labelled S painting T. | ||
| 2540 | fn zoomToSession( | 2218 | fn zoomToSession( |
| 2541 | alloc: std.mem.Allocator, | 2219 | alloc: std.mem.Allocator, |
| 2542 | tiles: []Tile, | 2220 | tiles: []Tile, |
| @@ -2633,17 +2311,7 @@ const EndAction = union(enum) { | |||
| 2633 | finish: struct { code: u8, msg: ?[]const u8 }, | 2311 | finish: struct { code: u8, msg: ?[]const u8 }, |
| 2634 | }; | 2312 | }; |
| 2635 | 2313 | ||
| 2636 | /// The rule, stated once (decisions.md carries it too): a session that ends | 2314 | /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user. |
| 2637 | /// under the zoom ends MUX when it was the wall's only tile — which is | ||
| 2638 | /// every plain `mux TARGET`, so the shell's exit code propagates exactly as | ||
| 2639 | /// it always did — and drops to the wall when it was not, because there is | ||
| 2640 | /// still something to look at and a tile that says what became of this one. | ||
| 2641 | /// | ||
| 2642 | /// `stdin_open` is the second half of that rule and belongs here rather | ||
| 2643 | /// than at the read that discovers it: a wall nobody can reach the keyboard | ||
| 2644 | /// of is not somewhere to drop a user, so a closed stdin makes every ending | ||
| 2645 | /// the wall's last. A piped `mux` otherwise waits in poll forever with its | ||
| 2646 | /// session's exit code in hand. | ||
| 2647 | fn endAction( | 2315 | fn endAction( |
| 2648 | tiles: []Tile, | 2316 | tiles: []Tile, |
| 2649 | present: []const bool, | 2317 | present: []const bool, |
| @@ -2698,22 +2366,9 @@ fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize { | |||
| 2698 | return hit; | 2366 | return hit; |
| 2699 | } | 2367 | } |
| 2700 | 2368 | ||
| 2701 | /// Is this tile still WAITING for a session — dialling, or rebuilding a | 2369 | /// The bare-Ctrl-\ abort is gated on exactly this: a tile whose pump ENDED is |
| 2702 | /// link that went? | 2370 | /// not waiting, so its `Ctrl-\ w` is the chord it looks like. The state comes |
| 2703 | /// | 2371 | /// back too: nothing was detached from a session that never came up. |
| 2704 | /// The bare-Ctrl-\ abort is gated on exactly this, and on nothing wider. | ||
| 2705 | /// The plain client's rule was "while dialling or reconnecting there is no | ||
| 2706 | /// session to command, and a bare Ctrl-\ still aborts", and both halves | ||
| 2707 | /// matter: a tile whose pump has ENDED is not waiting for anything, so its | ||
| 2708 | /// `Ctrl-\ w` is the chord it looks like and not an abort. Reading `state | ||
| 2709 | /// != .up` instead cost a zoom into a refused tile its only way out — the | ||
| 2710 | /// `w` of `\x1cw` never arrived, because the `\x1c` had already been | ||
| 2711 | /// spent quitting. | ||
| 2712 | /// | ||
| 2713 | /// `state` is read under `paint_mu`, the lock that field lives under; | ||
| 2714 | /// `alive` says whether anyone is still moving it. The state comes back | ||
| 2715 | /// with the answer because the two waits owe the user different sentences | ||
| 2716 | /// — nothing has been detached from a session that never came up. | ||
| 2717 | fn awaitingSession(t: *Tile) ?State { | 2372 | fn awaitingSession(t: *Tile) ?State { |
| 2718 | if (!t.alive.load(.acquire)) return null; | 2373 | if (!t.alive.load(.acquire)) return null; |
| 2719 | t.shared.paint_mu.lock(); | 2374 | t.shared.paint_mu.lock(); |
| @@ -2724,14 +2379,10 @@ fn awaitingSession(t: *Tile) ?State { | |||
| 2724 | }; | 2379 | }; |
| 2725 | } | 2380 | } |
| 2726 | 2381 | ||
| 2727 | /// Wait, briefly, for the pump to put a detach frame on the wire. | 2382 | /// Not tidiness: the daemon frees the slot when it reads the frame, and a |
| 2728 | /// | 2383 | /// `mux` typed straight after a `Ctrl-\ d` would otherwise race its own |
| 2729 | /// Not for tidiness: the daemon frees the slot when it reads the frame, and | 2384 | /// corpse for the session. Bounded so a wedged pump cannot hold the terminal |
| 2730 | /// a `mux` typed straight after a `Ctrl-\ d` would otherwise race its own | 2385 | /// hostage; the process exit closes the socket either way. |
| 2731 | /// corpse for the session — the failure the client's reconnect grace | ||
| 2732 | /// existed to paper over. Bounded because a wedged pump must not hold the | ||
| 2733 | /// terminal hostage; the process exit closes the socket either way, which | ||
| 2734 | /// is the slower path this is short-circuiting. | ||
| 2735 | fn awaitDetach(t: *Tile, shared: *Shared) void { | 2386 | fn awaitDetach(t: *Tile, shared: *Shared) void { |
| 2736 | const deadline = std.time.milliTimestamp() + 400; | 2387 | const deadline = std.time.milliTimestamp() + 400; |
| 2737 | while (!t.detach_ack.load(.acquire)) { | 2388 | while (!t.detach_ack.load(.acquire)) { |
| @@ -2745,18 +2396,8 @@ fn awaitDetach(t: *Tile, shared: *Shared) void { | |||
| 2745 | } | 2396 | } |
| 2746 | } | 2397 | } |
| 2747 | 2398 | ||
| 2748 | /// Fold the SAVED wall into this one, on the first unzoom. | 2399 | /// Deduped by SPELLING: the line the entry attach just wrote is the one |
| 2749 | /// | 2400 | /// already on screen. Best effort throughout. |
| 2750 | /// `Ctrl-\ w` shows THE wall — the file's tiles plus the one you are | ||
| 2751 | /// standing on — and this is where the two meet. Deduped by SPELLING, | ||
| 2752 | /// which is the file's own rule: the line the entry tile's attach just | ||
| 2753 | /// wrote is the line already on screen, and a second tile for it would be | ||
| 2754 | /// the model contradicting itself. | ||
| 2755 | /// | ||
| 2756 | /// Best effort throughout. No wall file, an unreadable one, a spelling the | ||
| 2757 | /// grammar refuses, a wall with no room left for another stripe — none of | ||
| 2758 | /// them is a reason to refuse the unzoom, because the tile the user is | ||
| 2759 | /// looking at is not in question. Returns how many tiles were added. | ||
| 2760 | fn hydrate( | 2401 | fn hydrate( |
| 2761 | alloc: std.mem.Allocator, | 2402 | alloc: std.mem.Allocator, |
| 2762 | tiles: []Tile, | 2403 | tiles: []Tile, |
| @@ -2798,21 +2439,8 @@ fn hydrate( | |||
| 2798 | return added; | 2439 | return added; |
| 2799 | } | 2440 | } |
| 2800 | 2441 | ||
| 2801 | /// `mux [TARGET] [--session S]`: attach, and be a wall of one tile, zoomed. | 2442 | /// The DIAL is on the main thread, before any wall: ssh can want the tty. |
| 2802 | /// | 2443 | /// The pump ADOPTS a link that is already up. |
| 2803 | /// The DIAL happens here, on the main thread, before any of the wall exists | ||
| 2804 | /// — and that placement is the whole seam. A first contact with a host | ||
| 2805 | /// reaches ssh through a shell, which can want the tty for a hostkey | ||
| 2806 | /// prompt or a password; the abort key has to work while it waits; and a | ||
| 2807 | /// dial that never came up owes the user the sentence and the exit code the | ||
| 2808 | /// plain client always gave (`client.openFailure`, unchanged). None of that | ||
| 2809 | /// is a pump's to do, so the pump ADOPTS a link that is already up. | ||
| 2810 | /// | ||
| 2811 | /// Everything after it is the wall's: one tile, entered zoomed, whose pump | ||
| 2812 | /// drives the same `interact.Core` over the same transport the old session | ||
| 2813 | /// loop drove. Bare `mux` is that with the default socket and session `0`, | ||
| 2814 | /// which is why it is unchanged in feel — it is the same machinery with | ||
| 2815 | /// nothing else on the wall yet. | ||
| 2816 | pub fn runAttach( | 2444 | pub fn runAttach( |
| 2817 | alloc: std.mem.Allocator, | 2445 | alloc: std.mem.Allocator, |
| 2818 | target: client.Target, | 2446 | target: client.Target, |
| @@ -3346,8 +2974,7 @@ test "labelText: the state word survives truncation at every width" { | |||
| 3346 | try std.testing.expect(widest.len <= buf.len); | 2974 | try std.testing.expect(widest.len <= buf.len); |
| 3347 | } | 2975 | } |
| 3348 | 2976 | ||
| 3349 | /// A wall of `n` tiles with none forgotten — what every pre-`x` assertion | 2977 | /// A wall of `n` tiles with none forgotten. |
| 3350 | /// about motion is really about. | ||
| 3351 | fn allPresent(comptime n: usize) [n]bool { | 2978 | fn allPresent(comptime n: usize) [n]bool { |
| 3352 | return [_]bool{true} ** n; | 2979 | return [_]bool{true} ** n; |
| 3353 | } | 2980 | } |
| @@ -3532,9 +3159,8 @@ test "showsSelf: hydration drops the tile the walling shell is standing in" { | |||
| 3532 | try std.testing.expect(!showsSelf(target, "0", sock, "")); | 3159 | try std.testing.expect(!showsSelf(target, "0", sock, "")); |
| 3533 | } | 3160 | } |
| 3534 | 3161 | ||
| 3535 | /// A wall of `n` tiles with an ending on tile `ended`, for the pure | 3162 | /// Only the fields `endAction` reads: a whole Tile describes the pump, not |
| 3536 | /// question `endAction` answers. Only the four fields it reads are set: | 3163 | /// the rule. |
| 3537 | /// building a whole Tile here would be describing the pump, not the rule. | ||
| 3538 | fn endBench( | 3164 | fn endBench( |
| 3539 | tiles: []Tile, | 3165 | tiles: []Tile, |
| 3540 | shared: *Shared, | 3166 | shared: *Shared, |
| @@ -3841,8 +3467,8 @@ test "the wall drains keys and reports in the order the terminal wrote them" { | |||
| 3841 | try std.testing.expect(d.next() == null); | 3467 | try std.testing.expect(d.next() == null); |
| 3842 | } | 3468 | } |
| 3843 | 3469 | ||
| 3844 | /// Everything sitting in the wall's output pipe right now. Non-blocking, | 3470 | /// Non-blocking, so an empty pipe reads as empty rather than parking the |
| 3845 | /// so an empty pipe reads as empty rather than parking the suite. | 3471 | /// suite. |
| 3846 | fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 { | 3472 | fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 { |
| 3847 | const n = std.posix.read(fd, buf) catch return ""; | 3473 | const n = std.posix.read(fd, buf) catch return ""; |
| 3848 | return buf[0..n]; | 3474 | return buf[0..n]; |