From 7b727114350030fa34281803cc12a1dbb7e6f7fc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 09:05:05 -0300 Subject: A command from a missing directory fails ENOENT with no pane; one whose shell never started ends exit 127 Exec made a command pane in a directory that was not there, whose shell never started: it said running for ever, a follower waited for an exit that never came, and Kill found it. The line now fails up front, as Tty does, `exec: : no such directory`; and a command pane whose shell the host could not start ends `exit 127` at once. A shell that could not start in its directory says `directory missing`, not `file not found`. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 6 +++++- src/exec.zig | 20 ++++++++++++++++++-- src/ninep/ctl.zig | 31 +++++++++++++++++++++++++++++++ src/pardes.zig | 12 +++++++++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 49685888..dc3e7533 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -378,7 +378,11 @@ same tree without leaving the process. exits, `exit` typed or run, closes its pane). Nothing is ever typed over text someone typed at a prompt and did not send. From anywhere else -- a file, a scratch, a tag, a terminal with a line typed at its prompt or - whose tty a program holds -- it runs as a command pane: a + whose tty a program holds -- it runs as a command pane (from a pane whose + directory is not there it runs nothing and makes no pane, `exec: : + no such directory`, ENOENT, as `Tty` there does; one whose shell the host + cannot start ends at once, `exit 127` in the log, its tag no + longer running, nothing for Kill): a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) run with `-c` and the line, in the pane's directory, with job control on (bash, sh, dash, zsh, ksh `-m`; fish `status job-control full`), diff --git a/src/exec.zig b/src/exec.zig index ebe90b98..79717963 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -351,11 +351,11 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { // start in it, and a dead pane would answer no run. Asked only of a host // that starts shells (none, as in the core's tests, starts none). const dir = p.execDir(from); - if (comptime hosted) if (p.host.vtable.spawn != null) if (@import("fs.zig").localPath(dir)) |local| if (!isDirectory(local)) { + if (missingDir(p, dir)) { var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined; p.reportFailure(from, std.fmt.bufPrint(&said, "Tty: {s}: no such directory", .{dir}) catch "Tty: no such directory"); return null; - }; + } const free = p.freeSlot() orelse { p.reportError(from, "Tty", error.NoPaneSlots); return null; @@ -375,6 +375,15 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { return nt; } +/// Whether a shell would be started in `dir` and `dir` is not there: asked +/// only of a host that starts shells, and only of a local path. +pub fn missingDir(p: *Pardes, dir: []const u8) bool { + if (comptime !hosted) return false; + if (p.host.vtable.spawn == null) return false; + const local = @import("fs.zig").localPath(dir) orelse return false; + return !isDirectory(local); +} + /// Whether `path` is a directory on this host, the turn out while it asks /// (it may be inside a mount this editor serves). pub fn isDirectory(path: []const u8) bool { @@ -662,6 +671,13 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { } const src = p.panes[from] orelse return null; const dir = p.execDir(from); + // A directory that is not there runs nothing, as Tty makes nothing + // there: no pane, and the write fails ENOENT. + if (missingDir(p, dir)) { + var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined; + p.reportFailure(from, std.fmt.bufPrint(&said, "exec: {s}: no such directory", .{dir}) catch "exec: no such directory"); + return null; + } // Not one whose pty a job it left behind still prints to: reusing it // would hang that job up. // From a column's tag, only a finished pane in that column is reused, diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 66092451..9cf97efc 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1918,6 +1918,37 @@ test "a terminal with text typed at its prompt is never typed into: a look at it try testing.expect(!typed_into); } +test "a command line from a missing directory fails ENOENT and makes no pane; a command whose shell never started ends 127" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const Starting = struct { + fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} + }; + p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; + defer p.host = .{}; + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const refused = wr(p, Node.of(serial, .exec), "echo hi\n"); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory"); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + // A command pane whose shell the host could not start. + _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "echo hi\n").reply.status); + const cmd = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, Node.of(serial, .exec), 0, 64).bytes, "\n"), 10)).?; + p.shellFailed(@intCast(cmd), error.FileNotFound); + const pane = p.panes[cmd].?; + try testing.expect(pane.command_done); + try testing.expectEqual(@as(?u8, 127), pane.command_status); + var rec: [32]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "exit {d} 127\n", .{pane.serial}))); + try testing.expect(th.logHas(p, "shell: directory missing")); +} + test "size is monotonic: growing is never refused, and a size once taken is taken again" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 }); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index 24595045..60a5dfe3 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5247,7 +5247,17 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; pane.shell_failed = true; ctlfs.pty.shellGone(p, pane, false); - p.reportError(id, "shell", err); + if (err == error.FileNotFound or err == error.NotDir) + p.reportFailure(id, "shell: directory missing (no such directory)") + else + p.reportError(id, "shell", err); + // A command that never started ended: `exit 127`, as a shell says of + // a command it could not run, so a follower waiting on its exit + // hears one, its tag stops saying it runs, and Kill finds nothing. + if (pane.command != null and !pane.command_done) { + pane.command_pty = false; + exec.commandDone(p, id, 127); + } } pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void { -- cgit v1.3