diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index 221d545f8..f9d6f6be7 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -1067,20 +1067,25 @@ fn decodeGraphemes( break :target .{ .row = row, .cell = cell }; }; - // Always consume every declared codepoint. Invalid scalars and - // suffixes that exceed the native capacity are dropped - // independently without affecting the rest of the grid. + // Always consume every declared codepoint. Invalid scalars and NUL are + // not meaningful grapheme suffix components and are ignored. If native + // capacity is exhausted, remove any prefix already attached so the + // cell never exposes a truncated cluster. var accept = target != null; for (0..cp_count) |_| { const cp = try io.readInt(reader, u32); if (!accept) continue; - if (!validScalar(cp)) continue; + if (cp == 0 or !validScalar(cp)) continue; page.appendGrapheme( target.?.row, target.?.cell, @intCast(cp), ) catch { + if (target.?.cell.hasGrapheme()) { + page.clearGrapheme(target.?.cell); + page.updateRowGraphemeFlag(target.?.row); + } accept = false; }; } @@ -1752,8 +1757,9 @@ test "grid drops undeliverable grapheme entries" { // Valid entry with one invalid scalar dropped from within it. try io.writeInt(&writer, u16, 0); try io.writeInt(&writer, u16, 0); - try io.writeInt(&writer, u16, 3); + try io.writeInt(&writer, u16, 4); try io.writeInt(&writer, u32, 0x0301); + try io.writeInt(&writer, u32, 0); try io.writeInt(&writer, u32, 0xD800); try io.writeInt(&writer, u32, 0x0302); // Duplicate entry for the same cell is consumed and dropped. @@ -1794,6 +1800,48 @@ test "grid drops undeliverable grapheme entries" { try testing.expect(!page.getRowAndCell(3, 0).cell.hasGrapheme()); } +test "grid drops a complete grapheme when capacity fails mid-cluster" { + const testing = std.testing; + var page = try TerminalPage.init(.{ + .cols = 1, + .rows = 1, + .grapheme_bytes = 16, + }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'x' })); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 0); + // BitmapAllocator rounds this capacity to 64 four-codepoint chunks. The + // 129th suffix needs 33 new chunks while the old 32-chunk slice is still + // live, forcing the append's atomic replacement allocation to fail. + try io.writeInt(&writer, u16, 129); + for (0..129) |i| try io.writeInt( + &writer, + u32, + @intCast(0x0300 + i), + ); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + + const cell = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), cell.cell.codepoint()); + try testing.expect(!cell.cell.hasGrapheme()); + try testing.expect(!cell.row.grapheme); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); +} + test "grid encodes rows at their narrowest width" { const testing = std.testing; var page = try TerminalPage.init(.{