diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 18:07:06 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 6f7beca8d27e2e00f7b90bb75015637a9aa4c950 (patch) | |
| tree | 823e143a5e29aca8049c905213e40520f3354383 /src/ninep | |
| parent | f4412d6dfdb1e2bd8549a7edba785ee242fd38f2 (diff) | |
| download | pardes-6f7beca8d27e2e00f7b90bb75015637a9aa4c950.tar.gz pardes-6f7beca8d27e2e00f7b90bb75015637a9aa4c950.zip | |
Run a line at a terminal's prompt through pty/run and read how it ended
/pane/<n>/pty/run takes factotum's rpc shape: write one line on an open,
read the answer on that same open. The answer is exit N once the command
ended and the shell is back at a prompt; busy at once when a command runs,
text is typed at the prompt, or the shell has not drawn its first prompt;
error not run when the shell refused the line without running it (fish on
a syntax error keeps it on the prompt, so it is taken back with Ctrl-U);
error shell gone when the pane closes or its shell is replaced; error no
prompt marks for a shell pardes could not instrument.
The end comes from the shell's OSC 133 marks. ghostty parses D's exit
status and drops it, so the stream now runs through a handler that wraps
ghostty's and follows prompt -> input -> running -> done. The marks pardes
injects into bash and fish carry aid=pardes and only those count, so fish
4's own marks (which doubled ours), a nested shell's, and a stray 133;D in
printed output are ignored.
Checked end to end against real bash and fish: false, exit 7, a syntax
error, sleep, busy while running, and the pane closing mid-command.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/events.zig | 10 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 8 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 210 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 9 |
4 files changed, 230 insertions, 7 deletions
diff --git a/src/ninep/events.zig b/src/ninep/events.zig index ebd1cd2c..fa0a4975 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -128,6 +128,7 @@ pub fn noteInstall(p: *Pardes, id: usize) void { /// A pane leaving; one never announced leaves silently. pub fn noteRetire(p: *Pardes, id: usize, pane: *Pane) void { if (id >= MAX_PANES) return; + tree.pty.shellGone(p, id); if (p.fs.panes[id].unannounced) { p.fs.panes[id].unannounced = false; return; @@ -168,10 +169,11 @@ fn pushLog(p: *Pardes, record: []u8) void { c.* = ' '; }; // One record larger than the ring would push every other out and then - // not fit itself; cut it to what fits instead. - const fit = @min(record.len, p.fs.log.cap - 4); - record[fit - 1] = '\n'; - p.fs.log.push(p.gpa, record[0..fit]); + // not fit itself; cut it to what fits instead, on a character boundary. + var end = @min(record.len, p.fs.log.cap - 4) - 1; + while (end > 0 and record[end] & 0xC0 == 0x80) end -= 1; + record[end] = '\n'; + p.fs.log.push(p.gpa, record[0 .. end + 1]); } /// An open freezes the ring's text, so `cat log` answers what happened lately diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 40d292c0..61073a55 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -41,6 +41,10 @@ pub const State = struct { tag_snap: std.ArrayList(u8) = .empty, pty_readers: u16 = 0, pty_out: events.Queue = .{}, + /// The pty/run open waiting on this shell's current command. + run: ?u8 = null, + /// The host started a shell it could not teach to mark its prompts. + unmarked: bool = false, /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, @@ -225,7 +229,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .pty_status => pty.status_len, .pty_ctl => pty.ctlLen(pane), .pty_data => events.pending(&pf.pty_out), - .dir, .errors, .pty => 0, + .dir, .errors, .pty, .pty_run => 0, }; } @@ -293,6 +297,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), .pty_ctl => pty.readCtl(p, req, pane), + .pty_run => pty.readRun(p, req), .dir, .errors, .pty => Reply.fail(req.tag, E.PERM), }; } @@ -386,6 +391,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply Reply.fail(req.tag, E.IO), .pty_ctl => pty.writeCtl(p, req, id), .pty_data => pty.writeData(p, req, id), + .pty_run => pty.writeRun(p, req, id, pane), .dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM), }; } diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index b34bd59d..c7a32fbf 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -15,6 +15,7 @@ pub fn fileNamed(name: []const u8) ?PaneFile { if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; if (std.mem.eql(u8, name, "status")) return .pty_status; if (std.mem.eql(u8, name, "data")) return .pty_data; + if (std.mem.eql(u8, name, "run")) return .pty_run; return null; } @@ -101,6 +102,147 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return tree.stagedReply(p, req); } +// ---- run: one command line in, how it ended out ---- +// +// factotum's rpc shape (factotum(4)): write a request on an open, read the +// answer on that same open, so the open is what ties a result to its +// command. The shell's own OSC 133 marks say when the command started (C) +// and ended (D, with its status); pardes injects them into bash and fish. + +pub const run_slots = 8; + +pub const Run = struct { + /// 0 is a free slot. + node: u64 = 0, + phase: enum { idle, sent, done } = .idle, + /// The shell's command count its C must reach for the end to be ours. + want: u32 = 0, + /// The shell's prompt count when the line went: a newer prompt with no + /// C for it means the shell never ran the line. + prompts: u32 = 0, + /// The refused line was taken back off the prompt. + cleared: bool = false, + answer: [48]u8 = undefined, + len: u8 = 0, +}; + +fn runOf(p: *Pardes, req: Req) ?*Run { + if (req.handle == 0 or req.handle > run_slots) return null; + const slot = &p.fs.runs[req.handle - 1]; + return if (slot.node == req.node) slot else null; +} + +pub fn openRun(p: *Pardes, req: Req, serial: u32) Reply { + _ = serial; + for (&p.fs.runs, 0..) |*slot, i| { + if (slot.node != 0) continue; + slot.* = .{ .node = req.node }; + return .{ .tag = req.tag, .handle = @intCast(i + 1) }; + } + return Reply.fail(req.tag, E.NFILE); +} + +fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void { + slot.len = @intCast((std.fmt.bufPrint(&slot.answer, fmt ++ "\n", args) catch unreachable).len); + slot.phase = .done; + pardes.turn.parked = true; // wake the read waiting on it +} + +pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL); + if (slot.phase == .sent) return tree.failText(req.tag, 16, tree.e_in_use); + const line = std.mem.trimEnd(u8, req.data, "\r\n"); + if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); + for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); + const pf = &p.fs.panes[id]; + const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); + const marks = &state.stream.handler; + if (pf.unmarked) { + answer(slot, "error no prompt marks", .{}); + } else if (pf.run != null or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) { + // Something is running, someone has typed at the prompt, or the + // shell has not drawn its first one yet: sending now would type into + // the middle of it. + answer(slot, "busy", .{}); + } else { + slot.want = marks.started +% 1; + slot.prompts = marks.prompts; + p.emitWrite(id, line); + p.emitWrite(id, "\r"); + slot.phase = .sent; + pf.run = @intCast(slot - &p.fs.runs[0]); + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +pub fn readRun(p: *Pardes, req: Req) Reply { + const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL); + switch (slot.phase) { + .idle => return .{ .tag = req.tag, .payload = .{ .staged = 0 } }, + .sent => return .{ .tag = req.tag, .status = .again }, + .done => {}, + } + const text = slot.answer[0..slot.len]; + const off = @min(req.off, text.len); + const n = @min(text.len - off, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, text[off..][0..n]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; +} + +pub fn releaseRun(p: *Pardes, req: Req) Reply { + const slot = runOf(p, req) orelse return .{ .tag = req.tag }; + const idx: u8 = @intCast(slot - &p.fs.runs[0]); + // The command runs on in the shell; nobody is waiting for it any more. + for (&p.fs.panes) |*pf| if (pf.run == idx) { + pf.run = null; + }; + slot.* = .{}; + return .{ .tag = req.tag }; +} + +/// After each chunk of a terminal's output: once the command the run sent +/// has ended and the shell is back at a prompt, the run answers how. Waiting +/// for the prompt means whoever reads the answer can send the next command +/// at once instead of finding the shell still drawing it. +pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { + if (comptime !pardes.panes.Terminal.enabled) return; + const state = pane.terminal orelse return; + const pf = &p.fs.panes[id]; + const slot = &p.fs.runs[pf.run orelse return]; + const marks = &state.stream.handler; + if (marks.phase != .input) return; + // ponytail: a prompt redrawn before the command starts (a resize in that + // instant) reads as not run; a per-command id in the marks would tell. + if (marks.started != slot.want and marks.prompts != slot.prompts) { + // fish keeps a refused line on the prompt for editing; it is the one + // this run typed into an empty prompt, so take it back (Ctrl-U), and + // answer once it is gone so the next line finds the prompt clear. + if (!pardes.panes.Terminal.promptInputEmpty(pane)) { + if (!slot.cleared) p.emitWrite(id, "\x15"); + slot.cleared = true; + return; + } + answer(slot, "error not run", .{}); + pf.run = null; + return; + } + const done = marks.finished orelse return; + if (done.seq != slot.want) return; + if (done.status) |status| answer(slot, "exit {d}", .{status}) else answer(slot, "exit", .{}); + pf.run = null; +} + +/// 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. +pub fn shellGone(p: *Pardes, id: usize) void { + const pf = &p.fs.panes[id]; + const idx = pf.run orelse return; + answer(&p.fs.runs[idx], "error shell gone", .{}); + pf.run = null; +} + +const e_bad_line = "bad command line"; + pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; const out = p.fs.stage(p.gpa); @@ -186,10 +328,11 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" { try testing.expect(th.nameAt(files, "pty").?.dir); const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqual(@as(usize, 4), inside.len); try testing.expectEqualStrings("ctl", inside[0].name); try testing.expectEqualStrings("status", inside[1].name); try testing.expectEqualStrings("data", inside[2].name); + try testing.expectEqualStrings("run", inside[3].name); for (inside) |d| try testing.expect(!d.dir); try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); @@ -386,6 +529,71 @@ test "pty/data writes at the shell and reads the raw stream" { try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); } +test "run answers how its command ended once the shell is back at a prompt" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .pty_run); + const out = struct { + fn put(pp: *Pardes, bytes: []const u8) void { + pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + while (pp.nextEffect()) |_| {} + } + }; + const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; + + // Before its first prompt the shell cannot take a line: busy, at once. + const early = call(p, .{ .tag = 1, .op = .open, .node = node }); + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" }); + try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = early.reply.handle }); + + out.put(p, prompt); + const r = call(p, .{ .tag = 5, .op = .open, .node = node }); + const h = r.reply.handle; + const sent = call(p, .{ .tag = 6, .op = .write, .node = node, .handle = h, .data = "make\n" }); + try testing.expectEqualStrings("make\r", sent.pty()); + try testing.expectEqual(Status.again, call(p, .{ .tag = 7, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); + // A second line on the same open while the first runs is refused. + try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = node, .handle = h, .data = "ls\n" }).reply.status); + + // Untagged marks (fish's own, a nested shell's, a file being printed) + // and bash's C from its PROMPT_COMMAND trap change nothing. + out.put(p, "\x1b]133;D;9\x07\x1b]133;C;aid=pardes\x07building\r\n"); + out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;2;aid=pardes\x07"); + try testing.expectEqual(Status.again, call(p, .{ .tag = 9, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); + out.put(p, prompt); + try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .size = 64 }).bytes); + // The answer stays for a re-read until the open closes. + try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes); + _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h }); + + // Typed text on the prompt line: busy, rather than typing into it. + out.put(p, "ls -"); + const typed = call(p, .{ .tag = 13, .op = .open, .node = node }); + _ = call(p, .{ .tag = 14, .op = .write, .node = node, .handle = typed.reply.handle, .data = "true\n" }); + try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 15, .op = .read, .node = node, .handle = typed.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 16, .op = .release, .node = node, .handle = typed.reply.handle }); + out.put(p, "\x08\x08\x08\x08\x1b[K"); + + // A line the shell refuses without running it (fish on a syntax error): + // a fresh prompt and no C. Waiting for a D would wait forever. + const refused = call(p, .{ .tag = 21, .op = .open, .node = node }); + _ = call(p, .{ .tag = 22, .op = .write, .node = node, .handle = refused.reply.handle, .data = "(exit 7)\n" }); + out.put(p, "\r\nfish: command substitutions not allowed\r\n" ++ prompt); + try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 24, .op = .release, .node = node, .handle = refused.reply.handle }); + + // The pane going away while a command runs ends the wait. + out.put(p, "\r\n" ++ prompt); + const gone = call(p, .{ .tag = 17, .op = .open, .node = node }); + _ = 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); + _ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle }); + for (p.fs.runs) |slot| try testing.expect(slot.node == 0); +} + test "the pty queue drops the oldest at its cap" { const gpa = testing.allocator; const p = try withTerm(gpa); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index a85a1873..0d328594 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -167,6 +167,7 @@ pub const PaneFile = enum(u5) { pty_ctl, pty_status, pty_data, + pty_run, pub fn fileName(f: PaneFile) []const u8 { return switch (f) { @@ -174,6 +175,7 @@ pub const PaneFile = enum(u5) { .pty_ctl => "ctl", .pty_status => "status", .pty_data => "data", + .pty_run => "run", else => @tagName(f), }; } @@ -193,7 +195,7 @@ pub const PaneFile = enum(u5) { pub fn inPty(f: PaneFile) bool { return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, + .pty, .pty_ctl, .pty_status, .pty_data, .pty_run => true, else => false, }; } @@ -531,6 +533,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { pf.event_reader = pf.event_reader or reader; if (reader) return .{ .tag = req.tag, .handle = reader_handle }; }, + .pty_run => return pty.openRun(p, req, t.serial), .pty_data => if (reads(req.omode)) { if (pf.pty_readers > 0) return failText(req.tag, e_busy, e_in_use); pf.pty_readers = 1; @@ -569,6 +572,8 @@ fn releaseHandle(p: *Pardes, req: Req) Reply { switch (target) { .top => {}, .pane => |t| { + // A run's open outlives its pane: it still holds the answer. + if (t.file == .pty_run) return pty.releaseRun(p, req); if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; const pf = &p.fs.panes[id]; @@ -633,6 +638,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { .root, .pane => Reply.fail(req.tag, E.PERM), }, .pane => |t| { + // A run's answer outlives the pane it ran in. + if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req); const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); |
