Skip to content
Merged
169 changes: 159 additions & 10 deletions Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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 ?? "",
Expand All @@ -108,18 +129,38 @@ 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/
// error sound — the next reload attempt will fire when cooldown
// 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 ?? ""
Expand Down Expand Up @@ -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<Void, Never>?
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")
Expand Down
5 changes: 4 additions & 1 deletion Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,10 @@
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)
Expand Down Expand Up @@ -1088,7 +1091,7 @@
}

/// Returns a snapshot of current UI state for ViewModel synchronization
/// This method allows KanataViewModel to read UI state without @Published properties

Check warning on line 1094 in Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift

View workflow job for this annotation

GitHub Actions / code-quality

Use @observable macro instead of @published. Properties on @observable classes are automatically tracked. (no_published)
func getCurrentUIState() -> KanataUIState {
buildUIState()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading