summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 20:35:01 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitc3e2cd3b854964ca7db3798cb8750ee6d0833057 (patch)
tree0073c6bb6ec4cd78fffa89b2880dd83b4a106e1d /src/ninep
parent5d9a56d47a9cd5eefc4c8bf03709f2e96febb212 (diff)
downloadpardes-c3e2cd3b854964ca7db3798cb8750ee6d0833057.tar.gz
pardes-c3e2cd3b854964ca7db3798cb8750ee6d0833057.zip
pty/run says cut when its output's start is gone, and reads only a bounded tail
A clear or reset while the command ran, a start or end mark that came on the alternate screen, or a garbage end pin read as a complete answer; now any of them, like a start that scrolled out, answers exit N cut. The output is read from at most output_rows rows above its end instead of the whole history on the editor's thread (cut if that clips it), a D with no status answers exit ? rather than exit 0, and running out of memory answers error out of memory rather than cut. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/pty.zig153
1 files changed, 133 insertions, 20 deletions
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 706163e5..936f9b73 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -113,6 +113,11 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
/// bytes, since that is where a failure says what went wrong.
pub const output_cap = 64 * 1024;
+/// The most rows above a command's end its output is read from: `output_cap`
+/// at eight bytes a row. The answer is made on the editor's thread, which
+/// would otherwise render a command's whole 16 MiB of history to keep 64 KiB.
+pub const output_rows = output_cap / 8;
+
/// What an open of run holds (tree.zig's open records).
pub const Run = struct {
phase: enum { idle, sent, done } = .idle,
@@ -258,34 +263,35 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
- const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa) catch null;
- defer if (printed) |text| p.gpa.free(text);
- const scrolled_out = if (state.stream.handler.out_start) |pin| pin.garbage else false;
+ pf.run = null;
+ 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);
// Keep the tail, cut at a line start so the first line kept is whole.
- var keep: []const u8 = printed orelse "";
+ var keep: []const u8 = printed.text;
if (keep.len > output_cap) {
keep = keep[keep.len - output_cap ..];
if (std.mem.indexOfScalar(u8, keep, '\n')) |nl| keep = keep[nl + 1 ..];
}
- const cut = (if (printed) |text| text.len else 0) - keep.len;
- const status = done.status orelse 0;
- // The header is the whole first line, so a count there can never be
- // mistaken for output; `cut` with no count: its start scrolled away.
- if (printed == null or scrolled_out)
- answer(p, slot, "exit {d} cut", .{status})
- else if (cut > 0)
- answer(p, slot, "exit {d} cut {d}", .{ status, cut })
- else
- answer(p, slot, "exit {d}", .{status});
if (keep.len > 0) {
const nl = @intFromBool(keep[keep.len - 1] != '\n');
- if (p.gpa.alloc(u8, keep.len + nl)) |owned| {
- @memcpy(owned[0..keep.len], keep);
- if (nl == 1) owned[keep.len] = '\n';
- slot.output = owned;
- } else |_| {}
+ const owned = p.gpa.alloc(u8, keep.len + nl) catch return answer(p, slot, "error out of memory", .{});
+ @memcpy(owned[0..keep.len], keep);
+ if (nl == 1) owned[keep.len] = '\n';
+ slot.output = owned;
}
- pf.run = null;
+ // 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 "?";
+ // 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.
+ if (!printed.whole)
+ answer(p, slot, "exit {s} cut", .{status})
+ else if (printed.text.len > keep.len)
+ answer(p, slot, "exit {s} cut {d}", .{ status, printed.text.len - keep.len })
+ else
+ answer(p, slot, "exit {s}", .{status});
}
/// The pane closed or its shell was replaced: the command's end will never
@@ -687,6 +693,113 @@ test "run answers how its command ended once the shell is back at a prompt" {
for (p.fs.opens) |o| try testing.expect(o.node == 0);
}
+test "a run's answer says cut when its output's start is gone, and reads a bounded tail" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .pty_run);
+ const pane = p.panes[0].?;
+ const sh = struct {
+ fn put(pp: *Pardes, bytes: []const u8) void {
+ pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ while (pp.nextEffect()) |_| {}
+ }
+ fn send(pp: *Pardes, n: u64) u32 {
+ const h = call(pp, .{ .tag = 1, .op = .open, .node = n }).reply.handle;
+ _ = call(pp, .{ .tag = 2, .op = .write, .node = n, .handle = h, .data = "x\n" });
+ return h;
+ }
+ /// The whole answer, then the open is closed.
+ fn answered(pp: *Pardes, n: u64, h: u32, into: []u8) []const u8 {
+ defer _ = call(pp, .{ .tag = 4, .op = .release, .node = n, .handle = h });
+ var len: usize = 0;
+ while (true) {
+ const a = call(pp, .{ .tag = 3, .op = .read, .node = n, .handle = h, .size = 8192 });
+ if (a.reply.status != .ok or a.bytes.len == 0) return into[0..len];
+ @memcpy(into[len..][0..a.bytes.len], a.bytes);
+ len += a.bytes.len;
+ }
+ }
+ };
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ const c = "\r\n\x1b]133;C;aid=pardes\x07";
+ const d = "\x1b]133;D;0;aid=pardes\x07";
+ const into = try gpa.alloc(u8, 2 * output_cap);
+ defer gpa.free(into);
+ sh.put(p, prompt);
+
+ // `clear` while it runs erases what it printed before, and the pins then
+ // span rows that no longer hold it (ED 3 even moves one to the next page
+ // without calling it garbage), so only the erase itself tells.
+ var h = sh.send(p, node);
+ sh.put(p, c ++ "before\r\n\x1b[H\x1b[2J\x1b[3Jafter\r\n" ++ d ++ prompt);
+ var got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+
+ // A resize between its start and end reflows the pins with the text.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "one\r\ntwo\r\n");
+ pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20);
+ sh.put(p, d ++ prompt);
+ try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into));
+ pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows);
+
+ // A full-screen program leaves nothing on the primary screen, and a D
+ // with no status is not a success.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "\x1b[?1049hwhole screen\x1b[?1049l\x1b]133;D;aid=pardes\x07" ++ prompt);
+ try testing.expectEqualStrings("exit ?\n", sh.answered(p, node, h, into));
+
+ // A megabyte of output: read from at most `output_rows` rows above its
+ // end (the history still holds its start), the last 64 KiB kept.
+ h = sh.send(p, node);
+ sh.put(p, c);
+ var line: [120]u8 = @splat('y');
+ line[118] = '\r';
+ line[119] = '\n';
+ for (0..(1 << 20) / line.len) |i| {
+ _ = std.fmt.bufPrint(&line, "{d:0>8}", .{i}) catch unreachable;
+ sh.put(p, &line);
+ }
+ try testing.expect(!pane.terminal.?.stream.handler.out_start.?.garbage);
+ sh.put(p, d ++ prompt);
+ got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+ try testing.expect(got.len <= "exit 0 cut\n".len + output_cap + 1);
+ try testing.expect(std.mem.endsWith(u8, got, "\n") and std.mem.indexOf(u8, got, "00008737") != null);
+
+ // Its start pruned from a history held to its least: what survived, and
+ // cut.
+ {
+ const q = try withTerm(gpa);
+ defer q.deinit();
+ const kept = q.panes[0].?;
+ kept.terminal.?.vt.screens.get(.primary).?.pages.explicit_max_size = 1;
+ const run = Node.of(serialOf(q), .pty_run);
+ sh.put(q, prompt);
+ h = sh.send(q, run);
+ sh.put(q, c);
+ const lines = "pruned\r\n" ** 64;
+ var fed: usize = 0;
+ while (!kept.terminal.?.stream.handler.out_start.?.garbage) : (fed += 1) {
+ if (fed == 1000) return error.TestUnexpectedResult;
+ sh.put(q, lines);
+ }
+ sh.put(q, d ++ prompt);
+ got = sh.answered(q, run, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\npruned\n"));
+ }
+
+ // No memory for the answer's output is said, not passed off as a cut.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "short\r\n");
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ sh.put(p, d ++ prompt);
+ p.gpa = gpa;
+ try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into));
+}
+
test "the pty queue drops the oldest at its cap" {
const gpa = testing.allocator;
const p = try withTerm(gpa);