From 5f4719da21f06b58694d52d354f5fda431ff8543 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 15:32:02 -0300 Subject: acmefs: a pane's terminal gets pty/data, pty/ctl and pty/status Step 3 of the 9P chain (docs/9p.typ 12.3, docs/registry.typ 9P-8). Nothing here is about 9P: it lands in the FUSE-served tree and any later transport inherits it. A script could write into a terminal that already existed and read its rendered scrollback. It could not START one, RESIZE one or SIGNAL one. Two of those were already effects the core emits, so `exec` and `winsize` are existing capabilities acquiring a name; only `sig` is new, and it brings the one new host method, `push_pty_signal`. pty/ctl winsize | sig INT|TERM|HUP|QUIT|KILL | exec one verb per line, validate-all then apply-all, EINVAL applies nothing -- `writeCtl`'s shape and `writeCtl`'s reason pty/status cols, rows, tty-taken as three %11d fields pty/data write is input to the process; read is the RAW output stream, gated on a reader count so a pane nobody reads costs one branch A pane that is not a terminal has no pty/ at all: the lookup is ENOENT and readdir does not list it. `PaneFile` is an enum(u4) and this takes it from 11 values to 15. ONE REMAINS. That is also why pty/ is a DIRECTORY and not three more flat names -- a subdirectory costs one value and buys its own namespace, so `ctl` and `data` did not have to be renamed. Two things the core does not know, and which are therefore not invented: a child's EXIT STATUS (a shell's death is `Event.eof`, which removes the pane, so there is no directory left to read it in) and RAW/COOKED (the core never sets a termios; the mode belongs to the program on the far side). Verified live against a daemon: pty/ appears only on the terminal pane; a `winsize 0 24` and a `sig SIGINT` are refused; a bad verb beside a good one applies neither; `echo pty-works` written to pty/data runs in the shell and its output reaches the body; and a blocking read of pty/data returns the raw stream, OSC 133 marks and all. fs-bench unchanged and still zero allocations. --- src/acmefs.zig | 834 ++++++++++++++++++++++++++++++++++++++++++++++-- src/detached/server.zig | 21 +- src/detached/wire.zig | 20 +- src/gui/gui.zig | 8 + src/host.zig | 10 + src/look.zig | 37 +++ src/macos.zig | 9 + src/pardes.zig | 32 +- src/tty/tty.zig | 8 + 9 files changed, 935 insertions(+), 44 deletions(-) (limited to 'src') 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, - - /// Every name IS the variant's name; only the directory itself is spelled - /// differently, because `.` is not an identifier. + /// 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, 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), }; }, } @@ -1377,6 +1581,87 @@ fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } +// =========================================================================== +// `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), }; }, } @@ -1999,6 +2287,161 @@ fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) b return true; } +// =========================================================================== +// `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 two decimals, each 1..65535 +/// sig 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//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); +} diff --git a/src/detached/server.zig b/src/detached/server.zig index 9a8dc24f..d8373149 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -590,14 +590,14 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } - /// Seventeen methods, and NOT the fullest host in the tree — that claim + /// Eighteen methods, and NOT the fullest host in the tree — that claim /// stood here, was believed, and was copied into docs/detached.md before an - /// audit counted the others. The tty and SDL shells fill NINETEEN each - /// (everything but `pull_gpio_toggle` and `push_detach`) and macOS fourteen, + /// audit counted the others. The tty and SDL shells fill TWENTY each + /// (everything but `pull_gpio_toggle` and `push_detach`) and macOS fifteen, /// so this host is the only one that implements `push_detach` and otherwise /// the least complete of the three desktop hosts. What is true is narrower /// and is the point anyway: it performs every MACHINE-LOCAL effect there is, - /// and the four of host.zig's twenty-one it leaves null are null because + /// and the four of host.zig's twenty-two it leaves null are null because /// there is nothing here for them to do. Two of those four are real losses a /// person can notice — no `pull_lsp` and no `pull_pipe`, because both want /// the worker pool this deliberately single-threaded loop does not have. The @@ -619,6 +619,7 @@ pub const Session = struct { .push_spawn = spawn, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, + .push_pty_signal = ptySignal, .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, @@ -771,6 +772,18 @@ pub const Session = struct { _ = posix.system.ioctl(fd, TIOCSWINSZ, @intFromPtr(&ws)); } + /// `pty/ctl`'s `sig` — and the host where it matters most, because these + /// shells outlive every frontend: a script that signals a build in a + /// detached session is signalling a process nobody has a terminal on. + /// `fd < 0` is a pane with no shell, the same silence `ptyWrite` gives it. + fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { + const s = of(ctx); + if (pane >= s.ptys.len) return; + const pt = s.ptys[pane]; + if (pt.fd < 0) return; + look.signalTty(pt.pid, pt.fd, sig); + } + /// Is this pane's tty still the prompt we forked, or has a program taken it? /// /// Answerable at all only because the pty is HERE. While a pane's shell diff --git a/src/detached/wire.zig b/src/detached/wire.zig index a84c2a68..23a85158 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -39,16 +39,18 @@ //! frontend must not have to have been built with the core's options — so it is //! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. //! -//! WHAT IS NOT HERE. The seam has twenty-one methods; this carries FIVE of +//! WHAT IS NOT HERE. The seam has twenty-two methods; this carries FIVE of //! them — `push_present` as `frame`, `push_set_clipboard`, -//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the sixteen -//! it does not are named here with their reasons. The five are spelled out -//! because this arithmetic has now gone stale twice in one day, once when the -//! machine-local eight moved into the daemon and once when `detach` arrived, -//! and a count nobody can check against a list is a comment that rots quietly. -//! * The eight machine-local ones — `push_spawn`, `push_pty_write`, -//! `push_pty_resize`, `push_write_file`, `push_write_dump`, -//! `push_watch_file`, `push_watch_theme`, `push_dump_themes` — are +//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the +//! seventeen it does not are named here with their reasons. The five are +//! spelled out because this arithmetic has now gone stale twice in one day, +//! once when the machine-local eight moved into the daemon and once when +//! `detach` arrived, and a count nobody can check against a list is a comment +//! that rots quietly. +//! * The nine machine-local ones — `push_spawn`, `push_pty_write`, +//! `push_pty_resize`, `push_pty_signal`, `push_write_file`, +//! `push_write_dump`, `push_watch_file`, `push_watch_theme`, +//! `push_dump_themes` — are //! performed by the detached core ITSELF, through `host_io.zig`. A unix //! socket means it is on the same machine, so there is no question of //! whose disk or whose process table is meant, and a pane's shell has to diff --git a/src/gui/gui.zig b/src/gui/gui.zig index da48d3fb..f7c4c51c 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3579,6 +3579,7 @@ const Shell = struct { .push_spawn = spawnPane, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, + .push_pty_signal = ptySignal, .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, @@ -3917,6 +3918,13 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { } } +/// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, which +/// is the same silence `ptyWrite` above gives it. +fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { + const s = shellOf(ctx); + if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.fd, sig); +} + /// Is a program (vim, a pager, an agent) holding this pane's tty instead of /// the shell we forked? Asked by the core only where it is about to type a /// command line, which is why the /proc walk behind it is not in pollCwds: diff --git a/src/host.zig b/src/host.zig index b6ccff34..d5f5c983 100644 --- a/src/host.zig +++ b/src/host.zig @@ -80,6 +80,16 @@ pub const Host = struct { push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, push_pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, + /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s + /// `sig INT`. A PUSH because there is no answer to have: `kill(2)` + /// either reaches a process that is already gone or reaches one whose + /// disposition the sender cannot see, and a script that wants to know + /// whether the program died reads the pane. NULL means this host owns + /// no pane shells and therefore has no child to signal — the browser + /// and the board, where the same null already makes `push_spawn` and + /// `push_pty_write` silent — and the effect is dropped exactly as a + /// write to a pane with no pty is. + push_pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, /// Is this pane's terminal still the prompt the host forked, or has a /// program (vim, a pager, an agent) taken its tty? An effect cannot /// answer it — the `execute` that asks must choose a destination inside diff --git a/src/look.zig b/src/look.zig index 90945013..c98e7988 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1064,6 +1064,43 @@ pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { } } +/// DELIVER A SIGNAL TO WHATEVER IS ON THIS PANE'S TTY — the host half of +/// `pty/ctl`'s `sig` verb, shared by every frontend that owns pane shells so +/// that the target is decided once instead of three times. +/// +/// THE TARGET IS THE FOREGROUND PROCESS GROUP, not the shell's pid, and the +/// difference is the whole usefulness of the verb. `tcgetpgrp` on the master +/// answers with the number the kernel would deliver a ^C to (see the comment +/// on the declaration above), which is the running build, the pager, the +/// agent — the thing a script means when it says `sig INT`. Aimed at the pid +/// instead, `sig INT` would reach an interactive shell, which ignores SIGINT +/// while it waits for a job: the verb would appear to work and do nothing on +/// the one case anybody wants it for. At an idle prompt the two are the same +/// number, because forkpty made the shell its own group leader. +/// +/// The pid is the FALLBACK, for an OS or a host whose master end will not +/// answer the ioctl. There `sig KILL` still ends the shell, which is the case +/// where being ignored is not an acceptable outcome. +pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { + // Whatever `std.c.SIG` spells these as on this platform, unconverted: the + // one call below wants exactly that type (see the `kill` beside `harvest`). + const sig = switch (which) { + .int => libc.SIG.INT, + .term => libc.SIG.TERM, + .hup => libc.SIG.HUP, + .quit => libc.SIG.QUIT, + .kill => libc.SIG.KILL, + }; + const fg = tcgetpgrp(master_fd); + // A process GROUP is addressed as its negated leader; `fg` is already a + // group id, so this is `kill(-fg)` and not `kill(-leader_of(fg))`. + if (fg > 0) { + _ = libc.kill(-fg, sig); + return; + } + if (shell_pid > 0) _ = libc.kill(shell_pid, sig); +} + /// Read a small /proc text in one go. These files are generated on read and /// answer completely in a single call at these sizes; a short read would only /// truncate a field, which every parser below treats as "no answer". diff --git a/src/macos.zig b/src/macos.zig index 2b04237e..9bbe3ac2 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1764,6 +1764,7 @@ const vtable: pardes.Host.VTable = .{ .push_spawn = spawnShell, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, + .push_pty_signal = ptySignal, .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, @@ -1830,6 +1831,14 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } +/// `pty/ctl`'s `sig`. Unlike `ttyTaken` above this is NOT degraded on darwin: +/// `tcgetpgrp` on the master and `kill` are both POSIX, and neither needs the +/// libproc descendant walk `look.ttyTaken` is still waiting for. +fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { + const st = hostState(ctx); + if (st.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); +} + /// Asked only where a command line is about to be typed: is a program holding /// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers /// `false` on darwin until it grows a libproc implementation, so this host diff --git a/src/pardes.zig b/src/pardes.zig index 55461c5d..111e2853 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3809,12 +3809,27 @@ pub const AttachRequest = struct { pane: u8, name: []const u8 }; /// freed the moment `off` reaches the end or the pane goes away. pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; +/// WHAT `pty/ctl`'s `sig` VERB CAN SEND. Named rather than numeric because the +/// core is freestanding: it has no `SIGINT` to name and a number written here +/// would be one platform's number travelling to a host that may not share it. +/// Five, and deliberately not the whole of signal(7): these are the ones a +/// human at a terminal already has a key or a `kill` for, and every one of +/// them means something to a program on a tty. `sig USR1` at a shell is a +/// message to a daemon, not a terminal operation, and nothing asked for it. +pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; + /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { spawn: struct { pane: u8, cwd: Buf(256) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, + /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s + /// `sig INT`, i.e. the ^C a script cannot type because ^C is not a byte + /// the pty would interpret on its own. The only genuinely new capability + /// the `pty/` directory added: `spawn` and `resize_pty` above were already + /// here, so `exec` and `winsize` are those two acquiring a name. + signal_pty: struct { pane: u8, sig: PtySignal }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) @@ -6805,8 +6820,11 @@ pub const Pardes = struct { } /// Has a program taken this pane's tty? A host that cannot tell says no, - /// which is how pardes behaved before the probe existed. - fn hostTtyTaken(p: *const Pardes, id: usize) bool { + /// which is how pardes behaved before the probe existed. Public because + /// `pty/status` reports it: it is the one field of that file the core does + /// not own itself, and asking here rather than reaching for the vtable in + /// acmefs keeps the null-method default in one place. + pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.pull_tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } @@ -6857,6 +6875,10 @@ pub const Pardes = struct { // screen, and the bytes are dropped rather than transcribed. .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + // A host with no signal method has no child to signal: the + // fallback host's ptys are silent (see `.write` above), so there + // is nothing to record and nothing to lie about. + .signal_pty => |s| if (v.push_pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .open_link => |u| if (v.push_open_link) |f| f(p.host.ctx, u.slice()) else @@ -7081,6 +7103,12 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; + // The RAW bytes, before the emulator eats them. `pty/data`'s + // read is the only thing that wants them — the grid is a + // rendering and cannot be un-rendered — and this is one load + // and one branch on a pane nobody is reading. See + // `acmefs.notePtyOutput`. + acmefs.notePtyOutput(p, o.pane, o.bytes); term_pane.feedOutput(p, pane, o.bytes); }, .eof => |e| p.removePane(e.pane), diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 5eb9a277..c85908d6 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1034,6 +1034,7 @@ const Shell = struct { .push_spawn = spawn, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, + .push_pty_signal = ptySignal, .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, @@ -1393,6 +1394,13 @@ const Shell = struct { } } + /// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, + /// which is the same silence `ptyWrite` above gives it. + fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { + const s = of(ctx); + if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + } + /// Is a pane's tty still the prompt we forked? Lazy by construction — it /// runs only where the core is about to type a command line, so the /proc /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a -- cgit v1.3