summaryrefslogtreecommitdiff
path: root/src/detached/server.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 18:58:37 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch)
tree6629cc215d6953090f6b29a7414b28cb9990e105 /src/detached/server.zig
parent11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff)
downloadpardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz
pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
Diffstat (limited to 'src/detached/server.zig')
-rw-r--r--src/detached/server.zig1169
1 files changed, 929 insertions, 240 deletions
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 6d13a4ae..93c964b4 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -1,53 +1,96 @@
//! THE DETACHED CORE: one `Pardes` instance in a process with no terminal,
//! serving N frontends over one unix socket.
//!
-//! THIS SIDE OWNS THE CORE. `Session` is a `host.Host` implementation whose
-//! methods encode wire messages instead of doing IO, and whose
-//! `pull_wait_input` is a `poll(2)` over the listener and every attached
-//! frontend. The frontends own terminals and nothing else (client.zig). So the
-//! `Pardes` is here, `update` is called from here, and the same screen is on
-//! every attached frontend at once — `screen -x`, not N sessions.
+//! THIS SIDE OWNS THE CORE, AND EVERYTHING UNDER IT. `Session` is a `host.Host`
+//! implementation that performs the machine-local half of a host itself — it
+//! forks the pane shells, writes the files, watches the paths — and whose
+//! `pull_wait_input` is one `poll(2)` over the listener, every attached
+//! frontend, every pane's pty master and the inotify descriptor. A frontend owns
+//! a screen and a keyboard and nothing else (client.zig). So the `Pardes` is
+//! here, `update` is called from here, the shells are forked from here, and the
+//! same screen is on every attached frontend at once — `screen -x`, not N
+//! sessions.
//!
-//! WHAT THIS SIDE SERVES ITSELF. Every method this vtable leaves null falls
-//! through to the core's own `host.Fallback`: the embedded source filesystem,
-//! the in-process clipboard, silent ptys. host.zig says in as many words that a
-//! zero-method host is a complete pardes, and that is exactly what a session
-//! with nothing attached is. Everything a real frontend can do BETTER — fork a
-//! shell on a real tty, put bytes on a real disk, reach a real desktop
-//! clipboard — is asked of a frontend, and the routing table below says which.
+//! WHAT THIS SIDE SERVES ITSELF, WHICH IS NOW ALL OF IT. A unix socket means
+//! the core and its frontends are on the SAME machine, so there is no question
+//! of whose process table, whose disk or whose inotify descriptor a call is
+//! about — and given that, the process that must hold them is the long-lived
+//! one. A shell forked by a frontend dies with that frontend, and a session
+//! whose whole promise is outliving the frontend attached to it cannot keep its
+//! panes that way. So this file forks the pane shells (`host_io.forkShell`),
+//! writes the files (`host_io.writeFileBytes`), marks the directories
+//! (file_watch.zig) and drains the pty masters in its own `poll(2)`. THE PANE
+//! SHELLS OUTLIVE EVERY FRONTEND: attach, detach, kill the terminal, attach
+//! from another one, and the build that was running in pane 3 is still running
+//! and has been scrolling into the core the whole time.
+//!
+//! Only what this vtable leaves null falls through to the core's own
+//! `host.Fallback` — and host.zig says in as many words that a zero-method host
+//! is a complete pardes. What a frontend can still do BETTER is exactly what
+//! needs the human's own display, and nothing else: put a yank on the clipboard
+//! in front of them, take a paste off it, open a link in their browser. Three
+//! messages, which is why the routing table below is as short as it is.
//!
//! ROUTING, and it is not "push means broadcast". A push reaches every HOST
//! (host.zig's rule, which `Fanout.isPull` enforces); this is ONE host that
//! happens to be backed by several frontends, and how it spreads a call inside
-//! itself is its own business. Three rules, one per kind of side effect:
+//! itself is its own business. Two rules over four messages:
//! * BROADCAST — the frame, and `set_clipboard`. Every screen must show the
//! same thing, and a yank in a shared session is a session-wide fact that
//! every attached desktop is entitled to.
-//! * PRIMARY ONLY — `spawn`, `pty_write`, `pty_resize`, `write_file`,
-//! `write_dump`, `watch_file`, `watch_theme`, `dump_themes`. Each of these
-//! has ONE real resource behind it, and doing it twice is not doing it
-//! twice as well: two frontends forking a shell for pane 3 gives the pane
-//! two shells, and two frontends writing one path race each other. Primary
-//! is the lowest attached slot, i.e. the oldest surviving attachment — a
-//! rule that is stable while frontends come and go and needs no election.
-//! A pane's shell therefore lives in the frontend that forked it: when that
-//! frontend leaves, its panes stop producing output and the session's text,
-//! files and layout carry on. That is a real limit and it is stated here
-//! rather than papered over, because migrating a live pty between processes
-//! is a different feature.
-//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire)
-//! and `open_link`. Both answer a thing a HUMAN just did, and the answer
-//! belongs on that human's machine: the paste must come from the keyboard
-//! that asked for it, and a link must open in front of the person who
-//! clicked it. `origin` is the frontend whose event was applied most
-//! recently. Effects drain after a whole batch of events (pardes.zig
+//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire),
+//! `open_link`, and `detach`. Each answers a thing a HUMAN just did, and the
+//! answer belongs to that human: the paste must come from the keyboard that
+//! asked for it, a link must open in front of the person who clicked it, and
+//! a `Detach` typed in one frontend must send THAT frontend away and leave
+//! the others painting. `origin` is the frontend whose event was applied
+//! most recently. Effects drain after a whole batch of events (pardes.zig
//! `pump`), so in the rare case where two frontends type in the same
-//! millisecond the second one wins; the fallback to primary covers an
+//! millisecond the second one wins; the fallback to `primary` — the lowest
+//! attached slot, i.e. the oldest surviving attachment, a rule that is
+//! stable while frontends come and go and needs no election — covers an
//! effect that no input caused at all.
//!
-//! FAIRNESS, and why no client can stall the core or another client:
-//! * every descriptor is non-blocking, and there is no thread per client. One
-//! `poll(2)` per pump covers the listener and all `max_clients` frontends.
+//! `detach` is the odd one and is worth naming as such: it is not an EFFECT the
+//! session performs on the world, it is SESSION CONTROL — one frontend asking to
+//! stop being a frontend. That is why wire.zig gives it 0x05, in the
+//! 0x01..0x0f session range beside `quit`, rather than a number in the 0x10..
+//! range where every tag is one `push_` method that reaches a disk, a clipboard
+//! or a browser. And it is why this side does nothing but send it: see `detach`.
+//! There is no third rule, and the class of message it used to serve is gone:
+//! `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`, `watch_file`,
+//! `watch_theme` and `dump_themes` were routed to ONE frontend precisely
+//! because each has one real resource behind it, and every one of them is now
+//! performed HERE, once, by the process that owns the resource. Two frontends
+//! can no longer fork two shells for pane 3 or race each other writing one
+//! path, because neither of them writes anything.
+//!
+//! FAIRNESS, and why no client — and no shell — can stall the core or another
+//! client. The property the bullets below add up to is worth stating as one
+//! sentence, because it is what a detached session is FOR: there is no path on
+//! which this process blocks indefinitely. Every descriptor it holds is
+//! non-blocking, the single `poll(2)` is the only place it sleeps, and every
+//! queue that could grow without bound has a ceiling with a stated answer for
+//! reaching it. A daemon nobody is looking at cannot be made to stop looking
+//! after the shells nobody else is keeping.
+//! * ONE `poll(2)` per pump covers the listener, all `max_clients` frontends,
+//! all `pardes.MAX_PANES` pty masters and the inotify descriptor:
+//! `poll_slots` descriptors, one syscall, no thread per client and none per
+//! pty. Putting the shells in the poll set the clients were already in is
+//! what lets a daemon own sixteen of them and stay single-threaded.
+//! * one read per pty per round, which is `receive`'s rule for clients
+//! applied to shells: a `yes` in pane 1 gets one turn and the loop moves on
+//! to the other panes, the frontends and the frame.
+//! * EVERY descriptor is non-blocking, sockets and pty masters alike, and a
+//! pane owes its bytes the same way a client does. A blocking write to a
+//! master was the one hole this file's own comment used to argue was safe —
+//! "the peer on a pty is a shell this process forked, not a stranger who can
+//! stop reading on purpose" — and that was wrong, because the peer is
+//! whatever program the human ran in that pane. `sleep 3600` plus a paste
+//! larger than the pty's input buffer parked the WHOLE daemon inside
+//! `write(2)`: no frame to any frontend, fifteen other masters unread, no
+//! `accept`, no `expire`, no inotify drain. So a pane has an out-queue and a
+//! POLLOUT, on the descriptor that was already in the set. See `ptyWrite`.
//! * FRAMES ARE NOT QUEUED. A client with bytes still owed to the kernel is
//! SKIPPED for this frame and its mirror is left alone, so the next frame
//! it does get is a diff against what it actually has. A slow frontend
@@ -96,11 +139,48 @@
//! collects, so the client checks the directory and the socket before it
//! connects, exactly as this side checks them before it binds.
const std = @import("std");
+const builtin = @import("builtin");
const libc = std.c;
+const linux = std.os.linux;
+const posix = std.posix;
const pardes = @import("../pardes.zig");
const host_api = @import("../host.zig");
const wire = @import("wire.zig");
+/// The machine-local half of a host — fork a shell onto a pty, put bytes on a
+/// disk — shared verbatim with the tty shell, and the sharing is the point:
+/// `spawn` and `writeFile` below are the same two operations tty.zig performs,
+/// and having them in one file is what keeps a daemon's pane and a terminal's
+/// pane the same pane. See host_io.zig's header for why the daemon is the side
+/// that performs them.
+const host_io = @import("../host_io.zig");
+
+/// ...and the inotify half, likewise shared: `applyEffect` is the mark-then-
+/// reconcile transaction the tty and sdl shells run, and this session runs the
+/// identical one. All that differs is who waits on the descriptor — a thread
+/// there, `waitInput`'s poll set here.
+const file_watch = @import("../file_watch.zig");
+
+/// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held
+/// by `Session` because a Session is exactly one host's lifetime.
+const shell_bin = @import("../shell_bin.zig");
+
+/// `shellCwd` for a pane's shell, `ttyTaken` for a pane the core is about to
+/// type a command line into, `readFile` for `run`'s `--load`.
+const look = @import("../look.zig");
+
+/// The "saved <path>" / "dumped themes <path>" message row, stamped the way
+/// every other host stamps it — one clock format across every frontend.
+const message = @import("../message.zig");
+
+/// `Dump themes` writes the reference set out as .zon, into the core's own
+/// `opts.config_dir`.
+const user_config = @import("../user_config.zig");
+
+// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). The
+// same constant the tty, gui and macos shells spell, for the same reason.
+const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
+
/// Diagnostics for whoever is running the daemon. Every one of these is a
/// `debug`, and the level is not a judgement about how bad the thing is:
/// main.zig's logFn drops this scope entirely unless PARDES_LOG is set, so what
@@ -136,11 +216,11 @@ const sun_path_len = nested.sun_path_len;
pub const max_clients = 32;
/// Bytes of un-drained CONTROL messages a client may owe before it is closed.
-/// Frames are not in here (see the module header), so this is a backlog of
-/// ACTIONS — spawns, clipboard mirrors, file writes — and a frontend that has
-/// not taken 1 MiB of those has stopped reading its socket. Checked before an
-/// append rather than after, so one oversized message is never the thing that
-/// trips it.
+/// Frames are not in here (see the module header), so this bounds a backlog of
+/// the three things that are still on the wire — a welcome, a clipboard mirror,
+/// a link to open — and a frontend that has not taken 1 MiB of those has
+/// stopped reading its socket. Checked before an append rather than after, so
+/// one oversized message is never the thing that trips it.
const out_backlog = 1 << 20;
/// One read per client per poll round (see `receive`). 16 KiB is two orders of
@@ -148,16 +228,73 @@ const out_backlog = 1 << 20;
/// 4 MiB paste arrives across several rounds, which is the point.
const read_chunk = 16 * 1024;
+/// Bytes taken off one pane's pty per poll round. 64 KiB is what every other
+/// host's pty reader uses (`readPty` in tty.zig, gui.zig and macos.zig), and it
+/// sits on `readPty`'s own frame rather than the loop's. Nothing is copied out
+/// of it: `Event.output` borrows the buffer for one `update` call, so a daemon
+/// serving a shell that is printing a build log asks the allocator for nothing.
+const pty_chunk = 64 * 1024;
+
+/// Descriptors in the ONE poll this process runs: the listener, every frontend,
+/// every pane's pty master, and the inotify descriptor behind every watch. 50
+/// on a full house, and one syscall covers all of them.
+const poll_slots = 1 + max_clients + pardes.MAX_PANES + 1;
+
+/// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s
+/// request for another pass within one round. See `reloadWatched`.
+const reload_retries = 4;
+
/// Bytes of client traffic — every in-queue and out-queue together — this
/// session may hold before it starts closing the peers holding it.
/// `out_backlog` bounds ONE slot and this bounds the table, which is not the
-/// same ceiling: 32 clients each a byte under their own cap is 32 MiB of a
-/// daemon nobody is looking at. 4 MiB is one whole paste in flight plus every
-/// frame queue a real session builds, and past it the fattest peer is the peer
-/// that stopped reading. The mirrors are NOT in this number: a mirror is this
-/// session's own bookkeeping for a client it chose to serve, not something a
-/// peer can grow.
-const session_backlog = 4 << 20;
+/// same ceiling: a client's `out` tops out at `out_backlog` plus the one
+/// oversized message allowed through whole, so `out_backlog` alone permits
+/// 32 * (1 + 1.6) MiB, about 83 MiB of a daemon nobody is looking at.
+///
+/// DERIVED, and the derivation IS the fix. This was the literal `4 << 20`,
+/// which was by coincidence the exact value of tty.zig's `max_paste_bytes` —
+/// and `in` grows to hold one WHOLE message, so a frontend assembling the very
+/// paste wire.zig names as one of the two messages that set `max_payload`
+/// crossed the table's ceiling while still receiving it. The session then
+/// closed the only frontend it had, mid-paste, with `.backlog`, which is the
+/// diagnostic for a peer that STOPPED reading. The documented maximum paste
+/// could not complete. Two whole `max_payload`s is the smallest number that is
+/// headroom rather than another coincidence: one peer may legitimately be
+/// assembling a message of the largest size `framed` will accept while the rest
+/// of the table holds frames, and past 32 MiB the fattest peer is the peer that
+/// stopped draining. Neither the mirrors nor the pane queues are in this
+/// number: a mirror is this session's own bookkeeping for a client it chose to
+/// serve, and a pane is bounded per pane by `pty_backlog` because it is not a
+/// peer and cannot be closed to reclaim anything.
+const session_backlog = 2 * @as(usize, wire.max_payload);
+
+/// Bytes of un-drained INPUT one pane's shell may owe before more is refused.
+///
+/// A pane is not a client, so the answer cannot be `out_backlog`'s: a client
+/// that stops draining is closed, and the thing at the other end of a pty is a
+/// program the human is running. This refuses the write and says so on the
+/// pane's message row instead, which is the only honest answer left — dropping
+/// input silently loses half a command line, and killing a shell to reclaim a
+/// megabyte destroys work.
+///
+/// Checked BEFORE the append, exactly as `queue` checks `out_backlog`, and that
+/// is what makes 1 MiB enough: any single write lands whole, so a maximum paste
+/// into an empty queue is never truncated. What gets refused is MORE input typed
+/// at a program that has stopped reading its input at all — `sleep 3600`, a
+/// stopped job, anything blocked on its own output.
+const pty_backlog = 1 << 20;
+
+/// How long a connection has to say `hello`, and the ONE number both ends of
+/// this transport time the handshake against. `pub` because a frontend that
+/// waited longer than the session is willing to hold its slot would report a
+/// timeout for a slot that had already been taken back, and a frontend that
+/// waited less would give up on a session that was still going to answer — two
+/// halves of one deadline, and two literals is how they drift apart.
+///
+/// The `Session` field it initialises is a field and not this constant for
+/// exactly one reason: the test for expiry would otherwise have to sleep five
+/// seconds. See `Session.greet_deadline_ms`.
+pub const greet_deadline_default_ms: u32 = 5_000;
/// What a DRAINED client is allowed to keep. `in` grows to hold one whole
/// message, so a single 4 MiB paste otherwise leaves 4 MiB resident in that
@@ -206,36 +343,52 @@ const Client = struct {
accepted_ms: i64 = 0,
};
-/// A pane's shell: which frontend was asked to fork it, and where.
+/// A pane's shell: forked by THIS process, drained by its poll set, reaped by
+/// it.
///
-/// WHY THE SESSION REMEMBERS THIS. A spawn is the one primary-only call that
-/// has to survive having no frontend to serve it. Every boot layout creates its
-/// panes before the socket exists, so a `--detach` performs its startup spawns
-/// with nobody attached — and dropping them meant a session that opened with
-/// panes whose shells had never been forked, forever, in silence. So a spawn
-/// with no primary is OWED, and asked of whoever attaches next.
-///
-/// It is also what makes `pty_write` reach the right process. A pane's pty
-/// lives in the frontend that forked it, which is not always the primary: A
-/// attaches and forks the shells, B attaches, A leaves — the panes are re-owed
-/// to B, and then C attaching into A's freed slot becomes primary while the
-/// ptys are in B. Routing a pane's bytes by its OWNER rather than by the
-/// primary is the difference between typing into a shell and typing into
-/// nothing.
-const Shell = struct {
- /// The slot that was asked to fork this pane's shell, or null when nobody
- /// has been.
- owner: ?u8 = null,
- /// A spawn owed to whoever attaches next: either it was never asked, or the
- /// frontend holding it left and took the pty with it.
- owed: bool = false,
- /// Copied, because `push_spawn`'s `cwd` borrows the core's memory for the
- /// length of that one call and this outlives it by definition.
- cwd: std.ArrayListUnmanaged(u8) = .empty,
+/// There is no owner here and nothing is owed, and the absence is the whole
+/// change. This struct used to record which frontend had been asked to fork a
+/// pane and re-ask the next arrival when that frontend left, because the pty
+/// lived in the frontend that forked it; and a `--detach`, whose panes always
+/// exist before its socket does, had nobody to ask at all and had to remember
+/// the request instead. Both were one problem, and forking here dissolves both:
+/// a startup layout's shells are forked during `run`'s pre-loop drain with
+/// nobody attached, and they are still those same shells when the tenth
+/// frontend attaches an hour later.
+const Pty = struct {
+ /// The pty master, non-negative exactly when this pane has a live shell.
+ /// While it is here it is in the poll set (`waitInput`), NON-BLOCKING like
+ /// every other descriptor this file holds — `spawn` flips it, because
+ /// `forkpty` hands it back blocking and tty.zig's streaming reader wants it
+ /// that way.
+ fd: c_int = -1,
+ /// Kept past the fork for `look.shellCwd` and `look.ttyTaken`, both of which
+ /// ask /proc about this pid rather than about the descriptor.
+ pid: posix.pid_t = 0,
+ /// Bytes owed to this shell's stdin, drained by POLLOUT and bounded by
+ /// `pty_backlog`. The same shape as `Client.out`, for the same reason: the
+ /// thing on the far side may not be reading, and this process must not wait
+ /// to find out. See `ptyWrite`.
+ out: std.ArrayListUnmanaged(u8) = .empty,
+};
+
+/// What one descriptor in `waitInput`'s poll set is. A tagged union rather than
+/// the bare slot index this loop used to carry alongside its `pollfd`s, because
+/// the set now holds four different kinds of thing and a `u8` cannot say which.
+const Source = union(enum) {
+ listener,
+ client: u8,
+ pty: u8,
+ inotify,
};
pub const Session = struct {
gpa: std.mem.Allocator,
+ /// The Io every filesystem read this host performs goes through:
+ /// file_watch.zig's reload of a changed pane, and `user_config.dumpThemes`.
+ /// Required and not optional — a session that owns the disk work cannot be
+ /// handed a null disk.
+ io: std.Io,
core: *pardes.Pardes,
/// -1 when nothing is bound: an unsupported platform, or a bind that
/// failed. A session with no listener is a session nobody can attach to,
@@ -258,9 +411,33 @@ pub const Session = struct {
/// needed rather than sized from `wire.max_payload`, which would be 16 MiB
/// of resident memory for a session whose frames are six kilobytes.
scratch: std.ArrayListUnmanaged(u8) = .empty,
- /// Where each pane's shell lives, and which spawns are still owed. See
- /// `Shell`.
- shells: [pardes.MAX_PANES]Shell = @splat(.{}),
+ /// Each pane's shell. Forked here, drained by the poll set, reaped by
+ /// `harvest`. See `Pty`.
+ ptys: [pardes.MAX_PANES]Pty = @splat(.{}),
+ /// The OSC 133 rc files a forked shell sources, staged once for the life of
+ /// this host exactly as tty.zig stages them for the life of a terminal:
+ /// `shell_bin.resolve` hands a child pointers into these buffers and the
+ /// child holds them until it execs, so they must not live in a stack frame.
+ /// The default is the empty one, which `resolve` reads as "this shell gets
+ /// no prompt marks"; `run` supplies a staged one.
+ prompt_rcs: shell_bin.PromptRcs = .{},
+ /// The one inotify descriptor behind every watch this session holds, and the
+ /// last member of the poll set. Opened lazily — see `inotify`.
+ inotify_fd: c_int = -1,
+ /// Which directory mark belongs to which pane, and the generation the core
+ /// has already accepted from each. file_watch.zig owns the shape and the
+ /// transaction; this host owns only the descriptor and the wake.
+ watches: file_watch.Table = @splat(null),
+ /// A reconcile pass is due: a watched directory had an edge, or the last
+ /// pass asked for another one. Consumed at the end of `waitInput`, which is
+ /// where a core change still makes the current frame — `Pardes.pump` renders
+ /// after `pull_wait_input` returns.
+ check_files: bool = false,
+ /// False during `run`'s pre-loop effect drain. Read in exactly one place:
+ /// `Pardes.loadThemeFile` animates an interactive theme change and must not
+ /// animate a startup one, and a `ThemeFile` in a boot layout is a startup
+ /// one. tty.zig spells the same distinction `threads_ok`.
+ in_loop: bool = false,
/// How long a connection may stay silent before the session takes its slot
/// back. `Client.open` writes its `hello` in the same call that connects,
/// so a peer that has said nothing for five seconds is not a frontend that
@@ -268,8 +445,10 @@ pub const Session = struct {
/// real frontend out with a `refuse .full`.
///
/// A field rather than a constant for exactly one reason: the test for that
- /// would otherwise have to sleep five seconds. Nothing else changes it.
- greet_deadline_ms: u32 = 5_000,
+ /// would otherwise have to sleep five seconds. Nothing else changes it, and
+ /// the number itself is `greet_deadline_default_ms`, which the frontend half
+ /// of this transport reads too.
+ greet_deadline_ms: u32 = greet_deadline_default_ms,
/// Monotonic milliseconds until which the LISTENER is left out of the poll
/// set, because an `accept` failed for a reason that persists. See `accept`.
accept_paused_ms: i64 = 0,
@@ -286,7 +465,19 @@ pub const Session = struct {
for (&s.clients) |*c| if (c.fd >= 0) s.close(c, .quitting);
s.unlisten();
s.scratch.deinit(s.gpa);
- for (&s.shells) |*sh| sh.cwd.deinit(s.gpa);
+ // The pane shells go with the SESSION and not with a frontend, which is
+ // this file's whole change. Closing a master is what hangs its shell up;
+ // `harvest` collects whatever has already exited, and the process is
+ // about to leave, so anything slower than that is the kernel's job.
+ for (0..s.ptys.len) |pane| s.closePty(@intCast(pane));
+ s.harvest();
+ // Every mark dies with the descriptor, so there is nothing to unmark.
+ if (s.inotify_fd >= 0) {
+ _ = libc.close(s.inotify_fd);
+ s.inotify_fd = -1;
+ }
+ // Unlinks the two rc files staged for this host's shells.
+ s.prompt_rcs.deinit();
}
/// Bind and listen. False when there is no socket, and a session without
@@ -362,17 +553,31 @@ pub const Session = struct {
return @ptrCast(@alignCast(ctx.?));
}
- /// Thirteen methods, and the seven that are missing are missing on purpose
- /// — see wire.zig's header for each one's reason. `push_poll_frame` and
- /// `push_post_present` carry no information a frame does not; the four
- /// synchronous or dispatched pulls and `push_fs_reply` belong to whoever
- /// owns the core, which is this process.
+ /// Sixteen 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, 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
+ /// five of host.zig's twenty-one it leaves null are null because there is
+ /// nothing here for them to do. Three of those five 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, and no
+ /// `push_fs_reply`, because this process mounted no /dev/fuse. The other two
+ /// are not losses at all: `push_post_present` marks the moment a frame
+ /// reached a screen and this process has no screen, and `pull_gpio_toggle`
+ /// wants pads.
+ ///
+ /// `push_detach` is the one entry here that is not an effect. See `detach`.
const vtable: host_api.Host.VTable = .{
.pull_wait_input = waitInput,
.push_present = present,
+ .push_poll_frame = pollFrame,
.push_spawn = spawn,
.push_pty_write = ptyWrite,
.push_pty_resize = ptyResize,
+ .pull_tty_taken = ttyTaken,
.push_write_file = writeFile,
.push_write_dump = writeDump,
.push_watch_file = watchFile,
@@ -381,6 +586,7 @@ pub const Session = struct {
.push_set_clipboard = setClipboard,
.pull_read_clipboard = readClipboard,
.push_open_link = openLink,
+ .push_detach = detach,
};
// ---- routing ----------------------------------------------------------
@@ -402,16 +608,6 @@ pub const Session = struct {
return s.primary();
}
- /// The frontend holding pane `pane`'s pty, which is NOT the primary in
- /// general — see `Shell`. Null when no frontend holds it, which is a pane
- /// with no child: the core's own answer to that is silence, and so is this.
- fn holder(s: *Session, pane: u8) ?*Client {
- if (pane >= s.shells.len) return null;
- const owner = s.shells[pane].owner orelse return null;
- const c = &s.clients[owner];
- return if (c.attached) c else null;
- }
-
/// Which slot this client is. From the pointer because every caller here
/// holds a `*Client` and not its index.
fn slotOf(s: *Session, c: *const Client) u8 {
@@ -422,84 +618,202 @@ pub const Session = struct {
for (&s.clients) |*c| if (c.attached) s.send(c, msg);
}
- // ---- the host methods -------------------------------------------------
+ // ---- the host methods: pseudo-terminals -------------------------------
fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const s = of(ctx);
- if (pane >= s.shells.len) return; // the core indexes its own panes
- const sh = &s.shells[pane];
- // Kept whether or not there is somebody to ask, because the pane now
- // exists either way and the cwd is the only thing that cannot be
- // reconstructed later.
- sh.cwd.clearRetainingCapacity();
- sh.cwd.appendSlice(s.gpa, cwd) catch {};
- if (s.primary()) |c| {
- sh.owner = s.slotOf(c);
- sh.owed = false;
- return s.send(c, .{ .spawn = .{ .pane = pane, .cwd = cwd } });
+ if (pane >= s.ptys.len) return; // the core indexes its own panes
+ // The in-process host's reaping rule and its reason, verbatim from
+ // tty.zig `spawn`: the core reuses pane ids and there is no close
+ // effect, so a deleted pane's shell lives in its slot until a respawn
+ // lands here.
+ s.closePty(pane);
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0 and cwd.len < cwd_buf.len) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
}
- // Nobody can fork a shell right now — a startup layout, or every
- // frontend gone. NOT dropped: `reconcile` asks the next arrival.
- sh.owner = null;
- sh.owed = true;
+ const child = host_io.forkShell(
+ s.core,
+ pane,
+ &s.prompt_rcs,
+ s.core.shellBin(),
+ cwd_z,
+ // The SESSION grid — which `reconcile` already made the smallest
+ // common one across everyone attached, and which survives every
+ // frontend leaving, so a shell forked into an empty session is
+ // still sized like the pane the core reflowed.
+ s.core.screen_h,
+ s.core.screen_w,
+ null, // no `--fs` mount in a daemon: nothing here answers /dev/fuse
+ );
+ // A `forkpty` that failed left `master` holding a number this process
+ // does not own. The shells get away with not checking because they hand
+ // the descriptor to a reader task that simply ends; this one would go
+ // into `poll(2)`, come back POLLNVAL, and be closed out from under
+ // whoever really owns it.
+ if (child.pid < 0) return;
+ s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid };
+ // ...and the master joins the rule every other descriptor in this file
+ // obeys. `forkpty` hands it back BLOCKING, and host_io.zig leaves it that
+ // way because tty.zig streams it from a thread that wants a blocking
+ // read; a poll loop wants the opposite, and one blocking `write(2)` here
+ // is the whole session parked. Only this side of the pty is affected —
+ // the master and the slave are separate open file descriptions, so the
+ // shell's own stdin stays exactly as `forkpty` made it.
+ setNonblock(child.file.handle);
+ // The pane's starting directory, for the tags. `pollFrame` keeps it
+ // current after a `cd`; this is the one before the first frame.
+ var lbuf: [1024]u8 = undefined;
+ if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
}
+ /// Keystrokes and pastes into the shell, QUEUED and never blocked on.
+ ///
+ /// This was one blocking `host_io.writeFd`, and the comment defending it
+ /// argued that "the peer is a shell this process forked rather than a
+ /// stranger who can stop reading on purpose". The peer is whatever program
+ /// the human ran in the pane: `sleep 3600`, a job stopped with ^Z, anything
+ /// blocked writing its own output. Any of those plus a paste larger than the
+ /// pty's input buffer — four kilobytes, and a frontend is entitled to send a
+ /// four-MEGABYTE paste — put this single-threaded process to sleep inside
+ /// `write(2)` with the whole session behind it: no frame to any frontend,
+ /// fifteen other masters unread, no `accept`, no `expire`, no inotify drain.
+ ///
+ /// So a pane owes bytes the way a client does, and the answer is the shape
+ /// this file already had for exactly this problem. What differs is what
+ /// happens when the queue will not drain: a client that stops reading is
+ /// CLOSED, and a pane cannot be, because closing it kills a program the
+ /// human is running. See `pty_backlog` — the write is refused and said out
+ /// loud on the pane's own message row.
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
const s = of(ctx);
- // To the frontend that forked this pane's shell, not to the primary:
- // the pty is in that process and nowhere else. A pane whose holder is
- // gone is silent, which is what the core does with a null method.
- if (s.holder(pane)) |c| s.send(c, .{ .pty_write = .{ .pane = pane, .bytes = bytes } });
+ if (pane >= s.ptys.len) return;
+ const pt = &s.ptys[pane];
+ // A pane with no shell swallows what is typed at it, which is exactly
+ // what the core does with a null method.
+ if (pt.fd < 0) return;
+ // BEFORE the append, which is `queue`'s rule and gives `queue`'s
+ // guarantee: one write always lands whole, so the biggest paste anyone
+ // can send is never truncated on arrival, and what is refused is the
+ // NEXT one typed at a program that has read nothing.
+ if (pt.out.items.len > pty_backlog) {
+ var mbuf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &mbuf,
+ "input refused: pane not reading ({d} bytes queued)",
+ .{pt.out.items.len},
+ ) catch "input refused: pane not reading";
+ return s.core.setMessage(pane, text);
+ }
+ pt.out.appendSlice(s.gpa, bytes) catch {
+ // Out of memory for a keystroke. The shell is fine and the session
+ // is fine; this one write is not, and saying so is all there is.
+ return s.core.setMessage(pane, "input refused: out of memory");
+ };
+ // Try immediately. On an idle pty this empties the queue in one write and
+ // the descriptor never asks for a POLLOUT at all, which keeps a session
+ // of keystrokes exactly as cheap as it was.
+ s.flushPty(pane);
}
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
const s = of(ctx);
- if (s.holder(pane)) |c| s.send(c, .{ .pty_resize = .{ .pane = pane, .cols = cols, .rows = rows } });
+ if (pane >= s.ptys.len) return;
+ const fd = s.ptys[pane].fd;
+ if (fd < 0) return;
+ const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(fd, TIOCSWINSZ, @intFromPtr(&ws));
+ }
+
+ /// 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
+ /// lived in a frontend this method had to stay null, and a null one means
+ /// the core types every `Exec` at the shell — into vim, into a pager, into
+ /// an agent waiting on stdin. Lazy by construction (host.zig): it runs where
+ /// the core is about to type a command line, so the /proc walk costs an
+ /// ordinary frame nothing.
+ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
+ const s = of(ctx);
+ if (pane >= s.ptys.len) return false;
+ const pt = s.ptys[pane];
+ if (pt.fd < 0) return false;
+ return look.ttyTaken(pt.pid, pt.fd);
}
+ // ---- the host methods: the filesystem ---------------------------------
+
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- if (s.primary()) |c| return s.send(c, .{ .write_file = .{ .pane = pane, .path = path, .bytes = bytes } });
- // Nobody attached, and a Put must not evaporate. This method being
- // non-null means the core did NOT reach for its own filesystem, so the
- // obligation a null method would have discharged is discharged here by
- // hand — the same shape `readClipboard` below has, and host.zig's rule
- // that a zero-method host is a complete pardes.
- s.core.fallback.writeFile(path, bytes);
+ if (!host_io.writeFileBytes(path, bytes)) return;
+ // Our own write is about to come back as an inotify edge: restamp from
+ // the bytes we just put there so the reconcile reads as "no change".
+ // Only when this IS the pane's watched file — a `Save <elsewhere>` must
+ // not silence a real change to the file the pane has open. Six lines
+ // shared with tty.zig `writeFile` over the same `file_watch.Table`,
+ // which is what makes a save in a detached pane behave like a save in a
+ // terminal one.
+ if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
+ if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
+ .pdf => {},
+ };
+ };
+ // ...and say so on the pane's message row. AFTER the write, not beside
+ // it: the early return above is a save that did not happen and must not
+ // be reported as one.
+ var mbuf: [256]u8 = undefined;
+ s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
}
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = of(ctx);
- if (s.primary()) |c| return s.send(c, .{ .write_dump = bytes });
- // ...and the same for a Dump, including the part that makes the bytes
- // reachable again: a real host reports where it landed, which is what
- // puts `Restore <path>` in the topbar (pardes.zig `write_dump`).
- s.core.fallback.writeFile(pardes.fallback_dump_path, bytes);
- s.core.setLastDump(pardes.fallback_dump_path);
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
+ // Where it landed, which is what puts `Restore <path>` in the topbar
+ // (pardes.zig `write_dump`). A dump of a detached session now lands in
+ // the same directory a terminal session's does, rather than in whatever
+ // directory the frontend that happened to be primary was started from.
+ s.core.setLastDump(path);
}
- fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void {
+ fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void {
const s = of(ctx);
- if (s.primary()) |c| return s.send(c, .{ .watch_file = .{ .pane = pane, .path = path, .on = on } });
- // No frontend to watch a path, so the core's own record of what was
- // asked is the whole of what a watch means here — exactly what a null
- // method leaves behind.
- if (pane < s.core.fallback.watched.len) s.core.fallback.watched[pane] = on;
+ // The path argument is unused because `applyEffect` takes it off the
+ // core's own pane, together with the serial and the generation that make
+ // the reconcile safe. That is the one thing a frontend could not do — it
+ // had no core — and it is why the frontend's copy of this method needed a
+ // second table of pathnames to go with the watch table.
+ if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify(), &s.watches, pane, on))
+ s.check_files = true;
}
fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void {
const s = of(ctx);
- // Dropped with nobody attached, and that is the whole of it: the core's
- // own `theme_file` effect does nothing for a null method either, so
- // there is no obligation left over. Same for `dump_themes` below.
- if (s.primary()) |c| s.send(c, .{ .watch_theme = .{ .generation = generation, .on = on } });
+ if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify(), &s.watches, generation, on, s.in_loop))
+ s.check_files = true;
}
fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
const s = of(ctx);
- if (s.primary()) |c| s.send(c, .{ .dump_themes = .{ .pane = pane } });
+ // A session started without one has nowhere to put them; the core's
+ // options are the only place that answer lives.
+ const config_dir = s.core.opts.config_dir orelse return;
+ const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
+ s.core.reportError(pane, "dump themes", err);
+ return;
+ };
+ defer s.gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
}
+ // ---- the host methods: the desktop ------------------------------------
+
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
const s = of(ctx);
// Mirrored into the core's own clipboard ALWAYS, not only when nobody
@@ -533,6 +847,254 @@ pub const Session = struct {
s.core.fallback.setLink(url);
}
+ // ---- the host methods: session control --------------------------------
+
+ /// `Detach` in an attached frontend: that frontend leaves, the session and
+ /// every other frontend carry on. tmux's `detach-client`.
+ ///
+ /// A `send` and NOTHING ELSE, and each of the three things it does not do is
+ /// deliberate. It does not quit — the whole point is that the session
+ /// survives, and a detach that took the daemon with it would be `quit` under
+ /// another name. It does not touch the core — no pane closes, no shell dies,
+ /// no frame changes; the grid is retaken by `reconcile` from the frontends
+ /// that remain, on the ordinary path, because a frontend leaving is already a
+ /// case this file handles. And it does not close the connection: the frontend
+ /// closes its own socket when it reads the message, and the peer-hangup path
+ /// then frees the slot exactly as it does for a frontend somebody killed.
+ /// Closing from this side would race the frontend's own teardown for no gain.
+ ///
+ /// It is therefore the one vtable entry here that is not an effect on the
+ /// world but SESSION CONTROL — one frontend asking to stop being a frontend
+ /// — which is why wire.zig numbers it 0x05, in the session range beside
+ /// `quit`, rather than in 0x10.. where every tag reaches a disk, a clipboard
+ /// or a browser. The module header's routing table says the same.
+ ///
+ /// ORIGIN, ELSE PRIMARY, for `read_clipboard`'s and `open_link`'s reason: it
+ /// answers something one particular human just typed, so it has to reach that
+ /// human's screen and not somebody else's — sending a detach to the wrong
+ /// frontend takes away a session from a person who did not ask. Nobody
+ /// attached at all is a no-op, and correctly so: there is no frontend to
+ /// detach, and the core has nothing to record about one.
+ fn detach(ctx: ?*anyopaque) void {
+ const s = of(ctx);
+ if (s.origins()) |c| s.send(c, .detach);
+ }
+
+ // ---- pane shells ------------------------------------------------------
+
+ /// Each pane's live cwd, for the tags. One readlink of /proc per pane that
+ /// has a shell, per frame, which is what tty.zig's `pollFrame` costs — and
+ /// why the far more expensive question, whether a program has taken the
+ /// pane's tty, is a pull asked at the `Exec` that cares (`ttyTaken`)
+ /// instead of polled here.
+ ///
+ /// This could not exist before. A detached session's shells lived in a
+ /// frontend, and a frontend has no core to report a cwd TO, so a `cd` in a
+ /// detached pane never reached its tag no matter how many frontends were
+ /// watching. The pids are here now, so it does.
+ fn pollFrame(ctx: ?*anyopaque) void {
+ const s = of(ctx);
+ for (&s.ptys, 0..) |*pt, pane| {
+ if (pt.fd < 0) continue;
+ var lbuf: [1024]u8 = undefined;
+ if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd);
+ }
+ }
+
+ /// Drop a pane's shell: out of the poll set, out of the process. Closing the
+ /// master is what hangs the shell up — which is true only because
+ /// `host_io.forkShell` puts FD_CLOEXEC on it, so no LATER pane's shell is
+ /// still holding a copy open. The pid is left to `harvest`, because a
+ /// `waitpid` here would return 0 for a shell that has not noticed the hangup
+ /// yet and that answer is worth nothing.
+ ///
+ /// The core is NOT told. Its two callers are `spawn` — a respawn, where the
+ /// core is the thing that asked — and `deinit`, where there is no core left
+ /// to tell. The path that does tell it is `paneEof`.
+ fn closePty(s: *Session, pane: u8) void {
+ const pt = &s.ptys[pane];
+ if (pt.fd < 0) return;
+ _ = libc.close(pt.fd);
+ // Before the reset, or the queue's allocation goes with the slot: what
+ // is in it is input a program that is not reading never took, and there
+ // is nobody left to hand it to.
+ pt.out.deinit(s.gpa);
+ pt.* = .{};
+ }
+
+ /// Push what the kernel will take of what this pane owes its shell, and
+ /// leave the rest for a POLLOUT. `flush`'s body, on a pty instead of a
+ /// socket, down to the `retire` that hands a drained megabyte back.
+ ///
+ /// The one difference is what an error means. A failed write to a SOCKET
+ /// closes a client; a failed write to a master means the slave side is gone,
+ /// which is the same event as a read of 0. It is NOT the same moment,
+ /// though, and that is why the error arm reads the pane before it ends it:
+ /// linux's `n_tty_write` returns EIO the instant the slave has no open
+ /// descriptors left, while `n_tty_read` on that same master still hands back
+ /// what the shell wrote before it went — so the write fails while the last
+ /// line is still retrievable, and ending the pane first would throw it away.
+ /// That is the very thing the dispatch's `.pty` branch protects against when
+ /// it takes POLLIN before POLLHUP, and it has to hold here too, because two
+ /// paths reach this arm with no read of their own in between: the dispatch
+ /// runs POLLOUT before POLLIN, and `ptyWrite` calls this during `perform`,
+ /// after this round's `readPty` has already been and gone.
+ fn flushPty(s: *Session, pane: u8) void {
+ const pt = &s.ptys[pane];
+ var off: usize = 0;
+ while (off < pt.out.items.len) {
+ const n = libc.write(pt.fd, pt.out.items.ptr + off, pt.out.items.len - off);
+ if (n < 0) switch (libc.errno(n)) {
+ .INTR => continue,
+ // The pty's input buffer is full: the rest waits for POLLOUT,
+ // and this is the case the whole change exists for.
+ .AGAIN => break,
+ else => {
+ // `readPty` either takes that last chunk or reaches the end
+ // itself and has already ended the pane; the guard is what
+ // stops the second `paneEof` from being a double-end.
+ s.readPty(pane);
+ if (s.ptys[pane].fd >= 0) s.paneEof(pane);
+ return;
+ },
+ };
+ // No progress and no error. host_io.zig's `writeFd` says why this is
+ // a `break` and never a retry: looping on a zero-byte write is a
+ // spin, and a spin in here is the whole session at 100% of a core
+ // with no syscall for a signal to interrupt.
+ if (n == 0) break;
+ off += @intCast(n);
+ }
+ if (off == 0) return;
+ if (off == pt.out.items.len) {
+ pt.out.clearRetainingCapacity();
+ return retire(s.gpa, &pt.out);
+ }
+ std.mem.copyForwards(u8, pt.out.items, pt.out.items[off..]);
+ pt.out.items.len -= off;
+ }
+
+ /// One read per readable pty per round — `receive`'s rule for clients,
+ /// applied to shells: a `yes` in pane 1 gets one turn and the loop moves on
+ /// to the other panes, the frontends and the frame.
+ ///
+ /// Nothing is copied. `Event.output` borrows the buffer for the length of
+ /// one `update` call, which is the same borrow window every other host gives
+ /// a pty chunk — tty.zig frees its duplicate the line after the update —
+ /// except that this one never allocated a duplicate to free. A daemon
+ /// serving sixteen shells printing build logs asks the allocator for
+ /// nothing.
+ fn readPty(s: *Session, pane: u8) void {
+ var buf: [pty_chunk]u8 = undefined;
+ const got = libc.read(s.ptys[pane].fd, &buf, buf.len);
+ if (got == 0) return s.paneEof(pane);
+ if (got < 0) return switch (libc.errno(got)) {
+ // A master that said POLLIN and then had nothing is not an error;
+ // the next round asks again.
+ .INTR, .AGAIN => {},
+ // EIO is how linux reports the slave side going away, which is the
+ // ordinary end of a shell rather than a fault.
+ else => s.paneEof(pane),
+ };
+ s.core.update(.{ .output = .{ .pane = pane, .bytes = buf[0..@intCast(got)] } });
+ }
+
+ /// The shell in `pane` is gone. The descriptor leaves the poll set BEFORE
+ /// the core is told, because an `eof` is what makes the core offer a respawn
+ /// and a respawn into a slot still holding the old fd would leak it.
+ fn paneEof(s: *Session, pane: u8) void {
+ s.closePty(pane);
+ s.harvest();
+ s.core.update(.{ .eof = .{ .pane = pane } });
+ }
+
+ /// Collect every child that has exited.
+ ///
+ /// `waitpid(-1)` and not a pid list, because the only children this process
+ /// forks are pane shells (`host_io.forkShell`) — so "any exited child" and
+ /// "an exited pane shell" are the same set — and because the pids a list
+ /// would hold are exactly the ones it cannot help with: a respawn closes a
+ /// master, and the shell that gets the hangup exits some milliseconds later
+ /// with its slot already reused by a different shell.
+ ///
+ /// Nothing here waits, so a session whose shells are all running pays one
+ /// syscall that returns 0. Called once per poll round and again wherever a
+ /// shell is dropped, which is what keeps a daemon that runs for a week and
+ /// spawns a thousand shells free of zombies — the one bookkeeping cost a
+ /// long-lived process pays that a frontend, which exits, never did.
+ fn harvest(_: *Session) void {
+ while (true) {
+ // 0: there are children and none has exited. -1: no children at all.
+ if (libc.waitpid(-1, null, libc.W.NOHANG) <= 0) return;
+ }
+ }
+
+ // ---- watched files ----------------------------------------------------
+
+ /// The one inotify descriptor behind every watch, opened on first use.
+ ///
+ /// Lazy for two reasons pointing the same way: a `Session` is built as a
+ /// struct literal (client.zig's test harness is one) and so has no init hook
+ /// to open it in, and a session whose panes are all shells never watches a
+ /// path and has no use for one. -1 on anything but linux and on a failed
+ /// `inotify_init1`, which file_watch.zig reads as "mark nothing" — the core
+ /// then keeps its own record of what was asked and simply never gets a
+ /// reload, which is what a host with no watcher has always done.
+ ///
+ /// NONBLOCK because this descriptor is drained from `poll`, not from a
+ /// thread parked in `read` (tty.zig `watchFiles`): `drainInotify` must be
+ /// able to stop.
+ fn inotify(s: *Session) c_int {
+ if (s.inotify_fd >= 0) return s.inotify_fd;
+ // The whole body is inside the comptime branch so that neither
+ // `inotify_init1` nor `linux.IN` is even analysed on a platform that has
+ // no inotify — the same shape file_watch.zig's `watchPath` uses.
+ if (comptime builtin.os.tag == .linux) {
+ s.inotify_fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
+ }
+ return s.inotify_fd;
+ }
+
+ /// A directory this session marked had an edge. The CONTENTS are discarded
+ /// on purpose, exactly as tty.zig's watcher thread discards them: a record
+ /// names a mark and a filename, and reconciling every mark against the
+ /// generation the core accepted is both cheaper and safer than deciding from
+ /// the record which pane it meant.
+ ///
+ /// DRAINED TO EMPTY, in a loop, and one read was a real cost rather than the
+ /// coalescing this comment used to claim. The descriptor is level-triggered,
+ /// so a queue left partly full makes `poll` return ready again immediately —
+ /// and each of those rounds is a whole `pump`: `reloadChanged` over all 17
+ /// slots, every watched text pane re-read from disk and re-hashed, a render,
+ /// a present. A `git checkout` can queue the kernel's whole 16384 events; at
+ /// roughly 128 records per 4 KiB that was ~128 spin rounds and some two
+ /// thousand whole-file reads for one command, at 100% of a core, while every
+ /// frontend got a frame per round it could not use. The fd is IN_NONBLOCK
+ /// (`inotify`), so the loop ends on EAGAIN.
+ fn drainInotify(s: *Session) void {
+ var buf: [4096]u8 = undefined;
+ while (libc.read(s.inotify_fd, &buf, buf.len) > 0) s.check_files = true;
+ }
+
+ /// Reconcile every marked pane and the theme file. Called at the END of
+ /// `waitInput`, which is what puts a reload in THIS frame: `Pardes.pump`
+ /// renders after `pull_wait_input` returns.
+ ///
+ /// The loop is `reloadChanged`'s contract. It asks for another pass when a
+ /// PDF's pathname changed between the stat before MuPDF reopened it and the
+ /// stat after — a save that landed mid-reconcile, where committing either
+ /// identity would lose a generation. tty.zig posts that request back into
+ /// its event queue; this loop has no queue, so it is retried here and
+ /// BOUNDED, because a file being rewritten in a loop must not hold the core.
+ /// What is left over is picked up by the next directory edge.
+ fn reloadWatched(s: *Session) void {
+ if (!s.check_files) return;
+ s.check_files = false;
+ for (0..reload_retries) |_| {
+ if (!file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) return;
+ }
+ }
+
// ---- the frame --------------------------------------------------------
fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
@@ -590,19 +1152,37 @@ pub const Session = struct {
// ---- the loop ---------------------------------------------------------
/// The only place this process sleeps, which is what `pull_wait_input`'s
- /// comment in host.zig requires of whoever serves it. One `poll(2)` covers
- /// the listener and every attached frontend; there is no thread per client
- /// and nothing here blocks on a single peer.
+ /// comment in host.zig requires of whoever serves it, and the only place it
+ /// waits on ANYTHING: one `poll(2)` over the listener, every attached
+ /// frontend, every pane's pty master and the inotify descriptor. No thread
+ /// per client, no thread per shell, no watcher thread, and nothing here
+ /// blocks on a single peer.
+ ///
+ /// That the shells are in this set and not on threads of their own is what
+ /// lets a daemon own sixteen of them and stay a single-threaded state
+ /// machine — and it costs the shells nothing, because a pty master is
+ /// pollable and a pane's output has nowhere to go but the core this loop is
+ /// driving anyway.
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = of(ctx);
// Push what the kernel will take before sleeping: a client that becomes
// writable while we are inside poll(2) would otherwise be a frame late,
// and a frame late is a frame skipped (see `present`).
for (&s.clients) |*c| if (c.fd >= 0) s.flush(c);
+ // The session grid, retaken BEFORE the sleep as well as after it. A
+ // client can leave OUTSIDE this function — a `set_clipboard` broadcast
+ // whose write failed during `perform` closes it — and the minimum across
+ // attached frontends would then stay sized for a frontend that is gone
+ // until some descriptor happened to become readable, which on an idle
+ // session is never. That is what this call buys, and `regridded` is what
+ // it costs: a round that has just told the core to reflow must not then
+ // sleep on it, because the frame carrying that reflow is the one
+ // `Pardes.pump` composes the moment this returns.
+ const regridded = s.reconcile();
const now = monotonicMs();
- var fds: [max_clients + 1]libc.pollfd = undefined;
- var slots: [max_clients + 1]u8 = undefined;
+ var fds: [poll_slots]libc.pollfd = undefined;
+ var src: [poll_slots]Source = undefined;
var n: usize = 0;
// The listener is left OUT of the set while accepting is paused, which
// is how an EMFILE is waited out without the core sleeping (see
@@ -610,7 +1190,7 @@ pub const Session = struct {
const watching_listener = s.listener >= 0 and now >= s.accept_paused_ms;
if (watching_listener) {
fds[n] = .{ .fd = s.listener, .events = poll_in, .revents = 0 };
- slots[n] = 0;
+ src[n] = .listener;
n += 1;
}
for (&s.clients, 0..) |*c, i| {
@@ -620,13 +1200,43 @@ pub const Session = struct {
.events = if (c.out.items.len != 0) poll_in | poll_out else poll_in,
.revents = 0,
};
- slots[n] = @intCast(i);
+ src[n] = .{ .client = @intCast(i) };
+ n += 1;
+ }
+ // The pane shells, and note what is NOT conditional on a frontend: a
+ // session with nobody attached still polls these, still reads them and
+ // still feeds the core. That is the difference between a detach that
+ // pauses your build and a detach that does not.
+ for (&s.ptys, 0..) |*pt, pane| {
+ if (pt.fd < 0) continue;
+ fds[n] = .{
+ .fd = pt.fd,
+ // POLLOUT only while this pane owes its shell bytes, which is
+ // the same rule and the same reason as a client's: asking for it
+ // unconditionally makes every idle pty a ready descriptor and
+ // turns the poll into a spin.
+ .events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in,
+ .revents = 0,
+ };
+ src[n] = .{ .pty = @intCast(pane) };
+ n += 1;
+ }
+ // Opened only once something asked to be watched, so an unwatched
+ // session simply has one fewer descriptor here (see `inotify`).
+ if (s.inotify_fd >= 0) {
+ fds[n] = .{ .fd = s.inotify_fd, .events = poll_in, .revents = 0 };
+ src[n] = .inotify;
n += 1;
}
- // A detached session with no listener and no clients has no event
- // source at all. Returning immediately would spin the outer
+ // A session with no listener, no clients, no shells and no watches has
+ // no event source at all. Returning immediately would spin the outer
// `while (!core.quit)` at full speed, so sleep the interval the core
// offered and, when it offered none, a frame's worth.
+ //
+ // Nothing is owed on this path. `check_files` is only ever set by a
+ // watch, and a watch means the inotify descriptor is in the set; and
+ // `reconcile` posts a resize only when a client is ATTACHED, which means
+ // its socket is in the set — so `n == 0` implies `!regridded` too.
if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms);
// Zero is the core's word for "sleep until something happens" (see
// pardes.zig `pump`: it passes a frame interval only while an animation
@@ -639,37 +1249,87 @@ pub const Session = struct {
// idle, IS the denial `greet_deadline_ms` exists to answer — so the
// wait is clamped to whichever is due first.
if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due);
+ // ...and two things are due on nothing at all rather than on a
+ // descriptor: a reconcile pass a watch effect asked for (`watchFile` ran
+ // during `perform`, outside this function) and a regrid this round has
+ // already performed. Both are consumed before this function returns, so
+ // the round must not sleep before reaching them.
+ if (s.check_files or regridded) timeout = 0;
const ready = libc.poll(&fds, @intCast(n), timeout);
- // Expired unconditionally: a slot held by silence comes back on a
- // timeout exactly as it does on a wakeup, and a poll that returned
- // nothing is the ordinary way this deadline is reached.
+ // A timeout is an ordinary frame boundary and EINTR is a signal we do not
+ // handle here. Neither skips anything below any more: what used to be an
+ // early `return` here is why a client closed without any descriptor being
+ // readable — which is every `expire` — left the session grid sized for a
+ // frontend that had gone, until the next readable event, on an idle
+ // session possibly hours later.
+ if (ready > 0) s.dispatch(fds[0..n], src[0..n]);
+ // AFTER the dispatch, and that ordering is itself a fix. `expire` frees a
+ // client slot and `accept` — which runs INSIDE the dispatch — fills the
+ // lowest free one, so an expire that ran first could hand a slot to a new
+ // connection within this same round and the dispatch would then apply the
+ // OLD connection's `revents` to the new descriptor: a POLLHUP from the
+ // peer that left, closing the peer that just arrived. The dispatch's
+ // `c.fd < 0` guard cannot see that, because the fd is perfectly valid —
+ // it is simply a different fd. Expiring after means a freed slot is
+ // refilled no earlier than the next round, which builds a fresh `fds` for
+ // it. It fixes a smaller thing for free, too: a connection whose `hello`
+ // arrived in THIS round is attached before its deadline is judged,
+ // instead of being taken back with its handshake still unread.
s.expire(monotonicMs());
- // A timeout is an ordinary frame boundary and EINTR is a signal we do
- // not handle here; both simply come back next pump.
- if (ready <= 0) return;
+ // Unconditional, and not only where a shell is noticed to have died: a
+ // shell whose master `spawn` closed on a respawn exits after that close,
+ // with no descriptor left for anyone to see it on. See `harvest`.
+ s.harvest();
+ // Both before this function returns, so a file that changed on disk and a
+ // frontend that left during this round are in the frame `Pardes.pump`
+ // composes next rather than the one after it.
+ s.reloadWatched();
+ _ = s.reconcile();
+ }
- var k: usize = 0;
- if (watching_listener) {
- if (fds[0].revents != 0) s.accept();
- k = 1;
- }
- while (k < n) : (k += 1) {
- const c = &s.clients[slots[k]];
- // A slot closed earlier in this same pass (its peer hung up, a
- // decode failed, its handshake expired) must not be touched
- // through a stale revents.
- if (c.fd < 0) continue;
- if (fds[k].revents & poll_out != 0) s.flush(c);
- if (c.fd < 0) continue;
- if (fds[k].revents & poll_in != 0) {
- s.receive(c, slots[k]);
- } else if (fds[k].revents & (poll_hup | poll_err | poll_nval) != 0) {
- // POLLIN wins when both are set: a peer that wrote and then
- // closed has bytes still worth reading.
- s.close(c, .peer);
- }
- }
- s.reconcile();
+ /// One pass over the descriptors `poll` reported ready. Split out of
+ /// `waitInput` for one reason: everything that must happen AFTER it —
+ /// `expire`, `harvest`, `reloadWatched`, `reconcile` — is then stated once,
+ /// in one order, where no early return can skip it. An early return past
+ /// that list is exactly what findings 5 and 6 were.
+ fn dispatch(s: *Session, fds: []const libc.pollfd, src: []const Source) void {
+ for (fds, src) |pfd, source| switch (source) {
+ .listener => if (pfd.revents != 0) s.accept(),
+ .client => |i| {
+ const c = &s.clients[i];
+ // A slot closed earlier in this same pass (its peer hung up, a
+ // decode failed) must not be touched through a stale revents.
+ if (c.fd < 0) continue;
+ if (pfd.revents & poll_out != 0) s.flush(c);
+ if (c.fd < 0) continue;
+ if (pfd.revents & poll_in != 0) {
+ s.receive(c, i);
+ } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) {
+ // POLLIN wins when both are set: a peer that wrote and then
+ // closed has bytes still worth reading.
+ s.close(c, .peer);
+ }
+ },
+ .pty => |pane| {
+ if (s.ptys[pane].fd < 0) continue;
+ // What this pane still owes its shell, which is the whole of
+ // finding 1's drain: `ptyWrite` queued it and stopped at EAGAIN
+ // rather than sleeping, and this is where the rest goes.
+ if (pfd.revents & poll_out != 0) s.flushPty(pane);
+ // `flushPty` ends the pane when the slave side has gone.
+ if (s.ptys[pane].fd < 0) continue;
+ // The same precedence as a client's, and it matters more here: a
+ // shell that printed its last line and exited reports
+ // POLLIN|POLLHUP together, and taking the hangup first would
+ // throw that line away. `readPty` reaches the EOF by reading 0.
+ if (pfd.revents & poll_in != 0) {
+ s.readPty(pane);
+ } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) {
+ s.paneEof(pane);
+ }
+ },
+ .inotify => if (pfd.revents & poll_in != 0) s.drainInotify(),
+ };
}
/// Milliseconds until the next deadline that is kept by the CLOCK rather
@@ -802,16 +1462,30 @@ pub const Session = struct {
}
fn apply(s: *Session, c: *Client, slot: u8, tag: u8, payload: []const u8) wire.Error!void {
- var scratch: wire.Scratch = .{};
- switch (try wire.decodeClient(tag, payload, &scratch)) {
+ // `Hello.version` BEFORE the payload is decoded, which is the whole
+ // point of wire.zig putting it first at a fixed offset: a mismatch has to
+ // stay diagnosable when the rest of the layout is the part that changed.
+ // Checking it inside the `.hello` arm defeated exactly that guarantee —
+ // `decodeClient` refuses a cols/rows this build does not like and refuses
+ // trailing bytes, so a v2 hello with one extra field came back as
+ // `.protocol` and the `refuse .version` the frontend needs to say
+ // something useful was never sent. `wire.helloVersion` reads the one
+ // field without decoding the rest, and lives in the file that owns the
+ // layout.
+ if (tag == @intFromEnum(wire.ClientTag.hello)) {
+ // A second hello on one connection is not a resize; it is a peer
+ // that is not speaking this protocol. Judged here rather than in the
+ // arm below so that a repeat hello is a protocol error whatever
+ // version it claims.
+ if (c.attached) return error.BadValue;
+ const claimed = try wire.helloVersion(payload);
+ if (claimed != wire.version) {
+ log.debug("frontend speaks protocol {d}, this session speaks {d}", .{ claimed, wire.version });
+ return s.refuse(c, .version);
+ }
+ }
+ switch (try wire.decodeClient(tag, payload)) {
.hello => |h| {
- // A second hello on one connection is not a resize; it is a
- // peer that is not speaking this protocol.
- if (c.attached) return error.BadValue;
- if (h.version != wire.version) {
- log.debug("frontend speaks protocol {d}, this session speaks {d}", .{ h.version, wire.version });
- return s.refuse(c, .version);
- }
if (s.core.quit) return s.refuse(c, .quitting);
c.cols = h.cols;
c.rows = h.rows;
@@ -847,7 +1521,12 @@ pub const Session = struct {
/// Settle the session grid and greet whoever arrived, once per poll round
/// rather than once per message: three frontends attaching in the same
/// round are one resize, not three reflows of every pane.
- fn reconcile(s: *Session) void {
+ ///
+ /// True when the CORE was told to reflow, which is the one thing a caller
+ /// has to react to: the frame carrying that reflow is the next one
+ /// `Pardes.pump` composes, so a `waitInput` that hears true must not go to
+ /// sleep before returning. See its `regridded`.
+ fn reconcile(s: *Session) bool {
var cols: u16 = 0;
var rows: u16 = 0;
for (&s.clients) |*c| {
@@ -858,6 +1537,7 @@ pub const Session = struct {
// Nobody attached: keep the grid we had. A detached session is not a
// session of no size, it is one nobody is looking at, and reflowing
// every pane to nothing for zero readers is work with no reader.
+ var regridded = false;
if (cols != 0 and (cols != s.cols or rows != s.rows)) {
s.cols = cols;
s.rows = rows;
@@ -867,33 +1547,14 @@ pub const Session = struct {
// safe too.
for (&s.clients) |*c| c.need_full = true;
s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ regridded = true;
}
for (&s.clients, 0..) |*c, i| {
if (!c.greet) continue;
c.greet = false;
s.send(c, .{ .welcome = .{ .slot = @intCast(i), .cols = s.cols, .rows = s.rows } });
}
- s.flushOwed();
- }
-
- /// Hand every owed spawn to the frontend that can serve it. Runs at the end
- /// of a poll round, so a frontend that has just been greeted is asked for
- /// its panes' shells in the same round it arrived — and a session that was
- /// started with panes and no frontend (which is every `--detach`) is a
- /// session whose panes get their shells from the first attach rather than
- /// never. See `Shell`.
- fn flushOwed(s: *Session) void {
- const c = s.primary() orelse return;
- const slot = s.slotOf(c);
- for (&s.shells, 0..) |*sh, pane| {
- if (!sh.owed) continue;
- sh.owed = false;
- sh.owner = slot;
- s.send(c, .{ .spawn = .{ .pane = @intCast(pane), .cwd = sh.cwd.items } });
- // The send closed it, and `close` put its panes back on the owed
- // list; the ones this loop has not reached are still owed anyway.
- if (c.fd < 0) return;
- }
+ return regridded;
}
// ---- bytes ------------------------------------------------------------
@@ -902,11 +1563,12 @@ pub const Session = struct {
const want = wire.serverBound(msg);
s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom);
const bytes = wire.encodeServer(s.scratch.allocatedSlice()[0..want], msg) catch |err| {
- // The only reachable case is a payload past `max_payload`: a save
- // of a pane holding more text than this protocol carries. The
- // session keeps it (the core's own filesystem already has it) and
- // the frontend's copy does not happen — said out loud rather than
- // silently.
+ // The only reachable case is a payload past `max_payload`, and with
+ // every effect that carried a whole file gone from this protocol the
+ // only payload that can still get there is a yank of more than
+ // 16 MiB. The session keeps it — `setClipboard` put it in the core's
+ // own clipboard before this was ever queued — and the frontends'
+ // desktop clipboards do not get it, out loud rather than silently.
log.debug("message {t} not encodable: {t}", .{ msg, err });
return;
};
@@ -918,7 +1580,8 @@ pub const Session = struct {
// what this refuses is a client that has stopped draining.
if (c.out.items.len > out_backlog) return s.close(c, .backlog);
// ...and the table as a whole, which `out_backlog` does not bound: 32
- // slots one byte under it each is 32 MiB. See `session_backlog`.
+ // slots one byte under it each, plus a frame apiece. See
+ // `session_backlog`.
s.account();
if (c.fd < 0) return; // the fattest peer was this one
c.out.appendSlice(s.gpa, bytes) catch return s.close(c, .oom);
@@ -933,13 +1596,23 @@ pub const Session = struct {
/// peer holding the most of it is the peer that stopped reading. The next
/// append asks again, so a second offender is closed a message later rather
/// than in a loop that could empty the table on one bad frame.
+ ///
+ /// `items.len` and NOT `capacity`, which was half of the bug in
+ /// `session_backlog`'s history. An ArrayList grows geometrically, so a
+ /// client's `in.capacity` crossed a 4 MiB ceiling while it was still
+ /// assembling a paste of roughly 2.8 MiB — the peer was punished for the
+ /// allocator's rounding rather than for anything it held. What this is
+ /// asking is "how much is a peer making this session hold RIGHT NOW", and
+ /// that is `items.len`; capacity above it is transient by construction,
+ /// because `retire` hands back anything over `idle_retain` the moment a
+ /// buffer empties.
fn account(s: *Session) void {
var total: usize = 0;
var worst: ?*Client = null;
var worst_bytes: usize = 0;
for (&s.clients) |*c| {
if (c.fd < 0) continue;
- const held = c.in.capacity + c.out.capacity;
+ const held = c.in.items.len + c.out.items.len;
total += held;
if (held > worst_bytes) {
worst_bytes = held;
@@ -998,8 +1671,9 @@ pub const Session = struct {
}
/// Free one slot. A frontend dying takes NOTHING with it: not the core, not
- /// the listener, not another frontend's frames. Its buffers go back and the
- /// slot is reusable on the next connect.
+ /// the listener, not another frontend's frames, and — since this file
+ /// forks — not its panes' shells either. Its buffers go back and the slot is
+ /// reusable on the next connect.
fn close(s: *Session, c: *Client, why: Closed) void {
if (c.fd < 0) return;
log.debug("frontend detached: {t}", .{why});
@@ -1013,19 +1687,12 @@ pub const Session = struct {
if (s.origin) |i| if (i == gone) {
s.origin = null;
};
- // ...and its panes' shells died with the process that forked them. They
- // go back on the owed list, so the frontend that replaces this one is
- // asked to fork them again in the directory they were forked in: the
- // alternative — which is what this did — is a pane that looks alive,
- // produces nothing, and swallows everything typed into it. Migrating a
- // live pty between processes is the other answer and is a different
- // feature; a fresh shell is the one this transport can keep.
- for (&s.shells) |*sh| {
- const owner = sh.owner orelse continue;
- if (owner != gone) continue;
- sh.owner = null;
- sh.owed = true;
- }
+ // ...and that is the whole of it. A frontend used to take its panes'
+ // shells with it and leave them owed to whoever attached next, because
+ // the ptys were in its process; a pane that survived a detach looked
+ // alive, produced nothing and swallowed everything typed into it. The
+ // shells are here now, so a frontend leaving is a screen going away and
+ // nothing else.
c.* = .{};
}
};
@@ -1038,12 +1705,14 @@ pub const Session = struct {
/// core's own `pump`, exactly as the tty and gui shells run it — this frontend
/// simply has no window of its own.
///
-/// The pre-loop effect drain is here for the same reason tty.zig has one: the
-/// startup spawns are already queued, and they have to be PERFORMED before the
-/// loop rather than left in the queue. They reach no frontend — there is none
-/// yet — and are remembered instead, then asked of the first attach; `Shell`
-/// says why that is the only shape that works for a session whose panes exist
-/// before its socket does.
+/// The pre-loop effect drain is here for the same reason tty.zig has one, and
+/// it is no longer half a promise: the startup spawns are already queued and are
+/// PERFORMED here, on this process's own process table. So a session binds its
+/// socket with every pane's shell already forked and already in the poll set,
+/// and the first frontend to attach — whether that is a second later or the
+/// next morning — is sent a frame of shells that have been printing into the
+/// core since before it existed. Nothing is remembered for a later frontend,
+/// because nothing is owed to one.
pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void {
const gpa = init.gpa;
const allocs = pardes.allocators.init(gpa);
@@ -1066,13 +1735,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
}
const core = if (options.load_path) |lp| blk: {
- const bytes = try @import("../look.zig").readFile(gpa, lp);
+ const bytes = try look.readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes);
} else try pardes.Pardes.init(allocs.pardes, options);
defer core.deinit();
- var session: Session = .{ .gpa = gpa, .core = core, .cols = options.cols, .rows = options.rows };
+ var session: Session = .{
+ .gpa = gpa,
+ .io = init.io,
+ .core = core,
+ .cols = options.cols,
+ .rows = options.rows,
+ // Staged before the first fork and owned by the Session for exactly as
+ // long as it can fork: `shell_bin.resolve` hands a child pointers into
+ // these buffers, and the child holds them until it execs.
+ .prompt_rcs = .init(),
+ };
defer session.deinit();
if (!session.listen(name)) {
// Loud, and on stderr rather than through the log: a `--detach` whose
@@ -1085,6 +1764,9 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
const h = session.host();
core.host = h;
while (core.nextEffect()) |effect| core.perform(effect);
+ // Past the startup drain: a `ThemeFile` reload from here on is a human's
+ // and animates. See `in_loop`.
+ session.in_loop = true;
while (!core.quit) try core.pump(h);
}
@@ -1137,7 +1819,14 @@ fn retire(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged(u8)) void {
/// Zero on failure, and every caller treats zero as "no clock" and enforces no
/// deadline at all — a session that cannot read a clock keeps every slot rather
/// than dropping every slot.
-fn monotonicMs() i64 {
+///
+/// `pub` for the same reason `setNonblock`, `nosignal` and the `poll_*`
+/// constants are: this file owns the transport's conventions and BOTH ends of
+/// it, and the clock a handshake is timed against is one of them. client.zig
+/// times its wait for a `welcome` on this and against
+/// `greet_deadline_default_ms`, so the two ends cannot disagree about how long
+/// the handshake is allowed to take.
+pub fn monotonicMs() i64 {
var ts: libc.timespec = undefined;
if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms);
@@ -1254,8 +1943,8 @@ fn alive(path: [:0]const u8) bool {
}
/// Unlink the sockets of detached sessions that are gone — our own litter,
-/// which `--attach`'s "the one session there is" would otherwise count as a
-/// session (tty.zig `sessionName`). `alive` is the whole of the judgement.
+/// which the bare `Attach`'s "whichever session is there" would otherwise count
+/// as a session (client.zig `resolve`). `alive` is the whole of the judgement.
///
/// Bounded: one readdir of a directory only we write to, one connect each.
fn sweep(dir: [:0]const u8) void {