From e8ed17089faa974667ba33a8df2350dd2836f5c7 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 12:31:14 -0700 Subject: [PATCH 1/7] Fix CLI lifecycle through SMAppService --- Sources/KeyPathApp/Info.plist | 2 +- Sources/KeyPathAppKit/CLI/SystemFacade.swift | 6 +- .../Services/Kanata/KanataDaemonService.swift | 120 +++++++++++++++--- Tests/KeyPathTests/CLI/CLIServiceTests.swift | 10 +- .../KanataDaemonServiceIntegrationTests.swift | 51 ++++++++ .../bugs/cli-service-control-helper-bypass.md | 9 ++ 6 files changed, 173 insertions(+), 25 deletions(-) diff --git a/Sources/KeyPathApp/Info.plist b/Sources/KeyPathApp/Info.plist index 5dbafacfc..3c7b2321b 100644 --- a/Sources/KeyPathApp/Info.plist +++ b/Sources/KeyPathApp/Info.plist @@ -11,7 +11,7 @@ CFBundleDisplayName KeyPath CFBundleVersion - 11 + 12 CFBundleShortVersionString 1.0.1 CFBundlePackageType diff --git a/Sources/KeyPathAppKit/CLI/SystemFacade.swift b/Sources/KeyPathAppKit/CLI/SystemFacade.swift index d47bdad37..b2a90c4d5 100644 --- a/Sources/KeyPathAppKit/CLI/SystemFacade.swift +++ b/Sources/KeyPathAppKit/CLI/SystemFacade.swift @@ -21,13 +21,13 @@ public struct SystemFacade: Sendable { public init() { self.init( startServiceOperation: { - try await HelperManager.shared.startKanataService() + try await KanataDaemonService.shared.start() }, stopServiceOperation: { - try await HelperManager.shared.stopKanataService() + try await KanataDaemonService.shared.stop() }, restartServiceOperation: { - try await HelperManager.shared.restartKanataService() + try await KanataDaemonService.shared.restart() }, runtimeCacheInvalidator: { await MainActor.run { diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 549ec9289..f03977de2 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -1,14 +1,18 @@ import Foundation import KeyPathCore import KeyPathDaemonLifecycle +import KeyPathInstallationWizard import ServiceManagement /// Errors related to recovery-daemon operations. enum KanataDaemonServiceError: LocalizedError, Equatable { + case startFailed(reason: String) case stopFailed(reason: String) var errorDescription: String? { switch self { + case let .startFailed(reason): + "Failed to start Kanata service: \(reason)" case let .stopFailed(reason): "Failed to stop Kanata service: \(reason)" } @@ -47,6 +51,8 @@ final class KanataDaemonService { // whatever is listening on the machine. DEBUG-only โ€” production always probes. #if DEBUG nonisolated(unsafe) static var tcpProbeOverride: ((Int, Int) -> Bool)? + nonisolated(unsafe) static var stoppedPostconditionOverride: (() async -> Bool)? + nonisolated(unsafe) static var privilegedStopOverride: (() async throws -> Void)? #endif // MARK: - Internal Dependencies (Hidden from consumers) @@ -111,6 +117,57 @@ final class KanataDaemonService { } } + private func registerDaemon() async throws { + let service = makeSMService() + do { + try service.register() + await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName) + } catch { + throw KanataDaemonServiceError.startFailed(reason: error.localizedDescription) + } + } + + private func stoppedPostconditionSatisfied() async -> Bool { + #if DEBUG + if let override = Self.stoppedPostconditionOverride { + return await override() + } + #endif + + let status = await SystemStateProvider.shared + .freshSMAppServiceStatus(for: Constants.daemonPlistName) + await pidCache.invalidateCache() + let processState = await detectProcessState() + let isUnregistered = status == .notRegistered || status == .notFound + return isUnregistered && !processState.isRunning + } + + private func waitForStoppedPostcondition( + maxAttempts: Int = 10, + delayMilliseconds: Int = 100 + ) async -> Bool { + for attempt in 1 ... maxAttempts { + if await stoppedPostconditionSatisfied() { + return true + } + if attempt < maxAttempts { + try? await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + } + return false + } + + private func forceStopStaleDaemon() async throws { + #if DEBUG + if let override = Self.privilegedStopOverride { + try await override() + return + } + #endif + + try await PrivilegeBroker().stopKanataDaemonService() + } + private func detectProcessState() async -> ProcessSnapshot { if let daemonPID = await pidCache.getCachedPID() { return ProcessSnapshot(isRunning: true, pid: Int(daemonPID)) @@ -141,37 +198,68 @@ final class KanataDaemonService { // MARK: - Public API + /// Start the service through its owning SMAppService registration. + func start() async throws { + AppLogger.shared.log("โ–ถ๏ธ [KanataDaemonService] Start requested") + try await registerDaemon() + await pidCache.invalidateCache() + lastObservedState = .unknown + AppLogger.shared.info("โœ… [KanataDaemonService] Start requested successfully") + } + /// Stop the service func stop() async throws { AppLogger.shared.log("๐Ÿ›‘ [KanataDaemonService] Stop requested") try await unregisterDaemon() - // Verify cleanup - try? await Task.sleep(for: .milliseconds(200)) // 0.2s - try? PIDFileManager.removePID() - await pidCache.invalidateCache() - let refreshedStatus = await refreshStatus() + // SMAppService removes its launchd job asynchronously. Across an app + // replacement, macOS can leave the prior bundle's registration alive + // after the first unregister request. Verify the real postcondition, + // retry the owning API once, then use the existing privileged bootout + // path only if the stale job still survives. + if !(await waitForStoppedPostcondition()) { + AppLogger.shared.warn( + "โš ๏ธ [KanataDaemonService] Job survived unregister; retrying SMAppService removal" + ) + try? await unregisterDaemon() + } - if case .running = refreshedStatus { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("๐Ÿงช [KanataDaemonService] Test environment stop fallback - marking service as stopped") - lastObservedState = .stopped - } else { - // If still running, it might be a zombie or external process - AppLogger.shared.warn("โš ๏ธ [KanataDaemonService] Service still running after stop request") - throw KanataDaemonServiceError.stopFailed(reason: "Process failed to terminate") + if !(await waitForStoppedPostcondition()) { + AppLogger.shared.warn( + "โš ๏ธ [KanataDaemonService] Stale job survived SMAppService retry; using privileged cleanup" + ) + do { + try await forceStopStaleDaemon() + } catch { + throw KanataDaemonServiceError.stopFailed(reason: error.localizedDescription) } } - if lastObservedState != .stopped { - AppLogger.shared.log("โ„น๏ธ [KanataDaemonService] Forcing state to stopped after successful stop") - lastObservedState = .stopped + guard await waitForStoppedPostcondition(maxAttempts: 20) else { + throw KanataDaemonServiceError.stopFailed( + reason: "Service remained registered or running after stale-job cleanup" + ) } + try? PIDFileManager.removePID() + await pidCache.invalidateCache() + lastObservedState = .stopped + AppLogger.shared.info("โœ… [KanataDaemonService] Stopped successfully") } + /// Restart by removing the SMAppService registration before registering it + /// again. This is the supported way to stop a loaded KeepAlive job; disabling + /// or signaling the launchd label alone does not suppress an existing job's + /// KeepAlive respawn. + func restart() async throws { + AppLogger.shared.log("๐Ÿ”„ [KanataDaemonService] Restart requested") + try await stop() + try await start() + AppLogger.shared.info("โœ… [KanataDaemonService] Restart requested successfully") + } + /// Returns whether the internal recovery daemon is currently active. func isDaemonRunning() async -> Bool { let status = await refreshStatus() diff --git a/Tests/KeyPathTests/CLI/CLIServiceTests.swift b/Tests/KeyPathTests/CLI/CLIServiceTests.swift index 9763e9e66..a9847a1b9 100644 --- a/Tests/KeyPathTests/CLI/CLIServiceTests.swift +++ b/Tests/KeyPathTests/CLI/CLIServiceTests.swift @@ -28,7 +28,7 @@ final class CLIServiceTests: XCTestCase { // MARK: - service lifecycle - func testStopServiceReturnsFalseWhenPrivilegedHelperFails() async { + func testStopServiceReturnsFalseWhenLifecycleOperationFails() async { let facade = SystemFacade( stopServiceOperation: { throw ServiceOperationError.failed }, runtimeSnapshotProvider: { Self.runtimeSnapshot(running: true, responding: true) }, @@ -41,7 +41,7 @@ final class CLIServiceTests: XCTestCase { XCTAssertFalse(stopped) } - func testStopServiceWaitsForStoppedRuntimeAfterHelperSuccess() async { + func testStopServiceWaitsForStoppedRuntimeAfterLifecycleSuccess() async { let snapshots = RuntimeSnapshotSequence([ Self.runtimeSnapshot(running: true, responding: true), Self.runtimeSnapshot(running: false, responding: false) @@ -62,7 +62,7 @@ final class CLIServiceTests: XCTestCase { XCTAssertEqual(stopCount, 1) } - func testRestartServiceDoesNotReportSuccessWhenPrivilegedHelperFails() async { + func testRestartServiceDoesNotReportSuccessWhenLifecycleOperationFails() async { let operations = ServiceOperationRecorder() let facade = SystemFacade( restartServiceOperation: { @@ -81,7 +81,7 @@ final class CLIServiceTests: XCTestCase { XCTAssertEqual(restartCount, 1) } - func testRestartServiceWaitsForHealthyRuntimeAfterHelperSuccess() async { + func testRestartServiceWaitsForHealthyRuntimeAfterLifecycleSuccess() async { let snapshots = RuntimeSnapshotSequence([ Self.runtimeSnapshot(running: false, responding: false), Self.runtimeSnapshot(running: true, responding: true) @@ -117,7 +117,7 @@ final class CLIServiceTests: XCTestCase { XCTAssertFalse(started) } - func testStartServiceWaitsForHealthyRuntimeAfterHelperSuccess() async { + func testStartServiceWaitsForHealthyRuntimeAfterLifecycleSuccess() async { let snapshots = RuntimeSnapshotSequence([ Self.runtimeSnapshot(running: false, responding: false), Self.runtimeSnapshot(running: true, responding: true) diff --git a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift index 25c92126c..53b0349a5 100644 --- a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift +++ b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift @@ -8,6 +8,7 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { var status: SMAppService.Status var registerCalled = false var unregisterCalled = false + var calls: [String] = [] init(status: SMAppService.Status = .notRegistered) { self.status = status @@ -15,6 +16,7 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { func register() throws { registerCalled = true + calls.append("register") // Simulate successful registration transition if status == .notRegistered || status == .notFound { status = .enabled @@ -23,6 +25,7 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { func unregister() async throws { unregisterCalled = true + calls.append("unregister") status = .notRegistered } } @@ -58,6 +61,7 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { // runner is a dev Mac with a real kanata listening on the default port, which // would otherwise make the probe succeed and contaminate these status tests. KanataDaemonService.tcpProbeOverride = { _, _ in false } + KanataDaemonService.stoppedPostconditionOverride = { true } // 2. Create Service under test service = KanataDaemonService() @@ -67,6 +71,8 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { KanataDaemonService.smServiceFactory = originalFactory SMAppServiceStatusProvider.shared = originalStatusProvider KanataDaemonService.tcpProbeOverride = nil + KanataDaemonService.stoppedPostconditionOverride = nil + KanataDaemonService.privilegedStopOverride = nil service = nil try await super.tearDown() } @@ -88,6 +94,51 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { } } + func testStartServiceRegisters() async throws { + let mock = MockSMAppService(status: .notRegistered) + KanataDaemonService.smServiceFactory = { _ in mock } + service = KanataDaemonService() + + try await service.start() + + XCTAssertTrue(mock.registerCalled) + XCTAssertEqual(mock.calls, ["register"]) + } + + func testRestartServiceUnregistersBeforeRegistering() async throws { + let mock = MockSMAppService(status: .enabled) + KanataDaemonService.smServiceFactory = { _ in mock } + service = KanataDaemonService() + + try await service.restart() + + XCTAssertTrue(mock.unregisterCalled) + XCTAssertTrue(mock.registerCalled) + XCTAssertEqual(mock.calls, ["unregister", "register"]) + } + + func testStopRetriesUnregisterThenUsesPrivilegedFallbackForStaleJob() async throws { + let mock = MockSMAppService(status: .enabled) + KanataDaemonService.smServiceFactory = { _ in mock } + + var postconditionChecks = 0 + KanataDaemonService.stoppedPostconditionOverride = { + postconditionChecks += 1 + return postconditionChecks > 20 + } + var privilegedStopCalls = 0 + KanataDaemonService.privilegedStopOverride = { + privilegedStopCalls += 1 + } + service = KanataDaemonService() + + try await service.stop() + + XCTAssertEqual(mock.calls, ["unregister", "unregister"]) + XCTAssertEqual(privilegedStopCalls, 1) + XCTAssertGreaterThanOrEqual(postconditionChecks, 21) + } + func testStatusRefresh_ShouldDetectChanges() async { // Given: Initial unknown state diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md index 6fa6ad2f5..28a6eb484 100644 --- a/docs/bugs/cli-service-control-helper-bypass.md +++ b/docs/bugs/cli-service-control-helper-bypass.md @@ -57,3 +57,12 @@ contract advanced to 1.3.2 so installations cannot retain either earlier behavio reporting the helper as fresh. A lifecycle lint test preserves the required inspect-before-disable-before-signal and enable-before-kickstart ordering; installed-app acceptance verifies the real launchd transition. + +The next acceptance run proved that even direct PID signaling is insufficient: launchd respawns +an already loaded KeepAlive job after `launchctl disable`. The CLI lifecycle facade now uses the +same `SMAppService` register/unregister ownership path as the KeyPath UI. Stop unregisters the +daemon and verifies both registration and process removal, start registers it, and restart +performs those operations in order. If macOS leaves a stale job across an app replacement, stop +retries the owning API once before using the existing helper-backed privileged cleanup. The +helper remains the privilege boundary for operations that require root, but it no longer tries +to emulate the owning app's SMAppService lifecycle with launchctl signals. From 5862b9ce062688ff7761cca799e88fac828d32f4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 12:35:00 -0700 Subject: [PATCH 2/7] Format Kanata lifecycle service --- .../Services/Kanata/KanataDaemonService.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index f03977de2..23420dca2 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -73,7 +73,9 @@ final class KanataDaemonService { case unknown var isRunning: Bool { - if case .running = self { return true } + if case .running = self { + return true + } return false } @@ -218,14 +220,14 @@ final class KanataDaemonService { // after the first unregister request. Verify the real postcondition, // retry the owning API once, then use the existing privileged bootout // path only if the stale job still survives. - if !(await waitForStoppedPostcondition()) { + if await !waitForStoppedPostcondition() { AppLogger.shared.warn( "โš ๏ธ [KanataDaemonService] Job survived unregister; retrying SMAppService removal" ) try? await unregisterDaemon() } - if !(await waitForStoppedPostcondition()) { + if await !waitForStoppedPostcondition() { AppLogger.shared.warn( "โš ๏ธ [KanataDaemonService] Stale job survived SMAppService retry; using privileged cleanup" ) From 69a0c2aeabcc58a21bdba609b1bd808849339f07 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 12:43:48 -0700 Subject: [PATCH 3/7] Verify Kanata lifecycle postconditions --- Sources/KeyPathAppKit/CLI/SystemFacade.swift | 4 +- .../Services/Kanata/KanataDaemonService.swift | 102 +++++++++++++++--- .../KanataDaemonServiceIntegrationTests.swift | 54 ++++++++-- .../bugs/cli-service-control-helper-bypass.md | 5 + 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/Sources/KeyPathAppKit/CLI/SystemFacade.swift b/Sources/KeyPathAppKit/CLI/SystemFacade.swift index b2a90c4d5..52f788595 100644 --- a/Sources/KeyPathAppKit/CLI/SystemFacade.swift +++ b/Sources/KeyPathAppKit/CLI/SystemFacade.swift @@ -110,8 +110,8 @@ public struct SystemFacade: Sendable { try await restartServiceOperation() await runtimeCacheInvalidator() - // The helper performs one launchctl kickstart -k against the fixed - // launchd job. Verify the final healthy runtime, not a transient gap. + // The lifecycle operation verifies registration plus process/TCP + // readiness. Independently verify the facade's final runtime view. return await waitForRuntime(timeoutSeconds: runtimeTransitionTimeoutSeconds) { snapshot in snapshot.isRunning && snapshot.isResponding } diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 23420dca2..4aafa3e22 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -6,11 +6,14 @@ import ServiceManagement /// Errors related to recovery-daemon operations. enum KanataDaemonServiceError: LocalizedError, Equatable { + case approvalRequired case startFailed(reason: String) case stopFailed(reason: String) var errorDescription: String? { switch self { + case .approvalRequired: + "Starting Kanata requires approval in System Settings." case let .startFailed(reason): "Failed to start Kanata service: \(reason)" case let .stopFailed(reason): @@ -51,6 +54,7 @@ final class KanataDaemonService { // whatever is listening on the machine. DEBUG-only โ€” production always probes. #if DEBUG nonisolated(unsafe) static var tcpProbeOverride: ((Int, Int) -> Bool)? + nonisolated(unsafe) static var runningPostconditionOverride: (() async -> Bool)? nonisolated(unsafe) static var stoppedPostconditionOverride: (() async -> Bool)? nonisolated(unsafe) static var privilegedStopOverride: (() async throws -> Void)? #endif @@ -129,27 +133,71 @@ final class KanataDaemonService { } } - private func stoppedPostconditionSatisfied() async -> Bool { + private func currentRegistrationStatus() async -> SMAppService.Status { + await SystemStateProvider.shared + .freshSMAppServiceStatus(for: Constants.daemonPlistName) + } + + private func processIsRunning() async -> Bool { + await pidCache.invalidateCache() + let processState = await detectProcessState() + return processState.isRunning + } + + private func waitForRunningPostcondition( + maxAttempts: Int = 80, + delayMilliseconds: Int = 250 + ) async -> Bool { #if DEBUG - if let override = Self.stoppedPostconditionOverride { + if let override = Self.runningPostconditionOverride { return await override() } #endif - let status = await SystemStateProvider.shared - .freshSMAppServiceStatus(for: Constants.daemonPlistName) - await pidCache.invalidateCache() - let processState = await detectProcessState() - let isUnregistered = status == .notRegistered || status == .notFound - return isUnregistered && !processState.isRunning + for attempt in 1 ... maxAttempts { + if await processIsRunning() { + let tcpPort = PreferencesService.shared.tcpServerPort + let tcpAlive = await SystemStateProvider.shared + .isTCPPortResponding(port: tcpPort, timeoutMs: 300) + if tcpAlive { + return true + } + } + if attempt < maxAttempts { + try? await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + } + return false } private func waitForStoppedPostcondition( maxAttempts: Int = 10, delayMilliseconds: Int = 100 ) async -> Bool { + #if DEBUG + if let override = Self.stoppedPostconditionOverride { + for attempt in 1 ... maxAttempts { + if await override() { + return true + } + if attempt < maxAttempts { + try? await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + } + return false + } + #endif + + // Registration is correctness-critical but SMAppService.status is slow + // synchronous IPC. Fetch it once per unregister phase, then poll only the + // inexpensive launchd-backed process evidence while removal settles. + let status = await currentRegistrationStatus() + guard status == .notRegistered || status == .notFound else { + return false + } + for attempt in 1 ... maxAttempts { - if await stoppedPostconditionSatisfied() { + if await !processIsRunning() { return true } if attempt < maxAttempts { @@ -203,10 +251,40 @@ final class KanataDaemonService { /// Start the service through its owning SMAppService registration. func start() async throws { AppLogger.shared.log("โ–ถ๏ธ [KanataDaemonService] Start requested") - try await registerDaemon() + + let finalRegistrationStatus: SMAppService.Status + switch await currentRegistrationStatus() { + case .enabled: + finalRegistrationStatus = .enabled + case .requiresApproval: + throw KanataDaemonServiceError.approvalRequired + case .notRegistered, .notFound: + try await registerDaemon() + finalRegistrationStatus = await currentRegistrationStatus() + @unknown default: + throw KanataDaemonServiceError.startFailed(reason: "Unknown SMAppService registration state") + } + + switch finalRegistrationStatus { + case .enabled: + break + case .requiresApproval: + throw KanataDaemonServiceError.approvalRequired + case .notRegistered, .notFound: + throw KanataDaemonServiceError.startFailed(reason: "Registration did not persist") + @unknown default: + throw KanataDaemonServiceError.startFailed(reason: "Unknown SMAppService registration state") + } + + guard await waitForRunningPostcondition() else { + throw KanataDaemonServiceError.startFailed( + reason: "Service registered but did not reach process and TCP readiness" + ) + } + await pidCache.invalidateCache() - lastObservedState = .unknown - AppLogger.shared.info("โœ… [KanataDaemonService] Start requested successfully") + lastObservedState = await refreshStatus() + AppLogger.shared.info("โœ… [KanataDaemonService] Started successfully") } /// Stop the service diff --git a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift index 53b0349a5..6c453a0d2 100644 --- a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift +++ b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift @@ -41,14 +41,18 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { /// Point the centralized status provider (#853) at the same status the service's /// factory would report, with a zero TTL so each refresh re-reads. `evaluateStatus` /// now sources status from the provider rather than the service's own factory. - private func useStatus(_ status: SMAppService.Status) { - KanataDaemonService.smServiceFactory = { _ in MockSMAppService(status: status) } + private func useService(_ service: MockSMAppService) { + KanataDaemonService.smServiceFactory = { _ in service } SMAppServiceStatusProvider.shared = SMAppServiceStatusProvider( cacheTTL: 0, - serviceFactory: { _ in MockSMAppService(status: status) } + serviceFactory: { _ in service } ) } + private func useStatus(_ status: SMAppService.Status) { + useService(MockSMAppService(status: status)) + } + override func setUp() async throws { try await super.setUp() @@ -61,6 +65,7 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { // runner is a dev Mac with a real kanata listening on the default port, which // would otherwise make the probe succeed and contaminate these status tests. KanataDaemonService.tcpProbeOverride = { _, _ in false } + KanataDaemonService.runningPostconditionOverride = { true } KanataDaemonService.stoppedPostconditionOverride = { true } // 2. Create Service under test @@ -71,6 +76,7 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { KanataDaemonService.smServiceFactory = originalFactory SMAppServiceStatusProvider.shared = originalStatusProvider KanataDaemonService.tcpProbeOverride = nil + KanataDaemonService.runningPostconditionOverride = nil KanataDaemonService.stoppedPostconditionOverride = nil KanataDaemonService.privilegedStopOverride = nil service = nil @@ -96,7 +102,7 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { func testStartServiceRegisters() async throws { let mock = MockSMAppService(status: .notRegistered) - KanataDaemonService.smServiceFactory = { _ in mock } + useService(mock) service = KanataDaemonService() try await service.start() @@ -107,7 +113,7 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { func testRestartServiceUnregistersBeforeRegistering() async throws { let mock = MockSMAppService(status: .enabled) - KanataDaemonService.smServiceFactory = { _ in mock } + useService(mock) service = KanataDaemonService() try await service.restart() @@ -117,9 +123,45 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { XCTAssertEqual(mock.calls, ["unregister", "register"]) } + func testStartServiceFailsExplicitlyWhenApprovalIsRequired() async { + let mock = MockSMAppService(status: .requiresApproval) + useService(mock) + service = KanataDaemonService() + + do { + try await service.start() + XCTFail("Expected approval-required failure") + } catch let error as KanataDaemonServiceError { + XCTAssertEqual(error, .approvalRequired) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertFalse(mock.registerCalled) + } + + func testStartServiceFailsWhenRegisteredRuntimeDoesNotBecomeReady() async { + let mock = MockSMAppService(status: .notRegistered) + useService(mock) + KanataDaemonService.runningPostconditionOverride = { false } + service = KanataDaemonService() + + do { + try await service.start() + XCTFail("Expected runtime-readiness failure") + } catch let error as KanataDaemonServiceError { + guard case let .startFailed(reason) = error else { + return XCTFail("Expected startFailed, got \(error)") + } + XCTAssertTrue(reason.contains("process and TCP readiness")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + func testStopRetriesUnregisterThenUsesPrivilegedFallbackForStaleJob() async throws { let mock = MockSMAppService(status: .enabled) - KanataDaemonService.smServiceFactory = { _ in mock } + useService(mock) var postconditionChecks = 0 KanataDaemonService.stoppedPostconditionOverride = { diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md index 28a6eb484..a4e531d6f 100644 --- a/docs/bugs/cli-service-control-helper-bypass.md +++ b/docs/bugs/cli-service-control-helper-bypass.md @@ -66,3 +66,8 @@ performs those operations in order. If macOS leaves a stale job across an app re retries the owning API once before using the existing helper-backed privileged cleanup. The helper remains the privilege boundary for operations that require root, but it no longer tries to emulate the owning app's SMAppService lifecycle with launchctl signals. + +Lifecycle verification keeps synchronous SMAppService status IPC out of polling loops: stop +reads registration status once per bounded cleanup phase and polls launchd process evidence +between phases. Start reports success only after registration is enabled and both process and +TCP readiness are proven; pending approval and failed launch readiness are explicit failures. From 928dae5bb842d91379ec020c29b2d77eb7912dbe Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 12:49:34 -0700 Subject: [PATCH 4/7] Distinguish stale registration from process exit --- .../Services/Kanata/KanataDaemonService.swift | 51 +++++++++++-------- .../KanataDaemonServiceIntegrationTests.swift | 37 ++++++++++---- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 4aafa3e22..284f1603b 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -54,8 +54,8 @@ final class KanataDaemonService { // whatever is listening on the machine. DEBUG-only โ€” production always probes. #if DEBUG nonisolated(unsafe) static var tcpProbeOverride: ((Int, Int) -> Bool)? + nonisolated(unsafe) static var processRunningOverride: (() async -> Bool)? nonisolated(unsafe) static var runningPostconditionOverride: (() async -> Bool)? - nonisolated(unsafe) static var stoppedPostconditionOverride: (() async -> Bool)? nonisolated(unsafe) static var privilegedStopOverride: (() async throws -> Void)? #endif @@ -139,6 +139,12 @@ final class KanataDaemonService { } private func processIsRunning() async -> Bool { + #if DEBUG + if let override = Self.processRunningOverride { + return await override() + } + #endif + await pidCache.invalidateCache() let processState = await detectProcessState() return processState.isRunning @@ -170,41 +176,33 @@ final class KanataDaemonService { return false } + private enum StoppedPostcondition: Equatable { + case satisfied + case registrationPresent + case processRunning + } + private func waitForStoppedPostcondition( maxAttempts: Int = 10, delayMilliseconds: Int = 100 - ) async -> Bool { - #if DEBUG - if let override = Self.stoppedPostconditionOverride { - for attempt in 1 ... maxAttempts { - if await override() { - return true - } - if attempt < maxAttempts { - try? await Task.sleep(for: .milliseconds(delayMilliseconds)) - } - } - return false - } - #endif - + ) async -> StoppedPostcondition { // Registration is correctness-critical but SMAppService.status is slow // synchronous IPC. Fetch it once per unregister phase, then poll only the // inexpensive launchd-backed process evidence while removal settles. let status = await currentRegistrationStatus() guard status == .notRegistered || status == .notFound else { - return false + return .registrationPresent } for attempt in 1 ... maxAttempts { if await !processIsRunning() { - return true + return .satisfied } if attempt < maxAttempts { try? await Task.sleep(for: .milliseconds(delayMilliseconds)) } } - return false + return .processRunning } private func forceStopStaleDaemon() async throws { @@ -298,14 +296,16 @@ final class KanataDaemonService { // after the first unregister request. Verify the real postcondition, // retry the owning API once, then use the existing privileged bootout // path only if the stale job still survives. - if await !waitForStoppedPostcondition() { + var stoppedPostcondition = await waitForStoppedPostcondition() + if stoppedPostcondition == .registrationPresent { AppLogger.shared.warn( "โš ๏ธ [KanataDaemonService] Job survived unregister; retrying SMAppService removal" ) try? await unregisterDaemon() + stoppedPostcondition = await waitForStoppedPostcondition() } - if await !waitForStoppedPostcondition() { + if stoppedPostcondition != .satisfied { AppLogger.shared.warn( "โš ๏ธ [KanataDaemonService] Stale job survived SMAppService retry; using privileged cleanup" ) @@ -314,9 +314,16 @@ final class KanataDaemonService { } catch { throw KanataDaemonServiceError.stopFailed(reason: error.localizedDescription) } + + // A bootout can stop the process, but only the owning API can remove + // a still-enabled registration and prevent a later KeepAlive respawn. + if stoppedPostcondition == .registrationPresent { + try await unregisterDaemon() + } + stoppedPostcondition = await waitForStoppedPostcondition(maxAttempts: 20) } - guard await waitForStoppedPostcondition(maxAttempts: 20) else { + guard stoppedPostcondition == .satisfied else { throw KanataDaemonServiceError.stopFailed( reason: "Service remained registered or running after stale-job cleanup" ) diff --git a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift index 6c453a0d2..d05918af1 100644 --- a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift +++ b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift @@ -9,6 +9,7 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { var registerCalled = false var unregisterCalled = false var calls: [String] = [] + var statusesAfterUnregister: [SMAppService.Status] = [] init(status: SMAppService.Status = .notRegistered) { self.status = status @@ -26,7 +27,9 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { func unregister() async throws { unregisterCalled = true calls.append("unregister") - status = .notRegistered + status = statusesAfterUnregister.isEmpty + ? .notRegistered + : statusesAfterUnregister.removeFirst() } } @@ -65,8 +68,8 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { // runner is a dev Mac with a real kanata listening on the default port, which // would otherwise make the probe succeed and contaminate these status tests. KanataDaemonService.tcpProbeOverride = { _, _ in false } + KanataDaemonService.processRunningOverride = { false } KanataDaemonService.runningPostconditionOverride = { true } - KanataDaemonService.stoppedPostconditionOverride = { true } // 2. Create Service under test service = KanataDaemonService() @@ -76,8 +79,8 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { KanataDaemonService.smServiceFactory = originalFactory SMAppServiceStatusProvider.shared = originalStatusProvider KanataDaemonService.tcpProbeOverride = nil + KanataDaemonService.processRunningOverride = nil KanataDaemonService.runningPostconditionOverride = nil - KanataDaemonService.stoppedPostconditionOverride = nil KanataDaemonService.privilegedStopOverride = nil service = nil try await super.tearDown() @@ -161,24 +164,40 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { func testStopRetriesUnregisterThenUsesPrivilegedFallbackForStaleJob() async throws { let mock = MockSMAppService(status: .enabled) + mock.statusesAfterUnregister = [.enabled, .enabled, .notRegistered] useService(mock) - var postconditionChecks = 0 - KanataDaemonService.stoppedPostconditionOverride = { - postconditionChecks += 1 - return postconditionChecks > 20 + var privilegedStopCalls = 0 + KanataDaemonService.privilegedStopOverride = { + privilegedStopCalls += 1 } + service = KanataDaemonService() + + try await service.stop() + + XCTAssertEqual(mock.calls, ["unregister", "unregister", "unregister"]) + XCTAssertEqual(privilegedStopCalls, 1) + XCTAssertEqual(mock.status, .notRegistered) + } + + func testStopUsesPrivilegedFallbackWithoutRedundantUnregisterWhenOnlyProcessLingers() async throws { + let mock = MockSMAppService(status: .enabled) + useService(mock) + + var processRunning = true + KanataDaemonService.processRunningOverride = { processRunning } var privilegedStopCalls = 0 KanataDaemonService.privilegedStopOverride = { privilegedStopCalls += 1 + processRunning = false } service = KanataDaemonService() try await service.stop() - XCTAssertEqual(mock.calls, ["unregister", "unregister"]) + XCTAssertEqual(mock.calls, ["unregister"]) XCTAssertEqual(privilegedStopCalls, 1) - XCTAssertGreaterThanOrEqual(postconditionChecks, 21) + XCTAssertEqual(mock.status, .notRegistered) } func testStatusRefresh_ShouldDetectChanges() async { From 76a85e101a7ddae4af5ac4152d393b35eb82720c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 12:54:51 -0700 Subject: [PATCH 5/7] Allow launchd graceful Kanata shutdown --- .../KeyPathAppKit/Services/Kanata/KanataDaemonService.swift | 4 +++- docs/bugs/cli-service-control-helper-bypass.md | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 284f1603b..e107032e5 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -183,7 +183,9 @@ final class KanataDaemonService { } private func waitForStoppedPostcondition( - maxAttempts: Int = 10, + // The launchd plist allows a five-second exit timeout. Give normal + // SMAppService shutdown slightly longer before escalating to the helper. + maxAttempts: Int = 60, delayMilliseconds: Int = 100 ) async -> StoppedPostcondition { // Registration is correctness-critical but SMAppService.status is slow diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md index a4e531d6f..3c468192f 100644 --- a/docs/bugs/cli-service-control-helper-bypass.md +++ b/docs/bugs/cli-service-control-helper-bypass.md @@ -69,5 +69,7 @@ to emulate the owning app's SMAppService lifecycle with launchctl signals. Lifecycle verification keeps synchronous SMAppService status IPC out of polling loops: stop reads registration status once per bounded cleanup phase and polls launchd process evidence -between phases. Start reports success only after registration is enabled and both process and -TCP readiness are proven; pending approval and failed launch readiness are explicit failures. +between phases. It allows normal shutdown slightly longer than launchd's five-second exit +timeout before escalating to privileged cleanup. Start reports success only after registration +is enabled and both process and TCP readiness are proven; pending approval and failed launch +readiness are explicit failures. From ec081c062953f3eeec0abac7e4cb3719b95a70dd Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 13:00:05 -0700 Subject: [PATCH 6/7] Verify stop after transient unregister error --- .../Services/Kanata/KanataDaemonService.swift | 6 +---- .../KanataDaemonServiceIntegrationTests.swift | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index e107032e5..43457d030 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -115,10 +115,6 @@ final class KanataDaemonService { // Drop the centralized status cache so the next read re-fetches. await SystemStateProvider.shared.invalidateSMAppServiceStatus(plistName: Constants.daemonPlistName) } catch { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("๐Ÿงช [KanataDaemonService] Ignoring unregister error in tests: \(error)") - return - } throw KanataDaemonServiceError.stopFailed(reason: error.localizedDescription) } } @@ -320,7 +316,7 @@ final class KanataDaemonService { // A bootout can stop the process, but only the owning API can remove // a still-enabled registration and prevent a later KeepAlive respawn. if stoppedPostcondition == .registrationPresent { - try await unregisterDaemon() + try? await unregisterDaemon() } stoppedPostcondition = await waitForStoppedPostcondition(maxAttempts: 20) } diff --git a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift index d05918af1..a71cff2c6 100644 --- a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift +++ b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift @@ -5,11 +5,16 @@ import ServiceManagement /// Mock implementation of SMAppServiceProtocol for testing private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { + enum MockError: Error { + case unregisterFailed + } + var status: SMAppService.Status var registerCalled = false var unregisterCalled = false var calls: [String] = [] var statusesAfterUnregister: [SMAppService.Status] = [] + var failingUnregisterCalls: Set = [] init(status: SMAppService.Status = .notRegistered) { self.status = status @@ -27,6 +32,9 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { func unregister() async throws { unregisterCalled = true calls.append("unregister") + if failingUnregisterCalls.contains(calls.count(where: { $0 == "unregister" })) { + throw MockError.unregisterFailed + } status = statusesAfterUnregister.isEmpty ? .notRegistered : statusesAfterUnregister.removeFirst() @@ -200,6 +208,23 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { XCTAssertEqual(mock.status, .notRegistered) } + func testStopVerifiesFinalStateWhenPostFallbackUnregisterThrows() async throws { + let mock = MockSMAppService(status: .enabled) + mock.statusesAfterUnregister = [.enabled, .enabled] + mock.failingUnregisterCalls = [3] + useService(mock) + + KanataDaemonService.privilegedStopOverride = { + mock.status = .notRegistered + } + service = KanataDaemonService() + + try await service.stop() + + XCTAssertEqual(mock.calls, ["unregister", "unregister", "unregister"]) + XCTAssertEqual(mock.status, .notRegistered) + } + func testStatusRefresh_ShouldDetectChanges() async { // Given: Initial unknown state From 888372d12f9ee2b8de3afe733e255899f4416264 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 13:06:17 -0700 Subject: [PATCH 7/7] Make lifecycle postconditions authoritative --- .../Services/Kanata/KanataDaemonService.swift | 17 +++--- .../KanataDaemonServiceIntegrationTests.swift | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift index 43457d030..ed39d0e75 100644 --- a/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift +++ b/Sources/KeyPathAppKit/Services/Kanata/KanataDaemonService.swift @@ -255,7 +255,9 @@ final class KanataDaemonService { case .requiresApproval: throw KanataDaemonServiceError.approvalRequired case .notRegistered, .notFound: - try await registerDaemon() + // Registration can race with an IPC error. The fresh status below + // is authoritative, so do not fail before observing the result. + try? await registerDaemon() finalRegistrationStatus = await currentRegistrationStatus() @unknown default: throw KanataDaemonServiceError.startFailed(reason: "Unknown SMAppService registration state") @@ -287,7 +289,10 @@ final class KanataDaemonService { func stop() async throws { AppLogger.shared.log("๐Ÿ›‘ [KanataDaemonService] Stop requested") - try await unregisterDaemon() + // Treat the mutation result as evidence, not the verdict. A transient + // SMAppService error can race with a successful state change; the real + // registration/process postcondition below decides whether to retry. + try? await unregisterDaemon() // SMAppService removes its launchd job asynchronously. Across an app // replacement, macOS can leave the prior bundle's registration alive @@ -307,11 +312,9 @@ final class KanataDaemonService { AppLogger.shared.warn( "โš ๏ธ [KanataDaemonService] Stale job survived SMAppService retry; using privileged cleanup" ) - do { - try await forceStopStaleDaemon() - } catch { - throw KanataDaemonServiceError.stopFailed(reason: error.localizedDescription) - } + // Like SMAppService mutations, helper delivery can report an error + // after changing state. The final postcondition remains authoritative. + try? await forceStopStaleDaemon() // A bootout can stop the process, but only the owning API can remove // a still-enabled registration and prevent a later KeepAlive respawn. diff --git a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift index a71cff2c6..d090b0a3c 100644 --- a/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift +++ b/Tests/KeyPathTests/Services/KanataDaemonServiceIntegrationTests.swift @@ -6,6 +6,7 @@ import ServiceManagement /// Mock implementation of SMAppServiceProtocol for testing private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { enum MockError: Error { + case registerFailed case unregisterFailed } @@ -15,6 +16,7 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { var calls: [String] = [] var statusesAfterUnregister: [SMAppService.Status] = [] var failingUnregisterCalls: Set = [] + var statusBeforeRegisterError: SMAppService.Status? init(status: SMAppService.Status = .notRegistered) { self.status = status @@ -23,6 +25,10 @@ private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { func register() throws { registerCalled = true calls.append("register") + if let statusBeforeRegisterError { + status = statusBeforeRegisterError + throw MockError.registerFailed + } // Simulate successful registration transition if status == .notRegistered || status == .notFound { status = .enabled @@ -122,6 +128,18 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { XCTAssertEqual(mock.calls, ["register"]) } + func testStartVerifiesFinalStateWhenRegisterThrowsAfterEnabling() async throws { + let mock = MockSMAppService(status: .notRegistered) + mock.statusBeforeRegisterError = .enabled + useService(mock) + service = KanataDaemonService() + + try await service.start() + + XCTAssertEqual(mock.calls, ["register"]) + XCTAssertEqual(mock.status, .enabled) + } + func testRestartServiceUnregistersBeforeRegistering() async throws { let mock = MockSMAppService(status: .enabled) useService(mock) @@ -225,6 +243,40 @@ final class KanataDaemonServiceIntegrationTests: KeyPathAsyncTestCase { XCTAssertEqual(mock.status, .notRegistered) } + func testStopRetriesWhenInitialUnregisterThrows() async throws { + let mock = MockSMAppService(status: .enabled) + mock.failingUnregisterCalls = [1] + useService(mock) + + var privilegedStopCalls = 0 + KanataDaemonService.privilegedStopOverride = { + privilegedStopCalls += 1 + } + service = KanataDaemonService() + + try await service.stop() + + XCTAssertEqual(mock.calls, ["unregister", "unregister"]) + XCTAssertEqual(privilegedStopCalls, 0) + XCTAssertEqual(mock.status, .notRegistered) + } + + func testStopVerifiesFinalStateWhenPrivilegedCleanupThrows() async throws { + let mock = MockSMAppService(status: .enabled) + mock.statusesAfterUnregister = [.enabled, .enabled] + useService(mock) + + KanataDaemonService.privilegedStopOverride = { + throw MockSMAppService.MockError.unregisterFailed + } + service = KanataDaemonService() + + try await service.stop() + + XCTAssertEqual(mock.calls, ["unregister", "unregister", "unregister"]) + XCTAssertEqual(mock.status, .notRegistered) + } + func testStatusRefresh_ShouldDetectChanges() async { // Given: Initial unknown state