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/detached | |
| 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/detached')
| -rw-r--r-- | src/detached/server.zig | 57 | ||||
| -rw-r--r-- | src/detached/wire.zig | 6 |
2 files changed, 54 insertions, 9 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, }, }; } |
