b91f705a
pty: add openForScenario — pty pair without a child
a73x 2026-04-19 13:22
Commit message
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, |