diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 20:51:06 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 015152bb7e28a26d135fc4bc67b09a5f53d5080b (patch) | |
| tree | d5ed31d3a0dee08e07c54f045f2fd0c13e2e4d8c | |
| parent | 78ee8672307adbdf30a2afffc38efb70217dc212 (diff) | |
| download | pardes-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.zon | 4 | ||||
| -rw-r--r-- | docs/fs.md | 11 | ||||
| -rw-r--r-- | src/Terminal.zig | 9 | ||||
| -rw-r--r-- | src/detached/server.zig | 12 | ||||
| -rw-r--r-- | src/exec.zig | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 12 | ||||
| -rw-r--r-- | src/macos.zig | 18 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 68 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 20 | ||||
| -rw-r--r-- | src/pardes.zig | 7 | ||||
| -rw-r--r-- | src/tty/tty.zig | 7 |
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 @@ -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) } }); }, |
