a73x

b91f705a

pty: add openForScenario — pty pair without a child

a73x   2026-04-19 13:22

Commit message
pty: add openForScenario — pty pair without a child

Uses openpty(3) instead of forkpty(3). master_fd is non-blocking
(same as spawn). child_pid is sentinel -1 so isChildAlive returns
true and deinit skips reaping. Used by scenario mode where the
main loop needs a live pty invariant but no shell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

src/pty.zig
Old New
@@ -85,6 +85,7 @@ pub const Pty = struct {
85 } 85 }
86 86
87 pub fn isChildAlive(self: *Pty) bool { 87 pub fn isChildAlive(self: *Pty) bool {
88 if (self.child_pid == -1) return true; // scenario mode: always alive
88 if (self.child_reaped) return false; 89 if (self.child_reaped) return false;
89 var status: c_int = 0; 90 var status: c_int = 0;
90 const rc = c.waitpid(self.child_pid, &status, c.WNOHANG); 91 const rc = c.waitpid(self.child_pid, &status, c.WNOHANG);
@@ -98,11 +99,43 @@ pub const Pty = struct {
98 99
99 pub fn deinit(self: *Pty) void { 100 pub fn deinit(self: *Pty) void {
100 std.posix.close(self.master_fd); 101 std.posix.close(self.master_fd);
101 if (!self.child_reaped) { 102 if (self.child_pid != -1 and !self.child_reaped) {
102 _ = std.c.kill(self.child_pid, std.c.SIG.TERM); 103 _ = std.c.kill(self.child_pid, std.c.SIG.TERM);
103 _ = std.c.waitpid(self.child_pid, null, 0); 104 _ = std.c.waitpid(self.child_pid, null, 0);
104 } 105 }
105 } 106 }
107
108 /// Open a Pty without spawning a child. Master and slave fds are real,
109 /// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true
110 /// forever so a scenario-mode main loop stays scheduled. No fork().
111 pub fn openForScenario(cols: u16, rows: u16) !Pty {
112 var master: c_int = undefined;
113 var slave: c_int = undefined;
114 var winsize = c.struct_winsize{
115 .ws_row = rows,
116 .ws_col = cols,
117 .ws_xpixel = 0,
118 .ws_ypixel = 0,
119 };
120
121 // openpty(3): allocates a pty pair without forking.
122 if (c.openpty(&master, &slave, null, null, &winsize) < 0) {
123 return error.OpenptyFailed;
124 }
125 // Slave fd isn't used by anyone in scenario mode; close to avoid leak.
126 _ = c.close(slave);
127
128 // Match spawn's O_NONBLOCK on master.
129 const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
130 const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true }));
131 _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit);
132
133 return .{
134 .master_fd = master,
135 .child_pid = -1, // sentinel: no child
136 .child_reaped = true, // there's nothing to reap
137 };
138 }
106 }; 139 };
107 140
108 test "Pty.write and read echoes through shell" { 141 test "Pty.write and read echoes through shell" {
@@ -168,6 +201,14 @@ test "Pty.resize sets winsize via ioctl" {
168 try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row); 201 try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row);
169 } 202 }
170 203
204 test "openForScenario: returns a pty with master fd and fake child" {
205 var p = try Pty.openForScenario(80, 24);
206 defer p.deinit();
207 try std.testing.expect(p.master_fd >= 0);
208 try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid);
209 try std.testing.expect(p.isChildAlive());
210 }
211
171 test "Pty.isChildAlive returns true while shell runs, false after exit" { 212 test "Pty.isChildAlive returns true while shell runs, false after exit" {
172 var pty = try Pty.spawn(.{ 213 var pty = try Pty.spawn(.{
173 .cols = 80, 214 .cols = 80,