diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 34 | ||||
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | build.zig | 20 | ||||
| -rw-r--r-- | docs/fs.md | 116 | ||||
| -rw-r--r-- | docs/tags.md | 3 | ||||
| -rw-r--r-- | src/9p_io.zig | 96 | ||||
| -rw-r--r-- | src/File.zig | 36 | ||||
| -rw-r--r-- | src/Messages.zig | 2 | ||||
| -rw-r--r-- | src/builtins.zig | 20 | ||||
| -rw-r--r-- | src/exec.zig | 50 | ||||
| -rw-r--r-- | src/fs.zig | 25 | ||||
| -rw-r--r-- | src/look.zig | 89 | ||||
| -rw-r--r-- | src/main.zig | 85 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 10 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 5 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 344 | ||||
| -rw-r--r-- | src/ninep/events.zig | 31 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 139 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 39 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 7 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 109 | ||||
| -rw-r--r-- | src/pardes.zig | 18 | ||||
| -rw-r--r-- | src/regexp.zig | 250 | ||||
| -rw-r--r-- | src/sam_edit.zig | 37 | ||||
| -rw-r--r-- | test/fs.py | 45 | ||||
| -rw-r--r-- | test/monkey9p.py | 1987 | ||||
| -rw-r--r-- | test/perf-baseline-gui-Debug.json | 2 | ||||
| -rw-r--r-- | test/perf-baseline-tty-Debug.json | 2 | ||||
| -rw-r--r-- | test/perf-baseline-tty-ReleaseFast.json | 2 | ||||
| -rw-r--r-- | test/perf.zig | 20 |
30 files changed, 3457 insertions, 176 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 16e90dc9..1d32ce7a 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -34,6 +34,10 @@ 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. +`pardes FILE` run in a pane opens FILE in that session and returns at once; +`pardes --wait FILE` returns when that pane is deleted (1 if the session goes +away), which is what `EDITOR='pardes --wait'` needs (`GIT_EDITOR` follows 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 @@ -164,9 +168,10 @@ 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 `.` +it again. Each command line runs once whole, however a mount cuts a big write; a +write's last line with no newline runs with it (and fails it), unless the +write filled its 9P message and may go on; an Edit block still open when the +file closes 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 -- @@ -209,7 +214,11 @@ 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`. +`Invalid argument`. Only writes log one: a refused open or truncation +(an OTRUNC open, as `data`'s after a failed `addr`), create or remove is +its error alone, as are a write to `pane/new` (`permission denied`) and a +write on a fid opened read-only (`bad use of fid`). Errors are words, never +a C errno string. A look miss quotes what was written: `no match for "zzq:#3"`. ## Edit through addresses, dot and the flag files @@ -274,6 +283,8 @@ 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). +plan9port's `9p write` always opens with OTRUNC, so `… | 9p write pane/body` +replaces the whole body: append with `>>` through a mount instead. | 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')` | @@ -290,13 +301,15 @@ 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 +(every branch anchored; a mix like `^def|x` is refused). `[éa-z]` and +`[à-ÿ]` match those runes (a range up to 256 runes); `[^é]` and a wider +range are 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 +and one search that runs past its step budget (about 300 ms; each search of +an Edit `x` has its own) fails with `regular expression search took too +much time, 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 @@ -318,8 +331,9 @@ an open nor a `>` resets it (acme resets it on the first open): each `echo /re/ 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), +isdir (0), the dirty flag, the width in cells, the font, the tab width, the +undo flag and the redo flag, as acme's fields — 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` @@ -36,6 +36,16 @@ 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)). +`pardes FILE` in a pane opens FILE in that session and returns at once. As +`$EDITOR`, use `EDITOR='pardes --wait'` (`GIT_EDITOR` follows it): `--wait` +returns when that pane is deleted, so fish's Ctrl-O, `git commit` and +`crontab -e` read the file after you edit it +([forwarding](docs/fs.md#filesystem)). + +plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write +pardes/<id>/body` replaces the whole body where acme would append. To append, +write through a mount with `>>` (`echo x >> $PARDES_MOUNT/<id>/body`). + ## Requirements Zig **0.16.0** (`build.zig.zon` pins `minimum_zig_version`). Dependencies are @@ -1568,7 +1568,25 @@ 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); + 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); @@ -15,6 +15,15 @@ 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. +A forwarded launch returns at once, as acme's `B` does. `--wait` (`-w`) +returns only once the pane the file landed in -- the one already showing +it, if any -- is deleted (exit 0), or the session goes away (exit 1), as +acme's `E` does: what an `$EDITOR` must do, since fish's Ctrl-O +(`edit_command_buffer`), `git commit` and `crontab -e` read the file back +when the editor exits. Set `EDITOR='pardes --wait'`; `GIT_EDITOR` follows +`EDITOR` when unset. Outside pardes `--wait` changes nothing, a session +blocking anyway. + Look resolves the OS filesystem first, then the editor's virtual filesystem. Explicit paths bypass that search: @@ -135,7 +144,9 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /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 + narrow to split`, ENOSPC; the root's Newcol halves the active column, + so reaching 16 means running Newcol from the widest column's tag), + 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 @@ -220,7 +231,8 @@ 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> +`restore <path>`. Undo history does not survive a Restore: a restored pane +starts with none. 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, @@ -255,7 +267,11 @@ 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 +(EINVAL: a request this build cannot take); acme's own words run as +pardes's where it has one -- `Put` is `Save`, `Delete` a `Del` that does +not ask -- and the rest (`Get`, `Putall`, `Snarf`, `Cut`, `Paste`, `Zerox`, +`Sort`, `Load`, `ID`, `Send`) are refused, EINVAL, `invalid: acme's Snarf +is not a pardes builtin`, never run as shell commands; 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 @@ -269,7 +285,12 @@ 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 +beside acme's `get`, `lock` and `unlock`; acme's other ctl words are taken +too, done by the file or builtin that replaces each: `name x` (the `name` +file), `put` (`Save`), `clean` and `dirty` (`dirty`), `del` (`Del`), +`delete` (a `Del` that does not ask), `dot=addr`, `addr=dot`, +`limit=addr`, `mark`, `nomark` (`mark`), `show` and `cleartag`; `dump`, +`dumpdir`, `font`, `menu` and `nomenu` are refused with why, EINVAL. 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 @@ -518,12 +539,13 @@ 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 +a write of its own. A write that fills its piece may go on in the next, so +the open keeps its last line with no newline until then; a write shorter +than a piece is the whole of what was written, as acme takes each write, so +its last line runs with it even with no newline (`printf Save > exec`), and +a failure is that write's. Only what needs more is held: an Edit block +whose `{` or `a`/`c`/`i` text has not ended waits for its next write, and +what is left when the file closes runs at the close -- 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 @@ -561,8 +583,9 @@ directory. A name alone is no edit: the pane's `dirty` stays what its text made (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, +until its newline, applied once however the writes cut it; a write with +no newline that is whole in its Twrite is the name then, so a bad one fails +that write, not the close. 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, @@ -574,7 +597,9 @@ 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` +reads the selected text and writing it replaces the selection, leaving dot +just past the text written (a `data` write moves dot as its text moves it, +so a dot at the address it wrote at ends just past that text too). `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 @@ -593,10 +618,12 @@ 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), +sends nothing, as in acme. `ctl` reads acme's window status line, field for +field as plan9port's — serial, tag length, body length, isdir (0), the dirty +flag, the width in cells, the font, the tab width, whether Undo has a step +(1) and whether Redo has one — followed by pardes's own: rio's `current` or +`notcurrent` (rio(4), `wctl`), whether the pane has the keyboard, and a +REPL's id. 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 @@ -704,7 +731,8 @@ 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 +matches at every line start (`$` on a CRLF line sits before the `\r`, +as the line's end is its `\r\n`), 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 @@ -712,7 +740,15 @@ 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 +mvzr keeps `^` only first. A pattern may be up to 512 of mvzr's operations, +about 512 characters (mvzr's own is 64; pardes builds it with more); a +longer one is refused, `bad regular expression: longer than mvzr's 512 +operations`. A class may hold non-ASCII runes (`[éa-z]`, `[à-ÿ]`): it is +taken as an alternation of them (`(é|[a-z])`), a range of up to 256 runes +spelled out, and the 512 counts the pattern as rewritten. A wider range +(`a range of runes wider than 256 in [...] is not supported`) and a negated +class with a non-ASCII rune (`[^é]`) are refused, since mvzr's classes hold +bytes. 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 @@ -729,9 +765,10 @@ 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 +about 300 ms, fails with `regular expression search took too much time, +gave up` rather +than answer a match it is not sure of. The budget is each search's: an +Edit `x` over 100k lines makes 100k searches, each with its own. 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. @@ -763,7 +800,11 @@ 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. +the writes, then `1` again. An open's writes in a row to one place -- an +append to `body`, inserts going on at `data`'s address -- are held and go +in as one edit when anything else comes (another request, the close, or +the editor's step once they pause 20 ms), so a 10 MB write is one copy, not +one per 8 KB piece; any read sees them. 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 @@ -776,7 +817,7 @@ 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 +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 @@ -787,9 +828,17 @@ 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 +`Modified`, Tty with no room, Edit) is no exception. The rule is a write's: +a refused open or truncation -- an OTRUNC open, such as `data`'s after a +failed `addr` --, create or remove answers its error and +logs no `err`, and so do a write to `pane/new` (`permission denied`: it is +only read) and a write on a fid opened OREAD (`bad use of fid`). Every +refusal is said in words, Plan 9's where pardes has none of its own +(`permission denied`, `file does not exist`, `bad argument`), never a C +library string such as `Operation not permitted`. 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 +(`look: no match for ...`, quoting what was written in every form -- +`no match for "zzq:#3"`, `no match for "zzq:2"` -- 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. @@ -797,7 +846,9 @@ a command pane ends in its own time, told by its `exit` record. 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, +it is drawn in, then the file. The editable text is 4096 bytes at most: a +write that would pass that is refused whole, ENOSPC, `tag: over 4096 +bytes`. 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 @@ -873,7 +924,8 @@ itself, is counted rather than repeated (`err 3 addr: no match for regexp 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 +follower sees each count as a new line: with a follower attached, a repeat +is a new line carrying its `(xN)` count, never an edit of the one read. 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 @@ -958,7 +1010,9 @@ acme's window keeps one `eventx`; a second read on it meanwhile fails with `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 +what it printed. One line a run: two lines in one write, or a next line on +the open before the last answer is read (bash writes `printf 'a\nb\n'` a +line at a time), are refused, EINVAL. 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 @@ -984,7 +1038,9 @@ 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: +its directory (a command pane's child is its command, which does not +restart: `exec` there is refused, EINVAL, `a command pane does not +restart`): 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 diff --git a/docs/tags.md b/docs/tags.md index 3ff5e0ef..7671e056 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -92,7 +92,8 @@ 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` +way (exec.c delcol), but changes nothing else in refusing -- no `+Unsaved` +opens, no focus moves; its `unsaved` records and notice say which. 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 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/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..7342f726 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -505,7 +505,7 @@ 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; + 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; @memcpy(p.fs.failure[0..kept.len], kept); diff --git a/src/builtins.zig b/src/builtins.zig index 8788e65f..e47afeb4 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"); } @@ -1372,11 +1374,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 +1387,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 +1398,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 +1415,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 +2005,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 +2018,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/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..ec2472d7 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=")) { @@ -328,6 +412,7 @@ fn nativeMain(init: std.process.Init) !void { 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; + if (wait) waitForDel(init.io, arena, parent.dial, path); return; } if (positional) |a| { 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..c0bf4a9a 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 @@ -1295,3 +1301,14 @@ test "a long err record is cut between words, with an ellipsis" { try testing.expect(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..bb4ebf86 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: 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,60 @@ 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()); + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: 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 +1274,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..30bf073d 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -454,6 +454,36 @@ 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); + return reply; +} + +/// 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 +1066,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 +1103,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(); @@ -1550,8 +1591,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 +1621,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 +1641,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..19fe51d1 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); @@ -5686,6 +5687,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 +5719,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 +7133,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 ') @@ -1224,6 +1224,47 @@ 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' + orphan = launch('--wait') + assert orphan.poll() is None + assert orphan.wait(timeout=5) == 1 + 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/monkey9p.py b/test/monkey9p.py new file mode 100644 index 00000000..c0e99fb8 --- /dev/null +++ b/test/monkey9p.py @@ -0,0 +1,1987 @@ +#!/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) + allowances(res, path, data, sizes, w.msize - 24) + opened = ok + sent = data + if ok: + offset = 0 + 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 not ok and op.get('stop_on_error', True): + break + 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 + allowances(res, path, data, [len(data)], sess.wire.msize - 24) + 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: + 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') + # Only for a write that succeeded: a failed one logs its one err. + failed_paths = {w[1] for w in failed} + allowed = sum(r[3] for r in res.requests if r[0] == 'allow' and r[1] not in failed_paths) + # 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, |
