94f783e5
refactor: the whole-terminal path goes — every session is a tile
a73x 2026-08-20 09:50
Commit message
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 |