a73x

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,