cb63edb8
feat: predicted echo — the keystroke paints before the RTT
a73x 2026-08-08 15:34
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -144,6 +144,9 @@ pub fn build(b: *std.Build) void { | |||
| 144 | client_mod.addImport("protocol", protocol_mod); | 144 | client_mod.addImport("protocol", protocol_mod); |
| 145 | client_mod.addImport("testtmp", testtmp_mod); | 145 | client_mod.addImport("testtmp", testtmp_mod); |
| 146 | client_mod.addImport("quic_client", quic_client_mod); | 146 | client_mod.addImport("quic_client", quic_client_mod); |
| 147 | // The client is the only thing that predicts: the overlay is a local | ||
| 148 | // display decision and never becomes state anybody else can see. | ||
| 149 | client_mod.addImport("predict", predict_mod); | ||
| 147 | 150 | ||
| 148 | const mux_mod = b.createModule(.{ | 151 | const mux_mod = b.createModule(.{ |
| 149 | .root_source_file = b.path("src/mux_main.zig"), | 152 | .root_source_file = b.path("src/mux_main.zig"), |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ const Engine = @import("engine").Engine; | |||
| 10 | const proto = @import("protocol"); | 10 | const proto = @import("protocol"); |
| 11 | const TmpDir = @import("testtmp").TmpDir; | 11 | const TmpDir = @import("testtmp").TmpDir; |
| 12 | const quic_client = @import("quic_client"); | 12 | const quic_client = @import("quic_client"); |
| 13 | const predict = @import("predict"); | ||
| 13 | 14 | ||
| 14 | var winch_flag = std.atomic.Value(bool).init(false); | 15 | var winch_flag = std.atomic.Value(bool).init(false); |
| 15 | 16 | ||
| @@ -457,6 +458,16 @@ fn session( | |||
| 457 | var exit_msg: ?[]const u8 = null; | 458 | var exit_msg: ?[]const u8 = null; |
| 458 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); | 459 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); |
| 459 | 460 | ||
| 461 | // Speculative echo. Born `.never` and stays there until a daemon tells | ||
| 462 | // it otherwise, so an old daemon that has never heard of pty_mode gets a | ||
| 463 | // client that predicts nothing at all. | ||
| 464 | // | ||
| 465 | // Registered here so the stats line lands after the terminal has been | ||
| 466 | // put back and on the normal screen, like every other message. | ||
| 467 | var overlay = predict.Overlay.init(alloc, size.cols, size.rows); | ||
| 468 | defer overlay.deinit(); | ||
| 469 | defer dumpPredictStats(overlay.counters); | ||
| 470 | |||
| 460 | // Raw mode when we own a terminal. The alternate screen is NOT entered | 471 | // Raw mode when we own a terminal. The alternate screen is NOT entered |
| 461 | // here — see the first-frame gate in the loop below. | 472 | // here — see the first-frame gate in the loop below. |
| 462 | var orig_termios: ?std.posix.termios = null; | 473 | var orig_termios: ?std.posix.termios = null; |
| @@ -591,6 +602,10 @@ fn session( | |||
| 591 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; | 602 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; |
| 592 | } | 603 | } |
| 593 | repaint_after_resync = true; | 604 | repaint_after_resync = true; |
| 605 | // Whatever was outstanding was predicted against a connection | ||
| 606 | // that no longer exists. Dropping it is not an accusation, so | ||
| 607 | // the counters stay where they are. | ||
| 608 | overlay.flush(); | ||
| 594 | continue; | 609 | continue; |
| 595 | } | 610 | } |
| 596 | if (winch_flag.swap(false, .acq_rel)) { | 611 | if (winch_flag.swap(false, .acq_rel)) { |
| @@ -609,6 +624,9 @@ fn session( | |||
| 609 | needs_reconnect = true; | 624 | needs_reconnect = true; |
| 610 | continue; | 625 | continue; |
| 611 | }; | 626 | }; |
| 627 | // The grid this would be painted on is about to stop | ||
| 628 | // existing; cleared when the answering snapshot lands. | ||
| 629 | overlay.setResizePending(true); | ||
| 612 | } | 630 | } |
| 613 | } | 631 | } |
| 614 | } | 632 | } |
| @@ -620,6 +638,14 @@ fn session( | |||
| 620 | _ = try std.posix.poll(&fds, transport.timeoutMs(100)); | 638 | _ = try std.posix.poll(&fds, transport.timeoutMs(100)); |
| 621 | transport.service(); | 639 | transport.service(); |
| 622 | 640 | ||
| 641 | // The idle path, and the only thing that can retire a prediction the | ||
| 642 | // application answered by going quiet: no frame is coming, so | ||
| 643 | // reconcile will never run again and the glyph would otherwise stay | ||
| 644 | // on screen for the rest of the session. | ||
| 645 | if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) { | ||
| 646 | try renderClipped(alloc, replica, size, stdout_fd); | ||
| 647 | } | ||
| 648 | |||
| 623 | // Labelled, because "no whole frame yet" must leave the REST of this | 649 | // Labelled, because "no whole frame yet" must leave the REST of this |
| 624 | // iteration running. Everything below — the detach chord, keystrokes, | 650 | // iteration running. Everything below — the detach chord, keystrokes, |
| 625 | // resize — lives in the same pass, so jumping straight back to the | 651 | // resize — lives in the same pass, so jumping straight back to the |
| @@ -677,6 +703,15 @@ fn session( | |||
| 677 | // scrolled, so leaving scroll mode paints current state. | 703 | // scrolled, so leaving scroll mode paints current state. |
| 678 | replica.reset(); | 704 | replica.reset(); |
| 679 | replica.feed(frame.payload[proto.snapshot_prefix_len..]); | 705 | replica.feed(frame.payload[proto.snapshot_prefix_len..]); |
| 706 | // A snapshot answers a resize, ends a reconnect, and | ||
| 707 | // rebuilds the screen under anything outstanding. None | ||
| 708 | // of that says a prediction was wrong — it says we can | ||
| 709 | // no longer find out, so the queue goes and the counters | ||
| 710 | // do not move. | ||
| 711 | overlay.setGrid(grid.cols, grid.rows); | ||
| 712 | overlay.setResizePending(false); | ||
| 713 | overlay.flush(); | ||
| 714 | overlay.noteSeq(prefix.seq); | ||
| 680 | if (scroll_pages == 0) { | 715 | if (scroll_pages == 0) { |
| 681 | try renderClipped(alloc, replica, size, stdout_fd); | 716 | try renderClipped(alloc, replica, size, stdout_fd); |
| 682 | repaint_after_resync = false; // banner painted over | 717 | repaint_after_resync = false; // banner painted over |
| @@ -708,19 +743,52 @@ fn session( | |||
| 708 | history_rows = composed.header.history_rows; | 743 | history_rows = composed.header.history_rows; |
| 709 | last_seq = composed.header.seq; | 744 | last_seq = composed.header.seq; |
| 710 | replica.feed(composed.bytes); | 745 | replica.feed(composed.bytes); |
| 746 | // Judged against the replica the frame has just been fed | ||
| 747 | // into, which is the only authority there is. | ||
| 748 | const verdict = reconcileOverlay( | ||
| 749 | alloc, | ||
| 750 | &overlay, | ||
| 751 | replica, | ||
| 752 | last_seq, | ||
| 753 | std.time.milliTimestamp(), | ||
| 754 | ); | ||
| 711 | // While scrolled the replica still tracks live output; the | 755 | // While scrolled the replica still tracks live output; the |
| 712 | // repaint on scroll exit comes from it. | 756 | // repaint on scroll exit comes from it. |
| 713 | if (scroll_pages == 0) { | 757 | if (scroll_pages == 0) { |
| 714 | if (repaint_after_resync) { | 758 | if (repaint_after_resync or verdict == .contradicted) { |
| 715 | // First frame back after a reconnect. The daemon | 759 | // First frame back after a reconnect. The daemon |
| 716 | // sent only what changed, which is correct — but | 760 | // sent only what changed, which is correct — but |
| 717 | // the screen still carries the banner, so repaint | 761 | // the screen still carries the banner, so repaint |
| 718 | // the whole thing from the replica instead. | 762 | // the whole thing from the replica instead. |
| 763 | // | ||
| 764 | // A contradiction (or an expiry) takes the same | ||
| 765 | // route: the whole queue has just been abandoned, | ||
| 766 | // and repainting everything from the replica is | ||
| 767 | // the simplest rollback that is certainly right. | ||
| 768 | // It is affordable precisely because reconcile v2 | ||
| 769 | // made contradictions rare — a burst outrunning | ||
| 770 | // the round trip is no longer one. | ||
| 719 | try renderClipped(alloc, replica, size, stdout_fd); | 771 | try renderClipped(alloc, replica, size, stdout_fd); |
| 720 | repaint_after_resync = false; | 772 | repaint_after_resync = false; |
| 721 | } else { | 773 | } else { |
| 722 | try paintDeltaClipped(alloc, frame.payload, size, stdout_fd); | 774 | try paintDeltaClipped(alloc, frame.payload, size, stdout_fd); |
| 723 | } | 775 | } |
| 776 | // Last, and after either paint: the rows the daemon | ||
| 777 | // just sent have overwritten anything drawn on them, | ||
| 778 | // including predictions that are still outstanding. | ||
| 779 | paintOverlay(alloc, &overlay, replica.cursorPos(), size, stdout_fd); | ||
| 780 | } | ||
| 781 | }, | ||
| 782 | .pty_mode => { | ||
| 783 | const flags = proto.decodePtyMode(frame.payload) catch continue; | ||
| 784 | // Mode churn is ordinary — readline hands the terminal | ||
| 785 | // back and forth around every command — so the repaint | ||
| 786 | // is spent only when the flush actually took something | ||
| 787 | // off the screen. | ||
| 788 | const had_pending = overlay.pendingCount() > 0; | ||
| 789 | overlay.setMode(flags); | ||
| 790 | if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) { | ||
| 791 | try renderClipped(alloc, replica, size, stdout_fd); | ||
| 724 | } | 792 | } |
| 725 | }, | 793 | }, |
| 726 | .scrollback_chunk => { | 794 | .scrollback_chunk => { |
| @@ -775,6 +843,10 @@ fn session( | |||
| 775 | if (history_rows > 0) { | 843 | if (history_rows > 0) { |
| 776 | const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; | 844 | const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; |
| 777 | if (scroll_pages < max_pages) scroll_pages += 1; | 845 | if (scroll_pages < max_pages) scroll_pages += 1; |
| 846 | // The cursor is no longer where the user is looking, | ||
| 847 | // so a prediction painted at it would land in the | ||
| 848 | // middle of history. | ||
| 849 | overlay.setScrollMode(true); | ||
| 778 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { | 850 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { |
| 779 | needs_reconnect = true; | 851 | needs_reconnect = true; |
| 780 | continue; | 852 | continue; |
| @@ -783,6 +855,7 @@ fn session( | |||
| 783 | } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) { | 855 | } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) { |
| 784 | if (scroll_pages > 0) scroll_pages -= 1; | 856 | if (scroll_pages > 0) scroll_pages -= 1; |
| 785 | if (scroll_pages == 0) { | 857 | if (scroll_pages == 0) { |
| 858 | overlay.setScrollMode(false); | ||
| 786 | try renderClipped(alloc, replica, size, stdout_fd); | 859 | try renderClipped(alloc, replica, size, stdout_fd); |
| 787 | } else { | 860 | } else { |
| 788 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { | 861 | requestScrollPage(transport, scroll_pages, history_rows, size) catch { |
| @@ -793,8 +866,13 @@ fn session( | |||
| 793 | } else if (scroll_pages > 0) { | 866 | } else if (scroll_pages > 0) { |
| 794 | // Any other key exits scroll mode (swallowed, not forwarded). | 867 | // Any other key exits scroll mode (swallowed, not forwarded). |
| 795 | scroll_pages = 0; | 868 | scroll_pages = 0; |
| 869 | overlay.setScrollMode(false); | ||
| 796 | try renderClipped(alloc, replica, size, stdout_fd); | 870 | try renderClipped(alloc, replica, size, stdout_fd); |
| 797 | } else { | 871 | } else { |
| 872 | // Speculate before sending, so the glyph is on screen | ||
| 873 | // while the keystroke is still in flight. The bytes that | ||
| 874 | // go out are unchanged either way. | ||
| 875 | offerKeystroke(alloc, &overlay, replica, buf[0..n], size, stdout_fd); | ||
| 798 | transport.writeFrame(.input, buf[0..n]) catch { | 876 | transport.writeFrame(.input, buf[0..n]) catch { |
| 799 | // These keystrokes are lost with the transport, by | 877 | // These keystrokes are lost with the transport, by |
| 800 | // the same policy that drops what is typed while | 878 | // the same policy that drops what is typed while |
| @@ -875,6 +953,159 @@ fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.S | |||
| 875 | try proto.writeAllFd(out_fd, paint.items); | 953 | try proto.writeAllFd(out_fd, paint.items); |
| 876 | } | 954 | } |
| 877 | 955 | ||
| 956 | // ---- prediction -------------------------------------------------------- | ||
| 957 | // | ||
| 958 | // The overlay is a display decision and nothing else. It never writes to | ||
| 959 | // the replica, so the replica keeps meaning exactly "what the daemon said" | ||
| 960 | // and stays comparable to `muxd dump` at every instant. Everything below | ||
| 961 | // either reads the replica or paints on top of it. | ||
| 962 | |||
| 963 | /// What the replica shows at one cell — the `prev_ch` a prediction is | ||
| 964 | /// judged against later. | ||
| 965 | /// | ||
| 966 | /// Read at the PREDICTED cursor, not the replica's own: mid-burst those are | ||
| 967 | /// different cells, and reading the wrong one hands reconcile a `prev_ch` | ||
| 968 | /// that belongs to somebody else's cell, which turns "the frame has not | ||
| 969 | /// answered yet" into "we were contradicted" and flushes the queue. That is | ||
| 970 | /// the failure the real-Engine test below exists to catch. | ||
| 971 | fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { | ||
| 972 | const plain = replica.dumpPlain(alloc) catch return ' '; | ||
| 973 | defer alloc.free(plain); | ||
| 974 | const grid: predict.PlainGrid = .{ .text = plain, .cols = @intCast(replica.term.cols) }; | ||
| 975 | return grid.cellChar(at.y, at.x) orelse ' '; | ||
| 976 | } | ||
| 977 | |||
| 978 | /// Judge the overlay against the replica as it now stands. Call after the | ||
| 979 | /// replica has taken the frame, never before: the whole question is what | ||
| 980 | /// the authoritative state says now. | ||
| 981 | fn reconcileOverlay( | ||
| 982 | alloc: std.mem.Allocator, | ||
| 983 | overlay: *predict.Overlay, | ||
| 984 | replica: *Engine, | ||
| 985 | seq: u64, | ||
| 986 | now_ms: i64, | ||
| 987 | ) predict.Verdict { | ||
| 988 | const plain = replica.dumpPlain(alloc) catch return .none; | ||
| 989 | defer alloc.free(plain); | ||
| 990 | return overlay.reconcile( | ||
| 991 | predict.PlainGrid{ .text = plain, .cols = @intCast(replica.term.cols) }, | ||
| 992 | seq, | ||
| 993 | now_ms, | ||
| 994 | ); | ||
| 995 | } | ||
| 996 | |||
| 997 | /// Paint every pending prediction on top of whatever is on screen, and | ||
| 998 | /// leave the cursor where the typist believes it is. | ||
| 999 | /// | ||
| 1000 | /// Idempotent and called after every authoritative paint as well as on each | ||
| 1001 | /// keystroke, because a delta repaints whole rows: the row content the | ||
| 1002 | /// daemon sent would otherwise wipe an underlined glyph whose prediction is | ||
| 1003 | /// still outstanding, and the burst would flicker away one frame after it | ||
| 1004 | /// was drawn. | ||
| 1005 | fn paintOverlay( | ||
| 1006 | alloc: std.mem.Allocator, | ||
| 1007 | overlay: *predict.Overlay, | ||
| 1008 | base: Engine.CursorPos, | ||
| 1009 | tty: proto.Size, | ||
| 1010 | out_fd: std.posix.fd_t, | ||
| 1011 | ) void { | ||
| 1012 | if (!overlay.confident or overlay.pendingCount() == 0) return; | ||
| 1013 | var paint: std.ArrayList(u8) = .empty; | ||
| 1014 | defer paint.deinit(alloc); | ||
| 1015 | paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l") catch return; | ||
| 1016 | |||
| 1017 | var i: usize = 0; | ||
| 1018 | while (i < overlay.pendingCount()) : (i += 1) { | ||
| 1019 | const cell = overlay.pendingAt(i).cell; | ||
| 1020 | if (cell.row >= tty.rows or cell.col >= tty.cols) continue; | ||
| 1021 | var b: [32]u8 = undefined; | ||
| 1022 | // Underlined, so a prediction is visibly a prediction until the | ||
| 1023 | // daemon's own row content replaces it. | ||
| 1024 | const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{ | ||
| 1025 | cell.row + 1, | ||
| 1026 | cell.col + 1, | ||
| 1027 | cell.ch, | ||
| 1028 | }) catch continue; | ||
| 1029 | paint.appendSlice(alloc, s) catch return; | ||
| 1030 | } | ||
| 1031 | |||
| 1032 | const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 1033 | const cur = clampCursor(.{ .x = pc.x, .y = pc.y }, tty); | ||
| 1034 | var cbuf: [32]u8 = undefined; | ||
| 1035 | const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H\x1b[?25h\x1b[?2026l", .{ | ||
| 1036 | cur.y + 1, | ||
| 1037 | cur.x + 1, | ||
| 1038 | }) catch return; | ||
| 1039 | paint.appendSlice(alloc, tail) catch return; | ||
| 1040 | proto.writeAllFd(out_fd, paint.items) catch {}; | ||
| 1041 | } | ||
| 1042 | |||
| 1043 | /// Offer one chunk of typed bytes to the overlay. The chunk goes to the | ||
| 1044 | /// daemon unchanged whatever happens here — prediction never alters what | ||
| 1045 | /// the shell receives, only what the screen shows before it answers. | ||
| 1046 | fn offerKeystroke( | ||
| 1047 | alloc: std.mem.Allocator, | ||
| 1048 | overlay: *predict.Overlay, | ||
| 1049 | replica: *Engine, | ||
| 1050 | chunk: []const u8, | ||
| 1051 | tty: proto.Size, | ||
| 1052 | out_fd: std.posix.fd_t, | ||
| 1053 | ) void { | ||
| 1054 | if (chunk.len != 1) { | ||
| 1055 | // An escape sequence, a multi-byte character, or a paste. None is | ||
| 1056 | // one cell's worth of change and M9 speculates about none of them. | ||
| 1057 | // Offering the lead byte records the refusal in the same counter as | ||
| 1058 | // every other refusal, which covers the two shapes that actually | ||
| 1059 | // arrive this way — both have a non-printable lead byte. | ||
| 1060 | if (chunk[0] < 0x20 or chunk[0] >= 0x7f) { | ||
| 1061 | _ = overlay.predictAt(.{ | ||
| 1062 | .cursor = .{}, | ||
| 1063 | .ch = chunk[0], | ||
| 1064 | .prev_ch = 0, | ||
| 1065 | .now_ms = std.time.milliTimestamp(), | ||
| 1066 | }); | ||
| 1067 | } | ||
| 1068 | return; | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | const base = replica.cursorPos(); | ||
| 1072 | const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 1073 | const out = overlay.predictAt(.{ | ||
| 1074 | .cursor = at, | ||
| 1075 | .ch = chunk[0], | ||
| 1076 | .prev_ch = replicaCellChar(alloc, replica, at), | ||
| 1077 | .now_ms = std.time.milliTimestamp(), | ||
| 1078 | }); | ||
| 1079 | switch (out) { | ||
| 1080 | .display => paintOverlay(alloc, overlay, base, tty, out_fd), | ||
| 1081 | // Queued but unearned, or refused outright: either way nothing is | ||
| 1082 | // drawn, which is the entire safety property. | ||
| 1083 | .hidden, .suppressed => {}, | ||
| 1084 | } | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | /// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure | ||
| 1088 | /// function so the format the e2e greps for is pinned by a test rather than | ||
| 1089 | /// by whatever the process happened to print. | ||
| 1090 | fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 { | ||
| 1091 | return std.fmt.bufPrint( | ||
| 1092 | buf, | ||
| 1093 | "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ | ||
| 1094 | " expired={d} suppressed={d}", | ||
| 1095 | .{ c.made, c.displayed, c.confirmed, c.contradicted, c.expired, c.suppressed }, | ||
| 1096 | ); | ||
| 1097 | } | ||
| 1098 | |||
| 1099 | pub const predict_stats_len = 192; | ||
| 1100 | |||
| 1101 | fn dumpPredictStats(c: predict.Counters) void { | ||
| 1102 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; | ||
| 1103 | if (!std.mem.eql(u8, want, "1")) return; | ||
| 1104 | var buf: [predict_stats_len]u8 = undefined; | ||
| 1105 | const line = formatPredictStats(&buf, c) catch return; | ||
| 1106 | std.debug.print("{s}\n", .{line}); | ||
| 1107 | } | ||
| 1108 | |||
| 878 | fn requestScrollPage( | 1109 | fn requestScrollPage( |
| 879 | transport: *Transport, | 1110 | transport: *Transport, |
| 880 | pages_up: u32, | 1111 | pages_up: u32, |
| @@ -1033,6 +1264,249 @@ fn renderScrollback( | |||
| 1033 | try proto.writeAllFd(out_fd, paint.items); | 1264 | try proto.writeAllFd(out_fd, paint.items); |
| 1034 | } | 1265 | } |
| 1035 | 1266 | ||
| 1267 | fn devNull() !std.posix.fd_t { | ||
| 1268 | return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | ||
| 1269 | } | ||
| 1270 | |||
| 1271 | test "prediction: prev_ch is read at the predicted cursor, not the replica's" { | ||
| 1272 | const alloc = std.testing.allocator; | ||
| 1273 | const null_fd = try devNull(); | ||
| 1274 | defer std.posix.close(null_fd); | ||
| 1275 | |||
| 1276 | // A real engine, fed real VT bytes — the one part of the prediction | ||
| 1277 | // contract no test inside predict.zig can reach, because that module | ||
| 1278 | // has never heard of an engine. | ||
| 1279 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1280 | defer replica.deinit(); | ||
| 1281 | // Content with the cursor parked ON a character and a DIFFERENT | ||
| 1282 | // character in the cell after it. That difference is the whole test: | ||
| 1283 | // with nothing pending the replica's cursor and the predicted one agree, | ||
| 1284 | // and mid-burst they do not. | ||
| 1285 | replica.feed("abcXY\x1b[1;4H"); | ||
| 1286 | try std.testing.expectEqual(@as(u16, 3), replica.cursorPos().x); | ||
| 1287 | |||
| 1288 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1289 | defer ov.deinit(); | ||
| 1290 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1291 | ov.noteSeq(1); | ||
| 1292 | |||
| 1293 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1294 | offerKeystroke(alloc, &ov, replica, "d", tty, null_fd); | ||
| 1295 | offerKeystroke(alloc, &ov, replica, "e", tty, null_fd); | ||
| 1296 | |||
| 1297 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1298 | try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch); | ||
| 1299 | // The second keystroke lands in the cell AFTER the first prediction, | ||
| 1300 | // and takes that cell's content as its prev_ch. | ||
| 1301 | try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col); | ||
| 1302 | try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch); | ||
| 1303 | |||
| 1304 | // A frame that changed neither cell: the daemon has not seen the | ||
| 1305 | // keystrokes yet, so it has said nothing about them and both | ||
| 1306 | // predictions must survive it. Read prev_ch from the wrong cell and | ||
| 1307 | // this is where it shows — the second cell holds 'Y', which is neither | ||
| 1308 | // the prediction nor the 'X' a cursor-based read would have recorded, | ||
| 1309 | // so the frame reads as a contradiction and the burst is flushed. | ||
| 1310 | try std.testing.expectEqual( | ||
| 1311 | predict.Verdict.none, | ||
| 1312 | reconcileOverlay(alloc, &ov, replica, 2, 0), | ||
| 1313 | ); | ||
| 1314 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1315 | try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); | ||
| 1316 | |||
| 1317 | // And when the daemon does answer, they confirm against the real grid. | ||
| 1318 | replica.feed("\x1b[1;4Hde"); | ||
| 1319 | try std.testing.expectEqual( | ||
| 1320 | predict.Verdict.confirmed, | ||
| 1321 | reconcileOverlay(alloc, &ov, replica, 3, 0), | ||
| 1322 | ); | ||
| 1323 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1324 | try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed); | ||
| 1325 | } | ||
| 1326 | |||
| 1327 | test "prediction: a burst advances the predicted cursor one cell per keystroke" { | ||
| 1328 | const alloc = std.testing.allocator; | ||
| 1329 | const null_fd = try devNull(); | ||
| 1330 | defer std.posix.close(null_fd); | ||
| 1331 | |||
| 1332 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1333 | defer replica.deinit(); | ||
| 1334 | |||
| 1335 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1336 | defer ov.deinit(); | ||
| 1337 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1338 | |||
| 1339 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1340 | for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, null_fd); | ||
| 1341 | |||
| 1342 | // The replica's own cursor has not moved — the daemon has answered | ||
| 1343 | // nothing — so every one of these came from the overlay. | ||
| 1344 | try std.testing.expectEqual(@as(u16, 0), replica.cursorPos().x); | ||
| 1345 | try std.testing.expectEqual(@as(usize, 5), ov.pendingCount()); | ||
| 1346 | for ("hello", 0..) |ch, i| { | ||
| 1347 | const p = ov.pendingAt(i); | ||
| 1348 | try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col); | ||
| 1349 | try std.testing.expectEqual(ch, p.cell.ch); | ||
| 1350 | try std.testing.expectEqual(@as(u8, ' '), p.prev_ch); | ||
| 1351 | } | ||
| 1352 | try std.testing.expectEqual( | ||
| 1353 | predict.CursorPos{ .x = 5, .y = 0 }, | ||
| 1354 | ov.predictedCursor(.{ .x = 0, .y = 0 }), | ||
| 1355 | ); | ||
| 1356 | } | ||
| 1357 | |||
| 1358 | test "prediction paints underlined, and parks the cursor past what it drew" { | ||
| 1359 | const alloc = std.testing.allocator; | ||
| 1360 | const p = try std.posix.pipe(); | ||
| 1361 | defer std.posix.close(p[0]); | ||
| 1362 | |||
| 1363 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1364 | defer replica.deinit(); | ||
| 1365 | replica.feed("\x1b[1;4H"); // cursor at column 3 (0-based) | ||
| 1366 | |||
| 1367 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1368 | defer ov.deinit(); | ||
| 1369 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1370 | |||
| 1371 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 1372 | std.posix.close(p[1]); | ||
| 1373 | |||
| 1374 | var out: std.ArrayList(u8) = .empty; | ||
| 1375 | defer out.deinit(alloc); | ||
| 1376 | var rbuf: [4096]u8 = undefined; | ||
| 1377 | while (true) { | ||
| 1378 | const n = try std.posix.read(p[0], &rbuf); | ||
| 1379 | if (n == 0) break; | ||
| 1380 | try out.appendSlice(alloc, rbuf[0..n]); | ||
| 1381 | } | ||
| 1382 | |||
| 1383 | // Drawn at the predicted cell, underlined so a speculation is visibly | ||
| 1384 | // one, and with the SGR closed again so it cannot bleed into the rest. | ||
| 1385 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null); | ||
| 1386 | // Cursor left one past it: the typist's next character goes there, and | ||
| 1387 | // if it did not the shell's own cursor would appear to lag a column | ||
| 1388 | // behind everything they typed. | ||
| 1389 | try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l")); | ||
| 1390 | // Wrapped in one synchronized update, so no terminal ever shows the | ||
| 1391 | // half-drawn state. | ||
| 1392 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h")); | ||
| 1393 | } | ||
| 1394 | |||
| 1395 | test "prediction: nothing is drawn for a context that has not earned it" { | ||
| 1396 | const alloc = std.testing.allocator; | ||
| 1397 | const p = try std.posix.pipe(); | ||
| 1398 | defer std.posix.close(p[0]); | ||
| 1399 | |||
| 1400 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1401 | defer replica.deinit(); | ||
| 1402 | |||
| 1403 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1404 | defer ov.deinit(); | ||
| 1405 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | ||
| 1406 | |||
| 1407 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 1408 | std.posix.close(p[1]); | ||
| 1409 | |||
| 1410 | // Queued, so it can be judged and earn the next one its visibility... | ||
| 1411 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 1412 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1413 | |||
| 1414 | // ...and not one byte went to the terminal. The effect, not the counter: | ||
| 1415 | // this is the assertion that a password prompt depends on. | ||
| 1416 | var rbuf: [64]u8 = undefined; | ||
| 1417 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 1418 | } | ||
| 1419 | |||
| 1420 | test "prediction: a chunk that is not one printable byte is never speculated about" { | ||
| 1421 | const alloc = std.testing.allocator; | ||
| 1422 | const null_fd = try devNull(); | ||
| 1423 | defer std.posix.close(null_fd); | ||
| 1424 | |||
| 1425 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1426 | defer replica.deinit(); | ||
| 1427 | |||
| 1428 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1429 | defer ov.deinit(); | ||
| 1430 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1431 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1432 | |||
| 1433 | // An arrow key: three bytes, and predicting its lead byte would paint an | ||
| 1434 | // escape character on the screen. | ||
| 1435 | offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, null_fd); | ||
| 1436 | // A multi-byte character, whose display width we do not know. | ||
| 1437 | offerKeystroke(alloc, &ov, replica, "é", tty, null_fd); | ||
| 1438 | // And a lone control byte, which goes down the single-byte path and is | ||
| 1439 | // refused there. | ||
| 1440 | offerKeystroke(alloc, &ov, replica, "\r", tty, null_fd); | ||
| 1441 | |||
| 1442 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1443 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 1444 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); | ||
| 1445 | |||
| 1446 | // A paste: several printable bytes in one read. This is the shape whose | ||
| 1447 | // lead byte would sail through the printability check, so the length | ||
| 1448 | // guard is the only thing refusing it — and M9 refuses it, because a | ||
| 1449 | // paste can carry newlines and bracketed-paste markers that are not one | ||
| 1450 | // cell's worth of change each. | ||
| 1451 | offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd); | ||
| 1452 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1453 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 1454 | // Not counted, and deliberately asserted as not counted rather than | ||
| 1455 | // left unsaid: a printable-lead chunk is the one refusal the overlay | ||
| 1456 | // has no way to record, since predictAt only counts refusals it makes | ||
| 1457 | // itself. Recorded in the M9 notes as an interface gap. | ||
| 1458 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); | ||
| 1459 | } | ||
| 1460 | |||
| 1461 | test "prediction: a repaint never reveals what was never shown" { | ||
| 1462 | const alloc = std.testing.allocator; | ||
| 1463 | const p = try std.posix.pipe(); | ||
| 1464 | defer std.posix.close(p[0]); | ||
| 1465 | |||
| 1466 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1467 | defer replica.deinit(); | ||
| 1468 | const null_fd = try devNull(); | ||
| 1469 | defer std.posix.close(null_fd); | ||
| 1470 | |||
| 1471 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1472 | defer ov.deinit(); | ||
| 1473 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident | ||
| 1474 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1475 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | ||
| 1476 | offerKeystroke(alloc, &ov, replica, "b", tty, null_fd); | ||
| 1477 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1478 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1479 | |||
| 1480 | // Every authoritative paint is followed by re-laying the overlay on top, | ||
| 1481 | // because a delta's row content wipes anything drawn over it. That | ||
| 1482 | // repaint is a second, quieter chance to show a prediction that was | ||
| 1483 | // never displayed in the first place — so it asks the same question the | ||
| 1484 | // keystroke path did, and gets the same answer. | ||
| 1485 | paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]); | ||
| 1486 | std.posix.close(p[1]); | ||
| 1487 | |||
| 1488 | var rbuf: [64]u8 = undefined; | ||
| 1489 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 1490 | } | ||
| 1491 | |||
| 1492 | test "the predict stats line is one greppable row of counters" { | ||
| 1493 | var buf: [predict_stats_len]u8 = undefined; | ||
| 1494 | const line = try formatPredictStats(&buf, .{ | ||
| 1495 | .made = 5, | ||
| 1496 | .displayed = 4, | ||
| 1497 | .confirmed = 3, | ||
| 1498 | .contradicted = 2, | ||
| 1499 | .expired = 1, | ||
| 1500 | .suppressed = 6, | ||
| 1501 | }); | ||
| 1502 | // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename | ||
| 1503 | // or a reorder is a broken suite rather than a cosmetic change. | ||
| 1504 | try std.testing.expectEqualStrings( | ||
| 1505 | "predict made=5 displayed=4 confirmed=3 contradicted=2 expired=1 suppressed=6", | ||
| 1506 | line, | ||
| 1507 | ); | ||
| 1508 | } | ||
| 1509 | |||
| 1036 | test "Transport.close is idempotent: the abort path closes what reconnect already closed" { | 1510 | test "Transport.close is idempotent: the abort path closes what reconnect already closed" { |
| 1037 | const alloc = std.testing.allocator; | 1511 | const alloc = std.testing.allocator; |
| 1038 | 1512 | ||