summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 17:27:30 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit63d584a0ba7806759f7139e87f45e411c9a1573d (patch)
tree00ec42e25dfcc0497162511041cc88d757ff9190
parentcf45be79a5ab96d3b81078e7e5e2ba58bd5e5cee (diff)
downloadpardes-63d584a0ba7806759f7139e87f45e411c9a1573d.tar.gz
pardes-63d584a0ba7806759f7139e87f45e411c9a1573d.zip
A Save the host cannot do fails the 9P write that asked for it, and the log names the path and why
The write answered ok and the pane stayed dirty, with `save: AccessDenied` on its message row: a script saw success. The failure now says `Save <path>: <why>`, and a 9P write that waited on the save fails with EIO and that text, logged as an err record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/9p_io.zig8
-rw-r--r--src/detached/server.zig2
-rw-r--r--src/fs.zig5
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/macos.zig2
-rw-r--r--src/pardes.zig16
-rw-r--r--src/tty/tty.zig2
-rw-r--r--test/fs.py10
8 files changed, 39 insertions, 8 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 2ca11a87..6bd03049 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -296,7 +296,15 @@ pub const Listener = struct {
// Set with the turn still held, so `reset` sees it and waits.
if (restoring) l.restore_writer.store(true, .release);
defer if (restoring) l.restore_writer.store(false, .release);
+ core.fs.late_failure_len = 0;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
+ // ponytail: one slot, so a failure of another client's effects that
+ // settle in the same wait is told to this write too.
+ if (l.core == core and core.fs.late_failure_len != 0) {
+ const failed = pardes.ctlfs.failText(req.tag, pardes.ctlfs.E.IO, core.fs.late_failure[0..core.fs.late_failure_len]);
+ pardes.ctlfs.events.noteError(core, req, failed);
+ return conn.reply(&failed, "");
+ }
// The Restore's own write is answered once it is done, and before
// `reset` hangs every connection up, this one too, so the writer
// hears that it happened rather than a cut; a failed one leaves the
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 5374bf73..3921d8d9 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -555,7 +555,7 @@ pub const Session = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
.text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
diff --git a/src/fs.zig b/src/fs.zig
index b2e2751d..2dc3394b 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1263,6 +1263,11 @@ pub const Namespace = struct {
refused: bool = false,
failure: [256]u8 = undefined,
failure_len: u16 = 0,
+ /// What went wrong performing a write's effects after it was answered
+ /// by the core (a save the host could not do): the 9P write, which
+ /// waits for them, fails with it (src/9p_io.zig).
+ late_failure: [256]u8 = undefined,
+ late_failure_len: u16 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [320]u8 = undefined,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 5bc1ea6d..84ec1c5c 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4422,7 +4422,7 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| {
if (std.mem.eql(u8, f.path, path))
if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) {
diff --git a/src/macos.zig b/src/macos.zig
index b472e6cc..b180d7ba 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2487,7 +2487,7 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const st = hostState(ctx);
filesystem.write(st.core, path, bytes) catch |err|
- return st.core.saveFailed(pane, "save", err);
+ return st.core.saveFailed(pane, path, err);
st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes));
var mbuf: [256]u8 = undefined;
st.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
diff --git a/src/pardes.zig b/src/pardes.zig
index f559b125..581f616e 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -2261,7 +2261,7 @@ test "a save the host could not do leaves the pane dirty" {
const Refusing = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void {
const core: *Pardes = @ptrCast(@alignCast(ctx.?));
- core.saveFailed(pane, "save", error.PermissionDenied);
+ core.saveFailed(pane, "/x/y.txt", error.PermissionDenied);
}
};
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -4906,15 +4906,23 @@ pub const Pardes = struct {
if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes);
// The in-process filesystem reports the same way a real host does, so
// an OOM here leaves the pane dirty rather than looking saved.
- if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory);
+ if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, path, error.OutOfMemory);
}
- pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void {
+ /// A write of `path` the host could not do: the pane stays dirty, the
+ /// message row says `Save <path>: <why>`, and the 9P write that asked
+ /// for it, waiting on the save, fails with that (`fs.late_failure`).
+ 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".
f.saved_revision = f.revision -% 1;
};
- p.reportError(id, what, err);
+ var what: [limits.host_path_cap + 8]u8 = undefined;
+ p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
+ const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
+ const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
+ @memcpy(p.fs.late_failure[0..kept.len], kept);
+ p.fs.late_failure_len = @intCast(kept.len);
}
/// The path a watch is about: a real file's, or a PDF's.
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 822c2d7f..d17316e8 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1314,7 +1314,7 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
.text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
diff --git a/test/fs.py b/test/fs.py
index 53a9d4df..72c52b7e 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -360,6 +360,16 @@ 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.
+ unsaved = new_pane(client, b'unsaved\n')
+ try:
+ client.write(f'/pane/{unsaved}/ctl', b'Save /nonexistent-pardes-dir/x.txt\n')
+ raise AssertionError('a Save into a missing directory succeeded')
+ except OSError as refused:
+ assert 'Save /nonexistent-pardes-dir/x.txt: ' in str(refused), refused
+ assert f'err {unsaved} ctl: Save /nonexistent-pardes-dir/x.txt: '.encode() in client.read('/log'), client.read('/log')
+ client.remove(f'/pane/{unsaved}')
print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock, focus, the ctl split and commands behave')