diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 20:35:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | c3e2cd3b854964ca7db3798cb8750ee6d0833057 (patch) | |
| tree | 0073c6bb6ec4cd78fffa89b2880dd83b4a106e1d /src/ninep/pty.zig | |
| parent | 5d9a56d47a9cd5eefc4c8bf03709f2e96febb212 (diff) | |
| download | pardes-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/pty.zig')
| -rw-r--r-- | src/ninep/pty.zig | 153 |
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); |
