From 594970f18dee92308fd1554663cc408cda4d102c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 18:20:06 -0300 Subject: Make pty/run and /log streams, and take refused lines back cleanly Review fixes to pty/run and /log: - Both are streams with a per-open cursor. A shell's exec 3<>file shares one offset between write and read, so cat <&3 after echo make >&3 asked for offset 5 and got "0" instead of "exit 0"; log after follow lost its first bytes the same way. - A run is accepted only in pardes's own tagged input phase, so a nested shell's prompt (ssh, a shell with its own integration) is never taken for this shell's. - A line the shell refused is taken back so the next finds the prompt clear: bash's continuation prompt (now tagged k=c) gets Ctrl-C and the answer waits for the fresh prompt; a bash syntax error (no C, but a D) is answered at once; fish's kept line gets Ctrl-U (a Ctrl-C sent while fish redraws is lost). fish's right prompt no longer reads as typed input. - bash marks a command's start from PS0 on bash 4.4+, not a DEBUG trap, so a user's own DEBUG trap (bash-preexec, atuin) survives. bash's own job notices now show as in plain bash (ttyfollow golden). - 32 run slots; a new line on an open starts a fresh answer. Checked live in bash, fish, and fish with a right prompt. Co-Authored-By: Claude Opus 5.5 --- src/host_io.zig | 7 ++- src/ninep/events.zig | 7 ++- src/ninep/pty.zig | 116 +++++++++++++++++++++++++++++++++------- src/ninep/screen.zig | 4 ++ src/panes.zig | 23 ++++++-- test/fs.py | 3 ++ test/snapshots/ttyfollow.golden | 26 ++++----- 7 files changed, 145 insertions(+), 41 deletions(-) diff --git a/src/host_io.zig b/src/host_io.zig index fcd2225b..34c2f57a 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -589,8 +589,13 @@ pub const Shell = struct { const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" \\PS1='\[\e]133;A;cl=line;aid=pardes\a\]'"$PS1"'\[\e]133;B;aid=pardes\a\]' + \\PS2='\[\e]133;A;k=c;aid=pardes\a\]'"$PS2"'\[\e]133;B;aid=pardes\a\]' \\PROMPT_COMMAND='printf "\e]133;D;%s;aid=pardes\a" "$?"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" - \\trap 'printf "\e]133;C;aid=pardes\a"' DEBUG + \\if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then + \\ PS0='\e]133;C;aid=pardes\a'"$PS0" + \\else + \\ trap 'printf "\e]133;C;aid=pardes\a"' DEBUG + \\fi \\ ; diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fa0a4975..20cdeda4 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -208,10 +208,11 @@ pub fn readLog(p: *Pardes, req: Req) Reply { if (slot.node != req.node) return Reply.fail(req.tag, E.INVAL); const frozen = slot.bytes orelse &.{}; const out = p.fs.stage(p.gpa); - if (!slot.follow or req.off < frozen.len) { - const off = @min(req.off, frozen.len); + if (!slot.follow or slot.pos < frozen.len) { + const off = @min(slot.pos, frozen.len); const len = @min(frozen.len - off, req.size); out.appendSlice(p.gpa, frozen[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); + slot.pos += len; return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; } const q = &p.fs.log; @@ -678,6 +679,8 @@ test "the log records whether or not anyone reads, and an open that follows wait const frozen = call(p, .{ .tag = 6, .op = .read, .node = log, .handle = fh, .size = 8192 }).bytes.len; try testing.expectEqual(Status.err, call(p, .{ .tag = 7, .op = .write, .node = log, .handle = fh, .data = "tail\n" }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = log, .handle = fh, .data = "follow\n" }).reply.status); + // A stream: after `echo follow >&3` a shell reads at offset 7, and gets + // what comes next, not the frozen text from byte 7. const rdf = struct { fn next(pp: *Pardes, node: u64, h: u32, off: u64) th.Answer { return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 }); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index c7a32fbf..ec2e18e5 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -109,7 +109,7 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // 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_slots = 32; pub const Run = struct { /// 0 is a free slot. @@ -120,8 +120,16 @@ pub const Run = struct { /// 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, + /// The shell's count of lines it handled without running when the line + /// went. + declined: u32 = 0, + /// How the refused line is being taken back. + cleared: enum { no, kept_line, continuation } = .no, + /// How much of the answer this open has read. A stream: a shell's + /// `exec 3<>run` shares one offset between the write and the read, so + /// the offset a read arrives with means nothing (acme's event file and + /// Plan 9's kprint read the same way). + read: u8 = 0, answer: [48]u8 = undefined, len: u8 = 0, }; @@ -151,6 +159,7 @@ fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void { 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); + slot.* = .{ .node = slot.node }; // a new line on this open: a new answer 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); @@ -159,14 +168,19 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { 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)) { + } else if (pf.run != null or marks.phase != .input 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. + // the middle of it. The phase is pardes's own marks: a nested + // shell's prompt (ssh, a shell with its own integration) looks like + // an empty prompt to ghostty but is not the shell this run knows. answer(slot, "busy", .{}); } else { slot.want = marks.started +% 1; slot.prompts = marks.prompts; + slot.declined = marks.declined; p.emitWrite(id, line); p.emitWrite(id, "\r"); slot.phase = .sent; @@ -182,10 +196,10 @@ pub fn readRun(p: *Pardes, req: Req) Reply { .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); + const text = slot.answer[slot.read..slot.len]; + const n = @min(text.len, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, text[0..n]) catch return Reply.fail(req.tag, E.NOMEM); + slot.read += @intCast(n); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; } @@ -214,14 +228,40 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { // 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; + // fish keeps a refused line on the prompt for editing, and bash's + // continuation prompt holds an unfinished one (its line looks empty + // but the command is still pending). Either is the line this run + // typed into an empty prompt, so throw it away with Ctrl-C, which + // drops the whole pending command, and answer at the prompt after + // that, so the next line finds the shell clear. + const empty = pardes.panes.Terminal.promptInputEmpty(pane); + switch (slot.cleared) { + .no => { + if (marks.continuation) { + // bash holds the start of the command: Ctrl-C drops all of it + p.emitWrite(id, "\x03"); + slot.cleared = .continuation; + slot.prompts = marks.prompts; // gone at the next prompt + return; + } + if (!empty) { + // fish keeps the refused line on the prompt; a run's line + // is one line, so Ctrl-U takes it all (a Ctrl-C sent while + // fish redraws is lost) + p.emitWrite(id, "\x15"); + slot.cleared = .kept_line; + return; + } + // bash said it handled the line (a D) and cleared its prompt + if (marks.declined == slot.declined) return; + // Otherwise an empty ordinary prompt means fish has not drawn + // the kept line yet, or a redraw landed before the line ran + // (its C is still coming): the next output says which. + }, + .continuation => if (marks.continuation) return, + .kept_line => {}, } + if (!empty) return; answer(slot, "error not run", .{}); pf.run = null; return; @@ -563,11 +603,25 @@ test "run answers how its command ended once the shell is back at a prompt" { 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); + // A shell's `exec 3<>run; echo make >&3; cat <&3` reads at the offset + // its write left (5): the answer is a stream and comes whole anyway. + try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes); + try testing.expectEqual(@as(usize, 0), call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes.len); _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h }); + // While a command runs, a nested shell's own (untagged) prompt looks + // like an empty prompt to the emulator; it is not this shell's. + const outer = call(p, .{ .tag = 30, .op = .open, .node = node }); + _ = call(p, .{ .tag = 31, .op = .write, .node = node, .handle = outer.reply.handle, .data = "ssh box\n" }); + out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;A\x07remote$ \x1b]133;B\x07"); + const nested = call(p, .{ .tag = 32, .op = .open, .node = node }); + _ = call(p, .{ .tag = 33, .op = .write, .node = node, .handle = nested.reply.handle, .data = "true\n" }); + try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 34, .op = .read, .node = node, .handle = nested.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 35, .op = .release, .node = node, .handle = nested.reply.handle }); + out.put(p, "logout\r\n\x1b]133;D;0;aid=pardes\x07" ++ prompt); + try testing.expectEqualStrings("exit 0\n", call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 37, .op = .release, .node = node, .handle = outer.reply.handle }); + // 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 }); @@ -580,10 +634,32 @@ test "run answers how its command ended once the shell is back at a prompt" { // 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); + out.put(p, "\r\nfish: command substitutions not allowed\r\n" ++ prompt ++ "(exit 7)"); + try testing.expectEqual(Status.again, call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).reply.status); + out.put(p, "^C\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 }); + // bash on a syntax error prints no C but still a D before its prompt. + const bad = call(p, .{ .tag = 40, .op = .open, .node = node }); + _ = call(p, .{ .tag = 41, .op = .write, .node = node, .handle = bad.reply.handle, .data = "fi\n" }); + out.put(p, "fi\r\nbash: syntax error\r\n\x1b]133;D;2;aid=pardes\x07" ++ prompt); + try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 42, .op = .read, .node = node, .handle = bad.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 43, .op = .release, .node = node, .handle = bad.reply.handle }); + + // An unfinished line (an open quote) leaves bash at its continuation + // prompt, tagged too: the line is thrown away with Ctrl-C, then answered. + const open_quote = call(p, .{ .tag = 25, .op = .open, .node = node }); + const typed_quote = call(p, .{ .tag = 26, .op = .write, .node = node, .handle = open_quote.reply.handle, .data = "echo 'x\n" }); + try testing.expectEqualStrings("echo 'x\r", typed_quote.pty()); + const cont = call(p, .{ .tag = 27, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }); + try testing.expectEqual(Status.again, cont.reply.status); + out.put(p, "echo 'x\r\n\x1b]133;A;k=c;aid=pardes\x07> \x1b]133;B;aid=pardes\x07"); + try testing.expectEqual(Status.again, call(p, .{ .tag = 28, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }).reply.status); + out.put(p, "^C\r\n" ++ prompt); + try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 29, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 29, .op = .release, .node = node, .handle = open_quote.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 }); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 1743fbb6..89ca369b 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -19,6 +19,10 @@ pub const Snapshot = struct { next: u64 = 0, /// How much of record `next` a short read already took. part: u32 = 0, + /// /log only: how much of `bytes` this open has read. Reads walk it as a + /// stream and ignore their offset, since `exec 3<>log; echo follow >&3` + /// leaves the shared offset past the word it wrote. + pos: u64 = 0, }; pub const snapshot_slots = 32; diff --git a/src/panes.zig b/src/panes.zig index 3b1bb1b4..410492b8 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -6520,6 +6520,12 @@ pub const Terminal = struct { /// Counts prompts drawn (B), so a line the shell refused without /// running (a fish syntax error: no C, no D, a new prompt) shows. prompts: u32 = 0, + /// D marks outside a running command: the shell handled a line + /// without starting it (bash on a syntax error prints no C). + declined: u32 = 0, + /// The newest prompt continues an unfinished line (bash's PS2, marked + /// `k=c`): the shell still holds the start of the command. + continuation: bool = false, /// The newest command to end: which one, and its status if given. finished: ?struct { seq: u32, status: ?i32 } = null, @@ -6530,7 +6536,10 @@ pub const Terminal = struct { const aid = value.readOption(.aid) orelse break :mark; if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; switch (value.action) { - .fresh_line_new_prompt => self.phase = .prompt, + .fresh_line_new_prompt => { + self.phase = .prompt; + self.continuation = value.readOption(.prompt_kind) == .continuation; + }, .end_prompt_start_input => { self.phase = .input; self.prompts +%= 1; @@ -6544,6 +6553,8 @@ pub const Terminal = struct { .end_command => if (self.phase == .running) { self.phase = .none; self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; + } else { + self.declined +%= 1; }, else => {}, } @@ -7346,14 +7357,16 @@ pub const Terminal = struct { state.vt.screens.active.cursor.semantic_content == .input; } - /// At a prompt with nothing typed on its line, wherever the cursor is - /// (someone may have typed and pressed Ctrl-A). fish suggests nothing on - /// an empty line, so its autosuggestions never make this false. + /// At a prompt with nothing typed on its line: no input left of the + /// cursor and none under it (text typed and then Ctrl-A'd starts at the + /// cursor). Nothing further right counts: that is where fish draws a + /// right prompt, and fish suggests nothing on an empty line. pub fn promptInputEmpty(pane: *const Pane) bool { if (comptime !enabled) return false; if (!promptInputReady(pane)) return false; const cursor = &pane.terminal.?.vt.screens.active.cursor; - for (cursor.page_pin.cells(.all)) |cell| + const row = cursor.page_pin.cells(.all); + for (row[0..@min(cursor.x + 1, row.len)]) |cell| if (cell.semantic_content == .input and !cell.isEmpty()) return false; return true; } diff --git a/test/fs.py b/test/fs.py index ff085a55..d44f417d 100644 --- a/test/fs.py +++ b/test/fs.py @@ -311,6 +311,9 @@ def run_file(binary): time.sleep(.05) assert run(client, term, b'false\n') == b'exit 1\n' assert run(client, term, b'sh -c "exit 7"\n') == b'exit 7\n' + assert run(client, term, b'fi\n') == b'error not run\n' # a syntax error runs nothing + assert run(client, term, b"echo 'open\n") == b'error not run\n' # nor does an open quote + assert run(client, term, b'true\n') == b'exit 0\n' # ...and the shell is clear after with Client(address) as other: slow = [] waiter = threading.Thread(target=lambda: slow.append(run(other, term, b'sleep 1\n')), daemon=True) diff --git a/test/snapshots/ttyfollow.golden b/test/snapshots/ttyfollow.golden index e77b3caa..77f27d8e 100644 --- a/test/snapshots/ttyfollow.golden +++ b/test/snapshots/ttyfollow.golden @@ -28,17 +28,17 @@ |8: 190 $ == snap tty-follows grid=100x31 cursor=4,16 |2: $ /tmp/pardes-snap/ttyfollow/cwd Tty Save Mode Fi$ /tmp/pardes-snap/ttyfollow/cwd Tty Save Mode Fi -|3: 288 $ ls -|4: 289 s.txt -|5: 290 $ touch go; until test -f done1; do sleep 0.05; -|6: 291 done; echo SYN''CED -|7: 292 SYNCED -|8: 293 $ -|9: 294 -|10: 295 -|11: 296 -|12: 297 -|13: 298 -|14: 299 -|15: 300 +|3: 292 $ ls +|4: 293 s.txt +|5: 294 $ touch go; until test -f done1; do sleep 0.05; +|6: 295 done; echo SYN''CED +|7: 296 SYNCED +|8: 297 $ +|9: 298 +|10: 299 +|11: 300 +|12: [1]+ Done ( until test -f +|13: go; do +|14: sleep 0.05; +|15: done; seq 201 260; touch done1 ) |16: $ -- cgit v1.3