repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

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
M src/util.zig
+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+}