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/pardes.zig | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) (limited to 'src/pardes.zig') 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), -- cgit v1.3