diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 01:43:25 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 4a662c32888ed055b3e42faa5afc31dcaeb09fc4 (patch) | |
| tree | 28a5dd2fda2ee0de32450600f1e3eec8395c2bf2 | |
| parent | 0423e315dfeaa5af48d2186a8f0997e1904f3da9 (diff) | |
| download | pardes-4a662c32888ed055b3e42faa5afc31dcaeb09fc4.tar.gz pardes-4a662c32888ed055b3e42faa5afc31dcaeb09fc4.zip | |
Kill stops a command pane's whole command, its & jobs included
With job control on, a command's & jobs get process groups of their own,
so Kill's signal to the foreground group and the shell's group left them
running, orphaned. A stop of a command pane now signals every process
group in its shell's session (Linux; a /proc scan). Jobs still outlive a
command that exits on its own. Docs and the skill say it per route.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 5 | ||||
| -rw-r--r-- | docs/fs.md | 10 | ||||
| -rw-r--r-- | src/host_io.zig | 39 |
3 files changed, 46 insertions, 8 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 844e028f..97050e25 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -77,8 +77,9 @@ $m/ctl the settings, one a line as a write takes them; write a setting or matching a command line's first word; Exit and Restore refuse once, naming every unsaved pane, `<a>, <b>: Modified (Exit again to discard them all)`, and the same word again DISCARDS that text -- not a retry, unlike lock's `file in use`; - Kill signals only the foreground job, so of `sleep 30; echo done` the - echo still runs; Joincol needs a column to the right of the keyboard's); + Kill stops a command pane's whole line, `&` jobs included; of a line typed into a + shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done` + fish runs the echo, bash abandons the line); Joincol needs a column to the right of the keyboard's); a pane's builtins (Del, Save f, Collapse, which folds that pane, and the column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) @@ -171,8 +171,10 @@ never needed); and `Kill`, which does not quit but stops commands, as acme's does: bare, every command pardes started, and `Kill make ls`, those whose line begins with one of the words. A command pardes started is a command pane's, until its child exits -- Kill -sends SIGTERM to its running job and to its shell, so the whole line stops -(of `sleep 30; echo done` the `echo` never runs) -- or a line it typed into +sends SIGTERM to its running job, to its shell and to every `&` job the +line started, so the whole command stops and leaves nothing running (of +`sleep 30; echo done` the `echo` never runs; an `&` job outlives only a +command that exits on its own) -- or a line it typed into a terminal (a word written to `exec`, a middle click on one, a `pty/run`), from its shell's start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the @@ -305,7 +307,7 @@ same tree without leaving the 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. A - background job outlives the command: job control gives it a process + background job outlives a command that exits on its own (Kill stops it too): 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 too, but its writes to the terminal then fail, so start one that @@ -317,7 +319,7 @@ same tree without leaving the process. the screen and its scrollback (`clear`, ED3) erases the history above it. A command pane's own exec starts the next command there too. The log says `run <serial> <line>` and `exit <serial> <N|?>`; `exec` reads back the command pane's serial; Kill stops - its whole line; a line is at most 1 KB. To run a command again, execute + its whole line, `&` jobs included; a line is at most 1 KB. To run a command again, execute its line again from its directory: `echo 'make test' > pane/<n>/exec` on the command pane runs it there, below the last run. A misspelled word is a command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes diff --git a/src/host_io.zig b/src/host_io.zig index df78f73c..f83c7d47 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1673,7 +1673,9 @@ fn reapShell(pid: libc.pid_t, grace_ms: u32) void { /// The tty's foreground job is signalled. A command pane's shell (`command`) /// is its command too: its line runs with job control on, so the job /// running is in a group of its own, and the shell's group goes as well, or -/// the rest of the line would run on after the job it was on. +/// the rest of the line would run on after the job it was on. A stop (TERM +/// or KILL) of a command reaches its `&` jobs too, each in a group of its +/// own in the shell's session: Kill leaves nothing the line started. pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal, command: bool) void { const sig = switch (which) { .int => libc.SIG.INT, @@ -1683,11 +1685,42 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna .kill => libc.SIG.KILL, }; const fg = tcgetpgrp(master_fd); + if (command and shell_pid > 0 and (which == .term or which == .kill)) signalSession(shell_pid, sig); if (fg > 0) _ = libc.kill(-fg, sig); if (command and shell_pid > 0 and fg != shell_pid) _ = libc.kill(-shell_pid, sig); if (fg <= 0 and !command and shell_pid > 0) _ = libc.kill(shell_pid, sig); } +/// Every process group in the session `sid` leads, but its own (which the +/// caller signals last). forkpty made the shell the session's leader, and +/// job control moves a job to a new group, never out of the session. +// ponytail: Linux only (a /proc scan); elsewhere a `&` job outlives Kill, +// add sysctl KERN_PROC_SESSION on macOS if that matters. +fn signalSession(sid: libc.pid_t, sig: libc.SIG) void { + if (comptime builtin.os.tag != .linux) return; + const d = libc.opendir("/proc") orelse return; + defer _ = libc.closedir(d); + var buf: [4096]u8 = undefined; + while (libc.readdir(d)) |ent| { + const name = std.mem.sliceTo(&ent.name, 0); + const pid = std.fmt.parseInt(libc.pid_t, name, 10) catch continue; + var path_buf: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&path_buf, "/proc/{d}/stat", .{pid}, 0) catch continue; + const ids = parseGroupSession(readProc(path, &buf) orelse continue) orelse continue; + if (ids.session == sid and ids.pgrp != sid) _ = libc.kill(-ids.pgrp, sig); + } +} + +fn parseGroupSession(stat: []const u8) ?struct { pgrp: libc.pid_t, session: libc.pid_t } { + const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; + var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); + _ = fields.next() orelse return null; // 3: state + _ = fields.next() orelse return null; // 4: ppid + const pgrp = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null; + const session = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null; + return .{ .pgrp = pgrp, .session = session }; +} + /// Kill: SIGTERM to the job in the tty's foreground, never to the shell. /// The shell leads its own group, and a job it runs has another; with job /// control off (`set +m`, a shell run as `sh -c`) the job shares the shell's @@ -2076,7 +2109,7 @@ test "a command's exit is its process's, told by its watcher, whether or not its } } -test "a command's background job outlives it and its pty, and Kill stops its whole line" { +test "a command's background job outlives it and its pty, and Kill stops its whole line, its & jobs too" { if (comptime !tty_probe_platform) return error.SkipZigTest; if (!haveFile("/bin/sh")) return error.SkipZigTest; const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); @@ -2094,6 +2127,8 @@ test "a command's background job outlives it and its pty, and Kill stops its who .{ .line = "(sleep 0.4; touch left) & exit 0", .kill = false, .made = true }, // The job running is in its own group; the shell goes too. .{ .line = "sleep 0.4; touch killed", .kill = true, .made = false }, + // Kill stops the whole command, its `&` jobs as well. + .{ .line = "(sleep 0.4; touch killed) & sleep 5", .kill = true, .made = false }, }) |c| { const id = core.freeSlot().?; _ = try core.newCommand(id, dir, c.line); |
