summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-03 15:45:52 -0300
committerGabriel Schneider <[email protected]>2026-09-03 15:45:52 -0300
commite7cc89761e693833fe3fcabf022408741cf87709 (patch)
tree33385b2c96e21e3ef1293d5be9a0a6c4e2e272fb /src
parentd89c0b532df23ed5b48495f83725893d5d82042b (diff)
downloadpardes-e7cc89761e693833fe3fcabf022408741cf87709.tar.gz
pardes-e7cc89761e693833fe3fcabf022408741cf87709.zip
errors: a mistyped flag is a sentence, and a pipe is not a document
Every argv refusal in main.zig was `return error.BadArgs` out of `main`, which std prints as `error: BadArgs` with a return trace under it — the same shape a real crash has, for the most ordinary thing a person can do. It also said `BadArgs` and nothing about which argument, at sites that knew exactly: pardes: no such option: --hepl pardes: -n takes 1 or 3, not 'abc' pardes: one file or directory at a time, and 'b' is the second pardes: --detach and --attach are opposites: one runs the session, the other joins one Try 'pardes --help'. stderr rather than the `+Errors` pane one function down, because argv is read before a core exists and the person who mistyped a flag is looking at the prompt they typed it into. `--attach`'s refusal already answered this way; now all eleven do. `getcwd` failing is no longer reported as an argument problem, and a `.url` or `@pN` positional says why a LAUNCH cannot act on it rather than being swept into the same word as a typo. AND `Look` ON A FIFO NO LONGER FREEZES THE EDITOR. `readFile` opened with a plain blocking `open`, so a named pipe with no writer waited forever — inside the keystroke that asked, with no frame, no message row and, in the tty shell, no Ctrl-C either, because the terminal is in raw mode. It is `O_NONBLOCK` now, the read loops answer `EAGAIN` rather than waiting, and `lseek` answering ESPIPE — a pipe, a socket, a terminal — is refused as `NotAFile`, which the boot pane spells "that is a pipe or a device, not a document". Without that last part an unwritten FIFO read as EOF and opened a silent empty pane, which says less than the hang did. The zero-size files worth streaming (procfs and its kin) seek fine and are untouched. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
Diffstat (limited to 'src')
-rw-r--r--src/look.zig30
-rw-r--r--src/main.zig64
-rw-r--r--src/pardes.zig1
3 files changed, 81 insertions, 14 deletions
diff --git a/src/look.zig b/src/look.zig
index f5d1488c..8c7014f5 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -857,7 +857,15 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
}
var pathbuf: [4096]u8 = undefined;
const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
- const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ // NONBLOCK, and it is the difference between an error and a dead editor.
+ // A plain blocking `open` of a FIFO waits for a writer that may never come,
+ // and this call runs INSIDE the keystroke that asked for it — no frame, no
+ // message row, and in the tty shell no Ctrl-C either, because the terminal
+ // is in raw mode. `Look` on a named pipe (or on a device that blocks until
+ // carrier) froze the whole program with nothing on screen to say why. The
+ // flag is cleared again below for the file kinds that are worth reading;
+ // the ones that are not are refused by name.
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true });
// WHY it would not open, not just that it would not. Every one of these is
// an ordinary thing to do by accident — `pardes /root`, a file left at mode
// 000, a name that was deleted between resolving and reading — and a caller
@@ -872,7 +880,20 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
else => error.OpenFailed,
};
defer _ = libc.close(fd);
+
+ // The flag STAYS SET, and the read loops below answer `EAGAIN` with
+ // `NotAFile`. A regular file ignores `O_NONBLOCK` entirely — the kernel
+ // never short-reads one for it — so this costs ordinary opens nothing and
+ // turns the one case that would have hung into an error with a name.
const end = libc.lseek(fd, 0, libc.SEEK.END);
+ // NOT SEEKABLE IS NOT A DOCUMENT. `lseek` answers `ESPIPE` for a pipe, a
+ // socket and a terminal, and those are exactly the things whose "contents"
+ // are a future rather than a file — with `O_NONBLOCK` above they no longer
+ // hang the editor, but an unwritten FIFO then reads as EOF and opened as a
+ // silent empty pane, which says even less than the hang did. The zero-size
+ // files that ARE worth streaming — procfs and its kin — seek fine and
+ // report 0, so they take the branch below untouched.
+ if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile;
const size: usize = if (end < 0) 0 else @intCast(end);
if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
if (size == 0) {
@@ -885,6 +906,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
const n = libc.read(fd, &first, first.len);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
+ // Nothing to read AND nothing that will end: an empty pipe with
+ // no writer. This is the hang, reported instead of waited on.
+ if (libc.errno(n) == .AGAIN) return error.NotAFile;
return error.ReadFailed;
}
first_len = @intCast(n);
@@ -899,6 +923,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
const n = libc.read(fd, stream[stream_len..].ptr, stream.len - stream_len);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
+ // A stream that has paused is a stream that has ended, as far
+ // as one keystroke is concerned: keep what came.
+ if (libc.errno(n) == .AGAIN) break;
return error.ReadFailed;
}
if (n == 0) break;
@@ -909,6 +936,7 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
while (true) {
const n = libc.read(fd, &extra, 1);
if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n < 0 and libc.errno(n) == .AGAIN) break;
if (n < 0) return error.ReadFailed;
if (n > 0) return error.FileTooLarge;
break;
diff --git a/src/main.zig b/src/main.zig
index 00eff433..b08a64da 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -136,6 +136,26 @@ const help_text =
\\
;
+/// A MISTAKE AT THE SHELL PROMPT, said in words and nothing else.
+///
+/// Every one of these used to be `return error.BadArgs` out of `main`, which
+/// std prints as `error: BadArgs` with a RETURN TRACE under it. That reads as a
+/// crash — it is the same shape a real panic has — for the most ordinary thing
+/// a person can do, which is mistype a flag. It also said `BadArgs` and nothing
+/// about WHICH argument, when the site that returned it knew exactly.
+///
+/// stderr and not an `+Errors` pane, which is the answer one line down in
+/// `Pardes.init`: argv is read before any core exists, and a person who typed
+/// a bad flag is looking at the prompt they typed it into rather than at an
+/// editor. `--attach`'s refusal three functions down already answers this way.
+fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
+ var buf: [1024]u8 = undefined;
+ const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch
+ "pardes: bad arguments\nTry 'pardes --help'.\n";
+ std.Io.File.stderr().writeStreamingAll(io, line) catch {};
+ std.process.exit(1);
+}
+
/// Built at COMPTIME, because both halves are: `version` comes out of
/// `build.zig.zon` through the options module and `commit` out of `git` at
/// configure time, so there is nothing here to format at runtime and no buffer
@@ -240,18 +260,21 @@ fn nativeMain(init: std.process.Init) !void {
if (std.mem.eql(u8, a, "--tty")) {
opts.tty_only = true;
} else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
- opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs;
+ opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse
+ badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{a["--tty-toggle=".len..]});
} else if (std.mem.eql(u8, a, "--tty-toggle")) {
i += 1;
- if (i >= args.len) return error.BadArgs;
- opts.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs;
+ if (i >= args.len) badArgs(init.io, "--tty-toggle needs a letter after it", .{});
+ opts.tty_toggle = parseCtrlKey(args[i]) orelse
+ badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{args[i]});
} else if (std.mem.eql(u8, a, "-n")) {
i += 1;
- if (i >= args.len) return error.BadArgs;
- opts.shells = std.fmt.parseInt(u8, args[i], 10) catch return error.BadArgs;
+ if (i >= args.len) badArgs(init.io, "-n needs a count after it: 1 or 3", .{});
+ opts.shells = std.fmt.parseInt(u8, args[i], 10) catch
+ badArgs(init.io, "-n takes 1 or 3, not '{s}'", .{args[i]});
} else if (std.mem.eql(u8, a, "-l")) {
i += 1;
- if (i >= args.len) return error.BadArgs;
+ if (i >= args.len) badArgs(init.io, "-l needs the path of a dump to load", .{});
opts.load_path = args[i];
} else if (std.mem.eql(u8, a, "--fs")) {
opts.fs = "";
@@ -297,8 +320,12 @@ fn nativeMain(init: std.process.Init) !void {
return;
} else if (a.len > 0 and a[0] != '-' and positional == null) {
positional = a;
+ } else if (a.len == 0) {
+ badArgs(init.io, "an empty argument names nothing", .{});
+ } else if (a[0] == '-') {
+ badArgs(init.io, "no such option: {s}", .{a});
} else {
- return error.BadArgs;
+ badArgs(init.io, "one file or directory at a time, and '{s}' is the second", .{a});
}
}
// Started INSIDE another pardes: hand it the file and get out of the way
@@ -328,7 +355,8 @@ fn nativeMain(init: std.process.Init) !void {
std.process.exit(1);
};
var cwdbuf: [4096]u8 = undefined;
- const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs;
+ const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse
+ badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{});
var realbuf: [4096]u8 = undefined;
const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
.dir => |d| nested.sendLook(outer_pid, d, 0),
@@ -356,7 +384,8 @@ fn nativeMain(init: std.process.Init) !void {
// dirs); anything else resolves as a file
if (std.c.chdir(a.ptr) != 0) {
var cwdbuf: [4096]u8 = undefined;
- const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs;
+ const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse
+ badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{});
var realbuf: [4096]u8 = undefined;
switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
.file => |t| {
@@ -390,7 +419,13 @@ fn nativeMain(init: std.process.Init) !void {
.word = try arena.dupe(u8, a),
.dir = try arena.dupe(u8, std.mem.span(@as([*:0]u8, @ptrCast(cwd)))),
},
- else => return error.BadArgs,
+ // A URL, an `@pN` pane address, or a directory that resolves
+ // and `chdir` refused. None is a typo, and none is something a
+ // LAUNCH can act on — the first two are words to click once
+ // pardes is open, and the third is a permission problem.
+ .url => badArgs(init.io, "a URL is not something a launch can open; start pardes and click it", .{}),
+ .pane => badArgs(init.io, "@pN addresses a pane of a running pardes, so there is none yet", .{}),
+ else => badArgs(init.io, "cannot enter that directory: {s}", .{a}),
}
}
}
@@ -410,7 +445,8 @@ fn nativeMain(init: std.process.Init) !void {
// with no core, so the two together are a contradiction with no useful
// reading. Refused rather than resolved by declaration order, which would
// silently drop whichever flag lost.
- if (detach != null and attach != null) return error.BadArgs;
+ if (detach != null and attach != null)
+ badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{});
// `--fs` mounts the acme control filesystem, and it needs a CORE to serve.
// `--attach` has none — it is a terminal whose state lives in another
// process — so the flag there would be parsed, stored, and served by
@@ -427,13 +463,15 @@ fn nativeMain(init: std.process.Init) !void {
// than the desktop shells pay. `--detach --fs` is now the configuration
// that most wants a control filesystem, because it is the one whose panes
// outlive every terminal that could otherwise have scripted them.
- if (opts.fs != null and attach != null) return error.BadArgs;
+ if (opts.fs != null and attach != null)
+ badArgs(init.io, "--fs serves a session's own core, and --attach has none of its own", .{});
// ...and `--fs9` for exactly that reason and no other: it is the same tree
// over a different transport, and an `--attach` has no core to serve it
// from either. Checked separately rather than folded into the line above
// so that neither flag's refusal is a side effect of the other's — they
// are independent everywhere else.
- if (opts.fs9 != null and attach != null) return error.BadArgs;
+ if (opts.fs9 != null and attach != null)
+ badArgs(init.io, "--fs9 serves a session's own core, and --attach has none of its own", .{});
// `--detach` replaces the frontend rather than choosing among them: the
// core runs here, with no terminal, and the frontends are elsewhere on a
// socket (src/detached/). It is checked before `platform` because it is not
diff --git a/src/pardes.zig b/src/pardes.zig
index 4956150a..d9a77c31 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -6401,6 +6401,7 @@ pub const Pardes = struct {
error.IsDirectory => "that is a directory, and not one that could be read",
error.PathTooLong => "that path is too long",
error.FileTooLarge => "that file is too large to open",
+ error.NotAFile => "that is a pipe or a device, not a document",
else => @errorName(err),
};
const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why });