diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /docs/detached.md | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'docs/detached.md')
| -rw-r--r-- | docs/detached.md | 359 |
1 files changed, 42 insertions, 317 deletions
diff --git a/docs/detached.md b/docs/detached.md index 0902f78a..9b9ea4ef 100644 --- a/docs/detached.md +++ b/docs/detached.md @@ -1,332 +1,57 @@ # Detached sessions -One pardes core with no terminal of its own, and any number of thin frontends -attached to it over a unix socket. The core holds every piece of state — the -text, the undo history, the layout, the pane shells — and outlives every -frontend that comes and goes. +A detached session owns the editor core, pane shells, files, undo history and +layout. TTY and SDL frontends can join and leave without ending the session. -The model is the ESP32-P4 serial console, which is why it is worth naming. On -the board, pardes runs as firmware and the host side is a dumb wire: keystrokes -in, bytes out, and the console performs no effects at all. A detached session is -that arrangement with a typed wire instead of raw ANSI: input in, cells out, and -a frontend that does almost nothing. +```sh +pardes --detach=work & +pardes --attach=work +pardes-gui --attach=work +``` -## Using it +Bare `--detach` names the session after its process ID. Bare `--attach` +requires exactly one listening session. The detached process runs in the +foreground unless the shell backgrounds it. - pardes --detach # a core named by this process's pid - pardes --detach=work # ...named `work` - pardes --attach # become a frontend of the one session there is - pardes --attach=work # ...of `work` - pardes-gui --attach=work # the SDL window is a frontend too +Inside an editor, `Attach work` (`SPC s a`) switches the current window to +that session. `Detach` (`SPC s D`) closes only that frontend. It does not +turn a local editor into a detached session. -And from inside a running editor, as ordinary acme words — type one in a tag and -execute it, or press its leader chord: +## Files and transport - Attach # hand this window to the session there is - Attach work # ...to `work` (chord: SPC s a) - Detach # leave the session, and leave it running - # (chord: SPC s D) +The frontend socket is `pardes-detached-<name>.sock` under +`$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when no runtime directory is +set. A second session cannot replace a live listener with the same name. +Shared Unix socket handling lives in `src/9p_io.zig`. -`Attach` switches **in place**: the window, the terminal and the process stay, -and what changes is where the state lives. The ordering is the feature — see -[The in-place switch](#the-in-place-switch). +The session also opens its default 9P socket. Optional TCP and QUIC listeners, +runtime mounts and the control filesystem belong to the session, not its +frontends. See [fs.md](fs.md). -`Detach` is its counterpart and the smaller of the two: only the frontend that -ran the word leaves. The session, its pane shells and every other attached -frontend are untouched, so leaving is a success — the terminal prints where to -come back to and exits 0. It routes ORIGIN-ELSE-PRIMARY, the same rule -`read_clipboard` takes, because it answers something one particular human just -did. +## Ownership -In a session with nothing to detach from, `Detach` says so on the pane's message -row and does nothing. That falls out of the design rather than being special- -cased: a local shell leaves `push_detach` null on its vtable, and `perform` -reports `NotAttached` for a null method. `Detach` does NOT turn a local session -into a daemon — that is the true inverse of the in-place switch, it needs real -daemonisation, and it is deliberately not this feature. +One poll loop owns all core mutation, frontend connections, PTY I/O and file +watch notifications. LSP and selection-pipe workers own request snapshots and +post completions through a bounded mailbox. Each subprocess is reaped by its +owner; PTY reaping cannot consume a language server or filter's exit status. -Bare `--attach` and bare `Attach` mean "the session that is there", because -bare `--detach` names itself by its own pid and nobody can be expected to read -a pid out of `$XDG_RUNTIME_DIR`. With exactly one session listening that is the -one meant; with none or several, `detached_client.resolve` says which case it is -rather than picking one. +Restore constructs a replacement core before changing the current one. It then +joins old work, clears obsolete completions, replaces panes and watches, and +sends a fresh frame to the existing frontends. -## Where the socket lives +Frontends provide input and presentation. Clipboard writes are broadcast; +clipboard reads, browser opens and Detach go to the originating frontend, +falling back to the primary attachment. Frontends never spawn pane shells, +write session files or install file watches. -`nested.socketDir`: `$XDG_RUNTIME_DIR`, else `~/.local/state/pardes`, created -`0700` by `nested.ensureSocketDir` — never `/tmp`, because this socket carries -keystrokes into a live editor, and a world-writable directory means both that -somebody else can plant a listener at a path you will derive and that a file -they planted cannot be unlinked. `server.socketPath` spells the name -`pardes-detached-<name>.sock` and refuses a name that is empty or holds a `/` -or a NUL, because either would move the address somewhere else. The prefix -differs from `nested.socketPath`'s `pardes-<pid>.sock` so that nested.zig's -sweeper, which recognises only an all-digit pid, can never unlink a live -session called `work`. +## Wire and tests -`bind(2)` decides who owns a name, because on a unix socket it is an atomic -exclusive create. `listen` does not unlink first: a name whose socket ANSWERS -is a live session and the bind is allowed to fail, and the only file this -process removes is one `alive` proved dead — which it says only of a connect -that was REFUSED. An unconditional unlink-before-bind is how a second -`--detach=work` used to take the socket away from every frontend attached to -the first. The window `alive` cannot see is stated in its own comment: a -session between its `bind` and its `listen(2)` also answers ECONNREFUSED, it -is two syscalls wide, and the loser of that race loses a NAME rather than a -session. +`src/detached/wire.zig` owns the versioned frontend protocol. Frames are full +grids or changes relative to each frontend's last queued frame. A new +attachment receives a full grid. Output queues and per-poll work are bounded; +a lagging frontend cannot hold the session's event loop. -Both ends vet, through one predicate — `server.zig` `vetted`, which asks `ours` -three questions of the DIRECTORY and then the same three of the SOCKET: the -right file type, our uid, and nothing granted to group or other. A frontend -that checks only one of the two has checked neither. `Client.open` asks -`access(F_OK)` before it vets, so a mistyped session name reports `NoSession` -rather than `NotPrivate` — the latter promises that the socket IS there and is -reachable by somebody else, which is a different sentence to say to a human. - -## What the daemon owns - -**Everything with an operating system under it.** The eight effects that were -once routed to one frontend to perform — `push_spawn`, `push_pty_write`, -`push_pty_resize`, `push_write_file`, `push_write_dump`, `push_watch_file`, -`push_watch_theme`, `push_dump_themes` — are performed by the daemon itself, -through `src/host_io.zig` and `src/file_watch.zig`. - -This is the whole design and it is worth stating why. A unix socket means the -core and its frontends are on the same machine, so there is no question of whose -disk or whose process table is meant. Given that, the pane shells belong to the -long-lived process: a shell forked by a frontend dies with that frontend, and -then the session has a pane with no shell in it — which contradicts the one -promise a detached session makes. It also meant `tty.zig` had to reimplement the -shell's own Host, so `spawn`, `writeFile`, `watchFile` and `dumpThemes` each -existed twice in that file, and a second frontend would have been a third copy. - -A consequence worth knowing: the daemon forks its pane shells whether or not -anybody is attached. Start a session, attach nothing, and `ps --ppid <daemon>` -already shows a shell. - -The daemon is **single-threaded**. Its pane pty masters and its one watch -descriptor live in the same `poll(2)` that accepts frontends — -`poll_slots = 1 + max_clients + MAX_PANES + 1` = 50 descriptors — so there is no -thread per pane and no thread per client. Pty output enters the core as -`core.update(.{ .output = ... })` out of a stack buffer, so a chunk is never -duplicated. Dead shells are reaped with `waitpid(-1, WNOHANG)`. - -Two capabilities exist only because the ptys are here: `pull_tty_taken` can -answer whether a pane's shell has a full-screen program in it (so an `Exec` is -typed into vim instead of at the shell), and `push_poll_frame` reports each -pane's live cwd to its tag. A frontend could do neither — it had the pid but no -core to report to. - -## What a frontend does - -Input and screen, and exactly three effects: - -| message | routing | what the frontend does | -|---|---|---| -| `set_clipboard` | broadcast | put it on **this** display's clipboard | -| `read_clipboard` | origin, else primary | read this display's clipboard, send it back as an ordinary paste | -| `open_link` | origin, else primary | open it in **this** display's browser | - -Those three survive on the wire because each needs the human's own display and -cannot be done by a process nobody is looking at. Everything else the frontend -receives is a `frame`. It never forks a shell, writes a file, or watches a path. - -`read_clipboard` and `open_link` go to the frontend whose event was applied most -recently, because both answer something a human just did: the paste must come -from the keyboard that asked for it, and a link must open in front of the person -who clicked it. The fallback to primary — the lowest attached slot, i.e. the -oldest surviving attachment — covers an effect no input caused. - -## The wire - -`src/detached/wire.zig`, protocol `version` 1, checked on connect and refused -loudly, because `zig build` replaces the binary under a running session and a -frontend decoding another version's frame layout would paint garbage and blame -the terminal. Every message is `tag:u8, len:u32le, payload[len]` — `header_len` -is 5 — and `max_payload` is 16 MiB, a bound derived from the two messages that -set it: a full frame of the largest grid the protocol admits (`max_cols` 512 by -`max_rows` 128, worst case one run per cell, about 1.6 MiB) and one paste, -which the tty frontend already caps at 4 MiB. - -**Core to frontend: eight messages** (`ServerTag`), and the split between the -two ranges is the design rather than housekeeping: - - # 0x01..0x0f — SESSION CONTROL. Not effects; the session talking about - # itself and about this connection's membership of it. - welcome = 0x01 refuse = 0x02 frame = 0x03 quit = 0x04 detach = 0x05 - - # 0x10.. — one `push_` method each, in Host.VTable's own order. Only the - # three that need THIS human's display are here; see "What the daemon owns". - set_clipboard = 0x10 read_clipboard = 0x11 open_link = 0x12 - -**Frontend to core: eleven** (`ClientTag`), and the whole set is a handshake, a -goodbye, and what a keyboard, a mouse, a trackpad or a window manager produces: - - hello = 0x01 bye = 0x02 - - key = 0x10 mouse = 0x11 resize = 0x12 paste = 0x18 - command = 0x19 pdf_scroll = 0x1a pinch = 0x1b - touch_scroll = 0x1c pointer_leave = 0x1d - -Six numbers are missing from that input run — `0x13..0x17` and `0x1e` — and the -gaps are left rather than tidied away, because renumbering is a change every -deployed frontend feels. They were `output`, `eof`, `lsp_resp`, `pipe_resp`, -`file_changed` and `tick`: the machine-local host's own reports, which stopped -being a frontend's business when the daemon took the disk and the process -table. Deleting them was not housekeeping either. `server.zig` `apply` routes -any decoded non-resize event straight into `core.update`, so while those tags -decoded an attached peer could forge a pane's output, forge an `eof` for a -shell that was still running — and unlike the daemon's own `paneEof` that path -never called `closePty`, so the master stayed open and the shell was orphaned -for the life of the session — or replace a pane's text with bytes the next -`Save` would write to disk. - -The format is deliberately **architecture-neutral**: explicit little-endian -widths, no `usize` anywhere, no struct blits, a length prefix on every slice, -tags chosen in this file rather than taken from `@intFromEnum` of a core type, -floats as their binary32 bit pattern inside an explicit `u32`, and a bool that -is 0 or 1 and a decode error otherwise. A pointer-sized field would be 4 bytes -on a 32-bit frontend and 8 here, so none is sent. It is **build-neutral** for -the same reason: `Event.resize.cell_pixels` exists only in a build with native -PDF placement compiled in, so it is always on the wire and dropped on arrival -by a build with nowhere to put it. Today both ends are x86-64 Linux; the -neutrality is what makes a riscv32 end possible later without a format change. - -Frames are diffs against what a client actually has. A client with bytes still -owed to the kernel is **skipped** for this frame and its mirror is left alone, -so a slow frontend sees fewer, larger frames rather than a growing queue. - -## The in-place switch - -`Attach` is a core-side word that emits `Effect.attach{pane, name}`; the frontend -drains it with `Pardes.takeAttach()` beside the existing `takeRestore()`. No host -method performs it, because attaching replaces the core the call is running -inside — so a shell that never polls simply cannot attach, and `host.zig` needed -no change. - -**Connect first, swap second.** The frontend opens the client and, only on a -handshake that actually succeeded, tears the local session down — reaping pane -shells, closing watches, unmounting the control filesystem, deinitialising the -core — and enters the thin attached loop. On any failure it changes *nothing*: -the message row on the pane that ran the word says why, and the editor carries -on locally with every pane and its undo history intact. A failed `Attach` is a -no-op, never a half-dead editor. - -## Fairness, and why no client can stall another - -* Every descriptor is non-blocking; one `poll(2)` per pump covers all of them. -* Frames are not queued (above), so coalescing costs no byte surgery. -* A client's out-queue holds control messages and is capped at 1 MiB - (`out_backlog`). The cap is checked *before* an append, so an oversized - message still goes out whole and what gets refused is a client that has - stopped draining: it is closed, its peers untouched, and it may reattach and - be sent a full frame. -* The TABLE is accounted too, not just each slot: `session_backlog` bounds every - in-queue and out-queue together, and a drained client hands back anything - above one `read_chunk` (`idle_retain`, 16 KiB). Its value is *derived* — - `2 * wire.max_payload`, 32 MiB — and the derivation is the fix. It used to be - a literal `4 << 20`, which was by coincidence exactly tty.zig's - `max_paste_bytes`; since a client's `in` grows to hold one WHOLE message, a - frontend assembling the very paste `max_payload` is sized for crossed the - table's ceiling *while still receiving it*, and the session closed its only - frontend mid-paste with the diagnostic for a peer that had stopped reading. -* A PANE is not a client, so it cannot be closed to reclaim anything. Its - shell's input is queued behind a POLLOUT on the descriptor already in the set, - bounded by `pty_backlog` (1 MiB), and past that the write is REFUSED and said - out loud on the pane's own message row — dropping input silently loses half a - command line, and killing a shell to reclaim a megabyte destroys work. Before - this, one `write(2)` to a master could park the whole daemon: `sleep 3600` - plus a paste larger than the pty's 4 KiB input buffer meant no frame to any - frontend, fifteen other masters unread, no `accept`, no watch drain. -* `max_clients` (32) is a **refusal**, not a queue. The listener is always - accepted from even when the table is full, so the refusal can be spoken — a - level-triggered `poll` on a backlog nobody accepts returns ready forever and - spins a core. A connection that never says `hello` also loses its slot, after - `greet_deadline_default_ms` (5 s), the one number both ends of this transport - time the handshake against; a slot held by silence is the same denial as a - queue arrived at from the other end. -* A peer speaking another protocol version gets `refuse(version)` and the - session survives. So does a peer that sends a byte the decoder does not know. - -## Limits - -Stated rather than papered over: - -* **The screen is shared, at the smallest common grid.** Two frontends of - different sizes converge on the smaller; the larger window letterboxes. Same - semantics as tmux. -* **A frontend asks for at most `max_cols` x `max_rows`** (512x128, above). - A window bigger than that — a 4K display at a small font is already past 128 - rows — attaches at 512x128 and letterboxes the rest, exactly as it does - beside a smaller frontend. `client.zig` clamps the hello and every resize, - because the geometry itself does not fit the wire: an unclamped one was - refused by the session's decoder as `BadValue`, and that refusal reaches the - frontend as a bare hangup with no reason attached. -* **No LSP and no selection pipe** in a detached session. The daemon implements - **seventeen** of `Host.VTable`'s **twenty-one** methods — fewer than the tty - and SDL shells, which install nineteen each, everything but - `pull_gpio_toggle` and `push_detach` — and it is the only host that - implements `push_detach` at all. The four it leaves null divide cleanly. - Two are real losses: `pull_lsp` and `pull_pipe` want a worker pool this - deliberately single-threaded loop has not got. They fall back - to the core's in-process defaults rather than failing, so the features are - quiet rather than broken. The other two are not losses at all: there is no - moment "after the frame is on screen" for a process with no screen - (`push_post_present`), and no pads to toggle on a PC (`pull_gpio_toggle`). -* **`--fs` is inert rather than refused.** `main.zig` hands `opts.fs` to - `server.run`, which imports no `fs_service` and mounts nothing, and `spawn` - passes a null mount directory to `host_io.forkShell`. So - `pardes --detach --fs` gives a session with no control filesystem, no - `PARDES_FS` in its pane shells, and no diagnostic saying so. -* **Frontends are the terminal and the SDL window only.** `main.zig` dispatches - `--attach` to `tty.run` and `gui.run`, and refuses any other platform with - `pardes: --attach needs the tty or gui shell`. The other three shells — - `-Dplatform=web`, `-Dplatform=macos`, `-Dplatform=esp32p4` — are entered by - their own hosts and never link that file at all. -* **Linux.** The code carries darwin branches (`sun_path` is 104 there rather - than 108, and SIGPIPE is per-socket rather than per-write), but only Linux is - built and tested. -* A pane's shell is the daemon's child, so `Kill` in a frontend ends a shell for - everybody attached. That is what one shared session means. -* **An attached window does not get the session's font.** `Font <name>` is a - core setting raised through `takeFontRequest`, and an attached SDL frontend - has no core to raise it — so a window that would load `MartianMono-NrRg` - locally keeps its embedded Adwaita Mono when attached, and its cell metrics - differ from the same window run locally. The choice belongs to the session but - the fonts belong to the display, so closing this means putting the request on - the wire; nothing does today. -* **An attached pane tagline is drawn in the body face**, not the condensed - tagline face. The compacted band is positioned from the pane rectangle that - produced it, and the wire carries cells rather than rectangles: in a - multi-column layout two panes' tag runs touch, so the origin cannot be - recovered from the frame alone without bleeding one column's band into its - neighbour. Painting and hit-testing therefore agree on the body grid, which is - what keeps a click landing on the glyph it was aimed at; the visible cost is - one row per pane of looser tracking. - -## Tests - -`zig build unit-test` runs twelve tests in `src/detached/client.zig`. Eleven -drive a real core over a real socket: a frontend is greeted -and sent a screen; input comes back as a diff; two frontends share one screen -at the smallest common grid; a frontend that dies takes nothing with it; a -wrong-version peer is refused, loudly; a peer that sends an undefined tag byte -loses its slot and not the session; the session outlives every frontend, keeps -the grid where the last one left it, and lets the next one take it over; the -three surviving effects route as documented above — `set_clipboard` to both -frontends, `read_clipboard` and `open_link` to the origin, and to the primary -once the origin is gone; the client table refuses rather than queues; and a -frontend that stops reading is dropped; and a window bigger than the protocol's -grid attaches at `max_cols` x `max_rows` rather than being refused, which is -also the one test that drives a full frame of the largest grid the wire carries. - -The twelfth builds no harness, opens no socket and touches no core, and that is -the point: it pins the DESIGN rather than the behaviour, and `wire.zig` has its -mirror, one test per direction. "A frontend is never asked to fork, write, or -watch" walks -`wire.ServerMsg`'s fields BY NAME — so re-adding `spawn` fails with the name in -the failure — and then every one of the 256 tag bytes, so a session built -before this change cannot talk a frontend into forking either. "A session is -never told to do a frontend's remembering" is the same argument pointed at the -other end, and it is what keeps the six deleted `ClientTag` numbers -undecodable. +`zig build unit-test` covers encoding, session ownership, real frontend +connections, worker completion and Restore. `zig build fs-test` drives +detached sessions through an independent 9P client. The snapshot suites also +exercise attach, detach and shared screen behavior. |
