5570679e
fix: normalize retained control cells in snapshots and deltas
a73x 2026-09-04 19:56
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -9204,3 +9204,19 @@ on a malformed or hostile daemon — which is exactly the case validating a | |||
| 9204 | payload is for. It is refused in the reader rather than stripped in the | 9204 | payload is for. It is refused in the reader rather than stripped in the |
| 9205 | painter because a stripped row is a row the client and the daemon disagree | 9205 | painter because a stripped row is a row the client and the daemon disagree |
| 9206 | about silently, and the dump-parity rule would go with it. | 9206 | about silently, and the dump-parity rule would go with it. |
| 9207 | |||
| 9208 | ## 2026-09-04 — binary output can leave controls in Ghostty cells | ||
| 9209 | |||
| 9210 | The preceding entry's assumption that Ghostty never retains control | ||
| 9211 | codepoints was incorrect: feeding a single DEL byte creates a cell holding | ||
| 9212 | U+007F, and binary output can also leave C0 codepoints. The writer emitted | ||
| 9213 | those cells, but the reader rejected them. A native client then exited with | ||
| 9214 | `SnapshotAborted`; reconnecting failed again while the same cells remained. | ||
| 9215 | |||
| 9216 | Both the cell writer and the shared reader now replace an entire head-form | ||
| 9217 | cluster containing C0 or DEL with U+FFFD. The reader consumes the original | ||
| 9218 | encoded length, preserving subsequent cells, rows, styles and widths. This | ||
| 9219 | also lets a new client attach to snapshots retained by an existing daemon. | ||
| 9220 | Terminal consumers still never receive raw control bytes from those cells. | ||
| 9221 | ASCII runs containing controls and structurally malformed rows remain | ||
| 9222 | errors; the snapshot corruption checks are unchanged. | ||
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -1760,3 +1760,48 @@ test "grid oracle: every cell's wide flag matches ghostty's page cell" { | |||
| 1760 | } | 1760 | } |
| 1761 | } | 1761 | } |
| 1762 | } | 1762 | } |
| 1763 | |||
| 1764 | test "binary output DEL is encoded as a visible cell and replays in snapshots" { | ||
| 1765 | const alloc = std.testing.allocator; | ||
| 1766 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2 }); | ||
| 1767 | defer e.deinit(); | ||
| 1768 | // A single DEL is enough: ghostty stores it as a cell. The old writer | ||
| 1769 | // emitted head=1, text=0x7f, and every client rejected the snapshot. | ||
| 1770 | e.feed("a\x7fb\r\nnext"); | ||
| 1771 | const row = try e.encodeViewportRow(alloc, 0); | ||
| 1772 | defer alloc.free(row); | ||
| 1773 | try std.testing.expectEqualSlices(u8, &.{ 3, 0, 3, 0, 0, 1, 'a', 3, 0xef, 0xbf, 0xbd, 1, 'b' }, row); | ||
| 1774 | const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = 7, .history_rows = 0, .cols = 8, .rows = 2, .epoch = 11 }); | ||
| 1775 | defer alloc.free(snapshot); | ||
| 1776 | const g = try Grid.init(alloc, 1, 1); | ||
| 1777 | defer g.deinit(); | ||
| 1778 | var replica = @import("term").replica.Replica.init(alloc, g); | ||
| 1779 | try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.snapshot, snapshot)); | ||
| 1780 | const dump = try g.dumpPlain(alloc); | ||
| 1781 | defer alloc.free(dump); | ||
| 1782 | try std.testing.expectEqualStrings("a\xef\xbf\xbdb\nnext", dump); | ||
| 1783 | } | ||
| 1784 | |||
| 1785 | test "binary output bounded deterministic flood keeps every encoded viewport replayable" { | ||
| 1786 | const alloc = std.testing.allocator; | ||
| 1787 | const previous_log_level = std.testing.log_level; | ||
| 1788 | std.testing.log_level = .err; | ||
| 1789 | defer std.testing.log_level = previous_log_level; | ||
| 1790 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1791 | defer e.deinit(); | ||
| 1792 | const g = try Grid.init(alloc, 80, 24); | ||
| 1793 | defer g.deinit(); | ||
| 1794 | var replica = @import("term").replica.Replica.init(alloc, g); | ||
| 1795 | var rng = std.Random.DefaultPrng.init(0x1948); | ||
| 1796 | var bytes: [4096]u8 = undefined; | ||
| 1797 | rng.random().bytes(&bytes); | ||
| 1798 | for (0..8) |batch| { | ||
| 1799 | e.feed(bytes[batch * 512 ..][0..512]); | ||
| 1800 | const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = batch + 1, .history_rows = 0, .cols = 80, .rows = 24, .epoch = 11 }); | ||
| 1801 | defer alloc.free(snapshot); | ||
| 1802 | try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.snapshot, snapshot)); | ||
| 1803 | for (g.lines) |row| { | ||
| 1804 | for (row.text.items) |b| try std.testing.expect(b >= 0x20 and b != 0x7f); | ||
| 1805 | } | ||
| 1806 | } | ||
| 1807 | } | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -1370,17 +1370,15 @@ fn asciiCell(wide: Wide, text: []const u8) bool { | |||
| 1370 | return wide == .narrow and text.len == 1 and asciiByte(text[0]); | 1370 | return wide == .narrow and text.len == 1 and asciiByte(text[0]); |
| 1371 | } | 1371 | } |
| 1372 | 1372 | ||
| 1373 | /// Whether a head-form cell's text is a cluster no terminal will ACT on. | 1373 | /// A visible replacement for a cell cluster containing terminal controls. |
| 1374 | /// | 1374 | /// Ghostty can retain DEL and C0 codepoints after binary output. They are |
| 1375 | /// The client writes a cell's text straight to the user's real terminal | 1375 | /// cell contents, never instructions for the client's terminal: replacing |
| 1376 | /// (`paint.rowToVtFrom`), and there is no VT parser on the client side any | 1376 | /// the whole cluster preserves its cell position without executing bytes. |
| 1377 | /// more to absorb what the daemon sent. So a control byte in a cell is | 1377 | /// The reader applies this too, so a client can attach to an older daemon |
| 1378 | /// refused HERE, in the reader, where every consumer of the wire is covered | 1378 | /// whose writer emitted those controls unchanged. |
| 1379 | /// at once: an honest ghostty grid never holds one, and a daemon that sends | 1379 | fn safeCellText(text: []const u8) []const u8 { |
| 1380 | /// one is spelling an escape sequence across cells onto somebody's screen. | 1380 | for (text) |b| if (b < 0x20 or b == 0x7F) return "\xef\xbf\xbd"; |
| 1381 | fn plainText(text: []const u8) bool { | 1381 | return text; |
| 1382 | for (text) |b| if (b < 0x20 or b == 0x7F) return false; | ||
| 1383 | return true; | ||
| 1384 | } | 1382 | } |
| 1385 | 1383 | ||
| 1386 | pub const CellRowWriter = struct { | 1384 | pub const CellRowWriter = struct { |
| @@ -1409,8 +1407,9 @@ pub const CellRowWriter = struct { | |||
| 1409 | return .{ .list = list, .alloc = alloc, .prefix_at = at }; | 1407 | return .{ .list = list, .alloc = alloc, .prefix_at = at }; |
| 1410 | } | 1408 | } |
| 1411 | 1409 | ||
| 1412 | pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void { | 1410 | pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, raw_text: []const u8) !void { |
| 1413 | std.debug.assert(text.len <= cell_text_max); | 1411 | std.debug.assert(raw_text.len <= cell_text_max); |
| 1412 | const text = safeCellText(raw_text); | ||
| 1414 | std.debug.assert(style.flags & flags_reserved == 0); | 1413 | std.debug.assert(style.flags & flags_reserved == 0); |
| 1415 | if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush(); | 1414 | if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush(); |
| 1416 | if (self.run.items.len == 0) self.run_style = style; | 1415 | if (self.run.items.len == 0) self.run_style = style; |
| @@ -1591,8 +1590,9 @@ pub const CellRowReader = struct { | |||
| 1591 | const head = self.rest[0]; | 1590 | const head = self.rest[0]; |
| 1592 | const len: usize = head & 0x3f; | 1591 | const len: usize = head & 0x3f; |
| 1593 | if (self.rest.len < 1 + len) return error.BadPayload; | 1592 | if (self.rest.len < 1 + len) return error.BadPayload; |
| 1594 | const text = self.rest[1 .. 1 + len]; | 1593 | const text = safeCellText(self.rest[1 .. 1 + len]); |
| 1595 | if (!plainText(text)) return error.BadPayload; | 1594 | // Consume the encoded length, not the replacement's UTF-8 length: |
| 1595 | // following cells and rows still begin at their original offsets. | ||
| 1596 | self.rest = self.rest[1 + len ..]; | 1596 | self.rest = self.rest[1 + len ..]; |
| 1597 | return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text }; | 1597 | return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text }; |
| 1598 | } | 1598 | } |
| @@ -2932,14 +2932,44 @@ test "cellrow: an ascii run carrying a control byte is refused" { | |||
| 2932 | try std.testing.expectError(error.BadPayload, r.next()); | 2932 | try std.testing.expectError(error.BadPayload, r.next()); |
| 2933 | } | 2933 | } |
| 2934 | 2934 | ||
| 2935 | test "cellrow: a head-form cell whose cluster holds a control byte is refused" { | 2935 | test "cellrow: head-form controls become visible replacements without shifting cells or rows" { |
| 2936 | const alloc = std.testing.allocator; | 2936 | const alloc = std.testing.allocator; |
| 2937 | var list: std.ArrayList(u8) = .empty; | 2937 | var list: std.ArrayList(u8) = .empty; |
| 2938 | defer list.deinit(alloc); | 2938 | defer list.deinit(alloc); |
| 2939 | // ncells=1; one non-ascii run; head byte says narrow, 2 bytes; "\x1b[". | 2939 | const unsafe = "a\x1b[31mb"; |
| 2940 | try list.appendSlice(alloc, &[_]u8{ 1, 0, 1, 0, 0x00, 0x02, 0x1b, '[' }); | 2940 | // Two styled cells, first wide with an embedded ESC. This is an old |
| 2941 | // daemon's wire: the current writer would normalize it before sending. | ||
| 2942 | try list.appendSlice(alloc, &.{ 2, 0, 2, 0, mask_flags, 1, 0, (1 << 6) | unsafe.len }); | ||
| 2943 | try list.appendSlice(alloc, unsafe); | ||
| 2944 | try list.appendSlice(alloc, &.{ 1, 'z', 1, 0, 1, 0, mask_ascii, 'Y' }); | ||
| 2941 | var r = try CellRowReader.init(list.items); | 2945 | var r = try CellRowReader.init(list.items); |
| 2942 | try std.testing.expectError(error.BadPayload, r.next()); | 2946 | const replaced = (try r.next()).?; |
| 2947 | try std.testing.expectEqualStrings("\xef\xbf\xbd", replaced.text); | ||
| 2948 | try std.testing.expectEqual(Wide.wide, replaced.wide); | ||
| 2949 | try std.testing.expectEqual(@as(u16, 1), replaced.style.flags); | ||
| 2950 | try std.testing.expectEqualStrings("z", (try r.next()).?.text); | ||
| 2951 | try std.testing.expectEqual(@as(?DecodedCell, null), try r.next()); | ||
| 2952 | var next = try CellRowReader.init(r.remaining()); | ||
| 2953 | try std.testing.expectEqualStrings("Y", (try next.next()).?.text); | ||
| 2954 | try std.testing.expectEqual(@as(?DecodedCell, null), try next.next()); | ||
| 2955 | try std.testing.expectEqual(@as(usize, 0), next.remaining().len); | ||
| 2956 | } | ||
| 2957 | |||
| 2958 | test "cellrow: writer and old-wire reader replace every C0 and DEL control" { | ||
| 2959 | const alloc = std.testing.allocator; | ||
| 2960 | for (0..33) |i| { | ||
| 2961 | const control: u8 = if (i == 32) 0x7f else @intCast(i); | ||
| 2962 | var list: std.ArrayList(u8) = .empty; | ||
| 2963 | defer list.deinit(alloc); | ||
| 2964 | var w = try CellRowWriter.begin(&list, alloc); | ||
| 2965 | try w.cell(.{}, .narrow, &.{control}); | ||
| 2966 | w.finish(); | ||
| 2967 | try std.testing.expectEqualSlices(u8, &.{ 1, 0, 1, 0, 0, 3, 0xef, 0xbf, 0xbd }, list.items); | ||
| 2968 | var old = try CellRowReader.init(&.{ 1, 0, 1, 0, 0, 1, control }); | ||
| 2969 | try std.testing.expectEqualStrings("\xef\xbf\xbd", (try old.next()).?.text); | ||
| 2970 | try std.testing.expectEqual(@as(?DecodedCell, null), try old.next()); | ||
| 2971 | try std.testing.expectEqual(@as(usize, 0), old.remaining().len); | ||
| 2972 | } | ||
| 2943 | } | 2973 | } |
| 2944 | 2974 | ||
| 2945 | test "cellrow: an OSC 52 spelled across cells never decodes into a row" { | 2975 | test "cellrow: an OSC 52 spelled across cells never decodes into a row" { |
src/engine/replica.zig
| Old | New | ||
|---|---|---|---|
| @@ -629,3 +629,33 @@ test "scrollStart: rows count up from the live viewport top, saturating at row 0 | |||
| 629 | test { | 629 | test { |
| 630 | std.testing.refAllDeclsRecursive(@This()); | 630 | std.testing.refAllDeclsRecursive(@This()); |
| 631 | } | 631 | } |
| 632 | |||
| 633 | test "replica: old daemon snapshot containing DEL recovers without changing its wire or session" { | ||
| 634 | const alloc = std.testing.allocator; | ||
| 635 | const g = try Grid.init(alloc, 1, 1); | ||
| 636 | defer g.deinit(); | ||
| 637 | var replica = Replica.init(alloc, g); | ||
| 638 | var snapshot: std.ArrayList(u8) = .empty; | ||
| 639 | defer snapshot.deinit(alloc); | ||
| 640 | var prefix: [proto.snapshot_prefix_len]u8 = undefined; | ||
| 641 | proto.writeSnapshotPrefix(&prefix, .{ .seq = 37, .history_rows = 0, .cols = 8, .rows = 2, .epoch = 93 }); | ||
| 642 | try snapshot.appendSlice(alloc, &prefix); | ||
| 643 | var cursor: [proto.snapshot_cursor_len]u8 = undefined; | ||
| 644 | proto.writeSnapshotCursor(&cursor, 4, 1); | ||
| 645 | try snapshot.appendSlice(alloc, &cursor); | ||
| 646 | // Literal old encoder output, deliberately bypassing today's writer: | ||
| 647 | // first row "a<DEL>b", second row "next". A retained daemon grid sends | ||
| 648 | // this same unsafe cell on every new attach until it is overwritten. | ||
| 649 | try snapshot.appendSlice(alloc, &.{ 3, 0, 3, 0, 0, 1, 'a', 1, 0x7f, 1, 'b', 4, 0, 4, 0, proto.mask_ascii, 'n', 'e', 'x', 't' }); | ||
| 650 | for (0..2) |_| { | ||
| 651 | replica.state_since_attach = false; | ||
| 652 | try std.testing.expectEqual(Replica.Applied.painted, try replica.apply(.snapshot, snapshot.items)); | ||
| 653 | try std.testing.expect(replica.state_since_attach); | ||
| 654 | try std.testing.expectEqual(@as(u64, 37), replica.last_seq); | ||
| 655 | try std.testing.expectEqual(@as(u64, 93), replica.session_epoch); | ||
| 656 | const dump = try g.dumpPlain(alloc); | ||
| 657 | defer alloc.free(dump); | ||
| 658 | try std.testing.expectEqualStrings("a\xef\xbf\xbdb\nnext", dump); | ||
| 659 | try std.testing.expectEqual(grid_mod.CursorPos{ .x = 4, .y = 1 }, g.cursor); | ||
| 660 | } | ||
| 661 | } | ||
test/native.sh
| Old | New | ||
|---|---|---|---|
| @@ -92,18 +92,27 @@ p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}') | |||
| 92 | [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; } | 92 | [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; } |
| 93 | ok "window-side total p99 ${p99} us under 20000 us" | 93 | ok "window-side total p99 ${p99} us under 20000 us" |
| 94 | 94 | ||
| 95 | # Raw DEL output previously crashed the reader and prevented reattachment. | ||
| 96 | # Keep this producer bounded, then retain a DEL beside the render marker. | ||
| 97 | printf '%s\n' "text:head -c 4096 /dev/zero | tr '\000' '\177'" >&8 | ||
| 98 | printf 'key:enter\n' >&8 | ||
| 99 | |||
| 95 | # Read back real pixels after asking the shell to draw coloured text. The | 100 | # Read back real pixels after asking the shell to draw coloured text. The |
| 96 | # shell's command echo cannot satisfy the red-pixel assertion. | 101 | # shell's command echo cannot satisfy the red-pixel assertion. |
| 97 | CAPTURE="$OUT.native.ppm" | 102 | CAPTURE="$OUT.native.ppm" |
| 98 | printf '%s\n' "text:printf '\033[2J\033[H\033[31mNATIVE-%s\033[0m\n' RENDER" >&8 | 103 | printf '%s\n' "text:printf '\033[2J\033[H\033[31mNATIVE-%s\177\033[0m\n' RENDER" >&8 |
| 99 | printf 'key:enter\n' >&8 | 104 | printf 'key:enter\n' >&8 |
| 100 | wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon" | 105 | wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon" |
| 101 | printf 'capture:%s\n' "$CAPTURE" >&8 | 106 | printf 'capture:%s\n' "$CAPTURE" >&8 |
| 102 | wait_until 50 "framebuffer capture completed" '[ -s "$CAPTURE" ]' | 107 | wait_until 50 "framebuffer capture completed" '[ -s "$CAPTURE" ]' |
| 103 | tail -n +4 "$CAPTURE" | od -An -v -tu1 | awk ' | 108 | has_red_glyphs() { |
| 104 | { for (i=1; i<=NF; i++) { channel=(n++ % 3); if(channel==0) r=$i; else if(channel==1) g=$i; else if(r>80 && r>g*2 && r>$i*2) red++; } } | 109 | [ -s "$1" ] || return 1 |
| 105 | END { if(red<30) exit 1; } | 110 | tail -n +4 "$1" | od -An -v -tu1 | awk ' |
| 106 | ' || { echo "native FAIL: framebuffer contains no rendered red text"; exit 1; } | 111 | { for (i=1; i<=NF; i++) { channel=(n++ % 3); if(channel==0) r=$i; else if(channel==1) g=$i; else if(r>80 && r>g*2 && r>$i*2) red++; } } |
| 112 | END { if(red<30) exit 1; } | ||
| 113 | ' | ||
| 114 | } | ||
| 115 | has_red_glyphs "$CAPTURE" || { echo "native FAIL: framebuffer contains no rendered red text"; exit 1; } | ||
| 107 | ok "the OpenGL framebuffer contains the session's coloured glyphs" | 116 | ok "the OpenGL framebuffer contains the session's coloured glyphs" |
| 108 | 117 | ||
| 109 | # 5. A resize through the hook is followed by the daemon: cols shrink from | 118 | # 5. A resize through the hook is followed by the daemon: cols shrink from |
| @@ -125,6 +134,18 @@ wait "$GPID" || { echo "native FAIL: muxg did not exit 0"; cat "$GLOG"; exit 1; | |||
| 125 | "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; } | 134 | "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; } |
| 126 | ok "closing the window detaches and leaves the session on its daemon" | 135 | ok "closing the window detaches and leaves the session on its daemon" |
| 127 | 136 | ||
| 137 | # Reattach to the snapshot containing DEL; it must still paint and detach. | ||
| 138 | SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \ | ||
| 139 | "$MUXG" --sock "$SOCK" 2>"$OUT.native-reattach.log" & | ||
| 140 | RPID=$! | ||
| 141 | defer_kill "$RPID" | ||
| 142 | printf 'capture:%s\n' "$OUT.reattach.ppm" >&8 | ||
| 143 | wait_until 50 "reattached framebuffer contains session glyphs" 'printf "capture:%s\n" "$OUT.reattach.ppm" >&8; has_red_glyphs "$OUT.reattach.ppm"' | ||
| 144 | printf 'quit\n' >&8 | ||
| 145 | wait_pid_gone "$RPID" "reattached muxg exits on quit" | ||
| 146 | wait "$RPID" || { echo "native FAIL: reattach after DEL failed"; cat "$OUT.native-reattach.log"; exit 1; } | ||
| 147 | ok "raw DEL output survives rendering and native reattachment" | ||
| 148 | |||
| 128 | pipe_mux "$OUT.terminal" "$OUT.terminal.err" timeout 15 "$MUX" --sock "$SOCK" | 149 | pipe_mux "$OUT.terminal" "$OUT.terminal.err" timeout 15 "$MUX" --sock "$SOCK" |
| 129 | await_out "$OUT.terminal" "NATIVE-RENDER" "terminal attach sees the GUI session" | 150 | await_out "$OUT.terminal" "NATIVE-RENDER" "terminal attach sees the GUI session" |
| 130 | pipe_send "printf 'terminal-%%s\\n' attached\n" | 151 | pipe_send "printf 'terminal-%%s\\n' attached\n" |