b2e686eb
refactor: engine, pty, prediction and wire keep the rule
a73x 2026-08-30 19:59
Commit message
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)); |