summaryrefslogtreecommitdiff
path: root/src/acmefs.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/acmefs.zig')
-rw-r--r--src/acmefs.zig832
1 files changed, 804 insertions, 28 deletions
diff --git a/src/acmefs.zig b/src/acmefs.zig
index 35523358..21beb7f6 100644
--- a/src/acmefs.zig
+++ b/src/acmefs.zig
@@ -171,7 +171,24 @@ pub const E = struct {
/// One file inside a pane's directory: acme's `dirtabw` minus the plan9
/// compatibility stubs (`editout` needs acme's Edit language; `draw`,
/// `consctl` and `label` are rio artefacts acme keeps for other programs'
-/// sake), plus nothing.
+/// sake), plus the `pty/` directory and its three files — the one thing here
+/// with no prior art anywhere, because acme has no terminals and `ad` has no
+/// terminal surface at all.
+///
+/// A pty is a file interface wearing the wrong clothes: everything one wants
+/// to do to it is an `ioctl`, and no dialect of this protocol has one. So
+/// `TIOCSWINSZ` becomes `winsize 80 24`, `kill` becomes `sig INT`, spawn
+/// becomes `exec`, and `TIOCGWINSZ` becomes a read of `status`. Three files
+/// and no more: being first is a reason to keep it small.
+///
+/// THE FIELD IS FULL AFTER THIS. `Node.file` is a u4 — sixteen values — and
+/// these four take it to fifteen used. ONE VALUE (15) IS LEFT. The next file
+/// added to a pane's directory needs a wider field, which means `Node`'s
+/// packing changes and every node id in flight through a transport changes
+/// with it; that is a deliberate wall, not an oversight, and it is why `pty/`
+/// is a DIRECTORY holding three names rather than three more names beside
+/// `body` — a subdirectory costs one value for the directory itself and buys
+/// a namespace of its own, so `ctl` and `data` did not have to be renamed.
pub const PaneFile = enum(u4) {
dir = 0,
addr,
@@ -184,22 +201,56 @@ pub const PaneFile = enum(u4) {
xdata,
rdsel,
wrsel,
+ /// the `pty/` directory itself, present only on a terminal pane
+ pty,
+ /// `pty/ctl`: `winsize`, `sig`, `exec`. Spelled with the prefix because
+ /// the enum is flat — the tree is two levels and the tag namespace is one
+ /// — and `name()` below is what puts the short name back on the wire.
+ pty_ctl,
+ /// `pty/status`: the dimensions and who holds the tty
+ pty_status,
+ /// `pty/data`: the raw stream, both directions
+ pty_data,
- /// Every name IS the variant's name; only the directory itself is spelled
- /// differently, because `.` is not an identifier.
+ /// Every name IS the variant's name, except the directory itself (`.` is
+ /// not an identifier) and the three inside `pty/`, whose names are already
+ /// taken by files beside `body` and so carry a prefix in the enum only.
pub fn name(f: PaneFile) []const u8 {
- return if (f == .dir) "." else @tagName(f);
+ return switch (f) {
+ .dir => ".",
+ .pty_ctl => "ctl",
+ .pty_status => "status",
+ .pty_data => "data",
+ else => @tagName(f),
+ };
}
/// acme's dirtabw modes: 0400 read, 0200 write, 0600 both.
pub fn mode(f: PaneFile) u16 {
return switch (f) {
- .dir => 0o500,
- .errors, .wrsel => 0o200,
- .rdsel => 0o400,
+ .dir, .pty => 0o500,
+ .errors, .wrsel, .pty_ctl => 0o200,
+ .rdsel, .pty_status => 0o400,
else => 0o600,
};
}
+
+ /// The two directories a pane has. Asked by `stat` and by every handler
+ /// that must refuse to treat a directory as a file.
+ pub fn isDir(f: PaneFile) bool {
+ return f == .dir or f == .pty;
+ }
+
+ /// Does this name exist ONLY on a terminal pane? A file pane has no pty,
+ /// so the whole subtree is absent there rather than present and refusing:
+ /// a script tests `-d $PARDES_FS/7/pty` to find out whether pane 7 is a
+ /// terminal, which is a question the tree could not answer before.
+ pub fn inPty(f: PaneFile) bool {
+ return switch (f) {
+ .pty, .pty_ctl, .pty_status, .pty_data => true,
+ else => false,
+ };
+ }
};
/// The files at the root, and the root itself. `new` is a directory whose
@@ -331,6 +382,29 @@ pub const Queue = struct {
}
}
+ /// Consume `n` bytes off the FRONT of the oldest record, leaving whatever
+ /// is left of it as the new oldest record.
+ ///
+ /// A record-framed queue can do this at all only because the frame is a
+ /// length written IMMEDIATELY BEFORE its bytes: shortening the record
+ /// means writing the new length into the four bytes that now sit just
+ /// before what remains, and those four bytes are inside the region the old
+ /// length and the consumed bytes already occupied. Nothing live is
+ /// overwritten and nothing moves.
+ ///
+ /// Only a STREAM wants this. `event`'s records are atomic — half a record
+ /// is unparseable and desynchronises the reader for the rest of the
+ /// session — so `event` uses `pop` and refuses a short read. `pty/data`
+ /// carries raw pty bytes, which have no framing of their own: the records
+ /// there are only "what arrived in one `.output` event" and a reader may
+ /// split them anywhere, exactly as `read(2)` on the pty itself would.
+ pub fn popFront(q: *Queue, n: usize) void {
+ const record = q.peek() orelse return;
+ if (n >= record.len) return q.pop();
+ q.head += n;
+ std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little);
+ }
+
fn compact(q: *Queue) void {
if (q.head == 0 or q.head * 2 < q.buf.items.len) return;
const rest = q.buf.items.len - q.head;
@@ -342,6 +416,15 @@ pub const Queue = struct {
pub fn empty(q: *const Queue) bool {
return q.peek() == null;
}
+
+ /// Drop everything AND give the memory back. A queue whose last reader
+ /// left must not hold `queue_cap` of a program's output until its pane
+ /// dies; `clearRetainingCapacity` inside `pop` is the right thing between
+ /// reads and the wrong thing between readers.
+ pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void {
+ q.buf.clearAndFree(gpa);
+ q.head = 0;
+ }
};
/// Per-pane filesystem state, indexed by pane SLOT (not serial): it dies with
@@ -364,11 +447,25 @@ pub const PaneFs = struct {
/// reported without a hook in every tag mutation. Only kept while somebody
/// is listening.
tag_snap: std.ArrayList(u8) = .empty,
+ /// How many opens of this pane's `pty/data` file are live. THE GATE on the
+ /// raw queue below, and deliberately NOT `readers` above: a script reading
+ /// a terminal's output stream is not claiming the pane's buttons, so a pty
+ /// reader must not make the pane script-driven. Nobody reading means
+ /// `notePtyOutput` is one load and one branch and copies nothing.
+ pty_readers: u16 = 0,
+ /// Raw pty bytes on their way to the emulator, kept only while somebody is
+ /// reading them. The core does not buffer these anywhere else — they go
+ /// into the grid, and a grid cannot be un-rendered back into a byte
+ /// stream — so this is where `pty/data`'s read comes from. Same
+ /// drop-oldest cap as `events`, for the same reason: a script that stops
+ /// reading must not grow the editor.
+ pty_out: Queue = .{},
pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
fn deinit(pf: *PaneFs, gpa: std.mem.Allocator) void {
pf.events.deinit(gpa);
+ pf.pty_out.deinit(gpa);
pf.tag_snap.deinit(gpa);
pf.* = .{};
}
@@ -574,6 +671,28 @@ pub fn noteAction(
return true;
}
+/// A PANE'S SHELL PRODUCED OUTPUT, raw, before the emulator ate it.
+///
+/// The one hook `pty/data`'s read needs, and the reason it has to be a hook at
+/// all: the core's only memory of a program's output is the emulator GRID,
+/// which is a rendering — the escape sequences are gone, the scrollback is
+/// reflowed, and no amount of reading it back gives a script the byte stream a
+/// pipe would have given it. So the bytes are copied here, where they arrive,
+/// or not at all.
+///
+/// GATED ON A READER COUNT, exactly as every recording hook in this file is
+/// gated on `scripted`: a pane nobody is reading pays one load and one branch
+/// and allocates nothing, which is what makes an editor that serves this
+/// filesystem cost the same as one that does not. `Queue` caps itself and
+/// drops the oldest, so a script that opens the file and then stops reading
+/// bounds the damage at `queue_cap` per pane.
+pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
+ if (id >= MAX_PANES or bytes.len == 0) return;
+ const pf = &p.fs.panes[id];
+ if (pf.pty_readers == 0) return;
+ pf.pty_out.push(p.gpa, bytes);
+}
+
// ============================================================================
// THE TRANSACTION.
// ============================================================================
@@ -630,9 +749,14 @@ fn attrOf(p: *Pardes, target: Target) AttrResult {
} },
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return .missing;
+ // THE WHOLE OF "a non-terminal pane has no pty/". Decided here so
+ // no handler has to: `lookup` answers with the target's attributes
+ // and `getattr` asks the same question, so one check makes the
+ // subtree ENOENT on a file pane for every operation at once.
+ if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing;
return .{ .ok = .{
.node = Node.of(t.serial, t.file),
- .dir = t.file == .dir,
+ .dir = t.file.isDir(),
.mode = t.file.mode(),
.size = paneFileSize(p, id, t.file),
} };
@@ -655,7 +779,10 @@ fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
return switch (f) {
.body, .data, .xdata => bodyLen(p, pane),
.tag => tagLen(p, pane),
+ // `pty/status` is formatted per read like `ctl` is, and `pty/data` is
+ // a stream whose length is not a property of anything.
.dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0,
+ .pty, .pty_ctl, .pty_status, .pty_data => 0,
};
}
@@ -835,14 +962,25 @@ fn wholeUtf8(data: []const u8) usize {
// LOOKUP — acme's `fsyswalk`, minus 9P's fid bookkeeping.
// ===========================================================================
-/// The files inside a pane's directory, by name. `.dir` is the directory
-/// itself and is never a name to resolve.
/// A name inside a pane's directory. `.` and `..` are the kernel's business,
-/// never ours, and the directory variant is not nameable — so a hit on the
-/// variant names is the whole lookup.
+/// never ours, the directory variant is not nameable, and the three inside
+/// `pty/` are not nameable HERE — their enum names carry a prefix precisely so
+/// that `stringToEnum` cannot hand `7/pty_ctl` back as a file beside `body`.
+/// `pty` itself resolves; whether it EXISTS is `attrOf`'s question.
fn paneFileNamed(name: []const u8) ?PaneFile {
const f = std.meta.stringToEnum(PaneFile, name) orelse return null;
- return if (f == .dir) null else f;
+ if (f == .dir) return null;
+ return if (f.inPty() and f != .pty) null else f;
+}
+
+/// ...and a name inside `pty/`, which is a separate namespace: `ctl` and
+/// `data` mean different files on the two sides of the slash, which is the
+/// whole reason `pty/` is a directory (see `PaneFile`).
+fn ptyFileNamed(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;
+ return null;
}
fn topFileNamed(name: []const u8) ?TopFile {
@@ -914,15 +1052,25 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply {
// window first and then fails the second component, which
// leaves an empty window behind for every typo.
const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
+ // `new/` makes a SCRATCH pane, which is a document and never a
+ // terminal, so `new/pty` names something that cannot exist.
+ // Refused before the pane is made, for the same reason every
+ // other bad name here is: a typo must leave no litter.
+ if (want.inPty()) return Reply.fail(req.tag, E.NOENT);
const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE);
break :new Node.of(serial, want);
},
else => return Reply.fail(req.tag, E.NOTDIR),
},
.pane => |t| pane: {
- if (t.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
_ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const f = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
+ // Two directories, two namespaces. `attrOf` below is what decides
+ // whether the `pty` half exists on this pane at all.
+ const f = switch (t.file) {
+ .dir => paneFileNamed(name),
+ .pty => ptyFileNamed(name),
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ } orelse return Reply.fail(req.tag, E.NOENT);
break :pane Node.of(t.serial, f);
},
};
@@ -956,17 +1104,34 @@ fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir:
out.appendSlice(gpa, name) catch return;
}
-/// The pane files, for a pane directory and for `new/`. `serial == 0` is
-/// `new/`: there is no pane yet — the LOOKUP is what creates one — so there is
-/// no id to report, and the transport substitutes one.
-fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void {
+/// The pane files, for a pane directory. `pty/` is listed only on a terminal:
+/// a file pane's listing is byte for byte what it was before that directory
+/// existed, which is what keeps every existing script's `ls` unsurprised.
+fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void {
inline for (comptime std.enums.values(PaneFile)) |f| {
- if (f != .dir) {
- if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name());
+ // The directory itself is never an entry, and the three names inside
+ // `pty/` belong to THAT directory's listing rather than to this one —
+ // the enum is flat, the tree is not.
+ if (comptime f == .dir or (f.inPty() and f != .pty)) continue;
+ // `pty/` itself is present only on a terminal. A runtime `continue`
+ // cannot leave an `inline for` body, so the entry is conditional
+ // rather than the iteration.
+ const present = f != .pty or terminal;
+ if (present) {
+ if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name());
}
}
}
+/// ...and the three inside `pty/`, in declaration order like every other
+/// listing here, so a script that walks the tree twice can diff the walks.
+fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void {
+ inline for (comptime std.enums.values(PaneFile)) |f| {
+ if (comptime !f.inPty() or f == .pty) continue;
+ if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name());
+ }
+}
+
fn readdir(p: *Pardes, req: Req, target: Target) Reply {
const out = p.fs.stage(p.gpa);
var skip = req.off;
@@ -1005,9 +1170,20 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
else => return Reply.fail(req.tag, E.NOTDIR),
},
.pane => |t| {
- if (t.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
- _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- stagePaneFiles(p, out, t.serial, &skip);
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const terminal = p.panes[id].?.isTerminal();
+ switch (t.file) {
+ .dir => stagePaneFiles(p, out, t.serial, terminal, &skip),
+ // A node id naming `pty/` can only have come from a pane that
+ // was a terminal when it was resolved. It may not be one now
+ // (a pane can acquire a document), so answer what a lookup
+ // would answer today rather than trusting the id.
+ .pty => {
+ if (!terminal) return Reply.fail(req.tag, E.NOENT);
+ stagePtyFiles(p, out, t.serial, &skip);
+ },
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ }
},
}
// Zero bytes is END OF DIRECTORY, never an error: the transport stops
@@ -1023,7 +1199,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
/// Open carries no per-open state, because there is none to carry: `addr` and
/// `limit` belong to the pane (as they do in acme, where they are Window
/// fields), and every read brings its own offset. What an open DOES do is
-/// arm the two things acme arms on open, and count event readers.
+/// arm the two things acme arms on open, and count the two kinds of reader.
///
/// So there is no fid table. acme needs one because 9P walks to a fid and
/// every later message names only that fid; FUSE puts the nodeid on every
@@ -1035,6 +1211,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const pf = &p.fs.panes[id];
+ // A file that is not there cannot be opened, so the reader count
+ // below cannot be armed on a pane with no pty. Same answer
+ // `lookup`, `read` and `write` give (`attrOf`).
+ if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT);
switch (t.file) {
// acme(4): "When the ctl file is first opened, regular
// expression context searches in addr addresses examine the
@@ -1059,6 +1239,13 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pf.readers +|= 1;
p.fs.listeners +|= 1;
},
+ // THE OTHER GATE, and deliberately a separate count: while
+ // this is non-zero the raw pty bytes are copied into
+ // `pty_out` as they arrive (`notePtyOutput`). It does NOT
+ // touch `listeners` — reading a terminal's output stream is
+ // not claiming the pane's buttons, and a script that did both
+ // would have opened `event` too.
+ .pty_data => pf.pty_readers +|= 1,
else => {},
}
},
@@ -1071,7 +1258,7 @@ fn release(p: *Pardes, req: Req) Reply {
switch (target) {
.top => {},
.pane => |t| {
- if (t.file != .event) return .{ .tag = req.tag };
+ if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag };
// The pane may have DIED while this was open. `State.forget` has
// then already taken its whole reader count out of `listeners`
// (the core calls it from `deinitPane`), so a serial that no
@@ -1080,6 +1267,18 @@ fn release(p: *Pardes, req: Req) Reply {
// button actions forever with no script left to interpret them.
const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag };
const pf = &p.fs.panes[id];
+ if (t.file == .pty_data) {
+ if (pf.pty_readers == 0) return .{ .tag = req.tag };
+ pf.pty_readers -= 1;
+ // The LAST pty reader leaving takes the queue's MEMORY with
+ // it, not merely its contents: `queue_cap` per pane held
+ // until the pane dies would be an editor that grew by being
+ // scripted once. And what is in it is stale anyway — the next
+ // reader wants the program's output from when IT opened the
+ // file, not a replay of somebody else's session.
+ if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa);
+ return .{ .tag = req.tag };
+ }
if (pf.readers == 0) return .{ .tag = req.tag };
pf.readers -= 1;
p.fs.listeners -|= 1;
@@ -1152,6 +1351,9 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const pane = p.panes[id].?;
const pf = &p.fs.panes[id];
+ // A `pty/` node whose pane is no longer a terminal reads as
+ // absent, not as empty: the same answer `lookup` gives today.
+ if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return switch (t.file) {
.addr => readAddr(p, req, pf, pane),
.body => readBody(p, req, id, pane),
@@ -1161,7 +1363,9 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.tag => readTag(p, req, pane),
.event => readQueue(p, req, &pf.events),
.rdsel => readRdsel(req, id, pane),
- .dir, .errors, .wrsel => Reply.fail(req.tag, E.PERM),
+ .pty_status => readPtyStatus(p, req, id, pane),
+ .pty_data => readPtyData(p, req, pf),
+ .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM),
};
},
}
@@ -1378,6 +1582,87 @@ fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
}
// ===========================================================================
+// `pty/` — the terminal a pane is, as three files. No prior art: acme has no
+// terminals and `ad` has no terminal surface at all, so nobody has made these
+// mistakes for us and nobody's scripts expect a particular spelling. Which is
+// the argument for three files and no fourth.
+// ===========================================================================
+
+/// `pty/status`: `%11d `-formatted, exactly like `ctl` and `index`, so a
+/// script splits it the same way and `read`s it at an offset.
+///
+/// THREE NUMBERS, and the choice of which three is the whole content of this
+/// function. The core knows the grid it asked for and it can ask the host who
+/// holds the tty; that is all it knows, and inventing a fourth field would be
+/// inventing the number behind it.
+///
+/// cols, rows the grid, in cells. What `TIOCGWINSZ` would answer, and the
+/// same pair `winsize` sets — so a script can set a size and
+/// read back that it took.
+/// taken 1 while a PROGRAM holds the tty (vim, a pager, a build), 0
+/// at the shell's own prompt. `pull_tty_taken`, the probe the
+/// core already asks before it types a command line; a host
+/// that cannot tell says 0, which is how pardes behaved before
+/// the probe existed.
+///
+/// WHAT IS NOT HERE, and why not, because a missing field is a fact about the
+/// core rather than an omission:
+///
+/// exit status NOT TRACKED ANYWHERE. A shell's death arrives as
+/// `Event.eof`, whose whole handler is `removePane` — the pane
+/// and its serial are gone, so by the time anybody could read
+/// a status file there is no directory to read it in. Reporting
+/// a zero here would be reporting a number the core does not
+/// have. Giving the pane an exit status means keeping the pane
+/// alive past its child, which is a change to what a terminal
+/// pane IS and does not belong in a status file's formatter.
+/// raw/cooked the draft's `TCSETS` line. The core never sets a termios:
+/// the mode belongs to the program on the far side of the pty,
+/// which sets it for itself and never tells us. There is
+/// nothing to report and nothing to set.
+fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const out = p.fs.stage(p.gpa);
+ out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{
+ pane.cols,
+ pane.rows,
+ @intFromBool(p.hostTtyTaken(id)),
+ }) catch {};
+ return staged(p, req);
+}
+
+/// `pty/data`, read side: THE RAW OUTPUT STREAM, as a stream.
+///
+/// FRAMING, which is the one decision here. `event` refuses a read smaller
+/// than one record because half a record is unparseable. Raw pty bytes have no
+/// records: what is in the queue is only "what arrived in one `.output`
+/// event", which is wherever the host's `read(2)` happened to land, so
+/// refusing a short read would be enforcing a boundary that means nothing —
+/// and a reader with a 1 KB buffer would deadlock against a 4 KB arrival
+/// forever. So this hands back as much as the count allows, spanning arrivals,
+/// and keeps the remainder (`Queue.popFront`). That is what `read(2)` on the
+/// pty itself would do.
+///
+/// The OFFSET is ignored, for the same reason `event`'s is: the queue is the
+/// position. And an empty queue is `Status.again` — nothing consumed, ask me
+/// again — which is the whole of how a blocking read works here.
+///
+/// A pane nobody has OPENED this file on has an empty queue by construction
+/// (`notePtyOutput` is gated on the count `open` keeps), so a read that beats
+/// the first byte of output and a read on a pane that never recorded any are
+/// the same cheap answer.
+fn readPtyData(p: *Pardes, req: Req, pf: *PaneFs) Reply {
+ if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again };
+ const out = p.fs.stage(p.gpa);
+ while (out.items.len < req.size) {
+ const chunk = pf.pty_out.peek() orelse break;
+ const n = @min(chunk.len, req.size - out.items.len);
+ out.appendSlice(p.gpa, chunk[0..n]) catch break;
+ pf.pty_out.popFront(n);
+ }
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+}
+
+// ===========================================================================
// WRITE
// ===========================================================================
@@ -1400,6 +1685,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const pane = p.panes[id].?;
+ if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return switch (t.file) {
.addr => writeAddr(p, req, id, pane),
.body => writeBody(p, req, id, pane),
@@ -1414,7 +1700,9 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.{ .tag = req.tag, .written = @intCast(took) }
else
Reply.fail(req.tag, E.IO),
- .dir, .rdsel => Reply.fail(req.tag, E.PERM),
+ .pty_ctl => writePtyCtl(p, req, id),
+ .pty_data => writePtyData(p, req, id),
+ .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM),
};
},
}
@@ -2000,6 +2288,161 @@ fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) b
}
// ===========================================================================
+// `pty/ctl` VERBS — the ioctls, as words.
+// ===========================================================================
+
+/// THE WHOLE GRAMMAR, one verb per line, blank lines ignored, each line
+/// trimmed and split on blanks:
+///
+/// winsize <cols> <rows> two decimals, each 1..65535
+/// sig <NAME> one of INT, TERM, HUP, QUIT, KILL
+/// exec no argument
+///
+/// An enum and an exhaustive switch for the same reason `Verb` above is one:
+/// adding a word is a compile error until it is handled, and matching WHOLE
+/// tokens makes acme's ordering bug (`del` shadowing `delete`) unrepresentable.
+const PtyVerb = enum { winsize, sig, exec };
+
+/// A `winsize` field.
+///
+/// ZERO IS REFUSED. `TIOCSWINSZ` reads a zero as "unknown", so `winsize 0 24`
+/// would not be a narrow terminal, it would be a terminal of no known width —
+/// which is what a program sees when nobody has set a size at all, and never
+/// something a script asked for on purpose.
+fn ptyDimension(word: []const u8) ?u16 {
+ if (word.len == 0 or word.len > 5) return null;
+ for (word) |c| if (c < '0' or c > '9') return null;
+ const n = std.fmt.parseInt(u16, word, 10) catch return null;
+ return if (n == 0) null else n;
+}
+
+/// `sig`'s argument: the five names, upper case, spelled the way `kill -INT`
+/// and `trap` spell them.
+///
+/// NOT A NUMBER, and not `SIGINT` either. A number would be one platform's
+/// number in a tree meant to be read from another machine, and the core has no
+/// signal numbers of its own (see `pardes.PtySignal`); the `SIG` prefix has
+/// been optional to `kill` since 1988 and carrying it here would mean
+/// accepting both spellings or refusing the shorter one people type.
+fn ptySignalNamed(word: []const u8) ?pardes.PtySignal {
+ if (std.mem.eql(u8, word, "INT")) return .int;
+ if (std.mem.eql(u8, word, "TERM")) return .term;
+ if (std.mem.eql(u8, word, "HUP")) return .hup;
+ if (std.mem.eql(u8, word, "QUIT")) return .quit;
+ if (std.mem.eql(u8, word, "KILL")) return .kill;
+ return null;
+}
+
+/// VALIDATE EVERY VERB, THEN APPLY — `writeCtl`'s shape, for `writeCtl`'s
+/// reason: acme applies verbs until one fails and answers with a byte count of
+/// how far it got, and nothing on Linux reads a short count on a `write(2)` as
+/// "the rest failed", so all-or-nothing is the only honest translation.
+///
+/// Simpler than `writeCtl` in exactly one way, and it is worth saying why the
+/// two passes need no shared bookkeeping here: no verb in this file can remove
+/// the pane or change what a later verb in the same write would decide. `ctl`
+/// has `del`, whose guard reads state `clean` sets, so its passes have to
+/// model each other; these three are independent, so the validation pass is a
+/// pure predicate.
+fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ var it = std.mem.splitScalar(u8, req.data, '\n');
+ while (it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// One `pty/ctl` verb. `apply` false is the validation pass and must change
+/// nothing whatsoever — not even a queued effect, which is the only state
+/// these three touch.
+fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
+ const pane = p.panes[id] orelse return false;
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ // the line is non-empty and trimmed, so there is always a first token
+ const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false;
+ switch (v) {
+ // `TIOCSWINSZ`, and DELIBERATELY NOTHING ELSE — in particular not the
+ // core's own grid.
+ //
+ // A pane's grid size is not a free variable here: `Pardes.sync` derives
+ // `pane.cols`/`pane.rows` from the pane's RECTANGLE at the end of every
+ // update, so a script that wrote them would have them overwritten
+ // before its write returned — and `sync` would then emit a second
+ // `resize_pty` putting the pty back to the layout's size, so the verb
+ // would visibly undo itself. Telling only the pty leaves the script's
+ // size in force until the pane's rectangle actually changes, which for
+ // a layout nobody is dragging is for good.
+ //
+ // Which is also why a `winsize` write is not read back from `status`:
+ // `status` reports the grid the editor computed, the only size the core
+ // has. What a program was last TOLD is remembered by the pty, and the
+ // pty will not say.
+ .winsize => {
+ const cols = ptyDimension(words.next() orelse return false) orelse return false;
+ const rows = ptyDimension(words.next() orelse return false) orelse return false;
+ if (words.next() != null) return false;
+ if (!apply) return true;
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ },
+ // The one genuinely new capability in the whole `pty/` directory:
+ // there is no `kill` anywhere in the host seam until this effect.
+ .sig => {
+ const which = ptySignalNamed(words.next() orelse return false) orelse return false;
+ if (words.next() != null) return false;
+ if (!apply) return true;
+ p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } });
+ },
+ // RESPAWN THIS PANE'S SHELL, and NO ARGUMENT — which is a limit of the
+ // effect and not a choice made here. `Effect.spawn` carries a pane and
+ // a cwd (pardes.zig) and has nowhere to put an argv; the host answers
+ // it by forking `core.shellBin()`, and the argv it builds is the
+ // prompt-integration rc files, not something a caller supplies. So
+ // `exec` respawns the configured shell in the pane's own directory,
+ // and `exec /bin/sh` is EINVAL — refused loudly rather than accepted
+ // and silently ignored, which is the failure a script cannot see.
+ //
+ // Giving it an argv means widening the effect and teaching four hosts
+ // to exec something the user did not configure, which is a change to
+ // what a terminal pane IS and wants its own argument.
+ //
+ // The host reaps the old child and forks a new one (every `push_spawn`
+ // opens by doing exactly that, because the core has no close effect).
+ // The GRID is not cleared: a terminal's body is a transcript, and the
+ // transcript of the shell that just died is the thing a script would
+ // want to read afterwards.
+ .exec => {
+ if (words.next() != null) return false;
+ if (!apply) return true;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ },
+ }
+ return true;
+}
+
+/// `pty/data`, write side: TYPE AT THE PROGRAM.
+///
+/// Identical to what a `body` write to a terminal already does (`writeBody`),
+/// and that is the point of the name rather than a duplication: `body` is a
+/// pty write because a transcript can only be written by typing, `pty/data` is
+/// a pty write because it IS the pty. A script that knows it is talking to a
+/// terminal says so; one that is generic over panes writes `body`.
+///
+/// The offset is ignored — a stream has no offsets — and the count is short at
+/// a character boundary exactly as every other write here is, so a caller
+/// whose buffer was split mid-sequence by the kernel's `max_write` retries the
+/// tail instead of having it dropped.
+fn writePtyData(p: *Pardes, req: Req, id: usize) Reply {
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ const take = wholeUtf8(req.data);
+ p.emitWrite(id, req.data[0..take]);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+// ===========================================================================
// EVENT WRITE-BACK — acme's xfideventwrite.
// ===========================================================================
@@ -2196,6 +2639,11 @@ const Answer = struct {
saved: bool = false,
pty_buf: [256]u8 = undefined,
pty_len: usize = 0,
+ /// `pty/ctl`'s three verbs are each ONE EFFECT and nothing else, so the
+ /// effect is the only thing a test can look at.
+ winsize: ?struct { cols: u16, rows: u16 } = null,
+ signal: ?pardes.PtySignal = null,
+ spawned: bool = false,
fn pty(a: *const Answer) []const u8 {
return a.pty_buf[0..a.pty_len];
@@ -2224,6 +2672,9 @@ fn call(p: *Pardes, req: Req) Answer {
@memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]);
ans.pty_len += n;
},
+ .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows },
+ .signal_pty => |s| ans.signal = s.sig,
+ .spawn => ans.spawned = true,
else => {},
};
return ans;
@@ -2256,6 +2707,19 @@ fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes {
return p;
}
+/// ...and a core whose slot 0 is a TERMINAL, which is what `pty/` is about.
+/// `tty_only` opens exactly one shell pane and nothing else, so there is no
+/// document anywhere and the geometry has already settled by the time the
+/// startup effects are drained — a later `.resize_pty` in a test is therefore
+/// one a verb caused.
+fn withTerm(gpa: std.mem.Allocator) !*Pardes {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ errdefer p.deinit();
+ while (p.nextEffect()) |_| {}
+ std.debug.assert(p.panes[0].?.isTerminal());
+ return p;
+}
+
fn serialOf(p: *Pardes) u32 {
return p.panes[0].?.serial;
}
@@ -2983,3 +3447,315 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(@as(u8, 'F'), p.fs.origin);
}
+test "a pane that is not a terminal has no pty/ at all" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "hello\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const dir = Node.of(serial, .dir);
+
+ // ABSENT, not present-and-refusing: `-d $PARDES_FS/<id>/pty` is how a
+ // script asks whether a pane is a terminal.
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno());
+ try testing.expectEqual(E.NOENT, call(p, .{
+ .tag = 1,
+ .op = .getattr,
+ .node = Node.of(serial, .pty),
+ }).errno());
+ try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno());
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno());
+ try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno());
+ // ...and an OPEN too, so the reader count that gates the raw queue can
+ // never be armed on a pane that has no pty to produce bytes
+ try testing.expectEqual(E.NOENT, call(p, .{
+ .tag = 2,
+ .op = .open,
+ .node = Node.of(serial, .pty_data),
+ }).errno());
+ try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
+
+ // ...and the listing is byte for byte the ten entries it always was
+ var buf: [32]Dirent = undefined;
+ const files = dirents(rdir(p, dir, 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 10), files.len);
+ try testing.expect(nameAt(files, "pty") == null);
+
+ // the enum's spelling is not a name in the tree: `pty_ctl` is how the flat
+ // enum spells `pty/ctl`, and neither directory answers to it
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno());
+
+ // `new/` makes a scratch, which can never be a terminal, so naming a pty
+ // file there creates nothing at all
+ const before = p.next_serial;
+ try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "pty").errno());
+ try testing.expectEqual(before, p.next_serial);
+}
+
+test "a terminal pane's pty/ holds exactly ctl, status and data" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const dir = Node.of(serial, .dir);
+
+ const pty = look_up(p, dir, "pty");
+ try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node);
+ try testing.expect(pty.reply.attr.dir);
+ try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode);
+
+ var buf: [32]Dirent = undefined;
+ const files = dirents(rdir(p, dir, 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 11), files.len); // the ten, plus pty
+ try testing.expect(nameAt(files, "pty").?.dir);
+
+ const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 3), inside.len);
+ try testing.expectEqualStrings("ctl", inside[0].name);
+ try testing.expectEqualStrings("status", inside[1].name);
+ try testing.expectEqualStrings("data", inside[2].name);
+ for (inside) |d| try testing.expect(!d.dir);
+ // the ids a listing reports are the ids a lookup resolves
+ try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node);
+
+ // ...and the two namespaces do not leak into each other
+ const ctl = look_up(p, Node.of(serial, .pty), "ctl");
+ try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node);
+ try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode);
+ try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode);
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno());
+
+ // a file is not a directory, on either side of the slash
+ try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno());
+ try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno());
+ // and the directory itself is not read(2)able, nor is a write-only file
+ try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno());
+ try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno());
+ try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno());
+}
+
+test "every pty/ctl verb, and every refusal" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const ctl = Node.of(serialOf(p), .pty_ctl);
+
+ // winsize reaches the effect queue, and ONLY the pty: the grid belongs to
+ // the layout, so the pane's own cols/rows are untouched.
+ const pane = p.panes[0].?;
+ const cols = pane.cols;
+ const rows = pane.rows;
+ const ws = wr(p, ctl, "winsize 132 44\n");
+ try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written);
+ try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols);
+ try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows);
+ try testing.expectEqual(cols, pane.cols);
+ try testing.expectEqual(rows, pane.rows);
+
+ // all five signal names, and no others
+ for ([_]struct { line: []const u8, want: pardes.PtySignal }{
+ .{ .line = "sig INT", .want = .int },
+ .{ .line = "sig TERM", .want = .term },
+ .{ .line = "sig HUP", .want = .hup },
+ .{ .line = "sig QUIT", .want = .quit },
+ .{ .line = "sig KILL", .want = .kill },
+ }) |c| {
+ const a = wr(p, ctl, c.line);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ try testing.expectEqual(c.want, a.signal.?);
+ }
+
+ // exec respawns the shell: the same effect `newShell` emits
+ const ex = wr(p, ctl, "exec\n");
+ try testing.expectEqual(Status.ok, ex.reply.status);
+ try testing.expect(ex.spawned);
+
+ // several verbs in one write, no trailing newline needed
+ const both = wr(p, ctl, "winsize 100 30\nsig TERM");
+ try testing.expectEqual(@as(u16, 100), both.winsize.?.cols);
+ try testing.expectEqual(pardes.PtySignal.term, both.signal.?);
+
+ // ...and EVERY malformed line refuses the WHOLE batch, so the good verb
+ // beside it never reached the queue. Two passes, one applied.
+ for ([_][]const u8{
+ "winsize", // no arguments
+ "winsize 80", // one argument
+ "winsize 80 24 extra", // three
+ "winsize 0 24", // zero is "unknown", never a width
+ "winsize 80 0",
+ "winsize -1 24", // not a decimal
+ "winsize 999999 24", // wider than a u16
+ "sig", // no name
+ "sig INT TERM", // two
+ "sig SIGINT", // the prefix `kill` dropped in 1988
+ "sig int", // lower case
+ "sig 9", // a number is one platform's number
+ "sig USR1", // a real signal, deliberately not offered
+ "exec /bin/sh", // the effect carries no argv; refused, never ignored
+ "raw", // the draft's TCSETS line, which the core cannot answer
+ "cooked",
+ "winsize 80 24\nbogus", // a good verb beside a bad one
+ "bogus\nwinsize 80 24",
+ "name x", // a `ctl` verb; the two files share no vocabulary
+ "del",
+ }) |bad| {
+ const a = wr(p, ctl, bad);
+ try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expect(a.winsize == null);
+ try testing.expect(a.signal == null);
+ try testing.expect(!a.spawned);
+ }
+
+ // blank lines and surrounding space are not verbs and not errors
+ const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
+ try testing.expectEqual(Status.ok, spaced.reply.status);
+ try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
+ // an empty write is a write of nothing
+ try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status);
+}
+
+/// A host that answers `pull_tty_taken` and nothing else, so `pty/status`'s
+/// third field can be tested with no pty anywhere. The same shape
+/// `pardes.zig`'s own `FakeTtyQuery` has, spelled again here because that one
+/// is private to its own tests.
+const FakeTty = struct {
+ taken: bool,
+
+ const vtable: pardes.Host.VTable = .{ .pull_tty_taken = answer };
+
+ fn answer(ctx: ?*anyopaque, pane: u8) bool {
+ _ = pane;
+ const f: *FakeTty = @ptrCast(@alignCast(ctx.?));
+ return f.taken;
+ }
+};
+
+test "pty/status reports the grid and who holds the tty" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const status = Node.of(pane.serial, .pty_status);
+
+ const a = rd(p, status, 0, 256);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ var want: [64]u8 = undefined;
+ const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 });
+ try testing.expectEqualStrings(whole, a.bytes);
+ // three `%11d ` fields, like `ctl` and `index`, and seekable like both.
+ // The expectation is compared against `want` and not against `a.bytes`,
+ // which the NEXT request's staging invalidates — the borrow window this
+ // whole module is built on.
+ try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len);
+ try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes);
+
+ // the third field is `pull_tty_taken`, the probe the core already has
+ var probe: FakeTty = .{ .taken = true };
+ p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable };
+ const held = rd(p, status, 0, 256);
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }),
+ held.bytes,
+ );
+}
+
+test "pty/data writes at the shell and reads the raw stream" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const data = Node.of(serial, .pty_data);
+
+ // WRITE is a pty write, exactly as a body write to a terminal is, and the
+ // offset is ignored because a stream has none
+ const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" });
+ try testing.expectEqual(@as(u32, 6), w.reply.written);
+ try testing.expectEqualStrings("ls -l\r", w.pty());
+ // short at a character boundary, never split, never zero for real bytes
+ try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written);
+ try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written);
+
+ // READ blocks — `.again`, nothing consumed — while there is nothing there
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ // THE READER COUNT IS THE GATE: output arriving at a pane nobody is
+ // reading is not recorded, so the queue stays empty and the pane pays
+ // nothing for a filesystem it is not using.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ _ = call(p, .{ .tag = 5, .op = .open, .node = data });
+ try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
+ // ...and it is NOT the event-suppression gate: reading a terminal's output
+ // is not claiming the pane's buttons.
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+ try testing.expect(!p.fs.scripted(0));
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ // UNFRAMED: a read smaller than one arrival is served and the remainder
+ // kept, because raw pty bytes have no records to split down the middle.
+ // `event` refuses exactly this read; that is the difference, on purpose.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes);
+ try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes);
+ // ...and a read SPANS arrivals, which one read(2) on the pty would too
+ p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes);
+
+ // the LAST reader leaving gives the memory back and drops what is stale
+ p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } });
+ while (p.nextEffect()) |_| {}
+ _ = call(p, .{ .tag = 6, .op = .release, .node = data });
+ try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
+ try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ // two readers: the second closing leaves the first still recording
+ _ = call(p, .{ .tag = 7, .op = .open, .node = data });
+ _ = call(p, .{ .tag = 8, .op = .open, .node = data });
+ _ = call(p, .{ .tag = 9, .op = .release, .node = data });
+ try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "still" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes);
+}
+
+test "the pty queue drops the oldest at its cap" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const data = Node.of(serialOf(p), .pty_data);
+ _ = call(p, .{ .tag = 5, .op = .open, .node = data });
+
+ // A script that opens the file and stops reading must BOUND the editor,
+ // not grow it. The oldest arrivals go; a reader that fell this far behind
+ // has lost the thread anyway and can re-read `body` to resynchronise.
+ const oldest: [4096]u8 = @splat('A');
+ const rest: [4096]u8 = @splat('B');
+ notePtyOutput(p, 0, &oldest);
+ for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest);
+ // LIVE bytes, not the buffer: `Queue` pops by moving `head` and reclaims
+ // the space lazily (`compact`), so the allocation trails the contents by
+ // design and the cap is a bound on what is still owed to a reader.
+ const q = &p.fs.panes[0].pty_out;
+ try testing.expect(q.buf.items.len - q.head <= queue_cap);
+
+ var seen: usize = 0;
+ while (true) {
+ const a = rd(p, data, 0, 1 << 16);
+ if (a.reply.status == .again) break;
+ try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null);
+ if (a.bytes.len == 0) break;
+ seen += a.bytes.len;
+ }
+ try testing.expect(seen > 0 and seen <= queue_cap);
+}