diff options
Diffstat (limited to 'docs/detached.md')
| -rw-r--r-- | docs/detached.md | 324 |
1 files changed, 324 insertions, 0 deletions
diff --git a/docs/detached.md b/docs/detached.md new file mode 100644 index 00000000..b4dde59c --- /dev/null +++ b/docs/detached.md @@ -0,0 +1,324 @@ +# 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. + +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. + +## Using 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 + +And from inside a running editor, as ordinary acme words — type one in a tag and +execute it, or press its leader chord: + + 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) + +`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). + +`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. + +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. + +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. + +## Where the socket lives + +`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`. + +`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. + +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 inotify +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 inotify 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. +* **No LSP, no selection pipe, no `--fs` control filesystem** in a detached + session. The daemon implements **sixteen** 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 five it leaves null divide cleanly. + Three are real losses: `pull_lsp` and `pull_pipe` want a + worker pool this deliberately single-threaded loop has not got, and + `push_fs_reply` wants a `/dev/fuse` this process never mounted. 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 eleven tests in `src/detached/client.zig`. Ten 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. + +The eleventh 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. |
