repos / zmx

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

commit
5319560
parent
2724be4
author
Eric Bower
date
2025-10-13 10:48:16 -0400 EDT
refactor(attach): use xev.Stream for stdout writes
1 files changed,  +64, -2
M src/attach.zig
+64, -2
  1@@ -11,9 +11,13 @@ const c = @cImport({
  2     @cInclude("sys/ioctl.h");
  3 });
  4 
  5+// Main context for the attach client that manages connection to daemon and terminal I/O.
  6+// Handles async streams for daemon socket, stdin, and stdout using libxev's event loop.
  7+// Tracks detach key sequence state and buffers partial binary frames from PTY output.
  8 const Context = struct {
  9     stream: xev.Stream,
 10     stdin_stream: xev.Stream,
 11+    stdout_stream: xev.Stream,
 12     allocator: std.mem.Allocator,
 13     loop: *xev.Loop,
 14     session_name: []const u8,
 15@@ -95,6 +99,7 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 16     ctx.* = .{
 17         .stream = xev.Stream.initFd(socket_fd),
 18         .stdin_stream = xev.Stream.initFd(posix.STDIN_FILENO),
 19+        .stdout_stream = xev.Stream.initFd(posix.STDOUT_FILENO),
 20         .allocator = allocator,
 21         .loop = &loop,
 22         .session_name = session_name,
 23@@ -171,6 +176,8 @@ fn writeCallback(
 24     return .disarm;
 25 }
 26 
 27+// Context for async socket read operations from daemon.
 28+// Uses large buffer (128KB) to handle initial session scrollback and binary PTY frames.
 29 const ReadContext = struct {
 30     ctx: *Context,
 31     buffer: [128 * 1024]u8, // 128KB to handle large scrollback messages
 32@@ -206,7 +213,7 @@ fn readCallback(
 33                 if (data.len >= expected_total) {
 34                     // We have the complete frame
 35                     const payload = data[@sizeOf(protocol.FrameHeader)..expected_total];
 36-                    _ = posix.write(posix.STDOUT_FILENO, payload) catch {};
 37+                    writeToStdout(ctx, payload);
 38                     return .rearm;
 39                 } else {
 40                     // Partial frame, buffer it
 41@@ -227,7 +234,7 @@ fn readCallback(
 42                 if (ctx.frame_buffer.items.len >= expected_total) {
 43                     // Complete frame received
 44                     const payload = ctx.frame_buffer.items[@sizeOf(protocol.FrameHeader)..expected_total];
 45-                    _ = posix.write(posix.STDOUT_FILENO, payload) catch {};
 46+                    writeToStdout(ctx, payload);
 47                     ctx.frame_buffer.clearRetainingCapacity();
 48                     ctx.frame_expecting_bytes = 0;
 49                 }
 50@@ -354,6 +361,8 @@ fn startStdinReading(ctx: *Context) void {
 51     ctx.stdin_stream.read(ctx.loop, stdin_completion, .{ .slice = &stdin_ctx.buffer }, StdinContext, stdin_ctx, stdinReadCallback);
 52 }
 53 
 54+// Context for async stdin read operations.
 55+// Captures user terminal input to forward to PTY via daemon.
 56 const StdinContext = struct {
 57     ctx: *Context,
 58     buffer: [4096]u8,
 59@@ -423,11 +432,64 @@ fn sendPtyInput(ctx: *Context, data: []const u8) void {
 60     ctx.stream.write(ctx.loop, write_completion, .{ .slice = owned_message }, StdinWriteContext, write_ctx, stdinWriteCallback);
 61 }
 62 
 63+// Context for async write operations to daemon socket.
 64+// Owns message buffer that gets freed after write completes.
 65 const StdinWriteContext = struct {
 66     allocator: std.mem.Allocator,
 67     message: []u8,
 68 };
 69 
 70+// Context for async write operations to stdout.
 71+// Owns PTY output data buffer that gets freed after write completes.
 72+const StdoutWriteContext = struct {
 73+    allocator: std.mem.Allocator,
 74+    data: []u8,
 75+};
 76+
 77+fn writeToStdout(ctx: *Context, data: []const u8) void {
 78+    const owned_data = ctx.allocator.dupe(u8, data) catch return;
 79+
 80+    const write_ctx = ctx.allocator.create(StdoutWriteContext) catch {
 81+        ctx.allocator.free(owned_data);
 82+        return;
 83+    };
 84+    write_ctx.* = .{
 85+        .allocator = ctx.allocator,
 86+        .data = owned_data,
 87+    };
 88+
 89+    const write_completion = ctx.allocator.create(xev.Completion) catch {
 90+        ctx.allocator.free(owned_data);
 91+        ctx.allocator.destroy(write_ctx);
 92+        return;
 93+    };
 94+
 95+    ctx.stdout_stream.write(ctx.loop, write_completion, .{ .slice = owned_data }, StdoutWriteContext, write_ctx, stdoutWriteCallback);
 96+}
 97+
 98+fn stdoutWriteCallback(
 99+    write_ctx_opt: ?*StdoutWriteContext,
100+    _: *xev.Loop,
101+    completion: *xev.Completion,
102+    _: xev.Stream,
103+    _: xev.WriteBuffer,
104+    write_result: xev.WriteError!usize,
105+) xev.CallbackAction {
106+    const write_ctx = write_ctx_opt.?;
107+    const allocator = write_ctx.allocator;
108+
109+    if (write_result) |_| {
110+        // Successfully wrote to stdout
111+    } else |_| {
112+        // Silently ignore stdout write errors
113+    }
114+
115+    allocator.free(write_ctx.data);
116+    allocator.destroy(write_ctx);
117+    allocator.destroy(completion);
118+    return .disarm;
119+}
120+
121 fn stdinReadCallback(
122     stdin_ctx_opt: ?*StdinContext,
123     _: *xev.Loop,