From 11f380f6d7222f2cad93c2cdf13701ea1f903d47 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 13:27:46 -0300 Subject: One core behind N frontends, the board's own runner moved in, and every board cap on one screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason. --- src/nested.zig | 61 +++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 20 deletions(-) (limited to 'src/nested.zig') diff --git a/src/nested.zig b/src/nested.zig index eb01b2e0..887d0703 100644 --- a/src/nested.zig +++ b/src/nested.zig @@ -36,21 +36,28 @@ const libc = std.c; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; -const darwin = switch (builtin.os.tag) { +/// THE SOCKET CONVENTIONS BELOW ARE SHARED, and the ones marked `pub` are +/// shared with src/detached/server.zig — a second unix socket in the same +/// per-user directory, under a different name (`pardes-detached-.sock` +/// rather than `pardes-.sock`). They were copied into that file when it +/// landed; one directory vetted by two different predicates is exactly the +/// divergence the reasoning here is meant to prevent, so there is one of each. +pub const darwin = switch (builtin.os.tag) { .macos, .ios, .tvos, .watchos, .visionos => true, else => false, }; /// This module is only as portable as its two ingredients: a way to name the -/// executable and parent of an arbitrary pid, and unix sockets. -const supported = builtin.os.tag == .linux or darwin; +/// executable and parent of an arbitrary pid, and unix sockets. The detached +/// transport needs the second alone, and the same answer. +pub const supported = builtin.os.tag == .linux or darwin; /// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard /// limit on this whole feature: a path that does not fit is not a socket /// address, it is a truncated one pointing somewhere else. Taken from the /// struct so that the buffers, the fit checks and the memcpy below cannot /// disagree with the kernel or with each other. -const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; +pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; /// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of /// `/proc//exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux @@ -72,10 +79,15 @@ extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_in extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; /// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. -/// The gap is a race only against a fork on another thread, and both callers -/// are past that: `listen` runs before the first pane exists, and `acceptLine` -/// runs on a thread of its own long after spawning has settled. -fn setCloexec(fd: c_int) void { +/// The gap is a race only against a fork on another thread, and every caller +/// is past that: `listen` runs before the first pane exists, `acceptLine` runs +/// on a thread of its own long after spawning has settled, and the detached +/// session forks nothing at all (its ptys live in its frontends). +/// +/// CLOEXEC still matters for both: a `--detach` session is long-lived, and an +/// inherited listener would keep its socket bound long after it ended — the +/// same shape as the inherited lock fd that once held a flock forever. +pub fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } @@ -87,10 +99,12 @@ pub const max_line = 4200; /// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs /// the login session already cleans up — else `~/.local/state/pardes`, which -/// is per-user for the same reason a home directory is. Asked by the client -/// (to derive the path), by the listener (to create and vet it) and by the -/// sweeper (to scan it), so it is written once. -fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { +/// is per-user for the same reason a home directory is. NEVER /tmp: these +/// sockets take a command line, or keystrokes into a live editor. Asked by the +/// client (to derive the path), by the listener (to create and vet it), by the +/// sweeper (to scan it) and by the detached transport (all three, for its own +/// name), so it is written once. +pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { if (libc.getenv("XDG_RUNTIME_DIR")) |x| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null; const home = libc.getenv("HOME") orelse return null; @@ -311,14 +325,19 @@ pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { return true; } -/// The three things ensureSocketDir has to know about a path, from whichever +/// The two things `ensureSocketDir` has to know about a path, from whichever /// call the platform actually offers. Darwin has fstatat and no statx; on /// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol -/// std cannot name — so linux asks statx for the same three fields. Both -/// spellings refuse to follow a symlink, which is the point of asking. -const DirFacts = struct { mode: u32, uid: libc.uid_t }; +/// std cannot name — so linux asks statx for the same fields. Both spellings +/// refuse to follow a symlink, which is the point of asking. +/// +/// `pub` for the detached transport, which vets the same directory and also +/// vets the SOCKET FILE with it (src/detached/server.zig `vetted`): `mode` +/// carries the type bits, so one call answers "is this a socket, ours, and +/// private" as well as it answers it for a directory. +pub const DirFacts = struct { mode: u32, uid: libc.uid_t }; -fn statNoFollow(path: [:0]const u8) ?DirFacts { +pub fn statNoFollow(path: [:0]const u8) ?DirFacts { if (comptime darwin) { var st: libc.Stat = undefined; if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null; @@ -334,9 +353,11 @@ fn statNoFollow(path: [:0]const u8) ?DirFacts { /// Create the socket directory if it is missing and refuse it unless it is a /// directory WE own with nothing granted to group or other. A planted path is -/// the whole attack on a socket that runs commands, and $XDG_RUNTIME_DIR -/// passes this untouched (the login session already makes it 0700). -fn ensureSocketDir(dir: [:0]const u8) bool { +/// the whole attack on a socket that runs commands — or, for the detached +/// transport that shares this, on one that carries keystrokes into a live +/// editor — and $XDG_RUNTIME_DIR passes this untouched (the login session +/// already makes it 0700). +pub fn ensureSocketDir(dir: [:0]const u8) bool { // mkdir -p, because the HOME branch is three levels deep and a machine // without ~/.local/state would otherwise switch the feature off in // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply -- cgit v1.3