summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig.zon6
-rw-r--r--docs/cloud9.md2
-rw-r--r--docs/config.md4
-rw-r--r--docs/fs.md61
-rw-r--r--src/9p.zig8
-rw-r--r--src/9p_io.zig22
-rw-r--r--src/CHANGELOG.md25
-rw-r--r--src/File.zig4
-rw-r--r--src/Output.zig25
-rw-r--r--src/builtins.zig12
-rw-r--r--src/config.zig62
-rw-r--r--src/detached/server.zig10
-rw-r--r--src/exec.zig39
-rw-r--r--src/host_io.zig4
-rw-r--r--src/layout.zig20
-rw-r--r--src/look.zig22
-rw-r--r--src/main.zig60
-rw-r--r--src/ninep/addr.zig15
-rw-r--r--src/ninep/cols.zig70
-rw-r--r--src/ninep/ctl.zig120
-rw-r--r--src/ninep/events.zig23
-rw-r--r--src/ninep/pane.zig22
-rw-r--r--src/ninep/pty.zig34
-rw-r--r--src/pardes.zig72
-rw-r--r--src/regexp.zig38
-rw-r--r--src/terminal.zig9
-rw-r--r--src/tty/tty.zig36
-rw-r--r--test/fs.py84
-rw-r--r--test/monkey9p.py3
-rw-r--r--test/selfmount.py7
30 files changed, 763 insertions, 156 deletions
diff --git a/build.zig.zon b/build.zig.zon
index e80961ef..8283c2c2 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.7",
+ .version = "0.0.8",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
@@ -9,8 +9,8 @@
// read-only HTTPS URL is what a manifest can carry. Re-pin with
// `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`.
.cloud9 = .{
- .url = "git+https://git.sr.ht/~gbrls/cloud9#f35b7ed09d0a170e4ffe55b5286c28f74c9a8760",
- .hash = "cloud9-0.1.0-yt86quEKHgCwewf5XRlEZji1JCmdsfufsrZYGZqzHj5_",
+ .url = "git+https://git.sr.ht/~gbrls/cloud9#705be665742fa0332585a4321640b5e2245610cb",
+ .hash = "cloud9-0.1.0-yt86qt8NHgDuS2WNp0TgW03h-eVIgWmkznn0t0nMQKbr",
},
// Pinned to the exact libvaxis commit ghostty is built against, so it
// is compatible with ghostty's uucode configuration (the one we share
diff --git a/docs/cloud9.md b/docs/cloud9.md
index cd92fd05..f993750f 100644
--- a/docs/cloud9.md
+++ b/docs/cloud9.md
@@ -9,7 +9,7 @@ use `.cloud9 = .{ .path = "../cloud9" }` while editing both.
- The engine (fids, jobs, parking, flush, hangup) is cloud9's `fs.Server`;
the control tree in `src/ninep/` is its backend. `src/9p.zig` names the
- editor's and the board's `fs.Options` (msize 8192, 256 fids).
+ editor's and the board's `fs.Options` (the editor's: msize 65536, 256 fids, 128 held reads a connection).
- Unix and TCP listeners run on cloud9's `serve.Runner` (`std.Io`: an accept
task per listener, a reader and a writer task per connection, 16
connections). Requests are answered on the connection's task, which takes
diff --git a/docs/config.md b/docs/config.md
index 35f0b6e7..1967a158 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -38,7 +38,9 @@ Save writes it, making its directory first. `DumpConfig` opens a
it and its value (`Shell /bin/bash`, `PanelSlide off`, `LocationsConfig …`)
and `unsupported` for one the frontend cannot show. After a blank line
come what is in effect but set by no word, such as the last shell spawned
-and the font in use, and the startup path (a right click opens it). The
+and the font in use, and the startup path (a right click opens it), each a
+`#` line. A line starting with `#` is a comment wherever a line runs (the
+init file, a ctl, an exec), so the report can be written back as is. The
root `ctl` file reads the settings back in the words a write takes
([fs.md](fs.md#the-root-ctl)).
diff --git a/docs/fs.md b/docs/fs.md
index 6a82b633..b37511dd 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -20,9 +20,12 @@ and `PARDES_PANE` (their pane's serial).
`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns
at once, as acme's `B` does. A FILE not there yet (`pardes notes/new.txt`)
opens a new pane named for it, empty, and its Save creates the file, making
-its directories first when they are not there either. A missing
-`PARDES_9P`/`PARDES_PANE` starts a separate editor instead. Bare `pardes` in
-a pane refuses and names `--nested`.
+its directories first when they are not there either. A session that
+answers never gets a nested editor in its pane: what it refuses (a bad
+name, a pane it has not) is printed, `pardes: <file>: <why>`, and the
+launch exits 1, leaving no pane behind. A missing `PARDES_9P`/`PARDES_PANE`,
+or a session that does not answer, starts a separate editor instead. Bare
+`pardes` in a pane refuses and names `--nested`.
`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or
the session goes away (exit 1), as acme's `E` does. Use
@@ -150,7 +153,8 @@ column's `exec` take one command a line. The whole write is checked first
(a control character other than a tab fails it all, EINVAL); then lines run
in order, and a failing line fails the write after the lines before it took
effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big
-write into pieces of at most one message (msize 8192, less the header), and
+write into pieces of at most one message (msize 64 KiB, 65536 negotiated,
+less the header), and
each line runs once it is whole. A write that does not fill its message is
whole, so its last line runs even without a newline (`printf Save > exec`). An `Edit` whose `{` or
`a`/`c`/`i` text is still open waits for the next write on that open, and
@@ -176,8 +180,11 @@ files`.
**Held reads.** A read with nothing to give yet (a followed `log`, `event`,
`pty/data`, `pty/run` before its answer) waits in the editor and is answered
when news comes. A second read on that open meanwhile fails `file in use`.
-A read the client flushed is dropped. Through a FUSE mount bash's `read -t`
-cannot time out: wrap the loop in `timeout N`.
+A read the client flushed is dropped. One connection holds at most 128
+reads at once; the next is refused `too many reads waiting: 128`. A mount
+(9ns) is one connection, so that is 128 followers through it, and the mount
+keeps answering everything else beside them. Through a FUSE mount bash's
+`read -t` cannot time out: wrap the loop in `timeout N`.
**Stats.** Lengths are real (for `event` and `pty/data` the next record's,
zero when none waits; for `log` what an open would freeze). The qid
@@ -213,6 +220,8 @@ A line written to `exec` is a middle click:
A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is
`wrong #args in control message "Msg"`, and so is one that takes none
written with one (`Config extra`).
+- a line starting with `#` is a comment, as in a shell: it runs as
+ nothing, silently, here and in every ctl.
- the language server's words ask about a file pane's text at its cursor
(set it with `addr` and `dot=addr` first): `Hover` fills `+Hover`,
`Rename new` renames the symbol everywhere and lists the places,
@@ -237,7 +246,9 @@ A line written to `exec` is a middle click:
second pane. From a pane whose directory is gone nothing runs: `exec:
<dir>: no such directory` (ENOENT).
-The root's `look` and `exec` act at the active pane and log as that pane's;
+The root's `look` and `exec` act at the active pane and log as that pane's
+(with no pane at all, in the session's directory: a look opens its file,
+making a column as `New` does, and an exec runs its command there);
`/pane/<n>/look` and `exec` at pane n; `/tagexec` and `/col/<n>/exec`
click in the workspace's or that column's tag, run commands in the session's
directory, and log as `-`. A pane's word (`Undo`, `Msg`, `Save`) is refused
@@ -249,8 +260,10 @@ goes on below `exit N` until it lets go of the pty. `Kill` (root ctl)
stops the commands pardes started: bare, all; `Kill make ls`, those whose
line starts with one of the words. For a command pane it signals the whole
line, `&` jobs included; for a line typed into a shell only the foreground
-job (SIGTERM), and the shell decides the rest. With nothing running it says
-`Kill: nothing running`. Kill does not reach a REPL's code: use `sig INT` on
+job (SIGTERM), and the shell decides the rest. With nothing running, named
+or not, it says `Kill: nothing running` and the write succeeds; words that
+name none of what runs fail it (`Kill: no running command has that first
+word`). Kill does not reach a REPL's code: use `sig INT` on
its `pty/ctl`.
## The root ctl
@@ -312,9 +325,14 @@ none). `current` is the column with the keyboard now, which may differ
from the active one. `/index`'s last field is each pane's column serial.
A session holds 16 columns (`no space for a column: 16 max`, ENOSPC), each at
-least 10 cells wide (`Newcol` from a column under 20 fails `this one is too
-narrow to split`). Since root `Newcol` halves the active column, reach 16 by
-writing `Newcol` to the widest column's `exec`.
+least 10 cells wide, so 16 wants a window 160 cells wide or more. `Newcol`
+halves the column it is run from, and one under 20 cells does not split
+(`this one is too narrow to split`): reach 16 by writing `Newcol` to the
+widest column's `exec` each time, not to the root's, which keeps halving the
+one just made. `Newcol` is refused as well when a narrower column would
+wrap its panes' tags onto more rows and leave one under its tag and two
+rows (`Newcol: no space for a column: the panes' tags would not fit`); a
+refused `Newcol` logs its `err` alone and uses up no column serial.
`/col/<n>/ctl` takes `Delcol`, `Joincol`, `New` and `Tty` for that column;
`/col/<n>/exec` is a click in its tag; `rmdir /col/<n>` closes an empty column
@@ -333,7 +351,7 @@ blank matters; `echo` would start a second line). A pane tag may hold
several lines; a column or workspace tag is one, a newline written into it
becoming a space. Control characters other than tab, DEL, C1 controls and
non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
-most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). All three kinds
+most 4096 bytes (`no space: over 4096 bytes`, ENOSPC; the log says `err <serial> tag: no space: ...`). All three kinds
take the same checks, whole or not at all, and a `>` whose write is refused
changes nothing: its truncation is done with the write that fits, or at the
close (or a read) when none came. A clear is an ordinary edit
@@ -349,13 +367,15 @@ answer the same serial. It goes where acme's makenewwindow puts a window
if empty, else the bottom half of its last pane. A session holds 64 panes
(`no space for a pane: 64 max`), and every pane keeps its tag and 2 rows
(`no space for a pane in that column: each keeps its tag and 2 rows`); both
-are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike.
+are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike. A
+`pane/new` whose column is full takes its rows from a pane in another column
+that has them, last column first, as acme does, and is refused only when
+no pane anywhere can give them.
`rmdir /pane/<n>` closes the pane, unsaved or not.
**`name`** reads the file name (a terminal's directory); a write renames the
buffer (relative to the pane's directory) and marks nothing dirty; `Save`
-then writes under the new name, making its directories first when the name
-went into one not there. A name is one line; refused (EINVAL) are a
+then writes under the new name. A name is one line; refused (EINVAL) are a
second line, a blank at either end, control bytes and non-UTF-8 (`bad
character in file name: a blank at its start`, ...). Up to 255 bytes a
component.
@@ -381,7 +401,8 @@ redo available, then `current`/`notcurrent` and a REPL's id if bound. It
takes:
- the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give
- its rows to the pane above or below), `Save [path]`, `Collapse` (fold),
+ its rows to the pane above or below), `Save [path]` (making the
+ directories the file goes in first, whichever name it writes), `Collapse` (fold),
`Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]`
(a new terminal pane in its directory), and the column words acting on
its column: `Delcol`, `Left`/`Right`/`Up`/`Down`.
@@ -533,7 +554,7 @@ text, which may hold newlines.
its text (`FX0 0 1 6 Msg hi`).
Write a record back to have it done as the click would: `<o><a><q0> <q1>\n`
-acts on that range; the whole record as read acts on its text (the only way
+acts on that range (the last record of a write needs no newline); the whole record as read acts on its text (the only way
for `0 0`). A chorded record with its two follow-ups, in one write or
three, runs once with its argument. `I`, `D`, `i`, `d` are refused. A helper
holding `event` that writes its own pane's `exec` gets its command back as a
@@ -566,7 +587,7 @@ Terminal panes also have `pty/`:
replay).
- `pty/status`: one line, `cols rows busy`; busy is 1 while a command runs or
text is typed at the prompt.
-- `pty/ctl`: `winsize C R` (at least 2 rows), `sig INT|TERM|HUP|QUIT|KILL`,
+- `pty/ctl`: `winsize C R` (at least 2 rows, fewer refused `invalid winsize: at least 2 rows`; at most 4096 a side), `sig INT|TERM|HUP|QUIT|KILL`,
`exec` (restart the shell in its directory: refused on a command pane, `a
command pane does not restart`; `exec: <dir>: no such directory` if it is
gone; a shell that cannot start fails and leaves the old one running).
@@ -654,7 +675,7 @@ become spaces.
| panes | 64 (16 on the board) |
| columns | 16 (6 on the board), each at least 10 cells wide |
| rows a pane keeps | its tag and 2 |
-| msize | 8192 offered |
+| msize | 65536 (64 KiB) offered |
| connections | 16 Unix+TCP, 16 QUIC |
| opens holding state | 64 |
| named mounts | 8 |
diff --git a/src/9p.zig b/src/9p.zig
index 837525cb..67b3b39a 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -61,7 +61,13 @@ pub const msize: u32 = 65536;
/// (src/9p_io.zig, `serve`), and a write it cannot keep would fail instead.
/// The largest write is msize less the Twrite header, 23 bytes (the test
/// below asks a client), one byte more than `iohdrsz` allows for.
-pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23 };
+/// Up to 128 reads held on one connection (a follower each of the log or
+/// an event file); the next is refused `too many reads waiting: 128`. A
+/// mount (9ns) keeps a wider window, 256, so held reads never take every
+/// one of its workers.
+pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23, .slot_capacity = max_held };
+
+pub const max_held: usize = 128;
/// The board's engine: the GPIO firmware's budget (src/esp32p4_9p.zig).
pub const board: cloud9.fs.Options = .{ .fid_capacity = board_fids, .name_capacity = board_name_capacity };
diff --git a/src/9p_io.zig b/src/9p_io.zig
index d1b2099c..0eaa2d7e 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -1542,6 +1542,11 @@ test "a change waits while the editor is out mid-step, a read does not, and the
try testing.expectEqualStrings("after\n", pane.file.?.content);
}
+test "a connection holds up to 128 reads, and the next is refused in words" {
+ try testing.expectEqual(@as(usize, 128), ninep.max_held);
+ if (comptime supported) try testing.expectEqualStrings("too many reads waiting: 128", Runner.Engine.e_waiting);
+}
+
test "a held read is answered when the log has news, and a flushed one spends nothing" {
if (comptime !supported) return error.SkipZigTest;
const gpa = testing.allocator;
@@ -1808,7 +1813,17 @@ pub const Client = struct {
var sock_buf: [sun_path_len]u8 = undefined;
const sock = try resolve(&sock_buf, dial);
var remote: RemoteError = .{};
- return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes));
+ return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)) catch |err| {
+ if (err == error.Remote) said = remote;
+ return err;
+ };
+ }
+
+ /// The peer's own words for the last error.Remote a read or write here
+ /// got (its Rerror), for a caller that says why.
+ var said: RemoteError = .{};
+ pub fn lastSaid() []const u8 {
+ return said.buf[0..said.len];
}
/// Whether a peer answers at `dial`: a version and attach, and its root
@@ -1828,7 +1843,10 @@ pub const Client = struct {
var sock_buf: [sun_path_len]u8 = undefined;
const sock = try resolve(&sock_buf, dial);
var remote: RemoteError = .{};
- const result = try fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes);
+ const result = fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes) catch |err| {
+ if (err == error.Remote) said = remote;
+ return err;
+ };
gpa.free(result);
}
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 3dd7c996..baff96b0 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## 0.0.8
+
+- A wheel over a terminal whose program asked for the mouse goes to the
+ program, as a report in the format it chose, or as arrow keys on the
+ alternate screen with alternate scroll: codex, htop, less and vim scroll.
+- `pardes FILE` in a pane whose session answers never starts a nested
+ editor: a refusal is printed and it exits 1. A tty pardes killed by
+ SIGTERM or SIGHUP puts its terminal back first.
+- A 9P connection holds up to 128 waiting reads, and refuses the next in
+ words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held
+ reads.
+- A backward search along one long line takes time in its length, not its
+ square. A detached session no client watches draws no frames: 50 MB of
+ output through `pty/run` takes 11 s, not over 30.
+- Save makes its file's missing directories, for its own name and `Save
+ path` alike. `pane/new` takes rows from another column before refusing.
+- With no pane at all, the root `look` opens its file and the root `exec`
+ runs in the session's directory. A refused `Newcol` logs only its error.
+- `Lspwhy` explains the last query in its own file and place; every `+Lsp`
+ answer shares one pane per directory.
+- DumpConfig's information lines start with `# `, and a line starting with
+ `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0`
+ is refused. `Kill X` with nothing running is a message, not a failure.
+ An event write-back needs no trailing newline.
+
## 0.0.7
- `lapis` draws its cells unblended and each plaque pixel once: about a
diff --git a/src/File.zig b/src/File.zig
index 43b921e3..84cec879 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -61,10 +61,6 @@ pub const State = struct {
/// edit to another length cannot be back to it, and is not hashed.
saved_len: ?usize = null,
watch_after_save: bool = false,
- /// Save makes the file's directory, and any above it, first: `Config`'s
- /// pane for an init file that is not there yet, whose directory may not
- /// be either.
- make_dirs: bool = false,
/// The file changed on disk under unsaved edits, which were kept (see
/// `changed`): its hash, so the same change is said once, until a Save
/// or get settles it. A Save warns once before overwriting it, as acme's
diff --git a/src/Output.zig b/src/Output.zig
index 545637b3..3541fa57 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -164,6 +164,23 @@ test "stacked empty previews distinguish separators from trailing newlines" {
try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked));
}
+test "Lspinfo and Lspwhy fill one +Lsp in a directory" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ _ = try p.setTestFile("x\n");
+ try fillResults(p, 0, "/work", .{ .query = .status }, "", try p.gpa.dupe(u8, "servers\n"), null);
+ try fillResults(p, 0, "/work", .{ .query = .explain }, "", try p.gpa.dupe(u8, "why\n"), null);
+ var lsp_panes: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ if (std.mem.eql(u8, f.path, "/work/+Lsp")) {
+ lsp_panes += 1;
+ try std.testing.expectEqualStrings("why\n", f.content);
+ }
+ }
+ try std.testing.expectEqual(@as(usize, 1), lsp_panes);
+}
+
test "plain output does not opt into location decoration" {
const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on");
try std.testing.expectEqualDeep(RowDecoration{}, report);
@@ -439,6 +456,12 @@ fn fileSearch(from: Origin) bool {
return from != .search and std.mem.eql(u8, traits(from).name, config.search_buffer);
}
+/// A language server's prose (Lspinfo, Lspwhy, a code action list, a
+/// format diff): one `+Lsp` a directory holds for all of them.
+fn lspProse(from: Origin) bool {
+ return from == .query and std.mem.eql(u8, traits(from).name, config.lsp_buffer);
+}
+
pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void {
var result_content = content;
errdefer p.gpa.free(result_content);
@@ -483,7 +506,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const o = if (rf.output) |*x| x else continue;
- const shared = fileSearch(from) and fileSearch(o.from);
+ const shared = (fileSearch(from) and fileSearch(o.from)) or (lspProse(from) and lspProse(o.from));
if (i == id and !shared) continue;
if (!shared and !std.meta.eql(o.from, from)) continue;
if (!shared and by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
diff --git a/src/builtins.zig b/src/builtins.zig
index 8fe49649..ba67766d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -609,11 +609,13 @@ pub const Kill = struct {
const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
var stopped = false;
var jobless = false;
+ var running = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
// A command pane's child is the command: the whole of its
// process group goes, as acme's Kill ends the process.
if (pane.command) |line| if (!pane.command_done) {
+ running = true;
const said = std.mem.trim(u8, line, " \t");
const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len];
if (names.len > 0) {
@@ -627,6 +629,7 @@ pub const Kill = struct {
continue;
};
if (!panes.terminal.commandRunning(pane)) continue;
+ running = true;
const sent = pane.sent_command.?;
if (names.len > 0) {
var words = std.mem.tokenizeAny(u8, names, " \t");
@@ -640,9 +643,11 @@ pub const Kill = struct {
}
if (stopped) return;
if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)");
- if (names.len > 0)
+ // Said, but no failure: a script's Kill, named or not, has nothing
+ // left to do. Only commands running, none of them the one named, is.
+ if (names.len > 0 and running)
c.p.reportFailure(c.id, "Kill: no running command has that first word")
- else // said, but no failure: a script's Kill has nothing left to do
+ else
c.p.setMessage(c.id, "Kill: nothing running");
}
};
@@ -1304,9 +1309,8 @@ pub const Config = struct {
// Not there yet: a pane named for it, which Save makes.
const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Config", error.NoPaneSlots);
c.p.newScratchFor(c.id, .scratch);
- const pane = c.p.panes[free] orelse return;
+ if (c.p.panes[free] == null) return;
@import("ninep/pane.zig").nameBuffer(c.p, free, path, false) catch |err| return c.p.reportError(c.id, "Config", err);
- pane.file.?.make_dirs = true;
c.p.active = free;
}
};
diff --git a/src/config.zig b/src/config.zig
index 94b32b88..592281d3 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1386,35 +1386,36 @@ pub const Runtime = struct {
};
if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations});
- // In effect, and set by no word.
+ // In effect, and set by no word: each a `#` line, which runs as
+ // nothing (exec.executeFrom), so the report can be written back.
try out.writeByte('\n');
- try out.print("Last shell spawned: {s}\n", .{shown(state.shell.effective.get())});
- try out.print("Shell change pending: {s}\n", .{onOff(state.shell.pending)});
+ try out.print("# Last shell spawned: {s}\n", .{shown(state.shell.effective.get())});
+ try out.print("# Shell change pending: {s}\n", .{onOff(state.shell.pending)});
if (find("Font").?.enabled(context.capabilities)) {
const f = &state.font;
- try out.print("Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) });
- try out.print("Font change pending: {s}\n", .{onOff(f.pending)});
+ try out.print("# Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) });
+ try out.print("# Font change pending: {s}\n", .{onOff(f.pending)});
}
if (find("WindowOpacity").?.enabled(context.capabilities))
- try out.print("Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)});
+ try out.print("# Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)});
if (context.capabilities.tagline_font_size and !find("TaglineSize").?.enabled(context.capabilities))
- try out.writeAll("Tagline size: build-time only\n");
+ try out.writeAll("# Tagline size: build-time only\n");
if (context.startup_config_path) |path|
- try out.print("Startup config: {s}\n", .{path})
+ try out.print("# Startup config: {s}\n", .{path})
else
- try out.writeAll("Startup config: no per-user config path\n");
+ try out.writeAll("# Startup config: no per-user config path\n");
try out.print(
- "Platform: {s}\n" ++
- "Default shell ($SHELL, else /bin/sh): {s}\n",
+ "# Platform: {s}\n" ++
+ "# Default shell ($SHELL, else /bin/sh): {s}\n",
.{ context.platform, context.compiled_default_shell },
);
if (context.hover_delay_frames) |frames|
- try out.print("Look hover delay: {d} frames\n", .{frames})
+ try out.print("# Look hover delay: {d} frames\n", .{frames})
else
- try out.writeAll("Look hover delay: off\n");
- try out.print("Native images: {s}\n", .{onOff(context.native_images)});
+ try out.writeAll("# Look hover delay: off\n");
+ try out.print("# Native images: {s}\n", .{onOff(context.native_images)});
if (std.mem.eql(u8, context.platform, "gui"))
- try out.print("Pet: {s}\n", .{@tagName(state.pet)});
+ try out.print("# Pet: {s}\n", .{@tagName(state.pet)});
}
test "setting names are unique" {
@@ -1761,15 +1762,15 @@ pub const Runtime = struct {
"Theme acme\n",
"WindowOpacity 85\n",
"Font /fonts/wanted.ttf:18.50\n",
- "Font in use: Effective Mono 13.75 points\n",
- "Font change pending: on\n",
- "Window opacity change pending: off\n",
+ "# Font in use: Effective Mono 13.75 points\n",
+ "# Font change pending: on\n",
+ "# Window opacity change pending: off\n",
"TaglineSize 82\n",
- "Startup config: /tmp/pardes/init\n",
- "Platform: gui\n",
- "Default shell ($SHELL, else /bin/sh): /bin/sh\n",
- "Look hover delay: 18 frames\n",
- "Native images: on\n",
+ "# Startup config: /tmp/pardes/init\n",
+ "# Platform: gui\n",
+ "# Default shell ($SHELL, else /bin/sh): /bin/sh\n",
+ "# Look hover delay: 18 frames\n",
+ "# Native images: on\n",
}) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
var defaults: Runtime = .{};
@@ -1794,22 +1795,25 @@ pub const Runtime = struct {
try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities));
try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities));
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell /bin/sh\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Last shell spawned: (none)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell change pending: on\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Last shell spawned: (none)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Shell change pending: on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Startup config: no per-user config path\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font in use") == null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "PanelSlide off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Look hover delay: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Native images: off\n") != null);
// A setting this shell does not have reads so, as a line to write.
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null);
// Every line before the blank one is a ctl word and its value.
const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?];
var lines = std.mem.splitScalar(u8, words, '\n');
while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null);
+ // ...and every one after it a `#` line, which runs as nothing.
+ var info = std.mem.splitScalar(u8, std.mem.trimEnd(u8, defaults_report[words.len + 2 ..], "\n"), '\n');
+ while (info.next()) |line| try std.testing.expect(std.mem.startsWith(u8, line, "# "));
defaults_context.platform = "web";
defaults_context.capabilities.panel_transitions = false;
@@ -1819,7 +1823,7 @@ pub const Runtime = struct {
const web_report = storage[0..out.end];
try std.testing.expect(std.mem.indexOf(u8, web_report, "PanelSlide unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, web_report, "Tagline size: build-time only\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "# Tagline size: build-time only\n") != null);
}
};
diff --git a/src/detached/server.zig b/src/detached/server.zig
index d8379793..001acc47 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -376,6 +376,7 @@ pub const Session = struct {
.now = clockNow,
.present = present,
.poll_frame = pollFrame,
+ .watched = watched,
.spawn = spawn,
.pty_write = ptyWrite,
.pty_resize = ptyResize,
@@ -641,6 +642,12 @@ pub const Session = struct {
s.mailbox.signal();
}
+ fn watched(ctx: ?*anyopaque) bool {
+ const s = of(ctx);
+ for (&s.clients) |*c| if (c.attached) return true;
+ return false;
+ }
+
fn pollFrame(ctx: ?*anyopaque) void {
const s = of(ctx);
if (s.ninep) |l| s.ninep_pending = l.tick().pending;
@@ -1052,6 +1059,9 @@ pub const Session = struct {
c.attached = true;
c.need_full = true;
c.greet = true;
+ // Unwatched until now, it may have drawn nothing: a frame
+ // for the one attaching (watched).
+ s.core.needs_frame = true;
},
.bye => s.close(c, .bye),
.event => |ev| {
diff --git a/src/exec.zig b/src/exec.zig
index eed9297a..7d682d74 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -643,12 +643,22 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
/// `execute` for text a gesture took from `id`'s body when `from_body`:
/// such text may be a bound REPL's. Run by name (`Exec ...`, whose line
/// comes back through `execute`) or written as a command word, never.
+/// A line that starts with `#` is a comment, as in a shell: it runs as
+/// nothing, silently, wherever a line runs (exec, a ctl, the init file),
+/// so DumpConfig's report reads back as configuration whole.
+pub fn isComment(line: []const u8) bool {
+ const t = std.mem.trimStart(u8, line, " \t");
+ return t.len > 0 and t[0] == '#';
+}
+
pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize {
- // An empty window runs its session words, and nothing else: no pane to
- // run a command for.
+ if (isComment(commandText(txt))) return null;
+ // An empty window runs its session words, and a command line in the
+ // session's directory (execDir), as its workspace tag would.
const pane = p.panes[id] orelse {
- _ = executeBuiltinLine(p, id, commandText(txt));
- return null;
+ const cmd = commandText(txt);
+ if (cmd.len == 0 or executeBuiltinLine(p, id, cmd)) return null;
+ return runCommand(p, id, cmd);
};
const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null;
const cmd = commandText(txt);
@@ -699,7 +709,9 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
p.reportFailure(from, "exec: a command line is at most 1024 bytes");
return null;
}
- const src = p.panes[from] orelse return null;
+ // None asking (an empty window): the session's directory, no pane of
+ // its own to reuse.
+ const src = p.panes[from];
const dir = p.execDir(from);
// A directory that is not there runs nothing, as Tty makes nothing
// there: no pane, and the write fails ENOENT.
@@ -712,7 +724,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
// would hang that job up.
// From a column's tag, only a finished pane in that column is reused,
// as the new one goes in that column (placeNew).
- const reuse: ?usize = if (p.exec_column == null and src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
+ const reuse: ?usize = if (p.exec_column == null and src != null and src.?.command != null and src.?.command_done and !src.?.command_pty) from else for (p.panes, 0..) |slot, i| {
const other = slot orelse continue;
if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue;
if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i;
@@ -838,6 +850,7 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
const raw = commandText(txt);
if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false;
+ if (isComment(raw)) return true;
const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len;
var renamed: [1024 + 16]u8 = undefined;
const cmd = for (acme_words) |w| {
@@ -1032,9 +1045,12 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
// No pane to halve, but the column holds every pane's minimum
// with the new one's: the rows are shared out again, so what is
// refused is only what the documented arithmetic cannot hold.
- // +Errors goes wherever there is room, last column first; with
- // none, its maker says the text some other way (appendErrors).
- if (layout.shareColumn(p, f.col)) {} else if (kind == .errors) {
+ // +Errors and 9P's pane/new go wherever there is room, last
+ // column first, as acme takes rows from a window that has them
+ // before saying there are none; with none, +Errors's maker says
+ // the text some other way (appendErrors), and pane/new is
+ // refused.
+ if (layout.shareColumn(p, f.col)) {} else if (kind == .errors or kind == .fs) {
layout.removePane(p, id);
var c = p.ncol;
while (c > 0) {
@@ -1051,6 +1067,11 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
} else {
layout.insert(p, f.col, p.col_n[f.col], id);
p.unplaced.set(id);
+ // No pane anywhere can give it rows: pane/new is refused.
+ if (kind == .fs) {
+ p.panes[id].?.vweight = 0;
+ p.reportError(from, "placement", error.NoPaneRoom);
+ }
return;
}
} else {
diff --git a/src/host_io.zig b/src/host_io.zig
index ac25e63c..c8c537b9 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -19,6 +19,10 @@ pub const Host = struct {
present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null,
post_present: ?*const fn (ctx: ?*anyopaque) void = null,
poll_frame: ?*const fn (ctx: ?*anyopaque) void = null,
+ /// Whether anyone sees a frame drawn now. A detached session with no
+ /// client attached draws none (a /screen read draws its own), and
+ /// the frame owed is drawn when one attaches. Absent: always.
+ watched: ?*const fn (ctx: ?*anyopaque) bool = null,
detach: ?*const fn (ctx: ?*anyopaque) void = null,
spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null,
pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null,
diff --git a/src/layout.zig b/src/layout.zig
index 28f3d270..4aebcf28 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -325,8 +325,14 @@ pub fn removePane(p: *Pardes, id: usize) void {
/// Close column `c` once its panes are gone, its width to the one left of
/// it (the first column's to its right), as acme's rowclose gives it.
pub fn dropColumn(p: *Pardes, c: usize) void {
- std.debug.assert(p.col_n[c] == 0);
const serial = columnSerial(p, c);
+ dropColumnUnannounced(p, c);
+ noteColumn(p, "delcol", serial);
+}
+
+/// dropColumn with no `delcol`, for a column never announced.
+pub fn dropColumnUnannounced(p: *Pardes, c: usize) void {
+ std.debug.assert(p.col_n[c] == 0);
tagline.exitHeader(p);
if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
p.col_tags[c].deinit(p.gpa);
@@ -341,12 +347,11 @@ pub fn dropColumn(p: *Pardes, c: usize) void {
p.ncol -= 1;
p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
- noteColumn(p, "delcol", serial);
}
/// `newcol <serial>` or `delcol <serial>` in the log: the column's serial,
/// as /layout and /col name it.
-fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
+pub fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
var buf: [16]u8 = undefined;
pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch return);
}
@@ -649,6 +654,14 @@ pub fn firstColumn(p: *Pardes) ?usize {
}
pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
+ const c = insertColumnUnannounced(p, col, before) orelse return null;
+ noteColumn(p, "newcol", columnSerial(p, c));
+ return c;
+}
+
+/// insertColumn with no serial given and no `newcol`: a column that may
+/// yet be taken back (Newcol's trial), which then leaves no trace.
+pub fn insertColumnUnannounced(p: *Pardes, col: usize, before: bool) ?usize {
if (!canSplitCol(p, col)) return null;
var old_weight = p.col_weight[col];
if (old_weight < 2 or old_weight % 2 != 0) {
@@ -672,7 +685,6 @@ pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
p.col_serial[c] = 0;
p.col_n[c] = 0;
p.ncol += 1;
- noteColumn(p, "newcol", columnSerial(p, c));
return c;
}
diff --git a/src/look.zig b/src/look.zig
index 36eae75c..de51804d 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1233,6 +1233,25 @@ fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void {
missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line });
}
+/// Whether `:line:col`, `:line:col-col` or `:line:col-line:col` after a
+/// path names a column 0, which parsePathLine reads as no column.
+fn zeroColumn(rest: []const u8) bool {
+ var fields = std.mem.splitScalar(u8, rest, ':');
+ _ = fields.next(); // before the first ':'
+ _ = fields.next() orelse return false; // the line
+ const col = fields.next() orelse return false;
+ var ends = std.mem.splitScalar(u8, col, '-');
+ if (std.mem.eql(u8, ends.next().?, "0")) return true;
+ if (ends.next()) |end| if (std.mem.eql(u8, end, "0")) return true;
+ if (fields.next()) |end_col| if (std.mem.eql(u8, end_col, "0")) return true;
+ return false;
+}
+
+test "a zero column after a path is found, in every spelling a look takes" {
+ for ([_][]const u8{ ":3:0", ":3:0-4", ":3:2-0", ":3:2-5:0" }) |rest| try std.testing.expect(zeroColumn(rest));
+ for ([_][]const u8{ "", ":3", ":3:1", ":3:2-5", ":3:2-5:7", ":30:10" }) |rest| try std.testing.expect(!zeroColumn(rest));
+}
+
fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
p.look_missed = true;
var said: [miss_path_cap + 64]u8 = undefined;
@@ -1504,6 +1523,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// the colon, evaluated in that file from its dot (openfile, look.c:675).
if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr);
const pl = parsePathLine(trimmed);
+ // `file:3:0`: columns count from 1, as addr's do, and a zero is refused
+ // in addr's words rather than taken as no column.
+ if (pl.at.line != 0 and zeroColumn(trimmed[pl.path.len..])) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero);
if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane);
if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
var realbuf: [4096]u8 = undefined;
diff --git a/src/main.zig b/src/main.zig
index 6bdf2bfd..576efc8e 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -390,6 +390,19 @@ fn nativeMain(init: std.process.Init) !void {
// leave a stale environment variable able to lock someone out of their
// own editor.
const parent = found orelse break :forwarding;
+ // Does it answer? One that answers takes the file or says why not,
+ // as acme's B does: from here on never a nested editor, whose
+ // screen would be drawn inside the pane that asked.
+ ninep_io.Client.probe(arena, parent.dial) catch break :forwarding;
+ const Refuse = struct {
+ fn with(io: std.Io, what: []const u8, err: anyerror) noreturn {
+ const why = if (err == error.Remote) ninep_io.Client.lastSaid() else @errorName(err);
+ var buf: [8192]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ what[0..@min(what.len, 4096)], why }) catch "pardes: refused\n";
+ std.Io.File.stderr().writeStreamingAll(io, text) catch {};
+ std.process.exit(1);
+ }
+ };
// The pane's `look` file: one line, and the line is the clicked text
// itself, which is what a right click in that pane would have been.
var look_buf: [64]u8 = undefined;
@@ -397,15 +410,12 @@ fn nativeMain(init: std.process.Init) !void {
const word = positional orelse {
var tag_buf: [64]u8 = undefined;
const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial});
- const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch break :forwarding;
+ const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch |err| Refuse.with(init.io, tag, err);
arena.free(contents);
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
};
- // A word this launch cannot turn into a path is an argument problem
- // rather than a nesting one: fall through and let the session that
- // starts report it, the way `pardes typo` outside pardes does.
- if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding;
+ if (std.mem.indexOfAny(u8, word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
var newbuf: [4096]u8 = undefined;
@@ -415,30 +425,46 @@ fn nativeMain(init: std.process.Init) !void {
// resolved and the name kept, a pane made for it that Save
// creates the file from.
const base = std.fs.path.basename(target.path);
- if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
+ if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) Refuse.with(init.io, word, error.NotAFileName);
const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse {
// Its directory not there either: the name made absolute as
- // written, and the pane's Save makes the directories (a
- // name into a missing one says so, pane.zig writeName).
+ // written, and the pane's Save makes the directories.
var cwd_buf: [4096]u8 = undefined;
- if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding;
+ if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) Refuse.with(init.io, word, error.NoWorkingDirectory);
const cwd = std.mem.sliceTo(&cwd_buf, 0);
- break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding;
+ break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err);
};
- break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
+ break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err);
};
if (found_path != null) {
var command_buf: [8192]u8 = undefined;
- const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch |err| Refuse.with(init.io, word, err);
+ ninep_io.Client.write(arena, parent.dial, look, command) catch |err| Refuse.with(init.io, word, err);
} else {
- const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding;
- const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding;
+ const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, word, err);
+ const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, word, err);
var name_buf: [64]u8 = undefined;
const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial});
var line_buf: [4200]u8 = undefined;
- const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding;
+ const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch |err| Refuse.with(init.io, word, err);
+ ninep_io.Client.write(arena, parent.dial, name, line) catch |err| {
+ // The name refused: the pane made for it goes too, no
+ // stray +New left behind.
+ var ctl_buf: [64]u8 = undefined;
+ const ctl = std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}) catch unreachable;
+ var why_buf: [256]u8 = undefined;
+ const said = ninep_io.Client.lastSaid();
+ const why = why_buf[0..@min(said.len, why_buf.len)];
+ @memcpy(why, said[0..why.len]);
+ ninep_io.Client.write(arena, parent.dial, ctl, "delete\n") catch {};
+ if (err == error.Remote) {
+ var buf: [8192]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ word[0..@min(word.len, 4096)], why }) catch "pardes: refused\n";
+ std.Io.File.stderr().writeStreamingAll(init.io, text) catch {};
+ std.process.exit(1);
+ }
+ Refuse.with(init.io, word, err);
+ };
}
if (wait) waitForDel(init.io, arena, parent.dial, path);
return;
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 9674a323..df2cfaf0 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -384,6 +384,21 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "a backward search along one long line takes time in its length, not its square" {
+ const gpa = testing.allocator;
+ const line = try gpa.alloc(u8, 7 * 16800);
+ defer gpa.free(line);
+ for (0..16800) |i| @memcpy(line[i * 7 ..][0..7], "answer ");
+ const p = try th.withFile(gpa, line);
+ defer p.deinit();
+ const t0 = std.Io.Timestamp.now(testing.io, .awake);
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .addr), "?a?").reply.status);
+ // Quadratic, it took seconds (6.4 s through 9P in Debug); linear, a
+ // few milliseconds.
+ const took = t0.durationTo(std.Io.Timestamp.now(testing.io, .awake)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+}
+
test "an alternation anchoring some branches and not others is refused with why, a $ not counting" {
const p = try th.withFile(testing.allocator, "def\nfoo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index 454cce4a..c4ee83eb 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -163,7 +163,7 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
+ if (line.len == 0 or pardes.exec.isComment(line)) continue;
const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
@@ -228,6 +228,69 @@ test "New written to a column's exec makes its scratch in the session's director
try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
}
+test "pane/new takes rows from a pane in another column before refusing ENOSPC" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const first = p.panes[p.active].?.serial;
+ // A second column holding one tall pane.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status);
+ const tall = try th.newPane(p);
+ const tall_col = layout.findPane(p, p.paneBySerial(tall).?).?.col;
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ // From the first column, until pane/new is refused.
+ var moved = false;
+ for (0..pardes.MAX_PANES) |_| {
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{first}));
+ const made = th.newPane(p) catch break;
+ if (layout.findPane(p, p.paneBySerial(made).?).?.col == tall_col) moved = true;
+ }
+ try testing.expect(moved);
+}
+
+test "a Newcol refused because the tags would not fit logs its err alone and spends no column serial" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ // Long tags wrap onto more rows as their column narrows: the boot's
+ // two panes share one, and each keeps its tag and two rows.
+ const made = try th.newPane(p);
+ const pane = p.panes[p.paneBySerial(made).?].?;
+ const col = layout.findPane(p, p.paneBySerial(made).?).?.col;
+ for (p.col_panes[col][0..p.col_n[col]]) |k| {
+ const each = p.panes[k].?;
+ if (each.tag.own) |old| p.gpa.free(old);
+ each.tag.own = try p.gpa.dupe(u8, "word " ** 60);
+ }
+ try testing.expect(p.col_n[col] >= 2);
+ p.sync();
+ const ctl = @intFromEnum(tree.TopFile.ctl);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ const own = try std.fmt.bufPrint(&digits, "{d}\n", .{pane.serial});
+ var refused = false;
+ for (0..pardes.MAX_COLS) |_| {
+ // From the long tag's column, so it is the one split narrower.
+ _ = th.wr(p, focus, own);
+ const serial_before = p.next_column_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ const r = th.wr(p, ctl, "Newcol\n");
+ if (r.reply.status == .ok) continue;
+ if (std.mem.indexOf(u8, r.reply.ename, "tags would not fit") == null) continue;
+ refused = true;
+ try testing.expectEqual(serial_before, p.next_column_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ const added = log_after.bytes[log_before.bytes.len..];
+ try testing.expect(std.mem.startsWith(u8, added, "err "));
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, added, "\n"));
+ break;
+ }
+ try testing.expect(refused);
+}
+
test "a tagexec word that makes, acts on and focuses no pane reads back nothing" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -458,7 +521,10 @@ test "a refused write after a truncation leaves every kind of tag as it was, and
const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle;
const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big });
try testing.expectEqual(E.NOSPC, r.errno());
- try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename);
+ try testing.expectEqualStrings("no space: over 4096 bytes", r.reply.ename);
+ // Its err record names the file once: `tag: no space`, never `tag: tag:`.
+ try testing.expect(th.logHas(p, "tag: no space: over 4096 bytes"));
+ try testing.expect(!th.logHas(p, "tag: tag:"));
_ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true });
try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes);
// A control character is refused the same way on every kind.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 0bbc0319..9a95a10f 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -192,8 +192,30 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control);
if (exec) if (tooLong(req, text)) |refusal| return refusal;
if (!apply) continue;
- const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
+ var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
+ // No pane at all: the root's look and exec act in the session's
+ // directory. An exec runs as the workspace tag's; a look from a
+ // scratch made as New makes one (and its column), dropped
+ // unannounced when the look went elsewhere.
+ var scratch: ?usize = null;
+ if (p.panes[id] == null and serial == null and p.anyPane() == null) {
+ if (exec) {
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (captured(p, req, id, exec, text, true)) |refusal| return refusal;
+ continue;
+ }
+ const free = p.freeSlot() orelse return Reply.fail(req.tag, E.NOSPC);
+ p.newScratchFor(id, .scratch);
+ if (p.panes[free] == null) return Reply.fail(req.tag, E.NOSPC);
+ p.active = free;
+ id = free;
+ scratch = free;
+ }
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ defer if (scratch) |s| if (p.active != s) if (p.panes[s]) |sp| if (sp.file) |f| {
+ if (f.content.len == 0) p.removePane(s, null) catch {};
+ };
p.fs.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
@@ -267,7 +289,8 @@ 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;
// 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;
+ if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid") or
+ std.mem.startsWith(u8, failure, "address out of range")) return E.INVAL;
return E.IO;
}
@@ -586,7 +609,7 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |line| {
- if (line.len == 0) continue;
+ if (line.len == 0 or exec_line.isComment(line)) continue;
if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
// A frontend's window is the size; only a session none is
@@ -812,7 +835,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
var held = !other and pane.fs.lock != null;
var it = Messages.init(p, req.data);
while (it.next()) |line_in| {
- if (line_in.len == 0) continue;
+ if (line_in.len == 0 or exec_line.isComment(line_in)) 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
@@ -1929,12 +1952,12 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag
p.update(.tick); // the pane's `new` is logged first
while (p.nextEffect()) |_| {}
const ctl_node = @intFromEnum(tree.TopFile.ctl);
- for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Unmount zzz\n").reply.status);
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n"));
- try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Unmount: no such mount zzz (x3)\n"));
+ try testing.expect(std.mem.indexOf(u8, text, "msg - Unmount") == null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
@@ -2445,6 +2468,81 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's"
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 "with no pane at all, the root look opens its file and the root exec runs in the session's directory" {
+ 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 = "file\n" });
+ var buf: [4096]u8 = undefined;
+ const session = buf[0..try tmp.dir.realPath(testing.io, &buf)];
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = @intCast(session.len);
+ const Empty = struct {
+ fn all(q: *Pardes) !void {
+ while (q.anyPane()) |k| _ = wr(q, Node.of(q.panes[k].?.serial, .ctl), "delete\n");
+ }
+ };
+ try Empty.all(p);
+ const look_node = @intFromEnum(tree.TopFile.look);
+ try testing.expectEqual(Status.ok, wr(p, look_node, "f.txt\n").reply.status);
+ var want: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&want, "{s}/f.txt", .{session});
+ var opened: usize = 0;
+ var scratches: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ opened += @intFromBool(std.mem.eql(u8, f.path, path));
+ scratches += @intFromBool(std.mem.endsWith(u8, f.path, "/+New"));
+ }
+ try testing.expectEqual(@as(usize, 1), opened);
+ try testing.expectEqual(@as(usize, 0), scratches);
+
+ try Empty.all(p);
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.exec), "echo hi\n").reply.status);
+ const made = p.panes[p.anyPane().?].?;
+ try testing.expect(made.command != null);
+ try testing.expectEqualStrings(session, made.cwdSlice());
+}
+
+test "a look at file:3:0 is refused as addr 3:0 is, a column counting from 1" {
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const by_addr = wr(p, Node.of(serial, .addr), "3:0");
+ const by_look = wr(p, Node.of(serial, .look), "/test.txt:3:0\n");
+ try testing.expectEqual(E.INVAL, by_addr.errno());
+ try testing.expectEqual(E.INVAL, by_look.errno());
+ try testing.expectEqualStrings(by_addr.reply.ename, by_look.reply.ename);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "/test.txt:3:1\n").reply.status);
+}
+
+test "Kill with a name and nothing running says so and does not fail, as bare Kill does" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const r = wr(p, @intFromEnum(tree.TopFile.ctl), "Kill zzz\n");
+ try testing.expectEqual(Status.ok, r.reply.status);
+ try testing.expect(th.logHas(p, "Kill: nothing running"));
+}
+
+test "a line starting with # runs as nothing at an exec or a ctl, silently" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ // The setup's own announcements are made by the first write's update.
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "# settle\n");
+ const before = p.next_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec), @intFromEnum(tree.TopFile.ctl), Node.of(serial, .ctl), Node.ofCol(layout.columnSerial(p, 0), .ctl) }) |node| {
+ try testing.expectEqual(Status.ok, wr(p, node, "# Platform: tty\n # indented too\n").reply.status);
+ }
+ try testing.expectEqual(before, p.next_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ try testing.expectEqualStrings(log_before.bytes, log_after.bytes);
+}
+
test "the ctl word name refuses a leading blank, as the name file does" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -3225,9 +3323,9 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
while (p.nextEffect()) |_| {}
const serial = serialOf(p);
for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| {
- const failed = wr(p, node, "Kill zzz\n");
+ const failed = wr(p, node, "Unmount zzz\n");
try testing.expectEqual(Status.err, failed.reply.status);
- try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Unmount: no such mount zzz") != null);
}
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
@@ -3235,8 +3333,8 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
try testing.expect(std.mem.indexOf(u8, text, "msg ") == null);
// The root's exec ran at the pane, so its err and the pane's exec's are
// the same record, counted.
- try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
- try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Unmount: no such mount zzz"));
+ try testing.expect(std.mem.indexOf(u8, text, "mount zzz (x2)") != null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
// A control character is refused with its reason.
try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename);
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index c6505f52..63405781 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -688,8 +688,17 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 {
}
/// Writing a Look or Exec record back performs the action it names.
-pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
- const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT);
+pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
+ const pane0 = p.panes[id] orelse return Reply.fail(asked.tag, E.NOENT);
+ // Its last record needs no newline, as every other line file's last
+ // line does not (`printf 'MX0 10' > event`).
+ var req = asked;
+ if (req.data.len > 0 and req.data[req.data.len - 1] != '\n') {
+ const whole = p.scratch.allocator().alloc(u8, req.data.len + 1) catch return Reply.fail(req.tag, E.NOMEM);
+ @memcpy(whole[0..req.data.len], req.data);
+ whole[req.data.len] = '\n';
+ req.data = whole;
+ }
const serial = pane0.serial;
{
const body = pane_files.bodyOf(pane0);
@@ -783,7 +792,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => unreachable,
}
}
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ return .{ .tag = req.tag, .written = @intCast(asked.data.len) };
}
// ---- tests ----
@@ -984,11 +993,17 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status);
try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+ // The last record needs no newline, as any line file's last line.
+ pane.msg_len = 0;
+ const bare = wr(p, event, "FX0 10");
+ try testing.expectEqual(Status.ok, bare.reply.status);
+ try testing.expectEqual(@as(u32, 6), bare.reply.written);
+ try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+
pane.msg_len = 0;
for ([_][]const u8{
"FX0 10\nFQ0 1\n", // unknown type character
"FX0 999\n", // out of range
- "FX0 10", // no newline
"FX5 1\n", // q0 > q1
"FD0 3\n", // a report, not a request
"F\n",
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 6a0fbba5..dc8b4b10 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -639,7 +639,7 @@ pub fn tagFault(data: []const u8) ?[]const u8 {
/// A tag write's limit, the same for a pane's, a column's and the
/// workspace's: whole or not at all.
-pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail});
+pub const e_tag_over = std.fmt.comptimePrint("no space: over {d} bytes", .{limits.max_tag_tail});
fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
@@ -867,11 +867,6 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
error.OutOfMemory => E.NOMEM,
else => E.INVAL,
});
- // A name into a directory not there yet asks for it, as Config's pane
- // does: its Save makes the directories (`pardes new/dir/f` forwarded).
- if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| {
- if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true;
- };
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -1319,7 +1314,7 @@ test "a tag write past the limit is refused whole, naming the limit" {
try testing.expectEqual(E.NOSPC, r.errno());
// `no space` is what 9ns maps to ENOSPC, so a shell through a mount
// sees the errno the tree gives.
- try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "no space: over "));
try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
}
@@ -1392,19 +1387,6 @@ test "a name with a component over 255 bytes is refused, and a long path's faile
try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory"));
}
-test "a name into a directory not there has Save make it; one into a directory there does not" {
- if (comptime !pardes.hosted) return error.SkipZigTest;
- const p = try withFile(testing.allocator, "x\n");
- defer p.deinit();
- const serial = serialOf(p);
- const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status);
- try testing.expect(f.make_dirs);
- f.make_dirs = false;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status);
- try testing.expect(!f.make_dirs);
-}
-
test "a name cut across writes is one name, applied once at its newline or its close" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 0f5202cc..2245255b 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -33,7 +33,17 @@ pub const pty_rows_min = 2;
/// As the root ctl's `size` (4096 a side).
const winsize_max = 4096;
-const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x{d} to {d}x{d}", .{ pty_rows_min, winsize_max, winsize_max });
+const e_winsize_rows = std.fmt.comptimePrint("invalid winsize: at least {d} rows", .{pty_rows_min});
+
+/// `winsize C R` whose R is a size but fewer rows than a pty takes.
+fn tooFewRows(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ _ = dimension(words.next() orelse return false) orelse return false;
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ return words.next() == null and rows < pty_rows_min;
+}
/// A winsize whose numbers are numbers but not sizes: said as such.
fn outOfRange(line: []const u8) bool {
@@ -79,7 +89,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused|
return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell");
};
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (tooFewRows(line)) e_winsize_rows else if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -93,8 +103,10 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
// Two rows at least, as a pane is laid out: a one-row pty
- // loses its prompt's input mark and reads busy for ever.
- const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
+ // loses its prompt's input mark and reads busy for ever. Fewer
+ // is refused, never quietly raised.
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ if (rows < pty_rows_min) return false;
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -619,6 +631,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.winsize == null);
}
+ // One row is a size, but fewer than a pty takes: refused, and said so.
+ const one = wr(p, ctl, "winsize 80 1");
+ try testing.expectEqual(E.INVAL, one.errno());
+ try testing.expectEqualStrings("invalid winsize: at least 2 rows", one.reply.ename);
+ try testing.expect(one.winsize == null);
+ try testing.expectEqualStrings("invalid winsize: out of range, 1x2 to 4096x4096", e_winsize_range);
+
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
@@ -960,6 +979,7 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
var got = sh.answered(p, node, h, into);
try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+
// A resize between its start and end reflows the pins with the text.
h = sh.send(p, node);
sh.put(p, c ++ "one\r\ntwo\r\n");
@@ -1022,6 +1042,12 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
sh.put(p, d ++ prompt);
p.gpa = gpa;
try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into));
+ // An end drawn above the start (the prompt redrawn over what was
+ // printed, as in a window two rows high): cut, never an empty whole.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "printed\r\nmore\r\n\x1b[4A" ++ d ++ prompt);
+ got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
}
test "a terminal's ctl counts its body, and a new directory is a rename in the log" {
diff --git a/src/pardes.zig b/src/pardes.zig
index f0d22d57..b16d39b7 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1112,7 +1112,33 @@ test "first document fallback splits like Tty from the originating pane" {
};
}
-test "Lspwhy after a Hover asks to explain that hover, at its place" {
+test "a frame no one watches is not drawn, and is drawn once someone does" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const Watch = struct {
+ var presents: usize = 0;
+ var seen = false;
+ const vtable: Host.VTable = .{ .present = present, .watched = watched };
+ fn present(_: ?*anyopaque, _: *const Surface) void {
+ presents += 1;
+ }
+ fn watched(_: ?*anyopaque) bool {
+ return seen;
+ }
+ };
+ Watch.presents = 0;
+ Watch.seen = false;
+ p.needs_frame = true;
+ try p.pump(.{ .vtable = &Watch.vtable });
+ try std.testing.expectEqual(@as(usize, 0), Watch.presents);
+ try std.testing.expect(p.needs_frame); // still owed
+ Watch.seen = true;
+ try p.pump(.{ .vtable = &Watch.vtable });
+ try std.testing.expectEqual(@as(usize, 1), Watch.presents);
+}
+
+test "Lspwhy after a Hover asks to explain that hover, in its file at its place, run from any pane" {
if (!lsp.supports.contains(.hover)) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -1131,12 +1157,16 @@ test "Lspwhy after a Hover asks to explain that hover, at its place" {
pane.body.cur_row = 0;
pane.body.cur_col = 0;
p.lsp_wait = null;
- p.lspRequest(0, .explain, "");
+ // Run from another file's pane: still the hover's file and place.
+ const other = p.freeSlot().?;
+ _ = try p.openTutorView(other);
+ p.lspRequest(other, .explain, "");
var seen = false;
while (p.nextEffect()) |e| if (e == .lsp) {
try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind);
try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice());
try std.testing.expectEqual(hover_at, e.lsp.offset);
+ try std.testing.expectEqual(@as(u8, 0), e.lsp.pane);
seen = true;
};
try std.testing.expect(seen);
@@ -4971,8 +5001,13 @@ pub const Pardes = struct {
// An empty window (Delcol took the last column): its first column.
if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c);
const col = p.keyboardColumn(from_id) orelse return;
+ // A trial: laying it out gives it a serial (layout.compute), which a
+ // refusal takes back. Every other column has its own already, so
+ // the counter as it is now is the trial's to return to.
+ for (0..p.ncol) |k| _ = layout.columnSerial(p, k);
+ const unspent = p.next_column_serial;
// Said, not silently nothing: "no space" is 9ns's word for ENOSPC.
- const c = layout.insertColumn(p, col, false) orelse {
+ const c = layout.insertColumnUnannounced(p, col, false) orelse {
var said: [96]u8 = undefined;
return p.reportFailure(from_id, if (p.ncol >= MAX_COLS)
std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column"
@@ -4984,10 +5019,13 @@ pub const Pardes = struct {
// out again, and where they cannot hold every minimum there is no
// new column, as a size too small is refused.
for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) {
- layout.dropColumn(p, c);
+ layout.dropColumnUnannounced(p, c);
+ p.next_column_serial = unspent;
layout.compute(p);
return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit");
};
+ // Kept: now it has a serial, and the log hears of it.
+ layout.noteColumn(p, "newcol", layout.columnSerial(p, c));
tagline.enterHeader(p, c);
}
@@ -5389,6 +5427,15 @@ pub const Pardes = struct {
p.noteLateFailure(id);
}
+ /// A Save makes the directories its file goes in, and any above them,
+ /// first: one rule, for the pane's own name and `Save path` alike. A
+ /// directory it cannot make leaves the write to fail and say why.
+ fn makeParents(path: []const u8) void {
+ if (comptime !hosted) return;
+ const local = filesystem.localPath(path) orelse return;
+ filesystem.makeDirs(std.fs.path.dirname(local) orelse "/");
+ }
+
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
@@ -5478,8 +5525,7 @@ pub const Pardes = struct {
const was = f.saved_revision;
const failures = p.fs.failures;
f.saved_revision = f.revision;
- if (f.make_dirs) if (filesystem.localPath(f.path)) |local|
- filesystem.makeDirs(std.fs.path.dirname(local) orelse "/");
+ makeParents(f.path);
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
@@ -5508,6 +5554,7 @@ pub const Pardes = struct {
if (pane.file) |*f| {
const was = f.saved_revision;
const failures = p.fs.failures;
+ makeParents(st.path.slice());
p.hostWriteFile(st.pane, st.path.slice(), f.content);
const after = p.panes[st.pane] orelse return;
if (after.serial != st.serial) return;
@@ -5523,6 +5570,7 @@ pub const Pardes = struct {
if (!pane.isTerminal()) return;
const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);
+ makeParents(st.path.slice());
p.hostWriteFile(st.pane, st.path.slice(), text);
},
.write_dump => {
@@ -5603,6 +5651,10 @@ pub const Pardes = struct {
// A shell with a clock draws what `advance` stepped; one without
// steps its own `.tick`s and draws while anything animates.
if (!p.needs_frame and !p.continuous() and (v.now != null or !p.animationActive())) return;
+ // No one to see it: the frame stays owed (needs_frame), not drawn.
+ // A pty streaming into an unwatched session spent most of its time
+ // drawing what nobody looked at.
+ if (v.watched) |f| if (!f(h.ctx)) return;
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
@@ -6307,8 +6359,12 @@ pub const Pardes = struct {
pub const SavePath = Effect.Buf(effect_path_cap);
- pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
+ pub fn lspRequest(p: *Pardes, asked: usize, kind: lsp.Kind, arg: []const u8) void {
if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary
+ // Lspwhy narrates the last query where it was asked -- that pane's
+ // file, place and argument -- from wherever it is run.
+ const last_pane: ?usize = if (kind == .explain) (if (p.lsp_last) |last| p.paneBySerial(last.serial) else null) else null;
+ const id = last_pane orelse asked;
var said: [128]u8 = undefined;
if (!lsp.supports.contains(kind))
return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: no language server here answers it", .{lspWord(kind)}) catch "no language server answers it");
@@ -6338,7 +6394,7 @@ pub const Pardes = struct {
if (kind == .explain) {
if (p.lsp_last) |last| {
sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch "";
- if (last.serial == pane.serial) at = last.offset;
+ if (last_pane != null) at = last.offset;
}
} else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) };
p.lsp_seq +%= 1;
diff --git a/src/regexp.zig b/src/regexp.zig
index 575e6861..86132f60 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -47,6 +47,14 @@ pub const Regex = struct {
/// mvzr steps left to every search with this compiled pattern together,
/// so that addr's backward scan and s/S's many calls share one bound.
steps: u64 = budget,
+ /// Where `find` last learned a line's start in a text (by its address
+ /// and length): the line holding `at` starts at `start`. A backward
+ /// search asks again at each match along a line, and looking back to
+ /// the line's start from the top each time was quadratic on a long one.
+ line_hint: struct { ptr: usize = 0, len: usize = 0, at: usize = 0, start: usize = 0 } = .{},
+ /// ...and where a line learned to end: the line from `start` ends at
+ /// `end` (its newline, or `hi`), asked again at each match along it.
+ end_hint: struct { ptr: usize = 0, len: usize = 0, start: usize = 0, hi: usize = 0, end: usize = 0 } = .{},
/// Timed on this machine's ReleaseSafe build: 10M steps of the worst
/// patterns take 45-80 ms, and a Debug build is ten times slower.
@@ -324,6 +332,28 @@ pub const Regex = struct {
pub const Match = struct { start: usize, end: usize };
+ /// Where the line from `start` ends (its newline, else `hi`), `at` on
+ /// it: known when asked before of this line of this text.
+ fn lineEnd(rx: *Regex, text: []const u8, start: usize, at: usize, hi: usize) usize {
+ const h = rx.end_hint;
+ if (h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.start == start and h.hi == hi and at <= h.end) return h.end;
+ // No newline between the line's start and `at`: the look starts there.
+ const end = std.mem.indexOfScalarPos(u8, text[0..hi], at, '\n') orelse hi;
+ rx.end_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .start = start, .hi = hi, .end = end };
+ return end;
+ }
+
+ /// Where the line holding `from` starts, looking back only to where
+ /// the last call learned one in this text.
+ fn lineStart(rx: *Regex, text: []const u8, from: usize) usize {
+ const h = rx.line_hint;
+ const known = h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.at <= from;
+ const lo = if (known) h.at else 0;
+ const s = if (std.mem.lastIndexOfScalar(u8, text[lo..from], '\n')) |nl| lo + nl + 1 else if (known) h.start else 0;
+ rx.line_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .at = from, .start = s };
+ return s;
+ }
+
/// The first match that starts in `from..=last` and ends by `hi`, as
/// offsets into `text`. The text before `from` still says where lines
/// begin.
@@ -345,7 +375,7 @@ pub const Regex = struct {
}
return null;
}
- var start: usize = if (rx.spans) 0 else if (std.mem.lastIndexOfScalar(u8, text[0..from], '\n')) |nl| nl + 1 else 0;
+ var start: usize = if (rx.spans) 0 else rx.lineStart(text, from);
var at = from - start;
// `^` cannot match in the middle of a line.
if (!rx.spans and rx.bol and at > 0) {
@@ -359,7 +389,9 @@ pub const Regex = struct {
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;
+ // `start` begins a line and `start + at` is on it: the
+ // look back ends there.
+ const line_start = if (std.mem.lastIndexOfScalar(u8, text[start + at .. q], '\n')) |nl| start + at + nl + 1 else start;
if (line_start > start) {
start = line_start;
at = q - line_start;
@@ -371,7 +403,7 @@ pub const Regex = struct {
}
}
}
- const end = if (rx.spans) hi else std.mem.indexOfScalarPos(u8, text[0..hi], start, '\n') orelse hi;
+ const end = if (rx.spans) hi else rx.lineEnd(text, start, start + at, hi);
const line = text[start..end];
// matchPos finds nothing at a haystack's very end, where `$` or
// an empty match still can.
diff --git a/src/terminal.zig b/src/terminal.zig
index 4751a09f..7874ca89 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -1093,9 +1093,14 @@ pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !Comm
const screen = state.vt.screens.get(.primary) orelse break :printed false;
if (end.garbage) break :printed false;
var whole = !marks.lost and !start.garbage;
+ // Nothing printed: the whole of it.
+ if (start.eql(end.*)) break :printed whole;
// D is where the next prompt begins: the output ends a cell before.
- const last = end.leftWrap(1) orelse break :printed whole;
- if (start.eql(end.*) or last.before(start.*)) break :printed whole;
+ // An end at the very top, or before the start, is output a redraw
+ // moved (a prompt drawn over it in a window two rows high): how
+ // much is not known, so it is not called whole.
+ const last = end.leftWrap(1) orelse break :printed false;
+ if (last.before(start.*)) break :printed false;
var from = start.*;
if (last.up(max_rows)) |floor| if (from.before(floor)) {
from = floor;
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index ef7f2761..1d7ff415 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1083,6 +1083,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
defer vx.exitAltScreen(tty.writer()) catch {};
try vx.setMouseMode(tty.writer(), true);
try vx.setBracketedPaste(tty.writer(), true);
+ Killed.arm(tty.fd.handle, tty.termios);
+ defer Killed.disarm();
if (attach != null) {
attach_end = attachSession(init, attach_name, &tty, &vx);
@@ -1119,6 +1121,40 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
}
}
+/// SIGTERM or SIGHUP: the terminal put back as a clean exit leaves it --
+/// the main screen, no mouse, paste or keyboard modes, the cursor shown,
+/// cooked termios -- and then the signal's own death. A nested editor
+/// killed in a pane must not leave that pane's terminal wedged.
+/// Async-signal-safe: write, tcsetattr, sigaction, raise.
+const Killed = struct {
+ var fd: posix.fd_t = -1;
+ var cooked: posix.termios = undefined;
+ const reset = "\x1b[<u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l";
+ const signals = [_]posix.SIG{ .TERM, .HUP };
+
+ fn arm(tty_fd: posix.fd_t, termios: posix.termios) void {
+ cooked = termios;
+ fd = tty_fd;
+ const on: posix.Sigaction = .{ .handler = .{ .handler = handle }, .mask = posix.sigemptyset(), .flags = posix.SA.RESETHAND };
+ for (signals) |sig| posix.sigaction(sig, &on, null);
+ }
+
+ fn disarm() void {
+ fd = -1;
+ const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 };
+ for (signals) |sig| posix.sigaction(sig, &default, null);
+ }
+
+ fn handle(sig: posix.SIG) callconv(.c) void {
+ if (fd >= 0) {
+ _ = std.c.write(fd, reset, reset.len);
+ _ = std.c.tcsetattr(fd, .FLUSH, &cooked);
+ }
+ // SA_RESETHAND put the default back: the same signal, now fatal.
+ _ = std.c.raise(sig);
+ }
+};
+
fn localSession(
init: std.process.Init,
opts: pardes.Options,
diff --git a/test/fs.py b/test/fs.py
index a6cd332e..b02b9e45 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -4,6 +4,7 @@ import fcntl
import json
import os
import shutil
+import select
import signal
from pathlib import Path
import subprocess
@@ -380,8 +381,18 @@ def discovery(binary, embedded=False):
# Through a mount both read as EINVAL; the log says which and why.
assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log')
client.remove(f'/pane/{scratch}')
- # A save the host cannot do fails the write that asked for it,
- # and the log's err record names the path and why.
+ # A Save makes the directories its file goes in, whatever names
+ # it: the pane's own name or Save's path.
+ made_dirs = new_pane(client, b'deep\n')
+ client.write(f'/pane/{made_dirs}/ctl', f'Save {root}/save-made/a/b/x.txt\n'.encode())
+ assert (root / 'save-made/a/b/x.txt').read_bytes() == b'deep\n'
+ client.write(f'/pane/{made_dirs}/name', f'{root}/save-named/c/y.txt\n'.encode())
+ client.write(f'/pane/{made_dirs}/ctl', b'Save\n')
+ assert (root / 'save-named/c/y.txt').read_bytes() == b'deep\n'
+ client.remove(f'/pane/{made_dirs}')
+ # A save the host cannot do (a directory it may not make) fails
+ # the write that asked for it, and the log's err record names
+ # the path and why.
unsaved = new_pane(client, b'unsaved\n')
try:
client.write(f'/pane/{unsaved}/ctl', b'Save /nonexistent-pardes-dir/x.txt\n')
@@ -435,9 +446,14 @@ def run_file(binary):
assert run(client, term, b'fi\n') == b'error not run\n' # a syntax error runs nothing
assert run(client, term, b"echo 'open\n") == b'error not run\n' # nor does an open quote
assert run(client, term, b'true\n') == b'exit 0\n' # ...and the shell is clear after
- # A pty asked for one row gets two: one loses the prompt's input
- # mark, and the shell would read busy for ever.
- client.write(f'/pane/{term}/pty/ctl', b'winsize 20 1\n')
+ # A pty is not given one row (it loses the prompt's input mark,
+ # and the shell would read busy for ever): refused, and said.
+ try:
+ client.write(f'/pane/{term}/pty/ctl', b'winsize 20 1\n')
+ raise AssertionError('winsize 20 1 was taken')
+ except OSError as refused:
+ assert 'at least 2 rows' in str(refused), refused
+ client.write(f'/pane/{term}/pty/ctl', b'winsize 20 2\n')
time.sleep(.5)
assert run(client, term, b'true\n') == b'exit 0\n'
assert client.read(f'/pane/{term}/pty/ctl').split()[2] == b'2', client.read(f'/pane/{term}/pty/ctl')
@@ -1219,11 +1235,18 @@ def test(binary, quic=False):
assert client.read('/index') == before
assert client.read('/pane/1/body') == b'initial\n'
+ # A session that answers takes the file or says why, as acme's B
+ # does: a pane it has not is its refusal and exit 1, never a
+ # nested editor drawn inside the pane that asked.
+ gone = subprocess.run([binary, '--tty', str(spaced)], cwd=root,
+ env=dict(env, PARDES_PANE='4294967295'), stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
+ assert gone.returncode == 1 and gone.stderr.startswith(b'pardes: '), gone
+ assert client.read('/index') == before
for name, identity, word in [
('stale-file', dict(inherited, PARDES_9P=str(root / 'absent.sock')), str(spaced)),
('stale-missing', dict(inherited, PARDES_9P=str(root / 'absent.sock')), 'missing-child-file.txt'),
('stale-noarg', dict(inherited, PARDES_9P=str(root / 'absent.sock')), None),
- ('stale-pane', dict(inherited, PARDES_PANE='4294967295'), str(spaced)),
# No pid means "not inside a pardes at all", which is what
# --nested withholds and what a plain shell has.
('not-nested', {k: v for k, v in inherited.items() if k != 'PARDES_PID'}, str(spaced)),
@@ -1310,10 +1333,59 @@ def test(binary, quic=False):
execute(client, named, 'Save')
client.remove(f'/pane/{named}')
assert deep.read_bytes() == b'deep\n'
+ # A name the session refuses: its reason and exit 1, as B's,
+ # never a nested editor, and no pane left for it.
+ before = serials()
+ refused = subprocess.run([binary, 'bad\x01name.txt'], cwd=root, env=env,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
+ assert refused.returncode == 1, refused
+ assert b'bad character in file name' in refused.stderr, refused.stderr
+ assert serials() == before, (serials(), before)
orphan = launch('--wait')
assert orphan.poll() is None
assert orphan.wait(timeout=5) == 1
+ # Killed with SIGTERM or SIGHUP, a tty editor puts its terminal
+ # back (main screen, cooked mode) before it dies: a nested one
+ # killed in a pane leaves that pane usable.
+ for sig in (signal.SIGTERM, signal.SIGHUP):
+ master, slave = os.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0))
+ cooked = termios.tcgetattr(slave)
+ env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color', PARDES_NOTIME='1')
+ killed = subprocess.Popen([binary, '--tty', '--9p=killed', 'x.txt'], cwd=root, env=env,
+ stdin=slave, stdout=slave, stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0))
+ seen = b''
+ try:
+ deadline = time.monotonic() + 10
+ while b'\x1b[?1049h' not in seen:
+ assert time.monotonic() < deadline, seen[-200:]
+ if select.select([master], [], [], .1)[0]:
+ seen += os.read(master, 65536)
+ time.sleep(.3)
+ killed.send_signal(sig)
+ assert killed.wait(timeout=5) == -sig
+ while select.select([master], [], [], .2)[0]:
+ try:
+ chunk = os.read(master, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ seen += chunk
+ tail = seen[seen.rindex(b'\x1b[?1049h'):]
+ assert b'\x1b[?1049l' in tail, tail[-200:]
+ assert termios.tcgetattr(slave)[3] & termios.ICANON == cooked[3] & termios.ICANON
+ finally:
+ if killed.poll() is None:
+ killed.kill()
+ killed.wait()
+ os.close(master)
+ os.close(slave)
+
# A launch with no terminal to draw on (a pty that is no one's
# controlling terminal) says so and exits 1, no error trace.
master, slave = os.openpty()
diff --git a/test/monkey9p.py b/test/monkey9p.py
index 7ab6cb0c..4d190bbc 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -1518,6 +1518,9 @@ def one_failure_rule(ctx, res):
# line cut across Twrites runs a piece a Twrite, and a piece that ran
# (a Save) says so while a later one is refused.
succeeded = [w for w in writes if w[2] is True and w[0] == 'write']
+ # A file watch's notice (`<file> deleted on disk ...`, `changed on
+ # disk`) is the host's news, said whenever it arrives: no write's.
+ msgs = [m for m in msgs if ' deleted on disk (' not in m and ' changed on disk (' not in m]
if failed and msgs and not succeeded:
return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}'
return None
diff --git a/test/selfmount.py b/test/selfmount.py
index 55809740..6534aa25 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -138,6 +138,13 @@ def run(binary):
check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount')
body = client.read(f'/pane/{scratch}/body').decode().splitlines()
check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)')
+ # Forty event reads held through the mount, a follower each, and
+ # the mount still answers the rest: 9ns keeps workers past what
+ # the editor holds (128), so `cat layout` is not queued behind
+ # them for ever.
+ held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; "
+ f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode())
+ check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})')
client.write('/exec', b'Msg still here\n')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('still here' in text, 'the editor answers after all of that')