From d70f0029f0808e0a3724150a081015e0f943dfb5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:59:17 -0300 Subject: 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 --- src/detached/server.zig | 57 +++++++++++++++++-- src/detached/wire.zig | 6 +- src/gui/gui.zig | 73 ++++++++++++++++++++---- src/host_io.zig | 145 +++++++++++++++++++++++++++++++++++++++++++++++- src/macos.zig | 64 ++++++++++++++++++--- src/pardes.zig | 18 ++++-- src/tty/tty.zig | 54 ++++++++++++++++-- 7 files changed, 381 insertions(+), 36 deletions(-) (limited to 'src') 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 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; -- cgit v1.3