diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 14:59:17 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | d70f0029f0808e0a3724150a081015e0f943dfb5 (patch) | |
| tree | 32cd92b54c52f2e6d9c6fec6d5ef42e34af7b9e4 /src/host_io.zig | |
| parent | ada81f05127506dec0dbb7af390cb43fd0da9d40 (diff) | |
| download | pardes-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.zig | 145 |
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) { |
