summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
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 /src/tty/tty.zig
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]>
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig54
1 files changed, 50 insertions, 4 deletions
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;