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..e51e15a --- /dev/null +++ b/Sources/Detection/SpawnedProcessRegistry.swift @@ -0,0 +1,265 @@ +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); 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() + + 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 + } + + /// 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 + 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 — 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. + case kill + } + + private let queue = DispatchQueue(label: "com.deckard.spawned-process-registry") + private let directory: URL + private let ownPid: pid_t + private let snapshotProvider: (pid_t) -> ProcessSnapshot? + /// Kills a pid and returns 0 on success or the failing errno. + private let killer: (pid_t) -> Int32 + private var entries: [Entry] = [] + + init(directory: URL? = nil, + ownPid: pid_t = getpid(), + snapshotProvider: @escaping (pid_t) -> ProcessSnapshot? = SpawnedProcessRegistry.systemSnapshot, + 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! + self.directory = appSupport.appendingPathComponent("Deckard") + } + try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) + self.ownPid = ownPid + self.snapshotProvider = snapshotProvider + self.killer = killer + } + + private var ownFileURL: URL { + directory.appendingPathComponent("\(Self.filePrefix)-\(ownPid).json") + } + + // 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 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 } + persist() + } + } + + /// 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] = [] + 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: 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) + } + } + 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 { + // 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 — 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 + } + + // 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 + + 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() { + 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 14ec9f4..7e3b1c3 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,10 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { NSEvent.removeMonitor(monitor) keyEventMonitor = nil } + // 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() } @@ -685,6 +692,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..9f6c253 --- /dev/null +++ b/Tests/SpawnedProcessRegistryTests.swift @@ -0,0 +1,248 @@ +import XCTest +@testable import Deckard + +final class SpawnedProcessRegistryTests: XCTestCase { + private var tempDir: URL! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("spawned-pids-tests-\(UUID().uuidString)") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + 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") + } + + 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() { + 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) + } + + 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 testReapKillsDeadInstanceOrphansAndSkipsDeadAndReused() { + // Instance A (pid 900) records three children, then "dies". + 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), + 103: .init(ppid: 900, startSec: 30, startUsec: 3) + ] + 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) + 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 instanceC = makeRegistry(ownPid: 902, snapshots: { snapshots[$0] }, + killer: { killedPids.append($0); return 0 }) + XCTAssertEqual(instanceC.reapOrphans(), [101]) + XCTAssertEqual(killedPids, [101]) + XCTAssertEqual(registryFiles(), []) + } + + 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"]) + + 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 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 = 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(registryFiles(), []) + } + + func testRecordSamePidReplacesEntry() { + 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 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 + + func testSystemSnapshotOfSelf() { + let snap = SpawnedProcessRegistry.systemSnapshot(pid: getpid()) + XCTAssertNotNil(snap) + XCTAssertEqual(snap?.ppid, getppid()) + XCTAssertGreaterThan(snap?.startSec ?? 0, 0) + } + + func testSystemSnapshotOfDeadPidIsNil() { + XCTAssertNil(SpawnedProcessRegistry.systemSnapshot(pid: 99_999_999)) + } +}