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/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..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; @@ -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();