summaryrefslogtreecommitdiff
path: root/src/host_io.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 14:59:17 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitd70f0029f0808e0a3724150a081015e0f943dfb5 (patch)
tree32cd92b54c52f2e6d9c6fec6d5ef42e34af7b9e4 /src/host_io.zig
parentada81f05127506dec0dbb7af390cb43fd0da9d40 (diff)
downloadpardes-d70f0029f0808e0a3724150a081015e0f943dfb5.tar.gz
pardes-d70f0029f0808e0a3724150a081015e0f943dfb5.zip
A command pane's command is over when its process exits, not when its pty closes
A job left in the background (sleep 100 &) held the pty open, so the pane stayed running and the child a zombie until the job ended; a command that closed its terminal and ran on got its end of file at once, the host waited 100 ms for an exit, reported exit ? and hung it up. Now each command's child is watched on a thread (waitid with WNOWAIT, so its pid stays its own until the host reaps it), and the host tells the core the exit from that: after the pty's end of file, so the output before the exit is in, or 50 ms after the exit without one, a job holding the pty. The pty stays open until both, so a command that let go of its terminal is never hung up by it. All four front ends; a host that cannot start the watcher reads the exit at end of file as before. Tests: host_io's for both cases, and cmdexit.snap end to end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/host_io.zig')
-rw-r--r--src/host_io.zig145
1 files changed, 144 insertions, 1 deletions
diff --git a/src/host_io.zig b/src/host_io.zig
index b167ef8a..0f6fd87e 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1368,6 +1368,116 @@ pub fn retireShell(pid: libc.pid_t) void {
thread.detach();
}
+// ---- a command pane's child, watched to its exit ----
+//
+// A command pane's command is done when its process exits, not when its pty
+// reaches end of file: a job it put in the background can hold the pty open
+// after it has gone, and a command can close its terminal and run on. A
+// thread per command waits for the exit without reaping (WNOWAIT), so the
+// pid is still the child's -- no Kill can reach another process that took
+// it -- until the host, told through `takeExited`, reaps it in its own loop
+// with `exitStatus` and tells the core.
+
+const waitid_p_pid: c_int = 1;
+const waitid_wexited: c_int = 4;
+const waitid_wnowait: c_int = if (builtin.os.tag == .linux) 0x01000000 else 0x20;
+extern "c" fn waitid(idtype: c_int, id: c_uint, infop: *anyopaque, options: c_int) c_int;
+
+var exited: [pardes.MAX_PANES * 2]std.atomic.Value(libc.pid_t) = @splat(.init(0));
+var exit_lock: libc.pthread_mutex_t = .{};
+var exit_wake: ?struct { ctx: ?*anyopaque, f: *const fn (?*anyopaque) void } = null;
+
+/// Who wakes the host's loop when a watched child exits; null at shutdown,
+/// after which a watcher still waiting tells nobody.
+pub fn setExitWake(ctx: ?*anyopaque, f: ?*const fn (?*anyopaque) void) void {
+ _ = libc.pthread_mutex_lock(&exit_lock);
+ defer _ = libc.pthread_mutex_unlock(&exit_lock);
+ exit_wake = if (f) |wake| .{ .ctx = ctx, .f = wake } else null;
+}
+
+/// Watches a command pane's child; false when no thread could be started,
+/// and the host then reads its exit at end of file instead.
+pub fn watchExit(pid: libc.pid_t) bool {
+ const thread = std.Thread.spawn(.{}, watchChild, .{pid}) catch return false;
+ thread.detach();
+ return true;
+}
+
+fn watchChild(pid: libc.pid_t) void {
+ var info: [128]u8 align(16) = undefined;
+ while (waitid(waitid_p_pid, @intCast(pid), &info, waitid_wexited | waitid_wnowait) < 0 and libc.errno(-1) == .INTR) {}
+ // A cell for it, waiting for one if the host is slow to take them.
+ place: while (true) {
+ for (&exited) |*cell| if (cell.cmpxchgStrong(0, pid, .acq_rel, .acquire) == null) break :place;
+ const ts: libc.timespec = .{ .sec = 0, .nsec = 5 * std.time.ns_per_ms };
+ _ = libc.nanosleep(&ts, null);
+ }
+ wakeForExit();
+ // Once more a little later: a job the command left holding its pty
+ // may keep its end of file from ever coming, and the host tells the
+ // exit then (`CommandWatch.due`).
+ const ts: libc.timespec = .{ .sec = 0, .nsec = (CommandWatch.grace_ms + 10) * std.time.ns_per_ms };
+ _ = libc.nanosleep(&ts, null);
+ wakeForExit();
+}
+
+fn wakeForExit() void {
+ _ = libc.pthread_mutex_lock(&exit_lock);
+ defer _ = libc.pthread_mutex_unlock(&exit_lock);
+ if (exit_wake) |wake| wake.f(wake.ctx);
+}
+
+/// Where a host's command pane is between its child's exit and its pty's
+/// end of file. The exit is told once the output before it has been read,
+/// at end of file -- the exit is often seen first, with output still in
+/// the pty -- or after `grace_ms` without one, a job the command left in
+/// the background holding the pty.
+pub const CommandWatch = struct {
+ pub const grace_ms = 50;
+ watched: bool = false,
+ eof: bool = false,
+ exited: bool = false,
+ told: bool = false,
+ status: ?u8 = null,
+ at: i64 = 0,
+
+ /// The child is reaped, with this status.
+ pub fn exit(w: *CommandWatch, status: ?u8) void {
+ w.exited = true;
+ w.status = status;
+ w.at = nowMs();
+ }
+
+ /// Whether the exit is to be told now.
+ pub fn due(w: *const CommandWatch) bool {
+ return w.exited and !w.told and (w.eof or nowMs() - w.at >= grace_ms);
+ }
+};
+
+/// Reaps `pid` if it has exited: its status (null: unknown, or not ours
+/// to read), or null when it is still running -- a pid a watcher reported
+/// may since have been reaped by another and taken by a new child.
+pub fn reapExited(pid: libc.pid_t) ?struct { status: ?u8 } {
+ if (pid <= 0) return .{ .status = null };
+ var status: c_int = 0;
+ const got = libc.waitpid(pid, &status, libc.W.NOHANG);
+ if (got == 0) return null;
+ if (got != pid) return .{ .status = null };
+ const s: u32 = @bitCast(status);
+ if (libc.W.IFEXITED(s)) return .{ .status = @truncate(libc.W.EXITSTATUS(s)) };
+ if (libc.W.IFSIGNALED(s)) return .{ .status = @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s)))) };
+ return .{ .status = null };
+}
+
+/// A watched child that has exited, not yet reaped, or null.
+pub fn takeExited() ?libc.pid_t {
+ for (&exited) |*cell| {
+ const pid = cell.swap(0, .acq_rel);
+ if (pid != 0) return pid;
+ }
+ return null;
+}
+
/// A pane's child at its pty's end of file: reaped, and how it exited
/// (its code, or 128 and the signal that ended it), if it is gone within
/// `wait_ms`; null if it is still running (a job it left holds nothing
@@ -1694,7 +1804,7 @@ fn haveFile(path: [*:0]const u8) bool {
return true;
}
-fn nowMs() i64 {
+pub fn nowMs() i64 {
var ts: libc.timespec = undefined;
_ = libc.clock_gettime(.MONOTONIC, &ts);
return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000);
@@ -1775,6 +1885,39 @@ test "a command pane's child is its shell running the one line, and its exit sta
try std.testing.expectEqual(@as(?u8, 7), exitStatus(child.pid, 2000));
}
+test "a command's exit is its process's, told by its watcher, whether or not its pty has ended" {
+ if (comptime !tty_probe_platform) return error.SkipZigTest;
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const rcs: Shell.PromptFiles = .{};
+ const Case = struct { line: []const u8, status: u8 };
+ // A job left in the background holds the pty open past the command's
+ // exit; a command that lets go of its terminal runs on past its end of
+ // file. Either way the exit is the process's.
+ for ([_]Case{
+ .{ .line = "sleep 3 & exit 4", .status = 4 },
+ .{ .line = "exec </dev/null >/dev/null 2>&1; sleep 0.3; exit 5", .status = 5 },
+ }) |c| {
+ const id = core.freeSlot().?;
+ _ = try core.newCommand(id, "", c.line);
+ while (core.nextEffect()) |_| {}
+ const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null);
+ defer _ = libc.close(child.file.handle);
+ try std.testing.expect(watchExit(child.pid));
+ var got: ?libc.pid_t = null;
+ var waited: i64 = 0;
+ while (got == null and waited < 5000) : (waited += 10) {
+ got = takeExited();
+ if (got == null) sleepMs(10);
+ }
+ try std.testing.expectEqual(@as(?libc.pid_t, child.pid), got);
+ // Told before it is reaped: the pid is still the child's.
+ try std.testing.expectEqual(@as(?u8, c.status), reapExited(child.pid).?.status);
+ try core.removePane(id, null);
+ }
+}
+
test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" {
const pid = libc.fork();
if (pid == 0) {