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/main.zig | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 9 deletions(-) (limited to 'src/main.zig') diff --git a/src/main.zig b/src/main.zig index c60fb888..08beae86 100644 --- a/src/main.zig +++ b/src/main.zig @@ -85,6 +85,19 @@ const help_text = \\ PARDES_FS and PARDES_PANE into every pane shell \\ --fs= ...at instead. Must be absolute; pardes \\ unmounts it on exit but leaves the directory + \\ --detach run this session with NO terminal of its own, + \\ serving frontends over a unix socket beside the + \\ nested-instance one. The core, the panes and the + \\ undo history outlive every frontend that attaches + \\ --detach= ...named rather than this process's pid, so + \\ a frontend can say which session it wants. One + \\ path component: no '/' and nothing empty + \\ --attach become a frontend of the one detached session + \\ that is running: draw its screen, send it input, + \\ fork its pane shells. Several frontends may be + \\ attached at once and all see the same screen + \\ --attach= ...of the session called , which is what to + \\ use when more than one is running \\ -h, --help show this help and exit \\ ; @@ -128,14 +141,27 @@ fn nativeMain(init: std.process.Init) !void { // bare `pardes` boots straight into tty mode — and so does a `pardes` // carrying nothing but flags that say something about the SESSION rather // than about its layout: --nested is about this session's relationship to - // its parent, --fs is about who may script it, and neither says anything - // about what should be on screen. Anything else (a FILE, -n, --tty) is - // layout, and answers this question itself further down. + // its parent, --fs is about who may script it, --detach is about who may + // WATCH it, --attach is about whose screen this one is showing, and none of + // the four says anything about what should be on screen. Anything else (a + // FILE, -n, --tty) is layout, and answers this question itself further + // down. opts.tty_only = for (args[1..]) |a| { if (!std.mem.eql(u8, a, "--nested") and !std.mem.eql(u8, a, "--fs") and - !std.mem.startsWith(u8, a, "--fs=")) break false; + !std.mem.startsWith(u8, a, "--fs=") and + !std.mem.eql(u8, a, "--detach") and + !std.mem.startsWith(u8, a, "--detach=") and + !std.mem.eql(u8, a, "--attach") and + !std.mem.startsWith(u8, a, "--attach=")) break false; } else true; + // `--detach[=]`: null when it was not given, so the empty string is + // free to mean "the default name" the way opts.fs uses it for a directory. + var detach: ?[]const u8 = null; + // ...and `--attach[=]`, the same shape: null when it was not given, + // and the empty string means "the one session there is" (tty.zig + // `sessionName`) rather than a session with no name. + var attach: ?[]const u8 = null; // Kept RAW until every flag is parsed: classifying it means chdir'ing into // a directory and recording nothing, and the nested client below still // needs the word itself to resolve. @@ -171,6 +197,22 @@ fn nativeMain(init: std.process.Init) !void { opts.fs = a["--fs=".len..]; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; + } else if (std.mem.eql(u8, a, "--detach")) { + detach = ""; + } else if (std.mem.startsWith(u8, a, "--detach=")) { + // `--detach=` and never `--detach `, for exactly the + // reason --fs gives above: the flag is useful bare, so a two-word + // form would make `pardes --detach README` a session called README + // that opens no file. + detach = a["--detach=".len..]; + } else if (std.mem.eql(u8, a, "--attach")) { + attach = ""; + } else if (std.mem.startsWith(u8, a, "--attach=")) { + // `--attach=` and never `--attach `, for the reason + // --fs states above and --detach repeats: the flag is useful bare, + // so a two-word form would make `pardes --attach README` an attach + // to a session called README that opens no file. + attach = a["--attach=".len..]; } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { try std.Io.File.stdout().writeStreamingAll(init.io, help_text); return; @@ -186,7 +228,17 @@ fn nativeMain(init: std.process.Init) !void { // instance resolves against ITS panes' directories, which are not ours. // A word naming nothing on disk sends nothing and falls through to the // classification below, which already refuses it — no second UI either way. - if (!opts.nested) if (nested.outer()) |outer_pid| { + // + // `--detach` is exempt for the same reason `--nested` is, arrived at from + // the other side: it stacks no UI at all. A detached session started from a + // pane is a session, not a request that the outer instance open something, + // and handing it our positional would leave the caller with no session. + // + // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI + // is a session that already exists somewhere else, and handing our word to + // the outer instance would open the file in the WRONG session and leave + // the caller with no frontend. + if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| { const word = positional orelse { try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); @@ -228,14 +280,41 @@ fn nativeMain(init: std.process.Init) !void { opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; + // `--detach` is the core with no terminal and `--attach` is a terminal + // with no core, so the two together are a contradiction with no useful + // reading. Refused rather than resolved by declaration order, which would + // silently drop whichever flag lost. + if (detach != null and attach != null) return error.BadArgs; + // `--detach` replaces the frontend rather than choosing among them: the + // core runs here, with no terminal, and the frontends are elsewhere on a + // socket (src/detached/). It is checked before `platform` because it is not + // a shell — the tty and gui builds can both be asked for one. + if (detach) |name| { + // Bare `--detach` is named by this process's pid, which is the one name + // nobody has to be told and no two sessions can share. Unsigned: `{d}` + // prints a leading '+' for a positive SIGNED int, which is nested.zig's + // note about the same cast. + const named = if (name.len != 0) + name + else + try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))}); + return @import("detached/server.zig").run(init, opts, named); + } + // A frontend is a SHELL, and only the tty one knows how to be one today. + // Comptime-folded, so a tty build carries none of this. + if (attach != null and pardes.platform != .tty) { + try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty shell\n"); + std.process.exit(1); + } switch (pardes.platform) { - .tty => try @import("tty/tty.zig").run(init, opts), + .tty => try @import("tty/tty.zig").run(init, opts, attach), .gui => try @import("gui/gui.zig").run(init, opts), // Every other shell is entered by its host and never links this file // at all: the browser through src/web.zig, the macOS app through - // src/macos.zig, and the ESP32-P4 firmware through its own app root in - // the zig-p4 package, which imports this package's `pardes_p4` module. - .web, .macos, .p4 => unreachable, + // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig, + // which is a root of its own in this repository and links the + // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig. + .web, .macos, .esp32p4 => unreachable, } } @@ -277,6 +356,19 @@ test { // silence. That is the fonts.zig story above, told once already. _ = @import("acmefs.zig"); _ = @import("fuse.zig"); + // The detached-session transport (src/detached/), same story as fuse.zig + // above: it speaks the core's Event/Surface, so it belongs in THIS module + // rather than a standalone b.addTest, and nothing the core analyses reaches + // it. A TTY build does — tty.zig imports client.zig for `--attach` — but + // these names are what makes the transport's tests exist in every other + // build too, and `--detach` is not a tty-only feature. + // client.zig's own tests drive a real `Session` over a real socket, so + // naming it reaches server.zig too — but server.zig is named anyway, for + // the acmefs.zig reason: the day client.zig stops importing it is the day + // those tests vanish in silence. + _ = @import("detached/wire.zig"); + _ = @import("detached/server.zig"); + _ = @import("detached/client.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); // tty.zig calls the compositor only from its runtime loop, so merely -- cgit v1.3