diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index be6d7afd0..953e9b98d 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -51,26 +51,62 @@ extension ServiceLifecycleCoordinator { do { let (application, url, nonce) = try await launchSessionProcess(capabilitiesOnly: true) defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - for _ in 0 ..< 40 { - if let report = Self.readSessionReport(url), - report.state == .capabilities, - report.isCurrent(nonce: nonce, pid: application.processIdentifier, uid: getuid(), now: Date()) - { - return Self.permissionSet(report) + let report = try await Self.awaitCapabilityReport( + read: { + guard let report = Self.readSessionReport(url), report.state == .capabilities, + report.isCurrent(nonce: nonce, pid: application.processIdentifier, uid: getuid(), now: Date()) + else { return nil } + return report + }, + sleep: { try await Task.sleep(for: .milliseconds(50)) }, + abandon: { + // NSWorkspace may return an application without a valid PID (e.g. -1). + // Never let that become a process-group/broadcast signal target. + // Signal only the independently launched process owned by this call. + let probePID = application.processIdentifier + if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) } } - try await Task.sleep(for: .milliseconds(50)) - } - // NSWorkspace may return an application without a valid PID (e.g. -1). - // Never let that become a process-group/broadcast signal target. - // Signal only the independently launched process owned by this call. - let probePID = application.processIdentifier - if probePID > 0, !application.isTerminated { kill(probePID, SIGTERM) } + ) + if let report { return Self.permissionSet(report) } } catch { AppLogger.shared.warn("Session permission report unavailable") } return nil } + /// The worker's argv. Uninstall's sweep recognizes workers by these flags, + /// so a test pins them to SessionWorkerProcess.parse. + nonisolated static func sessionLaunchArguments( + capabilitiesOnly: Bool, reportPath: String, nonce: String, ownerPID: Int32, port: Int, configPath: String + ) -> [String] { + [ + capabilitiesOnly ? "--session-capabilities" : "--session-runtime", + "--session-report", reportPath, "--session-nonce", nonce, + "--session-owner", String(ownerPID), "--session-port", String(port), + "--session-config", configPath + ] + } + + /// Polls about two seconds for the probe's report. A timeout, a cancelled + /// wait, or a thrown error all abandon the probe, and every one of them + /// signals it: on 2026-11-03 a cancelled wait skipped the signal and two + /// probes outlived their app. + static func awaitCapabilityReport( + attempts: Int = 40, read: () -> SessionRuntimeReport?, + sleep: () async throws -> Void, abandon: () -> Void + ) async throws -> SessionRuntimeReport? { + var reported = false + defer { if !reported { abandon() } } + for _ in 0 ..< attempts { + if let report = read() { + reported = true + return report + } + try await sleep() + } + return nil + } + private func isLegacyDaemonRunning() async -> Bool { #if DEBUG if let probe = testSessionLegacyDaemonRunning { return await probe() } @@ -789,12 +825,10 @@ extension ServiceLifecycleCoordinator { configuration.activates = false configuration.hides = true configuration.addsToRecentItems = false - configuration.arguments = [ - capabilitiesOnly ? "--session-capabilities" : "--session-runtime", - "--session-report", url.path, "--session-nonce", nonce, - "--session-owner", String(getpid()), "--session-port", String(KeyPathConstants.Networking.defaultTCPPort), - "--session-config", KeyPathConstants.Config.mainConfigPath - ] + configuration.arguments = Self.sessionLaunchArguments( + capabilitiesOnly: capabilitiesOnly, reportPath: url.path, nonce: nonce, ownerPID: getpid(), + port: KeyPathConstants.Networking.defaultTCPPort, configPath: KeyPathConstants.Config.mainConfigPath + ) sessionCapsIntermediate = nil if !capabilitiesOnly { sessionCapsPaused = nil diff --git a/Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift b/Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift index 62052d5b6..7c551f52b 100644 --- a/Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/SessionUninstallCoordinator.swift @@ -1,6 +1,14 @@ import AppKit import KeyPathWizardCore +/// What a sweep of this user's KeyPath session workers proved. +enum SessionWorkerSweepResult: Equatable, Sendable { + case noneRunning + case stillRunning + /// The process table could not be read, so absence is unproven. + case unverified +} + /// Removes only the current user's driverless installation. No privileged cleanup. @MainActor final class SessionUninstallCoordinator: WizardUninstalling { @@ -12,6 +20,7 @@ final class SessionUninstallCoordinator: WizardUninstalling { private let resume: () async -> Void private let trash: (URL) throws -> Void private let removeLoginItem: () async throws -> Void + private let stopSessionWorkers: () async -> SessionWorkerSweepResult private let files = FileManager.default init( @@ -22,6 +31,7 @@ final class SessionUninstallCoordinator: WizardUninstalling { prepare: @escaping () async -> Bool, resume: @escaping () async -> Void, removeLoginItem: @escaping () async throws -> Void = {}, + stopSessionWorkers: @escaping () async -> SessionWorkerSweepResult = { .noneRunning }, trash: @escaping (URL) throws -> Void = { try FileManager.default.trashItem(at: $0, resultingItemURL: nil) } ) { self.home = home @@ -32,6 +42,7 @@ final class SessionUninstallCoordinator: WizardUninstalling { self.resume = resume self.trash = trash self.removeLoginItem = removeLoginItem + self.stopSessionWorkers = stopSessionWorkers } func performUninstall(deleteConfig: Bool, removeVirtualHID: Bool, allowAdminFallback: Bool) async -> WizardUninstallResult { @@ -60,6 +71,19 @@ final class SessionUninstallCoordinator: WizardUninstalling { await resume() return WizardUninstallResult(success: false, failureReason: "Keyboard cleanup could not be verified. Nothing was removed. Try again after releasing all keys.") } + // prepare stops only the worker the live app tracks. Orphans of an + // earlier app process, or probes it abandoned, would otherwise keep + // running from the trashed bundle. + switch await stopSessionWorkers() { + case .noneRunning: + break + case .stillRunning: + await resume() + return WizardUninstallResult(success: false, failureReason: "A KeyPath keyboard process did not stop. Nothing was removed. Restart your Mac, then try again.") + case .unverified: + await resume() + return WizardUninstallResult(success: false, failureReason: "KeyPath could not check which of its processes are running. Nothing was removed. Try again.") + } let backup = home.appendingPathComponent("Downloads/KeyPath-Uninstall-Backup-\(UUID().uuidString)", isDirectory: true) do { @@ -100,6 +124,20 @@ final class SessionUninstallCoordinator: WizardUninstalling { guard !files.fileExists(atPath: app.path) else { throw CocoaError(.fileWriteUnknown) } + // Success means nothing is left running, so check again at the end. + // No resume() here, unlike the paths above: the app and its settings + // are gone, so there is nothing to resume watching or starting. + let finalSweep = await stopSessionWorkers() + guard finalSweep == .noneRunning else { + let state = finalSweep == .stillRunning + ? "a KeyPath keyboard process is still running. Restart your Mac to stop it." + : "KeyPath could not check that its processes stopped. Restart your Mac to be sure." + return WizardUninstallResult( + success: false, + failureReason: "KeyPath was removed, but \(state) Your backup is at \(backup.path).", + logs: ["Backup saved to \(backup.path)"] + ) + } return WizardUninstallResult(success: true, logs: ["Backup saved to \(backup.path)"]) } catch { await resume() diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionOwnerWatch.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionOwnerWatch.swift new file mode 100644 index 000000000..bd0d77b53 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionOwnerWatch.swift @@ -0,0 +1,100 @@ +import Foundation +import KeyPathCore + +/// Retires a worker whose owning app is gone. It polls on a background queue, +/// so a worker wedged before its main-loop tick exists still exits: on +/// 2026-11-03 two capability probes outlived their owner by hours this way. +final class SessionOwnerWatch: @unchecked Sendable { + enum Verdict: Equatable, Sendable { + case ownerExited + case deadlineExceeded + } + + /// Owner death wins over the deadline; a worker without an owner PID is + /// bounded only by its deadline. + nonisolated static func verdict(ownerPID: Int32, ownerAlive: Bool, elapsed: TimeInterval, + deadline: TimeInterval?) -> Verdict? + { + if ownerPID > 0, !ownerAlive { return .ownerExited } + if let deadline, elapsed >= deadline { return .deadlineExceeded } + return nil + } + + private let lock = NSLock() + private var handler: @Sendable (Verdict) -> Void + private let timer: DispatchSourceTimer + + /// The resumed timer retains itself, and its handler retains the watch, + /// until the first verdict cancels it. + init( + ownerPID: Int32, deadline: TimeInterval?, interval: TimeInterval = 0.25, + isAlive: @escaping @Sendable (Int32) -> Bool = { SystemStateProvider.isProcessAlive(pid: $0) }, + uptime: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + queue: DispatchQueue = DispatchQueue(label: "com.keypath.session-owner-watch", qos: .utility), + handler: @escaping @Sendable (Verdict) -> Void + ) { + self.handler = handler + timer = DispatchSource.makeTimerSource(queue: queue) + let started = uptime() + // The first check runs immediately: the owner may already be gone. + timer.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(Int(interval * 250))) + timer.setEventHandler { [self] in + guard let verdict = Self.verdict(ownerPID: ownerPID, ownerAlive: isAlive(ownerPID), + elapsed: uptime() - started, deadline: deadline) + else { return } + timer.cancel() + // Deliver under the lock: once setHandler returns, a verdict + // already in flight has either run the old handler (and exited) + // or will run the new one, never the old one late. Both handlers + // return at once, so holding the lock cannot stall setHandler. + lock.lock() + self.handler(verdict) + lock.unlock() + } + timer.resume() + } + + /// The runtime hands off from a hard exit to an orderly shutdown once it + /// may own outputs or a Caps mapping. Blocks while a verdict is being + /// delivered, so the old handler cannot run after this returns. + func setHandler(_ handler: @escaping @Sendable (Verdict) -> Void) { + lock.lock() + self.handler = handler + lock.unlock() + } +} + +/// Bounds an owner-exit retirement that the worker's run loop never runs. An +/// orderly shutdown cancels it when it begins, so a slow Caps restore or key +/// release is never cut off; the HID join it waits on stays unbounded. +final class SessionShutdownFallback: @unchecked Sendable { + static let shared = SessionShutdownFallback() + + private let lock = NSLock() + private var shutdownBegan = false + private var pending: DispatchWorkItem? + + /// Arms `action` after `grace`, once. Returns whether an owner-exit + /// retirement should proceed: false when an orderly shutdown already began, + /// true otherwise, including when an earlier call already armed it. + @discardableResult + func arm(after grace: TimeInterval, queue: DispatchQueue = .global(qos: .utility), + action: @escaping @Sendable () -> Void) -> Bool + { + lock.lock() + defer { lock.unlock() } + guard !shutdownBegan, pending == nil else { return !shutdownBegan } + let item = DispatchWorkItem(block: action) + pending = item + queue.asyncAfter(deadline: .now() + grace, execute: item) + return true + } + + func shutdownBegins() { + lock.lock() + shutdownBegan = true + pending?.cancel() + pending = nil + lock.unlock() + } +} diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 8cef8e6ad..0747ba1d8 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -103,11 +103,21 @@ public final class SessionRuntimeWorker { let path = argument("--session-report"), let nonce = argument("--session-nonce"), UUID(uuidString: nonce) != nil else { exit(64) } + let ownerPID = argument("--session-owner").flatMap(Int32.init) ?? 0 + let probeOnly = arguments.contains("--session-capabilities") + // Until the runtime can own outputs, nothing needs releasing: a dead + // owner, or a probe still running after its parent stopped waiting, + // ends the process at once. _exit cannot block on a wedged main thread. + // A runtime's tick already checks the owner every 5 ms; this backstop + // for a wedged run loop polls slowly, since the 30 s grace dominates. + let ownerWatch = SessionOwnerWatch(ownerPID: ownerPID, + deadline: probeOnly ? capabilityProbeDeadline : nil, + interval: probeOnly ? 0.25 : 2) { _ in _exit(0) } - if arguments.contains("--session-capabilities") { + if probeOnly { await writeCapabilityReport( reportURL: URL(fileURLWithPath: path), nonce: nonce, - ownerPID: argument("--session-owner").flatMap(Int32.init) ?? 0, + ownerPID: ownerPID, port: argument("--session-port").flatMap(UInt16.init) ?? 0, snapshotProvider: { await SystemStateProvider.shared.currentProcessSessionCapabilitySnapshot() @@ -118,7 +128,7 @@ public final class SessionRuntimeWorker { let capabilities = await SystemStateProvider.shared.currentProcessPermissionCapabilities() let worker = SessionRuntimeWorker( reportURL: URL(fileURLWithPath: path), nonce: nonce, - ownerPID: argument("--session-owner").flatMap(Int32.init) ?? 0, + ownerPID: ownerPID, port: argument("--session-port").flatMap(UInt16.init) ?? 0, capabilities: capabilities ) @@ -131,12 +141,35 @@ public final class SessionRuntimeWorker { worker.experimentalRawCapabilities = SystemStateProvider.shared.currentProcessRawPermissionCapabilities() worker.experimentalRawPosting = await SystemStateProvider.shared.currentProcessRawEventPostingStatus() #endif + // From here the worker may hold keys or a Caps mapping, so a dead owner + // retires it on its run loop, which releases them before exiting. A + // run loop that never gets there would keep an orphan forever; after + // the grace it exits anyway, and the Caps journal survives for recovery. + // A shutdown already under way, or one this starts, cancels that exit. + ownerWatch.setHandler { [weak worker] _ in + guard SessionShutdownFallback.shared.arm(after: ownerExitShutdownGrace, action: { _exit(1) }) else { return } + CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) { + MainActor.assumeIsolated { worker?.retire(reason: "owner-exited") } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) + } worker.start(configPath: config) // Keep the worker and loaded bridge alive through process termination; // dropping a runtime cannot stop its detached TCP listener threads. worker.runLoop() } + /// The parent waits about two seconds for a capability report and then + /// abandons the probe; this bounds a probe the parent could not signal. + nonisolated static let capabilityProbeDeadline: TimeInterval = 15 + /// How long an orphaned runtime gets to release outputs on its run loop. + nonisolated static let ownerExitShutdownGrace: TimeInterval = 30 + + private func retire(reason: String) { + guard !finished else { return } + finish(.stopped, reason: reason) + } + /// Capability mode samples passive APIs and writes evidence only. In particular, /// this path never enters start(), creates an event tap, or loads configuration. static func writeCapabilityReport( @@ -1154,6 +1187,8 @@ public final class SessionRuntimeWorker { acknowledge: (() -> Void)? = nil ) -> Never { finished = true + // The orderly path owns shutdown from here; no hard exit may cut it off. + SessionShutdownFallback.shared.shutdownBegins() // Every exit records why, so a stop with no failure is still explainable. AppLogger.shared.log("🛑 [SessionWorker] Finishing: state=\(state) reason=\(reason ?? "none")") AppLogger.shared.flushBuffer() diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionWorkerSweep.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionWorkerSweep.swift new file mode 100644 index 000000000..e6c44c241 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionWorkerSweep.swift @@ -0,0 +1,238 @@ +import Darwin +import Foundation + +/// One KeyPath session worker found in this user's process table. +struct SessionWorkerProcess: Equatable, Sendable { + enum Mode: Equatable, Sendable { + case runtime + case capabilities + } + + let pid: pid_t + let mode: Mode + let ownerPID: pid_t? + var executable = "" + + /// `arguments[0]` is the executable path. Matches any KeyPath bundle, + /// including one already moved to the Trash, but never the app itself. + static func parse(pid: pid_t, arguments: [String]) -> SessionWorkerProcess? { + guard let executable = arguments.first, executable.hasSuffix(".app/Contents/MacOS/KeyPath") else { return nil } + let mode: Mode + if arguments.contains("--session-runtime") { + mode = .runtime + } else if arguments.contains("--session-capabilities") { + mode = .capabilities + } else { + return nil + } + let owner = arguments.firstIndex(of: "--session-owner") + .flatMap { arguments.index(after: $0) < arguments.endIndex ? arguments[arguments.index(after: $0)] : nil } + .flatMap(pid_t.init) + return SessionWorkerProcess(pid: pid, mode: mode, ownerPID: owner, executable: executable) + } + + /// Uninstall stops workers of the bundle it removes, and orphans of any + /// bundle; a separate KeyPath install whose app is alive keeps its worker. + /// The owner counts as alive only if that PID is a KeyPath app running the + /// worker's own executable: orphans outlive owners by hours, long enough + /// for the PID to be reused by an unrelated process. + func belongsToUninstall(of app: URL, appExecutable: (pid_t) -> String?) -> Bool { + // The kernel reports a resolved path; the bundle URL may reach it + // through a symlink. + let bundles = Set([app.standardizedFileURL.path, app.resolvingSymlinksInPath().path]) + if bundles.contains(where: { executable.hasPrefix($0 + "/") }) { return true } + guard let ownerPID, ownerPID > 0 else { return true } + return appExecutable(ownerPID) != executable + } +} + +/// Stops every KeyPath session worker this user runs, not only the one the +/// live app tracks: orphans of an app that exited, and probes it abandoned. +/// Uninstall uses it so success means none remain (see AGENTS.md postconditions). +struct SessionWorkerSweep: Sendable { + struct Outcome: Equatable, Sendable { + let found: [SessionWorkerProcess] + let remaining: [SessionWorkerProcess] + /// False when the process table could not be read: absence unproven. + var verified = true + var succeeded: Bool { + verified && remaining.isEmpty + } + + var result: SessionWorkerSweepResult { + guard verified else { return .unverified } + return remaining.isEmpty ? .noneRunning : .stillRunning + } + } + + /// nil means the process table could not be read, never "no workers". + var list: @Sendable () -> [SessionWorkerProcess]? + var signal: @Sendable (pid_t, Int32) -> Void + var sleep: @Sendable (Duration) async -> Void + var uptime: @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + + static func live(uninstalling app: URL) -> SessionWorkerSweep { + SessionWorkerSweep( + list: { + guard let table = currentUserKeyPathProcesses() else { return nil } + return table.workers.filter { $0.belongsToUninstall(of: app) { table.apps[$0] } } + }, + signal: { pid, number in _ = kill(pid, number) }, + sleep: { try? await Task.sleep(for: $0) } + ) + } + + /// A runtime worker gets SIGTERM only: its shutdown releases held keys and + /// restores Caps mappings, and killing it would strand them. A capability + /// probe owns nothing, so one still alive after `probeGrace` is killed. + /// Workers that appear mid-sweep are signalled too. Graces are wall-clock. + func stopAll(probeGrace: TimeInterval = 2, runtimeGrace: TimeInterval = 10, + poll: Duration = .milliseconds(100)) async -> Outcome + { + /// One unreadable listing may be a process exiting mid-read; two is unknown. + func read() -> [SessionWorkerProcess]? { + list() ?? list() + } + guard let found = read() else { return Outcome(found: [], remaining: [], verified: false) } + guard !found.isEmpty else { return Outcome(found: [], remaining: []) } + let started = uptime() + var termed: Set = [] + var killed: Set = [] + var everFound = found + var remaining = found + while true { + // A cancelled sleep returns at once; without this the loop would + // re-read the process table as fast as it can for the whole grace. + guard !Task.isCancelled else { return Outcome(found: everFound, remaining: remaining, verified: false) } + let elapsed = uptime() - started + for worker in remaining where !termed.contains(worker.pid) { + termed.insert(worker.pid) + signal(worker.pid, SIGTERM) + } + if elapsed >= probeGrace { + for worker in remaining where worker.mode == .capabilities && !killed.contains(worker.pid) { + killed.insert(worker.pid) + signal(worker.pid, SIGKILL) + } + } + guard !remaining.isEmpty, elapsed < runtimeGrace else { break } + await sleep(poll) + guard let listed = read() else { return Outcome(found: everFound, remaining: remaining, verified: false) } + remaining = listed + everFound += listed.filter { worker in !everFound.contains { $0.pid == worker.pid } } + } + return Outcome(found: everFound, remaining: remaining) + } + + /// This UID's KeyPath processes: session workers, and the apps (any other + /// KeyPath process) by PID with their executable path. + struct ProcessTable: Equatable, Sendable { + var workers: [SessionWorkerProcess] = [] + var apps: [pid_t: String] = [:] + } + + static func currentUserSessionWorkers() -> [SessionWorkerProcess]? { + currentUserKeyPathProcesses()?.workers + } + + /// Reads the kernel process table directly: no pgrep, and only this UID. + static func currentUserKeyPathProcesses() -> ProcessTable? { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_UID, Int32(bitPattern: getuid())] + var processes: [kinfo_proc] = [] + var size = 0 + // Processes started between sizing and reading make the read ENOMEM. + for _ in 0 ..< 4 { + guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return nil } + size += 32 * MemoryLayout.stride + processes = [kinfo_proc](repeating: kinfo_proc(), count: size / MemoryLayout.stride) + // Never claim more bytes than the array holds. + size = processes.count * MemoryLayout.stride + if sysctl(&mib, 4, &processes, &size, nil, 0) == 0 { break } + guard errno == ENOMEM else { return nil } + processes = [] + } + guard !processes.isEmpty else { return nil } + let count = size / MemoryLayout.stride + let me = getpid() + // One buffer for every process: kern.argmax is 1 MiB. + guard let argumentMax = argumentMax() else { return nil } + var buffer = [UInt8](repeating: 0, count: argumentMax) + var table = ProcessTable() + for process in processes.prefix(count) { + let pid = process.kp_proc.p_pid + // Only KeyPath processes pay for an argument read. A zombie has no + // arguments and runs nothing. + guard pid > 0, pid != me, Int32(process.kp_proc.p_stat) != SZOMB, + command(of: process) == "KeyPath" else { continue } + guard let arguments = arguments(of: pid, buffer: &buffer) else { + // Exited or became a zombie between the two reads: it runs + // nothing. A live process whose arguments cannot be read is unknown. + if isRunning(pid) { return nil } + continue + } + if let worker = SessionWorkerProcess.parse(pid: pid, arguments: arguments) { + table.workers.append(worker) + } else if let executable = arguments.first { + table.apps[pid] = executable + } + } + return table + } + + /// True while the PID exists and is not a zombie. Signalling a zombie + /// succeeds, so kill(pid, 0) cannot tell. + private static func isRunning(_ pid: pid_t) -> Bool { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var process = kinfo_proc() + var size = MemoryLayout.stride + guard sysctl(&mib, 4, &process, &size, nil, 0) == 0, size > 0 else { return false } + return Int32(process.kp_proc.p_stat) != SZOMB + } + + private static func command(of process: kinfo_proc) -> String { + withUnsafeBytes(of: process.kp_proc.p_comm) { raw in + String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self) + } + } + + static func argumentMax() -> Int? { + var argumentMax: Int32 = 0 + var argumentMaxSize = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_ARGMAX] + guard sysctl(&mib, 2, &argumentMax, &argumentMaxSize, nil, 0) == 0, argumentMax > 0 else { return nil } + return Int(argumentMax) + } + + /// KERN_PROCARGS2 is argc, the executable path, padding, then argv. + /// Returns the executable path followed by argv[1...]. + static func arguments(of pid: pid_t, buffer: inout [UInt8]) -> [String]? { + var size = buffer.count + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil } + return parseProcArgs(Array(buffer.prefix(size))) + } + + static func parseProcArgs(_ bytes: [UInt8]) -> [String]? { + let intSize = MemoryLayout.size + guard bytes.count > intSize else { return nil } + let argc = bytes.prefix(intSize).withUnsafeBytes { Int($0.loadUnaligned(as: Int32.self)) } + guard argc > 0 else { return nil } + var index = intSize + func nextString() -> String? { + guard index < bytes.count, let end = bytes[index...].firstIndex(of: 0) else { return nil } + let value = String(decoding: bytes[index ..< end], as: UTF8.self) + index = end + 1 + return value + } + guard let executable = nextString() else { return nil } + while index < bytes.count, bytes[index] == 0 { + index += 1 + } + var argv: [String] = [] + for _ in 0 ..< argc { + guard let argument = nextString() else { break } + argv.append(argument) + } + return [executable] + argv.dropFirst() + } +} diff --git a/Sources/KeyPathAppKit/WizardProtocolConformances.swift b/Sources/KeyPathAppKit/WizardProtocolConformances.swift index 7ccdd9e0a..bc5cb5776 100644 --- a/Sources/KeyPathAppKit/WizardProtocolConformances.swift +++ b/Sources/KeyPathAppKit/WizardProtocolConformances.swift @@ -163,6 +163,12 @@ public func configureWizardDependencies(runtimeCoordinator: RuntimeCoordinator) runtimeCoordinator.startConfigFileWatching() }, removeLoginItem: { try await LaunchAtLoginController.shared.setEnabled(false) + }, stopSessionWorkers: { + let outcome = await SessionWorkerSweep.live(uninstalling: Bundle.main.bundleURL).stopAll() + AppLogger.shared.log( + "[Uninstall] Session worker sweep verified=\(outcome.verified) found=\(outcome.found.map(\.pid)) remaining=\(outcome.remaining.map(\.pid))" + ) + return outcome.result }) } WizardDependencies.executePrivilegedBatch = { batch in diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapabilityProbeWaitTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapabilityProbeWaitTests.swift new file mode 100644 index 000000000..a66a147ad --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapabilityProbeWaitTests.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import KeyPathAppKit +import KeyPathCore +import XCTest + +/// The 2026-11-03 regression: a cancelled wait skipped the signal, so the +/// probe outlived the app. Every abandoned wait must signal the probe. +@MainActor +final class SessionCapabilityProbeWaitTests: XCTestCase { + private struct Cancelled: Error {} + + private func report() -> SessionRuntimeReport { + SessionRuntimeReport(nonce: "probe", pid: 123, uid: 502, state: .capabilities, + accessibility: true, effectiveInputAccess: true, tapActive: false, + tcpPort: 0, inputCount: 0, outputCount: 0) + } + + func testCancelledWaitSignalsTheProbe() async { + var abandoned = 0 + do { + _ = try await ServiceLifecycleCoordinator.awaitCapabilityReport( + read: { nil }, sleep: { throw Cancelled() }, abandon: { abandoned += 1 } + ) + XCTFail("The cancellation must propagate") + } catch {} + XCTAssertEqual(abandoned, 1) + } + + func testTimeoutSignalsTheProbeOnce() async throws { + var abandoned = 0 + var reads = 0 + let result = try await ServiceLifecycleCoordinator.awaitCapabilityReport( + attempts: 3, read: { reads += 1; return nil }, sleep: {}, abandon: { abandoned += 1 } + ) + XCTAssertNil(result) + XCTAssertEqual(reads, 3) + XCTAssertEqual(abandoned, 1) + } + + /// A probe that reported exits on its own and is not signalled. + func testReportedProbeIsNotSignalled() async throws { + var reads = 0 + let result = try await ServiceLifecycleCoordinator.awaitCapabilityReport( + read: { reads += 1; return reads == 2 ? self.report() : nil }, sleep: {}, + abandon: { XCTFail("A reported probe must not be signalled") } + ) + XCTAssertEqual(result?.nonce, "probe") + } +} diff --git a/Tests/KeyPathTests/SessionRuntime/SessionOwnerWatchTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionOwnerWatchTests.swift new file mode 100644 index 000000000..63bc2f704 --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionOwnerWatchTests.swift @@ -0,0 +1,141 @@ +import Foundation +@testable import KeyPathAppKit +import XCTest + +final class SessionOwnerWatchTests: XCTestCase { + func testVerdictRetiresWhenOwnerIsGone() { + XCTAssertEqual(SessionOwnerWatch.verdict(ownerPID: 30378, ownerAlive: false, elapsed: 0, deadline: nil), .ownerExited) + XCTAssertNil(SessionOwnerWatch.verdict(ownerPID: 30378, ownerAlive: true, elapsed: 3600, deadline: nil)) + } + + func testOwnerExitWinsOverDeadline() { + XCTAssertEqual(SessionOwnerWatch.verdict(ownerPID: 7, ownerAlive: false, elapsed: 20, deadline: 15), .ownerExited) + } + + func testDeadlineBoundsAProbeWhoseOwnerIsAlive() { + XCTAssertNil(SessionOwnerWatch.verdict(ownerPID: 7, ownerAlive: true, elapsed: 14.9, deadline: 15)) + XCTAssertEqual(SessionOwnerWatch.verdict(ownerPID: 7, ownerAlive: true, elapsed: 15, deadline: 15), .deadlineExceeded) + } + + /// A missing --session-owner is not a dead owner; only the deadline applies. + func testMissingOwnerIsNotTreatedAsExited() { + XCTAssertNil(SessionOwnerWatch.verdict(ownerPID: 0, ownerAlive: false, elapsed: 1, deadline: nil)) + XCTAssertEqual(SessionOwnerWatch.verdict(ownerPID: 0, ownerAlive: false, elapsed: 16, deadline: 15), .deadlineExceeded) + } + + func testWatchFiresOnceWhenOwnerDiesWithoutTheMainThread() { + let alive = Locked(true) + let fired = expectation(description: "Owner exit observed") + let calls = Locked(0) + let queue = DispatchQueue(label: "owner-watch-test") + let watch = SessionOwnerWatch(ownerPID: 30378, deadline: nil, interval: 0.01, + isAlive: { _ in alive.value }, queue: queue) + { verdict in + XCTAssertFalse(Thread.isMainThread) + XCTAssertEqual(verdict, .ownerExited) + calls.update { $0 += 1 } + fired.fulfill() + } + alive.update { $0 = false } + wait(for: [fired], timeout: 2) + // The verdict cancels the timer on its own serial queue, so once that + // queue drains no further tick can be pending. + queue.sync {} + XCTAssertEqual(calls.value, 1) + withExtendedLifetime(watch) {} + } + + /// setHandler waits for a verdict being delivered, so the startup + /// hard-exit handler can never run after the runtime handler is installed. + func testSetHandlerWaitsForAVerdictInFlight() { + let alive = Locked(true) + let inOldHandler = DispatchSemaphore(value: 0) + let releaseOldHandler = DispatchSemaphore(value: 0) + let order = Locked<[String]>([]) + let watch = SessionOwnerWatch(ownerPID: 30378, deadline: nil, interval: 0.01, + isAlive: { _ in alive.value }) + { _ in + order.update { $0.append("old") } + inOldHandler.signal() + releaseOldHandler.wait() + } + alive.update { $0 = false } + XCTAssertEqual(inOldHandler.wait(timeout: .now() + 2), .success) + let swapped = expectation(description: "setHandler returned") + DispatchQueue.global().async { + watch.setHandler { _ in order.update { $0.append("new") } } + order.update { $0.append("swapped") } + swapped.fulfill() + } + releaseOldHandler.signal() + wait(for: [swapped], timeout: 2) + XCTAssertEqual(order.value, ["old", "swapped"]) + } + + func testReplacedHandlerReceivesTheVerdict() { + let alive = Locked(true) + let fired = expectation(description: "Replacement handler called") + let watch = SessionOwnerWatch(ownerPID: 30378, deadline: nil, interval: 0.01, + isAlive: { _ in alive.value }) { _ in XCTFail("Startup handler must be replaced") } + watch.setHandler { _ in fired.fulfill() } + alive.update { $0 = false } + wait(for: [fired], timeout: 2) + } + + func testWatchEndsAProbeAtItsDeadline() { + let now = Locked(100.0) + let fired = expectation(description: "Deadline observed") + let watch = SessionOwnerWatch(ownerPID: 30378, deadline: 15, interval: 0.01, + isAlive: { _ in true }, uptime: { now.value }) + { verdict in + XCTAssertEqual(verdict, .deadlineExceeded) + fired.fulfill() + } + now.update { $0 = 115 } + wait(for: [fired], timeout: 2) + withExtendedLifetime(watch) {} + } +} + +final class SessionShutdownFallbackTests: XCTestCase { + func testOrderlyShutdownCancelsTheHardExit() { + let fallback = SessionShutdownFallback() + let fired = expectation(description: "Hard exit must not run") + fired.isInverted = true + XCTAssertTrue(fallback.arm(after: 0.05) { fired.fulfill() }) + fallback.shutdownBegins() + wait(for: [fired], timeout: 0.3) + } + + /// An owner exit during a shutdown already under way arms nothing. + func testNothingIsArmedOnceShutdownBegan() { + let fallback = SessionShutdownFallback() + fallback.shutdownBegins() + XCTAssertFalse(fallback.arm(after: 0) { XCTFail("Must not run") }) + } + + func testArmedFallbackRunsWhenNoShutdownStarts() { + let fallback = SessionShutdownFallback() + let fired = expectation(description: "Hard exit runs") + XCTAssertTrue(fallback.arm(after: 0.01) { fired.fulfill() }) + // A second owner verdict does not arm a second exit. + XCTAssertTrue(fallback.arm(after: 0.01) { XCTFail("Armed twice") }) + wait(for: [fired], timeout: 2) + } +} + +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + init(_ value: Value) { + stored = value + } + + var value: Value { + lock.lock(); defer { lock.unlock() }; return stored + } + + func update(_ body: (inout Value) -> Void) { + lock.lock(); body(&stored); lock.unlock() + } +} diff --git a/Tests/KeyPathTests/SessionRuntime/SessionWorkerSweepTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionWorkerSweepTests.swift new file mode 100644 index 000000000..ee25f8e5b --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionWorkerSweepTests.swift @@ -0,0 +1,313 @@ +import Darwin +import Foundation +@testable import KeyPathAppKit +import XCTest + +final class SessionWorkerSweepTests: XCTestCase { + private static let trashedExecutable = "/Users/someone/.Trash/KeyPath.app/Contents/MacOS/KeyPath" + + // MARK: - Recognizing workers + + /// The 2026-11-03 orphans: capability probes from a trashed bundle whose owner had exited. + func testParsesAnOrphanedProbeFromATrashedBundle() { + let worker = SessionWorkerProcess.parse(pid: 13284, arguments: [ + Self.trashedExecutable, "--session-capabilities", "--session-report", "/tmp/r.json", + "--session-nonce", UUID().uuidString, "--session-owner", "30378", "--session-port", "37001", + "--session-config", "/Users/someone/.config/keypath/keypath.kbd", + ]) + XCTAssertEqual(worker, SessionWorkerProcess(pid: 13284, mode: .capabilities, ownerPID: 30378, executable: Self.trashedExecutable)) + } + + func testParsesARuntimeWorker() { + let worker = SessionWorkerProcess.parse(pid: 42, arguments: [ + "/Applications/KeyPath.app/Contents/MacOS/KeyPath", "--session-runtime", "--session-owner", "41", + ]) + XCTAssertEqual(worker?.mode, .runtime) + XCTAssertEqual(worker?.ownerPID, 41) + } + + func testIgnoresTheAppItselfAndOtherProcesses() { + XCTAssertNil(SessionWorkerProcess.parse(pid: 1, arguments: ["/Applications/KeyPath.app/Contents/MacOS/KeyPath"])) + XCTAssertNil(SessionWorkerProcess.parse(pid: 2, arguments: ["/usr/bin/grep", "--session-runtime"])) + XCTAssertNil(SessionWorkerProcess.parse(pid: 3, arguments: [ + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", "--session-runtime", + ])) + XCTAssertNil(SessionWorkerProcess.parse(pid: 4, arguments: [])) + } + + func testMissingOwnerValueParsesWithoutOwner() { + let worker = SessionWorkerProcess.parse(pid: 5, arguments: [Self.trashedExecutable, "--session-runtime", "--session-owner"]) + XCTAssertEqual(worker?.mode, .runtime) + XCTAssertNil(worker?.ownerPID) + } + + func testParsesKernelArgumentLayout() { + var bytes = withUnsafeBytes(of: Int32(3)) { Array($0) } + for string in [Self.trashedExecutable, "", "", "KeyPath", "--session-capabilities", "--session-owner"] { + bytes += Array(string.utf8) + [0] + } + bytes += Array("KEYPATH=1".utf8) + [0] + XCTAssertEqual(SessionWorkerSweep.parseProcArgs(bytes), + [Self.trashedExecutable, "--session-capabilities", "--session-owner"]) + XCTAssertNil(SessionWorkerSweep.parseProcArgs([1, 0])) + } + + // MARK: - Stopping workers + + private final class FakeProcesses: @unchecked Sendable { + private let lock = NSLock() + private var running: [SessionWorkerProcess] + /// Workers that exit on SIGTERM; others ignore it. + private let exitOnTerm: Set + private(set) var signals: [(pid_t, Int32)] = [] + + init(_ running: [SessionWorkerProcess], exitOnTerm: Set) { + self.running = running + self.exitOnTerm = exitOnTerm + } + + /// Each sleep advances a fake clock, so graces are wall-clock bounded. + let clock = Locked(0.0) + var arrivals: [Int: SessionWorkerProcess] = [:] + private var polls = 0 + + var sweep: SessionWorkerSweep { + SessionWorkerSweep( + list: { self.locked { + if let late = self.arrivals[self.polls] { self.running.append(late) } + self.polls += 1 + return Optional(self.running) + } }, + signal: { pid, number in + self.locked { + self.signals.append((pid, number)) + if number == SIGKILL || self.exitOnTerm.contains(pid) { self.running.removeAll { $0.pid == pid } } + } + }, + sleep: { duration in + self.clock.update { $0 += Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 } + }, + uptime: { self.clock.value } + ) + } + + func locked(_ body: () -> T) -> T { + lock.lock(); defer { lock.unlock() }; return body() + } + + func received(_ number: Int32) -> [pid_t] { + locked { signals.filter { $0.1 == number }.map(\.0) } + } + } + + func testNoWorkersSucceedsWithoutSignalling() async { + let fake = FakeProcesses([], exitOnTerm: []) + let outcome = await fake.sweep.stopAll() + XCTAssertTrue(outcome.succeeded) + XCTAssertTrue(fake.locked { fake.signals.isEmpty }) + } + + /// Every worker is stopped, not only the one the live app tracks. + func testStopsEveryWorkerIncludingOrphans() async { + let tracked = SessionWorkerProcess(pid: 100, mode: .runtime, ownerPID: 99) + let orphanA = SessionWorkerProcess(pid: 13284, mode: .capabilities, ownerPID: 30378) + let orphanB = SessionWorkerProcess(pid: 29722, mode: .capabilities, ownerPID: 30378) + let fake = FakeProcesses([tracked, orphanA, orphanB], exitOnTerm: [100, 13284, 29722]) + let outcome = await fake.sweep.stopAll() + XCTAssertTrue(outcome.succeeded) + XCTAssertEqual(Set(fake.received(SIGTERM)), [100, 13284, 29722]) + XCTAssertTrue(fake.received(SIGKILL).isEmpty) + } + + func testKillsAWedgedProbeAfterItsGrace() async { + let probe = SessionWorkerProcess(pid: 13284, mode: .capabilities, ownerPID: 30378) + let fake = FakeProcesses([probe], exitOnTerm: []) + let outcome = await fake.sweep.stopAll(probeGrace: 0.2, runtimeGrace: 1, poll: .milliseconds(100)) + XCTAssertTrue(outcome.succeeded) + XCTAssertEqual(fake.received(SIGKILL), [13284]) + } + + /// A runtime worker may hold a Caps mapping; it is never killed, and a + /// worker that ignores SIGTERM is reported, not hidden. + func testNeverKillsARuntimeWorkerAndReportsItRemaining() async { + let runtime = SessionWorkerProcess(pid: 100, mode: .runtime, ownerPID: 99) + let fake = FakeProcesses([runtime], exitOnTerm: []) + let outcome = await fake.sweep.stopAll(probeGrace: 0.1, runtimeGrace: 0.5, poll: .milliseconds(100)) + XCTAssertFalse(outcome.succeeded) + XCTAssertEqual(outcome.remaining, [runtime]) + XCTAssertTrue(fake.received(SIGKILL).isEmpty) + } + + /// A worker that appears after the first listing is signalled, and a late + /// probe is still killed once the probe grace has passed. + func testSignalsWorkersThatAppearMidSweep() async { + let first = SessionWorkerProcess(pid: 100, mode: .runtime, ownerPID: 99) + let late = SessionWorkerProcess(pid: 200, mode: .capabilities, ownerPID: 99) + let fake = FakeProcesses([first], exitOnTerm: [100]) + // Listed on the first poll, after the initial SIGTERM went out. + fake.arrivals[1] = late + let outcome = await fake.sweep.stopAll(probeGrace: 0.2, runtimeGrace: 2, poll: .milliseconds(100)) + XCTAssertTrue(outcome.succeeded) + XCTAssertEqual(Set(fake.received(SIGTERM)), [100, 200]) + XCTAssertEqual(fake.received(SIGKILL), [200]) + XCTAssertEqual(Set(outcome.found.map(\.pid)), [100, 200]) + } + + func testUninstallScopeIsTheRemovedBundleAndOrphans() { + let app = URL(fileURLWithPath: "/Applications/KeyPath.app") + let dev = "/Users/me/dev/KeyPath.app/Contents/MacOS/KeyPath" + let own = SessionWorkerProcess(pid: 1, mode: .runtime, ownerPID: 10, + executable: "/Applications/KeyPath.app/Contents/MacOS/KeyPath") + let otherLive = SessionWorkerProcess(pid: 2, mode: .runtime, ownerPID: 20, executable: dev) + let orphan = SessionWorkerProcess(pid: 3, mode: .capabilities, ownerPID: 30378, + executable: Self.trashedExecutable) + let ownerless = SessionWorkerProcess(pid: 4, mode: .capabilities, ownerPID: nil, + executable: Self.trashedExecutable) + let apps: [pid_t: String] = [10: "/Applications/KeyPath.app/Contents/MacOS/KeyPath", 20: dev] + XCTAssertTrue(own.belongsToUninstall(of: app) { apps[$0] }) + XCTAssertFalse(otherLive.belongsToUninstall(of: app) { apps[$0] }) + XCTAssertTrue(orphan.belongsToUninstall(of: app) { apps[$0] }) + XCTAssertTrue(ownerless.belongsToUninstall(of: app) { apps[$0] }) + // A sibling bundle whose name starts with the removed one is not inside + // it: its live, matching owner keeps its worker. + let siblingExecutable = "/Applications/KeyPath.app.old/Contents/MacOS/KeyPath" + let sibling = SessionWorkerProcess(pid: 5, mode: .runtime, ownerPID: 21, executable: siblingExecutable) + XCTAssertFalse(sibling.belongsToUninstall(of: app) { [21: siblingExecutable][$0] }, + "A sibling bundle is not inside the removed one") + } + + /// A bundle reached through a symlink still claims its workers, which the + /// kernel reports at the resolved path, even when a matching owner is live. + func testSymlinkedBundleClaimsItsResolvedWorkers() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("real/KeyPath.app") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + let link = root.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real.deletingLastPathComponent()) + let executable = real.resolvingSymlinksInPath().path + "/Contents/MacOS/KeyPath" + let worker = SessionWorkerProcess(pid: 1, mode: .runtime, ownerPID: 10, executable: executable) + XCTAssertTrue(worker.belongsToUninstall(of: link.appendingPathComponent("KeyPath.app")) { _ in executable }) + } + + /// The 2026-11-03 orphans outlived their owner by hours. A reused owner + /// PID now belonging to an unrelated process, or to another KeyPath + /// bundle, does not make the orphan look owned. + func testReusedOwnerPIDDoesNotHideAnOrphan() { + let app = URL(fileURLWithPath: "/Applications/KeyPath.app") + let orphan = SessionWorkerProcess(pid: 13284, mode: .runtime, ownerPID: 30378, + executable: Self.trashedExecutable) + // PID 30378 is now some non-KeyPath process: not in the app table at all. + XCTAssertTrue(orphan.belongsToUninstall(of: app) { _ in nil }) + // PID 30378 is now a KeyPath app from a different bundle. + XCTAssertTrue(orphan.belongsToUninstall(of: app) { _ in "/Users/me/dev/KeyPath.app/Contents/MacOS/KeyPath" }) + } + + /// The parser recognizes exactly the argv the app launches workers with. + func testParsesTheRealLaunchArguments() { + for probe in [true, false] { + let arguments = ServiceLifecycleCoordinator.sessionLaunchArguments( + capabilitiesOnly: probe, reportPath: "/tmp/r/report.json", nonce: UUID().uuidString, + ownerPID: 30378, port: 37001, configPath: "/Users/me/.config/keypath/keypath.kbd" + ) + let worker = SessionWorkerProcess.parse(pid: 1, arguments: [Self.trashedExecutable] + arguments) + XCTAssertEqual(worker?.mode, probe ? .capabilities : .runtime) + XCTAssertEqual(worker?.ownerPID, 30378) + } + } + + /// One unreadable listing is retried; a process exiting mid-read must not + /// make the whole sweep unverified. + func testSingleUnreadableListingIsRetried() async { + let reads = Locked(0) + let sweep = SessionWorkerSweep(list: { + reads.update { $0 += 1 } + return reads.value == 1 ? nil : [] + }, signal: { _, _ in XCTFail("Nothing to signal") }, sleep: { _ in }) + let outcome = await sweep.stopAll() + XCTAssertTrue(outcome.succeeded) + XCTAssertEqual(outcome.result, .noneRunning) + } + + /// A cancelled sweep stops at once instead of re-reading the table for + /// the whole grace, and claims nothing it did not verify. + func testCancelledSweepStopsUnverified() async { + let reads = Locked(0) + let runtime = SessionWorkerProcess(pid: 100, mode: .runtime, ownerPID: 99) + let sweep = SessionWorkerSweep(list: { reads.update { $0 += 1 }; return [runtime] }, + signal: { _, _ in }, sleep: { _ in }) + let task = Task { await sweep.stopAll() } + task.cancel() + let outcome = await task.value + XCTAssertEqual(outcome.result, .unverified) + XCTAssertLessThanOrEqual(reads.value, 2) + } + + func testOutcomeResultSeparatesRunningFromUnverified() { + let worker = SessionWorkerProcess(pid: 1, mode: .runtime, ownerPID: 2) + XCTAssertEqual(SessionWorkerSweep.Outcome(found: [worker], remaining: []).result, .noneRunning) + XCTAssertEqual(SessionWorkerSweep.Outcome(found: [worker], remaining: [worker]).result, .stillRunning) + XCTAssertEqual(SessionWorkerSweep.Outcome(found: [], remaining: [], verified: false).result, .unverified) + } + + /// Reads the real process table without signalling anything: this test + /// process is not a KeyPath worker, so it must never list itself. + func testLiveListingNeverIncludesThisProcess() throws { + let workers = try XCTUnwrap(SessionWorkerSweep.currentUserSessionWorkers(), "Process table must be readable") + XCTAssertFalse(workers.contains { $0.pid == getpid() }) + var buffer = try [UInt8](repeating: 0, count: XCTUnwrap(SessionWorkerSweep.argumentMax())) + let arguments = try XCTUnwrap(SessionWorkerSweep.arguments(of: getpid(), buffer: &buffer)) + // The kernel reports the executable path, which need not equal argv[0], + // and test runners may rewrite argv, so only check the shape. + XCTAssertTrue(arguments.first?.hasPrefix("/") == true) + } + + /// An unreadable process table proves nothing, so it is not success. + func testUnreadableProcessTableIsNotSuccess() async { + let unreadable = SessionWorkerSweep(list: { nil }, signal: { _, _ in XCTFail("Nothing to signal") }, sleep: { _ in }) + let outcome = await unreadable.stopAll() + XCTAssertFalse(outcome.succeeded) + XCTAssertFalse(outcome.verified) + } + + func testTableThatBecomesUnreadableMidSweepIsNotSuccess() async { + let reads = Locked(0) + let probe = SessionWorkerProcess(pid: 13284, mode: .capabilities, ownerPID: 30378) + let sweep = SessionWorkerSweep(list: { + reads.update { $0 += 1 } + return reads.value == 1 ? [probe] : nil + }, signal: { _, _ in }, sleep: { _ in }) + let outcome = await sweep.stopAll() + XCTAssertFalse(outcome.succeeded) + XCTAssertEqual(outcome.remaining, [probe]) + } + + func testMalformedKernelArgumentsDoNotCrash() { + // argc larger than the strings present: returns what is there. + var bytes = withUnsafeBytes(of: Int32(5)) { Array($0) } + bytes += Array(Self.trashedExecutable.utf8) + [0] + Array("KeyPath".utf8) + [0] + XCTAssertEqual(SessionWorkerSweep.parseProcArgs(bytes), [Self.trashedExecutable]) + // No terminating NUL after the executable path. + let unterminated = withUnsafeBytes(of: Int32(1)) { Array($0) } + Array("/x".utf8) + XCTAssertNil(SessionWorkerSweep.parseProcArgs(unterminated)) + // Non-UTF8 bytes decode with replacement rather than failing. + let invalid = withUnsafeBytes(of: Int32(1)) { Array($0) } + [0xFF, 0xFE, 0] + [0x41, 0] + XCTAssertEqual(SessionWorkerSweep.parseProcArgs(invalid)?.count, 1) + } +} + +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + init(_ value: Value) { + stored = value + } + + var value: Value { + lock.lock(); defer { lock.unlock() }; return stored + } + + func update(_ body: (inout Value) -> Void) { + lock.lock(); body(&stored); lock.unlock() + } +} diff --git a/Tests/KeyPathTests/Uninstall/SessionUninstallCoordinatorTests.swift b/Tests/KeyPathTests/Uninstall/SessionUninstallCoordinatorTests.swift index 7a89643f7..a8fbfa104 100644 --- a/Tests/KeyPathTests/Uninstall/SessionUninstallCoordinatorTests.swift +++ b/Tests/KeyPathTests/Uninstall/SessionUninstallCoordinatorTests.swift @@ -88,6 +88,77 @@ final class SessionUninstallCoordinatorTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent(".config/keypath/keypath.kbd").path)) } + func testWorkersThatDoNotStopRemoveNothing() async { + var resumed = false + let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain, + prepare: { true }, resume: { resumed = true }, + stopSessionWorkers: { .stillRunning }, + trash: { _ in XCTFail("Must not trash while a worker runs") }) + let result = await coordinator.performUninstall(deleteConfig: true, removeVirtualHID: false, allowAdminFallback: false) + XCTAssertFalse(result.success) + XCTAssertTrue(resumed) + XCTAssertTrue(result.failureReason?.contains("Nothing was removed") == true) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent(".config/keypath/keypath.kbd").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: app.path)) + } + + /// An unreadable process table is not reported as a running process. + func testUnverifiedSweepRemovesNothingAndSaysWhy() async { + var resumed = false + let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain, + prepare: { true }, resume: { resumed = true }, + stopSessionWorkers: { .unverified }, + trash: { _ in XCTFail("Must not trash an unverified state") }) + let result = await coordinator.performUninstall(deleteConfig: true, removeVirtualHID: false, allowAdminFallback: false) + XCTAssertFalse(result.success) + XCTAssertTrue(resumed) + XCTAssertTrue(result.failureReason?.contains("could not check") == true) + XCTAssertFalse(result.failureReason?.contains("did not stop") == true) + } + + /// The final sweep runs after the app and settings are gone, so a failure + /// there must not resume watching or starting anything. + func testFinalSweepFailureDoesNotResume() async { + var sweeps = 0 + let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain, + prepare: { true }, resume: { XCTFail("Nothing is left to resume") }, + stopSessionWorkers: { sweeps += 1; return sweeps == 1 ? .noneRunning : .unverified }, + trash: { try FileManager.default.removeItem(at: $0) }) + let result = await coordinator.performUninstall(deleteConfig: true, removeVirtualHID: false, allowAdminFallback: false) + XCTAssertFalse(result.success) + XCTAssertTrue(result.failureReason?.contains("could not check") == true) + } + + /// Success is reported only after a final sweep finds no worker left. + func testWorkerFoundAfterRemovalIsNotReportedAsSuccess() async { + var sweeps = 0 + let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain, + prepare: { true }, resume: {}, + stopSessionWorkers: { sweeps += 1; return sweeps == 1 ? .noneRunning : .stillRunning }, + trash: { try FileManager.default.removeItem(at: $0) }) + let result = await coordinator.performUninstall(deleteConfig: true, removeVirtualHID: false, allowAdminFallback: false) + XCTAssertEqual(sweeps, 2) + XCTAssertFalse(result.success) + XCTAssertTrue(result.failureReason?.contains("still running") == true) + XCTAssertTrue(result.failureReason?.contains("Your backup is at") == true) + } + + func testSuccessfulUninstallSweepsWorkersBeforeAndAfterRemoval() async { + var sweepsBeforeTrash = 0 + var sweeps = 0 + let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain, + prepare: { true }, resume: { XCTFail("Unexpected failure") }, + stopSessionWorkers: { sweeps += 1; return .noneRunning }, + trash: { url in + sweepsBeforeTrash = sweeps + try FileManager.default.removeItem(at: url) + }) + let result = await coordinator.performUninstall(deleteConfig: true, removeVirtualHID: false, allowAdminFallback: false) + XCTAssertTrue(result.success, result.failureReason ?? "") + XCTAssertEqual(sweepsBeforeTrash, 1) + XCTAssertEqual(sweeps, 2) + } + func testBackupFailurePreservesSettingsAndApp() async throws { try write("Downloads", "not a directory") let coordinator = SessionUninstallCoordinator(home: root, app: app, defaults: defaults, domain: domain,