diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 13:05:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | dc37b4ea777b7eb881a993e737457c18699c5b2c (patch) | |
| tree | 3f65229ed1e4c41090cf9fd10fbdf6d640652578 | |
| parent | ebcb870ac8ea962fd705068c7aaff935c5a5c15b (diff) | |
| download | pardes-dc37b4ea777b7eb881a993e737457c18699c5b2c.tar.gz pardes-dc37b4ea777b7eb881a993e737457c18699c5b2c.zip | |
pardes FILE in a pane whose session answers never falls back to a nested editor: the refusal is printed, exit 1, and no stray pane is left
When the environment named a live session that answered, several
failures broke out of forwarding and started a whole editor inside the
pane that asked: a refused name, a pane the session has not, a failed look
write. Its screen was drawn over the shell. Now, once the session answers
(Client.probe), every refusal is printed as `pardes: <file>: <why>`, in
the session's own words (the Rerror, now kept by the client), and the
launch exits 1, as acme's B does. A new name the session refuses deletes
the pane made for it, so no empty +New is left. A missing environment or a
session that does not answer still starts a separate editor. fs.py checks
a refused name and a stale PARDES_PANE.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | docs/fs.md | 9 | ||||
| -rw-r--r-- | src/9p_io.zig | 17 | ||||
| -rw-r--r-- | src/main.zig | 60 | ||||
| -rw-r--r-- | test/fs.py | 17 |
4 files changed, 80 insertions, 23 deletions
@@ -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 diff --git a/src/9p_io.zig b/src/9p_io.zig index d1b2099c..5a68238c 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -1808,7 +1808,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 +1838,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/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; @@ -1219,11 +1219,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,6 +1317,14 @@ 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 |
