summaryrefslogtreecommitdiff
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
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]>
-rw-r--r--docs/fs.md5
-rw-r--r--src/detached/server.zig57
-rw-r--r--src/detached/wire.zig6
-rw-r--r--src/gui/gui.zig73
-rw-r--r--src/host_io.zig145
-rw-r--r--src/macos.zig64
-rw-r--r--src/pardes.zig18
-rw-r--r--src/tty/tty.zig54
-rw-r--r--test/snapshots/cmdexit.golden14
-rw-r--r--test/snapshots/cmdexit.snap26
10 files changed, 425 insertions, 37 deletions
diff --git a/docs/fs.md b/docs/fs.md
index ebc85f12..d3e32e3a 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -238,7 +238,10 @@ same tree without leaving the process.
a terminal whose tty a program holds -- it runs as a command pane: a
terminal whose child is `$SHELL -c` the line in the pane's directory,
which shows its output and then `exit N` (its tag reads `<dir> (<line>)
- running`, then `exit N`), and stays. The directory's next command runs in
+ running`, then `exit N`), and stays. The command is over when its process
+ exits, as in acme, not when its terminal closes: a job it left in the
+ background prints on below `exit N` until it lets go of the pty, and a
+ command that lets go of its terminal early runs on to its own exit. The directory's next command runs in
that pane once it is done, below what it showed, after a `% <line>` line;
one still running gets a second pane. Before the next command the pane
leaves any alternate screen and turns off the modes a program left on
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 86ba2206..202eafb3 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -70,8 +70,18 @@ const Pty = struct {
pid: posix.pid_t = 0,
kill_at: i64 = 0,
out: std.ArrayListUnmanaged(u8) = .empty,
+ /// A command pane's child, watched to its exit (host_io.watchExit);
+ /// the pty stays open, unpolled after its end of file, until both its
+ /// end of file and its exit.
+ cmd: host_io.CommandWatch = .{},
};
+/// A watched child exited: wake the loop, which takes it (`takeExits`).
+fn wakeForExit(ctx: ?*anyopaque) void {
+ const box: *Mailbox = @ptrCast(@alignCast(ctx.?));
+ box.signal();
+}
+
const RetiredShell = struct { pid: posix.pid_t = 0, kill_at: i64 = 0 };
const Completion = union(enum) {
@@ -188,6 +198,7 @@ pub const Session = struct {
}
s.mailbox.wake = pair;
pardes.lsp.setStatusSink(s, lspStatus);
+ host_io.setExitWake(&s.mailbox, wakeForExit);
}
fn cancelWorkers(s: *Session) void {
@@ -255,6 +266,7 @@ pub const Session = struct {
pub fn deinit(s: *Session) void {
if (s.mailbox.wake[0] >= 0) pardes.lsp.setStatusSink(null, null);
+ if (s.mailbox.wake[0] >= 0) host_io.setExitWake(null, null);
s.cancelWorkers();
for (s.mailbox.wake) |fd| if (fd >= 0) {
_ = libc.close(fd);
@@ -471,7 +483,8 @@ pub const Session = struct {
s.core.screen_w,
s.ninep,
) catch |err| return s.core.reportError(pane, "shell", err);
- s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid };
+ const command = if (s.core.panes[pane]) |pn| pn.command != null else false;
+ s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid, .cmd = .{ .watched = command and host_io.watchExit(child.pid) } };
setNonblock(child.file.handle);
var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
@@ -679,12 +692,43 @@ pub const Session = struct {
}
fn paneEof(s: *Session, pane: u8) void {
- // Reaped here, its status kept, it is no shell to retire.
- const status = host_io.exitStatus(s.ptys[pane].pid, 100);
- if (status != null) s.ptys[pane].pid = 0;
+ const pt = &s.ptys[pane];
+ // A command's pty stays open until its child has exited: closing
+ // it would hang up one that runs on without it.
+ // Its exit, if it came first, is told now its output is in.
+ if (pt.cmd.watched) {
+ pt.cmd.eof = true;
+ s.core.update(.{ .eof = .{ .pane = pane } });
+ return s.takeExits();
+ }
+ // Unwatched, a command's exit is read here, as its end.
+ const unwatched = if (s.core.panes[pane]) |pn| pn.command != null else false;
+ var status: ?u8 = null;
+ if (unwatched) {
+ status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ }
s.closePty(pane);
s.harvest();
- s.core.update(.{ .eof = .{ .pane = pane, .status = status } });
+ if (unwatched) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
+ s.core.update(.{ .eof = .{ .pane = pane } });
+ }
+
+ /// Each watched child that exited: reaped, the core told, and its pty
+ /// closed if its end of file came first.
+ fn takeExits(s: *Session) void {
+ while (host_io.takeExited()) |pid| for (&s.ptys) |*pt| {
+ if (pt.fd < 0 or pt.pid != pid or pt.cmd.exited) continue;
+ pt.cmd.exit((host_io.reapExited(pid) orelse break).status);
+ pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next
+ break;
+ };
+ for (&s.ptys, 0..) |*pt, id| {
+ if (pt.fd < 0 or !pt.cmd.due()) continue;
+ pt.cmd.told = true;
+ s.core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } });
+ if (pt.cmd.eof) s.closePty(@intCast(id));
+ }
}
fn harvest(s: *Session) void {
@@ -804,7 +848,7 @@ pub const Session = struct {
n += 1;
}
for (&s.ptys, 0..) |*pt, pane| {
- if (pt.fd < 0) continue;
+ if (pt.fd < 0 or pt.cmd.eof) continue;
fds[n] = .{
.fd = pt.fd,
.events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in,
@@ -849,6 +893,7 @@ pub const Session = struct {
// tick in a detached session, and without it an animation never ends.
if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick);
_ = s.drainCompletions(true);
+ s.takeExits();
s.expire(monotonicMs());
s.harvest();
s.reloadWatched();
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 94bd7f0b..0854ce3e 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -819,7 +819,7 @@ fn clientTag(msg: ClientMsg) ClientTag {
return switch (msg) {
.event => |ev| switch (ev) {
// Machine-local reports and 9P requests belong to the session owner.
- .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
+ .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
inline else => |_, t| @field(ClientTag, @tagName(t)),
},
inline else => |_, t| @field(ClientTag, @tagName(t)),
@@ -946,7 +946,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
.touch_scroll => |v| try w.putF32(v),
.pointer_leave => {},
// See `clientTag`: no tag, so nothing to encode.
- .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
+ .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
},
}
try finishMessage(&w, at);
@@ -1076,7 +1076,7 @@ pub fn clientBound(msg: ClientMsg) usize {
.paste => |b| b.len,
.command => |line| line.len,
// See `clientTag`: not on this wire in this direction.
- .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
+ .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
},
};
}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index bfa83175..175db9e2 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -840,8 +840,17 @@ const Pty = struct {
kill_at: i64 = 0,
reader: ?std.Thread = null,
stop: [2]c_int = .{ -1, -1 },
+ /// A command pane's child, watched to its exit (host_io.watchExit);
+ /// the pty stays open until both its end of file and its exit.
+ cmd: host_io.CommandWatch = .{},
};
+/// A watched child exited: wake the loop, which takes it (`takeExits`).
+fn wakeForExit(ctx: ?*anyopaque) void {
+ const q: *Queue = @ptrCast(@alignCast(ctx.?));
+ q.wake();
+}
+
const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 };
const Msg = union(enum) {
@@ -2536,6 +2545,8 @@ fn localSession(
shell.threads_ok = true;
pardes.lsp.setStatusSink(&queue, lspStatusSink);
defer pardes.lsp.setStatusSink(null, null);
+ host_io.setExitWake(&queue, wakeForExit);
+ defer host_io.setExitWake(null, null);
while (!core.quit) {
pardes.turn.restoreSettled();
@@ -2824,6 +2835,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
shell.threads_ok = true;
pardes.lsp.setStatusSink(&queue, lspStatusSink);
defer pardes.lsp.setStatusSink(null, null);
+ host_io.setExitWake(&queue, wakeForExit);
+ defer host_io.setExitWake(null, null);
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
@@ -3865,8 +3878,29 @@ const Shell = struct {
break :vt v;
};
+ /// Each watched child that exited: reaped, the core told, and its pty
+ /// closed if its end of file came first.
+ fn takeExits(s: *Shell) void {
+ while (host_io.takeExited()) |pid| for (s.ptys) |*slot| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (pt.pid != pid or pt.cmd.exited) continue;
+ pt.cmd.exit((host_io.reapExited(pid) orelse break).status);
+ pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next
+ break;
+ };
+ for (s.ptys, 0..) |*slot, id| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (!pt.cmd.due()) continue;
+ pt.cmd.told = true;
+ s.core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } });
+ if (pt.cmd.eof) s.closePty(@intCast(id));
+ s.saw_event = true;
+ }
+ }
+
fn drainQueue(s: *Shell) void {
s.reconcilePtys();
+ s.takeExits();
var msgs = s.queue.take();
var check_files = false;
for (msgs.slice()) |m| switch (m) {
@@ -3877,20 +3911,38 @@ const Shell = struct {
s.saw_event = true;
},
.eof => |e| {
- if (s.gens[e.pane] == e.gen) {
- // Reaped here, its status kept, it is no shell to retire.
- const status = if (s.ptys[e.pane]) |*pt| status: {
- const got = host_io.exitStatus(pt.pid, 100);
- if (got != null) pt.pid = 0;
- break :status got;
- } else null;
- s.closePty(e.pane);
+ if (s.gens[e.pane] == e.gen) eof: {
if (e.failure) |err| {
+ s.closePty(e.pane);
if (s.core.panes[e.pane]) |pane| {
pane.body.mode = .normal;
s.core.reportError(e.pane, "terminal reader", err);
}
- } else s.core.update(.{ .eof = .{ .pane = e.pane, .status = status } });
+ break :eof;
+ }
+ const pt = if (s.ptys[e.pane]) |*pt| pt else {
+ s.core.update(.{ .eof = .{ .pane = e.pane } });
+ break :eof;
+ };
+ // A command's pty stays open until its child has exited:
+ // closing it would hang up one that runs on without it.
+ // Its exit, if it came first, is told now its output is in.
+ if (pt.cmd.watched) {
+ pt.cmd.eof = true;
+ s.core.update(.{ .eof = .{ .pane = e.pane } });
+ s.takeExits();
+ break :eof;
+ }
+ // Unwatched, a command's exit is read here, as its end.
+ const unwatched = if (s.core.panes[e.pane]) |pane| pane.command != null else false;
+ var status: ?u8 = null;
+ if (unwatched) {
+ status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ }
+ s.closePty(e.pane);
+ if (unwatched) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ s.core.update(.{ .eof = .{ .pane = e.pane } });
}
s.saw_event = true;
},
@@ -4165,7 +4217,8 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy);
s.gens[pane] +%= 1;
const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err);
- const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial };
+ const command = s.core.panes[pane].?.command != null;
+ const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial, .cmd = .{ .watched = command and host_io.watchExit(child.pid) } };
s.ptys[pane] = pt;
var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
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) {
diff --git a/src/macos.zig b/src/macos.zig
index 97dab9a8..1327b064 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -126,8 +126,39 @@ const Pty = struct {
pid: posix.pid_t,
gen: u32,
reader: std.Io.Future(anyerror!void),
+ /// A command pane's child, watched to its exit (host_io.watchExit);
+ /// the pty stays open until both its end of file and its exit.
+ cmd: host_io.CommandWatch = .{},
};
+/// A watched child exited: wake the host, which takes it (`takeExits`).
+fn wakeForExit(ctx: ?*anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(ctx orelse return));
+ wake(st);
+}
+
+/// Each watched child that exited: reaped, the core told, and its pty
+/// closed if its end of file came first.
+fn takeExits(st: *State) bool {
+ var did = false;
+ while (host_io.takeExited()) |pid| for (&st.ptys) |*slot| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (pt.pid != pid or pt.cmd.exited) continue;
+ pt.cmd.exit((host_io.reapExited(pid) orelse break).status);
+ pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next
+ break;
+ };
+ for (&st.ptys, 0..) |*slot, id| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (!pt.cmd.due()) continue;
+ pt.cmd.told = true;
+ st.core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } });
+ if (pt.cmd.eof) reap(st, @intCast(id));
+ did = true;
+ }
+ return did;
+}
+
const WatchedFile = struct {
path: []u8,
serial: u32,
@@ -783,6 +814,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id));
pardes.lsp.setStatusSink(st, lspStatusSink);
+ host_io.setExitWake(st, wakeForExit);
// From here the core is touched only inside the entry points above,
// each of which takes the turn for its own duration; between them the
// 9P connections have it.
@@ -802,6 +834,7 @@ export fn pardes_deinit() void {
st.ninep = null;
}
pardes.lsp.setStatusSink(null, null);
+ host_io.setExitWake(null, null);
if (st.lsp_task) |*t| {
t.future.cancel(st.io) catch {};
st.lsp_task = null;
@@ -1269,7 +1302,7 @@ export fn pardes_watch_changed(pane: u8, generation: u32) void {
fn drainInbox(st: *State) bool {
var batch = st.inbox.take(st.io);
- var did = batch.len > 0;
+ var did = takeExits(st) or batch.len > 0;
for (batch.slice()) |msg| {
defer msg.free(st.gpa);
switch (msg) {
@@ -1280,14 +1313,28 @@ fn drainInbox(st: *State) bool {
},
.eof => |e| {
if (st.gens[e.pane] != e.gen) continue;
- // Reaped here, its status kept, it is no shell to retire.
- const status = if (st.ptys[e.pane]) |*pt| status: {
- const got = host_io.exitStatus(pt.pid, 100);
- if (got != null) pt.pid = 0;
- break :status got;
- } else null;
+ if (st.ptys[e.pane]) |*pt| {
+ // A command's pty stays open until its child has exited:
+ // closing it would hang up one that runs on without it.
+ // Its exit, if it came first, is told now its output is in.
+ if (pt.cmd.watched) {
+ pt.cmd.eof = true;
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
+ _ = takeExits(st);
+ continue;
+ }
+ // Unwatched, a command's exit is read here, as its end.
+ if (st.core.panes[e.pane]) |pane| if (pane.command != null) {
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ reap(st, e.pane);
+ st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
+ continue;
+ };
+ }
reap(st, e.pane);
- st.core.update(.{ .eof = .{ .pane = e.pane, .status = status } });
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
},
.lsp_done => |d| {
st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
@@ -2413,6 +2460,7 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
.pid = child.pid,
.gen = gen,
.reader = .{ .any_future = null, .result = {} },
+ .cmd = .{ .watched = (if (core.panes[pane]) |pn| pn.command != null else false) and host_io.watchExit(child.pid) },
};
var lbuf: [1024]u8 = undefined;
if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd);
diff --git a/src/pardes.zig b/src/pardes.zig
index 97ba534a..1539637c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1425,7 +1425,10 @@ test "a command pane shows how its command ended, and the next command there run
try std.testing.expect(other != dst);
while (p.nextEffect()) |_| {}
p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } });
- p.update(.{ .eof = .{ .pane = @intCast(dst), .status = 2 } });
+ // Its output ending is not its end: that is its child's exit.
+ p.update(.{ .eof = .{ .pane = @intCast(dst) } });
+ try std.testing.expect(p.panes[dst] == pane and !pane.command_done);
+ p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } });
try std.testing.expect(p.panes[dst] == pane);
try std.testing.expect(pane.command_done);
try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2"));
@@ -3134,9 +3137,13 @@ pub const Event = union(enum) {
row_metrics: ?RowMetrics = null,
},
output: struct { pane: u8, bytes: []const u8 },
- /// The pane's child hung up its pty; `status` is how it exited, when
- /// the host could reap it at once (a command pane shows it).
+ /// The pane's pty reached end of file: its output is over. An
+ /// interactive terminal closes; a command pane waits for `exited`.
eof: struct { pane: u8, status: ?u8 = null },
+ /// A command pane's child exited, with this status (null: unknown). It
+ /// is the command's end, whatever still holds its pty (a job it left in
+ /// the background prints on until end of file).
+ exited: struct { pane: u8, status: ?u8 },
lsp_resp: struct { id: u32, rows: ?[]const u8 },
pipe_resp: struct {
id: u32,
@@ -4869,8 +4876,11 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
+ .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
exec.commandDone(p, e.pane, e.status);
+ },
+ .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
+ // Output is over; the command is over when its child exits.
} else p.removePane(e.pane, null) catch |err| {
pane.body.mode = .normal;
p.reportError(e.pane, "terminal exited; Del retries close", err);
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 490958ee..8e3809b0 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -317,8 +317,17 @@ const Pty = struct {
file: std.Io.File,
pid: posix.pid_t,
reader: std.Io.Future(anyerror!void),
+ /// A command pane's child, watched to its exit (host_io.watchExit);
+ /// the pty stays open until both its end of file and its exit.
+ cmd: host_io.CommandWatch = .{},
};
+/// A watched child exited: wake the loop, which takes it (`takeExits`).
+fn wakeForExit(ctx: ?*anyopaque) void {
+ const loop: *Loop = @ptrCast(@alignCast(ctx.?));
+ _ = loop.tryPostEvent(.nop) catch {};
+}
+
const KittyPlacement = struct {
cell_x: u16,
cell_y: u16,
@@ -730,7 +739,9 @@ fn localSession(
.fs = fs,
};
pardes.lsp.setStatusSink(&sh, lspStatusSink);
+ host_io.setExitWake(loop, wakeForExit);
defer {
+ host_io.setExitWake(null, null);
pardes.lsp.setStatusSink(null, null);
for (&sh.ptys) |*slot| if (slot.*) |*pt| {
pt.reader.cancel(io) catch {};
@@ -797,6 +808,7 @@ fn localSession(
frames: while (!core.quit) {
pardes.turn.restoreSettled();
try core.pump(host);
+ sh.takeExits();
if (core.takeRestore()) |rp| blk: {
const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| {
core.reportError(core.active, "Restore", err);
@@ -1006,15 +1018,26 @@ const Shell = struct {
return true;
},
.pty_eof => |e| if (s.gens[e.id] == e.gen) {
- var status: ?u8 = null;
if (s.ptys[e.id]) |*pt| {
pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks
+ // A command's pty stays open until its child has exited:
+ // closing it would hang up one that runs on without it.
+ // Its exit, if it came first, is told now its output is in.
+ if (pt.cmd.watched) {
+ pt.cmd.eof = true;
+ core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
+ s.takeExits();
+ return true;
+ }
_ = libc.close(pt.file.handle);
- status = host_io.exitStatus(pt.pid, 100);
+ // Unwatched, a command's exit is read here, as its end.
+ const command = if (core.panes[e.id]) |pn| pn.command != null else false;
+ const status = if (command) host_io.exitStatus(pt.pid, 100) else null;
if (status == null) host_io.retireShell(pt.pid);
s.ptys[e.id] = null;
+ if (command) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
}
- core.update(.{ .eof = .{ .pane = @intCast(e.id), .status = status } });
+ core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},
.key_press => |key| if (s.in_paste) {
const bytes = pasteBytes(key);
@@ -1201,7 +1224,8 @@ const Shell = struct {
closePty(ctx, pane); // a shell still in the slot goes first, reaped
s.gens[pane] +%= 1;
const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err);
- s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
+ const command = if (s.core.panes[pane]) |pn| pn.command != null else false;
+ s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} }, .cmd = .{ .watched = command and host_io.watchExit(child.pid) } };
var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
if (s.threads_ok) {
@@ -1248,6 +1272,28 @@ const Shell = struct {
return host_io.ttyTaken(pt.pid, pt.file.handle);
}
+ /// Each watched child that exited: reaped, the core told, and its pty
+ /// closed if its end of file came first.
+ fn takeExits(s: *@This()) void {
+ while (host_io.takeExited()) |pid| for (&s.ptys) |*slot| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (pt.pid != pid or pt.cmd.exited) continue;
+ pt.cmd.exit((host_io.reapExited(pid) orelse break).status);
+ pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next
+ break;
+ };
+ for (&s.ptys, 0..) |*slot, id| {
+ const pt = if (slot.*) |*pt| pt else continue;
+ if (!pt.cmd.due()) continue;
+ pt.cmd.told = true;
+ s.core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } });
+ if (pt.cmd.eof) {
+ _ = libc.close(pt.file.handle);
+ slot.* = null;
+ }
+ }
+ }
+
fn killJob(ctx: ?*anyopaque, pane: u8) bool {
const s = of(ctx);
const pt = s.ptys[pane] orelse return false;
diff --git a/test/snapshots/cmdexit.golden b/test/snapshots/cmdexit.golden
new file mode 100644
index 00000000..58412752
--- /dev/null
+++ b/test/snapshots/cmdexit.golden
@@ -0,0 +1,14 @@
+== snap opened grid=120x31 cursor=7,3
+|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/cmdexit/cwd/cmds.txt Save Tty Collapse Del
+| 1 sleep 30 & echo BG''STARTED
+| 2 exec </dev/null >/dev/null 2>&1; sleep 1; false
+== snap background grid=120x31 cursor=7,3
+|17: /tmp/pardes-snap/cmdexit/cwd (sleep 30 & echo BG''STARTED) exit 0 Kill Save Collapse Del
+|18: BGSTARTED
+|19: exit 0
+== snap letgo grid=120x31 cursor=7,3
+|17: /tmp/pardes-snap/cmdexit/cwd (exec </dev/null >/dev/null 2>&1; sleep 1; false) exit 1 Kill Save Collapse Del
+|20: % exec </dev/null >/dev/null 2>&1; sleep 1; false
+|21: exit 1
diff --git a/test/snapshots/cmdexit.snap b/test/snapshots/cmdexit.snap
new file mode 100644
index 00000000..ff60a881
--- /dev/null
+++ b/test/snapshots/cmdexit.snap
@@ -0,0 +1,26 @@
+# A command pane's command is over when its process exits, not when its pty
+# closes. A job it leaves in the background holds the pty open, and the pane
+# still says `exit 0` as soon as the command itself is done; a command that
+# lets go of its terminal and runs on is not hung up at its pty's end, and
+# says how it ended when it does.
+file cmds.txt sleep 30 & echo BG''STARTED\nexec </dev/null >/dev/null 2>&1; sleep 1; false
+config Verbose off
+start 31 120 cmds.txt
+wait 8000 cmds.txt
+stable 700 20000
+snap opened
+# line 1: `sleep 30` keeps the pty, and the pane says exit 0 regardless
+press middle 7 4
+drag middle 34 4
+release middle 34 4
+wait 10000 exit 0
+stable 700 15000
+snap background
+# line 2, in the same pane now its command is done: its end of file comes at
+# once, and its exit a second later, from `false`
+press middle 7 5
+drag middle 54 5
+release middle 54 5
+wait 10000 exit 1
+stable 700 15000
+snap letgo