a73x

b2e686eb

refactor: engine, pty, prediction and wire keep the rule

a73x   2026-08-30 19:59

Commit message
refactor: engine, pty, prediction and wire keep the rule

61 essays to 23. The pty's CLOEXEC and signal-reset comments still name
the wedge they prevent; the paragraphs walking through which session
number first exposed the fd leak, and which WAN run measured the immune
sleep, are gone. protocol.zig's doubled `seq` field still warns both
readings apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

docscheck.blocks
Old New
@@ -5,7 +5,7 @@ client.zig 15
5 cmd.zig 2 5 cmd.zig 2
6 delta.zig 8 6 delta.zig 8
7 docscheck.zig 4 7 docscheck.zig 4
8 engine.zig 17 8 engine.zig 5
9 flags.zig 2 9 flags.zig 2
10 handoff.zig 6 10 handoff.zig 6
11 hosts.zig 5 11 hosts.zig 5
@@ -17,10 +17,10 @@ muxa.zig 1
17 mux_main.zig 1 17 mux_main.zig 1
18 mux.zig 2 18 mux.zig 2
19 paint.zig 11 19 paint.zig 11
20 predict.zig 15 20 predict.zig 5
21 protocol.zig 13 21 protocol.zig 9
22 proxy.zig 1 22 proxy.zig 1
23 pty.zig 16 23 pty.zig 4
24 quic_server.zig 3 24 quic_server.zig 3
25 quic.zig 6 25 quic.zig 6
26 replica.zig 3 26 replica.zig 3
src/engine/engine.zig
Old New
@@ -9,11 +9,9 @@ const vt = @import("ghostty-vt");
9 const StockHandler = vt.TerminalStream.Handler; 9 const StockHandler = vt.TerminalStream.Handler;
10 const StreamAction = vt.StreamAction; 10 const StreamAction = vt.StreamAction;
11 11
12 /// The stock ghostty-vt handler forwards OSC 133 into the terminal and 12 /// The stock ghostty-vt handler forwards OSC 133 and drops the exit code —
13 /// drops the exit code on the floor; there is no semantic-prompt callback 13 /// there is no semantic-prompt callback in its Effects. So mux intercepts the
14 /// in its Effects. So mux brings its own handler: intercept the one action 14 /// one action it wants and forwards everything, terminal state unchanged.
15 /// we care about, forward everything (including that one) to the stock
16 /// handler so terminal state stays identical.
17 pub const MuxHandler = struct { 15 pub const MuxHandler = struct {
18 inner: StockHandler, 16 inner: StockHandler,
19 17
@@ -30,12 +28,9 @@ pub const MuxHandler = struct {
30 if (comptime action == .clipboard_contents) self.onClipboard(value); 28 if (comptime action == .clipboard_contents) self.onClipboard(value);
31 if (comptime action == .bell) self.onBell(); 29 if (comptime action == .bell) self.onBell();
32 self.inner.vt(action, value); 30 self.inner.vt(action, value);
33 // After the stock handler, so the mode is already on when the first 31 // After the stock handler, so the mode is on when the first report goes
34 // report goes out. The stock handler (a bare vt, no app above it) 32 // out. A bare vt answers DECRQM 2048 as recognised but never speaks the
35 // answers DECRQM 2048 as "recognised" but never speaks the report — 33 // report, and an app that turns it on then ignores SIGWINCH and waits.
36 // in ghostty proper the app layer does. An app that turns it on
37 // (nvim 0.11+) then IGNORES SIGWINCH and waits for this; without it
38 // the session paints at the old size forever.
39 if (comptime action == .set_mode) { 34 if (comptime action == .set_mode) {
40 if (value.mode == .in_band_size_reports) self.engineOf().reportSize(); 35 if (value.mode == .in_band_size_reports) self.engineOf().reportSize();
41 } 36 }
@@ -81,11 +76,9 @@ pub const MuxHandler = struct {
81 value: StreamAction.Value(.clipboard_contents), 76 value: StreamAction.Value(.clipboard_contents),
82 ) void { 77 ) void {
83 if (std.mem.eql(u8, value.data, "?")) return; 78 if (std.mem.eql(u8, value.data, "?")) return;
84 // A zero-length payload is the OSC 52 "clear the clipboard" form, 79 // A zero-length payload is the OSC 52 "clear" form, and also what an
85 // and it also arrives by accident: a copy interrupted mid-sequence 80 // interrupted copy looks like. mux is not obliged to proxy a clear, and
86 // by another escape on the same pty ends up here too. mux is not 81 // an accident must not wipe what the human last copied.
87 // obliged to proxy the clear form, and an accident must not
88 // silently wipe whatever the human last copied.
89 if (value.data.len == 0) return; 82 if (value.data.len == 0) return;
90 const eng = self.engineOf(); 83 const eng = self.engineOf();
91 if (value.data.len > eng.clipboard_max) return; 84 if (value.data.len > eng.clipboard_max) return;
@@ -134,13 +127,10 @@ pub const Engine = struct {
134 pub const MarkEvent = struct { 127 pub const MarkEvent = struct {
135 pub const Kind = enum(u8) { prompt_start, command_start, command_end }; 128 pub const Kind = enum(u8) { prompt_start, command_start, command_end };
136 kind: Kind, 129 kind: Kind,
137 /// Absolute screen-space row (historyRows + cursor.y) at mark time. 130 /// Absolute screen-space row at mark time. A best-effort locator, not a
138 /// Best-effort locator, not a durable anchor: pruning past 131 /// durable anchor: pruning shifts the origin, resize reflow renumbers
139 /// max_scrollback shifts the origin (a command longer than the 132 /// history, and alt-screen marks live in another coordinate space. Point
140 /// scrollback can even leave end_row < start_row), resize reflow 133 /// a human at output with it, never key durable state.
141 /// renumbers history, and alt-screen marks live in a different
142 /// coordinate space entirely (historyRows() is 0 there). Use it to
143 /// point a human at output, never to key durable state.
144 row: u32, 134 row: u32,
145 /// Only ever set on command_end, and only when the mark carried one. 135 /// Only ever set on command_end, and only when the mark carried one.
146 exit_code: ?u8, 136 exit_code: ?u8,
@@ -152,13 +142,10 @@ pub const Engine = struct {
152 pub const SideEvent = struct { 142 pub const SideEvent = struct {
153 pub const Kind = enum(u8) { clipboard, bell }; 143 pub const Kind = enum(u8) { clipboard, bell };
154 kind: Kind, 144 kind: Kind,
155 /// The OSC 52 target byte ('c', 'p', ...). Meaningless for bell. 145 /// The OSC 52 target byte ('c', 'p', ...), meaningless for bell.
156 /// Attacker-chosen and unvalidated here — it is whatever byte sat 146 /// Attacker-chosen and unvalidated: whatever byte sat between the two
157 /// between the two semicolons of the pty's `ESC]52;X;...BEL`, and 147 /// semicolons, passed through unchanged. The base64 half is re-validated
158 /// this queue passes it through unchanged. The base64 half gets 148 /// on the wire and this byte is not, so the CLIENT must whitelist it.
159 /// re-validated on the wire (`protocol.zig`); this byte does not,
160 /// so the client must whitelist it before replaying the escape to
161 /// the host terminal.
162 target: u8 = 0, 149 target: u8 = 0,
163 /// Base64 as it arrived, undecoded. Empty for bell. 150 /// Base64 as it arrived, undecoded. Empty for bell.
164 payload: []const u8 = &.{}, 151 payload: []const u8 = &.{},
@@ -168,21 +155,14 @@ pub const Engine = struct {
168 cols: u16, 155 cols: u16,
169 rows: u16, 156 rows: u16,
170 max_scrollback: usize = 10_000, 157 max_scrollback: usize = 10_000,
171 /// The largest OSC 52 payload to queue and forward, in base64 158 /// The largest OSC 52 payload to queue and forward, in base64 bytes.
172 /// bytes. Supplied by the caller rather than defined here: 159 /// Supplied by the caller because `protocol.zig` owns the wire's shape
173 /// `protocol.zig` owns the wire's shape, and `engine` deliberately 160 /// and `engine` cannot import it; must equal `clipboard_base64_max`,
174 /// does not import it — both are layer 0 and both build for 161 /// pinned by a test in `server.zig`.
175 /// wasm32. Must equal `protocol.clipboard_base64_max`; pinned equal
176 /// by a test in `server.zig`.
177 /// 162 ///
178 /// This bounds what `side_events` retains and what mux forwards — 163 /// It bounds what mux retains and forwards, NOT ghostty's own parse
179 /// it does NOT bound ghostty's own parse buffer. The 164 /// buffer: the callback fires only on the terminator, so an unterminated
180 /// `clipboard_contents` callback only fires once the terminator 165 /// OSC 52 grows that buffer regardless of this cap.
181 /// (ST/BEL) arrives, so an unterminated OSC 52 grows ghostty's
182 /// transient buffer regardless of this cap; that exposure is
183 /// pre-existing in `.initAlloc` and is tracked separately, not
184 /// fixed here. A clipboard is a clipboard, not a file transfer —
185 /// the reason to cap the half we control at all.
186 clipboard_max: usize = 64 * 1024, 166 clipboard_max: usize = 64 * 1024,
187 }; 167 };
188 168
@@ -192,11 +172,10 @@ pub const Engine = struct {
192 pub const Status = enum { ok, invalid, too_large }; 172 pub const Status = enum { ok, invalid, too_large };
193 173
194 status: Status, 174 status: Status,
195 /// `historyRows()` of the screen this extraction resolved its pins 175 /// `historyRows()` of the screen this extraction resolved against,
196 /// against, sampled here rather than by the caller so it cannot name 176 /// sampled here so it cannot name a different moment than the text does.
197 /// a different moment than the text does. Absolute screen rows are 177 /// A page eviction renames every absolute row; this is how a requester
198 /// counted from the oldest RETAINED row, so a page eviction renames 178 /// notices.
199 /// every one of them; this is what lets a requester notice.
200 history_rows: u32 = 0, 179 history_rows: u32 = 0,
201 text: ?[]u8 = null, 180 text: ?[]u8 = null,
202 181
@@ -228,13 +207,10 @@ pub const Engine = struct {
228 207
229 self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } }); 208 self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } });
230 self.stream.handler.inner.effects.write_pty = &onWritePty; 209 self.stream.handler.inner.effects.write_pty = &onWritePty;
231 // Wired because silence here is a TIMEOUT, not a degradation: DA1 210 // Silence here is a TIMEOUT, not a degradation: DA1 is the barrier TUIs
232 // is the barrier TUIs (nvim among them) send after their capability 211 // send after their capability probes and block on. The color and termcap
233 // probes and block on — unanswered, every nvim start and quit eats 212 // queries stay silent on purpose — a headless engine inventing a
234 // a full 1s. The color/termcap queries stay silent on purpose: with 213 // background colour would lie to theme detection.
235 // DA1 answered they resolve instantly as "unsupported", and a
236 // headless engine inventing a background color would lie to theme
237 // detection on whatever terminal is really attached.
238 self.stream.handler.inner.effects.device_attributes = &onDeviceAttributes; 214 self.stream.handler.inner.effects.device_attributes = &onDeviceAttributes;
239 return self; 215 return self;
240 } 216 }
@@ -301,12 +277,9 @@ pub const Engine = struct {
301 return self.viewportRows(0, @intCast(self.term.rows - 1)); 277 return self.viewportRows(0, @intCast(self.term.rows - 1));
302 } 278 }
303 279
304 /// A selection spanning columns [x0, x1] of ONE viewport row. 280 /// A selection spanning columns [x0, x1] of ONE viewport row. One row at a
305 /// 281 /// time on purpose: a selection is a text RANGE, which is what a copy wants
306 /// One row at a time on purpose: a selection is a text RANGE, so a 282 /// and the opposite of what a per-row highlight does.
307 /// multi-row one runs from the anchor to the end of its line and from
308 /// the start of the last line to the active — which is what a copy
309 /// wants and the opposite of what a per-row highlight does.
310 fn viewportSpan(self: *Engine, y: u16, x0: u16, x1: u16) ?vt.Selection { 283 fn viewportSpan(self: *Engine, y: u16, x0: u16, x1: u16) ?vt.Selection {
311 const screen = self.term.screens.active; 284 const screen = self.term.screens.active;
312 const tl = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return null; 285 const tl = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return null;
@@ -359,19 +332,14 @@ pub const Engine = struct {
359 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y)); 332 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y));
360 } 333 }
361 334
362 /// Where a row is painted. `col_off` is the screen column its grid 335 /// Where a row is painted: `col_off` is the screen column its grid column
363 /// column zero lands on; `cols` is how many columns of it the pane 336 /// zero lands on, `cols` how many the pane shows. Neither is optional — the
364 /// shows. Neither is optional: the dumps below address columns with 337 /// dumps address columns with CHA, which is screen-absolute.
365 /// CHA, which is screen-absolute, and under latest-wins the grid can
366 /// be wider than the pane painting it.
367 pub const RowView = struct { col_off: u16, cols: u16 }; 338 pub const RowView = struct { col_off: u16, cols: u16 };
368 339
369 /// The last grid column of row `y` that fits in `view`, or null when 340 /// The last grid column of row `y` that fits in `view`, or null when not one
370 /// not one character does. 341 /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and
371 /// 342 /// half a wide glyph past it is a column stolen from the neighbour.
372 /// Inwards, unlike `snapWide`: a highlight widens to cover the glyph
373 /// under the hand, but a pane edge is a wall — half a wide glyph past
374 /// it is a column stolen from the rail and the neighbour.
375 fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 { 343 fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 {
376 if (view.cols == 0 or self.term.cols == 0) return null; 344 if (view.cols == 0 or self.term.cols == 0) return null;
377 var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1))); 345 var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1)));
@@ -392,16 +360,11 @@ pub const Engine = struct {
392 return self.formatSelection(alloc, "\x1b[0m", self.viewportSpan(y, 0, hi)); 360 return self.formatSelection(alloc, "\x1b[0m", self.viewportSpan(y, 0, hi));
393 } 361 }
394 362
395 /// Widen a column span to whole characters. 363 /// Widen a column span to whole characters. A wide cell is two columns and a
396 /// 364 /// drag stops where the hand stopped, so a span may cut one in half —
397 /// A wide cell is two columns and a drag stops where the hand stopped, 365 /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three
398 /// so a span may cut one in half. ghostty resolves the partial cell by 366 /// pieces emits a straddling one TWICE. Outwards: half a character under the
399 /// emitting the WHOLE character — wrong for `dumpVtRowSpan`, whose three 367 /// pointer means the character is under the pointer.
400 /// pieces each resolve their own edge, so a straddling character is
401 /// emitted TWICE and the row lands a column wider than the grid.
402 ///
403 /// Outwards, not inwards: half a character under the pointer means the
404 /// character is under the pointer.
405 fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { 368 fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } {
406 const screen = self.term.screens.active; 369 const screen = self.term.screens.active;
407 const last: u16 = @intCast(self.term.cols - 1); 370 const last: u16 = @intCast(self.term.cols - 1);
@@ -420,27 +383,16 @@ pub const Engine = struct {
420 return .{ .from = lo, .to = hi }; 383 return .{ .from = lo, .to = hi };
421 } 384 }
422 385
423 /// `dumpVtRow`, with grid columns [from, to] painted inverted. 386 /// `dumpVtRow`, with grid columns [from, to] painted inverted. No VT
424 /// 387 /// sequence inverts part of a row already on screen, so a highlighted row is
425 /// No VT sequence inverts part of a row already on screen: the painters 388 /// re-emitted rather than decorated.
426 /// emit whole rows of raw SGR bytes, so a highlighted row is re-emitted
427 /// rather than decorated. Same contract as `dumpVtRow` otherwise.
428 ///
429 /// Three pieces joined by CHA (`CSI n G`) rather than by counting
430 /// characters: the formatter trims trailing whitespace, so the head's
431 /// byte length says nothing about where it left the cursor. CHA is
432 /// SCREEN-absolute, hence `view.col_off`: a pane that addressed its own
433 /// grid columns would paint the row into its left neighbour. The span is
434 /// emitted PLAIN, which neutralizes the row's own SGR inside it — a cell
435 /// that kept its colour reads as a hole in the highlight, and one already
436 /// reverse-video would vanish into it.
437 ///
438 /// The inversion closes with a full reset rather than `\x1b[27m`: the
439 /// tail's formatter assumes it starts from default, and a reset is what
440 /// makes that true rather than what happens to be true.
441 /// 389 ///
442 /// Wide cells, graphemes and styling are ghostty's `Selection` and 390 /// Three pieces joined by CHA rather than by counting characters: the
443 /// formatter doing the work; nothing here walks a cell. 391 /// formatter trims trailing whitespace, so the head's byte length says
392 /// nothing about where it left the cursor. CHA is SCREEN-absolute, hence
393 /// `view.col_off`. The span is emitted PLAIN, or a cell that kept its colour
394 /// reads as a hole; the inversion closes with a full reset, which is what
395 /// makes the tail's from-default assumption true.
444 pub fn dumpVtRowSpan(self: *Engine, alloc: std.mem.Allocator, y: u16, from: u16, to: u16, view: RowView) ![]u8 { 396 pub fn dumpVtRowSpan(self: *Engine, alloc: std.mem.Allocator, y: u16, from: u16, to: u16, view: RowView) ![]u8 {
445 std.debug.assert(y < self.term.rows); 397 std.debug.assert(y < self.term.rows);
446 std.debug.assert(from <= to); 398 std.debug.assert(from <= to);
@@ -474,17 +426,15 @@ pub const Engine = struct {
474 return try aw.toOwnedSlice(); 426 return try aw.toOwnedSlice();
475 } 427 }
476 428
477 /// Full terminal state as a canonical VT byte sequence. Feeding this 429 /// Full terminal state as a canonical VT byte sequence — the Snapshot
478 /// into a fresh engine of the same size reconstructs the state: this 430 /// payload body. Feeding it into a fresh engine of the same size
479 /// is the Snapshot payload body. 431 /// reconstructs the state.
480 /// 432 ///
481 /// When the alt screen is active, the primary screen's visible content 433 /// On the alt screen the primary's visible content is emitted FIRST, since
482 /// is emitted first (the replica starts on the primary screen after 434 /// the replica starts on the primary after its reset; the mode section then
483 /// its reset), then the full-state dump — whose mode section switches 435 /// switches before the alt content lands, so leaving the alt screen reveals
484 /// to the alt screen before the alt content lands. Leaving the alt 436 /// real primary content. The primary's saved-cursor lands at the end of that
485 /// screen on the replica then reveals real primary content. The 437 /// content rather than the pre-TUI spot.
486 /// primary's saved-cursor ends up at the end of its content rather
487 /// than the exact pre-TUI spot; acceptable for the prototype.
488 pub fn dumpState(self: *Engine, alloc: std.mem.Allocator) ![]u8 { 438 pub fn dumpState(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
489 var aw: std.Io.Writer.Allocating = .init(alloc); 439 var aw: std.Io.Writer.Allocating = .init(alloc);
490 defer aw.deinit(); 440 defer aw.deinit();
@@ -628,15 +578,11 @@ pub const Engine = struct {
628 // centralized in ghostty-vt instead of reimplementing them here. 578 // centralized in ghostty-vt instead of reimplementing them here.
629 const selection = vt.Selection.init(anchor, active, false); 579 const selection = vt.Selection.init(anchor, active, false);
630 580
631 // Sized from the selection's own extent, not from the cap: a 581 // Sized from the selection's own extent, not from the cap: a three-row
632 // three-row copy has no business allocating a megabyte on the 582 // copy has no business allocating a megabyte on the pty pump. Four bytes
633 // single-threaded pty pump. Four bytes per cell plus one line 583 // per cell covers every single-codepoint cell but NOT a grapheme
634 // terminator covers every single-codepoint cell — but NOT a cell 584 // cluster, so this is a first ATTEMPT — exhausting it retries at the real
635 // holding a grapheme cluster, whose byte length has no bound this 585 // cap, and only that second writer may say `.too_large`.
636 // can compute. So the estimate is a first ATTEMPT and never a
637 // verdict: exhausting it retries at the real cap, and only that
638 // second writer is allowed to say `.too_large`. Status is therefore
639 // exactly what it was when every extraction allocated the cap.
640 const span: u64 = @as(u64, @max(anchor_row, active_row) - 586 const span: u64 = @as(u64, @max(anchor_row, active_row) -
641 @min(anchor_row, active_row)) + 1; 587 @min(anchor_row, active_row)) + 1;
642 const estimate: usize = @intCast(@min( 588 const estimate: usize = @intCast(@min(
@@ -1615,11 +1561,9 @@ test "engine: a span boundary inside a wide cell does not shift the row" {
1615 1561
1616 try expectSpansAgree(alloc, a, 20); 1562 try expectSpansAgree(alloc, a, 20);
1617 1563
1618 // The third `Wide` variant: a wide character that does not fit before 1564 // The third `Wide` variant: a wide character that does not fit leaves a
1619 // the right edge leaves a SPACER HEAD in the last column and moves 1565 // SPACER HEAD in the last column and moves to the next row. The formatter
1620 // itself to the next row. The formatter skips a row whose span starts 1566 // skips a row whose span starts on one, so a boundary there blanks it.
1621 // on one, so a boundary there is the case that blanks a row rather
1622 // than widening it.
1623 var c = try Engine.init(alloc, .{ .cols = 20, .rows = 3 }); 1567 var c = try Engine.init(alloc, .{ .cols = 20, .rows = 3 });
1624 defer c.deinit(); 1568 defer c.deinit();
1625 c.feed("aaaaaaaaaaaaaaaaaaa\u{6f22}"); 1569 c.feed("aaaaaaaaaaaaaaaaaaa\u{6f22}");
@@ -1627,10 +1571,8 @@ test "engine: a span boundary inside a wide cell does not shift the row" {
1627 } 1571 }
1628 1572
1629 /// Every span of row 0 must repaint that row exactly as it stands: a span 1573 /// Every span of row 0 must repaint that row exactly as it stands: a span
1630 /// changes which columns are INVERTED and nothing else, so the row's plain 1574 /// changes which columns are INVERTED and nothing else, so the row's plain text
1631 /// text through a fresh engine has to come back identical whatever the 1575 /// through a fresh engine comes back identical whatever the boundaries were.
1632 /// boundaries were. Row 0 only — a wide character that wrapped to row 1 is
1633 /// not what this paints.
1634 fn expectSpansAgree(alloc: std.mem.Allocator, eng: *Engine, cols: u16) !void { 1576 fn expectSpansAgree(alloc: std.mem.Allocator, eng: *Engine, cols: u16) !void {
1635 const full = try eng.dumpPlain(alloc); 1577 const full = try eng.dumpPlain(alloc);
1636 defer alloc.free(full); 1578 defer alloc.free(full);
src/engine/protocol.zig
Old New
@@ -1,12 +1,7 @@
1 //! Wire protocol: length-prefixed frames over a byte stream (unix socket, 1 //! Wire protocol: length-prefixed frames over a byte stream, plus the apply
2 //! QUIC stream, WebSocket message), plus the 2 //! semantics for deltas. Frame = 1 byte MsgType, u32 LE payload length, payload.
3 //! apply semantics for deltas (see composeDelta). 3 //! Hand-rolled deliberately — payloads are row-keyed blobs and fixed-width
4 //! Frame = 1 byte MsgType, u32 LE payload length, payload bytes. 4 //! little-endian integers, which readInt/writeInt cover without a dependency.
5 //! The wire format is deliberately hand-rolled: payloads are row-keyed
6 //! byte blobs and fixed-width little-endian integers, which a few lines of
7 //! readInt/writeInt cover without a dependency. A serialization library
8 //! (msgpack) becomes due when payloads turn truly structured — cell runs,
9 //! multi-rect damage, capability negotiation.
10 // folder rule 4 exemption: a delta row IS painted bytes on the wire — composeDelta stamps CUP and EL around a row the far side replays. 5 // folder rule 4 exemption: a delta row IS painted bytes on the wire — composeDelta stamps CUP and EL around a row the far side replays.
11 const std = @import("std"); 6 const std = @import("std");
12 7
@@ -85,12 +80,10 @@ pub const Delimited = struct {
85 consumed: usize, 80 consumed: usize,
86 }; 81 };
87 82
88 /// Delimit the frame at the front of `buf`, without copying. 83 /// Delimit the frame at the front of `buf`, without copying. Null is a partial
89 /// 84 /// tail — the ordinary state of a byte stream, never an error.
90 /// Null means the tail is still partial, which is the ordinary state of a 85 /// `error.FrameTooLarge` means reading on would size an allocation from a
91 /// byte stream and never an error. `error.FrameTooLarge` means a length no 86 /// number the peer chose.
92 /// frame can legitimately carry: the stream is not what we think it is, and
93 /// reading on would size an allocation from a number the peer chose.
94 pub fn delimitFrame(buf: []const u8) !?Delimited { 87 pub fn delimitFrame(buf: []const u8) !?Delimited {
95 if (buf.len < frame_header_len) return null; 88 if (buf.len < frame_header_len) return null;
96 const len = std.mem.readInt(u32, buf[1..5], .little); 89 const len = std.mem.readInt(u32, buf[1..5], .little);
@@ -225,11 +218,9 @@ pub fn decodeSize(payload: []const u8) !Size {
225 }; 218 };
226 } 219 }
227 220
228 /// The smallest grid a session may live at, as a wire contract rather 221 /// The smallest grid a session may live at, as a WIRE contract: the daemon
229 /// than a server detail: `attach` and `resize` carry cols/rows, and the 222 /// refuses an attach or resize below it, and the client's stripe cut reads the
230 /// daemon refuses both below this floor. The client's stripe cut reads 223 /// same constant so a too-thin stripe is refused at the wall rather than frozen.
231 /// the same constant so a stripe too thin to hold these rows is refused
232 /// at the wall rather than frozen on a grid the daemon would not resize.
233 pub const min_session_cols = 2; 224 pub const min_session_cols = 2;
234 pub const min_session_rows = 2; 225 pub const min_session_rows = 2;
235 226
@@ -269,17 +260,11 @@ pub fn decodeEndpointReply(payload: []const u8) !u16 {
269 /// head-of-line-block a delta. 260 /// head-of-line-block a delta.
270 pub const agent_id_len = 4; 261 pub const agent_id_len = 4;
271 pub const agent_data_max = 4096; 262 pub const agent_data_max = 4096;
272 /// How many agent connections one daemon carries at once. Daemon-wide 263 /// How many agent connections one daemon carries at once. Daemon-wide, and a
273 /// rather than per-session because the cost is one descriptor and one poll 264 /// burst allowance rather than a population: ssh opens a channel per
274 /// slot each, and because ssh opens a channel per authentication attempt 265 /// authentication attempt and closes it moments later. Spelled here because
275 /// and closes it moments later — the table is a burst allowance, not a 266 /// BOTH ends size a table from it — bigger has slots nothing can fill, smaller
276 /// population. Running out refuses the newest dial, which is the same 267 /// refuses channels the daemon believes it opened.
277 /// answer "nobody is offering" already gives and which ssh already handles.
278 ///
279 /// Spelled here, with the frames, because BOTH ends size a table from it:
280 /// the daemon cannot hold more channels than this, so a client table any
281 /// bigger has slots nothing can ever fill and any smaller refuses channels
282 /// the daemon believes it opened.
283 pub const agent_chans_max = 8; 268 pub const agent_chans_max = 8;
284 /// The one env name both ends agree on, named here for the same reason as 269 /// The one env name both ends agree on, named here for the same reason as
285 /// sock_env and session_env: three spellings is three chances to rename one 270 /// sock_env and session_env: three spellings is three chances to rename one
@@ -394,20 +379,14 @@ pub const SelectionStatus = enum(u8) {
394 pub const SelectionReply = struct { 379 pub const SelectionReply = struct {
395 id: u32, 380 id: u32,
396 status: SelectionStatus, 381 status: SelectionStatus,
397 /// Retained history rows on the screen the text was extracted from, 382 /// Retained history rows on the screen the text was extracted from. Absolute
398 /// sampled at extraction time. Absolute screen rows are counted from 383 /// rows count from the OLDEST RETAINED row, so an eviction shifts every one
399 /// the OLDEST RETAINED row, so they are not a stable name for a line: 384 /// of them under a request in flight and the reply is `.ok`, valid UTF-8,
400 /// when the page list evicts a page, every absolute row shifts under a 385 /// and the wrong text.
401 /// request already in flight, and a reply pinned to the old numbering
402 /// is `.ok`, valid UTF-8, and the wrong text.
403 /// 386 ///
404 /// This is the watermark that makes that visible, and it is a watermark 387 /// A watermark rather than a lease on purpose: the daemon holds no
405 /// rather than a lease on purpose — the daemon holds no per-client 388 /// per-client selection state, so the requester compares and decides.
406 /// selection state, so the requester compares and decides. Ordinary 389 /// Ordinary output RAISES it without moving row zero; only eviction lowers it.
407 /// output RAISES this without moving row zero (new rows are appended
408 /// below); only eviction lowers it. Measured on a 5×3 grid: history
409 /// climbed to 10000 and then dropped to 5214 in one step, with absolute
410 /// row zero changing identity at exactly that drop.
411 history_rows: u32, 390 history_rows: u32,
412 /// Borrowed from the frame payload and valid only as long as that 391 /// Borrowed from the frame payload and valid only as long as that
413 /// payload remains alive and unchanged. 392 /// payload remains alive and unchanged.
@@ -479,40 +458,23 @@ pub const CmdState = struct {
479 exit_code: ?u8, 458 exit_code: ?u8,
480 start_row: u32, 459 start_row: u32,
481 end_row: u32, 460 end_row: u32,
482 /// Two different numbers travel in this field, and which one it is 461 /// TWO different numbers travel in this field, depending on the frame:
483 /// depends on the frame carrying it:
484 ///
485 /// * In `status_reply` and in the `cmd_state` pushes the marks stream
486 /// produces, it is the RETURN WATERMARK — the seq stamped when a
487 /// command last returned, 0 if none has this session. This is the
488 /// series `AwaitReq.since_seq` is compared against.
489 /// * In an `await_reply` resolved by anything other than marks (the
490 /// pgid, settle and timeout fallbacks), it is instead the delta
491 /// tracker's CURRENT seq — a grid-content ordering, so the answer
492 /// can be placed against the deltas the client holds.
493 /// 462 ///
494 /// So: an agent must take its next `since_seq` from a status or marks 463 /// * In `status_reply` and the marks stream's `cmd_state` pushes, the
495 /// reply, never from a fallback one. Feeding a tracker seq back as a 464 /// RETURN WATERMARK — the seq stamped when a command last returned.
496 /// watermark compares across two series, and a client that did it would 465 /// This is the series `AwaitReq.since_seq` is compared against.
497 /// wait out its next await for a return that had already happened. 466 /// * In an `await_reply` resolved by pgid, settle or timeout, the delta
467 /// tracker's CURRENT seq — a grid-content ordering.
498 /// 468 ///
499 /// OPEN DESIGN NOTE, recorded rather than acted on: it is not settled 469 /// So an agent must take its next `since_seq` from a status or marks reply,
500 /// that the fallback arms should stamp anything into this field, since 470 /// never a fallback one: feeding a tracker seq back compares two series.
501 /// they have no return to watermark. Making them send the watermark
502 /// instead would collapse the two meanings into one — and would change
503 /// the bytes on the wire, so it is a protocol decision, not a cleanup.
504 /// 471 ///
505 /// The same doubling has a third consequence, on the watermark side this 472 /// The doubling has one more consequence. The watermark IS a tracker seq,
506 /// time: the watermark IS a tracker seq, stamped at the moment of return, 473 /// and the tracker only advances when the grid changes — so a command whose
507 /// and the tracker only advances when it sees the grid change. A command 474 /// lifecycle leaves the grid byte-identical returns at the seq the previous
508 /// whose whole lifecycle leaves the grid byte-identical therefore returns 475 /// return claimed, and the strictly-greater test falls through to settle.
509 /// at the seq the previous return already claimed, so the strictly-greater 476 /// A command-boundary ordering riding a grid-content counter; only a seq of
510 /// test an await makes against `since_seq` is false and the wait falls 477 /// its own would separate them. Changing it changes the wire.
511 /// through to settle or timeout instead of answering `returned`. It is an
512 /// edge and not a field bug — the echoed command line moves the grid on
513 /// any real interactive session — but it is the same root cause: a
514 /// command-boundary ordering is riding a grid-content counter, and only a
515 /// seq of its own would separate them.
516 seq: u64, 478 seq: u64,
517 }; 479 };
518 480
@@ -549,28 +511,19 @@ pub fn decodeCmdState(payload: []const u8) !CmdState {
549 }; 511 };
550 } 512 }
551 513
552 /// A request to be told when the session next returns to rest. 514 /// A request to be told when the session next returns to rest. `since_seq` is
553 /// 515 /// what the caller already knows: only a strictly newer return may answer,
554 /// `since_seq` is what the caller already knows about: only a return strictly 516 /// which makes re-issuing after a dropped connection safe. Both durations treat
555 /// newer than it may answer, which is what makes re-issuing after a dropped 517 /// 0 as OFF and never as "immediately" — a caller that wants to poll asks for a
556 /// connection safe rather than a second wait. 518 /// small timeout, never a zero one.
557 ///
558 /// Both durations treat 0 as "off", not as "immediately": `settle_ms = 0`
559 /// declines the output-silence mechanism altogether, and `timeout_ms = 0` is
560 /// an await with no deadline, ending only when marks, the pgid edge or settle
561 /// end it. A caller that wants to poll rather than wait asks for a small
562 /// timeout, never a zero one.
563 pub const AwaitReq = struct { 519 pub const AwaitReq = struct {
564 since_seq: u64, 520 since_seq: u64,
565 settle_ms: u32, 521 settle_ms: u32,
566 timeout_ms: u32, 522 timeout_ms: u32,
567 /// Borrowed from the payload; valid only while the frame lives. Empty 523 /// Borrowed from the payload; valid only while the frame lives. Empty means
568 /// means the default session — see the note beside `session_name_max`. 524 /// the default session. Defaulted, unlike `AttachReq.name`, so the fixed-part
569 /// Defaulted (unlike `AttachReq.name`) so the fixed-part struct 525 /// literals already in the tree keep building — `decodeAttach` is the only
570 /// literals already in the tree (`mux a`) keep building without every 526 /// place that constructs an `AttachReq`, so there is no literal there.
571 /// call site learning about names; `AttachReq.name` has no such
572 /// default because the only place that constructs an `AttachReq` is
573 /// `decodeAttach` itself, so there is no literal to keep compiling.
574 name: []const u8 = "", 527 name: []const u8 = "",
575 }; 528 };
576 529
@@ -692,29 +645,19 @@ pub fn decodePtyMode(payload: []const u8) !PtyModeFlags {
692 645
693 /// Terminal modes the SESSION has set that the host terminal must be told 646 /// Terminal modes the SESSION has set that the host terminal must be told
694 /// about, because the client paints a grid and no mode survives a repaint. 647 /// about, because the client paints a grid and no mode survives a repaint.
695 /// Sampled state, not events: read off the engine, sent when changed and 648 /// Sampled state, not events.
696 /// unconditionally on attach.
697 ///
698 /// The reserved bits are for focus reporting and cursor shape, so that
699 /// adding them later needs no new frame type and no version check. They go
700 /// out zero and are ignored on receipt: decode does not mask them away, so
701 /// a client that echoes modes back cannot silently downgrade bits set by a
702 /// newer daemon.
703 ///
704 /// A daemon predating the mouse bits sends them zero, which a new client
705 /// reads as "no application wants the mouse" — it keeps the wheel for
706 /// scrollback, as every client did before.
707 /// 649 ///
708 /// u32, not the dozen-ish bits the modes need: this frame is rare enough 650 /// The reserved bits are for focus reporting and cursor shape, so adding them
709 /// that four bytes is free, and a wire field cannot be narrowed later. 651 /// needs no new frame type and no version check. They go out zero and decode
652 /// does not mask them, so a client echoing modes back cannot downgrade a newer
653 /// daemon's bits. u32 rather than the dozen needed: a wire field cannot be
654 /// narrowed later, and this frame is rare enough that four bytes is free.
710 pub const TermModes = packed struct(u32) { 655 pub const TermModes = packed struct(u32) {
711 bracketed_paste: bool, 656 bracketed_paste: bool,
712 // One bit per mouse DEC mode the session set, in `mouse_modes` order — 657 // One bit per mouse DEC mode the session set, in `mouse_modes` order — that
713 // that table is what turns them back into DECSET numbers on the 658 // table turns them back into DECSET numbers. Carried individually rather
714 // client's own tty. Carried individually rather than collapsed into 659 // than collapsed, because the client must ask its terminal for the SAME
715 // "the app wants the mouse" because the client has to ask its terminal 660 // modes: a report in a format the application did not ask for is garbage.
716 // for the SAME modes: a report in a format the application did not ask
717 // for is garbage typed into it.
718 mouse_x10: bool = false, 661 mouse_x10: bool = false,
719 mouse_normal: bool = false, 662 mouse_normal: bool = false,
720 mouse_button: bool = false, 663 mouse_button: bool = false,
@@ -775,29 +718,19 @@ pub const TermEvent = union(Kind) {
775 }; 718 };
776 }; 719 };
777 720
778 /// The largest OSC 52 payload the wire will carry, in base64 bytes 721 /// The largest OSC 52 payload the wire will carry, in base64 bytes (~48 KiB of
779 /// (~48 KiB of text). Lives here because the wire module owns the shape: 722 /// text). Here because the wire module owns the shape: the daemon caps on the
780 /// the daemon caps on the way in and the client re-validates on the way 723 /// way in and the client re-validates on the way out.
781 /// out, and two binaries knowing this number separately are two binaries 724 /// `Engine.Options.clipboard_max` spells it a second time and must equal it —
782 /// that can disagree about it. 725 /// `engine` cannot import this module, so a test in `server.zig` holds them
783 /// 726 /// together. Change this number and change that default with it.
784 /// `Engine.Options.clipboard_max` spells this number a second time and must
785 /// equal it — `engine` cannot import this module (both are layer 0 and both
786 /// build for wasm32), so nothing but a test holds them together. That test
787 /// is in `server.zig`, the module that imports both. Change this number and
788 /// change that default with it.
789 pub const clipboard_base64_max: usize = 64 * 1024; 727 pub const clipboard_base64_max: usize = 64 * 1024;
790 728
791 /// The longest `term_title` payload either end will send or act on. A title 729 /// The longest `term_title` payload either end will send or act on. A title is
792 /// is a window decoration: anything longer is a bug, or an attempt to push 730 /// a window decoration: anything longer is a bug, or an attempt to push bytes
793 /// bytes down a channel nobody inspects on the way past. 731 /// down a channel nobody inspects. 1024 is ghostty's own `max_title_len`, so a
794 /// 732 /// title read off the engine cannot exceed it — but both ends check anyway,
795 /// 1024 is not an independent choice — it is the length ghostty's own 733 /// since the peer need not be this version and that truncation is not a contract.
796 /// handler already truncates a title to before it reaches `Terminal.title`
797 /// (terminal/stream_terminal.zig, `max_title_len`), so a title read off
798 /// the engine can never exceed it today. Both ends check it anyway: the peer is not
799 /// necessarily this version of the daemon, and the engine's truncation is not
800 /// part of any contract mux is entitled to lean on.
801 pub const term_title_max: usize = 1024; 734 pub const term_title_max: usize = 1024;
802 735
803 /// Appends rather than returning a fixed buffer: `clipboard_base64_max` is 736 /// Appends rather than returning a fixed buffer: `clipboard_base64_max` is
@@ -884,15 +817,11 @@ pub fn wireName(name: []const u8) []const u8 {
884 return if (std.mem.eql(u8, name, default_session)) "" else name; 817 return if (std.mem.eql(u8, name, default_session)) "" else name;
885 } 818 }
886 819
887 /// What the daemon plants in every session shell it spawns: the socket path 820 /// What the daemon plants in every session shell it spawns: the socket path it
888 /// it bound, and the session's RESOLVED name. Not wire bytes — but they are 821 /// bound and the session's RESOLVED name. Not wire bytes, but a contract between
889 /// a contract between three modules that cannot import one another (the 822 /// three modules that cannot import one another, and this is the lowest module
890 /// daemon plants them, `mux_main` refuses a self-attach on them, `wallview` 823 /// all three already import. One spelling, because nothing would catch a rename
891 /// drops a self-tile from an auto-built wall on them), and this is the 824 /// on one side — the shell only ever sees what the planter wrote.
892 /// lowest module all three already import. Named in one place because three
893 /// spellings of the same variable is three chances for one to be renamed
894 /// alone, which nothing would catch: the shell only ever sees whatever the
895 /// planter wrote.
896 pub const sock_env = "MUX_SOCK"; 825 pub const sock_env = "MUX_SOCK";
897 pub const session_env = "MUX_SESSION"; 826 pub const session_env = "MUX_SESSION";
898 827
@@ -1129,15 +1058,11 @@ pub fn deltaRowIterator(payload: []const u8) DeltaRowIterator {
1129 1058
1130 pub const ComposedDelta = struct { header: DeltaHeader, bytes: []u8 }; 1059 pub const ComposedDelta = struct { header: DeltaHeader, bytes: []u8 };
1131 1060
1132 /// Turn a delta payload into the VT byte string that applies it: for each 1061 /// Turn a delta payload into the VT byte string that applies it: per row, CUP
1133 /// row, CUP to the row start + EL(2) + the row's styled content; finally a 1062 /// to the row start, EL(2), the row's styled content, then a CUP to the delta's
1134 /// CUP to the delta's cursor. Feed the result to a replica engine and/or 1063 /// cursor. The composed bytes assume the receiver has no scroll region or origin
1135 /// paint it (inside sync-output brackets) to a terminal. 1064 /// mode — true of a freshly reset replica and of the client's full-screen paint.
1136 /// The composed bytes assume the receiver has no scroll region or origin 1065 /// The header's `row_count` is authoritative.
1137 /// mode active — true of a freshly reset replica and of the client's
1138 /// full-screen paint.
1139 /// The header's row_count is authoritative: a payload carrying a different
1140 /// number of rows is rejected as error.BadPayload.
1141 pub fn composeDelta(alloc: std.mem.Allocator, payload: []const u8) !ComposedDelta { 1066 pub fn composeDelta(alloc: std.mem.Allocator, payload: []const u8) !ComposedDelta {
1142 const hdr = try readDeltaHeader(payload); 1067 const hdr = try readDeltaHeader(payload);
1143 var out: std.ArrayList(u8) = .empty; 1068 var out: std.ArrayList(u8) = .empty;
src/server/pty.zig
Old New
@@ -13,15 +13,10 @@ pub const Pty = struct {
13 child: std.posix.pid_t, 13 child: std.posix.pid_t,
14 exit_status: ?u32 = null, 14 exit_status: ?u32 = null,
15 15
16 /// One variable to set in the child. Spelled here rather than imported 16 /// One variable to set in the child. Spelled here rather than imported so
17 /// so this module stays a leaf: a pty knows how to hand a child an 17 /// this module stays a leaf. A null value UNSETS the variable rather than
18 /// environment, and deliberately does not know that shell integration 18 /// setting it empty: an empty `SSH_AUTH_SOCK` is still a socket to ssh, and
19 /// is what currently wants one. The daemon maps its own pairs onto 19 /// inheriting the daemon's is worse than either.
20 /// these — one loop, and the layering stays the right way up.
21 /// A null value UNSETS the variable in the child rather than setting
22 /// it empty. "No socket" has to be able to mean no agent: an empty
23 /// SSH_AUTH_SOCK is still a socket to ssh, and inheriting the daemon's
24 /// is worse than either.
25 pub const EnvPair = struct { key: [:0]const u8, value: ?[:0]const u8 }; 20 pub const EnvPair = struct { key: [:0]const u8, value: ?[:0]const u8 };
26 21
27 pub const SpawnArgvOptions = struct { 22 pub const SpawnArgvOptions = struct {
@@ -63,35 +58,20 @@ pub const Pty = struct {
63 // Child. xterm-256color: ghostty-vt understands more, but this 58 // Child. xterm-256color: ghostty-vt understands more, but this
64 // terminfo exists everywhere the shell will look. 59 // terminfo exists everywhere the shell will look.
65 _ = c.setenv("TERM", "xterm-256color", 1); 60 _ = c.setenv("TERM", "xterm-256color", 1);
66 // After TERM so a caller could override it, and before the 61 // Overwrite (1), and a CONTRACT rather than a detail: this is a loop
67 // signal work so the environment is settled whatever follows. 62 // over an ordered slice, so a LATER pair beats an earlier one for the
68 // 63 // same key. That is what lets `extra_env` override a variable the
69 // Overwrite (1), and that is a contract rather than a detail: 64 // shell-integration injection set, and a reorder would invert it.
70 // the daemon's own value for a name it was handed is not the
71 // one it means the child to see, and — because this is a loop
72 // over an ordered slice — a LATER pair beats an earlier one for
73 // the same key. That is what lets Server.Options.extra_env
74 // override a variable the shell-integration injection set,
75 // which is exactly how the integration tests point HOME at a
76 // temp directory. The rule was pinned only by that usage; it is
77 // spelled out here so a reorder of the slice cannot quietly
78 // invert it.
79 for (opts.env) |kv| _ = if (kv.value) |v| 65 for (opts.env) |kv| _ = if (kv.value) |v|
80 c.setenv(kv.key.ptr, v.ptr, 1) 66 c.setenv(kv.key.ptr, v.ptr, 1)
81 else 67 else
82 c.unsetenv(kv.key.ptr); 68 c.unsetenv(kv.key.ptr);
83 69
84 // Ctrl-C must work in the session, and without this it does not. 70 // Ctrl-C must work in the session, and without this it does not: a
85 // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for 71 // non-interactive shell sets SIGINT to SIG_IGN for anything it
86 // any command it backgrounds with `&` — which is how every script 72 // backgrounds with `&`, which is how every script starts the daemon.
87 // starts the daemon, test/e2e.sh and test/wan.sh included. SIG_IGN 73 // SIG_IGN survives exec, and a shell keeps entry-ignored signals
88 // is the one disposition that survives exec, so it rides through 74 // ignored for every job — so `isig` reads on and ^C does nothing.
89 // forkpty into the session shell; and a shell keeps signals that
90 // were ignored on entry ignored for every job it spawns. The
91 // result measured over the WAN: the pty had `isig` on, ^C was
92 // echoed, the foreground process group was correct, and `sleep
93 // 300` was still immune. Resetting here, in the child and after
94 // the fork, is what the exec'd shell actually inherits.
95 var dfl: std.posix.Sigaction = .{ 75 var dfl: std.posix.Sigaction = .{
96 .handler = .{ .handler = std.posix.SIG.DFL }, 76 .handler = .{ .handler = std.posix.SIG.DFL },
97 .mask = std.posix.sigemptyset(), 77 .mask = std.posix.sigemptyset(),
@@ -110,20 +90,16 @@ pub const Pty = struct {
110 // flushes stdio buffers inherited from the parent. 90 // flushes stdio buffers inherited from the parent.
111 if (opts.stderr_fd) |fd| { 91 if (opts.stderr_fd) |fd| {
112 std.posix.dup2(fd, 2) catch std.os.linux.exit_group(126); 92 std.posix.dup2(fd, 2) catch std.os.linux.exit_group(126);
113 // The dup left a spare copy at the caller's fd number, and 93 // The dup left a spare copy at the caller's fd number and
114 // pipe() sets no CLOEXEC, so it rides through exec into the 94 // `pipe()` sets no CLOEXEC, so it would ride through exec into
115 // client and everything the client spawns — none of which 95 // everything the client spawns. One handle, so the write end dies
116 // know it is there. Closing it leaves the child exactly one 96 // with the child's stderr and not later.
117 // handle on the pipe, the fd 2 it is meant to have, so the
118 // write-end dies with the child's stderr and not later.
119 if (fd > 2) std.posix.close(fd); 97 if (fd > 2) std.posix.close(fd);
120 } 98 }
121 // The barrier that needs no list. CLOEXEC is set fd by fd, and 99 // The barrier that needs no list: CLOEXEC is set fd by fd, and an
122 // an upgrade exec clears every one and must seal them again 100 // upgrade clears every one and must seal them again — two hand-kept
123 // (`Server.sealAdoptedFds`) — two hand-kept lists that have to 101 // lists that have to agree, or a key-carrying memfd rides into the
124 // agree, or a listener or a key-carrying memfd rides into the 102 // shell. ENOSYS leaves the flags to do the work alone.
125 // shell. Nothing above stderr is the child's, so close it all;
126 // ENOSYS (pre-5.9) leaves the flags to do the work alone.
127 _ = std.os.linux.syscall3(.close_range, 3, std.math.maxInt(u32), 0); 103 _ = std.os.linux.syscall3(.close_range, 3, std.math.maxInt(u32), 0);
128 std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {}; 104 std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {};
129 std.os.linux.exit_group(127); 105 std.os.linux.exit_group(127);
@@ -131,22 +107,13 @@ pub const Pty = struct {
131 107
132 // Parent. The master is THIS session's private handle and must never 108 // Parent. The master is THIS session's private handle and must never
133 // ride an exec into anybody else's child: glibc's forkpty returns it 109 // ride an exec into anybody else's child: glibc's forkpty returns it
134 // without CLOEXEC, so without this every session spawned later 110 // without CLOEXEC, so every later session would inherit every earlier
135 // inherits every earlier session's master — one spare /dev/ptmx per 111 // one's master.
136 // predecessor, sitting in a shell that has no idea it is holding it.
137 // 112 //
138 // Not a tidiness point. A master with a second holder never sees its 113 // Not tidiness. A master with a second holder never sees its last close,
139 // last close, so `deinit`'s close stops hanging up the far side; the 114 // so `deinit`'s close stops hanging up, the interactive shell ignores the
140 // shell is interactive and therefore ignores the SIGTERM that 115 // SIGTERM that follows, and the blocking waitpid never returns. Set in
141 // follows; and the blocking waitpid after THAT never returns. One 116 // the parent because forkpty owns the open and takes no flags.
142 // leaked fd is a daemon that cannot shut down. Unreachable at one
143 // session per daemon, which is why the multi-session daemon is
144 // where it surfaced.
145 //
146 // Set in the parent rather than asked for at open time because
147 // forkpty owns the open: there is no flags argument to pass. Nothing
148 // can spawn in the window before it — the daemon's pump is single
149 // threaded — so the fd is private before any fork can copy it.
150 _ = std.posix.fcntl(master, std.posix.F.SETFD, std.posix.FD_CLOEXEC) catch |err| { 117 _ = std.posix.fcntl(master, std.posix.F.SETFD, std.posix.FD_CLOEXEC) catch |err| {
151 // Only EBADF is possible on an fd forkpty just handed back, but 118 // Only EBADF is possible on an fd forkpty just handed back, but
152 // swallowing it would restore the wedge in a form no test looks 119 // swallowing it would restore the wedge in a form no test looks
@@ -160,12 +127,10 @@ pub const Pty = struct {
160 return .{ .master = master, .child = pid }; 127 return .{ .master = master, .child = pid };
161 } 128 }
162 129
163 /// The two line-discipline bits that decide who echoes a keystroke. 130 /// The two line-discipline bits that decide who echoes a keystroke. Read off
164 /// Read off the MASTER, which on Linux shares one termios with the 131 /// the MASTER, which on Linux shares one termios with the slave, so what the
165 /// slave: the daemon holds this fd already, so what the program in the 132 /// session did with tcsetattr is one syscall away. Polled, which is the only
166 /// session did with tcsetattr is one syscall away and needs no 133 /// option — the kernel notifies nobody when a mode changes.
167 /// cooperation from it. Cheap enough to poll, which is the only option —
168 /// the kernel offers no notification when a mode changes.
169 pub const Mode = struct { icanon: bool, echo: bool }; 134 pub const Mode = struct { icanon: bool, echo: bool };
170 135
171 pub fn mode(self: *const Pty) !Mode { 136 pub fn mode(self: *const Pty) !Mode {
@@ -192,11 +157,9 @@ pub const Pty = struct {
192 if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed; 157 if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed;
193 } 158 }
194 159
195 // Build a Pty from an fd and pid that already belong to this process. 160 // Build a Pty from an fd and pid that already belong to this process. The
196 // The exec keeps the pid, so the adopted child is STILL this process's 161 // exec keeps the pid, so the adopted child is STILL this process's child and
197 // child: checkExited's waitpid works unchanged and exit_status carries 162 // `waitpid` works unchanged — the reason an upgrade re-execs.
198 // the shell's real code. That is the reason the upgrade re-execs
199 // instead of handing fds to a second process.
200 pub fn adopt(master: std.posix.fd_t, child: std.posix.pid_t) Pty { 163 pub fn adopt(master: std.posix.fd_t, child: std.posix.pid_t) Pty {
201 return .{ .master = master, .child = child, .exit_status = null }; 164 return .{ .master = master, .child = child, .exit_status = null };
202 } 165 }
@@ -226,11 +189,9 @@ pub const Pty = struct {
226 self.reap(std.time.milliTimestamp() + term_grace_ms); 189 self.reap(std.time.milliTimestamp() + term_grace_ms);
227 } 190 }
228 191
229 /// Close the master and ask the child to go. Split from `reap` so a 192 /// Close the master and ask the child to go. Split from `reap` so a caller
230 /// caller with a table can spend ONE grace across it: a supervisor's 193 /// with a table spends ONE grace across it: a teardown costing
231 /// stop timeout is a fixed number, and a teardown that costs 194 /// sessions × `term_grace_ms` is SIGKILLed halfway through.
232 /// sessions × term_grace_ms is SIGKILLed halfway through, leaving the
233 /// socket and the per-session agent sockets unlinked.
234 pub fn requestExit(self: *Pty) void { 195 pub fn requestExit(self: *Pty) void {
235 if (self.master >= 0) { 196 if (self.master >= 0) {
236 std.posix.close(self.master); 197 std.posix.close(self.master);
@@ -238,19 +199,10 @@ pub const Pty = struct {
238 } 199 }
239 if (self.exit_status != null) return; 200 if (self.exit_status != null) return;
240 201
241 // Bounded, and that is the whole point. This used to be a straight 202 // Bounded, and that is the point: an interactive shell IGNORES SIGTERM,
242 // blocking `waitpid` after the TERM, which is fine only while every 203 // and only sees the SIGHUP from the close above if this process held the
243 // child is guaranteed to die of it — and one is not: an interactive 204 // master's last handle. A shutdown path must not depend on a signal the
244 // shell IGNORES SIGTERM, and it only sees the SIGHUP from the close 205 // peer is free to ignore, so `reap` bounds the exit by a deadline.
245 // above if this process held the master's last handle. When a leaked
246 // master fd broke that assumption the daemon hung here forever, and
247 // because Server.deinit walks sessions in slot order, hanging on the
248 // first meant none of the rest were torn down either.
249 //
250 // The fd leak is fixed (see spawnArgv's CLOEXEC). This is the second
251 // half of that lesson: a shutdown path must not depend on a signal
252 // the peer is free to ignore. SIGKILL is not ignorable, so `reap`
253 // bounds the exit by a deadline rather than by the child's manners.
254 std.posix.kill(self.child, std.posix.SIG.TERM) catch {}; 206 std.posix.kill(self.child, std.posix.SIG.TERM) catch {};
255 } 207 }
256 208
@@ -283,16 +235,10 @@ pub const Pty = struct {
283 }; 235 };
284 236
285 test "Pty: deinit is bounded even when the child ignores HUP and TERM" { 237 test "Pty: deinit is bounded even when the child ignores HUP and TERM" {
286 // FIRST test in this file on purpose. What it pins is a HANG, and a 238 // FIRST test in this file on purpose: what it pins is a HANG, and a wedged
287 // wedged `zig test` step prints nothing at all for its whole timeout — 239 // `zig test` step prints nothing for its whole timeout — so a regression has
288 // so if this regresses, it has to be the thing that stops, not the 240 // to be the thing that stops. The child ignores exactly the two signals
289 // twentieth test after something else already went quiet. 241 // `deinit` relies on, which is what an interactive shell does with TERM.
290 //
291 // The child ignores exactly the two signals deinit relies on: HUP (what
292 // closing the master sends) and TERM (what deinit sends next). That is
293 // not a contrived process — it is what an interactive shell does with
294 // TERM — and before deinit bounded its wait, this shape hung the daemon
295 // forever with every session's shell still alive.
296 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "trap '' HUP TERM; while :; do sleep 1; done" }; 242 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "trap '' HUP TERM; while :; do sleep 1; done" };
297 var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv }); 243 var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
298 244
@@ -404,12 +350,9 @@ test "Pty: the session shell does not inherit an ignored SIGINT" {
404 defer ready.deinit(alloc); 350 defer ready.deinit(alloc);
405 try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-INT") != null); 351 try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-INT") != null);
406 352
407 // The signal has to be aimed at a *job* of the session shell, not at the 353 // Aimed at a JOB of the session shell, not the shell: an interactive shell
408 // shell itself. An interactive shell catches SIGINT to abandon the 354 // catches SIGINT to abandon the line and proves nothing. A non-interactive
409 // current line, so it abandons `printf` either way and proves nothing. 355 // child installs no handler, so what it does with INT is what it inherited.
410 // A non-interactive child installs no handler, so what it does with INT
411 // is exactly what it inherited — which is the thing under test, and is
412 // also the real symptom: commands run in the session were immune to ^C.
413 _ = try std.posix.write(pty.master, "sh -c 'kill -INT $$; printf \"survived-%s\\n\" INT'\n"); 356 _ = try std.posix.write(pty.master, "sh -c 'kill -INT $$; printf \"survived-%s\\n\" INT'\n");
414 var out = try readUntil(alloc, &pty, "survived-INT", 3000); 357 var out = try readUntil(alloc, &pty, "survived-INT", 3000);
415 defer out.deinit(alloc); 358 defer out.deinit(alloc);
@@ -435,11 +378,9 @@ test "Pty: resize is visible via TIOCGWINSZ" {
435 } 378 }
436 379
437 test "Pty: mode reads the line discipline off the master" { 380 test "Pty: mode reads the line discipline off the master" {
438 // /bin/cat, not a shell: an interactive shell drives the tty itself 381 // /bin/cat, not a shell: readline takes the tty out of canonical mode and
439 // (readline takes it out of canonical mode to read a line and puts it 382 // puts it back, so the bits would depend on where in that cycle the read
440 // back to run a command), so what the bits say would depend on where in 383 // landed. cat sets nothing, so the pty says what this test put there.
441 // that cycle the read landed. cat sets nothing, which leaves the pty
442 // saying exactly what this test put there.
443 var pty = try spawnShell(80, 24, "/bin/cat"); 384 var pty = try spawnShell(80, 24, "/bin/cat");
444 defer pty.deinit(); 385 defer pty.deinit();
445 386
@@ -489,20 +430,11 @@ test "Pty: checkExited reports shell exit" {
489 } 430 }
490 431
491 test "Pty: a later spawn does not inherit an earlier session's master" { 432 test "Pty: a later spawn does not inherit an earlier session's master" {
492 // The hangup contract, and the reason a multi-session daemon needs it 433 // The hangup contract: closing a master is how the daemon hangs up on the
493 // stated as a test: 434 // shell behind it, and that only works if the close is the LAST one. glibc's
494 // closing a master is how the daemon hangs up on the shell behind it, 435 // forkpty hands the master back without CLOEXEC, so every later session
495 // and that only works if the close is the LAST one. glibc's forkpty 436 // inherits every earlier one's — and the daemon then wedges on shutdown with
496 // hands the master back without CLOEXEC, so before this was fixed every 437 // every session's shell still alive.
497 // session spawned LATER inherited every earlier session's master — one
498 // extra /dev/ptmx fd per predecessor, right through exec into the shell.
499 //
500 // What it cost: the daemon's close stopped hanging up, the shell (bash,
501 // and therefore ignoring SIGTERM because it is interactive) outlived the
502 // kill in deinit, and the blocking waitpid after it never returned. The
503 // daemon wedged on shutdown with every session's shell still alive.
504 // Invisible at one session per daemon, which is why it arrived with the
505 // second one.
506 var p1 = try spawnShell(80, 24, "/bin/sh"); 438 var p1 = try spawnShell(80, 24, "/bin/sh");
507 // No `defer p1.deinit()`: this test does p1's close itself, and deinit 439 // No `defer p1.deinit()`: this test does p1's close itself, and deinit
508 // would be a second close of that same fd. The cleanup is deinit's job 440 // would be a second close of that same fd. The cleanup is deinit's job
@@ -654,11 +586,9 @@ test "Pty: fgPgid tracks the foreground job" {
654 } 586 }
655 std.Thread.sleep(50 * std.time.ns_per_ms); 587 std.Thread.sleep(50 * std.time.ns_per_ms);
656 } 588 }
657 // Dash and busybox sh run foreground jobs in the shell's own group when 589 // Dash and busybox sh run foreground jobs in the shell's own group with job
658 // job control is off (non-interactive stdin heuristics differ), so a 590 // control off, so a never-moved pgid is legal for the fallback design — but
659 // never-moved pgid is a legal outcome for the fallback design — but on 591 // on a pty, POSIX shells enable job control. If this flakes, log and skip.
660 // a pty, POSIX shells enable job control. Assert movement; if this
661 // flakes on some /bin/sh, relax to a log + skip, not a green lie.
662 try std.testing.expect(moved); 592 try std.testing.expect(moved);
663 593
664 // ...and returns to the shell when the job ends. 594 // ...and returns to the shell when the job ends.
src/tui/predict.zig
Old New
@@ -1,24 +1,15 @@
1 //! Speculative local echo, as an OVERLAY: predictions live in a queue 1 //! Speculative local echo, as an OVERLAY: predictions live in a queue beside
2 //! beside the replica and never enter it (CLAUDE.md's "Prediction is an 2 //! the replica and never enter it, so a wrong guess costs a repaint and never a
3 //! overlay"), so a wrong guess costs a repaint, never a desync. 3 //! desync. Engine-free — `reconcile` takes its grid duck-typed.
4 //! 4 //!
5 //! Engine-free: `reconcile` takes its grid duck-typed, so the policy is 5 //! Judgment is about EVIDENCE, not arrival order: a frame showing the predicted
6 //! tested with no engine, pty or daemon. 6 //! cell unchanged was probably built before the keystroke arrived, so the
7 //! prediction stays pending. Only a cell that moved to something that is
8 //! neither our guess nor what was there refutes.
7 //! 9 //!
8 //! Judgment is about EVIDENCE, not arrival order: a frame showing the 10 //! The tiers describe ECHO bits: readline echoes itself, so a shell prompt is
9 //! predicted cell unchanged was probably built before the keystroke got 11 //! `.adaptive` and never `.always`. Predictions COPY bytes and the queue is
10 //! there, so the prediction stays pending, and only a cell that moved to 12 //! read by index — a slice goes stale on the next append.
11 //! something neither our guess nor what was there before refutes.
12 //!
13 //! The tiers describe ECHO bits and invite the wrong reading: readline
14 //! echoes itself, so a bash or zsh prompt is `.adaptive` and never
15 //! `.always`, which covers `cat`, a shell's `read`, dash. The bits move
16 //! once or twice per command, and every move re-earns display, so the
17 //! first keystrokes after each prompt are invisible.
18 //!
19 //! Predictions copy bytes, never slice a frame payload or an engine row,
20 //! and the queue is read by index — a slice goes stale on the next
21 //! append.
22 const std = @import("std"); 13 const std = @import("std");
23 const proto = @import("term").protocol; 14 const proto = @import("term").protocol;
24 15
@@ -53,20 +44,16 @@ pub const Pred = struct {
53 painted: bool = false, 44 painted: bool = false,
54 }; 45 };
55 46
56 /// Units are mixed on purpose and stated on every field, because reading 47 /// Units are mixed on purpose and stated per field: `contradicted` counts
57 /// these numbers as though they shared one denominator is how a measurement 48 /// EVENTS while `made` counts PREDICTIONS, so `confirmed + contradicted` totals
58 /// goes wrong quietly: `contradicted` counts EVENTS while `made` counts 49 /// nothing. `made == confirmed + abandoned + pending` is the identity that
59 /// PREDICTIONS, so `confirmed + contradicted` is not a total of anything and 50 /// holds, and `abandoned` exists so it can.
60 /// never was. `made == confirmed + abandoned + pending` is the identity that
61 /// does hold, and `abandoned` exists so it can.
62 pub const Counters = struct { 51 pub const Counters = struct {
63 /// PER PREDICTION: queued, whether or not it was ever shown. 52 /// PER PREDICTION: queued, whether or not it was ever shown.
64 made: u64 = 0, 53 made: u64 = 0,
65 /// PER PREDICTION: ever reached the screen — counted the first time it 54 /// PER PREDICTION: ever reached the screen, counted the first time it is
66 /// is painted, whether that was when it was made or later, when a 55 /// painted — whether that was when it was made or later, when a promotion
67 /// promotion mid-queue made an already-queued prediction visible. 56 /// made an already-queued one visible. Never exceeds `made`.
68 /// Counted once however many repaints redraw the cell, and never
69 /// exceeds `made`.
70 displayed: u64 = 0, 57 displayed: u64 = 0,
71 /// PER PREDICTION: retired because the authoritative grid agreed. 58 /// PER PREDICTION: retired because the authoritative grid agreed.
72 confirmed: u64 = 0, 59 confirmed: u64 = 0,
@@ -83,18 +70,11 @@ pub const Counters = struct {
83 /// nowhere else, which is what used to leave predictions unaccounted 70 /// nowhere else, which is what used to leave predictions unaccounted
84 /// for: made, never confirmed, and no number saying where they went. 71 /// for: made, never confirmed, and no number saying where they went.
85 abandoned: u64 = 0, 72 abandoned: u64 = 0,
86 /// PER INPUT: keystrokes declined for prediction, BY WHOEVER MADE THE 73 /// PER INPUT: keystrokes declined for prediction, BY WHOEVER MADE THE CALL.
87 /// CALL. Most are refusals predictAt made on its own (a control byte, a 74 /// Most are `predictAt`'s own refusals, but the client increments this
88 /// last column, a `.never` context), but the client increments this 75 /// directly for input it declines to offer at all — a paste, whose lead byte
89 /// directly for input it declines to offer at all — a paste, whose lead 76 /// is printable. So this is NOT "times `predictAt` said no", and none of
90 /// byte is printable and would otherwise be predicted as though someone 77 /// these became predictions.
91 /// had typed it.
92 ///
93 /// The unit is spelled out because the number's meaning is wider than
94 /// it looks: this is NOT "times predictAt said no", and reading it that
95 /// way would understate declines by exactly the cases the overlay never
96 /// saw. Not a subset of `made` either — none of these became
97 /// predictions.
98 suppressed: u64 = 0, 78 suppressed: u64 = 0,
99 /// PER PREDICTION: queued with display earned, and hidden anyway 79 /// PER PREDICTION: queued with display earned, and hidden anyway
100 /// because the path was measured too fast to show it. A subset of 80 /// because the path was measured too fast to show it. A subset of
@@ -104,43 +84,33 @@ pub const Counters = struct {
104 }; 84 };
105 85
106 /// What the pty's mode bits say about predicting here. 86 /// What the pty's mode bits say about predicting here.
107 /// .always — icanon && echo: the line discipline is going to print the 87 /// .always — icanon && echo: the line discipline prints the character
108 /// character itself, so predicting it is deduction. 88 /// itself, so predicting it is deduction.
109 /// .never — icanon && !echo: a password prompt. Nothing is predicted, 89 /// .never — icanon && !echo: a password prompt.
110 /// so there is nothing to leak, hide, or get wrong. 90 /// .adaptive — !icanon: raw mode, where the application decides what a
111 /// .adaptive — !icanon: raw mode. The application decides what a 91 /// keystroke looks like and the guess must be earned.
112 /// keystroke looks like and we have to earn the right to
113 /// guess by being repeatedly right.
114 pub const Context = enum { always, never, adaptive }; 92 pub const Context = enum { always, never, adaptive };
115 93
116 /// Consecutive confirmations that earn display in `.adaptive`. 94 /// Consecutive confirmations that earn display in `.adaptive`.
117 pub const promote_after: u8 = 2; 95 pub const promote_after: u8 = 2;
118 96
119 /// How many judging frames a prediction may go unanswered before it is 97 /// How many judging frames a prediction may go unanswered before it is given up
120 /// given up on. The phantom-glyph guard: an application that consumes a 98 /// on — the phantom-glyph guard. An application that consumes a keystroke and
121 /// keystroke and repaints some OTHER row (nvim taking a `j` in normal mode) 99 /// repaints some OTHER row leaves the predicted cell untouched forever.
122 /// leaves the predicted cell untouched forever, and without a bound the
123 /// glyph we painted would sit there for the rest of the session.
124 pub const expire_after_frames: u8 = 8; 100 pub const expire_after_frames: u8 = 8;
125 101
126 /// The same guard in wall time, for the case the frame bound cannot catch: 102 /// The same guard in wall time, for the case the frame bound cannot catch:
127 /// the application answers by going quiet. Milliseconds. 103 /// the application answers by going quiet. Milliseconds.
128 pub const expire_after_ms: i64 = 1000; 104 pub const expire_after_ms: i64 = 1000;
129 105
130 /// The round trip below which a prediction is never worth SHOWING. Over a 106 /// The round trip below which a prediction is never worth SHOWING. On a local
131 /// unix socket to a daemon on the same box a confirm lands in about a 107 /// socket a confirm lands inside the frame the keystroke was painted in, so a
132 /// millisecond, inside the frame the keystroke was painted in — so a 108 /// correct prediction is invisible by construction and the only ones the eye
133 /// correct prediction is invisible by construction and the only ones the 109 /// catches are the wrong ones, underlined until they expire.
134 /// eye can catch are the wrong ones, sitting underlined until they expire.
135 /// That is the "bbb" the operator saw in a vim-mode input box
136 /// (2026-08-23): every phantom, no benefit, on a path where prediction has
137 /// nothing to buy.
138 /// 110 ///
139 /// Two triggers rather than one, with the smoothed estimate moving between 111 /// Two triggers rather than one, with the smoothed estimate moving between
140 /// them, so a path with jitter around the line does not flap the overlay 112 /// them, so jitter around the line does not flap the overlay per keystroke. The
141 /// on and off per keystroke. The 20/30 pair is mosh's, which has had its 113 /// 20/30 pair is mosh's; the smoothing is TCP's 1/8.
142 /// adaptive-display mode gated on srtt the same way for a decade; the
143 /// smoothing is TCP's 1/8.
144 pub const local_below_ms: i64 = 20; 114 pub const local_below_ms: i64 = 20;
145 pub const local_above_ms: i64 = 30; 115 pub const local_above_ms: i64 = 30;
146 116
@@ -166,11 +136,9 @@ pub const Outcome = union(enum) {
166 suppressed, 136 suppressed,
167 /// Queued, and to be painted at this cell now. 137 /// Queued, and to be painted at this cell now.
168 display: Cell, 138 display: Cell,
169 /// Queued but deliberately invisible — adaptive mode gathering the 139 /// Queued but deliberately invisible — adaptive mode gathering the evidence
170 /// evidence that would let the next one be seen. The paint decision 140 /// that lets the next one be seen. The paint decision arrives WITH the cell
171 /// arrives with the cell rather than being a separate question the 141 /// rather than as a question the caller must remember to ask.
172 /// caller has to remember to ask, because "displayed" is what leg 3 of
173 /// the criterion counts and a forgotten check is how it gets violated.
174 hidden: Cell, 142 hidden: Cell,
175 }; 143 };
176 144
@@ -249,17 +217,13 @@ pub const Overlay = struct {
249 self.pending.deinit(self.alloc); 217 self.pending.deinit(self.alloc);
250 } 218 }
251 219
252 /// Adopt what the daemon says the pty is doing. No frame ever having 220 /// Adopt what the daemon says the pty is doing. No frame ever arriving
253 /// arrived leaves the overlay at `.never`, which is the safe default an 221 /// leaves the overlay at `.never`, the safe default an old daemon gets free.
254 /// old daemon gets for free.
255 /// 222 ///
256 /// ANY change to the bits flushes the queue and un-earns display, even 223 /// ANY change to the bits flushes the queue and un-earns display, even one
257 /// one that lands in the same tier. Predictions in flight across a mode 224 /// within the same tier: predictions in flight across a mode transition are
258 /// transition are unverifiable — whatever the application did at the 225 /// unverifiable, and confidence under one line discipline is not evidence
259 /// moment it changed the line discipline is not something we predicted — 226 /// about the next. Re-sending the SAME bits is not a change.
260 /// and confidence gathered under one line discipline is not evidence
261 /// about the next. Re-sending the SAME bits is not a change and costs
262 /// nothing, which matters because a reattach re-states the mode.
263 pub fn setMode(self: *Overlay, flags: proto.PtyModeFlags) void { 227 pub fn setMode(self: *Overlay, flags: proto.PtyModeFlags) void {
264 const byte: u8 = @bitCast(flags); 228 const byte: u8 = @bitCast(flags);
265 if (self.mode_byte) |prev| { 229 if (self.mode_byte) |prev| {
@@ -311,17 +275,11 @@ pub const Overlay = struct {
311 self.last_seq = seq; 275 self.last_seq = seq;
312 } 276 }
313 277
314 /// Speculate one printable byte at the cursor, or refuse to. 278 /// Speculate one printable byte at the cursor, or refuse to. Every refusal
315 /// 279 /// is a place where being wrong costs more than being slow: a control byte,
316 /// Every refusal is a place where being wrong would cost more than 280 /// a multi-byte sequence, the last column, a scrolled viewport, a pending
317 /// being slow: a control byte we cannot render, a multi-byte sequence 281 /// resize, a `.never` context. Infallible by construction — an allocation
318 /// whose width we do not know, the last column (wrap is the 282 /// failure suppresses rather than propagating.
319 /// application's policy, not ours), a viewport that is scrolled away
320 /// from the cursor, a grid that is about to be resized out from under
321 /// the paint, and a `.never` context where the answer is the whole
322 /// point. Infallible by construction: an allocation failure suppresses
323 /// rather than propagating, because no keystroke is worth failing over
324 /// a speculation.
325 pub fn predictAt(self: *Overlay, k: Keystroke) Outcome { 283 pub fn predictAt(self: *Overlay, k: Keystroke) Outcome {
326 if (self.ctx == .never) return self.suppress(); 284 if (self.ctx == .never) return self.suppress();
327 if (self.scroll_mode or self.resize_pending) return self.suppress(); 285 if (self.scroll_mode or self.resize_pending) return self.suppress();
@@ -390,14 +348,10 @@ pub const Overlay = struct {
390 self.counters.displayed += 1; 348 self.counters.displayed += 1;
391 } 349 }
392 350
393 /// Judge everything the newly applied frame is entitled to judge. 351 /// Judge everything the newly applied frame is entitled to judge. A
394 /// 352 /// confirmed prediction retires and lengthens the streak; a contradicted one
395 /// A confirmed prediction retires and lengthens the streak. A 353 /// takes the WHOLE queue, because every prediction made after a wrong one was
396 /// contradicted one takes the WHOLE queue with it — mosh's epoch bump, 354 /// made against a screen that never existed.
397 /// and the reason is not economy: every prediction made after a wrong
398 /// one was made against a screen that never existed, so retiring only
399 /// the wrong cell would leave the rest to be "confirmed" against a
400 /// reality they were never predicting.
401 pub fn reconcile(self: *Overlay, reader: anytype, applied_seq: u64, now_ms: i64) Verdict { 355 pub fn reconcile(self: *Overlay, reader: anytype, applied_seq: u64, now_ms: i64) Verdict {
402 var verdict: Verdict = .none; 356 var verdict: Verdict = .none;
403 var i: usize = 0; 357 var i: usize = 0;
@@ -643,10 +597,9 @@ test "a burst outruns the round trip without refuting itself" {
643 try std.testing.expect(typeAt(&ov, 2, 0, 'l') == .display); 597 try std.testing.expect(typeAt(&ov, 2, 0, 'l') == .display);
644 598
645 // The first frame back was built when the daemon had seen only 'h'. The 599 // The first frame back was built when the daemon had seen only 'h'. The
646 // other two cells are still blank — which is what they were when we 600 // other two cells are still blank, which is what they were when we predicted
647 // predicted, so this frame has said nothing about them. Reading that 601 // — so this frame said nothing about them, and reading that silence as
648 // silence as disagreement is what used to flush the whole queue once 602 // disagreement flushes the queue once per round trip.
649 // per round trip, erasing prediction exactly when it was working.
650 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"h"}, 1)); 603 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"h"}, 1));
651 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); 604 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
652 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); 605 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
@@ -803,11 +756,9 @@ test "a contradiction flushes the whole queue, not merely the cell that was wron
803 _ = typeAt(&ov, 2, 0, 'c'); 756 _ = typeAt(&ov, 2, 0, 'c');
804 try std.testing.expectEqual(@as(usize, 3), ov.pendingCount()); 757 try std.testing.expectEqual(@as(usize, 3), ov.pendingCount());
805 758
806 // Somebody else wrote the FIRST cell: it holds neither our guess nor 759 // Somebody else wrote the FIRST cell: it holds neither our guess nor the
807 // the blank that was there, so this is a refutation and not silence. 760 // blank that was there, so this is refutation and not silence. The two
808 // The two behind it would each have matched — which is the point: 761 // behind it would each have matched, which is the point.
809 // everything typed after a wrong prediction was typed into a screen
810 // that never existed.
811 try std.testing.expectEqual( 762 try std.testing.expectEqual(
812 Verdict.contradicted, 763 Verdict.contradicted,
813 try seeRows(alloc, &ov, &.{"xbc"}, 1), 764 try seeRows(alloc, &ov, &.{"xbc"}, 1),
@@ -1083,13 +1034,9 @@ test "adaptive is demoted by one contradiction and must earn display again" {
1083 // prediction, one demotion: that is leg 3 of the criterion. 1034 // prediction, one demotion: that is leg 3 of the criterion.
1084 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden); 1035 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
1085 1036
1086 // Re-earning costs the FULL promote_after, not one confirm. The streak 1037 // Re-earning costs the FULL `promote_after`, not one confirm: otherwise
1087 // that earned display the first time was reset by the demotion, so a 1038 // "display stops until it is earned again" means "for one keystroke", and an
1088 // single confirmation must not hand it straight back — otherwise 1039 // application that contradicts every other one paints half of them wrong.
1089 // "display stops until it is earned again" means "display stops for one
1090 // keystroke", and an application that contradicts every other keystroke
1091 // would spend half its time painting predictions it has just been told
1092 // are wrong.
1093 try std.testing.expectEqual(@as(u8, 0), ov.streak); 1040 try std.testing.expectEqual(@as(u8, 0), ov.streak);
1094 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXd"}, 4)); 1041 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXd"}, 4));
1095 try std.testing.expect(!ov.confident); 1042 try std.testing.expect(!ov.confident);
@@ -1217,15 +1164,10 @@ test "flush drops predictions without calling any of them wrong" {
1217 } 1164 }
1218 1165
1219 test "a flush leaves scroll mode exactly where it found it" { 1166 test "a flush leaves scroll mode exactly where it found it" {
1220 // The contract behind a real bug, so it is pinned rather than assumed. 1167 // `flush` empties the queue; it does not decide where the viewport is, and
1221 // `flush` empties the queue; it does not decide where the viewport is, 1168 // must not, because every snapshot flushes and a snapshot is no reason to
1222 // and it must not, because every snapshot flushes and a snapshot is not 1169 // leave history. So leaving scroll mode is the CLIENT's job on every path —
1223 // a reason to leave history. The consequence is that leaving scroll mode 1170 // an overlay left scrolled suppresses every keystroke for the session.
1224 // is the CLIENT's job on every path that leaves it — including the
1225 // reconnect path, which used to clear its own `scroll_pages` and say
1226 // nothing here. An overlay left scrolled suppresses every keystroke for
1227 // the rest of the session, and with `scroll_pages` already zeroed the
1228 // exit-scroll branch can never run again to undo it.
1229 const alloc = std.testing.allocator; 1171 const alloc = std.testing.allocator;
1230 var ov = Overlay.init(alloc, 80, 24); 1172 var ov = Overlay.init(alloc, 80, 24);
1231 defer ov.deinit(); 1173 defer ov.deinit();
@@ -1269,11 +1211,9 @@ test "any move in the bits flushes and un-earns display, tier or no tier" {
1269 ov.setMode(.{ .icanon = true, .echo = false }); 1211 ov.setMode(.{ .icanon = true, .echo = false });
1270 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); 1212 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1271 1213
1272 // And now the case a tier comparison would miss entirely. Both of these 1214 // The case a tier comparison misses: both are raw mode, so the policy tier
1273 // are raw mode, so the policy tier does not change — but the 1215 // does not change — but the application has taken the terminal somewhere
1274 // application has taken the terminal somewhere else, and predictions 1216 // else, and predictions made before are about a screen that has moved on.
1275 // made before it did are about a screen that no longer answers for
1276 // itself. Earn display first, so there is something to lose.
1277 ov.setMode(.{ .icanon = false, .echo = false }); 1217 ov.setMode(.{ .icanon = false, .echo = false });
1278 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden); 1218 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1279 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1)); 1219 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));