From 8fe872109d2c66c9851d21d3c8792d5752814bc4 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Tue, 14 Jul 2026 00:35:10 +0800 Subject: [PATCH 1/2] feat: reap orphaned agent processes on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Deckard dies abnormally, spawned claude/codex processes survive as orphans (they ignore the PTY hangup), and session restore then resumes the same sessions again — two live processes appending to one session file. Observed at 41-orphan scale after a single force-kill. Track direct (non-tmux) shell PIDs in a persistent registry keyed by kernel process start time (guards against PID reuse). On launch, before restore spawns replacements, kill recorded processes that are alive but reparented to launchd. Entries owned by another live instance are left alone; tmux sessions remain persistent by design. Fixes #100 Co-Authored-By: Claude Fable 5 --- Deckard.xcodeproj/project.pbxproj | 8 + Sources/App/AppDelegate.swift | 7 + .../Detection/SpawnedProcessRegistry.swift | 159 ++++++++++++++++++ Sources/Terminal/TerminalSurface.swift | 12 ++ Tests/SpawnedProcessRegistryTests.swift | 118 +++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 Sources/Detection/SpawnedProcessRegistry.swift create mode 100644 Tests/SpawnedProcessRegistryTests.swift diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index 7ab24da..7b62e86 100644 --- a/Deckard.xcodeproj/project.pbxproj +++ b/Deckard.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ TM000001TM000001TM000001 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = TM000002TM000002TM000002 /* ThemeManager.swift */; }; TC000001TC000001TC000001 /* TerminalColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = TC000002TC000002TC000002 /* TerminalColorScheme.swift */; }; DD220001DD220001DD220001 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD220002DD220002DD220002 /* ProcessMonitor.swift */; }; + SP4W0001SP4W0001SP4W0001 /* SpawnedProcessRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = SP4W0002SP4W0002SP4W0002 /* SpawnedProcessRegistry.swift */; }; DD330001DD330001DD330001 /* DiagnosticLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD330002DD330002DD330002 /* DiagnosticLog.swift */; }; DD440001DD440001DD440001 /* CrashReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD440002DD440002DD440002 /* CrashReporter.swift */; }; SC000001SC000001SC000001 /* SidebarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SC000002SC000002SC000002 /* SidebarController.swift */; }; @@ -48,6 +49,7 @@ AA160001AA160001AA160001 /* DiagnosticLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA160002AA160002AA160002 /* DiagnosticLogTests.swift */; }; AA170001AA170001AA170001 /* HookHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA170002AA170002AA170002 /* HookHandlerTests.swift */; }; AA180001AA180001AA180001 /* ProcessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA180002AA180002AA180002 /* ProcessMonitorTests.swift */; }; + SP4WT001SP4WT001SP4WT001 /* SpawnedProcessRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SP4WT002SP4WT002SP4WT002 /* SpawnedProcessRegistryTests.swift */; }; AA190001AA190001AA190001 /* ContextMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA190002AA190002AA190002 /* ContextMonitorTests.swift */; }; AA1A0001AA1A0001AA1A0001 /* TerminalSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1A0002AA1A0002AA1A0002 /* TerminalSurfaceTests.swift */; }; AA1B0001AA1B0001AA1B0001 /* WindowControllerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B0002AA1B0002AA1B0002 /* WindowControllerLogicTests.swift */; }; @@ -94,6 +96,7 @@ TM000002TM000002TM000002 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; TC000002TC000002TC000002 /* TerminalColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalColorScheme.swift; sourceTree = ""; }; DD220002DD220002DD220002 /* ProcessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitor.swift; sourceTree = ""; }; + SP4W0002SP4W0002SP4W0002 /* SpawnedProcessRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpawnedProcessRegistry.swift; sourceTree = ""; }; DD330002DD330002DD330002 /* DiagnosticLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLog.swift; sourceTree = ""; }; DD440002DD440002DD440002 /* CrashReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporter.swift; sourceTree = ""; }; SC000002SC000002SC000002 /* SidebarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarController.swift; sourceTree = ""; }; @@ -111,6 +114,7 @@ AA160002AA160002AA160002 /* DiagnosticLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogTests.swift; sourceTree = ""; }; AA170002AA170002AA170002 /* HookHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookHandlerTests.swift; sourceTree = ""; }; AA180002AA180002AA180002 /* ProcessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitorTests.swift; sourceTree = ""; }; + SP4WT002SP4WT002SP4WT002 /* SpawnedProcessRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpawnedProcessRegistryTests.swift; sourceTree = ""; }; AA190002AA190002AA190002 /* ContextMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMonitorTests.swift; sourceTree = ""; }; AA1A0002AA1A0002AA1A0002 /* TerminalSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceTests.swift; sourceTree = ""; }; AA1B0002AA1B0002AA1B0002 /* WindowControllerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowControllerLogicTests.swift; sourceTree = ""; }; @@ -162,6 +166,7 @@ DD330002DD330002DD330002 /* DiagnosticLog.swift */, 4A2B375A023FEBCB272DCFD7 /* HookHandler.swift */, DD220002DD220002DD220002 /* ProcessMonitor.swift */, + SP4W0002SP4W0002SP4W0002 /* SpawnedProcessRegistry.swift */, QA200001QA200001QA200001 /* QuotaMonitor.swift */, ); path = Detection; @@ -280,6 +285,7 @@ FDAC0004FDAC0004FDAC0004 /* FullDiskAccessCheckerTests.swift */, AA170002AA170002AA170002 /* HookHandlerTests.swift */, AA180002AA180002AA180002 /* ProcessMonitorTests.swift */, + SP4WT002SP4WT002SP4WT002 /* SpawnedProcessRegistryTests.swift */, QA200005QA200005QA200005 /* QuotaMonitorTests.swift */, AA130002AA130002AA130002 /* SessionStateTests.swift */, AA1F0002AA1F0002AA1F0002 /* SidebarGroupTests.swift */, @@ -441,6 +447,7 @@ TM000001TM000001TM000001 /* ThemeManager.swift in Sources */, TC000001TC000001TC000001 /* TerminalColorScheme.swift in Sources */, DD220001DD220001DD220001 /* ProcessMonitor.swift in Sources */, + SP4W0001SP4W0001SP4W0001 /* SpawnedProcessRegistry.swift in Sources */, DD330001DD330001DD330001 /* DiagnosticLog.swift in Sources */, DD440001DD440001DD440001 /* CrashReporter.swift in Sources */, QA200002QA200002QA200002 /* QuotaMonitor.swift in Sources */, @@ -468,6 +475,7 @@ FDAC0003FDAC0003FDAC0003 /* FullDiskAccessCheckerTests.swift in Sources */, AA170001AA170001AA170001 /* HookHandlerTests.swift in Sources */, AA180001AA180001AA180001 /* ProcessMonitorTests.swift in Sources */, + SP4WT001SP4WT001SP4WT001 /* SpawnedProcessRegistryTests.swift in Sources */, AA190001AA190001AA190001 /* ContextMonitorTests.swift in Sources */, AA1A0001AA1A0001AA1A0001 /* TerminalSurfaceTests.swift in Sources */, AA1B0001AA1B0001AA1B0001 /* WindowControllerLogicTests.swift in Sources */, diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index e32d9ab..fca35a5 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -86,6 +86,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { log.log("startup", "tmux not available") } + // Kill agent processes orphaned by a previous app instance BEFORE + // session restore resumes the same sessions — otherwise two live + // processes end up appending to one session file (#100). + let reaped = SpawnedProcessRegistry.shared.reapOrphans() + log.log("startup", "Reaped \(reaped.count) orphaned agent process(es)" + + (reaped.isEmpty ? "" : ": \(reaped.map(String.init).joined(separator: ", "))")) + // Create and show the main window. log.log("startup", "Creating window controller...") windowController = DeckardWindowController() diff --git a/Sources/Detection/SpawnedProcessRegistry.swift b/Sources/Detection/SpawnedProcessRegistry.swift new file mode 100644 index 0000000..df6b617 --- /dev/null +++ b/Sources/Detection/SpawnedProcessRegistry.swift @@ -0,0 +1,159 @@ +import Foundation +import Darwin + +/// Tracks direct-spawn (non-tmux) shell PIDs across app runs so that a crash +/// or force-kill of Deckard doesn't leave orphaned agent processes behind. +/// +/// Agent processes (claude, codex) ignore the PTY hangup when the app dies, +/// so they survive as invisible orphans — and session restore then resumes +/// the same sessions again, leaving two live processes appending to one +/// session file (#100). +/// +/// Every direct spawn is recorded with the kernel process start time (to +/// guard against PID reuse); clean exits remove the record. At launch, +/// before session restore respawns anything, `reapOrphans()` kills recorded +/// processes that are still alive but have been reparented to launchd +/// (ppid == 1). +final class SpawnedProcessRegistry { + static let shared = SpawnedProcessRegistry() + + struct Entry: Codable, Equatable { + let pid: Int32 + /// Kernel process start time, seconds and microseconds since epoch. + let startSec: Int64 + let startUsec: Int64 + let surfaceId: String + } + + /// Live process facts used for reap decisions. Injectable for tests. + struct ProcessSnapshot: Equatable { + let ppid: pid_t + let startSec: Int64 + let startUsec: Int64 + } + + enum ReapDecision: Equatable { + /// Process is gone (or its PID was reused) — drop the record. + case forget + /// Process is alive but still parented (e.g. a second live Deckard + /// instance owns it) — keep the record, do not touch the process. + case keep + /// Process is alive, matches the recorded start time, and has been + /// reparented to launchd — kill it and drop the record. + case kill + } + + private let queue = DispatchQueue(label: "com.deckard.spawned-process-registry") + private let fileURL: URL + private let snapshotProvider: (pid_t) -> ProcessSnapshot? + private let killer: (pid_t) -> Void + private var entries: [Entry] + + init(fileURL: URL? = nil, + snapshotProvider: @escaping (pid_t) -> ProcessSnapshot? = SpawnedProcessRegistry.systemSnapshot, + killer: @escaping (pid_t) -> Void = { Darwin.kill($0, SIGKILL) }) { + if let fileURL { + self.fileURL = fileURL + } else { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("Deckard") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.fileURL = dir.appendingPathComponent("spawned-pids.json") + } + self.snapshotProvider = snapshotProvider + self.killer = killer + if let data = try? Data(contentsOf: self.fileURL), + let loaded = try? JSONDecoder().decode([Entry].self, from: data) { + entries = loaded + } else { + entries = [] + } + } + + // MARK: - Public API + + /// Record a freshly spawned direct (non-tmux) shell PID. + func record(pid: pid_t, surfaceId: String) { + guard pid > 0, let snap = snapshotProvider(pid) else { return } + queue.async { [self] in + entries.removeAll { $0.pid == pid } + entries.append(Entry(pid: pid, startSec: snap.startSec, + startUsec: snap.startUsec, surfaceId: surfaceId)) + persist() + } + } + + /// Remove the record for a cleanly exited or terminated PID. + func remove(pid: pid_t) { + queue.async { [self] in + entries.removeAll { $0.pid == pid } + persist() + } + } + + /// Kill recorded processes orphaned by a previous app instance. + /// Call at launch BEFORE session restore spawns replacements. + /// Returns the PIDs that were killed. + @discardableResult + func reapOrphans() -> [pid_t] { + queue.sync { [self] in + var killed: [pid_t] = [] + var kept: [Entry] = [] + for entry in entries { + switch Self.decide(entry: entry, snapshot: snapshotProvider(entry.pid)) { + case .forget: + break + case .keep: + kept.append(entry) + case .kill: + killer(entry.pid) + killed.append(entry.pid) + DiagnosticLog.shared.log("surface", + "reapOrphans: killed orphaned pid=\(entry.pid) surfaceId=\(entry.surfaceId)") + } + } + entries = kept + persist() + return killed + } + } + + /// Block until queued record/remove writes have been applied (for tests). + func flush() { + queue.sync {} + } + + // MARK: - Decision logic (pure, tested) + + static func decide(entry: Entry, snapshot: ProcessSnapshot?) -> ReapDecision { + guard let snapshot else { return .forget } + // Start time mismatch means the PID was reused by an unrelated process. + guard snapshot.startSec == entry.startSec, snapshot.startUsec == entry.startUsec else { + return .forget + } + // Still parented — likely owned by another live Deckard instance. + guard snapshot.ppid == 1 else { return .keep } + return .kill + } + + // MARK: - System snapshot + + static func systemSnapshot(pid: pid_t) -> ProcessSnapshot? { + var info = kinfo_proc() + var size = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, 4, &info, &size, nil, 0) == 0, size > 0, + info.kp_proc.p_pid == pid else { return nil } + return ProcessSnapshot(ppid: info.kp_eproc.e_ppid, + startSec: Int64(info.kp_proc.p_starttime.tv_sec), + startUsec: Int64(info.kp_proc.p_starttime.tv_usec)) + } + + // MARK: - Persistence (called on queue) + + private func persist() { + guard let data = try? JSONEncoder().encode(entries) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index 14ec9f4..51d4553 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -454,6 +454,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { } } else if clientPid > 0 { ProcessMonitor.shared.registerShellPid(clientPid, forSurface: surfaceId.uuidString) + // Track direct spawns so a crashed app instance's orphans can be + // reaped on next launch (tmux sessions survive by design). + SpawnedProcessRegistry.shared.record(pid: clientPid, surfaceId: surfaceId.uuidString) } DiagnosticLog.shared.log("surface", @@ -500,6 +503,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { NSEvent.removeMonitor(monitor) keyEventMonitor = nil } + if let pid = terminalView.process?.shellPid, pid > 0 { + SpawnedProcessRegistry.shared.remove(pid: pid) + } terminalView.process?.terminate() killTmuxSession() } @@ -508,6 +514,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { func detach() { guard !processExited else { return } processExited = true + if let pid = terminalView.process?.shellPid, pid > 0 { + SpawnedProcessRegistry.shared.remove(pid: pid) + } // Just kill the local process — tmux session survives terminalView.process?.terminate() } @@ -685,6 +694,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { func processTerminated(source: TerminalView, exitCode: Int32?) { processExited = true + if let pid = terminalView.process?.shellPid, pid > 0 { + SpawnedProcessRegistry.shared.remove(pid: pid) + } DiagnosticLog.shared.log("surface", "processTerminated: surfaceId=\(surfaceId) exitCode=\(exitCode ?? -1)") onProcessExit?(self) diff --git a/Tests/SpawnedProcessRegistryTests.swift b/Tests/SpawnedProcessRegistryTests.swift new file mode 100644 index 0000000..3ea84b3 --- /dev/null +++ b/Tests/SpawnedProcessRegistryTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import Deckard + +final class SpawnedProcessRegistryTests: XCTestCase { + private var tempFile: URL! + + override func setUp() { + super.setUp() + tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent("spawned-pids-\(UUID().uuidString).json") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempFile) + super.tearDown() + } + + private func entry(pid: Int32, sec: Int64 = 100, usec: Int64 = 5) -> SpawnedProcessRegistry.Entry { + SpawnedProcessRegistry.Entry(pid: pid, startSec: sec, startUsec: usec, surfaceId: "S") + } + + // MARK: - Decision logic + + func testDecideForgetsDeadProcess() { + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 42), snapshot: nil), .forget) + } + + func testDecideForgetsReusedPid() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 999, startUsec: 5) + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 42), snapshot: snap), .forget) + } + + func testDecideKeepsParentedProcess() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 4321, startSec: 100, startUsec: 5) + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 42), snapshot: snap), .keep) + } + + func testDecideKillsMatchingOrphan() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 100, startUsec: 5) + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 42), snapshot: snap), .kill) + } + + // MARK: - Registry behavior + + func testRecordPersistsAndReapKillsOnlyOrphans() { + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 101: .init(ppid: 500, startSec: 10, startUsec: 1), // parented (alive Deckard) + 102: .init(ppid: 500, startSec: 20, startUsec: 2), // will become orphan + 103: .init(ppid: 500, startSec: 30, startUsec: 3) // will die + ] + let registry1 = SpawnedProcessRegistry(fileURL: tempFile, + snapshotProvider: { snapshots[$0] }, + killer: { _ in XCTFail("no kill expected yet") }) + registry1.record(pid: 101, surfaceId: "A") + registry1.record(pid: 102, surfaceId: "B") + registry1.record(pid: 103, surfaceId: "C") + registry1.flush() + + // Simulate app death and relaunch: 102 reparented to launchd, 103 gone. + snapshots[102] = .init(ppid: 1, startSec: 20, startUsec: 2) + snapshots[103] = nil + + var killedPids: [pid_t] = [] + let registry2 = SpawnedProcessRegistry(fileURL: tempFile, + snapshotProvider: { snapshots[$0] }, + killer: { killedPids.append($0) }) + let reaped = registry2.reapOrphans() + + XCTAssertEqual(killedPids, [102]) + XCTAssertEqual(reaped, [102]) + + // Registry file should retain only the still-parented process. + let registry3 = SpawnedProcessRegistry(fileURL: tempFile, + snapshotProvider: { snapshots[$0] }, + killer: { _ in XCTFail("nothing left to kill") }) + XCTAssertEqual(registry3.reapOrphans(), []) + } + + func testRemoveDropsEntry() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 10, startUsec: 1) + var killedPids: [pid_t] = [] + let registry = SpawnedProcessRegistry(fileURL: tempFile, + snapshotProvider: { _ in snap }, + killer: { killedPids.append($0) }) + registry.record(pid: 201, surfaceId: "A") + registry.remove(pid: 201) + registry.flush() + XCTAssertEqual(registry.reapOrphans(), []) + XCTAssertTrue(killedPids.isEmpty) + } + + func testRecordSamePidReplacesEntry() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 10, startUsec: 1) + var killedPids: [pid_t] = [] + let registry = SpawnedProcessRegistry(fileURL: tempFile, + snapshotProvider: { _ in snap }, + killer: { killedPids.append($0) }) + registry.record(pid: 301, surfaceId: "A") + registry.record(pid: 301, surfaceId: "B") + registry.flush() + XCTAssertEqual(registry.reapOrphans(), [301]) + XCTAssertEqual(killedPids, [301]) + } + + // MARK: - System snapshot sanity + + func testSystemSnapshotOfSelf() { + let snap = SpawnedProcessRegistry.systemSnapshot(pid: getpid()) + XCTAssertNotNil(snap) + XCTAssertEqual(snap?.ppid, getppid()) + XCTAssertGreaterThan(snap?.startSec ?? 0, 0) + } + + func testSystemSnapshotOfDeadPidIsNil() { + // PID 0 is the kernel; sysctl returns it, but a huge unused pid should fail. + XCTAssertNil(SpawnedProcessRegistry.systemSnapshot(pid: 99_999_999)) + } +} From 71b0fb09fddba352678cc6cc36a5f06af0d6ea8c Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Tue, 14 Jul 2026 01:57:46 +0800 Subject: [PATCH 2/2] fix: address orphan-reaper review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terminate()/detach() no longer remove registry entries: they only express kill intent, and agents observably survive SIGTERM — eager removal made such survivors permanently unreapable. Entries are removed on confirmed exit (processTerminated) or reaped next launch. - per-instance registry files (spawned-pids-.json) stamped with the owner's pid + start time replace the shared last-writer-wins file, so concurrent instances (Debug build + installed app) cannot lose each other's records; reap skips files whose owner is alive and processes/deletes files of dead instances (legacy ownerless format still consumed; undecodable files left untouched) - decide() hard-guards pid > 0 (kill(0)/kill(-1) target process groups) - kill() failures are logged and the entry retained for a later attempt - versioned file format; kept entries are written back, verified by test Co-Authored-By: Claude Fable 5 --- .../Detection/SpawnedProcessRegistry.swift | 192 +++++++++++---- Sources/Terminal/TerminalSurface.swift | 10 +- Tests/SpawnedProcessRegistryTests.swift | 224 ++++++++++++++---- 3 files changed, 330 insertions(+), 96 deletions(-) diff --git a/Sources/Detection/SpawnedProcessRegistry.swift b/Sources/Detection/SpawnedProcessRegistry.swift index df6b617..e51e15a 100644 --- a/Sources/Detection/SpawnedProcessRegistry.swift +++ b/Sources/Detection/SpawnedProcessRegistry.swift @@ -10,10 +10,17 @@ import Darwin /// session file (#100). /// /// Every direct spawn is recorded with the kernel process start time (to -/// guard against PID reuse); clean exits remove the record. At launch, -/// before session restore respawns anything, `reapOrphans()` kills recorded -/// processes that are still alive but have been reparented to launchd -/// (ppid == 1). +/// guard against PID reuse); confirmed process exits remove the record. At +/// launch, before session restore respawns anything, `reapOrphans()` kills +/// recorded processes that are still alive but have been reparented to +/// launchd (ppid == 1). +/// +/// Each app instance writes its own registry file +/// (`spawned-pids-.json`, stamped with the owner's pid + start time) +/// so concurrent instances (e.g. a Debug build next to the installed app) +/// never clobber each other's records. `reapOrphans()` scans all registry +/// files: files whose owning instance is still alive are skipped; files of +/// dead instances are processed and deleted. final class SpawnedProcessRegistry { static let shared = SpawnedProcessRegistry() @@ -25,6 +32,19 @@ final class SpawnedProcessRegistry { let surfaceId: String } + /// On-disk format: entries plus the identity of the app instance that + /// owns the file, so reap can tell live siblings from dead ancestors. + private struct RegistryFile: Codable { + var version: Int + var ownerPid: Int32 + var ownerStartSec: Int64 + var ownerStartUsec: Int64 + var entries: [Entry] + } + + private static let formatVersion = 1 + private static let filePrefix = "spawned-pids" + /// Live process facts used for reap decisions. Injectable for tests. struct ProcessSnapshot: Equatable { let ppid: pid_t @@ -35,8 +55,7 @@ final class SpawnedProcessRegistry { enum ReapDecision: Equatable { /// Process is gone (or its PID was reused) — drop the record. case forget - /// Process is alive but still parented (e.g. a second live Deckard - /// instance owns it) — keep the record, do not touch the process. + /// Process is alive but still parented — keep the record untouched. case keep /// Process is alive, matches the recorded start time, and has been /// reparented to launchd — kill it and drop the record. @@ -44,31 +63,32 @@ final class SpawnedProcessRegistry { } private let queue = DispatchQueue(label: "com.deckard.spawned-process-registry") - private let fileURL: URL + private let directory: URL + private let ownPid: pid_t private let snapshotProvider: (pid_t) -> ProcessSnapshot? - private let killer: (pid_t) -> Void - private var entries: [Entry] + /// Kills a pid and returns 0 on success or the failing errno. + private let killer: (pid_t) -> Int32 + private var entries: [Entry] = [] - init(fileURL: URL? = nil, + init(directory: URL? = nil, + ownPid: pid_t = getpid(), snapshotProvider: @escaping (pid_t) -> ProcessSnapshot? = SpawnedProcessRegistry.systemSnapshot, - killer: @escaping (pid_t) -> Void = { Darwin.kill($0, SIGKILL) }) { - if let fileURL { - self.fileURL = fileURL + killer: @escaping (pid_t) -> Int32 = { Darwin.kill($0, SIGKILL) == 0 ? 0 : errno }) { + if let directory { + self.directory = directory } else { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let dir = appSupport.appendingPathComponent("Deckard") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - self.fileURL = dir.appendingPathComponent("spawned-pids.json") + self.directory = appSupport.appendingPathComponent("Deckard") } + try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) + self.ownPid = ownPid self.snapshotProvider = snapshotProvider self.killer = killer - if let data = try? Data(contentsOf: self.fileURL), - let loaded = try? JSONDecoder().decode([Entry].self, from: data) { - entries = loaded - } else { - entries = [] - } + } + + private var ownFileURL: URL { + directory.appendingPathComponent("\(Self.filePrefix)-\(ownPid).json") } // MARK: - Public API @@ -84,7 +104,10 @@ final class SpawnedProcessRegistry { } } - /// Remove the record for a cleanly exited or terminated PID. + /// Remove the record for a PID whose exit has been confirmed. + /// Only call once the process is known dead (`processTerminated`) — + /// removing on mere kill *intent* would make a SIGTERM-surviving agent + /// permanently unreapable, recreating #100. func remove(pid: pid_t) { queue.async { [self] in entries.removeAll { $0.pid == pid } @@ -92,29 +115,58 @@ final class SpawnedProcessRegistry { } } - /// Kill recorded processes orphaned by a previous app instance. - /// Call at launch BEFORE session restore spawns replacements. - /// Returns the PIDs that were killed. + /// Kill processes recorded by previous (now dead) app instances that are + /// still alive as orphans. Call at launch BEFORE session restore spawns + /// replacements. Returns the PIDs that were killed. @discardableResult func reapOrphans() -> [pid_t] { queue.sync { [self] in var killed: [pid_t] = [] - var kept: [Entry] = [] - for entry in entries { - switch Self.decide(entry: entry, snapshot: snapshotProvider(entry.pid)) { - case .forget: - break - case .keep: - kept.append(entry) - case .kill: - killer(entry.pid) - killed.append(entry.pid) + let files = (try? FileManager.default.contentsOfDirectory(at: directory, + includingPropertiesForKeys: nil))? + .filter { $0.lastPathComponent.hasPrefix(Self.filePrefix) && + $0.pathExtension == "json" } ?? [] + for file in files { + guard let registry = Self.load(file) else { + // Unreadable or unknown future format — leave it alone. DiagnosticLog.shared.log("surface", - "reapOrphans: killed orphaned pid=\(entry.pid) surfaceId=\(entry.surfaceId)") + "reapOrphans: skipping undecodable registry file \(file.lastPathComponent)") + continue + } + if let owner = registry.owner, + let snap = snapshotProvider(owner.pid), + snap.startSec == owner.startSec, snap.startUsec == owner.startUsec { + // Owning instance is alive (start time proves it's not a + // reused pid) — its records, its business. This also + // covers our own fresh file. + continue + } + var kept: [Entry] = [] + for entry in registry.entries { + switch Self.decide(entry: entry, snapshot: snapshotProvider(entry.pid)) { + case .forget: + break + case .keep: + kept.append(entry) + case .kill: + let err = killer(entry.pid) + if err == 0 { + killed.append(entry.pid) + DiagnosticLog.shared.log("surface", + "reapOrphans: killed orphaned pid=\(entry.pid) surfaceId=\(entry.surfaceId)") + } else { + kept.append(entry) + DiagnosticLog.shared.log("surface", + "reapOrphans: kill failed pid=\(entry.pid) errno=\(err)") + } + } + } + if kept.isEmpty { + try? FileManager.default.removeItem(at: file) + } else if kept != registry.entries { + Self.save(kept, owner: registry.owner, to: file) } } - entries = kept - persist() return killed } } @@ -127,13 +179,20 @@ final class SpawnedProcessRegistry { // MARK: - Decision logic (pure, tested) static func decide(entry: Entry, snapshot: ProcessSnapshot?) -> ReapDecision { + // Never signal pid <= 0 (kill(0)/kill(-1) target whole process groups). + guard entry.pid > 0 else { return .forget } guard let snapshot else { return .forget } // Start time mismatch means the PID was reused by an unrelated process. guard snapshot.startSec == entry.startSec, snapshot.startUsec == entry.startUsec else { return .forget } - // Still parented — likely owned by another live Deckard instance. + // Still parented — not an orphan (should not happen for a dead owner, + // but never kill a process some live parent still manages). guard snapshot.ppid == 1 else { return .keep } + // SIGKILL rather than SIGTERM+wait: agents observably ignore SIGTERM + // for seconds while idle-orphaned, and their session-file appends are + // single write() calls, so a graceful window buys little and delays + // every crash-recovery launch. return .kill } @@ -150,10 +209,57 @@ final class SpawnedProcessRegistry { startUsec: Int64(info.kp_proc.p_starttime.tv_usec)) } - // MARK: - Persistence (called on queue) + // MARK: - Persistence + private struct Owner { + let pid: Int32 + let startSec: Int64 + let startUsec: Int64 + } + + private struct LoadedRegistry { + let owner: Owner? + let entries: [Entry] + } + + private static func load(_ url: URL) -> LoadedRegistry? { + guard let data = try? Data(contentsOf: url) else { return nil } + if let file = try? JSONDecoder().decode(RegistryFile.self, from: data), + file.version <= formatVersion { + return LoadedRegistry(owner: Owner(pid: file.ownerPid, + startSec: file.ownerStartSec, + startUsec: file.ownerStartUsec), + entries: file.entries) + } + // Legacy format: bare [Entry] array with no owner stamp. + if let entries = try? JSONDecoder().decode([Entry].self, from: data) { + return LoadedRegistry(owner: nil, entries: entries) + } + return nil + } + + private static func save(_ entries: [Entry], owner: Owner?, to url: URL) { + let file = RegistryFile(version: formatVersion, + ownerPid: owner?.pid ?? 0, + ownerStartSec: owner?.startSec ?? 0, + ownerStartUsec: owner?.startUsec ?? 0, + entries: entries) + if let data = try? JSONEncoder().encode(file) { + try? data.write(to: url, options: .atomic) + } + } + + /// Write our own registry file (called on queue). private func persist() { - guard let data = try? JSONEncoder().encode(entries) else { return } - try? data.write(to: fileURL, options: .atomic) + if entries.isEmpty { + try? FileManager.default.removeItem(at: ownFileURL) + return + } + let ownStart = snapshotProvider(ownPid) + Self.save(entries, + owner: Owner(pid: ownPid, + startSec: ownStart?.startSec ?? 0, + startUsec: ownStart?.startUsec ?? 0), + to: ownFileURL) } } diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index 51d4553..7e3b1c3 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -503,9 +503,10 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { NSEvent.removeMonitor(monitor) keyEventMonitor = nil } - if let pid = terminalView.process?.shellPid, pid > 0 { - SpawnedProcessRegistry.shared.remove(pid: pid) - } + // Registry entry is NOT removed here: terminate() only expresses kill + // intent (SIGTERM), and agents can survive it. processTerminated + // removes the entry once the exit is confirmed; if the process + // outlives us instead, the next launch reaps it. terminalView.process?.terminate() killTmuxSession() } @@ -514,9 +515,6 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { func detach() { guard !processExited else { return } processExited = true - if let pid = terminalView.process?.shellPid, pid > 0 { - SpawnedProcessRegistry.shared.remove(pid: pid) - } // Just kill the local process — tmux session survives terminalView.process?.terminate() } diff --git a/Tests/SpawnedProcessRegistryTests.swift b/Tests/SpawnedProcessRegistryTests.swift index 3ea84b3..9f6c253 100644 --- a/Tests/SpawnedProcessRegistryTests.swift +++ b/Tests/SpawnedProcessRegistryTests.swift @@ -2,16 +2,16 @@ import XCTest @testable import Deckard final class SpawnedProcessRegistryTests: XCTestCase { - private var tempFile: URL! + private var tempDir: URL! override func setUp() { super.setUp() - tempFile = FileManager.default.temporaryDirectory - .appendingPathComponent("spawned-pids-\(UUID().uuidString).json") + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("spawned-pids-tests-\(UUID().uuidString)") } override func tearDown() { - try? FileManager.default.removeItem(at: tempFile) + try? FileManager.default.removeItem(at: tempDir) super.tearDown() } @@ -19,6 +19,17 @@ final class SpawnedProcessRegistryTests: XCTestCase { SpawnedProcessRegistry.Entry(pid: pid, startSec: sec, startUsec: usec, surfaceId: "S") } + private func makeRegistry(ownPid: pid_t, + snapshots: @escaping (pid_t) -> SpawnedProcessRegistry.ProcessSnapshot?, + killer: @escaping (pid_t) -> Int32) -> SpawnedProcessRegistry { + SpawnedProcessRegistry(directory: tempDir, ownPid: ownPid, + snapshotProvider: snapshots, killer: killer) + } + + private func registryFiles() -> [String] { + ((try? FileManager.default.contentsOfDirectory(atPath: tempDir.path)) ?? []).sorted() + } + // MARK: - Decision logic func testDecideForgetsDeadProcess() { @@ -40,66 +51,186 @@ final class SpawnedProcessRegistryTests: XCTestCase { XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 42), snapshot: snap), .kill) } - // MARK: - Registry behavior + func testDecideNeverSignalsNonPositivePids() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 100, startUsec: 5) + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: 0), snapshot: snap), .forget) + XCTAssertEqual(SpawnedProcessRegistry.decide(entry: entry(pid: -1), snapshot: snap), .forget) + } + + // MARK: - Cross-instance reaping - func testRecordPersistsAndReapKillsOnlyOrphans() { + func testReapKillsDeadInstanceOrphansAndSkipsDeadAndReused() { + // Instance A (pid 900) records three children, then "dies". var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ - 101: .init(ppid: 500, startSec: 10, startUsec: 1), // parented (alive Deckard) - 102: .init(ppid: 500, startSec: 20, startUsec: 2), // will become orphan - 103: .init(ppid: 500, startSec: 30, startUsec: 3) // will die + 900: .init(ppid: 1000, startSec: 1, startUsec: 0), + 101: .init(ppid: 900, startSec: 10, startUsec: 1), + 102: .init(ppid: 900, startSec: 20, startUsec: 2), + 103: .init(ppid: 900, startSec: 30, startUsec: 3) ] - let registry1 = SpawnedProcessRegistry(fileURL: tempFile, - snapshotProvider: { snapshots[$0] }, - killer: { _ in XCTFail("no kill expected yet") }) - registry1.record(pid: 101, surfaceId: "A") - registry1.record(pid: 102, surfaceId: "B") - registry1.record(pid: 103, surfaceId: "C") - registry1.flush() - - // Simulate app death and relaunch: 102 reparented to launchd, 103 gone. + let instanceA = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, + killer: { _ in XCTFail("no kill expected yet"); return 0 }) + instanceA.record(pid: 101, surfaceId: "A") + instanceA.record(pid: 102, surfaceId: "B") + instanceA.record(pid: 103, surfaceId: "C") + instanceA.flush() + XCTAssertEqual(registryFiles(), ["spawned-pids-900.json"]) + + // A dies: 101 orphaned, 102 dead, 103's pid reused by another process. + snapshots[900] = nil + snapshots[101] = .init(ppid: 1, startSec: 10, startUsec: 1) + snapshots[102] = nil + snapshots[103] = .init(ppid: 1, startSec: 777, startUsec: 7) + + var killedPids: [pid_t] = [] + let instanceB = makeRegistry(ownPid: 901, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(instanceB.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) + // Everything resolved — dead instance's file is gone. + XCTAssertEqual(registryFiles(), []) + } + + func testReapSkipsLiveSiblingInstanceFile() { + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 900: .init(ppid: 1000, startSec: 1, startUsec: 0), + 101: .init(ppid: 900, startSec: 10, startUsec: 1) + ] + let sibling = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, + killer: { _ in XCTFail("sibling kill"); return 0 }) + sibling.record(pid: 101, surfaceId: "A") + sibling.flush() + + // Sibling instance 900 is still alive — its file must be untouched, + // even though 101 could look reapable if misattributed. + snapshots[101] = .init(ppid: 1, startSec: 10, startUsec: 1) + let me = makeRegistry(ownPid: 901, snapshots: { snapshots[$0] }, + killer: { _ in XCTFail("must not kill"); return 0 }) + XCTAssertEqual(me.reapOrphans(), []) + XCTAssertEqual(registryFiles(), ["spawned-pids-900.json"]) + } + + func testReapProcessesStaleFileWithReusedOwnerPid() { + // A dead instance's file whose owner pid now belongs to *us* + // (pid reuse): owner start time mismatch → must be processed. + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 900: .init(ppid: 1000, startSec: 1, startUsec: 0), + 101: .init(ppid: 900, startSec: 10, startUsec: 1) + ] + let oldInstance = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, + killer: { _ in 0 }) + oldInstance.record(pid: 101, surfaceId: "A") + oldInstance.flush() + + // Relaunch reuses pid 900 with a different start time; 101 orphaned. + snapshots[900] = .init(ppid: 1000, startSec: 999, startUsec: 9) + snapshots[101] = .init(ppid: 1, startSec: 10, startUsec: 1) + var killedPids: [pid_t] = [] + let newInstance = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(newInstance.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) + } + + func testReapRetainsKeptEntriesInFile() { + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 900: .init(ppid: 1000, startSec: 1, startUsec: 0), + 101: .init(ppid: 900, startSec: 10, startUsec: 1), + 102: .init(ppid: 900, startSec: 20, startUsec: 2) + ] + let instanceA = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, killer: { _ in 0 }) + instanceA.record(pid: 101, surfaceId: "A") + instanceA.record(pid: 102, surfaceId: "B") + instanceA.flush() + + // A dies; 101 still parented elsewhere (keep), 102 orphaned (kill). + snapshots[900] = nil + snapshots[101] = .init(ppid: 555, startSec: 10, startUsec: 1) snapshots[102] = .init(ppid: 1, startSec: 20, startUsec: 2) - snapshots[103] = nil + let instanceB = makeRegistry(ownPid: 901, snapshots: { snapshots[$0] }, killer: { _ in 0 }) + XCTAssertEqual(instanceB.reapOrphans(), [102]) + XCTAssertEqual(registryFiles(), ["spawned-pids-900.json"]) + // The kept entry must still be in the file: once 101 becomes an + // orphan, a later reap must still find and kill it. + snapshots[101] = .init(ppid: 1, startSec: 10, startUsec: 1) var killedPids: [pid_t] = [] - let registry2 = SpawnedProcessRegistry(fileURL: tempFile, - snapshotProvider: { snapshots[$0] }, - killer: { killedPids.append($0) }) - let reaped = registry2.reapOrphans() + let instanceC = makeRegistry(ownPid: 902, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(instanceC.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) + XCTAssertEqual(registryFiles(), []) + } - XCTAssertEqual(killedPids, [102]) - XCTAssertEqual(reaped, [102]) + func testReapRetainsEntryWhenKillFails() { + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 101: .init(ppid: 1, startSec: 10, startUsec: 1) + ] + let dir = tempDir! + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Legacy ownerless file so it is always processed. + let legacy = [entry(pid: 101, sec: 10, usec: 1)] + try? JSONEncoder().encode(legacy).write(to: dir.appendingPathComponent("spawned-pids.json")) + + let registry = makeRegistry(ownPid: 901, snapshots: { snapshots[$0] }, + killer: { _ in EPERM }) + XCTAssertEqual(registry.reapOrphans(), []) + // Kill failed → entry retained for a future attempt. + XCTAssertEqual(registryFiles(), ["spawned-pids.json"]) - // Registry file should retain only the still-parented process. - let registry3 = SpawnedProcessRegistry(fileURL: tempFile, - snapshotProvider: { snapshots[$0] }, - killer: { _ in XCTFail("nothing left to kill") }) - XCTAssertEqual(registry3.reapOrphans(), []) + var killedPids: [pid_t] = [] + let retry = makeRegistry(ownPid: 902, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(retry.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) } - func testRemoveDropsEntry() { - let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 10, startUsec: 1) + func testReapMigratesLegacyFormatAndIgnoresCorruptFiles() { + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let legacy = [entry(pid: 101, sec: 10, usec: 1)] + try? JSONEncoder().encode(legacy).write(to: tempDir.appendingPathComponent("spawned-pids.json")) + try? Data("not json{".utf8).write(to: tempDir.appendingPathComponent("spawned-pids-777.json")) + var killedPids: [pid_t] = [] - let registry = SpawnedProcessRegistry(fileURL: tempFile, - snapshotProvider: { _ in snap }, - killer: { killedPids.append($0) }) + let registry = makeRegistry( + ownPid: 901, + snapshots: { pid in pid == 101 ? .init(ppid: 1, startSec: 10, startUsec: 1) : nil }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(registry.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) + // Legacy file consumed; corrupt file left untouched, not treated as empty. + XCTAssertEqual(registryFiles(), ["spawned-pids-777.json"]) + } + + // MARK: - Record/remove lifecycle + + func testRemoveDropsEntryAndDeletesEmptyFile() { + let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 900, startSec: 10, startUsec: 1) + let registry = makeRegistry(ownPid: 900, snapshots: { _ in snap }, killer: { _ in 0 }) registry.record(pid: 201, surfaceId: "A") + registry.flush() + XCTAssertEqual(registryFiles(), ["spawned-pids-900.json"]) registry.remove(pid: 201) registry.flush() - XCTAssertEqual(registry.reapOrphans(), []) - XCTAssertTrue(killedPids.isEmpty) + XCTAssertEqual(registryFiles(), []) } func testRecordSamePidReplacesEntry() { - let snap = SpawnedProcessRegistry.ProcessSnapshot(ppid: 1, startSec: 10, startUsec: 1) + var snapshots: [pid_t: SpawnedProcessRegistry.ProcessSnapshot] = [ + 900: .init(ppid: 1000, startSec: 1, startUsec: 0), + 301: .init(ppid: 900, startSec: 10, startUsec: 1) + ] + let instanceA = makeRegistry(ownPid: 900, snapshots: { snapshots[$0] }, killer: { _ in 0 }) + instanceA.record(pid: 301, surfaceId: "A") + instanceA.record(pid: 301, surfaceId: "B") + instanceA.flush() + + snapshots[900] = nil + snapshots[301] = .init(ppid: 1, startSec: 10, startUsec: 1) var killedPids: [pid_t] = [] - let registry = SpawnedProcessRegistry(fileURL: tempFile, - snapshotProvider: { _ in snap }, - killer: { killedPids.append($0) }) - registry.record(pid: 301, surfaceId: "A") - registry.record(pid: 301, surfaceId: "B") - registry.flush() - XCTAssertEqual(registry.reapOrphans(), [301]) - XCTAssertEqual(killedPids, [301]) + let instanceB = makeRegistry(ownPid: 901, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(instanceB.reapOrphans(), [301]) + XCTAssertEqual(killedPids, [301], "duplicate record must not kill twice") } // MARK: - System snapshot sanity @@ -112,7 +243,6 @@ final class SpawnedProcessRegistryTests: XCTestCase { } func testSystemSnapshotOfDeadPidIsNil() { - // PID 0 is the kernel; sysctl returns it, but a huge unused pid should fail. XCTAssertNil(SpawnedProcessRegistry.systemSnapshot(pid: 99_999_999)) } }