70cad621
refactor: the wall owns its interaction loop, painter, selector and overlay as child files
a73x 2026-08-30 11:42
Commit message
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"); |