18c4c7f5
fix: tp2b syncs on unique markers and silence — sentinel counts have no order under split deltas
a73x 2026-08-10 11:30
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1654,11 +1654,18 @@ send PS1=tp2rdy@\n | |||
| 1654 | # Twice, and the fixture's cursor is what makes that meaningful: the first | 1654 | # Twice, and the fixture's cursor is what makes that meaningful: the first |
| 1655 | # hit is the ECHO of the assignment line, the second is the prompt the | 1655 | # hit is the ECHO of the assignment line, the second is the prompt the |
| 1656 | # shell painted from it. Only the second proves PS1 took effect. | 1656 | # shell painted from it. Only the second proves PS1 took effect. |
| 1657 | # | ||
| 1658 | # Counting sentinel occurrences is safe HERE and nowhere else in tp2: this | ||
| 1659 | # grid is nearly empty, so nothing scrolls, no repaint splits across | ||
| 1660 | # deltas, and the two hits keep the order they were painted in. tp2b runs | ||
| 1661 | # on a full screen and cannot assume any of that — see the hazard note | ||
| 1662 | # there before copying this shape. | ||
| 1657 | expect tp2rdy@ 10000 | 1663 | expect tp2rdy@ 10000 |
| 1658 | expect tp2rdy@ 10000 | 1664 | expect tp2rdy@ 10000 |
| 1659 | send printf 'tp2-%s\\n' claim\n | 1665 | send printf 'tp2-%s\\n' claim\n |
| 1660 | expect tp2-claim 10000 | 1666 | expect tp2-claim 10000 |
| 1661 | expect tp2rdy@ 10000 | 1667 | expect tp2rdy@ 10000 |
| 1668 | settle 500 15000 | ||
| 1662 | send \x1c | 1669 | send \x1c |
| 1663 | waitexit 10000 | 1670 | waitexit 10000 |
| 1664 | EOF | 1671 | EOF |
| @@ -1681,48 +1688,42 @@ assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30 | |||
| 1681 | # trailing prompt is consumed BEFORE the resize. That leaves the expect | 1688 | # trailing prompt is consumed BEFORE the resize. That leaves the expect |
| 1682 | # after `resize` satisfiable only by the answering snapshot's repaint. | 1689 | # after `resize` satisfiable only by the answering snapshot's repaint. |
| 1683 | # | 1690 | # |
| 1684 | # THE HAZARD, which cost a 1-in-21 hang before it was understood: on a | 1691 | # THE HAZARD, and it is worth stating precisely because three shapes of |
| 1685 | # FULL screen the output scrolls, and the daemon can flush the new prompt | 1692 | # this scenario died to it: on a FULL screen the output scrolls, and the |
| 1686 | # row in one delta and the scrolled rows in a LATER one. A sentinel can | 1693 | # daemon can flush the new prompt row in one delta and the scrolled rows |
| 1687 | # therefore reach the byte stream ~600 bytes BEFORE an output needle that | 1694 | # in a LATER one. Prompt-row sentinels therefore have NO reliable order |
| 1688 | # precedes it in this script. Any `expect NEEDLE` followed by `expect | 1695 | # relative to the output rows around them, and counting their occurrences |
| 1689 | # SENTINEL` can then deadlock: the needle's match consumes the cursor past | 1696 | # fails in both directions. Too late: the sentinel's bytes arrive before |
| 1690 | # the sentinel, and the sentinel expect waits on a shell that has gone | 1697 | # the output needle that precedes it in the script, the needle's match |
| 1691 | # idle — a hang, on a client whose grid was right all along. So no expect | 1698 | # consumes the cursor past them, and the next sentinel expect waits on a |
| 1692 | # here may assume that one ROW reached the stream before another. | 1699 | # shell that has gone idle — a hang. Too early: a post-resize sentinel |
| 1700 | # matches bytes from BEFORE the resize and the client detaches mid-repaint, | ||
| 1701 | # leaving a capture in the old geometry. Measured under load, that second | ||
| 1702 | # one is the dangerous one: it produces a diff byte-identical to row 7's | ||
| 1703 | # — missing rows and a still-wrapped wide row — so a healthy build fails | ||
| 1704 | # wearing the bug's own signature. | ||
| 1693 | # | 1705 | # |
| 1694 | # What this scenario must NOT do to fix that is replace `expect 00007` | 1706 | # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS |
| 1695 | # with a second sentinel. Measured against the row-7 mutation: 00007 is | 1707 | # (`fill-done`, `00007`) for progress: each is printed once, by a command |
| 1696 | # 10/10 red, doubled sentinels 1/10. The reason is that a sentinel is | 1708 | # whose typed line cannot contain it, so its arrival is a fact about the |
| 1697 | # satisfied by any repaint of the prompt row, including the ones the | 1709 | # session rather than about paint order. And SILENCE for quiesce: `settle` |
| 1698 | # scroll itself emits, so the resize fires before the wide row's deltas | 1710 | # waits for the master to stop changing, which names no row at all and so |
| 1699 | # have landed and the later deltas — which bypass the replica — heal the | 1711 | # cannot be raced by one. Occurrence-counting of a REPAINTED row — which |
| 1700 | # damage before the repaint can expose it. `expect 00007` is what holds | 1712 | # is what a prompt sentinel is — is not a substitute for either. |
| 1701 | # the resize until the wide row is actually ON the screen it will be | ||
| 1702 | # repainted from. | ||
| 1703 | # | 1713 | # |
| 1704 | # The post-resize needle is 91 zeros, and it is a WIDTH WITNESS rather | 1714 | # `expect 00007` earns its place as more than a marker: its arrival proves |
| 1705 | # than a sync point: at 90 columns the wide row is painted wrapped, so the | 1715 | # the daemon processed the wide row while the grid was still 90 columns, |
| 1706 | # longest contiguous run the client can emit is 90, and only a paint at | 1716 | # which is what puts the damage on the screen the resize will repaint from. |
| 1707 | # the reflowed width can produce 91. That makes it immune to the hazard | ||
| 1708 | # above (it cannot have arrived before the resize, whatever order the rows | ||
| 1709 | # came in) and a discriminator in its own right — under the mutation it | ||
| 1710 | # never arrives and the leg fails on the fixture's timeout in ~10s rather | ||
| 1711 | # than on the convergence diff. The trailing sentinel is safe where it now | ||
| 1712 | # sits: a repaint emits its rows top to bottom, so the prompt row follows | ||
| 1713 | # the wide row within the very paint the witness just matched. | ||
| 1714 | set +e | 1717 | set +e |
| 1715 | timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ | 1718 | timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ |
| 1716 | "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' | 1719 | "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' |
| 1717 | expect \x1b[?1049h 10000 | 1720 | expect \x1b[?1049h 10000 |
| 1718 | send seq 1 60; printf 'fill-%s\\n' done\n | 1721 | send seq 1 60; printf 'fill-%s\\n' done\n |
| 1719 | expect fill-done 10000 | 1722 | expect fill-done 10000 |
| 1720 | expect tp2rdy@ 10000 | ||
| 1721 | send printf '%095d\\n' 7\n | 1723 | send printf '%095d\\n' 7\n |
| 1722 | expect 00007 10000 | 1724 | expect 00007 10000 |
| 1723 | resize 100 30 | 1725 | resize 100 30 |
| 1724 | expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000 | 1726 | settle 500 15000 |
| 1725 | expect tp2rdy@ 10000 | ||
| 1726 | send \x1c | 1727 | send \x1c |
| 1727 | waitexit 10000 | 1728 | waitexit 10000 |
| 1728 | EOF | 1729 | EOF |
| @@ -1731,16 +1732,14 @@ set -e | |||
| 1731 | [ "$RC" -eq 0 ] || { | 1732 | [ "$RC" -eq 0 ] || { |
| 1732 | echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } | 1733 | echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } |
| 1733 | # Graded against the mutation (row 7: the prefix resize commented out), | 1734 | # Graded against the mutation (row 7: the prefix resize commented out), |
| 1734 | # 10 runs out of 10 red. What the mutated build does now is fail ABOVE, | 1735 | # and this is where it dies — `settle` waits for the repaint rather than |
| 1735 | # on the width witness: the 91-zero needle never arrives, so the fixture | 1736 | # racing it, so the capture faithfully records what the stale geometry |
| 1736 | # times out at that verb and the RC check fires. Before the witness | 1737 | # painted, and both halves of it land in one diff: the 30-row grid comes |
| 1737 | # existed the same build reached this line and failed here instead, with | 1738 | # back short its top rows, because a 28-row replica has only 28 rows to |
| 1738 | # both halves of the stale geometry in one diff — the 30-row grid coming | 1739 | # repaint from, and the 95-wide row comes back still WRAPPED at 90 into |
| 1739 | # back short its top rows because a 28-row replica has only 28 to repaint | 1740 | # "...0" + "00007" while the daemon had rejoined it into one row at 100. |
| 1740 | # from, and the 95-wide row coming back still WRAPPED at 90 into "...0" + | 1741 | # Quiescing before the check does not heal that — the damage is in the |
| 1741 | # "00007" while the daemon had rejoined it at 100. Both checks are kept: | 1742 | # repaint itself, not in bytes that were still in flight. |
| 1742 | # the witness names the width directly and fails in ~10s, convergence | ||
| 1743 | # still covers everything the witness does not look at. | ||
| 1744 | assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 | 1743 | assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 |
| 1745 | rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ | 1744 | rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ |
| 1746 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" | 1745 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" |
test/ptyclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -72,6 +72,7 @@ const Verb = union(enum) { | |||
| 72 | send: []u8, | 72 | send: []u8, |
| 73 | expect: struct { needle: []u8, deadline_ms: u64 }, | 73 | expect: struct { needle: []u8, deadline_ms: u64 }, |
| 74 | resize: struct { cols: u16, rows: u16 }, | 74 | resize: struct { cols: u16, rows: u16 }, |
| 75 | settle: struct { quiet_ms: u64, deadline_ms: u64 }, | ||
| 75 | waitexit: u64, | 76 | waitexit: u64, |
| 76 | 77 | ||
| 77 | /// Exhaustive on purpose: a future arm that owns memory will not | 78 | /// Exhaustive on purpose: a future arm that owns memory will not |
| @@ -81,7 +82,7 @@ const Verb = union(enum) { | |||
| 81 | switch (self) { | 82 | switch (self) { |
| 82 | .send => |s| alloc.free(s), | 83 | .send => |s| alloc.free(s), |
| 83 | .expect => |e| alloc.free(e.needle), | 84 | .expect => |e| alloc.free(e.needle), |
| 84 | .resize, .waitexit => {}, | 85 | .resize, .settle, .waitexit => {}, |
| 85 | } | 86 | } |
| 86 | } | 87 | } |
| 87 | }; | 88 | }; |
| @@ -114,6 +115,12 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb { | |||
| 114 | const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | 115 | const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; |
| 115 | if (it.next() != null) return error.BadVerb; | 116 | if (it.next() != null) return error.BadVerb; |
| 116 | return .{ .resize = .{ .cols = cols, .rows = rows } }; | 117 | return .{ .resize = .{ .cols = cols, .rows = rows } }; |
| 118 | } else if (std.mem.eql(u8, verb, "settle")) { | ||
| 119 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 120 | const quiet = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 121 | const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 122 | if (it.next() != null) return error.BadVerb; | ||
| 123 | return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } }; | ||
| 117 | } else if (std.mem.eql(u8, verb, "waitexit")) { | 124 | } else if (std.mem.eql(u8, verb, "waitexit")) { |
| 118 | const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; | 125 | const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; |
| 119 | return .{ .waitexit = ms }; | 126 | return .{ .waitexit = ms }; |
| @@ -293,6 +300,49 @@ pub fn main() !void { | |||
| 293 | pty.resize(r.cols, r.rows) catch |e| | 300 | pty.resize(r.cols, r.rows) catch |e| |
| 294 | fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); | 301 | fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); |
| 295 | }, | 302 | }, |
| 303 | .settle => |s| { | ||
| 304 | // Quiesce by CONDITION rather than by needle: succeed once | ||
| 305 | // the master has gone quiet_ms without a byte. A needle can | ||
| 306 | // only ever say "this row arrived", and once a scrolling | ||
| 307 | // repaint splits across deltas the rows have no reliable | ||
| 308 | // order — so every needle-shaped wait is a race with some | ||
| 309 | // row it did not name, in both directions (a needle that | ||
| 310 | // matches too early detaches mid-repaint; one whose bytes | ||
| 311 | // were consumed by an earlier match waits forever). Silence | ||
| 312 | // names no row at all, which is why it closes all of those | ||
| 313 | // at once. | ||
| 314 | // | ||
| 315 | // Sound only where it is used: after the session has gone | ||
| 316 | // idle. The bytes still in flight are then deltas the daemon | ||
| 317 | // has already generated, so a quiet window means they landed | ||
| 318 | // — it is not a claim that nothing WILL ever arrive. | ||
| 319 | const start = std.time.milliTimestamp(); | ||
| 320 | var last_seen = start; | ||
| 321 | while (true) { | ||
| 322 | const before = exp.buf.items.len; | ||
| 323 | if (!try drain(alloc, &pty, out, &exp)) { | ||
| 324 | // Nothing has asked the client to leave yet, so a | ||
| 325 | // closed pty here is a death, not a quiet screen. | ||
| 326 | std.debug.print("ptyclient: verb {d}: client closed the pty while settling\n", .{verb_no}); | ||
| 327 | dumpTail(exp.buf.items); | ||
| 328 | std.process.exit(EXIT_CHILD_DIED); | ||
| 329 | } | ||
| 330 | const now = std.time.milliTimestamp(); | ||
| 331 | if (exp.buf.items.len != before) last_seen = now; | ||
| 332 | if (now - last_seen >= s.quiet_ms) break; | ||
| 333 | if (now - start > s.deadline_ms) { | ||
| 334 | std.debug.print("ptyclient: verb {d}: settle: never saw {d}ms of silence within {d}ms\n", .{ verb_no, s.quiet_ms, s.deadline_ms }); | ||
| 335 | dumpTail(exp.buf.items); | ||
| 336 | std.process.exit(EXIT_TIMEOUT); | ||
| 337 | } | ||
| 338 | var fds = [_]std.posix.pollfd{ | ||
| 339 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 340 | }; | ||
| 341 | // Well under the smallest useful quiet window, so the | ||
| 342 | // silence is measured rather than rounded up to a poll. | ||
| 343 | _ = try std.posix.poll(&fds, 20); | ||
| 344 | } | ||
| 345 | }, | ||
| 296 | .waitexit => |deadline_ms| { | 346 | .waitexit => |deadline_ms| { |
| 297 | const start = std.time.milliTimestamp(); | 347 | const start = std.time.milliTimestamp(); |
| 298 | while (true) { | 348 | while (true) { |
| @@ -410,6 +460,18 @@ test "parseLine: verbs, spaces in payloads, comments" { | |||
| 410 | const w = (try parseLine(alloc, "waitexit 10000")).?; | 460 | const w = (try parseLine(alloc, "waitexit 10000")).?; |
| 411 | try std.testing.expectEqual(@as(u64, 10000), w.waitexit); | 461 | try std.testing.expectEqual(@as(u64, 10000), w.waitexit); |
| 412 | 462 | ||
| 463 | const st = (try parseLine(alloc, "settle 500 15000")).?; | ||
| 464 | try std.testing.expectEqual(@as(u64, 500), st.settle.quiet_ms); | ||
| 465 | try std.testing.expectEqual(@as(u64, 15000), st.settle.deadline_ms); | ||
| 466 | // Both bounds are required and there are exactly two: a settle that | ||
| 467 | // silently defaulted its deadline would hang a suite instead of | ||
| 468 | // failing it, and a third token means the operator meant something | ||
| 469 | // this verb does not do. | ||
| 470 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500")); | ||
| 471 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 15000 extra")); | ||
| 472 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle soon 15000")); | ||
| 473 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 later")); | ||
| 474 | |||
| 413 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x")); | 475 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x")); |
| 414 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline")); | 476 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline")); |
| 415 | // Doubled space: the needle would be empty, and an empty needle is a | 477 | // Doubled space: the needle would be empty, and an empty needle is a |