summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 13:05:18 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commitdc37b4ea777b7eb881a993e737457c18699c5b2c (patch)
tree3f65229ed1e4c41090cf9fd10fbdf6d640652578
parentebcb870ac8ea962fd705068c7aaff935c5a5c15b (diff)
downloadpardes-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.md9
-rw-r--r--src/9p_io.zig17
-rw-r--r--src/main.zig60
-rw-r--r--test/fs.py17
4 files changed, 80 insertions, 23 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 6a82b633..2660bada 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
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;
diff --git a/test/fs.py b/test/fs.py
index a6cd332e..98f46900 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -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