summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 22:01:41 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit5ee1e87a917bdca38351feeca88999cd67440994 (patch)
tree89eccb4c95839eb2ddc3bf1cdfd3b812366c90a1
parent015152bb7e28a26d135fc4bc67b09a5f53d5080b (diff)
downloadpardes-5ee1e87a917bdca38351feeca88999cd67440994.tar.gz
pardes-5ee1e87a917bdca38351feeca88999cd67440994.zip
A command pane's background job outlives its command
A dogfood agent's background jobs died when the command that started them finished, even under nohup. The command's shell leads its terminal's session, and on its exit the kernel hangs up the terminal's foreground group, which with job control off is the shell's and every job's. The line now runs with job control on (bash, sh, dash, zsh, ksh -m; fish status job-control full), so a job has a group of its own and lives on, printing below exit N, and survives the pane closing. While a job holds the pty the pane is not reused, so the directory's next command gets a second pane (cmdexit's golden, re-recorded by name). Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md11
-rw-r--r--src/host_io.zig37
-rw-r--r--test/snapshots/cmdexit.golden6
-rw-r--r--test/snapshots/cmdexit.snap6
4 files changed, 50 insertions, 10 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 3800e8cb..a883e185 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -249,12 +249,19 @@ same tree without leaving the process.
is typed into that shell. From anywhere else -- a file, a scratch, a tag,
a terminal whose tty a program holds -- it runs as a command pane: a
terminal whose child is the root ctl's `Shell` (fish unless set) run
- with `-c` and the line, in the pane's directory,
+ 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`),
which shows its output and then `exit N` (its tag reads `<dir> (<line>)
running`, then `exit N`), and stays. The command is over when its process
exits, as in acme, not when its terminal closes: a job it left in the
background prints on below `exit N` until it lets go of the pty, and a
- command that lets go of its terminal early runs on to its own exit. The directory's next command runs in
+ command that lets go of its terminal early runs on to its own exit. A
+ background job outlives the command: job control gives it a process
+ group of its own, so the hangup the kernel sends the terminal's
+ foreground group when the shell exits misses it. It survives the pane
+ closing or taking the next command too, but its writes to the terminal
+ then fail, so start one that must keep writing with `nohup` or its
+ output redirected. The directory's next command runs in
that pane once it is done, below what it showed, after a `% <line>` line;
one still running gets a second pane. Before the next command the pane
leaves any alternate screen and turns off the modes a program left on
diff --git a/src/host_io.zig b/src/host_io.zig
index 25bd61e1..53e1628d 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -792,7 +792,7 @@ pub const Shell = struct {
pub const Spawn = struct {
path: [*:0]const u8,
- argv: [4:null]?[*:0]const u8,
+ argv: [6:null]?[*:0]const u8,
};
// Resolve in the parent. The path buffer and prompt files must survive through exec.
@@ -803,7 +803,24 @@ pub const Shell = struct {
.fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
.none => .{ null, null },
};
- return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
+ return .{ .path = path, .argv = .{ path, marks[0], marks[1], null, null, null } };
+ }
+
+ /// A command pane's shell runs its one line with job control on, so a
+ /// job the line puts in the background has a process group of its own.
+ /// The shell leads the terminal's session, and when it exits the kernel
+ /// hangs up the terminal's foreground group: with job control off that
+ /// is the shell's own, the job's too, and the job died with the command
+ /// (nohup'd or not; only setsid got out, and not always in time). A
+ /// shell pardes does not know runs the line as it is.
+ pub fn commandArgv(path: [*:0]const u8, line: [*:0]const u8) [6:null]?[*:0]const u8 {
+ const bin = std.mem.span(path);
+ const base = if (std.mem.lastIndexOfScalar(u8, bin, '/')) |s| bin[s + 1 ..] else bin;
+ const posix_shells = [_][]const u8{ "bash", "sh", "dash", "zsh", "ksh", "mksh", "yash" };
+ for (posix_shells) |name| if (std.mem.eql(u8, base, name))
+ return .{ path, "-m", "-c", line, null, null };
+ if (family(bin) == .fish) return .{ path, "-C", "status job-control full", "-c", line, null };
+ return .{ path, "-c", line, null, null, null };
}
/// A shell by its path or its name in the usual directories, or null.
@@ -840,6 +857,20 @@ pub const Shell = struct {
return @ptrCast(buf);
}
+ test "a command pane's line runs with job control on in the shells that have it" {
+ const want = struct {
+ fn check(path: [*:0]const u8, words: []const []const u8) !void {
+ const argv = commandArgv(path, "sleep 9 &");
+ for (words, 0..) |w, i| try std.testing.expectEqualStrings(w, std.mem.span(argv[i].?));
+ try std.testing.expect(argv[words.len] == null);
+ }
+ };
+ try want.check("/usr/bin/bash", &.{ "/usr/bin/bash", "-m", "-c", "sleep 9 &" });
+ try want.check("/bin/sh", &.{ "/bin/sh", "-m", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/fish", &.{ "/usr/bin/fish", "-C", "status job-control full", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/nu", &.{ "/usr/bin/nu", "-c", "sleep 9 &" });
+ }
+
test "a path is taken at its word, a name is looked up, and both pick their own marks" {
if (builtin.os.tag == .windows) return;
var buf: [std.fs.max_path_bytes]u8 = undefined;
@@ -1070,7 +1101,7 @@ pub fn forkShell(
if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand;
@memcpy(command_buf[0..line.len], line);
command_buf[line.len] = 0;
- spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null };
+ spawn.argv = Shell.commandArgv(spawn.path, command_buf[0..line.len :0].ptr);
}
var helper_buf: [4096]u8 = undefined;
const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
diff --git a/test/snapshots/cmdexit.golden b/test/snapshots/cmdexit.golden
index de95c383..4e9dc5fc 100644
--- a/test/snapshots/cmdexit.golden
+++ b/test/snapshots/cmdexit.golden
@@ -10,6 +10,6 @@
|19: BGSTARTED
|20: exit 0
== snap letgo grid=120x31 cursor=7,3
-|17: /tmp/pardes-snap/cmdexit/cwd (exec </dev/null >/dev/null 2>&1; sleep 1; false) exit 1 Kill Save Collapse Del
-|21: % exec </dev/null >/dev/null 2>&1; sleep 1; false
-|22: exit 1
+|22: /tmp/pardes-snap/cmdexit/cwd (exec </dev/null >/dev/null 2>&1; sleep 1; false) exit 1 Kill Save Collapse Del
+|23: % exec </dev/null >/dev/null 2>&1; sleep 1; false
+|24: exit 1
diff --git a/test/snapshots/cmdexit.snap b/test/snapshots/cmdexit.snap
index ff60a881..e9f39bfd 100644
--- a/test/snapshots/cmdexit.snap
+++ b/test/snapshots/cmdexit.snap
@@ -16,8 +16,10 @@ release middle 34 4
wait 10000 exit 0
stable 700 15000
snap background
-# line 2, in the same pane now its command is done: its end of file comes at
-# once, and its exit a second later, from `false`
+# line 2, in a second pane: the first's command is done but its background
+# `sleep 30` lives on (job control gives it its own group) and still holds
+# that pane's pty; here the end of file comes at once, and the exit a second
+# later, from `false`
press middle 7 5
drag middle 54 5
release middle 54 5