From af3e7127208f717e9a03809f2b12589ab1e34736 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:32:04 -0700 Subject: [PATCH 01/10] Suppress transient startup TCP errors --- .../Managers/ConfigReloadCoordinator.swift | 64 ++++++++++++++++++- .../Managers/RuntimeCoordinator.swift | 5 +- .../ServiceLifecycleCoordinator.swift | 8 +++ .../Services/Networking/KanataTCPClient.swift | 2 +- .../UI/Overlay/LiveKeyboardOverlayTypes.swift | 19 ++++++ .../LiveKeyboardOverlayView+Header.swift | 55 ++++++++++++++-- .../OverlayDragHeader+LayerPicker.swift | 19 ++++++ .../ConfigReloadCoordinatorTests.swift | 63 ++++++++++++++++-- .../OverlayTCPStatusPresentationTests.swift | 41 ++++++++++++ ...7-28-startup-reload-runtime-replacement.md | 30 +++++++++ 10 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 Tests/KeyPathTests/UI/OverlayTCPStatusPresentationTests.swift create mode 100644 docs/bugs/2026-07-28-startup-reload-runtime-replacement.md diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 775c0579f..3b1806202 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -15,6 +15,8 @@ final class ConfigReloadCoordinator { private let reloadSafetyMonitor: ReloadSafetyMonitor private let healthStatusProvider: @MainActor @Sendable (Int) async -> ServiceHealthStatus private let processLifecycleManager: ProcessLifecycleManager + private let isRuntimeTransitioning: @MainActor @Sendable () -> Bool + private let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? // MARK: - Callbacks (set by RuntimeCoordinator after init) @@ -27,12 +29,16 @@ final class ConfigReloadCoordinator { engineClient: EngineClient, reloadSafetyMonitor: ReloadSafetyMonitor, healthStatusProvider: @escaping @MainActor @Sendable (Int) async -> ServiceHealthStatus, - processLifecycleManager: ProcessLifecycleManager + processLifecycleManager: ProcessLifecycleManager, + isRuntimeTransitioning: @escaping @MainActor @Sendable () -> Bool = { false }, + tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = nil ) { self.engineClient = engineClient self.reloadSafetyMonitor = reloadSafetyMonitor self.healthStatusProvider = healthStatusProvider self.processLifecycleManager = processLifecycleManager + self.isRuntimeTransitioning = isRuntimeTransitioning + self.tcpReloadOverride = tcpReloadOverride } // MARK: - Reload Operations @@ -95,7 +101,7 @@ final class ConfigReloadCoordinator { // Successful reload -> clear stale diagnostics onReloadSuccess?() // Notify UI that we recovered from a previous reload failure. - NotificationCenter.default.post(name: .configReloadRecovered, object: nil) + NotificationCenter.default.post(name: .configReloadRecovered, object: self) return ReloadResult( success: true, response: tcpResult.response ?? "", @@ -108,6 +114,20 @@ final class ConfigReloadCoordinator { "šŸ“” [Reload] TCP reload failed: \(tcpResult.errorMessage ?? "Unknown error")" ) let errorMessage = tcpResult.errorMessage ?? "TCP reload failed" + if case .networkError = tcpResult, isRuntimeTransitioning() { + AppLogger.shared.log( + "ā³ [Reload] Runtime transition closed the TCP reload connection; deferring until Kanata settles" + ) + scheduleDeferredReloadAfterRuntimeTransition() + return ReloadResult( + success: false, + response: tcpResult.response, + errorMessage: "Kanata is restarting; reload will retry shortly", + protocol: .tcp, + disposition: .pending + ) + } + // Cooldown blocks are a deliberate throttle, not a real failure. // Schedule a deferred retry so the write we just persisted // actually reaches kanata, and suppress the user-facing toast/ @@ -119,7 +139,7 @@ final class ConfigReloadCoordinator { } else { NotificationCenter.default.post( name: .configReloadFailed, - object: nil, + object: self, userInfo: [ "message": errorMessage, "response": tcpResult.response ?? "" @@ -162,6 +182,7 @@ final class ConfigReloadCoordinator { /// deferred retry once the cooldown expires; de-duped via a single /// outstanding task so rapid edits coalesce into one final reload. private var deferredReloadTask: Task? + private var transitionReloadTask: Task? private func scheduleDeferredReloadAfterCooldown() { deferredReloadTask?.cancel() @@ -175,8 +196,45 @@ final class ConfigReloadCoordinator { } } + /// Wait for an intentional runtime replacement to finish, then deliver the + /// config that was persisted while the old TCP connection was disappearing. + /// Readiness is bounded by the same grace period used by user-facing startup UI. + private func scheduleDeferredReloadAfterRuntimeTransition() { + if TestEnvironment.isRunningTests { return } + + transitionReloadTask?.cancel() + transitionReloadTask = Task { [weak self] in + let deadline = Date().addingTimeInterval(RuntimeStartupTiming.uiGracePeriod) + + while !Task.isCancelled, Date() < deadline { + guard let self else { return } + if !isRuntimeTransitioning() { + let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) + if health.isHealthy { + AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") + await triggerReload() + transitionReloadTask = nil + return + } + } + + try? await Task.sleep(for: .milliseconds(500)) + } + + guard let self, !Task.isCancelled else { return } + AppLogger.shared.warn( + "āš ļø [Reload] Runtime did not become ready before deferred reload grace expired" + ) + transitionReloadTask = nil + } + } + /// TCP-based config reload (no authentication required - see ADR-013) func triggerTCPReload() async -> TCPReloadResult { + if let tcpReloadOverride { + return await tcpReloadOverride() + } + if TestEnvironment.isRunningTests { AppLogger.shared.debug("🧪 [TCP Reload] Skipping TCP reload in test environment") return .networkError("Test environment - TCP disabled") diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index f024b4bfa..4a9b8a133 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -323,7 +323,10 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { healthStatusProvider: { [diagnosticsManager] tcpPort in await diagnosticsManager.checkHealth(tcpPort: tcpPort) }, - processLifecycleManager: lifecycleManager + processLifecycleManager: lifecycleManager, + isRuntimeTransitioning: { [serviceLifecycleCoordinator] in + serviceLifecycleCoordinator.isRuntimeTransitionInProgress + } ) // Initialize RuleCollectionsCoordinator (after all managers, before Task captures self) diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift index d9ca50b17..ba3a8766b 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift @@ -150,6 +150,14 @@ final class ServiceLifecycleCoordinator { return false } + /// True while the runtime is deliberately moving between executable instances. + /// Reload failures during this window are expected transport churn, not user-facing + /// configuration failures: the old TCP server can disappear while stale-runtime + /// recovery replaces it with the newly bundled process. + var isRuntimeTransitionInProgress: Bool { + isStartingKanata || isIntentionalTransitionInProgress + } + private let windowEvaluator = TransientStartupWindowEvaluator( gracePeriod: RuntimeStartupTiming.uiGracePeriod, createdAt: Date() diff --git a/Sources/KeyPathAppKit/Services/Networking/KanataTCPClient.swift b/Sources/KeyPathAppKit/Services/Networking/KanataTCPClient.swift index 445f98815..2217f11ed 100644 --- a/Sources/KeyPathAppKit/Services/Networking/KanataTCPClient.swift +++ b/Sources/KeyPathAppKit/Services/Networking/KanataTCPClient.swift @@ -161,7 +161,7 @@ actor KanataTCPClient { // MARK: - Result Types -enum TCPReloadResult { +enum TCPReloadResult: Sendable { case success(response: String) case failure(error: String, response: String) case networkError(String) diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift index 538900e07..390d3b363 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift @@ -40,6 +40,25 @@ enum HealthIndicatorState: Equatable { case dismissed } +enum OverlayTCPStatusPresentation: Equatable { + case hidden + case starting + case restarting + case disconnected + + static func resolve( + isConnected: Bool, + hasSeenConnection: Bool, + isWithinStartupGrace: Bool + ) -> Self { + if isConnected { return .hidden } + if isWithinStartupGrace { + return hasSeenConnection ? .restarting : .starting + } + return .disconnected + } +} + @Observable @MainActor final class LiveKeyboardOverlayUIState { diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift index 8f1a24b43..1f2d073c9 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift @@ -180,6 +180,9 @@ struct OverlayDragHeader: View { @State var hoveredLayer: String? /// When the health indicator dismissed — used to suppress "No TCP" briefly at startup @State private var healthDismissedAt: Date? + @State private var headerAppearedAt: Date + @State private var hasSeenKanataConnection: Bool + @State private var tcpStartupGraceExpired = false /// Tracks whether the KindaVim Mode Display pack is installed, so the /// header can render its mode badge without polling on every redraw. @@ -223,6 +226,8 @@ struct OverlayDragHeader: View { self.currentLayerName = currentLayerName self.isLauncherMode = isLauncherMode self.isKanataConnected = isKanataConnected + _headerAppearedAt = State(initialValue: Date()) + _hasSeenKanataConnection = State(initialValue: isKanataConnected) self.healthIndicatorState = healthIndicatorState self.drawerButtonHighlighted = drawerButtonHighlighted self.layoutHasDrawerButtons = layoutHasDrawerButtons @@ -322,6 +327,9 @@ struct OverlayDragHeader: View { .onAppear { refreshAvailableLayers() Task { await refreshKindaVimPackInstalled() } + if isKanataConnected { + hasSeenKanataConnection = true + } } .onReceive(NotificationCenter.default.publisher(for: .ruleCollectionsChanged)) { _ in refreshAvailableLayers() @@ -339,6 +347,14 @@ struct OverlayDragHeader: View { healthDismissedAt = Date() } } + .onChange(of: isKanataConnected) { _, connected in + if connected { + hasSeenKanataConnection = true + } + } + .task(id: healthDismissedAt) { + await scheduleTCPStartupGraceExpiration() + } } /// Refresh available layers from rule collections @@ -391,11 +407,31 @@ struct OverlayDragHeader: View { Color.white.opacity(isDark ? 0.7 : 0.6) } - /// Whether the "No TCP" pill should be suppressed (grace period after health dismissal). - /// The EventListener takes a moment to connect after startup; avoid a brief flash of "No TCP". - private var isTcpGracePeriodActive: Bool { - guard let dismissedAt = healthDismissedAt else { return false } - return Date().timeIntervalSince(dismissedAt) < 5.0 + private var tcpStatusPresentation: OverlayTCPStatusPresentation { + .resolve( + isConnected: isKanataConnected, + hasSeenConnection: hasSeenKanataConnection, + isWithinStartupGrace: !tcpStartupGraceExpired + ) + } + + private func scheduleTCPStartupGraceExpiration() async { + tcpStartupGraceExpired = false + let reference = healthDismissedAt ?? headerAppearedAt + let deadline = reference.addingTimeInterval(RuntimeStartupTiming.uiGracePeriod) + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + tcpStartupGraceExpired = true + return + } + + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + tcpStartupGraceExpired = true } private func statusSlot(indicatorCornerRadius: CGFloat, buttonSize: CGFloat) -> some View { @@ -409,7 +445,14 @@ struct OverlayDragHeader: View { ) } else { HStack(spacing: 6) { - if !isKanataConnected, !isTcpGracePeriodActive { + switch tcpStatusPresentation { + case .hidden: + EmptyView() + case .starting: + kanataTransitionPill("Starting…", indicatorCornerRadius: indicatorCornerRadius) + case .restarting: + kanataTransitionPill("Restarting…", indicatorCornerRadius: indicatorCornerRadius) + case .disconnected: kanataDisconnectedPill(indicatorCornerRadius: indicatorCornerRadius) } diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift index b7b1a264b..cc7335373 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift @@ -264,4 +264,23 @@ extension OverlayDragHeader { .accessibilityIdentifier("overlay-kanata-disconnected-indicator") .accessibilityLabel("Not connected to Kanata TCP server") } + + func kanataTransitionPill(_ label: String, indicatorCornerRadius: CGFloat) -> some View { + HStack(spacing: 4) { + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") + .font(.caption2.weight(.medium)) + Text(label) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(Color.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: indicatorCornerRadius) + .fill(Color.secondary.opacity(isDark ? 0.12 : 0.1)) + ) + .help("Kanata is starting") + .accessibilityIdentifier("overlay-kanata-transition-indicator") + .accessibilityLabel(label) + } } diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 379fb78d0..f6858d875 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -78,7 +78,9 @@ struct ConfigReloadCoordinatorTests { /// behaviour before invoking `triggerConfigReload()` etc. private static func makeSUT( engineResult: EngineReloadResult = .success(response: "ok"), - healthy: Bool = true + healthy: Bool = true, + runtimeTransitioning: Bool = false, + tcpReloadResult: TCPReloadResult? = nil ) -> ( coordinator: ConfigReloadCoordinator, engine: MockEngineClient, @@ -93,12 +95,19 @@ struct ConfigReloadCoordinatorTests { let safetyMonitor = ReloadSafetyMonitor() let processLifecycle = ProcessLifecycleManager() + let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = if let tcpReloadResult { + { tcpReloadResult } + } else { + nil + } let coordinator = ConfigReloadCoordinator( engineClient: engine, reloadSafetyMonitor: safetyMonitor, healthStatusProvider: { _ in healthStatus.value }, - processLifecycleManager: processLifecycle + processLifecycleManager: processLifecycle, + isRuntimeTransitioning: { runtimeTransitioning }, + tcpReloadOverride: tcpReloadOverride ) return (coordinator, engine, healthStatus) @@ -162,7 +171,7 @@ struct ConfigReloadCoordinatorTests { let received = NotificationFlag() let observer = NotificationCenter.default.addObserver( forName: .configReloadRecovered, - object: nil, + object: coordinator, queue: .main ) { _ in received.set() } defer { NotificationCenter.default.removeObserver(observer) } @@ -183,6 +192,52 @@ struct ConfigReloadCoordinatorTests { // MARK: - triggerConfigReload: notification on non-cooldown failure + @Test("network failure during runtime transition stays pending without failure notification") + func transitionNetworkFailureStaysPending() async { + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitioning: true, + tcpReloadResult: .networkError("Connection failed: Connection closed") + ) + + let received = NotificationFlag() + let observer = NotificationCenter.default.addObserver( + forName: .configReloadFailed, + object: coordinator, + queue: nil + ) { _ in received.set() } + defer { NotificationCenter.default.removeObserver(observer) } + + let result = await coordinator.triggerConfigReload() + + #expect(result.isSuccess == false) + #expect(result.disposition == .pending) + #expect(result.errorMessage?.contains("restarting") == true) + #expect(received.value == false) + } + + @Test("network failure outside runtime transition remains a visible failure") + func settledNetworkFailureStillNotifies() async { + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitioning: false, + tcpReloadResult: .networkError("Connection failed: Connection closed") + ) + + let received = NotificationFlag() + let observer = NotificationCenter.default.addObserver( + forName: .configReloadFailed, + object: coordinator, + queue: nil + ) { _ in received.set() } + defer { NotificationCenter.default.removeObserver(observer) } + + let result = await coordinator.triggerConfigReload() + + #expect(result.disposition == .failed) + #expect(received.value == true) + } + @Test("triggerConfigReload posts configReloadFailed on non-cooldown failure") func reloadPostsFailedNotification() async { let (coordinator, _, _) = Self.makeSUT( @@ -194,7 +249,7 @@ struct ConfigReloadCoordinatorTests { let message = NotificationMessage() let observer = NotificationCenter.default.addObserver( forName: .configReloadFailed, - object: nil, + object: coordinator, queue: nil // deliver synchronously on posting thread ) { notification in received.set() diff --git a/Tests/KeyPathTests/UI/OverlayTCPStatusPresentationTests.swift b/Tests/KeyPathTests/UI/OverlayTCPStatusPresentationTests.swift new file mode 100644 index 000000000..4040af4ff --- /dev/null +++ b/Tests/KeyPathTests/UI/OverlayTCPStatusPresentationTests.swift @@ -0,0 +1,41 @@ +@testable import KeyPathAppKit +import Testing + +@Suite("Overlay TCP status presentation") +struct OverlayTCPStatusPresentationTests { + @Test("connected runtime hides TCP status") + func connectedHidesStatus() { + #expect(OverlayTCPStatusPresentation.resolve( + isConnected: true, + hasSeenConnection: true, + isWithinStartupGrace: true + ) == .hidden) + } + + @Test("initial connection grace reports starting") + func initialGraceReportsStarting() { + #expect(OverlayTCPStatusPresentation.resolve( + isConnected: false, + hasSeenConnection: false, + isWithinStartupGrace: true + ) == .starting) + } + + @Test("lost connection during grace reports restarting") + func reconnectGraceReportsRestarting() { + #expect(OverlayTCPStatusPresentation.resolve( + isConnected: false, + hasSeenConnection: true, + isWithinStartupGrace: true + ) == .restarting) + } + + @Test("settled disconnection reports No TCP state") + func settledDisconnectionReportsFailure() { + #expect(OverlayTCPStatusPresentation.resolve( + isConnected: false, + hasSeenConnection: true, + isWithinStartupGrace: false + ) == .disconnected) + } +} diff --git a/docs/bugs/2026-07-28-startup-reload-runtime-replacement.md b/docs/bugs/2026-07-28-startup-reload-runtime-replacement.md new file mode 100644 index 000000000..11347f223 --- /dev/null +++ b/docs/bugs/2026-07-28-startup-reload-runtime-replacement.md @@ -0,0 +1,30 @@ +# Startup reload during runtime replacement + +## Problem + +Rule collection bootstrap can persist the generated configuration and begin a +TCP reload while app startup is replacing a stale Kanata runtime. The old +runtime may acknowledge the reload, then close the connection when privileged +stale-process recovery terminates it. KeyPath previously treated that expected +transport loss as a settled reload failure, producing an error sound, a +`Reload delayed: Connection failed: Connection closed` toast, and a transient +`No TCP` badge while the replacement runtime became ready. + +## Resolution + +`ServiceLifecycleCoordinator` now exposes its intentional start/stop transition +state to `ConfigReloadCoordinator`. A network failure inside that exact window +is classified as pending, does not post the user-facing failure notification, +and schedules one bounded retry after the transition ends and runtime health is +restored. Validation failures and network failures outside an intentional +transition remain visible failures. + +The overlay uses the canonical runtime startup grace period and whether it has +previously observed a live connection to show `Starting…` or `Restarting…`. +After the grace period, a settled disconnection still shows `No TCP`. + +## Invariant + +Expected transport churn caused by an intentional runtime replacement is a +pending lifecycle state, not a configuration failure. User-facing TCP errors +must represent failures that persist outside the startup/restart grace window. From f1c4dbdacda3c2bd4d71fe1dc88d2fcf228477c7 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:39:58 -0700 Subject: [PATCH 02/10] Coordinate deferred reload retries --- .../Managers/ConfigReloadCoordinator.swift | 69 +++++++++++------- .../ConfigReloadCoordinatorTests.swift | 70 +++++++++++++++++-- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 3b1806202..41d4f9a0b 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -17,6 +17,8 @@ final class ConfigReloadCoordinator { private let processLifecycleManager: ProcessLifecycleManager private let isRuntimeTransitioning: @MainActor @Sendable () -> Bool private let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? + private let transitionRetryMaximumPolls: Int + private let transitionRetryWait: @MainActor @Sendable () async -> Void // MARK: - Callbacks (set by RuntimeCoordinator after init) @@ -31,7 +33,11 @@ final class ConfigReloadCoordinator { healthStatusProvider: @escaping @MainActor @Sendable (Int) async -> ServiceHealthStatus, processLifecycleManager: ProcessLifecycleManager, isRuntimeTransitioning: @escaping @MainActor @Sendable () -> Bool = { false }, - tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = nil + tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = nil, + transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), + transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { + try? await Task.sleep(for: .milliseconds(500)) + } ) { self.engineClient = engineClient self.reloadSafetyMonitor = reloadSafetyMonitor @@ -39,6 +45,8 @@ final class ConfigReloadCoordinator { self.processLifecycleManager = processLifecycleManager self.isRuntimeTransitioning = isRuntimeTransitioning self.tcpReloadOverride = tcpReloadOverride + self.transitionRetryMaximumPolls = transitionRetryMaximumPolls + self.transitionRetryWait = transitionRetryWait } // MARK: - Reload Operations @@ -179,10 +187,10 @@ final class ConfigReloadCoordinator { /// When a reload is blocked by the cooldown, we still want it to actually /// happen so the config file we just wrote reaches kanata. Schedule a - /// deferred retry once the cooldown expires; de-duped via a single - /// outstanding task so rapid edits coalesce into one final reload. + /// deferred retry once the cooldown expires. Cooldown and runtime-transition + /// retries share one task so rapid edits and overlapping recovery states + /// coalesce into one final reload. private var deferredReloadTask: Task? - private var transitionReloadTask: Task? private func scheduleDeferredReloadAfterCooldown() { deferredReloadTask?.cancel() @@ -200,33 +208,40 @@ final class ConfigReloadCoordinator { /// config that was persisted while the old TCP connection was disappearing. /// Readiness is bounded by the same grace period used by user-facing startup UI. private func scheduleDeferredReloadAfterRuntimeTransition() { - if TestEnvironment.isRunningTests { return } - - transitionReloadTask?.cancel() - transitionReloadTask = Task { [weak self] in - let deadline = Date().addingTimeInterval(RuntimeStartupTiming.uiGracePeriod) - - while !Task.isCancelled, Date() < deadline { - guard let self else { return } - if !isRuntimeTransitioning() { - let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) - if health.isHealthy { - AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") - await triggerReload() - transitionReloadTask = nil - return - } - } + deferredReloadTask?.cancel() + deferredReloadTask = Task { [weak self] in + guard let self else { return } + let didRetry = await retryAfterRuntimeTransition() + if !didRetry, !Task.isCancelled { + AppLogger.shared.warn( + "āš ļø [Reload] Runtime did not become ready before deferred reload grace expired" + ) + } + deferredReloadTask = nil + } + } - try? await Task.sleep(for: .milliseconds(500)) + /// Poll the intentional transition and retry once the replacement runtime is healthy. + /// The injected poll bound and wait operation keep the production grace period bounded + /// while allowing deterministic tests without sleeping. + @discardableResult + func retryAfterRuntimeTransition() async -> Bool { + for _ in 0 ..< transitionRetryMaximumPolls { + guard !Task.isCancelled else { return false } + + if !isRuntimeTransitioning() { + let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) + if health.isHealthy { + AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") + await triggerReload() + return true + } } - guard let self, !Task.isCancelled else { return } - AppLogger.shared.warn( - "āš ļø [Reload] Runtime did not become ready before deferred reload grace expired" - ) - transitionReloadTask = nil + await transitionRetryWait() } + + return false } /// TCP-based config reload (no authentication required - see ADR-013) diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index f6858d875..101866bd6 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -38,6 +38,17 @@ private final class MutableHealthStatus { } } +@MainActor +private final class MutableRuntimeTransition { + var isTransitioning: Bool + var waitCount = 0 + var reloadCount = 0 + + init(isTransitioning: Bool) { + self.isTransitioning = isTransitioning + } +} + /// Thread-safe boolean flag for use in notification observer closures. private final class NotificationFlag: @unchecked Sendable { private let lock = NSLock() @@ -80,7 +91,12 @@ struct ConfigReloadCoordinatorTests { engineResult: EngineReloadResult = .success(response: "ok"), healthy: Bool = true, runtimeTransitioning: Bool = false, - tcpReloadResult: TCPReloadResult? = nil + runtimeTransitionState: MutableRuntimeTransition? = nil, + tcpReloadResult: TCPReloadResult? = nil, + transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), + transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { + try? await Task.sleep(for: .milliseconds(500)) + } ) -> ( coordinator: ConfigReloadCoordinator, engine: MockEngineClient, @@ -95,6 +111,8 @@ struct ConfigReloadCoordinatorTests { let safetyMonitor = ReloadSafetyMonitor() let processLifecycle = ProcessLifecycleManager() + let transitionState = runtimeTransitionState + ?? MutableRuntimeTransition(isTransitioning: runtimeTransitioning) let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = if let tcpReloadResult { { tcpReloadResult } } else { @@ -106,8 +124,10 @@ struct ConfigReloadCoordinatorTests { reloadSafetyMonitor: safetyMonitor, healthStatusProvider: { _ in healthStatus.value }, processLifecycleManager: processLifecycle, - isRuntimeTransitioning: { runtimeTransitioning }, - tcpReloadOverride: tcpReloadOverride + isRuntimeTransitioning: { transitionState.isTransitioning }, + tcpReloadOverride: tcpReloadOverride, + transitionRetryMaximumPolls: transitionRetryMaximumPolls, + transitionRetryWait: transitionRetryWait ) return (coordinator, engine, healthStatus) @@ -197,7 +217,8 @@ struct ConfigReloadCoordinatorTests { let (coordinator, _, _) = Self.makeSUT( healthy: true, runtimeTransitioning: true, - tcpReloadResult: .networkError("Connection failed: Connection closed") + tcpReloadResult: .networkError("Connection failed: Connection closed"), + transitionRetryMaximumPolls: 0 ) let received = NotificationFlag() @@ -216,6 +237,47 @@ struct ConfigReloadCoordinatorTests { #expect(received.value == false) } + @Test("transition retry waits for readiness and reloads once") + func transitionRetryWaitsForReadiness() async { + let transition = MutableRuntimeTransition(isTransitioning: true) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadResult: .success(response: "ok"), + transitionRetryMaximumPolls: 2, + transitionRetryWait: { + transition.waitCount += 1 + transition.isTransitioning = false + } + ) + coordinator.onReloadSuccess = { transition.reloadCount += 1 } + + let didRetry = await coordinator.retryAfterRuntimeTransition() + + #expect(didRetry == true) + #expect(transition.waitCount == 1) + #expect(transition.reloadCount == 1) + } + + @Test("transition retry expires without reloading when runtime never settles") + func transitionRetryExpires() async { + let transition = MutableRuntimeTransition(isTransitioning: true) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadResult: .success(response: "ok"), + transitionRetryMaximumPolls: 2, + transitionRetryWait: { transition.waitCount += 1 } + ) + coordinator.onReloadSuccess = { transition.reloadCount += 1 } + + let didRetry = await coordinator.retryAfterRuntimeTransition() + + #expect(didRetry == false) + #expect(transition.waitCount == 2) + #expect(transition.reloadCount == 0) + } + @Test("network failure outside runtime transition remains a visible failure") func settledNetworkFailureStillNotifies() async { let (coordinator, _, _) = Self.makeSUT( From 612d56e6a25e333cb90ed678015007c145c1790e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:41:31 -0700 Subject: [PATCH 03/10] Use yielding retry test clock --- Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 101866bd6..5b672b7ef 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -95,7 +95,7 @@ struct ConfigReloadCoordinatorTests { tcpReloadResult: TCPReloadResult? = nil, transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { - try? await Task.sleep(for: .milliseconds(500)) + await Task.yield() } ) -> ( coordinator: ConfigReloadCoordinator, From 6a74d50badbaff49a2f4b2b1796170d901f456e8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:51:54 -0700 Subject: [PATCH 04/10] Surface exhausted restart reloads --- .../Managers/ConfigReloadCoordinator.swift | 14 +++++++++++++- .../Overlay/OverlayDragHeader+LayerPicker.swift | 2 +- .../Services/ConfigReloadCoordinatorTests.swift | 16 +++++++++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 41d4f9a0b..cd25c7dce 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -19,6 +19,7 @@ final class ConfigReloadCoordinator { private let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? private let transitionRetryMaximumPolls: Int private let transitionRetryWait: @MainActor @Sendable () async -> Void + private let automaticDeferredRetriesEnabled: Bool // MARK: - Callbacks (set by RuntimeCoordinator after init) @@ -37,7 +38,8 @@ final class ConfigReloadCoordinator { transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { try? await Task.sleep(for: .milliseconds(500)) - } + }, + automaticDeferredRetriesEnabled: Bool = true ) { self.engineClient = engineClient self.reloadSafetyMonitor = reloadSafetyMonitor @@ -47,6 +49,7 @@ final class ConfigReloadCoordinator { self.tcpReloadOverride = tcpReloadOverride self.transitionRetryMaximumPolls = transitionRetryMaximumPolls self.transitionRetryWait = transitionRetryWait + self.automaticDeferredRetriesEnabled = automaticDeferredRetriesEnabled } // MARK: - Reload Operations @@ -208,6 +211,8 @@ final class ConfigReloadCoordinator { /// config that was persisted while the old TCP connection was disappearing. /// Readiness is bounded by the same grace period used by user-facing startup UI. private func scheduleDeferredReloadAfterRuntimeTransition() { + guard automaticDeferredRetriesEnabled else { return } + deferredReloadTask?.cancel() deferredReloadTask = Task { [weak self] in guard let self else { return } @@ -241,6 +246,13 @@ final class ConfigReloadCoordinator { await transitionRetryWait() } + guard !Task.isCancelled else { return false } + let message = "Kanata did not become ready after restarting; config reload was not applied" + NotificationCenter.default.post( + name: .configReloadFailed, + object: self, + userInfo: ["message": message, "response": ""] + ) return false } diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift index cc7335373..614ec7bd1 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift @@ -279,7 +279,7 @@ extension OverlayDragHeader { RoundedRectangle(cornerRadius: indicatorCornerRadius) .fill(Color.secondary.opacity(isDark ? 0.12 : 0.1)) ) - .help("Kanata is starting") + .help(label == "Restarting…" ? "Kanata is restarting" : "Kanata is starting") .accessibilityIdentifier("overlay-kanata-transition-indicator") .accessibilityLabel(label) } diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 5b672b7ef..17547b86a 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -96,7 +96,8 @@ struct ConfigReloadCoordinatorTests { transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { await Task.yield() - } + }, + automaticDeferredRetriesEnabled: Bool = true ) -> ( coordinator: ConfigReloadCoordinator, engine: MockEngineClient, @@ -127,7 +128,8 @@ struct ConfigReloadCoordinatorTests { isRuntimeTransitioning: { transitionState.isTransitioning }, tcpReloadOverride: tcpReloadOverride, transitionRetryMaximumPolls: transitionRetryMaximumPolls, - transitionRetryWait: transitionRetryWait + transitionRetryWait: transitionRetryWait, + automaticDeferredRetriesEnabled: automaticDeferredRetriesEnabled ) return (coordinator, engine, healthStatus) @@ -218,7 +220,7 @@ struct ConfigReloadCoordinatorTests { healthy: true, runtimeTransitioning: true, tcpReloadResult: .networkError("Connection failed: Connection closed"), - transitionRetryMaximumPolls: 0 + automaticDeferredRetriesEnabled: false ) let received = NotificationFlag() @@ -270,12 +272,20 @@ struct ConfigReloadCoordinatorTests { transitionRetryWait: { transition.waitCount += 1 } ) coordinator.onReloadSuccess = { transition.reloadCount += 1 } + let received = NotificationFlag() + let observer = NotificationCenter.default.addObserver( + forName: .configReloadFailed, + object: coordinator, + queue: nil + ) { _ in received.set() } + defer { NotificationCenter.default.removeObserver(observer) } let didRetry = await coordinator.retryAfterRuntimeTransition() #expect(didRetry == false) #expect(transition.waitCount == 2) #expect(transition.reloadCount == 0) + #expect(received.value == true) } @Test("network failure outside runtime transition remains a visible failure") From a05c8346c2bb58f7b298b1f2d7357f34cc0805c9 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:57:31 -0700 Subject: [PATCH 05/10] Recheck readiness at retry deadline --- .../Managers/ConfigReloadCoordinator.swift | 22 ++++++++++++------- .../ConfigReloadCoordinatorTests.swift | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index cd25c7dce..feb31b501 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -234,19 +234,14 @@ final class ConfigReloadCoordinator { for _ in 0 ..< transitionRetryMaximumPolls { guard !Task.isCancelled else { return false } - if !isRuntimeTransitioning() { - let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) - if health.isHealthy { - AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") - await triggerReload() - return true - } - } + if await retryReloadIfRuntimeReady() { return true } await transitionRetryWait() } guard !Task.isCancelled else { return false } + if await retryReloadIfRuntimeReady() { return true } + let message = "Kanata did not become ready after restarting; config reload was not applied" NotificationCenter.default.post( name: .configReloadFailed, @@ -256,6 +251,17 @@ final class ConfigReloadCoordinator { return false } + private func retryReloadIfRuntimeReady() async -> Bool { + guard !isRuntimeTransitioning() else { return false } + + let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) + guard health.isHealthy else { return false } + + AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") + await triggerReload() + return true + } + /// TCP-based config reload (no authentication required - see ADR-013) func triggerTCPReload() async -> TCPReloadResult { if let tcpReloadOverride { diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 17547b86a..c99268ad4 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -246,7 +246,7 @@ struct ConfigReloadCoordinatorTests { healthy: true, runtimeTransitionState: transition, tcpReloadResult: .success(response: "ok"), - transitionRetryMaximumPolls: 2, + transitionRetryMaximumPolls: 1, transitionRetryWait: { transition.waitCount += 1 transition.isTransitioning = false From 277d12623eaa69b2ec1e079dc055369badbc82c6 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 23:11:31 -0700 Subject: [PATCH 06/10] Track deferred reload outcomes --- .../Managers/ConfigReloadCoordinator.swift | 20 ++++++------ .../ConfigReloadCoordinatorTests.swift | 32 ++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index feb31b501..898805fad 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -56,7 +56,7 @@ final class ConfigReloadCoordinator { /// Main reload method using TCP protocol. /// Checks service health, permission gates, and delegates to TCP reload. - func triggerConfigReload() async -> ReloadResult { + func triggerConfigReload(notifyOnFailure: Bool = true) async -> ReloadResult { // Use the manager refresh path instead of the unbounded synchronous // currentManagementState cache; the underlying SMAppService provider // still coalesces IPC with a short TTL. @@ -147,7 +147,7 @@ final class ConfigReloadCoordinator { // notify as before. if isCooldownBlockMessage(errorMessage) { scheduleDeferredReloadAfterCooldown() - } else { + } else if notifyOnFailure { NotificationCenter.default.post( name: .configReloadFailed, object: self, @@ -203,7 +203,6 @@ final class ConfigReloadCoordinator { guard let self, !Task.isCancelled else { return } AppLogger.shared.log("šŸ” [Reload] Firing deferred reload after cooldown expiry") await triggerReload() - deferredReloadTask = nil } } @@ -222,7 +221,6 @@ final class ConfigReloadCoordinator { "āš ļø [Reload] Runtime did not become ready before deferred reload grace expired" ) } - deferredReloadTask = nil } } @@ -234,13 +232,13 @@ final class ConfigReloadCoordinator { for _ in 0 ..< transitionRetryMaximumPolls { guard !Task.isCancelled else { return false } - if await retryReloadIfRuntimeReady() { return true } + if await retryReloadIfRuntimeReady() == true { return true } await transitionRetryWait() } guard !Task.isCancelled else { return false } - if await retryReloadIfRuntimeReady() { return true } + if await retryReloadIfRuntimeReady() == true { return true } let message = "Kanata did not become ready after restarting; config reload was not applied" NotificationCenter.default.post( @@ -251,15 +249,15 @@ final class ConfigReloadCoordinator { return false } - private func retryReloadIfRuntimeReady() async -> Bool { - guard !isRuntimeTransitioning() else { return false } + private func retryReloadIfRuntimeReady() async -> Bool? { + guard !isRuntimeTransitioning() else { return nil } let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) - guard health.isHealthy else { return false } + guard health.isHealthy else { return nil } AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") - await triggerReload() - return true + let result = await triggerConfigReload(notifyOnFailure: false) + return result.isSuccess } /// TCP-based config reload (no authentication required - see ADR-013) diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index c99268ad4..f48677121 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -43,6 +43,7 @@ private final class MutableRuntimeTransition { var isTransitioning: Bool var waitCount = 0 var reloadCount = 0 + var tcpReloadCount = 0 init(isTransitioning: Bool) { self.isTransitioning = isTransitioning @@ -93,6 +94,7 @@ struct ConfigReloadCoordinatorTests { runtimeTransitioning: Bool = false, runtimeTransitionState: MutableRuntimeTransition? = nil, tcpReloadResult: TCPReloadResult? = nil, + tcpReloadOverride customTCPReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = nil, transitionRetryMaximumPolls: Int = Int((RuntimeStartupTiming.uiGracePeriod / 0.5).rounded(.up)), transitionRetryWait: @escaping @MainActor @Sendable () async -> Void = { await Task.yield() @@ -114,7 +116,9 @@ struct ConfigReloadCoordinatorTests { let processLifecycle = ProcessLifecycleManager() let transitionState = runtimeTransitionState ?? MutableRuntimeTransition(isTransitioning: runtimeTransitioning) - let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = if let tcpReloadResult { + let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = if let customTCPReloadOverride { + customTCPReloadOverride + } else if let tcpReloadResult { { tcpReloadResult } } else { nil @@ -261,6 +265,32 @@ struct ConfigReloadCoordinatorTests { #expect(transition.reloadCount == 1) } + @Test("transition retry checks reload outcome and retries a transient failure") + func transitionRetryChecksReloadOutcome() async { + let transition = MutableRuntimeTransition(isTransitioning: false) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadOverride: { + transition.tcpReloadCount += 1 + if transition.tcpReloadCount == 1 { + return .networkError("Connection closed") + } + return .success(response: "ok") + }, + transitionRetryMaximumPolls: 2, + transitionRetryWait: { transition.waitCount += 1 } + ) + coordinator.onReloadSuccess = { transition.reloadCount += 1 } + + let didRetry = await coordinator.retryAfterRuntimeTransition() + + #expect(didRetry == true) + #expect(transition.tcpReloadCount == 2) + #expect(transition.waitCount == 1) + #expect(transition.reloadCount == 1) + } + @Test("transition retry expires without reloading when runtime never settles") func transitionRetryExpires() async { let transition = MutableRuntimeTransition(isTransitioning: true) From 14a66d565f3f52168cc6f6800cb0b6df31548c2a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 23:17:01 -0700 Subject: [PATCH 07/10] Keep transition retries bounded --- .../Managers/ConfigReloadCoordinator.swift | 18 ++++++++++++++---- .../ConfigReloadCoordinatorTests.swift | 6 +++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 898805fad..1b3e2863b 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -56,7 +56,10 @@ final class ConfigReloadCoordinator { /// Main reload method using TCP protocol. /// Checks service health, permission gates, and delegates to TCP reload. - func triggerConfigReload(notifyOnFailure: Bool = true) async -> ReloadResult { + func triggerConfigReload( + notifyOnFailure: Bool = true, + scheduleRetryOnPending: Bool = true + ) async -> ReloadResult { // Use the manager refresh path instead of the unbounded synchronous // currentManagementState cache; the underlying SMAppService provider // still coalesces IPC with a short TTL. @@ -129,7 +132,9 @@ final class ConfigReloadCoordinator { AppLogger.shared.log( "ā³ [Reload] Runtime transition closed the TCP reload connection; deferring until Kanata settles" ) - scheduleDeferredReloadAfterRuntimeTransition() + if scheduleRetryOnPending { + scheduleDeferredReloadAfterRuntimeTransition() + } return ReloadResult( success: false, response: tcpResult.response, @@ -146,7 +151,9 @@ final class ConfigReloadCoordinator { // expires. Real failures (validation, network, etc.) still // notify as before. if isCooldownBlockMessage(errorMessage) { - scheduleDeferredReloadAfterCooldown() + if scheduleRetryOnPending { + scheduleDeferredReloadAfterCooldown() + } } else if notifyOnFailure { NotificationCenter.default.post( name: .configReloadFailed, @@ -256,7 +263,10 @@ final class ConfigReloadCoordinator { guard health.isHealthy else { return nil } AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") - let result = await triggerConfigReload(notifyOnFailure: false) + let result = await triggerConfigReload( + notifyOnFailure: false, + scheduleRetryOnPending: false + ) return result.isSuccess } diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index f48677121..15ec291dd 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -274,12 +274,16 @@ struct ConfigReloadCoordinatorTests { tcpReloadOverride: { transition.tcpReloadCount += 1 if transition.tcpReloadCount == 1 { + transition.isTransitioning = true return .networkError("Connection closed") } return .success(response: "ok") }, transitionRetryMaximumPolls: 2, - transitionRetryWait: { transition.waitCount += 1 } + transitionRetryWait: { + transition.waitCount += 1 + transition.isTransitioning = false + } ) coordinator.onReloadSuccess = { transition.reloadCount += 1 } From 773c5fb5c607bb83079368dcafc78c27f36dd80e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 29 Jul 2026 06:06:43 -0700 Subject: [PATCH 08/10] Surface deferred reload rejections --- .../Managers/ConfigReloadCoordinator.swift | 57 +++++++++++++++---- .../ConfigReloadCoordinatorTests.swift | 27 +++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 1b3e2863b..891e40d9d 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -202,6 +202,12 @@ final class ConfigReloadCoordinator { /// coalesce into one final reload. private var deferredReloadTask: Task? + private enum TransitionRetryAttempt { + case applied + case pending + case rejected + } + private func scheduleDeferredReloadAfterCooldown() { deferredReloadTask?.cancel() deferredReloadTask = Task { [weak self] in @@ -222,12 +228,7 @@ final class ConfigReloadCoordinator { deferredReloadTask?.cancel() deferredReloadTask = Task { [weak self] in guard let self else { return } - let didRetry = await retryAfterRuntimeTransition() - if !didRetry, !Task.isCancelled { - AppLogger.shared.warn( - "āš ļø [Reload] Runtime did not become ready before deferred reload grace expired" - ) - } + await retryAfterRuntimeTransition() } } @@ -239,15 +240,30 @@ final class ConfigReloadCoordinator { for _ in 0 ..< transitionRetryMaximumPolls { guard !Task.isCancelled else { return false } - if await retryReloadIfRuntimeReady() == true { return true } + switch await retryReloadIfRuntimeReady() { + case .applied: + return true + case .rejected: + return false + case .pending: + break + } await transitionRetryWait() } guard !Task.isCancelled else { return false } - if await retryReloadIfRuntimeReady() == true { return true } + switch await retryReloadIfRuntimeReady() { + case .applied: + return true + case .rejected: + return false + case .pending: + break + } let message = "Kanata did not become ready after restarting; config reload was not applied" + AppLogger.shared.warn("āš ļø [Reload] \(message)") NotificationCenter.default.post( name: .configReloadFailed, object: self, @@ -256,18 +272,35 @@ final class ConfigReloadCoordinator { return false } - private func retryReloadIfRuntimeReady() async -> Bool? { - guard !isRuntimeTransitioning() else { return nil } + private func retryReloadIfRuntimeReady() async -> TransitionRetryAttempt { + guard !isRuntimeTransitioning() else { return .pending } let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) - guard health.isHealthy else { return nil } + guard health.isHealthy else { return .pending } AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") let result = await triggerConfigReload( notifyOnFailure: false, scheduleRetryOnPending: false ) - return result.isSuccess + switch result.disposition { + case .applied: + return .applied + case .pending, .failed: + return .pending + case .rejected: + let message = result.errorMessage ?? "Config reload was rejected" + AppLogger.shared.warn("āš ļø [Reload] Deferred config reload rejected: \(message)") + NotificationCenter.default.post( + name: .configReloadFailed, + object: self, + userInfo: [ + "message": message, + "response": result.response ?? "" + ] + ) + return .rejected + } } /// TCP-based config reload (no authentication required - see ADR-013) diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 15ec291dd..e0b083fbc 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -295,6 +295,33 @@ struct ConfigReloadCoordinatorTests { #expect(transition.reloadCount == 1) } + @Test("transition retry surfaces a permanent reload rejection immediately") + func transitionRetrySurfacesPermanentRejection() async { + let transition = MutableRuntimeTransition(isTransitioning: false) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadResult: .failure(error: "validation error", response: "bad config"), + transitionRetryMaximumPolls: 2, + transitionRetryWait: { transition.waitCount += 1 } + ) + let message = NotificationMessage() + let observer = NotificationCenter.default.addObserver( + forName: .configReloadFailed, + object: coordinator, + queue: nil + ) { notification in + message.set(notification.userInfo?["message"] as? String) + } + defer { NotificationCenter.default.removeObserver(observer) } + + let didRetry = await coordinator.retryAfterRuntimeTransition() + + #expect(didRetry == false) + #expect(transition.waitCount == 0) + #expect(message.value == "validation error") + } + @Test("transition retry expires without reloading when runtime never settles") func transitionRetryExpires() async { let transition = MutableRuntimeTransition(isTransitioning: true) From 685e6810d2bce24adc13e1dfc7709f4517a0978e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 29 Jul 2026 06:11:31 -0700 Subject: [PATCH 09/10] Keep expected retry failures quiet in tests --- Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 891e40d9d..718c94ad0 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -263,7 +263,7 @@ final class ConfigReloadCoordinator { } let message = "Kanata did not become ready after restarting; config reload was not applied" - AppLogger.shared.warn("āš ļø [Reload] \(message)") + AppLogger.shared.warnUnlessQuietTest("āš ļø [Reload] \(message)") NotificationCenter.default.post( name: .configReloadFailed, object: self, @@ -290,7 +290,7 @@ final class ConfigReloadCoordinator { return .pending case .rejected: let message = result.errorMessage ?? "Config reload was rejected" - AppLogger.shared.warn("āš ļø [Reload] Deferred config reload rejected: \(message)") + AppLogger.shared.warnUnlessQuietTest("āš ļø [Reload] Deferred config reload rejected: \(message)") NotificationCenter.default.post( name: .configReloadFailed, object: self, From 6987232dd8adb730b58abe2bfd1eb11e32f44462 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 29 Jul 2026 06:17:04 -0700 Subject: [PATCH 10/10] Close restart transition reload race --- .../Managers/ConfigReloadCoordinator.swift | 21 +++++++++++++-- .../LiveKeyboardOverlayView+Header.swift | 12 +++++++-- .../OverlayDragHeader+LayerPicker.swift | 8 ++++-- .../ConfigReloadCoordinatorTests.swift | 26 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 718c94ad0..1262a0dd0 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -110,6 +110,7 @@ final class ConfigReloadCoordinator { // Try TCP reload AppLogger.shared.debug("šŸ“” [Reload] Attempting TCP reload") + let runtimeWasTransitioning = isRuntimeTransitioning() let tcpResult = await triggerTCPReload() if tcpResult.isSuccess { // Successful reload -> clear stale diagnostics @@ -128,7 +129,9 @@ final class ConfigReloadCoordinator { "šŸ“” [Reload] TCP reload failed: \(tcpResult.errorMessage ?? "Unknown error")" ) let errorMessage = tcpResult.errorMessage ?? "TCP reload failed" - if case .networkError = tcpResult, isRuntimeTransitioning() { + if case .networkError = tcpResult, + runtimeWasTransitioning || isRuntimeTransitioning() + { AppLogger.shared.log( "ā³ [Reload] Runtime transition closed the TCP reload connection; deferring until Kanata settles" ) @@ -201,6 +204,7 @@ final class ConfigReloadCoordinator { /// retries share one task so rapid edits and overlapping recovery states /// coalesce into one final reload. private var deferredReloadTask: Task? + private var deferredReloadGeneration: UInt = 0 private enum TransitionRetryAttempt { case applied @@ -210,10 +214,15 @@ final class ConfigReloadCoordinator { private func scheduleDeferredReloadAfterCooldown() { deferredReloadTask?.cancel() + deferredReloadGeneration &+= 1 + let generation = deferredReloadGeneration deferredReloadTask = Task { [weak self] in + guard let self else { return } + defer { clearDeferredReloadTask(ifGeneration: generation) } + // 3s cooldown + a little slop so the safety check passes. try? await Task.sleep(nanoseconds: 3_200_000_000) - guard let self, !Task.isCancelled else { return } + guard !Task.isCancelled else { return } AppLogger.shared.log("šŸ” [Reload] Firing deferred reload after cooldown expiry") await triggerReload() } @@ -226,12 +235,20 @@ final class ConfigReloadCoordinator { guard automaticDeferredRetriesEnabled else { return } deferredReloadTask?.cancel() + deferredReloadGeneration &+= 1 + let generation = deferredReloadGeneration deferredReloadTask = Task { [weak self] in guard let self else { return } + defer { clearDeferredReloadTask(ifGeneration: generation) } await retryAfterRuntimeTransition() } } + private func clearDeferredReloadTask(ifGeneration generation: UInt) { + guard deferredReloadGeneration == generation else { return } + deferredReloadTask = nil + } + /// Poll the intentional transition and retry once the replacement runtime is healthy. /// The injected poll bound and wait operation keep the production grace period bounded /// while allowing deterministic tests without sleeping. diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift index 1f2d073c9..07eb212cd 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView+Header.swift @@ -449,9 +449,17 @@ struct OverlayDragHeader: View { case .hidden: EmptyView() case .starting: - kanataTransitionPill("Starting…", indicatorCornerRadius: indicatorCornerRadius) + kanataTransitionPill( + "Starting…", + helpText: "Kanata is starting", + indicatorCornerRadius: indicatorCornerRadius + ) case .restarting: - kanataTransitionPill("Restarting…", indicatorCornerRadius: indicatorCornerRadius) + kanataTransitionPill( + "Restarting…", + helpText: "Kanata is restarting", + indicatorCornerRadius: indicatorCornerRadius + ) case .disconnected: kanataDisconnectedPill(indicatorCornerRadius: indicatorCornerRadius) } diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift index 614ec7bd1..fda890f95 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift @@ -265,7 +265,11 @@ extension OverlayDragHeader { .accessibilityLabel("Not connected to Kanata TCP server") } - func kanataTransitionPill(_ label: String, indicatorCornerRadius: CGFloat) -> some View { + func kanataTransitionPill( + _ label: String, + helpText: String, + indicatorCornerRadius: CGFloat + ) -> some View { HStack(spacing: 4) { Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") .font(.caption2.weight(.medium)) @@ -279,7 +283,7 @@ extension OverlayDragHeader { RoundedRectangle(cornerRadius: indicatorCornerRadius) .fill(Color.secondary.opacity(isDark ? 0.12 : 0.1)) ) - .help(label == "Restarting…" ? "Kanata is restarting" : "Kanata is starting") + .help(helpText) .accessibilityIdentifier("overlay-kanata-transition-indicator") .accessibilityLabel(label) } diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index e0b083fbc..5dff16ed9 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -243,6 +243,32 @@ struct ConfigReloadCoordinatorTests { #expect(received.value == false) } + @Test("network failure stays pending when transition finishes during TCP request") + func transitionEndingDuringTCPFailureStaysPending() async { + let transition = MutableRuntimeTransition(isTransitioning: true) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadOverride: { + transition.isTransitioning = false + return .networkError("Connection failed: Connection closed") + }, + automaticDeferredRetriesEnabled: false + ) + let received = NotificationFlag() + let observer = NotificationCenter.default.addObserver( + forName: .configReloadFailed, + object: coordinator, + queue: nil + ) { _ in received.set() } + defer { NotificationCenter.default.removeObserver(observer) } + + let result = await coordinator.triggerConfigReload() + + #expect(result.disposition == .pending) + #expect(received.value == false) + } + @Test("transition retry waits for readiness and reloads once") func transitionRetryWaitsForReadiness() async { let transition = MutableRuntimeTransition(isTransitioning: true)