From 1e88f12d702bf342f6be2bd3645c27927f64dd5e Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 17:18:03 -0300 Subject: [PATCH 1/2] feat: setting to turn off the leftover agent process reaper, with tests (INC-489) --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 51 ++++ Resources/settings.schema.json | 5 + Sources/AgentSupervision.swift | 26 +- Sources/LeftoverProcessReaper.swift | 217 +++++++++----- Sources/ProgramaSettingsFileStore.swift | 4 + Sources/SettingsModels.swift | 10 + Sources/SettingsView.swift | 20 ++ docs/settings-json.md | 1 + docs/workspace-interface.md | 21 ++ .../LeftoverProcessReaperTests.swift | 267 ++++++++++++++++++ 11 files changed, 558 insertions(+), 68 deletions(-) create mode 100644 programaTests/LeftoverProcessReaperTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e17ec0851..5da6fb8a6 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ AGSV0003A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGSV0004A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift */; }; AGRP0001A1B2C3D4E5F60719 /* AgentRPCDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGRP0002A1B2C3D4E5F60719 /* AgentRPCDispatcher.swift */; }; AGSV0005A1B2C3D4E5F60719 /* AgentSupervisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGSV0006A1B2C3D4E5F60719 /* AgentSupervisionTests.swift */; }; + LPRT0001A1B2C3D4E5F60719 /* LeftoverProcessReaperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LPRT0002A1B2C3D4E5F60719 /* LeftoverProcessReaperTests.swift */; }; AGOV0001A1B2C3D4E5F60719 /* AgentHelpersPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGOV0002A1B2C3D4E5F60719 /* AgentHelpersPopover.swift */; }; AGOV0003A1B2C3D4E5F60719 /* AgentHelpersPopoverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGOV0004A1B2C3D4E5F60719 /* AgentHelpersPopoverTests.swift */; }; AMDT000001 /* AgentManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AMDT000002 /* AgentManifest.swift */; }; @@ -488,6 +489,7 @@ AGSV0004A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentSupervision.swift"; sourceTree = ""; }; AGRP0002A1B2C3D4E5F60719 /* AgentRPCDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRPCDispatcher.swift; sourceTree = ""; }; AGSV0006A1B2C3D4E5F60719 /* AgentSupervisionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSupervisionTests.swift; sourceTree = ""; }; + LPRT0002A1B2C3D4E5F60719 /* LeftoverProcessReaperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftoverProcessReaperTests.swift; sourceTree = ""; }; AGOV0002A1B2C3D4E5F60719 /* AgentHelpersPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHelpersPopover.swift; sourceTree = ""; }; AGOV0004A1B2C3D4E5F60719 /* AgentHelpersPopoverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHelpersPopoverTests.swift; sourceTree = ""; }; AMDT000002 /* AgentManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentManifest.swift; sourceTree = ""; }; @@ -1350,6 +1352,7 @@ BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, D1A0B0C1A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift */, AGSV0006A1B2C3D4E5F60719 /* AgentSupervisionTests.swift */, + LPRT0002A1B2C3D4E5F60719 /* LeftoverProcessReaperTests.swift */, AGOV0004A1B2C3D4E5F60719 /* AgentHelpersPopoverTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, @@ -1834,6 +1837,7 @@ CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, D1A0B0C0A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift in Sources */, AGSV0005A1B2C3D4E5F60719 /* AgentSupervisionTests.swift in Sources */, + LPRT0001A1B2C3D4E5F60719 /* LeftoverProcessReaperTests.swift in Sources */, AGOV0003A1B2C3D4E5F60719 /* AgentHelpersPopoverTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8649ffe46..f13a52762 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10090,6 +10090,57 @@ } } }, + "settings.app.reapLeftoverAgentProcesses": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop Leftover Agent Processes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残ったエージェントのプロセスを停止" + } + } + } + }, + "settings.app.reapLeftoverAgentProcesses.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processes Claude Code started keep running after it exits." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code が起動したプロセスは、終了後も実行され続けます。" + } + } + } + }, + "settings.app.reapLeftoverAgentProcesses.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When Claude Code exits, processes it started and left running are stopped. Processes started with nohup are kept." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code の終了時に、起動したまま残っているプロセスを停止します。nohup で起動したプロセスは残ります。" + } + } + } + }, "settings.app.settingsFile.openButton": { "extractionState": "manual", "localizations": { diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index 302026cb5..1f89a08ea 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -104,6 +104,11 @@ "default": true, "description": "Resume Claude Code, Codex and OpenCode sessions that were running at quit when their pane is restored." }, + "reapLeftoverAgentProcesses": { + "type": "boolean", + "default": true, + "description": "When a Claude Code session exits, stop the processes it started and left running (MCP servers, dev servers, debug binaries). Processes started with nohup are kept." + }, "commandPaletteSearchesAllSurfaces": { "type": "boolean", "default": false, diff --git a/Sources/AgentSupervision.swift b/Sources/AgentSupervision.swift index c1a3f5d4e..b411ee564 100644 --- a/Sources/AgentSupervision.swift +++ b/Sources/AgentSupervision.swift @@ -1,3 +1,4 @@ +import Bonsplit import Combine import CryptoKit import Foundation @@ -303,7 +304,7 @@ final class AgentSupervisionRegistry: ObservableObject { let ids = recordsById.values.compactMap { record in record.host == host && record.session == session && !record.state.isFinished ? record.id : nil } - return ids.compactMap { try? finish(id: $0, state: state, now: now) } + return ids.compactMap { id in logFailure("finish", id: id) { try finish(id: id, state: state, now: now) } } } func finishActiveSurface( @@ -316,7 +317,7 @@ final class AgentSupervisionRegistry: ObservableObject { throw AgentSupervisionRegistryError.finishStateRequired } let ids = activeSurfaceRecordIds(workspaceId: workspaceId, surfaceId: surfaceId) - return ids.compactMap { try? finish(id: $0, state: state, now: now) } + return ids.compactMap { id in logFailure("finish", id: id) { try finish(id: id, state: state, now: now) } } } func updateActiveSurface( @@ -329,7 +330,7 @@ final class AgentSupervisionRegistry: ObservableObject { throw AgentSupervisionRegistryError.finishStateRequired } let ids = activeSurfaceRecordIds(workspaceId: workspaceId, surfaceId: surfaceId) - return ids.compactMap { try? update(id: $0, state: state, now: now) } + return ids.compactMap { id in logFailure("update", id: id) { try update(id: id, state: state, now: now) } } } /// Finishes the workspace's in-process helpers whose parent agent exited without a @@ -349,7 +350,24 @@ final class AgentSupervisionRegistry: ObservableObject { ? record.id : nil } - return ids.compactMap { try? finish(id: $0, state: state, now: now) } + return ids.compactMap { id in logFailure("finish", id: id) { try finish(id: id, state: state, now: now) } } + } + + /// Runs one step of a multi-record loop: a failure is logged and skipped so the rest still run. + private func logFailure( + _ operation: String, + id: UUID, + _ body: () throws -> AgentTaskRecord + ) -> AgentTaskRecord? { + do { + return try body() + } catch { +#if DEBUG + dlog("agent.supervision \(operation) id=\(id.uuidString.prefix(8)) error=\(error)") +#endif + dilog("agent", "supervision \(operation) id=\(id.uuidString.prefix(8)) error=\(error)") + return nil + } } func removeAll() { diff --git a/Sources/LeftoverProcessReaper.swift b/Sources/LeftoverProcessReaper.swift index 899d3378f..0686c3b24 100644 --- a/Sources/LeftoverProcessReaper.swift +++ b/Sources/LeftoverProcessReaper.swift @@ -21,6 +21,30 @@ final class LeftoverProcessReaper { /// clean SessionEnd hook has had time to clear the panel's agent presence. var onAgentExit: (@MainActor (pid_t) -> Void)? + struct ProcessSnapshot { + let startTime: UInt64 + let uid: uid_t + let ignoresSIGHUP: Bool + } + + /// The kernel lookups, signals and timers the reaper uses, so tests can run it against fake + /// processes. + struct System { + /// `nil` when the private lookup is missing; the reaper then does nothing. + var responsiblePid: ((pid_t) -> pid_t)? + var processInfo: (pid_t) -> ProcessSnapshot? + var allPids: () -> [pid_t] + var processName: (pid_t) -> String + /// `kill(2)`: returns 0, or the errno it failed with. + var sendSignal: (pid_t, Int32) -> Int32 + /// Calls the handler on the queue once the pid exits; returns the cancel function. + var watchExit: (pid_t, DispatchQueue, @escaping () -> Void) -> () -> Void + var runAfter: (DispatchTimeInterval, DispatchQueue, @escaping () -> Void) -> Void + /// Starts a repeating timer on the queue; returns the cancel function. + var startTimer: (DispatchTimeInterval, DispatchQueue, @escaping () -> Void) -> () -> Void + var isEnabled: () -> Bool + } + private struct Leftover { let startTime: UInt64 let name: String @@ -28,51 +52,56 @@ final class LeftoverProcessReaper { private struct Owner { let startTime: UInt64 - let exitSource: DispatchSourceProcess + let cancelExitWatch: () -> Void var leftovers: [pid_t: Leftover] = [:] /// Set at exit, so a snapshot during the reap grace never sees the owner as live (a /// zombie keeps its start time) and wipes the leftovers its exit re-pointed. var exited = false } - private let queue = DispatchQueue(label: "com.darkroom.programa.leftover-reaper", qos: .utility) - /// Mirror of `owners.keys` readable from any thread, so the per-hook-event `trackAgent` - /// call from main skips the queue hop for an owner that is already tracked. + private let system: System + private let queue: DispatchQueue + /// Guards `trackedPids` and `pollingSuspended`, which the per-hook-event `trackAgent` call + /// reads from main without a queue hop. private let trackedLock = NSLock() + /// Mirror of `owners.keys`. private var trackedPids = Set() + /// Set while the setting is off and owners are tracked, so the next hook event restarts + /// polling once the setting is back on. + private var pollingSuspended = false private var owners: [pid_t: Owner] = [:] - private var snapshotTimer: DispatchSourceTimer? + private var cancelSnapshotTimer: (() -> Void)? // SHORTCUT: ownership snapshots every 10 s. ceiling: anything an agent launches in its // last ~10 s of life is missed and survives. upgrade: if leftovers are still reported, // follow forks with kqueue NOTE_FORK on each owner instead of polling. - private static let snapshotInterval: DispatchTimeInterval = .seconds(10) - private static let initialSnapshotDelay: DispatchTimeInterval = .seconds(3) - private static let reapGrace: DispatchTimeInterval = .seconds(2) - private static let killGrace: DispatchTimeInterval = .seconds(3) + static let snapshotInterval: DispatchTimeInterval = .seconds(10) + static let initialSnapshotDelay: DispatchTimeInterval = .seconds(3) + static let reapGrace: DispatchTimeInterval = .seconds(2) + static let killGrace: DispatchTimeInterval = .seconds(3) private static let exitNotifyGrace: DispatchTimeInterval = .milliseconds(500) - private typealias ResponsibleLookup = @convention(c) (pid_t) -> pid_t - /// `responsibility_get_pid_responsible_for_pid` is private libsystem API; when it is - /// missing the reaper does nothing. - private static let responsibleLookup: ResponsibleLookup? = { - guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "responsibility_get_pid_responsible_for_pid") else { - return nil - } - return unsafeBitCast(symbol, to: ResponsibleLookup.self) - }() - - private init() {} + init( + system: System = .live, + queue: DispatchQueue = DispatchQueue(label: "com.darkroom.programa.leftover-reaper", qos: .utility) + ) { + self.system = system + self.queue = queue + } /// Registers an agent pid as an owner of leftovers. Idempotent and cheap: called on every /// hook event that carries the agent's pid. func trackAgent(pid: pid_t) { - guard pid > 1, pid != getpid(), Self.responsibleLookup != nil else { return } + guard pid > 1, pid != getpid(), system.responsiblePid != nil else { return } trackedLock.lock() let inserted = trackedPids.insert(pid).inserted + let suspended = pollingSuspended trackedLock.unlock() - guard inserted else { return } - queue.async { [self] in startTracking(pid: pid) } + if inserted { + queue.async { [self] in startTracking(pid: pid) } + } else if suspended { + queue.async { [self] in resumePolling() } + } } /// Re-reads which live processes `owner` is responsible for. @@ -84,53 +113,77 @@ final class LeftoverProcessReaper { private func startTracking(pid: pid_t) { guard owners[pid] == nil else { return } - guard let info = Self.processInfo(pid: pid) else { + guard let info = system.processInfo(pid) else { untrack(pid) return } - let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: queue) - source.setEventHandler { [weak self] in self?.ownerExited(pid: pid, startTime: info.startTime) } - owners[pid] = Owner(startTime: info.startTime, exitSource: source) - source.resume() + // Exits are watched even with reaping off: `onAgentExit` drives the interrupted-agent banner. + let cancelExitWatch = system.watchExit(pid, queue) { [weak self] in + self?.ownerExited(pid: pid, startTime: info.startTime) + } + owners[pid] = Owner(startTime: info.startTime, cancelExitWatch: cancelExitWatch) // A process source never fires for a pid that exited before it was armed. - if kill(pid, 0) == -1, errno == ESRCH { + if system.sendSignal(pid, 0) == ESRCH { ownerExited(pid: pid, startTime: info.startTime) return } + guard pollingEnabled() else { return } ensureSnapshotTimer() // MCP servers start a moment after the agent reports in. - queue.asyncAfter(deadline: .now() + Self.initialSnapshotDelay) { [weak self] in + system.runAfter(Self.initialSnapshotDelay, queue) { [weak self] in self?.snapshot(owners: [pid]) } } + /// Reads the setting; when it is off, stops polling and forgets every snapshot. + private func pollingEnabled() -> Bool { + let enabled = system.isEnabled() + if !enabled { + stopSnapshotTimer() + for pid in owners.keys { + owners[pid]?.leftovers = [:] + } + } + trackedLock.lock() + pollingSuspended = !enabled && !owners.isEmpty + trackedLock.unlock() + return enabled + } + + private func resumePolling() { + guard !owners.isEmpty, pollingEnabled() else { return } + ensureSnapshotTimer() + snapshot(owners: Set(owners.keys)) + } + private func ensureSnapshotTimer() { - guard snapshotTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + Self.snapshotInterval, repeating: Self.snapshotInterval, leeway: .seconds(2)) - timer.setEventHandler { [weak self] in + guard cancelSnapshotTimer == nil else { return } + cancelSnapshotTimer = system.startTimer(Self.snapshotInterval, queue) { [weak self] in guard let self else { return } self.snapshot(owners: Set(self.owners.keys)) } - snapshotTimer = timer - timer.resume() + } + + private func stopSnapshotTimer() { + cancelSnapshotTimer?() + cancelSnapshotTimer = nil } private func snapshot(owners requested: Set) { - guard let lookup = Self.responsibleLookup else { return } + guard let lookup = system.responsiblePid, pollingEnabled() else { return } let live = requested.filter { pid in guard let owner = owners[pid], !owner.exited else { return false } - return Self.processInfo(pid: pid)?.startTime == owner.startTime + return system.processInfo(pid)?.startTime == owner.startTime } guard !live.isEmpty else { return } let selfPid = getpid() let uid = getuid() var found: [pid_t: [pid_t: Leftover]] = Dictionary(uniqueKeysWithValues: live.map { ($0, [:]) }) - for pid in Self.allPids() where pid > 1 && pid != selfPid && owners[pid] == nil { + for pid in system.allPids() where pid > 1 && pid != selfPid && owners[pid] == nil { let responsible = lookup(pid) guard responsible > 0, live.contains(responsible), - let info = Self.processInfo(pid: pid), info.uid == uid else { continue } - found[responsible]?[pid] = Leftover(startTime: info.startTime, name: Self.processName(pid: pid)) + let info = system.processInfo(pid), info.uid == uid else { continue } + found[responsible]?[pid] = Leftover(startTime: info.startTime, name: system.processName(pid)) } for (owner, leftovers) in found { owners[owner]?.leftovers = leftovers @@ -140,42 +193,44 @@ final class LeftoverProcessReaper { private func ownerExited(pid: pid_t, startTime: UInt64) { guard let owner = owners[pid], owner.startTime == startTime, !owner.exited else { return } owners[pid]?.exited = true - owner.exitSource.cancel() + owner.cancelExitWatch() DispatchQueue.main.asyncAfter(deadline: .now() + Self.exitNotifyGrace) { [weak self] in MainActor.assumeIsolated { self?.onAgentExit?(pid) } } - queue.asyncAfter(deadline: .now() + Self.reapGrace) { [weak self] in + system.runAfter(Self.reapGrace, queue) { [weak self] in self?.reap(owner: pid) } } private func reap(owner pid: pid_t) { - guard let owner = owners[pid] else { return } + guard let owner = owners.removeValue(forKey: pid) else { return } + untrack(pid) + if owners.isEmpty { + stopSnapshotTimer() + } + guard system.isEnabled() else { + dilog("reaper", "owner=\(pid) exited reaping=off") + return + } let selfPid = getpid() var targets: [(pid: pid_t, leftover: Leftover)] = [] var skippedNohup = 0 for (leftoverPid, leftover) in owner.leftovers where leftoverPid != selfPid { - guard let info = Self.processInfo(pid: leftoverPid), info.startTime == leftover.startTime else { continue } + guard let info = system.processInfo(leftoverPid), info.startTime == leftover.startTime else { continue } if info.ignoresSIGHUP { skippedNohup += 1 continue } - if kill(leftoverPid, SIGTERM) == 0 { + if system.sendSignal(leftoverPid, SIGTERM) == 0 { targets.append((leftoverPid, leftover)) } } let names = targets.prefix(10).map(\.leftover.name).joined(separator: ",") dilog("reaper", "owner=\(pid) exited reaped=\(targets.count) skippedNohup=\(skippedNohup) names=\(names)") - owners.removeValue(forKey: pid) - untrack(pid) - if owners.isEmpty { - snapshotTimer?.cancel() - snapshotTimer = nil - } guard !targets.isEmpty else { return } - queue.asyncAfter(deadline: .now() + Self.killGrace) { - for target in targets where Self.processInfo(pid: target.pid)?.startTime == target.leftover.startTime { - kill(target.pid, SIGKILL) + system.runAfter(Self.killGrace, queue) { [system] in + for target in targets where system.processInfo(target.pid)?.startTime == target.leftover.startTime { + _ = system.sendSignal(target.pid, SIGKILL) } } } @@ -183,18 +238,52 @@ final class LeftoverProcessReaper { private func untrack(_ pid: pid_t) { trackedLock.lock() trackedPids.remove(pid) + if trackedPids.isEmpty { + pollingSuspended = false + } trackedLock.unlock() } +} - // MARK: - Kernel lookups +// MARK: - Kernel lookups - private struct ProcessSnapshot { - let startTime: UInt64 - let uid: uid_t - let ignoresSIGHUP: Bool +extension LeftoverProcessReaper.System { + static var live: Self { + Self( + responsiblePid: responsibleLookup.map { lookup in { pid in lookup(pid) } }, + processInfo: liveProcessInfo(pid:), + allPids: liveAllPids, + processName: liveProcessName(pid:), + sendSignal: { pid, signal in kill(pid, signal) == 0 ? 0 : errno }, + watchExit: { pid, queue, handler in + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: queue) + source.setEventHandler(handler: handler) + source.resume() + return { source.cancel() } + }, + runAfter: { delay, queue, work in queue.asyncAfter(deadline: .now() + delay, execute: work) }, + startTimer: { interval, queue, handler in + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(2)) + timer.setEventHandler(handler: handler) + timer.resume() + return { timer.cancel() } + }, + isEnabled: { LeftoverProcessReaperSettings.isEnabled() } + ) } - private static func processInfo(pid: pid_t) -> ProcessSnapshot? { + private typealias ResponsibleLookup = @convention(c) (pid_t) -> pid_t + /// `responsibility_get_pid_responsible_for_pid` is private libsystem API; when it is + /// missing the reaper does nothing. + private static let responsibleLookup: ResponsibleLookup? = { + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "responsibility_get_pid_responsible_for_pid") else { + return nil + } + return unsafeBitCast(symbol, to: ResponsibleLookup.self) + }() + + private static func liveProcessInfo(pid: pid_t) -> LeftoverProcessReaper.ProcessSnapshot? { var info = kinfo_proc() var size = MemoryLayout.stride var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] @@ -202,14 +291,14 @@ final class LeftoverProcessReaper { size > 0, info.kp_proc.p_pid == pid else { return nil } let start = info.kp_proc.p_un.__p_starttime - return ProcessSnapshot( + return LeftoverProcessReaper.ProcessSnapshot( startTime: UInt64(start.tv_sec) * 1_000_000 + UInt64(start.tv_usec), uid: info.kp_eproc.e_ucred.cr_uid, ignoresSIGHUP: info.kp_proc.p_sigignore & (sigset_t(1) << sigset_t(SIGHUP - 1)) != 0 ) } - private static func allPids() -> [pid_t] { + private static func liveAllPids() -> [pid_t] { let estimate = proc_listallpids(nil, 0) guard estimate > 0 else { return [] } // Headroom for processes spawned between the sizing call and the read. @@ -221,7 +310,7 @@ final class LeftoverProcessReaper { return Array(pids.prefix(Int(count))) } - private static func processName(pid: pid_t) -> String { + private static func liveProcessName(pid: pid_t) -> String { var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN) * 2 + 1) guard proc_name(pid, &buffer, UInt32(buffer.count)) > 0 else { return "?" } return String(cString: buffer) diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index a85e88969..3709c918f 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -444,6 +444,9 @@ final class ProgramaSettingsFileStore { if let value = jsonBool(section["autoResumeAgents"]) { snapshot.managedUserDefaults[AgentAutoResumeSettings.autoResumeAgentsKey] = .bool(value) } + if let value = jsonBool(section["reapLeftoverAgentProcesses"]) { + snapshot.managedUserDefaults[LeftoverProcessReaperSettings.reapLeftoverAgentProcessesKey] = .bool(value) + } if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) } @@ -1163,6 +1166,7 @@ final class ProgramaSettingsFileStore { "minimalMode": WorkspacePresentationModeSettings.defaultMode == .minimal, "warnBeforeQuit": QuitWarningSettings.defaultWarnBeforeQuit, "autoResumeAgents": AgentAutoResumeSettings.defaultAutoResumeAgents, + "reapLeftoverAgentProcesses": LeftoverProcessReaperSettings.defaultReapLeftoverAgentProcesses, "commandPaletteSearchesAllSurfaces": CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces, ], ], diff --git a/Sources/SettingsModels.swift b/Sources/SettingsModels.swift index 63000c6d9..2fc799067 100644 --- a/Sources/SettingsModels.swift +++ b/Sources/SettingsModels.swift @@ -104,6 +104,16 @@ enum AgentAutoResumeSettings { } } +enum LeftoverProcessReaperSettings { + static let reapLeftoverAgentProcessesKey = "agentReapLeftoverProcesses" + static let defaultReapLeftoverAgentProcesses = true + private static let flag = UserDefaultsFlag(key: reapLeftoverAgentProcessesKey, defaultValue: defaultReapLeftoverAgentProcesses) + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } +} + enum ScrollbackPersistenceSettings { static let persistScrollbackKey = "sessionPersistScrollback" static let defaultPersistScrollback = true diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 8ce4e602f..d77f6f293 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -49,6 +49,8 @@ struct SettingsView: View { @AppStorage(ScrollbackPersistenceSettings.persistScrollbackKey) private var sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback @AppStorage(ScrollbackPersistenceSettings.failureKey) private var scrollbackPersistenceFailure: String? @AppStorage(AgentAutoResumeSettings.autoResumeAgentsKey) private var sessionAutoResumeAgents = AgentAutoResumeSettings.defaultAutoResumeAgents + @AppStorage(LeftoverProcessReaperSettings.reapLeftoverAgentProcessesKey) + private var reapLeftoverAgentProcesses = LeftoverProcessReaperSettings.defaultReapLeftoverAgentProcesses @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) @@ -519,6 +521,23 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.reapLeftoverAgentProcesses", defaultValue: "Stop Leftover Agent Processes"), + subtitle: reapLeftoverAgentProcesses + ? String(localized: "settings.app.reapLeftoverAgentProcesses.subtitleOn", defaultValue: "When Claude Code exits, processes it started and left running are stopped. Processes started with nohup are kept.") + : String(localized: "settings.app.reapLeftoverAgentProcesses.subtitleOff", defaultValue: "Processes Claude Code started keep running after it exits.") + ) { + Toggle("", isOn: $reapLeftoverAgentProcesses) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.reapLeftoverAgentProcesses", defaultValue: "Stop Leftover Agent Processes") + ) + } + .managedBySettingsFile(settingsFileStatus.isManaged(LeftoverProcessReaperSettings.reapLeftoverAgentProcessesKey)) + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), subtitle: commandPaletteSearchAllSurfaces @@ -978,6 +997,7 @@ struct SettingsView: View { warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit ScrollbackPersistenceSettings.setEnabled(ScrollbackPersistenceSettings.defaultPersistScrollback) sessionAutoResumeAgents = AgentAutoResumeSettings.defaultAutoResumeAgents + reapLeftoverAgentProcesses = LeftoverProcessReaperSettings.defaultReapLeftoverAgentProcesses commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints diff --git a/docs/settings-json.md b/docs/settings-json.md index c1f3fa0a8..23102cb3b 100644 --- a/docs/settings-json.md +++ b/docs/settings-json.md @@ -22,6 +22,7 @@ General app preferences from Settings > App. | `minimalMode` | boolean | `false` | Hide the workspace title bar and move controls into the sidebar. | | `warnBeforeQuit` | boolean | `true` | Show a confirmation before quitting Programa. | | `autoResumeAgents` | boolean | `true` | Resume Claude Code, Codex and OpenCode sessions that were running at quit when their pane is restored. | +| `reapLeftoverAgentProcesses` | boolean | `true` | When a Claude Code session exits, stop the processes it started and left running (MCP servers, dev servers, debug binaries). Processes started with `nohup` are kept. See [Processes an agent leaves behind](workspace-interface.md#processes-an-agent-leaves-behind). | | `commandPaletteSearchesAllSurfaces` | boolean | `false` | Search every surface in the command palette switcher instead of only the active workspace. | ## `notifications` diff --git a/docs/workspace-interface.md b/docs/workspace-interface.md index 1b164b6d2..1e9458f31 100644 --- a/docs/workspace-interface.md +++ b/docs/workspace-interface.md @@ -259,3 +259,24 @@ recorded. After a restart, each terminal pane remembers the last agent session it ran. Focus the pane and run **Resume Agent Session** from the command palette (`⌘ ⇧ P`). The action appears only for a terminal that has a resumable session and no running agent. + +## Processes an agent leaves behind + +Claude Code often starts processes that outlive it: MCP servers, dev servers, debug builds it ran +to test something. macOS counts each of them as belonging to the `claude` process, so they pile up +in the Dock, and force-quitting one of them from the Dock also kills the agent. When a Claude Code +session exits, Programa sends `SIGTERM` to every process that session started and left running, +then `SIGKILL` to any that are still alive 3 seconds later. While a session runs, Programa checks +every 10 seconds which processes it started. + +Only processes that run as your user and that the exiting session started are stopped. Processes +started with `nohup` (or anything else that ignores `SIGHUP`) are kept, and so is anything you +start yourself from a shell, such as `colima` or `docker`. A process started in the last few +seconds before the session exits can be missed and keep running. Each exit is logged to the +[diagnostics log](diagnostics.md) under `reaper`, with how many processes were stopped and their +names. + +To keep everything a session started, turn off **Settings > App > Stop Leftover Agent Processes**, +or set `app.reapLeftoverAgentProcesses` to `false` in +[settings.json](settings-json.md#app). With it off, Programa stops checking which processes a +session started. diff --git a/programaTests/LeftoverProcessReaperTests.swift b/programaTests/LeftoverProcessReaperTests.swift new file mode 100644 index 000000000..37631a93f --- /dev/null +++ b/programaTests/LeftoverProcessReaperTests.swift @@ -0,0 +1,267 @@ +import Darwin +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Drives `LeftoverProcessReaper` against a fake process table. Pids are above the macOS pid +/// limit (99998), so even a bug that reached `kill(2)` could not hit a real process. +final class LeftoverProcessReaperTests: XCTestCase { + private let owner: pid_t = 200_000 + private var fake: FakeProcessTable! + private var queue: DispatchQueue! + private var reaper: LeftoverProcessReaper! + + override func setUp() { + super.setUp() + fake = FakeProcessTable() + queue = DispatchQueue(label: "LeftoverProcessReaperTests") + reaper = LeftoverProcessReaper(system: fake.system, queue: queue) + fake.add(owner, responsible: owner) + } + + override func tearDown() { + reaper = nil + queue = nil + fake = nil + super.tearDown() + } + + func testReapsOnlyLiveLeftoversChargedToTheTrackedAgent() { + fake.add(200_001, responsible: owner) + fake.add(200_002, responsible: 200_100) + fake.add(200_003, responsible: owner, uid: getuid() + 1) + fake.add(200_004, responsible: owner) + fake.add(200_005, responsible: 0) + + track(owner) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + fake.add(200_004, responsible: 200_004, startTime: 99) + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.sentSignals, [Signal(pid: 200_001, signal: SIGTERM)]) + } + + func testOwnerPidReusedBeforeSnapshotChargesNothingToIt() { + fake.add(200_001, responsible: owner) + + track(owner) + fake.add(owner, responsible: owner, startTime: 2) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.sentSignals, []) + } + + func testSkipsLeftoversThatIgnoreSIGHUP() { + fake.add(200_001, responsible: owner, ignoresSIGHUP: true) + fake.add(200_002, responsible: owner) + + track(owner) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.sentSignals, [Signal(pid: 200_002, signal: SIGTERM)]) + } + + func testEscalatesToSIGKILLOnlyAfterKillGraceAndOnlyForSurvivors() { + fake.add(200_001, responsible: owner) + fake.add(200_002, responsible: owner) + fake.exitsOnSIGTERM = [200_002] + + track(owner) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual( + Set(fake.sentSignals), + [Signal(pid: 200_001, signal: SIGTERM), Signal(pid: 200_002, signal: SIGTERM)] + ) + XCTAssertEqual(fake.delayed.map(\.delay), [LeftoverProcessReaper.killGrace]) + + runDelayed(LeftoverProcessReaper.killGrace) + + XCTAssertEqual(fake.sentSignals.filter { $0.signal == SIGKILL }, [Signal(pid: 200_001, signal: SIGKILL)]) + } + + func testDoesNotPollOrSignalWhenSettingIsOffButStillReportsTheExit() { + fake.enabled = false + fake.add(200_001, responsible: owner) + let exitReported = expectation(description: "onAgentExit") + let expectedPid = owner + reaper.onAgentExit = { pid in + XCTAssertEqual(pid, expectedPid) + exitReported.fulfill() + } + + track(owner) + + XCTAssertEqual(fake.timerStarts, 0) + XCTAssertEqual(fake.delayed.map(\.delay), []) + + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.allPidsCalls, 0) + XCTAssertEqual(fake.sentSignals, []) + XCTAssertTrue(fake.delayed.isEmpty) + wait(for: [exitReported], timeout: 5) + } + + func testTurningSettingOffStopsPollingAndTurningItBackOnResumes() { + fake.add(200_001, responsible: owner) + + track(owner) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + XCTAssertEqual(fake.timerStarts, 1) + let scansWhileOn = fake.allPidsCalls + + fake.enabled = false + fireTimer() + + XCTAssertEqual(fake.timerCancels, 1) + XCTAssertNil(fake.timerHandler) + XCTAssertEqual(fake.allPidsCalls, scansWhileOn) + + fake.enabled = true + track(owner) + + XCTAssertEqual(fake.timerStarts, 2) + XCTAssertEqual(fake.allPidsCalls, scansWhileOn + 1) + + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.sentSignals, [Signal(pid: 200_001, signal: SIGTERM)]) + } + + func testSettingOffAtExitForgetsLeftoversRecordedWhileOn() { + fake.add(200_001, responsible: owner) + + track(owner) + runDelayed(LeftoverProcessReaper.initialSnapshotDelay) + fake.enabled = false + exitOwner() + runDelayed(LeftoverProcessReaper.reapGrace) + + XCTAssertEqual(fake.sentSignals, []) + XCTAssertEqual(fake.timerCancels, 1) + } + + // MARK: - Driving the reaper + + private func track(_ pid: pid_t) { + reaper.trackAgent(pid: pid) + queue.sync {} + } + + /// Runs the first work item the reaper scheduled with `delay`, on the reaper's queue. + private func runDelayed(_ delay: DispatchTimeInterval, file: StaticString = #filePath, line: UInt = #line) { + queue.sync { + guard let index = fake.delayed.firstIndex(where: { $0.delay == delay }) else { + XCTFail("nothing scheduled after \(delay); scheduled: \(fake.delayed.map(\.delay))", file: file, line: line) + return + } + fake.delayed.remove(at: index).work() + } + } + + private func fireTimer() { + queue.sync { fake.timerHandler?() } + } + + /// The owner exits and, as macOS does, each leftover's responsible pid points back at itself. + private func exitOwner(file: StaticString = #filePath, line: UInt = #line) { + for (pid, process) in fake.processes where process.responsible == owner && pid != owner { + fake.processes[pid]?.responsible = pid + } + queue.sync { + guard let handler = fake.exitHandlers[owner] else { + XCTFail("owner exit is not watched", file: file, line: line) + return + } + handler() + } + } +} + +private struct Signal: Hashable, CustomStringConvertible { + let pid: pid_t + let signal: Int32 + + var description: String { "\(pid):\(signal)" } +} + +/// Every closure runs on the reaper's queue, which the tests only touch through `queue.sync`. +private final class FakeProcessTable { + struct Process { + var responsible: pid_t + var info: LeftoverProcessReaper.ProcessSnapshot + } + + var processes: [pid_t: Process] = [:] + var enabled = true + var exitsOnSIGTERM: Set = [] + private(set) var sentSignals: [Signal] = [] + private(set) var exitHandlers: [pid_t: () -> Void] = [:] + var delayed: [(delay: DispatchTimeInterval, work: () -> Void)] = [] + private(set) var timerHandler: (() -> Void)? + private(set) var timerStarts = 0 + private(set) var timerCancels = 0 + private(set) var allPidsCalls = 0 + + func add( + _ pid: pid_t, + responsible: pid_t, + startTime: UInt64 = 1, + uid: uid_t = getuid(), + ignoresSIGHUP: Bool = false + ) { + processes[pid] = Process( + responsible: responsible, + info: LeftoverProcessReaper.ProcessSnapshot(startTime: startTime, uid: uid, ignoresSIGHUP: ignoresSIGHUP) + ) + } + + var system: LeftoverProcessReaper.System { + LeftoverProcessReaper.System( + responsiblePid: { [unowned self] pid in processes[pid]?.responsible ?? 0 }, + processInfo: { [unowned self] pid in processes[pid]?.info }, + allPids: { [unowned self] in + allPidsCalls += 1 + return processes.keys.sorted() + }, + processName: { pid in "proc\(pid)" }, + sendSignal: { [unowned self] pid, signal in + guard processes[pid] != nil else { return ESRCH } + guard signal != 0 else { return 0 } + sentSignals.append(Signal(pid: pid, signal: signal)) + if signal == SIGKILL || (signal == SIGTERM && exitsOnSIGTERM.contains(pid)) { + processes[pid] = nil + } + return 0 + }, + watchExit: { [unowned self] pid, _, handler in + exitHandlers[pid] = handler + return { [unowned self] in exitHandlers[pid] = nil } + }, + runAfter: { [unowned self] delay, _, work in delayed.append((delay, work)) }, + startTimer: { [unowned self] _, _, handler in + timerStarts += 1 + timerHandler = handler + return { [unowned self] in + timerCancels += 1 + timerHandler = nil + } + }, + isEnabled: { [unowned self] in enabled } + ) + } +} From 0ab79a614f8893f6edb1b394fd31ce4c01eb249a Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 17:37:56 -0300 Subject: [PATCH 2/2] fix: skip the SIGKILL escalation when the reaper is turned off during the grace period (INC-489) --- Sources/LeftoverProcessReaper.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/LeftoverProcessReaper.swift b/Sources/LeftoverProcessReaper.swift index 0686c3b24..053eea8fa 100644 --- a/Sources/LeftoverProcessReaper.swift +++ b/Sources/LeftoverProcessReaper.swift @@ -229,6 +229,7 @@ final class LeftoverProcessReaper { dilog("reaper", "owner=\(pid) exited reaped=\(targets.count) skippedNohup=\(skippedNohup) names=\(names)") guard !targets.isEmpty else { return } system.runAfter(Self.killGrace, queue) { [system] in + guard system.isEnabled() else { return } for target in targets where system.processInfo(target.pid)?.startTime == target.leftover.startTime { _ = system.sendSignal(target.pid, SIGKILL) }