a73x

94f783e5

refactor: the whole-terminal path goes — every session is a tile

a73x   2026-08-20 09:50

Commit message
refactor: the whole-terminal path goes — every session is a tile

`Core.init`, `takeTerminal`, `ownTerminal`, `readTyped` and the `Claim.whole`
arm have had no production callers since the convergence made every session a
wall tile: reachable only from `refAllDeclsRecursive` and from tests of
themselves. 206 lines out of interact.zig, only 47 of them code.

The comment sweep is what forced it. A doc paragraph saying "No driver takes
this path today" had already been added, and the essays under it described a
client that no longer exists — a process owning a screen for its whole run, a
reconnect loop re-entering with `claim` already set. Dead code is a drift
magnet and the docs gate cannot help: a citation of a live symbol from a dead
function resolves perfectly.

Traced before deleting. `terminal_setup` went with the path — `ownTerminal`
was its only writer — and so did three Core fields nothing else touched:
`prefix`, `in_buf` (16 KiB per Core) and `orig_termios` with the `deinit`
termios restore that read it, since only `takeTerminal` ever set it. Kept,
because the wall derives its lifecycle from them: `terminal_frame_setup`
(`wall_setup` is built from it), `session_claim`/`session_release`,
`terminal_teardown` (`wall_teardown` IS it, and it must still end in `?1049l`
for e2e tp1's doctored control), the comptime mouse pairing, `ttySize` and
`watchWinch`.

The arguments moved where they still defend live code. `ownTerminal`'s
claim-is-a-flag-not-an-ordering essay is now on `Claim`, which is the flag.
Its title-stack essay (an unmatched pop restores a stranger's title) is now on
`wall_setup`, which pushes `22;0t` today. `takeTerminal`'s ISIG/IXON argument
moved to the wall's raw-mode block, which sets those bits. The `?7l` autowrap
argument moved to `terminal_frame_setup`. The DEC 1007 argument was already
stated at `claimTerminal`; only its second copy dropped. Died with the code:
"enter the alt screen late so `--via`'s stderr survives" (the wall enters at
startup, before it dials) and "call this ahead of routing every frame".

Tests: the four-way pairing test lost its `terminal_setup` assertion and kept
the rest; the teardown pin retargeted its push half to `wall_setup`. The
promote/demote pipe test and the mouse-mode drift tests are untouched.

No behavior change. `zig build check`, `make test`, `make e2e` green, e2e pin
unmoved at 55 scenarios / 35 convergence points.

docs/decisions.md
Old New
@@ -4876,3 +4876,69 @@ check that never ran.
4876 4876
4877 `tools/` joined `zig fmt --check`'s paths, which is how the tool's own 4877 `tools/` joined `zig fmt --check`'s paths, which is how the tool's own
4878 formatting is held to the same standard as the code it inspects. 4878 formatting is held to the same standard as the code it inspects.
4879
4880 ## 2026-08-20 (the whole-terminal path goes)
4881
4882 `interact.Core` carried two ways to take a terminal. The whole-terminal one —
4883 `init` (measure the tty and size the Engine from it), `takeTerminal` (raw mode
4884 and the SIGWINCH handler), `ownTerminal` (the alternate screen, entered on the
4885 first frame), `readTyped` (read stdin, split the chord off the front) and the
4886 `Claim.whole` arm that told the exit teardown which bytes to write — has had
4887 zero production callers since the convergence made every session a wall tile.
4888 Reachable only from `refAllDeclsRecursive` and from tests of itself.
4889
4890 `src/interact.zig` goes 2893 → 2789 lines: 206 deleted against 102 rewritten
4891 in place, and only 47 of the deletions were code. The rest was prose. **Why
4892 now:** the
4893 comment-discipline sweep the day before kept finding drift *inside* this path —
4894 a doc paragraph had already been added saying "No driver takes this path
4895 today", and the essays underneath it described a client that no longer exists
4896 (a process that owns a screen for its whole run, a reconnect loop that
4897 re-enters with `claim` already set). Dead code is a drift magnet: it costs
4898 nothing to run and everything to keep honest, and every future comment sweep
4899 pays for it again. The docs gate cannot help here — a citation of a live symbol
4900 from a dead function resolves perfectly.
4901
4902 **What stayed, and why.** The wall derives its terminal lifecycle from the same
4903 building blocks, so tracing each one mattered more than the deletion:
4904
4905 - `terminal_frame_setup` — the SCREEN half. `wall_setup` is built from it.
4906 - `session_claim` / `session_release` — the SESSION half. `claimTerminal` and
4907 every demote path.
4908 - `terminal_teardown` — `wall_teardown` *is* it. Still the only `?2004l` mux is
4909 certain to write, and still the string that must end in `?1049l` for e2e's
4910 `tp1` doctored control.
4911 - `client_mouse_capture` / `client_mouse_setup` / `inClientCapture` /
4912 `mouse_teardown` — the comptime-generated mouse pairing, unchanged.
4913 - `ttySize` / `watchWinch` — public, and the wall calls both directly.
4914
4915 `terminal_setup` (`terminal_frame_setup ++ session_claim`) went with the path:
4916 `ownTerminal` was its only writer. So did three Core fields nothing else
4917 touched — `prefix` and `in_buf` (16 KiB per Core, `readTyped`'s alone) and
4918 `orig_termios` with the `deinit` restore reading it, since only `takeTerminal`
4919 ever set it and the wall owns raw mode now.
4920
4921 **The essays moved rather than died,** where the argument still defends live
4922 code. `ownTerminal`'s claim-is-a-flag-not-an-ordering argument (a `?2004h`
4923 written outside a claim is one nothing will ever undo; it was a race before it
4924 was a flag) is now on `Claim` itself, which is the flag. Its title-stack
4925 argument (an unmatched pop restores a stranger's title, so the push must be
4926 exactly one deep) is now on `wall_setup`, which is what pushes `22;0t` today.
4927 `takeTerminal`'s ISIG/IXON argument moved to the wall's raw-mode block, which
4928 sets those bits. The DEC 1007 alternate-scroll argument was already stated at
4929 `claimTerminal` and needed only its second copy dropped. What died with the
4930 code: the "enter the alt screen late so `--via`'s stderr survives" argument
4931 (the wall enters at startup, before it dials — a different design, not this
4932 one's) and the "call this ahead of routing every frame" discipline, which was
4933 about a call nobody makes.
4934
4935 **Tests.** The four-way pairing test lost its one assertion about
4936 `terminal_setup` and kept the rest; the teardown pin retargeted its push
4937 half from `terminal_setup` to `wall_setup`, which is the same six bytes of
4938 title-and-alt-screen a wall writes. The promote/demote pipe test and the
4939 mouse-mode `inline for` drift tests are untouched.
4940
4941 **No behavior change**, and the suite was the referee: `zig build check`,
4942 `make test` and `make e2e` all green with the e2e pin still at 55 scenarios /
4943 35 convergence points. Recovery, if a whole-terminal client is ever wanted
4944 again: git remembers it, at the commit this entry ships in.
src/interact.zig
Old New
@@ -128,8 +128,10 @@ pub const PrefixFilter = struct {
128 } 128 }
129 }; 129 };
130 130
131 /// One read of the session's stdin. The mouse filter's scratch is sized 131 /// The most one read of the session's stdin can hand `Core.forward`. The
132 /// from it, so they are one constant. 132 /// driver owns the read (the wall's is `wallview.mailbox_max`); this is
133 /// what the mouse filter's scratch is sized from, so a chunk that outgrew
134 /// it would be one the filter could not hold a candidate across.
133 const stdin_chunk = 16 * 1024; 135 const stdin_chunk = 16 * 1024;
134 136
135 /// How many rows one wheel notch moves the scrollback view. Three is what 137 /// How many rows one wheel notch moves the scrollback view. Three is what
@@ -286,30 +288,20 @@ pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
286 // terminal. Untrusted wire validation belongs to client_core; these adapters 288 // terminal. Untrusted wire validation belongs to client_core; these adapters
287 // only perform the native platform operation selected by that shared core. 289 // only perform the native platform operation selected by that shared core.
288 290
289 /// Everything the client does TO the host terminal on the way in, in the 291 /// Everything a driver does TO the host terminal to own a SCREEN, in the
290 /// order it does it: push the title, enter the alternate screen, hide the 292 /// order it does it: push the title, enter the alternate screen, hide the
291 /// cursor, disable autowrap. Named rather than inline because it is one 293 /// cursor, disable autowrap. Named rather than inline because it is one
292 /// half of a pair — `terminal_teardown` undoes each of these, and the pair 294 /// half of a pair — `terminal_teardown` undoes each of these, and the pair
293 /// is pinned together in one test so neither half can drift alone. 295 /// is pinned together in one test so neither half can drift alone.
294 /// 296 ///
295 /// Written exactly once per process, under the same `claim` gate that 297 /// Written once for a driver's whole LIFETIME rather than per session,
296 /// admits the teardown; the argument for that gate is at the call site. 298 /// because the driver is the wall (`wall_setup`): it holds the screen while
299 /// its tiles promote and demote underneath it, and a tile writes only
300 /// `session_claim`.
297 /// 301 ///
298 /// The mouse enables are last and they are the client's OWN: with no mouse 302 /// Autowrap (`?7l`) goes off with the alternate screen and for its sake: an
299 /// reporting on, a host terminal answers the wheel by synthesising arrow 303 /// oversized grid row must clip at the right edge rather than wrap and
300 /// keys on the alternate screen (DEC 1007, "alternate scroll"), which land 304 /// shift the whole paint down a line.
301 /// in the session as input and move the shell's history instead of the
302 /// view. Asking for real wheel events is what makes the wheel scrollable at
303 /// all, and it turns 1007's synthesis off as a side effect. The session's
304 /// own modes arrive moments later in the first `term_modes` and level-set
305 /// these; this is what the wheel does until they do.
306 const terminal_setup = terminal_frame_setup ++ session_claim;
307
308 /// The half of `terminal_setup` that is about owning a SCREEN — the title
309 /// stack, the alternate screen, the cursor, autowrap. A driver that already
310 /// holds a screen and lends it to one session at a time writes this once
311 /// for its own lifetime and `session_claim` per promoted session; see
312 /// `wall_setup`.
313 const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l"; 305 const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
314 306
315 /// The half that is about holding a SESSION on somebody's terminal: the 307 /// The half that is about holding a SESSION on somebody's terminal: the
@@ -319,10 +311,17 @@ const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
319 /// may not touch it. 311 /// may not touch it.
320 /// 312 ///
321 /// Spelled as a shared constant rather than repeated, so the pairing is 313 /// Spelled as a shared constant rather than repeated, so the pairing is
322 /// structural: `terminal_setup` and `wall_setup` are both built from these 314 /// structural: this is the session half and `wall_setup` is the screen
323 /// pieces, and `session_release` appears verbatim inside every teardown. 315 /// half, and `session_release` appears verbatim inside every teardown.
324 /// A mouse mode added to one claim is added to all of them, and one dropped 316 /// A mouse mode added here is added to every claim there is, and one
325 /// from the release is dropped from every path that undoes a claim. 317 /// dropped from the release is dropped from every path that undoes a claim.
318 ///
319 /// The mouse enables are the CLIENT's own, not a session's: with no mouse
320 /// reporting on, a host terminal answers the wheel by synthesising arrow
321 /// keys on the alternate screen (DEC 1007, "alternate scroll"), which land
322 /// in the session as input and move the shell's history instead of the
323 /// view. Asking for real wheel events is what makes the wheel scrollable at
324 /// all, and it turns 1007's synthesis off as a side effect.
326 const session_claim = client_mouse_setup; 325 const session_claim = client_mouse_setup;
327 326
328 /// The mouse modes the client asks its own terminal for when no application 327 /// The mouse modes the client asks its own terminal for when no application
@@ -431,6 +430,18 @@ pub const session_release = "\x1b[?2004l" ++ mouse_teardown;
431 /// through four sessions sees four titles and gets their own back when the 430 /// through four sessions sees four titles and gets their own back when the
432 /// wall exits. Restoring at each demote would need a title to restore TO, 431 /// wall exits. Restoring at each demote would need a title to restore TO,
433 /// and mux cannot read one back — the tmux behaviour, and the right one. 432 /// and mux cannot read one back — the tmux behaviour, and the right one.
433 ///
434 /// That push (`22;0t`, "icon name and window title", matching the OSC 0
435 /// `appendTermTitle` writes) is exactly one deep, and the guarantee was
436 /// checked rather than assumed — an unmatched POP does not restore a
437 /// title, it pops whatever the terminal had underneath, which is somebody
438 /// else's. This write and the pop inside `terminal_teardown` are the only
439 /// two in the tree, and the wall writes each once: one path in, and every
440 /// path out is the same teardown. What escapes is a signal that kills the
441 /// process outright — and that loses `?1049l` and `?25h` with it, leaving
442 /// the user on an alternate screen with no cursor, so a title stack one
443 /// deeper is not the part they will notice. Same exposure as every other
444 /// line of the teardown, not a new one.
434 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J"; 445 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
435 pub const wall_teardown = terminal_teardown; 446 pub const wall_teardown = terminal_teardown;
436 447
@@ -923,20 +934,31 @@ pub const Sink = struct {
923 end: ?*const fn (?*anyopaque) void = null, 934 end: ?*const fn (?*anyopaque) void = null,
924 }; 935 };
925 936
926 /// Which claim a Core holds on its terminal, and therefore which teardown 937 /// Whether a Core holds a claim on its terminal, and therefore whether
927 /// puts it back. 938 /// there is a teardown to write.
928 /// 939 ///
929 /// A client OWNS its terminal for its whole run: the alternate screen, the 940 /// A Core never owns a SCREEN. The driver that owns one lends it to a
930 /// cursor, autowrap, the title stack. A wall tile BORROWS one the wall 941 /// session at a time (`wall_setup`), so a claim is only what a SESSION
931 /// already owns, so its claim is only what a SESSION brings — the mouse 942 /// brings — the mouse modes, and whatever the session then asks this
932 /// modes, and whatever the session then asks this terminal for. 943 /// terminal for.
933 /// 944 ///
934 /// It is also the flag that arms the undo, which is the load-bearing part: 945 /// It is also the flag that arms the undo, which is the load-bearing part:
935 /// nothing may write a mode to a terminal this is `.none` on, because 946 /// nothing may write a mode to a terminal this is `.none` on, because
936 /// `.none` is exactly the state in which nothing is arranged to unset it. 947 /// `.none` is exactly the state in which nothing is arranged to unset it.
937 /// See `ownTerminal` for the whole argument — it is one flag, not an 948 /// `?2004l` is the case that bites. A session that arms bracketed paste
938 /// ordering, and it was a race before it was a flag. 949 /// and then dies never sends the `term_modes` frame that would unset it,
939 pub const Claim = enum { none, whole, session }; 950 /// so the teardown this flag gates is the only `?2004l` mux is certain to
951 /// write — and a `?2004h` that went out under `.none` is one nothing will
952 /// ever undo, handing the user back a terminal that eats their pastes with
953 /// nothing on screen to say why.
954 ///
955 /// It is one flag, not an ordering, and it was a race before it was a
956 /// flag: the arm used to go out unconditionally and the claim simply
957 /// always won on a tty. `writeSideChannel` takes the claim and refuses
958 /// everything while it is false, so the set and its undo are gated on one
959 /// flag rather than on who ran first — and the title is how the hole the
960 /// race left open was found; see that function.
961 pub const Claim = enum { none, session };
940 962
941 /// One session's interaction state, and everything done to a terminal on 963 /// One session's interaction state, and everything done to a terminal on
942 /// its behalf: the replica the daemon's frames are replayed into, the 964 /// its behalf: the replica the daemon's frames are replayed into, the
@@ -946,24 +968,22 @@ pub const Claim = enum { none, whole, session };
946 /// How you drive it. The driver owns the transport and the loop; the Core 968 /// How you drive it. The driver owns the transport and the loop; the Core
947 /// owns what happens at each event: 969 /// owns what happens at each event:
948 /// 970 ///
949 /// * `init` / `deinit` — the Core owns its Engine, its overlay and, once 971 /// * `initSized` / `deinit` — the Core owns its Engine, its overlay and
950 /// `takeTerminal` has run, the terminal's mode. `deinit` puts all three 972 /// whatever terminal claim it still holds. `deinit` puts all three
951 /// back; see there for the order. 973 /// back; see there for the order.
952 /// * `takeTerminal` + `ownTerminal` are the WHOLE-terminal path: raw mode 974 /// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS
953 /// here, alternate screen on the first frame. No driver takes it today — 975 /// for as long as it is promoted. Raw mode and the SIGWINCH handler
954 /// the wall arms raw mode and SIGWINCH itself, and every session is a 976 /// belong to the driver that owns the screen (the wall arms both); a
955 /// tile, which uses `claimTerminal`/`releaseTerminal` instead. See 977 /// Core only ever holds a session's claim. See `Claim`.
956 /// `Claim`.
957 /// * per pass: `winch`, then `idle`. 978 /// * per pass: `winch`, then `idle`.
958 /// * per frame: `frame(type, payload)` routes it and returns what is 979 /// * per frame: `frame(type, payload)` routes it and returns what is
959 /// LEFT (see `Routed`) — the replica's apply included, because a driver 980 /// LEFT (see `Routed`) — the replica's apply included, because a driver
960 /// that applied on its own would be a second exhaustive switch over the 981 /// that applied on its own would be a second exhaustive switch over the
961 /// wire, which is what this replaced. 982 /// wire, which is what this replaced.
962 /// * per read of stdin: `readTyped`, then the driver acts on the action 983 /// * per read of stdin: the driver's, not the Core's. The wall's keyboard
963 /// and passes the rest to `forward`. The wall's keyboard is on another 984 /// is on its own thread, so it feeds its own `PrefixFilter`, acts on
964 /// thread, so it feeds its own `PrefixFilter` and calls `forward` with 985 /// the action, and calls `forward` with the bytes that were not a
965 /// what that thread handed over — the chord layer is the same table 986 /// chord — everything below the chord layer (mouse, wheel, scrollback,
966 /// either way, and everything below it (mouse, wheel, scrollback,
967 /// prediction) is in `forward`. 987 /// prediction) is in `forward`.
968 /// * around a reconnect: `dropScrollView` before, `reattached` after. 988 /// * around a reconnect: `dropScrollView` before, `reattached` after.
969 /// 989 ///
@@ -979,15 +999,15 @@ pub const Claim = enum { none, whole, session };
979 /// no, and the wall paints the stripe from `grid()` instead. 999 /// no, and the wall paints the stripe from `grid()` instead.
980 pub const Core = struct { 1000 pub const Core = struct {
981 alloc: std.mem.Allocator, 1001 alloc: std.mem.Allocator,
982 /// The user's terminal. `in_fd` is the tty this session is typed at — 1002 /// The user's terminal. `in_fd` is the tty this session is typed at,
983 /// `readTyped` reads it, and a driver whose keyboard is on another 1003 /// and the Core asks it exactly one question — whether it IS one —
984 /// thread passes it only for `is_tty` and the termios restore. 1004 /// because the keyboard belongs to the driver. `out_fd` is everything
985 /// `out_fd` is everything this paints on. 1005 /// this paints on.
986 in_fd: std.posix.fd_t, 1006 in_fd: std.posix.fd_t,
987 out_fd: std.posix.fd_t, 1007 out_fd: std.posix.fd_t,
988 /// Whether there is a terminal to own at all. A client whose stdin is a 1008 /// Whether there is a terminal to claim at all. A session whose stdin
989 /// pipe takes no raw mode, enters no alternate screen and asks nobody 1009 /// is a pipe asks nobody for mouse reports and claims nothing — see
990 /// for mouse reports — see `writeSideChannel` for what that costs. 1010 /// `writeSideChannel` for what that costs.
991 is_tty: bool, 1011 is_tty: bool,
992 /// The local clip size. The replica follows the daemon's authoritative 1012 /// The local clip size. The replica follows the daemon's authoritative
993 /// grid, which under latest-wins may differ from this; paints are 1013 /// grid, which under latest-wins may differ from this; paints are
@@ -1007,8 +1027,6 @@ pub const Core = struct {
1007 /// handed to the native adapters above. The web client owns another 1027 /// handed to the native adapters above. The web client owns another
1008 /// instance of this same platform-neutral state machine. 1028 /// instance of this same platform-neutral state machine.
1009 semantic: client_core.ClientCore = .{}, 1029 semantic: client_core.ClientCore = .{},
1010 /// Chord state lives across reads, so it outlives one buffer.
1011 prefix: PrefixFilter = .{},
1012 /// Mouse reports arrive in the same reads as keystrokes; this splits 1030 /// Mouse reports arrive in the same reads as keystrokes; this splits
1013 /// them back out, across read boundaries. 1031 /// them back out, across read boundaries.
1014 mouse: MouseFilter = .{}, 1032 mouse: MouseFilter = .{},
@@ -1020,7 +1038,7 @@ pub const Core = struct {
1020 /// Replica (which holds grid/seq/epoch/history — see replica.zig). 1038 /// Replica (which holds grid/seq/epoch/history — see replica.zig).
1021 scroll_rows: u32 = 0, 1039 scroll_rows: u32 = 0,
1022 /// What this Core holds on the terminal, and therefore what it may 1040 /// What this Core holds on the terminal, and therefore what it may
1023 /// write there and what it must undo. See `Claim` and `ownTerminal`. 1041 /// write there and what it must undo. See `Claim`.
1024 claim: Claim = .none, 1042 claim: Claim = .none,
1025 /// Whether this Core may paint right now, and what to hold while it 1043 /// Whether this Core may paint right now, and what to hold while it
1026 /// does. Set by a driver that shares its terminal; see `Sink`. 1044 /// does. Set by a driver that shares its terminal; see `Sink`.
@@ -1036,34 +1054,22 @@ pub const Core = struct {
1036 /// exit. The wall publishes its counters instead; see 1054 /// exit. The wall publishes its counters instead; see
1037 /// `dumpPredictStats`. 1055 /// `dumpPredictStats`.
1038 owns_stats: bool = true, 1056 owns_stats: bool = true,
1039 /// Null when there was no terminal to put into raw mode.
1040 orig_termios: ?std.posix.termios = null,
1041 /// The first paint after a reconnect must be a full one, so the 1057 /// The first paint after a reconnect must be a full one, so the
1042 /// [reconnecting] banner goes away with everything else now stale. 1058 /// [reconnecting] banner goes away with everything else now stale.
1043 repaint_after_resync: bool = false, 1059 repaint_after_resync: bool = false,
1044 /// One read of stdin, and the mouse filter's scratch — sized to hold 1060 /// The mouse filter's scratch — sized to hold one read of stdin plus
1045 /// one chunk plus whatever a previous read left mid-report (see 1061 /// whatever a previous read left mid-report (see MouseFilter.feed).
1046 /// MouseFilter.feed).
1047 in_buf: [stdin_chunk]u8 = undefined,
1048 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, 1062 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
1049 1063
1050 /// The terminal is measured here, not passed in: the Engine has to be 1064 /// Born at a size the driver already measured, because the Engine has
1051 /// born at the size the first paint will be clipped to. 1065 /// to be born at the size the first paint will be clipped to.
1052 pub fn init(
1053 alloc: std.mem.Allocator,
1054 in_fd: std.posix.fd_t,
1055 out_fd: std.posix.fd_t,
1056 ) !Core {
1057 return initSized(alloc, in_fd, out_fd, ttySize(out_fd) orelse .{ .cols = 80, .rows = 24 });
1058 }
1059
1060 /// The same, at a size the driver already measured.
1061 /// 1066 ///
1062 /// For a driver whose layout was cut from ONE reading of the terminal — 1067 /// Measured by the DRIVER and not here, because the driver's layout was
1063 /// the wall measures at startup and re-reads the terminal only through 1068 /// cut from ONE reading of the terminal — the wall measures at startup
1064 /// the promoted tile that answers the SIGWINCH (`setWallSize`) — so 1069 /// and re-reads the terminal only through the promoted tile that
1065 /// that its tiles clip to the size its stripes were cut from, rather 1070 /// answers the SIGWINCH (`setWallSize`) — so that its tiles clip to the
1066 /// than to whatever a second ioctl says after the user dragged a corner. 1071 /// size its stripes were cut from, rather than to whatever a second
1072 /// ioctl says after the user dragged a corner.
1067 /// Two answers to "how big is this terminal" inside one screen is a 1073 /// Two answers to "how big is this terminal" inside one screen is a
1068 /// promoted tile painting at rows the wall does not believe in. 1074 /// promoted tile painting at rows the wall does not believe in.
1069 pub fn initSized( 1075 pub fn initSized(
@@ -1084,46 +1090,24 @@ pub const Core = struct {
1084 }; 1090 };
1085 } 1091 }
1086 1092
1087 /// The terminal goes back FIRST, so the stats line and whatever the 1093 /// The terminal claim goes back FIRST, so the stats line and whatever
1088 /// driver prints on its way out land on the normal screen rather than 1094 /// the driver prints on its way out land on a terminal already out of
1089 /// the wiped alternate one. 1095 /// this session's modes.
1090 pub fn deinit(self: *Core) void { 1096 pub fn deinit(self: *Core) void {
1091 // Only undo what was actually claimed: leaving the alternate screen 1097 // Only undo what was actually claimed, and a claim is a SESSION's
1092 // we never entered would wipe the user's own scrollback, and a wall 1098 // modes and nothing more: the screen belongs to the driver, which
1093 // tile never entered it at all — the wall is still on it, and still 1099 // is still on it and still using it after this tile's pump has
1094 // using it, after this tile's pump has gone. 1100 // gone.
1095 // `.write`, always. This is the path a pump takes when its SESSION 1101 // `.write`, always. This is the path a pump takes when its SESSION
1096 // ends under a zoom — nobody moved the zoom, so nobody else wrote 1102 // ends under a zoom — nobody moved the zoom, so nobody else wrote
1097 // the release, and the wall goes on running with the terminal still 1103 // the release, and the wall goes on running with the terminal still
1098 // in that session's modes. 1104 // in that session's modes.
1099 self.releaseTerminal(.write); 1105 self.releaseTerminal(.write);
1100 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {};
1101 if (self.owns_stats) dumpPredictStats(self.overlay.counters); 1106 if (self.owns_stats) dumpPredictStats(self.overlay.counters);
1102 self.overlay.deinit(); 1107 self.overlay.deinit();
1103 self.rep.eng.deinit(); 1108 self.rep.eng.deinit();
1104 } 1109 }
1105 1110
1106 /// Raw mode, and the SIGWINCH handler that makes `winch` reachable.
1107 /// The alternate screen is NOT entered here — see `ownTerminal`.
1108 pub fn takeTerminal(self: *Core) !void {
1109 if (!self.is_tty) return;
1110 const orig = try std.posix.tcgetattr(self.in_fd);
1111 self.orig_termios = orig;
1112 var raw = orig;
1113 raw.lflag.ICANON = false;
1114 raw.lflag.ECHO = false;
1115 // ISIG and IXON off because those keys belong to the SESSION, not
1116 // to mux: Ctrl-C has to reach the remote shell as a byte, and
1117 // Ctrl-S must not freeze the local terminal out from under a
1118 // session that wanted it. It is also why `Ctrl-\` is the only way
1119 // out — nothing else here raises a signal any more.
1120 raw.lflag.ISIG = false;
1121 raw.iflag.IXON = false;
1122 raw.iflag.ICRNL = false;
1123 try std.posix.tcsetattr(self.in_fd, .FLUSH, raw);
1124 watchWinch();
1125 }
1126
1127 /// Adopt a size somebody else measured. 1111 /// Adopt a size somebody else measured.
1128 /// 1112 ///
1129 /// The wall's, at a promote. A tile's Core is born at the terminal's 1113 /// The wall's, at a promote. A tile's Core is born at the terminal's
@@ -1136,94 +1120,21 @@ pub const Core = struct {
1136 self.size = size; 1120 self.size = size;
1137 } 1121 }
1138 1122
1139 /// Enter the alternate screen, once, on the first frame that proves the
1140 /// transport works.
1141 ///
1142 /// No driver takes this path today — every session is a wall tile and
1143 /// claims through `claimTerminal`. What follows is what any future
1144 /// whole-terminal client has to keep.
1145 ///
1146 /// Entering at setup would erase whatever the `--via` command wrote to its
1147 /// inherited stderr (ssh reports auth and connection failures hundreds of
1148 /// ms after spawn), and would blank the screen for the whole of a hang like
1149 /// `mux --via "sleep 30"`. Autowrap goes off with it: an oversized grid row
1150 /// must clip at the right edge rather than wrap and shift the whole paint.
1151 ///
1152 /// The driver must call this AHEAD of routing a frame, and for EVERY frame
1153 /// type, not just the ones that paint. `claim` gates the exit
1154 /// teardown, which is the only `?2004l` mux is certain to write — a session
1155 /// that asks for bracketed paste and then dies never sends the term_modes
1156 /// frame that would unset it. So a term_modes handled while `claim`
1157 /// was still false would turn bracketing ON with nothing arranged to turn
1158 /// it off — the user gets their terminal back still bracketing pastes long
1159 /// after mux exited, with nothing on screen to say why.
1160 ///
1161 /// This comment is the whole defence, and no fixture can be written to take
1162 /// over from it: `sendResync`'s `defer` puts term_modes LAST, so no fixture
1163 /// driving the real daemon can deliver one to a client that has not already
1164 /// seen a frame. (A test with a hand-built frame stream could: a Transport
1165 /// is two fds, so a same-file test can construct one over a pipe. What
1166 /// stops it is that the interesting path needs `is_tty`, i.e. a real pty —
1167 /// which is why the coverage that exists is out-of-process in
1168 /// test/ptyclient. So this is "nobody has written one", not "one cannot
1169 /// exist".) The hazard is unreachable from outside and one edit away from
1170 /// inside.
1171 ///
1172 /// Read it as two claims, because only the first is about ordering. (1)
1173 /// This runs before any arm can write `?2004h`. (2) `?2004l` is written
1174 /// only under a `claim`, so a `?2004h` written outside one is nothing
1175 /// will undo.
1176 ///
1177 /// Claim (2) used to be held by a race — `?2004h` went out unconditionally,
1178 /// and on a tty this block simply always won. It is now structural:
1179 /// `writeSideChannel` takes the claim and refuses everything while it is
1180 /// false, so the set and its undo are gated on one flag rather than on an
1181 /// ordering. That closed a hole the race left open, and the title is how it
1182 /// was found — see that function. A `--no-altscreen` or an inline mode
1183 /// still needs its own teardown gate; what it no longer needs is for this
1184 /// to win a race first.
1185 ///
1186 /// The title push (`22;0t`) rides the same gate for the same pairing
1187 /// argument, and needs it more: an unmatched POP does not restore a title,
1188 /// it pops whatever the terminal had underneath — somebody else's. Pushed
1189 /// here and popped in `terminal_teardown`, both under `claim`, is what
1190 /// makes the pair exactly one deep. `0` is "icon name and window title",
1191 /// matching the OSC 0 `appendTermTitle` writes.
1192 ///
1193 /// A push without a pop is a stack that only grows, so the guarantee was
1194 /// checked rather than assumed: this write and the pop are the only two in
1195 /// the tree, every exit from a session is a `return` (no process.exit, no
1196 /// exec, no panic here), and the reconnect path cannot push twice because
1197 /// it re-enters the loop with `claim` already set. What escapes is a
1198 /// signal that kills the process outright — and that loses `?1049l` and
1199 /// `?25h` with it, leaving the user on an alternate screen with no cursor,
1200 /// so a title stack one deeper is not the part they will notice. Same
1201 /// exposure as every other line of the teardown, not a new one.
1202 pub fn ownTerminal(self: *Core) !void {
1203 if (self.is_tty and self.claim == .none) {
1204 try proto.writeAllFd(self.out_fd, terminal_setup);
1205 self.claim = .whole;
1206 }
1207 }
1208
1209 /// Take the terminal for this session ALONE, on a screen somebody else 1123 /// Take the terminal for this session ALONE, on a screen somebody else
1210 /// already owns — the wall's promote. 1124 /// already owns — the wall's promote.
1211 /// 1125 ///
1212 /// Where `ownTerminal` writes the whole `terminal_setup` once per 1126 /// This writes `session_claim` once per PROMOTE; the wall wrote the
1213 /// process, this writes `session_claim` once per PROMOTE and the wall 1127 /// screen half (`wall_setup`) for its own lifetime. What a session
1214 /// wrote the screen half (`wall_setup`) for its own lifetime. What a 1128 /// needs is exactly the mouse modes: without them a host terminal
1215 /// session needs is exactly the mouse modes: without them a host 1129 /// answers the wheel by synthesising arrow keys (DEC 1007) that land in
1216 /// terminal answers the wheel by synthesising arrow keys (DEC 1007) 1130 /// the session as input, which is what a zoomed tile did before this
1217 /// that land in the session as input, which is what a zoomed tile did 1131 /// existed — the whole point of routing a tile's input through a Core.
1218 /// before this existed — the whole point of routing a tile's input
1219 /// through a Core.
1220 /// 1132 ///
1221 /// It sets `claim`, and `claim` is what arms the undo. Every mode a 1133 /// It sets `claim`, and `claim` is what arms the undo. Every mode a
1222 /// promoted session then asks this terminal for goes out under that 1134 /// promoted session then asks this terminal for goes out under that
1223 /// flag (`writeSideChannel`), so the set and its undo are one flag 1135 /// flag (`writeSideChannel`), so the set and its undo are one flag
1224 /// rather than an ordering — `ownTerminal`'s argument, applied to the 1136 /// rather than an ordering — see `Claim`, whose pair turns over many
1225 /// pair that turns over many times per wall instead of once per 1137 /// times per wall instead of once per process.
1226 /// process.
1227 /// 1138 ///
1228 /// Taken under the SINK, and that is about order, not about painting. 1139 /// Taken under the SINK, and that is about order, not about painting.
1229 /// A driver that shares its terminal writes the previous holder's 1140 /// A driver that shares its terminal writes the previous holder's
@@ -1306,7 +1217,6 @@ pub const Core = struct {
1306 if (undo == .already_written) return; 1217 if (undo == .already_written) return;
1307 switch (held) { 1218 switch (held) {
1308 .none => {}, 1219 .none => {},
1309 .whole => proto.writeAllFd(self.out_fd, terminal_teardown) catch {},
1310 .session => proto.writeAllFd(self.out_fd, session_release) catch {}, 1220 .session => proto.writeAllFd(self.out_fd, session_release) catch {},
1311 } 1221 }
1312 } 1222 }
@@ -1653,21 +1563,6 @@ pub const Core = struct {
1653 } 1563 }
1654 } 1564 }
1655 1565
1656 /// Read one chunk of what the user typed and split the chord off the
1657 /// front of it. Null is EOF: there is nothing more coming from this
1658 /// descriptor and the driver stops polling it.
1659 ///
1660 /// The action is handed back rather than acted on, because what an
1661 /// action MEANS is the driver's — see `PrefixFilter`. Whatever was
1662 /// typed AHEAD of the chord is in `forward` and is still owed to the
1663 /// session, so a driver that does not end the run passes it to
1664 /// `forward` below.
1665 pub fn readTyped(self: *Core) ?PrefixFilter.Out {
1666 const n = std.posix.read(self.in_fd, &self.in_buf) catch 0;
1667 if (n == 0) return null;
1668 return self.prefix.feed(self.in_buf[0..n]);
1669 }
1670
1671 /// Everything that happens to typed bytes on their way to the session: 1566 /// Everything that happens to typed bytes on their way to the session:
1672 /// the mouse split, alternate scroll, the scrollback view, and finally 1567 /// the mouse split, alternate scroll, the scrollback view, and finally
1673 /// the prediction and the input frame. 1568 /// the prediction and the input frame.
@@ -2558,13 +2453,14 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the
2558 terminal_teardown, 2453 terminal_teardown,
2559 ); 2454 );
2560 // The pop is worthless — worse, it pops a stranger's title — without 2455 // The pop is worthless — worse, it pops a stranger's title — without
2561 // the push that pairs with it, and the two live far apart: the push is 2456 // the push that pairs with it, and the two live far apart: the push
2562 // a literal inside the frame loop's alt-screen entry. Pinned here 2457 // rides in on the wall's screen entry, one write at startup for a
2563 // together so deleting either one fails, rather than quietly leaving 2458 // teardown written on every way out. Pinned here together so deleting
2564 // the terminal one push deep forever or one pop too many. 2459 // either one fails, rather than quietly leaving the terminal one push
2460 // deep forever or one pop too many.
2565 try std.testing.expectEqualStrings( 2461 try std.testing.expectEqualStrings(
2566 "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h", 2462 "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J",
2567 terminal_setup, 2463 wall_setup,
2568 ); 2464 );
2569 // Every mode the setup turns on has an `l` for it in the teardown. The 2465 // Every mode the setup turns on has an `l` for it in the teardown. The
2570 // mouse half is the one that can drift, because the daemon can ask for 2466 // mouse half is the one that can drift, because the daemon can ask for
@@ -2578,17 +2474,16 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the
2578 } 2474 }
2579 } 2475 }
2580 2476
2581 test "interact: a borrowed terminal's claim is the client's, and every teardown undoes it" { 2477 test "interact: a borrowed terminal's claim is a session's, and every teardown undoes it" {
2582 // The claim a wall tile takes at a promote is a SUBSTRING of what a 2478 // The claim a wall tile takes at a promote is the mouse modes and
2583 // client takes at startup, and the release is the head of every 2479 // nothing else — the SCREEN was taken once, by the wall — and the
2584 // teardown there is. 2480 // release is the head of every teardown there is.
2585 // 2481 //
2586 // The pairing this pins turns over many times per run — once per zoom, 2482 // The pairing this pins turns over many times per run — once per zoom,
2587 // where a client's turns over once per process — so a half that only 2483 // where the screen's turns over once per process — so a half that only
2588 // works on the exit path is a wall that leaves a terminal reporting 2484 // works on the exit path is a wall that leaves a terminal reporting
2589 // clicks into a shell the moment somebody unzooms. 2485 // clicks into a shell the moment somebody unzooms.
2590 try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1006h", session_claim); 2486 try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1006h", session_claim);
2591 try std.testing.expect(std.mem.endsWith(u8, terminal_setup, session_claim));
2592 try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release)); 2487 try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release));
2593 try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null); 2488 try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null);
2594 // And it is exported, because the DEMOTE is written by a thread that 2489 // And it is exported, because the DEMOTE is written by a thread that
src/wallview.zig
Old New
@@ -2221,9 +2221,9 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2221 // 80x24 grid and this one has to paint the same one. 2221 // 80x24 grid and this one has to paint the same one.
2222 const size = measured orelse proto.Size{ .cols = 80, .rows = 24 }; 2222 const size = measured orelse proto.Size{ .cols = 80, .rows = 24 };
2223 // Whether there is a TERMINAL is a different question from whether it 2223 // Whether there is a TERMINAL is a different question from whether it
2224 // could be MEASURED, and `Core.init` has always answered them apart: 2224 // could be MEASURED, and the two are answered apart: `is_tty` off
2225 // `is_tty` off stdin, the size off an ioctl that gives up below two 2225 // stdin, the size off an ioctl (`interact.ttySize`) that gives up below
2226 // columns or rows. A 1x1 pty is a real terminal that reports a size 2226 // two columns or rows. A 1x1 pty is a real terminal that reports a size
2227 // nothing can paint at, and the plain client took it over and painted 2227 // nothing can paint at, and the plain client took it over and painted
2228 // 80x24 into it. Conflating the two put that client on no alternate 2228 // 80x24 into it. Conflating the two put that client on no alternate
2229 // screen at all. 2229 // screen at all.
@@ -2258,6 +2258,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2258 var raw = o; 2258 var raw = o;
2259 raw.lflag.ICANON = false; 2259 raw.lflag.ICANON = false;
2260 raw.lflag.ECHO = false; 2260 raw.lflag.ECHO = false;
2261 // ISIG and IXON off because those keys belong to the SESSION, not
2262 // to mux: Ctrl-C has to reach the remote shell as a byte, and
2263 // Ctrl-S must not freeze the local terminal out from under a
2264 // session that wanted it. It is also why `Ctrl-\` is the only way
2265 // out — nothing else here raises a signal any more.
2261 raw.lflag.ISIG = false; 2266 raw.lflag.ISIG = false;
2262 raw.iflag.IXON = false; 2267 raw.iflag.IXON = false;
2263 raw.iflag.ICRNL = false; 2268 raw.iflag.ICRNL = false;
@@ -2272,10 +2277,10 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2272 // session from the first paint, and a session's cursor is its own. 2277 // session from the first paint, and a session's cursor is its own.
2273 proto.writeAllFd(stdout_fd, interact.wall_setup) catch {}; 2278 proto.writeAllFd(stdout_fd, interact.wall_setup) catch {};
2274 if (!entry.zoom0) proto.writeAllFd(stdout_fd, "\x1b[?25l") catch {}; 2279 if (!entry.zoom0) proto.writeAllFd(stdout_fd, "\x1b[?25l") catch {};
2275 // Armed here rather than by a Core's `takeTerminal`, which no tile 2280 // Armed by the wall and not by any Core: the wall owns this
2276 // calls: the wall owns this terminal's raw mode. The tile that is 2281 // terminal's raw mode, and a tile only ever borrows a session's
2277 // ZOOMED answers the signal, because it is a full-screen client and 2282 // claim on it. The tile that is ZOOMED answers the signal, because
2278 // has to follow the tty. 2283 // it is a full-screen client and has to follow the tty.
2279 interact.watchWinch(); 2284 interact.watchWinch();
2280 } 2285 }
2281 // From here to the key loop every step can fail — a tile array, a 2286 // From here to the key loop every step can fail — a tile array, a