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