summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 09:51:11 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitad3403adb72e3457eb76d6b3fc91cf8df3d738ae (patch)
tree1669efb3e6891b3a32a71c1fcffe8dd48428d9b2
parent6e2ed2e057ba95b3a86186c255d1965e1861bdf7 (diff)
downloadpardes-ad3403adb72e3457eb76d6b3fc91cf8df3d738ae.tar.gz
pardes-ad3403adb72e3457eb76d6b3fc91cf8df3d738ae.zip
A shell that cannot start fails Tty and pty/ctl exec with why, before either answers
A shell whose exec failed (a script's missing interpreter) was reported started: Tty and pty/ctl exec succeeded, then the pane died. The child now reports a failed chdir or exec through a close-on-exec pipe the parent reads before acknowledging the shell; the host's spawn fails with the reason (`shell not found`, ENOENT), which fails the waiting write. A Tty whose first shell never ran leaves no pane; a terminal restarted by pty/ctl exec keeps its pane. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md8
-rw-r--r--src/9p_io.zig2
-rw-r--r--src/host_io.zig45
-rw-r--r--src/panes.zig3
-rw-r--r--src/pardes.zig6
-rw-r--r--test/fs.py22
6 files changed, 81 insertions, 5 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 95c72baa..c3c42d0d 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -927,8 +927,12 @@ one line, three right-aligned fields and a newline: the pty's columns and
rows, then busy (0 or 1). `pty/ctl` takes `winsize C R`, `sig
INT|TERM|HUP|QUIT|KILL` and `exec`, which starts the pane's shell again in
its directory: one that is gone is refused before anything runs, `exec:
-<dir>: no such directory` (ENOENT), and a shell the host cannot start fails
-the write with why, as any builtin's failure does. A directory removed
+<dir>: no such directory` (ENOENT), and a shell the host cannot start (not
+there, not executable, a script whose interpreter is not there) fails the
+write with why -- `shell: shell not found`, ENOENT -- keeping the
+terminal; a `Tty` whose shell cannot start fails the same way and leaves
+no pane. The host knows before it answers: the child reports a failed exec
+through a close-on-exec pipe. A directory removed
under a running shell leaves the pane its name (never `... (deleted)`), so
an `exec` works there once the directory is back. The size, and `pty/ctl`'s `winsize` read back, is
what `winsize C R` last set (R of 1 is taken as 2: a one-row pty loses
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 4c9c9bcf..72897979 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -318,7 +318,7 @@ pub const Listener = struct {
const late = core.fs.late_failure[0..core.fs.late_failure_len];
// What is not there (`no such directory`) is ENOENT, as a
// builtin's failure saying so is (ctl.failureErrno).
- const errno = if (std.mem.indexOf(u8, late, "no such") != null) pardes.ctlfs.E.NOENT else pardes.ctlfs.E.IO;
+ const errno = if (std.mem.indexOf(u8, late, "no such") != null or std.mem.indexOf(u8, late, "not found") != null) pardes.ctlfs.E.NOENT else pardes.ctlfs.E.IO;
const failed = pardes.ctlfs.failText(req.tag, errno, late);
// Its err record says it (the path in it); the msg the failure
// was also said as goes, as a builtin's failing a write does.
diff --git a/src/host_io.zig b/src/host_io.zig
index 10bcf0d7..fbdfb703 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1156,17 +1156,58 @@ pub fn forkShell(
var env_slots: ChildEnv.Slots = undefined;
const envp = ChildEnv.build(&env_slots);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ // The child says here why it could not become the shell (its chdir or
+ // its exec failed, as for a script whose interpreter is not there): the
+ // pipe closes on exec, so the parent reads nothing when the shell runs,
+ // and a reason when it did not, before anyone is told it started.
+ var told: [2]c_int = .{ -1, -1 };
+ // pipe and then CLOEXEC, not pipe2, which macOS has not.
+ if (libc.pipe(&told) != 0) return error.PipeFailed;
+ ninep_io.setCloexec(told[0]);
+ ninep_io.setCloexec(told[1]);
const pid = forkpty(&master, null, null, &ws);
- if (pid < 0) return error.ForkFailed;
+ if (pid < 0) {
+ _ = libc.close(told[0]);
+ _ = libc.close(told[1]);
+ return error.ForkFailed;
+ }
if (pid == 0) {
var set = posix.sigemptyset();
posix.sigaddset(&set, posix.SIG.WINCH);
posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
- if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126);
+ if (cwd_z) |path| if (chdir(path.ptr) != 0) {
+ const why = [_]u8{ 'c', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) };
+ _ = libc.write(told[1], &why, why.len);
+ _exit(126);
+ };
if (envp) |env| _ = execve(spawn.path, &spawn.argv, env);
_ = execv(spawn.path, &spawn.argv);
+ const why = [_]u8{ 'x', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) };
+ _ = libc.write(told[1], &why, why.len);
_exit(127);
}
+ _ = libc.close(told[1]);
+ const failed = failed: {
+ var why: [2]u8 = undefined;
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const n = libc.read(told[0], &why, why.len);
+ _ = libc.close(told[0]);
+ if (n != 2) break :failed null;
+ break :failed why;
+ };
+ if (failed) |why| {
+ _ = libc.close(master);
+ _ = libc.waitpid(pid, null, 0);
+ const e: libc.E = @enumFromInt(why[1]);
+ if (why[0] == 'c') return if (e == .NOENT or e == .NOTDIR) error.FileNotFound else error.AccessDenied;
+ return switch (e) {
+ .NOENT => error.ShellNotFound, // itself, or its script's interpreter
+ .ACCES, .PERM => error.ShellNotExecutable,
+ .NOEXEC => error.ShellNotExecutable,
+ else => error.ShellDidNotStart,
+ };
+ }
ninep_io.setCloexec(master);
if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), one_line == null and spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
diff --git a/src/panes.zig b/src/panes.zig
index f2fe5037..32ad7b6b 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -113,6 +113,9 @@ pub const Pane = struct {
/// then /proc says this process's directory, not the shell's
/// (Pardes.setCwd keeps the one it was started in).
shell_spoke: bool = false,
+ /// A shell has run in it (Pardes.acknowledgeShell): a Tty whose first
+ /// shell fails to start closes, one started again keeps its pane.
+ had_shell: bool = false,
pending_command: Terminal.PendingCommand = .{},
/// The last command line pardes typed into this terminal (an exec, a
/// middle click, a pty/run): its first word, which Kill matches, and
diff --git a/src/pardes.zig b/src/pardes.zig
index 26c9246a..35b44871 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5023,6 +5023,7 @@ pub const Pardes = struct {
if (id < MAX_PANES) if (p.panes[id]) |pane| {
ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran
pane.shell_failed = false;
+ pane.had_shell = true;
pane.fs.unmarked = !prompt_marks;
// The tag's Tty word names the shell the host ran, and a click
// on it (`Tty+fish`) opens another of the same.
@@ -5266,6 +5267,11 @@ pub const Pardes = struct {
if (pane.command != null and !pane.command_done) {
pane.command_pty = false;
exec.commandDone(p, id, 127);
+ } else if (pane.command == null and !pane.had_shell) {
+ // A Tty whose shell never ran: no dead pane left behind; the
+ // write that asked for it has failed with why. A terminal whose
+ // shell was started again (pty/ctl exec) keeps its pane.
+ p.unplaced.set(id);
}
}
diff --git a/test/fs.py b/test/fs.py
index 3c10c53b..20a9bbff 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -515,6 +515,28 @@ def new_terminals_named_once(binary):
for serial in made:
assert f'\nrename {serial} ' not in '\n' + log, (serial, log)
assert f'\nnew {serial} {root}' in '\n' + log, (serial, log)
+ # A shell that cannot start (its interpreter is not there): Tty
+ # fails the write with why and leaves no pane; pty/ctl exec
+ # fails with why and keeps its terminal.
+ bad = root / 'badsh'
+ bad.write_bytes(b'#!/nonexistent/interp\n')
+ bad.chmod(0o755)
+ panes = client.read('/index')
+ try:
+ client.write('/pane/1/ctl', f'Tty {bad}\n'.encode())
+ raise AssertionError('Tty of a shell that cannot start was taken')
+ except OSError as refused:
+ assert 'shell' in str(refused), refused
+ time.sleep(.3)
+ assert client.read('/index').count(b'\n') == panes.count(b'\n'), client.read('/index')
+ client.write('/ctl', f'Shell {bad}\n'.encode())
+ try:
+ client.write(f'/pane/{made[0]}/pty/ctl', b'exec\n')
+ raise AssertionError('pty/ctl exec of a shell that cannot start was taken')
+ except OSError as refused:
+ assert 'shell' in str(refused), refused
+ client.write('/ctl', b'Shell\n')
+ assert str(made[0]).encode() in b' '.join(r.split()[0] for r in client.read('/index').splitlines())
def workflow_canary(binary):