summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 12:06:13 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit8b18331f5e3f6c346b992019163ec3f5b6e6b721 (patch)
treef6e0236af8b0ac0428078d5ba512df10a1f3f43d /src
parent990c2e1b184e9cb1fddd8eb1b05a472cfb113dde (diff)
downloadpardes-8b18331f5e3f6c346b992019163ec3f5b6e6b721.tar.gz
pardes-8b18331f5e3f6c346b992019163ec3f5b6e6b721.zip
Kill asks the host to signal at once and says when a shell has no job to signal
Kill queued a signal effect for later, so the job it saw running by its marks could have ended, and another started, before the signal went; and with job control off the job shares the shell's process group, so the host skipped it and Kill reported nothing. Kill now calls the host's kill_job while it holds the turn, and when the only job is the shell's own group says 'Kill: no job to signal', which also fails a write of Kill to ctl. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig5
-rw-r--r--src/builtins.zig36
-rw-r--r--src/detached/server.zig9
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig35
-rw-r--r--src/macos.zig7
-rw-r--r--src/pardes.zig8
-rw-r--r--src/tty/tty.zig7
8 files changed, 97 insertions, 18 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index f4ea1c3e..72b3b63c 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -388,6 +388,11 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
+ reportFailure(p, id, text);
+}
+
+/// reportError with the words already chosen.
+pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
// A builtin a ctl write runs: its first error is also the write's.
if (p.fs.no_prompt and p.fs.failure_len == 0) {
const n = @min(text.len, p.fs.failure.len);
diff --git a/src/builtins.zig b/src/builtins.zig
index 0154e707..93eae8ec 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -235,16 +235,23 @@ test "Kill stops the commands pardes typed, bare all and by name those that matc
const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
p.update(.{ .output = .{ .pane = @intCast(term), .bytes = prompt } });
while (p.nextEffect()) |_| {}
+ // The host the core asks: how many it was asked to stop, and whether
+ // their shells run jobs in groups of their own.
const killed = struct {
+ var asked: usize = 0;
+ var job_control = true;
+ const vtable: pardes.Host.VTable = .{ .kill_job = answer };
+ fn answer(_: ?*anyopaque, _: u8) bool {
+ asked += 1;
+ return job_control;
+ }
fn count(pp: *Pardes, line: []const u8) usize {
+ asked = 0;
_ = pp.executeBuiltinLine(pp.active, line);
- var n: usize = 0;
- while (pp.nextEffect()) |e| if (e == .signal_pty and e.signal_pty.sig == .command) {
- n += 1;
- };
- return n;
+ return asked;
}
};
+ p.host = .{ .vtable = &killed.vtable };
// Nothing running: nothing to stop, and never the shell.
try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill"));
_ = exec.execute(p, term, "make -j8");
@@ -257,6 +264,15 @@ test "Kill stops the commands pardes typed, bare all and by name those that matc
try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill ls make"));
try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill"));
try std.testing.expect(!p.quit);
+ // With job control off there is no job apart from the shell: said, and
+ // over ctl the write fails with it.
+ killed.job_control = false;
+ p.fs.no_prompt = true;
+ try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill"));
+ try std.testing.expectStringStartsWith(p.fs.failure[0..p.fs.failure_len], "Kill: no job to signal");
+ p.fs.no_prompt = false;
+ p.fs.failure_len = 0;
+ killed.job_control = true;
// Ended (a D mark): nothing to stop again.
p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;D;143;aid=pardes\x07" ++ prompt } });
while (p.nextEffect()) |_| {}
@@ -362,6 +378,7 @@ pub const Kill = struct {
pub fn run(c: Ctx) void {
const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
var stopped = false;
+ var jobless = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
if (!panes.Terminal.commandRunning(pane)) continue;
@@ -372,10 +389,13 @@ pub const Kill = struct {
if (std.mem.eql(u8, w, sent.word[0..sent.len])) break;
} else continue;
}
- c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = .command } });
- stopped = true;
+ // Asked of the host now, not queued: the job the marks say is
+ // running is the one signalled, not whatever runs by then.
+ if (c.p.hostKillJob(id)) stopped = true else jobless = true;
}
- if (!stopped and names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand);
+ if (stopped) return;
+ if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)");
+ if (names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand);
}
};
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 0cf1729f..5c0e1487 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -353,6 +353,7 @@ pub const Session = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
.watch_file = watchFile,
@@ -521,6 +522,14 @@ pub const Session = struct {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
+ const s = of(ctx);
+ if (pane >= s.ptys.len) return false;
+ const pt = s.ptys[pane];
+ if (pt.fd < 0) return false;
+ return host_io.killJob(pt.pid, pt.fd);
+ }
+
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 2369dd28..6978284b 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -3841,6 +3841,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
.watch_file = watchFile,
@@ -4196,6 +4197,13 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+fn killJob(ctx: ?*anyopaque, pane: u8) bool {
+ const s = shellOf(ctx);
+ const pt = s.ptys[pane] orelse return false;
+ if (pt.fd < 0) return false;
+ return host_io.killJob(pt.pid, pt.fd);
+}
+
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
filesystem.write(s.core, path, bytes) catch |err|
diff --git a/src/host_io.zig b/src/host_io.zig
index 81d784c5..6009c00f 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -22,6 +22,9 @@ pub const Host = struct {
pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null,
close_pty: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null,
tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
+ /// Answered at once, not queued as an effect: Kill asks while the
+ /// job it saw running is still the one in the tty (host_io.killJob).
+ kill_job: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null,
write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null,
write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null,
@@ -1366,14 +1369,8 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.hup => libc.SIG.HUP,
.quit => libc.SIG.QUIT,
.kill => libc.SIG.KILL,
- .command => libc.SIG.TERM,
};
const fg = tcgetpgrp(master_fd);
- // The shell leads its own group: a job it runs has another.
- if (which == .command) {
- if (fg > 0 and fg != shell_pid) _ = libc.kill(-fg, sig);
- return;
- }
if (fg > 0) {
_ = libc.kill(-fg, sig);
return;
@@ -1381,6 +1378,16 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
if (shell_pid > 0) _ = libc.kill(shell_pid, sig);
}
+/// 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
+/// group, and there is no job to signal apart from it: false.
+pub fn killJob(shell_pid: libc.pid_t, master_fd: c_int) bool {
+ const fg = tcgetpgrp(master_fd);
+ if (fg <= 0 or fg == shell_pid) return false;
+ return libc.kill(-fg, libc.SIG.TERM) == 0;
+}
+
fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 {
const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
if (fd < 0) return null;
@@ -1675,23 +1682,33 @@ test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands i
try std.testing.expect(sh.waitText(test_prompt, 10_000));
}
-test "Kill's signal stops the foreground job and never the shell" {
+test "Kill stops the foreground job, never the shell, and finds none with job control off" {
if (comptime !tty_probe_platform) return error.SkipZigTest;
var sh = TestShell.start() orelse return error.SkipZigTest;
defer sh.stop();
// At the prompt the shell is the foreground: there is nothing to stop.
- signalTty(sh.pid, sh.master, .command);
+ try std.testing.expect(!killJob(sh.pid, sh.master));
sleepMs(100);
try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
sh.send("sleep 30\n");
try std.testing.expect(sh.waitTaken(true, 10_000));
sh.forget();
- signalTty(sh.pid, sh.master, .command);
+ try std.testing.expect(killJob(sh.pid, sh.master));
try std.testing.expect(sh.waitTaken(false, 10_000));
try std.testing.expect(sh.waitText(test_prompt, 10_000));
try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
+
+ // Job control off: the job runs in the shell's own group.
+ sh.forget();
+ sh.send("set +m; sleep 30\n");
+ sleepMs(300);
+ try std.testing.expect(!killJob(sh.pid, sh.master));
+ sh.forget();
+ sh.send("\x03");
+ try std.testing.expect(sh.waitText(test_prompt, 10_000));
+ try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
}
test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" {
diff --git a/src/macos.zig b/src/macos.zig
index ebe8193b..62858887 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2312,6 +2312,7 @@ const vtable: pardes.Host.VTable = .{
.pty_signal = ptySignal,
.close_pty = closePty,
.tty_taken = ttyTaken,
+ .kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
.watch_file = watchFile,
@@ -2435,6 +2436,12 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return host_io.ttyTaken(pt.pid, pt.file.handle);
}
+fn killJob(ctx: ?*anyopaque, pane: u8) bool {
+ const st = hostState(ctx);
+ const pt = st.ptys[pane] orelse return false;
+ return host_io.killJob(pt.pid, pt.file.handle);
+}
+
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const st = hostState(ctx);
filesystem.write(st.core, path, bytes) catch |err|
diff --git a/src/pardes.zig b/src/pardes.zig
index 59556649..6e78380a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3111,7 +3111,7 @@ pub const PendingWrite = struct { bytes: []u8, off: usize = 0 };
/// `command` is Kill's: SIGTERM to the terminal's foreground job, and never
/// to the shell itself when the shell is in the foreground.
-pub const PtySignal = enum(u8) { int, term, hup, quit, kill, command };
+pub const PtySignal = enum(u8) { int, term, hup, quit, kill };
/// IO the core wants done. Payloads are inline (fixed buffers): effects are
/// queued values with no lifetime ties back into the core.
@@ -3928,6 +3928,7 @@ pub const Pardes = struct {
pub const setStatus = Messages.setStatus;
pub const setMessage = Messages.setMessage;
pub const reportError = Messages.reportError;
+ pub const reportFailure = Messages.reportFailure;
/// Running a builtin line, called from everywhere as
/// `p.executeBuiltinLine(...)`; it lives with the rest of exec in exec.zig.
@@ -4510,6 +4511,11 @@ pub const Pardes = struct {
return f(p.host.ctx, @intCast(id));
}
+ pub fn hostKillJob(p: *const Pardes, id: usize) bool {
+ const f = p.host.vtable.kill_job orelse return false;
+ return f(p.host.ctx, @intCast(id));
+ }
+
fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void {
if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes);
// The in-process filesystem reports the same way a real host does, so
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 128a7ead..32f9138f 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -926,6 +926,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePty,
.tty_taken = ttyTaken,
+ .kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
.watch_file = watchFile,
@@ -1244,6 +1245,12 @@ const Shell = struct {
return host_io.ttyTaken(pt.pid, pt.file.handle);
}
+ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
+ const s = of(ctx);
+ const pt = s.ptys[pane] orelse return false;
+ return host_io.killJob(pt.pid, pt.file.handle);
+ }
+
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|