From d89c0b532df23ed5b48495f83725893d5d82042b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 3 Sep 2026 15:39:43 -0300 Subject: errors: a save that could not happen, and two panics on an ordinary click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of what this program does when the environment says no. The finding that reframes it: there were almost NO panics on ordinary paths — the rule already held — but there was a great deal of silence, and one case worse than any panic. SILENT DATA LOSS ON SAVE. `saveFile` marked the pane saved the moment it QUEUED the effect, before any host had tried; `host_io.writeFd` returned void, so a short or failed write was indistinguishable from a complete one; and `writeFileBytes` returned true regardless. A save to a read-only file, or into a directory removed under the pane, therefore cleared the tag's ` *` and posted nothing — and `Del` makes no dirty check, so the next click threw the edits away with the screen saying they were safe. On a full disk it was worse: the file is already `O_TRUNC`'d when `write` fails, so the message row said `saved` over a file that had just been emptied. Now: `writeFd` reports, `writeFileBytes` returns WHY (`PermissionDenied`, `NoSpaceLeft`, `ReadOnlyFilesystem`, …) including a failed `close`, which is where write-back filesystems report at all; the core marks the pane saved around `perform` rather than at emit, which is also where the bytes are read; and a host that could not write calls `Pardes.saveFailed`, which puts the reason on the message row and takes the clean mark back. That is a CALL and not a return value because host.zig enforces, at comptime, that a `push_` method reaching every host in a fan-out cannot have one answer — the first attempt at this changed the signature and the compiler was right to refuse it. TWO PANICS ON AN ORDINARY KEYSTROKE, in look.zig's number scans. `v = v * 10 + d` over caller-supplied digits, reached from `parsePathLine` and the `@pN` scan — which every Look, every right-click and every n/N motion runs on whatever word is under the pointer. A hash in a log, a CSV column, any output shaped `foo:99999999999999999999`, and the editor died with "integer overflow". Both saturate now, the same way acmefs.zig's address parser already did; a saturated line is refused by `file_pane.open`'s `line <= total` and a saturated pane id by `focusPaneLine`'s `id < MAX_PANES`, so nothing addressable changes. A BOOT FILE THAT WILL NOT OPEN joins the missing-name case in the `+Errors` pane instead of taking the launch down: `pardes /root` resolves as a `.file`, could not be read, and left `error: PermissionDenied` and a return trace. `look.readFile` now says which errno it was, so the pane can say "permission denied" rather than a word from the source code. The tag-marker test drained no effects and passed anyway, which is exactly the defect; it drains now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf --- src/host_io.zig | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) (limited to 'src/host_io.zig') diff --git a/src/host_io.zig b/src/host_io.zig index 001446b1..6ffc890e 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -136,16 +136,43 @@ pub fn forkShell( /// Truncate-or-create `path` and put `bytes` there. False on any failure, and /// the caller reports it: a save that did not happen must not be announced as /// one. -pub fn writeFileBytes(path: []const u8, bytes: []const u8) bool { +/// WHY it failed, and not merely that it did. A save is the one operation in +/// this program whose failure a user must not be able to miss, and until this +/// returned an error there was nothing for a host to put on the message row: +/// the bool said "no" and every caller answered it with a bare `return`. +/// `NoSpaceLeft` is the one that most needs saying — the file has already been +/// truncated by the time it happens, so a save that reports nothing has +/// destroyed the file it was asked to preserve. +pub const WriteError = error{ + PathTooLong, + PermissionDenied, + IsDirectory, + ReadOnlyFilesystem, + NoSpaceLeft, + OpenFailed, + WriteFailed, +}; + +pub fn writeFileBytes(path: []const u8, bytes: []const u8) WriteError!void { var pathbuf: [4096:0]u8 = undefined; - if (path.len >= pathbuf.len) return false; + if (path.len >= pathbuf.len) return error.PathTooLong; @memcpy(pathbuf[0..path.len], path); pathbuf[path.len] = 0; const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return false; - writeFd(fd, bytes); - _ = libc.close(fd); - return true; + if (fd < 0) return switch (libc.errno(fd)) { + .ACCES, .PERM => error.PermissionDenied, + .ISDIR => error.IsDirectory, + .ROFS => error.ReadOnlyFilesystem, + .NOSPC, .DQUOT => error.NoSpaceLeft, + .NAMETOOLONG => error.PathTooLong, + else => error.OpenFailed, + }; + const wrote = writeFd(fd, bytes); + // The close is part of the write. NFS and every write-back filesystem + // report a deferred error here and nowhere else, so a close that fails on a + // file we believe we wrote is a file we did not write. + const closed = libc.close(fd) == 0; + if (!wrote or !closed) return error.WriteFailed; } /// A whole-buffer write that finishes short writes, retries EINTR, and refuses @@ -164,15 +191,20 @@ pub fn writeFileBytes(path: []const u8, bytes: []const u8) bool { /// matters more now that detached/server.zig reaches this file from a /// single-threaded poll loop: a blocked `write` is one syscall a signal can /// interrupt, and a spin is 100% of a core with the whole session behind it. -pub fn writeFd(fd: c_int, data: []const u8) void { +/// True when every byte went. The answer is new: this used to return `void`, so +/// a full disk and a completed write were the same event to every caller — and +/// the one caller that matters had already truncated the file. A pty write +/// ignores it, which is what `_ =` at those call sites means. +pub fn writeFd(fd: c_int, data: []const u8) bool { var off: usize = 0; while (off < data.len) { const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; - return; + return false; } - if (n == 0) return; + if (n == 0) return false; off += @intCast(n); } + return true; } -- cgit v1.3