a73x

5570679e

fix: normalize retained control cells in snapshots and deltas

a73x   2026-09-04 19:56

Commit message
fix: normalize retained control cells in snapshots and deltas

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"