Commit 8ba312d

凡辞  ·  2026-07-22 12:02:23 -0400 EDT
parent 935c484
fix(send): preserve client leadership
3 files changed,  +34, -4
+1, -0
1@@ -19,6 +19,7 @@ Use spec: https://common-changelog.org/
2 - `zmx run` will now detect heredocs and add the completion marker to a newline
3 - Race between `zmx kill X; zmx run X`
4 - Improved claim leader detection
5+- `zmx send` no longer claims client leadership or triggers a resize probe
6 - Improved `ctrl+\` key detection
7 - Support for linux kernel < 4.11 by avoiding statx calls
8 
+5, -3
 1@@ -22,15 +22,16 @@ pub const Tag = enum(u8) {
 2     LabelSet = 15,
 3     LabelClear = 16,
 4     LabelData = 17,
 5+    Send = 18,
 6     // Non-exhaustive: this enum comes off the wire via bytesToValue and
 7-    // @enumFromInt, so out-of-range values (14-255) are representable
 8+    // @enumFromInt, so out-of-range values are representable
 9     // rather than UB. Switches must handle `_` (unknown tag).
10     _,
11 };
12 
13 comptime {
14     if (@typeInfo(Tag).@"enum".is_exhaustive) @compileError(
15-        "ipc.Tag must stay non-exhaustive — old daemons rely on `_` to ignore unknown tags",
16+        "ipc.Tag must stay non-exhaustive -- old daemons rely on `_` to ignore unknown tags",
17     );
18 }
19 
20@@ -57,7 +58,7 @@ pub fn getTerminalSize(fd: i32) Resize {
21 pub const MAX_CMD_LEN = 256;
22 pub const MAX_CWD_LEN = 256;
23 
24-/// Frozen wire shape. Do NOT add fields — new stats go in new `Tag` values
25+/// Frozen wire shape. Do NOT add fields! New stats go in new `Tag` values
26 /// so old daemons (whose `_` arm ignores unknown tags) stay reachable.
27 /// Changing `@sizeOf(Info)` breaks `zmx list` against running daemons.
28 pub const Info = extern struct {
29@@ -306,6 +307,7 @@ test "Tag wire values are frozen" {
30         .{ Tag.Run, 9 },       .{ Tag.Ack, 10 },          .{ Tag.Switch, 11 },
31         .{ Tag.Write, 12 },    .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
32         .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 },   .{ Tag.LabelData, 17 },
33+        .{ Tag.Send, 18 },
34     }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
35 }
36 
+28, -1
 1@@ -287,7 +287,7 @@ pub fn main() !void {
 2             error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
 3             error.OutOfMemory => return err,
 4         };
 5-        return send(&cfg, sesh, socket_path, text_parts.items, .Input);
 6+        return send(&cfg, sesh, socket_path, text_parts.items, .Send);
 7     } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
 8         const session_name = args.next() orelse "";
 9         if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
10@@ -1046,6 +1046,11 @@ const Daemon = struct {
11         }
12     }
13 
14+    /// Queue input from `zmx send` without changing interactive client leadership.
15+    pub fn handleSend(self: *Daemon, payload: []const u8) void {
16+        self.queuePtyInput(payload);
17+    }
18+
19     pub fn handleSwitch(self: *Daemon, session_name: []const u8) !void {
20         for (self.clients.items) |client| {
21             if (self.leader_client_fd == client.socket_fd) {
22@@ -1371,6 +1376,27 @@ const Daemon = struct {
23     }
24 };
25 
26+test "send queues PTY input without changing leader" {
27+    const alloc = std.testing.allocator;
28+    var daemon = Daemon{
29+        .cfg = undefined,
30+        .alloc = alloc,
31+        .clients = .empty,
32+        .leader_client_fd = 42,
33+        .session_name = "test",
34+        .socket_path = "",
35+        .running = true,
36+        .pid = 0,
37+        .created_at = 0,
38+    };
39+    defer daemon.pty_write_buf.deinit(alloc);
40+
41+    daemon.handleSend("hello");
42+
43+    try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
44+    try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
45+}
46+
47 fn printVersion(cfg: *Cfg) !void {
48     var buf: [256]u8 = undefined;
49     var w = std.fs.File.stdout().writer(&buf);
50@@ -3042,6 +3068,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
51                 while (client.read_buf.next()) |msg| {
52                     switch (msg.header.tag) {
53                         .Input => try daemon.handleInput(client, msg.payload),
54+                        .Send => daemon.handleSend(msg.payload),
55                         .Output => try daemon.handleOutput(msg.payload, &vt_stream),
56                         .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
57                         .Switch => try daemon.handleSwitch(msg.payload),