aac3c2da
feat: prediction hides itself on a path too fast to see it
a73x 2026-08-23 11:14
Commit message
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -778,7 +778,7 @@ fn paintOverlay( | |||
| 778 | tty: proto.Size, | 778 | tty: proto.Size, |
| 779 | out_fd: std.posix.fd_t, | 779 | out_fd: std.posix.fd_t, |
| 780 | ) void { | 780 | ) void { |
| 781 | if (!overlay.confident or overlay.pendingCount() == 0) return; | 781 | if (!overlay.visible() or overlay.pendingCount() == 0) return; |
| 782 | var paint: std.ArrayList(u8) = .empty; | 782 | var paint: std.ArrayList(u8) = .empty; |
| 783 | defer paint.deinit(alloc); | 783 | defer paint.deinit(alloc); |
| 784 | paint.appendSlice(alloc, paint_mod.sync_begin) catch return; | 784 | paint.appendSlice(alloc, paint_mod.sync_begin) catch return; |
| @@ -858,10 +858,10 @@ fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 { | |||
| 858 | return std.fmt.bufPrint( | 858 | return std.fmt.bufPrint( |
| 859 | buf, | 859 | buf, |
| 860 | "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ | 860 | "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ |
| 861 | " expired={d} abandoned={d} suppressed={d}", | 861 | " expired={d} abandoned={d} suppressed={d} local={d}", |
| 862 | .{ | 862 | .{ |
| 863 | c.made, c.displayed, c.confirmed, c.contradicted, | 863 | c.made, c.displayed, c.confirmed, c.contradicted, |
| 864 | c.expired, c.abandoned, c.suppressed, | 864 | c.expired, c.abandoned, c.suppressed, c.local, |
| 865 | }, | 865 | }, |
| 866 | ); | 866 | ); |
| 867 | } | 867 | } |
| @@ -2623,12 +2623,17 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" { | |||
| 2623 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | 2623 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned |
| 2624 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | 2624 | const tty = proto.Size{ .cols = 80, .rows = 24 }; |
| 2625 | 2625 | ||
| 2626 | // The keystrokes are stamped with the real clock, so the frames that | ||
| 2627 | // confirm them land 100ms later: a remote path. At 0 the overlay would | ||
| 2628 | // measure a 0ms round trip and hide the promotion this test is about. | ||
| 2629 | const later = std.time.milliTimestamp() + 100; | ||
| 2630 | |||
| 2626 | // One confirm banked, one short of promotion. | 2631 | // One confirm banked, one short of promotion. |
| 2627 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | 2632 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); |
| 2628 | replica.feed("a"); | 2633 | replica.feed("a"); |
| 2629 | try std.testing.expectEqual( | 2634 | try std.testing.expectEqual( |
| 2630 | predict.Verdict.confirmed, | 2635 | predict.Verdict.confirmed, |
| 2631 | reconcileOverlay(alloc, &ov, replica, 1, 0), | 2636 | reconcileOverlay(alloc, &ov, replica, 1, later), |
| 2632 | ); | 2637 | ); |
| 2633 | 2638 | ||
| 2634 | // Two more typed while still invisible, and the promoting confirmation | 2639 | // Two more typed while still invisible, and the promoting confirmation |
| @@ -2639,9 +2644,9 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" { | |||
| 2639 | replica.feed("b"); | 2644 | replica.feed("b"); |
| 2640 | try std.testing.expectEqual( | 2645 | try std.testing.expectEqual( |
| 2641 | predict.Verdict.confirmed, | 2646 | predict.Verdict.confirmed, |
| 2642 | reconcileOverlay(alloc, &ov, replica, 2, 0), | 2647 | reconcileOverlay(alloc, &ov, replica, 2, later), |
| 2643 | ); | 2648 | ); |
| 2644 | try std.testing.expect(ov.confident); | 2649 | try std.testing.expect(ov.visible()); |
| 2645 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | 2650 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); |
| 2646 | // Nothing has been drawn yet: it was queued invisible and no repaint | 2651 | // Nothing has been drawn yet: it was queued invisible and no repaint |
| 2647 | // has happened since the promotion. | 2652 | // has happened since the promotion. |
| @@ -2675,6 +2680,7 @@ test "the predict stats line is one greppable row of counters" { | |||
| 2675 | .expired = 1, | 2680 | .expired = 1, |
| 2676 | .abandoned = 7, | 2681 | .abandoned = 7, |
| 2677 | .suppressed = 6, | 2682 | .suppressed = 6, |
| 2683 | .local = 8, | ||
| 2678 | }); | 2684 | }); |
| 2679 | // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename | 2685 | // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename |
| 2680 | // or a reorder is a broken suite rather than a cosmetic change. The | 2686 | // or a reorder is a broken suite rather than a cosmetic change. The |
| @@ -2682,7 +2688,7 @@ test "the predict stats line is one greppable row of counters" { | |||
| 2682 | // why every one of them is on the line rather than a chosen few. | 2688 | // why every one of them is on the line rather than a chosen few. |
| 2683 | try std.testing.expectEqualStrings( | 2689 | try std.testing.expectEqualStrings( |
| 2684 | "predict made=5 displayed=4 confirmed=3 contradicted=2" ++ | 2690 | "predict made=5 displayed=4 confirmed=3 contradicted=2" ++ |
| 2685 | " expired=1 abandoned=7 suppressed=6", | 2691 | " expired=1 abandoned=7 suppressed=6 local=8", |
| 2686 | line, | 2692 | line, |
| 2687 | ); | 2693 | ); |
| 2688 | } | 2694 | } |
src/predict.zig
| Old | New | ||
|---|---|---|---|
| @@ -96,6 +96,11 @@ pub const Counters = struct { | |||
| 96 | /// saw. Not a subset of `made` either — none of these became | 96 | /// saw. Not a subset of `made` either — none of these became |
| 97 | /// predictions. | 97 | /// predictions. |
| 98 | suppressed: u64 = 0, | 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, | ||
| 99 | }; | 104 | }; |
| 100 | 105 | ||
| 101 | /// What the pty's mode bits say about predicting here. | 106 | /// What the pty's mode bits say about predicting here. |
| @@ -122,6 +127,23 @@ pub const expire_after_frames: u8 = 8; | |||
| 122 | /// the application answers by going quiet. Milliseconds. | 127 | /// the application answers by going quiet. Milliseconds. |
| 123 | pub const expire_after_ms: i64 = 1000; | 128 | pub const expire_after_ms: i64 = 1000; |
| 124 | 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 | |||
| 125 | /// Engine-free mirror of the engine's cursor position. | 147 | /// Engine-free mirror of the engine's cursor position. |
| 126 | pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 }; | 148 | pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 }; |
| 127 | 149 | ||
| @@ -190,10 +212,21 @@ pub const Overlay = struct { | |||
| 190 | alloc: std.mem.Allocator, | 212 | alloc: std.mem.Allocator, |
| 191 | pending: std.ArrayList(Pred) = .empty, | 213 | pending: std.ArrayList(Pred) = .empty, |
| 192 | ctx: Context = .never, | 214 | ctx: Context = .never, |
| 193 | /// The single gate on painting: true in `.always`, false in `.never`, | 215 | /// What the POLICY says about painting: true in `.always`, false in |
| 194 | /// and earned in `.adaptive`. | 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. | ||
| 195 | confident: bool = false, | 218 | confident: bool = false, |
| 196 | streak: u8 = 0, | 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, | ||
| 197 | counters: Counters = .{}, | 230 | counters: Counters = .{}, |
| 198 | cols: u16, | 231 | cols: u16, |
| 199 | rows: u16, | 232 | rows: u16, |
| @@ -310,13 +343,33 @@ pub const Overlay = struct { | |||
| 310 | }) catch return self.suppress(); | 343 | }) catch return self.suppress(); |
| 311 | 344 | ||
| 312 | self.counters.made += 1; | 345 | self.counters.made += 1; |
| 313 | if (self.confident) { | 346 | if (self.visible()) { |
| 314 | self.markPainted(self.pending.items.len - 1); | 347 | self.markPainted(self.pending.items.len - 1); |
| 315 | return .{ .display = cell }; | 348 | return .{ .display = cell }; |
| 316 | } | 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; | ||
| 317 | return .{ .hidden = cell }; | 355 | return .{ .hidden = cell }; |
| 318 | } | 356 | } |
| 319 | 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 | |||
| 320 | fn suppress(self: *Overlay) Outcome { | 373 | fn suppress(self: *Overlay) Outcome { |
| 321 | self.recordSuppressed(); | 374 | self.recordSuppressed(); |
| 322 | return .suppressed; | 375 | return .suppressed; |
| @@ -359,6 +412,7 @@ pub const Overlay = struct { | |||
| 359 | if (shown != null and shown.? == p.cell.ch) { | 412 | if (shown != null and shown.? == p.cell.ch) { |
| 360 | _ = self.pending.orderedRemove(i); | 413 | _ = self.pending.orderedRemove(i); |
| 361 | self.counters.confirmed += 1; | 414 | self.counters.confirmed += 1; |
| 415 | self.samplePath(now_ms -| p.made_ms); | ||
| 362 | self.streak +|= 1; | 416 | self.streak +|= 1; |
| 363 | if (self.ctx == .adaptive and self.streak >= promote_after) { | 417 | if (self.ctx == .adaptive and self.streak >= promote_after) { |
| 364 | self.confident = true; | 418 | self.confident = true; |
| @@ -471,14 +525,17 @@ fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome { | |||
| 471 | }); | 525 | }); |
| 472 | } | 526 | } |
| 473 | 527 | ||
| 474 | /// The clock stands still; tests about time use `seeRowsAt`. | 528 | /// A frame from a remote path. Tests about time or the gate use `seeRowsAt`. |
| 475 | fn seeRows( | 529 | fn seeRows( |
| 476 | alloc: std.mem.Allocator, | 530 | alloc: std.mem.Allocator, |
| 477 | ov: *Overlay, | 531 | ov: *Overlay, |
| 478 | rows: []const []const u8, | 532 | rows: []const []const u8, |
| 479 | seq: u64, | 533 | seq: u64, |
| 480 | ) !Verdict { | 534 | ) !Verdict { |
| 481 | return seeRowsAt(alloc, ov, rows, seq, 0); | 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); | ||
| 482 | } | 539 | } |
| 483 | 540 | ||
| 484 | fn seeRowsAt( | 541 | fn seeRowsAt( |
| @@ -652,6 +709,89 @@ test "adaptive earns the right to display, one confirm at a time" { | |||
| 652 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); | 709 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); |
| 653 | } | 710 | } |
| 654 | 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 | |||
| 655 | test "a contradiction flushes the whole queue, not merely the cell that was wrong" { | 795 | test "a contradiction flushes the whole queue, not merely the cell that was wrong" { |
| 656 | const alloc = std.testing.allocator; | 796 | const alloc = std.testing.allocator; |
| 657 | var ov = Overlay.init(alloc, 80, 24); | 797 | var ov = Overlay.init(alloc, 80, 24); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1353,9 +1353,9 @@ cleanup() { | |||
| 1353 | "$OUT.s8.err" "$OUT.race" "$OUT.g9.err" "$OUT.env1.err" "$OUT.env2.err" \ | 1353 | "$OUT.s8.err" "$OUT.race" "$OUT.g9.err" "$OUT.env1.err" "$OUT.env2.err" \ |
| 1354 | "$OUT.m7.err" "$OUT.m7b.err" "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err" \ | 1354 | "$OUT.m7.err" "$OUT.m7b.err" "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err" \ |
| 1355 | "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ | 1355 | "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ |
| 1356 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ | 1356 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.rwl" "$OUT.pr" \ |
| 1357 | "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.pr.err" \ | 1357 | "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.rwl.err" "$OUT.pr.err" \ |
| 1358 | "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.pr.d" \ | 1358 | "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.rwl.d" "$OUT.pr.d" \ |
| 1359 | "$OUT.d1.d" "$OUT.d2.d" "$OUT.d3a.d" "$OUT.d3b.d" "$OUT.d4.d" \ | 1359 | "$OUT.d1.d" "$OUT.d2.d" "$OUT.d3a.d" "$OUT.d3b.d" "$OUT.d4.d" \ |
| 1360 | "$OUT.d9.d" "$OUT.d10.d" "$OUT.d17.d" \ | 1360 | "$OUT.d9.d" "$OUT.d10.d" "$OUT.d17.d" \ |
| 1361 | "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ | 1361 | "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ |
| @@ -2738,14 +2738,23 @@ D6PID="" | |||
| 2738 | # 4. Raw mode: display is earned, then lost to a keystroke the application | 2738 | # 4. Raw mode: display is earned, then lost to a keystroke the application |
| 2739 | # swallows. rawmode echoes like an editor in insert mode until it is sent | 2739 | # swallows. rawmode echoes like an editor in insert mode until it is sent |
| 2740 | # 0x00, after which it consumes input and prints nothing. | 2740 | # 0x00, after which it consumes input and prints nothing. |
| 2741 | # | ||
| 2742 | # Through delaypipe, like leg 1, and for the same reason leg 4b below is | ||
| 2743 | # NOT: the overlay measures its own round trip and hides everything on a | ||
| 2744 | # path faster than `predict.local_below_ms`. This leg is about the | ||
| 2745 | # earn/lose policy, which only a remote path lets through to the screen. | ||
| 2746 | # Keys are 1s apart so each confirm (600ms) lands before the next key — | ||
| 2747 | # at 0.5s `c` would find only `a` confirmed and stay hidden. | ||
| 2741 | "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rw.d" 2>&1 & | 2748 | "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rw.d" 2>&1 & |
| 2742 | D7PID=$! | 2749 | D7PID=$! |
| 2743 | wait_sock "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound" | 2750 | wait_sock "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound" |
| 2744 | 2751 | ||
| 2745 | set +e | 2752 | set +e |
| 2746 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \ | 2753 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \ |
| 2747 | printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | 2754 | printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \ |
| 2748 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rw" 2> "$OUT.rw.err" | 2755 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ |
| 2756 | --via "$DELAYPIPE | $MUXD proxy --sock $SOCK7 | $DELAYPIPE" \ | ||
| 2757 | > "$OUT.rw" 2> "$OUT.rw.err" | ||
| 2749 | RC=$? | 2758 | RC=$? |
| 2750 | set -e | 2759 | set -e |
| 2751 | [ "$RC" -eq 0 ] || { | 2760 | [ "$RC" -eq 0 ] || { |
| @@ -2781,6 +2790,44 @@ RW_ABND=$(predict_stat "$OUT.rw.err" abandoned) | |||
| 2781 | # exactly the phantom the styled half of this check exists to catch. | 2790 | # exactly the phantom the styled half of this check exists to catch. |
| 2782 | assert_converged "$OUT.rw" "$SOCK7" "raw mode" | 2791 | assert_converged "$OUT.rw" "$SOCK7" "raw mode" |
| 2783 | 2792 | ||
| 2793 | # 4b. The same keystrokes on the bare socket: a local daemon answers in | ||
| 2794 | # about a millisecond, and that is the one path on which a prediction | ||
| 2795 | # can only ever be SEEN when it is wrong. Display is still earned (the | ||
| 2796 | # counters say so) and nothing is painted — `local` is where `c` and | ||
| 2797 | # `j` went instead of `displayed`. `j` still expires: the queue keeps | ||
| 2798 | # judging while hidden, which is how the gate would lift again on a | ||
| 2799 | # box that got slow. The screen check is the point: no phantom `j`, | ||
| 2800 | # because there was never an underlined one to leave behind. | ||
| 2801 | # | ||
| 2802 | # A fresh daemon: the rawmode above has taken its 0x00 and echoes | ||
| 2803 | # nothing now, and the leg needs the insert-mode half back. | ||
| 2804 | kill "$D7PID" 2>/dev/null || true | ||
| 2805 | wait "$D7PID" 2>/dev/null || true | ||
| 2806 | rm -f "$SOCK7" | ||
| 2807 | "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rwl.d" 2>&1 & | ||
| 2808 | D7PID=$! | ||
| 2809 | wait_sock "$SOCK7" "$OUT.rwl.d" "rawmode local daemon never bound" | ||
| 2810 | |||
| 2811 | set +e | ||
| 2812 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \ | ||
| 2813 | printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | ||
| 2814 | MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rwl" 2> "$OUT.rwl.err" | ||
| 2815 | RC=$? | ||
| 2816 | set -e | ||
| 2817 | [ "$RC" -eq 0 ] || { | ||
| 2818 | echo "e2e FAIL: rawmode local client exited $RC" | ||
| 2819 | cat "$OUT.rwl" "$OUT.rwl.err" 2>/dev/null; exit 1; } | ||
| 2820 | want_stat "$OUT.rwl.err" made 4 "raw mode local" | ||
| 2821 | want_stat "$OUT.rwl.err" confirmed 3 "raw mode local" | ||
| 2822 | want_stat "$OUT.rwl.err" displayed 0 "raw mode local" | ||
| 2823 | want_stat "$OUT.rwl.err" local 2 "raw mode local" | ||
| 2824 | want_stat "$OUT.rwl.err" expired 1 "raw mode local" | ||
| 2825 | grep -q "$(printf '\033\[4m')" "$OUT.rwl" && { | ||
| 2826 | echo "e2e FAIL: raw mode local: an underlined prediction reached the screen" | ||
| 2827 | cat -v "$OUT.rwl"; exit 1; | ||
| 2828 | } | ||
| 2829 | assert_converged "$OUT.rwl" "$SOCK7" "raw mode local" | ||
| 2830 | |||
| 2784 | kill "$D7PID" 2>/dev/null || true | 2831 | kill "$D7PID" 2>/dev/null || true |
| 2785 | D7PID="" | 2832 | D7PID="" |
| 2786 | 2833 | ||
| @@ -8136,8 +8183,8 @@ DPID="" | |||
| 8136 | echo " a scenario was added (update the pin) or silently lost" | 8183 | echo " a scenario was added (update the pin) or silently lost" |
| 8137 | exit 1 | 8184 | exit 1 |
| 8138 | } | 8185 | } |
| 8139 | [ "$CONV_COUNT" = "36" ] || { | 8186 | [ "$CONV_COUNT" = "37" ] || { |
| 8140 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 36" | 8187 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" |
| 8141 | exit 1 | 8188 | exit 1 |
| 8142 | } | 8189 | } |
| 8143 | echo "e2e OK (65 scenarios, 36 convergence points)" | 8190 | echo "e2e OK (65 scenarios, 37 convergence points)" |