diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 09:51:11 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | ad3403adb72e3457eb76d6b3fc91cf8df3d738ae (patch) | |
| tree | 1669efb3e6891b3a32a71c1fcffe8dd48428d9b2 | |
| parent | 6e2ed2e057ba95b3a86186c255d1965e1861bdf7 (diff) | |
| download | pardes-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.md | 8 | ||||
| -rw-r--r-- | src/9p_io.zig | 2 | ||||
| -rw-r--r-- | src/host_io.zig | 45 | ||||
| -rw-r--r-- | src/panes.zig | 3 | ||||
| -rw-r--r-- | src/pardes.zig | 6 | ||||
| -rw-r--r-- | test/fs.py | 22 |
6 files changed, 81 insertions, 5 deletions
@@ -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); } } @@ -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): |
