From 5ce65d731132754a5e774e69067f18347f9c7343 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 5 Jul 2026 12:27:42 -0600 Subject: [PATCH 1/2] fix: tear down the session's whole process tree on kill (#98) boo kill signaled only the session shell, so grandchildren that ignore SIGHUP, joined their own process group, or were backgrounded survived the session and kept holding ports and files. Snapshot the shell's descendant tree, hang up its process group, SIGTERM every descendant by pid, then SIGKILL survivors after a bounded grace, guarding each bare-pid signal against pid reuse. Enumeration is /proc on Linux and libproc on macOS. Fixes #97. --- src/daemon.zig | 15 +- src/main.zig | 1 + src/reap.zig | 421 +++++++++++++++++++++++++++++++++++++++++++ test/integration.zig | 71 ++++++++ 4 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 src/reap.zig diff --git a/src/daemon.zig b/src/daemon.zig index e1f560e..85feca2 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -20,6 +20,7 @@ const keys = @import("keys.zig"); const altscreen = @import("altscreen.zig"); const paths = @import("paths.zig"); const cwd = @import("cwd.zig"); +const reap = @import("reap.zig"); const windowpkg = @import("window.zig"); const Window = windowpkg.Window; const main = @import("main.zig"); @@ -587,11 +588,17 @@ pub const Daemon = struct { // connecting to the dying daemon and reading EOF. self.retireListener(); conn.send(.ok, ""); - if (self.win) |w| { - posix.kill(w.child_pid, posix.SIG.HUP) catch {}; - } + // Tell attached clients the session ended and flush those + // notices (and the ack above) before the teardown, so the + // kill returns and clients learn the outcome without waiting + // on a stubborn descendant's grace period. self.broadcastExit("session terminated"); - self.quitting = true; + self.drainOutbound(); + // End the whole tree the session started, not just the shell, + // so a grandchild that ignores the hangup or escaped into its + // own process group cannot outlive the session and keep its + // resources (the bug in issue #97). + if (self.win) |w| reap.terminateTree(self.alloc, w.child_pid); } else { conn.send(.err, "unknown command"); } diff --git a/src/main.zig b/src/main.zig index c0e6a9d..b7e5cd9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1128,6 +1128,7 @@ test { _ = @import("cwd.zig"); _ = @import("keys.zig"); _ = @import("pty.zig"); + _ = @import("reap.zig"); _ = @import("altscreen.zig"); _ = @import("oscquery.zig"); _ = @import("osc52.zig"); diff --git a/src/reap.zig b/src/reap.zig new file mode 100644 index 0000000..22aa9f5 --- /dev/null +++ b/src/reap.zig @@ -0,0 +1,421 @@ +//! Full-tree teardown for a session's process tree. +//! +//! `boo kill` must end everything the session started, not just the +//! shell. The shell is the session's PTY child; it ran setsid(), so it +//! leads its own session and process group (child_pid == pgid). Signaling +//! only that pid, or even only its process group, leaves behind +//! grandchildren that ignore the signal, were placed in their own process +//! group by the shell's job control, or were backgrounded, so they keep +//! holding ports and files after the session is gone. +//! +//! terminateTree() snapshots the whole descendant tree while it is still +//! intact (before anything is signaled: once the leader dies its children +//! reparent to init and the parent links vanish), hangs up the leader's +//! process group, SIGTERMs every descendant by pid, waits a bounded grace +//! for them to exit, then SIGKILLs whatever remains. Every bare-pid signal +//! is guarded by the process start time recorded at snapshot, so a pid +//! reused between the snapshot and a later SIGKILL is never hit. +//! +//! A descendant that double-forked into its own session and reparented to +//! init before the kill cannot be discovered from the leader and is not +//! reached; that is the same caveat tmux and screen live with. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; + +const log = std.log.scoped(.reap); + +/// A descendant captured at snapshot time. `start` is the process start +/// time; comparing it before each signal distinguishes this process from a +/// later one that reuses the pid. `start_unknown` means the platform could +/// not report it, so the reuse guard is skipped (best effort). +const Proc = struct { + pid: posix.pid_t, + start: u64, +}; + +/// One process seen while enumerating the whole process table, used to +/// rebuild the descendant tree by walking parent links. +const Entry = struct { + pid: posix.pid_t, + ppid: posix.pid_t, + start: u64, +}; + +/// Sentinel for "start time unavailable". A real start time is normalized +/// away from this value (see normalizeStart). +const start_unknown: u64 = 0; + +/// Upper bound on processes enumerated, so a fork bomb cannot make +/// teardown allocate or scan without limit. +const max_procs: usize = 16 * 1024; + +/// How long to wait after SIGTERM before escalating to SIGKILL, polled in +/// small slices so teardown returns as soon as the tree drains. +const grace_ms: i64 = 2000; +const poll_slice_ms: u64 = 20; + +// Run the darwin layout asserts on every target (matching cwd.zig), so a +// struct drift fails the build loudly rather than only on macOS. This +// references a type, so it does not pull the libproc externs into a +// non-macOS link. +comptime { + _ = darwin.proc_bsdinfo; +} + +/// Tear down the entire process tree rooted at `leader` (the session's PTY +/// child, a process-group leader via setsid()). `leader` must be the +/// caller's direct child and still alive, so it can be reaped and its +/// descendants enumerated before anything is signaled. Best effort: any +/// step that cannot run is skipped rather than failing teardown. +pub fn terminateTree(alloc: std.mem.Allocator, leader: posix.pid_t) void { + var procs: std.ArrayList(Proc) = .empty; + defer procs.deinit(alloc); + collect(alloc, leader, &procs); + + // Hang up the leader's process group first: the polite terminal-closed + // signal a shell forwards to its own jobs. child_pid == pgid, so + // -leader targets the shell and everything still in its group. + posix.kill(-leader, posix.SIG.HUP) catch {}; + + // SIGTERM every descendant by pid. This reaches foreground jobs the + // shell placed in their own process groups, which the group signal + // above cannot. + for (procs.items) |p| signalProc(p, posix.SIG.TERM); + + // Wait for the tree to drain, reaping the leader as it exits, and + // return as soon as nothing we started survives. + const deadline = std.time.milliTimestamp() + grace_ms; + while (std.time.milliTimestamp() < deadline) { + reapChildren(); + if (!anyAlive(procs.items)) return; + std.Thread.sleep(poll_slice_ms * std.time.ns_per_ms); + } + + // Escalate: SIGKILL whatever is still the same process we snapshotted. + for (procs.items) |p| signalProc(p, posix.SIG.KILL); + reapChildren(); +} + +/// Reap any of our direct children that have exited (the leader). Orphaned +/// grandchildren reparent to init, which reaps them; this only clears our +/// own zombie so it stops counting as alive. +fn reapChildren() void { + var status: c_int = undefined; + while (std.c.waitpid(-1, &status, std.c.W.NOHANG) > 0) {} +} + +/// Whether any snapshotted process is still alive and still itself (not a +/// zombie, and not a different process that reused the pid). +fn anyAlive(procs: []const Proc) bool { + for (procs) |p| { + const cur = startTimeIfAlive(p.pid) orelse continue; + if (p.start == start_unknown or cur == p.start) return true; + } + return false; +} + +/// Signal `p` only if it is still the process captured at snapshot time, +/// so a delayed SIGKILL never lands on an unrelated process that reused +/// the pid. A process that is already gone or a zombie is skipped. +fn signalProc(p: Proc, sig: u8) void { + if (p.start != start_unknown) { + const cur = startTimeIfAlive(p.pid) orelse return; + if (cur != p.start) return; + } + posix.kill(p.pid, sig) catch {}; +} + +/// Snapshot the descendant tree of `leader` into `out`, including the +/// leader itself. The leader is always present so at least the shell is +/// torn down, even when enumeration fails or the platform is unsupported. +fn collect(alloc: std.mem.Allocator, leader: posix.pid_t, out: *std.ArrayList(Proc)) void { + var all: std.ArrayList(Entry) = .empty; + defer all.deinit(alloc); + enumerateAll(alloc, &all) catch {}; + buildTree(alloc, leader, all.items, out) catch {}; + + for (out.items) |p| if (p.pid == leader) return; + out.append(alloc, .{ + .pid = leader, + .start = startTimeIfAlive(leader) orelse start_unknown, + }) catch {}; +} + +/// Collect the transitive descendants of `leader` from a full process +/// snapshot: a process is in the tree when its parent is. Iterated to a +/// fixpoint because a process record only points up (to its parent). +fn buildTree( + alloc: std.mem.Allocator, + leader: posix.pid_t, + all: []const Entry, + out: *std.ArrayList(Proc), +) !void { + var member: std.AutoHashMapUnmanaged(posix.pid_t, u64) = .empty; + defer member.deinit(alloc); + + var leader_start: u64 = startTimeIfAlive(leader) orelse start_unknown; + for (all) |e| if (e.pid == leader) { + leader_start = e.start; + break; + }; + try member.put(alloc, leader, leader_start); + + var changed = true; + while (changed) { + changed = false; + for (all) |e| { + if (member.contains(e.pid)) continue; + // A process is never its own parent; guard against a self-loop + // (pid 1) that would otherwise spin the fixpoint. + if (e.pid == e.ppid) continue; + if (member.contains(e.ppid)) { + try member.put(alloc, e.pid, e.start); + changed = true; + } + } + } + + var it = member.iterator(); + while (it.next()) |e| { + try out.append(alloc, .{ .pid = e.key_ptr.*, .start = e.value_ptr.* }); + } +} + +fn enumerateAll(alloc: std.mem.Allocator, all: *std.ArrayList(Entry)) !void { + switch (builtin.os.tag) { + .linux => try enumerateLinux(alloc, all), + .macos, .ios, .tvos, .watchos, .visionos => try enumerateDarwin(alloc, all), + else => {}, + } +} + +/// The process start time if `pid` names a live, non-zombie process, else +/// null. Used both to detect liveness and, compared against a recorded +/// value, to detect pid reuse. +fn startTimeIfAlive(pid: posix.pid_t) ?u64 { + switch (builtin.os.tag) { + .linux => { + const st = readLinuxStat(pid) orelse return null; + if (st.state == 'Z') return null; // zombie: effectively dead + return st.start; + }, + .macos, .ios, .tvos, .watchos, .visionos => return darwin.startTime(pid), + else => return null, + } +} + +/// Map a real start time of 0 to 1 so it never collides with the +/// start_unknown sentinel. A start time of 0 is not observed for the +/// processes a session spawns. +fn normalizeStart(s: u64) u64 { + return if (s == start_unknown) 1 else s; +} + +// -- Linux ---------------------------------------------------------------- + +const Stat = struct { ppid: posix.pid_t, state: u8, start: u64 }; + +/// Enumerate every process from /proc. +fn enumerateLinux(alloc: std.mem.Allocator, all: *std.ArrayList(Entry)) !void { + var dir = try std.fs.openDirAbsolute("/proc", .{ .iterate = true }); + defer dir.close(); + var it = dir.iterate(); + while (try it.next()) |entry| { + // Only numeric entries are processes; "self", "cpuinfo", etc. are + // filtered out by the parse failing. + const pid = std.fmt.parseInt(posix.pid_t, entry.name, 10) catch continue; + const st = readLinuxStat(pid) orelse continue; + try all.append(alloc, .{ .pid = pid, .ppid = st.ppid, .start = st.start }); + if (all.items.len >= max_procs) break; + } +} + +fn readLinuxStat(pid: posix.pid_t) ?Stat { + var path_buf: [64]u8 = undefined; + const path = std.fmt.bufPrintZ(&path_buf, "/proc/{d}/stat", .{pid}) catch return null; + var buf: [512]u8 = undefined; + const content = readFileZ(path, &buf) orelse return null; + return parseStat(content); +} + +/// Parse ppid (field 4), state (field 3), and starttime (field 22) out of +/// a /proc//stat line. The comm field (2) is parenthesized and may +/// itself contain spaces and ')', so scanning starts after the final ')'. +fn parseStat(content: []const u8) ?Stat { + const rp = std.mem.lastIndexOfScalar(u8, content, ')') orelse return null; + if (rp + 1 >= content.len) return null; + var it = std.mem.tokenizeScalar(u8, content[rp + 1 ..], ' '); + const state_tok = it.next() orelse return null; // field 3 + const ppid_tok = it.next() orelse return null; // field 4 + if (state_tok.len == 0) return null; + const ppid = std.fmt.parseInt(posix.pid_t, ppid_tok, 10) catch return null; + // We have consumed through field 4; count on to field 22 (starttime). + var field: usize = 4; + const start: u64 = while (it.next()) |tok| { + field += 1; + if (field == 22) break std.fmt.parseInt(u64, tok, 10) catch return null; + } else return null; + return .{ .ppid = ppid, .state = state_tok[0], .start = normalizeStart(start) }; +} + +fn readFileZ(path: [*:0]const u8, buf: []u8) ?[]u8 { + const fd = posix.openZ(path, .{ .ACCMODE = .RDONLY }, 0) catch return null; + defer posix.close(fd); + var total: usize = 0; + while (total < buf.len) { + const n = posix.read(fd, buf[total..]) catch return null; + if (n == 0) break; + total += n; + } + return buf[0..total]; +} + +// -- macOS ---------------------------------------------------------------- + +/// Enumerate every process the caller can inspect via libproc. proc_pidinfo +/// yields both the parent pid and the start time, so the shared fixpoint in +/// buildTree can rebuild the descendant tree exactly as on Linux. +fn enumerateDarwin(alloc: std.mem.Allocator, all: *std.ArrayList(Entry)) !void { + const pids = try alloc.alloc(c_int, max_procs); + defer alloc.free(pids); + + // proc_listallpids returns the number of pids written, not a byte + // count. + const count = darwin.proc_listallpids(pids.ptr, @intCast(pids.len * @sizeOf(c_int))); + if (count <= 0) return; + const n = @min(@as(usize, @intCast(count)), pids.len); + for (pids[0..n]) |pid_c| { + if (pid_c <= 0) continue; + const pid: posix.pid_t = @intCast(pid_c); + const bi = darwin.info(pid) orelse continue; // gone or not inspectable + try all.append(alloc, .{ + .pid = pid, + .ppid = @intCast(bi.pbi_ppid), + .start = darwin.startFromInfo(bi), + }); + } +} + +/// Minimal mirror of `` for PROC_PIDTBSDINFO. proc_pidinfo +/// rejects any buffer whose size differs from `struct proc_bsdinfo`, so the +/// whole layout is reproduced and its size locked at comptime. +const darwin = struct { + const PROC_PIDTBSDINFO: c_int = 3; + const SZOMB: u32 = 5; // : process is a zombie + const MAXCOMLEN = 16; + + const proc_bsdinfo = extern struct { + pbi_flags: u32, + pbi_status: u32, + pbi_xstatus: u32, + pbi_pid: u32, + pbi_ppid: u32, + pbi_uid: u32, + pbi_gid: u32, + pbi_ruid: u32, + pbi_rgid: u32, + pbi_svuid: u32, + pbi_svgid: u32, + rfu_1: u32, + pbi_comm: [MAXCOMLEN]u8, + pbi_name: [2 * MAXCOMLEN]u8, + pbi_nfiles: u32, + pbi_pgid: u32, + pbi_pjobc: u32, + e_tdev: u32, + e_tpgid: u32, + pbi_nice: i32, + pbi_start_tvsec: u64, + pbi_start_tvusec: u64, + }; + + comptime { + // Verified against macOS ; the syscall rejects any + // other size, so a layout drift must fail the build loudly. + std.debug.assert(@sizeOf(proc_bsdinfo) == 136); + std.debug.assert(@offsetOf(proc_bsdinfo, "pbi_status") == 4); + std.debug.assert(@offsetOf(proc_bsdinfo, "pbi_ppid") == 16); + std.debug.assert(@offsetOf(proc_bsdinfo, "pbi_start_tvsec") == 120); + } + + extern "c" fn proc_pidinfo( + pid: c_int, + flavor: c_int, + arg: u64, + buffer: ?*anyopaque, + buffersize: c_int, + ) c_int; + + // Returns the number of pids written (buffer holds c_int pids). + extern "c" fn proc_listallpids(buffer: ?*anyopaque, buffersize: c_int) c_int; + + fn info(pid: posix.pid_t) ?proc_bsdinfo { + var bi: proc_bsdinfo = undefined; + const size: c_int = @sizeOf(proc_bsdinfo); + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bi, size) != size) return null; + return bi; + } + + fn startFromInfo(bi: proc_bsdinfo) u64 { + return normalizeStart(bi.pbi_start_tvsec *% 1_000_000 +% bi.pbi_start_tvusec); + } + + fn startTime(pid: posix.pid_t) ?u64 { + const bi = info(pid) orelse return null; + if (bi.pbi_status == SZOMB) return null; // zombie: effectively dead + return startFromInfo(bi); + } +}; + +test "parseStat reads ppid, state, and starttime past a tricky comm" { + // The comm contains spaces and a ')': the parser must scan from the + // last ')' so those cannot shift the field offsets. + const line = "1234 (weird )name) S 1000 1234 1000 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0\n"; + const st = parseStat(line) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(posix.pid_t, 1000), st.ppid); + try std.testing.expectEqual(@as(u8, 'S'), st.state); + try std.testing.expectEqual(@as(u64, 987654), st.start); +} + +test "parseStat rejects malformed input" { + try std.testing.expect(parseStat("") == null); + try std.testing.expect(parseStat("no parens here") == null); + // Truncated before starttime (field 22). + try std.testing.expect(parseStat("1 (x) S 0 1 1") == null); +} + +test "normalizeStart never yields the unknown sentinel" { + try std.testing.expectEqual(@as(u64, 1), normalizeStart(0)); + try std.testing.expectEqual(@as(u64, 42), normalizeStart(42)); +} + +test "buildTree collects transitive descendants and skips unrelated ones" { + const alloc = std.testing.allocator; + // 100 -> 200 -> 300 is the tree; 400 (child of init) is unrelated, and + // 1 is its own parent (a self-loop that must not spin the fixpoint). + const all = [_]Entry{ + .{ .pid = 1, .ppid = 1, .start = 10 }, + .{ .pid = 100, .ppid = 1, .start = 11 }, + .{ .pid = 200, .ppid = 100, .start = 12 }, + .{ .pid = 300, .ppid = 200, .start = 13 }, + .{ .pid = 400, .ppid = 1, .start = 14 }, + }; + var out: std.ArrayList(Proc) = .empty; + defer out.deinit(alloc); + try buildTree(alloc, 100, &all, &out); + + var seen_100 = false; + var seen_200 = false; + var seen_300 = false; + for (out.items) |p| { + switch (p.pid) { + 100 => seen_100 = true, + 200 => seen_200 = true, + 300 => seen_300 = true, + else => return error.TestUnexpectedResult, // 1 and 400 are not descendants + } + } + try std.testing.expect(seen_100 and seen_200 and seen_300); +} diff --git a/test/integration.zig b/test/integration.zig index 3f7dbe1..96679f9 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -482,6 +482,20 @@ fn waitFileEquals(alloc: std.mem.Allocator, path: []const u8, expected: []const } } +/// Poll `path` until it holds a pid, then return it. Fails on timeout. +fn waitPidFile(alloc: std.mem.Allocator, path: []const u8) !posix.pid_t { + var deadline = Deadline.init(default_timeout_ms); + while (true) { + const content = std.fs.cwd().readFileAlloc(alloc, path, 64) catch ""; + defer if (content.len > 0) alloc.free(content); + const trimmed = std.mem.trim(u8, content, " \t\r\n"); + if (trimmed.len > 0) { + if (std.fmt.parseInt(posix.pid_t, trimmed, 10)) |pid| return pid else |_| {} + } + try deadline.tick("survivor pid file never appeared"); + } +} + test "detached session: send and peek round-trip through libghostty" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); @@ -1311,6 +1325,63 @@ test "kill --all banishes every session" { try h.runExit(&.{ "peek", "ghost1" }, 3); } +test "kill tears down the whole process tree, not just the session shell" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // A survivor that escapes a shell-only hangup: it ignores SIGHUP and + // SIGTERM and loops, so only a full-tree SIGKILL escalation can end + // it. It records its pid so the test can follow it across the kill. + const pid_path = try std.fmt.allocPrint(alloc, "{s}/survivor.pid", .{h.dir}); + defer alloc.free(pid_path); + const script_path = try std.fmt.allocPrint(alloc, "{s}/survivor.sh", .{h.dir}); + defer alloc.free(script_path); + const body = try std.fmt.allocPrint( + alloc, + "#!/bin/sh\necho $$ > {s}\ntrap '' HUP TERM\nwhile :; do sleep 1; done\n", + .{pid_path}, + ); + defer alloc.free(body); + try std.fs.cwd().writeFile(.{ .sub_path = script_path, .data = body }); + try posix.fchmodat(posix.AT.FDCWD, script_path, 0o755, 0); + + try h.startDetached("orph", &.{"sh"}); + + // Launch the survivor as a grandchild of the session shell through an + // intermediate shell that stays alive (wait), so at kill time the + // survivor is still a descendant of the session shell rather than a + // process that already reparented to init. + const launch = try std.fmt.allocPrint(alloc, "sh -c '{s} & wait' &", .{script_path}); + defer alloc.free(launch); + try h.sendLine("orph", launch); + + // Wait until the survivor is up, then read its pid. + const survivor_pid = try waitPidFile(alloc, pid_path); + // Safety net: never leak the survivor if the test bails out early. + defer posix.kill(survivor_pid, posix.SIG.KILL) catch {}; + + // It is really alive before the kill. + try posix.kill(survivor_pid, 0); + + try h.runOk(&.{ "kill", "orph" }); + + // The session is gone... + try h.runExit(&.{ "peek", "orph" }, 3); + + // ...and so is the whole tree it started: the orphan is torn down + // rather than left holding its resources. The shell-only SIGHUP of + // the old behavior left this process alive. + var deadline = Deadline.init(default_timeout_ms); + while (true) { + posix.kill(survivor_pid, 0) catch break; // ESRCH: the orphan is gone + deadline.tick("orphaned descendant survived kill") catch |err| { + std.debug.print("survivor pid {d} still alive after kill\n", .{survivor_pid}); + return err; + }; + } +} + test "exit codes distinguish usage, missing sessions, and ambiguity" { var h = try Harness.init(std.testing.allocator); defer h.deinit(); From 39245a70b2b3c66da3e5f47e80e84926d790863f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 5 Jul 2026 12:33:39 -0600 Subject: [PATCH 2/2] chore: release v0.6.4 (#99) Bump version to 0.6.4. --- build.zig.zon | 2 +- src/main.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3c0a6c6..20514a4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .boo, - .version = "0.6.3", + .version = "0.6.4", .fingerprint = 0x8b7acdfd255f0e34, .minimum_zig_version = "0.15.2", .dependencies = .{ diff --git a/src/main.zig b/src/main.zig index b7e5cd9..f3bb59f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,7 +11,7 @@ const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); const ui = @import("ui.zig"); -pub const version = "0.6.3"; +pub const version = "0.6.4"; /// Exit codes, documented in `boo help`. const exit_runtime: u8 = 1;