summaryrefslogtreecommitdiff
path: root/docs/detached.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 18:58:37 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch)
tree6629cc215d6953090f6b29a7414b28cb9990e105 /docs/detached.md
parent11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff)
downloadpardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz
pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
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.