a73x

70cad621

refactor: the wall owns its interaction loop, painter, selector and overlay as child files

a73x   2026-08-30 11:42

Commit message
refactor: the wall owns its interaction loop, painter, selector and overlay as child files

build.zig
Old New
@@ -163,11 +163,6 @@ const mod_table = [_]ModSpec{
163 // table, and deliberately no protocol import — it carries opaque bytes, 163 // table, and deliberately no protocol import — it carries opaque bytes,
164 // exactly as proxy.zig does. 164 // exactly as proxy.zig does.
165 .{ .name = "quic_server", .path = "src/server/quic_server.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true }, 165 .{ .name = "quic_server", .path = "src/server/quic_server.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true },
166 // Speculative local echo: the overlay and its policy, and deliberately
167 // nothing else. No engine import, which is what lets the whole state
168 // machine be exercised without a terminal — or a daemon — anywhere in
169 // the picture; reconcile takes its grid duck-typed instead.
170 .{ .name = "predict", .path = "src/engine/predict.zig", .layer = 1, .imports = &.{"protocol"} },
171 // This image, as a path something can exec. Under src/cli/ because it 166 // This image, as a path something can exec. Under src/cli/ because it
172 // asks the OS about the process it is in — a question no headless 167 // asks the OS about the process it is in — a question no headless
173 // client may spell. 168 // client may spell.
@@ -214,21 +209,10 @@ const mod_table = [_]ModSpec{
214 // server's test fixtures. Engine plus protocol and nothing else, and 209 // server's test fixtures. Engine plus protocol and nothing else, and
215 // deliberately platform-free — it must compile for wasm32. 210 // deliberately platform-free — it must compile for wasm32.
216 .{ .name = "replica", .path = "src/engine/replica.zig", .layer = 1, .wasm = true, .imports = &.{ "engine", "protocol" } }, 211 .{ .name = "replica", .path = "src/engine/replica.zig", .layer = 1, .wasm = true, .imports = &.{ "engine", "protocol" } },
217 // Painting the replica onto a tty. Takes an fd out and replica/engine
218 // types in, and knows nothing about transports — which is what lets its
219 // tests drive every painter through a pipe with no daemon anywhere.
220 .{ .name = "paint", .path = "src/tui/paint.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
221 // The container tree that owns the wall's pane geometry. Pure: no tty, 212 // The container tree that owns the wall's pane geometry. Pure: no tty,
222 // no engine, no imports — floors arrive as a parameter so wallview can 213 // no engine, no imports — floors arrive as a parameter so wallview can
223 // pass the protocol minimums without this module knowing they are that. 214 // pass the protocol minimums without this module knowing they are that.
224 .{ .name = "layout", .path = "src/client/layout.zig", .layer = 1 }, 215 .{ .name = "layout", .path = "src/client/layout.zig", .layer = 1 },
225 // What a press, a drag and a release MEAN, and nothing else: no tty, no
226 // transport, no engine, no allocation. It imports nothing at all, and is
227 // still layer 1 rather than 0 — layer 0 is this program's vocabulary
228 // (the wire, the engine, the pty), and a drag is a thing the client does
229 // with them. Its two drivers sit at layers 2 and 4, which is the whole
230 // reason it is a module: one meaning, two worlds acting on it.
231 .{ .name = "select", .path = "src/tui/select.zig", .layer = 1 },
232 // Replays a captured client stdout stream and prints the final grid in 216 // Replays a captured client stdout stream and prints the final grid in
233 // `muxd dump`'s formats — the client half of the M11 render-vs-dump 217 // `muxd dump`'s formats — the client half of the M11 render-vs-dump
234 // convergence check. Imports the engine module so both sides of the 218 // convergence check. Imports the engine module so both sides of the
@@ -239,13 +223,6 @@ const mod_table = [_]ModSpec{
239 // under it. 223 // under it.
240 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, 224 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } },
241 // ---- layer 2 ---- 225 // ---- layer 2 ----
242 // Everything that happens between a user at a terminal and one already
243 // open session: the chord table, the wheel splitter, prediction, the
244 // side channels, terminal ownership. It sits BELOW client because it
245 // must be drivable by anything holding a transport — the CLI client
246 // today, a wall tile from the wall's phase-3 convergence on — and it
247 // names no transport type for exactly that reason (see its header).
248 .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select", "askpass" } },
249 // quic and quic_server both: the listener it owns, and the vocabulary 226 // quic and quic_server both: the listener it owns, and the vocabulary
250 // it names directly (the key it loads, the idle default it falls back 227 // it names directly (the key it loads, the idle default it falls back
251 // to). xdg is for endpoint_req's lazy bind — the default key path, 228 // to). xdg is for endpoint_req's lazy bind — the default key path,
@@ -287,11 +264,13 @@ const mod_table = [_]ModSpec{
287 // through. Same layer as webhub for the same reason — both sit on 264 // through. Same layer as webhub for the same reason — both sit on
288 // client's Transport and hosts' grammar; neither may import the other; 265 // client's Transport and hosts' grammar; neither may import the other;
289 // both resolve a spelling through `client.Target.fromSpec`. 266 // both resolve a spelling through `client.Target.fromSpec`.
290 // `predict` is here because a zoomed tile speculates like any other 267 // The interaction loop, the painter, the selector and the prediction
291 // typed-at session; the overlay machinery itself is interact's, shared — 268 // overlay are CHILD FILES of this root rather than modules of their own,
292 // and phase 3 promotes the tile into that core rather than growing a 269 // so nothing outside src/tui/ can name one: a second module claiming any
293 // second copy of it. 270 // of those files is a file-in-multiple-modules compile error. Their
294 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout", "askpass", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 271 // needs — replica, client_core, keymap — are why this row imports what a
272 // painter and a keyboard loop want and the wall itself never spells.
273 .{ .name = "wall", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "hosts", "handoff", "proxy", "engine", "replica", "client_core", "keymap", "layout", "askpass", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
295 // ---- layer 5 ---- 274 // ---- layer 5 ----
296 // hosts owns the host grammar and the state file, so argv is parsed by 275 // hosts owns the host grammar and the state file, so argv is parsed by
297 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not 276 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not
@@ -304,7 +283,7 @@ const mod_table = [_]ModSpec{
304 // --session has to be a usage error here, at parse, not bytes some 283 // --session has to be a usage error here, at parse, not bytes some
305 // daemon downstream has to notice and refuse. Layer 5 since the no-arg 284 // daemon downstream has to notice and refuse. Layer 5 since the no-arg
306 // wall pulled in wallview (layer 4). 285 // wall pulled in wallview (layer 4).
307 .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 286 .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wall", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
308 // ---- layer 6: the one binary ---- 287 // ---- layer 6: the one binary ----
309 // Four words, one image. Nothing but the mode letter lives here, which 288 // Four words, one image. Nothing but the mode letter lives here, which
310 // is why it may sit above every other main without owning any of them. 289 // is why it may sit above every other main without owning any of them.
@@ -549,22 +528,34 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
549 /// stopped importing it; found in review 2026-08-14, and this is the check 528 /// stopped importing it; found in review 2026-08-14, and this is the check
550 /// that would have refused the build instead.) 529 /// that would have refused the build instead.)
551 /// 530 ///
552 /// Textual on purpose, and sound because of a property of this table: every 531 /// Textual on purpose, and scanned over the module's WHOLE file set, not the
553 /// row is a single-file module — no row's source pulls in a sibling by 532 /// root alone: a domain root owns child files reached by relative import (the
554 /// relative path — so the root file is the only file that can spell the 533 /// wall's interaction loop, the server's session table), and a grant is the
555 /// import. What the text cannot tell is WHICH column a used import belongs 534 /// module's, so any of those files may be the one that spends it. Scanning
556 /// in: a production grant referenced only inside a `test` block still reads 535 /// the root alone would call a child's import stale and refuse the build.
557 /// as used here. Separating the columns is the test twin's job in build(), 536 /// What the text cannot tell is WHICH column a used import belongs in: a
558 /// not this scan's. 537 /// production grant referenced only inside a `test` block still reads as used
538 /// here. Separating the columns is the test twin's job in build(), not this
539 /// scan's.
559 fn checkGrantsUsed(b: *std.Build) void { 540 fn checkGrantsUsed(b: *std.Build) void {
560 for (&mod_table) |spec| { 541 for (&mod_table) |spec| {
561 const src = b.build_root.handle.readFileAlloc(b.allocator, spec.path, 4 << 20) catch |err| 542 var files: std.ArrayList([]const u8) = .empty;
562 fatal("module table: cannot read {s} ({s})", .{ spec.path, @errorName(err) }); 543 moduleFiles(b, spec.path, &files);
563 for ([_][]const []const u8{ spec.imports, spec.test_imports }, 0..) |col, which| { 544 for ([_][]const []const u8{ spec.imports, spec.test_imports }, 0..) |col, which| {
564 for (col) |dep| { 545 for (col) |dep| {
565 if (std.mem.indexOf(u8, src, b.fmt("@import(\"{s}\")", .{dep})) == null) fatal( 546 const needle = b.fmt("@import(\"{s}\")", .{dep});
566 "module table: row '{s}' grants {s} '{s}', but {s} never " ++ 547 var used = false;
567 "writes @import(\"{s}\") — delete the stale grant; the " ++ 548 for (files.items) |f| {
549 const src = b.build_root.handle.readFileAlloc(b.allocator, f, 4 << 20) catch |err|
550 fatal("module table: cannot read {s} ({s})", .{ f, @errorName(err) });
551 if (std.mem.indexOf(u8, src, needle) != null) {
552 used = true;
553 break;
554 }
555 }
556 if (!used) fatal(
557 "module table: row '{s}' grants {s} '{s}', but no file of " ++
558 "{s} writes @import(\"{s}\") — delete the stale grant; the " ++
568 "table is the program's import graph, not a wish list", 559 "table is the program's import graph, not a wish list",
569 .{ spec.name, if (which == 0) "import" else "test_import", dep, spec.path, dep }, 560 .{ spec.name, if (which == 0) "import" else "test_import", dep, spec.path, dep },
570 ); 561 );
@@ -573,6 +564,31 @@ fn checkGrantsUsed(b: *std.Build) void {
573 } 564 }
574 } 565 }
575 566
567 /// The files one module is built from: its root, plus every `.zig` file the
568 /// root reaches by relative import, transitively. The set the compiler will
569 /// claim for that module, which is the set a grant can be spent in.
570 fn moduleFiles(b: *std.Build, path: []const u8, out: *std.ArrayList([]const u8)) void {
571 for (out.items) |seen| if (std.mem.eql(u8, seen, path)) return;
572 out.append(b.allocator, path) catch @panic("OOM");
573 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |err|
574 fatal("module table: cannot read {s} ({s})", .{ path, @errorName(err) });
575 const dir = std.fs.path.dirname(path) orelse ".";
576 const open = "@import(\"";
577 var i: usize = 0;
578 while (std.mem.indexOfPos(u8, src, i, open)) |at| {
579 const start = at + open.len;
580 const close = std.mem.indexOfScalarPos(u8, src, start, '"') orelse break;
581 i = close + 1;
582 const spelled = src[start..close];
583 if (!std.mem.endsWith(u8, spelled, ".zig")) continue;
584 const child = b.pathJoin(&.{ dir, spelled });
585 // A relative import the compiler will refuse anyway (a file in
586 // another module, a path that is not there): let it say so.
587 b.build_root.handle.access(child, .{}) catch continue;
588 moduleFiles(b, child, out);
589 }
590 }
591
576 /// A split file's tests live beside it, and analysis is what registers a 592 /// A split file's tests live beside it, and analysis is what registers a
577 /// test: the `_ = @import(…)` lines in the root's test block are the only 593 /// test: the `_ = @import(…)` lines in the root's test block are the only
578 /// thing that reaches them. Drop one line and that whole domain stops 594 /// thing that reaches them. Drop one line and that whole domain stops
@@ -814,12 +830,14 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
814 /// they carry the argument parsers — a test that is never built is not a 830 /// they carry the argument parsers — a test that is never built is not a
815 /// test (decisions.md). 831 /// test (decisions.md).
816 const test_order = [_][]const u8{ 832 const test_order = [_][]const u8{
817 "script", "select", "protocol", "client_core", "interact", "engine", "pty", 833 "script", "protocol", "client_core", "engine", "pty",
818 "delta", "cmd", "hosts", "askpass", "upgrade", "shellint", "replica", 834 "delta", "cmd", "hosts", "askpass", "upgrade",
819 "keymap", "webhub", "wallview", "sockpath", "agent_main", "server", "client", 835 "shellint", "replica", "keymap", "webhub", "wall",
820 "proxy", "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", 836 "sockpath", "agent_main", "server", "client", "proxy",
821 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", 837 "client_main", "quic", "quic_server", "daemon_main", "testtmp",
822 "render", "ptyclient", "hub_main", "wsclient", "cliflags", "mux", 838 "rawmode", "delaypipe", "xdg", "spawn", "handoff",
839 "layout", "render", "ptyclient", "hub_main", "wsclient",
840 "cliflags", "mux",
823 }; 841 };
824 842
825 comptime { 843 comptime {
src/cli/mux_main.zig
Old New
@@ -21,7 +21,7 @@ const xdg = @import("xdg");
21 const spawn = @import("spawn"); 21 const spawn = @import("spawn");
22 const handoff = @import("handoff"); 22 const handoff = @import("handoff");
23 const sockpath = @import("sockpath"); 23 const sockpath = @import("sockpath");
24 const wallview = @import("wallview"); 24 const wallview = @import("wall");
25 const hosts = @import("hosts"); 25 const hosts = @import("hosts");
26 const cliflags = @import("cliflags"); 26 const cliflags = @import("cliflags");
27 const TmpDir = @import("testtmp").TmpDir; 27 const TmpDir = @import("testtmp").TmpDir;
src/engine/predict.zig
Old New
@@ -1,1430 +0,0 @@
1 //! Speculative local echo, as an OVERLAY: predictions live in a queue
2 //! beside the replica and never enter it (CLAUDE.md's "Prediction is an
3 //! overlay"), so a wrong guess costs a repaint, never a desync.
4 //!
5 //! Engine-free: `reconcile` takes its grid duck-typed, so the policy is
6 //! tested with no engine, pty or daemon.
7 //!
8 //! Judgment is about EVIDENCE, not arrival order: a frame showing the
9 //! predicted cell unchanged was probably built before the keystroke got
10 //! there, so the prediction stays pending, and only a cell that moved to
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");
23 const proto = @import("protocol");
24
25 /// One predicted character at one place on the grid. Printable ASCII only,
26 /// deliberately: `ch` is a byte by value, so there is nothing here that can
27 /// outlive what it was copied from.
28 pub const Cell = struct { row: u16, col: u16, ch: u8 };
29
30 pub const Pred = struct {
31 cell: Cell,
32 /// What the cell showed when the prediction was made. The load-bearing
33 /// field of reconcile v2: a cell that STILL shows this is a cell the
34 /// authoritative stream has said nothing about yet, which is a
35 /// different thing from one that disagrees with us.
36 prev_ch: u8,
37 /// The authoritative seq the client held when this was predicted. A
38 /// frame carrying a HIGHER seq is the first one that could possibly
39 /// have been built after the keystroke reached the daemon, and so the
40 /// first one entitled to have an opinion about it.
41 made_seq: u64,
42 /// Wall clock at prediction time, supplied by the caller — the module
43 /// never reads a clock, so every deadline in here is testable.
44 made_ms: i64,
45 /// Judging frames that have looked at this cell and found it unchanged.
46 /// Bounded by expire_after_frames, because "no evidence yet" must not
47 /// be a state a prediction can sit in forever.
48 frames: u8 = 0,
49 /// Whether this prediction has ever been drawn. Not the same question as
50 /// whether the overlay is confident now: one queued while unconfident
51 /// and painted later, after a promotion, has reached the screen exactly
52 /// once and must be counted exactly once.
53 painted: bool = false,
54 };
55
56 /// Units are mixed on purpose and stated on every field, because reading
57 /// these numbers as though they shared one denominator is how a measurement
58 /// goes wrong quietly: `contradicted` counts EVENTS while `made` counts
59 /// PREDICTIONS, so `confirmed + contradicted` is not a total of anything and
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 {
63 /// PER PREDICTION: queued, whether or not it was ever shown.
64 made: u64 = 0,
65 /// PER PREDICTION: ever reached the screen — counted the first time it
66 /// is painted, whether that was when it was made or later, when a
67 /// promotion mid-queue made an already-queued prediction visible.
68 /// Counted once however many repaints redraw the cell, and never
69 /// exceeds `made`.
70 displayed: u64 = 0,
71 /// PER PREDICTION: retired because the authoritative grid agreed.
72 confirmed: u64 = 0,
73 /// PER EVENT: one refutation or expiry, however many predictions it
74 /// discarded — a contradiction takes the whole queue, so this counts
75 /// how often we were wrong, not how much was thrown away.
76 contradicted: u64 = 0,
77 /// PER EVENT: the subset of `contradicted` where nothing ever answered
78 /// rather than something disagreed. Counted in both, so a reader can tell
79 /// "we guessed wrong" apart from "the application went quiet".
80 expired: u64 = 0,
81 /// PER PREDICTION: queued but discarded without a verdict — by a
82 /// contradiction, an expiry, or a flush. The flush case is counted
83 /// nowhere else, which is what used to leave predictions unaccounted
84 /// for: made, never confirmed, and no number saying where they went.
85 abandoned: u64 = 0,
86 /// PER INPUT: keystrokes declined for prediction, BY WHOEVER MADE THE
87 /// CALL. Most are refusals predictAt made on its own (a control byte, a
88 /// last column, a `.never` context), but the client increments this
89 /// directly for input it declines to offer at all — a paste, whose lead
90 /// byte is printable and would otherwise be predicted as though someone
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,
99 /// PER PREDICTION: queued with display earned, and hidden anyway
100 /// because the path was measured too fast to show it. A subset of
101 /// `made` and disjoint from `displayed`; the number that says whether
102 /// the local gate is what is keeping an overlay quiet.
103 local: u64 = 0,
104 };
105
106 /// What the pty's mode bits say about predicting here.
107 /// .always — icanon && echo: the line discipline is going to print the
108 /// character itself, so predicting it is deduction.
109 /// .never — icanon && !echo: a password prompt. Nothing is predicted,
110 /// so there is nothing to leak, hide, or get wrong.
111 /// .adaptive — !icanon: raw mode. The application decides what a
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 };
115
116 /// Consecutive confirmations that earn display in `.adaptive`.
117 pub const promote_after: u8 = 2;
118
119 /// How many judging frames a prediction may go unanswered before it is
120 /// given up on. The phantom-glyph guard: an application that consumes a
121 /// keystroke and repaints some OTHER row (nvim taking a `j` in normal mode)
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;
125
126 /// The same guard in wall time, for the case the frame bound cannot catch:
127 /// the application answers by going quiet. Milliseconds.
128 pub const expire_after_ms: i64 = 1000;
129
130 /// The round trip below which a prediction is never worth SHOWING. Over a
131 /// unix socket to a daemon on the same box a confirm lands in about a
132 /// millisecond, inside the frame the keystroke was painted in — so a
133 /// correct prediction is invisible by construction and the only ones the
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 ///
139 /// 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
141 /// on and off per keystroke. The 20/30 pair is mosh's, which has had its
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;
145 pub const local_above_ms: i64 = 30;
146
147 /// Engine-free mirror of the engine's cursor position.
148 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 };
149
150 /// A struct, not four positional arguments: `ch` and `prev_ch` are
151 /// adjacent bytes, so transposing them compiles silently and turns every
152 /// prediction into a no-op or a wrong guess.
153 pub const Keystroke = struct {
154 cursor: CursorPos,
155 /// The byte the user typed.
156 ch: u8,
157 /// What the client's replica shows at that cell right now. The caller
158 /// reads it; the overlay stays engine-free.
159 prev_ch: u8,
160 now_ms: i64,
161 };
162
163 pub const Outcome = union(enum) {
164 /// Refused. Nothing queued, nothing painted; the keystroke still goes
165 /// to the daemon exactly as it would have.
166 suppressed,
167 /// Queued, and to be painted at this cell now.
168 display: Cell,
169 /// Queued but deliberately invisible — adaptive mode gathering the
170 /// evidence that would let the next one be seen. The paint decision
171 /// arrives with the cell rather than being a separate question the
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,
175 };
176
177 pub const Verdict = enum {
178 /// Nothing pending was old enough to judge.
179 none,
180 /// At least one prediction was confirmed and retired; none were wrong.
181 confirmed,
182 /// One was wrong, so the queue is empty and the caller should repaint.
183 contradicted,
184 };
185
186 /// Reads cells out of a plain grid dump: rows joined by '\n', trailing
187 /// blanks absent, which is the shape `Engine.dumpPlain` produces. Holds a
188 /// borrowed slice and is meant to be built, used and dropped inside one
189 /// reconcile call — never stored.
190 pub const PlainGrid = struct {
191 text: []const u8,
192 cols: u16,
193
194 /// null means "outside the grid", not "blank": a dump carries no
195 /// trailing blanks, so past a row's end or the dump's end is blank. A
196 /// multi-byte cell answers with its lead byte, which can never equal a
197 /// predicted printable ASCII byte — so it contradicts, the safe way.
198 pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 {
199 if (col >= self.cols) return null;
200 var y: u16 = 0;
201 var it = std.mem.splitScalar(u8, self.text, '\n');
202 while (it.next()) |line| : (y += 1) {
203 if (y != row) continue;
204 if (col >= line.len) return ' ';
205 return line[col];
206 }
207 return ' ';
208 }
209 };
210
211 pub const Overlay = struct {
212 alloc: std.mem.Allocator,
213 pending: std.ArrayList(Pred) = .empty,
214 ctx: Context = .never,
215 /// What the POLICY says about painting: true in `.always`, false in
216 /// `.never`, and earned in `.adaptive`. Not the gate itself any more —
217 /// that is `visible`, which also asks whether the path is worth it.
218 confident: bool = false,
219 streak: u8 = 0,
220 /// Smoothed keystroke-to-confirm round trip, or null until the first
221 /// confirm has measured one. Counted from the keystroke's own clock to
222 /// the frame that confirmed it, so it is the whole path — tty read,
223 /// wire, daemon, pty, engine, wire back — and not a transport ping.
224 srtt_ms: ?i64 = null,
225 /// The path is too fast to show predictions on. Moves only when
226 /// `srtt_ms` crosses a trigger, so it carries the hysteresis; an
227 /// unmeasured path is not local, which keeps a fresh WAN attach on the
228 /// behaviour the numbers in decisions.md were measured against.
229 local: bool = false,
230 counters: Counters = .{},
231 cols: u16,
232 rows: u16,
233 scroll_mode: bool = false,
234 resize_pending: bool = false,
235 /// The last mode byte adopted, or null before any pty_mode frame has
236 /// arrived. The raw byte rather than the derived context, because the
237 /// churn rule is about the BITS moving: a change inside one tier is
238 /// still an application taking the terminal somewhere else.
239 mode_byte: ?u8 = null,
240 /// The last authoritative seq the client applied; stamped onto each new
241 /// prediction as `made_seq`.
242 last_seq: u64 = 0,
243
244 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) Overlay {
245 return .{ .alloc = alloc, .cols = cols, .rows = rows };
246 }
247
248 pub fn deinit(self: *Overlay) void {
249 self.pending.deinit(self.alloc);
250 }
251
252 /// Adopt what the daemon says the pty is doing. No frame ever having
253 /// arrived leaves the overlay at `.never`, which is the safe default an
254 /// old daemon gets for free.
255 ///
256 /// ANY change to the bits flushes the queue and un-earns display, even
257 /// one that lands in the same tier. Predictions in flight across a mode
258 /// transition are unverifiable — whatever the application did at the
259 /// moment it changed the line discipline is not something we predicted —
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 {
264 const byte: u8 = @bitCast(flags);
265 if (self.mode_byte) |prev| {
266 if (prev == byte) return;
267 }
268 self.mode_byte = byte;
269 const next: Context = blk: {
270 // A bit we do not understand means the byte describes a
271 // terminal we cannot reason about. Predict nothing rather than
272 // mask it off and carry on as though we had understood.
273 if (flags._pad != 0) break :blk .never;
274 if (!flags.icanon) break :blk .adaptive;
275 break :blk if (flags.echo) .always else .never;
276 };
277 self.ctx = next;
278 // Outstanding predictions were made under the old line discipline,
279 // and the new one may be that they should never have been visible.
280 self.flush();
281 self.streak = 0;
282 self.confident = (next == .always);
283 }
284
285 /// A resize invalidates every prediction on the old grid, and moves the
286 /// edge that the last-column refusal is measured against.
287 pub fn setGrid(self: *Overlay, cols: u16, rows: u16) void {
288 if (cols == self.cols and rows == self.rows) return;
289 self.cols = cols;
290 self.rows = rows;
291 self.flush();
292 }
293
294 pub fn setScrollMode(self: *Overlay, on: bool) void {
295 if (on == self.scroll_mode) return;
296 self.scroll_mode = on;
297 // Entering scroll mode the cursor stops being where the user is
298 // looking; leaving it, the whole viewport is repainted. Either way
299 // what is queued no longer describes the screen.
300 self.flush();
301 }
302
303 pub fn setResizePending(self: *Overlay, pending: bool) void {
304 if (pending == self.resize_pending) return;
305 self.resize_pending = pending;
306 if (pending) self.flush();
307 }
308
309 /// For a frame applied without judging it — a snapshot.
310 pub fn noteSeq(self: *Overlay, seq: u64) void {
311 self.last_seq = seq;
312 }
313
314 /// Speculate one printable byte at the cursor, or refuse to.
315 ///
316 /// Every refusal is a place where being wrong would cost more than
317 /// being slow: a control byte we cannot render, a multi-byte sequence
318 /// whose width we do not know, the last column (wrap is the
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 {
326 if (self.ctx == .never) return self.suppress();
327 if (self.scroll_mode or self.resize_pending) return self.suppress();
328 if (k.ch < 0x20 or k.ch >= 0x7f) return self.suppress();
329 if (self.cols == 0 or self.rows == 0) return self.suppress();
330 if (k.cursor.y >= self.rows) return self.suppress();
331 if (k.cursor.x >= self.cols -| 1) return self.suppress();
332 // The glyph is already there. Painting it changes nothing on screen,
333 // and queueing it would put a prediction into the queue that cannot
334 // be told apart from "no evidence yet" no matter what happens next.
335 if (k.ch == k.prev_ch) return self.suppress();
336
337 const cell: Cell = .{ .row = k.cursor.y, .col = k.cursor.x, .ch = k.ch };
338 self.pending.append(self.alloc, .{
339 .cell = cell,
340 .prev_ch = k.prev_ch,
341 .made_seq = self.last_seq,
342 .made_ms = k.now_ms,
343 }) catch return self.suppress();
344
345 self.counters.made += 1;
346 if (self.visible()) {
347 self.markPainted(self.pending.items.len - 1);
348 return .{ .display = cell };
349 }
350 // Queued even when local, deliberately: a hidden prediction is still
351 // judged, so it keeps measuring the path and keeps the streak. That
352 // is what lets the overlay come back on if the box gets slow, and
353 // what made the local-gate tests cheap — no second state machine.
354 if (self.confident) self.counters.local += 1;
355 return .{ .hidden = cell };
356 }
357
358 /// The gate on painting: earned, AND on a path slow enough to show it.
359 pub fn visible(self: *const Overlay) bool {
360 return self.confident and !self.local;
361 }
362
363 /// Fold one measured round trip into the estimate and move `local`
364 /// if it crossed a trigger. Confirms only — an expiry says nothing
365 /// about how fast the path is, only that the application went quiet.
366 fn samplePath(self: *Overlay, rtt_ms: i64) void {
367 const srtt = if (self.srtt_ms) |s| s + @divTrunc(rtt_ms - s, 8) else rtt_ms;
368 self.srtt_ms = srtt;
369 if (srtt <= local_below_ms) self.local = true;
370 if (srtt >= local_above_ms) self.local = false;
371 }
372
373 fn suppress(self: *Overlay) Outcome {
374 self.recordSuppressed();
375 return .suppressed;
376 }
377
378 /// A refusal the CALLER made: a plain-ASCII paste, whose lead byte is
379 /// printable.
380 pub fn recordSuppressed(self: *Overlay) void {
381 self.counters.suppressed += 1;
382 }
383
384 /// A prediction queued while unconfident is invisible; a later
385 /// promotion makes the next repaint draw it. Count it once, however
386 /// many repaints redraw the cell.
387 pub fn markPainted(self: *Overlay, i: usize) void {
388 if (self.pending.items[i].painted) return;
389 self.pending.items[i].painted = true;
390 self.counters.displayed += 1;
391 }
392
393 /// Judge everything the newly applied frame is entitled to judge.
394 ///
395 /// A confirmed prediction retires and lengthens the streak. A
396 /// contradicted one takes the WHOLE queue with it — mosh's epoch bump,
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 {
402 var verdict: Verdict = .none;
403 var i: usize = 0;
404 while (i < self.pending.items.len) {
405 const p = self.pending.items[i];
406 if (p.made_seq >= applied_seq) {
407 i += 1; // too new to be evidence about
408 continue;
409 }
410 const shown = reader.cellChar(p.cell.row, p.cell.col);
411
412 if (shown != null and shown.? == p.cell.ch) {
413 _ = self.pending.orderedRemove(i);
414 self.counters.confirmed += 1;
415 self.samplePath(now_ms -| p.made_ms);
416 self.streak +|= 1;
417 if (self.ctx == .adaptive and self.streak >= promote_after) {
418 self.confident = true;
419 }
420 verdict = .confirmed;
421 continue; // index i now holds the next prediction
422 }
423
424 if (shown != null and shown.? == p.prev_ch) {
425 // Silence, not disagreement. This frame was almost certainly
426 // built before the keystroke landed — which over any real
427 // path is the ordinary case for everything typed after the
428 // first character of a burst.
429 self.pending.items[i].frames +|= 1;
430 if (self.pending.items[i].frames >= expire_after_frames or
431 now_ms -| p.made_ms >= expire_after_ms)
432 {
433 self.counters.expired += 1;
434 self.noteSeq(applied_seq);
435 return self.abandonAll();
436 }
437 i += 1;
438 continue;
439 }
440
441 // The cell moved to something that is neither our guess nor what
442 // was there before: somebody else wrote it, and every prediction
443 // behind this one was made against a screen that never existed.
444 self.noteSeq(applied_seq);
445 return self.abandonAll();
446 }
447 self.noteSeq(applied_seq);
448 return verdict;
449 }
450
451 /// An app that swallows a keystroke then goes quiet produces no more
452 /// frames, so `reconcile` never runs again and the phantom sits there
453 /// forever. The client calls this from its idle path.
454 pub fn expire(self: *Overlay, now_ms: i64) Verdict {
455 for (self.pending.items) |p| {
456 if (now_ms -| p.made_ms < expire_after_ms) continue;
457 self.counters.expired += 1;
458 return self.abandonAll();
459 }
460 return .none;
461 }
462
463 /// Drop everything and stop trusting ourselves: the shared tail of a
464 /// contradiction and an expiry, which differ only in what made them
465 /// necessary. Callers repaint in full.
466 fn abandonAll(self: *Overlay) Verdict {
467 self.counters.contradicted += 1;
468 self.counters.abandoned += self.pending.items.len;
469 // Re-earning display costs the full promote_after again. Without
470 // this the streak survives the demotion, one confirm re-promotes,
471 // and leg 3 of the criterion — display stops until it is earned back
472 // — quietly becomes display stops for one keystroke.
473 self.streak = 0;
474 // Demotion is an adaptive-only idea. In canonical echo the pty is
475 // going to print the character whatever we believe, so a
476 // disagreement means we put it in the wrong place, not that we
477 // should stop predicting.
478 if (self.ctx == .adaptive) self.confident = false;
479 self.pending.clearRetainingCapacity();
480 return .contradicted;
481 }
482
483 /// Snapshot, resize, scroll mode, reconnect: nothing here is wrong, so
484 /// confidence and the streak survive.
485 pub fn flush(self: *Overlay) void {
486 self.counters.abandoned += self.pending.items.len;
487 self.pending.clearRetainingCapacity();
488 }
489
490 /// Where the cursor appears to be, given what is queued: the
491 /// authoritative position advanced past the last pending prediction.
492 /// With nothing pending it is the daemon's own answer, untouched.
493 pub fn predictedCursor(self: *const Overlay, base: CursorPos) CursorPos {
494 const last = self.pending.getLastOrNull() orelse return base;
495 return .{ .x = last.cell.col + 1, .y = last.cell.row };
496 }
497
498 pub fn pendingCount(self: *const Overlay) usize {
499 return self.pending.items.len;
500 }
501
502 /// By value: the queue reallocates, so a slice would go stale on the
503 /// next keystroke.
504 pub fn pendingAt(self: *const Overlay, i: usize) Pred {
505 return self.pending.items[i];
506 }
507 };
508
509 // ---- tests ------------------------------------------------------------
510
511 /// A grid row set built the way a plain dump arrives: rows joined by '\n',
512 /// trailing blanks absent. Caller frees.
513 fn plainOf(alloc: std.mem.Allocator, rows: []const []const u8) ![]u8 {
514 return std.mem.join(alloc, "\n", rows);
515 }
516
517 /// Type one character into a cell that is currently blank — the ordinary
518 /// case, and the one most of these tests are about.
519 fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome {
520 return ov.predictAt(.{
521 .cursor = .{ .x = x, .y = y },
522 .ch = ch,
523 .prev_ch = ' ',
524 .now_ms = 0,
525 });
526 }
527
528 /// A frame from a remote path. Tests about time or the gate use `seeRowsAt`.
529 fn seeRows(
530 alloc: std.mem.Allocator,
531 ov: *Overlay,
532 rows: []const []const u8,
533 seq: u64,
534 ) !Verdict {
535 // 100ms after the keystroke, not 0: at 0 every confirm is a measured
536 // 0ms round trip, the local gate closes, and every `.display` the
537 // policy tests assert goes `.hidden` for a reason they are not about.
538 return seeRowsAt(alloc, ov, rows, seq, 100);
539 }
540
541 fn seeRowsAt(
542 alloc: std.mem.Allocator,
543 ov: *Overlay,
544 rows: []const []const u8,
545 seq: u64,
546 now_ms: i64,
547 ) !Verdict {
548 const text = try plainOf(alloc, rows);
549 defer alloc.free(text);
550 return ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, seq, now_ms);
551 }
552
553 test "setMode maps the pty's two bits onto the three policies" {
554 const alloc = std.testing.allocator;
555 const cases = [_]struct { icanon: bool, echo: bool, want: Context }{
556 // Canonical and echoing: the tty itself will put the character on
557 // the screen, so predicting it is not a guess at all.
558 .{ .icanon = true, .echo = true, .want = .always },
559 // Canonical and silent: a password prompt. Nothing may be shown.
560 .{ .icanon = true, .echo = false, .want = .never },
561 // Raw: the application decides what a keystroke looks like, and the
562 // only way to find out is to be right about it repeatedly.
563 .{ .icanon = false, .echo = true, .want = .adaptive },
564 .{ .icanon = false, .echo = false, .want = .adaptive },
565 };
566 for (cases) |c| {
567 var ov = Overlay.init(alloc, 80, 24);
568 defer ov.deinit();
569 ov.setMode(.{ .icanon = c.icanon, .echo = c.echo });
570 try std.testing.expectEqual(c.want, ov.ctx);
571 }
572 }
573
574 test "a mode byte carrying a bit we do not understand predicts nothing" {
575 const alloc = std.testing.allocator;
576 var ov = Overlay.init(alloc, 80, 24);
577 defer ov.deinit();
578 ov.setMode(.{ .icanon = true, .echo = true });
579 try std.testing.expectEqual(Context.always, ov.ctx);
580
581 // A future daemon defines a third bit. Read as canonical-and-echoing
582 // with the extra bit masked away, this would keep predicting against a
583 // terminal whose description we have only partly understood.
584 ov.setMode(.{ .icanon = true, .echo = true, ._pad = 1 });
585 try std.testing.expectEqual(Context.never, ov.ctx);
586 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
587 }
588
589 test "an overlay predicts nothing until it has been told what the pty is" {
590 const alloc = std.testing.allocator;
591 var ov = Overlay.init(alloc, 80, 24);
592 defer ov.deinit();
593 // No pty_mode frame has arrived (or the daemon is too old to send one).
594 // No frame, no prediction: the safe direction is the default one.
595 try std.testing.expectEqual(Context.never, ov.ctx);
596 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
597 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
598 }
599
600 test "always: the first keystroke paints, with no evidence required" {
601 const alloc = std.testing.allocator;
602 var ov = Overlay.init(alloc, 80, 24);
603 defer ov.deinit();
604 ov.setMode(.{ .icanon = true, .echo = true });
605
606 const out = typeAt(&ov, 3, 2, 'k');
607 try std.testing.expect(out == .display);
608 try std.testing.expectEqual(@as(u16, 3), out.display.col);
609 try std.testing.expectEqual(@as(u16, 2), out.display.row);
610 try std.testing.expectEqual(@as(u8, 'k'), out.display.ch);
611 try std.testing.expectEqual(@as(u64, 1), ov.counters.made);
612 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
613 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
614 }
615
616 test "never: nothing is made, so nothing can leak" {
617 const alloc = std.testing.allocator;
618 var ov = Overlay.init(alloc, 80, 24);
619 defer ov.deinit();
620 ov.setMode(.{ .icanon = true, .echo = false });
621
622 for ("hunter2") |ch| {
623 try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
624 }
625 // Both counters, deliberately: leg 3 of the criterion asserts made as
626 // well as displayed, because "made but hidden" in a password context
627 // would still put the password in a buffer the overlay paints from.
628 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
629 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
630 try std.testing.expectEqual(@as(u64, 7), ov.counters.suppressed);
631 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
632 }
633
634 test "a burst outruns the round trip without refuting itself" {
635 const alloc = std.testing.allocator;
636 var ov = Overlay.init(alloc, 80, 24);
637 defer ov.deinit();
638 ov.setMode(.{ .icanon = true, .echo = true });
639
640 // Three characters typed faster than the daemon can answer.
641 try std.testing.expect(typeAt(&ov, 0, 0, 'h') == .display);
642 try std.testing.expect(typeAt(&ov, 1, 0, 'e') == .display);
643 try std.testing.expect(typeAt(&ov, 2, 0, 'l') == .display);
644
645 // 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
647 // predicted, so this frame has said nothing about them. Reading that
648 // silence as disagreement is what used to flush the whole queue once
649 // per round trip, erasing prediction exactly when it was working.
650 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"h"}, 1));
651 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
652 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
653
654 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"he"}, 2));
655 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
656
657 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"hel"}, 3));
658 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
659
660 // Every one of them landed, and nothing was ever called wrong.
661 try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
662 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
663 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
664 }
665
666 test "predicting what is already on the screen is a no-op, and refused" {
667 const alloc = std.testing.allocator;
668 var ov = Overlay.init(alloc, 80, 24);
669 defer ov.deinit();
670 ov.setMode(.{ .icanon = true, .echo = true });
671
672 // Painting an 'a' over an 'a' changes nothing, and the prediction could
673 // never be judged: "still shows 'a'" would be both the confirmation and
674 // the no-evidence-yet answer, which are the two things reconcile exists
675 // to tell apart.
676 const out = ov.predictAt(.{
677 .cursor = .{ .x = 0, .y = 0 },
678 .ch = 'a',
679 .prev_ch = 'a',
680 .now_ms = 0,
681 });
682 try std.testing.expect(out == .suppressed);
683 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
684 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
685 }
686
687 test "adaptive earns the right to display, one confirm at a time" {
688 const alloc = std.testing.allocator;
689 var ov = Overlay.init(alloc, 80, 24);
690 defer ov.deinit();
691 ov.setMode(.{ .icanon = false, .echo = false });
692
693 // Written out with literal counts rather than a loop over promote_after:
694 // a loop parameterised by the constant moves its own goalposts when the
695 // constant is mutated, and would have passed at 1 and at 3 alike.
696 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
697 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
698
699 // Still hidden: one confirm is not two.
700 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
701 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
702
703 try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
704 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
705
706 // Two consecutive confirms, and the third keystroke paints.
707 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
708 try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
709 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
710 }
711
712 test "a path that answers faster than the eye can see earns display and still hides" {
713 const alloc = std.testing.allocator;
714 var ov = Overlay.init(alloc, 80, 24);
715 defer ov.deinit();
716 ov.setMode(.{ .icanon = false, .echo = false });
717
718 // Two confirms, each 1ms after its keystroke: a unix socket to a daemon
719 // on the same box. Confidence is earned exactly as over a WAN...
720 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
721 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
722 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
723 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 1));
724 try std.testing.expect(ov.confident);
725
726 // ...and the third keystroke is queued but NOT shown: a prediction the
727 // real glyph overtakes within a frame can only ever be seen when it is
728 // wrong. `displayed` stays at zero, which is what the repro counted.
729 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
730 try std.testing.expect(!ov.visible());
731 try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
732 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
733 try std.testing.expectEqual(@as(u64, 1), ov.counters.local);
734 }
735
736 test "the local gate has hysteresis, and an unmeasured path is not local" {
737 const alloc = std.testing.allocator;
738 var ov = Overlay.init(alloc, 80, 24);
739 defer ov.deinit();
740 ov.setMode(.{ .icanon = false, .echo = false });
741 // Before any confirm there is no measurement, and the WAN behaviour
742 // stands unchanged: earn display, show.
743 try std.testing.expect(!ov.local);
744 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
745 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 150));
746 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
747 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 150));
748 try std.testing.expect(ov.visible());
749 try std.testing.expectEqual(@as(i64, 150), ov.srtt_ms.?);
750
751 // One fast confirm does not flip it: the estimate is smoothed
752 // (150 - 150/8 = 132), and nothing above `local_below_ms` turns it off.
753 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
754 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"abc"}, 3, 0));
755 try std.testing.expectEqual(@as(i64, 132), ov.srtt_ms.?);
756 try std.testing.expect(ov.visible());
757
758 // A run of fast confirms does. The keystroke clock stays at 0 (typeAt),
759 // so each confirm at 0 is a 0ms round trip.
760 var col: u16 = 3;
761 var row = [_]u8{ 'a', 'b', 'c' } ++ [_]u8{' '} ** 60;
762 while (ov.visible()) : (col += 1) {
763 row[col] = 'x';
764 _ = typeAt(&ov, col, 0, 'x');
765 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 0));
766 }
767 try std.testing.expect(ov.local);
768 try std.testing.expect(ov.srtt_ms.? <= local_below_ms);
769
770 // Back above the upper trigger and it shows again; the band between the
771 // two is where a jittery path would otherwise flap every keystroke.
772 while (!ov.visible()) : (col += 1) {
773 row[col] = 'x';
774 _ = typeAt(&ov, col, 0, 'x');
775 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 400));
776 }
777 try std.testing.expect(ov.srtt_ms.? >= local_above_ms);
778 }
779
780 test "a mode change keeps the path estimate: the wire did not move" {
781 const alloc = std.testing.allocator;
782 var ov = Overlay.init(alloc, 80, 24);
783 defer ov.deinit();
784 ov.setMode(.{ .icanon = false, .echo = false });
785 _ = typeAt(&ov, 0, 0, 'a');
786 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
787 try std.testing.expect(ov.local);
788 // readline hands the tty back and forth around every command; the
789 // daemon is no further away afterwards.
790 ov.setMode(.{ .icanon = true, .echo = true });
791 try std.testing.expect(ov.local);
792 try std.testing.expect(!ov.visible());
793 }
794
795 test "a contradiction flushes the whole queue, not merely the cell that was wrong" {
796 const alloc = std.testing.allocator;
797 var ov = Overlay.init(alloc, 80, 24);
798 defer ov.deinit();
799 ov.setMode(.{ .icanon = true, .echo = true });
800
801 _ = typeAt(&ov, 0, 0, 'a');
802 _ = typeAt(&ov, 1, 0, 'b');
803 _ = typeAt(&ov, 2, 0, 'c');
804 try std.testing.expectEqual(@as(usize, 3), ov.pendingCount());
805
806 // Somebody else wrote the FIRST cell: it holds neither our guess nor
807 // the blank that was there, so this is a refutation and not silence.
808 // The two 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(
812 Verdict.contradicted,
813 try seeRows(alloc, &ov, &.{"xbc"}, 1),
814 );
815
816 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
817 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
818 // Zero, not two. An implementation that retired only the wrong cell and
819 // carried on judging would count the other two as confirmed, and would
820 // be claiming agreement about a screen it had already been told it was
821 // wrong about.
822 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
823 // A refutation is not an expiry, and the counters read these apart.
824 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
825 }
826
827 test "a prediction nothing ever answers is given up on, by frame count" {
828 const alloc = std.testing.allocator;
829 var ov = Overlay.init(alloc, 80, 24);
830 defer ov.deinit();
831 ov.setMode(.{ .icanon = true, .echo = true });
832
833 // The nvim case: a keystroke the application consumes silently. The
834 // cell it was predicted into never changes, so no frame ever confirms
835 // or refutes it, and the glyph we painted would stay there forever.
836 _ = typeAt(&ov, 0, 0, 'j');
837
838 // Seven frames of silence are patience, not evidence. Literal counts,
839 // for the same reason as promote_after: a loop over the constant would
840 // pass at any value of it.
841 var n: u64 = 1;
842 while (n <= 7) : (n += 1) {
843 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, n));
844 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
845 }
846
847 // The eighth is where patience runs out.
848 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{""}, 8));
849 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
850 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
851 // Counted as a contradiction too: evidentially that is what it is, and
852 // leg 2 of the criterion asks that no abandoned prediction outlive the
853 // frame that abandoned it, however it was abandoned.
854 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
855 }
856
857 test "a prediction nothing ever answers is given up on, by the clock" {
858 const alloc = std.testing.allocator;
859 var ov = Overlay.init(alloc, 80, 24);
860 defer ov.deinit();
861 ov.setMode(.{ .icanon = true, .echo = true });
862 _ = typeAt(&ov, 0, 0, 'j'); // made at now_ms = 0
863
864 // A frame short of the deadline leaves it alone...
865 try std.testing.expectEqual(
866 Verdict.none,
867 try seeRowsAt(alloc, &ov, &.{""}, 1, 999),
868 );
869 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
870
871 // ...and one at it does not. The clock comes from the caller, so this
872 // deadline is exercised without anything here reading a real one.
873 try std.testing.expectEqual(
874 Verdict.contradicted,
875 try seeRowsAt(alloc, &ov, &.{""}, 2, 1000),
876 );
877 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
878 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
879 }
880
881 test "expire gives up on a silent prediction with no frame to prompt it" {
882 const alloc = std.testing.allocator;
883 var ov = Overlay.init(alloc, 80, 24);
884 defer ov.deinit();
885 ov.setMode(.{ .icanon = true, .echo = true });
886 _ = typeAt(&ov, 0, 0, 'j');
887
888 // The frame bound cannot reach the case that motivates the guard: an
889 // application that swallows the keystroke and then says nothing at all
890 // produces no further frames, so reconcile is never called again. This
891 // is the client's idle path, and without it the phantom is permanent.
892 try std.testing.expectEqual(Verdict.none, ov.expire(999));
893 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
894
895 try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
896 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
897 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
898 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
899 }
900
901 test "a promotion mid-queue counts the predictions it makes visible, once each" {
902 const alloc = std.testing.allocator;
903 var ov = Overlay.init(alloc, 80, 24);
904 defer ov.deinit();
905 ov.setMode(.{ .icanon = false, .echo = false });
906
907 // One confirm banked, so the overlay is one short of promotion.
908 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
909 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
910
911 // Two more typed while still invisible — and the second confirmation
912 // arrives while the last of them is still outstanding.
913 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
914 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
915 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
916 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
917 try std.testing.expect(ov.confident);
918 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
919
920 // The next repaint draws 'c', which was queued invisible and has just
921 // become visible without anybody typing anything. It reached the screen,
922 // so it counts.
923 ov.markPainted(0);
924 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
925
926 // Every subsequent repaint redraws the same cell — a delta rewrites the
927 // row and the overlay is re-laid on top — and none of them is a second
928 // arrival on screen.
929 ov.markPainted(0);
930 ov.markPainted(0);
931 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
932 try std.testing.expect(ov.counters.displayed <= ov.counters.made);
933 }
934
935 test "a refusal the caller made on its own authority still counts" {
936 const alloc = std.testing.allocator;
937 var ov = Overlay.init(alloc, 80, 24);
938 defer ov.deinit();
939 ov.setMode(.{ .icanon = true, .echo = true });
940
941 // The client declines chunks the overlay never sees — a paste, whose
942 // lead byte is printable and would otherwise be predicted as though it
943 // were a keystroke. The decision is the client's; the count belongs
944 // with every other refusal, or the numbers disagree with the behaviour.
945 ov.recordSuppressed();
946 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
947 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
948 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
949
950 // And it is the same counter predictAt's own refusals land in.
951 try std.testing.expect(typeAt(&ov, 0, 0, 0x1b) == .suppressed);
952 try std.testing.expectEqual(@as(u64, 2), ov.counters.suppressed);
953 }
954
955 test "stale frames age nothing: only a frame that could have seen it counts" {
956 const alloc = std.testing.allocator;
957 var ov = Overlay.init(alloc, 80, 24);
958 defer ov.deinit();
959 ov.setMode(.{ .icanon = true, .echo = true });
960
961 ov.noteSeq(10);
962 _ = typeAt(&ov, 0, 0, 'j');
963
964 // Eight frames the daemon built before it could have seen the keystroke.
965 // The expiry bound counts evidence, and these are not evidence: age a
966 // prediction on frames that predate it and a busy session kills its own
967 // predictions faster the more output it produces.
968 var n: usize = 0;
969 while (n < 8) : (n += 1) {
970 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 10));
971 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
972 }
973 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
974
975 // The first frame that could have seen it is the first one that ages it,
976 // so the prediction is still alive here rather than eight frames stale.
977 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 11));
978 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
979 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
980 }
981
982 test "a flush costs the queue but never the confidence that was earned" {
983 const alloc = std.testing.allocator;
984 var ov = Overlay.init(alloc, 80, 24);
985 defer ov.deinit();
986 ov.setMode(.{ .icanon = false, .echo = false });
987
988 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
989 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
990 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
991 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
992 try std.testing.expect(ov.confident);
993 try std.testing.expectEqual(@as(u8, 2), ov.streak);
994
995 _ = typeAt(&ov, 2, 0, 'c');
996 ov.flush();
997
998 // The confirmations that earned display were real, and a resize is not
999 // evidence against them. Demote here and every window resize would cost
1000 // the next two keystrokes their visibility.
1001 try std.testing.expect(ov.confident);
1002 try std.testing.expectEqual(@as(u8, 2), ov.streak);
1003 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .display);
1004 }
1005
1006 test "every prediction is accounted for: made = confirmed + abandoned + pending" {
1007 const alloc = std.testing.allocator;
1008 var ov = Overlay.init(alloc, 80, 24);
1009 defer ov.deinit();
1010 ov.setMode(.{ .icanon = true, .echo = true });
1011
1012 // One confirmed...
1013 _ = typeAt(&ov, 0, 0, 'a');
1014 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1015
1016 // ...two thrown away by a refutation, which is ONE event...
1017 _ = typeAt(&ov, 1, 0, 'b');
1018 _ = typeAt(&ov, 2, 0, 'c');
1019 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"aZ"}, 2));
1020 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
1021 try std.testing.expectEqual(@as(u64, 2), ov.counters.abandoned);
1022
1023 // ...one thrown away by a flush, which is counted nowhere else...
1024 _ = typeAt(&ov, 1, 0, 'd');
1025 ov.flush();
1026 try std.testing.expectEqual(@as(u64, 3), ov.counters.abandoned);
1027 // ...and a flush is still not an accusation.
1028 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
1029
1030 // ...leaving one outstanding.
1031 _ = typeAt(&ov, 1, 0, 'e');
1032
1033 const c = ov.counters;
1034 try std.testing.expectEqual(@as(u64, 5), c.made);
1035 try std.testing.expectEqual(
1036 c.made,
1037 c.confirmed + c.abandoned + @as(u64, ov.pendingCount()),
1038 );
1039 }
1040
1041 test "an expiry demotes adaptive exactly as a refutation would" {
1042 const alloc = std.testing.allocator;
1043 var ov = Overlay.init(alloc, 80, 24);
1044 defer ov.deinit();
1045 ov.setMode(.{ .icanon = false, .echo = false });
1046
1047 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1048 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1049 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1050 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1051 try std.testing.expect(ov.confident);
1052
1053 // A keystroke the application swallows: the confidence that earned
1054 // display was evidence about a mode the application has left.
1055 _ = typeAt(&ov, 2, 0, 'c');
1056 try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
1057 try std.testing.expect(!ov.confident);
1058 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
1059 }
1060
1061 test "adaptive is demoted by one contradiction and must earn display again" {
1062 const alloc = std.testing.allocator;
1063 var ov = Overlay.init(alloc, 80, 24);
1064 defer ov.deinit();
1065 ov.setMode(.{ .icanon = false, .echo = false });
1066
1067 // Earn it.
1068 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1069 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1070 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1071 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1072 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
1073
1074 // Lose it: the application put its own content in the cell we drew in,
1075 // which is a refutation and not silence.
1076 try std.testing.expectEqual(
1077 Verdict.contradicted,
1078 try seeRows(alloc, &ov, &.{"abX"}, 3),
1079 );
1080 try std.testing.expect(!ov.confident);
1081
1082 // ...and the very next keystroke is invisible again. One contradicted
1083 // prediction, one demotion: that is leg 3 of the criterion.
1084 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
1085
1086 // Re-earning costs the FULL promote_after, not one confirm. The streak
1087 // that earned display the first time was reset by the demotion, so a
1088 // single confirmation must not hand it straight back — otherwise
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);
1094 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXd"}, 4));
1095 try std.testing.expect(!ov.confident);
1096 try std.testing.expect(typeAt(&ov, 4, 0, 'e') == .hidden);
1097
1098 // The second confirmation is the one that earns it back.
1099 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXde"}, 5));
1100 try std.testing.expect(ov.confident);
1101 try std.testing.expect(typeAt(&ov, 5, 0, 'f') == .display);
1102 }
1103
1104 test "always is never demoted: a contradiction costs the queue, not the policy" {
1105 const alloc = std.testing.allocator;
1106 var ov = Overlay.init(alloc, 80, 24);
1107 defer ov.deinit();
1108 ov.setMode(.{ .icanon = true, .echo = true });
1109
1110 _ = typeAt(&ov, 0, 0, 'a');
1111 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"z"}, 1));
1112 // In canonical echo the pty is going to print the character whatever we
1113 // do, so a disagreement means we mis-placed it, not that we should stop
1114 // predicting. Confidence here is not earned and cannot be lost.
1115 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1116 }
1117
1118 test "predictAt refuses everything the plan says it must" {
1119 const alloc = std.testing.allocator;
1120
1121 // Non-printables: control bytes carry meaning we cannot render, and the
1122 // high half is a multi-byte sequence whose width we do not know.
1123 for ([_]u8{ 0x00, 0x08, 0x09, 0x0a, 0x0d, 0x1b, 0x7f, 0x80, 0xc3, 0xff }) |ch| {
1124 var ov = Overlay.init(alloc, 80, 24);
1125 defer ov.deinit();
1126 ov.setMode(.{ .icanon = true, .echo = true });
1127 try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
1128 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
1129 }
1130
1131 // The last column: what happens there is the application's policy
1132 // (wrap, scroll, truncate, refuse) and we do not get to guess it.
1133 {
1134 var ov = Overlay.init(alloc, 80, 24);
1135 defer ov.deinit();
1136 ov.setMode(.{ .icanon = true, .echo = true });
1137 try std.testing.expect(typeAt(&ov, 78, 0, 'a') == .display);
1138 try std.testing.expect(typeAt(&ov, 79, 0, 'a') == .suppressed);
1139 }
1140
1141 // Off the grid entirely.
1142 {
1143 var ov = Overlay.init(alloc, 80, 24);
1144 defer ov.deinit();
1145 ov.setMode(.{ .icanon = true, .echo = true });
1146 try std.testing.expect(typeAt(&ov, 0, 24, 'a') == .suppressed);
1147 }
1148
1149 // Scrolled back: the cursor is not where the user is looking, so a
1150 // prediction painted at it would land in the middle of history.
1151 {
1152 var ov = Overlay.init(alloc, 80, 24);
1153 defer ov.deinit();
1154 ov.setMode(.{ .icanon = true, .echo = true });
1155 ov.setScrollMode(true);
1156 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1157 ov.setScrollMode(false);
1158 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1159 }
1160
1161 // A resize we have asked for but not yet been answered about: the grid
1162 // the prediction would be painted on is about to stop existing.
1163 {
1164 var ov = Overlay.init(alloc, 80, 24);
1165 defer ov.deinit();
1166 ov.setMode(.{ .icanon = true, .echo = true });
1167 ov.setResizePending(true);
1168 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1169 ov.setResizePending(false);
1170 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1171 }
1172 }
1173
1174 test "a frame that cannot have seen the keystroke does not get to judge it" {
1175 const alloc = std.testing.allocator;
1176 var ov = Overlay.init(alloc, 80, 24);
1177 defer ov.deinit();
1178 ov.setMode(.{ .icanon = true, .echo = true });
1179
1180 // The client is holding seq 7 when the key is pressed.
1181 ov.noteSeq(7);
1182 _ = typeAt(&ov, 0, 0, 'a');
1183
1184 // A frame numbered 7 is the one we already had. Even though it carries
1185 // content that WOULD refute the prediction, it is not evidence about a
1186 // keystroke made after it, and must not be read as any.
1187 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{"Z"}, 7));
1188 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
1189 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1190 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
1191
1192 // Seq 8 is the first frame the daemon could have built after seeing it.
1193 try std.testing.expectEqual(
1194 Verdict.contradicted,
1195 try seeRows(alloc, &ov, &.{"Z"}, 8),
1196 );
1197 }
1198
1199 test "flush drops predictions without calling any of them wrong" {
1200 const alloc = std.testing.allocator;
1201 var ov = Overlay.init(alloc, 80, 24);
1202 defer ov.deinit();
1203 ov.setMode(.{ .icanon = true, .echo = true });
1204
1205 _ = typeAt(&ov, 0, 0, 'a');
1206 _ = typeAt(&ov, 1, 0, 'b');
1207 ov.flush();
1208
1209 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1210 // A snapshot, a resize or a reconnect is not evidence that a prediction
1211 // was mistaken — it is evidence that we can no longer find out. Counting
1212 // it as a contradiction would make the demotion machinery fire on a
1213 // window resize.
1214 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1215 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
1216 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
1217 }
1218
1219 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.
1221 // `flush` empties the queue; it does not decide where the viewport is,
1222 // and it must not, because every snapshot flushes and a snapshot is not
1223 // a reason to leave history. The consequence is that leaving scroll mode
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;
1230 var ov = Overlay.init(alloc, 80, 24);
1231 defer ov.deinit();
1232 ov.setMode(.{ .icanon = true, .echo = true });
1233
1234 ov.setScrollMode(true);
1235 ov.flush();
1236 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1237
1238 // And the mirror: a flush does not switch it ON either, so an ordinary
1239 // reconnect on a live screen keeps predicting.
1240 ov.setScrollMode(false);
1241 ov.flush();
1242 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1243 }
1244
1245 test "the same mode bits again cost nothing" {
1246 const alloc = std.testing.allocator;
1247 var ov = Overlay.init(alloc, 80, 24);
1248 defer ov.deinit();
1249 ov.setMode(.{ .icanon = true, .echo = true });
1250 _ = typeAt(&ov, 0, 0, 'a');
1251 _ = typeAt(&ov, 1, 0, 'b');
1252
1253 // A reattach re-states the mode, and at an idle prompt the daemon's
1254 // poll re-reads bits that have not moved. Neither is a transition, and
1255 // treating them as one would throw away predictions that are still
1256 // perfectly good — on every reconnect.
1257 ov.setMode(.{ .icanon = true, .echo = true });
1258 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
1259 }
1260
1261 test "any move in the bits flushes and un-earns display, tier or no tier" {
1262 const alloc = std.testing.allocator;
1263 var ov = Overlay.init(alloc, 80, 24);
1264 defer ov.deinit();
1265
1266 // Into a password prompt with predictions outstanding: they go.
1267 ov.setMode(.{ .icanon = true, .echo = true });
1268 _ = typeAt(&ov, 0, 0, 'a');
1269 ov.setMode(.{ .icanon = true, .echo = false });
1270 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1271
1272 // And now the case a tier comparison would miss entirely. Both of these
1273 // are raw mode, so the policy tier does not change — but the
1274 // application has taken the terminal somewhere else, and predictions
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 });
1278 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1279 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1280 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1281 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1282 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
1283 try std.testing.expect(ov.confident);
1284
1285 // Raw to raw, one bit different.
1286 ov.setMode(.{ .icanon = false, .echo = true });
1287 try std.testing.expectEqual(Context.adaptive, ov.ctx);
1288 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1289 try std.testing.expect(!ov.confident);
1290 try std.testing.expectEqual(@as(u8, 0), ov.streak);
1291 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
1292
1293 // Flushing is not an accusation: nothing here was contradicted.
1294 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1295 }
1296
1297 test "entering raw mode starts unconfident however confident we just were" {
1298 const alloc = std.testing.allocator;
1299 var ov = Overlay.init(alloc, 80, 24);
1300 defer ov.deinit();
1301 ov.setMode(.{ .icanon = true, .echo = true });
1302 try std.testing.expect(ov.confident);
1303
1304 ov.setMode(.{ .icanon = false, .echo = false });
1305 try std.testing.expect(!ov.confident);
1306 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1307 }
1308
1309 test "a resize flushes and moves the edge the last column is measured from" {
1310 const alloc = std.testing.allocator;
1311 var ov = Overlay.init(alloc, 80, 24);
1312 defer ov.deinit();
1313 ov.setMode(.{ .icanon = true, .echo = true });
1314 _ = typeAt(&ov, 0, 0, 'a');
1315
1316 ov.setGrid(40, 12);
1317 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1318 try std.testing.expect(typeAt(&ov, 39, 0, 'a') == .suppressed);
1319 try std.testing.expect(typeAt(&ov, 38, 0, 'a') == .display);
1320
1321 // The same size again is not a resize and costs nothing.
1322 _ = typeAt(&ov, 0, 1, 'b');
1323 const before = ov.pendingCount();
1324 ov.setGrid(40, 12);
1325 try std.testing.expectEqual(before, ov.pendingCount());
1326 }
1327
1328 test "predictedCursor advances the base past everything pending" {
1329 const alloc = std.testing.allocator;
1330 var ov = Overlay.init(alloc, 80, 24);
1331 defer ov.deinit();
1332 ov.setMode(.{ .icanon = true, .echo = true });
1333
1334 // With nothing pending the cursor is the daemon's, untouched.
1335 try std.testing.expectEqual(
1336 CursorPos{ .x = 5, .y = 1 },
1337 ov.predictedCursor(.{ .x = 5, .y = 1 }),
1338 );
1339
1340 var cur = CursorPos{ .x = 5, .y = 1 };
1341 for ("abc") |ch| {
1342 _ = typeAt(&ov, cur.x, cur.y, ch);
1343 cur = ov.predictedCursor(.{ .x = 5, .y = 1 });
1344 }
1345 try std.testing.expectEqual(CursorPos{ .x = 8, .y = 1 }, cur);
1346
1347 // And it comes back to the authoritative cursor when the queue empties.
1348 ov.flush();
1349 try std.testing.expectEqual(
1350 CursorPos{ .x = 5, .y = 1 },
1351 ov.predictedCursor(.{ .x = 5, .y = 1 }),
1352 );
1353 }
1354
1355 test "a queued prediction owns its byte; the caller's buffer may be reused" {
1356 const alloc = std.testing.allocator;
1357 var ov = Overlay.init(alloc, 80, 24);
1358 defer ov.deinit();
1359 ov.setMode(.{ .icanon = true, .echo = true });
1360
1361 // Exactly the shape of the client's stdin path: bytes are read into a
1362 // buffer that is about to be read into again. Anything the overlay keeps
1363 // pointing at that buffer is the UAF-that-never-crashes.
1364 const src = try alloc.alloc(u8, 3);
1365 defer alloc.free(src);
1366 @memcpy(src, "abc");
1367 for (src, 0..) |ch, i| {
1368 _ = typeAt(&ov, @intCast(i), 0, ch);
1369 }
1370 @memset(src, 0xFF);
1371
1372 try std.testing.expectEqual(@as(u8, 'a'), ov.pendingAt(0).cell.ch);
1373 try std.testing.expectEqual(@as(u8, 'b'), ov.pendingAt(1).cell.ch);
1374 try std.testing.expectEqual(@as(u8, 'c'), ov.pendingAt(2).cell.ch);
1375
1376 // And the judgement is made against what was typed, not against
1377 // whatever the buffer holds by the time the frame comes back.
1378 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abc"}, 1));
1379 try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
1380 }
1381
1382 test "the queue survives its own growth" {
1383 const alloc = std.testing.allocator;
1384 var ov = Overlay.init(alloc, 80, 24);
1385 defer ov.deinit();
1386 ov.setMode(.{ .icanon = true, .echo = true });
1387
1388 // Well past any initial capacity, so the backing array is reallocated
1389 // several times underneath the predictions already in it.
1390 var i: u16 = 0;
1391 while (i < 70) : (i += 1) {
1392 const ch: u8 = 'a' + @as(u8, @intCast(i % 26));
1393 try std.testing.expect(typeAt(&ov, i, 0, ch) == .display);
1394 }
1395 try std.testing.expectEqual(@as(usize, 70), ov.pendingCount());
1396
1397 i = 0;
1398 while (i < 70) : (i += 1) {
1399 const want: u8 = 'a' + @as(u8, @intCast(i % 26));
1400 try std.testing.expectEqual(want, ov.pendingAt(i).cell.ch);
1401 try std.testing.expectEqual(i, ov.pendingAt(i).cell.col);
1402 }
1403 }
1404
1405 test "PlainGrid reads a cell out of a dump, and blanks where the dump stops" {
1406 const alloc = std.testing.allocator;
1407 const text = try plainOf(alloc, &.{ "ab", "cd" });
1408 defer alloc.free(text);
1409 const g = PlainGrid{ .text = text, .cols = 80 };
1410
1411 try std.testing.expectEqual(@as(?u8, 'a'), g.cellChar(0, 0));
1412 try std.testing.expectEqual(@as(?u8, 'b'), g.cellChar(0, 1));
1413 try std.testing.expectEqual(@as(?u8, 'c'), g.cellChar(1, 0));
1414 try std.testing.expectEqual(@as(?u8, 'd'), g.cellChar(1, 1));
1415
1416 // A dump carries no trailing blanks, so a cell past the end of a row —
1417 // or past the last row — is a blank cell, not a missing one.
1418 try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(0, 2));
1419 try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(9, 0));
1420
1421 // Outside the grid is a different answer: nothing to compare against.
1422 try std.testing.expectEqual(@as(?u8, null), g.cellChar(0, 80));
1423 }
1424
1425 // Forces semantic analysis of every pub decl under `zig build test`, so an
1426 // unreferenced decl must at least compile (the silent-module-loss hazard,
1427 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
1428 test {
1429 std.testing.refAllDeclsRecursive(@This());
1430 }
src/tui/interact.zig
Old New
@@ -23,13 +23,13 @@ const std = @import("std");
23 const Engine = @import("engine").Engine; 23 const Engine = @import("engine").Engine;
24 const Replica = @import("replica").Replica; 24 const Replica = @import("replica").Replica;
25 const proto = @import("protocol"); 25 const proto = @import("protocol");
26 const predict = @import("predict"); 26 const predict = @import("predict.zig");
27 const client_core = @import("client_core"); 27 const client_core = @import("client_core");
28 // Named `paint_mod` because paintOverlay holds a local ArrayList called 28 // Named `paint_mod` because paintOverlay holds a local ArrayList called
29 // `paint`, which a container-level `paint` would collide with. 29 // `paint`, which a container-level `paint` would collide with.
30 const paint_mod = @import("paint"); 30 const paint_mod = @import("paint.zig");
31 const askpass = @import("askpass"); 31 const askpass = @import("askpass");
32 const select = @import("select"); 32 const select = @import("select.zig");
33 // The command prefix byte. Owned by keymap because the dialler in client.zig 33 // The command prefix byte. Owned by keymap because the dialler in client.zig
34 // watches for it with no session and no terminal in the picture. 34 // watches for it with no session and no terminal in the picture.
35 const detach_key = @import("keymap").detach_key; 35 const detach_key = @import("keymap").detach_key;
src/tui/predict.zig
Old New
@@ -0,0 +1,1430 @@
1 //! Speculative local echo, as an OVERLAY: predictions live in a queue
2 //! beside the replica and never enter it (CLAUDE.md's "Prediction is an
3 //! overlay"), so a wrong guess costs a repaint, never a desync.
4 //!
5 //! Engine-free: `reconcile` takes its grid duck-typed, so the policy is
6 //! tested with no engine, pty or daemon.
7 //!
8 //! Judgment is about EVIDENCE, not arrival order: a frame showing the
9 //! predicted cell unchanged was probably built before the keystroke got
10 //! there, so the prediction stays pending, and only a cell that moved to
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");
23 const proto = @import("protocol");
24
25 /// One predicted character at one place on the grid. Printable ASCII only,
26 /// deliberately: `ch` is a byte by value, so there is nothing here that can
27 /// outlive what it was copied from.
28 pub const Cell = struct { row: u16, col: u16, ch: u8 };
29
30 pub const Pred = struct {
31 cell: Cell,
32 /// What the cell showed when the prediction was made. The load-bearing
33 /// field of reconcile v2: a cell that STILL shows this is a cell the
34 /// authoritative stream has said nothing about yet, which is a
35 /// different thing from one that disagrees with us.
36 prev_ch: u8,
37 /// The authoritative seq the client held when this was predicted. A
38 /// frame carrying a HIGHER seq is the first one that could possibly
39 /// have been built after the keystroke reached the daemon, and so the
40 /// first one entitled to have an opinion about it.
41 made_seq: u64,
42 /// Wall clock at prediction time, supplied by the caller — the module
43 /// never reads a clock, so every deadline in here is testable.
44 made_ms: i64,
45 /// Judging frames that have looked at this cell and found it unchanged.
46 /// Bounded by expire_after_frames, because "no evidence yet" must not
47 /// be a state a prediction can sit in forever.
48 frames: u8 = 0,
49 /// Whether this prediction has ever been drawn. Not the same question as
50 /// whether the overlay is confident now: one queued while unconfident
51 /// and painted later, after a promotion, has reached the screen exactly
52 /// once and must be counted exactly once.
53 painted: bool = false,
54 };
55
56 /// Units are mixed on purpose and stated on every field, because reading
57 /// these numbers as though they shared one denominator is how a measurement
58 /// goes wrong quietly: `contradicted` counts EVENTS while `made` counts
59 /// PREDICTIONS, so `confirmed + contradicted` is not a total of anything and
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 {
63 /// PER PREDICTION: queued, whether or not it was ever shown.
64 made: u64 = 0,
65 /// PER PREDICTION: ever reached the screen — counted the first time it
66 /// is painted, whether that was when it was made or later, when a
67 /// promotion mid-queue made an already-queued prediction visible.
68 /// Counted once however many repaints redraw the cell, and never
69 /// exceeds `made`.
70 displayed: u64 = 0,
71 /// PER PREDICTION: retired because the authoritative grid agreed.
72 confirmed: u64 = 0,
73 /// PER EVENT: one refutation or expiry, however many predictions it
74 /// discarded — a contradiction takes the whole queue, so this counts
75 /// how often we were wrong, not how much was thrown away.
76 contradicted: u64 = 0,
77 /// PER EVENT: the subset of `contradicted` where nothing ever answered
78 /// rather than something disagreed. Counted in both, so a reader can tell
79 /// "we guessed wrong" apart from "the application went quiet".
80 expired: u64 = 0,
81 /// PER PREDICTION: queued but discarded without a verdict — by a
82 /// contradiction, an expiry, or a flush. The flush case is counted
83 /// nowhere else, which is what used to leave predictions unaccounted
84 /// for: made, never confirmed, and no number saying where they went.
85 abandoned: u64 = 0,
86 /// PER INPUT: keystrokes declined for prediction, BY WHOEVER MADE THE
87 /// CALL. Most are refusals predictAt made on its own (a control byte, a
88 /// last column, a `.never` context), but the client increments this
89 /// directly for input it declines to offer at all — a paste, whose lead
90 /// byte is printable and would otherwise be predicted as though someone
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,
99 /// PER PREDICTION: queued with display earned, and hidden anyway
100 /// because the path was measured too fast to show it. A subset of
101 /// `made` and disjoint from `displayed`; the number that says whether
102 /// the local gate is what is keeping an overlay quiet.
103 local: u64 = 0,
104 };
105
106 /// What the pty's mode bits say about predicting here.
107 /// .always — icanon && echo: the line discipline is going to print the
108 /// character itself, so predicting it is deduction.
109 /// .never — icanon && !echo: a password prompt. Nothing is predicted,
110 /// so there is nothing to leak, hide, or get wrong.
111 /// .adaptive — !icanon: raw mode. The application decides what a
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 };
115
116 /// Consecutive confirmations that earn display in `.adaptive`.
117 pub const promote_after: u8 = 2;
118
119 /// How many judging frames a prediction may go unanswered before it is
120 /// given up on. The phantom-glyph guard: an application that consumes a
121 /// keystroke and repaints some OTHER row (nvim taking a `j` in normal mode)
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;
125
126 /// The same guard in wall time, for the case the frame bound cannot catch:
127 /// the application answers by going quiet. Milliseconds.
128 pub const expire_after_ms: i64 = 1000;
129
130 /// The round trip below which a prediction is never worth SHOWING. Over a
131 /// unix socket to a daemon on the same box a confirm lands in about a
132 /// millisecond, inside the frame the keystroke was painted in — so a
133 /// correct prediction is invisible by construction and the only ones the
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 ///
139 /// 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
141 /// on and off per keystroke. The 20/30 pair is mosh's, which has had its
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;
145 pub const local_above_ms: i64 = 30;
146
147 /// Engine-free mirror of the engine's cursor position.
148 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 };
149
150 /// A struct, not four positional arguments: `ch` and `prev_ch` are
151 /// adjacent bytes, so transposing them compiles silently and turns every
152 /// prediction into a no-op or a wrong guess.
153 pub const Keystroke = struct {
154 cursor: CursorPos,
155 /// The byte the user typed.
156 ch: u8,
157 /// What the client's replica shows at that cell right now. The caller
158 /// reads it; the overlay stays engine-free.
159 prev_ch: u8,
160 now_ms: i64,
161 };
162
163 pub const Outcome = union(enum) {
164 /// Refused. Nothing queued, nothing painted; the keystroke still goes
165 /// to the daemon exactly as it would have.
166 suppressed,
167 /// Queued, and to be painted at this cell now.
168 display: Cell,
169 /// Queued but deliberately invisible — adaptive mode gathering the
170 /// evidence that would let the next one be seen. The paint decision
171 /// arrives with the cell rather than being a separate question the
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,
175 };
176
177 pub const Verdict = enum {
178 /// Nothing pending was old enough to judge.
179 none,
180 /// At least one prediction was confirmed and retired; none were wrong.
181 confirmed,
182 /// One was wrong, so the queue is empty and the caller should repaint.
183 contradicted,
184 };
185
186 /// Reads cells out of a plain grid dump: rows joined by '\n', trailing
187 /// blanks absent, which is the shape `Engine.dumpPlain` produces. Holds a
188 /// borrowed slice and is meant to be built, used and dropped inside one
189 /// reconcile call — never stored.
190 pub const PlainGrid = struct {
191 text: []const u8,
192 cols: u16,
193
194 /// null means "outside the grid", not "blank": a dump carries no
195 /// trailing blanks, so past a row's end or the dump's end is blank. A
196 /// multi-byte cell answers with its lead byte, which can never equal a
197 /// predicted printable ASCII byte — so it contradicts, the safe way.
198 pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 {
199 if (col >= self.cols) return null;
200 var y: u16 = 0;
201 var it = std.mem.splitScalar(u8, self.text, '\n');
202 while (it.next()) |line| : (y += 1) {
203 if (y != row) continue;
204 if (col >= line.len) return ' ';
205 return line[col];
206 }
207 return ' ';
208 }
209 };
210
211 pub const Overlay = struct {
212 alloc: std.mem.Allocator,
213 pending: std.ArrayList(Pred) = .empty,
214 ctx: Context = .never,
215 /// What the POLICY says about painting: true in `.always`, false in
216 /// `.never`, and earned in `.adaptive`. Not the gate itself any more —
217 /// that is `visible`, which also asks whether the path is worth it.
218 confident: bool = false,
219 streak: u8 = 0,
220 /// Smoothed keystroke-to-confirm round trip, or null until the first
221 /// confirm has measured one. Counted from the keystroke's own clock to
222 /// the frame that confirmed it, so it is the whole path — tty read,
223 /// wire, daemon, pty, engine, wire back — and not a transport ping.
224 srtt_ms: ?i64 = null,
225 /// The path is too fast to show predictions on. Moves only when
226 /// `srtt_ms` crosses a trigger, so it carries the hysteresis; an
227 /// unmeasured path is not local, which keeps a fresh WAN attach on the
228 /// behaviour the numbers in decisions.md were measured against.
229 local: bool = false,
230 counters: Counters = .{},
231 cols: u16,
232 rows: u16,
233 scroll_mode: bool = false,
234 resize_pending: bool = false,
235 /// The last mode byte adopted, or null before any pty_mode frame has
236 /// arrived. The raw byte rather than the derived context, because the
237 /// churn rule is about the BITS moving: a change inside one tier is
238 /// still an application taking the terminal somewhere else.
239 mode_byte: ?u8 = null,
240 /// The last authoritative seq the client applied; stamped onto each new
241 /// prediction as `made_seq`.
242 last_seq: u64 = 0,
243
244 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) Overlay {
245 return .{ .alloc = alloc, .cols = cols, .rows = rows };
246 }
247
248 pub fn deinit(self: *Overlay) void {
249 self.pending.deinit(self.alloc);
250 }
251
252 /// Adopt what the daemon says the pty is doing. No frame ever having
253 /// arrived leaves the overlay at `.never`, which is the safe default an
254 /// old daemon gets for free.
255 ///
256 /// ANY change to the bits flushes the queue and un-earns display, even
257 /// one that lands in the same tier. Predictions in flight across a mode
258 /// transition are unverifiable — whatever the application did at the
259 /// moment it changed the line discipline is not something we predicted —
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 {
264 const byte: u8 = @bitCast(flags);
265 if (self.mode_byte) |prev| {
266 if (prev == byte) return;
267 }
268 self.mode_byte = byte;
269 const next: Context = blk: {
270 // A bit we do not understand means the byte describes a
271 // terminal we cannot reason about. Predict nothing rather than
272 // mask it off and carry on as though we had understood.
273 if (flags._pad != 0) break :blk .never;
274 if (!flags.icanon) break :blk .adaptive;
275 break :blk if (flags.echo) .always else .never;
276 };
277 self.ctx = next;
278 // Outstanding predictions were made under the old line discipline,
279 // and the new one may be that they should never have been visible.
280 self.flush();
281 self.streak = 0;
282 self.confident = (next == .always);
283 }
284
285 /// A resize invalidates every prediction on the old grid, and moves the
286 /// edge that the last-column refusal is measured against.
287 pub fn setGrid(self: *Overlay, cols: u16, rows: u16) void {
288 if (cols == self.cols and rows == self.rows) return;
289 self.cols = cols;
290 self.rows = rows;
291 self.flush();
292 }
293
294 pub fn setScrollMode(self: *Overlay, on: bool) void {
295 if (on == self.scroll_mode) return;
296 self.scroll_mode = on;
297 // Entering scroll mode the cursor stops being where the user is
298 // looking; leaving it, the whole viewport is repainted. Either way
299 // what is queued no longer describes the screen.
300 self.flush();
301 }
302
303 pub fn setResizePending(self: *Overlay, pending: bool) void {
304 if (pending == self.resize_pending) return;
305 self.resize_pending = pending;
306 if (pending) self.flush();
307 }
308
309 /// For a frame applied without judging it — a snapshot.
310 pub fn noteSeq(self: *Overlay, seq: u64) void {
311 self.last_seq = seq;
312 }
313
314 /// Speculate one printable byte at the cursor, or refuse to.
315 ///
316 /// Every refusal is a place where being wrong would cost more than
317 /// being slow: a control byte we cannot render, a multi-byte sequence
318 /// whose width we do not know, the last column (wrap is the
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 {
326 if (self.ctx == .never) return self.suppress();
327 if (self.scroll_mode or self.resize_pending) return self.suppress();
328 if (k.ch < 0x20 or k.ch >= 0x7f) return self.suppress();
329 if (self.cols == 0 or self.rows == 0) return self.suppress();
330 if (k.cursor.y >= self.rows) return self.suppress();
331 if (k.cursor.x >= self.cols -| 1) return self.suppress();
332 // The glyph is already there. Painting it changes nothing on screen,
333 // and queueing it would put a prediction into the queue that cannot
334 // be told apart from "no evidence yet" no matter what happens next.
335 if (k.ch == k.prev_ch) return self.suppress();
336
337 const cell: Cell = .{ .row = k.cursor.y, .col = k.cursor.x, .ch = k.ch };
338 self.pending.append(self.alloc, .{
339 .cell = cell,
340 .prev_ch = k.prev_ch,
341 .made_seq = self.last_seq,
342 .made_ms = k.now_ms,
343 }) catch return self.suppress();
344
345 self.counters.made += 1;
346 if (self.visible()) {
347 self.markPainted(self.pending.items.len - 1);
348 return .{ .display = cell };
349 }
350 // Queued even when local, deliberately: a hidden prediction is still
351 // judged, so it keeps measuring the path and keeps the streak. That
352 // is what lets the overlay come back on if the box gets slow, and
353 // what made the local-gate tests cheap — no second state machine.
354 if (self.confident) self.counters.local += 1;
355 return .{ .hidden = cell };
356 }
357
358 /// The gate on painting: earned, AND on a path slow enough to show it.
359 pub fn visible(self: *const Overlay) bool {
360 return self.confident and !self.local;
361 }
362
363 /// Fold one measured round trip into the estimate and move `local`
364 /// if it crossed a trigger. Confirms only — an expiry says nothing
365 /// about how fast the path is, only that the application went quiet.
366 fn samplePath(self: *Overlay, rtt_ms: i64) void {
367 const srtt = if (self.srtt_ms) |s| s + @divTrunc(rtt_ms - s, 8) else rtt_ms;
368 self.srtt_ms = srtt;
369 if (srtt <= local_below_ms) self.local = true;
370 if (srtt >= local_above_ms) self.local = false;
371 }
372
373 fn suppress(self: *Overlay) Outcome {
374 self.recordSuppressed();
375 return .suppressed;
376 }
377
378 /// A refusal the CALLER made: a plain-ASCII paste, whose lead byte is
379 /// printable.
380 pub fn recordSuppressed(self: *Overlay) void {
381 self.counters.suppressed += 1;
382 }
383
384 /// A prediction queued while unconfident is invisible; a later
385 /// promotion makes the next repaint draw it. Count it once, however
386 /// many repaints redraw the cell.
387 pub fn markPainted(self: *Overlay, i: usize) void {
388 if (self.pending.items[i].painted) return;
389 self.pending.items[i].painted = true;
390 self.counters.displayed += 1;
391 }
392
393 /// Judge everything the newly applied frame is entitled to judge.
394 ///
395 /// A confirmed prediction retires and lengthens the streak. A
396 /// contradicted one takes the WHOLE queue with it — mosh's epoch bump,
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 {
402 var verdict: Verdict = .none;
403 var i: usize = 0;
404 while (i < self.pending.items.len) {
405 const p = self.pending.items[i];
406 if (p.made_seq >= applied_seq) {
407 i += 1; // too new to be evidence about
408 continue;
409 }
410 const shown = reader.cellChar(p.cell.row, p.cell.col);
411
412 if (shown != null and shown.? == p.cell.ch) {
413 _ = self.pending.orderedRemove(i);
414 self.counters.confirmed += 1;
415 self.samplePath(now_ms -| p.made_ms);
416 self.streak +|= 1;
417 if (self.ctx == .adaptive and self.streak >= promote_after) {
418 self.confident = true;
419 }
420 verdict = .confirmed;
421 continue; // index i now holds the next prediction
422 }
423
424 if (shown != null and shown.? == p.prev_ch) {
425 // Silence, not disagreement. This frame was almost certainly
426 // built before the keystroke landed — which over any real
427 // path is the ordinary case for everything typed after the
428 // first character of a burst.
429 self.pending.items[i].frames +|= 1;
430 if (self.pending.items[i].frames >= expire_after_frames or
431 now_ms -| p.made_ms >= expire_after_ms)
432 {
433 self.counters.expired += 1;
434 self.noteSeq(applied_seq);
435 return self.abandonAll();
436 }
437 i += 1;
438 continue;
439 }
440
441 // The cell moved to something that is neither our guess nor what
442 // was there before: somebody else wrote it, and every prediction
443 // behind this one was made against a screen that never existed.
444 self.noteSeq(applied_seq);
445 return self.abandonAll();
446 }
447 self.noteSeq(applied_seq);
448 return verdict;
449 }
450
451 /// An app that swallows a keystroke then goes quiet produces no more
452 /// frames, so `reconcile` never runs again and the phantom sits there
453 /// forever. The client calls this from its idle path.
454 pub fn expire(self: *Overlay, now_ms: i64) Verdict {
455 for (self.pending.items) |p| {
456 if (now_ms -| p.made_ms < expire_after_ms) continue;
457 self.counters.expired += 1;
458 return self.abandonAll();
459 }
460 return .none;
461 }
462
463 /// Drop everything and stop trusting ourselves: the shared tail of a
464 /// contradiction and an expiry, which differ only in what made them
465 /// necessary. Callers repaint in full.
466 fn abandonAll(self: *Overlay) Verdict {
467 self.counters.contradicted += 1;
468 self.counters.abandoned += self.pending.items.len;
469 // Re-earning display costs the full promote_after again. Without
470 // this the streak survives the demotion, one confirm re-promotes,
471 // and leg 3 of the criterion — display stops until it is earned back
472 // — quietly becomes display stops for one keystroke.
473 self.streak = 0;
474 // Demotion is an adaptive-only idea. In canonical echo the pty is
475 // going to print the character whatever we believe, so a
476 // disagreement means we put it in the wrong place, not that we
477 // should stop predicting.
478 if (self.ctx == .adaptive) self.confident = false;
479 self.pending.clearRetainingCapacity();
480 return .contradicted;
481 }
482
483 /// Snapshot, resize, scroll mode, reconnect: nothing here is wrong, so
484 /// confidence and the streak survive.
485 pub fn flush(self: *Overlay) void {
486 self.counters.abandoned += self.pending.items.len;
487 self.pending.clearRetainingCapacity();
488 }
489
490 /// Where the cursor appears to be, given what is queued: the
491 /// authoritative position advanced past the last pending prediction.
492 /// With nothing pending it is the daemon's own answer, untouched.
493 pub fn predictedCursor(self: *const Overlay, base: CursorPos) CursorPos {
494 const last = self.pending.getLastOrNull() orelse return base;
495 return .{ .x = last.cell.col + 1, .y = last.cell.row };
496 }
497
498 pub fn pendingCount(self: *const Overlay) usize {
499 return self.pending.items.len;
500 }
501
502 /// By value: the queue reallocates, so a slice would go stale on the
503 /// next keystroke.
504 pub fn pendingAt(self: *const Overlay, i: usize) Pred {
505 return self.pending.items[i];
506 }
507 };
508
509 // ---- tests ------------------------------------------------------------
510
511 /// A grid row set built the way a plain dump arrives: rows joined by '\n',
512 /// trailing blanks absent. Caller frees.
513 fn plainOf(alloc: std.mem.Allocator, rows: []const []const u8) ![]u8 {
514 return std.mem.join(alloc, "\n", rows);
515 }
516
517 /// Type one character into a cell that is currently blank — the ordinary
518 /// case, and the one most of these tests are about.
519 fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome {
520 return ov.predictAt(.{
521 .cursor = .{ .x = x, .y = y },
522 .ch = ch,
523 .prev_ch = ' ',
524 .now_ms = 0,
525 });
526 }
527
528 /// A frame from a remote path. Tests about time or the gate use `seeRowsAt`.
529 fn seeRows(
530 alloc: std.mem.Allocator,
531 ov: *Overlay,
532 rows: []const []const u8,
533 seq: u64,
534 ) !Verdict {
535 // 100ms after the keystroke, not 0: at 0 every confirm is a measured
536 // 0ms round trip, the local gate closes, and every `.display` the
537 // policy tests assert goes `.hidden` for a reason they are not about.
538 return seeRowsAt(alloc, ov, rows, seq, 100);
539 }
540
541 fn seeRowsAt(
542 alloc: std.mem.Allocator,
543 ov: *Overlay,
544 rows: []const []const u8,
545 seq: u64,
546 now_ms: i64,
547 ) !Verdict {
548 const text = try plainOf(alloc, rows);
549 defer alloc.free(text);
550 return ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, seq, now_ms);
551 }
552
553 test "setMode maps the pty's two bits onto the three policies" {
554 const alloc = std.testing.allocator;
555 const cases = [_]struct { icanon: bool, echo: bool, want: Context }{
556 // Canonical and echoing: the tty itself will put the character on
557 // the screen, so predicting it is not a guess at all.
558 .{ .icanon = true, .echo = true, .want = .always },
559 // Canonical and silent: a password prompt. Nothing may be shown.
560 .{ .icanon = true, .echo = false, .want = .never },
561 // Raw: the application decides what a keystroke looks like, and the
562 // only way to find out is to be right about it repeatedly.
563 .{ .icanon = false, .echo = true, .want = .adaptive },
564 .{ .icanon = false, .echo = false, .want = .adaptive },
565 };
566 for (cases) |c| {
567 var ov = Overlay.init(alloc, 80, 24);
568 defer ov.deinit();
569 ov.setMode(.{ .icanon = c.icanon, .echo = c.echo });
570 try std.testing.expectEqual(c.want, ov.ctx);
571 }
572 }
573
574 test "a mode byte carrying a bit we do not understand predicts nothing" {
575 const alloc = std.testing.allocator;
576 var ov = Overlay.init(alloc, 80, 24);
577 defer ov.deinit();
578 ov.setMode(.{ .icanon = true, .echo = true });
579 try std.testing.expectEqual(Context.always, ov.ctx);
580
581 // A future daemon defines a third bit. Read as canonical-and-echoing
582 // with the extra bit masked away, this would keep predicting against a
583 // terminal whose description we have only partly understood.
584 ov.setMode(.{ .icanon = true, .echo = true, ._pad = 1 });
585 try std.testing.expectEqual(Context.never, ov.ctx);
586 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
587 }
588
589 test "an overlay predicts nothing until it has been told what the pty is" {
590 const alloc = std.testing.allocator;
591 var ov = Overlay.init(alloc, 80, 24);
592 defer ov.deinit();
593 // No pty_mode frame has arrived (or the daemon is too old to send one).
594 // No frame, no prediction: the safe direction is the default one.
595 try std.testing.expectEqual(Context.never, ov.ctx);
596 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
597 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
598 }
599
600 test "always: the first keystroke paints, with no evidence required" {
601 const alloc = std.testing.allocator;
602 var ov = Overlay.init(alloc, 80, 24);
603 defer ov.deinit();
604 ov.setMode(.{ .icanon = true, .echo = true });
605
606 const out = typeAt(&ov, 3, 2, 'k');
607 try std.testing.expect(out == .display);
608 try std.testing.expectEqual(@as(u16, 3), out.display.col);
609 try std.testing.expectEqual(@as(u16, 2), out.display.row);
610 try std.testing.expectEqual(@as(u8, 'k'), out.display.ch);
611 try std.testing.expectEqual(@as(u64, 1), ov.counters.made);
612 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
613 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
614 }
615
616 test "never: nothing is made, so nothing can leak" {
617 const alloc = std.testing.allocator;
618 var ov = Overlay.init(alloc, 80, 24);
619 defer ov.deinit();
620 ov.setMode(.{ .icanon = true, .echo = false });
621
622 for ("hunter2") |ch| {
623 try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
624 }
625 // Both counters, deliberately: leg 3 of the criterion asserts made as
626 // well as displayed, because "made but hidden" in a password context
627 // would still put the password in a buffer the overlay paints from.
628 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
629 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
630 try std.testing.expectEqual(@as(u64, 7), ov.counters.suppressed);
631 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
632 }
633
634 test "a burst outruns the round trip without refuting itself" {
635 const alloc = std.testing.allocator;
636 var ov = Overlay.init(alloc, 80, 24);
637 defer ov.deinit();
638 ov.setMode(.{ .icanon = true, .echo = true });
639
640 // Three characters typed faster than the daemon can answer.
641 try std.testing.expect(typeAt(&ov, 0, 0, 'h') == .display);
642 try std.testing.expect(typeAt(&ov, 1, 0, 'e') == .display);
643 try std.testing.expect(typeAt(&ov, 2, 0, 'l') == .display);
644
645 // 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
647 // predicted, so this frame has said nothing about them. Reading that
648 // silence as disagreement is what used to flush the whole queue once
649 // per round trip, erasing prediction exactly when it was working.
650 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"h"}, 1));
651 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
652 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
653
654 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"he"}, 2));
655 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
656
657 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"hel"}, 3));
658 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
659
660 // Every one of them landed, and nothing was ever called wrong.
661 try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
662 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
663 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
664 }
665
666 test "predicting what is already on the screen is a no-op, and refused" {
667 const alloc = std.testing.allocator;
668 var ov = Overlay.init(alloc, 80, 24);
669 defer ov.deinit();
670 ov.setMode(.{ .icanon = true, .echo = true });
671
672 // Painting an 'a' over an 'a' changes nothing, and the prediction could
673 // never be judged: "still shows 'a'" would be both the confirmation and
674 // the no-evidence-yet answer, which are the two things reconcile exists
675 // to tell apart.
676 const out = ov.predictAt(.{
677 .cursor = .{ .x = 0, .y = 0 },
678 .ch = 'a',
679 .prev_ch = 'a',
680 .now_ms = 0,
681 });
682 try std.testing.expect(out == .suppressed);
683 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
684 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
685 }
686
687 test "adaptive earns the right to display, one confirm at a time" {
688 const alloc = std.testing.allocator;
689 var ov = Overlay.init(alloc, 80, 24);
690 defer ov.deinit();
691 ov.setMode(.{ .icanon = false, .echo = false });
692
693 // Written out with literal counts rather than a loop over promote_after:
694 // a loop parameterised by the constant moves its own goalposts when the
695 // constant is mutated, and would have passed at 1 and at 3 alike.
696 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
697 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
698
699 // Still hidden: one confirm is not two.
700 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
701 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
702
703 try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
704 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
705
706 // Two consecutive confirms, and the third keystroke paints.
707 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
708 try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
709 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
710 }
711
712 test "a path that answers faster than the eye can see earns display and still hides" {
713 const alloc = std.testing.allocator;
714 var ov = Overlay.init(alloc, 80, 24);
715 defer ov.deinit();
716 ov.setMode(.{ .icanon = false, .echo = false });
717
718 // Two confirms, each 1ms after its keystroke: a unix socket to a daemon
719 // on the same box. Confidence is earned exactly as over a WAN...
720 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
721 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
722 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
723 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 1));
724 try std.testing.expect(ov.confident);
725
726 // ...and the third keystroke is queued but NOT shown: a prediction the
727 // real glyph overtakes within a frame can only ever be seen when it is
728 // wrong. `displayed` stays at zero, which is what the repro counted.
729 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
730 try std.testing.expect(!ov.visible());
731 try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
732 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
733 try std.testing.expectEqual(@as(u64, 1), ov.counters.local);
734 }
735
736 test "the local gate has hysteresis, and an unmeasured path is not local" {
737 const alloc = std.testing.allocator;
738 var ov = Overlay.init(alloc, 80, 24);
739 defer ov.deinit();
740 ov.setMode(.{ .icanon = false, .echo = false });
741 // Before any confirm there is no measurement, and the WAN behaviour
742 // stands unchanged: earn display, show.
743 try std.testing.expect(!ov.local);
744 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
745 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 150));
746 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
747 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 150));
748 try std.testing.expect(ov.visible());
749 try std.testing.expectEqual(@as(i64, 150), ov.srtt_ms.?);
750
751 // One fast confirm does not flip it: the estimate is smoothed
752 // (150 - 150/8 = 132), and nothing above `local_below_ms` turns it off.
753 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
754 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"abc"}, 3, 0));
755 try std.testing.expectEqual(@as(i64, 132), ov.srtt_ms.?);
756 try std.testing.expect(ov.visible());
757
758 // A run of fast confirms does. The keystroke clock stays at 0 (typeAt),
759 // so each confirm at 0 is a 0ms round trip.
760 var col: u16 = 3;
761 var row = [_]u8{ 'a', 'b', 'c' } ++ [_]u8{' '} ** 60;
762 while (ov.visible()) : (col += 1) {
763 row[col] = 'x';
764 _ = typeAt(&ov, col, 0, 'x');
765 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 0));
766 }
767 try std.testing.expect(ov.local);
768 try std.testing.expect(ov.srtt_ms.? <= local_below_ms);
769
770 // Back above the upper trigger and it shows again; the band between the
771 // two is where a jittery path would otherwise flap every keystroke.
772 while (!ov.visible()) : (col += 1) {
773 row[col] = 'x';
774 _ = typeAt(&ov, col, 0, 'x');
775 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 400));
776 }
777 try std.testing.expect(ov.srtt_ms.? >= local_above_ms);
778 }
779
780 test "a mode change keeps the path estimate: the wire did not move" {
781 const alloc = std.testing.allocator;
782 var ov = Overlay.init(alloc, 80, 24);
783 defer ov.deinit();
784 ov.setMode(.{ .icanon = false, .echo = false });
785 _ = typeAt(&ov, 0, 0, 'a');
786 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
787 try std.testing.expect(ov.local);
788 // readline hands the tty back and forth around every command; the
789 // daemon is no further away afterwards.
790 ov.setMode(.{ .icanon = true, .echo = true });
791 try std.testing.expect(ov.local);
792 try std.testing.expect(!ov.visible());
793 }
794
795 test "a contradiction flushes the whole queue, not merely the cell that was wrong" {
796 const alloc = std.testing.allocator;
797 var ov = Overlay.init(alloc, 80, 24);
798 defer ov.deinit();
799 ov.setMode(.{ .icanon = true, .echo = true });
800
801 _ = typeAt(&ov, 0, 0, 'a');
802 _ = typeAt(&ov, 1, 0, 'b');
803 _ = typeAt(&ov, 2, 0, 'c');
804 try std.testing.expectEqual(@as(usize, 3), ov.pendingCount());
805
806 // Somebody else wrote the FIRST cell: it holds neither our guess nor
807 // the blank that was there, so this is a refutation and not silence.
808 // The two 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(
812 Verdict.contradicted,
813 try seeRows(alloc, &ov, &.{"xbc"}, 1),
814 );
815
816 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
817 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
818 // Zero, not two. An implementation that retired only the wrong cell and
819 // carried on judging would count the other two as confirmed, and would
820 // be claiming agreement about a screen it had already been told it was
821 // wrong about.
822 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
823 // A refutation is not an expiry, and the counters read these apart.
824 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
825 }
826
827 test "a prediction nothing ever answers is given up on, by frame count" {
828 const alloc = std.testing.allocator;
829 var ov = Overlay.init(alloc, 80, 24);
830 defer ov.deinit();
831 ov.setMode(.{ .icanon = true, .echo = true });
832
833 // The nvim case: a keystroke the application consumes silently. The
834 // cell it was predicted into never changes, so no frame ever confirms
835 // or refutes it, and the glyph we painted would stay there forever.
836 _ = typeAt(&ov, 0, 0, 'j');
837
838 // Seven frames of silence are patience, not evidence. Literal counts,
839 // for the same reason as promote_after: a loop over the constant would
840 // pass at any value of it.
841 var n: u64 = 1;
842 while (n <= 7) : (n += 1) {
843 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, n));
844 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
845 }
846
847 // The eighth is where patience runs out.
848 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{""}, 8));
849 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
850 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
851 // Counted as a contradiction too: evidentially that is what it is, and
852 // leg 2 of the criterion asks that no abandoned prediction outlive the
853 // frame that abandoned it, however it was abandoned.
854 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
855 }
856
857 test "a prediction nothing ever answers is given up on, by the clock" {
858 const alloc = std.testing.allocator;
859 var ov = Overlay.init(alloc, 80, 24);
860 defer ov.deinit();
861 ov.setMode(.{ .icanon = true, .echo = true });
862 _ = typeAt(&ov, 0, 0, 'j'); // made at now_ms = 0
863
864 // A frame short of the deadline leaves it alone...
865 try std.testing.expectEqual(
866 Verdict.none,
867 try seeRowsAt(alloc, &ov, &.{""}, 1, 999),
868 );
869 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
870
871 // ...and one at it does not. The clock comes from the caller, so this
872 // deadline is exercised without anything here reading a real one.
873 try std.testing.expectEqual(
874 Verdict.contradicted,
875 try seeRowsAt(alloc, &ov, &.{""}, 2, 1000),
876 );
877 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
878 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
879 }
880
881 test "expire gives up on a silent prediction with no frame to prompt it" {
882 const alloc = std.testing.allocator;
883 var ov = Overlay.init(alloc, 80, 24);
884 defer ov.deinit();
885 ov.setMode(.{ .icanon = true, .echo = true });
886 _ = typeAt(&ov, 0, 0, 'j');
887
888 // The frame bound cannot reach the case that motivates the guard: an
889 // application that swallows the keystroke and then says nothing at all
890 // produces no further frames, so reconcile is never called again. This
891 // is the client's idle path, and without it the phantom is permanent.
892 try std.testing.expectEqual(Verdict.none, ov.expire(999));
893 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
894
895 try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
896 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
897 try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
898 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
899 }
900
901 test "a promotion mid-queue counts the predictions it makes visible, once each" {
902 const alloc = std.testing.allocator;
903 var ov = Overlay.init(alloc, 80, 24);
904 defer ov.deinit();
905 ov.setMode(.{ .icanon = false, .echo = false });
906
907 // One confirm banked, so the overlay is one short of promotion.
908 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
909 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
910
911 // Two more typed while still invisible — and the second confirmation
912 // arrives while the last of them is still outstanding.
913 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
914 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
915 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
916 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
917 try std.testing.expect(ov.confident);
918 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
919
920 // The next repaint draws 'c', which was queued invisible and has just
921 // become visible without anybody typing anything. It reached the screen,
922 // so it counts.
923 ov.markPainted(0);
924 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
925
926 // Every subsequent repaint redraws the same cell — a delta rewrites the
927 // row and the overlay is re-laid on top — and none of them is a second
928 // arrival on screen.
929 ov.markPainted(0);
930 ov.markPainted(0);
931 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
932 try std.testing.expect(ov.counters.displayed <= ov.counters.made);
933 }
934
935 test "a refusal the caller made on its own authority still counts" {
936 const alloc = std.testing.allocator;
937 var ov = Overlay.init(alloc, 80, 24);
938 defer ov.deinit();
939 ov.setMode(.{ .icanon = true, .echo = true });
940
941 // The client declines chunks the overlay never sees — a paste, whose
942 // lead byte is printable and would otherwise be predicted as though it
943 // were a keystroke. The decision is the client's; the count belongs
944 // with every other refusal, or the numbers disagree with the behaviour.
945 ov.recordSuppressed();
946 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
947 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
948 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
949
950 // And it is the same counter predictAt's own refusals land in.
951 try std.testing.expect(typeAt(&ov, 0, 0, 0x1b) == .suppressed);
952 try std.testing.expectEqual(@as(u64, 2), ov.counters.suppressed);
953 }
954
955 test "stale frames age nothing: only a frame that could have seen it counts" {
956 const alloc = std.testing.allocator;
957 var ov = Overlay.init(alloc, 80, 24);
958 defer ov.deinit();
959 ov.setMode(.{ .icanon = true, .echo = true });
960
961 ov.noteSeq(10);
962 _ = typeAt(&ov, 0, 0, 'j');
963
964 // Eight frames the daemon built before it could have seen the keystroke.
965 // The expiry bound counts evidence, and these are not evidence: age a
966 // prediction on frames that predate it and a busy session kills its own
967 // predictions faster the more output it produces.
968 var n: usize = 0;
969 while (n < 8) : (n += 1) {
970 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 10));
971 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
972 }
973 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
974
975 // The first frame that could have seen it is the first one that ages it,
976 // so the prediction is still alive here rather than eight frames stale.
977 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 11));
978 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
979 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
980 }
981
982 test "a flush costs the queue but never the confidence that was earned" {
983 const alloc = std.testing.allocator;
984 var ov = Overlay.init(alloc, 80, 24);
985 defer ov.deinit();
986 ov.setMode(.{ .icanon = false, .echo = false });
987
988 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
989 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
990 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
991 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
992 try std.testing.expect(ov.confident);
993 try std.testing.expectEqual(@as(u8, 2), ov.streak);
994
995 _ = typeAt(&ov, 2, 0, 'c');
996 ov.flush();
997
998 // The confirmations that earned display were real, and a resize is not
999 // evidence against them. Demote here and every window resize would cost
1000 // the next two keystrokes their visibility.
1001 try std.testing.expect(ov.confident);
1002 try std.testing.expectEqual(@as(u8, 2), ov.streak);
1003 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .display);
1004 }
1005
1006 test "every prediction is accounted for: made = confirmed + abandoned + pending" {
1007 const alloc = std.testing.allocator;
1008 var ov = Overlay.init(alloc, 80, 24);
1009 defer ov.deinit();
1010 ov.setMode(.{ .icanon = true, .echo = true });
1011
1012 // One confirmed...
1013 _ = typeAt(&ov, 0, 0, 'a');
1014 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1015
1016 // ...two thrown away by a refutation, which is ONE event...
1017 _ = typeAt(&ov, 1, 0, 'b');
1018 _ = typeAt(&ov, 2, 0, 'c');
1019 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"aZ"}, 2));
1020 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
1021 try std.testing.expectEqual(@as(u64, 2), ov.counters.abandoned);
1022
1023 // ...one thrown away by a flush, which is counted nowhere else...
1024 _ = typeAt(&ov, 1, 0, 'd');
1025 ov.flush();
1026 try std.testing.expectEqual(@as(u64, 3), ov.counters.abandoned);
1027 // ...and a flush is still not an accusation.
1028 try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
1029
1030 // ...leaving one outstanding.
1031 _ = typeAt(&ov, 1, 0, 'e');
1032
1033 const c = ov.counters;
1034 try std.testing.expectEqual(@as(u64, 5), c.made);
1035 try std.testing.expectEqual(
1036 c.made,
1037 c.confirmed + c.abandoned + @as(u64, ov.pendingCount()),
1038 );
1039 }
1040
1041 test "an expiry demotes adaptive exactly as a refutation would" {
1042 const alloc = std.testing.allocator;
1043 var ov = Overlay.init(alloc, 80, 24);
1044 defer ov.deinit();
1045 ov.setMode(.{ .icanon = false, .echo = false });
1046
1047 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1048 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1049 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1050 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1051 try std.testing.expect(ov.confident);
1052
1053 // A keystroke the application swallows: the confidence that earned
1054 // display was evidence about a mode the application has left.
1055 _ = typeAt(&ov, 2, 0, 'c');
1056 try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
1057 try std.testing.expect(!ov.confident);
1058 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
1059 }
1060
1061 test "adaptive is demoted by one contradiction and must earn display again" {
1062 const alloc = std.testing.allocator;
1063 var ov = Overlay.init(alloc, 80, 24);
1064 defer ov.deinit();
1065 ov.setMode(.{ .icanon = false, .echo = false });
1066
1067 // Earn it.
1068 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1069 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1070 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1071 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1072 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
1073
1074 // Lose it: the application put its own content in the cell we drew in,
1075 // which is a refutation and not silence.
1076 try std.testing.expectEqual(
1077 Verdict.contradicted,
1078 try seeRows(alloc, &ov, &.{"abX"}, 3),
1079 );
1080 try std.testing.expect(!ov.confident);
1081
1082 // ...and the very next keystroke is invisible again. One contradicted
1083 // prediction, one demotion: that is leg 3 of the criterion.
1084 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
1085
1086 // Re-earning costs the FULL promote_after, not one confirm. The streak
1087 // that earned display the first time was reset by the demotion, so a
1088 // single confirmation must not hand it straight back — otherwise
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);
1094 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXd"}, 4));
1095 try std.testing.expect(!ov.confident);
1096 try std.testing.expect(typeAt(&ov, 4, 0, 'e') == .hidden);
1097
1098 // The second confirmation is the one that earns it back.
1099 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXde"}, 5));
1100 try std.testing.expect(ov.confident);
1101 try std.testing.expect(typeAt(&ov, 5, 0, 'f') == .display);
1102 }
1103
1104 test "always is never demoted: a contradiction costs the queue, not the policy" {
1105 const alloc = std.testing.allocator;
1106 var ov = Overlay.init(alloc, 80, 24);
1107 defer ov.deinit();
1108 ov.setMode(.{ .icanon = true, .echo = true });
1109
1110 _ = typeAt(&ov, 0, 0, 'a');
1111 try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"z"}, 1));
1112 // In canonical echo the pty is going to print the character whatever we
1113 // do, so a disagreement means we mis-placed it, not that we should stop
1114 // predicting. Confidence here is not earned and cannot be lost.
1115 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1116 }
1117
1118 test "predictAt refuses everything the plan says it must" {
1119 const alloc = std.testing.allocator;
1120
1121 // Non-printables: control bytes carry meaning we cannot render, and the
1122 // high half is a multi-byte sequence whose width we do not know.
1123 for ([_]u8{ 0x00, 0x08, 0x09, 0x0a, 0x0d, 0x1b, 0x7f, 0x80, 0xc3, 0xff }) |ch| {
1124 var ov = Overlay.init(alloc, 80, 24);
1125 defer ov.deinit();
1126 ov.setMode(.{ .icanon = true, .echo = true });
1127 try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
1128 try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
1129 }
1130
1131 // The last column: what happens there is the application's policy
1132 // (wrap, scroll, truncate, refuse) and we do not get to guess it.
1133 {
1134 var ov = Overlay.init(alloc, 80, 24);
1135 defer ov.deinit();
1136 ov.setMode(.{ .icanon = true, .echo = true });
1137 try std.testing.expect(typeAt(&ov, 78, 0, 'a') == .display);
1138 try std.testing.expect(typeAt(&ov, 79, 0, 'a') == .suppressed);
1139 }
1140
1141 // Off the grid entirely.
1142 {
1143 var ov = Overlay.init(alloc, 80, 24);
1144 defer ov.deinit();
1145 ov.setMode(.{ .icanon = true, .echo = true });
1146 try std.testing.expect(typeAt(&ov, 0, 24, 'a') == .suppressed);
1147 }
1148
1149 // Scrolled back: the cursor is not where the user is looking, so a
1150 // prediction painted at it would land in the middle of history.
1151 {
1152 var ov = Overlay.init(alloc, 80, 24);
1153 defer ov.deinit();
1154 ov.setMode(.{ .icanon = true, .echo = true });
1155 ov.setScrollMode(true);
1156 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1157 ov.setScrollMode(false);
1158 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1159 }
1160
1161 // A resize we have asked for but not yet been answered about: the grid
1162 // the prediction would be painted on is about to stop existing.
1163 {
1164 var ov = Overlay.init(alloc, 80, 24);
1165 defer ov.deinit();
1166 ov.setMode(.{ .icanon = true, .echo = true });
1167 ov.setResizePending(true);
1168 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1169 ov.setResizePending(false);
1170 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1171 }
1172 }
1173
1174 test "a frame that cannot have seen the keystroke does not get to judge it" {
1175 const alloc = std.testing.allocator;
1176 var ov = Overlay.init(alloc, 80, 24);
1177 defer ov.deinit();
1178 ov.setMode(.{ .icanon = true, .echo = true });
1179
1180 // The client is holding seq 7 when the key is pressed.
1181 ov.noteSeq(7);
1182 _ = typeAt(&ov, 0, 0, 'a');
1183
1184 // A frame numbered 7 is the one we already had. Even though it carries
1185 // content that WOULD refute the prediction, it is not evidence about a
1186 // keystroke made after it, and must not be read as any.
1187 try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{"Z"}, 7));
1188 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
1189 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1190 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
1191
1192 // Seq 8 is the first frame the daemon could have built after seeing it.
1193 try std.testing.expectEqual(
1194 Verdict.contradicted,
1195 try seeRows(alloc, &ov, &.{"Z"}, 8),
1196 );
1197 }
1198
1199 test "flush drops predictions without calling any of them wrong" {
1200 const alloc = std.testing.allocator;
1201 var ov = Overlay.init(alloc, 80, 24);
1202 defer ov.deinit();
1203 ov.setMode(.{ .icanon = true, .echo = true });
1204
1205 _ = typeAt(&ov, 0, 0, 'a');
1206 _ = typeAt(&ov, 1, 0, 'b');
1207 ov.flush();
1208
1209 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1210 // A snapshot, a resize or a reconnect is not evidence that a prediction
1211 // was mistaken — it is evidence that we can no longer find out. Counting
1212 // it as a contradiction would make the demotion machinery fire on a
1213 // window resize.
1214 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1215 try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
1216 try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
1217 }
1218
1219 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.
1221 // `flush` empties the queue; it does not decide where the viewport is,
1222 // and it must not, because every snapshot flushes and a snapshot is not
1223 // a reason to leave history. The consequence is that leaving scroll mode
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;
1230 var ov = Overlay.init(alloc, 80, 24);
1231 defer ov.deinit();
1232 ov.setMode(.{ .icanon = true, .echo = true });
1233
1234 ov.setScrollMode(true);
1235 ov.flush();
1236 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
1237
1238 // And the mirror: a flush does not switch it ON either, so an ordinary
1239 // reconnect on a live screen keeps predicting.
1240 ov.setScrollMode(false);
1241 ov.flush();
1242 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
1243 }
1244
1245 test "the same mode bits again cost nothing" {
1246 const alloc = std.testing.allocator;
1247 var ov = Overlay.init(alloc, 80, 24);
1248 defer ov.deinit();
1249 ov.setMode(.{ .icanon = true, .echo = true });
1250 _ = typeAt(&ov, 0, 0, 'a');
1251 _ = typeAt(&ov, 1, 0, 'b');
1252
1253 // A reattach re-states the mode, and at an idle prompt the daemon's
1254 // poll re-reads bits that have not moved. Neither is a transition, and
1255 // treating them as one would throw away predictions that are still
1256 // perfectly good — on every reconnect.
1257 ov.setMode(.{ .icanon = true, .echo = true });
1258 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
1259 }
1260
1261 test "any move in the bits flushes and un-earns display, tier or no tier" {
1262 const alloc = std.testing.allocator;
1263 var ov = Overlay.init(alloc, 80, 24);
1264 defer ov.deinit();
1265
1266 // Into a password prompt with predictions outstanding: they go.
1267 ov.setMode(.{ .icanon = true, .echo = true });
1268 _ = typeAt(&ov, 0, 0, 'a');
1269 ov.setMode(.{ .icanon = true, .echo = false });
1270 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1271
1272 // And now the case a tier comparison would miss entirely. Both of these
1273 // are raw mode, so the policy tier does not change — but the
1274 // application has taken the terminal somewhere else, and predictions
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 });
1278 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1279 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
1280 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
1281 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
1282 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
1283 try std.testing.expect(ov.confident);
1284
1285 // Raw to raw, one bit different.
1286 ov.setMode(.{ .icanon = false, .echo = true });
1287 try std.testing.expectEqual(Context.adaptive, ov.ctx);
1288 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1289 try std.testing.expect(!ov.confident);
1290 try std.testing.expectEqual(@as(u8, 0), ov.streak);
1291 try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
1292
1293 // Flushing is not an accusation: nothing here was contradicted.
1294 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1295 }
1296
1297 test "entering raw mode starts unconfident however confident we just were" {
1298 const alloc = std.testing.allocator;
1299 var ov = Overlay.init(alloc, 80, 24);
1300 defer ov.deinit();
1301 ov.setMode(.{ .icanon = true, .echo = true });
1302 try std.testing.expect(ov.confident);
1303
1304 ov.setMode(.{ .icanon = false, .echo = false });
1305 try std.testing.expect(!ov.confident);
1306 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
1307 }
1308
1309 test "a resize flushes and moves the edge the last column is measured from" {
1310 const alloc = std.testing.allocator;
1311 var ov = Overlay.init(alloc, 80, 24);
1312 defer ov.deinit();
1313 ov.setMode(.{ .icanon = true, .echo = true });
1314 _ = typeAt(&ov, 0, 0, 'a');
1315
1316 ov.setGrid(40, 12);
1317 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1318 try std.testing.expect(typeAt(&ov, 39, 0, 'a') == .suppressed);
1319 try std.testing.expect(typeAt(&ov, 38, 0, 'a') == .display);
1320
1321 // The same size again is not a resize and costs nothing.
1322 _ = typeAt(&ov, 0, 1, 'b');
1323 const before = ov.pendingCount();
1324 ov.setGrid(40, 12);
1325 try std.testing.expectEqual(before, ov.pendingCount());
1326 }
1327
1328 test "predictedCursor advances the base past everything pending" {
1329 const alloc = std.testing.allocator;
1330 var ov = Overlay.init(alloc, 80, 24);
1331 defer ov.deinit();
1332 ov.setMode(.{ .icanon = true, .echo = true });
1333
1334 // With nothing pending the cursor is the daemon's, untouched.
1335 try std.testing.expectEqual(
1336 CursorPos{ .x = 5, .y = 1 },
1337 ov.predictedCursor(.{ .x = 5, .y = 1 }),
1338 );
1339
1340 var cur = CursorPos{ .x = 5, .y = 1 };
1341 for ("abc") |ch| {
1342 _ = typeAt(&ov, cur.x, cur.y, ch);
1343 cur = ov.predictedCursor(.{ .x = 5, .y = 1 });
1344 }
1345 try std.testing.expectEqual(CursorPos{ .x = 8, .y = 1 }, cur);
1346
1347 // And it comes back to the authoritative cursor when the queue empties.
1348 ov.flush();
1349 try std.testing.expectEqual(
1350 CursorPos{ .x = 5, .y = 1 },
1351 ov.predictedCursor(.{ .x = 5, .y = 1 }),
1352 );
1353 }
1354
1355 test "a queued prediction owns its byte; the caller's buffer may be reused" {
1356 const alloc = std.testing.allocator;
1357 var ov = Overlay.init(alloc, 80, 24);
1358 defer ov.deinit();
1359 ov.setMode(.{ .icanon = true, .echo = true });
1360
1361 // Exactly the shape of the client's stdin path: bytes are read into a
1362 // buffer that is about to be read into again. Anything the overlay keeps
1363 // pointing at that buffer is the UAF-that-never-crashes.
1364 const src = try alloc.alloc(u8, 3);
1365 defer alloc.free(src);
1366 @memcpy(src, "abc");
1367 for (src, 0..) |ch, i| {
1368 _ = typeAt(&ov, @intCast(i), 0, ch);
1369 }
1370 @memset(src, 0xFF);
1371
1372 try std.testing.expectEqual(@as(u8, 'a'), ov.pendingAt(0).cell.ch);
1373 try std.testing.expectEqual(@as(u8, 'b'), ov.pendingAt(1).cell.ch);
1374 try std.testing.expectEqual(@as(u8, 'c'), ov.pendingAt(2).cell.ch);
1375
1376 // And the judgement is made against what was typed, not against
1377 // whatever the buffer holds by the time the frame comes back.
1378 try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abc"}, 1));
1379 try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
1380 }
1381
1382 test "the queue survives its own growth" {
1383 const alloc = std.testing.allocator;
1384 var ov = Overlay.init(alloc, 80, 24);
1385 defer ov.deinit();
1386 ov.setMode(.{ .icanon = true, .echo = true });
1387
1388 // Well past any initial capacity, so the backing array is reallocated
1389 // several times underneath the predictions already in it.
1390 var i: u16 = 0;
1391 while (i < 70) : (i += 1) {
1392 const ch: u8 = 'a' + @as(u8, @intCast(i % 26));
1393 try std.testing.expect(typeAt(&ov, i, 0, ch) == .display);
1394 }
1395 try std.testing.expectEqual(@as(usize, 70), ov.pendingCount());
1396
1397 i = 0;
1398 while (i < 70) : (i += 1) {
1399 const want: u8 = 'a' + @as(u8, @intCast(i % 26));
1400 try std.testing.expectEqual(want, ov.pendingAt(i).cell.ch);
1401 try std.testing.expectEqual(i, ov.pendingAt(i).cell.col);
1402 }
1403 }
1404
1405 test "PlainGrid reads a cell out of a dump, and blanks where the dump stops" {
1406 const alloc = std.testing.allocator;
1407 const text = try plainOf(alloc, &.{ "ab", "cd" });
1408 defer alloc.free(text);
1409 const g = PlainGrid{ .text = text, .cols = 80 };
1410
1411 try std.testing.expectEqual(@as(?u8, 'a'), g.cellChar(0, 0));
1412 try std.testing.expectEqual(@as(?u8, 'b'), g.cellChar(0, 1));
1413 try std.testing.expectEqual(@as(?u8, 'c'), g.cellChar(1, 0));
1414 try std.testing.expectEqual(@as(?u8, 'd'), g.cellChar(1, 1));
1415
1416 // A dump carries no trailing blanks, so a cell past the end of a row —
1417 // or past the last row — is a blank cell, not a missing one.
1418 try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(0, 2));
1419 try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(9, 0));
1420
1421 // Outside the grid is a different answer: nothing to compare against.
1422 try std.testing.expectEqual(@as(?u8, null), g.cellChar(0, 80));
1423 }
1424
1425 // Forces semantic analysis of every pub decl under `zig build test`, so an
1426 // unreferenced decl must at least compile (the silent-module-loss hazard,
1427 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
1428 test {
1429 std.testing.refAllDeclsRecursive(@This());
1430 }
src/tui/wall_layout.zig
Old New
@@ -5,7 +5,7 @@
5 const std = @import("std"); 5 const std = @import("std");
6 const proto = @import("protocol"); 6 const proto = @import("protocol");
7 const hosts = @import("hosts"); 7 const hosts = @import("hosts");
8 const interact = @import("interact"); 8 const interact = @import("interact.zig");
9 const layout = @import("layout"); 9 const layout = @import("layout");
10 const wall_host = @import("wall_host.zig"); 10 const wall_host = @import("wall_host.zig");
11 const wv = @import("wallview.zig"); 11 const wv = @import("wallview.zig");
src/tui/wall_picker.zig
Old New
@@ -9,7 +9,7 @@ const proto = @import("protocol");
9 const client = @import("client"); 9 const client = @import("client");
10 const hosts = @import("hosts"); 10 const hosts = @import("hosts");
11 const handoff = @import("handoff"); 11 const handoff = @import("handoff");
12 const interact = @import("interact"); 12 const interact = @import("interact.zig");
13 const askpass = @import("askpass"); 13 const askpass = @import("askpass");
14 const wall_host = @import("wall_host.zig"); 14 const wall_host = @import("wall_host.zig");
15 const wall_layout = @import("wall_layout.zig"); 15 const wall_layout = @import("wall_layout.zig");
src/tui/wall_pump.zig
Old New
@@ -7,7 +7,7 @@ const std = @import("std");
7 const proto = @import("protocol"); 7 const proto = @import("protocol");
8 const client = @import("client"); 8 const client = @import("client");
9 const handoff = @import("handoff"); 9 const handoff = @import("handoff");
10 const interact = @import("interact"); 10 const interact = @import("interact.zig");
11 const wv = @import("wallview.zig"); 11 const wv = @import("wallview.zig");
12 const EndReason = wv.EndReason; 12 const EndReason = wv.EndReason;
13 const Shared = wv.Shared; 13 const Shared = wv.Shared;
src/tui/wall_test_picker.zig
Old New
@@ -2,7 +2,7 @@
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const client = @import("client"); 4 const client = @import("client");
5 const interact = @import("interact"); 5 const interact = @import("interact.zig");
6 const layout = @import("layout"); 6 const layout = @import("layout");
7 const fixture = @import("wall_test_harness.zig"); 7 const fixture = @import("wall_test_harness.zig");
8 const wall_host = @import("wall_host.zig"); 8 const wall_host = @import("wall_host.zig");
src/tui/wall_test_pump.zig
Old New
@@ -2,7 +2,7 @@
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const client = @import("client"); 4 const client = @import("client");
5 const interact = @import("interact"); 5 const interact = @import("interact.zig");
6 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
7 const askpass = @import("askpass"); 7 const askpass = @import("askpass");
8 const fixture = @import("wall_test_harness.zig"); 8 const fixture = @import("wall_test_harness.zig");
src/tui/wall_test_wall.zig
Old New
@@ -2,7 +2,7 @@
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const client = @import("client"); 4 const client = @import("client");
5 const interact = @import("interact"); 5 const interact = @import("interact.zig");
6 const fixture = @import("wall_test_harness.zig"); 6 const fixture = @import("wall_test_harness.zig");
7 const wall_host = @import("wall_host.zig"); 7 const wall_host = @import("wall_host.zig");
8 const wall_layout = @import("wall_layout.zig"); 8 const wall_layout = @import("wall_layout.zig");
src/tui/wallview.zig
Old New
@@ -26,13 +26,13 @@ const askpass = @import("askpass");
26 const spawn = @import("spawn"); 26 const spawn = @import("spawn");
27 const proxy = @import("proxy"); 27 const proxy = @import("proxy");
28 const Engine = @import("engine").Engine; 28 const Engine = @import("engine").Engine;
29 const paint = @import("paint"); 29 const paint = @import("paint.zig");
30 const select = @import("select"); 30 const select = @import("select.zig");
31 // Counters ride out through `Shared` because a detached pump never reaches 31 // Counters ride out through `Shared` because a detached pump never reaches
32 // a `Core.deinit`. 32 // a `Core.deinit`.
33 // The chord table and the prediction hooks a focused tile shares with the 33 // The chord table and the prediction hooks a focused tile shares with the
34 // client: one interaction core, not a second copy (interact.zig). 34 // client: one interaction core, not a second copy (interact.zig).
35 const interact = @import("interact"); 35 const interact = @import("interact.zig");
36 const layout = @import("layout"); 36 const layout = @import("layout");
37 const TmpDir = @import("testtmp").TmpDir; 37 const TmpDir = @import("testtmp").TmpDir;
38 const wall_host = @import("wall_host.zig"); 38 const wall_host = @import("wall_host.zig");
@@ -2414,6 +2414,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2414 // Reaching a file is what registers its tests; build.zig gates 2414 // Reaching a file is what registers its tests; build.zig gates
2415 // the list. 2415 // the list.
2416 test { 2416 test {
2417 _ = @import("interact.zig");
2418 _ = @import("paint.zig");
2419 _ = @import("select.zig");
2420 _ = @import("predict.zig");
2417 _ = @import("wall_test_harness.zig"); 2421 _ = @import("wall_test_harness.zig");
2418 _ = @import("wall_test_host.zig"); 2422 _ = @import("wall_test_host.zig");
2419 _ = @import("wall_test_layout.zig"); 2423 _ = @import("wall_test_layout.zig");