- commit
- 3971f2d
- parent
- 3df5416
- author
- Michael Sakaluk
- date
- 2026-03-18 13:18:02 -0400 EDT
fix: two-phase terminal state serialization for nested session cursor corruption Fixes #31 (cursor position corruption on re-attach over SSH). Partially addresses #86 (cursor style error after attach). Contributes to #96 (testing framework). Problem: When running nested zmx sessions (zmx→SSH→zmx), re-attaching to the outer session causes cursor positions and screen content to appear at wrong rows. The root cause is that serializeTerminalState() writes scrollback + visible content sequentially. Scrollback lines scroll the terminal before CUP sequences arrive, shifting all visible content by the scrollback overflow. Fix: Split serialization into two phases: 1. Phase 1: Emit scrollback content only (no modes/cursor/keyboard). These lines scroll into the terminal's scrollback buffer. 2. Clear visible screen (ESC[2J ESC[H ESC[0m) to reset for phase 2. 3. Phase 2: Emit visible screen only with full extras (modes, cursor, keyboard, scrolling region). The clear between phases ensures visible content starts from a clean slate regardless of how much scrollback preceded it. Scrollback is preserved in the terminal's buffer for native scroll-up. This approach is inspired by tmux's visible-only redraw strategy but preserves terminal scrollback. Tests: Added 7 integration tests using ghostty-vt roundtrip verification: - Cursor position preservation after roundtrip - CUP-positioned markers survive roundtrip - Scrollback does not shift visible content (the core bug) - Nested double-roundtrip (inner→outer→client) - Alternate screen content not leaked - Terminal size mismatch (30→24 rows) + roundtrip - Scrollback + size mismatch + nested roundtrip (stress test) All tests run in <1 second via `zig build test`.
1 files changed,
+366,
-4
+366,
-4
1@@ -315,9 +315,67 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
2 term.modes.set(.synchronized_output, false);
3 }
4
5- var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
6- term_formatter.content = .{ .selection = null };
7- term_formatter.extra = .{
8+ const pages = &term.screens.active.pages;
9+ const screen_top = pages.getTopLeft(.screen);
10+ const active_top = pages.getTopLeft(.active);
11+ const has_scrollback = !screen_top.eql(active_top);
12+
13+ // Two-phase serialization to preserve scrollback without corrupting
14+ // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
15+ // where the outer daemon's ghostty-vt accumulates inner session scrollback.
16+ //
17+ // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
18+ // These lines scroll past the visible area into the terminal's scrollback buffer.
19+ // Phase 2: Clear visible screen, then emit visible content with full extras.
20+ // The clear ensures visible content starts from a clean slate regardless of
21+ // how much scrollback preceded it. CUP cursor positioning is then correct.
22+ //
23+ // See: https://github.com/neurosnap/zmx/issues/31
24+
25+ // Phase 1: scrollback only (if any exists)
26+ if (has_scrollback) {
27+ if (active_top.up(1)) |sb_bottom_row| {
28+ var sb_bottom = sb_bottom_row;
29+ sb_bottom.x = @intCast(pages.cols - 1);
30+
31+ var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
32+ scroll_fmt.content = .{ .selection = ghostty_vt.Selection.init(
33+ screen_top,
34+ sb_bottom,
35+ false,
36+ ) };
37+ scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
38+ scroll_fmt.format(&builder.writer) catch |err| {
39+ std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
40+ };
41+ }
42+
43+ // Clear visible screen after scrollback. \x1b[2J clears only the visible
44+ // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
45+ // SGR style so phase 1 styles don't bleed into phase 2.
46+ builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
47+ }
48+
49+ // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
50+ var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
51+
52+ // Restrict content to the active viewport only
53+ const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
54+ const active_br = pages.pin(.{ .active = .{
55+ .x = @intCast(pages.cols - 1),
56+ .y = @intCast(pages.rows - 1),
57+ } });
58+
59+ if (active_tl != null and active_br != null) {
60+ vis_fmt.content = .{ .selection = ghostty_vt.Selection.init(
61+ active_tl.?,
62+ active_br.?,
63+ false,
64+ ) };
65+ }
66+ // Fallback: if pins are somehow invalid, use null selection (all content)
67+
68+ vis_fmt.extra = .{
69 .palette = false,
70 .modes = true,
71 .scrolling_region = true,
72@@ -327,7 +385,7 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
73 .screen = .all,
74 };
75
76- term_formatter.format(&builder.writer) catch |err| {
77+ vis_fmt.format(&builder.writer) catch |err| {
78 std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
79 return null;
80 };
81@@ -756,3 +814,307 @@ test "serializeTerminalState excludes synchronized output replay" {
82 try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
83 try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
84 }
85+
86+// ---------------------------------------------------------------------------
87+// Integration tests: serializeTerminalState roundtrip verification
88+//
89+// These tests exercise the ghostty-vt roundtrip pattern:
90+// 1. Create Terminal A, feed VT sequences (scrollback, markers, cursor)
91+// 2. Serialize A via serializeTerminalState()
92+// 3. Create Terminal B (same dimensions), feed serialized bytes
93+// 4. Compare B's screen content and cursor with A's
94+//
95+// This verifies that what the user sees after re-attach matches what the
96+// daemon's terminal state actually contains — including in nested sessions
97+// (zmx→SSH→zmx) where serialized state flows through multiple layers.
98+// ---------------------------------------------------------------------------
99+
100+fn testCreateTerminal(alloc: std.mem.Allocator, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
101+ var term = try ghostty_vt.Terminal.init(alloc, .{
102+ .cols = cols,
103+ .rows = rows,
104+ .max_scrollback = 10_000_000,
105+ });
106+ if (vt_data.len > 0) {
107+ var stream = term.vtStream();
108+ defer stream.deinit();
109+ try stream.nextSlice(vt_data);
110+ }
111+ return term;
112+}
113+
114+fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
115+ const exp_str = try expected.plainString(alloc);
116+ defer alloc.free(exp_str);
117+ const act_str = try actual.plainString(alloc);
118+ defer alloc.free(act_str);
119+ try std.testing.expectEqualStrings(exp_str, act_str);
120+}
121+
122+fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
123+ const cursor = &term.screens.active.cursor;
124+ try std.testing.expectEqual(col, cursor.x);
125+ try std.testing.expectEqual(row, cursor.y);
126+}
127+
128+fn serializeRoundtrip(alloc: std.mem.Allocator, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
129+ const serialized = serializeTerminalState(alloc, source) orelse
130+ return error.SerializationFailed;
131+ defer alloc.free(serialized);
132+
133+ var dest = try ghostty_vt.Terminal.init(alloc, .{
134+ .cols = source.screens.active.pages.cols,
135+ .rows = source.screens.active.pages.rows,
136+ .max_scrollback = 10_000_000,
137+ });
138+ var stream = dest.vtStream();
139+ defer stream.deinit();
140+ try stream.nextSlice(serialized);
141+ return dest;
142+}
143+
144+fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
145+ const plain = try term.plainString(alloc);
146+ defer alloc.free(plain);
147+ var row: usize = 0;
148+ var iter = std.mem.splitScalar(u8, plain, '\n');
149+ while (iter.next()) |line| {
150+ if (std.mem.indexOf(u8, line, marker) != null) {
151+ try std.testing.expectEqual(expected_row, row);
152+ return;
153+ }
154+ row += 1;
155+ }
156+ std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
157+ return error.TestExpectedEqual;
158+}
159+
160+test "serializeTerminalState roundtrip preserves cursor position" {
161+ const alloc = std.testing.allocator;
162+
163+ var term = try testCreateTerminal(alloc, 80, 24,
164+ "\x1b[2J" ++ // clear
165+ "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
166+ );
167+ defer term.deinit(alloc);
168+
169+ try expectCursorAt(&term, 9, 19); // 0-indexed
170+
171+ var client = try serializeRoundtrip(alloc, &term);
172+ defer client.deinit(alloc);
173+
174+ try expectCursorAt(&client, 9, 19);
175+}
176+
177+test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
178+ const alloc = std.testing.allocator;
179+
180+ var term = try testCreateTerminal(alloc, 80, 24,
181+ "\x1b[2J" ++
182+ "\x1b[2;5HMARK_A" ++
183+ "\x1b[6;15HMARK_B" ++
184+ "\x1b[10;30HMARK_C" ++
185+ "\x1b[14;50HMARK_D" ++
186+ "\x1b[16;20H"
187+ );
188+ defer term.deinit(alloc);
189+
190+ var client = try serializeRoundtrip(alloc, &term);
191+ defer client.deinit(alloc);
192+
193+ try expectScreensMatch(alloc, &term, &client);
194+ try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
195+ try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
196+ try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
197+ try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
198+ try expectCursorAt(&client, 15, 19);
199+}
200+
201+test "serializeTerminalState with scrollback preserves visible content" {
202+ const alloc = std.testing.allocator;
203+
204+ var term = try testCreateTerminal(alloc, 80, 24, "");
205+ defer term.deinit(alloc);
206+
207+ var stream = term.vtStream();
208+ defer stream.deinit();
209+
210+ // Generate 80 lines of scrollback (more than 24 visible rows)
211+ var buf: [32]u8 = undefined;
212+ for (0..80) |i| {
213+ const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
214+ try stream.nextSlice(line);
215+ }
216+
217+ // Clear screen and place markers at specific positions
218+ try stream.nextSlice(
219+ "\x1b[2J" ++
220+ "\x1b[2;5HMARK_A" ++
221+ "\x1b[6;15HMARK_B" ++
222+ "\x1b[10;30HMARK_C" ++
223+ "\x1b[16;20H"
224+ );
225+
226+ // Verify source terminal has scrollback
227+ const pages = &term.screens.active.pages;
228+ const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
229+ try std.testing.expect(has_scrollback);
230+
231+ // Roundtrip: serialize → feed into fresh terminal
232+ var client = try serializeRoundtrip(alloc, &term);
233+ defer client.deinit(alloc);
234+
235+ // Visible content must match (this is the core cursor corruption test)
236+ try expectScreensMatch(alloc, &term, &client);
237+ try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
238+ try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
239+ try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
240+ try expectCursorAt(&client, 15, 19);
241+}
242+
243+test "serializeTerminalState nested roundtrip preserves content" {
244+ // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
245+ // This is the exact nested session scenario (zmx → SSH → zmx).
246+ const alloc = std.testing.allocator;
247+
248+ // "Inner" terminal with scrollback + markers
249+ var inner = try testCreateTerminal(alloc, 80, 24, "");
250+ defer inner.deinit(alloc);
251+
252+ {
253+ var inner_stream = inner.vtStream();
254+ defer inner_stream.deinit();
255+ var buf: [32]u8 = undefined;
256+ for (0..60) |i| {
257+ const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
258+ try inner_stream.nextSlice(line);
259+ }
260+ try inner_stream.nextSlice(
261+ "\x1b[2J" ++
262+ "\x1b[3;10HINNER_A" ++
263+ "\x1b[12;25HINNER_B" ++
264+ "\x1b[20;5H"
265+ );
266+ }
267+
268+ // Record inner's ground truth
269+ const inner_cursor_x = inner.screens.active.cursor.x;
270+ const inner_cursor_y = inner.screens.active.cursor.y;
271+
272+ // Serialize inner (simulates inner daemon re-attach to inner client)
273+ const inner_serialized = serializeTerminalState(alloc, &inner) orelse
274+ return error.SerializationFailed;
275+ defer alloc.free(inner_serialized);
276+
277+ // "Outer" terminal processes inner's serialized output
278+ var outer = try testCreateTerminal(alloc, 80, 24, "");
279+ defer outer.deinit(alloc);
280+
281+ {
282+ var outer_stream = outer.vtStream();
283+ defer outer_stream.deinit();
284+ try outer_stream.nextSlice(inner_serialized);
285+ }
286+
287+ // Serialize outer (simulates outer daemon re-attach after detach)
288+ var client = try serializeRoundtrip(alloc, &outer);
289+ defer client.deinit(alloc);
290+
291+ // Client must see the same content as inner's visible screen
292+ try expectScreensMatch(alloc, &inner, &client);
293+ try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
294+ try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
295+ try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
296+}
297+
298+test "serializeTerminalState alternate screen not leaked" {
299+ const alloc = std.testing.allocator;
300+
301+ var term = try testCreateTerminal(alloc, 80, 24,
302+ "\x1b[?1049h" ++ // enter alt screen
303+ "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
304+ "\x1b[?1049l" ++ // exit alt screen
305+ "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
306+ );
307+ defer term.deinit(alloc);
308+
309+ var client = try serializeRoundtrip(alloc, &term);
310+ defer client.deinit(alloc);
311+
312+ try expectScreensMatch(alloc, &term, &client);
313+
314+ const plain = try client.plainString(alloc);
315+ defer alloc.free(plain);
316+ try std.testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
317+ try std.testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
318+}
319+
320+test "serializeTerminalState size mismatch roundtrip" {
321+ const alloc = std.testing.allocator;
322+
323+ var term = try testCreateTerminal(alloc, 80, 30,
324+ "\x1b[2J" ++
325+ "\x1b[3;10HSIZE_A" ++
326+ "\x1b[12;20HSIZE_B" ++
327+ "\x1b[20;40HSIZE_C" ++
328+ "\x1b[15;15H"
329+ );
330+ defer term.deinit(alloc);
331+
332+ // Resize to 24 rows (simulates outer terminal being smaller)
333+ try term.resize(alloc, 80, 24);
334+
335+ var client = try serializeRoundtrip(alloc, &term);
336+ defer client.deinit(alloc);
337+
338+ try expectScreensMatch(alloc, &term, &client);
339+ try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
340+}
341+
342+test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
343+ const alloc = std.testing.allocator;
344+
345+ var inner = try testCreateTerminal(alloc, 80, 30, "");
346+ defer inner.deinit(alloc);
347+
348+ {
349+ var inner_stream = inner.vtStream();
350+ defer inner_stream.deinit();
351+ var buf: [32]u8 = undefined;
352+ for (0..80) |i| {
353+ const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
354+ try inner_stream.nextSlice(line);
355+ }
356+ try inner_stream.nextSlice(
357+ "\x1b[2J" ++
358+ "\x1b[3;10HSTRESS_A" ++
359+ "\x1b[12;25HSTRESS_B" ++
360+ "\x1b[16;20H"
361+ );
362+ }
363+
364+ // Resize inner to 24 rows (outer terminal is smaller)
365+ try inner.resize(alloc, 80, 24);
366+
367+ const inner_cursor_x = inner.screens.active.cursor.x;
368+ const inner_cursor_y = inner.screens.active.cursor.y;
369+
370+ // Inner serialize → outer processes → outer serialize → client
371+ const inner_ser = serializeTerminalState(alloc, &inner) orelse
372+ return error.SerializationFailed;
373+ defer alloc.free(inner_ser);
374+
375+ var outer = try testCreateTerminal(alloc, 80, 24, "");
376+ defer outer.deinit(alloc);
377+ {
378+ var outer_stream = outer.vtStream();
379+ defer outer_stream.deinit();
380+ try outer_stream.nextSlice(inner_ser);
381+ }
382+
383+ var client = try serializeRoundtrip(alloc, &outer);
384+ defer client.deinit(alloc);
385+
386+ try expectScreensMatch(alloc, &inner, &client);
387+ try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
388+}