a73x

888e14a7

docs: the two worst narrators stop telling the story

a73x   2026-08-23 08:56

Commit message
docs: the two worst narrators stop telling the story

client.zig and wallview.zig carried 43,368 bytes of prose the tier-3
ruler flagged — 6,576 of it in two module headers that opened by
justifying their own existence. Both files now flag nothing.

What survives in the source is the why a reader at the decl needs and
cannot get from the code: the unbounded memcpy behind `validPick`, the
single-threaded `Transport` rule the pump layout rests on, the splice
hazard that makes `sendKeys` drop a whole chunk. What left is history
and measurement — a past compile, a past hang, a rule the spec and the
tests already state — and it went to decisions.md, which is grepped and
not re-billed every turn.

The budget lines drop 16274 -> 0 and 27094 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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];