diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 775c0579f..1262a0dd0 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -15,6 +15,11 @@ 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)? + private let transitionRetryMaximumPolls: Int + private let transitionRetryWait: @MainActor @Sendable () async -> Void + private let automaticDeferredRetriesEnabled: Bool // MARK: - Callbacks (set by RuntimeCoordinator after init) @@ -27,19 +32,34 @@ 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, + 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 self.healthStatusProvider = healthStatusProvider self.processLifecycleManager = processLifecycleManager + self.isRuntimeTransitioning = isRuntimeTransitioning + self.tcpReloadOverride = tcpReloadOverride + self.transitionRetryMaximumPolls = transitionRetryMaximumPolls + self.transitionRetryWait = transitionRetryWait + self.automaticDeferredRetriesEnabled = automaticDeferredRetriesEnabled } // MARK: - Reload Operations /// 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, + 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. @@ -90,12 +110,13 @@ 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 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 +129,24 @@ final class ConfigReloadCoordinator { "šŸ“” [Reload] TCP reload failed: \(tcpResult.errorMessage ?? "Unknown error")" ) let errorMessage = tcpResult.errorMessage ?? "TCP reload failed" + if case .networkError = tcpResult, + runtimeWasTransitioning || isRuntimeTransitioning() + { + AppLogger.shared.log( + "ā³ [Reload] Runtime transition closed the TCP reload connection; deferring until Kanata settles" + ) + if scheduleRetryOnPending { + 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/ @@ -115,11 +154,13 @@ final class ConfigReloadCoordinator { // expires. Real failures (validation, network, etc.) still // notify as before. if isCooldownBlockMessage(errorMessage) { - scheduleDeferredReloadAfterCooldown() - } else { + if scheduleRetryOnPending { + scheduleDeferredReloadAfterCooldown() + } + } else if notifyOnFailure { NotificationCenter.default.post( name: .configReloadFailed, - object: nil, + object: self, userInfo: [ "message": errorMessage, "response": tcpResult.response ?? "" @@ -159,24 +200,132 @@ 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 deferredReloadGeneration: UInt = 0 + + private enum TransitionRetryAttempt { + case applied + case pending + case rejected + } 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() - deferredReloadTask = nil + } + } + + /// 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() { + 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. + @discardableResult + func retryAfterRuntimeTransition() async -> Bool { + for _ in 0 ..< transitionRetryMaximumPolls { + guard !Task.isCancelled else { return false } + + switch await retryReloadIfRuntimeReady() { + case .applied: + return true + case .rejected: + return false + case .pending: + break + } + + await transitionRetryWait() + } + + guard !Task.isCancelled else { return false } + 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.warnUnlessQuietTest("āš ļø [Reload] \(message)") + NotificationCenter.default.post( + name: .configReloadFailed, + object: self, + userInfo: ["message": message, "response": ""] + ) + return false + } + + private func retryReloadIfRuntimeReady() async -> TransitionRetryAttempt { + guard !isRuntimeTransitioning() else { return .pending } + + let health = await healthStatusProvider(PreferencesService.shared.tcpServerPort) + guard health.isHealthy else { return .pending } + + AppLogger.shared.log("šŸ” [Reload] Retrying config reload after runtime transition") + let result = await triggerConfigReload( + notifyOnFailure: false, + scheduleRetryOnPending: false + ) + switch result.disposition { + case .applied: + return .applied + case .pending, .failed: + return .pending + case .rejected: + let message = result.errorMessage ?? "Config reload was rejected" + AppLogger.shared.warnUnlessQuietTest("āš ļø [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) 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..07eb212cd 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,22 @@ struct OverlayDragHeader: View { ) } else { HStack(spacing: 6) { - if !isKanataConnected, !isTcpGracePeriodActive { + switch tcpStatusPresentation { + case .hidden: + EmptyView() + case .starting: + kanataTransitionPill( + "Starting…", + helpText: "Kanata is starting", + indicatorCornerRadius: indicatorCornerRadius + ) + case .restarting: + 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 b7b1a264b..fda890f95 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayDragHeader+LayerPicker.swift @@ -264,4 +264,27 @@ extension OverlayDragHeader { .accessibilityIdentifier("overlay-kanata-disconnected-indicator") .accessibilityLabel("Not connected to Kanata TCP server") } + + 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)) + 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(helpText) + .accessibilityIdentifier("overlay-kanata-transition-indicator") + .accessibilityLabel(label) + } } diff --git a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift index 379fb78d0..5dff16ed9 100644 --- a/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift +++ b/Tests/KeyPathTests/Services/ConfigReloadCoordinatorTests.swift @@ -38,6 +38,18 @@ private final class MutableHealthStatus { } } +@MainActor +private final class MutableRuntimeTransition { + var isTransitioning: Bool + var waitCount = 0 + var reloadCount = 0 + var tcpReloadCount = 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() @@ -78,7 +90,16 @@ 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, + 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() + }, + automaticDeferredRetriesEnabled: Bool = true ) -> ( coordinator: ConfigReloadCoordinator, engine: MockEngineClient, @@ -93,12 +114,26 @@ struct ConfigReloadCoordinatorTests { let safetyMonitor = ReloadSafetyMonitor() let processLifecycle = ProcessLifecycleManager() + let transitionState = runtimeTransitionState + ?? MutableRuntimeTransition(isTransitioning: runtimeTransitioning) + let tcpReloadOverride: (@MainActor @Sendable () async -> TCPReloadResult)? = if let customTCPReloadOverride { + customTCPReloadOverride + } else if let tcpReloadResult { + { tcpReloadResult } + } else { + nil + } let coordinator = ConfigReloadCoordinator( engineClient: engine, reloadSafetyMonitor: safetyMonitor, healthStatusProvider: { _ in healthStatus.value }, - processLifecycleManager: processLifecycle + processLifecycleManager: processLifecycle, + isRuntimeTransitioning: { transitionState.isTransitioning }, + tcpReloadOverride: tcpReloadOverride, + transitionRetryMaximumPolls: transitionRetryMaximumPolls, + transitionRetryWait: transitionRetryWait, + automaticDeferredRetriesEnabled: automaticDeferredRetriesEnabled ) return (coordinator, engine, healthStatus) @@ -162,7 +197,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 +218,185 @@ 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"), + 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.isSuccess == false) + #expect(result.disposition == .pending) + #expect(result.errorMessage?.contains("restarting") == true) + #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) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadResult: .success(response: "ok"), + transitionRetryMaximumPolls: 1, + 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 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 { + transition.isTransitioning = true + return .networkError("Connection closed") + } + return .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.tcpReloadCount == 2) + #expect(transition.waitCount == 1) + #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) + let (coordinator, _, _) = Self.makeSUT( + healthy: true, + runtimeTransitionState: transition, + tcpReloadResult: .success(response: "ok"), + transitionRetryMaximumPolls: 2, + 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") + 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 +408,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.