b91f705a
pty: add openForScenario — pty pair without a child
a73x 2026-04-19 13:22
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>
diff --git a/src/pty.zig b/src/pty.zig index 86d3c55..23b42ea 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -85,6 +85,7 @@ pub const Pty = struct { } pub fn isChildAlive(self: *Pty) bool { if (self.child_pid == -1) return true; // scenario mode: always alive if (self.child_reaped) return false; var status: c_int = 0; const rc = c.waitpid(self.child_pid, &status, c.WNOHANG); @@ -98,11 +99,43 @@ pub const Pty = struct { pub fn deinit(self: *Pty) void { std.posix.close(self.master_fd); if (!self.child_reaped) { if (self.child_pid != -1 and !self.child_reaped) { _ = std.c.kill(self.child_pid, std.c.SIG.TERM); _ = std.c.waitpid(self.child_pid, null, 0); } } /// Open a Pty without spawning a child. Master and slave fds are real, /// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true /// forever so a scenario-mode main loop stays scheduled. No fork(). pub fn openForScenario(cols: u16, rows: u16) !Pty { var master: c_int = undefined; var slave: c_int = undefined; var winsize = c.struct_winsize{ .ws_row = rows, .ws_col = cols, .ws_xpixel = 0, .ws_ypixel = 0, }; // openpty(3): allocates a pty pair without forking. if (c.openpty(&master, &slave, null, null, &winsize) < 0) { return error.OpenptyFailed; } // Slave fd isn't used by anyone in scenario mode; close to avoid leak. _ = c.close(slave); // Match spawn's O_NONBLOCK on master. const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0); const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })); _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit); return .{ .master_fd = master, .child_pid = -1, // sentinel: no child .child_reaped = true, // there's nothing to reap }; } }; test "Pty.write and read echoes through shell" { @@ -168,6 +201,14 @@ test "Pty.resize sets winsize via ioctl" { try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row); } test "openForScenario: returns a pty with master fd and fake child" { var p = try Pty.openForScenario(80, 24); defer p.deinit(); try std.testing.expect(p.master_fd >= 0); try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid); try std.testing.expect(p.isChildAlive()); } test "Pty.isChildAlive returns true while shell runs, false after exit" { var pty = try Pty.spawn(.{ .cols = 80,