src/server/pty.zig
Ref: Size: 28.8 KiB History
//! PTY lifecycle for a session: a pty forked through `server_os.forkPty` with
//! the user's shell — or with any argv, which is how the e2e fixture drives a
//! real client — blocking master fd (the daemon's poll loop drives readiness),
//! exit detection.
const std = @import("std");
const server_os = @import("server_os");
// Declared rather than @cInclude'd, and not `server_os`'s business either:
// setenv(3) and unsetenv(3) are POSIX, spelled the same on every OS mux runs
// on, so there is nothing for a platform arm to choose between. Two externs
// also keep this file's whole C surface visible on two lines, instead of a
// header's entire namespace.
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
pub const Pty = struct {
master: std.posix.fd_t,
child: std.posix.pid_t,
exit_status: ?u32 = null,
/// One variable to set in the child. Spelled here rather than imported so
/// this module stays a leaf. A null value UNSETS the variable rather than
/// setting it empty: an empty `SSH_AUTH_SOCK` is still a socket to ssh, and
/// inheriting the daemon's is worse than either.
pub const EnvPair = struct { key: [:0]const u8, value: ?[:0]const u8 };
pub const SpawnArgvOptions = struct {
cols: u16,
rows: u16,
/// Null-terminated argv; argv[0] is an absolute path (execve, no
/// PATH search — every caller in this repo holds artifact paths).
argv: [*:null]const ?[*:0]const u8,
/// When set, the child's stderr goes here instead of the pty slave.
/// The e2e fixture uses this to keep predict stats out of the
/// capture, matching the suite's `.err` sibling convention.
stderr_fd: ?std.posix.fd_t = null,
/// Set in the child between fork and exec, after TERM. A child's env
/// comes from nowhere else, so this field is where injection enters.
env: []const EnvPair = &.{},
};
/// The one child-setup path: everything that has to be true of a process
/// on the far side of a pty is done here, so `spawn` and the e2e fixture
/// cannot drift apart in what they hand the child.
pub fn spawnArgv(opts: SpawnArgvOptions) !Pty {
const ws: server_os.Winsize = .{ .row = opts.rows, .col = opts.cols, .xpixel = 0, .ypixel = 0 };
// Diagnosed in the parent, where it can still be an error: an empty
// argv exec'd in the child is indistinguishable from a real exec
// failure, and costs a fork to say so.
if (opts.argv[0] == null) return error.EmptyArgv;
const f = try server_os.forkPty(ws);
const pid = f.pid;
const master = f.master;
if (pid == 0) {
// Child. xterm-256color: ghostty-vt understands more, but this
// terminfo exists everywhere the shell will look.
_ = setenv("TERM", "xterm-256color", 1);
// Overwrite (1), and a CONTRACT rather than a detail: this is a loop
// over an ordered slice, so a LATER pair beats an earlier one for the
// same key. That is what lets `extra_env` override a variable the
// shell-integration injection set, and a reorder would invert it.
for (opts.env) |kv| _ = if (kv.value) |v|
setenv(kv.key.ptr, v.ptr, 1)
else
unsetenv(kv.key.ptr);
// Ctrl-C must work in the session, and without this it does not: a
// non-interactive shell sets SIGINT to SIG_IGN for anything it
// backgrounds with `&`, which is how every script starts the daemon.
// SIG_IGN survives exec, and a shell keeps entry-ignored signals
// ignored for every job — so `isig` reads on and ^C does nothing.
var dfl: std.posix.Sigaction = .{
.handler = .{ .handler = std.posix.SIG.DFL },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.INT, &dfl, null);
std.posix.sigaction(std.posix.SIG.QUIT, &dfl, null);
// SIGPIPE for the same survives-exec reason, as hardening: the
// daemon ignores it for its own sockets, and only the order of
// that ignore against this fork currently keeps it out of the
// session shell. Resetting here makes it order-independent.
std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null);
// `exitNow`, never `std.process.exit` — see `server_os.exitNow`
// for why.
if (opts.stderr_fd) |fd| {
std.posix.dup2(fd, 2) catch server_os.exitNow(126);
// The dup left a spare copy at the caller's fd number and
// `pipe()` sets no CLOEXEC, so it would ride through exec into
// everything the client spawns. One handle, so the write end dies
// with the child's stderr and not later.
if (fd > 2) std.posix.close(fd);
}
server_os.closeFrom(3);
std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {};
server_os.exitNow(127);
}
// Parent. The master is THIS session's private handle and must never
// ride an exec into anybody else's child: glibc's forkpty returns it
// without CLOEXEC, so every later session would inherit every earlier
// one's master.
//
// Not tidiness. A master with a second holder never sees its last close,
// so `deinit`'s close stops hanging up, the interactive shell ignores the
// SIGTERM that follows, and the blocking waitpid never returns. Set in
// the parent because forkpty owns the open and takes no flags.
_ = std.posix.fcntl(master, std.posix.F.SETFD, std.posix.FD_CLOEXEC) catch |err| {
// Only EBADF is possible on an fd forkpty just handed back, but
// swallowing it would restore the wedge in a form no test looks
// for. Take the child down with the failure and name it.
std.posix.kill(pid, std.posix.SIG.KILL) catch {};
_ = std.posix.waitpid(pid, 0);
std.posix.close(master);
return err;
};
return .{ .master = master, .child = pid };
}
pub const Mode = server_os.PtyMode;
pub fn mode(self: *const Pty) !Mode {
return server_os.ptyMode(self.master);
}
/// Equal to `child` means no foreground job: the kernel's "command
/// returned", with zero shell cooperation. No exit code and no output
/// span; marks are for that.
pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
return server_os.ptyFgPgid(self.master);
}
pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
return server_os.setWinsize(self.master, .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 });
}
// Build a Pty from an fd and pid that already belong to this process. The
// exec keeps the pid, so the adopted child is STILL this process's child and
// `waitpid` works unchanged — the reason an upgrade re-execs.
pub fn adopt(master: std.posix.fd_t, child: std.posix.pid_t) Pty {
return .{ .master = master, .child = child, .exit_status = null };
}
/// Non-blocking: exit code if the child has exited, else null.
pub fn checkExited(self: *Pty) ?u32 {
if (self.exit_status) |s| return s;
const res = std.posix.waitpid(self.child, std.posix.W.NOHANG);
if (res.pid != self.child) return null;
self.exit_status = if (std.posix.W.IFEXITED(res.status))
std.posix.W.EXITSTATUS(res.status)
else
128;
return self.exit_status;
}
/// How long a child gets to honour SIGTERM before SIGKILL. Only a child
/// that survives the master's close (SIGHUP) AND ignores SIGTERM — the
/// leaked-fd case this bound exists for — pays it at all. It is a
/// DEADLINE, not a per-child budget: a caller holding a table asks every
/// child to go first and then shares one of these across all of them, so
/// a shutdown budget does not scale with the session count.
pub const term_grace_ms = 500;
pub fn deinit(self: *Pty) void {
self.requestExit();
self.reap(std.time.milliTimestamp() + term_grace_ms);
}
/// Close the master and ask the child to go. Split from `reap` so a caller
/// with a table spends ONE grace across it: a teardown costing
/// sessions × `term_grace_ms` is SIGKILLed halfway through.
pub fn requestExit(self: *Pty) void {
if (self.master >= 0) {
std.posix.close(self.master);
self.master = -1;
}
if (self.exit_status != null) return;
// Bounded, and that is the point: an interactive shell IGNORES SIGTERM,
// and only sees the SIGHUP from the close above if this process held the
// master's last handle. A shutdown path must not depend on a signal the
// peer is free to ignore, so `reap` bounds the exit by a deadline.
std.posix.kill(self.child, std.posix.SIG.TERM) catch {};
}
/// Wait for the child asked to leave by `requestExit`, then SIGKILL it
/// once `deadline_ms` has passed. A deadline already behind us is not an
/// error: it means an earlier child in the same teardown already spent
/// the shared grace, and this one has had exactly as long to answer.
pub fn reap(self: *Pty, deadline_ms: i64) void {
if (self.exit_status != null) return;
while (std.time.milliTimestamp() < deadline_ms) {
if (std.posix.waitpid(self.child, std.posix.W.NOHANG).pid == self.child) {
self.exit_status = 128;
return;
}
std.Thread.sleep(10 * std.time.ns_per_ms);
}
if (std.posix.waitpid(self.child, std.posix.W.NOHANG).pid == self.child) {
self.exit_status = 128;
return;
}
std.posix.kill(self.child, std.posix.SIG.KILL) catch {};
// Blocking, and safe to be: nothing survives SIGKILL, so the only
// way this does not return promptly is a kernel that has stopped
// reaping — in which case there is nothing left to bound.
_ = std.posix.waitpid(self.child, 0);
self.exit_status = 128;
}
};
test "Pty: deinit is bounded even when the child ignores HUP and TERM" {
// FIRST test in this file on purpose: what it pins is a HANG, and a wedged
// `zig test` step prints nothing for its whole timeout — so a regression has
// to be the thing that stops. The child ignores exactly the two signals
// `deinit` relies on, which is what an interactive shell does with TERM.
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "trap '' HUP TERM; while :; do sleep 1; done" };
var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
// Let the shell reach its trap before we start signalling: a TERM
// delivered before `trap` runs would be honoured, and the test would
// pass without ever exercising the SIGKILL path.
std.Thread.sleep(300 * std.time.ns_per_ms);
var t = try std.time.Timer.start();
pty.deinit();
const elapsed_ms = t.read() / std.time.ns_per_ms;
// The LOWER bound is the property. A child that died to the TERM sent
// before its `trap` line ran is dead too, and every assertion here holds
// for it while the SIGKILL path this test is named for was never walked —
// a vacuous pass whose only symptom is the suite running faster. The
// 300 ms above is 15x the worst arming measured (21 ms, bash 3.2 on a
// loaded Mac), so losing that race means the box is slow, not that the
// product changed; this turns it into a sentence rather than a green run.
// `pty` cannot reach the harness's armed-marker door — that is the
// daemon's fixture and this file is under it — so the clock is the pin.
if (elapsed_ms < Pty.term_grace_ms) {
std.debug.print(
"the child died {d} ms into the {d} ms grace: it did not ignore the TERM, " ++
"so the SIGKILL that bounds deinit was never reached\n",
.{ elapsed_ms, Pty.term_grace_ms },
);
return error.ChildDiedInsideTheGrace;
}
// Returned at all is the headline. The upper bound is the assertion that
// can still fail fast if someone widens the grace period without meaning to.
try std.testing.expect(elapsed_ms < 3000);
// Reaped, not merely abandoned: a deinit that returned while leaving a
// zombie would satisfy the clock and leak the process.
try std.testing.expect(pty.exit_status != null);
}
/// Tests only: the product spawns an argv, never a shell word (rule 5).
fn spawnShell(cols: u16, rows: u16, shell: [:0]const u8) !Pty {
var argv = [_:null]?[*:0]const u8{shell.ptr};
return Pty.spawnArgv(.{ .cols = cols, .rows = rows, .argv = &argv });
}
test "Pty: spawn /bin/sh, echo round trip" {
var pty = try spawnShell(80, 24, "/bin/sh");
defer pty.deinit();
_ = try std.posix.write(pty.master, "echo m1-pty-ok\n");
var out: std.ArrayList(u8) = .empty;
defer out.deinit(std.testing.allocator);
var buf: [4096]u8 = undefined;
// Poll-read up to 5s total; a loaded machine can be slow to exec sh.
var waited_ms: u64 = 0;
while (waited_ms < 5000) {
var fds = [_]std.posix.pollfd{
.{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
};
const ready = try std.posix.poll(&fds, 100);
waited_ms += 100;
if (ready == 0) continue;
const n = std.posix.read(pty.master, &buf) catch break;
if (n == 0) break;
try out.appendSlice(std.testing.allocator, buf[0..n]);
if (std.mem.indexOf(u8, out.items, "m1-pty-ok") != null) break;
}
try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null);
}
/// Wait for the shell behind `pty` to exit, DRAINING the master while it
/// waits, and return its code (null if the budget ran out). Draining is not
/// tidiness: the daemon reads every session's master continuously, and on
/// Darwin a shell cannot finish exiting while the output it wrote sits
/// undrained in the tty — the last close of the slave waits for that queue
/// to empty, so a test that only slept would deadlock against a shell that
/// had already run `exit`. Measured 2026-09-03 on macOS 26 with a plain
/// forkpty and no mux in the picture: the same child exits in 600 ms when
/// the master is read and never at all when it is not. Linux lets the exit
/// through either way, which is why this went unnoticed until the Mac.
fn waitExitDraining(pty: *Pty, budget_ms: u64) ?u32 {
var buf: [4096]u8 = undefined;
var waited_ms: u64 = 0;
while (waited_ms < budget_ms) : (waited_ms += 50) {
if (pty.checkExited()) |code| return code;
var fds = [_]std.posix.pollfd{
.{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
};
// EOF and EIO both mean the pty is finished; keep waiting for the
// status either way, because the exit code is what is being asked
// for and `checkExited` above is what answers it.
const ready = std.posix.poll(&fds, 50) catch 0;
if (ready > 0) _ = std.posix.read(pty.master, &buf) catch {};
}
return pty.checkExited();
}
/// Returns everything read, so a caller asserting absence can show what
/// it got.
fn readUntil(
alloc: std.mem.Allocator,
pty: *Pty,
needle: []const u8,
budget_ms: u64,
) !std.ArrayList(u8) {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
var buf: [4096]u8 = undefined;
var waited_ms: u64 = 0;
while (waited_ms < budget_ms) {
var fds = [_]std.posix.pollfd{
.{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
};
const ready = try std.posix.poll(&fds, 100);
waited_ms += 100;
if (ready == 0) continue;
// The shell dying to its own signal closes the pty: EOF and EIO are
// both "nothing more is coming", not test failures.
const n = std.posix.read(pty.master, &buf) catch break;
if (n == 0) break;
try out.appendSlice(alloc, buf[0..n]);
if (std.mem.indexOf(u8, out.items, needle) != null) break;
}
return out;
}
test "Pty: the session shell does not inherit an ignored SIGINT" {
const alloc = std.testing.allocator;
// Reproduce exactly the state a backgrounded daemon runs in: a parent
// with SIGINT ignored. Without the reset in spawn(), SIG_IGN survives
// exec and the session shell is immune to its own INT — which is what
// made Ctrl-C dead in every session a script started.
var ign: std.posix.Sigaction = .{
.handler = .{ .handler = std.posix.SIG.IGN },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
var prev: std.posix.Sigaction = undefined;
std.posix.sigaction(std.posix.SIG.INT, &ign, &prev);
defer std.posix.sigaction(std.posix.SIG.INT, &prev, null);
var pty = try spawnShell(80, 24, "/bin/sh");
defer pty.deinit();
// Absence proves nothing unless the shell was demonstrably alive and
// executing first: without this the test would pass just as happily
// against a shell that never started. The marker text is assembled by
// printf so it cannot be satisfied by the tty's echo of the command.
_ = try std.posix.write(pty.master, "printf \"ready-%s\\n\" INT\n");
var ready = try readUntil(alloc, &pty, "ready-INT", 5000);
defer ready.deinit(alloc);
try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-INT") != null);
// Aimed at a JOB of the session shell, not the shell: an interactive shell
// catches SIGINT to abandon the line and proves nothing. A non-interactive
// child installs no handler, so what it does with INT is what it inherited.
_ = try std.posix.write(pty.master, "sh -c 'kill -INT $$; printf \"survived-%s\\n\" INT'\n");
var out = try readUntil(alloc, &pty, "survived-INT", 3000);
defer out.deinit(alloc);
if (std.mem.indexOf(u8, out.items, "survived-INT") != null) {
std.debug.print("shell survived its own SIGINT; pty said:\n{s}\n", .{out.items});
return error.SigintWasIgnored;
}
}
test "Pty: resize is visible via TIOCGWINSZ" {
var pty = try spawnShell(80, 24, "/bin/sh");
defer pty.deinit();
try pty.resize(120, 40);
// Asked of the kernel, not of `server_os`: the ioctl that reads the size
// back has to be a different call from the one that set it, or the test
// grades the platform arm against itself.
var ws: std.posix.winsize = undefined;
try std.testing.expectEqual(
@as(c_int, 0),
std.c.ioctl(pty.master, @intCast(std.c.T.IOCGWINSZ), &ws),
);
try std.testing.expectEqual(@as(u16, 120), ws.col);
try std.testing.expectEqual(@as(u16, 40), ws.row);
}
test "Pty: mode reads the line discipline off the master" {
// /bin/cat, not a shell: readline takes the tty out of canonical mode and
// puts it back, so the bits would depend on where in that cycle the read
// landed. cat sets nothing, so the pty says what this test put there.
var pty = try spawnShell(80, 24, "/bin/cat");
defer pty.deinit();
// What forkpty hands a new session: canonical input, echoed by the
// kernel. This is the state in which a client may echo a keystroke early
// and be sure the pty will agree with it.
const initial = try pty.mode();
try std.testing.expect(initial.icanon);
try std.testing.expect(initial.echo);
// Master and slave share one termios on Linux, so setting it from here
// is indistinguishable from the program in the session setting it — and
// that is precisely why the daemon is allowed to read the master fd it
// already holds rather than needing a handle on the slave.
var t = try std.posix.tcgetattr(pty.master);
t.lflag.ECHO = false;
try std.posix.tcsetattr(pty.master, .NOW, t);
const quiet = try pty.mode();
try std.testing.expect(!quiet.echo);
// `stty -echo` (and every password prompt) turns echo off and leaves
// canonical on. The two bits are independent and are reported that way.
try std.testing.expect(quiet.icanon);
t.lflag.ICANON = false;
try std.posix.tcsetattr(pty.master, .NOW, t);
const raw = try pty.mode();
try std.testing.expect(!raw.icanon);
try std.testing.expect(!raw.echo);
}
test "Pty: checkExited reports shell exit" {
var pty = try spawnShell(80, 24, "/bin/sh");
defer pty.deinit();
try std.testing.expect(pty.checkExited() == null);
_ = try std.posix.write(pty.master, "exit 7\n");
try std.testing.expectEqual(@as(?u32, 7), waitExitDraining(&pty, 5000));
}
test "Pty: a later spawn does not inherit an earlier session's master" {
// The hangup contract: closing a master is how the daemon hangs up on the
// shell behind it, and that only works if the close is the LAST one. glibc's
// forkpty hands the master back without CLOEXEC, so every later session
// inherits every earlier one's — and the daemon then wedges on shutdown with
// every session's shell still alive.
var p1 = try spawnShell(80, 24, "/bin/sh");
// No `defer p1.deinit()`: this test does p1's close itself, and deinit
// would be a second close of that same fd. The cleanup is deinit's job
// by hand and with SIGKILL, which nothing can ignore — so a run that
// FAILS here reaps its shell instead of leaking one.
defer if (p1.exit_status == null) {
std.posix.kill(p1.child, std.posix.SIG.KILL) catch {};
_ = std.posix.waitpid(p1.child, 0);
};
var p2 = try spawnShell(80, 24, "/bin/sh");
defer p2.deinit();
std.posix.close(p1.master);
// Polled with a deadline, never `waitpid(child, 0)`: with the fd leaked
// this assertion has to FAIL in five seconds rather than wedge the test
// runner for good. A hung `zig build test` step prints nothing at all,
// so the bug that hangs must never be caught by hanging.
var waited_ms: u64 = 0;
while (waited_ms < 5000) : (waited_ms += 50) {
if (p1.checkExited() != null) break;
std.Thread.sleep(50 * std.time.ns_per_ms);
}
try std.testing.expect(p1.exit_status != null);
}
test "Pty: spawnArgv runs an argv and propagates exit status" {
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf argv-ok; exit 7" };
var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
defer pty.deinit();
var out = try readUntil(std.testing.allocator, &pty, "argv-ok", 5000);
defer out.deinit(std.testing.allocator);
try std.testing.expect(std.mem.indexOf(u8, out.items, "argv-ok") != null);
// Reap with a bounded poll: the child has already written its last byte,
// so 5s is a budget for a loaded box, not for the operation.
var waited_ms: u64 = 0;
while (pty.checkExited() == null and waited_ms < 5000) {
std.Thread.sleep(100 * std.time.ns_per_ms);
waited_ms += 100;
}
try std.testing.expectEqual(@as(?u32, 7), pty.checkExited());
}
test "Pty: a daemon fd without CLOEXEC still does not reach the shell" {
// pipe(2) sets no CLOEXEC — exactly the state an upgrade exec leaves
// the adopted fds in. The child looks for its own copy: through /dev/fd,
// which every OS mux runs on has.
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var cmd_buf: [96]u8 = undefined;
const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]});
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr };
var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
defer pty.deinit();
var waited_ms: u64 = 0;
while (pty.checkExited() == null and waited_ms < 5000) {
std.Thread.sleep(50 * std.time.ns_per_ms);
waited_ms += 50;
}
try std.testing.expectEqual(@as(?u32, 0), pty.checkExited());
}
test "Pty: spawnArgv applies the requested winsize" {
// stty prints "rows cols" as read off its own tty: 31 101 proves the
// winsize survived forkpty, not that a default happened to match.
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "stty size" };
var pty = try Pty.spawnArgv(.{ .cols = 101, .rows = 31, .argv = &argv });
defer pty.deinit();
var out = try readUntil(std.testing.allocator, &pty, "31 101", 5000);
defer out.deinit(std.testing.allocator);
try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null);
}
test "Pty: spawnArgv env pairs reach the child" {
// The child prints the variable rather than being asked about it: an
// exported name that the exec'd process cannot read is the failure
// this guards, so the assertion has to come from inside the child.
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" };
var pty = try Pty.spawnArgv(.{
.cols = 80,
.rows = 24,
.argv = &argv,
.env = &.{.{ .key = "MUX_T", .value = "ok" }},
});
defer pty.deinit();
var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000);
defer out.deinit(std.testing.allocator);
// "env-" alone would appear for an unset variable too, which is exactly
// the broken case; the value is what makes this an assertion.
try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null);
}
test "Pty: spawnArgv redirects stderr off the pty when asked" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "echo to-err 1>&2; printf to-out" };
var pty = try Pty.spawnArgv(.{
.cols = 80,
.rows = 24,
.argv = &argv,
.stderr_fd = pipe[1],
});
defer pty.deinit();
// Drop the parent's write-end once the child holds its own: while any
// copy stays open the read below cannot EOF, so a child that died
// without writing would hang this test instead of failing it.
std.posix.close(pipe[1]);
var out = try readUntil(std.testing.allocator, &pty, "to-out", 5000);
defer out.deinit(std.testing.allocator);
try std.testing.expect(std.mem.indexOf(u8, out.items, "to-out") != null);
try std.testing.expect(std.mem.indexOf(u8, out.items, "to-err") == null);
var errbuf: [64]u8 = undefined;
const n = try std.posix.read(pipe[0], &errbuf);
try std.testing.expect(std.mem.indexOf(u8, errbuf[0..n], "to-err") != null);
}
test "Pty: fgPgid tracks the foreground job" {
const alloc = std.testing.allocator;
var pty = try spawnShell(80, 24, "/bin/sh");
defer pty.deinit();
// Prove the shell is up before asking anything of the pgid.
_ = try std.posix.write(pty.master, "printf 'ready-%s\\n' PGID\n");
var ready = try readUntil(alloc, &pty, "ready-PGID", 5000);
defer ready.deinit(alloc);
try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-PGID") != null);
// At the prompt, the foreground pgid is the shell's own process group.
// sh is the session leader post-forkpty, so its pgid == its pid.
try std.testing.expectEqual(pty.child, try pty.fgPgid());
// A foreground job moves the fg pgid off the shell... eventually: an
// interactive sh creates a new process group for the job. Poll for the
// change rather than racing it.
_ = try std.posix.write(pty.master, "sleep 2\n");
var moved = false;
var waited_ms: u64 = 0;
while (waited_ms < 3000) : (waited_ms += 50) {
if (try pty.fgPgid() != pty.child) {
moved = true;
break;
}
std.Thread.sleep(50 * std.time.ns_per_ms);
}
// Dash and busybox sh run foreground jobs in the shell's own group with job
// control off, so a never-moved pgid is legal for the fallback design — but
// on a pty, POSIX shells enable job control. If this flakes, log and skip.
try std.testing.expect(moved);
// ...and returns to the shell when the job ends.
waited_ms = 0;
while (waited_ms < 5000) : (waited_ms += 100) {
if (try pty.fgPgid() == pty.child) break;
std.Thread.sleep(100 * std.time.ns_per_ms);
}
try std.testing.expectEqual(pty.child, try pty.fgPgid());
}
test "Pty.adopt: an adopted pair still reports the child's real exit code" {
const p = try spawnShell(80, 24, "/bin/sh");
// No defer p.deinit(): the adopted struct owns the master fd and the
// child now, and deinit would close both. We deinit the adopted copy.
var adopted = Pty.adopt(p.master, p.child);
_ = try std.posix.write(adopted.master, "exit 7\n");
try std.testing.expectEqual(@as(?u32, 7), waitExitDraining(&adopted, 5000));
adopted.deinit();
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}