repos / zmx

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

commit
ddf78a8
parent
25acaf8
author
Eric Bower
date
2025-10-10 22:36:20 -0400 EDT
fix: alt-screen
1 files changed,  +53, -8
M src/daemon.zig
+53, -8
  1@@ -57,6 +57,10 @@ const Session = struct {
  2     vt_handler: VTHandler,
  3     attached_clients: std.AutoHashMap(std.posix.fd_t, void),
  4 
  5+    // Buffer for incomplete UTF-8 sequences from previous read
  6+    utf8_partial: [3]u8,
  7+    utf8_partial_len: usize,
  8+
  9     fn deinit(self: *Session) void {
 10         self.allocator.free(self.name);
 11         self.buffer.deinit(self.allocator);
 12@@ -756,29 +760,68 @@ fn readPtyCallback(
 13             return .disarm;
 14         }
 15 
 16-        const data = read_buffer.slice[0..bytes_read];
 17-        std.debug.print("PTY output ({d} bytes)\n", .{bytes_read});
 18+        // Combine any partial UTF-8 from previous read with new data
 19+        var combined_buf: [4096 + 3]u8 = undefined;
 20+        const total_len = session.utf8_partial_len + bytes_read;
 21+
 22+        if (session.utf8_partial_len > 0) {
 23+            @memcpy(combined_buf[0..session.utf8_partial_len], session.utf8_partial[0..session.utf8_partial_len]);
 24+            @memcpy(combined_buf[session.utf8_partial_len..total_len], read_buffer.slice[0..bytes_read]);
 25+        } else {
 26+            @memcpy(combined_buf[0..bytes_read], read_buffer.slice[0..bytes_read]);
 27+        }
 28+
 29+        const data = combined_buf[0..total_len];
 30+        std.debug.print("PTY output ({d} bytes, {d} from partial)\n", .{ bytes_read, session.utf8_partial_len });
 31+
 32+        // Check for incomplete UTF-8 sequence at end
 33+        var valid_len = total_len;
 34+        session.utf8_partial_len = 0;
 35+
 36+        if (total_len > 0) {
 37+            // Scan backwards to find if we have a partial UTF-8 sequence
 38+            var i = total_len;
 39+            while (i > 0 and i > total_len - 4) {
 40+                i -= 1;
 41+                const byte = data[i];
 42+                // Check if this is a UTF-8 start byte
 43+                if (byte & 0x80 == 0) break; // ASCII, we're good
 44+                if (byte & 0xC0 == 0xC0) {
 45+                    // This is a UTF-8 start byte, check if sequence is complete
 46+                    const expected_len: usize = if (byte & 0xE0 == 0xC0) 2 else if (byte & 0xF0 == 0xE0) 3 else if (byte & 0xF8 == 0xF0) 4 else 1;
 47+                    if (i + expected_len > total_len) {
 48+                        // Save partial sequence for next read
 49+                        session.utf8_partial_len = total_len - i;
 50+                        @memcpy(session.utf8_partial[0..session.utf8_partial_len], data[i..total_len]);
 51+                        valid_len = i;
 52+                    }
 53+                    break;
 54+                }
 55+            }
 56+        }
 57+
 58+        const valid_data = data[0..valid_len];
 59 
 60         // Store PTY output in buffer for session restore
 61-        session.buffer.appendSlice(session.allocator, data) catch |err| {
 62+        session.buffer.appendSlice(session.allocator, valid_data) catch |err| {
 63             std.debug.print("Buffer append error: {s}\n", .{@errorName(err)});
 64         };
 65 
 66         // ALWAYS parse through libghostty-vt to maintain state
 67-        session.vt_stream.nextSlice(data) catch |err| {
 68+        session.vt_stream.nextSlice(valid_data) catch |err| {
 69             std.debug.print("VT parse error: {s}\n", .{@errorName(err)});
 70         };
 71 
 72         // Only proxy to clients if someone is attached
 73-        if (session.attached_clients.count() > 0) {
 74+        if (session.attached_clients.count() > 0 and valid_len > 0) {
 75             // Build JSON response with properly escaped text
 76             var response_buf = std.ArrayList(u8).initCapacity(session.allocator, 4096) catch return .disarm;
 77             defer response_buf.deinit(session.allocator);
 78 
 79             response_buf.appendSlice(session.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"") catch return .disarm;
 80 
 81-            // Manually escape JSON special characters
 82-            for (data) |byte| {
 83+            // Escape JSON special characters while preserving UTF-8 sequences
 84+            for (valid_data) |byte| {
 85                 switch (byte) {
 86                     '"' => response_buf.appendSlice(session.allocator, "\\\"") catch return .disarm,
 87                     '\\' => response_buf.appendSlice(session.allocator, "\\\\") catch return .disarm,
 88@@ -787,7 +830,7 @@ fn readPtyCallback(
 89                     '\t' => response_buf.appendSlice(session.allocator, "\\t") catch return .disarm,
 90                     0x08 => response_buf.appendSlice(session.allocator, "\\b") catch return .disarm,
 91                     0x0C => response_buf.appendSlice(session.allocator, "\\f") catch return .disarm,
 92-                    0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
 93+                    0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F => {
 94                         const escaped = std.fmt.allocPrint(session.allocator, "\\u{x:0>4}", .{byte}) catch return .disarm;
 95                         defer session.allocator.free(escaped);
 96                         response_buf.appendSlice(session.allocator, escaped) catch return .disarm;
 97@@ -944,6 +987,8 @@ fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Sessi
 98         .vt_handler = VTHandler{ .terminal = &session.vt },
 99         .vt_stream = undefined,
100         .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
101+        .utf8_partial = undefined,
102+        .utf8_partial_len = 0,
103     };
104 
105     // Initialize the stream after session is created since handler needs terminal pointer