summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-27 15:32:02 -0300
committerGabriel Schneider <[email protected]>2026-08-27 16:12:35 -0300
commit5f4719da21f06b58694d52d354f5fda431ff8543 (patch)
treeeadd147cd18c53637f2287a3ed9e62fe1187280d /src
parent0a21ea831d6791b406eef70b3e95cfd319d8ed36 (diff)
downloadpardes-5f4719da21f06b58694d52d354f5fda431ff8543.tar.gz
pardes-5f4719da21f06b58694d52d354f5fda431ff8543.zip
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 <cols> <rows> | 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.
Diffstat (limited to 'src')
-rw-r--r--src/acmefs.zig832
-rw-r--r--src/detached/server.zig21
-rw-r--r--src/detached/wire.zig20
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host.zig10
-rw-r--r--src/look.zig37
-rw-r--r--src/macos.zig9
-rw-r--r--src/pardes.zig32
-rw-r--r--src/tty/tty.zig8
9 files changed, 934 insertions, 43 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);
+}
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