summaryrefslogtreecommitdiff
path: root/docs/detached.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/detached.md')
-rw-r--r--docs/detached.md359
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.