diff options
41 files changed, 5020 insertions, 3116 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 16e90dc9..7cdca1a0 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -1,472 +1,174 @@ --- name: pardes-9p -description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging. +description: Inspect and drive a running Pardes editor through its 9P control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging. --- # Pardes over 9P -Pardes is driven by reading and writing files. Prefer ordinary file tools over -a mount; reach for the Python client only when nothing is mounted, or when the -work needs a fid held open. Do not build another wire client: project tooling -stays in Zig. Run the examples from the repository root; paths below are -relative to that root unless linked. +Pardes is files: `cat`, `echo >`, `ls` and shell scripts are the whole +interface. [docs/fs.md](../../../docs/fs.md) is the reference for every +file; this page is recipes. Paths are relative to the repository root. -## Find the session through the mount - -Check `$NINE_MOUNT` first. `9ns --mntgen` sets it for every process it starts -(an interactive shell on this machine runs inside one), so a set `$NINE_MOUNT` -means the posted-9P registry is mounted there, usually `/mnt/9p`, and each -running editor is a directory `$NINE_MOUNT/pardes/<pid>/` (a `--detach=NAME` -session's is `pardes/NAME/`; `pardes --detach=NAME &` runs the editor as -that process, so `$!` is its pid, and `$m/status`'s first line, `pid <n>`, -says the same). Inside a pane, `$PARDES_9P` is that session's -socket (`/run/user/1000/pardes-9p-<pid or NAME>.sock`), which names the -directory either way, and `$PARDES_PANE` is the calling pane's serial: +## Find the session ```sh [ -n "$NINE_MOUNT" ] || echo 'no 9P mount: use the Python client below' s=${PARDES_9P##*/pardes-9p-}; m=$NINE_MOUNT/pardes/${s%.sock} -# under `9ns --unix SOCKET -- cmd` the session is the mount itself: m=$NINE_MOUNT cat "$m/index" -cat "$m/pane/$PARDES_PANE/body" ``` -From there the editor is files: `cat`, `echo >`, `ls` and shell scripts are -the whole interface, and nothing below needs more than they do. +`$NINE_MOUNT` is set by `9ns --mntgen` (usually `/mnt/9p`). `$PARDES_9P` is +the session socket of the pane you run in, `$PARDES_PANE` that pane's +serial. Under `9ns --unix SOCK -- cmd` the session is the mount itself: +`m=$NINE_MOUNT`. In a `Tty9p` shell: `m=$PARDES_MOUNT`. A dead session's +entry answers `Input/output error`: name the session, never glob. A running +session serves the binary that started it. -Entries for dead sessions stay listed and answer `Input/output error` on any -access, so name the session you mean rather than globbing or taking the newest. -`cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports -the `pid`, `version` and `panes` of a session new enough to serve it. +## Look around -`$NINE_MOUNT/pardes` is the registry of posted sessions, mounted once for the -machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which -gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md) -for that. Either mountpoint serves the same tree. A `9ns --unix SOCKET -- cmd` -mount exists only inside `cmd`'s private namespace, and there `$NINE_MOUNT` is -the session's own root (`$NINE_MOUNT/index`), not a registry; to share one, -use the registry (`9ns --mntgen`). +```sh +cat "$m/index" # serial kind dirty name column, a pane a line +cat "$m/layout" # columns, then `active <serial>` +cat "$m/focus" # the pane with the keyboard +cat "$m/README" # the one-screen guide +cat "$m/commands" # every builtin and where it goes +``` -A running session serves whatever binary started it. If a listing does not -match this document, that session predates the change; restart it. +Parse an index row with `rsplit(maxsplit=1)` first: names may hold blanks. +Re-read the index after anything that opens or closes panes. -## The tree, and what to do with it +## Open a file, go to a place +```sh +echo "$PWD/main.zig:120" > "$m/look"; cat "$m/look" # serial it landed in +echo "main.zig:0/fn main/" > "$m/look" # first match; without 0, the next after dot ``` -$m/README the served guide, worth reading first -$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial -$m/status pid, version, panes -$m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/ - select by address, as acme's look, from the file's dot: file:0/re/ for the - first match; file:N selects the line; a miss changes nothing, logs err, and - look reads back empty) -$m/exec write a line = a middle click: an editor command word, or a shell line -$m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open: - exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 - (a fresh terminal: waits for its first prompt; if none comes, interrupt the read) -$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>, - run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>, - ask <serial> <what> <choices>, answer <serial> <choice|->, changed <serial> - [reloaded|deleted] (under unsaved edits; a clean reload; gone from disk), - unsaved <serial> <name> (each pane a refusal is about), newcol|delcol <serial>, restored|restoredcol <old> <new>, - dump|restore <path>, err <serial|-> <file>: <why> - (exec 3<>$m/log; echo follow >&3; cat <&3 replays what is there, then waits for new - ones; `echo follow new` waits for new ones only; tail -f does not wait; - while read -r line <&3; do ...; done loses nothing within a session (through - FUSE, `read -t` never times out: use `timeout N` around the loop): a Restore - hangs every connection up, so dial again, and restart a 9ns mount) -$m/screen the rendered screen as JSON, frozen per open -$m/listeners this session's dial addresses -$m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it); - echo a serial into it to move the keyboard (a folded pane stays folded) -$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin; - `size <cols> <rows>` sizes a --detach session no frontend is attached to (160x50 before) - (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the - commands pardes started (command panes, lines it typed into shells), a word - matching a command line's first word; Exit and Restore refuse once, - logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with - `<name>: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit - again to discard)` for more (Restore's says `Restore again`); on screen the list stays - in the one +Unsaved pane under a short notice (`3 unsaved panes — Exit again to discard`); a second refusal names only the panes edited since the last, - as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`; - Kill stops a command pane's whole line, `&` jobs included; of a line typed into a - shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done` - bash and fish both run the echo); Joincol folds the keyboard's column into the one - on its right, its panes going below that column's own, in order, and needs such a column); - a pane's builtins (Del, Save f, Collapse, which folds that pane, and the - column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl -$m/commands every builtin: `Word`, `Word arg`, then `root`, `pane` or `both` (which ctl takes - it), a setting's values comma-joined, then ` -- ` and one sentence of what it does -$m/recent files opened lately, closed ones too, most recent first: `open|closed <path>`; - lost a file you closed? read this, or run Recent and look at its row to reopen it -$m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial> -$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do) -$m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec - file reads back the serials its last write touched -$m/col/<serial>/ctl Delcol, Joincol, New, Tty on that column; col/<serial>/exec a word as a click in its tag; - rmdir col/<serial> closes an empty column -$m/pane/new open it to make a pane (a scratch named <dir>/+New), read names it; it goes in - the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if - empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules); - a session holds 64 panes (16 on the board): past that, every route that opens one - fails with `no space for a pane: 64 max` (ENOSPC), and so does one whose column - has no room (each pane keeps its tag and 2 rows: `no space for a pane in that column`); rmdir $m/pane/<n> closes it; a column's last pane leaves the column EMPTY - (focus reads empty, the log says only del), and the session's LAST pane QUITS it -$m/pane/<n>/errors write-only: text appended to the +Errors pane of the pane's directory -$m/os/ the host filesystem -``` -The first field of an index row is a stable pane serial, not a slot or row -number. A row is `serial kind dirty name column`, the last word the pane's -column serial, so `head, col = row.rsplit(maxsplit=1)` then -`serial, kind, dirty, name = head.split(maxsplit=3)` keeps a name with spaces -intact. Re-read the index after anything that might open, -reuse or close a pane. +A miss opens nothing, reads back empty and logs an `err <serial> look: ...` +line; the write itself succeeds. `L:C` columns are bytes. + +## Make, fill, name and save a pane ```sh -cat "$m/index" # which panes exist -n=$(cat "$m/pane/new") # make one, take its serial -printf 'text\n' > "$m/pane/$n/body" # replace its text (> truncates) -printf 'more\n' >> "$m/pane/$n/body" # append -cat "$m/pane/$n/tag" # what its tagline offers -echo notes.txt > "$m/pane/$n/name" # rename the buffer -echo Save > "$m/pane/$n/exec" # save it (or to its ctl) -echo Tty > "$m/pane/$n/ctl" # a terminal in its directory -echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it landed -rmdir "$m/pane/$n" # close it, dirty or not +n=$(cat "$m/pane/new") # each open makes one: read it once +printf 'hello\n' > "$m/pane/$n/body" # > replaces the text +printf 'more\n' >> "$m/pane/$n/body" # >> appends +echo "$PWD/notes.txt" > "$m/pane/$n/name" +echo Save > "$m/pane/$n/ctl" +rmdir "$m/pane/$n" # close it, saved or not ``` -A session may open its own mount from inside itself: a Look at `$m/anything` -in the editor that serves `$m` is answered on the connection's task while the -editor's own syscall waits. `/n/self/...` names the same tree without leaving -the process. +## Edit text -**Opening** `$m/pane/new` is what makes a pane, and reading the open file -answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one, -two reads of the same open file answer the same serial, and closing it leaves -the pane. A pane is named by the serial the editor gives it, never by a name -you choose. - -A *stat* makes nothing, which is the whole reason the allocation sits on open: -`new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and -anything else that stats every name a listing handed it stay inert. acme -allocates on the walk instead and lets it land inside the new window, so -`/dev/new/body` works in one step — it can afford that because a Plan 9 -directory read carries every entry's stat and nothing walks. Under a kernel or -FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be -created or removed, and no read creates anything. +```sh +p=$m/pane/$n +echo 'Edit ,x/foo/c/bar/' > $p/ctl # sam edit, one undo step +echo '/old/' > $p/addr; printf 'new' > $p/data # replace the next match +echo 0 > $p/addr # back to the top +echo '3' > $p/addr; : > $p/data # delete line 3 +cp $p/addr $p/dot; cat $p/sel # select it, read the selection +``` -`look` and `exec` are the editor's two clicks, one per line of a write, at the -active pane from the root and at that pane from `$m/pane/<n>/look` and -`$m/pane/<n>/exec`. Reading any of them answers the serials the last command -made, or the pane it focused or acted on: an open's own last write's, or, on -an open that never wrote, the session's last. With other clients about, -write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`). -A read is a stream: once read, a second read on the same fd gives EOF (on -an open that wrote, until its next write); open again, or seek to 0, to read -it again. Each command line runs once whole, however a mount cuts a big write; a last -line with no newline runs when the open closes, and an Edit block still open -then fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.` -line), changing nothing. One rule: a builtin that fails, whether through a -ctl (a pane's pty/ctl `exec` too), look, exec, tagexec or a column's exec, -fails the write (EINVAL for a malformed line, ENOENT for what is not there -- -a directory gone, a Find or Grep with no hit -- else EIO or an errno that -fits) and logs one `err` with the reason, no `msg`. A look that finds nothing is no failure: it answers -nothing and logs one `err`. A command line run in a command pane is judged -by its `exit` record. A word no builtin -knows (a typo included) is a command line: written at a terminal at its -prompt it is typed into that shell; from anywhere else it runs as a command -pane, a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) -running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run -<serial> <line>` and `exit <serial> <N|?>` -- `exec` reads back its serial, -so follow `log` for the exit. The directory's next command reuses a finished -command pane, below what it showed. A terminal bound as a REPL (`Repl -python` on its ctl) takes the middle clicks made on a `.py` body instead, -but never a 9P write: a script sends code by writing the REPL pane's -`pty/data`, and runs a command from such a file with `Exec <text>` or the -tag. A bound terminal's `ctl` line ends with its id (`python-a`); an event -record written back from such a file's body goes to its REPL, as the click -would. `Repl -` unbinds; a bare `Repl` says the binding. With several -REPLs bound for a language an exec asks which, logged `ask <serial> repl a b` -(Del's side from the keyboard is `ask <serial> del k j`): answer with -`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing (the -log says `answer <serial> a|-`). Kill does not stop what a REPL runs (it was -typed, not started by pardes): `echo 'sig INT' > $m/pane/<repl>/pty/ctl` -interrupts it. A range of a `.py` pane goes to its REPL by writing the event -record `MX<q0> <q1>` to that pane's `event`. A chorded exec's record (flag 8) -is followed by two, its argument and where it came from; write all three back -as read (one write or three) and it runs once with its argument. `Tty`'s argument is a shell -(`Tty fish`), and `Tty` on a pane's ctl opens a new terminal pane. After a -Restore, panes have new serials (`restored <old> <new>` in the log), a -command pane shows how it ended, `exit N` (`exit ?` if it was still running when -dumped), and does not run again, and REPLs are unbound. Multi-line code -written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~`, -then, in a separate write once the REPL has echoed the paste (Python 3.13+ -takes a `\r` read with the paste as part of it, even for one line), `\r`. -A paste of one line needs that one `\r`; a paste of more than one line -needs a second `\r` unless the pasted code ends in a newline (one Enter -leaves a multi-line input at `...`). A middle click, or an event -write-back, sends what it needs by itself. Sent line by line, a blank line ends a Python block, and Python -3.14's REPL auto-indents each line it is typed. Every refused 9P write adds an `err <serial|-> -<file>: <why>` record to `$m/log`; through a mount the write itself only says -`Invalid argument`. +`addr` searches on from the last address (it is the pane's, shared by all +clients); after a `data` write it sits just past the text, so write `addr` +before each replacement. A failed `addr` leaves none and `data` refuses: +check the write's status. An Edit block of several lines goes in one open: +`printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl`. -## Edit through addresses, dot and the flag files +## Run a command -For a file or scratch pane, with `pane=$m/pane/<serial>`: +```sh +echo 'make test' > "$m/exec"; c=$(cat "$m/exec") # a command pane; its serial +grep "^exit $c " "$m/log" | tail -1 # `exit <serial> <N>` once done +``` -Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/' -> $pane/ctl`. `Edit` takes sam's command language (acme's): addresses, `x y g -v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from -exec or a tag, not a one-line ctl write). Its changes are one undo step, and -one that fails changes nothing and fails the write with acme's words (`Edit: -no substitution`), logged as `err`; an `x` that finds nothing is no failure -(sam's), so `,x/zzz/c/bar/` with no `zzz` succeeds silently. A block goes to the pane's `ctl`, the -root `ctl` (the active pane) or `exec` on one open, in one write or several -(bash's `printf` writes line by line): an `Edit` line takes the lines after it -until its `{` closes or its `a`/`c`/`i` text ends with `.`, and runs then. `sel` -reads the selected text, and a write to it replaces the selection: +At a terminal's prompt, run and get the status and output in one go: ```sh -cat > $pane/ctl <<'END' -Edit ,x/area_of/{ -i/[/ -a/]/ -} -END -env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl +t=$(awk '$2=="term"{print $1; exit}' "$m/index") +exec 3<>"$m/pane/$t/pty/run"; echo 'ls' >&3; cat <&3; exec 3<&- # `exit N`, then output +printf 'q' > "$m/pane/$t/pty/data" # raw keystrokes (\r Enter, \x03 Ctrl-C) +echo 'sig INT' > "$m/pane/$t/pty/ctl" # interrupt ``` - `p` and `=` print to the directory's -`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`. - -> **sam gotchas** (as sam does them, pardes too) -> - `$-1` is the last line when the text ends in a newline (`a\nb\n`: `b\n`), -> but the one before it when it does not (`a\nb`: `$` is on `b`, so `a\n`). -> - With a final newline, the line after the last is the empty place at the -> end, not an error: `N+1` at the last line is `#<len>,#<len>`. Without one -> (`a\nb`), the last line runs to the end and `N+1` is out of range. -> - `^` and `$` match at the text's end too: `/^/` from the end finds the -> empty place after a final newline. -> - `2,1` is no error: it is `#<start of 2>,#<end of 1>`, an empty range at -> line 2's start (only a range whose end is before its start is refused). -> - `y` yields the stretch before the first match too, empty if the text -> starts with one: `,y/a/` on `abc` gives `` and `bc`. -> - `c/&/` puts a literal `&`; only `s` expands `&` to the match. -> - `line:col` columns count bytes from 1: `12:0` is refused -> (`a column counts from 1`); a tool's character column is the same only -> on an ASCII line. -| Operation | Shell | Python client | -|---|---|---| -| Read text | `cat $pane/body` | `client.read(pane + '/body')` | -| Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` | -| Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` | -| Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` | -| Replace that range | `printf 'pub fn' > $pane/data` (`>>` too) | `client.write(pane + '/data', b'pub fn')` | -| Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) | +`busy: <program> is running` means the prompt is not free: a program holds +the terminal, so talk to it through `pty/data`. -A write leaves `addr` just past what it wrote, so a second `echo x > data` -inserts after the first: write `addr` again before each replacement. The -writes of one open of `data` are one undo step (a multi-line `printf` too); -to make a loop's writes one, `echo 1 > mark` -(an undo point now), `echo 0 > mark`, the writes, then `echo 1 > mark`. -A `+New` scratch counts as unsaved (and holds up Exit, Restore, Del) only -once it holds 100 bytes or more. A read -of `data` or `xdata` moves `addr` past what it read, as in acme. -Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts). -| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads | -| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` | -| Reload from disk | `echo get > $pane/ctl` (refused once while there are unsaved edits; again discards) | `client.write(pane + '/ctl', b'get\n')` | -| Close it | `rmdir $pane` | `client.remove(pane)` | +## Follow what happens -`addr`, `dot` and `limit` each read the pair of offsets they also accept, which -is why copying one onto another is all that acme's `addr=dot`, `dot=addr` and -`limit=addr` ever were; a write may also be an address expression (`#0,#5`, -`/pattern/`, `2+1`, or pardes's `12:5`: line 12, byte column 5, composing as -`12:5,14:1`; a Recent, +Search or Jumplist row's `12:5-14:2` is taken too, -through 14:2 inclusive), whose regexps are mvzr's searched as sam searches: -`^`/`$` match at any line's start and end, `.` and `[^...]` never match a -newline, the leftmost match wins (the first alternative there, not the -longest). In a pattern with `\n`, `^` works only first (`^def .*\n` finds -every def line) and `$` only just before a `\n`; anywhere else the pattern is -refused, not silently unmatched. `^def|^ ` finds lines starting either way -(every branch anchored; a mix like `^def|x` is refused). An expression is evaluated from the current address (the last one -written, or just past the last `data` write): `.` is that address, not the -selection, `/re/` searches on from its end and wraps unless `limit` is set, -`?re?` or `-/re/` searches back, `#100,#50` fails `addresses out of order`, -and a search that backtracks -past a step budget (about 300 ms) fails with `regular expression search -gave up, ...`. A failed address says -why (`no match for regexp`, `address out of range`) and leaves no address: -`addr` reads empty and `data` refuses until the next good one, so a missed target is never -written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds -only the end of a forward search, as in acme, and reads empty until set; -truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps -past the end of the line; a line past the end is `address out of range`. -A refused write repeated the same way is one `err` line counted, `(x4)`. +```sh +exec 3<>"$m/log"; echo 'follow new' >&3 +timeout 30 cat <&3 # one record a line, as they come +exec 3<&- +``` -`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the -buffer differs from its file, whether a write pushes an undo point, and whether -a write scrolls. `tag` reads the path, then the text you may edit: `> tag` -replaces that text (default words too), `>> tag` appends to it. +`follow` (without `new`) replays the ring first. `tail -f` sees nothing new. +Through FUSE `read -t` never times out: use `timeout`. After a `Restore` +every connection is cut: dial again and restart the mount. -Address state belongs to the pane, not to a client: it keeps the last range -written until someone writes another, so writing an address and reading it back -evaluates it, and two clients addressing the same pane will interfere. Neither -an open nor a `>` resets it (acme resets it on the first open): each `echo /re/ -> addr` searches on from the last address, so a find-and-replace loop advances. -Write `0` to start from the top. The search wraps, so stop a find-all loop when -the address comes back to where it began, or set `limit`. -`$pane/ctl` reads acme's window status line — serial, tag length, body length, -a reserved zero, the dirty flag, the width in cells, the font and the tab -width, then `current` or `notcurrent` — and takes `get` (reload from disk), -`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`, -`Collapse`, `Undo`/`Redo`: 256 steps; with none left they say so, and the -write succeeds). A `lock` another open holds fails at once with `file in use` -(EBUSY): retry it. Session builtins and settings go to the root `ctl`, which reads -back every setting in the syntax it takes. A ctl write is checked whole -first and refused as `unknown control message "X"` (EINVAL) and the like, -a required argument missing included (`wrong #args ... "Mount"`); then a line -whose builtin reports an error fails the write with that error and the line -(EIO, or an errno that fits: ENOENT for a missing pane, file or dump), after the lines before it took effect. A `Save` on a scratch fails -rather than prompt. The lock binds only clients that take it, and is held by the -open that wrote it, so a shell holds an fd across the edit: -`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`. +## Traps -Terminal panes have no file: writing their `body` sends child input, and -truncation does not erase terminal history. +- A refused write says only `Invalid argument` or `Input/output error`; the + reason is the `err` line in `$m/log` (`tail -1 "$m/log"`). +- Only writes log: a refused open, truncation (`> data` after a failed + `addr`) or `rmdir` has its errno alone. +- Settings and session words go to `$m/ctl`; pane words (`Undo`, `Save`, + `Del`) to `$m/pane/<n>/ctl`. The wrong one is refused naming the right one. +- `Exit`, `Restore`, `Del`, `get` refuse once over unsaved text; the same word + again discards. +- A word no builtin knows is a shell command (`exit 127` if a typo). +- `lock` needs a held fd: `exec 3>$p/ctl; echo lock >&3; ...; exec 3>&-`. +- plan9port `9p write` truncates: it replaces a whole `body`. -## The Python client, for what a shell cannot express +## No mount: the Python client -Use the existing [Python client](../../../test/ninep.py) when there is no -mount, or when the work needs a fid held open across several operations — -`log`, `event` and `pty/data` are consuming queues whose reads park, and shell -redirection cannot hold one open. +Use [test/ninep.py](../../../test/ninep.py) when nothing is mounted, or when +a fid must stay open (`event`, `pty/data`, a followed `log`). Its paths are +the served root (`/index`, `/pane/2/body`). ```sh PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' import sys from ninep import Client - -with Client(sys.argv[1]) as client: - print(client.read('/index').decode(), end='') - print(client.read('/listeners').decode(), end='') +with Client(sys.argv[1]) as c: + print(c.read('/index').decode(), end='') + n = int(c.read('/pane/new')) + c.write(f'/pane/{n}/body', b'hello\n', truncate=True) + fid = c.open(f'/pane/{n}/event', 0) # hold event: clicks come here + c.write(f'/pane/{n}/exec', b'Msg hi\n') + print(c.read_fid(fid, 0, 4096)) # b'FX0 0 1 6 Msg hi\n' + c.close(fid) + c.remove(f'/pane/{n}') PY ``` -`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does -not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a -five-second socket timeout, and closes on leaving `with`. Its paths are the -served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer` -and `/virtual` are editor Look paths, not server paths — Look -`/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`. - -For live terminal output or plugin events, use `open` / `read_fid` / `close`, -not the read-until-EOF helper. `pty/data` captures output while held open; it -is not a history replay. Both files are shared, consuming queues, not -per-client broadcasts, so a slow reader loses older data. Holding `event` open -intercepts that pane's Look and Exec clicks -- and lines written to that pane's -own `look`/`exec`, or to the root's while it has the keyboard, as `F` records -at `0 0` with the text, and clicks in a terminal's body, also at `0 0` -- so -it is not a passive logger: a helper holding `event` that writes its own -pane's exec gets its command back as a record; run it through `ctl` or write -the record back. To have a record done, write it back: the short form -`<origin><action><q0> <q1>\n` acts on that range's text, and the whole record -as read acts on its text when the range is empty (the only way for a record -at `0 0`). Chord reports need explicit handling. A record is -`<origin><action><q0> <q1> <flag> <n> <text>\n` and its text may hold -newlines: read `n` bytes of text, never up to the next newline (acme counts -runes; pardes counts bytes, as all its offsets are). Every address lands on a -rune boundary, never inside one and never widened to a grapheme cluster: `#n` -or `line:col` inside a rune snaps back to its start, a match covers the runes -it touches, and a combining mark or a CRLF's `\r` is addressable alone. -A Restore puts a new editor under every client: the Restore write is -answered, then every connection is hung up (their fids name the old -editor's panes); dial again, and the new log has a `new` for each restored pane, then -`restore <path>`, then `restored <old> <new>` for each pane and -`restoredcol <old> <new>` for each column -- `restore <path>` the authority, since a slow client may see the cut -before the answer. A 9ns older than cloud9 2a7137c could fail the Restore -write with ECONNRESET although the Restore went ahead; trust the log. `Dump` writes `pardes-<date>-<time>.zon`, the time in UTC, under -`DumpDir` (`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) and logs -`dump <path>`. A relative `Restore <path>` is looked for in `DumpDir` first, -then in the directory pardes started in; a bare `Restore` takes the last dump -this session wrote (none yet: refused, name one). -Read the event implementation before building an interceptor. Close handles in -`finally`, and disconnect after a socket timeout. The service shares sixteen -connection slots (the next client's version gets `too many connections`) -and 32 screen/terminal-history snapshot handles. - -A file's qid version is the pane's revision for `body`, `data` and `xdata`, so -`stat` sees an edit land without reading the text; it stays zero elsewhere. -Stat sizes are real: for `event` and `pty/data` the length of the record a read -would answer (zero when nothing is waiting), for `log` the whole ring, the text -an open would freeze now. - -## Terminal input and screen observations +`Client` takes a Unix socket path or `(ip, port)`; `client.screen()` returns +the parsed `/screen`. For `event` records and writing them back, see +[fs.md#event](../../../docs/fs.md#event). -Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not -`body`: `client.write(pane + '/pty/data', b'printf hello\r')` submits a shell -command. For an interactive application, send its actual input bytes; `b'\x03'` -is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go -to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`, -`sig INT` and `exec` (restart the shell; a directory that is gone fails ENOENT), one per line. `pty/status` reads one line: the pty's cols, -rows and busy (1 while a command runs or text is typed at the prompt). +## An isolated session -`client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major -`cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve -each cell's style through `styles` when checking highlighting, and compare -colors and attributes rather than style-table indices or flattened text. - -Each screen open freezes one frame, and a terminal `body` freezes its history -on its first read. `client.read` and `client.screen` reopen each time, so -repeat the call for a fresh observation rather than polling a stale handle. -Poll a specific condition with a deadline and a short delay, not a fixed long -sleep. For large histories, measure whole-body reads separately from screen -polling; write-to-observation timing includes RPC, rendering and polling. - -## Exercise an isolated session - -Reuse [test/fs.py](../../../test/fs.py), which starts a private session with -temporary configuration and cleans up its editor process. Pass an existing -native binary, not a benchmark executable: +Never experiment on a session someone is using. Build a binary with +`zig build install -Dplatform=tty` (a bare `zig build`, or `--prefix +zig-out`, installs over `~/.local/bin`). `test/fs.py` starts a private +session and cleans it up: ```sh -PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY' +PYTHONPATH=test python3 -B - "$(realpath zig-out/bin/pardes)" <<'PY' +import sys, tempfile from pathlib import Path -import sys -import tempfile from fs import session, new_pane, execute - -binary = str(Path(sys.argv[1]).resolve()) -with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory: - with session(binary, Path(directory), 'skill') as (client, address): - serial = new_pane(client, b'fn main() void {}\n') - pane = f'/pane/{serial}' - client.write(pane + '/name', b'probe.zig\n') - client.write(pane + '/addr', b'#0,#2') - client.write(pane + '/data', b'pub fn') - assert client.read(pane + '/body') == b'pub fn main() void {}\n' - execute(client, serial, 'Msg 9p-ready') - frame = client.screen() - assert '9p-ready' in ''.join(cell[0] for cell in frame['cells']) - print('9P edit, builtin and screen checks passed') +with tempfile.TemporaryDirectory(prefix='pardes-9p-') as d: + with session(sys.argv[1], Path(d), 'probe') as (client, address): + n = new_pane(client, b'fn main() void {}\n') + execute(client, n, 'Msg ready') + assert 'ready' in ''.join(c[0] for c in client.screen()['cells']) PY ``` -`new_pane` opens `/pane/new` and reads the serial it answers; `execute` writes -one line to a pane's `exec`. Both are in -`test/fs.py`. For terminal tests, use -`session(..., tty=True)` and read -[test/agent_session.py](../../../test/agent_session.py) for bounded interactive -driving; its readiness text and history threshold are application-specific, and -session cleanup alone does not guarantee arbitrary grandchildren have exited. -Use [test/snapshot.zig](../../../test/snapshot.zig) when editor key/mouse input -or an independent terminal-rendering comparison matters; 9P screen inspection -alone does not test physical input routing or the host renderer. - -Read [docs/fs.md](../../../docs/fs.md) for runtime mounts, TCP/QUIC listeners, -Plan9port and Linux v9fs compatibility. Unix is always available; network -listeners are opt-in and grant full session and OS-file access, so use isolated -loopback listeners for tests. For event details or anything this page leaves -open, read the implementation and its tests in -[src/ninep/](../../../src/ninep/). +By hand: `pardes --detach=NAME &` with its own `HOME` and `XDG_*` +directories, then `9ns --mntgen` (the session is `$NINE_MOUNT/pardes/NAME`) +or `9ns --unix $XDG_RUNTIME_DIR/pardes-9p-NAME.sock -- sh`. Strip every +`PARDES_*` variable first, or a file argument goes to the session you are +inside. `session(..., tty=True)` and +[test/agent_session.py](../../../test/agent_session.py) drive terminals. @@ -10,31 +10,16 @@ One core, five frontends. The core owns editing, layout, rendering, and the filesystem namespace. Frontends translate native input into `pardes.Event`, present `pardes.Surface`, and perform host effects such as spawning processes. -Fifteen [native Pardes themes](docs/themes.md) coordinate the editor, search, -diagnostics and embedded terminal: `orchard` (the near-black default), `dusk`, -`ink`, `paper`, `daybreak`, and the Acme-inspired `atelier`. `ink` and -`daybreak` provide high contrast dark and light choices. Six classic-inspired -adaptations add `forge`, `lagoon`, `solarium`, `spectrum`, `harvest`, and `clay`. -`forge_black` and `orchard_black` offer pure-black variations; `forge_soft` -offers a deliberately softer contrast. Execute `Themes` to choose native -themes first, followed by the existing legacy/imported collection. `FocusTint` toggles -the active pane and column tag tints (on by default), and `SyntaxBold` toggles bold -syntax keywords (off by default), in both GUI and TTY. +Every session serves its panes, columns and tags as a 9P control filesystem, +as acme does ([docs/fs.md](docs/fs.md)). `pardes FILE` run in a pane opens +FILE in that session; `EDITOR='pardes --wait'` makes it your editor +([forwarding](docs/fs.md#connecting)). `Tty9p` opens a terminal with the +tree mounted ([docs/v9fs.md](docs/v9fs.md)). -SDL also supports `Font <name>:<size>` and optional pixel companions in the -unused workspace tag: `Pet cat`, `Pet frog`, or `Pet off`. - -`Mini path` opens a braille minimap with syntax colors: two text columns by four -lines per cell. It reads through the normal filesystem namespace; run it again -to refresh. Mini snapshots survive Dump/Restore without rereading the source. - -On Linux, `Tty9p` (`SPC n 9`) opens a terminal with the session's 9P tree -mounted through kernel v9fs. It asks sudo in that pane, then starts your shell -as your normal user. Access the tree through `$PARDES_MOUNT`. -See [mounted terminals](docs/v9fs.md). Beyond acme's per-window files the tree -serves the layout: `/layout` lists the columns, their places and panes, and -`/tag` and `/col/<serial>/tag` are the workspace and column tags, editable like a -window's ([the tree](docs/fs.md#the-served-tree)). +Fifteen [native themes](docs/themes.md) lead a ring of ports and imports; +`Themes` lists them. `Recent` (`SPC f r`) lists files opened lately, closed +ones too, and reopens one at its last place. `Mini path` opens a braille +minimap. Settings and the startup file are in [docs/config.md](docs/config.md). ## Requirements @@ -43,7 +28,7 @@ fetched and pinned by the manifest; no system package is required for the terminal build. The SDL shell builds SDL3 and FreeType from source. Native PDF support builds MuPDF and is on by default (`-Dmupdf=false` to drop it). Optional 9P-over-QUIC support (`-Dquic=true`) uses system OpenSSL 3.6+ and -pkg-config. The default Unix socket and optional TCP transport do not need it. +pkg-config. ## Build @@ -51,76 +36,42 @@ pkg-config. The default Unix socket and optional TCP transport do not need it. zig build ``` -That is the whole of it, and it is an *install*: it builds both native shells and -puts them in `~/.local/bin`. - -``` -~/.local/bin/pardes the terminal shell (libvaxis) -~/.local/bin/pardes-gui the SDL3 window -``` - -Override with `--prefix <dir>`. Test, benchmark and run steps build what they -need without installing development binaries. +A bare `zig build` builds both native shells and **installs** them into +`~/.local/bin` (`pardes`, the terminal shell; `pardes-gui`, the SDL window; +`pardes-v9fs`, the Tty9p helper). `--prefix <dir>` installs elsewhere, except +`--prefix zig-out`, which counts as no prefix. With `-Dplatform=<shell>` the +default prefix is `zig-out`. Test, benchmark and run steps build what they +need without installing. -``` -pardes --version e.g. pardes 0.0.1 (e61bbb2e86bd) -pardes --help every flag -``` - -The version comes from `build.zig.zon`'s `.version`; the commit is read from -`git` at configure time and is simply absent when there is no repository to ask. - -## The five platforms +`pardes --version` prints `pardes <version>`, plus the commit for release +builds and the `~/.local` install (`-Dstamp-commit=true` forces it). +`pardes --help` lists every flag. | build | what it is | |---|---| | `zig build` | the terminal shell and the SDL window, together | | `zig build -Dplatform=tty` | the terminal shell alone | | `zig build -Dplatform=gui` | the SDL3 window alone | -| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript, rendered as HTML/CSS | -| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` | +| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript ([docs/web.md](docs/web.md)) | +| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` ([docs/macos.md](docs/macos.md)) | | `zig build -Dplatform=esp32p4` | a freestanding riscv32 editor object for ESP32-P4, using a 384 KiB heap | Firmware images are built in the sibling `../05-zig-p4` toolchain, which needs ESP-IDF register headers. After building the editor object here, `zig build -Dpardes` there links `src/esp32p4/app.zig`. The separate GPIO 9P image uses -`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; it does not link the -editor. Its fixed GPIO namespace is in `src/esp32p4_gpio.zig`. +`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; its fixed GPIO +namespace is in `src/esp32p4_gpio.zig`. ## Detached sessions -A shell need not be in the same process as the core. - ``` pardes --detach=work a core with no terminal of its own pardes --attach=work become a frontend of it pardes-gui --attach=work ...the SDL window can attach too ``` -Several frontends may be attached at once and all see the same screen. The -detached core performs every effect that needs a disk or a process table, so its -pane shells outlive every frontend; a frontend keeps only what needs the human's -own display. From inside the editor, `Attach` and `Detach` do the same thing as -words. See `docs/detached.md`. - -## Recent files - -A file closed by accident is found again. `Recent` (`SPC f r`) lists the -files opened lately, most recent first, closed ones too, each marked `open` -or `closed` at the place its dot was when it closed: - -``` -/home/me/src/main.zig:120:5 closed -/home/me/notes.txt open -``` - -A look at a row opens that file there. The list keeps 200 files, each once, -and lasts across sessions in `$XDG_STATE_HOME/pardes/recent` (else -`~/.local/state/pardes/recent`). The jumplist keeps a closed file's entries -too: `Back` to one opens the file again at its place, and `+Jumps` marks it -`(closed)`. Over 9P, `/recent` reads `open <path>` or `closed <path>` a line. -acme has nothing like it (its dump and Load are the nearest); pardes goes -beyond acme here. +The detached core owns panes, shells and files; frontends come and go. See +[docs/detached.md](docs/detached.md). ## Tests @@ -159,130 +110,66 @@ zig build web-driver-test browser driver timeouts and cleanup zig build web-e2e Chrome-driven DOM end-to-end suite ``` -`snap -- --record=/tmp/captures` writes independent snapshots for comparing -binaries without changing goldens. `snap -- --update` replaces goldens; -`snap -- --no-retry` makes the first failure decisive. Retried runs retain -each attempt's report and mismatching grid in a fresh printed directory. -For custom differential cases, use -`zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]`. -Without a reference, the driver only emits results; exit zero does not mean a -comparison passed. Custom arguments must include `--strict` to check coverage. -The benchmark steps — `perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`, -`lspbench`, `fs-bench` — all accept `-- --json`. - -Snapshot scripts can use `snap9p` to capture core cells and styles through the -default socket alongside the terminal emulator's independent captures. - -`zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] [--keep]` -writes one random script per seed (keys, leader bursts, typing, clicks, drags, -wheel, tiny resizes, prompts and long prompt text over bad-UTF-8 and wide-glyph -files), runs it through pardes-snap and looks for a panic's `.zig:N:N: 0x` -frame in the report and in the captured grid. A hit keeps the script, its -captures and its log in DIR (default `/tmp/pardes-monkey`); a seed always makes -the same script, so `--from=N` with one seed replays it. A trace longer than -the grid scrolls away: replay the kept script at a larger `start` size. Each -crash found gets a fix and a regression script in `test/snapshots/`. The tty's -tagline is the body's pitch, so what a narrower tagline does is fuzzed by a -unit test instead (`a monkey over notices…` in `src/draw.zig`, longer with -`PARDES_FUZZ_SEED`/`PARDES_FUZZ_STEPS`). - -`-Dtest-filter=<text>` applies to every unit-test binary. Use -`-Doptimize=ReleaseFast` for performance measurements. To record output and -first/warm command runtimes across revisions, run: - -``` -jj status -zig build history -- run 'ancestors(@, 2)' /tmp/pardes-history -- zig build unit-test -zig build history -- compare /tmp/pardes-history/<before>.json /tmp/pardes-history/<after>.json 1.20 -``` - -The history runner uses `jj run` serially, with recordings outside its isolated -checkouts without integrating history changes. Run `jj status` before measuring -`@`: the runner deliberately does not snapshot pending edits. `history record` -measures current files directly. Add `run --allow-immutable` -to measure immutable revisions. Each command runs four times; the first run is reported separately -from the warm median. Standard output and errors are preserved for every run. -Commands must exist in the selected revisions. The optional comparison ratio -fails the command when runtime regresses beyond that limit. Add `--snapshots` -to require stable, identical stdout and stderr, or `--benchmarks` to compare -individual `syntax-bench` cases, including allocation counts; the ratio limit -also applies to each case's cold and median time, using medians across the last -three processes. First-process cold times are reported separately, not gated. -All four captures must contain the same cases and input sizes. Old revisions -need the same harness and uncached test execution for meaningful comparisons. -The recorded Zig version identifies the recorder's compiler; record the child -toolchain separately when comparing different compilers. -Recordings are exclusive: use a fresh output directory to repeat a measurement. -Existing results and partial runs are never overwritten. - -`perf -- --base old.json` requires matching build metadata, harness, viewport, -repetition count, fixtures, and `--only` selection. Reports identify the compiler, -resolved target/CPU, driver/core/dependency optimization modes, and feature flags. -Missing or different metadata is rejected; old reports must be regenerated. -JSON reports omit unmeasured cells. Gesture checks run -outside the timed interval, and each process owns and removes its fixture directory. -Hardware, machine load and runtime library versions must be matched separately. - -`zig build perf -Dplatform=tty -Doptimize=ReleaseFast -Dtree-sitter=zig -- --mini --json` -measures braille generation, full highlighting plus generation, and cached Mini -redraws separately, checking output hashes and allocation balance. - -`zig build perf -Doptimize=ReleaseFast -- --terminal-mib 64 --json` streams -colored, wrapped agent-like output past the terminal history limit. It checks -the newest text, colors and input, and measures raw redraws, modal movement, -edit-overlay redraws and memory. Linux RSS comes from the current process image's -`VmHWM`; allocator counts exclude the terminal library's private mappings. - -`python3 -B test/agent_session.py zig-out/bin/pardes --ready 'ready text' --min-rows 20000 -- command args` -runs a caller-selected interactive command in a private POSIX shell and checks -its history and an unsubmitted input probe through 9P. Use an owned copy of any -saved conversation. -Reports contain counts, hashes and 9P observation times, not conversation text -or physical keyboard latency. GUI builds need `--gui-grid`. - -`test-build -Dtest-rebuild` forces fresh Zig test compilation while retaining -cached C dependencies. Use a disposable local cache for repeated cold-build -experiments. Ordinary `unit-test` always runs its tests, even with cached builds. -`unit-profile` measures the request-to-result interval, including test-runner -communication, and reports whole-process time and memory separately. -Filtered test steps fail if no named test matches, even when import guards pass. - -The default differential suites require exact case coverage and reject stale -waivers. Each waiver pins both the reference and editor result. Live Helix -steps use `-Dhelix-harness=<path>`, `HX_HARNESS`, or `hx-harness` on PATH. +- `-Dtest-filter=<text>` applies to every unit-test binary; a filter that + matches nothing fails. `-Dtest-rebuild` forces fresh Zig test compilation. +- `snap -- --record=DIR` writes snapshots without touching goldens, + `snap -- --update` replaces goldens, `snap -- --no-retry` makes the first + failure decisive. Snapshot scripts can use `snap9p` to capture core cells + through 9P. +- `zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] + [--keep]` writes one random script per seed, runs it through pardes-snap + and keeps any script that panics (in `/tmp/pardes-monkey` by default); a + seed always makes the same script. Each crash found gets a fix and a + regression script in `test/snapshots/`. +- `zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]` + runs custom differential cases; without `--strict` and a reference it only + emits results. Live helix steps take `-Dhelix-harness=<path>`, + `HX_HARNESS`, or `hx-harness` on PATH. +- Benchmarks (`perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`, + `lspbench`, `fs-bench`) accept `-- --json`; measure with + `-Doptimize=ReleaseFast`. `perf -- --base old.json` refuses reports whose + build metadata differs. +- `zig build history -- run 'ancestors(@, 2)' DIR -- zig build unit-test` + records a command's output and cold/warm runtimes across revisions (`jj + status` first: pending edits are not snapshotted); `history -- compare + a.json b.json 1.20` fails past that runtime ratio (`--snapshots`, + `--benchmarks` for stricter checks). Output directories are never + overwritten. +- `python3 -B test/agent_session.py <pardes> --ready 'text' --min-rows N -- + command args` drives an interactive command in a private shell and checks + it through 9P (`--gui-grid` for GUI builds). ## Documentation Start with `src/panes.zig` (and the pane kinds it names, `src/File.zig`, `src/Terminal.zig`, ...), `src/layout.zig`, and `src/fs.zig` for ownership and operations, and `src/pardes.zig` for input, with one file per thing beside it -(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...). `docs/design.typ` is the architecture -sketch; `docs/design.pdf` is a retained rendering/search fixture and may lag it. +(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...). | file | subject | |---|---| -| `docs/design.typ` | architecture: the seams, the data model, the build graph | -| `docs/detached.md` | one core, many frontends, over a unix socket | -| `docs/config.md` | build options and runtime configuration | -| `docs/tags.md` | editable workspace, column and pane tags; filename drafts | -| `docs/selections.md` | the normal-mode selection model and how it differs from helix's | -| `docs/fs.md` | default 9P service, Look resolution, and named mounts | -| `docs/lsp.md` | in-process ZLS and external language servers | -| `docs/lsp-evaluation.md` | why that backend, measured against the alternatives | -| `docs/helix-keys.md` | the helix-compatible key model and its differential suite | -| `docs/macos.md` | the native macOS shell, its bundle and its signing | -| `docs/web.md` | the browser shell | -| `docs/ghostty-macos-notes.md` | notes on the ghostty dependency | -| `docs/ideas.typ` | scratch notes; nothing compiles it, and it says so | +| `docs/fs.md` | the 9P control filesystem: every file, error and limit | +| `docs/config.md` | settings, the startup file, dumps, build options | +| `docs/tags.md` | tags, columns, and where new panes go | +| `docs/detached.md` | one core, many frontends | +| `docs/v9fs.md` | Tty9p: a terminal with the tree kernel-mounted | +| `docs/cloud9.md` | the 9P library and the posted-9P registry | +| `docs/selections.md`, `docs/helix-keys.md` | the normal-mode model and the helix key map | +| `docs/themes.md`, `docs/effects.md` | themes and visual effects | +| `docs/lsp.md` | language servers | +| `docs/web.md`, `docs/macos.md` | the browser and macOS shells | +| `docs/design.typ` | architecture sketch (`docs/design.pdf` is a test fixture and may lag it) | +| `docs/divergences.md`, `docs/open-questions.md` | bookmarks off `main`; undecided questions | +| other `docs/*.md` | design and research notes (`render-pipeline`, `lsp-evaluation`, `ui-review`, ...) | -`next-steps.txt` is a wishlist with a status header saying which items have -shipped; `transactions.txt` records one open structural gap against helix, and -says which waiver proves it is still open. +`next-steps.txt` is a wishlist with a status header; `transactions.txt` +records one open structural gap against helix and the waiver that proves it. ## Layout ``` src/ core events (pardes.zig), panes, edit, look, exec, layout, fs, syntax +src/ninep/ the 9P control tree src/detached/ the wire, the detached core, the frontend client src/lsp/ ZLS and external language-server backends test/ harnesses, snapshot goldens, helix cases @@ -1622,7 +1622,37 @@ pub fn build(b: *std.Build) void { if (enable_quic) run_fs_test.addArg("--quic"); run_fs_test.setCwd(b.path(".")); run_fs_test.has_side_effects = true; - b.step("fs-test", "exercise default 9P service and mounts with an independent client").dependOn(&run_fs_test.step); + const fs_test_step = b.step("fs-test", "exercise default 9P service and mounts with an independent client"); + fs_test_step.dependOn(&run_fs_test.step); + + // Random 9P operations against a throwaway detached session, checking + // the documented rules (test/monkey9p.py). The fixed-seed smoke run is + // part of fs-test; the long run is manual. + const run_monkey9p = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" }); + run_monkey9p.addArtifactArg(exe); + if (b.args) |args| run_monkey9p.addArgs(args); + run_monkey9p.setCwd(b.path(".")); + run_monkey9p.has_side_effects = true; + b.step("monkey-9p", "random 9P operations checking the documented rules (-- [--seed N]... [--steps M] [--replay F] [--shrink F] [--no-shrink])").dependOn(&run_monkey9p.step); + // The GUI's effects under random input in a hidden test window, each + // Motion x Lift x Bloom (test/gui_monkey.py): a gate, not part of + // fs-test, since it needs a Wayland display. + const monkey_gui_step = b.step("monkey-gui", "random input to hidden GUI test windows across the effect settings (-Dplatform=gui; -- [--seed N] [--steps M])"); + if (platform == .gui) { + const run_monkey_gui = b.addSystemCommand(&.{ "python3", "-B", "test/gui_monkey.py" }); + run_monkey_gui.addArtifactArg(exe); + if (b.args) |args| run_monkey_gui.addArgs(args); + run_monkey_gui.setCwd(b.path(".")); + run_monkey_gui.has_side_effects = true; + monkey_gui_step.dependOn(&run_monkey_gui.step); + } else monkey_gui_step.dependOn(&b.addFail("monkey-gui needs -Dplatform=gui").step); + const run_monkey9p_smoke = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" }); + run_monkey9p_smoke.addArtifactArg(exe); + run_monkey9p_smoke.addArg("--smoke"); + run_monkey9p_smoke.setCwd(b.path(".")); + run_monkey9p_smoke.has_side_effects = true; + b.step("monkey-9p-smoke", "a short fixed-seed 9P monkey run (part of fs-test)").dependOn(&run_monkey9p_smoke.step); + fs_test_step.dependOn(&run_monkey9p_smoke.step); const run_discovery = b.addSystemCommand(&.{ "python3", "-B", "test/fs.py" }); run_discovery.addArtifactArg(exe); diff --git a/build.zig.zon b/build.zig.zon index 877dfacb..5ee65c4b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pardes, - .version = "0.0.3", + .version = "0.0.4", .dependencies = .{ // 9P sessions and transports live in the published cloud9 package, so // the manifest pins the build: `zig build` fetches this commit into diff --git a/docs/cloud9.md b/docs/cloud9.md index 7365bf65..cd92fd05 100644 --- a/docs/cloud9.md +++ b/docs/cloud9.md @@ -1,107 +1,46 @@ -# cloud9 integration +# cloud9 -The published `cloud9` package owns the base 9P2000 wire format, client and server -connections, and TCP/Unix/QUIC transports. `build.zig.zon` pins a commit from -`[email protected]:~gbrls/cloud9`, so `zig build` fetches it into `zig-pkg/` like every -other dependency; no sibling checkout is required. Re-pin with -`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, and swap in -`.cloud9 = .{ .path = "../cloud9" }` while editing both packages at once. +The `cloud9` package owns the 9P2000 wire format, client and server +connections, the file-server engine and the Unix/TCP/QUIC transports. +`build.zig.zon` pins a commit from `git.sr.ht/~gbrls/cloud9`, fetched into +`zig-pkg/` like any dependency. Re-pin with +`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, or +use `.cloud9 = .{ .path = "../cloud9" }` while editing both. -The file-server engine (fids, jobs, parking, flush, hangup) is cloud9's -`fs.Server`; the control tree in `src/ninep/` is its backend, using cloud9's -`fs.Req`, `fs.Op`, `fs.Status`, `fs.E` and `fs.ReplyWith` (extended with the -editor's reply payload locator). `src/9p.zig` names the editor's and the -board's `fs.Options` and re-exports the wire names the transports use. -Mounting, Unix namespace discovery and permissions, the editor event loop, -connection limits, and exported tree policy remain here. The Unix and TCP -listeners run on cloud9's `serve.Runner` (`std.Io`: an accept task per -listener, a reader and a writer task per connection, four slots); its handler -answers every backend request on the connection's task, taking the editor's -turn with the core (`pardes.turn`) while the editor waits for input or is out -in a syscall. A request that would change a pane while the editor is mid-step -is parked with `Status.again` -- the engine parks reads, writes, opens, -truncations, clunks and removes -- and retried by `Runner.wakeAll` when the -editor next rests. A read with nothing yet parks the same way, but is never -retried for news: the core holds it (`ctlfs.hold`) and `answerHeld` answers -it through the ticket `Conn.hold` gave its park, with `Conn.answerWith`, -which makes the answer only while that very park still waits (not flushed, -its fid not clunked, not out being retried) and under the same lock. `src/9p_quic.zig` selects the existing `pardes-9p` ALPN for -cloud9's optional OpenSSL transport; QUIC still runs on the poll loop in -`src/9p_io.zig` on the editor's thread, since cloud9's QUIC adapter is -nonblocking-descriptor based rather than `std.Io` based. -The standalone protocol/GPIO tests import the same module. The separate -`05-zig-p4` build also supplies cloud9 for the GPIO firmware entry. +- The engine (fids, jobs, parking, flush, hangup) is cloud9's `fs.Server`; + the control tree in `src/ninep/` is its backend. `src/9p.zig` names the + editor's and the board's `fs.Options` (msize 8192, 256 fids). +- Unix and TCP listeners run on cloud9's `serve.Runner` (`std.Io`: an accept + task per listener, a reader and a writer task per connection, 16 + connections). Requests are answered on the connection's task, which takes + the editor's turn (`pardes.turn`) while the editor waits for input or is + out in a syscall. A request that would change a pane while the editor is + mid-step parks (`Status.again`) and is retried when the editor rests. A + read with nothing to answer yet is held by the core and answered through + the ticket `Conn.hold` gave it, only while that park still waits. +- QUIC (`src/9p_quic.zig`, ALPN `pardes-9p`) still runs on the editor's + poll loop in `src/9p_io.zig`, since cloud9's QUIC adapter is + nonblocking-descriptor based. -Run `zig build 9p-test` for the engine configurations and -`zig build 9p-io-test -Dquic=true` for native transport/client integration. Run cloud9's `zig build test`, -`transport-test`, `quic-test -Dquic=true`, `fuzz`, and `differential` steps for the -shared implementation. See cloud9's `docs/validation.md` for recorded runs and -known test-environment limits. - -Invalid framing now terminates a server connection. Cloud9 also checks reply -counts and reserves tags until flush completion. Client metadata is slightly -larger to track those reservations, and its bounds tests reflect that fixed cost. +Tests: `zig build 9p-test` (engine configurations), `zig build 9p-io-test +-Dquic=true` (transports and client). cloud9's own `zig build test`, +`transport-test`, `quic-test -Dquic=true`, `fuzz` and `differential` cover +the shared code. ## The posted-9P registry -`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: a server posts itself in it -under a name, and clients dial names rather than paths. cloud9 owns both -sides (`cloud9.post`), and `9ns --mntgen` mounts the whole registry at -`/mnt/9p` for programs that want it as a filesystem. pardes's only part in -it is to put itself there. - -**Serving.** A listening editor advertises itself at -`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to the socket it already -binds. One directory for the program, one entry per editor, so several -editors group instead of crowding the registry root — the layout zmx posts -its sessions under. The socket itself does not move: adopting the registry -only advertises. Only the runtime-directory socket posts; an instance that -fell back to `~/.local/state/pardes` stays out of the user's registry, the -way a private `ZMX_DIR` does for zmx. Stopping unposts, and only while the -entry is still ours, so a name another editor has since claimed is never -unlinked. - -An exit that cannot run any code of its own — an aborted test, a kill, a -crash — leaves its entry and its socket behind, so posting first sweeps the -group: every entry that is a symlink and whose socket answers a connect with -a definite ECONNREFUSED is unlinked, along with the socket it points at when -a `stat` agrees that is a socket of ours. Anything that is not a symlink is -somebody else's, and any other answer — connected, busy, refused permission, -a surprise — counts as live, because uncertainty belongs to the server that -owns the socket rather than to the sweeper. That is `cloud9.post.Probe`'s -classification, repeated in `src/9p_io.zig` only because `post.probe` is raw -Linux syscalls and pardes also builds for darwin. - -**Consuming.** Nothing. `9ns --mntgen` mounts the whole registry at -`/mnt/9p`, and an interactive fish already self-wraps in one, so a pardes -started from a terminal sees every posted service as ordinary files — -`/mnt/9p/harness/active/...` is read with the same code that reads any other -path. Teaching pardes to dial the registry itself would put discovery in a -second place for no gain: mounting is the client's job and 9ns is the -client. `--mount=<name>=<dial>` keeps meaning exactly what it always did, -and a dial keeps resolving exactly as it always did — a bare name is another -pardes session, and anything with a slash is a path, relative ones included. - -The one case that is not free: a pardes started outside a mntgen mount has -no `/mnt/9p`. That is 9ns's problem to solve — by being in the namespace — -not a reason for pardes to carry its own registry client. - -### What this diverges from, deliberately +`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: servers post themselves +there by name, and `9ns --mntgen` mounts the whole registry (default +`/mnt/9p`). A pardes whose socket is in the runtime directory posts +`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to its socket (one directory +per program, as zmx posts its sessions); a socket that fell back to +`~/.local/state/pardes` is not posted. Stopping unposts the entry if it is +still ours. Posting first sweeps the group: an entry that is a symlink whose +socket refuses a connect (ECONNREFUSED) is removed with its socket; anything +else counts as live. -* **pardes binds its own socket; it does not post through `cloud9.post`.** - `post` would give us its hardened claim protocol (temp-bind plus atomic - rename under a lock) instead of the stale-socket retry in `listen`, but it - claims *flat* names only: `legalName` rejects `/`, and `claimName` derives - its lock directory by stripping `/9p` from the registry path, so a name - inside a subdirectory cannot go through it. zmx hand-rolls the same - symlink for the same reason. Unifying them means teaching `post` a group — - passing the lock directory in rather than deriving it — and that is a - change to adversarially-hardened code, not a rename. -* **The registry entry is a symlink, not the socket.** A reader that expects - every registry entry to be a socket must `stat` following symlinks. - `9ns --mntgen` and `cloud9.post.dial` both do. -* **Dialing is untouched.** An earlier draft taught `resolve` to fall back - to the registry for a bare name and to read `<group>/<name>` as a - subdirectory entry. Both were reverted: the second reinterpreted relative - dials, which are a feature, and the first duplicated what 9ns already - does. `src/9p_io.zig`'s `resolve` is byte-identical to what it was. +pardes does not dial the registry: `9ns` is the client, and `--mount` dials +resolve as always (a bare name is a pardes session, anything with a slash a +path). pardes binds its own socket instead of posting through `cloud9.post`, +because `post` takes only flat names and pardes posts into a group +directory. A reader of the registry must `stat` through the symlink. diff --git a/docs/config.md b/docs/config.md index 349814a6..f25c8746 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,79 +1,20 @@ -# Startup configuration +# Configuration -Native pardes builds use a per-user `pardes` configuration directory. Its -main command file is named `init`: +## The startup file -- Unix: `$XDG_CONFIG_HOME/pardes/init`, falling back to - `~/.config/pardes/init`. -- macOS: `$XDG_CONFIG_HOME/pardes/init` when that variable is set, otherwise - `~/Library/Application Support/pardes/init`. -- Windows: `%LOCALAPPDATA%\pardes\init`, with - `%USERPROFILE%\AppData\Local\pardes\init` as the fallback. - -On the two unixes `XDG_CONFIG_HOME` counts only when it is ABSOLUTE, as the -XDG base-directory specification requires; an empty or relative value falls -back to the home-directory form (`config.User.path`, and the test beside -it). Windows never consults it. An `init` that does not fit the `max_bytes` -read limit — 1 MiB, `config.User` in `src/config.zig` — or that cannot be read at all is -treated as no file: `load` takes the `readFileAlloc` error and keeps going. The -path still resolves, because "nothing is there yet" is the answer `Config` -exists to give. There is one case with no path at all: a native launch with no -`HOME` set (or, on Windows, neither `%LOCALAPPDATA%` nor `%USERPROFILE%`), -which `Config` reports as `no per-user config path`. - -`Config` (`SPC f c`, or the word executed anywhere) opens one refreshable -`+Config` pane. It reports the startup path and every live config-like value: -theme, colors, focus tint, syntax weight, wrapping, tag position, debug mode, the requested shell and the -executable actually resolved at the last spawn, requested/effective GUI font -and size, tagline scale, window opacity, ligatures (SDL GUI only), panel transition, -scene effects, hover delay, platform, native-image support, and (on SDL) whether -the executable uses live-built shaders or the paired prebuilt shader snapshot. -Platform-dependent rows say `unsupported` instead of looking like an off or -empty supported setting. The fields of `config.Runtime.Capabilities` gate -them and are stated once as plain data in `builtins.capabilities`: -`font_picker` is the SDL GUI and -native macOS only, `scene_shaders` the same two, `panel_transitions` every -hosted shell, `window_opacity` the SDL GUI and macOS, `window_blur` macOS -only, `ligatures` the SDL GUI only, and `tagline_font_size` -everything but the TTY and the board. `ligatures` is the exception to -`unsupported`: where it is off, `Ligatures` is not a builtin at all and -`Config` has no row for it. So -the TTY reports Font, TaglineSize and the scene shaders as unsupported; the -browser reports Font, panel transitions and the scene shaders as unsupported, -and its TaglineSize row reads `82 (build-time only)` — tagline font size is -its own capability precisely because GUI font SELECTION is native-only while -the browser still applies the compiled percentage to its DOM glyphs. -The startup path is printed whether or not a file exists — that is usually when -it is most useful — and is ordinary selectable text, so a right click on it -opens the file. Shell follows the same requested/effective/pending model as -Font. `Default shell` is the one used while no `Shell` is set: `$SHELL`, the -user's login shell, else `/bin/sh` (also when `$SHELL` names nothing -executable); an explicit `Shell` overrides it. `Shell <name or path>` is -refused unless it names an executable file (`Shell: shell "x" not found (...)`, or -`Shell: not a shell: /etc is a directory`; a bare name is looked for in the -usual bin directories), as `Tty <shell>` is, and a -bare `Shell` goes back to the default. The root ctl reads `Shell <the one the -next terminal runs>`. `Shell -effective (last spawn)` is the executable the native host really chose after -installation lookup and fallback. A changed request remains pending until a -terminal is spawned, because the core does not resolve native executables. +Native builds read one command file, `init`, from the per-user `pardes` +configuration directory: -The mutable global values live together in the plain `config.Runtime` record. -One plain capability record gates the setting registry, leader table, -`EffectCode`, and report; the compile-time setting table generates both setter -builtins and their `Config` rows. Exhaustive checks require every table-backed -toggle, transition, and scene-effect switch to occur exactly once, so those -generated setting builtins cannot quietly lose their query row or leave a -renderer switch unnamed. Manual pane-local actions remain with their payload (for -example an image tag reports its renderer choices); they are not global -configuration. +- Unix: `$XDG_CONFIG_HOME/pardes/init` (only an absolute `XDG_CONFIG_HOME` + counts), else `~/.config/pardes/init`. +- macOS: `$XDG_CONFIG_HOME/pardes/init` when set, else + `~/Library/Application Support/pardes/init`. +- Windows: `%LOCALAPPDATA%\pardes\init`, else + `%USERPROFILE%\AppData\Local\pardes\init`. -The browser build has no local user-config path and does not load this file. -(Nor does it have `Font`, the effect builtins or `EffectCode`, a language -backend, or ptys of its own — see `docs/web.md`.) +The browser build has none. A file over 1 MiB or unreadable counts as absent. -The format is one existing builtin command per line, using the same spelling -and argument parsing as commands executed inside pardes: +Each line is one builtin, spelled as it would be executed in pardes: ```text Theme orchard @@ -83,620 +24,171 @@ Shell zsh Wrap ``` -A line matches a builtin whose name takes NO argument only as that whole word: -`Kill` runs, `Kill something` does not. A builtin that takes one -(`takes_arg` in `src/builtins.zig`, or a `settings` row whose action is -`shell`, `theme`, `font`, `tagline_size` or `window_opacity` in `config.Runtime.settings`) takes -everything after the name as the argument. On a native build that is `Theme`, -`ThemeFile`, `Font`, `TaglineSize`, `Shell`, `Save`, `Restore`, `Attach`, -`Mount`, `Unmount`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`, `Exec`, -`Msg` and `EffectCode`. -The SDL GUI also has `WindowOpacity`, which takes one argument. -(`Peek`, `Poke`, `Hexdump` and `Gpio` take one too, but they exist only where -`builtins.Board.enabled` holds, and that build has no config file.) - -`Theme <name>` wants one of the names in the compiled ring. Do not derive the -spelling — read it off `Themes` (`SPC t t`), which lists every one as the -exact `Theme <name>` line that selects it. `slug` in `tools/gen_themes.zig` -lowercases, folds punctuation runs to a single `_` and then TRIMS leading and -trailing ones (`penumbra+.toml` is `penumbra`, not `penumbra_`), and every -variant read out of a zed `.json` gets `_zed` on the end so it cannot collide -with a helix theme of the same name — zed's "Ayu Mirage" is `ayu_mirage_zed` -and `ayu_mirage` is helix's `ayu_mirage.toml`. The suffix goes on all of them -rather than only the eight that clash today, so a name cannot move when either -project gains or loses a file. A name that is not in the ring is ignored. - -The fifteen [native Pardes themes](themes.md) lead the ring: `orchard` (the -default), `dusk`, `ink`, `paper`, `daybreak`, `atelier`, `forge`, `lagoon`, -`solarium`, `spectrum`, `harvest`, `clay`, `forge_black`, `forge_soft`, and -`orchard_black`, then `acme` and `lapis`. `Themes` lists them first under -Pardes themes. They add coordinated -focus, search, diagnostic and terminal colors; `ink` and `daybreak` are high -contrast dark and light options. After them come the -[faithful ports](themes.md#faithful-ports) of well-known themes, a section per -family, then the legacy `helix` and `dark`, then everything imported. Where a -port takes an imported theme's name (`dracula`), the imported one gains -`_helix` (`dracula_helix`), as zed's carry `_zed`. `NextColor` walks the same -ring in the same order. - -`FocusTint` toggles the focused pane and column tag tints; it is on by default. -Workspace, column and pane command text can be edited directly; see -[editable tags](tags.md) for naming and saved-workspace behavior. -The column command row is always shown; an old `ColumnTags` init line is -ignored with a message. -`Placement acme` (the default) puts new panes where acme would, in the active -column; `Placement pardes` brings back pardes's own rules, which open a first -document in a column of its own. See [where new panes go](tags.md#where-new-panes-go). -`BootShell keep` (the default) leaves a shell alone when a document is dragged -into the left column beside it, whatever it holds; `BootShell replace` closes -such a shell there when it is the column's only one and nobody has typed -into it (no scrollback, cursor still on the first prompt line), the boot's -placeholder giving its rows to the document. Bare `BootShell` flips it, as -bare `Placement` does: a setting that chooses among words (`on`/`off` -switches, `Placement`, `BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`, -`ShaderAnimation`) steps to its -next value when written bare, as its word clicked in a tag does, and a value -it does not take is refused with the values it takes, which `/commands` also -lists. -`Verbose` toggles the message-row announcement every builtin makes of its own -name before it runs; it is on by default, and the builtins that own the message -row themselves (`Msg`) never announce. Turning it off leaves the row to the -messages a builtin chooses to write. -`MessageAnimation` toggles how a message comes and goes; it is on by default. -A message eases down into its row, fast at first and settling at the end, its -colour whole by half way (graphical frontends slide it out from under the -tagline as it fades up; a terminal only fades it in), stays until the next key or click as it -always has, then lingers for `MessageLinger` milliseconds (default 800) before -it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve` -(default 150: leaving is quicker than arriving) set how long the fall and the dissolve take, also in -milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with -`MessageAnimation off` restores the old behaviour, a message cleared by the -very input that follows it. An updated line on a row already showing one swaps -its text in place rather than falling again. -`SyntaxBold` toggles bold syntax keywords; it is off by default. These -settings are shared by GUI and TTY, and `Config` reports their current -states. Like `Colors` and `Wrap`, these commands take no argument and invert -the current value. A `FocusTint` line in a fresh startup configuration disables -the tint; a `SyntaxBold` line enables the stronger keyword weight. Those two -appearance controls leave focus, selections and editing behavior unchanged. - -`Ligatures` (SDL GUI only) toggles a font's programming ligatures, such as -Maple Mono's `->` and `!=` drawn across their cells; it is on by default. -Off, every cell draws its own glyph, exactly as a font without ligatures does. -`Ligatures on` and `Ligatures off` set it explicitly. The native macOS shell -draws CoreText ligatures of its own, which this setting does not reach. - -`TreeContext` toggles sticky declaration headers for the current source pane. -It is off by default and appears in the default pane tag when a tree-sitter -grammar supports that file. `TreeContext on` and `TreeContext off` set it -explicitly. Scrolling inside a function, type or module keeps its enclosing -declarations above the body, with source line numbers, syntax colors and a -subtle background tint. Clicking a header moves the cursor there without -scrolling; scrolling upward reveals that source line as the headers recede. -`Dump` and `Restore` preserve the setting per pane. Customized tags retain -their text; the command can still be executed from any source pane. - -`TreeContextTagStyle` toggles the experimental tagline treatment for those -headers and is on by default. In graphical frontends it uses the tagline font, -line height and thin border. A matching separator marks gaps between the -headers' source lines. Turning it off restores body-sized context rows. -`TreeContext` itself still defaults off; this appearance option does not enable -it. `Config` reports the appearance option, and dumps preserve it. - -Search, Grep and LSP location-result panes include `LocationsConfig` in their -default tags. Custom tags keep their edits. - -Location highlighting is enabled by the command that produces a location list. -Plain reports, including `LocationsConfig`, keep ordinary text colors even -when their text resembles a location. `Mini` keeps its own syntax colors. -LSP references and goto results highlight the exact symbol span supplied by -the server, using the search-result colors. Source indentation is retained so -those byte ranges stay aligned; context and descriptive labels remain unmarked. - -`LocationsConfig` prints the current settings for Search, Grep and LSP location -results in an output pane. Execute the printed line to apply it again, or -supply just the fields to change: - -```text -LocationsConfig context:5 tscontext:on tslocations:off layout:stacked -``` - -- `context` is the number of source lines above and below each match (default - `0`). Overlapping context is shown once. These neighboring lines omit their - locations and keep their code aligned with the matching result. -- `tscontext` includes enclosing tree-sitter declaration headers in source - order (default `off`). Preceding neighboring context stops at the outermost - enclosing declaration, keeping unrelated lines above it out of the result. -- `tslocations` shows a location on the first line of each declaration header - (default `on`); continuation lines keep the same alignment without repeating - the location. Declaration headers use the same muted color as locations, with or - without their locations visible. -- `layout:stacked` (default) puts each location on its own line, followed by its - source preview. Hidden context locations do not add an empty address line. - Use `layout:inline` to put locations beside the source instead. +A builtin that takes no argument matches only as the whole line (`Kill` runs, +`Kill something` does not); one that takes an argument takes the rest of the +line. Blank, unknown, malformed or failing lines are ignored silently and do +not stop later ones. Text that is no builtin is not run as a shell command +(`Exec ...` still is). Key bindings are compile-time choices in +`src/config.zig`; `init` does not remap them. -In inline layout, result locations share padding in groups of eight matches, so a long path only -widens its own group. Context does not count toward the eight; at group boundaries, -it aligns with the nearer match (ties stay with the preceding group). Source -indentation is preserved. All visible locations start flush left; `n` and `N` -still stop on matches. -With `tscontext` enabled, asterisks after a match location show its declaration -depth (for example, `main.zig:42 ***`). Ordinary neighboring source lines retain -syntax colors; declaration headers do not. -A declaration context line ends with ` ...` when source lines are omitted -before the next displayed row from that file. -Source analysis is reused across result queries while the source bytes stay the -same. The cache retains at most 64 files and 64 MiB; it checks current buffer or -filesystem contents on each refresh. -Open file buffers supply context from their current edits. Missing files still -leave the original result available. These settings apply to subsequent result -generation and survive `Dump`/`Restore`. Invalid fields reject the entire -update; if a field appears twice, its last value wins. +`Config` (`SPC f c`) opens a `+Config` pane with the startup path (a right +click opens it) and every live setting; a setting the frontend cannot show +reads `unsupported`. The root `ctl` file reads the settings back in the +words a write takes ([fs.md](fs.md#the-root-ctl)). -`WindowOpacity <percent>` controls the opacity of everything in the SDL window -except text and the cursor, which stay fully opaque. Use `WindowOpacity 85` -to see the desktop through the editor, or `WindowOpacity 100` to restore full -opacity (the default). The argument must be a whole number from -0 through 100; missing or invalid values -leave the setting unchanged. At zero only text and the cursor remain visible. -Add the command to `init` to persist it. +## Settings -The same opacity applies to editor and embedded terminal backgrounds, UI -chrome, borders, scrollbars, gutters, and images. Overlapping non-text drawing -does not make those areas more opaque. Regular text, syntax colors, tagline -text, terminal glyphs, and the cursor keep their normal opacity. This does -not blend foreground colors into their cell backgrounds. The TTY -and other non-SDL hosts do not emulate this effect; their `Config` report says -`WindowOpacity unsupported`. +A setting that chooses among words (`on`/`off` switches, `Placement`, +`BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`, +`ShaderAnimation`) steps to its next value when given bare, as its word +clicked in a tag does; a value it does not take is refused, naming those it +does. `/commands` lists every builtin and its values. -On native Wayland, Pardes uses an alpha-capable transparent surface. It does -not use whole-window opacity protocols such as `wp_alpha_modifier_v1`, because -those would also fade the text. Presentation uses the existing GPU offscreen -renderer followed by a readback and SDL renderer upload per presented frame, -which adds rendering cost. Other SDL drivers can report background transparency -as unsupported unless their rendering configuration is alpha-capable. -If the rendering backend cannot apply the request, Pardes reports the error -and keeps the last successfully applied opacity. `Config` reports the -percentage and marks a request as pending until the SDL host handles it. - -## Crash records - -A panic appends to `crashes` in that same directory, beside `init`, and only -then prints to stderr (`src/crash.zig`, wired into the panic handlers in -`main.zig` and — because the macOS build roots there — `macos.zig`). stderr is -the one place this program cannot keep a trace: in the TTY shell stderr IS the -screen, so the trace lands on a grid the terminal is being reset out of; the SDL -and AppKit shells have no terminal at all; and a `--detach` session's stderr -goes wherever its launcher left it. The file is appended, never rewritten, and -each record is two lines — build metadata, then the panic message: - -```text -pardes 0.0.2 (a1b2c3d) 2026-09-03T11:20:44Z linux-x86_64 pid 48812 -panic: index out of bounds: index 4, len 4 -``` - -NO STACK TRACE, and that is a measured decision rather than an omission. The -frames stay on stderr, where `std.debug.defaultPanic` prints them. Collecting -them here instead HANGS the process: `writeCurrentStackTrace` called from a -panic handler before `defaultPanic` has run wedges at 0% CPU, and -`captureCurrentStackTrace` — which looks like the safe half — takes `SelfInfo`'s -rwlock exclusively on its first call, so a panic inside the walk leaves that -lock held and `defaultPanic` then waits on it forever. What makes `defaultPanic` -survive the same hazard is its own private `panic_stage`, which nothing outside -`std.debug` can reach. A crash that becomes a hang is worse than the crash, so -this file keeps only what it can gather without asking the process any -questions: which build, when, where, and what it said. - -Everything about it is best effort and silent: no config directory (a launch -with no `HOME`) means no file, and a directory that cannot be created or opened -leaves the panic exactly as it was before — stderr alone. The directory itself -is created if it does not exist, because the user who never wrote an `init` is -as likely as any other to hit a bug. One record at a time: two threads panicking -at once would otherwise interleave into one buffer, so the second falls straight -through to stderr. Only panics come here; a SIGSEGV is caught one level lower -(`main.zig`'s `debug.handleSegfault`) and unwinding one needs the signal's saved -CPU context. - -## Runtime theme files - -`ThemeFile <path>` loads one complete theme from a `.zon` file. An absolute -path is used as written; a relative path is resolved from the `pardes` -configuration directory, not from the process working directory. A typical -layout is: - -```text -~/.config/pardes/ -├── init -└── themes/ - └── mine.zon -``` - -and the corresponding init line is: - -```text -ThemeFile themes/mine.zon -``` - -After a successful load, hosts with document live reload watch the path with -the same parent-directory mechanism, so in-place writes and editor-style -rename-over saves reload the theme live. A malformed or incomplete save does -not replace the last valid theme; fixing and saving the file applies the next -valid snapshot. Selecting a compiled theme with `Theme <name>` or `NextColor` -stops the custom-file watch. - -Execute `DumpThemes` to write every theme compiled into the executable to: - -```text -<config directory>/themes/builtin/<name>.zon -``` - -The command replaces those generated reference files but leaves unrelated -files alone. Copy one into `themes/`, rename it, change its `.name`, and use it -as the starting point for a custom theme. The dumped file is also the complete -format, and it is `pardes.Theme` serialised by `std.zon.stringify`: the theme -name, thirteen required RGB roles (`sel_bg`, `sel_fg`, `tag_bg`, `tag_fg`, -`box`, `box_dim`, `kw`, `str`, `num`, `comment`, `lineno`, `scroll_track`, -`scroll_thumb`), nullable `bg`/`fg`, and either a 16-color RGB `palette` or -`null`. Optional nullable RGB roles extend this format: -`tag_active_bg`, `tag_active_fg`, `tag_name_fg`, `tag_active_name_fg`, `border`, `empty_col`, `lineno_active`, `search_bg`, `search_fg`, -`diagnostic_error`, `diagnostic_warning`, `diagnostic_info`, -`diagnostic_hint`, `tag_sel_bg`, `tag_rule`, `box_border` and `box_dirty`; -`sweep_bg` and `sweep_fg` take three triples, and `rule_px` and `rail_px` a -pixel count. Missing new roles use backward-compatible defaults, so -previously exported files remain valid. [Theme customization](themes.md) -describes each role and its fallback. RGB values are three-byte arrays, and -hex literals are accepted. The original fields remain required; there is no -inheritance or partial override layer. - -`tag_name_fg` gives the filename at the end of a pane's path a separate -foreground. `tag_active_name_fg` can adjust that tint for active tags; it falls -back to `tag_name_fg`. When both are omitted or `null`, the filename uses -the corresponding tag foreground. All canonical themes use a different hue -at similar perceived brightness to the surrounding text in both states. -Directory text and tag commands retain -their regular colors; selecting filename text uses the selection colors. -Terminal tags use this color for `Tty`, which comes immediately after the path. - -`Filter` in a terminal's tag projects that pane's ANSI colors through the -active theme, and does it in two stages. The default foreground and background -roles are mapped FIRST, because ghostty-vt generates the whole 256-color -projection from that pair; every other color follows, by reducing it to its -nearest canonical xterm key and reading the key back out of the projection. - -That reduction compares RGB triples, so it knows about hue and nothing about -the page — and the projection's cube corners ARE the two anchors, which is how -a foreground used to end up painted the exact color of the paper behind it -(`\x1b[38;2;255;255;255m` on acme's `#ffffea`, and the ANSI black a shell -writes with `\x1b[30m` on either dark theme). So a foreground additionally has -to keep `tty_filter_min_contrast` — a WCAG ratio, `1.5` by default — against -the mapped background. One that cannot is not mapped: it takes whichever of -the theme's own two anchors is still visible on that background. Backgrounds -are exempt, since a background is the page the floor is measured against. Set -the constant to `1.0` to accept every projected color, collapses included. - -`Shell <name>` sets the binary that the NEXT terminal pane execs; panes -already open keep the shell they are running. A bare name is resolved against -the handful of directories a shell actually lives in, not `$PATH`. - -On Linux, `Tty9p` (`SPC n 9`) starts that shell with a private kernel 9P mount, -asking sudo inside the new terminal. `$PARDES_MOUNT` names the mountpoint. -The installed `pardes-v9fs` helper lives beside the editor; development builds -can set `PARDES_V9FS_HELPER` to its absolute path. See [v9fs.md](v9fs.md). - -Ctrl-B switches between raw TTY and editor mode. Plain Escape at a detected -shell prompt hops back to the previous pane. Other keys, including Ctrl-O, -Ctrl-W and modified Escape, belong to the child. Use `Mode` in the pane tag to -return to editor mode in place. Desktop paste events still feed the terminal. - -The paste chords are the exception the window keeps. Ctrl-V types the yank -register at the program, and Ctrl-Shift-V asks the desktop for its clipboard -and types that; both go through the program's bracketed paste when it has -asked for one. Neither reaches the child as a keystroke, so an application -that would otherwise answer Ctrl-V by reading the system clipboard itself -never gets the chance to read the wrong thing. - -`Font` and `Fonts` exist ONLY in the SDL GUI and native macOS builds — a -terminal's font belongs to its emulator and a browser's to the page — so a -`Font` line is one of the silently-ignored ones everywhere else. Both builds -resolve the name by walking the font directories on every lookup, so a face -installed a moment ago is findable. - -Use `Font <name>:<size>` to change face and size together, for example -`Font MartianMono-NrRg:18` in the startup file or an editable tag. Fractional -sizes such as `:18.5` are supported; the accepted range is 8–72 (pixels in SDL, -points on macOS). `Font <name>` without a suffix preserves the current size. -Invalid sizes or unknown faces leave the current font unchanged. - -The SDL GUI also has a tiny optional workspace-tag companion: `Pet cat`, -`Pet frog`, or `Pet off` (the default). Its original pixel sprite walks and -idles in the trailing blank area of the global tag only. It hides while that -tag is being edited, when the tag is full, or when the font leaves too little -room; it never replaces text, receives clicks, or appears in pane/column tags. -Animation runs at ten steps per second, uses theme ink, and requires no image -assets or shaders. Put `Pet cat` in the startup configuration to keep it; -`Pet off` disables the companion and its animation. Other hosts ignore the -startup command. `Config` reports the current choice in SDL. +| setting | default | | +|---|---|---| +| `Theme <name>` | `orchard` | names as `Themes` (`SPC t t`) lists them ([themes.md](themes.md)); `NextColor` walks the ring | +| `ThemeFile <path>` | | a `.zon` theme, relative to the config directory; reloads live when saved | +| `FocusTint` | on | tint the focused pane's and column's tags | +| `SyntaxBold` | off | bold syntax keywords | +| `Verbose` | on | a builtin announces its name on the message row | +| `MessageAnimation` | on | messages ease in and dissolve | +| `MessageLinger`, `MessageFall`, `MessageDissolve` | 800, 180, 150 | milliseconds, at most 60000 | +| `Placement acme\|pardes` | `acme` | where new panes go ([tags.md](tags.md#where-new-panes-go)) | +| `BootShell keep\|replace` | `keep` | `replace` closes the untouched lone shell a dragged document lands beside | +| `LookWord search\|list` | `search` | a looked-at word selects its next place, or lists all in `+Search` | +| `Shell <name or path>` | `$SHELL`, else the login shell, else `/bin/sh` | the shell the next terminal runs; a bare name is looked for in the usual bin directories, not `$PATH`; bare `Shell` returns to the default | +| `DumpDir <dir>` | `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes` | where `Dump` writes; `~/` is home; bare returns to the default | +| `TreeContext` | off | sticky declaration headers in a source pane (per pane, dumped) | +| `TreeContextTagStyle` | on | draw those headers in the tagline style | +| `LocationsConfig ...` | | Search, Grep and LSP result layout (below) | +| `Wrap`, `Colors`, `Tagbottom`, `Debug` | | toggles | +| `Font <name>[:<size>]`, `Fonts` | | SDL and macOS only; size 8-72 (pixels in SDL, points on macOS) | +| `TaglineSize <1-100>` | 82 | tagline face, percent; SDL and macOS | +| `WindowOpacity <0-100>` | 100 | SDL only: everything but text and the cursor | +| `Ligatures` | on | SDL only; macOS draws CoreText's own | +| `Pet cat\|frog\|off` | off | SDL only: a sprite in the workspace tag's blank space | -`Font` is asynchronous at the renderer boundary. `Config` therefore keeps -requested name/path/size, pending state, and the effective face/point-or-pixel size -as separate facts; a failed request never gets reported as the face on screen. -Taglines use a distinct face size in both native GUI renderers. Execute -`TaglineSize <percent>` to change it live, for example `TaglineSize 70`; the -accepted range is 1 through 100 and the default comes from -`gui_tagline_font_percent` in `src/config.zig` (82). The native renderer -remeasures both the glyph and its visible tag band while retaining body-grid -pane geometry. Graphical tag text uses the smaller face's measured monospace -advance and fills the available pixel width, including workspace and column -tags. Text selection and scrolling use that same capacity; drag grips retain -their physical body-grid width and position. The 100% ceiling is -deliberate: a tagline remains exactly one logical grid row, so a larger face or -band would overlap its pane body or a neighbour instead of leaving the body -grid stable. `Config` reports the active percentage. The browser applies the -same compiled percentage to its DOM glyphs but has no runtime setter. +`Tty9p` (`SPC n 9`) is described in [v9fs.md](v9fs.md); the +`PARDES_V9FS_HELPER` variable points development builds at the helper. -Both native GUIs separate the reduced-height global and pane tagline bands with -a `gui_topbar_pane_border_px` physical-pixel rule. Set it to zero to leave it out. -`gui_topbar_pane_border_rgb` can pin an RGB color; its default `null` follows -the theme's `border` role in SDL (falling back to `scroll_track` for older -themes), and `scroll_track` in the macOS shell. Every band is centred in its -row, so the workspace, column and pane tag text share one baseline offset and -the anchors get the same margin above, below and to the left. With `Tagbottom` -enabled, a tagline on the final grid row is bottom-aligned so the unused -half-band does not show beneath it. The rule lives in the core -(`pardes.taglineBandOffset`), and the macOS shell reaches it over the C ABI -rather than keeping its own copy. When the window is not a whole number of cells, -bands and rules at the right and bottom edges run on through the leftover pixels. +### Terminals -What happens to a codepoint the chosen face has no glyph for differs by shell. -The SDL GUI falls back through a chain it builds itself: embedded Adwaita -Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`, -`SymbolsNerdFont-Regular`, `NotoSansSymbols2-Regular`, -`NotoSansSymbols-Regular`, and `DejaVuSans`, in that order, missing entries -skipped; the rasterized glyphs are retained in its GPU atlas. The macOS shell -has none of that and needs none. It embeds no font. Its default face is -`NSFont.monospacedSystemFont`, resolved through the descriptor rather than by -name, and `Menlo` only if that face turns out not to be fixed-pitch — a -proportional face in a fixed grid is a broken screen, not a cosmetic problem -(`PardesView.defaultFace`). A `Font <name>` arrives as a PATH the core already -resolved by walking the font directories, and a file CoreText cannot measure -leaves the face already on screen rather than substituting one, because the -alternative is a terminal with no way back out (`PardesView.fromFile`). -Per-codepoint fallback is CoreText's own cascade at draw time. What the shell -caches is `CGGlyph` ids, not pixels. +Ctrl-B switches a terminal between raw input and editor mode. Plain Escape +at a detected shell prompt hops back to the previous pane; other keys, +Ctrl-O, Ctrl-W and modified Escape included, go to the program. `Mode` in the +tag returns to editor mode in place. Ctrl-V types the yank register and +Ctrl-Shift-V the desktop clipboard, through bracketed paste when the program +asked for it; neither reaches the program as a keystroke. -Blank, unknown, malformed, or unsuccessful lines are ignored silently, and a -bad line does not prevent later lines from running. Top-level text that is not -a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and -therefore keeps its normal behavior.) Key bindings remain compile-time choices -in `src/config.zig`; this startup file does not remap them. +`Filter` in a terminal's tag maps its ANSI colours through the theme, +keeping each foreground at least `tty_filter_min_contrast` (WCAG 1.5, in +`src/config.zig`) against its background. -## Panel and scene effects +### Location results -Exactly one panel transition is selected at a time. Executing its builtin a -second time turns it off; selecting another replaces it: +`LocationsConfig` with no argument prints the current settings as a line +that can be run again; with fields it changes only those: ```text -PanelSlide -PanelZoom -PanelDissolve -PanelAscii -PanelVertical -PanelEdges -PanelFall -PanelWave -PanelCurtain -PanelScramble -PanelType +LocationsConfig context:5 tscontext:on tslocations:off layout:stacked ``` -All panel transitions start off. To disable one, execute its builtin again: -`PanelDissolve` turns off an active dissolve, and `PanelAscii` turns off an -active ASCII transition. `Config` shows the active command under -`Panel transition`. Remove that command from your startup configuration to -keep it off after restarting. Executing a different transition enables that -one instead; these commands are toggles, not an unconditional animation-off command. +- `context` (0): source lines shown above and below each match. +- `tscontext` (off): include the enclosing tree-sitter declaration headers. +- `tslocations` (on): show a location on each declaration header. +- `layout` (`stacked`): the location on its own line; `inline` puts it + beside the source, padded in groups of eight matches. -Slide uses cubic ease-out and zoom uses an -overshooting ease-out-back. Dissolve and ASCII compare the last successfully -presented grid with the new one. Dissolve switches visually changed cells at -stable noise thresholds. -ASCII walks every changed single-byte printable glyph -from its old `u8` value to its new one, spending that byte distance as the -frames of the walk. The walk is eased in and out: a glyph creeps at both ends -and crosses the middle of its distance in a few large skips, inside the same -number of frames a constant one-value-per-frame walk would have taken. -Glyph-stable style changes -and non-ASCII graphemes become canonical immediately. A walk is capped at -twelve movement frames, and each pane lasts only as long as its longest walk. -The core computes and composes that semantic diff once for every backend; -pixel attachments, which have no character value, pass through unchanged. +An invalid field rejects the whole line; a repeated field's last value wins. +The settings survive Dump and Restore. Source analysis is cached for 64 +files and 64 MiB. -Six further transitions are character *motion* over the same frozen/new grid -pair, and are composed in the core the same way: +### Effects -- `PanelEdges` — whole rows slide in from alternating screen edges. -- `PanelFall` — columns rain down into place, each with its own head start. -- `PanelWave` — a vertical ripple travels across the pane and decays. -- `PanelCurtain` — a wipe, left to right, behind a soft edge two cells wide. -- `PanelScramble` — every cell churns through printable ASCII and locks onto - its final glyph at its own stable noise threshold. -- `PanelType` — reading-order reveal with a caret sitting on the write head. +Panel transitions, one at a time; running the active one again turns it +off: `PanelSlide`, `PanelZoom`, `PanelDissolve`, `PanelAscii`, +`PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`, +`PanelScramble`, `PanelType`. All start off; the web shell has none. -Unlike dissolve and ASCII these carry *every* glyph in the pane, changed or -not: text flying in from a screen edge has to bring its unchanged glyphs with -it. A cell whose glyph has not arrived shows the frozen old cell rather than a -blank or a blend, so every intermediate frame is made of real characters. No -cell is a valid input target until its own glyph has settled. +Scene passes (SDL GUI, and a GUI attached to a detached session), each at a +level 0-3 (`on` is 2), all off by default: `Crt`, `Bloom`, `Vignette`, +`Grain`. `Shader <file.glsl>` adds a Shadertoy file written for ghostty to +the chain (`Shader off` removes it; it recompiles when saved); +`ShaderAnimation off|on|always` says when the chain animates by itself. -Vertical is a pane-lifecycle effect: a newly added -pane rises from below inside its own fixed box, and a deleted pane's frozen -content drops back down; surviving panes are never animated. The TTY -implementation performs its remaining geometry/dissolve operations directly -on a copy of the core-composed presentation grid. -The SDL and native macOS GUI implementations pass plain panel tracks to their -GPU shaders, including native image/PDF pixels; layout itself commits -immediately and remains the one authoritative geometry. DOM web intentionally -does not expose these builtins: its renderer is selectable HTML/CSS and has no -canvas or shader stage. +The focused pane can stand off the page: `Lift shadow|rim|auto|off`, +`InactiveDim <percent>`, `Motion off|crisp|smooth|bouncy|playful` +(default `smooth`), `SelectionGlow`, `HoverGlow`, `Occlusion`, `Parallax`, +`JumpTrail`, `ChipShadow`, `ThumbFlash`, `CursorBlink`, `GripWidth <50-300>`. +Most are GUI-only; `InactiveDim` works everywhere, and `JumpTrail`, +`ChipShadow` and `ThumbFlash` are the terminal's. No effect may lower the +contrast of text, the selection or a focus indicator +([effects.md](effects.md)). -The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2: +`EffectCode <effect>` lists that effect's sources under `/virtual` when the +build embeds them (`-Dembed-sources=true`). `zig build shaders` refreshes +the committed SPIR-V with its GLSL. -```text -Crt -Crt 3 -``` +### Look preview -The SDL GUI runs it as the bundled pass of its post chain, which also takes -Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`; -a file compiles again when it is saved, and a save that fails to compile -keeps the last good one and says why), and -`ShaderAnimation off|on|always` says when the chain animates on its own. A -GUI attached to a detached session runs the session's chain the same way. With -the chain empty the pass is bypassed. CRT works in linear light with restrained -scanlines, mask, bloom and vignette, and no curvature, so clicks land where -they are drawn. Its slow hum and dither move on their own, so with Crt on the -GUI keeps drawing while idle (under `ShaderAnimation on`, while focused); -the other bundled passes are still and let it rest. +Resting the pointer on text for about 32 ms (`look_preview_delay_frames`, 2 +frames) tints what a right click would look at, with no other effect. Set +`look_preview_delay_frames` to `null` in `src/config.zig` to turn it off. -Three more bundled passes take the same levels, each off by default and -each costing nothing while off: +## Themes from files -```text -Bloom 2 the brightest ink glows a little (only what is brighter than - the page; a dual Kawase blur at half size and down) -Vignette 2 the window's corners fall a little into shade -Grain 2 the page's own ground takes a fine, still grain, like paper -``` +`ThemeFile themes/mine.zon` loads a complete theme; a malformed save keeps +the last good one, and `Theme <name>` stops the watch. `DumpThemes` writes +every compiled theme to `<config dir>/themes/builtin/<name>.zon`: copy one, +change its `.name`, and edit. The format is `pardes.Theme` as +`std.zon.stringify` writes it; [themes.md](themes.md) describes each role. -None of them touches a tag, a grip, a notice or the cursor, and each is -capped per theme so text and the selection keep their own contrast or 4.5 -(docs/effects.md). - -The focused pane can stand off the page (SDL GUI, off by default): - -```text -Lift shadow a soft drop shadow, on the other panes' bodies only -Lift rim a hairline just above the focused tag (light, or shade on a light page) -Lift auto a shadow on a light page; on a dark one the others recede -Lift off -InactiveDim 30 the unfocused panes' text fades 30% toward its ground -Motion smooth off, crisp, smooth (the default), bouncy or playful -SelectionGlow on a soft halo of the selection's colour round it in the body, - fading in over 100 ms, never over a tag, a grip or the cursor -HoverGlow on a soft glow under the word a look-hover would open, fading - in over 80 ms -Occlusion on pane bodies darken faintly toward their edges (2%), never - over the cursor -Parallax on a theme's page pattern (lapis's dots) moves with the text - at a quarter of its speed -JumpTrail on in a terminal, a jump of the cursor of three cells or more - leaves a trail of a few cells that fades in 120 ms - (truecolor terminals; a pixel shell glides instead) -ChipShadow on in a terminal, a notice chip casts a shadow a cell right and - down: half blocks on blank cells, a darker ground on text -ThumbFlash on in a terminal, a pane's scroll thumb brightens as it scrolls - and fades back over 250 ms -CursorBlink on the cursor blinks, solid while typing and half a second - after, eased at each edge, solid after 10 idle seconds -GripWidth 150 the grip's button and the scrollbar under it, percent of the - theme's rail_px (12px at a 17px tagline; 50 to 300) -``` +## Dumps -`InactiveDim` works everywhere (a grid shows it at once) and never takes a -pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30 -while unset. `Motion` sets how every such effect moves: see docs/effects.md. +`Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`, creating the +last directory if it is missing (a missing parent fails `no such +directory`); `$PARDES_DUMP` overrides the file. `Restore <path>` looks for a +relative path in `DumpDir`, then in the directory pardes started in; bare +`Restore` takes the last dump this session wrote. `pardes -l <dump.zon>` +starts from one. -`EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's -build-embedded source paths under `/virtual`. Look opens each full file; -no checkout is needed, but the build must carry them (`-Dembed-sources=true`, -off by default); otherwise the command reports them unavailable. TTY exposes grid transitions, native GUI builds also -expose scene shaders, and web has neither. Shared implementations share paths. -SDL reports whether GLSL was compiled during this build or came from the -`-Dprebuilt-shaders` snapshot paired with the committed SPIR-V. -`zig build shaders` refreshes both files of every pair together, -so editing live GLSL without that explicit refresh changes neither half of a -prebuilt executable. +A dump keeps panes, columns and their tags, each text pane's selection, the +theme, and the settings that differ from a fresh session's; the font stays +the frontend's. A terminal comes back with its last MiB of output as +history, a `── restored history ──` line, and a new shell in its old +directory; a command pane comes back finished (`exit ?` if it was running). +Undo history and REPL bindings are not kept. A dump holds at most 6 columns. -## Delayed Look preview - -The preview is enabled by default. Moving the pointer onto selectable text and -leaving it still for `look_preview_delay_frames` — 2 animation ticks, and -`animation.frame_ms` is 16, so about 32 ms — paints a subtle theme-derived -preview of the exact operand a right-click Look would receive. -Repeated motion reports in the same semantic grid cell do not restart the -delay. The preview uses the same side-effect-free word/path expansion as Look; -it does not focus a pane, move a cursor, install a selection, activate a PDF -page, or execute anything. Motion to another operand, pointer leave, input, -pane teardown, and relevant content changes cancel it. - -Set this compile-time option in `src/config.zig` to disable the feature: +## Crash records -```zig -pub const look_preview_delay_frames: ?u16 = null; -``` +A panic appends two lines to `crashes` beside `init` (build, time, platform, +pid; then the panic message) before printing to stderr. There is no stack +trace in it: collecting one from a panic handler can hang the process. The +trace stays on stderr. -## Build-time configuration +## Build options -Runtime settings live in `src/config.zig`. Build options are listed below; -`zig build --help` lists the options available for the selected platform, -including the standard `-Dtarget` and `-Doptimize` options. +`zig build --help` lists the options for the selected platform. | option | values | default | |---|---|---| -| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent builds the tty cli and the SDL gui together | +| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent: the tty and SDL shells together, installed into `~/.local` | | `-Dstatic` | bool | `false` | -| `-Dquic` | bool; 9P over QUIC using system OpenSSL 3.6+ | `false` | -| `-Dmupdf` | bool | on for a native target, off for web and esp32p4 | -| `-Djpx` | bool | `true` — JPEG 2000, and with it scanned PDFs | +| `-Dquic` | bool; 9P over QUIC with system OpenSSL 3.6+ | `false` | +| `-Dmupdf` | bool | on natively, off for web and esp32p4 | +| `-Djpx` | bool; JPEG 2000, and with it scanned PDFs | `true` | | `-Dtree-sitter` | `disabled`, `zig`, `minimal`, `full` | `full` natively, `zig` for web, `disabled` for esp32p4 | -| `-Dtheme-animation` | bool | on everywhere except `-Dplatform=esp32p4` | -| `-Dworkspace-tag` | bool; draw the workspace tag row — the macOS shell hands its commands to the native menu bar instead | on except on `-Dplatform=macos` | -| `-Dprebuilt-shaders` | bool | on for a bare `zig build`, off when `-Dplatform` names a shell | -| `-Dtracy` | path to a Tracy source checkout | off | +| `-Dembed-sources` | bool; serve the sources under `/src` | `false` | +| `-Dstamp-commit` | bool; the git commit in `--version` and crash records | on for release builds and the `~/.local` install | +| `-Dtheme-animation` | bool | on except for esp32p4 | +| `-Dworkspace-tag` | bool; draw the workspace tag row | on except for macOS, whose menu bar carries it | +| `-Dprebuilt-shaders` | bool; embed the committed SPIR-V | on for a bare `zig build`, off with `-Dplatform` | +| `-Dtracy` | path to a Tracy checkout | off | | `-Dmacos-identity` | codesigning identity for `pardes.app` | `-` (ad-hoc) | | `-Ddump` | a `dump.zon` to embed in the web shell | none | -| `-Dtest-filter` | substring; run only tests whose name contains it | none | -| `-Dtest-rebuild` | bool; force fresh Zig test compilation, retaining cached C dependencies | `false` | -| `-Dhelix-harness` | native reference executable for live differential tests | `HX_HARNESS`, otherwise `hx-harness` on PATH | -| `-Desp32p4-cols` | u16, the board's grid width in cells | `56` | -| `-Desp32p4-rows` | u16, the board's grid height in cells | `14` | - -The local board build emits an object. Firmware clock, serial port and profiling -options belong to the sibling `05-zig-p4` toolchain's build. - -Two build inputs reach the running binary as ordinary values rather than as -behaviour. `build.zig` reads `.version` from `build.zig.zon` through an untyped -`@import("build.zig.zon")` — one place to bump — and `gitCommit(b)` reads the -revision at configure time. Both land in `pardes_config` and are re-exported as -`pardes.version` and `pardes.commit`, and `pardes --version` prints -`pardes <version> (<commit>)`, or `pardes <version>` alone when there is no -commit: a tarball, a container with no `git`, or a checkout outside version -control all yield null, and the flag has to work anyway. Neither is a question -asked at runtime — a binary that shelled out to `git` would describe whatever -tree it was standing in rather than the one it came from. - -`Restore a.dump` first looks for the relative path in the dump directory: -`DumpDir <path>` when set (a leading `~/` is your home; bare `DumpDir` returns -to the default), else `$XDG_DATA_HOME/pardes` or `~/.local/share/pardes`. -`Config` reports the directory in effect as `DumpDir <path>`, so the line can -be fed back as configuration. If it is absent, Restore -uses the argument as a path as before. Absolute paths and argument-free Restore -retain their existing behavior. `Dump` still honors `$PARDES_DUMP` and otherwise -writes a timestamped file in the default directory. - -A dump keeps each text pane's dot (its selection, or the caret), as acme's -keeps a window's q0 and q1, and a Restore puts it back where the kept view -shows it. It keeps the settings that differ from a fresh session's (`Placement -pardes`, `Verbose off`, a shader), as the root ctl reads them, and a Restore -sets them again; the theme is kept with the layout, and the font stays the -frontend's. REPL bindings are not kept. +| `-Dtest-filter` | run only tests whose name contains it | none | +| `-Dtest-rebuild` | bool; fresh Zig test compilation | `false` | +| `-Dhelix-harness` | reference executable for live differential tests | `HX_HARNESS`, else `hx-harness` on PATH | +| `-Desp32p4-cols`, `-Desp32p4-rows` | the board's grid | 56, 14 | -A restored terminal comes back live: its recorded output (the last MiB of it) -is replayed as history, a dim `── restored history ──` line marks where it -ends, and a new shell starts below it in the directory the old one was in, -the same shell it ran (a `Tty bash` pane comes back running bash). A -view left scrolled back stays where it was. Only the shell is new; nothing the -old one was running is restarted. A command pane comes back finished, with -what it printed and its `exit N`, and is not run again (a command still -running when dumped comes back as `exit ?`). The web shell, which has no ptys, still -shows the history alone. +The version comes from `build.zig.zon`'s `.version`. diff --git a/docs/detached.md b/docs/detached.md index 46e93fd5..e010677c 100644 --- a/docs/detached.md +++ b/docs/detached.md @@ -1,7 +1,7 @@ # Detached sessions -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. +A detached session owns the editor core, pane shells, files, undo history +and layout. TTY and SDL frontends join and leave without ending it. ```sh pardes --detach=work & @@ -9,61 +9,41 @@ pardes --attach=work pardes-gui --attach=work ``` -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. +Bare `--detach` names the session after its pid; bare `--attach` needs +exactly one listening session. The detached process runs in the foreground +unless the shell backgrounds it. Inside an editor, `Attach work` (`SPC s a`) +switches this window to that session and `Detach` (`SPC s D`) closes only +this frontend. -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. - -A message stays until the next key or click dismisses it. With no frontend -attached there is none to come, so a detached session drops each pane's -messages once the newest has been up `MessageLinger` on the clock, checked -by the session's own loop between the editor's steps; /screen then shows -what is current. +With no frontend attached, `/ctl`'s `size C R` sets the screen (160x50 until +then; [fs.md](fs.md#the-root-ctl)), and messages clear after `MessageLinger` +on the clock, since no key will come to dismiss them. ## Files and transport 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`. - -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). +`$XDG_RUNTIME_DIR`, else `~/.local/state/pardes`, beside the session's 9P +socket `pardes-9p-<name>.sock`. A second session cannot take a live name. +TCP and QUIC listeners, mounts and the control filesystem belong to the +session, not its frontends. ## Ownership -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. - -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. - -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. +One poll loop owns core mutation, frontend connections, PTY I/O and file +watches; LSP and selection-pipe workers post completions through a bounded +mailbox. Restore builds the replacement core before changing the current +one, then sends existing frontends a fresh frame. Frontends provide input +and presentation only: clipboard writes are broadcast; clipboard reads, +browser opens and Detach go to the frontend that asked (else the first +attached). Frontends never spawn shells, write session files or watch files. ## Wire and tests -`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. Protocol version 6 -also carries the pointer shape, compact source-context body layers, source-gap -separators and independently sized tag text. Pointer -updates work even when no grid cells change, and graphical clients use the -same compact row and tag geometry for drawing and mouse input. Tag text positions -are separate from physical pane and drag-handle coordinates. Terminal clients retain -the ordinary fixed grid. +`src/detached/wire.zig` owns the versioned frontend protocol (version 8). +Frames are full grids or changes against each frontend's last frame; a new +attachment gets a full grid. Queues are bounded, so a lagging frontend +cannot hold up the session. -`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. +`zig build unit-test` covers the wire, ownership, real frontend connections +and Restore; `zig build fs-test` drives detached sessions over 9P; the +snapshot suites exercise attach, detach and shared screens. diff --git a/docs/divergences.md b/docs/divergences.md index 6679d962..2ab65819 100644 --- a/docs/divergences.md +++ b/docs/divergences.md @@ -1,94 +1,17 @@ # Divergences -What is not on `main`, and what on `main` is known to be wrong. Written so -that moving a bookmark does not quietly orphan work or hide a failure. +What is not on `main`, and what on `main` is known to be wrong, so that +moving a bookmark does not quietly orphan work or hide a failure. -## Two lines, forked at `01104e7c` +## Bookmarks off `main` -`main` is not the only living line, and the other one is not behind it — -they are siblings: +| bookmark | | +|---|---| +| `reload-perf-wip` | unfinished: Reload presentation transport regression | +| `reload` | reload core code with shell-owned allocators | +| `reload-start` | names the Core/Shell seam, deferred Reload request | +| `tty-colors-mouse`, `vibes-ghostty`, `mouse` | older prototypes (divergent), also on the `vps` remote | -``` -◆ rruwvuzm 09-17 editor work: syntax, panes, modal, gui, fs, output -│ ◆ xqxpolmw 2b547e15 main 09-20 "Serve Unix and TCP 9P through cloud9.serve" -├─╯ -◆ lsnxpxtq 01104e7c 09-16 "Add macOS backdrop blur and preserve PDF ink opacity" -``` - -* **`main`** carries the 9P work: the `cloud9.serve` runner, and now the - posted-9P registry (`docs/cloud9.md`). -* **`rruwvuzm`** carries editor work — roughly 1300 lines across - `src/syntax.zig`, `src/panes.zig`, `src/modal.zig`, `src/gui/gui.zig`, - `src/fs.zig`, `src/pardes.zig`, `test/output.zig`, `docs/fs.md`. Reach it - with `jj edit rruwvuzm`. - -The fork matters for one concrete reason: **the cloud9 pin lives on `main` -only**. `rruwvuzm` still pins `ae310a20` (2026-09-14), `main` now pins -`9c4d668c`. Rebasing or merging the editor line will want the newer pin, or -`zig build` there fetches a cloud9 that predates `fs.Server`'s current shape. - -## Other bookmarks - -| bookmark | | | -|---|---|---| -| `reload-perf-wip` | 09-11 | unfinished: Reload presentation transport regression | -| `reload` | 09-10 | reload core code with shell-owned allocators | -| `reload-start` | 09-10 | names the Core/Shell seam, deferred Reload request | -| `ninep` | 08-27 | a 9P design note and a design registry to argue it in | -| `macos-fix` | 07-23 | | -| `full-prototype`, `term`, `tty-colors-mouse`, `vibes-ghostty`, `mouse` | 06-xx | older prototypes, also on the `vps` remote | - -None of these are published to the FreeBSD mirror: only `main` is pushed -there, deliberately, because that box serves a public site. - -## Known-failing on `main`, not caused by the 9P work - -* **`zig build snap` fails `nested-optout`, and the cause is a real bug.** - At the SECOND level of nesting -- a pardes whose shell runs a pardes whose - shell runs a command -- two U+E016 codepoints are prepended to whatever is - typed. U+E016 is 57366, which is vaxis's private-use spelling of **F3** - (`Key.zig`, "kitty encodes these keys directly in the private use area"), so - something in the chain is decoding a capability-query REPLY as a key and - forwarding it to the shell as text. bash then sees - `$'\356\200\226\356\200\226echo'` and answers `command not found`; with a - path it answers `No such file or directory` for a path that exists and runs - perfectly from the same shell a moment later. - - Minimal repro, in a snapshot script: - - ``` - dirmk innerdir - file innerdir/deeper.txt deepest-marker\n - start 31 100 - wait 8000 $ - stable 700 20000 - text (cd innerdir && $(readlink /proc/$PPID/exe) --nested) - key enter - wait 20000 cwd/innerdir - stable 700 20000 - text E=$(readlink /proc/$PPID/exe); "$E" deeper.txt - key enter - settle 5000 - stable 700 10000 - snap probe - ``` - - `bash: E=/home/.../pardes: No such file or directory` -- bash did not even - parse the assignment, because the line does not start where it looks like it - starts. `--nested` is exactly the flag meant to opt a child out of this, so - the fix belongs next to the key-forwarding path in `panes.Terminal.forwardKey` - and whatever answers terminal capability queries on a nested stdin. - -* **pardes does not start headless.** `pardes --9p=<name>` with no terminal - exits 1 from the argument-forwarding path; the installed build fails - earlier still, with `error.NoDevice` opening a terminal device. So the - registry posting above is covered by unit tests - (`zig build 9p-io-test`) rather than by running the editor. - -## Upstream - -`build.zig.zon` pins cloud9 `9c4d668c`. That commit exists because pinning -cloud9 `534c084f` here failed: cloud9's `build.zig` `@import`s each program's -build fragment, and `9harness` was missing from its `.paths`, so the -published package built from a checkout and not from a tarball. The pardes -build was the first consumer to notice. +`ninep`, `fx`, `macos-fix`, `full-prototype`, `term`, and the `before-*` and +`merged-*` bookmarks mark points already in `main`'s history. Only +`main` is pushed to the FreeBSD mirror, which serves a public site. @@ -1,1086 +1,658 @@ # Filesystem -Every native session serves 9P2000 on a Unix socket. Pane shells receive -`PARDES_PID` (the editor's process id), `PARDES_9P` (socket path) and -`PARDES_PANE` (pane serial). The socket is -`$XDG_RUNTIME_DIR/pardes-9p-<pid>.sock`, or lives under -`~/.local/state/pardes` when XDG_RUNTIME_DIR is unset. Detached sessions use -their session name; `--9p=<name>` overrides it. +Every native session serves 9P2000 (not .u, not .L) as a control +filesystem, in acme's manner: panes, columns, tags and the session are files. +This page is the reference for what each file does. The served +[`/README`](../src/fs-help.txt) is its one-screen summary. -A `pardes <file>` launched from a pane forwards Look to that pane over 9P. -`PARDES_PID` alone says the shell is inside pardes; `PARDES_9P` and -`PARDES_PANE` say how to reach it, and a launch that has the first without the -other two refuses rather than opening a second editor. `--nested` opens a -separate editor and withholds `PARDES_PID` from its pane shells, so a pardes -started in one of them runs a session of its own; its 9P service stays -available. +## Connecting -Look resolves the OS filesystem first, then the editor's virtual filesystem. -Explicit paths bypass that search: +The socket is `$XDG_RUNTIME_DIR/pardes-9p-<name>.sock` (else under +`~/.local/state/pardes`), `<name>` being the pid, the `--detach=NAME`, or +`--9p=<name>`. A socket in the runtime directory is also posted in the 9P +registry as `$XDG_RUNTIME_DIR/9p/pardes/<name>` (a symlink to the socket; +[cloud9.md](cloud9.md#the-posted-9p-registry)). -| Editor path | Meaning | 9P server path | -|---|---|---| -| `/n/os/proc/self` | OS filesystem | `/os/proc/self` | -| `/n/self/pane/2/body` | pane 2's text | `/pane/2/body` | -| `/virtual/pane/2/body` | the same, in the editor's own spelling | `/pane/2/body` | -| `/virtual/src/pardes.zig` | source embedded in this build | `/src/pardes.zig` | -| `/n/peer/pane/2/body` | another session's text | peer's `/pane/2/body` | - -The mount name `self` is reserved and maps to the server root, so `/n/self/X` -and `/virtual/X` both name the served `/X`. +Pane shells get `PARDES_PID` (the editor's pid), `PARDES_9P` (the socket) +and `PARDES_PANE` (their pane's serial). -`--mount=peer=work` mounts the named session `work`; the dial can also be an -absolute socket path, `unix!/path`, `tcp!IP!port`, or `quic!IP!port`. -At runtime, use `Mount peer dial` and -`Unmount peer`. Mount dials the peer when it mounts it and fails its write -if nothing answers, `Mount peer /tmp/s: dial failed: no answer` (or `timed -out`, `hung up`), mounting nothing; a peer that goes away later is found out -by the next use, as `look: /n/peer/f: dial failed: no answer`. There are eight named mounts; `os` and `self` are reserved. -Unmount refuses mounts still used by a pane, its working directory, or a -pending Save. Mounts are saved in dumps. Save uses the file's original mount. +**Forwarding.** `pardes FILE` run in a pane (a live `PARDES_PID`, with +`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns +at once, as acme's `B` does. FILE must already exist; a name that does not +resolve, or a missing `PARDES_9P`/`PARDES_PANE`, starts a separate editor +instead. Bare `pardes` in a pane refuses and names `--nested`. -`pardes --9p-tcp='tcp!127.0.0.1!5640'` adds a TCP listener alongside the Unix -socket. Build with `-Dquic=true` and system OpenSSL 3.6+ to enable QUIC; -`--9p-quic='quic!127.0.0.1!5641'` adds its listener. Both accept numeric -IPv4/IPv6 addresses, not DNS names. Listener port zero chooses a free port; -`/listeners` reports all active dial addresses. +`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or +the session goes away (exit 1), as acme's `E` does. Use +`EDITOR='pardes --wait'` (`GIT_EDITOR` follows `EDITOR`), so fish's Ctrl-O, +`git commit` and `crontab -e` read the file after you close its pane. +`--nested` runs a separate session whose shells do not forward to it. -All connections have session access, including `os`. TCP is unencrypted. -QUIC uses an ephemeral TLS identity without peer verification or login. -It carries 9P2000 on one bidirectional stream with ALPN `pardes-9p`. -Unix and TCP connections share sixteen slots served by cloud9's `std.Io` -runner; QUIC has sixteen of its own on the editor's poll loop. A client -that finds every Unix/TCP slot taken gets an Rerror `too many connections` -to its Tversion, and the log an `err - 9p: too many connections` record. OpenSSL's -internal buffers are separate, dynamically allocated memory. - -[Plan9port's client](https://9fans.github.io/plan9port/man/man1/9p.html) can -drive Unix or TCP without a kernel mount, and `9ns` mounts the tree in a -private namespace: +**Clients.** ```sh -9p -n -a "unix!$PARDES_9P" read index -9p -n -a 'tcp!127.0.0.1!5640' ls pane/1 -9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"' +9p -a "unix!$PARDES_9P" read index # plan9port, no mount +9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"' # private mount +9ns --mntgen # the whole registry at /mnt/9p ``` -(Under `9ns --unix`, `$NINE_MOUNT` is that session's root itself.) +Under `9ns --unix` the session is `$NINE_MOUNT` itself and exists only inside +that command. Under `9ns --mntgen` every posted session is +`$NINE_MOUNT/pardes/<pid or NAME>/`; take the name from `$PARDES_9P`. A dead +session's entry stays listed and answers `Input/output error`, so name the +session rather than globbing. `Tty9p` gives one pane's shell a kernel mount +at `$PARDES_MOUNT` ([v9fs.md](v9fs.md)). -A `9ns --unix` mount lives in the private namespace of the command it runs, -and nothing outside that command sees it. The mount everyone on the machine -shares is the registry one, `9ns --mntgen` (default `/mnt/9p`): every running -editor posts itself there, so `$NINE_MOUNT/pardes/<pid>/` is that editor's tree -for any process, and `$NINE_MOUNT/pardes/NAME/` a `--detach=NAME` session's. -9ns exports `$NINE_MOUNT` to everything it starts, so a script checks that -variable to know the mount is there, and takes the name from `$PARDES_9P` -(`pardes-9p-<pid or NAME>.sock`). A new pane made through `pane/new` is a scratch named -`<dir>/+New` until it is given a name, where `<dir>` is the session's -directory (the one pardes started in), whichever pane last had the keyboard: -no pane asked for it, as acme's new window has acme's directory. So is a -`New` written to a column's `exec` or to `/tagexec`, a word in a tag no -pane owns. A column may hold no pane, as in acme: -`Newcol` makes one empty, and closing a column's last pane leaves it empty -with its tag holding the keyboard (`focus` reads empty) and logs only the -`del`. `pane/new` places its pane as acme's makenewwindow(nil) does: in the -active column, filling it when it is empty, else taking the bottom half of its -last pane ([where new panes go](tags.md#where-new-panes-go)). `Delcol` closes the column. Closing the -session's last pane quits pardes; see [tags](tags.md#empty-columns). +plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write +pane/3/body` replaces the whole body where acme would append. Append with +`>>` through a mount. -For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html), -use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp` -with `port=5640`. Set `uname`, `dfltuid`, and `dfltgid` for the local user. -Leave `aname` empty. The opt-in -[Linux v9fs experiment](v9fs.md) tests a kernel mount in a separate subprocess -namespace (`zig build v9fs-test`, requiring explicit mount authorization). -Neither 9P2000.u nor 9P2000.L is implemented. -Existing Plan9port/v9fs clients need a userspace bridge for QUIC. +For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html) +use `version=9p2000,cache=none,access=any`, `trans=unix` (or `trans=tcp` +with `port=`), `uname`, `dfltuid` and `dfltgid` for the local user, and an +empty `aname`. -## The served tree +**Listeners.** `--9p-tcp='tcp!127.0.0.1!5640'` adds TCP; +`--9p-quic='quic!127.0.0.1!5641'` adds QUIC (build with `-Dquic=true`, +OpenSSL 3.6+; ALPN `pardes-9p`, an ephemeral TLS identity, no peer +verification). Addresses are numeric IPv4/IPv6; port 0 picks one; `/listeners` +reads them back. Every connection has full session access, `/os` included, +and TCP is unencrypted: use loopback. Unix and TCP share 16 connection slots; +a 17th client's Tversion gets `too many connections` (and the log +`err - 9p: too many connections (N turned away)`). QUIC has 16 of its own. +Plan9port and v9fs need a userspace bridge for QUIC. -``` -/README this guide, also src/fs-help.txt -/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial - (the name as the log shows it: one line of UTF-8, a newline in it `\n`, - a backslash `\\`, a byte not UTF-8 `\xNN`) -/status pid, version and pane count -/look write a line: a right click on it at the active pane; read: the serials the last - look, exec or ctl write touched (made, else acted at) -/exec write a line: a middle click; read the same serials -/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>, - dump|restore <path>, err <serial|-> <file>: <why>; - write follow to that open to wait for more -/screen rendered screen JSON; frozen per open handle -/listeners the session's dial addresses -/focus the serial of the pane with the keyboard; write a serial to give it the keyboard, - which makes its column the active one as a click there would -/ctl the settings, one a line as a write takes them; write a setting or a session builtin; - `size <cols> <rows>` sets the screen of a session no frontend is attached to - (`--detach`, 160x50 until then; refused while a frontend owns the size), - from 20x6 to 4096x4096 (outside that, `invalid size`, EINVAL), and refused - when a column has not the rows for its panes' minima, each its tag and 2 - rows, the minimum placement keeps; so a size once taken is taken again, - and growing is never refused. A pane the resize took under its minimum - gets its rows back from its column's others; a terminal's pty follows its - pane on every resize -/commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it: - Edit is both, at the active pane from the root; a pane's word such as Undo or Msg - is refused at the root), a setting's values, then ` -- ` and what it does -/recent the files opened lately, closed ones too, most recent first, a line each: - `open <path>` or `closed <path>` (read-only; `Recent` shows them in a pane, - a look at a row reopening the file at its last dot; kept across sessions - in $XDG_STATE_HOME/pardes/recent, 200 files, the oldest closed one dropped - first, never an open one; only files on disk, not a name never saved nor - /virtual/; a name escaped as /index's is; acme has none, its dump and - Load the nearest) -/layout one line per column (16 at most; the board 6; Newcol past that fails, - `no space for a column: 16 max`, ENOSPC; and each at least 10 cells - wide, so Newcol from a column under 20 fails `this one is too - narrow to split`, ENOSPC), left to right: serial index x width current|notcurrent - (the column with the keyboard now) empty|full pane-serials...; then active - <serial>: acme's activecol, which the keyboard leaving for another column's - tag does not move, so the two can differ -- the active column, where - pane/new and a look place a pane next (- when there is none) -/tag the workspace tag; > replaces it, >> appends, one line; a control character - but a tab (a NUL too), DEL, a C1 control or bytes not UTF-8 are refused, `invalid - tag text: ...` (EINVAL), in any tag, a pane's or a column's too -/tagexec write a word: a middle click on it in the workspace tag; read as /exec. A - pane's word (Undo, Msg, Save, Edit too) is refused there and at a column's exec, - `not a session control message "Undo": write it to pane/<n>/ctl` (EINVAL), - never done at the pane with the keyboard; a tag's own words (New, Tty, - Find, Grep in a column's) run -/col/<n>/tag the tag of the column with serial n, the same way -/col/<n>/ctl write Delcol, Joincol, New or Tty: each acts on that column, as from its tag -/col/<n>/exec write a word: a middle click on it in that column's tag; read as /exec; rmdir col/<n> closes - an empty column (a column with panes is refused, ENOTEMPTY) -/pane/new open it to make a pane (the bottom half of the active column's last - pane, acme's coladd; with no room there, last all the same, taking half - the tallest pane's rows); the read answers that pane's serial. A session holds - 64 panes (16 on the board); at that, every route that would open one -- this - open, look, exec, New, Tty -- fails with `no space for a pane: 64 max` (ENOSPC through 9ns, which has - no word for ENFILE) and an err - record, and look reads back empty; a column with no room for one - (each pane keeps its tag and 2 rows) refuses it the same way, - `no space for a pane in that column` (docs/tags.md) -/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll - errors event look exec tagexec (a word as a click in its tag), plus - pty/{ctl,status,data} on terminals -/os/ the host filesystem -/src/ the editor's embedded sources, only when built with -Dembed-sources=true -``` +**Look paths and mounts.** Look resolves the OS filesystem first, then the +editor's own tree. Explicit paths skip that search: -`/layout`, `/tag` and `/col` go past acme, which serves no column files -- -its columns are only where a window sits. They are here so a script can see -where the panes are (`/index`'s last word is each one's column serial) and -edit the tags a person clicks in: a column tag is one line, a newline -written into it a space, and a truncating write (`echo Make > col/3/tag`) -clears it and drops the newline that ends it, as a pane tag's does. A -column is named by its serial, as a pane is: it stays while the column -lives, whatever opens or closes beside it, and is never reused; /layout -gives each column's serial and its index left to right, and the log says -`newcol <serial>` and `delcol <serial>` as columns come and go, and after a -Restore `restoredcol <old> <new>` for each column as `restored` does for -panes. Joincol folds a column into the one on its right, which keeps its own -serial and tag; the joined column's panes go below that column's own, in -their order, and its serial is gone (`delcol`). The root ctl -takes no column word: its `Delcol` is refused, pointing at -`col/<serial>/ctl`. +| Look path | Meaning | Served path | +|---|---|---| +| `/n/os/proc/self` | the host filesystem | `/os/proc/self` | +| `/n/self/pane/2/body`, `/virtual/pane/2/body` | this session's tree | `/pane/2/body` | +| `/virtual/src/pardes.zig` | sources embedded with `-Dembed-sources=true` | `/src/pardes.zig` | +| `/n/peer/pane/2/body` | a mounted session | the peer's `/pane/2/body` | -Control messages are split by what they act on, as acme keeps window verbs -on a window's ctl and webfs and upas/fs keep session settings on a root ctl. -Each builtin declares its scope in src/builtins.zig (`scope = .session`; -every setting is one, the rest act on a pane). `/ctl` takes the session's -builtins, one a line, at whichever pane has the keyboard as each runs -- -`Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`, -which quits the editor as acme's does (it refuses once over the panes with -unsaved text, a `+New` scratch of 100 bytes or more too, whether it came -through a `ctl` write or a click: first one `/log` record per pane, -`unsaved <serial> <name>`, then the write fails with one line that is -never a list cut short, `<name>: Modified (Exit again to discard)` for one -pane, `4 unsaved panes: Modified (Exit again to discard)` for more, and -the `err` record says the same. On screen the notice is short, `3 unsaved -panes — Exit again to discard`, and the whole list stays in one `+Unsaved` -pane, filled again by each refusal: a row a pane, `<name>: Modified` (a -`+New` scratch with its serial, as scratches share a name, `/dir/+New -(pane 12): Modified`), then `Exit again to discard`. Restore, -Del and Delcol refuse the same way, with their own word; an `Exit` after more editing refuses -again naming only the panes edited since the last refusal, as acme's does, -and an `Exit` with nothing edited since quits, throwing all of it away; a scratch or a -command's output under 100 bytes is not asked about, as acme's winclean -asks about no small unnamed window (so a scratch's `dirty` of 1 in `/index` -blocks nothing until it holds 100 bytes: it has no file to be out of step -with, and a few lines typed to try something are not work to lose); `Restore`, which replaces every pane, -asks the same first -- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in -`DumpDir` (default `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) -and logs `dump <path>`, and `Restore` with no path takes the last one; a -Restore puts a new editor under every client, so the write of it is -answered and then every connection is hung up, their fids naming the old -editor's panes (a 9ns older than cloud9 2a7137c could fail the write with -ECONNRESET all the same, when another request's send met the hang-up -before the answer was read; the log is the authority): dial again -- a 9ns -mount is one such connection, so after a Restore stop it and start 9ns -again, or every file under it fails -- and the new -log names the restored panes and -`restore <path>`. Restored panes have new serials (`restored <old> -<new>` maps them) and so may columns (`restoredcol`; a fresh editor counts -column serials from 1 again, so they often come back the same). A command -pane comes back showing what it showed, its tag saying how it ended, -`exit 0` (`exit ?` for one still running when dumped, whose end nobody -saw): its command is not run again. REPL bindings are not dumped. The answer has 200 ms to leave before the cut, so a slow -client may see only the cut; the log's `restore <path>` is what says the -Restore happened. Keeping connections across it would mean carrying serials -and opens into the new editor, which acme, whose Load only adds windows, -never needed); and `Kill`, which -does not quit but stops commands, as acme's does: bare, every command pardes -started, and `Kill make ls`, those whose line begins with one of the words. A -command pardes started is a command pane's, until its child exits -- Kill -sends SIGTERM to its running job, to its shell and to every `&` job the -line started, so the whole command stops and leaves nothing running (of -`sleep 30; echo done` the `echo` never runs; an `&` job outlives only a -command that exits on its own) -- or a line it typed into -a terminal (a -word written to `exec`, a middle click on one, a `pty/run`), from its shell's -start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the -"kill" note, which ends a process -- and never signals the shell itself; -in a shell running without job control (`set +m`) the job shares the -shell's group, so there is none to signal: Kill says `Kill: no job to -signal`, and a write of it to `ctl` fails with that; with nothing running -it says `Kill: nothing running`; and, of a typed line, only the -foreground job, after which what the rest of the line does is the shell's -affair: of `sleep 30; echo done` typed at a prompt, bash (saying -`Terminated`) and fish (saying `Job 1, 'sleep 30' terminated by signal -SIGTERM`) both go on and run the `echo`: SIGTERM, unlike an interrupt, -ends only the job, not the line -- -and reads every setting there is, one a line, in the words a write of it -takes (`Verbose on`, `WindowOpacity 70`, `PanelSlide off`, `DumpDir -<the directory in effect>`, `LocationsConfig ...`), so writing what it reads -back changes nothing; a setting the frontend cannot show (`Lift`, -`GripWidth` on a terminal) is refused as `Lift is GUI-only, invalid here` -(EINVAL: a request this build cannot take), and so is a builtin only the -GUI has (`Fonts`), written to an `exec` too, where it would otherwise run as -a shell command; a `Shell` or `Tty` naming no -shell says `shell "x" not found` (ENOENT); a `DumpDir` whose last -directory is missing has it made at the Dump, and one further up missing -says `Dump <path>: no such directory` (ENOENT), one that is no directory -(`/dev/null`) `Dump /dev/null/pardes-<time>.zon: /dev/null is not a -directory`, each naming the dump file it would have written; platform and startup facts are `/status`'s and the -Config window's, not settings. A pane's `ctl` takes the builtins that act on -a pane (`Del`, or `Del k`/`Del j` to give its rows to the pane above or -below, `Save f`, `Collapse`, which folds that pane, `Undo` and -`Redo`, which step its body through its last 256 edits -- with none left -they say `Undo: nothing to undo` and the write still succeeds, as acme's -Undo is silent --, `Find pat`) -beside acme's `get`, `lock` and `unlock`. The column words are pane words -too, acting on the column that pane is in: `Delcol`, `DelAbove`, `DelBelow` -and the focus moves `Left`/`Right`/`Up`/`Down` from it. `Joincol` and -`Newcol` are the root's: they act on the column of the pane with the -keyboard, and `Joincol` needs a column to its right (`Joincol: no column to -the right`). The words are case-sensitive and do not alias: acme's verbs -are lowercase and the builtins keep their tag spelling, so `Get` is no word -and `del` none either. +`--mount=peer=work` or `Mount peer <dial>` mounts a session name, an absolute +socket path, `unix!/path`, `tcp!IP!port` or `quic!IP!port`; `Unmount peer` +removes it. Mount dials at once and fails if nothing answers (`dial failed: +no answer`, `timed out`, `hung up`). There are eight named mounts; `os` and +`self` are reserved. Unmount refuses a mount a pane, a working directory or +a pending Save still uses. Mounts are dumped. -A write is checked whole before any line of it runs, and a line is refused -in Plan 9's words for a ctl (kernel/misc/parse.c:82-97), quoting the line: -`unknown control message "X"`; `wrong #args in control message "X"` for an -argument to a builtin that takes none, or none to one that needs it (`Msg`, -`Mount`, `Find`, a setting's value but a switch's, which flips bare); -`bad value in control message "X"` for a setting's value it does not take -(for `Theme`, whose names are any case, naming every theme that shares -the name's first letter when they fit the 128 bytes a 9P error carries, -else the nearest few, since all of them, `Themes`'s list, are too many); -and `not a session control message "X": write it to pane/<n>/ctl` or `not -a window control message "X": write it to /ctl` for a word of the other ctl. 9ns maps them all to EINVAL, and a write -refused here has done nothing. A line that then fails as it runs fails the -write with the error the editor reports for it, in its own words, which -name what failed, the line not quoted after them (only a line refused -before it runs, as no message at all, is quoted), e.g. `Mount: already -mounted` (EIO), and `control message needs its -argument "Save"` (EINVAL), for a builtin that would have asked at a prompt -(a `Save` on a scratch) rather than open one nobody is there to answer. A -builtin that means nothing without its argument (`Mount`, `Msg`, `Find`), -written bare to an `exec` or `/tagexec`, is refused the same way, `wrong -#args in control message "Mount"` (EINVAL), before it runs. The -lines before a failing one have taken effect and those after it never run, -which is what acme's ctl loop does (editors/acme/xfid.c:600-790). An error -that only happens as the editor performs what a line asked for -- a `Save` -whose disk write fails -- fails the write too, once the editor has tried -(`Save /root/x.txt: access denied`, which 9ns reads as EACCES, a shell's -`Permission denied`), and changes nothing: a scratch -keeps its name and stays a scratch, a clean file stays clean, a dirty one -dirty. Like any write, a ctl write answers once the editor has -performed what it asked for (a save written, a shell started). A click on -the same word, or the word written to `exec`, still opens its prompt. +A session may open its own tree through a mount (a Look at +`$m/pane/2/body` from the editor serving `$m`): requests are answered on the +connection's task while the editor waits in its syscall. Through QUIC that +still hangs. -`/commands` lists every builtin the registry holds, in registry order, one -a line: its word, `arg` when it takes one, `root`, `pane` or `both` for the -ctl that takes it, for a setting that chooses among words those words, -comma-joined, then ` -- ` and one sentence of what it does (a builtin's doc -comment's first sentence, a setting's doc), e.g. +## The tree ``` -Newcol root -- An empty column right of the keyboard's, its tag taking the keyboard. -Save arg pane -- Write the pane's text to its file, or to the file its argument names. -Verbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off. -Placement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules. +/README the one-screen guide (src/fs-help.txt) +/index a line per pane: serial kind dirty name column +/status pid, version, panes +/look /exec write a line: a right / middle click at the active pane; read: the serials touched +/log the event log; write `follow` to wait for more +/screen the rendered screen as JSON +/listeners dial addresses +/focus the serial of the pane with the keyboard; write one to move it +/ctl settings and session builtins +/commands every builtin, one a line +/recent files opened lately: open|closed <path> +/layout a line per column, then `active <serial>` +/tag /tagexec the workspace tag, and a word clicked in it +/col/<n>/ tag ctl exec of column <n>; rmdir closes an empty one +/pane/new open it to make a pane; read answers the serial +/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll + errors event look exec tagexec, and pty/{ctl,status,data,run} on terminals; + rmdir closes the pane +/os/ the host filesystem +/src/ the editor's sources (only with -Dembed-sources=true) ``` -Every builtin has its sentence; a test fails the build of one without. Such a setting written bare steps to its -next value, so a two-valued one flips (`Crt`, `Placement`, `BootShell`), as -its word clicked in a tag does; a value it does not take is refused with -`bad value in control message; takes ...` naming those it does. It is -generated from the registry, so it is always this build's own list. +Panes and columns are named by serials the editor gives: stable while they +live, never reused. Nothing is created by a listing, stat, walk or read: +only an open of `/pane/new` makes a pane (so `ls -l` and `find` are safe), +only `rmdir` of `/pane/<n>` or an empty `/col/<n>` removes. Tcreate is +refused everywhere. + +`/index` rows are `serial kind dirty name column`, kind `text`, `term`, +`pdf` or `image`, the name `<dir>/+New` for an unnamed scratch. Names may +hold blanks, so split `head, col = row.rsplit(maxsplit=1)`, then +`serial, kind, dirty, name = head.split(maxsplit=3)`. Names in `/index`, +the log and a terminal's tag are escaped: a newline `\n`, a backslash `\\`, a byte that +is not UTF-8 `\xNN`. + +## Rules for every file + +**Failure.** A write fails whenever what it asked for fails, and logs one +`err <serial|-> <file>: <why>` record, with no `msg`. Only writes log: a +refused open or truncation (an OTRUNC open such as `> data` after a failed +`addr`), create or remove answers its error alone, as do a write to +`pane/new` (`permission denied`) and a write on a read-only fid (`bad use +of fid`). Errors are words (Plan 9's where pardes has none of its own), +never a C library string. Through 9ns the kernel sees an errno 9ns reads +from those words (cloud9 `9ns/src/nine.zig`, `enameToErrno`): `control +message`, `invalid` or `bad ` is EINVAL (malformed input); `no such`, `not +found` ENOENT; `in use` EBUSY; `no space` ENOSPC; `denied` EACCES; anything +else, such as `no match for regexp`, `address out of range` or `Modified`, +EIO. The `err` record has the words; a shell sees +only the errno, most often `Invalid argument` or `Input/output error`. + +Not failures: a look that finds nothing (it answers nothing and logs one +`err`), an Edit `x` that matches nothing, `Undo` with nothing to undo (a +`msg`), and a command pane's command, which ends in its own time with an +`exit` record. + +**Command lines.** `look`, `exec`, `tagexec`, the three kinds of `ctl` and a +column's `exec` take one command a line. The whole write is checked first +(a control character other than a tab fails it all, EINVAL); then lines run +in order, and a failing line fails the write after the lines before it took +effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big +write into pieces of at most one message (msize 8192, less the header), and +each line runs once it is whole. A write that does not fill its message is +whole, so its last line runs even without a newline (`printf Save > exec`). An `Edit` whose `{` or +`a`/`c`/`i` text is still open waits for the next write on that open, and +fails at the close if it never ends (``unmatched `{'``). A line held past +1 MiB is refused. + +**Answers.** Reading `look`, `exec` or `tagexec` answers the serials the +last command made, or else the pane it acted on or focused, one a line. An +open that wrote reads its own last answer; an open that never wrote reads +the session's last. A read is a stream: once read, the next read on that +fid is EOF until the next write (or seek to 0). With other clients about, +write and read on one open: +`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`. + +**Snapshots.** `/index`, `/layout`, `/recent`, `/commands`, `/status`, +`/listeners`, a read-only `ctl`, `/log`, `/screen` and a terminal's `body` +freeze at the open, so one read in several chunks never splices two moments; +open again for now. Such an open, or a `run`, `event` or `pty/data` open, +takes one of 64 open records; past that the open fails `too many open +files`. + +**Held reads.** A read with nothing to give yet (a followed `log`, `event`, +`pty/data`, `pty/run` before its answer) waits in the editor and is answered +when news comes. A second read on that open meanwhile fails `file in use`. +A read the client flushed is dropped. Through a FUSE mount bash's `read -t` +cannot time out: wrap the loop in `timeout N`. + +**Stats.** Lengths are real (for `event` and `pty/data` the next record's, +zero when none waits; for `log` what an open would freeze). The qid +version of `body`, `data` and `xdata` is the pane's revision, so a stat sees +an edit land. Modes are 0644/0666, 0444 read-only, 0222 write-only. + +## look and exec + +A line written to `look` is a right click on it: + +- a path opens the file (the pane already showing it, if any); `file:12` + selects line 12, newline included; `file:12:5` puts the caret at line 12, + byte column 5; `file:<addr>` takes any address (below), **evaluated from + the file's dot**: `file:/re/` finds the next match after the selection, + `file:0/re/` the first. `:addr` addresses the pane itself. +- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines + too. +- a directory types `ls` into a terminal idle there, else opens one there. +- a URL opens in the browser. +- a plain word selects its next place after dot, wrapping (`LookWord list` + on the root ctl lists every place in a `+Search` pane instead). In a + terminal a word is always listed, rows spelled `@p3:12:5-9`. + +A miss logs `err <serial> look: ...` (`no match for "zzq:#3"` quoting what +was written when nothing by that name exists; `<path>: no match for regexp` +or `address out of range` when the address fails), opens nothing, and leaves +`look` reading empty; the write succeeds. + +A line written to `exec` is a middle click: + +- a builtin word runs (`/commands` lists them): `Save`, `Del`, `New`, `Tty + [shell]`, `Msg text`, `Find`, `Grep`, `Edit ...`, `Mount`, ... + A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is + `wrong #args in control message "Msg"`. +- acme's words run as pardes's where it has one (`Put` is `Save`, `Delete` + a `Del` that does not ask); the rest (`Get`, `Putall`, `Snarf`, `Cut`, + `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`) are refused, `invalid: + acme's Get is not a pardes builtin: ...`, never run as commands. So is a + GUI-only builtin (`Fonts`) on another frontend. +- anything else is a command line, at most 1024 bytes. At a terminal idle + at an empty prompt it is typed into that shell. Anywhere else it runs as + a **command pane**: a terminal running the root ctl's `Shell` (`$SHELL`, + else `/bin/sh`) with `-c` and the line, in the pane's directory, with job + control on. Its tag reads `<dir> (<line>) running`, then `exit N`; the log + says `run <serial> <line>` and `exit <serial> <N|?>`; `exec` reads back its + serial. A typo ends `exit 127`. The directory's next command reuses a + finished command pane, below what it showed; one still running gets a + second pane. From a pane whose directory is gone nothing runs: `exec: + <dir>: no such directory` (ENOENT). + +The root's `look` and `exec` act at the active pane and log as that pane's; +`/pane/<n>/look` and `exec` at pane n; `/tagexec` and `/col/<n>/exec` +click in the workspace's or that column's tag, run commands in the session's +directory, and log as `-`. A pane's word (`Undo`, `Msg`, `Save`) is refused +at `/tagexec` and a column's exec: `not a session control message "Undo": +write it to pane/<n>/ctl`. + +A background job (`&`) outlives a command that exits on its own; its output +goes on below `exit N` until it lets go of the pty. `Kill` (root ctl) +stops the commands pardes started: bare, all; `Kill make ls`, those whose +line starts with one of the words. For a command pane it signals the whole +line, `&` jobs included; for a line typed into a shell only the foreground +job (SIGTERM), and the shell decides the rest. With nothing running it says +`Kill: nothing running`. Kill does not reach a REPL's code: use `sig INT` on +its `pty/ctl`. + +## The root ctl + +Reading `/ctl` gives every setting, one a line, in the words a write takes +(`Theme orchard`, `Verbose on`, `Placement acme`, `DumpDir <dir>`, +`Shell /bin/bash`, ...), so writing back what it reads changes nothing. +Writes take settings and session builtins (`scope = .session` in +`src/builtins.zig`), acting at the pane with the keyboard: + +- A setting written bare steps to its next value (a switch flips; so do + `Placement`, `BootShell`, `Crt`). A value it does not take is `bad value in + control message; ...` naming what it takes; `/commands` lists them. A + setting the frontend cannot show is refused (`Lift is GUI-only, invalid + here`). +- `Newcol` makes an empty column right of the keyboard's, halving the active + column. `Joincol` folds the keyboard's column into the one on its right + (its panes go below that column's), `Joincol: no column to the right`. +- `Exit` quits. It refuses once while panes hold unsaved text: one `unsaved + <serial> <name>` record per pane, then the write fails `<name>: Modified + (Exit again to discard)` or `4 unsaved panes: Modified (Exit again to + discard)` (EIO), and the list stays in a `+Unsaved` pane. The same word + again with nothing edited since discards; after more editing it refuses + again, naming only the panes edited since. `Restore`, `Del`, `Delcol` and + a pane's `get` refuse the same way with their own word. A `+New` scratch + under 100 bytes, or a command's output, is never asked about. +- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir` + ([config.md](config.md#dumps)) and logs `dump <path>`. `Restore [path]` + replaces every pane (bare: the last dump this session wrote). The Restore + write is answered, then **every connection is hung up**: dial again, and + restart a 9ns mount. The new log has a `new` per pane, `restore <path>`, + then `restored <old> <new>` per pane and `restoredcol <old> <new>` per + column. Undo history and REPL bindings are not restored; a command pane + comes back showing how it ended (`exit ?` if it was running) and does not + run again. +- `Kill [word...]` (above), `Mount name dial`, `Unmount name`, `Theme x`, + `size <cols> <rows>`. +- `size C R` sizes a `--detach` session no frontend is attached to (160x50 + until then): from 20x6 to 4096x4096, else `invalid size`; refused while a + frontend owns the size, and when a column would lose its panes' minimum + rows (`size: too small for the panes, each its tag and 2 rows`). + +A write is refused whole, before anything runs, in Plan 9's words: +`unknown control message "X"`, `wrong #args in control message "X"`, +`bad value in control message ...`, or a word of the other ctl: `not a +session control message "Undo": write it to pane/<n>/ctl`, `... "Delcol": +write it to col/<serial>/ctl`, `not a window control message "X": write it +to /ctl`. A builtin that would open a prompt (`Save` on a scratch) fails +`control message needs its argument "Save"`. A line that fails as it runs +fails with the editor's words (`Mount: already mounted`, `Save /root/x: +access denied`), changing nothing. + +## Columns and tags -`/focus` reads the serial of the pane with the keyboard, and a serial written -to it gives that pane the keyboard, off any column or workspace tag that had -it -- rio's `current` written to a window's `wctl`, named once for the whole -tree since there is one keyboard. While a column's or the workspace's tag has -the keyboard no pane does: `/focus` reads empty and every pane's `ctl` says -`notcurrent`. A write gives the keyboard and nothing else: a folded pane -stays folded (unfold it with `Collapse` on its ctl), as rio keeps `current` -apart from `unhide`. A serial no pane has fails with `no such -pane`; anything but a number, with `ill-formed control message`. +`/layout` has a line per column, left to right: `serial index x width +current|notcurrent empty|full pane-serials...`, then `active <serial>` +(acme's activecol: where `pane/new` and a look put the next pane; `-` when +none). `current` is the column with the keyboard now, which may differ +from the active one. `/index`'s last field is each pane's column serial. -A pane is made by **opening** `/pane/new`, and closed by Tremove on -`/pane/<n>` (`rmdir`), which is the only remove the tree serves; Tcreate is -refused everywhere, as it is in acme. Reading the open fid answers the serial -of the pane that open made, so `n=$(cat /pane/new)` makes one and names it in -a line. Each open makes another pane, and two reads of one fid answer the same -serial: the open acted, the read only observes. Closing the fid leaves the -pane. +A session holds 16 columns (`no space for a column: 16 max`, ENOSPC), each at +least 10 cells wide (`Newcol` from a column under 20 fails `this one is too +narrow to split`). Since root `Newcol` halves the active column, reach 16 by +writing `Newcol` to the widest column's `exec`. -This is `/net/tcp/clone`'s mechanism, not acme's `new`, and the difference is -deliberate. acme allocates during the *walk* and lets the walk land inside the -new window, so `/dev/new/body` works in one step (acme(4): "accessing any file -in `new` creates a new window"). acme can also afford to list `new`, because a -Plan 9 directory read carries the stat of every entry and nothing walks. A -kernel or FUSE mount is not so lucky: it walks and stats each name a listing -gave it, so an allocate-on-walk name would make a pane per `ls -l`. Allocating -on open instead keeps `new` listed and `ls` honest — a stat is not an open — -at the cost of acme's one-step `new/body`. Nothing in the tree is created by -list, stat, walk or read; only that one open. Every other name in `/pane` is a -serial. +`/col/<n>/ctl` takes `Delcol`, `Joincol`, `New` and `Tty` for that column; +`/col/<n>/exec` is a click in its tag; `rmdir /col/<n>` closes an empty column +(one with panes: ENOTEMPTY). A column may be empty, as in acme: `Newcol` +makes one, and closing its last pane leaves it (the keyboard goes to its +tag, `focus` reads empty, the log says only `del`). Closing the session's +last pane quits pardes. The log says `newcol <serial>` and `delcol <serial>`. -A session can open its own tree through a mount: a Look at -`/mnt/9p/pardes/<me>/pane/2/body` from inside that very editor opens it, and -a Save of that pane writes back through the mount into pane 2. Requests on -the Unix and TCP listeners are answered on the 9P connection's own task, not -by the editor's loop, so the realpath, the stat and the read the editor makes -out through the mount come back while it waits for them. The one rule is -whose turn it is with the core (`pardes.turn`): the editor has it, and gives -it up while it waits for input and while a step of it is out in a syscall. A -step of a connection task's own -- a Look written to `look` -- goes out the -same way, and the editor waits for it to return before it takes a step of -its own. While any step is out, a request that would change a pane (a write, -a truncation, an rmdir) parks in the engine until none is; everything else, -opening `pane/new` and `screen` included, is answered at once, which is why a -Look at any path in the tree comes back. A write into the tree from the -editor itself only ever happens between steps (a Save), so nothing it waits -on out there is a request that has to park. QUIC is still served on the -editor's loop, so through QUIC the old hang remains. `/n/self/...` names the -same tree without leaving the process. +`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag. +A pane's starts with its computed path (an image's with its mode words, a +PDF's with its page), then the editable text. `>` replaces the editable text +(the default words too: `echo Make > tag` leaves only `Make`) and drops the +one newline that ends it; `>>` appends, so `printf ' Make' >> tag` (the +blank matters; `echo` would start a second line). A pane tag may hold +several lines; a column or workspace tag is one, a newline written into it +becoming a space. Control characters other than tab, DEL, C1 controls and +non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at +most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). A clear is an ordinary edit +and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen. -`/look` and `/exec` are the editor's two clicks, one per line of a write: +## Panes -- a line written to `look` is a right click on it: a path opens a file, - `file:12` jumps to a line, a directory types `ls` into a terminal idle - at an empty prompt there, else opens a terminal there that runs - `ls` once (pardes has no directory listing pane of acme's kind; a shell - in it is where one goes on from a directory), a URL opens in the - browser, and a plain word, as acme's look3 does, selects its next - place in that pane after the dot, wrapping at the end, opening nothing - (`LookWord list` on the root ctl lists every place in a `+Search` pane - instead, as pardes did before; `LookWord search` is the default). In a - terminal, which has no dot to go on from, a plain word is always listed - in a `+Search` pane, its rows naming the terminal as - `@p<serial>:<line>:<cols>`, the columns a range, `@p3:12:5-9` (bytes 5 - through 9 of logical line 12, 1-based, both ends in), which a look of the - row selects, - and look reads that pane back. -- a line written to `exec` is a middle click: a command word from - `src/builtins.zig` (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`, - `Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...; - `Tty`'s argument is the shell it runs, `Tty fish`, and `Tty` on a pane's - ctl opens a new terminal pane, not in that one: in the active column, as - acme's makenewwindow puts a new window (util.c:456-467: the active column - first, then the pane's own), under the text with room or halving the - tallest, never shorter than its tag and 2 rows), - or anything else, a command line. Written at a terminal idle at an EMPTY - prompt it is typed into that shell -- any line that is no builtin, so - `Delcol x` at a terminal goes to the shell -- (and a terminal whose shell - exits, `exit` typed or run, closes its pane). Nothing is ever typed over - text someone typed at a prompt and did not send. From anywhere else -- a - file, a scratch, a tag, a terminal with a line typed at its prompt or - whose tty a program holds -- it runs as a command pane (from a pane whose - directory is not there it runs nothing and makes no pane, `exec: <dir>: - no such directory`, ENOENT, as `Tty` there does; one whose shell the host - cannot start ends at once, `exit <serial> 127` in the log, its tag no - longer running, nothing for Kill): a - terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) run - with `-c` and the line, in the pane's directory, with job control on - (bash, sh, dash, zsh, ksh `-m`; fish `status job-control full`), - which shows its output and then `exit N` (its tag reads `<dir> (<line>) - running`, then `exit N`), and stays. The command is over when its process - exits, as in acme, not when its terminal closes: a job it left in the - background prints on below `exit N` until it lets go of the pty, and a - command that lets go of its terminal early runs on to its own exit. A - background job outlives a command that exits on its own (Kill stops it too): job control gives it a process - group of its own, so the hangup the kernel sends the terminal's - foreground group when the shell exits misses it. It survives the pane - closing too, but its writes to the terminal then fail, so start one that - must keep writing with `nohup` or its output redirected. The directory's - next command runs in that pane once it is done and nothing holds its pty, below what it showed, after a `% <line>` line; - one still running gets a second pane. Before the next command the pane - leaves any alternate screen and turns off the modes a program left on - (mouse reports, bracketed paste, a hidden cursor); a command that clears - the screen and its scrollback (`clear`, ED3) erases the history above it. A command pane's own exec starts - the next command there too. The log says `run <serial> <line>` and `exit - <serial> <N|?>`; `exec` reads back the command pane's serial; Kill stops - its whole line, `&` jobs included; a command line is at most 1024 bytes, and a - longer one written to an exec fails the write (EINVAL, `invalid command - line: a command line is at most 1024 bytes`, and one with a control - character (DEL too) but a tab, `invalid command line: it holds a control - character (or DEL) other than a tab`; the root's exec logs either against the pane it would - have run at) before anything in it runs; a builtin's line (a long - `Msg`, an Edit block) may be longer. To run a command again, execute - its line again from its directory: `echo 'make test' > pane/<n>/exec` on - the command pane runs it there, below the last run. A misspelled word is a - command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes - an interactive terminal in that pane's directory. +**Making and closing.** Opening `/pane/new` makes a scratch `<dir>/+New` +(the session's directory), and reading that open answers its serial: +`n=$(cat $m/pane/new)`. Each open makes another pane; two reads of one open +answer the same serial. It goes where acme's makenewwindow puts a window +([tags.md](tags.md#where-new-panes-go)): in the active column, filling it +if empty, else the bottom half of its last pane. A session holds 64 panes +(`no space for a pane: 64 max`), and every pane keeps its tag and 2 rows +(`no space for a pane in that column: each keeps its tag and 2 rows`); both +are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike. +`rmdir /pane/<n>` closes the pane, unsaved or not. -A terminal can be bound as a language's REPL: `Repl python` in its tag or -on its `ctl` (the language names are the syntax table's, or a code fence's -alias such as `py`, in any case; `Repl -` unbinds, -`Repl` bare says the binding, `Repl python` again changes nothing). Its tag -shows its id, `python-a`, `python-b` for the next, a freed letter reused, -and so does the end of its `ctl` line, after `current`/`notcurrent`. -A builtin's word still runs first, bound or not: `Del` clicked in the file -closes its pane. Then any other exec made by a gesture on the body of a file -in that language -- a -middle click, the execute key, on a selection or a single word, even `make` -in a comment -- or on the REPL's own body is typed into the REPL instead of -run: bracketed paste when its program asked for it (DECSET 2004), else line -by line, where a blank line inside a Python block ends the block (said -once), then Enter. The message row says `→ python-a` in the tag's name tint -and the log `send <from> <to> <id>`. With several REPLs bound for the -language the pane asks which, on its notice band, one key answering and Esc -sending nowhere; nothing is remembered. A REPL takes text only while its -program has the terminal: a command pane's until its command is done (the -binding goes with it, and a done one cannot be bound), an interactive -terminal's while a program other than its shell holds the tty -- after -Ctrl-D the shell would run the text, so nothing is sent and the pane says -so. Still commands, whatever is bound: a word in a tag (so -the tag is how to run `make` from that file), `Exec <text>` run by name -(typed, a 2-1 chord onto `Exec`, a `ctl` line) -- in a `.py` pane bound to -a REPL, `Exec print(1)` runs `print(1)` as a command, never in the REPL -- -and a command word @`cmd` in -the text, looked at or clicked (`# @`pytest -x`` in a script). A 9P `exec` -is no gesture and is never sent: a script writes to the REPL pane's -`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~`, -then `\r` in a write of its own once the REPL has echoed the paste -- -Python 3.13's REPL takes an Enter read with the paste as part of it, even a -one-line one -- and, for a paste of more than one line that does not end -in a newline, a second `\r`: one Enter leaves a multi-line input at `...`; a middle click does all of this -itself, holding the Enter until the REPL answers the paste), since line by line a blank line ends a Python block and Python -3.14's REPL auto-indents each line typed into it. The REPL gets the text -wherever it is -- at a `pdb` or `input()` prompt too. Over 9P, a range of a -`.py` pane goes to its bound REPL as a click would: write the event record -`MX<q0> <q1>` back to the `.py` pane's `event` (a range past its end is -refused, `range past end of body`). `Kill` does not stop -what a REPL runs, since pardes did not start it; `sig INT` on the REPL -pane's `pty/ctl` interrupts it as Ctrl-C would. Bindings are not dumped, so a Restore leaves none. +**`name`** reads the file name (a terminal's directory); a write renames the +buffer (relative to the pane's directory) and marks nothing dirty; `Save` +then writes under the new name. A name is one line; refused (EINVAL) are a +second line, a blank at either end, control bytes and non-UTF-8 (`bad +character in file name: a blank at its start`, ...). Up to 255 bytes a +component. -The root's pair clicks at the active pane and `/pane/<n>/look` and -`/pane/<n>/exec` at that pane; `/tagexec` and `/col/<n>/exec` click in the -workspace's or that column's tag (never an event reader's, which hears -only its pane's; a command they run starts in the session's directory, -where pardes started, not the focused pane's), and what the word says is logged as the session's, -`msg -`. Blank lines are skipped, and every other line -is checked before any of them runs, so a control character fails the whole -write with EINVAL; a builtin that fails there fails the write as well -(below: one rule). Reading any of these files answers the -serials of the panes the last command created, or, when it created none, the -pane a look focused or the pane an exec acted on (even one it closed), one -per line; a look that found text answers the pane the text is selected in, -not the hits buffer it opened. +**`body`** reads the text; a write appends; `>` (OTRUNC) replaces it all. +A terminal's body is its history as plain text in logical lines (wrapped +rows joined), frozen per open; writing it sends input to the child. A PDF's +body is the text layer of the page shown; images and PDFs take no write +(`this pane has no text`). -Reads are a stream: once an open has read the answer, a second read on it -gives EOF (on an open that wrote, until its next write); open it again, or -seek to 0, to read it again. The answer belongs to the open, as -/net/tcp/clone's does: an open that -wrote reads what its own last write touched, from the start after each -write, whatever offset the read comes at (a shell's `exec 3<>look` shares -one offset between its write and its read, as with `pty/run`); an open -that never wrote reads the session's last answer as it stood when it was -opened. So `echo x > look; cat look` works for one client, but two -clients doing it at once may read each other's; for that, write and read -on one open: `exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`. +**`sel`** reads the selected text; a write replaces it. **`errors`** is +write-only: text appended to the directory's `+Errors` pane (logged as +`msg` records when no column has room). -A write of command lines -- to `look`, `exec`, `tagexec`, a `ctl` of the -root, a pane or a column, or a column's `exec` -- runs each line once it is -whole: a mount cuts a big write at its message size (4 KiB through the -kernel's, 8 KiB from a client that asks), anywhere, and each piece comes as -a write of its own, so the open keeps a last line with no newline yet, or -an Edit block whose text has not ended, until its next write, and runs it -then; what is left when it closes runs at the close -- so a line written -with no newline (`printf Save > exec`) runs only as the file closes, after -the write has been answered, and its failure is in the log alone: end each -line with its newline to have the write itself fail -- where an Edit block -whose `{` or `a`/`c`/`i` text never ended fails and changes nothing -(``unmatched `{'``, or `a, c or i text not ended by a . line`, logged as an -`err`): sam takes the end of input for a `.`, but a block that reaches Edit -unfinished was cut short. A fragment never runs on its -own. A line or Edit block held past 1 MiB is refused (EINVAL). +**`focus`** (root): a serial written gives that pane the keyboard and makes +its column active (a folded pane stays folded); `no such pane`, or +`ill-formed control message` for a non-number. It reads empty while a +column or workspace tag has the keyboard. -`@p<serial>:<address>` addresses the pane with that serial as `file:<address>` -does a file, with any sam address (`/re/`, `?re?`, `#n`, `$`, `0/re/`, a range), -a terminal too: its body is then the text of its logical lines (the lines -a `+Search` of it lists), addressed from its cursor, and the match is -selected. A miss, and a serial no pane has (with a line, `@p77:3`, as -with any other address), each log an `err`, and look reads back empty. A look takes acme's addresses after a colon (editors/acme/look.c:450): a -line written to `look` as `file:/re/`, `file:?re?`, `file:#n`, `file:$` or any address -opens (or finds) the file and selects what the address names. **The -address is evaluated from the file's dot**, as acme's is: `file:/re/` -finds the next match after the current selection, not the first in the -file. For the first, start at the top: `file:0/re/` (or `file:#0/re/`). -A pattern that can match empty, such as `^`, passes over the empty match -at #0 as sam does (a search never answers where it started), so it finds -the next one: for the start itself write `file:0` or `file:#0`. -`:addr` does the same in the pane itself, and a pattern may hold blanks -(`calc.py:/return a/`). `file:12` selects line 12, its newline included, -as acme's does; `file:12:5` puts the caret at line 12, column 5 (columns count bytes from 1, -as `addr`'s `12:5` does and as the rows of Grep, +Search and the language -servers write them). A bare -`/re/` is a path, as in acme, and failing that a search for its text. A -look that finds nothing, an address that does not evaluate, or a line past -the file's end (`calc.py:99`) says so on the message row and in the log -(`err <serial> look: ...`), focuses and opens nothing, keeps the -selection, and leaves `look` reading back empty. +**Pane ctl.** Reading it gives acme's status line: serial, tag length, body +length, isdir (0), dirty, width in cells, font, tab width, undo available, +redo available, then `current`/`notcurrent` and a REPL's id if bound. It +takes: -`/pane/<n>/name` reads the pane's file name (a terminal's directory) and -writing it renames the buffer; a relative name resolves against the pane's -directory. A name alone is no edit: the pane's `dirty` stays what its text made it -(a renamed clean file is still 0, and nothing asks about it at Exit, -Restore or Del, which ask only about text edited), and `Save` writes it -under the new name all the same. An open's writes are one name: held -until its newline, or its close when it has none, then applied once, however -the writes cut it; nothing else is trimmed. Two lines are refused, EINVAL, -in one write or as a second line on the same open (bash's `printf -'a\nb\n' > name` writes a line at a time: the first names it). A blank inside a name is taken -(`two words.zig`); refused, EINVAL, in acme's words (xfid.c:650) and why, -are a blank at either end (not quietly cut off), `bad character in file -name: a blank at its start` (or `end`), a second line, `...: a newline (a name is one -line)`, a control byte, DEL or a C1 control (U+0080-U+009F), `...: a -control character`, and bytes that are not UTF-8, `...: not UTF-8`. `body` appends on write and replaces on truncating open. A -terminal's `body` is its history as plain text, frozen per open, in logical -lines: a row the terminal wrapped is joined back to the row before it (the -wrap is ghostty's, as `pty/run`'s output unwraps), and the last line ends -with a newline. `sel` -reads the selected text and writing it replaces the selection. `errors` -appends to the directory's `+Errors` pane; with no room for that pane in -any column, what it would have shown is logged as `msg` records instead, a -line each, and the write still succeeds. Holding `event` open redirects the -pane's Look and Exec clicks to that client, and so does a line written to the -pane's own `look` or `exec`, or to the root's while that pane has the -keyboard (never its `tagexec`, which is a click in its tag and runs), a -click with no place in the text: an `F` record at `0 0` carrying -the line. So a client holding `event` that wants a command run gets its own -exec back as a record: it runs it through `ctl`, or writes the record back. -Writing a record back performs the action, as the click would have (acme's -xfideventwrite): a body `X` goes to a REPL bound for the text as a middle -click does, where an `F` record, a line written to `exec`, runs as the -command it was. acme takes back only `<origin> -<action><q0> <q1>`, the text of that range; pardes takes the record whole as -it was read too, and for an empty range acts on its text, which is how such -a line is done. A click in a file's body carries the offsets of the text it -took; one in a terminal's body cannot, since that body is a history -snapshot, and is also at `0 0` with its text. A click that takes no text -sends nothing, as in acme. `ctl` reads acme's window status line — serial, tag length, body -length, a reserved zero, the dirty flag, the width in cells, the font and the -tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`): -whether the pane has the keyboard. It takes the pane's builtins (below), -`get`, which reloads the buffer from the name it -carries (unsaved edits are refused once, listed in `+Unsaved` with a short -notice as Exit's are, the write failing `<name>: Modified (get again to -discard)`, as acme's get asks winclean, exec.c:513). A file that changes on -disk reloads by itself only into a buffer with no unsaved edits; one with -them keeps its text and stays dirty, says `<name> changed on disk (get -reloads it, Save overwrites it)` and logs `changed <serial>`, and then its -`Save` warns once, `<name> modified on disk since read (Save again to -overwrite)`, as acme's Put does (exec.c:577) -- acme reloads nothing by -itself. `answer <choice>` -for the question the pane asks on its notice band, which the log names as -`ask <serial> <what> <choices>` -- `ask 4 del k j` for Del's side from the -keyboard (`k` the pane above takes the rows, `j` the one below), `ask 4 -repl a b` for which bound REPL takes an exec (a REPL's letter) -- `answer -` -taking it back as Esc does (a choice the question does not offer is refused, -naming those it does, and the question stands; with no question standing, -`answer` is refused), -and acme's `lock` and `unlock` (editors/acme/xfid.c:603-611), for an -edit of several writes to `addr` and `data` that another client must not -land in the middle of. As in acme the lock binds only the clients that take -it: a `lock` while another open holds it fails at once with `file in use` -(EBUSY), to be tried again, until that open writes `unlock` or closes (or -the pane does) -- where acme's blocks, because through a kernel or FUSE -mount a blocked write would hold up the holder's own `unlock` and close on -that file -- and nothing else is refused for it -- -not a write to any other file, not the person at the keyboard. It belongs to -the open that wrote it, so only that open's `unlock` is taken; a write on an -open that cannot write (or the editor's own, on none) cannot lock. From a -shell the lock needs an open held across the edit, since `echo lock > ctl` -closes, and so unlocks, at once: `exec 3>ctl; echo lock >&3; ...edits...; -exec 3>&-`. +- the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give + its rows to the pane above or below), `Save [path]`, `Collapse` (fold), + `Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]` + (a new terminal pane in its directory), and the column words acting on + its column: `Delcol`, `Left`/`Right`/`Up`/`Down`. +- `get`: reload from the file (refused once over unsaved edits, `<name>: + Modified (get again to discard)`). +- `lock`/`unlock` (acme's). The lock binds only clients that take it and + belongs to the open that wrote it: `exec 3>$pane/ctl; echo lock >&3; ...; + exec 3>&-`. A `lock` another open holds fails at once, `file in use` + (EBUSY): retry. +- `answer <choice>` to the question the pane asks on its notice band, logged + `ask <serial> <what> <choices>` (`ask 4 del k j`, `ask 4 repl a b`, `ask 4 + save path`); `answer -` takes it back. +- acme's lowercase ctl words, done by what replaces each: `name x`, `put` + (Save), `clean`/`dirty`, `del`, `delete` (no asking), `dot=addr`, + `addr=dot`, `limit=addr`, `mark`/`nomark`, `show`, `cleartag`. `dump`, + `dumpdir`, `font`, `menu`, `nomenu` are refused, EINVAL. These lowercase + words are ctl-only: written to an `exec`, `del` is a command line. -The three range files `addr`, `dot` and `limit` each read the pair of offsets -they also accept, so copying one onto another is all that acme's `addr=dot`, -`dot=addr` and `limit=addr` ever were. A write is either that pair or an -address expression (`#0,#5`, `/pattern/`, `2+1`, and pardes's own `12:5`, -below); `addr` selects where `data` reads from -- to the end of the text, -as acme's does, so `cat data` reads everything after the address -- and -the range `xdata` reads, just that; either replaces the range, `dot` is the editor's own selection and moving it -scrolls the pane into view, and `limit` bounds only the end of a forward -search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it -- -though a write after the truncation that fails puts the old limit back, so -`echo /bad/ > limit` changes nothing -- and truncating `addr` leaves it as -it is (below). +A file changed on disk reloads by itself only when the buffer has no unsaved +edits; otherwise it stays dirty, says `<name> changed on disk (get reloads +it, Save overwrites it)`, logs `changed <serial>`, and its next `Save` +warns once. -A rename everywhere, or any other sam edit, is one write to the pane's -`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as -one undo step; a failure fails the write with acme's words and changes -nothing. A write is one message a line, except that an `Edit` line takes -the lines after it while its `{` group is open or its `a`, `c` or `i` text -block waits for its `.` line, on a pane's `ctl`, the root's (at the active -pane) and `exec` alike; an unclosed group is refused (``unmatched `{'``). -A block may come in several writes on one open (bash's builtin `printf` -writes a line at a time): the open holds it until it ends (below). +### Addresses and data -A line number counts newlines as sam's lineaddr does (editors/sam/ -address.c:180), so the empty line just past a text's last newline is an -address: `1` of an empty text is `#0,#0`, `2` of `a\n` is `#2,#2`, and -`Edit 1i/header/` on an empty file inserts; a line past that is `address -out of range`. +`addr`, `dot` and `limit` read the pair of byte offsets they take, so +copying one onto another (`cp $p/addr $p/dot`) is acme's `dot=addr`. A +write is a pair or an address expression. `addr` names where `data` reads +(to the end of text) and the range `xdata` reads; `dot` is the selection +(moving it scrolls the pane there); `limit` bounds the end of a forward +search and reads empty until set. Truncating `dot` empties it, truncating +`limit` lifts it. -`line:col` is a pardes extension to sam's addresses, the spelling Look -takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it -composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is -in bytes from 1, as Look's is, clamped to the end of the line and snapped -back to the start of the rune it falls in; a line past the end, or -column 0, is `address out of range`. Since the column counts bytes, a -column a tool gives in characters (pytest's, a compiler's) is the same -only on an ASCII line; elsewhere address the line and search it -(`12/name/`) or use `#n`. sam would read `12:5` as a syntax -error. The rows of Recent, a +Search and the Jumplist spell a range -`12:5-14:2` (or `12:5-9` on one line), and `addr` takes that too, so a row -pastes in; the two spellings side by side: +- A write to `data` or `xdata` **replaces** the `addr` range (`>` and `>>` + alike); `: > data` deletes it. Truncating `data` is pardes's own (acme + ignores OTRUNC there); only truncating `body` empties the buffer. +- A write leaves `addr` just past what it wrote, so a second `echo x > data` + inserts after the first: write `addr` before each replacement. A read of + `data`/`xdata` moves `addr` past what it read. +- `addr` belongs to the pane, not the client, and neither an open nor a + truncation resets it (acme resets on first open): each `echo /re/ > addr` + searches on from the last, so a find loop advances. Write `0` to start + at the top; searches wrap, so stop a loop when the address comes back or + set `limit`. +- A failed address leaves **no address**: `addr` reads empty and `data`/ + `xdata` refuse (`no address: the last one written to addr failed`) until + a standalone address (`2`, `#0`, `/re/`) is written, so a missed target is + never written at the old one. - 12:5,14:2 sam's range: from line 12 column 5 up to the point at 14:2 - 12:5-14:2 a row's range: 12:5 through the character at 14:2, inclusive +Addresses are sam's: `#n`, a line number, `/re/`, `?re?`, `-/re/`, `$`, +`.`, `0`, ranges `a,b` and `a;b`, `+`/`-`. They are evaluated from the +current address (the last written to `addr`, or just past the last `data` +write), not from the selection. pardes adds `12:5`: line 12, **byte** +column 5 from 1, clamped to the line's end (`12:0` is `address out of range: +a column counts from 1`); it composes (`12:5,14:1`). A tool's character +column matches only on an ASCII line; elsewhere use `12/name/` or `#n`. A +row of Recent, `+Search` or the Jumplist spells a range `12:5-14:2` (through +14:2 inclusive) and `addr` takes it as such. -A `-` right after `L:C` and a digit is this range, not sam's "back N -lines" from that point. -A write to `data` or `xdata` replaces the range `addr` names, as acme's -does (editors/acme/xfid.c:491-523: it deletes the range, then inserts), so -`echo NEW > data` and `echo NEW >> data` both replace that range, and -truncating deletes it and nothing else, so `: > data` deletes it; only -truncating `body` empties the whole buffer. Truncating `data` is pardes's -own: acme ignores OTRUNC there (editors/acme/fsys.c:543). And as in acme a write -leaves `addr` just past what it wrote, so a second `echo x > data` deletes -the empty range there and inserts after the first rather than replacing it -again; write `addr` before each replacement. A read of `data` or `xdata` -moves `addr` past what it read, as acme's does. -`addr` belongs to the pane rather than to a client and keeps what was written -until someone writes another, so writing an address and reading it back -evaluates it, which is what acme(4) promises of its own `addr`. Unlike acme, -neither an open nor a truncation resets it: acme sets it to `#0` when the -first client opens `addr` (editors/acme/xfid.c:105-108), which suits a -client that holds the fid, but a shell opens the file anew for every -`echo /re/ > addr` and so would search from the top each time and never -advance. Here each such write searches on from the last address, as `>>` -does; write `0` to start again from the top. A search wraps at the end of -the text, so a find-all loop stops when the address comes back to where it -began, or bounds itself with `limit`. +Offsets and counts are bytes everywhere (acme counts runes), but every +address lands on a rune boundary: `#n` or `L:C` inside a rune snaps to its +start, a match covers the runes it touches, and a combining mark or a CRLF's +`\r` is addressable alone. -The regular expressions are mvzr's (sets, `\d`/`\w`/`\s`, `{m,n}` and -lazy `*?` included), searched the way sam searches (editors/acme/regx.c): as -lines, so `^` and `$` match at the start and end of any line, `.` and a -negated class never match a newline, and `$` also matches at the end of a -text with no final newline. A pattern that names a newline (`\n`) runs over -the whole text instead, its `.` kept to one line; there a leading `^` still -matches at every line start, and `$` may stand just before a `\n` (where it -changes nothing). Any other `^` or `$` in such a pattern, `(^|\n)def` or -`a\nb$`, is refused, EINVAL, with `bad regular expression: in a pattern with \n, ^ can only come first and $ -only just before a \n`, since mvzr would read it as the start or end of the -whole text: never a search that silently finds nothing. An alternation -whose every branch starts with `^` (`^def|^ `) finds a line that starts -either way (it is taken as `^(def| )`); one that mixes anchored and -unanchored branches (`^def|x`) is refused, `bad regular expression`, since -mvzr keeps `^` only first. An expression is -evaluated from the current address, the range last written to `addr` (or -left by the last `data` write, just past it), as acme evaluates it from -`w->addr` (xfid.c:446): `.` is that address, not the selection (`dot` is -the selection's own file), and `#9/re/` searches from `#9`; in `/a/;/b/` -the second search starts at the end of the first, as acme's `;` does, where -`/a/,/b/` starts both at the current address. `/re/` searches -forward from the end of the current range to `limit` if one is set, and -otherwise wraps to the start of the text; `?re?` and `-/re/` find the last -match ending before the range, wrapping to the text's last. The match is the leftmost, but of -the alternatives at that place mvzr takes the first that matches where sam -takes the longest (`/gam|gamma/` finds `gam`); in a search begun in the -middle of a line, `^` inside a group can match there; and in a -pattern that spans lines, `^`, `$` and `[^...]` keep mvzr's own meaning. -mvzr backtracks without bound of its own (`a?` twenty times then twenty -`a`s is 2^20 steps from each place it tries), and a search holds the editor, so pardes patches a -step budget into mvzr's matcher (build.zig): a search that spends it, -about 300 ms, fails with `regular expression search gave up, backtracking -past its step budget` rather -than answer a match it is not sure of. Ordinary patterns spend a few -thousand steps; what runs out is exponential backtracking, and a quadratic -pattern over a very long line (`\s*(\w+)\s*=` over 20 KB of letters). -pardes has no regex engine of its own on purpose; these are its limits. -Normal mode's `s` and `S` search a selection the same way (src/regexp.zig -is the one place both call), so `^` there also means a line's start. +Refusals: `bad address syntax`, `no match for regexp`, `address out of +range`, `addresses out of order` (`#100,#50`), `bad regular expression`. -An address that does not evaluate fails the write with why: `bad address -syntax`, `no match for regexp`, `address out of range`, `bad regular -expression`, `regular expression search gave up, ...`, or sam's -`addresses out of order` for a range that ends before it starts -(`#100,#50`), which acme lets through. Each end is checked first, so in a -text shorter than 100 bytes `#100,#50` says `address out of range`. A failed write to `addr` leaves no -address at all, where acme -keeps the old one: until a good address is written, `addr` reads empty -(as an unset `limit` does), and reading, writing or truncating `data` and -`xdata` fail with `no address: the last one written to addr failed`, so a script that -missed its target cannot then write at the last one; so does an address -written to `addr` that goes from the current one (`.`, `+1`, `-/re/`), which -there is none of, until an address that stands alone (`2`, `#0`, `/re/`) is. +sam details kept: `$-1` is the last line when the text ends in a newline, +else the one before; with a final newline the empty place after it is a line +(`1` of an empty text is `#0,#0`, so `Edit 1i/x/` works on an empty file); +`2,1` is an empty range at line 2's start; `/^/` finds the empty place after +a final newline; a pattern that can match empty (`^`) passes over the match +where the search starts. -The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take -`0` or `1`: whether the buffer differs from its file (a file deleted on -disk counts, as in acme: its text is only here now, and Del, Exit and the -rest ask first), whether a write pushes -an undo point (writing `1` pushes one now), and whether a write scrolls the -pane. The writes of one open of `data`, `xdata` or `body` are one undo -step while `mark` is 1 (so `printf 'a\nb\n' > data` is one, though a -shell writes it a line at a time), and the next open starts another. The -pane has one history, so two opens writing at once take turns in it: each -open's first write after the other's starts a step of its own; to -make a loop's writes one step, write `1` (an undo point here), then `0`, -the writes, then `1` again. +### Regular expressions -One rule for what fails: a write fails whenever what it asked for fails, -whether it came to a `ctl` (the root's, a pane's, a column's, a pane's -`pty/ctl` -- its `exec` included), `look`, `exec`, `tagexec` or a -column's `exec`, or to any other file -- with an -errno that fits, EINVAL for malformed input (an unknown word, a control -character, a command line over 1024 bytes, a `size` or `winsize` out of -range, a bad address or event record), else EIO or the errno the words -name (ENOENT for a pane, file or directory gone -- `look .` from a pane -whose directory is gone says `look: <dir>: no such directory`, and a -`./zz.txt` or `../x` that is not there `look: ./zz.txt: no such file`, while a -plain `zz.txt` is looked for as text, a miss logged as any look's -- and for a -Find or Grep that finds nothing, `grep: pattern not found`; Grep walks -every pane's directory on this host, passing over panes of the served -tree (`/virtual/`, a peer's `/n/<name>/`) and directories not there, -so none of them spoils the rest; Find, Grep and a language server's lists -(Symbols, Diagnostics, Callers and the rest) share one `+Search` a -directory, each run replacing what the last showed, as acme reuses a -directory's `+Errors` (the exec reads that pane back; one that finds -nothing empties it rather than leave the last rows), while a plain -word's `LookWord list` search keeps a pane a pattern, ENOSPC for no room or -slot, EBUSY for a held lock) -- and logs its reason exactly once, as `err <serial|-> -<file>: <why>`, with no `msg` for it. A builtin a click runs (Save, get's -`Modified`, Tty with no room, Edit) is no exception. What is not a -failure: a look that finds nothing answers nothing and logs one `err` -(`look: no match for ...`, the same miss again counted, `(x2)`, as any -repeated `err` is), the write succeeding; and a command line run in -a command pane ends in its own time, told by its `exit` record. +Patterns are [mvzr](https://github.com/mnemnion/mvzr)'s (classes, `\d\w\s`, +`{m,n}`, lazy `*?`), searched as sam searches, line by line: `^` and `$` +match at any line's start and end, `.` and `[^...]` never match a newline. +The same code (`src/regexp.zig`) serves addresses, Edit, and normal mode's +`s` and `S`. The ceiling: + +- The leftmost match wins, but among alternatives the first that matches, + not sam's longest (`/gam|gamma/` finds `gam`). mvzr keeps no + submatches, so Edit's `s` has no `\1`-`\9`. +- A pattern holding `\n` runs over the whole text: there `^` may only come + first and `$` only just before a `\n`, else it is refused. +- An alternation must anchor every branch or none (`^def|^ ` works, + `^def|x` is refused). +- A class may hold non-ASCII runes (`[éa-z]`, a range up to 256 runes); a + wider range or a negated class with one (`[^é]`) is refused. +- At most 512 operations (about 512 characters, counted after that + rewriting): `bad regular expression: longer than mvzr's 512 operations + (about 512 pattern characters)`. +- Each search has a step budget (about 300 ms; each search of an Edit `x` + its own): `regular expression search took too much time, gave up`. What + runs out is exponential backtracking (`a?` twenty times then twenty `a`s) + or a quadratic pattern over a very long line. + +### Edit + +`Edit <sam commands>` on a pane's `ctl` or `exec` (or the root's, at the +active pane) runs acme's Edit on the body: addresses as above, commands `x y +g v c a i d s p = m t u` and `{ }`. All changes are one undo step, applied +only if every command succeeds; a failure changes nothing and fails the write +with acme's words (`Edit: no substitution`). An `x` that finds nothing +succeeds silently. `p` and `=` print to the directory's `+Errors`. Not +there: `b B D e r w f X Y`, `< | >`, `\1`-`\9`. In `s`, `&` is the match +(`\&` a literal); in `c`, `a`, `i` it is a literal. `y` yields the stretch +before the first match too. + +Braces take a command a line, so a block goes on one open, in one write or +several: + +```sh +printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl +``` + +### Flags and undo + +`dirty`, `mark` and `scroll` read and take `0` or `1`: the buffer differs +from its file (a file deleted on disk counts); a write pushes an undo point +(writing `1` pushes one now); a write scrolls the pane. `/index`'s dirty +flag is `dirty`; a `+New` scratch reads 1 but holds up nothing under 100 +bytes. + +The writes of one open of `data`, `xdata` or `body` are one undo step (so a +multi-line `printf` is one). To make a loop of opens one step: `echo 1 > +mark` (a point now), `echo 0 > mark`, the writes, `echo 1 > mark`. + +### event + +Holding `event` open takes the pane's Look and Exec clicks: they come to the +reader as records instead of acting, as do lines written to the pane's own +`look`/`exec` (or the root's while it has the keyboard). A record is acme's +`<origin><action><q0> <q1> <flag> <n> <text>\n`; read `n` **bytes** of +text, which may hold newlines. + +- origin: `E` a 9P write to body or tag, `F` other files and the editor's + own lines, `K` keyboard, `M` mouse. +- action: `X`/`L` executed/looked in the body, `x`/`l` in the tag (offsets + into the whole tag, path included), `I`/`D` body text inserted/deleted, + `i`/`d` the tag's. +- flag: 1 the text's first word is a builtin, 4 (look) a file name or + address, 8 (exec) chorded: two records follow, the argument and where it + came from. pardes never sends flag 2. +- A written line, or a click in a terminal's body, has no place: `0 0` with + its text (`FX0 0 1 6 Msg hi`). + +Write a record back to have it done as the click would: `<o><a><q0> <q1>\n` +acts on that range; the whole record as read acts on its text (the only way +for `0 0`). A chorded record with its two follow-ups, in one write or +three, runs once with its argument. `I`, `D`, `i`, `d` are refused. A helper +holding `event` that writes its own pane's `exec` gets its command back as a +record: run it through `ctl` instead. + +### REPLs + +`Repl python` on a terminal's `ctl` (or in its tag) binds it as that +language's REPL (names as a code fence spells them: `py`, `sh`, ...); its +tag and `ctl` line show its id, `python-a`. `Repl -` unbinds, bare `Repl` +says the binding. A middle click or the execute key on a `.py` body then +types the text into the REPL instead of running it; builtin words, tag +words, `Exec <text>` and @`cmd` words still run. With several bound, the +pane asks (`ask <serial> repl a b`). Bindings are not dumped. + +A 9P `exec` is never sent to a REPL. A script either writes the event record +`MX<q0> <q1>` to the `.py` pane's `event` (sent as the click would be), or +writes the REPL's `pty/data` itself: multi-line code as a bracketed paste, +`\e[200~<code>\e[201~`, then `\r` in a separate write once the REPL has +echoed the paste; a paste of more than one line not ending in a newline needs +a second `\r`. Line by line, a blank line ends a Python block and Python +3.14 auto-indents each line. + +## Terminals + +Terminal panes also have `pty/`: + +- `pty/data`: write bytes as typed (`printf 'ls\r'`, `\x03` is Ctrl-C); + read the live output stream (a consuming queue shared by readers, not a + replay). +- `pty/status`: one line, `cols rows busy`; busy is 1 while a command runs or + text is typed at the prompt. +- `pty/ctl`: `winsize C R` (at least 2 rows), `sig INT|TERM|HUP|QUIT|KILL`, + `exec` (restart the shell in its directory: refused on a command pane, `a + command pane does not restart`; `exec: <dir>: no such directory` if it is + gone; a shell that cannot start fails and leaves the old one running). +- `pty/run`: one line at the shell's prompt, answered on the same open: + + ```sh + exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3; exec 3<&- + ``` + + The answer's first line is the header: `exit N` then the command's output + (as the screen showed it: no colour, `\r` progress collapsed, trailing + blanks dropped; the last 64 KiB, `exit N cut M` when M bytes were left + out, bare `cut` when the start scrolled away or was cleared). Or: `busy: + <program> is running` (bare `busy` when text is typed at the prompt), + `exit ?` (no status reported, not a success), `error not run` (the shell + refused the line, e.g. a fish syntax error), `error shell gone`, `error no + prompt marks`, and on a command pane `error a command runs here, not a + shell` (`error command done; not a shell` once it ended). A line written + before a fresh terminal's first prompt waits for it. One line per run; + a second line before the answer is refused. + +`pty/run` relies on the OSC 133 marks pardes injects into bash and fish; +`exec zsh` or a continuation prompt never reports an end, so cancel the +read. A program on the alternate screen (vim, less) leaves no output. A +program holding the terminal (a REPL, `less`) takes no run: write to +`pty/data`. + +## /log + +One 64 KiB ring, one record a line, kept whether or not anyone reads it. An +open freezes it and reads to EOF. Write `follow` to that open to then wait +for each new record (`follow new` skips the history, as `tail -n0 -f`); +`tail -f` never writes `follow`, so it sees nothing new. + +```sh +exec 3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done +``` -`tag` reads the whole tag as the pane shows it: the computed path or PDF -page (no mark for unsaved text: the grip shows that, and `dirty` says it), -then the text you may edit. An image's tag begins with its mode words, not -its path: `img petscii:off palette:commodore ascii:on <path>` by default, the modes -it is drawn in, then the file. A write appends to that text, -newlines included, and a tag with more than one line takes a row per line on -screen; truncating `tag` clears it, as acme's `cleartag` does -- the default -words (`Save Tty Collapse Del` and the rest) with it, since they are that text until -you edit it, so `echo Make > tag` leaves only `Make` -- a truncating write -drops the one newline that ends what it wrote, which would draw an empty -row, and keeps any other; append with `>>` to -keep them, with `printf ' Make' >> tag`. The leading blank is needed: the -tag reads back with no blank after its last word, so `printf Make >> tag` -glues `Make` onto it; and `echo ' Make' >> tag` ends with a newline, which -starts a new line of the tag. The clearing is an edit of the tag like a typed one and its undo -history is kept: `u` in the tag brings back the text it cleared, words -included. +A follower the ring outran reads `lost N` first. A Restore hangs the +follower up: dial again and read from `restore <path>`. -Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`, -`name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and -for `event` and `pty/data` the length of the record a read would -answer, which is zero when nothing is waiting; for `log`, the text an open -would freeze now. Modes are 0644/0666 (0444 for -read-only files, 0222 for write-only); mtime is the pane's last edit or the -process start. The qid version of `body`, `data` and `xdata` is the pane's -revision, so a stat sees an edit land without reading the text; every other -file leaves it zero rather than promise a version it cannot keep. Directory -entries carry no sizes, and neither does `/screen`, which has no length until -an open renders its frame; stat the entry. +| record | when | +|---|---| +| `new <serial> <name>`, `del`, `rename`, `save` | a pane made, closed, renamed (a terminal's too, as its shell changes directory), saved | +| `newcol <serial>`, `delcol <serial>` | a column made or closed | +| `msg <serial\|-> <text>` | the editor said something (not a builtin's own name under `Verbose`) | +| `err <serial\|-> <file>: <why>` | a write was refused or failed | +| `run <serial> <line>`, `exit <serial> <N\|?>` | a command pane's command started and ended; also a terminal whose shell exited, before its `del` | +| `send <from> <to> <repl-id>` | text went to a REPL | +| `ask <serial> <what> <choices>`, `answer <serial> <choice\|->` | a pane asked, and was answered (`-`: taken back, or the pane closed) | +| `changed <serial> [reloaded\|deleted]` | its file changed on disk (bare: under unsaved edits) | +| `unsaved <serial> <name>` | a pane an Exit, Restore, Del or Delcol refused over, before the `err` | +| `dump <path>`, `restore <path>` | a Dump written; a Restore, after its panes' `new`s | +| `restored <old> <new>`, `restoredcol <old> <new>` | serial maps after a Restore | -`/log` is one ring (64 KiB) that records whether or not anyone reads it: -`new`, `del`, `rename` (a terminal's too, as its shell changes directory -- -a new terminal is `new N <dir>`, named by the directory it is started in, -and renamed only when its shell goes elsewhere (the boot's, started with -no directory given, is `new N /` until its shell says where it is); it -runs `ls` once, at its shell's first prompt only, never later over what -someone ran or typed first, a greeting that shows the directory it opened -in, since a terminal is named by its directory and nothing else on it says -where it is), `exit <serial> <N>` before -the `del` of a terminal whose shell exited by itself, `ask <serial> <what> -<choices>` when a pane asks a question (answered by `answer` on its ctl; -`ask <serial> save path` when a Save made over 9P, on a terminal or a -scratch, opens its prompt for a path, answered `answer <path>` or `answer --`; asked again, the one open stands, not a second), -`answer <serial> <choice|->` when it is answered, by key or ctl, `-` for -taken back or for its pane closing with the question standing, -`changed <serial>` when a pane's file changed on disk under its unsaved -edits (below), `changed <serial> reloaded` when a pane with none reloaded -it, and `changed <serial> deleted` when it was deleted or moved away, `unsaved <serial> <name>` for each pane an Exit, Restore, -Del or Delcol refuses over (before that write's `err`), and `save <serial> <name>`, -`dump <path>` when a Dump is written and `restore <path>` in a Restore's -new log after its panes' `new`s (a relative Restore path is looked for in -`DumpDir`, then in the directory pardes started in; a bare Restore takes the -last dump this session wrote), then `restored <old> <new>` for each pane, -mapping the serial it had to the one it has now, and `restoredcol <old> -<new>` for each column, and `msg <serial|-> <text>` -for every line the editor says (a builtin announcing its own name as it -runs, with `Verbose` on, is the message row's alone, never logged: a `msg` -is something said, `Kill: nothing running`). A builtin that -fails its write (`ctl`, `exec`, `/tagexec` or a column's `exec`, and an -open of `pane/new`) is logged by that write's `err` alone, no `msg`, so -the same failure again is the same record again; a line said again word for word is counted, `msg 3 Undo: nothing to -undo (x40)`, as `err` is (below); a text past 256 bytes is cut there, -between words, and ends in an ellipsis, `…`, as an `err`'s reason is past -200; its serial is the pane it ran at -- a line written to the root's -`exec` or `look` runs at the active pane, and is logged as that pane's, -even while the keyboard is on a column's or the workspace's tag -- and `-` -for a line to the root's `ctl`, `/tagexec`, or a column's `ctl` or `exec`, -which run in a tag no pane owns), and `err <serial|-> -<file>: <why>` (the serial is that of the pane whose file it is, and for -the root's `exec` and `look` the pane the line ran at, `err 3 exec: ...`; -`-` for another root file) for every write or truncation the tree refused or that -failed -- through a mount a shell sees only the errno its kernel mapped the -reply to, usually `Invalid argument`, and this is the reason (`err 3 addr: -no match for regexp`). A record said again word for word, straight after -itself, is counted rather than repeated (`err 3 addr: no match for regexp -(x4)`: four in all, counting the first), so a client retrying a failing -write does not push the rest out of the ring. A record a follower has -already read is never rewritten: the next repeat is a line of its own -carrying the running total, `(x5)`, and counting goes on from there, so a -follower sees each count as a new line. Through a kernel mount a client sees only an errno, which 9ns reads from the -error's words (cloud9's 9ns/src/nine.zig, `enameToErrno`): a malformed write --- an unknown or ill-formed control message, `bad address syntax`, `bad -regular expression` -- is EINVAL; a lock another open holds, EBUSY; a pane -gone, ENOENT; a well-formed write that fails -- `no match for regexp`, -`address out of range`, `addresses out of order`, a search that gave up, -`<name>: Modified (Exit again to discard)` -- EIO. The err record has the -words. There is no per-pane error file to read instead: -acme's `errors` only takes text, and one record stream is simpler to watch -than a file per pane. A `msg` said while a -pane is being made can precede that pane's `new`; panes present at boot are -recorded before anything else. -Every EINVAL a write gets says why, in its reply and in its `err` record -(`bad character in file name: an empty name`, `invalid write to log: it -takes \`follow\` or \`follow new\``, `invalid write: this pane has no -text`), never a bare `Invalid argument`. -Control characters, DEL and C1 controls (U+0080-U+009F) in a record become -spaces, and a byte that is not UTF-8 is written `\xNN`, so a record is one -line of UTF-8; a pane's name, in a record, in `/index` and in a terminal's -tag alike, shows a newline in it as `\n` rather than a space and its own -backslash as `\\`, so a directory named with a newline and one named -with a backslash and an `n` read back differently, as what they are. -(An `event` record is not: acme's `<origin><action><q0> <q1> <flag> <n> -<text>\n`, whose text may hold newlines. The origin is `E` (a 9P write to body or tag), `F` (other -files, the editor's own lines), `K` (the keyboard) or `M` (the mouse); the -action's case says where: `x`/`l` a click executed or looked at in the tag -(its offsets count the tag's whole text, path included, as `tag` reads), -`X`/`L` in the body, `I`/`D` text put in or taken out of the body, `i`/`d` -of the tag. The flag is acme's: 1 the text's first word is a builtin's, 2 -an expansion record follows (acme's look.c:42-43, exec.c:154-157; pardes -never sends one: a click's record already carries the word it took, its -range and text, so 2 is never set, whatever the origin), 4 (a look) the -text is a file name or address, 8 (an exec) chorded: two records -follow, the argument's text and where it came from, `<file>:#q0,#q1`, each -with no place of its own: offsets `0 0` and flag 0, `Mx0 0 0 5 hello` (as -acme's exec.c:182 writes them). -Written back, an `X`/`x` record executes and an `L`/`l` record looks, as -the click would have. A chorded one (flag 8) runs with its argument: the -record after it in the same write, else the one kept from the click; its -two follow-up records written back after it, together or in writes of -their own, are consumed as its, never run as commands (written back alone -with no argument kept, it waits for its argument record); the origin letter -written back is ignored but for `F` on `X`, which runs as the command it -was rather than going to a bound REPL; `I`, `D`, `i` and `d` are reports and -are refused. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and -count pardes serves is in bytes, `#n` and `q0`/`q1` too; the event count -follows them rather than switch alone, so an acme library reads pardes -correctly for ASCII text and not beyond it. Offsets are bytes, but every -address lands on a rune boundary, as sam's and acme's work in runes, never -inside a multibyte rune and never widened to a grapheme cluster: a `#n` -inside a rune snaps back to its start, a `line:col` likewise, a search's -match covers the runes it touches, and a copy of addr to dot keeps its -runes, so a lone combining mark or the `\r` of a CRLF is addressable on its -own (an Edit's `x`, `y` and `s` match the same way: `.` is one rune); `addr` reads back the snapped offset. (How the terminal draws such a -text, a cluster to a cell, is apart from this.) A click in a -tag gives offsets into the whole tag as `tag` reads it, the path first.) An -open freezes the ring's text the way `/screen` freezes a frame: reads walk it -and end. Writing `follow` to that same open makes reads past it wait for the -next record, one per read, after first reading all that the open froze (the -ring's whole history, up to 64 KiB); `follow new` skips that and waits for -what comes after, as `tail -n0 -f` does. A follower the ring outran reads -`lost N` first. -Closing the open is the only way back, as with rio's `consctl`. A follower -misses nothing within a session only: a Restore hangs its connection up -(and a 9ns mount with it, which must be restarted), so it dials again and -reads the new log from its `restore <path>`. +The serial is the pane the line ran at (the active pane for the root's +`look`/`exec`), `-` for the root `ctl`, `/tagexec` and column files. A +record said again straight after itself is counted, `err 3 addr: no match +for regexp (x4)`; a follower sees each count as a new line. A `msg` is cut +at 256 bytes and an `err` reason at 200, ending in `…`. Control characters +become spaces. -A read with nothing to give yet -- a following `log`, `event`, `pty/data`, a -`pty/run` before its answer -- is held, the way factotum holds its log's reads -(security/auth/factotum/log.c) and acme an event read: the core keeps it, and -whoever next has news for it (a record, output, a run's answer, the pane -closing, which answers `no such pane: its window shut`, ENOENT as any -other file of a gone pane gives, where acme says "window shut down") answers it on the -connection it came on as the turn is given up. Nothing else parked is -retried for it. The core keeps the ticket cloud9 gave the park -(`Conn.hold`) and answers only while that very park waits, so a read the -client flushed or whose fid it clunked meanwhile is dropped unanswered, no -record is spent on it, and a tag the client reuses is never answered with -what was meant for the old one. An open waits with one read at a time, as -acme's window keeps one `eventx`; a second read on it meanwhile fails with -"file in use". QUIC connections still retry their parked reads each tick. +## Other files -`pty/run` runs one line at a terminal's prompt and answers how it ended, on -the same open (factotum's `rpc` shape): write the line, then read `exit N` -once the command has ended and the shell is back at a prompt, followed by -what it printed. The output is what the screen showed between the command's -start and end marks: stderr interleaved, `\r` progress collapsed to its last -state, no colour, tabs as the spaces they drew, trailing spaces trimmed and -trailing blank lines dropped (`printf 'a\n\n\n'` answers `a`), leading -whitespace kept; a program on the alternate screen (vim, less, htop) leaves -none, and rows a program redrew above its start are missed. A bash job notice -printed before its PROMPT_COMMAND lands in the next run's output. Only its -last 64 KiB are kept, from a line start, and the header then reads `exit N -cut M` (M bytes left out). `exit N cut`, with no count, says the output's -start is not there to read: it scrolled out of the history, the command -erased the screen (`clear`, `watch`, a full-screen program's redraw, a -reset -- so such a command's output may read as cut), a start or end mark -came on the alternate -screen, or the command printed more than 8192 rows, of which only the last -are read so that the answer costs the editor a bounded amount. `exit ?` is a -command whose end mark carried no status, which is not a success; `error out -of memory` is an answer that could not be made. The header is always the -whole first line. It reads -`busy: <program> is running` at once when a command is running (bare `busy` -where the host cannot name the program, and when text is typed at the prompt) --- a run is a line typed at the shell's prompt, so a program holding the -terminal (a REPL, `less`) takes none: write to `pty/data` for it -- -which is also when the third field of `pty/status` reads 1. `pty/status` is -one line, three right-aligned fields and a newline: the pty's columns and -rows, then busy (0 or 1). `pty/ctl` takes `winsize C R`, `sig -INT|TERM|HUP|QUIT|KILL` and `exec`, which starts the pane's shell again in -its directory: one that is gone is refused before anything runs, `exec: -<dir>: no such directory` (ENOENT), and a shell the host cannot start (not -there, not executable, a script whose interpreter is not there) fails the -write with why -- `shell: shell not found`, or `shell: interpreter -/no/such/interp not found` for a script whose `#!` names a program that is -not there, ENOENT -- keeping the terminal and its running shell: a shell -not there is refused before anything runs, and the host starts the new one -before the old goes, so one that cannot start leaves the old be; a `Tty` naming such a shell or -script is refused before anything runs (`Tty: interpreter ... not found`, -its `err` the only record), and one whose shell cannot start fails the -same way and leaves no pane. The host knows before it answers: the child reports a failed exec -through a close-on-exec pipe. A directory removed -under a running shell leaves the pane its name (never `... (deleted)`), so -an `exec` works there once the directory is back. The size, and `pty/ctl`'s `winsize` read back, is -what `winsize C R` last set (R of 1 is taken as 2: a one-row pty loses -its prompt's mark and would read busy for ever), until the pane itself -resizes and gives the pty its grid again. A line written -before a new terminal's shell has drawn its first prompt is not busy: it -waits for that prompt (a respawn meanwhile keeps it waiting for the new -shell's) and is sent then, so the first command a script gives a fresh -terminal is not lost. A shell that never draws a tagged prompt (one pardes -could not instrument, or a startup that hangs) leaves such a line waiting -for ever: cancel the read (interrupt it, or close the open) to give up; -`error not run` when the shell refused -the line without running it (a fish syntax error; the line is taken back off -the prompt): the shell's marks say only that it did not run, so the answer -carries no reason or code, and the shell's own complaint is in the pane's -body (`tail body`); `exit N` and what it printed when the line ended the shell -itself (`exit 3`, or `echo bye; exit 3`): its terminal closes, and a read -of the run's open still answers after the pane is gone; `error shell gone` -when the pane closed or its shell was replaced, the shell went without -an exit status to tell, or it never started (a `Tty` in a directory that is -not there is refused before, `Tty: <dir>: no such directory`, and makes no -pane); `error no prompt marks` for a shell pardes could not instrument; -`error command done; not a shell` (or `error a command runs here, not a -shell`) on a command pane, whose child is its command. -It relies on the OSC 133 marks pardes injects into bash and fish, tagged -`aid=pardes` so fish's own marks and a nested shell's are ignored. A second -line on an open whose command still runs fails the write. `exec zsh` or a -continuation prompt never reports an end; cancel the read. A read of `run` -waits in pardes, so through 9ns it needs 9ns's concurrent requests or it -holds up the rest of the mount. A record -longer than a read comes in pieces, so a shell's `read` loop works: `exec -3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done`. bash's -`read` takes a chunk, keeps one line and seeks back to just past it; a -followed log, `event` and `pty/data` answer a read at an offset inside -their last answer from that answer again, so no record is lost. Through a -FUSE mount (9ns --mntgen) bash's `read -t` does not time out on a followed -file: the read waits in the mount, where its timer cannot cut it; wrap the -loop in `timeout N`, or read with `cat` in the background and poll what it -wrote. `tail -f` -never writes `follow`, so it sees nothing new: use the follow open instead. `/screen` returns JSON with -`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of -`[grapheme, style_index]`. Each open freezes one frame until close. A -terminal `body` freezes its history on the first read of each open handle; -a PDF's `body` reads the text layer of the page it shows (MuPDF's -extraction; turn the page for another), and it, as an image's, takes no -write (`this pane has no text`, EINVAL); -`pty/data` streams live output. The listings -- `/index`, `/layout`, -`/recent`, `/commands`, `/status`, `/listeners`, and a `ctl` opened only to -read -- freeze at the open too, so one read in several chunks never splices -two moments; open again for what is there now. An open that holds something between open -and close -- a frozen screen, listing, terminal body or log, a run, an `event` or -`pty/data` open -- takes one of 64 records (lib9p's per-fid aux, acme's -Fid), released on close or disconnect; past that such an open fails with -`ENFILE`. Other opens hold nothing and are not counted. +- `/screen`: JSON `cols`, `rows`, `cursor`, `styles`, and row-major `cells` + of `[grapheme, style_index]`; one frame per open. Compare a cell's style + through `styles`, not the index. +- `/commands`: `Word [arg] root|pane|both [values] -- sentence`, one a line, + generated from the builtin registry (`both`: Edit, at the active pane from + the root). +- `/recent`: up to 200 files, most recent first, `open <path>` or `closed + <path>`; kept in `$XDG_STATE_HOME/pardes/recent`. `Recent` shows them in a + pane; a look at a row reopens the file at its last dot. +- `/status`: `pid`, `version`, `panes`. +- `/os/`: existing regular files take read, write and truncation to zero; + create, remove, rename and metadata changes are refused; ownership is + synthetic. Linux v9fs's truncation `mtime` hint is accepted and dropped. +- `/src/` (and `/shaders` on GUI builds) with `-Dembed-sources=true`; + `EffectCode <effect>` lists an effect's files under `/virtual`. -`-Dembed-sources=true` embeds the editor's sources and serves them under -`/src` (and `/shaders` on GUI builds). `EffectCode <effect>` lists the current -backend's implementation files under `/virtual`, which Look opens; without the -option the command reports the sources as unavailable. It is off by default -everywhere; the esp32p4 image in particular has no room for them (~1.8 MB of -source against a 1.5 MiB app partition). +## Limits -This is a control filesystem, not a complete POSIX export. Native filenames -may contain up to 255 bytes. Existing regular OS files support read, write, -and truncation to zero; under `/os` protocol create, remove, rename and other -metadata changes are refused, as is every create in the control tree and every -remove in it but a pane directory's. Ownership and permissions under `/os` are -synthetic. -Zero-length truncation accepts the accompanying `mtime` hint sent by Linux -v9fs; the hint is not stored. Standalone timestamp changes remain refused. +| | | +|---|---| +| panes | 64 (16 on the board) | +| columns | 16 (6 on the board), each at least 10 cells wide | +| rows a pane keeps | its tag and 2 | +| msize | 8192 offered | +| connections | 16 Unix+TCP, 16 QUIC | +| opens holding state | 64 | +| named mounts | 8 | +| command line | 1024 bytes; a held line or Edit block 1 MiB | +| tag text | 4096 bytes | +| regular expression | 512 operations; a step budget per search (~300 ms) | +| undo | 256 steps | +| log | 64 KiB ring; `msg` 256 bytes, `err` reason 200 | +| `pty/run` output | 64 KiB | +| file name component | 255 bytes | -The tree lives in `src/ninep/`: `tree.zig` (nodes, lookup, readdir, dispatch, -and the editor's reply payload over cloud9's backend contract), `pane.zig` -(pane files), `ctl.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log -streams), `screen.zig` and `sources.zig`. The protocol engine is cloud9's -`fs.Server`, configured in `src/9p.zig` (the editor's and the board's -capacities); the transports are `src/9p_io.zig` (cloud9's `serve.Runner` -for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts); -`src/fs.zig` keeps host access, mounts, resolution, find and grep. +## Source and tests -`zig build fs-test` drives real sessions using the independent Python client -in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing -creates nothing, that an open of `/pane/new` and a remove work, and that -`look`, `exec`, `name`, `sel` and `log` behave. `zig build -9p-test` checks the two engine configurations' budgets (the engine's own -tests are cloud9's `zig build test`); `zig build fs-bench` measures -filesystem transactions in the core. -`zig build fs-test quic-test -Dquic=true` also exercises QUIC mounts and I/O. +`src/ninep/`: `tree.zig` (nodes, dispatch), `pane.zig`, `ctl.zig`, +`cols.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log), `screen.zig`, +`sources.zig`. The engine is cloud9's `fs.Server` (`src/9p.zig`); transports +are in `src/9p_io.zig`; `src/fs.zig` holds host access, mounts and +resolution. `zig build fs-test` drives real sessions with `test/ninep.py`; +`zig build 9p-test` checks the engine budgets. diff --git a/docs/open-questions.md b/docs/open-questions.md index d4b5699c..3f365ce9 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -1,76 +1,17 @@ # Open questions Decisions deliberately left open, with what is known so far, so they can be -picked up without redoing the research. +picked up without redoing the research. None is open now. -## Where does an unknown command word run? +## Decided -Raised 2026-09-27 in the review of pardes against acme (Plan 9 source at -`~/05-genizah/principia-softwarica`). - -**Decided 2026-09-28: a command pane.** Clicked or written at an interactive -terminal at its prompt, the line is typed into that shell, whose state the -clicker can see. From anywhere else it runs as its own terminal pane whose -child is the root ctl's `Shell` run with `-c` and the line, in the pane's -directory: full emulation -(colours, `less`, `vim`, `sudo`'s prompt work), the exit status from waiting -on the child (`exit N`, `exit 127` for a misspelling, no prompt marks -needed), Kill signalling its process group, `run`/`exit` records in the log, -and `exec` answering its serial. One command pane per directory is reused -once done, and keeps what it showed, as acme appends to `+Errors` and never -clears it (util.c:213-221). Chosen over acme's process-into-`+Errors` because -it keeps interactive programs working without a streaming runner, and over -a PATH check before typing into a shell, which misjudges builtins, aliases -and functions and leaves the shell's state in the way. The analysis below is -what it was decided from. - -**Today.** A middle-click, an `exec` write or a tag word that is not a builtin -is typed into a terminal pane: the pane itself when it takes a command line, -else a shell for the pane's directory (`execute`, `ttyForDir` in -`src/exec.zig`). The command shares that shell's cwd, environment, history -and aliases, and its output lands in the terminal. - -**acme.** An external command runs as its own process: stdin from -`/dev/null`, stdout and stderr to the directory's `+Errors` window, `$winid` -and `%` set for it (`editors/acme/exec.c`, `run()` and its callers around -:1180-1300 and :1425). No shell state is shared between commands. - -**What each costs.** - -* Typing into a shell: interactive programs work, and so do aliases and - functions. But every command depends on that shell's state (is it busy, - which directory it is in, what was typed at its prompt), and a misspelled - builtin silently becomes a shell command. The 9P `exec` file cannot report - that the command failed. -* A process per command: the misspelling hazard goes away at the root, the - editor knows each command's exit status and output, and `exec` could answer - the way `pty/run` does. But aliases, shell functions and interactive - programs need a terminal of their own, and output goes to a `+Errors`-style - buffer rather than a live terminal. - -**Evidence from use (2026-09-28).** A fresh agent driving pardes through -9P wrote a misspelled word to `exec`; it was typed into a terminal's fish -shell ("Unknown command"), nothing reached `log`, and the write succeeded. -Its report ranked this among the confusing behaviours. - -**Related.** `pty/run` (see `docs/fs.md`) already gives an agent the second -behaviour inside a chosen terminal: a line in, `exit N` and its output out. -The planned root `ctl` (session builtins) and pane `ctl` (pane builtins) -refuse unknown words, whatever is decided here; the question is only what -`exec` and the middle-click do with them. - -## Where does an exec from a code file go? - -**Decided 2026-09-28: to a REPL bound for its language, when one is.** -`Repl python` binds a terminal; then an exec made by a gesture (a middle -click, the execute key, a selection or a single word) on the body of a file -in that language, or on the REPL's own body, is typed into the REPL -instead of run. What stays a command whatever is bound: a builtin's word, -a word in a tag, `Exec <text>` run by name, a command word @`cmd` in the -text, and a 9P `exec` write (a script writes the REPL's `pty/data`). An -event record written back is done as the click it was, REPL and all. With -several bound the pane asks which, one key answering, and remembers -nothing. A REPL takes text only while its program has the terminal, and a -done command pane cannot be bound. Chosen over a per-file or per-directory -binding, which would need a place to keep and show it; bindings are not -dumped. `docs/fs.md` has the behaviour. +- **Where an unknown command word runs** (2026-09-28): at a terminal idle at + its prompt it is typed into that shell; anywhere else it runs as a command + pane, a terminal whose child is `Shell -c <line>`, reporting `exit N` in + its tag and the log ([fs.md](fs.md#look-and-exec)). Chosen over acme's + process writing into `+Errors` because interactive programs keep working + without a streaming runner, and over checking `PATH` before typing into a + shell, which misjudges builtins, aliases and functions. +- **Where an exec from a code file goes** (2026-09-28): to a REPL bound for + its language, when one is ([fs.md](fs.md#repls)). Chosen over a per-file + or per-directory binding, which would need a place to keep and show it. diff --git a/docs/tags.md b/docs/tags.md index 3ff5e0ef..8fe63804 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -1,312 +1,161 @@ -# Editable tags +# Tags and columns -Pardes has three levels of command text: the workspace tag, one tag per -column, and each pane's tag. Column commands act on that column and run in -its active pane (or its first pane when focus comes from another column); a -column with no pane is described under [empty columns](#empty-columns). This makes -`New`, `Tty`, `Find`, and `Grep` available beside the work they act on. -A command or a `Tty` run from the workspace tag or a column tag starts in -the session's directory, where pardes was started, as acme's row and column -tags have no directory of their own; one run from a pane's tag or text -starts in that pane's directory. -`New` appears only in the column tag by default; pane tags keep their own -save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal. -In a terminal's own tag the word names the shell it runs, `Tty+fish`, tinted -with the tag's name colour: `Tty+arg` is one word a tag can hold for `Tty -arg`, so clicking it opens another terminal on that shell, as `Tty fish` -would. Only a builtin that declares `plus_arg` reads a `+` so; `Tty` is the -one, since other arguments (a dump's name) may contain `+`. A command pane's tag reads `<dir> (<line>) running` or `exit N` -and offers `Kill`. `Repl python` in a terminal's tag binds it as Python's -REPL, its id, `python-a`, beside the Tty word: a middle click or the execute -key on a `.py` body then types the text into it rather than running it, and -several bound ask which. `Repl` takes any language a code fence names -(`py`, `Python`, `sh`): ada, bash, c, c_sharp, clojure, cpp, css, elixir, -erlang, fortran, go, haskell, html, java, javascript, json, kotlin, ocaml, -markdown, pascal, php, powershell, python, ruby, rust, scala, typst, zig; -an unknown one is refused, naming a few of these. The tag's own words, `Exec <text>` by name and a -command word @`cmd` still run as commands (docs/fs.md). -A tag word runs as acme's does, in the pane's directory with no file named: -`wc` alone waits on its stdin. pardes has no `$%` for the pane's file; name -it (`wc notes.txt`), or select the name and middle-click `wc`, which takes a -held selection as its argument. -Pane and column command text leave a small gap after their aligned drag grips. -GUI pane mode symbols are centered by their visible ink; changing the font or -its size refreshes the cached measurements. -The column grip has a different -color from a pane grip. It stays blank and muted even when its column is active, -and lights in its full accent while it is held. -Drag it past a neighbour's middle to move the whole column there; dropped short -of that, it moves the column's left edge instead, as a border drag would. While -it is held, a dashed rail in the grip's accent stands where the column's left -edge will land, beside the rule as a border drag's rail does. Only columns -between the old and new positions shift. Each column keeps its width, panes and -command text. - -Two panes in a column are resized as acme's windows are, by the grip: drag -a pane's grip up or down its own column and its top follows, the pane above -taking or giving the rows, either one down to its tag alone (acme's -coldragwin keeps a window its tag line); drop it in another column and the -pane moves there. In the GUI the 2 px rule between two panes also drags -their seam. No row of text is a handle: a pane's last row takes a click like -any other, and a terminal, which draws no rule, resizes by the grip. A -column's right edge still drags its width. - -When Look opens the first document and its source column is too narrow for -a new column, it splits below the originating pane, just like `Tty`, instead -of inserting at the top of the leftmost column. The originating terminal is -kept. As with `Tty`, a tagline-only source uses a roomier split parent. - -`Collapse` reduces a pane to just its tagline, giving all its body space to -the nearest expanded pane above, or below if none is above. Other panes keep -their heights. Execute it again to reclaim its former height from that one -neighbor, as far as available space allows. If every pane is collapsed, the -unused area stays blank. -Its text and terminal process are kept; -the command stays available in the visible tag. Every default pane tag includes -`Collapse`, including file, terminal, image, and PDF panes. - -`Edit` is acme's: the rest of the line is sam's command language, run on -the pane's body (`Edit ,x/foo/c/bar/` renames every `foo`), from a tag, a -pane's `ctl` or `exec`. Addresses are the `addr` file's, `line:col` -included; the commands are `x y g v c a i d s p = m t`, `u` alone, and `{ }` -with a command per line. All its changes are one undo step, applied only -if every command ran; an error says why in acme's words and changes -nothing. `p` and `=` print to the directory's `+Errors`. Left out: the file -commands `b B D e r w f X Y`, the pipes `< | >`, and `\1`-`\9` in `s`, -since mvzr keeps no submatches; acme applies changes that come out of -sequence with a warning, pardes refuses the Edit. In `s`, `&` in the -replacement is the matched text (`\&` a plain `&`); in `c`, `a` and `i` an -`&` is only an `&`, as in sam. A pattern that finds nothing says so with the -pattern (`no match for regexp /nomatch/`), and an `s` on an empty dot says -`no substitution: dot is empty`. A loop that finds nothing is no error, as -in sam: `Edit ,x/zzz/c/bar/` with no `zzz` changes nothing and succeeds, -silent, where `,s/zzz/bar/` says `no substitution`. - -`Undo` and `Redo` are acme's: typed or clicked in a pane's tag, or written -to its `ctl`, they step the body back and forward through its edits, as the -`u` and `U` keys do. They are not in the default tags. +Pardes has three levels of command text: the workspace tag, a tag per +column, and each pane's tag. A column tag's words act on that column and run +in its active pane (its first pane when focus comes from another column). A +command or `Tty` run from the workspace or a column tag starts in the +session's directory (where pardes started), as acme's row and column tags +have none of their own; one run from a pane's tag or text starts in that +pane's directory. Over 9P the tags are `/tag`, `/col/<n>/tag` and +`/pane/<n>/tag` ([fs.md](fs.md#columns-and-tags)). -`Del` closes a pane and gives its rows to one neighbor; the rest of the -column keeps its heights. A pane with unsaved text is refused once, as -acme's Del warns (exec.c del, wind.c winclean): a short notice, `1 unsaved -pane — Del again to discard`, the pane listed in a kept `+Unsaved` (`<name>: -Modified`, then `Del again to discard`), and the same `Del` again, nothing edited since, closes it and -throws the text away; `Delcol` refuses a column holding such a pane the same -way (exec.c delcol). Each word warns on its own, from a key, a tag, `exec` -or a ctl, where the refusal fails the write with EIO. `Del k` (or `DelAbove`) gives them to the nearest -expanded pane above, `Del j` (or `DelBelow`) to the one below, each falling -back to the other side. Bare `Del` from the keyboard (`SPC d`, or Enter on -the word) on a pane with expanded panes both above and below asks on the -pane's notice band: `k` or Up gives the rows above, `j` or Down below, and -any other key keeps the pane. A click, a 9P write or a startup line never -asks; there bare `Del` gives the rows to the nearest document above, as it -always has. A collapsed pane is never asked about, and collapsed neighbors -are passed over: they only keep a weight for later. +## Default words -A tag is a text like a pane's body: pane tags, column tags and the -workspace tag all edit with the body's own normal and insert modes, undo -included. A pane's tag may hold more than one line, and a line wider than -the pane wraps onto more rows, as acme's tag does: it takes a row per shown -line, up to eight, and leaves its body at least one row. A line exactly as -wide as the pane stays one row, its caret at the tag's edge past the last -character, as acme's tick is (in a terminal, on the last cell). A word the -wrap breaks across rows is still one word to a click. A collapsed pane shows -only the first row. +- Workspace: `Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor + Debug Exit`. +- Column: `New Tty Find Grep Joincol Delcol`. +- File pane: `Save Tty Collapse Del`; a source file with a grammar adds + `TreeContext`, a result list `LocationsConfig`. PDF: `manual.pdf [1/12] + Tty Del PdfSections PdfTint Collapse`. +- Terminal: `Tty+bash Save Mode Filter Collapse Del`. `Tty+bash` is one word + for `Tty bash`, opening another terminal on that shell (only `Tty` reads a + `+` so). `Mode` cycles raw terminal input, normal mode and insert mode. +- Command pane: `<dir> (<line>) running`, then `exit N`, and `Kill`. -A pane tag starts expanded, showing all its rows, and can be collapsed to -one row, as acme's Tagup does. In the tag's insert mode Up on its first row -collapses it and Down on its last row expands it; Alt-Up and Alt-Down do -the same in either mode. acme expands or collapses on any arrow key typed in -the tag (Up collapses, the others expand); here arrows keep moving the -cursor, and only these keys at the tag's edges change its height. The column and workspace tags -are one line, as acme's (cols.c:244 gives a column tag one font height): a -newline typed, pasted or written into one becomes a space, and a dump that -holds a column or workspace tag of several lines, from before, comes back -with its lines joined by spaces. The path and a PDF's page at the start of a -pane tag are computed, never stored, and read-only. The keyboard reaches -them as the mouse does: `0` goes to the line's start, the path's, as in -acme, and motions select and yank across the path and the commands. An edit -that would change them is refused and leaves the cursor where it was; typing -into a file's path instead drafts a new name, as clicking it does. When they -grow or shrink (a rename, a PDF's page) the cursor keeps -its place in the text after them. +`Undo`, `Redo` and `Mode` (on files) work typed or clicked though they are +not in the default tags. Customized tags keep their text. -Left-click a tag or a header to type into it at the click, in insert mode. -A pane tag wraps instead of scrolling sideways; a column or workspace tag -reveals the caret horizontally when its text is wider than its column. -Esc is normal mode, where everything a body's normal mode does works. There -Tab executes the word under the cursor (or an explicit selection), and Enter -looks it up in a pane's tag but runs it in a column or workspace tag, whose -words are all commands; in insert mode Enter is a new line. Executing gives -the keyboard back to the body first, so `Del`, `Kill` and `Restore` never return into a tag that is gone. -Pasted text goes to the focused tag, not to the file or embedded shell -beneath it. +A tag word runs as acme's does, in the pane's directory with no file named: +`wc` alone waits on stdin. There is no `$%`; name the file (`wc notes.txt`), +or select the name and middle-click `wc`, which takes a held selection as its +argument. -`:` is the one key a tag and a body do not share: in the body's normal mode -it focuses the pane's tag in normal mode, and in the tag's normal mode it -goes back to the body; in a column or workspace tag it goes back to the -active pane. Each pane, column and workspace tag remembers its own cursor -during the session; the first `:` into a pane tag starts on its `Save`. A -tag's text changing under it (a 9P write, a shorter tag) pulls the cursor -back inside it. +`Repl python` in a terminal's tag binds it as that language's REPL +([fs.md](fs.md#repls)). `Repl` takes the languages a code fence names: ada, +bash, c, c_sharp, clojure, cpp, css, elixir, erlang, fortran, go, haskell, +html, java, javascript, json, kotlin, ocaml, markdown, pascal, php, +powershell, python, ruby, rust, scala, typst, zig, and aliases such as `py` +and `sh`. -Moving between tags is the window keys' job, as it is between bodies -(`Ctrl-w` or `SPC w` with `h/j/k/l`): they move to the neighbouring pane's -body. Up from a pane with nothing above it reaches its column's tag, then -the workspace's; Down comes back the same way, and Left and Right walk the -column tags. +## Pane builtins -Search (`/`), `s`/`S`, pipe (`|`) and Save's path prompt are not typed into -the tag: each gets a line of its own on the pane's notice band, with the -body's insert-mode keys. Pressed in a tag, `s`, `S` and `|` answer for the -tag's own text, as they do for a body, and a header's go on the active pane's -band; `/` searches the body wherever it is pressed, as acme's Look from a tag -does. +`Collapse` folds a pane to its tagline, giving its rows to the nearest +expanded pane above (else below); again, it takes them back. Its text and +process are kept. -Pane filenames and commands now have a single separator space rather than -generated right-alignment padding. Intentionally customized spacing is kept. +`Del` closes a pane, giving its rows to one neighbour. `Del k` (`DelAbove`) +gives them to the nearest expanded pane above, `Del j` (`DelBelow`) below, +each falling back to the other side. Bare `Del` from the keyboard (`SPC d`), +with expanded panes above and below, asks on the notice band (`k`/Up above, +`j`/Down below, any other key keeps the pane); a click, a 9P write or an +`init` line never asks and gives the rows above. -## File names +A pane with unsaved text is refused once: a notice `1 unsaved pane — Del +again to discard`, the pane listed in `+Unsaved`, and over 9P the write +fails (EIO). The same `Del` again, nothing edited since, discards. `Delcol` +refuses a column holding such a pane the same way, without opening +`+Unsaved` or moving focus. `Exit` and `Restore` do the same over the whole +session. A `+New` scratch under 100 bytes is never asked about. -Clicking a file pane's name, or typing into it from the tag, starts a draft -of it, typed into where it was clicked or typed. -Enter or Tab confirms the new buffer name; Escape or leaving the pane -cancels the draft. Confirmation changes the -buffer's save target and marks it unsaved. It does **not** rename, create or -overwrite a disk file. A subsequent explicit `Save` writes the buffer to its -committed name. Executing a pane-tag command confirms a valid name draft -first, so a visible draft cannot silently save to the previous name. +`Edit` runs sam's command language on the body ([fs.md](fs.md#edit)). +`Undo` and `Redo` step the body through its last 256 edits, as `u` and `U`. -Terminal working directories and generated image/PDF status remain managed -by their corresponding commands. Their command tails are editable just like -file command tails. +Unsaved text shows on the pane's grip, as acme's modbutton, not in the tag. +In a terminal the grip is two cells: the pane's mode (blank normal, `^` +insert, `$` tty mode), then `*` while unsaved. -PDF tags follow the same filename-first layout: `manual.pdf [1/12] Tty Del -PdfSections PdfTint Collapse`. Sections and tint commands remain in the editable -tail, without displaying the current tint state. `PdfFit` (`SPC t z`) remains -available, as do the shortcuts for `PdfTint` (`SPC t i`) and `PdfSections` -(`SPC t s`, or `f` on a PDF). +## Editing tags -Terminal tags include `Mode`, which cycles through raw terminal input, normal -editor mode, insert mode, and back to terminal input. On files and text output -panes, `Mode` cycles between normal and insert mode; it is available as a command -but does not appear in their default tags. Images and PDFs keep their normal mode. -Executing `Mode` from a tag leaves the tag and advances the parked body mode. +A tag is text like a body, with the body's normal and insert modes and undo. +A pane tag may hold several lines and wraps, taking a row per shown line up +to eight and leaving its body at least one; a collapsed pane shows the first. +Up on its first row in insert mode (or Alt-Up in either mode) folds a tag to +one row, Down on its last (Alt-Down) unfolds it. Column and workspace tags are +one line: a newline typed, pasted or written into one becomes a space. -Ctrl-B keeps its terminal/editor toggle. Existing custom tags can still use -`Togglettymode` for that two-way terminal toggle. Old default terminal tags -upgrade to `Mode`; customized command text is preserved. +The path (and a PDF's page) at the start of a pane tag is computed and +read-only; `0` goes to its start, and motions select and yank across it. +Typing into a file's path, or clicking it, starts a draft of a new name: +Enter or Tab confirms it, Escape or leaving the pane cancels. Confirming +changes the buffer's save target and marks it unsaved; nothing on disk is +renamed until `Save`. A pane-tag command confirms a valid draft first. -## Saved workspaces +Left-click a tag to type at the click, in insert mode. Esc is normal mode: +there Tab executes the word under the cursor (or the selection); Enter looks +it up in a pane's tag and runs it in a column or workspace tag. Executing +gives the keyboard back to the body first. Paste goes to the focused tag. -`Dump` and `Restore` preserve customized workspace and column tags, including -intentionally empty tags, and columns that hold no pane. New columns start with the standard column tag. -Closing a column keeps surviving columns' tags; `Joincol` keeps the destination -column's tag. Old dumps without these optional fields retain the defaults. -The automatic `Restore` shortcut does not overwrite a customized workspace -tag. +`:` in a body's normal mode focuses its tag (the first time, on `Save`); `:` +in a tag goes back. The window keys (`Ctrl-w`, `SPC w` with `h/j/k/l`) move +between panes; up from the top pane reaches its column's tag, then the +workspace's, and Left/Right walk the column tags. Search, `s`/`S`, pipe and +Save's path prompt get a line on the pane's notice band; in a tag `s`, `S` +and `|` act on the tag's own text, while `/` searches the body. -The column row stays above panes with `TagBottom` enabled. On screens shorter -than three rows it is omitted so a pane still has room. SDL and TTY share the -same tag text, editing and layout; SDL additionally uses compact font sizing -and subtle pixel separators. +## Moving and resizing -The column row is always shown. An old `ColumnTags` line in an init file is -ignored, with a message saying so. -`FocusTint` controls active-column and active-pane emphasis. SDL also honors -the shared bold, underline, and strikethrough attributes, including diagnostic -underlines and the optional `SyntaxBold` keyword weight. +Drag a pane's grip up or down its column and its top follows, the pane +above giving or taking rows, down to its tag alone; drop it in another +column and it moves there. In the GUI the 2 px rule between panes drags too. +A column's right edge drags its width. Drag a column's grip past a +neighbour's middle to move the whole column there; short of that it moves +the column's left edge. -Unsaved text shows on the pane's grip button, as acme's modbutton does, -not in the tag: the tag names the file and nothing more. In a terminal the -grip is two cells: the second is `*` while the pane holds unsaved text, and -the first is the pane's mode: blank in normal mode, `^` in insert, `$` when -the keyboard goes to a terminal's program (tty mode). The `dirty` file -and `index`'s flag say the same to a script. +A terminal keeps its tag and 2 body rows: no drag, squeeze or smaller +window takes it below that, and `pty/ctl`'s `winsize` gives a pty 2 rows at +least. A text pane can be dragged down to its tag. ## Empty columns A column can hold no pane, as acme's can: its tag stands over blank space in -the theme's `empty_col` colour, white in the acme theme as acme paints it -(cols.c:186-188), and the frame's border fill in themes that do not set it. `Newcol` makes an -empty column right of the keyboard's and gives its tag the keyboard. Closing a -column's last pane (`Del`, `Del k`/`Del j`, a shell exiting, a drag to another -column) leaves the column empty where it was, and the keyboard goes to its tag -if it was on that pane. Only `Delcol` and `Joincol` take a column away. - -`Delcol` and `Joincol` from a column's tag act on that column; `Delcol` -written to a pane's ctl closes that pane's column. A pane dragged onto an -empty column fills it. +the theme's `empty_col` colour. `Newcol` makes one right of the keyboard's +and gives its tag the keyboard. Closing a column's last pane leaves the +column empty, the keyboard on its tag. Only `Delcol` and `Joincol` take a +column away; `Joincol` keeps the right column's tag, its panes below. A pane +dragged onto an empty column fills it. `Delcol` of the last column leaves +the workspace tag alone; `Newcol` or `New` starts again. Unlike acme, pardes +quits when the session's last pane closes. ## Where new panes go -Every new pane goes through one placement, chosen by the `Placement` setting: -`acme` (the default) or `pardes`. `Placement pardes` or `Placement acme` sets -it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c -p` is its leader path, and `Config` reports it. +Every new pane goes through one placement, chosen by `Placement acme` (the +default) or `Placement pardes` (bare flips it; `SPC c p`). -Under either, no placement leaves a pane, new or split, shorter than its tag -and two body rows (acme's minht keeps one). Where the place chosen has not -that room, the column's tallest pane is halved instead; where no one pane -can give it but the column's rows hold every pane's tag and two rows with -the new one's, the rows are shared out again, each at least its minimum; -only when they do not -- the arithmetic, not the halving, decides -- is -the new pane refused and closed, with `no space for a -pane in that column: each keeps its tag and 2 rows` (a 9P write or open of -`pane/new` fails with it, ENOSPC). A pane alone in its column always fits. +No placement leaves a pane shorter than its tag and 2 body rows. Where the +chosen place has not that room, the column's tallest pane is halved; where +no one pane can give it but the column holds every pane's minimum with the +new one's, the rows are shared out again; otherwise the pane is refused, +`no space for a pane in that column: each keeps its tag and 2 rows` (ENOSPC +over 9P). A pane alone in its column always fits. -After placement a terminal keeps that floor too: no drag of a grip, no -squeeze of its column by the others' weights and no smaller window takes -it below its tag and two body rows (a one-row terminal loses its prompt's -mark and would read busy for ever); its neighbours give the rows, and only -a window too short for every floor leaves it less. A text pane keeps -acme's way and can be dragged down to its tag alone; acme has no -terminals to follow here. A folded terminal (Collapse) is a tag by choice. -`pty/ctl`'s `winsize` likewise gives a pty two rows at least. -A `+Errors` pane goes to any column with room, the last first; with none, -what it would have shown (an Edit's `p` or `=`, a write to `errors`) is -logged as `msg` records, a line each, and the Edit still succeeds. +`Placement acme` is acme's makenewwindow. The active column is the one last +typed or clicked in, dropped into, whose tag was given the keyboard, or that +was given the last new pane; a Look moves the keyboard, not the active +column. A new pane goes into the column whose tag the command came from, +else the active column, never into a new column: -`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps -acme's *active column* (activecol, dat.c:37): the column last typed in -(acme.c:487), clicked in with the select button (acme.c:659), dropped into by -a grip (acme.c:640), whose tag was given the keyboard (`Newcol`, an emptied -column, `Ctrl-w k`), or that was given the last new pane (util.c:467). A Look -click moves the keyboard but not the active column, as button 3 does not in -acme. A new pane goes into the column a command's tag belongs to when it came -from a column tag, else the active column, else the keyboard's pane's, and -never into a new column: +- an empty column it takes whole; +- from a tag, or 9P's `pane/new`, it takes the bottom half of the column's + last pane; +- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it + goes under the text of the pane with the most blank rows when that is + more than 15 rows, or more than 3 and more than half the biggest pane; + otherwise it halves the biggest pane, or the asking pane when that is in + the column and not much smaller; +- `New` goes to the bottom half of its own column's last pane; +- a command pane or `+Errors` pane goes to the last column's last pane (a + command from a column's tag to that column, reusing a finished command + pane only there). With no room anywhere, `+Errors` text is logged as `msg` + records. -- an empty column it takes whole (util.c:468-469); -- from a tag, or 9P's `pane/new` (acme's `t->w == nil`), it takes the bottom - half of the column's last pane (coladd, cols.c:62-65); -- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it goes - right under the text of the pane with the most blank rows when that is more - than 15 rows, or more than 3 and more than half the biggest pane - (util.c:482-486); otherwise it halves the biggest pane, or the asking pane - when that is in the column and not much smaller (util.c:487-491); -- `New` goes into its own column, the bottom half of its last pane - (look.c:921-923); -- a command pane or a `+Errors` pane goes to the last column, the bottom - half of its last pane (util.c:94-98); a command run from a column's tag - goes to that column instead, and reuses a finished command pane only in - that column. +`Placement pardes`: an empty column whose tag asked, or has the keyboard, is +filled; a scratch goes under the pane that asked; a shell under it or the +nearest pane with room; a document beside the last one read, or in a column +of its own on the left when there is none and the column is at least 200 +cells wide; a command pane at the foot of the last column. -`Placement pardes` is what pardes did before: an empty column whose tag asked, -or has the keyboard, is filled; a scratch goes right under the pane that asked; -a shell under it or the nearest pane with room; a document beside the last one -read, or in a column of its own on the left when there is none and the column -is at least 200 cells wide; a command pane at the foot of the last column. - -`BootShell replace` brings back one more placeholder: a document dragged into -the left column closes the column's lone shell if nobody has typed into it. -`BootShell keep`, the default, never closes a pane for another one. - -Down from an empty column's tag stays there, and the pane-to-pane keys pass -over an empty column; Left and Right from a tag walk every column's tag. +## Saved workspaces -`Delcol` of the last column does as acme's does: the column goes and the -window stays, empty but for the workspace tag, where `Newcol` (or `New`, -which makes the column it goes in) starts it again. One divergence from -acme remains: acme keeps running when its last window closes; pardes quits -when the session's last pane closes by `Del`, a shell exiting or the like. +`Dump` and `Restore` keep workspace and column tags (empty ones too) and +empty columns ([config.md](config.md#dumps)). The column row stays above +the panes with `Tagbottom` on; on screens under three rows it is left out. diff --git a/docs/v9fs.md b/docs/v9fs.md index a105fe06..26460658 100644 --- a/docs/v9fs.md +++ b/docs/v9fs.md @@ -1,114 +1,56 @@ -# Linux terminals with a kernel 9P mount +# Tty9p: a terminal with a kernel 9P mount -Execute `Tty9p`, or press `SPC n 9` in editor mode, to open a terminal below -this pane with the current Pardes session mounted through Linux v9fs. - -The new pane asks for your sudo password when needed. After mounting, it starts -your configured shell as your normal user, with your account's supplementary -groups. The shell receives `PARDES_MOUNT`, the absolute mountpoint: +On Linux, `Tty9p` (`SPC n 9`) opens a terminal below this pane with the +session mounted through the kernel's v9fs. The pane asks for your sudo +password, mounts, and starts your shell as your normal user (with your +supplementary groups). The shell gets `PARDES_MOUNT`, the mountpoint: ```sh -ls "$PARDES_MOUNT/pane" cat "$PARDES_MOUNT/index" -cat "$PARDES_MOUNT/README" cat "$PARDES_MOUNT/pane/$PARDES_PANE/body" echo 'Msg hello' > "$PARDES_MOUNT/exec" n=$(cat "$PARDES_MOUNT/pane/new") ``` -**Opening** `pane/new` makes a pane, and reading that open file answers its -serial; address the pane as `pane/<serial>` from then on. Each open makes -another one, so read it once and keep the number. A *stat* makes nothing, -which is why `new` can be listed at all: `ls`, `ls -l` and `find` over the -whole mount create nothing, because none of them open it. - -The mount belongs to that pane's subprocess tree. Other panes and the editor -core keep their original mount namespace. It works in native Linux TTY and SDL -sessions, including detached sessions. A frontend attaching from elsewhere does -not perform the mount; the session host starts the new terminal. - -## Build and setup - -The normal Linux build installs the ordinary `pardes-v9fs` executable beside -`pardes` and `pardes-gui`: - -```sh -zig build -``` +The files are those of [fs.md](fs.md). The mount lives in that pane's +private mount namespace: other panes and the editor do not see it, so a +Look at a path under `$PARDES_MOUNT` opens nothing. Each `Tty9p` makes its +own mount and takes one of the session's 16 Unix/TCP connection slots. It +works in TTY, SDL and detached sessions (the session host starts the shell, +not an attached frontend). Dump/Restore does not remake the mount. -Start an updated editor to get the builtin. An already running core retains -its old code. The host resolves the helper beside its own executable; -`PARDES_V9FS_HELPER=/absolute/path/to/pardes-v9fs` overrides this for development -builds whose editor and helper live in different build-cache directories. +## Setup -Linux must support `9p` and its Unix transport (`9pnet_fd`). v9fs mounting needs -`CAP_SYS_ADMIN` in the initial user namespace, which sudo supplies. The launcher -uses `sudo -E` to retain the shell environment; local sudo policy must allow -that. It restores the caller's PATH after dropping privileges because sudo's -`secure_path` can replace it even with `-E`. +The build installs `pardes-v9fs` beside `pardes`; the host looks for it +beside its own executable, or at `PARDES_V9FS_HELPER` (an absolute path). +A running editor keeps the code it started with. -No setuid installation, passwordless sudo policy, system group, or FUSE is -installed. The helper currently accepts explicit mount paths and a command; -it is not a restricted privilege broker. Do not grant it blanket passwordless -access. A group-authorized helper would require a separate restricted design. +Linux needs `9p` and its Unix transport (`9pnet_fd`); mounting needs +`CAP_SYS_ADMIN`, which sudo supplies. The launcher runs `sudo -E` (local +policy must allow it) and restores the caller's `PATH` after dropping +privileges. Nothing setuid, no passwordless sudo rule and no FUSE is +installed. The helper takes explicit mount paths and a command; it is not a +restricted privilege broker, so do not grant it passwordless sudo. -## Runtime organization +## How it works -`src/linux/v9fs.zig` builds `pardes-v9fs` and provides helper discovery to the -native host. `Tty9p` marks the new pane for a mounted shell; `host_io.forkShell` -starts the normal interactive shell and queues a quoted helper command. For -bash and fish, the command waits for the shell's prompt-ready mark. The helper -runs as a foreground shell job, so sudo uses the terminal like a manually run -command. Authentication failure or cancellation returns to the original shell; -exiting the mounted shell also returns there. +`Tty9p` starts the normal shell and queues a quoted helper command, which +bash and fish run once their prompt is ready, as a foreground job, so sudo +uses the terminal. A failed or cancelled authentication returns to that +shell, as does exiting the mounted one. The unprivileged launcher makes a +private temporary mountpoint and runs sudo; the elevated helper makes a +private mount namespace, mounts the session's socket with +`trans=unix,version=9p2000,cache=none,access=any,nosuid,nodev,noexec`, drops +every root id and capability, and executes the shell. The namespace, and +the mount, go when its last process exits. Code: `src/linux/v9fs.zig`. -The unprivileged launcher creates a private temporary mountpoint and invokes -sudo inside the new PTY. The elevated helper creates a private mount namespace, -makes propagation recursively private, and mounts the session's Unix socket -with `version=9p2000,cache=none,access=any,nosuid,nodev,noexec`. It restores the -calling user's account groups, drops all real/effective/saved root IDs and -mount capabilities, and executes the shell. Ordinary commands such as sudo -remain available for subsequent explicit authentication. The launcher waits for sudo, -forwards termination signals, and removes its empty temporary directory on exit. -Namespace destruction releases the mount when its last process exits. - -The core stays outside the mount namespace, which is the helper's private -one: a path under `$PARDES_MOUNT` names nothing in the editor's own namespace, -so a Look on one from that shell opens nothing. - -Each `Tty9p` currently creates its own mount and consumes a server connection; -the session has four application connection slots across all transports. This -is the explicit per-pane version. A shared launcher for all pane shells remains -future work. Detached sessions retain the running mounted pane when frontends -leave; Dump/Restore does not reconstruct mounted-shell namespaces. Descendants -that deliberately outlive the terminal may retain their namespace until exit. +Linux follows `O_TRUNC` with a `Twstat` of zero length and an `mtime` hint; +pardes takes the truncation and drops the hint. ## Tests ```sh -zig build v9fs-terminal-test -Dplatform=tty -zig build v9fs-driver-test -Dplatform=tty -zig build 9p-test -Dplatform=tty -sudo -v -zig build v9fs-test -Dplatform=tty +zig build v9fs-terminal-test -Dplatform=tty # builtin, launcher, cleanup; a sudo stand-in, no mount +zig build v9fs-driver-test -Dplatform=tty # the probe launcher, no privileges +sudo -v; zig build v9fs-test -Dplatform=tty # a real kernel mount (sudo -n); fails, not skips, without it ``` - -`v9fs-terminal-test` exercises the builtin, real launcher, PTY input, session -environment, quoted helper paths, hidden password input, and core responsiveness -using an unprivileged sudo stand-in. It checks that authentication failure and -interruption clean up the temporary mountpoint and leave the original shell usable. It does not claim kernel-mount coverage. - -`v9fs-test` mounts through the same runtime helper. Its driver keeps the editor -unprivileged and uses `sudo -n`, retaining the calling terminal's authorization. -Missing authorization or kernel support fails the test instead of skipping it. -It checks mount isolation, privilege dropping, inherited access, directory -refresh, body reads and truncation, independent wire updates, addressed edits, -shell redirection to name, Exec dispatch, rendered screen JSON, and OS-file reads. - -Linux follows `O_TRUNC` with a `Twstat` carrying zero length and an `mtime` hint. -Pardes accepts this truncation without storing caller-selected timestamps; -standalone timestamp, permission, and ownership changes remain unsupported. - -The initial kernel probe passed on this host on 2026-09-14. The broader -`fs-test` has an existing syntax-bold assertion failure (now `test/fs.py:615`), -also reproduced on the cached editor binary preceding the truncation fix. @@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min; /// The smallest msize a native listener offers (src/9p_io.zig). pub const min_msize: u32 = 4096; /// What a native listener negotiates. -pub const msize: u32 = 8192; +pub const msize: u32 = 65536; /// The editor's engine: native fid count, native file names, and room to /// park a write of any size a frame can carry -- a request that would change diff --git a/src/9p_io.zig b/src/9p_io.zig index 91240b57..78ff5d59 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -261,6 +261,7 @@ pub const Listener = struct { return conn.reply(&later, ""); } const core = l.core; + core.fs.write_room = if (conn.engine.protocol.msize > 24) conn.engine.protocol.msize - 24 else 0; const epoch = pardes.turn.epoch; const restores = pardes.turn.restores; // Asked first: a release that runs a held line has none after. @@ -311,11 +312,14 @@ pub const Listener = struct { if (restoring) l.restore_writer.store(true, .release); defer if (restoring) l.restore_writer.store(false, .release); core.fs.late_failure_len = 0; + // What fails while this waits is this write's err, never a msg too. + core.fs.write_waits = true; if (core.effects_len != 0) pardes.turn.awaitSettled(epoch); if (core.fs.lsp_answer_at) |n| { core.fs.lsp_answer_at = null; pardes.turn.awaitLsp(n); } + core.fs.write_waits = false; // ponytail: one slot, so a failure of another client's effects that // settle in the same wait is told to this write too. if (l.core == core and core.fs.late_failure_len != 0) { @@ -326,9 +330,8 @@ pub const Listener = struct { pardes.ctlfs.E.NOENT else if (std.mem.indexOf(u8, late, "no space") != null) pardes.ctlfs.E.NOSPC else pardes.ctlfs.E.IO; const failed = pardes.ctlfs.failText(req.tag, errno, late); - // Its err record says it (the path in it); the msg the failure - // was also said as goes, as a builtin's failing a write does. - pardes.ctlfs.events.dropMessage(core, core.fs.late_failure[0..core.fs.late_failure_len]); + // Its err record says it (the path in it), the one record: it + // was said with no msg while this waited (fs.write_waits). pardes.ctlfs.events.noteError(core, req, failed); return conn.reply(&failed, ""); } @@ -506,6 +509,7 @@ pub const Listener = struct { /// Answers one QUIC request, on the editor's thread. fn step(l: *Listener, srv: *Srv, req: pardes.ctlfs.Req) void { + l.core.fs.write_room = if (srv.protocol.msize > 24) srv.protocol.msize - 24 else 0; const reply = l.core.serveFs(req); srv.reply(&reply, l.core.fsPayload(reply)); if (req.op == .release) l.collectOs(); @@ -1943,14 +1947,8 @@ pub const Client = struct { } }; - fn transact( - s: *Session, - names: []const []const u8, - out: *std.Io.Writer.Allocating, - remote: *RemoteError, - write_bytes: ?[]const u8, - read_limit: usize, - ) !void { + /// Version, attach and a walk to `names`: the fid there and its qid. + fn walkTo(s: *Session, names: []const []const u8, remote: *RemoteError) !struct { fid: u32, qid: ninep.Qid } { _ = try s.ask(.{ .version = .{} }, remote); if (s.cl.msize == 0) return Error.Botch; @@ -1974,6 +1972,20 @@ pub const Client = struct { next = if (next == 1) 2 else 1; i += n; } + return .{ .fid = cur, .qid = here }; + } + + fn transact( + s: *Session, + names: []const []const u8, + out: *std.Io.Writer.Allocating, + remote: *RemoteError, + write_bytes: ?[]const u8, + read_limit: usize, + ) !void { + const at = try walkTo(s, names, remote); + const cur = at.fid; + const here = at.qid; defer s.dropNoWait(cur); const directory = here.type & ninep.qtdir != 0; @@ -2032,15 +2044,20 @@ pub const Client = struct { read_limit: usize, ) ![]u8 { if (comptime !supported) return Error.Dial; + const s = try startSession(gpa, sock, display_path); + defer endSession(gpa, s); + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + try transact(s, names, &out, remote, write_bytes, read_limit); + return out.toOwnedSlice(); + } + + /// A connected session whose requests have the usual deadline. + fn startSession(gpa: std.mem.Allocator, sock: Dial, display_path: []const u8) !*Session { const deadline = nowMs() +| budget_ms; const s = try gpa.create(Session); s.* = .{ .fd = -1, .deadline = deadline, .display_path = display_path }; - defer { - if (quic_enabled and s.quic != null) { - s.quic.?.deinit(); - } else if (s.fd >= 0) _ = libc.close(s.fd); - gpa.destroy(s); - } + errdefer endSession(gpa, s); if (sock == .quic) { if (comptime quic_enabled) { s.quic = quic.Connection.dial(sock.quic) catch return Error.Dial; @@ -2048,11 +2065,48 @@ pub const Client = struct { } else return Error.QuicUnavailable; } else s.fd = try connect(sock, deadline); s.cl = .init(.{ .in = &s.in, .out = &s.out }); + return s; + } - var out: std.Io.Writer.Allocating = .init(gpa); - errdefer out.deinit(); - try transact(s, names, &out, remote, write_bytes, read_limit); - return out.toOwnedSlice(); + fn endSession(gpa: std.mem.Allocator, s: *Session) void { + if (quic_enabled and s.quic != null) { + s.quic.?.deinit(); + } else if (s.fd >= 0) _ = libc.close(s.fd); + gpa.destroy(s); + } + + /// Opens `path` on one connection and writes `first` to it (/log's + /// `follow new`), all with the usual deadline; then, once `ready(ctx)` + /// has said it is not done already, reads it with no deadline, a read at + /// a time, until `record(ctx, bytes)` says done. An end of file or a + /// dropped connection is Hangup. What `pardes --wait` blocks on. + pub fn follow( + gpa: std.mem.Allocator, + dial: []const u8, + path: []const u8, + first: []const u8, + ctx: anytype, + comptime ready: fn (@TypeOf(ctx)) bool, + comptime record: fn (@TypeOf(ctx), []const u8) bool, + ) !void { + if (comptime !supported) return error.Unsupported; + var names: [max_depth][]const u8 = undefined; + const n = try elements(path, &names); + var sock_buf: [sun_path_len]u8 = undefined; + const sock = try resolve(&sock_buf, dial); + var remote: RemoteError = .{}; + const s = try startSession(gpa, sock, path); + defer endSession(gpa, s); + const fid = (try walkTo(s, names[0..n], &remote)).fid; + _ = try s.ask(.{ .open = .{ .fid = fid, .mode = ninep.ordwr } }, &remote); + _ = try s.ask(.{ .write = .{ .fid = fid, .offset = 0, .data = first } }, &remote); + if (ready(ctx)) return; + s.deadline = std.math.maxInt(i64); + while (true) { + const data = (try s.ask(.{ .read = .{ .fid = fid, .offset = 0, .count = s.cl.maxRead() } }, &remote)).read; + if (data.len == 0) return Error.Hangup; + if (record(ctx, data)) return; + } } fn connect(sock: Dial, deadline: i64) Error!c_int { diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 142287b2..d2641aa9 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,6 +1,60 @@ # Changelog -## 0.0.3 +## 0.0.4 + +- Drive the editor over 9P the way acme is driven. Panes take sam + addresses (`/re/`, `#n`, `$-1`, `0/re/`), `L:C` and inclusive `L:C-L2:C2` + ranges on `addr`, with `data` and `xdata`; `Edit` runs the sam command + language as one undo step. `exec` and `ctl` buffer lines per open, and a + line without a newline runs with its write. One rule for failure: a + builtin that fails fails the write and logs one `err`, no `msg`. +- New 9P files: `/layout`, `/tag`, `/tagexec`, `col/<serial>/{tag,ctl,exec}`, + `pane/<n>/tagexec`, `/recent`, ask/answer records, and a `size` ctl for + detached sessions (160x50 by default). Listings freeze at their open, so + a read in several chunks never splices two moments. +- acme's ctl words (`name`, `put`, `clean`, `dirty`, `del`, `delete`, + `dot=addr`, `addr=dot`, `limit=addr`, `mark`, `nomark`, `show`, + `cleartag`) are accepted again, done by the files that replace them; a + pane's ctl reads acme's fields in acme's order. `Put` saves and `Delete` + closes; acme builtins pardes lacks are refused, never run as shell + commands. plan9port's `9p write` always truncates, so piping into `body` + replaces it: append with `>>` through a mount. +- Command panes and a REPL binding; `pty/run` runs one line and answers its + exit status. Every child starts with default signal dispositions. +- `Recent` lists recently opened files, closed ones included, and persists + in `$XDG_STATE_HOME/pardes/recent`. A Jumplist row for a closed file says + `(closed)`. `+Unsaved` lists dirty panes. `ThemeSel` and `FontSel` are now + `Themes` and `Fonts`; `Exit`'s refusal opens in an output pane. +- `pardes --wait FILE` (`-w`) run inside a pane opens FILE there and returns + only when its pane is deleted, as plan9port's `E` does: set + `EDITOR='pardes --wait'` for fish's Ctrl-O, git and the rest. Without it, + `pardes FILE` still hands the file over and returns at once. +- Only a write logs an `err`: a refused open, create or remove fails with + Plan 9 words (`permission denied`, `file does not exist`) and logs nothing. +- The tag is a full text buffer: multi-line, with undo. +- Helix keys: the skipped non-conflicting keys, full registers and per-range + anchors, checked against the latest helix. +- Deleting the last column leaves the window empty but for its tag, as in + acme. A buffer whose file was deleted on disk counts as dirty. +- 62 themes in 13 families, each as close as possible to its original, + grouped by family in `Themes`; an `acme` theme taken from plan9port's + source, with acme's grips, 2 px rules and a 1 px tag rule. Sizes follow + display density. +- GUI motion: `Motion` flavours (crisp, smooth, bouncy, playful, off), `Lift`, + a cursor that glides and moves with its pane, smooth scroll, and optional + Bloom, Vignette, Grain, SelectionGlow, HoverGlow and Shadertoy post passes + that recompile on save. Still passes let the GUI rest; Crt keeps drawing. +- The terminal: replies to capability queries, malformed escape sequences + and lone modifier keys never become typed text; function keys, Insert and + the keypad reach terminal panes; a lone Escape is Escape at once; a focus + change no longer resends every blank cell. +- Speed: the theme's chrome is worked out once, not per grapheme; typing + tells `g.` where its edit ended; large body and data writes are linear; + a pattern that opens with a literal jumps to it. `zig build perf-gate` + fails any gesture more than 3x its recorded baseline. +- Robustness: over-long paths, non-UTF-8 bytes and odd 9P input are refused, + never a panic; seeded GUI, core and 9P monkeys (`zig build monkey-9p`) + replay and shrink what they find. - A column can be empty, as in acme. `Newcol` makes an empty column and gives its tag the keyboard; closing a column's last pane, or dragging it @@ -63,6 +117,9 @@ carry real lengths, modes and mtimes. Sources are served under `/src` only with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to `src/ninep/`. + +## 0.0.3 + - Add pinned tree-sitter declaration context, with optional compact tagline styling and separators between discontinuous source lines. - Configure search and LSP result context with `LocationsConfig`. Results use diff --git a/src/File.zig b/src/File.zig index 1d838c4f..9f0f329c 100644 --- a/src/File.zig +++ b/src/File.zig @@ -266,7 +266,36 @@ test "stacked location metadata requires matching adjacent preview ownership" { pub fn graphemeDisplayWidth(grapheme: []const u8) usize { if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + if (std.unicode.utf8ValidateSlice(grapheme)) return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + // vaxis's gwidth counts back a replacement rune's 3 bytes over the one + // invalid byte it stood for (`\xee` before `e\u{301}` overflowed it): + // each invalid byte is a cell, as surface.zig draws it, and each valid + // run between is measured alone. + var width: usize = 0; + var run: usize = 0; + var i: usize = 0; + while (i < grapheme.len) { + const n = std.unicode.utf8ByteSequenceLength(grapheme[i]) catch 0; + if (n > 0 and i + n <= grapheme.len and std.unicode.utf8ValidateSlice(grapheme[i .. i + n])) { + i += n; + continue; + } + if (run < i) width += vaxis.gwidth.gwidth(grapheme[run..i], .unicode); + width += 1; + i += 1; + run = i; + } + if (run < grapheme.len) width += vaxis.gwidth.gwidth(grapheme[run..], .unicode); + return @max(1, width); +} + +test "an invalid byte in a grapheme is one cell, and never reaches vaxis's width" { + try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xeee\xcc\x81")); + try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\xee\xffe")); + try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\u{4e16}\x80")); + // The whole path the 9P fuzzer took: a body holding it, fitted to a width. + const text = "\xeee\xcc\x81"; + try std.testing.expectEqual(@as(usize, 4), fitEnd(text, 0, 10)); } pub fn byteDisplayWidth(byte: u8) usize { @@ -433,8 +462,11 @@ test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { } for ([_][]const u8{ "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", - "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", + "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); + // Two invalid bytes are two cells, as surface.zig draws them, where + // vaxis's own count gives the pair one. + try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xe4\xb8")); } test "the ASCII run in fitEnd survives an exhaustive byte sweep" { diff --git a/src/Messages.zig b/src/Messages.zig index aebaff0c..42200863 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -503,18 +503,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 { /// reportError with the words already chosen. pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { p.fs.failures +%= 1; - // A builtin a ctl write runs: its first error is also the write's, cut - // between words. - const failing_write = p.fs.no_prompt or p.fs.capturing; + // A builtin a ctl write runs: its first error is also the write's, its + // path shortened in the middle if it must be, never its reason. + const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits; if (failing_write and p.fs.failure_len == 0) { - const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text; + var room: @TypeOf(p.fs.failure) = undefined; + const kept = pardes.ctlfs.fitErr(text, &room); @memcpy(p.fs.failure[0..kept.len], kept); - var n = kept.len; - if (kept.len < text.len) { - @memcpy(p.fs.failure[n..][0..3], "..."); - n += 3; - } - p.fs.failure_len = @intCast(n); + p.fs.failure_len = @intCast(kept.len); } // The write fails with it, and its err record says it: no msg for it, // so the same failure again is the same record again, counted diff --git a/src/builtins.zig b/src/builtins.zig index 8788e65f..8a01b3d6 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -562,7 +562,9 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { } if (count > 0) { listed.print(arena, "\n{s} again to discard\n", .{@tagName(asking)}) catch {}; - if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {} + // A refused Delcol changes nothing, the layout included: its records + // and its notice say it, and no +Unsaved opens. + if (asking != .Delcol) if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {}; var short: [96]u8 = undefined; c.p.reportFailure(c.id, std.fmt.bufPrint(&short, "{d} unsaved pane{s} \u{2014} {s} again to discard", .{ count, if (count == 1) "" else "s", @tagName(asking) }) catch "unsaved panes"); } @@ -572,7 +574,8 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) }) else std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified"; - const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len); + var room: @TypeOf(c.p.fs.failure) = undefined; + const kept = pardes.ctlfs.fitErr(said, &room); @memcpy(c.p.fs.failure[0..kept.len], kept); c.p.fs.failure_len = @intCast(kept.len); } @@ -1372,11 +1375,12 @@ pub const Pet = struct { /// Open a minimap of the file in braille dots. pub const Mini = struct { pub const takes_arg = true; + pub const requires_arg = true; // a file to map: bare, wrong #args (EINVAL) pub const output: OutputTraits = .{ .name = "Mini", .doc = true }; pub fn run(c: Ctx) void { panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "mini", err); + c.p.reportError(c.id, "Mini", err); } }; @@ -1384,8 +1388,8 @@ pub const Mini = struct { /// A Find or Grep that finds nothing fails as one, in words 9ns reads as /// ENOENT (`not found`), the same for both. -const e_search_miss_find = "find: name not found"; -const e_search_miss_grep = "grep: pattern not found"; +const e_search_miss_find = "Find: name not found"; +const e_search_miss_grep = "Grep: pattern not found"; /// List the files below this pane's directory whose names hold its argument, in a +Search. pub const Find = struct { @@ -1395,7 +1399,7 @@ pub const Find = struct { pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) { - const rows = look.runSearch(c.p, c.id, pat, .find, .top, false) catch |err| return c.p.reportError(c.id, "find", err); + const rows = look.runSearch(c.p, c.id, pat, .find, .top, false) catch |err| return c.p.reportError(c.id, "Find", err); if (rows == 0) c.p.reportFailure(c.id, e_search_miss_find); return; } @@ -1412,7 +1416,7 @@ pub const Grep = struct { pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) { - const rows = look.runSearch(c.p, c.id, pat, .grep, .top, false) catch |err| return c.p.reportError(c.id, "grep", err); + const rows = look.runSearch(c.p, c.id, pat, .grep, .top, false) catch |err| return c.p.reportError(c.id, "Grep", err); if (rows == 0) c.p.reportFailure(c.id, e_search_miss_grep); return; } @@ -2002,7 +2006,7 @@ test "Exit and Restore name a modified +New scratch as well as files" { // Delcol, Restore and Exit name it the same way. p.fs.failure_len = 0; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); - try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") != null); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") == null); try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), named[0 .. config.scratch_buffer.len + 7]) != null); } @@ -2015,7 +2019,8 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo const serial = pane.serial; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); try std.testing.expect(p.paneBySerial(serial) != null); - try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDelcol again to discard\n")); + // A refused Delcol changes nothing: no +Unsaved (its records say it). + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol") == null); // Each word is warned on its own: Del asks too, then closes. try std.testing.expect(p.executeBuiltinLine(p.active, "Del")); try std.testing.expect(p.paneBySerial(serial) != null); diff --git a/src/dump.zig b/src/dump.zig index fca88a2d..a2efb95d 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -31,7 +31,8 @@ const Pardes = pardes.Pardes; /// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other. -/// Creates the pardes dir (parents assumed; a failure surfaces at open). +/// Creates the directory and any parent missing (a fresh HOME has no +/// ~/.local/share); a failure surfaces at open. pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; @@ -40,7 +41,13 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; - _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine + // Each missing directory on the way, as `mkdir -p`: EEXIST is fine. + for (dz[1..dir.len], 1..) |c, i| if (c == '/') { + dz[i] = 0; + _ = std.c.mkdir(dz[0..i :0], 0o755); + dz[i] = '/'; + }; + _ = std.c.mkdir(dz[0..dir.len :0], 0o755); var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.REALTIME, &ts); const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) }; @@ -109,6 +116,21 @@ pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 { return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null; } +test "a dump makes every directory missing on the way to its DumpDir" { + if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var at: [4096]u8 = undefined; + const base = at[0..try tmp.dir.realPath(std.testing.io, &at)]; + var setting: [4200]u8 = undefined; + const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base}); + var out: [1024:0]u8 = undefined; + const path = outPath(&out, dir).?; + try std.testing.expect(std.mem.startsWith(u8, path, dir)); + var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{}); + made.close(std.testing.io); +} + test "a DumpDir reads back absolute, however it was written" { var buf: [4096]u8 = undefined; try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?); @@ -129,7 +151,7 @@ pub fn defaultDirectory(buf: []u8) ?[]const u8 { pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = MAX_PANES; -pub const max_cols: usize = 6; +pub const max_cols: usize = MAX_COLS; /// Output arguments are typed in the same bounded one-line tag storage. Keep /// the schema limit named independently so a dump reader can validate it /// without importing the output-pane implementation. The bound itself is @@ -987,6 +1009,24 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor try std.testing.expect(!restored.settings.verbose); } +test "a session with every column it may hold dumps and restores them all" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }); + defer p.deinit(); + while (p.ncol < MAX_COLS) { + layout.compute(p); + var widest: usize = 0; + for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) { + widest = c; + }; + _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn; + } + try dumpState(p); + const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol); +} + test "a restored pane keeps its dot, as acme's dump keeps a window's" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); diff --git a/src/exec.zig b/src/exec.zig index 06e5d375..2c42389c 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -811,9 +811,42 @@ pub fn namesBuiltin(txt: []const u8) bool { return std.meta.stringToEnum(Builtin, cmd[0..end]) != null; } +/// acme's words, run by their pardes names (Put is Save; Delete is a Del +/// that does not ask), or refused, never run as a shell command: a Put that +/// exited 127 looked like a save that worked. +pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: []const u8 = "" }{ + .{ .acme = "Put", .pardes = "Save" }, + .{ .acme = "Delete", .pardes = "Del" }, + .{ .acme = "Get", .pardes = null, .hint = ": write get to the pane's ctl" }, + .{ .acme = "Putall", .pardes = null, .hint = ": Save each pane" }, + .{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" }, + .{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" }, + .{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" }, + .{ .acme = "Zerox", .pardes = null }, + .{ .acme = "Sort", .pardes = null }, + .{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" }, + .{ .acme = "ID", .pardes = null }, + .{ .acme = "Send", .pardes = null }, +}; + pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { - const cmd = commandText(txt); - if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; + const raw = commandText(txt); + if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false; + const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len; + var renamed: [1024 + 16]u8 = undefined; + const cmd = for (acme_words) |w| { + if (!std.mem.eql(u8, raw[0..word_end], w.acme)) continue; + const now = w.pardes orelse { + var said: [128]u8 = undefined; + p.reportFailure(id, std.fmt.bufPrint(&said, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); + return true; + }; + // Delete: Del, the text thrown away unasked. + if (std.mem.eql(u8, w.acme, "Delete")) if (p.panes[id]) |pane| if (pane.file) |f| { + pane.discard_warned = .{ .revision = f.revision, .by = .Del }; + }; + break std.fmt.bufPrint(&renamed, "{s}{s}", .{ now, raw[word_end..] }) catch raw; + } else raw; // `Word arg`, or for a word that says so, `Word+arg` (`Tty+fish`). const plus = std.mem.indexOfScalar(u8, cmd, '+'); var sp = std.mem.indexOfAny(u8, cmd, " \t"); @@ -1117,7 +1150,7 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void { .scratch, .fs => if (layout.findPane(p, from)) |sf| { layout.insert(p, sf.col, sf.idx + 1, id); layout.splitBelow(p, from, nt); - }, + } else atFoot(p, id), .shell => belowParent(p, from, id), .doc, .errors => pardesDoc(p, from, id), .command => { @@ -1127,9 +1160,18 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void { } } +/// Where a pane goes when the one it would go under is in no column (Delcol +/// took them all, or it was never placed): the foot of the active column, +/// else of the last. +fn atFoot(p: *Pardes, id: usize) void { + const c = p.activeColumn() orelse p.ncol - 1; + layout.insert(p, c, p.col_n[c], id); + if (p.col_n[c] == 1) p.panes[id].?.vweight = 1; +} + fn belowParent(p: *Pardes, from: usize, id: usize) void { const parent = layout.splitParent(p, from); - const f = layout.findPane(p, parent).?; + const f = layout.findPane(p, parent) orelse return atFoot(p, id); layout.insert(p, f.col, f.idx + 1, id); layout.splitBelow(p, parent, p.panes[id].?); } @@ -1326,6 +1326,31 @@ pub const Namespace = struct { /// A 9P write asked a language server something: the answer count it /// waits to change (`turn.lsp_answers`), so its failure is the write's. lsp_answer_at: ?u64 = null, + /// The most a Twrite of the request being served can carry (msize less + /// its header), 0 when unknown: a write shorter than it is the whole of + /// what its client wrote, one that fills it may be cut and go on. + write_room: u32 = 0, + /// A 9P write waits on what the editor performs for it (a Save's disk + /// write, a shell, a language server): a failure said meanwhile is that + /// write's err, so it posts no msg (Messages.reportFailure). + write_waits: bool = false, + /// The whole text of the newest counted log record, hashed: its record + /// may be cut, and a repeat is only the same whole text again. + log_full_hash: u64 = 0, + /// Writes one open made in a row into one place of a pane's body (an + /// append to `body`, an insert at `data`'s address), held and put in + /// as one edit when anything else comes (ninep/pane.zig flushBatch): a + /// 10 MB write in 8 KB pieces is one copy and one undo step, not 1280. + batch: struct { + serial: u32 = 0, + node: u64 = 0, + handle: u32 = 0, + at: usize = 0, + bytes: std.ArrayList(u8) = .empty, + /// The editor's clock at the last write held: its step puts them in + /// once the writes have paused (`batch_pause_ns`). + last_ns: u64 = 0, + } = .{}, /// The serial of the pane the root's exec or look last ran a line at /// (the keyboard's), 0 before one: its err record names that pane. ran_at: u32 = 0, diff --git a/src/look.zig b/src/look.zig index 79cbd50d..fe5a3ac1 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1302,6 +1302,41 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } { return .{ .path = tok[0..colon], .expr = expr }; } +/// A look at text that names nothing to open: a `./` name is a missing +/// file, anything else a search for the text as written. +fn lookUnresolved(p: *Pardes, id: usize, pane: *Pane, trimmed: []const u8) void { + // A `./` or `../` name opened nothing: said, ENOENT, not searched + // for as a word. What is missing is the pane's directory when + // that is gone, else the file named. + if (filesystem.dotRelative(trimmed)) { + const dir = Pardes.paneDir(pane); + var said: [4200]u8 = undefined; + const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true; + if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory"); + var cut: [miss_path_cap + "…".len]u8 = undefined; + return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file"); + } + // A raw terminal's clicks are its program's; a look written over + // 9P is no keyboard input, and searches whatever the key mode. + if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return; + if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed); + const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| { + p.reportError(id, "search", err); + return; + }; + if (rows == 0) { + var cut: [miss_path_cap + "…".len]u8 = undefined; + return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)}); + } + clearNavigationSelection(p, pane); + const at = pane.search_row; + _ = searchStep(p, id, 1); + if (at != null and pane.search_row == at) { + pane.search_row = null; + _ = searchStep(p, id, 1); + } +} + /// Opens (or finds) `path` as Look would, `id`'s own pane when it is empty, /// and selects what `expr` addresses there from its dot. An address that /// does not evaluate moves nothing and says why (acme: no jump). @@ -1321,9 +1356,18 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { const by_serial = std.mem.startsWith(u8, path, config.pane_addr); if (by_serial and open == null) return missed(p, id, "no pane {s}", .{path[0..@min(path.len, 32)]}); if (open) |o| target = o else if (path.len > 0) { + // A path that names nothing to open: the whole of what was written + // is looked for, as `zzq:2` is, and a miss quotes it as written. + if (!by_serial and resolveLookTarget(p, id, path, &realbuf) == .none) + return lookUnresolved(p, id, p.panes[id] orelse return, path.ptr[0 .. path.len + 1 + expr.len]); const before = p.active; + const failures = p.fs.failures; + const was_missed = p.look_missed; lookAt(p, id, path); target = p.active; + // Look said why it opened nothing (its placement refused, say): + // that one failure is the look's, no miss of the address after it. + if (p.fs.failures != failures or (p.look_missed and !was_missed)) return; // The path opened nothing (a search, or no such file): Look said so. if (target == before and target != id) return; } @@ -1334,11 +1378,14 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { }; if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return; var a: Addr = .{ .text = f.content, .lim = null, .expr = expr }; + // A miss names the file it was looked for in, and is that file's pane's. + var cut: [miss_path_cap + "…".len]u8 = undefined; + const name = missText(f.path, &cut); const r = a.address(ninep_pane.dotOf(pane)) orelse { - return missed(p, id, "{s}", .{a.err}); + return missed(p, target, "{s}: {s}", .{ name, a.err }); }; if (a.i < expr.len or r.q0 > r.q1) { - return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"}); + return missed(p, target, "{s}: {s}", .{ name, if (r.q0 > r.q1) "addresses out of order" else "bad address syntax" }); } clearNavigationSelection(p, pane); ninep_pane.setDot(pane, r); @@ -1481,39 +1528,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { pane.sel = @splat(.{}); } switch (found) { - .none => { - // A `./` or `../` name opened nothing: said, ENOENT, not searched - // for as a word. What is missing is the pane's directory when - // that is gone, else the file named. - if (filesystem.dotRelative(trimmed)) { - const dir = Pardes.paneDir(pane); - var said: [4200]u8 = undefined; - const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true; - if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory"); - var cut: [miss_path_cap + "…".len]u8 = undefined; - return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file"); - } - // A raw terminal's clicks are its program's; a look written over - // 9P is no keyboard input, and searches whatever the key mode. - if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return; - if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed); - const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| { - p.reportError(id, "search", err); - return; - }; - if (rows == 0) { - var cut: [miss_path_cap + "…".len]u8 = undefined; - return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)}); - } - clearNavigationSelection(p, pane); - const at = pane.search_row; - _ = searchStep(p, id, 1); - if (at != null and pane.search_row == at) { - pane.search_row = null; - _ = searchStep(p, id, 1); - } - - }, + .none => lookUnresolved(p, id, pane, trimmed), .pane => |t| { // `@p<serial>` names no open pane: a miss, said. if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]}); @@ -1570,7 +1585,11 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); + const failures = p.fs.failures; exec.placeNew(p, id, id, free, .doc); + // Refused a place: that is the look's one failure, not a + // miss of its line too. + if (p.fs.failures != failures) return; layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { diff --git a/src/main.zig b/src/main.zig index aaa237f6..ab6f1527 100644 --- a/src/main.zig +++ b/src/main.zig @@ -58,6 +58,12 @@ const help_text = \\ A FILE argument boots just that file instead. \\ --tty-toggle <key> use Ctrl-<key> to toggle tty/editor mode \\ -l <dump.zon> load a dump of another instance (see Dump) + \\ -w, --wait inside a pardes pane, hand FILE to the outer + \\ session and return only once the pane it opened + \\ (or already had) is deleted: 0 then, 1 if the + \\ session goes away. For $EDITOR (acme's E): + \\ EDITOR='pardes --wait', which GIT_EDITOR follows. + \\ Outside pardes a session blocks anyway. \\ --nested run a full session even inside another pardes. \\ Without it, a pardes started inside a pardes \\ hands its FILE argument to the outer one. This @@ -92,6 +98,81 @@ const help_text = \\ ; +/// `--wait` after a forwarded look, as plan9port's E reads acme's log: the +/// pane `path` landed in (the one it already had, if open), then /log +/// followed on one connection until that pane's `del` -- exit 0 -- or the +/// connection ends with the session -- exit 1. +fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8) noreturn { + const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1); + const serial = paneShowing(first, path) orelse { + std.Io.File.stderr().writeStreamingAll(io, "pardes: --wait: no pane shows that file\n") catch {}; + std.process.exit(1); + }; + gpa.free(first); + const Wait = struct { + gpa: std.mem.Allocator, + dial: []const u8, + serial: u32, + /// Deleted between the look and the follow: /index, read once the + /// follow stands, says so, and a `del` after it is followed. + fn gone(w: *const @This()) bool { + const index = ninep_io.Client.read(w.gpa, w.dial, "/index", "/index") catch return false; + defer w.gpa.free(index); + return !hasPane(index, w.serial); + } + fn deleted(w: *const @This(), bytes: []const u8) bool { + return isDel(bytes, w.serial); + } + }; + const w: Wait = .{ .gpa = gpa, .dial = dial, .serial = serial }; + ninep_io.Client.follow(gpa, dial, "/log", "follow new", &w, Wait.gone, Wait.deleted) catch std.process.exit(1); + std.process.exit(0); +} + +/// Whether log records `bytes` hold `del <serial> ...`. +fn isDel(bytes: []const u8, serial: u32) bool { + var records = std.mem.splitScalar(u8, bytes, '\n'); + while (records.next()) |rec| { + if (!std.mem.startsWith(u8, rec, "del ")) continue; + const word = rec[4 .. std.mem.indexOfScalarPos(u8, rec, 4, ' ') orelse rec.len]; + if ((std.fmt.parseInt(u32, word, 10) catch continue) == serial) return true; + } + return false; +} + +/// The serial of /index's first row named `path` (`serial kind dirty name col`). +fn paneShowing(index: []const u8, path: []const u8) ?u32 { + var rows = std.mem.splitScalar(u8, index, '\n'); + while (rows.next()) |row| { + var words = std.mem.splitScalar(u8, row, ' '); + const serial = std.fmt.parseInt(u32, words.first(), 10) catch continue; + _ = words.next() orelse continue; // kind + _ = words.next() orelse continue; // dirty + const rest = words.rest(); + const end = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue; + if (std.mem.eql(u8, rest[0..end], path)) return serial; + } + return null; +} + +fn hasPane(index: []const u8, serial: u32) bool { + var rows = std.mem.splitScalar(u8, index, '\n'); + while (rows.next()) |row| { + const first = row[0 .. std.mem.indexOfScalar(u8, row, ' ') orelse row.len]; + if ((std.fmt.parseInt(u32, first, 10) catch continue) == serial) return true; + } + return false; +} + +test "--wait finds the pane /index names a file by, spaces and all, and sees it gone" { + const index = "1 term 0 /home/u 1\n4 text 1 /tmp/a b.txt 2\n"; + try std.testing.expectEqual(@as(?u32, 4), paneShowing(index, "/tmp/a b.txt")); + try std.testing.expectEqual(@as(?u32, null), paneShowing(index, "/tmp/a")); + try std.testing.expect(hasPane(index, 4) and !hasPane(index, 2)); + try std.testing.expect(isDel("new 5 /x\ndel 4 /tmp/a b.txt\n", 4)); + try std.testing.expect(!isDel("del 41 /x\n", 4) and !isDel("undel 4\n", 4)); +} + fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch @@ -159,6 +240,7 @@ fn nativeMain(init: std.process.Init) !void { var detach: ?[]const u8 = null; var attach: ?[]const u8 = null; var positional: ?[:0]const u8 = null; + var wait = false; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; @@ -239,6 +321,8 @@ fn nativeMain(init: std.process.Init) !void { mounts_len += 1; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; + } else if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--wait")) { + wait = true; } else if (std.mem.eql(u8, a, "--detach")) { detach = ""; } else if (std.mem.startsWith(u8, a, "--detach=")) { @@ -324,10 +408,31 @@ fn nativeMain(init: std.process.Init) !void { if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; - const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; - var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding; + var newbuf: [4096]u8 = undefined; + const found_path: ?[]const u8 = if (pardes.filesystem.isVirtual(target.path)) target.path else if (pardes.filesystem.resolveOs(target.path, &realbuf)) |r| r.path else null; + const path = found_path orelse named: { + // A name not there yet, as acme's B takes one: its directory + // resolved and the name kept, a pane made for it that Save + // creates the file from. + const base = std.fs.path.basename(target.path); + if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding; + const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding; + break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding; + }; + if (found_path != null) { + var command_buf: [8192]u8 = undefined; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding; + } else { + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding; + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding; + var name_buf: [64]u8 = undefined; + const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial}); + var line_buf: [4200]u8 = undefined; + const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding; + } + if (wait) waitForDel(init.io, arena, parent.dial, path); return; } if (positional) |a| { @@ -383,7 +488,15 @@ fn nativeMain(init: std.process.Init) !void { std.process.exit(1); } switch (pardes.platform) { - .tty => try @import("tty/tty.zig").run(init, opts, attach), + .tty => @import("tty/tty.zig").run(init, opts, attach) catch |err| switch (err) { + // No controlling terminal (a detached pty, a daemon): said, not + // an error trace. + error.NoDevice => { + std.Io.File.stderr().writeStreamingAll(init.io, "pardes: no terminal to draw on: /dev/tty will not open; run it in a terminal, or --detach\n") catch {}; + std.process.exit(1); + }, + else => return err, + }, .gui => try @import("gui/gui.zig").run(init, opts, attach), .web, .macos, .esp32p4 => unreachable, } diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index f60c07b1..33a3f646 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -17,7 +17,7 @@ pub const e_col_zero = "address out of range: a column counts from 1"; pub const e_regexp = "bad regular expression"; /// Not "took too long": 9ns reads errors by their words, and that would be /// ENAMETOOLONG. -pub const e_slow = "regular expression search gave up, backtracking past its step budget"; +pub const e_slow = "regular expression search took too much time, gave up"; pub const e_syntax = "bad address syntax"; pub const e_order = "addresses out of order"; @@ -299,7 +299,13 @@ pub const Addr = struct { return null; } var rx = regexp_.Regex.compile(pat) catch |err| { - a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp; + a.err = switch (err) { + error.Anchor => regexp_.Regex.e_anchor, + error.TooLong => regexp_.Regex.e_long, + error.WideRange => regexp_.Regex.e_wide, + error.NegatedRunes => regexp_.Regex.e_negated, + error.Bad => e_regexp, + }; return null; }; // sam's nextmatch (editors/sam/address.c:97-119): an empty match diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index 6b59f9e5..cc583c93 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -368,10 +368,11 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno()); var rec: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty}))); - // rmdir refuses a column with a pane, and says why. + // rmdir refuses a column with a pane, and says why, in its error alone. const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true }); try testing.expectEqual(E.NOTEMPTY, full.errno()); - try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty}))); + try testing.expect(std.mem.startsWith(u8, full.reply.ename, "column not empty")); + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty}))); // exec runs a word as a click in that column's tag: Delcol closes it. try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status); try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty)); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 397f3339..5bf354eb 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -251,8 +251,9 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i /// EIO. fn failureErrno(failure: []const u8) u16 { if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; - // A pattern refused (Edit's too) is malformed input, as 9ns reads it. - if (std.mem.indexOf(u8, failure, "bad regular expression") != null) return E.INVAL; + // A pattern refused (Edit's too) is malformed input, as 9ns reads it; + // so is a word this build has not (`invalid: acme's Put ...`). + if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid")) return E.INVAL; return E.IO; } @@ -330,13 +331,13 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { try w.writeByte('\''); } -pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; +pub const pane_text_cap = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; -/// acme's winctlprint: serial, tag length, body length, a reserved zero, the -/// dirty flag, then the width in cells, the font and the tab width. Then -/// rio's word for whether the window has the keyboard, `current` or -/// `notcurrent` (rio(4), wctl), which acme's line has no room for because -/// acme has one window for input at a time only by the mouse. +/// acme's winctlprint (plan9port wind.c), field for field: serial, tag +/// length, body length, isdir (0), the dirty flag, the width in cells, the +/// font, the tab width, whether Undo has a step, whether Redo has one. Then +/// pardes's own: rio's word for whether the window has the keyboard, +/// `current` or `notcurrent` (rio(4), wctl), and a REPL's id. pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { var w = std.Io.Writer.fixed(buf); w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ @@ -352,7 +353,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered(); // While a column's or the workspace's tag has the keyboard, no pane does. const current = p.panes[p.active] == pane and !p.header_focus; - w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {}; + const f = pane_files.fileOf(pane); + const undo = if (f) |file| file.history.undo_len > 0 else false; + const redo = if (f) |file| file.history.redo_len > 0 else false; + w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {}; // A terminal bound as a REPL ends with its id, `python-a`. var idbuf: [48]u8 = undefined; const id = exec_line.replId(&idbuf, pane); @@ -387,7 +391,13 @@ pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: [] /// as on a tag; acme's lowercase verbs are the pane ctl's and alias none. fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply { const sp = std.mem.indexOfAny(u8, line, " \t"); - const word = line[0 .. sp orelse line.len]; + const typed = line[0 .. sp orelse line.len]; + // acme's words: checked as the pardes word they run as, or refused. + const word = for (pardes.exec.acme_words) |w| { + if (!std.mem.eql(u8, typed, w.acme)) continue; + const now = w.pardes orelse return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); + break now; + } else typed; const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else ""; const b = std.meta.stringToEnum(Builtin, word) orelse { // A setting this build's frontend cannot show (Lift, GripWidth on a @@ -777,10 +787,19 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = Messages.init(p, req.data); - while (it.next()) |line| { - if (line.len == 0) continue; + while (it.next()) |line_in| { + if (line_in.len == 0) continue; // A line before this one may have closed the pane (`Del`). if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT); + // acme's ctl words: done by the file that replaces each, or the + // builtin that does it, checked with the rest before any runs. + var line = line_in; + switch (acmeCtl(p, req, serial, line_in, apply)) { + .no => {}, + .done => continue, + .refused => |r| return r, + .builtin => |word| line = word, + } if (std.mem.eql(u8, line, "get")) { if (!apply) continue; // Quoting the line, as a builtin's refusal (Save's) does. @@ -854,6 +873,72 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } +/// acme's pane ctl vocabulary (editors/acme/xfid.c xfidctlwrite): each word +/// done here by the file that replaces it, or run as the builtin that does +/// it; those with no place here refused with why. +const AcmeCtl = union(enum) { no, done, refused: Reply, builtin: []const u8 }; + +fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) AcmeCtl { + const id = p.paneBySerial(serial) orelse return .no; + const pane = p.panes[id].?; + const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len]; + const rest = std.mem.trim(u8, line[word.len..], " "); + const File = tree.PaneFile; + // A word written to one of the pane's own files, as acme's is to its. + const Into = struct { file: File, data: []const u8 }; + const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0) + .{ .file = .name, .data = rest } + else if (std.mem.eql(u8, line, "clean")) + .{ .file = .dirty, .data = "0" } + else if (std.mem.eql(u8, line, "dirty")) + .{ .file = .dirty, .data = "1" } + else if (std.mem.eql(u8, line, "mark")) + .{ .file = .mark, .data = "1" } + else if (std.mem.eql(u8, line, "nomark")) + .{ .file = .mark, .data = "0" } + else + null; + if (into) |w| { + if (!apply) return .done; + const r = pane_files.write(p, .{ .tag = req.tag, .op = .write, .node = tree.Node.of(serial, w.file), .data = w.data }, id, pane, w.file); + return if (r.status == .err) .{ .refused = r } else .done; + } + if (std.mem.eql(u8, line, "put")) return .{ .builtin = "Save" }; + if (std.mem.eql(u8, line, "del")) return .{ .builtin = "Del" }; + if (std.mem.eql(u8, line, "delete")) return .{ .builtin = "Delete" }; + const pf = &pane.fs; + if (std.mem.eql(u8, line, "dot=addr")) { + if (apply) pane_files.setDot(pane, pf.addr); + return .done; + } + if (std.mem.eql(u8, line, "addr=dot")) { + if (apply) pf.addr = pane_files.dotOf(pane); + return .done; + } + if (std.mem.eql(u8, line, "limit=addr")) { + if (apply) pf.limit = pf.addr; + return .done; + } + if (std.mem.eql(u8, line, "show")) { + if (apply) pane_files.showOffset(pane, pane_files.dotOf(pane).q0); + return .done; + } + if (std.mem.eql(u8, line, "cleartag")) { + if (apply) _ = pane_files.truncate(p, pane, .tag); + return .done; + } + const none: ?[]const u8 = if (std.mem.eql(u8, word, "dump") or std.mem.eql(u8, word, "dumpdir")) + "a pane is dumped with the session: Dump and DumpDir on /ctl" + else if (std.mem.eql(u8, word, "font")) + "the font is the session's: Font on /ctl" + else if (std.mem.eql(u8, line, "menu") or std.mem.eql(u8, line, "nomenu")) + "pardes has no menu" + else + null; + if (none) |why| return .{ .refused = tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s}: {s}", .{ word, why }) catch "invalid: acme's ctl word") }; + return .no; +} + const e_not_locked = "pane not locked by this open"; /// Who holds it is another open of this ctl, whose client pardes cannot name. const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock"; @@ -922,7 +1007,7 @@ const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); -test "pane ctl read is index's five fields plus width in cells, font, tab width and whether it is current" { +test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); @@ -932,8 +1017,8 @@ test "pane ctl read is index's five fields plus width in cells, font, tab width try testing.expectEqual(Status.ok, a.reply.status); var want: std.ArrayList(u8) = .empty; defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{ - pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{ + pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0, }); try testing.expectEqualStrings(want.items, a.bytes); try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); @@ -1034,13 +1119,9 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); - // acme's other ctl words have files of their own here, and a builtin is - // its own capitalised word: `Get`, `DEL` and `del` are none of them. - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL", - "put", "del", "delete", "clean", "dirty", "show", "mark", - "Get", "limit=addr", "get x", "look /tmp", "exec Del", - }) |bad| { + // A builtin is its own capitalised word: `GET` and `DEL` are none; + // acme's own words are done or refused as acme's (the test below). + for ([_][]const u8{ "lock x", "bogus", "DEL", "GET", "get x", "look /tmp", "exec Del" }) |bad| { const refused = wr(p, ctl_node, bad); try testing.expectEqual(E.INVAL, refused.errno()); var want: [64]u8 = undefined; @@ -1671,6 +1752,59 @@ test "a look after Newcol fills the empty column the keyboard is on" { try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } +test "a look at file:addr whose file is not there is one miss, quoting what was written, in a terminal too" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const looked = Node.of(serialOf(p), .look); + for ([_][]const u8{ "zzq:#3", "zzq:2", "zzq:/re/" }) |written| { + var line: [64]u8 = undefined; + _ = wr(p, looked, try std.fmt.bufPrint(&line, "{s}\n", .{written})); + var want: [64]u8 = undefined; + try testing.expectEqual(@as(usize, 1), th.logCount(p, try std.fmt.bufPrint(&want, "look: no match for \"{s}\"\n", .{written}))); + } + try testing.expectEqual(@as(usize, 3), th.logCount(p, "\nerr ")); +} + +test "a look whose pane is refused a place fails with that alone, not a missing line too" { + const gpa = testing.allocator; + // A screen with rows for one pane only. + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{at})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "no space for a pane") != null); + try testing.expectEqual(@as(usize, 1), th.logCount(p, "\nerr ")); +} + +test "a look under pardes's placement, from a pane pane/new made after Delcol, opens its file" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + const first = serialOf(p); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Placement\n").reply.status); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Delcol\n").reply.status); + const made = try th.newPane(p); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .look), try std.fmt.bufPrint(&line, "{s}/f.txt:#4\n", .{at})).reply.status); + // The pane pane/new made is placed, and so is the one its look opened. + for (p.panes, 0..) |slot, i| if (slot != null) try testing.expect(layout.findPane(p, i) != null); + try testing.expect(p.panes[p.active].?.serial != made); +} + test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try withFile(testing.allocator, "x\n"); @@ -1924,10 +2058,10 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir for (p.panes) |slot| count += @intFromBool(slot != null); const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, missed.errno()); - try testing.expectEqualStrings("grep: pattern not found", missed.reply.ename); + try testing.expectEqualStrings("Grep: pattern not found", missed.reply.ename); const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, unfound.errno()); - try testing.expectEqualStrings("find: name not found", unfound.reply.ename); + try testing.expectEqualStrings("Find: name not found", unfound.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); @@ -2234,6 +2368,133 @@ test "a buffer whose file was deleted on disk is dirty, and Del asks first" { try testing.expect(p.paneBySerial(serial) == null); } +test "a failure said while a 9P write waits on it posts no msg, only the write's err" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.fs.write_waits = true; + p.saveFailed(@intCast(p.active), "/nowhere/zz.txt", error.FileNotFound); + p.fs.write_waits = false; + try testing.expect(!th.logHas(p, "msg ")); + try testing.expect(p.fs.late_failure_len > 0); +} + +test "Mini with no argument is wrong #args, EINVAL, as every word that needs one" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const r = wr(p, Node.of(serialOf(p), .ctl), "Mini\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings("wrong #args in control message \"Mini\"", r.reply.ename); +} + +test "a look that misses file:/re/ names the file, and is logged as its pane's" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "alpha\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); + p.sync(); + const f_serial = p.panes[p.active].?.serial; + _ = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}/f.txt:/zzqq/\n", .{dir})); + var want: [4300]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir }))); +} + +test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| { + for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| { + const r = wr(p, node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null); + } + } + // Put saves, as Save does. + _ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n"); + const put = wr(p, Node.of(serial, .exec), "Put\n"); + try testing.expectEqual(Status.ok, put.reply.status); + try testing.expect(put.saved); + // Delete closes an edited pane without asking. + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), "/tmp/pardes-delete.txt\n"); + _ = wr(p, Node.of(made, .body), "edited\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Delete\n").reply.status); + p.sync(); + try testing.expect(p.paneBySerial(made) == null); +} + +test "a pane's ctl takes acme's words: done by the files that replace them, or refused with why" { + const p = try withFile(testing.allocator, "one two\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const pane = p.panes[p.paneBySerial(serial).?].?; + try testing.expectEqual(Status.ok, wr(p, ctl_node, "name /tmp/pardes-acme-ctl.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/pardes-acme-ctl.txt", pane.file.?.path); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "dirty\n").reply.status); + try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "clean\n").reply.status); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + _ = wr(p, Node.of(serial, .addr), "/two/"); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\nshow\nnomark\nmark\nlimit=addr\n").reply.status); + try testing.expectEqual(@as(u32, 4), pane_files.dotOf(pane).q0); + try testing.expect(pane.fs.limit != null); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "cleartag\n").reply.status); + for ([_][]const u8{ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono" }) |line| { + const r = wr(p, ctl_node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "invalid: acme's ")); + } + // Checked whole: a refused word anywhere and nothing ran. + try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nmenu\n").errno()); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); +} + +test "ctl reads acme's fields in acme's order, undo and redo flags included, then pardes's own" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const fields = struct { + fn of(text: []const u8, buf: *[16][]const u8) [][]const u8 { + var it = std.mem.tokenizeScalar(u8, text, ' '); + var n: usize = 0; + while (it.next()) |t| : (n += 1) buf[n] = t; + return buf[0..n]; + } + }; + var buf: [16][]const u8 = undefined; + var f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("0", f[8]); // nothing to undo + try testing.expectEqualStrings("0", f[9]); // nothing to redo + try testing.expect(std.mem.eql(u8, f[10], "current") or std.mem.eql(u8, f[10], "notcurrent")); + _ = wr(p, Node.of(serial, .body), "two\n"); + f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("1", f[8]); + _ = wr(p, ctl_node, "Undo\n"); + f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("1", f[9]); +} + +test "a write to addr, limit or mark asks for no frame; one to body does" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_]tree.PaneFile{ .addr, .limit, .mark }) |file| { + p.needs_frame = false; + _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, file), .data = if (file == .mark) "1" else "#0" }); + try testing.expect(!p.needs_frame); + } + p.needs_frame = false; + _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, .body), .data = "two\n" }); + try testing.expect(p.needs_frame); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2960,7 +3221,7 @@ test "@p<serial>:<address> takes any sam address; a miss and an unknown serial a } var line: [64]u8 = undefined; _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial})); - try testing.expect(th.logHas(p, "look: no match for regexp")); + try testing.expect(th.logHas(p, ": no match for regexp")); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); _ = wr(p, root, "@p999:/foo/\n"); try testing.expect(th.logHas(p, "look: no pane @p999")); @@ -3020,3 +3281,36 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select try testing.expectEqual(row_before, pane.body.cur_row); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); } + +test "a bad command inside an Edit block written whole is named, not the } after it" { + const p = try withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const r = wr(p, Node.of(serialOf(p), .ctl), "Edit {\n,s/a/b/\nzq\n}\n"); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "\"}\"") == null); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "z") != null); + try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); +} + +test "a refused Delcol changes nothing: no pane, no focus" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n"); + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), "/tmp/pardes-delcol-refused.txt\n"); + _ = wr(p, Node.of(made, .body), "edit\n"); + p.sync(); + const col = layout.findPane(p, p.paneBySerial(made).?).?.col; + const active = p.active; + const active_column = p.active_column; + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + try testing.expectEqual(Status.err, wr(p, Node.ofCol(layout.columnSerial(p, col), .ctl), "Delcol\n").reply.status); + p.sync(); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + try testing.expectEqual(active, p.active); + try testing.expectEqual(active_column, p.active_column); + try testing.expect(th.logHas(p, "unsaved ")); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 570cae78..fe7af77e 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -171,11 +171,11 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void { pushCounting(p, (if (serial == 0) std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut }) else - std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return); + std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return, said); } -/// Records `err <serial|-> <file>: <why>` for a write or truncation that was -/// refused or failed: through a mount a shell sees only the errno the +/// Records `err <serial|-> <file>: <why>` for a write that was refused or +/// failed: through a mount a shell sees only the errno the /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. @@ -186,7 +186,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void { pub fn noteErrCounted(p: *Pardes, text: []const u8) void { bootFirst(p); var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; - pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return); + pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return, text); } pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { @@ -239,7 +239,7 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') { c.* = ' '; }; - pushCounting(p, record); + pushCounting(p, record, reason); } /// Drops the newest record when it is a msg saying `text` (of any pane) @@ -256,7 +256,11 @@ pub fn dropMessage(p: *Pardes, text: []const u8) void { /// counts that one instead: `<record> (x2)`, the count being every time it /// was said. One a follower has already read is not rewritten: the repeat /// is a new line carrying the running count, `(x3)`. -fn pushCounting(p: *Pardes, raw: []u8) void { +/// `full` is the text before any cut: two records that agree only in what +/// was cut to fit are no repeat (`fs.log_full_hash`). +fn pushCounting(p: *Pardes, raw: []u8, full: []const u8) void { + const full_hash = std.hash.Wyhash.hash(0, full); + defer p.fs.log_full_hash = full_hash; // A client retrying a write that fails the same way would fill the ring // with one line, so a repeat of the newest record is that record counted, // `(x3)`, as +Messages counts its repeats; unless a follower has read it @@ -272,7 +276,7 @@ fn pushCounting(p: *Pardes, raw: []u8) void { said = said[0..x]; } else |_| {} }; - if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record); + if (!std.mem.eql(u8, said, record[0 .. record.len - 1]) or p.fs.log_full_hash != full_hash) return pushLog(p, record); var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined; const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return; if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at); @@ -373,6 +377,8 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { } fn pushLog(p: *Pardes, raw: []u8) void { + // A record not counted: the next one is no repeat of a counted one. + p.fs.log_full_hash = 0; var clean: [4 * 4096 + 256]u8 = undefined; const record = sanitize(raw, &clean); // One record larger than the ring would push every other out and then @@ -1283,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); } -test "a long err record is cut between words, with an ellipsis" { +test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n"); @@ -1292,6 +1298,19 @@ test "a long err record is cut between words, with an ellipsis" { const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?; const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1]; - try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n")); + try testing.expect(std.mem.indexOf(u8, record, "…") != null); + try testing.expect(std.mem.startsWith(u8, record, "err - ctl: ")); + try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } + +test "two failures that agree only in what their records cut are two records, not one counted" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const long = "why " ** 80; + p.setMessage(0, long ++ "first"); + p.setMessage(0, long ++ "second"); + try testing.expect(!th.logHas(p, "(x2)")); + p.setMessage(0, long ++ "second"); + try testing.expect(th.logHas(p, "(x2)")); +} diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 9f85a75f..3c1e181e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -475,6 +475,55 @@ fn joinOnly(pane: *Pane, handle: u32) void { if (pane.fs.joined_by != 0 and pane.fs.joined_by != handle) pane.fs.joined = null; } +/// How long writes in a row may pause before the editor's own step puts +/// them in: long enough that a stream of 8 KB writes is one edit. +pub const batch_pause_ns: u64 = 20 * std.time.ns_per_ms; + +/// The editor's step: the held writes go in once they have paused. +pub fn flushPausedBatch(p: *Pardes) void { + const b = &p.fs.batch; + if (b.bytes.items.len == 0) return; + if (p.now_ns != 0 and p.now_ns -| b.last_ns < batch_pause_ns) return; + flushBatch(p); +} + +/// Puts the held writes (`fs.batch`) in as one edit: before any other +/// request, the open's release, or the editor's own step. +pub fn flushBatch(p: *Pardes) void { + const b = &p.fs.batch; + if (b.bytes.items.len == 0) return; + defer b.bytes.clearRetainingCapacity(); + const id = p.paneBySerial(b.serial) orelse return; + const pane = p.panes[id].?; + if (fileOf(pane) == null) return; + const before = dotOf(pane); + joinOnly(pane, b.handle); + const take = spliceBody(p, pane, b.at, b.at, b.bytes.items) orelse return; + pane.fs.joined = fileOf(pane).?.revision; + pane.fs.joined_by = b.handle; + setDot(pane, .{ + .q0 = shiftOne(before.q0, clip(b.at), 0, clip(take)), + .q1 = shiftOne(before.q1, clip(b.at), 0, clip(take)), + }); + if (!pane.fs.noscroll) showOffset(pane, b.at + take); +} + +/// The held bytes a write at `at` on this open can go on from: its own +/// batch, when it ends right at `at`; any other is put in first. +fn batchFor(p: *Pardes, req: Req, pane: *Pane, at: usize) ?*@TypeOf(p.fs.batch) { + const b = &p.fs.batch; + const mine = b.serial == pane.serial and b.node == req.node and b.handle == req.handle; + if (b.bytes.items.len > 0 and !(mine and b.at + b.bytes.items.len == at)) flushBatch(p); + // Only an open's writes are held: one with no record (a test's, handle + // 0) is put in at once. A hold past 64 MiB is put in first. + if (req.handle == 0 or b.bytes.items.len > 64 << 20) { + flushBatch(p); + return null; + } + if (b.bytes.items.len == 0) b.* = .{ .serial = pane.serial, .node = req.node, .handle = req.handle, .at = at, .bytes = b.bytes }; + return b; +} + fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; if (pane.file == null) { @@ -484,7 +533,14 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { p.emitWrite(id, req.data[0..take]); return .{ .tag = req.tag, .written = @intCast(take) }; } - const at = bodyOf(pane).len; + const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; + const at = bodyOf(pane).len + held; + if (batchFor(p, req, pane, at)) |b| { + const take = wholeUtf8(req.data); + b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); + b.last_ns = p.now_ns; + return .{ .tag = req.tag, .written = @intCast(take) }; + } joinOnly(pane, req.handle); const take = spliceBody(p, pane, at, at, req.data) orelse return Reply.fail(req.tag, E.NOMEM); @@ -514,8 +570,9 @@ fn writeTag(req: Req, pane: *Pane) Reply { const pf = &pane.fs; const had = tagline.curTail(pane).len; const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline); - if (room == 0) return Reply.fail(req.tag, E.NOSPC); - const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); + // Whole or not at all: a write that would pass the limit changes nothing. + if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail})); + const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. if (pf.tag_held_newline) { @@ -538,9 +595,19 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const pf = &pane.fs; if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed); - clampAddr(pf, bodyOf(pane).len); + const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; + clampAddr(pf, bodyOf(pane).len + held); const q0: usize = pf.addr.q0; const q1: usize = @max(q0, @as(usize, pf.addr.q1)); + // An insert (an empty address) goes on from the open's held bytes. + if (q1 == q0) if (batchFor(p, req, pane, q0)) |b| { + const take = wholeUtf8(req.data); + b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); + b.last_ns = p.now_ns; + pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; + return .{ .tag = req.tag, .written = @intCast(take) }; + }; + flushBatch(p); const before = dotOf(pane); joinOnly(pane, req.handle); const take = spliceBody(p, pane, q0, q1, req.data) orelse @@ -1097,6 +1164,62 @@ test "the writes of one open are one undo step, as bash's line-buffered printf m } } +test "an open's writes in a row are held and go in as one edit, seen by the next request" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + const revision = p.panes[0].?.file.?.revision; + for (0..100) |_| _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "0123456789\n" }); + // Held: nothing edited yet. + try testing.expectEqual(revision, p.panes[0].?.file.?.revision); + // Any other request puts them in, once. + try testing.expectEqual(@as(usize, 4 + 1100), rd(p, body, 0, 4096).bytes.len); + try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); + _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = h, .opened = true }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); + // data inserts at its address go on the same way. + const data = Node.of(serial, .data); + _ = wr(p, Node.of(serial, .addr), "#0"); + const d = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "a" }); + _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "b" }); + _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = d, .opened = true }); + try testing.expectEqualStrings("abone\n", rd(p, body, 0, 64).bytes); +} + +test "writes held before the shell's clock is first seen go in at its first step" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const body = Node.of(serialOf(p), .body); + try testing.expect(!p.clock_started); + const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + const revision = p.panes[0].?.file.?.revision; + _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "x\n" }); + try testing.expectEqual(@as(u64, 0), p.fs.batch.last_ns); + // A detached host's first step: its clock is days into the machine's. + p.advance(700_000 * std.time.ns_per_s); + flushPausedBatch(p); + try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); +} + +test "a tag write past the limit is refused whole, naming the limit" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tag = Node.of(serialOf(p), .tag); + const before = try testing.allocator.dupe(u8, rd(p, tag, 0, 1 << 16).bytes); + defer testing.allocator.free(before); + const big = "w" ** (limits.max_tag_tail + 10); + const r = wr(p, tag, big); + try testing.expectEqual(E.NOSPC, r.errno()); + // `no space` is what 9ns maps to ENOSPC, so a shell through a mount + // sees the errno the tree gives. + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over ")); + try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes); +} + test "two opens writing one body are two undo steps, however their writes interleave" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); @@ -1153,13 +1276,19 @@ test "a name cut across writes is one name, applied once at its newline or its c const node = Node.of(serial, .name); const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expect(h != 0); + // A piece that fills its Twrite goes on in the next. + p.fs.write_room = "/tmp/pardes-na".len; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" }); + p.fs.write_room = 0; try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); - // No newline: the close applies it. - _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-closed.txt" }); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); + // No newline, the whole write: applied at once, on its own open. + const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" }); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true }); try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); } diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index d32c35fd..8ba9f4df 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -61,6 +61,10 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; + // A command pane's child is its command, done or running: exec + // would run its line again with no header, footer or records. + if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pane.command != null) + return tree.failText(req.tag, E.INVAL, "invalid exec: a command pane does not restart"); // A shell restarted where the directory is gone would not start: // refused before anything runs, as Tty there is. if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) { @@ -204,6 +208,12 @@ fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run; if (slot.phase == .sent or slot.phase == .waiting) return tree.failText(req.tag, E.BUSY, tree.e_in_use); + // One line a run, its answer read before the next: a second line (bash + // writes printf 'a\nb\n' a line at a time) would run and lose the + // first's answer. + const one = std.mem.trimEnd(u8, req.data, "\r\n"); + if (std.mem.indexOfScalar(u8, one, '\n') != null or (slot.phase == .done and slot.read == 0)) + return tree.failText(req.tag, E.INVAL, e_one_line); // A new line on this open: a new answer. p.gpa.free(slot.output); p.gpa.free(slot.line); @@ -421,6 +431,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run { } const e_bad_line = "bad command line"; +const e_one_line = "invalid run: one line a run, its answer read before the next"; const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec"; pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { @@ -1087,3 +1098,31 @@ test "a run's answer longer than its room is cut, its newline kept" { try testing.expectEqual(slot.answer.len, slot.len); try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]); } + +test "exec on a command pane is refused: a command pane does not restart" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const serial = th.serialOf(p); + const pane = p.panes[p.paneBySerial(serial).?].?; + pane.command = try p.gpa.dupe(u8, "echo hi"); + const r = th.wr(p, tree.Node.of(serial, .pty_ctl), "exec\n"); + try testing.expectEqual(tree.E.INVAL, r.errno()); + try testing.expectEqualStrings("invalid exec: a command pane does not restart", r.reply.ename); +} + +test "pty/run takes one line a run: two in one write, or a second before the first's answer is read, are refused" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const serial = th.serialOf(p); + const run = tree.Node.of(serial, .pty_run); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = run, .omode = 2 }).reply.handle; + const two = th.call(p, .{ .tag = 2, .op = .write, .node = run, .handle = h, .data = "true\nfalse\n" }); + try testing.expectEqual(tree.E.INVAL, two.errno()); + try testing.expectEqualStrings(e_one_line, two.reply.ename); + // A finished run whose answer nobody read takes no next line. + const slot = &tree.openOf(p, .{ .tag = 0, .op = .read, .node = run, .handle = h }).?.what.run; + slot.phase = .done; + slot.len = 7; + try testing.expectEqual(tree.E.INVAL, th.call(p, .{ .tag = 3, .op = .write, .node = run, .handle = h, .data = "true\n" }).errno()); + _ = th.call(p, .{ .tag = 4, .op = .release, .node = run, .handle = h, .opened = true }); +} diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index 927552e2..b8eaa02b 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -58,11 +58,16 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { /// Whether the log, read whole from a fresh open, holds `needle`. pub fn logHas(p: *Pardes, needle: []const u8) bool { + return logCount(p, needle) > 0; +} + +/// How many times the log, read whole from a fresh open, holds `needle`. +pub fn logCount(p: *Pardes, needle: []const u8) usize { const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; - return std.mem.indexOf(u8, text, needle) != null; + return std.mem.count(u8, text, needle); } pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index d6f4211b..5f3ffc9c 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -454,6 +454,86 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { // ---- dispatch ---- pub fn handle(p: *Pardes, req: Req) Reply { + var reply = serve(p, req); + if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno); + if (reply.status == .err and reply.ename.len > cloud9.fs.errmax) + if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| { + reply.ename = fitErr(reply.ename, room); + } else |_| {}; + return reply; +} + +/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes) +/// keeps its end, which says why: the longest path in it -- else the whole +/// text -- gives up its middle to `…`. +pub fn fitErr(text: []const u8, out: []u8) []const u8 { + const ell = "…"; + if (text.len <= out.len) return text; + const over = text.len - out.len + ell.len; + var from: usize = 0; + var to: usize = text.len; + var words = std.mem.tokenizeAny(u8, text, " \t"); + var longest: usize = 0; + while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) { + longest = w.len; + from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr); + to = from + w.len; + }; + var a = from + (to - from - over) / 2; + var b = a + over; + while (a > from and text[a] & 0xC0 == 0x80) a -= 1; + while (b < to and text[b] & 0xC0 == 0x80) b += 1; + return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len]; +} + +test "a reason past 128 bytes keeps its end: the path in it gives up its middle" { + var out: [128]u8 = undefined; + const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory"; + const fit = fitErr(long, &out); + try testing.expect(fit.len <= 128); + try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/")); + try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory")); + try testing.expect(std.mem.indexOf(u8, fit, "…") != null); + try testing.expect(std.unicode.utf8ValidateSlice(fit)); + // No path: the whole text's middle goes, the end stays. + const words = "why " ** 40 ++ "the reason"; + try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason")); + try testing.expectEqualStrings("short", fitErr("short", &out)); + // Through the tree: a look at a long ./ name not there says why at the end. + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var line: [512]u8 = undefined; + const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(r.reply.ename.len <= 128); + try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory")); +} + +/// A refusal with no reason of its own said in Plan 9's words, not the C +/// library's (`Operation not permitted`); each maps back to its errno in +/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does. +pub fn errWords(errno: u16) []const u8 { + return switch (errno) { + E.PERM => "permission denied", + E.NOENT => "file does not exist", + E.NOMEM => "out of memory", + E.BUSY => "file in use", + E.EXIST => "file already exists", + E.NOTDIR => "not a directory", + E.ISDIR => "is a directory", + E.INVAL => "bad argument", + E.NFILE => "too many open files", + E.NOSPC => "no space left", + E.NOSYS => "not supported", + E.NOTEMPTY => "directory not empty", + else => "i/o error", + }; +} + +fn serve(p: *Pardes, req: Req) Reply { + // Held writes go in before anything but the next write of their open. + if (p.fs.batch.bytes.items.len > 0 and !(req.op == .write and req.node == p.fs.batch.node and req.handle == p.fs.batch.handle)) + pane.flushBatch(p); if (req.op == .open and req.create) { if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null) return Reply.fail(req.tag, E.NOENT); @@ -1036,7 +1116,7 @@ fn linesFile(target: Target) bool { .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec, .col => |c| c.file == .ctl or c.file == .exec, // `name` too: one name, however its writes cut it, applied once - // its newline or its close comes. + // its newline comes, or with its write when that is whole. .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec or t.file == .name, }; } @@ -1073,7 +1153,18 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { // to fail unseen at the close. if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); - const end = ctl.completeEnd(p, o.pending.items); + var end = ctl.completeEnd(p, o.pending.items); + // A tail with no newline is a whole line when the write is the whole of + // what its client wrote (shorter than a Twrite can carry): it runs now, + // and its failure is this write's, as acme takes each write whole. Held + // only when it is not whole yet -- an open `{` block, an `a`/`c`/`i` + // text awaiting its `.` -- or the write filled its Twrite and may go on. + const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room; + if (end < o.pending.items.len and !cut) { + o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM); + const whole = ctl.completeEnd(p, o.pending.items); + if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1); + } if (end == 0) { if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; o.pending.clearRetainingCapacity(); @@ -1537,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(before, p.next_serial); } +test "random placements, refused or not, leave no pane under its tag and two rows" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var look_line: [4200]u8 = undefined; + const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir}); + var seed: u64 = 0; + while (seed < 300) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const r = prng.random(); + const rows = r.intRangeAtMost(u16, 6, 40); + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n"); + if (r.boolean()) p.settings.placement = .pardes; + for (0..40) |step| { + var serials: [pardes.MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |pn| { + serials[n] = pn.serial; + n += 1; + }; + if (n == 0) break; + const at = serials[r.uintLessThan(usize, n)]; + const action = r.uintLessThan(u8, 8); + switch (action) { + 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"), + 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }), + 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"), + 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"), + 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"), + 5 => _ = th.wr(p, Node.of(at, .look), looked), + 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"), + else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"), + } + for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { + std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c }); + for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) }); + return error.TestUnexpectedResult; + }; + } + } +} + test "at the pane cap, pane/new, look and New each say so, and look reads back empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -1550,8 +1690,8 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e const refused = call(p, .{ .tag = 2, .op = .open, .node = new }); try testing.expectEqual(E.NOSPC, refused.errno()); try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said)); - try testing.expect(th.logHas(p, said)); - // Its err alone says it: no msg besides (the one rule). + // A refused open is its error alone: no err, and no msg besides. + try testing.expect(!th.logHas(p, said)); try testing.expect(!th.logHas(p, "New: no space")); // A look that would open a pane fails its write, and says the same. var tmp = testing.tmpDir(.{}); @@ -1580,8 +1720,12 @@ test "a command line cut across writes runs once whole, and the last runs at rel const exec = @intFromEnum(TopFile.exec); const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; try testing.expect(h != 0); - for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| + // Each piece fills its Twrite (a client's one write cut at msize). + for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| { + p.fs.write_room = @intCast(piece.len); try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status); + } + p.fs.write_room = 0; try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n")); try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true }); @@ -1596,6 +1740,60 @@ test "a command line cut across writes runs once whole, and the last runs at rel try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content); } +test "a refused open, create or remove says why in words and logs no err; a refused write logs one" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + // pane/new is read, never written: a write is refused, and is no + // failure the log tells of. + const new = th.wr(p, @intFromEnum(TopFile.new), "x\n"); + try testing.expectEqual(Status.err, new.reply.status); + try testing.expectEqualStrings("permission denied", new.reply.ename); + const made = call(p, .{ .tag = 2, .op = .open, .node = Node.of(serial, .body), .create = true }); + try testing.expectEqualStrings("permission denied", made.reply.ename); + const gone = th.rmdir(p, @intFromEnum(TopFile.index)); + try testing.expectEqual(Status.err, gone.reply.status); + try testing.expect(gone.reply.ename.len > 0 and !std.ascii.isUpper(gone.reply.ename[0])); + // An OTRUNC open's truncation of data after a failed addr: refused, no err. + _ = th.wr(p, Node.of(serial, .addr), "/nothere/"); + const errs = th.logCount(p, "\nerr "); + try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }).reply.status); + try testing.expectEqual(errs, th.logCount(p, "\nerr ")); + try testing.expectEqual(@as(usize, 1), errs); // the addr write's own + // A write is the rule's: refused, it logs its one err, in words too. + try testing.expectEqual(Status.err, th.wr(p, Node.of(serial, .ctl), "bogus\n").reply.status); + try testing.expectEqual(@as(usize, 2), th.logCount(p, "\nerr ")); +} + +test "a write with no newline, whole in its Twrite, runs then and fails the write; one needing more waits" { + const p = try th.withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; + p.fs.write_room = 8192; + defer p.fs.write_room = 0; + try testing.expectEqual(Status.err, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status); + try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/zzz/y/" }).reply.status); + // An `a` needs its text and `.`: held, not run, not failed. + try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status); + try testing.expectEqualStrings("abc\nmore\n", p.panes[0].?.file.?.content); + _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + // The root's ctl too. + const root_ctl = @intFromEnum(TopFile.ctl); + const r = call(p, .{ .tag = 7, .op = .open, .node = root_ctl, .omode = 1 }).reply.handle; + try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = root_ctl, .handle = r, .data = "bogus" }).reply.status); + _ = call(p, .{ .tag = 9, .op = .release, .node = root_ctl, .handle = r, .opened = true }); + // And a name: a bad one fails its write, not the close after it. + const name = Node.of(serial, .name); + const n = call(p, .{ .tag = 10, .op = .open, .node = name, .omode = 1 }).reply.handle; + const bad = call(p, .{ .tag = 11, .op = .write, .node = name, .handle = n, .data = "delete " }); + try testing.expectEqual(Status.err, bad.reply.status); + try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a blank at its end") != null); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status); +} + test "look and exec read back what their own open's write touched; a fresh open reads the session's last" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index dc479909..22d5f7a1 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4792,6 +4792,7 @@ pub const Pardes = struct { for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane); p.saveRecent(); p.recent.deinit(p.recentGpa()); + p.fs.batch.bytes.deinit(p.gpa); if (p.stand_in) |pane| p.teardownPane(pane); look.cancelLookHover(p); if (p.repl_ask) |ask| p.gpa.free(ask.text); @@ -5081,6 +5082,15 @@ pub const Pardes = struct { else "Newcol: no space for a column: this one is too narrow to split"); }; + // A narrower column wraps long tags onto more rows, which can leave + // a pane under its tag and two rows: its column's rows are shared + // out again, and where they cannot hold every minimum there is no + // new column, as a size too small is refused. + for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) { + layout.dropColumn(p, c); + layout.compute(p); + return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit"); + }; tagline.enterHeader(p, c); } @@ -5686,6 +5696,8 @@ pub const Pardes = struct { f(h.ctx, timeout); } if (v.now) |clock| p.advance(clock(h.ctx)); + // What a 9P open wrote in a row goes in once the writes pause. + ctlfs.pane.flushPausedBatch(p); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); turn.settle(); @@ -5716,14 +5728,21 @@ pub const Pardes = struct { /// local socket costs microseconds and not a vsync. pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply { if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req); - p.needs_frame = true; + // An address, a limit or the mark flag is nothing drawn: a script's + // addr write then its close costs no frame between them. + const drawn = if (ctlfs.Node.target(req.node)) |t| !(t == .pane and (t.pane.file == .addr or t.pane.file == .limit or t.pane.file == .mark)) else true; + if (drawn) p.needs_frame = true; p.raw_hover_intent = false; look.cancelLookHover(p); p.fs.serving = true; p.fs.lsp_answer_at = null; const reply = ctlfs.handle(p, req); p.fs.serving = false; - if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply); + // The one-failure rule is a write's: a refused open (its OTRUNC's + // truncation, a setattr, too), create or remove is its Rerror alone, + // no err, as is a write to pane/new, which is only ever read. + const ruled = req.op == .write and req.node != @intFromEnum(ctlfs.TopFile.new); + if (reply.status == .err and ruled) ctlfs.events.noteError(p, req, reply); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -7123,6 +7142,10 @@ pub const Pardes = struct { /// sleep exactly as long as nothing changes. pub fn nextWake(p: *const Pardes) ?u64 { const next = p.stepped_ns + animation.frame_ns; + // Held 9P writes go in when they pause: wake for it, never before + // the next frame (writes served before the shell's clock was first + // seen are stamped 0, which `advance` would count back from). + if (p.fs.batch.bytes.items.len > 0) return @max(next, p.fs.batch.last_ns + ctlfs.pane.batch_pause_ns); if (p.chrome_animation.isActive() or p.presentation.animating()) return next; var frames: ?u64 = null; if (p.look_hover_wait) |waiting| { diff --git a/src/regexp.zig b/src/regexp.zig index ca396ff1..ddb5eb28 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -27,8 +27,19 @@ const mvzr = @import("mvzr"); /// enough line (`\s*(\w+)\s*=` over 20 KB of letters) runs out of budget. A /// regex engine of sam's own would lift these; the user chose not to have /// one. +/// mvzr's own `Regex` holds 64 operations, some 64 pattern characters; a +/// search pattern is often longer. Past these a pattern is refused naming +/// the limit (`e_long`). +pub const max_ops = 512; +const Compiled = mvzr.SizedRegex(max_ops, 64); + pub const Regex = struct { - re: mvzr.Regex, + re: Compiled, + /// The literal every match starts with, when the pattern opens with one + /// (`line 049`, `foo` in `foo.*bar`): a search goes straight to where it + /// occurs rather than trying mvzr at every line. + lit: [32]u8 = undefined, + lit_len: u8 = 0, /// The pattern names a newline: it runs over the whole text. spans: bool, /// The pattern starts with `^`: a search begun mid-line skips the line. @@ -41,19 +52,48 @@ pub const Regex = struct { /// patterns take 45-80 ms, and a Debug build is ten times slower. pub const budget: u64 = if (builtin.mode == .Debug) 4_000_000 else 32_000_000; + pub const e_long = std.fmt.comptimePrint("bad regular expression: longer than mvzr's {d} operations (about {d} pattern characters)", .{ max_ops, max_ops }); pub const e_anchor = "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n"; + pub const e_wide = std.fmt.comptimePrint("bad regular expression: a range of runes wider than {d} in [...] is not supported", .{max_range}); + pub const e_negated = "bad regular expression: a [^...] with non-ASCII runes is not supported (mvzr's classes hold bytes)"; + + /// The most runes a non-ASCII range in a class is spelled out as. + pub const max_range = 256; /// `Anchor`: a pattern that names a newline has `^` other than first, /// or `$` other than just before a `\n`, which mvzr would read as the /// ends of the whole text and so never match where sam would. - pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex { + /// The plain characters a pattern opens with, each of which every match + /// must start with: up to the first metacharacter, less the last one + /// when a quantifier makes it optional or repeated. + fn literalPrefix(pat: []const u8, out: *[32]u8) u8 { + // ponytail: any `|` and there is none (a match may start with + // another branch); a top-level-only check would keep `a(b|c)`'s. + if (std.mem.indexOfScalar(u8, pat, '|') != null) return 0; + var n: usize = 0; + while (n < pat.len and n < out.len) : (n += 1) { + if (std.mem.indexOfScalar(u8, "\\^$.[]()|*+?{}", pat[n]) != null) break; + out[n] = pat[n]; + } + if (n < pat.len and std.mem.indexOfScalar(u8, "*?{", pat[n]) != null) n -|= 1; + return @intCast(n); + } + + pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes }; + + pub fn compile(pat: []const u8) Error!Regex { if (pat.len == 0) return error.Bad; + // mvzr's classes hold bytes: `[éa-z]` is written `(é|[a-z])` for it. + // The limits apply to the pattern as rewritten. + var runes: [5 * max_ops + 2]u8 = undefined; + if (try runeClasses(pat, &runes)) |whole| return compile(whole); // mvzr takes `^` only at its pattern's start, so `^def|^ ` (a `^` // after a `|`) is written `^(def| )` for it: the same lines. A mix, // `^a|b`, has no such spelling and is refused rather than wrong. - var joined: [258]u8 = undefined; + var joined: [5 * max_ops + 2]u8 = undefined; if (try anchoredAlternation(pat, &joined)) |whole| return compile(whole); - var buf: [256]u8 = undefined; + // `.` may become `[^\n]`: five bytes for one. + var buf: [5 * max_ops]u8 = undefined; var len: usize = 0; var spans = false; // Twice over the pattern: the first pass learns whether it names a @@ -90,22 +130,150 @@ pub const Regex = struct { return error.Anchor; } if (!emit) continue; - if (len + piece.len > buf.len) return error.Bad; + if (len + piece.len > buf.len) return error.TooLong; @memcpy(buf[len..][0..piece.len], piece); len += piece.len; } } + var lit: [32]u8 = undefined; + const lit_len = if (spans) 0 else literalPrefix(pat, &lit); return .{ - .re = mvzr.compile(buf[0..len]) orelse return error.Bad, + .lit = lit, + .lit_len = lit_len, + .re = Compiled.compile(buf[0..len]) orelse { + // Too long, or malformed: told apart by trying it with room. + if (mvzr.SizedRegex(4 * max_ops, 256).compile(buf[0..len]) != null) return error.TooLong; + return error.Bad; + }, .spans = spans, .bol = pat[0] == '^', }; } + /// `pat` with each class that holds a non-ASCII rune written as an + /// alternation of its runes and a class of the rest (`[éa-z]` is + /// `(é|[a-z])`), a range spelled out rune by rune; null when no class + /// holds one. + fn runeClasses(pat: []const u8, out: *[5 * max_ops + 2]u8) Error!?[]const u8 { + var w = std.Io.Writer.fixed(out); + var any = false; + var i: usize = 0; + while (i < pat.len) : (i += 1) { + const c = pat[i]; + if (c == '\\') { + w.writeAll(pat[i..@min(i + 2, pat.len)]) catch return error.TooLong; + i += 1; + continue; + } + if (c != '[') { + w.writeByte(c) catch return error.TooLong; + continue; + } + // The class's end, as mvzr finds it: the first unescaped `]`. + var end = i + 1; + while (end < pat.len and pat[end] != ']') : (end += 1) { + if (pat[end] == '\\') end += 1; + } + if (end >= pat.len) return error.Bad; + const body = pat[i + 1 .. end]; + if (for (body) |b| { + if (b >= 0x80) break false; + } else true) { + w.writeAll(pat[i .. end + 1]) catch return error.TooLong; + i = end; + continue; + } + if (body[0] == '^') return error.NegatedRunes; + any = true; + try runeClass(body, &w); + i = end; + } + return if (any) w.buffered() else null; + } + + fn runeClass(body: []const u8, w: *std.Io.Writer) Error!void { + var ascii: [5 * max_ops]u8 = undefined; + var n: usize = 0; + w.writeByte('(') catch return error.TooLong; + var alts: usize = 0; + var j: usize = 0; + while (j < body.len) { + if (body[j] == '\\') { + const len: usize = if (j + 1 < body.len and body[j + 1] == 'x') 4 else 2; + if (j + len > body.len) return error.Bad; + // An escape at one end of a range whose other end is a rune. + if (j + len + 1 < body.len and body[j + len] == '-' and body[j + len + 1] >= 0x80) return error.Bad; + if (n + len > ascii.len) return error.TooLong; + @memcpy(ascii[n..][0..len], body[j..][0..len]); + n += len; + j += len; + continue; + } + const lo, const lo_len = try rune(body[j..]); + j += lo_len; + var hi = lo; + if (j + 1 < body.len and body[j] == '-') { + if (body[j + 1] == '\\' and lo >= 0x80) return error.Bad; + if (body[j + 1] != '\\') { + hi, const hi_len = try rune(body[j + 1 ..]); + j += 1 + hi_len; + } + } + if (hi < lo) return error.Bad; + if (hi - lo + 1 > max_range) return error.WideRange; + // Runes that differ only in their last byte go as one + // alternative, `\xc3[\xa0-\xbf]` for `[à-ÿ]`: 256 runes one a + // time would pass `max_ops`. + var run: [4]u8 = undefined; + var run_len: usize = 0; + var run_hi: u8 = 0; + var cp = lo; + while (cp <= hi + 1) : (cp += 1) { + var enc: [4]u8 = undefined; + const len = if (cp > hi) 0 else std.unicode.utf8Encode(cp, &enc) catch continue; // a surrogate + if (run_len > 0 and (len != run_len or enc[len - 1] != run_hi + 1 or + !std.mem.eql(u8, enc[0 .. len - 1], run[0 .. len - 1]))) + { + if (alts > 0) w.writeByte('|') catch return error.TooLong; + w.writeAll(run[0 .. run_len - 1]) catch return error.TooLong; + if (run_hi == run[run_len - 1]) + w.writeByte(run_hi) catch return error.TooLong + else + w.print("[\\x{x:0>2}-\\x{x:0>2}]", .{ run[run_len - 1], run_hi }) catch return error.TooLong; + alts += 1; + run_len = 0; + } + if (cp > hi) break; + if (cp < 0x80) { + // `\xHH` rather than the byte: `]`, `^`, `-` and `\` + // would otherwise mean something in the class. + if (n + 4 > ascii.len) return error.TooLong; + _ = std.fmt.bufPrint(ascii[n..][0..4], "\\x{x:0>2}", .{cp}) catch unreachable; + n += 4; + continue; + } + if (run_len == 0) { + run = enc; + run_len = len; + } + run_hi = enc[len - 1]; + } + } + if (n > 0) w.print("|[{s}]", .{ascii[0..n]}) catch return error.TooLong; + w.writeByte(')') catch return error.TooLong; + } + + /// The rune `s` opens with and its length in bytes. + fn rune(s: []const u8) error{Bad}!struct { u21, usize } { + const len = std.unicode.utf8ByteSequenceLength(s[0]) catch return error.Bad; + if (len > s.len) return error.Bad; + return .{ std.unicode.utf8Decode(s[0..len]) catch return error.Bad, len }; + } + /// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at /// its top level and every branch starts with `^`; null when it is not /// one, or no branch does. - fn anchoredAlternation(pat: []const u8, out: *[258]u8) error{Bad}!?[]const u8 { + fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{Bad}!?[]const u8 { var bars: [16]usize = undefined; var n: usize = 0; var depth: usize = 0; @@ -183,6 +351,24 @@ pub const Regex = struct { at = 0; } while (start <= hi and start <= last) { + // A pattern that opens with a literal matches only where it + // occurs: on to the line where it next does. + if (rx.lit_len > 0 and !rx.spans) { + const q = std.mem.indexOfPos(u8, text[0..hi], start + at, rx.lit[0..rx.lit_len]) orelse return null; + if (q > last) return null; + if (q > start + at) { + const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..q], '\n')) |nl| nl + 1 else 0; + if (line_start > start) { + start = line_start; + at = q - line_start; + } else at = q - start; + if (rx.bol and at > 0) { + start = (std.mem.indexOfScalarPos(u8, text[0..hi], q, '\n') orelse return null) + 1; + at = 0; + continue; + } + } + } const end = if (rx.spans) hi else std.mem.indexOfScalarPos(u8, text[0..hi], start, '\n') orelse hi; const line = text[start..end]; // matchPos finds nothing at a haystack's very end, where `$` or @@ -240,6 +426,56 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li try std.testing.expectError(error.Bad, Regex.compile("a\\")); } +test "a pattern opening with a literal finds what a search from each line finds" { + const text = "alpha beta\nbeta gamma\ngamma alpha\nfoo line 049999 x\n"; + for ([_][]const u8{ "beta", "gam+a", "line 049999", "alph?a", "a.*a", "^gamma", "o+", "h$|zz", "beta|x" }) |pat| { + var fast = try Regex.compile(pat); + var slow = try Regex.compile(pat); + slow.lit_len = 0; + var from: usize = 0; + while (from < text.len) : (from += 1) { + const a = try fast.find(text, from, text.len, text.len); + const b = try slow.find(text, from, text.len, text.len); + try std.testing.expectEqual(b == null, a == null); + if (a) |m| try std.testing.expectEqual(b.?.start, m.start); + } + } +} + +test "a class with non-ASCII runes matches those runes: [éa-z], a range of them, and refuses a wide range or [^é]" { + const text = "1 é 2 b 3 ü 4 ñ\n"; + var mixed = try Regex.compile("[éa-z]+"); + try std.testing.expectEqual(@as(usize, 2), (try mixed.find(text, 0, text.len, text.len)).?.start); + const m = (try mixed.find(text, 5, text.len, text.len)).?; + try std.testing.expectEqualStrings("b", text[m.start..m.end]); + var range = try Regex.compile("3 [à-ÿ]"); + const r = (try range.find(text, 0, text.len, text.len)).?; + try std.testing.expectEqualStrings("3 ü", text[r.start..r.end]); + // An ASCII member that means something in a class stays a member. + var odd = try Regex.compile("[ñ\\]^-]"); + try std.testing.expectEqual(@as(usize, 16), (try odd.find(text, 0, text.len, text.len)).?.start); + var none = try Regex.compile("[ö]"); + try std.testing.expect(try none.find(text, 0, text.len, text.len) == null); + try std.testing.expectError(error.WideRange, Regex.compile("[ā-ӿ]")); + try std.testing.expectError(error.NegatedRunes, Regex.compile("[^é]")); + try std.testing.expectError(error.Bad, Regex.compile("[é")); + try std.testing.expectError(error.Bad, Regex.compile("[ÿ-à]")); + // A full 256-rune range fits the limit as rewritten. + var wide = try Regex.compile("x[Ā-ǿ]"); + try std.testing.expectEqual(@as(usize, 2), (try wide.find("ǿxǿ", 0, 5, 5)).?.start); + var kana = try Regex.compile("[ぁ-ゟ]"); // 3 bytes, across a last-byte wrap + try std.testing.expectEqual(@as(usize, 1), (try kana.find("aゞ", 0, 4, 4)).?.start); + try std.testing.expectError(error.WideRange, Regex.compile("[Ā-Ȁ]")); +} + +test "a pattern past 64 characters compiles, and one past the limit says so" { + var long = try Regex.compile("a" ** 200); + const text = "x" ++ "a" ** 200 ++ "\n"; + try std.testing.expectEqual(@as(usize, 1), (try long.find(text, 0, text.len, text.len)).?.start); + try std.testing.expectError(error.TooLong, Regex.compile("a" ** (max_ops + 8))); + try std.testing.expectError(error.Bad, Regex.compile("a[b")); +} + test "a ^ after | anchors that branch: ^def|^ finds a line that starts either way, and a mix is refused" { const text = "x def\n a\ndef b\n"; var both = try Regex.compile("^def|^ "); diff --git a/src/sam_edit.zig b/src/sam_edit.zig index a88728aa..0f317a83 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -111,11 +111,26 @@ pub fn waitsFor(arena: std.mem.Allocator, command: []const u8) Wait { var ps: Parser = .{ .arena = arena, .src = src, .why = &why }; while (true) { const c = ps.parse(0) catch - return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'")) .group else .none; + return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'") or groupOpen(command)) .group else .none; if (c == null) return if (ps.open_text) .text else .none; } } +/// A `{` block not closed yet by its `}` line: a command in it that fails +/// to parse still leaves the block to end at its `}`, so the error is that +/// command's, not the `}`'s taken as a line of its own. +fn groupOpen(command: []const u8) bool { + var depth: usize = 0; + var lines = std.mem.splitScalar(u8, command, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (std.mem.eql(u8, line, "}")) { + depth -|= 1; + } else if (std.mem.endsWith(u8, line, "{")) depth += 1; + } + return depth > 0; +} + pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool { var why: Why = .{}; const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false; @@ -445,6 +460,9 @@ const Exec = struct { fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex { return regexp.Regex.compile(pat) catch |err| switch (err) { error.Anchor => fail(ex.why, "{s}", .{regexp.Regex.e_anchor}), + error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}), + error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}), + error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}), error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}), }; } @@ -455,6 +473,9 @@ const Exec = struct { /// reaches back into what the last one took. fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match { if (from > hi) return null; + // The budget is each search's own: an x over 100k lines makes 100k + // searches, none of which is the slow one. + rx.steps = regexp.Regex.budget; const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null; var start = modal.runeStart(ex.text, m.start); if (start < from) start = modal.runeEnd(ex.text, m.start); @@ -693,6 +714,20 @@ fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void { try std.testing.expectEqualStrings(want, got); } +test "an x over 100k lines is 100k searches, each with its own step budget" { + const th = @import("ninep/testing.zig"); + const line = "x" ** 80 ++ "\n"; + const text = try std.testing.allocator.alloc(u8, line.len * 100_000); + defer std.testing.allocator.free(text); + for (0..100_000) |k| @memcpy(text[line.len * k ..][0..line.len], line); + const p = try th.withFile(std.testing.allocator, text); + defer p.deinit(); + const tree = @import("ninep/tree.zig"); + const r = th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit ,x/x+/c/z/\n"); + try std.testing.expectEqual(tree.Status.ok, r.reply.status); + try std.testing.expect(std.mem.startsWith(u8, p.panes[0].?.file.?.content, "z\nz\n")); +} + test "sam's classic commands" { try expectEdit("foo x foo y foo\n", ",x/foo/c/foobar/", "foobar x foobar y foobar\n"); try expectEdit("a b\nc d\n", ",x/ /c/_/", "a_b\nc_d\n"); @@ -622,11 +622,11 @@ def workflow_canary(binary): rows = client.read(f'/pane/{grep}/body').decode() assert 'a.txt:2' in rows and 'b.txt:2' in rows, rows client.remove(f'/pane/{grep}') - execute_refused(client, a, 'Grep zzqqnomatch', 'grep: pattern not found') + execute_refused(client, a, 'Grep zzqqnomatch', 'Grep: pattern not found') found = made_by(a, b'Find b.txt\n') assert 'b.txt' in client.read(f'/pane/{found}/body').decode() client.remove(f'/pane/{found}') - execute_refused(client, a, 'Find zzqqnomatch', 'find: name not found') + execute_refused(client, a, 'Find zzqqnomatch', 'Find: name not found') # A plain word looked at is found in the pane, after its dot. client.write(f'/pane/{a}/dot', b' 0 0 ') @@ -953,6 +953,20 @@ def test(binary, quic=False): with Client(address, msize=256) as small: assert set(small.list('/os' + str(listing))) == { f'entry-{number:02}' for number in range(24)} + # 64 KiB frames: a ctl line with no newline, whole in its one + # Twrite, runs with it however long -- the cutoff for a write + # that may go on is the frame the client asked for, less 24. + with Client(address, msize=65536) as big: + assert big.msize == 65536 + ctl = big.open('/pane/1/ctl', 1) + line = b'bogus' + b'x' * 20000 + try: + big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line) + except OSError: + pass # refused with its write (a line over 1024 bytes), not held + else: + raise AssertionError('a whole 20000-byte line waited for a newline') + big.close(ctl) frozen = client.open('/screen') before = bytearray(client.read_fid(frozen, count=31)) @@ -1224,6 +1238,79 @@ def test(binary, quic=False): cell[0] for cell in local.screen()['cells']) assert client.read('/index') == before + # --wait (acme's E): a launch inside a pane lasts until the pane it + # opened is deleted, as an $EDITOR must; one on a file already open + # waits on that pane; a session that goes away ends it with 1. + with session(binary, root, 'wait') as (client, address): + status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) + env = {k: v for k, v in os.environ.items() if k != 'PARDES_FORWARD_LOOK'} + env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid']) + edited = root / 'wait edit.txt' + edited.write_bytes(b'edit me\n') + + def serials(): + return {int(row.split()[0]) for row in client.read('/index').splitlines()} + + def launch(flag): + before = serials() + child = subprocess.Popen([binary, flag, str(edited)], cwd=root, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + deadline = time.monotonic() + 3 + while child.poll() is None and not serials() - before and time.monotonic() < deadline: + time.sleep(.01) + return child + + first = launch('--wait') + opened, = serials() - {1} + second = launch('-w') + time.sleep(.5) + assert first.poll() is None and second.poll() is None, (first.stderr.read(), second.stderr.read()) + assert serials() == {1, opened} + client.write(f'/pane/{opened}/body', b'edited\n', truncate=True) + execute(client, opened, 'Save') + client.remove(f'/pane/{opened}') + deleted = time.monotonic() + assert first.wait(timeout=5) == 0 + # Woken by the `del` record, not a poll. + assert time.monotonic() - deleted < .05, time.monotonic() - deleted + assert second.wait(timeout=5) == 0 + assert edited.read_bytes() == b'edited\n' + # A name not there yet opens a pane named it, as acme's B does, + # and Save creates it: git's and fish's temporary files are. + fresh = root / 'wait new.txt' + before = serials() + made = subprocess.Popen([binary, '--wait', fresh.name], cwd=root, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + deadline = time.monotonic() + 3 + while not serials() - before: + assert made.poll() is None, made.stderr.read() + assert time.monotonic() < deadline, 'a new name opened no pane' + time.sleep(.01) + named, = serials() - before + assert str(fresh).encode() in client.read('/index') + client.write(f'/pane/{named}/body', b'new\n') + execute(client, named, 'Save') + client.remove(f'/pane/{named}') + assert made.wait(timeout=5) == 0 + assert fresh.read_bytes() == b'new\n' + orphan = launch('--wait') + assert orphan.poll() is None + assert orphan.wait(timeout=5) == 1 + + # A launch with no terminal to draw on (a pty that is no one's + # controlling terminal) says so and exits 1, no error trace. + master, slave = os.openpty() + try: + env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')} + env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color') + result = subprocess.run([binary, '--tty', 'x.txt'], cwd=root, env=env, stdin=slave, stdout=slave, + stderr=subprocess.PIPE, start_new_session=True, timeout=10) + finally: + os.close(master) + os.close(slave) + assert result.returncode == 1, result + assert b'pardes: no terminal to draw on' in result.stderr and b'.zig:' not in result.stderr, result.stderr + for tty in [False, True]: name = 'lsp-tty' if tty else 'lsp-detached' with session(binary, root, name, tty=tty) as (client, _): diff --git a/test/gui_monkey.py b/test/gui_monkey.py new file mode 100644 index 00000000..2b32f050 --- /dev/null +++ b/test/gui_monkey.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden +test window gets a random burst of clicks, drags, wheels, keys, new and +joined columns, opened files and theme changes, then must settle (its +frames stop changing) with no crash record and its process alive. + +gui_monkey.py <pardes-gui> [outdir] [--seed N] [--steps N]; `zig build +monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built +window. Exits 1 when any combination failed; what failed is in outdir. +""" +import argparse +import hashlib +import itertools +import os +import random +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fs import session # noqa: E402 + +MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful'] +LIFTS = ['off', 'shadow', 'rim', 'auto'] +BLOOMS = ['0', '2'] +THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak'] +W, H = 1280, 840 + + +def settled(cap, timeout=20): + latest = cap / 'latest.ppm' + previous = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(.4) + try: + data = latest.read_bytes() + except FileNotFoundError: + continue + digest = hashlib.sha256(data).hexdigest() + if digest == previous: + return True + previous = digest + return False + + +def run(binary, out, motion, lift, bloom, seed, steps): + rng = random.Random(seed) + root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-')) + cap = root / 'cap' + cap.mkdir() + (root / 'config/pardes').mkdir(parents=True) + (root / 'config/pardes/init').write_text( + f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n') + src = root / 'a.zig' + src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200))) + for name in ('b.txt', 'c.txt'): + (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80))) + display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0') + env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0', + 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} + name = 'monkey' + launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)] + handles = {} + verdict = 'ok' + log = [] + try: + with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _): + fd = handles['input_fd'] + time.sleep(1.0) + + def mouse(kind, button, x, y): + os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode()) + + def first(): + rows = client.read('/index').decode().splitlines() + return int(rows[0].split()[0]) if rows else None + + for _ in range(steps): + x, y = rng.randrange(W), rng.randrange(H) + action = rng.randrange(12) + try: + if action in (0, 1): + mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}') + elif action == 2: + x2, y2 = rng.randrange(W), rng.randrange(H) + mouse('down', 1, x, y) + for i in range(1, 6): + mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5) + mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}') + elif action == 3: + amount = rng.choice([-6, -2, 2, 6]) + os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}') + elif action == 4: + mouse('motion', 0, x, y); log.append(f'hover {x},{y}') + elif action == 5: + client.write('/ctl', b'Newcol\n'); log.append('Newcol') + elif action == 6: + serial = first() + if serial is not None: + target = root / rng.choice(['b.txt', 'c.txt', 'a.zig']) + client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}') + elif action == 7: + serial = first() + if serial is not None: + # Delcol too: the last column's going leaves an + # empty window now (acme's), not a quit. + word = rng.choice(['Joincol', 'Collapse', 'Delcol']) + try: + client.write(f'/pane/{serial}/exec', (word + '\n').encode()) + except OSError: + pass + log.append(word) + else: + # An empty window: pane/new makes its column and a pane. + try: + client.read('/pane/new') + except OSError: + pass + log.append('pane/new (empty window)') + elif action == 8: + os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b'])) + log.append('keys') + elif action == 9: + theme = rng.choice(THEMES) + client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}') + elif action == 10: + mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}') + else: + time.sleep(rng.choice([0, .05, .2])); log.append('pause') + except OSError as why: + log.append(f'refused: {why}') + time.sleep(.03) + if not settled(cap): + verdict = 'never settled' + try: + os.kill(handles['pid'], 0) + except OSError: + verdict = 'process gone' + client.read('/index') + except Exception as why: # noqa: BLE001 + verdict = f'failed: {type(why).__name__}: {str(why)[:300]}' + crashes = list((root / 'config').rglob('*crash*')) + if crashes: + verdict = 'crash record: ' + ', '.join(str(c) for c in crashes) + label = f'{motion}-{lift}-bloom{bloom}' + if verdict != 'ok': + (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n') + for c in crashes: + shutil.copy(c, out / f'{label}-{c.name}') + if (cap / 'latest.ppm').exists(): + shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm') + shutil.rmtree(root, ignore_errors=True) + return label, verdict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('binary', type=Path) + ap.add_argument('out', type=Path, nargs='?') + ap.add_argument('--seed', type=int, default=1) + ap.add_argument('--steps', type=int, default=60) + args = ap.parse_args() + if args.out is None: + args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-')) + args.out.mkdir(parents=True, exist_ok=True) + bad = 0 + for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)): + label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps) + print(f'{label}: {verdict}', flush=True) + bad += verdict != 'ok' + print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True) + sys.exit(1 if bad else 0) + + +if __name__ == '__main__': + main() diff --git a/test/monkey9p.py b/test/monkey9p.py new file mode 100644 index 00000000..ee0b0ebc --- /dev/null +++ b/test/monkey9p.py @@ -0,0 +1,1993 @@ +#!/usr/bin/env python3 +"""pardes 9P monkey: random 9P operations against a throwaway detached session. + + zig build monkey-9p -Dplatform=tty --prefix <scratch> -- [--seed N] [--steps M] + zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --replay FILE + zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --shrink FILE + python3 test/monkey9p.py <pardes> [same flags] + +A run starts `pardes --detach` with its own HOME, XDG dirs and a short +runtime dir in /tmp, speaks raw 9P to it (test/ninep.py), and for each +step asks a GENERATOR for one concrete operation, runs it, then runs every +INVARIANT. Everything random comes from --seed; every operation it ran is +written to <out>/runs/seed-N/ops.jsonl, concrete but for pane and column +references ({p3}: the 4th serial of /index, mod its length, when the op +runs, so a shrunk sequence still names live panes), and `--replay` runs +the same requests again without the generator. A failure writes a bug record +(seed, step, the replayable operations since that session began) to +<out>/bugs/, shrinks it by delta debugging over replays unless +--no-shrink, and the run carries on in a fresh session. + +Plug-in points: a generator is a function `(ctx, rng) -> op dict` under +@generator(weight); an op kind is a function `(sess, op) -> Result` under +@op_kind(name); an invariant is a function `(ctx, res) -> str | None` +under @invariant, returning why it failed. + +Safety: the session never sees PARDES_*, NINE_MOUNT or NAMESPACE; every +payload that could reach a shell is drawn from an alphabet with no path +or shell metacharacters (shell_safe), Edit text from a grammar without +sam's w/e/r/f/b/B/D/n/!/</>/| commands, and nothing is ever written, +created or removed under /os or /src (the host filesystem). This process +makes itself a child subreaper, so every shell the session starts is its +descendant even after pardes dies, and teardown kills them all. +""" +import argparse +import collections +import ctypes +import hashlib +import json +import os +import random +import re +import select +import shutil +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from ninep import Client, string # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_OUT = os.path.join(os.path.dirname(REPO), '.scratch', 'monkey9p') + +# 9P message types. +TWALK, TOPEN, TCREATE, TREAD, TWRITE, TCLUNK, TREMOVE, TSTAT, TFLUSH = 110, 112, 114, 116, 118, 120, 122, 124, 108 +RERROR = 107 +OREAD, OWRITE, ORDWR, OTRUNC = 0, 1, 2, 16 +DMDIR = 0x80000000 + +TIMEOUT = 5.0 # every reply, unless the op is a documented slow one +SLOW_TIMEOUT = 30.0 # Grep, Find, Dump, Restore, Tty, huge writes +HELD_WAIT = 0.1 # how long a read of a held file is let wait before Tflush +MSIZE = 65536 + 24 + + +class Hang(Exception): + """A 9P reply did not come within its timeout.""" + + +class Dropped(Exception): + """The server closed the connection (or reset it).""" + + +class Ended(Exception): + """pardes quit cleanly: exit 0, no crash file. Closing the session's last + pane quits it (fs.md: 'Closing the session's last pane quits pardes'), + and a terminal whose shell exits closes its pane, so this can follow any + step; the run starts a fresh session and carries on.""" + + +# -------------------------------------------------------------------------- +# Wire: raw 9P with per-request timeouts and Tflush for held reads. + +class Wire(Client): + def __init__(self, address, msize=MSIZE): + super().__init__(address, timeout=TIMEOUT, msize=msize) + self.next_fid = 100 + + def newfid(self): + self.next_fid += 1 + return self.next_fid + + def _send(self, kind, payload): + self.tag = (self.tag + 1) % 65535 + packet = struct.pack('<IBH', 7 + len(payload), kind, self.tag) + payload + if len(packet) > self.msize: + raise ValueError(f'harness bug: a {len(packet)}-byte request exceeds msize {self.msize}') + try: + self.socket.sendall(packet) + except socket.timeout: + raise Hang('send did not drain within the timeout') + except OSError as why: + raise Dropped(f'send: {why}') + return self.tag + + def _recv(self, timeout): + self.socket.settimeout(timeout) + try: + size, kind, tag = struct.unpack('<IBH', self.receive(7)) + body = self.receive(size - 7) + except socket.timeout: + raise Hang(f'no reply within {timeout:g}s') + except (EOFError, ConnectionResetError, BrokenPipeError) as why: + raise Dropped(str(why)) + except OSError as why: + if isinstance(why, socket.timeout): + raise Hang(f'no reply within {timeout:g}s') + raise Dropped(str(why)) + return kind, tag, body + + def call(self, kind, payload, timeout=TIMEOUT): + """(ok, body_or_error_text).""" + tag = self._send(kind, payload) + while True: + rkind, rtag, body = self._recv(timeout) + if rtag != tag: + continue # a late reply to a flushed request + if rkind == RERROR: + n = struct.unpack_from('<H', body)[0] + return False, body[2:2 + n].decode(errors='replace') + if rkind != kind + 1: + raise Dropped(f'expected reply {kind + 1}, received {rkind}') + return True, body + + def call_held(self, kind, payload, wait=HELD_WAIT): + """A request the server may hold: after `wait`, flush it. Returns + (ok, body) for an answer, or (None, 'flushed').""" + tag = self._send(kind, payload) + self.socket.settimeout(None) + ready, _, _ = select.select([self.socket], [], [], wait) + if ready: + rkind, rtag, body = self._recv(TIMEOUT) + if rtag == tag: + if rkind == RERROR: + n = struct.unpack_from('<H', body)[0] + return False, body[2:2 + n].decode(errors='replace') + return True, body + ftag = self._send(TFLUSH, struct.pack('<H', tag)) + answer = (None, 'flushed') + while True: + rkind, rtag, body = self._recv(TIMEOUT) + if rtag == tag: + if rkind == RERROR: + n = struct.unpack_from('<H', body)[0] + answer = (False, body[2:2 + n].decode(errors='replace')) + else: + answer = (True, body) + elif rtag == ftag: + return answer + + # Convenience over call(). + def walk_names(self, names, timeout=TIMEOUT): + """(fid or None, error).""" + fid = self.newfid() + old = 1 + if not names: + ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, 0), timeout) + return (fid, None) if ok else (None, body) + for start in range(0, len(names), 16): + part = names[start:start + 16] + ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, len(part)) + + b''.join(string(n) for n in part), timeout) + if not ok: + if old != 1: + self.call(TCLUNK, struct.pack('<I', fid)) + return None, body + if struct.unpack_from('<H', body)[0] != len(part): + if old != 1: + self.call(TCLUNK, struct.pack('<I', fid)) + return None, 'walk stopped short' + old = fid + return fid, None + + def clunk(self, fid): + return self.call(TCLUNK, struct.pack('<I', fid)) + + def read_all(self, fid, count=None, limit=1 << 22): + count = min(count or (self.msize - 11), self.msize - 11) + data = bytearray() + while len(data) < limit: + ok, body = self.call(TREAD, struct.pack('<IQI', fid, len(data), count)) + if not ok: + raise OSError(body) + chunk = body[4:] + if not chunk: + break + data.extend(chunk) + return bytes(data) + + def read_path(self, path): + fid, err = self.walk_names(split(path)) + if fid is None: + raise FileNotFoundError(f'{path}: {err}') + try: + ok, body = self.call(TOPEN, struct.pack('<IB', fid, OREAD)) + if not ok: + raise OSError(f'{path}: {body}') + return self.read_all(fid) + finally: + self.clunk(fid) + + def write_path(self, path, data): + """(ok, error) for one open, one write, one clunk.""" + fid, err = self.walk_names(split(path)) + if fid is None: + return False, err + try: + ok, body = self.call(TOPEN, struct.pack('<IB', fid, OWRITE)) + if not ok: + return False, body + ok, body = self.call(TWRITE, struct.pack('<IQI', fid, 0, len(data)) + data) + return (True, None) if ok else (False, body) + finally: + self.clunk(fid) + + +def split(path): + return [p.encode('latin-1') if isinstance(p, str) else p for p in path.split('/') if p] + + +def dir_names(data): + names, offset = [], 0 + while offset + 2 <= len(data): + size = struct.unpack_from('<H', data, offset)[0] + n = struct.unpack_from('<H', data, offset + 41)[0] + names.append(data[offset + 43:offset + 43 + n].decode('latin-1')) + offset += size + 2 + return names + + +# -------------------------------------------------------------------------- +# Session lifecycle. + +def _libc(): + try: + return ctypes.CDLL(None, use_errno=True) + except OSError: + return None + + +def become_subreaper(): + """Orphaned shells re-parent to us, not init, so teardown finds them.""" + libc = _libc() + if libc is not None: + libc.prctl(36, 1, 0, 0, 0) # PR_SET_CHILD_SUBREAPER + + +def descendants(root): + parent = {} + for entry in os.listdir('/proc'): + if not entry.isdigit(): + continue + try: + with open(f'/proc/{entry}/stat', 'rb') as f: + stat = f.read() + ppid = int(stat[stat.rindex(b')') + 2:].split()[1]) + except (OSError, ValueError): + continue + parent.setdefault(ppid, []).append(int(entry)) + out, stack = [], [root] + while stack: + for child in parent.get(stack.pop(), []): + out.append(child) + stack.append(child) + return out + + +def tokened(token): + """Our own processes whose environment carries this session's token.""" + found, needle = [], f'M9P_TOKEN={token}'.encode() + for entry in os.listdir('/proc'): + if not entry.isdigit(): + continue + try: + with open(f'/proc/{entry}/environ', 'rb') as f: + if needle in f.read().split(b'\0'): + found.append(int(entry)) + except OSError: + continue + return found + + +def sweep(prefix): + """Kill every process whose M9P_TOKEN starts with `prefix`, until none.""" + needle = f'M9P_TOKEN={prefix}'.encode() + for _ in range(100): + found = [] + for entry in os.listdir('/proc'): + if not entry.isdigit() or int(entry) == os.getpid(): + continue + try: + with open(f'/proc/{entry}/environ', 'rb') as f: + if any(v.startswith(needle) for v in f.read().split(b'\0')): + found.append(int(entry)) + except OSError: + continue + if not found: + return + for pid in found: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + reap_zombies() + time.sleep(0.02) + + +def reap_zombies(): + while True: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return + if pid == 0: + return + + +RUNTIME_DIRS = [] # removed at exit, whatever ended the run + + +class Session: + """One throwaway `pardes --detach`, its dirs, its connection(s).""" + + counter = 0 + + def __init__(self, binary, run_dir, follower): + Session.counter += 1 + self.binary = binary + self.follower_mode = follower + self.token = f'{os.getpid()}-{Session.counter}-{random.SystemRandom().randrange(1 << 30)}' + self.dir = os.path.join(run_dir, f'sess{Session.counter}') + self.home = os.path.join(self.dir, 'home') + self.runtime = tempfile.mkdtemp(prefix='m9p.', dir='/tmp') + RUNTIME_DIRS.append(self.runtime) + self.proc = None + self.wire = None + self.follower = None + self.handles = {} + self.dump_written = False + self.marker = None + self.window_base = 0 + self.start() + + def env(self): + env = {k: v for k, v in os.environ.items() + if not k.startswith('PARDES_') and k not in ('NINE_MOUNT', 'NAMESPACE')} + env.update(HOME=self.home, XDG_RUNTIME_DIR=self.runtime, + XDG_CONFIG_HOME=os.path.join(self.home, 'config'), + XDG_STATE_HOME=os.path.join(self.home, 'state'), + XDG_DATA_HOME=os.path.join(self.home, 'data'), + XDG_CACHE_HOME=os.path.join(self.home, 'cache'), + SHELL='/bin/sh', PARDES_NOTIME='1', M9P_TOKEN=self.token, + PARDES_DUMP=os.path.join(self.home, 'm9p.dump.zon')) + for language in ['RS', 'C', 'GO', 'TS', 'PY', 'ZIG']: + env['PARDES_LSP_' + language] = '' + return env + + def start(self): + os.makedirs(self.home, exist_ok=True) + for name, text in FIXTURES.items(): + with open(os.path.join(self.home, name), 'wb') as f: + f.write(text) + self.stderr = open(os.path.join(self.dir, 'stderr'), 'wb') + self.proc = subprocess.Popen([self.binary, '--detach=m9p', 'alpha.txt'], cwd=self.home, + env=self.env(), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=self.stderr, + start_new_session=True) + self.address = os.path.join(self.runtime, 'pardes-9p-m9p.sock') + self.connect(first=True) + # Two more panes, so one close does not end the session. + for name in (b'utf8.txt\n', b'crlf.txt\n'): + self.wire.write_path('/look', name) + + def connect(self, first=False): + deadline = time.monotonic() + (15 if first else 10) + last = None + while time.monotonic() < deadline: + if self.proc.poll() is not None: + raise Dropped(f'pardes exited {self.proc.returncode} before its socket answered') + if os.path.exists(self.address): + try: + self.wire = Wire(self.address) + break + except OSError as why: + last = why + time.sleep(0.01) + else: + raise Hang(f'9P socket did not answer: {last}') + self.handles = {} + self.marker = None + if self.follower_mode: + self.follower = Follower(self.address) + + def reconnect(self): + self.close_wires() + # A Restore hangs up and serves again: let the old socket go. + time.sleep(0.05) + self.connect() + + def close_wires(self): + if self.follower is not None: + self.follower.stop() + self.follower = None + if self.wire is not None: + try: + self.wire.socket.close() + except OSError: + pass + self.wire = None + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def crash_text(self): + """The crash file's text (src/crash.zig) and stderr's tail.""" + out = [] + for base, _, files in os.walk(self.home): + if 'crashes' in files: + with open(os.path.join(base, 'crashes'), 'rb') as f: + out.append(f.read().decode(errors='replace')) + try: + self.stderr.flush() + with open(os.path.join(self.dir, 'stderr'), 'rb') as f: + text = f.read() + # The panic line leads; the frames under it can run long. + tail = (text if len(text) <= 6000 else text[:3000] + b'\n...\n' + text[-2000:]).decode(errors='replace') + if tail.strip(): + out.append('stderr: ' + tail) + except OSError: + pass + return '\n'.join(out) + + def has_crash_file(self): + for base, _, files in os.walk(os.path.join(self.home, 'config')): + if 'crashes' in files: + return True + return False + + def close(self, keep=False): + """Kill pardes and everything it started; verify nothing survives.""" + self.close_wires() + if self.proc is not None and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + survivors = [] + for _ in range(100): + reap_zombies() + pids = set(tokened(self.token)) | set(descendants(os.getpid())) + pids.discard(os.getpid()) + if not pids: + break + for pid in pids: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + survivors = pids + time.sleep(0.02) + else: + raise RuntimeError(f'processes survived teardown: {sorted(survivors)}') + reap_zombies() + self.stderr.close() + shutil.rmtree(self.runtime, ignore_errors=True) + if not keep: + shutil.rmtree(self.dir, ignore_errors=True) + + +class Follower: + """A second connection holding `follow new` on /log, reading records.""" + + def __init__(self, address): + self.lines = [] + self.cond = threading.Condition() + self.done = False + self.wire = Wire(address) + fid, err = self.wire.walk_names([b'log']) + ok, body = self.wire.call(TOPEN, struct.pack('<IB', fid, ORDWR)) + ok, body = self.wire.call(TWRITE, struct.pack('<IQI', fid, 0, 11) + b'follow new\n') + if not ok: + raise OSError(f'follow new: {body}') + self.fid = fid + self.thread = threading.Thread(target=self.run, daemon=True) + self.thread.start() + + def run(self): + offset = 0 + try: + while not self.done: + tag = self.wire._send(TREAD, struct.pack('<IQI', self.fid, offset, 8192)) + while True: + self.wire.socket.settimeout(None) + kind, rtag, body = self.wire._recv(None) + if rtag == tag: + break + if kind == RERROR: + break + chunk = body[4:] + if not chunk: + break + offset += len(chunk) + with self.cond: + self.lines.extend(chunk.decode(errors='replace').splitlines()) + self.cond.notify_all() + except (Hang, Dropped, OSError, struct.error): + pass + with self.cond: + self.done = True + self.cond.notify_all() + + def wait_for(self, needle, timeout=TIMEOUT): + deadline = time.monotonic() + timeout + with self.cond: + while True: + for i in range(len(self.lines) - 1, -1, -1): + if needle in self.lines[i]: + return i + left = deadline - time.monotonic() + if left <= 0 or self.done: + return None + self.cond.wait(left) + + def stop(self): + self.done = True + try: + self.wire.socket.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.wire.socket.close() + self.thread.join(timeout=2) + + +FIXTURES = { + 'alpha.txt': b'alpha beta gamma\nsecond line\n\nthird after a blank\n', + 'utf8.txt': 'naïve café 日本語 é \U0001f600\nline two\n'.encode(), + 'crlf.txt': b'one\r\ntwo\r\nthree\r\n', + 'bad.txt': b'hello \xff\xfe world\nlatin-1 caf\xe9\ntruncated \xe6\x97 here\n', + 'empty.txt': b'', + 'noeol.txt': b'no newline at the end', +} + + +# -------------------------------------------------------------------------- +# Results, registries. + +class Result: + def __init__(self): + self.requests = [] # (kind, path, ok, why) + self.hung_up = False # the op expected the server to hang up (Restore) + self.note = '' + + def add(self, kind, path, ok, why=None): + self.requests.append((kind, path, ok, why)) + + def writes(self): + return [r for r in self.requests if r[0] in ('write', 'clunk-write', 'open-new', 'trunc')] + + +GENERATORS = [] # (weight, fn) +OPS = {} # name -> fn(sess, op) -> Result +INVARIANTS = [] # fn(ctx, res) -> str | None + + +def generator(weight): + def wrap(fn): + GENERATORS.append((weight, fn)) + return fn + return wrap + + +def op_kind(name): + def wrap(fn): + OPS[name] = fn + return fn + return wrap + + +def invariant(fn): + INVARIANTS.append(fn) + return fn + + +def host_path(path): + """Guard: nothing mutating ever touches the served host tree.""" + parts = split(path) + return bool(parts) and parts[0] in (b'os', b'src') + + +def is_held(path): + return (path.rstrip('/') == '/log' or path.endswith('/event') or + path.endswith('/pty/data') or path.endswith('/pty/run')) + + +def slow(data): + return len(data) > 65536 or any(w in data for w in (b'Grep', b'Find', b'Dump', b'Restore', b'Tty', b'Edit')) + + +# -------------------------------------------------------------------------- +# Op kinds: each takes a concrete op (what the ops log holds) and runs it. + +def dec(s): + return s.encode('latin-1') + + +def enc(b): + return b.decode('latin-1') + + +@op_kind('write') +def op_write(sess, op): + """open(path, mode); write data in the given chunk sizes; clunk.""" + res = Result() + path, data, mode = op['path'], dec(op['data']), op.get('mode', OWRITE) + if host_path(path): + res.note = 'refused by guard: host tree' + return res + w = sess.wire + fid, err = w.walk_names(split(path)) + if fid is None: + res.add('walk', path, False, err) + return res + timeout = SLOW_TIMEOUT if slow(data) else TIMEOUT + ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout) + res.add(open_kind(path, mode), path, ok, None if ok else body) + sizes = chunking(op.get('chunks', 'whole'), len(data), w.msize - 23) + opened = ok + sent = data + if ok: + offset = 0 + took = [] + for size in sizes: + piece = data[offset:offset + size] + ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout) + # A write on a fid opened for reading is 9P misuse, not a write + # the tree refused: the rule does not name it. + res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body) + offset += size + sent = data[:offset] + if ok: + took.append(size) + if not ok and op.get('stop_on_error', True): + break + # Misses are allowed the Twrites that succeeded: a line written a + # byte a Twrite is a look a byte, each its own write. + allowances(res, path, data[:sum(took)], took, w.msize - 24) + ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout) + res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body) + # What the open holds when it closes: the last line written, if unended. + if opened and mode & 3 and sent and not sent.endswith(b'\n'): + res.add('close-runs', path, ok) + return res + + +def served_lines(data, sizes, room): + """The lines a lines file runs for `data` written in pieces of `sizes` + (fs.md, tree.zig write): each ended line, and the unended tail of a piece + shorter than a Twrite holds (`room`, msize less 24), which is whole in + itself, as acme takes each write; a piece that fills its Twrite may go + on, and its tail waits for the next. A line written a byte a Twrite is + so as many lines as bytes.""" + lines, pending, at = [], b'', 0 + for size in sizes: + piece = data[at:at + size] + at += size + pending += piece + *done, pending = pending.split(b'\n') + lines += done + if pending and len(piece) < room: + lines.append(pending) + pending = b'' + if pending: + lines.append(pending) + return lines + + +def allowances(res, path, data, sizes, room): + """Errs a successful write may log (fs.md): a look that finds nothing logs + one, per line, whether written to look, written back as an event record + (acme's xfid.c:842) or run as the Look builtin; an Edit block whose text + never ended runs, and fails, at the close.""" + base = path.rsplit('/', 1)[-1] + lines = [line for line in served_lines(data, sizes, room) if line.strip()] + if base in ('look', 'event'): + n = len(lines) + elif base in ('exec', 'ctl', 'tagexec'): + n = sum(1 for line in lines if line.split()[:1] == [b'Look']) + if b'Edit' in data and len(lines) > 1: + n += 1 + else: + return + if n: + res.add('allow', path, True, n) + + +def open_kind(path, mode): + """An open the rule names: pane/new's (it makes a pane) and a truncation + ('every write or truncation the tree refused'); others are 'open'.""" + if path.rstrip('/') == '/pane/new' and not mode & 3: + return 'open-new' + if mode & OTRUNC and mode & 3: + return 'trunc' + return 'open' + + +def check_served_lines(): + assert served_lines(b'a\nb\n', [4], 8000) == [b'a', b'b'] + assert served_lines(b'ab\n', [1, 1, 1], 8000) == [b'a', b'b', b''] + assert served_lines(b'abcd', [2, 2], 2) == [b'abcd'] + assert served_lines(b'ab\ncd', [3, 2], 8000) == [b'ab', b'cd'] + + +def chunking(spec, n, cap): + if n == 0: + return [0] + if spec == 'bytes': + return [1] * n + if isinstance(spec, list): + out, total = [], 0 + for s in spec: + s = max(1, min(s, cap, n - total)) + out.append(s) + total += s + if total >= n: + return out + while total < n: + s = min(cap, n - total) + out.append(s) + total += s + return out + out, total = [], 0 + while total < n: + s = min(cap, n - total) + out.append(s) + total += s + return out + + +@op_kind('read') +def op_read(sess, op): + """open(path, OREAD); read from offset in chunks of count until EOF or cap.""" + res = Result() + path, count, offset = op['path'], op.get('count', 8192), op.get('offset', 0) + w = sess.wire + fid, err = w.walk_names(split(path)) + if fid is None: + res.add('walk', path, False, err) + return res + mode = op.get('mode', OREAD) + ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode)) + res.add(open_kind(path, mode), path, ok, None if ok else body) + if ok: + for _ in range(op.get('reads', 4)): + payload = struct.pack('<IQI', fid, offset, min(count, w.msize - 11)) + if is_held(path): + ok, body = w.call_held(TREAD, payload) + else: + ok, body = w.call(TREAD, payload) + res.add('read', path, ok, body if ok is False else None) + if not ok or len(body) <= 4: + break + offset += len(body) - 4 + ok, body = w.call(TCLUNK, struct.pack('<I', fid)) + res.add('clunk', path, ok, None if ok else body) + return res + + +@op_kind('stat') +def op_stat(sess, op): + res = Result() + fid, err = sess.wire.walk_names(split(op['path'])) + res.add('walk', op['path'], fid is not None, err) + if fid is not None: + ok, body = sess.wire.call(TSTAT, struct.pack('<I', fid)) + res.add('stat', op['path'], ok, None if ok else body) + sess.wire.clunk(fid) + return res + + +@op_kind('remove') +def op_remove(sess, op): + res = Result() + if host_path(op['path']): + res.note = 'refused by guard: host tree' + return res + fid, err = sess.wire.walk_names(split(op['path'])) + res.add('walk', op['path'], fid is not None, err) + if fid is not None: + ok, body = sess.wire.call(TREMOVE, struct.pack('<I', fid), SLOW_TIMEOUT) + res.add('remove', op['path'], ok, None if ok else body) + return res + + +@op_kind('create') +def op_create(sess, op): + res = Result() + if host_path(op['path']): + res.note = 'refused by guard: host tree' + return res + fid, err = sess.wire.walk_names(split(op['path'])) + res.add('walk', op['path'], fid is not None, err) + if fid is not None: + ok, body = sess.wire.call(TCREATE, struct.pack('<I', fid) + string(dec(op['name'])) + + struct.pack('<IB', op.get('perm', 0o666), op.get('mode', OREAD))) + res.add('create', op['path'] + '/' + op['name'], ok, None if ok else body) + sess.wire.clunk(fid) + return res + + +@op_kind('hold') +def op_hold(sess, op): + """Open and keep a fid across later ops as handle op['h'].""" + res = Result() + path, mode = op['path'], op.get('mode', OREAD) + if host_path(path) and mode & 3: + res.note = 'refused by guard: host tree' + return res + fid, err = sess.wire.walk_names(split(path)) + if fid is None: + res.add('walk', path, False, err) + return res + ok, body = sess.wire.call(TOPEN, struct.pack('<IB', fid, mode)) + res.add(open_kind(path, mode), path, ok, None if ok else body) + if ok: + sess.handles[op['h']] = [fid, path, mode, [0], b''] + else: + sess.wire.clunk(fid) + return res + + +@op_kind('hwrite') +def op_hwrite(sess, op): + res = Result() + h = sess.handles.get(op['h']) + if h is None: + res.note = 'no such handle' + return res + fid, path, mode, off, _ = h + data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite + h[4] = data + ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data, + SLOW_TIMEOUT if slow(data) else TIMEOUT) + res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body) + if ok: + allowances(res, path, data, [len(data)], sess.wire.msize - 24) + if ok: + off[0] += len(data) + return res + + +@op_kind('hread') +def op_hread(sess, op): + res = Result() + h = sess.handles.get(op['h']) + if h is None: + res.note = 'no such handle' + return res + fid, path, _, off, _ = h + offset = op['offset'] if op.get('offset') is not None else off[0] + payload = struct.pack('<IQI', fid, offset, min(op.get('count', 8192), sess.wire.msize - 11)) + ok, body = sess.wire.call_held(TREAD, payload) if is_held(path) else sess.wire.call(TREAD, payload) + res.add('read', path, ok, body if ok is False else None) + if ok: + off[0] = offset + len(body) - 4 + return res + + +@op_kind('clunk') +def op_clunk(sess, op): + res = Result() + h = sess.handles.pop(op['h'], None) + if h is None: + res.note = 'no such handle' + return res + ok, body = sess.wire.call(TCLUNK, struct.pack('<I', h[0])) + res.add('clunk-write' if h[2] & 3 else 'clunk', h[1], ok, None if ok else body) + if h[2] & 3 and h[4] and not h[4].endswith(b'\n'): + res.add('close-runs', h[1], ok) + return res + + +@op_kind('restore') +def op_restore(sess, op): + """Write Restore [f] to /ctl: refused with an Rerror, or the server + hangs up and serves the dump's panes again (fs.md: a Restore hangs its + connection up).""" + res = Result() + data = dec(op['data']) + try: + ok, why = sess.wire.write_path('/ctl', data) + res.add('write', '/ctl', ok, why) + if ok: + # Taken: the hangup follows the reply. + try: + sess.wire.call(TSTAT, struct.pack('<I', 1), 2.0) + except (Dropped, Hang): + pass + res.hung_up = True + except Dropped: + res.add('write', '/ctl', True, None) + res.hung_up = True + if res.hung_up: + sess.reconnect() + return res + + +@op_kind('host') +def op_host(sess, op): + """A change on disk under the session's HOME: write or delete a file.""" + res = Result() + name = op['name'] + assert '/' not in name and name not in ('.', '..') + path = os.path.join(sess.home, name) + if op['action'] == 'rm': + try: + os.remove(path) + except OSError: + pass + else: + with open(path, 'wb') as f: + f.write(dec(op['data'])) + return res + + +@op_kind('seq') +def op_seq(sess, op): + """Several ops as one step (e.g. clearing the window).""" + res = Result() + for sub in op['ops']: + r = OPS[sub['op']](sess, sub) + res.requests.extend(r.requests) + res.hung_up |= r.hung_up + return res + + +# -------------------------------------------------------------------------- +# Input: data pools and generators (deliberately simple; swap freely). + +SAFE = (b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ' + b'-_=+,:#^@%()\n\r\t\0') +UNSAFE = set(b'/~$`;|&<>.\\*?![]{}\'"') + + +def shell_safe(data): + """No path or shell metacharacters, whatever else it holds.""" + return bytes(b for b in data if b not in UNSAFE) + + +RUNES = ['é', 'é', '日本', '\U0001f600', 'Δ', ' ', '', '\u0080', '\u009f'] +ODD = [b'', b'\n', b'\r\n', b'\0', b'\xff', b'\xfe\xff', b'\xe6\x97', b'\xc0\x80', b'\x1b[2J', b'\x7f', b'\t', + b'\n\n\n', b' ', b' \n'] +WORDS = [b'alpha', b'beta', b'foo', b'bar', b'x', b'hello', b'zz', b'0', b'1', b'-1', b'99999999999', + b'on', b'off', b'follow', b'follow new', b'lock', b'unlock', b'clean', b'dirty', b'show', + b'dot=addr', b'addr=dot', b'limit=addr', b'nomark', b'mark', b'get', b'put', b'del', b'delete', + b'name', b'cleartag', b'answer', b'answer -', b'size 80 24', b'size 20 6', b'size 5000 5000', + b'winsize 80 24', b'winsize 0 0', b'sig INT', b'sig TERM', b'sig HUP', b'sig KILL', b'sig BOGUS', + b'exec', b'Placement acme', b'Placement pardes', b'Wrap on', b'Wrap off'] +SHELL_LINES = [b'true', b'false', b'echo hi', b'echo m9p', b'sleep 1', b'ls', b'cat nonexist', + b'printf abc', b'exit 3', b'seq 1 2000', b'yes | head -n 3000', b'date', b'pwd', b'env | wc'] +DENY = {'Exit', 'Attach', 'Detach', 'Mount', 'Unmount', 'Tty9p', 'ClipYank', 'ClipYankMain', 'ClipPaste', + 'ClipPasteBefore', 'ClipReplace', 'Shell', 'ThemeFile', 'EffectCode', 'Help', 'Tutor', 'Changelog', + 'Rename', 'Hover', 'CodeAction', 'SelectRefs', 'Symbols', 'Diagnostics', 'WsDiagnostics', 'Callers', + 'Callees', 'Supertypes', 'Subtypes', 'WsSymbols', 'Lspinfo', 'Lspwhy', 'Restore'} +BUILTINS = ['Look', 'Exec', 'Kill', 'Dump', 'Msg', 'NextColor', 'Themes', 'DumpThemes', 'TreeContext', 'PdfFit', + 'PdfTint', 'PdfSections', 'Petscii', 'Palette', 'Ascii', 'Save', 'New', 'Newcol', 'Del', 'Filter', + 'Mode', 'Togglettymode', 'Edit', 'Undo', 'Redo', 'Collapse', 'Delcol', 'DelAbove', 'DelBelow', 'Tty', + 'Repl', 'Joincol', 'Config', 'LocationsConfig', 'Messages', 'Mini', 'Find', 'Grep', 'Left', 'Down', + 'Up', 'Right', 'Back', 'Forward', 'Last', 'Recent', 'Jumplist', 'Colors', 'Wrap', 'Tagbottom', 'Debug', + 'FocusTint', 'Verbose', 'Motion', 'InactiveDim', 'DumpDir', 'Theme', 'Placement', 'BootShell', + 'LookWord', 'Get', 'Put', 'Undo', 'Redo', 'Zerox', 'Snarf', 'Paste', 'Cut', 'Sort', 'Font', 'Local'] +PANE_FILES = ['name', 'body', 'tag', 'ctl', 'addr', 'dot', 'limit', 'data', 'xdata', 'sel', 'dirty', 'mark', + 'scroll', 'errors', 'event', 'look', 'exec', 'tagexec', 'pty', 'pty/run', 'pty/data', 'pty/ctl', + 'pty/status'] +ROOT_FILES = ['README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent', 'look', 'exec', 'log', 'screen', + 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'pane/new', 'col', ''] +COL_FILES = ['tag', 'ctl', 'exec'] +WRITABLE = ['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'body', 'data', 'xdata', 'addr', 'dot', 'limit', + 'name', 'sel', 'dirty', 'mark', 'scroll', 'errors', 'event', 'pty/run', 'pty/data', 'pty/ctl', 'log'] + + +class Ctx: + """What the generators and invariants see.""" + + def __init__(self, seed): + self.seed = seed + self.rng = random.Random(seed) + self.sess = None + self.step = 0 + self.serials = [] + self.cols = [] + self.gone = [] # serials seen and since closed (stale) + self.next_h = 0 + self.last_index = b'' + self.verbose = False + + +def pick_serial(ctx, rng): + """A live pane as a token, {pN}: the Nth serial of /index (mod its + length) when the op runs, so a shrunk replay still names a pane that + exists; or a stale or impossible serial, literally.""" + r = rng.random() + if ctx.serials and r < 0.85: + return '{p%d}' % rng.randrange(64) + if ctx.gone and r < 0.95: + return rng.choice(ctx.gone) + return rng.choice([0, 1, 999, 4294967295, 4294967296, -1]) + + +def pick_col(ctx, rng): + if ctx.cols and rng.random() < 0.85: + return '{c%d}' % rng.randrange(16) + return rng.choice([0, 99, 4294967295]) + + +def odd_bytes(rng, n): + out = bytearray() + while len(out) < n: + r = rng.random() + if r < 0.5: + out += rng.choice(WORDS) + elif r < 0.7: + out += rng.choice(RUNES).encode() + elif r < 0.9: + out += rng.choice(ODD) + else: + out.append(rng.randrange(256)) + if rng.random() < 0.5: + out += b' ' + return bytes(out[:n]) + + +def payload(rng, maxlen=64): + """Plain or odd bytes, sometimes huge, with or without a newline.""" + r = rng.random() + if r < 0.35: + data = rng.choice(WORDS) + elif r < 0.55: + data = odd_bytes(rng, rng.randrange(0, maxlen)) + elif r < 0.62: + data = rng.choice(ODD) + elif r < 0.66: + data = (rng.choice(WORDS) + b' ') * rng.randrange(100, 20000) # huge + elif r < 0.7: + data = b'x' * rng.choice([255, 256, 1023, 1024, 1025, 65535, 65536, 70000]) + else: + data = rng.choice(WORDS) + b' ' + odd_bytes(rng, rng.randrange(0, 12)) + if rng.random() < 0.6 and not data.endswith(b'\n'): + data += rng.choice([b'\n', b'\n', b'\r\n', b'\n\n']) + return shell_safe(data) + + +def weird_name(rng): + r = rng.random() + if r < 0.3: + return 'x' * rng.choice([255, 256, 300, 1000]) + if r < 0.5: + return rng.choice(['..', '.', '', ' ', 'new', 'NEW', '01', '1 ', '\n', 'a\0b']) + if r < 0.7: + return enc(rng.choice(RUNES).encode()) + return enc(bytes(rng.randrange(1, 256) for _ in range(rng.randrange(1, 20))).replace(b'/', b'_')) + + +def random_path(ctx, rng, writable=False): + r = rng.random() + if r < 0.6: + f = rng.choice(WRITABLE if writable else PANE_FILES) + return f'/pane/{pick_serial(ctx, rng)}/{f}' + if r < 0.75: + f = rng.choice(['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'log'] if writable else ROOT_FILES) + return '/' + f + if r < 0.85: + return f'/col/{pick_col(ctx, rng)}/{rng.choice(COL_FILES)}' + if r < 0.93: + return f'/pane/{pick_serial(ctx, rng)}/{weird_name(rng)}' + return '/' + '/'.join(weird_name(rng) for _ in range(rng.randrange(1, 4))) + + +def data_for(ctx, rng, path): + """Payload a file is likely to take, often mutated.""" + base = path.rsplit('/', 1)[-1] + r = rng.random() + if r < 0.25: + return payload(rng) + if base in ('addr', 'dot', 'limit', 'sel'): + return address(rng) + if base == 'ctl' and path.endswith('pty/ctl'): + return rng.choice([b'sig INT\n', b'sig TERM\n', b'sig HUP\n', b'sig QUIT\n', b'sig KILL\n', + b'winsize 80 24\n', b'winsize 1 1\n', b'winsize 99999 3\n', b'exec\n', b'sig\n']) + if base in ('ctl', 'exec', 'tagexec'): + return builtin_line(ctx, rng, path) + if base == 'run': + return shell_safe(rng.choice(SHELL_LINES)) + b'\n' + if base == 'data' and '/pty/' in path: + return shell_safe(rng.choice(SHELL_LINES)) + rng.choice([b'\r', b'\n', b'', b'\x03', b'\x04']) + if base == 'event': + return event_record(rng) + if base == 'focus': + return str(pick_serial(ctx, rng)).encode() + rng.choice([b'\n', b'']) + if base == 'look': + return shell_safe(rng.choice([b'alpha', b'alpha.txt', b'alpha.txt:2', b'utf8.txt:#3', b'nothere', + b'bad.txt', b'crlf.txt:1', b'gamma', b'beta'])) + b'\n' + if base in ('dirty', 'mark', 'scroll'): + return rng.choice([b'0', b'1', b'0\n', b'1\n', b'2\n', b'', b'yes\n']) + if base == 'log': + return rng.choice([b'follow\n', b'follow new\n', b'follow', b'nope\n']) + return payload(rng, 200) + + +def mutate(rng, data, rounds=None): + data = bytearray(data) + for _ in range(rounds or rng.randrange(1, 4)): + if not data: + data += rng.choice(WORDS) + continue + i = rng.randrange(len(data)) + r = rng.random() + if r < 0.3: + del data[i] + elif r < 0.55: + data[i:i] = shell_safe(rng.choice(ODD) or b'\0') + elif r < 0.75: + j = rng.randrange(i, len(data) + 1) + data[i:i] = data[i:j] + else: + data[i:i] = rng.choice(RUNES).encode() + return bytes(data) + + +def builtin_line(ctx, rng, path): + word = rng.choice(BUILTINS).encode() + r = rng.random() + if r < 0.4: + line = word + elif r < 0.7: + line = word + b' ' + rng.choice(WORDS) + elif r < 0.8: + line = b'Edit ' + edit_command(rng) + return line + b'\n' # Edit text keeps its / delimiters + else: + line = mutate(rng, word + b' ' + rng.choice(WORDS)) + if rng.random() < 0.1: + line = shell_safe(rng.choice(SHELL_LINES)) + if rng.random() < 0.8: + line += b'\n' + return shell_safe(line) + + +# sam's commands without the ones that reach files or shells. +EDIT_TEXT = b'abcdghijklmopqstuvxyz0123456789 ACEGHIJKLMNOPQRSTUVWXYZ_-' + + +def edit_text(rng): + s = bytes(rng.choice(EDIT_TEXT) for _ in range(rng.randrange(0, 8))) + if rng.random() < 0.3: + s += rng.choice(RUNES).encode() + return s + + +def regex(rng): + parts = [edit_text(rng) or b'a'] + for _ in range(rng.randrange(0, 4)): + parts.append(rng.choice([b'(a|b)', b'^', b'$', b'[a-z]', b'[^ ]', b'.*', b'\\n', + '[é日]'.encode(), b'x+', b'(', b'[', b'\\', b'a**', + b'(((a)))', b'[z-a]', (b'a|' * rng.randrange(1, 300)) + b'b'])) + parts.append(edit_text(rng)) + return b''.join(parts) + + +def address(rng): + """sam addresses: valid, near-valid and garbage.""" + simple = [b'0', b'$', b'.', b',', b';', b'#0', b'#3', b'#99999', b'1', b'2', b'3:2', b'1:1', b'0:0', + b'#1,#2', b'1,$', b'2,1', b'-', b'+', b'-1', b'+2', b'#-1', b'99999', b'1:99', b'?a?'] + r = rng.random() + if r < 0.4: + a = rng.choice(simple) + elif r < 0.7: + a = b'/' + regex(rng) + b'/' + elif r < 0.85: + a = rng.choice(simple) + rng.choice([b',', b';', b'+', b'-']) + rng.choice(simple + [b'/a/']) + else: + a = mutate(rng, rng.choice(simple) + b'/' + regex(rng) + b'/') + return a + rng.choice([b'', b'\n']) + + +def edit_command(rng): + t = lambda: edit_text(rng) # noqa: E731 + cmds = [lambda: b',x/' + regex(rng) + b'/c/' + t() + b'/', + lambda: b's/' + regex(rng) + b'/' + t() + b'/g', + lambda: b'a/' + t() + b'/', + lambda: b'i/' + t() + b'/', + lambda: b'c/' + t() + b'/', + lambda: b'd', + lambda: b',d', + lambda: b',y/' + regex(rng) + b'/d', + lambda: b',x/' + regex(rng) + b'/g/' + regex(rng) + b'/d', + lambda: b',x/' + regex(rng) + b'/v/' + regex(rng) + b'/c/' + t() + b'/', + lambda: b'1,2m$', + lambda: b'1t0', + lambda: b'k', + lambda: b'p', + lambda: b'=', + lambda: b'u', + lambda: b'{\na/' + t() + b'/\ni/' + t() + b'/\n}', + lambda: b',x/\\n/a/' + t() + b'/', + lambda: b'0,$s/' + regex(rng) + b'/&&/g'] + cmd = rng.choice(cmds)() + if rng.random() < 0.4: + cmd = address(rng).rstrip(b'\n') + cmd + if rng.random() < 0.2: + cmd = mutate(rng, cmd) + # Mutation never introduces a banned command letter or shell char. + cmd = bytes(b for b in cmd if b not in b'werfbBDn!<>|') + return cmd + + +def event_record(rng): + origin = rng.choice(b'EFKMZ') + action = rng.choice(b'xXlLiIdDZ') + q0, q1 = rng.choice([0, 1, 5, 10]), rng.choice([0, 2, 7, 1 << 40, -1]) + rec = bytes([origin, action]) + f'{q0} {q1} {rng.choice([0, 1, 2, 8])} '.encode() + text = shell_safe(edit_text(rng)) + rec += str(len(text)).encode() + b' ' + text + b'\n' + if rng.random() < 0.3: + rec = mutate(rng, rec) + return shell_safe(rec) + + +def new_handle(ctx): + ctx.next_h += 1 + return f'h{ctx.next_h}' + + +@generator(30) +def gen_write(ctx, rng): + path = random_path(ctx, rng, writable=True) + data = data_for(ctx, rng, path) + mode = rng.choice([OWRITE, OWRITE, ORDWR, OWRITE | OTRUNC, ORDWR | OTRUNC, OREAD]) + r = rng.random() + chunks = 'bytes' if r < 0.08 and len(data) <= 64 else ( + [rng.randrange(1, 16) for _ in range(4)] if r < 0.15 else 'whole') + op = {'op': 'write', 'path': path, 'data': enc(data), 'mode': mode, 'chunks': chunks} + if rng.random() < 0.05: + op['offset'] = rng.choice([1, 100, 1 << 40]) + return op + + +@generator(14) +def gen_read(ctx, rng): + path = random_path(ctx, rng) + return {'op': 'read', 'path': path, 'count': rng.choice([1, 2, 7, 64, 200, 8192, 1 << 20]), + 'offset': rng.choice([0, 0, 0, 1, 3, 100, 1 << 33]), 'reads': rng.choice([1, 2, 8])} + + +@generator(4) +def gen_stat(ctx, rng): + path = random_path(ctx, rng) + if rng.random() < 0.2: + path = '/os/' + rng.choice(['etc', 'etc/hosts', 'nonexistent', 'x' * 300]) + return {'op': 'stat', 'path': path} + + +@generator(5) +def gen_newpane(ctx, rng): + if rng.random() < 0.7: + return {'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1} + return {'op': 'hold', 'h': new_handle(ctx), 'path': '/pane/new', 'mode': rng.choice([OREAD, ORDWR])} + + +@generator(2) +def gen_remove(ctx, rng): + r = rng.random() + if r < 0.6: + path = f'/pane/{pick_serial(ctx, rng)}' + elif r < 0.85: + path = f'/col/{pick_col(ctx, rng)}' + else: + path = random_path(ctx, rng) + return {'op': 'remove', 'path': path} + + +@generator(2) +def gen_create(ctx, rng): + where = rng.choice(['/col', '/pane', '/', f'/pane/{pick_serial(ctx, rng)}']) + return {'op': 'create', 'path': where, 'name': weird_name(rng) if rng.random() < 0.5 else 'new', + 'perm': rng.choice([0o666, DMDIR | 0o777]), 'mode': rng.choice([OREAD, OWRITE])} + + +@generator(2) +def gen_clear_window(ctx, rng): + """Down to the empty window (every pane closed), sometimes rebuilt.""" + keep = rng.choice(ctx.serials) if ctx.serials and rng.random() < 0.8 else None + ops = [{'op': 'remove', 'path': f'/pane/{s}'} for s in ctx.serials if s != keep] + if rng.random() < 0.5: + ops += [{'op': 'remove', 'path': f'/col/{c}'} for c in ctx.cols] + if rng.random() < 0.6: + ops.append({'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1}) + return {'op': 'seq', 'ops': ops} + + +@generator(6) +def gen_hold(ctx, rng): + held = [h for h in (ctx.sess.handles if ctx.sess else {})] + r = rng.random() + if not held or r < 0.35: + path = random_path(ctx, rng) + if rng.random() < 0.3: + path = rng.choice(['/log', f'/pane/{pick_serial(ctx, rng)}/event', '/screen', '/index', + f'/pane/{pick_serial(ctx, rng)}/ctl']) + return {'op': 'hold', 'h': new_handle(ctx), 'path': path, 'mode': rng.choice([OREAD, ORDWR, OWRITE])} + h = rng.choice(held) + path = ctx.sess.handles[h][1] + if r < 0.6: + return {'op': 'hwrite', 'h': h, 'data': enc(data_for(ctx, rng, path))} + if r < 0.85: + return {'op': 'hread', 'h': h, 'count': rng.choice([1, 16, 8192]), + 'offset': rng.choice([None, None, 0, 5])} + return {'op': 'clunk', 'h': h} + + +@generator(6) +def gen_shell(ctx, rng): + s = pick_serial(ctx, rng) + line = shell_safe(rng.choice(SHELL_LINES)) + r = rng.random() + if r < 0.25: + return {'op': 'write', 'path': f'/pane/{s}/ctl', 'data': enc(b'Tty\n'), 'mode': OWRITE} + if r < 0.45: + return {'op': 'write', 'path': f'/pane/{s}/pty/run', 'data': enc(line + b'\n'), 'mode': ORDWR} + if r < 0.6: + return {'op': 'write', 'path': f'/pane/{s}/pty/data', 'data': enc(line + b'\r'), 'mode': OWRITE} + if r < 0.75: + return {'op': 'write', 'path': f'/pane/{s}/pty/ctl', + 'data': enc(rng.choice([b'sig INT\n', b'sig TERM\n', b'sig KILL\n', b'sig HUP\n'])), 'mode': OWRITE} + return {'op': 'write', 'path': rng.choice(['/exec', f'/pane/{s}/exec']), 'data': enc(line + b'\n'), + 'mode': OWRITE} + + +@generator(6) +def gen_edit(ctx, rng): + s = pick_serial(ctx, rng) + path = rng.choice([f'/pane/{s}/ctl', f'/pane/{s}/exec', '/exec']) + return {'op': 'write', 'path': path, 'data': enc(b'Edit ' + edit_command(rng) + b'\n'), 'mode': OWRITE} + + +@generator(8) +def gen_addr(ctx, rng): + s = pick_serial(ctx, rng) + ops = [{'op': 'write', 'path': f'/pane/{s}/addr', 'data': enc(address(rng)), 'mode': OWRITE}] + r = rng.random() + if r < 0.4: + ops.append({'op': 'read', 'path': f'/pane/{s}/{rng.choice(["xdata", "data", "addr", "dot"])}', + 'count': 8192, 'reads': 2}) + elif r < 0.7: + ops.append({'op': 'write', 'path': f'/pane/{s}/{rng.choice(["data", "xdata"])}', + 'data': enc(payload(rng, 20)), 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])}) + return {'op': 'seq', 'ops': ops} + + +@generator(3) +def gen_tag(ctx, rng): + path = rng.choice([f'/pane/{pick_serial(ctx, rng)}/tag', '/tag', f'/col/{pick_col(ctx, rng)}/tag']) + return {'op': 'write', 'path': path, 'data': enc(payload(rng, 40)), + 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])} + + +@generator(3) +def gen_event(ctx, rng): + s = pick_serial(ctx, rng) + return {'op': 'hold', 'h': new_handle(ctx), 'path': f'/pane/{s}/event', 'mode': ORDWR} + + +@generator(2) +def gen_dump_restore(ctx, rng): + r = rng.random() + if r < 0.5: + return {'op': 'write', 'path': '/ctl', 'data': enc(rng.choice([b'Dump\n', b'Dump', b'Dump m9pdump\n'])), + 'mode': OWRITE} + return {'op': 'restore', 'data': enc(rng.choice([b'Restore\n', b'Restore\n', b'Restore m9pdump\n', + b'Restore nothere\n']))} + + +@generator(3) +def gen_host(ctx, rng): + name = rng.choice(list(FIXTURES) + ['fresh.txt']) + if rng.random() < 0.5: + return {'op': 'host', 'action': 'rm', 'name': name} + return {'op': 'host', 'action': 'write', 'name': name, 'data': enc(payload(rng, 100))} + + +@generator(3) +def gen_look_fixture(ctx, rng): + name = rng.choice(list(FIXTURES) + ['fresh.txt']) + suffix = rng.choice([b'', b':2', b':#4', b':1:3', b':/beta/']) + return {'op': 'write', 'path': rng.choice(['/look', f'/pane/{pick_serial(ctx, rng)}/look']), + 'data': enc(name.encode() + suffix + b'\n'), 'mode': OWRITE} + + +# -------------------------------------------------------------------------- +# Log window: the records one step caused, between two unique markers. + +MARK = re.compile(r'unknown control message "(m9p-mark-\d+)"') + + +def write_marker(sess, step): + """An unknown ctl word logs `err - ctl: unknown control message "<word>"`.""" + word = f'm9p-mark-{step}' + ok, why = sess.wire.write_path('/ctl', word.encode() + b'\n') + if ok: + return None, 'marker write was taken' + prev, sess.marker = sess.marker, word + if sess.follower is not None: + end = sess.follower.wait_for(f'"{word}"') + if end is None: + return None, 'marker record never reached the follower' + lines = sess.follower.lines[:end] + start = 0 + if prev is not None: + for i in range(len(lines) - 1, -1, -1): + if f'"{prev}"' in lines[i]: + start = i + 1 + break + else: + return None, None + window = lines[start:] + del sess.follower.lines[:end] + return window, None + lines = sess.wire.read_path('/log').decode(errors='replace').splitlines() + end = next((i for i in range(len(lines) - 1, -1, -1) if f'"{word}"' in lines[i]), None) + if end is None: + return None, 'marker record missing from /log' + if prev is None: + return None, None + start = next((i for i in range(end - 1, -1, -1) if f'"{prev}"' in lines[i]), None) + if start is None: + return None, None # the ring moved past it + return lines[start + 1:end], None + + +COUNT = re.compile(r'^(.*) \(x(\d+)\)$') + + +def occurrences(lines, kind): + """Records of `kind` (err|msg), counting `(xN)` repeats (fs.md: a record said + again is counted; a follower sees each count as a line of its own).""" + out, last_text, last_n = [], None, 0 + for line in lines: + if not line.startswith(kind + ' '): + last_text = None + continue + m = COUNT.match(line) + text, n = (m.group(1), int(m.group(2))) if m else (line, 1) + if text == last_text: + new = n - last_n + else: + new = n + out.extend([text] * max(new, 0)) + last_text, last_n = text, n + return out + + +# -------------------------------------------------------------------------- +# Invariants: small, cheap, each with its doc citation. + +@invariant +def alive(ctx, res): + """Never crashes or panics (src/crash.zig: a panic leaves its crash file).""" + if not ctx.sess.alive(): + return f'pardes died (exit {ctx.sess.proc.returncode})' + if ctx.sess.has_crash_file(): + return 'crash file written: ' + ctx.sess.crash_text()[-2000:] + return None + + +@invariant +def one_failure_rule(ctx, res): + """fs.md 'One rule for what fails': a write fails whenever what it asked + for fails ... and logs its reason exactly once, as `err <serial|-> <file>: + <why>`, with no `msg` for it. What is not a failure: a look that finds + nothing answers nothing and logs one `err`, the write succeeding.""" + if res.window is None or res.hung_up: + return None + writes = res.writes() + # The rule is a write's: a refused open (pane/new's and an OTRUNC one + # too), create or remove logs no err, nor does a write to pane/new + # (`permission denied`: it is only read). + failed = [w for w in writes if w[2] is False and w[0] in ('write', 'clunk-write') + and w[1].rstrip('/') != '/pane/new'] + errs = occurrences(res.window, 'err') + msgs = occurrences(res.window, 'msg') + # Allowed only the Twrites that succeeded (allowances): a failed one + # logs its one err. + allowed = sum(r[3] for r in res.requests if r[0] == 'allow') + # fs.md 'A write of command lines ... what is left when it closes runs at + # the close ... and its failure is in the log alone'. + closes = [r for r in res.requests if r[0] == 'close-runs'] + lo, hi = len(failed), len(failed) + allowed + len(closes) + if not lo <= len(errs) <= hi: + return (f'{len(failed)} failed write(s) logged {len(errs)} err record(s) ' + f'(allowed {lo}..{hi}); failed={[(w[0], w[1], w[3]) for w in failed]} window={res.window}') + if failed and msgs: + return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}' + return None + + +LINE_FILES = ('look', 'exec', 'tagexec', 'ctl') + + +@invariant +def close_runs_only_line_files(ctx, res): + """fs.md: 'A write of command lines -- to look, exec, tagexec, a ctl of the + root, a pane or a column, or a column's exec -- runs each line once it is + whole ... what is left when it closes runs at the close'. Another file + that fails only at its close breaks 'a write fails whenever what it asked + for fails' (or the list is short).""" + if res.window is None: + return None + closes = [r[1] for r in res.requests if r[0] == 'close-runs'] + failed_files = {w[1].rsplit('/', 1)[-1] for w in res.writes() if w[2] is False} + for line in occurrences(res.window, 'err'): + m = re.match(r'err \S+ ([^:]+):', line) + if not m: + continue + name = m.group(1).rsplit('/', 1)[-1] + if name in LINE_FILES or name in failed_files: + continue + for path in closes: + if path.rsplit('/', 1)[-1] == name: + return f'a write to {name} with no newline was answered, then failed at the close: {line}' + return None + + +@invariant +def index_matches_pane_dirs(ctx, res): + """fs.md /index: one line per pane; /pane/<n>/ each pane's directory.""" + w = ctx.sess.wire + first = w.read_path('/index') + fid, err = w.walk_names([b'pane']) + if fid is None: + return f'walking /pane failed: {err}' + ok, _ = w.call(TOPEN, struct.pack('<IB', fid, OREAD)) + names = dir_names(w.read_all(fid)) + w.clunk(fid) + second = w.read_path('/index') + ctx.last_index = second + serials = [] + for line in second.decode(errors='replace').splitlines(): + try: + serials.append(int(line.split()[0])) + except (IndexError, ValueError): + return f'/index line unparsable: {line!r}' + old = set(ctx.serials) + ctx.serials = serials + ctx.gone = sorted(set(ctx.gone) | (old - set(serials)))[-20:] + if first != second: + return None # it changed between the reads; no claim + dirs = sorted(int(n) for n in names if n.isdigit()) + extra = [n for n in names if not n.isdigit() and n != 'new'] + if extra: + return f'/pane lists non-serial entries {extra}' + if sorted(serials) != dirs: + return f'/index serials {sorted(serials)} != /pane dirs {dirs}' + return None + + +LISTINGS = ['/index', '/layout', '/', '/pane', '/col', '/pane/{s}', '/status', '/commands', '/pane/{s}/tag', + '/recent', '/README'] + + +@invariant +def chunked_equals_whole(ctx, res): + """9P read(5): reads at successive offsets see the one file; a listing read + in chunks equals one read whole within the same open.""" + w = ctx.sess.wire + path = LISTINGS[ctx.step % len(LISTINGS)] + if '{s}' in path: + if not ctx.serials: + return None + path = path.format(s=ctx.serials[ctx.step % len(ctx.serials)]) + fid, err = w.walk_names(split(path)) + if fid is None: + return None # gone since + try: + ok, body = w.call(TOPEN, struct.pack('<IB', fid, OREAD)) + if not ok: + return None + whole = w.read_all(fid) + isdir = path in ('/', '/pane', '/col') or path.count('/') == 2 and path.startswith('/pane/') + size = 128 + (ctx.step * 37) % 900 if isdir else 1 + (ctx.step * 7) % 50 + chunked = w.read_all(fid, count=size) + if isdir: + whole_n, chunk_n = dir_names(whole), dir_names(chunked) + if whole_n != chunk_n: + return f'{path}: whole {whole_n} != chunked({size}) {chunk_n}' + elif whole != chunked: + return f'{path}: whole {whole[:200]!r} != chunked({size}) {chunked[:200]!r}' + finally: + w.clunk(fid) + return None + + +# -------------------------------------------------------------------------- +# Runner. + +class Failure(Exception): + def __init__(self, kind, why, step): + super().__init__(f'{kind}: {why}') + self.kind, self.why, self.step = kind, why, step + + def signature(self): + """What makes two failures the same bug: the kind, and the message with + numbers, quoted words and pane paths blurred.""" + why = re.sub(r'"[^"]*"', '""', self.why) + why = re.sub(r"'[^']*'", "''", why) if 'window=' not in why else why + why = re.sub(r'/home/\S*', 'PATH', why) + return self.kind + ':' + re.sub(r'\d+', 'N', why)[:300] + + +def check_death(sess, step, what): + """After an error: did pardes quit cleanly (Ended), or die (Failure)?""" + deadline = time.monotonic() + 1.0 + while sess.alive() and time.monotonic() < deadline: + time.sleep(0.02) + if sess.alive(): + return + if sess.proc.returncode == 0 and not sess.has_crash_file(): + raise Ended(what) + raise Failure('crash', f'{what}: pardes exit {sess.proc.returncode} ' + sess.crash_text()[:3000], step) + + +TOKEN = re.compile(r'\{([pc])(\d+)\}') + + +def resolve(ctx, op): + """Replace {pN}/{cN} in an op's paths with live serials.""" + def sub(m): + live = ctx.serials if m.group(1) == 'p' else ctx.cols + return str(live[int(m.group(2)) % len(live)]) if live else '0' + out = dict(op) + if 'path' in out: + out['path'] = TOKEN.sub(sub, out['path']) + if 'ops' in out: + out['ops'] = [resolve(ctx, o) for o in out['ops']] + if 'data' in out and '/focus' in out.get('path', ''): + out['data'] = TOKEN.sub(sub, out['data']) + return out + + +def run_step(ctx, op): + """Run one op and every invariant. Raises Failure or Ended.""" + sess = ctx.sess + op = resolve(ctx, op) + ctx.last_op = op + what = f'{op["op"]} {op.get("path", "")}'.strip() + try: + res = OPS[op['op']](sess, op) + except Hang as why: + check_death(sess, ctx.step, what) + raise Failure('hang', f'{what}: {why}', ctx.step) + except Dropped as why: + check_death(sess, ctx.step, what) + raise Failure('dropped', f'{what}: {why}', ctx.step) + res.window = None + try: + if not sess.alive(): + check_death(sess, ctx.step, what) + # Liveness: a trivial request answers within the timeout. + ok, _ = sess.wire.call(TSTAT, struct.pack('<I', 1)) + window, why = write_marker(sess, ctx.step) + if why: + raise Failure('log', why, ctx.step) + res.window = None if res.hung_up else window + if ctx.verbose: + print(f' op {ctx.step}: {json.dumps(op)[:300]}') + for r in res.requests: + print(f' {r[0]} {r[1]!r} -> {"ok" if r[2] else ("held" if r[2] is None else "Rerror")}' + f'{": " + str(r[3]) if r[3] else ""}') + for line in window or []: + print(f' log: {line}') + for check in INVARIANTS: + problem = check(ctx, res) + if problem: + raise Failure(check.__name__, problem, ctx.step) + # The column list, for the generators. + fid, err = sess.wire.walk_names([b'col']) + if fid is not None: + sess.wire.call(TOPEN, struct.pack('<IB', fid, OREAD)) + ctx.cols = [int(n) for n in dir_names(sess.wire.read_all(fid)) if n.isdigit()] + sess.wire.clunk(fid) + except Hang as why: + check_death(sess, ctx.step, what) + raise Failure('hang', f'after {what}: {why}', ctx.step) + except Dropped as why: + check_death(sess, ctx.step, what) + raise Failure('dropped', f'after {what}: {why}', ctx.step) + except Failure as f: + # An invariant that failed because pardes was quitting is not one. + check_death(sess, ctx.step, what) + raise f + except OSError as why: + check_death(sess, ctx.step, what) + raise Failure('checker', f'after {what}: {why}', ctx.step) + return res + + +class Runner: + def __init__(self, args): + self.args = args + self.out = args.out + os.makedirs(os.path.join(self.out, 'bugs'), exist_ok=True) + os.makedirs(os.path.join(self.out, 'runs'), exist_ok=True) + + def new_session(self, ctx, follower, run_dir): + if ctx.sess is not None: + ctx.sess.close() + ctx.sess = Session(self.args.binary, run_dir, follower) + ctx.serials, ctx.cols, ctx.gone = [], [], [] + idx = ctx.sess.wire.read_path('/index') + ctx.serials = [int(line.split()[0]) for line in idx.decode(errors='replace').splitlines() if line] + + def execute(self, ops, run_dir, ctx=None, log=None, stop_on_failure=True, verbose=False): + """Run concrete ops (a replay). Returns the first Failure or None.""" + ctx = ctx or Ctx(0) + ctx.verbose = verbose + failure = None + try: + for i, op in enumerate(ops): + ctx.step = i + if op['op'] == 'fresh': + self.new_session(ctx, op.get('follower', False), run_dir) + continue + if ctx.sess is None: + self.new_session(ctx, False, run_dir) + try: + run_step(ctx, op) + except Ended: + ctx.sess.close() + ctx.sess = None + except Failure as f: + failure = f + if verbose: + print(f'step {i}: {f}') + if stop_on_failure: + break + finally: + if ctx.sess is not None: + ctx.sess.close(keep=False) + ctx.sess = None + return failure + + def fuzz(self, seed, steps): + name = f'seed-{seed}' + run_dir = os.path.join(self.out, 'runs', name) + shutil.rmtree(run_dir, ignore_errors=True) + os.makedirs(run_dir) + ctx = Ctx(seed) + rng = ctx.rng + log_path = os.path.join(run_dir, 'ops.jsonl') + segment = [] + found = collections.OrderedDict() + t0 = time.monotonic() + weights = [w for w, _ in GENERATORS] + ended = 0 + with open(log_path, 'w') as log: + log.write(json.dumps({'seed': seed, 'steps': steps, 'binary': self.args.binary}) + '\n') + + def fresh(): + op = {'op': 'fresh', 'follower': rng.random() < 0.5} + log.write(json.dumps(op) + '\n') + segment.clear() + segment.append(op) + self.new_session(ctx, op['follower'], run_dir) + + fresh() + for step in range(steps): + ctx.step = step + gen = rng.choices(GENERATORS, weights)[0][1] + op = gen(ctx, rng) + log.write(json.dumps(op) + '\n') + log.flush() + segment.append(op) + f = None + try: + run_step(ctx, op) + except Ended: + ended += 1 + fresh() + continue + except Failure as failure: + f = failure + except Exception as why: # a harness bug: say so, carry on + import traceback + f = Failure('harness', f'{type(why).__name__}: {why} ' + + traceback.format_exc()[-1500:], step) + if f is not None: + sig = f.signature() + first = sig not in found + found.setdefault(sig, []).append(step) + print(f'[seed {seed} step {step}] FAIL {f.kind}: {f.why[:400]}', flush=True) + if first: + crash = ctx.sess.crash_text() + ctx.sess.close(keep=True) + ctx.sess = None + self.record(seed, step, f, list(segment), crash) + fresh() + if self.args.progress and step and step % self.args.progress == 0: + rate = step / (time.monotonic() - t0) + print(f'[seed {seed}] step {step}/{steps} {rate:.0f} steps/s, {len(found)} distinct failure(s)', + flush=True) + if ctx.sess is not None: + ctx.sess.close() + dt = time.monotonic() - t0 + print(f'[seed {seed}] {steps} steps in {dt:.0f}s ({steps / dt:.0f}/s), {ended} clean quit(s); ' + f'ops log {log_path}') + for sig, at in found.items(): + print(f' {len(at)}x {sig[:200]} (first at step {at[0]})') + return found + + def record(self, seed, step, f, ops, crash): + digest = hashlib.sha1(f.signature().encode()).hexdigest()[:8] + path = os.path.join(self.out, 'bugs', f'{f.kind}-{digest}-seed{seed}-step{step}.jsonl') + header = {'seed': seed, 'step': step, 'kind': f.kind, 'why': f.why, 'signature': f.signature(), + 'crash': crash[-4000:], 'binary': self.args.binary} + write_ops(path, header, ops) + print(f' bug record {path}', flush=True) + if not self.args.no_shrink: + self.shrink(path) + + def shrink(self, path): + header, ops = read_ops(path) + # The first replay pins the signature (this code's, which may have + # changed since the record was written); later ones must match it. + sig = None + kind = header.get('kind') + run_dir = os.path.join(self.out, 'runs', f'shrink-{os.getpid()}') + os.makedirs(run_dir, exist_ok=True) + follower = ops[0].get('follower', False) if ops and ops[0]['op'] == 'fresh' else False + body = [op for op in ops if op['op'] != 'fresh'] + budget = [self.args.shrink_budget] + deadline = time.monotonic() + self.args.shrink_seconds + + def fails(candidate): + if budget[0] <= 0 or time.monotonic() > deadline: + return False + budget[0] -= 1 + nonlocal sig + f = self.execute([{'op': 'fresh', 'follower': follower}] + candidate, run_dir) + if f is None: + return False + if sig is None: + if kind is not None and f.kind != kind: + return False + sig = f.signature() + return True + return f.signature() == sig + + if not fails(body): + # One more try with the other log mode, then give up. + follower = not follower + if not fails(body): + header['shrink'] = 'did not reproduce on replay' + write_ops(path, header, ops) + print(f' not reproduced on replay: {path}', flush=True) + return + small = ddmin(body, fails) + # Then shrink each op's payload. + small = shrink_payloads(small, fails) + header['shrink'] = f'{len(body)} -> {len(small)} ops' + out = path.replace('.jsonl', '.min.jsonl') + write_ops(out, header, [{'op': 'fresh', 'follower': follower}] + small) + print(f' shrunk {len(body)} -> {len(small)} ops: {out}', flush=True) + + +def ddmin(items, fails): + """Zeller's delta debugging: a 1-minimal failing subsequence.""" + n = 2 + # Trailing ops after the failure never matter; the failing op is last. + while len(items) >= 2: + size = max(1, len(items) // n) + chunks = [items[i:i + size] for i in range(0, len(items), size)] + reduced = False + for i in range(len(chunks)): + complement = [op for j, c in enumerate(chunks) if j != i for op in c] + if complement and fails(complement): + items, n, reduced = complement, max(n - 1, 2), True + break + if not reduced: + if n >= len(items): + break + n = min(len(items), n * 2) + return items + + +def shrink_payloads(ops, fails): + """Halve long data payloads while the failure stays.""" + ops = [dict(op) for op in ops] + for i, op in enumerate(ops): + while 'data' in op and len(op['data']) > 8: + half = dict(op, data=op['data'][:len(op['data']) // 2]) + trial = ops[:i] + [half] + ops[i + 1:] + if fails(trial): + ops[i] = op = half + else: + break + if op.get('chunks') not in (None, 'whole'): + whole = dict(op, chunks='whole') + if fails(ops[:i] + [whole] + ops[i + 1:]): + ops[i] = whole + return ops + + +def write_ops(path, header, ops): + with open(path, 'w') as f: + f.write(json.dumps(header) + '\n') + for op in ops: + f.write(json.dumps(op) + '\n') + + +def read_ops(path): + with open(path) as f: + lines = [json.loads(line) for line in f if line.strip()] + return lines[0], lines[1:] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + ap.add_argument('binary') + ap.add_argument('--seed', type=int, action='append', help='seed (repeatable); default 1') + ap.add_argument('--steps', type=int, default=1000) + ap.add_argument('--replay', help='run an ops log (or bug record) exactly') + ap.add_argument('--shrink', help='shrink a bug record by delta debugging over replays') + ap.add_argument('--no-shrink', action='store_true', help='record failures without shrinking them') + ap.add_argument('--shrink-budget', type=int, default=300, help='replays a shrink may spend') + ap.add_argument('--shrink-seconds', type=float, default=600) + ap.add_argument('--smoke', action='store_true', help='fixed seed, few steps, fail on any failure') + ap.add_argument('--progress', type=int, default=500) + ap.add_argument('--verbose', '-v', action='store_true', help='replay: print each request and log record') + ap.add_argument('--out', default=DEFAULT_OUT) + args = ap.parse_args() + check_served_lines() + args.binary = os.path.abspath(args.binary) + for key in [k for k in os.environ if k.startswith('PARDES_') or k in ('NINE_MOUNT', 'NAMESPACE')]: + del os.environ[key] + become_subreaper() + signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) + runner = Runner(args) + try: + return run_main(runner, args) + finally: + sweep(f'{os.getpid()}-') + for d in RUNTIME_DIRS: + shutil.rmtree(d, ignore_errors=True) + + +def run_main(runner, args): + if args.replay: + header, ops = read_ops(args.replay) + run_dir = os.path.join(args.out, 'runs', f'replay-{os.getpid()}') + os.makedirs(run_dir, exist_ok=True) + f = runner.execute(ops, run_dir, verbose=args.verbose) + print(f'replay {args.replay}: ' + (f'FAIL at op {f.step}: {f.kind}: {f.why}' if f else 'passed')) + return 1 if f else 0 + if args.shrink: + runner.shrink(args.shrink) + return 0 + if args.smoke: + args.no_shrink = True + found = runner.fuzz(20260929, args.steps if args.steps != 1000 else 800) + return 1 if found else 0 + total = 0 + for seed in args.seed or [1]: + total += len(runner.fuzz(seed, args.steps)) + return 1 if total else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json index 6bfe2c90..659e49d1 100644 --- a/test/perf-baseline-gui-Debug.json +++ b/test/perf-baseline-gui-Debug.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1887,"med_us":2147,"p90_us":2277,"max_us":2326},{"op":"render","fixture":"medium","min_us":73,"med_us":76,"p90_us":84,"max_us":96},{"op":"key-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":91,"max_us":173},{"op":"page-down","fixture":"medium","min_us":75,"med_us":78,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":72,"med_us":76,"p90_us":79,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1269,"med_us":1437,"p90_us":1680,"max_us":1697}]} +{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1841,"med_us":2197,"p90_us":2308,"max_us":2328},{"op":"render","fixture":"medium","min_us":72,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-down","fixture":"medium","min_us":74,"med_us":74,"p90_us":80,"max_us":81},{"op":"key-right","fixture":"medium","min_us":74,"med_us":80,"p90_us":85,"max_us":100},{"op":"page-down","fixture":"medium","min_us":74,"med_us":81,"p90_us":84,"max_us":176},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":79,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1086,"med_us":1515,"p90_us":1692,"max_us":1822},{"op":"body-2m","fixture":"medium","min_us":9044,"med_us":16111,"p90_us":19782,"max_us":21405}]} diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json index 6ffd034c..bed59c8c 100644 --- a/test/perf-baseline-tty-Debug.json +++ b/test/perf-baseline-tty-Debug.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1907,"med_us":2037,"p90_us":2283,"max_us":2371},{"op":"render","fixture":"medium","min_us":72,"med_us":80,"p90_us":87,"max_us":87},{"op":"key-down","fixture":"medium","min_us":73,"med_us":78,"p90_us":82,"max_us":88},{"op":"key-right","fixture":"medium","min_us":72,"med_us":75,"p90_us":87,"max_us":87},{"op":"page-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":93,"max_us":94},{"op":"wheel","fixture":"medium","min_us":73,"med_us":75,"p90_us":84,"max_us":88},{"op":"edit-char","fixture":"medium","min_us":1333,"med_us":1712,"p90_us":1956,"max_us":2636}]} +{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1800,"med_us":2113,"p90_us":2193,"max_us":2326},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":85,"max_us":97},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":82,"max_us":84},{"op":"key-right","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":95},{"op":"page-down","fixture":"medium","min_us":74,"med_us":78,"p90_us":83,"max_us":83},{"op":"wheel","fixture":"medium","min_us":72,"med_us":75,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1027,"med_us":1418,"p90_us":1699,"max_us":1834},{"op":"body-2m","fixture":"medium","min_us":8357,"med_us":17722,"p90_us":20080,"max_us":21699}]} diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json index 5d68e6b2..d13442a5 100644 --- a/test/perf-baseline-tty-ReleaseFast.json +++ b/test/perf-baseline-tty-ReleaseFast.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1771,"med_us":2022,"p90_us":2188,"max_us":2228},{"op":"render","fixture":"medium","min_us":75,"med_us":76,"p90_us":76,"max_us":86},{"op":"key-down","fixture":"medium","min_us":73,"med_us":79,"p90_us":80,"max_us":80},{"op":"key-right","fixture":"medium","min_us":72,"med_us":78,"p90_us":81,"max_us":82},{"op":"page-down","fixture":"medium","min_us":72,"med_us":76,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":75,"med_us":76,"p90_us":78,"max_us":79},{"op":"edit-char","fixture":"medium","min_us":1106,"med_us":1436,"p90_us":1692,"max_us":1721}]} +{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1819,"med_us":2002,"p90_us":2068,"max_us":2122},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":73,"med_us":75,"p90_us":76,"max_us":82},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":86,"max_us":126},{"op":"page-down","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":74,"p90_us":76,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1493,"p90_us":1805,"max_us":1967},{"op":"body-2m","fixture":"medium","min_us":8482,"med_us":17227,"p90_us":20296,"max_us":23040}]} diff --git a/test/perf.zig b/test/perf.zig index e244091c..c639178f 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -46,6 +46,9 @@ const Op = enum { page_down, wheel, edit_char, + /// 2 MB appended to the file through 9P, on one open, in 256 KB + /// writes: linear, not a copy of the whole buffer per write. + body_write, fn label(o: Op) []const u8 { return switch (o) { @@ -56,10 +59,25 @@ const Op = enum { .page_down => "page-down", .wheel => "wheel", .edit_char => "edit-char", + .body_write => "body-2m", }; } }; +var body_chunk: [256 * 1024]u8 = @splat('y'); + +/// The body_write gesture: an open of the pane's body, eight 256 KB writes, +/// a read (which the held writes go in before), the close. +fn bodyWrite(core: *pardes.Pardes, serial: u32) void { + const Node = pardes.ctlfs.Node; + const body = Node.of(serial, .body); + for (&body_chunk, 0..) |*c, i| c.* = if (i % 64 == 63) '\n' else 'y'; + const h = core.serveFs(.{ .tag = 1, .op = .open, .node = body, .omode = 1 }).handle; + for (0..8) |_| _ = core.serveFs(.{ .tag = 2, .op = .write, .node = body, .handle = h, .data = &body_chunk }); + _ = core.serveFs(.{ .tag = 3, .op = .getattr, .node = body, .handle = h }); + _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true }); +} + const TermFixture = struct { name: []const u8, kb: usize, @@ -514,12 +532,14 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u .page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }), .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }), .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), + .body_write => bodyWrite(core, pane.serial), } pump(core); _ = try frame(core); const dt = nowNs() -| t0; switch (op) { .open => unreachable, + .body_write => if (pane.file.?.content.len != before.len + 8 * body_chunk.len) return error.BodyWriteWasNotAppended, .render => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len) return error.RenderChangedDocument, .key_down => if (pane.body.cur_row != before.row + 1) return error.DownDidNotMove, |
