summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
7 files changed, 381 insertions, 36 deletions
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;