summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 20:51:06 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit015152bb7e28a26d135fc4bc67b09a5f53d5080b (patch)
treed5ed31d3a0dee08e07c54f045f2fd0c13e2e4d8c
parent78ee8672307adbdf30a2afffc38efb70217dc212 (diff)
downloadpardes-015152bb7e28a26d135fc4bc67b09a5f53d5080b.tar.gz
pardes-015152bb7e28a26d135fc4bc67b09a5f53d5080b.zip
A shell that exits under a run answers exit N, and the log says exit before del
A dogfood agent ran `exit 3` through pty/run: the reader got ENOENT, and the log went straight to del. The hosts now read a shell's exit status at its pty's end as they do a command's; a run waiting on the line answers `exit 3` with what it printed, the log says `exit <serial> 3` before the pane's `del`, and an open run still stats after its pane is gone (cat fstats its input). Pins cloud9 f35b7ed, whose stat of an open fid names its open. Writes to a gone pane's pty/data, held open or not, fail ENOENT. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--build.zig.zon4
-rw-r--r--docs/fs.md11
-rw-r--r--src/Terminal.zig9
-rw-r--r--src/detached/server.zig12
-rw-r--r--src/exec.zig2
-rw-r--r--src/gui/gui.zig12
-rw-r--r--src/macos.zig18
-rw-r--r--src/ninep/pty.zig68
-rw-r--r--src/ninep/tree.zig20
-rw-r--r--src/pardes.zig7
-rw-r--r--src/tty/tty.zig7
11 files changed, 134 insertions, 36 deletions
diff --git a/build.zig.zon b/build.zig.zon
index 781571aa..877dfacb 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -9,8 +9,8 @@
// read-only HTTPS URL is what a manifest can carry. Re-pin with
// `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`.
.cloud9 = .{
- .url = "git+https://git.sr.ht/~gbrls/cloud9#09b77cf7c13c3384a53594e76f51161cab64e5dd",
- .hash = "cloud9-0.1.0-yt86qmwFHgDi2ZU-ZRIyVZshMZ4Ce6A6Iaxu-FtJG2pI",
+ .url = "git+https://git.sr.ht/~gbrls/cloud9#f35b7ed09d0a170e4ffe55b5286c28f74c9a8760",
+ .hash = "cloud9-0.1.0-yt86quEKHgCwewf5XRlEZji1JCmdsfufsrZYGZqzHj5_",
},
// ZLS as a LIBRARY, not a language server: src/lsp_zls.zig imports the
// `zls` module its build.zig publishes and calls the analyser in
diff --git a/docs/fs.md b/docs/fs.md
index 29f15571..3800e8cb 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -489,7 +489,9 @@ an open renders its frame; stat the entry.
`/log` is one ring (64 KiB) that records whether or not anyone reads it:
`new`, `del`, `rename` (a terminal's too, as its shell changes directory,
-since a terminal is named by its directory) and `save <serial> <name>`,
+since a terminal is named by its directory), `exit <serial> <N>` before
+the `del` of a terminal whose shell exited by itself, and `save <serial>
+<name>`,
`dump <path>` when a Dump is written and `restore <path>` in a Restore's
new log after its panes' `new`s, and `msg <serial|-> <text>`
for every line the editor says, repeats included (with `verbose` on, that
@@ -576,8 +578,11 @@ could not instrument, or a startup that hangs) leaves such a line waiting
for ever: cancel the read (interrupt it, or close the open) to give up;
`error not run` when the shell refused
the line without running it (a fish syntax error; the line is taken back off
-the prompt); `error shell gone` when the pane closed or its shell was
-replaced; `error no prompt marks` for a shell pardes could not instrument;
+the prompt); `exit N` and what it printed when the line ended the shell
+itself (`exit 3`, or `echo bye; exit 3`): its terminal closes, and a read
+of the run's open still answers after the pane is gone; `error shell gone`
+when the pane closed or its shell was replaced, or the shell went without
+an exit status to tell; `error no prompt marks` for a shell pardes could not instrument;
`error command done; not a shell` (or `error a command runs here, not a
shell`) on a command pane, whose child is its command.
It relies on the OSC 133 marks pardes injects into bash and fish, tagged
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 19e23624..a390b76b 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -961,6 +961,15 @@ pub const CommandOutput = struct {
/// What the newest finished command printed: the screen text between its
/// C and D marks, read from at most `max_rows` rows above its end, so a
/// command that printed the whole history costs no more than its tail.
+/// The shell exited in the middle of a command (its `exit 3`): no end mark
+/// will come, so the command's output ends where the cursor is.
+pub fn endOutputHere(pane: *Pane) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ const marks = &state.stream.handler;
+ if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true);
+}
+
pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
// Unless there is text to read, whether the nothing it printed is the
// whole of it.
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 3921d8d9..2fc165eb 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -706,16 +706,14 @@ pub const Session = struct {
// 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) return host_io.commandEof(s.core, &s.ptys, pane, s, closeWatched);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end, and a
+ // shell's, for a run waiting on it and the log.
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
- }
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
s.closePty(pane);
s.harvest();
- if (unwatched) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = pane } });
}
diff --git a/src/exec.zig b/src/exec.zig
index ee7c31e9..1e9d4b2d 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -634,7 +634,7 @@ fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void {
/// The whole line, one record as `msg` carries its text: its control
/// characters, a newline too, become spaces (events.pushLog). A new pane is
/// announced first, so `new` comes before the `run` in it.
-fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
+pub fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
pardes.ctlfs.events.announce(p);
var buf: [command_max + 16]u8 = undefined;
pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, std.mem.trim(u8, said, " \t\r\n") }) catch return);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 321c80b5..cac311e2 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4057,15 +4057,13 @@ const Shell = struct {
host_io.commandEof(s.core, s.ptys, e.pane, s, closeWatched);
break :eof;
}
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
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
- }
+ const 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 } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = e.pane } });
}
s.saw_event = true;
diff --git a/src/macos.zig b/src/macos.zig
index b180d7ba..a2175f3d 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1313,15 +1313,15 @@ fn drainInbox(st: *State) bool {
host_io.commandEof(st.core, &st.ptys, e.pane, st, closeWatched);
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;
- };
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
+ const command = if (st.core.panes[e.pane]) |pane| pane.command != null else false;
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ reap(st, e.pane);
+ if (command or status != null) 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 } });
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 37132f90..393516ce 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -296,6 +296,12 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
pf.run = null;
+ finish(p, slot, pane, done.status);
+}
+
+/// Answers a run whose command ended with `status`: the header and the
+/// output it printed.
+fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void {
const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch
return answer(p, slot, "error out of memory", .{});
defer p.gpa.free(printed.text);
@@ -314,7 +320,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
// A D that carries no status says nothing of how the command went.
var code: [16]u8 = undefined;
- const status = if (done.status) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
+ const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
// The header is the whole first line, so a count there can never be
// mistaken for output; `cut` with no count: its start is not there to
// count from.
@@ -326,6 +332,22 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
answer(p, slot, "exit {s}", .{status});
}
+/// The shell itself exited (a run's `exit 3`, or one typed): a run waiting
+/// on the command it ran answers with the shell's status and what it
+/// printed, and the log says `exit` before the pane's `del`. Unknown, the
+/// status is not guessed: the run hears `error shell gone` when the pane
+/// goes.
+pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void {
+ const code = status orelse return;
+ if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) {
+ pane.fs.run = null;
+ pardes.panes.Terminal.endOutputHere(pane);
+ finish(p, slot, pane, code);
+ };
+ var buf: [4]u8 = undefined;
+ pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable);
+}
+
/// The pane closed or its shell was replaced: the command's end will never
/// be reported, so the run says so instead of waiting forever. A line not
/// sent yet waits on for the replacement's first prompt.
@@ -763,8 +785,52 @@ test "run answers how its command ended once the shell is back at a prompt" {
_ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" });
try p.removePane(0, null);
try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes);
+ // It still stats while the run is open (a reader's fstat), on the
+ // open or on the file's name, and not once it is released.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node, .handle = gone.reply.handle }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node }).reply.status);
_ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle });
for (p.fs.opens) |o| try testing.expect(o.node == 0);
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 21, .op = .getattr, .node = node }).errno());
+}
+
+test "a shell that exits under a run answers its status, and the log says exit before del" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const node = Node.of(serial, .pty_run);
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ p.update(.{ .output = .{ .pane = 0, .bytes = prompt } });
+ while (p.nextEffect()) |_| {}
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+
+ const r = call(p, .{ .tag = 4, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 5, .op = .write, .node = node, .handle = r.reply.handle, .data = "echo bye; exit 3\n" });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "echo bye; exit 3\r\n\x1b]133;C;aid=pardes\x07bye\r\n" } });
+ p.update(.{ .exited = .{ .pane = 0, .status = 3 } });
+ p.update(.{ .eof = .{ .pane = 0 } });
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.paneBySerial(serial) == null);
+ try testing.expectEqualStrings("exit 3\nbye\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = r.reply.handle, .size = 64 }).bytes);
+ var want: [128]u8 = undefined;
+ // A followed log reads a record at a time: the exit, then the del.
+ var records: [2][]const u8 = undefined;
+ var at = frozen;
+ for (&records) |*rec| {
+ rec.* = try gpa.dupe(u8, call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = at, .size = 4096 }).bytes);
+ at += rec.len;
+ }
+ defer for (records) |rec| gpa.free(rec);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "exit {d} 3\n", .{serial}), records[0]);
+ try testing.expect(std.mem.startsWith(u8, records[1], try std.fmt.bufPrint(&want, "del {d} ", .{serial})));
+ // A write to its pty/data now finds no pane.
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_data), "x").errno());
+ _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = r.reply.handle });
+ _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = f });
}
test "run on a command pane says no shell is there" {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index b3f3dde6..779ecff3 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -385,7 +385,7 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
return switch (req.op) {
.lookup => lookup(p, req, target),
- .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) },
+ .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse runAttr(p, req, target) orelse return Reply.fail(req.tag, E.NOENT) },
.setattr => setattr(p, req, target),
.open => open(p, req, target),
.release => release(p, req),
@@ -395,6 +395,24 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
}
+/// A run's answer outlives its pane, so its file still stats while a run
+/// is open on it (a reader's fstat, cat's), as acme's fid keeps its window.
+/// The stat names the open when it came on one; a mount's comes on the
+/// file's walked name, and serials are never reused, so any run open on
+/// this node is the reader's.
+fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr {
+ const t = switch (target) {
+ .pane => |t| t,
+ .top => return null,
+ };
+ if (t.file != .pty_run) return null;
+ const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| {
+ if (o.node == req.node and o.what == .run) break o;
+ } else null;
+ if ((run orelse return null).what != .run) return null;
+ return .{ .name = t.file.fileName(), .node = Node.of(t.serial, t.file), .mode = t.file.mode(), .mtime = p.fs.started };
+}
+
fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
switch (target) {
.top => |f| return .{
diff --git a/src/pardes.zig b/src/pardes.zig
index a3436870..239987ea 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5157,8 +5157,11 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
- exec.commandDone(p, e.pane, e.status);
+ .exited => |e| if (p.panes[e.pane]) |pane| {
+ if (pane.command == null)
+ ctlfs.pty.shellExited(p, pane, e.status)
+ else if (!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.
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index d17316e8..b18f4de5 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1050,12 +1050,13 @@ const Shell = struct {
return true;
}
_ = libc.close(pt.file.handle);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
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;
+ const status = host_io.exitStatus(pt.pid, 100);
if (status == null) host_io.retireShell(pt.pid);
s.ptys[e.id] = null;
- if (command) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
+ if (command or status != null) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
}
core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},