summaryrefslogtreecommitdiff
path: root/docs/detached.md
blob: 58a2f9c667eb15068f8e52c0bcae11125f8306d0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# 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 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.
* **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 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.