Architecture

Runtime & Service Lifecycle

How Kanata starts, stops, recovers, and stays healthy.

Orchestrate. Lifecycle. Monitor.

The runtime layer manages the Kanata process after installation. It starts the daemon, watches its health, reloads config over TCP, and recovers from crashes.

Orchestrate

RuntimeCoordinator wires sub-coordinators and delegates all work.

Lifecycle

ServiceLifecycleCoordinator is the ONLY start/stop/restart entry point.

Monitor

ServiceHealthChecker polls launchctl status + TCP probe.

The Complete Picture

RuntimeCoordinator sits at the top and delegates to focused coordinators. Health flows up from launchctl and TCP probes.
ORCHESTRATOR RuntimeCoordinator LIFECYCLE ServiceLifecycleCoordinator CONFIG RELOAD ConfigReloadCoordinator RECOVERY RecoveryCoordinator DAEMON MANAGEMENT KanataDaemonService HEALTH MONITORING ServiceHealthChecker launchctl status PID + exit code TCP probe port 6174 healthy / unhealthy composite result Blue arrows: delegation. Gray arrows: data flow. Health combines launchctl + TCP.
Core invariant. The ServiceHealthChecker is the single source of truth for whether Kanata is running. It combines launchctl status with TCP probe results into a KanataServiceRuntimeSnapshot. No other code should check process state directly.

Six Focused Coordinators

Each owns a single responsibility. RuntimeCoordinator wires them together but contains no business logic itself.
RuntimeCoordinator Orchestrator
Thin wiring layer. Initializes all sub-coordinators, sets up callbacks, and delegates every public method. Not an ObservableObject — UI state lives in KanataViewModel, which reads snapshots via getCurrentUIState(). Annotated @MainActor.
ServiceLifecycleCoordinator Lifecycle
The only entry point for start, stop, and restart. Registers the LaunchDaemon via KanataDaemonManager.register(), kills orphaned processes before each start, kickstarts if registered but not running. Caches SMAppService pending status with a 5-second TTL to avoid blocking IPC in polling loops.
func startKanata(reason: String) async -> Bool
func stopKanata(reason: String) async -> Bool
func restartKanata(reason: String) async -> Bool
func currentRuntimeStatus() async -> RuntimeStatus
ServiceHealthChecker Singleton
The sole authority on whether Kanata is running. Combines launchctl exit codes with TCP probes on port 6174 to produce a KanataServiceRuntimeSnapshot. Short-lived cache (2s TTL) deduplicates parallel health checks. Thread-safe via OSAllocatedUnfairLock.
public struct KanataServiceRuntimeSnapshot {
    let managementState: WizardServiceManagementState
    let isRunning: Bool         // launchctl says process exists
    let isResponding: Bool      // TCP probe got a reply
    let inputCaptureReady: Bool // kanata capturing keys
}
KanataDaemonService Daemon
Manages the com.keypath.kanata LaunchDaemon via SMAppService. Handles stop/unregister, polls launchd-backed status, and debounces transient "enabled but no PID" samples to avoid false failure reports. Factory-based SMAppService creation provides a test seam.
ConfigReloadCoordinator Config
Sends config reload commands over TCP after rule changes. Checks service health and permission gates before reloading. Uses cached KanataDaemonManager state to avoid synchronous IPC in hot paths. Coordinates with ReloadSafetyMonitor and ProcessLifecycleManager.
func triggerConfigReload() async -> ReloadResult
RecoveryCoordinator Recovery
Coordinates keyboard and VirtualHID recovery. Executes a 5-step sequence: kill all Kanata processes, wait for keyboard release, restart Karabiner daemon, wait before retry, restart Kanata. Handlers are injected after RuntimeCoordinator initialization.

Runtime Events

Every runtime interaction follows a predictable path through the coordinator stack.
Start Kanata
ServiceLifecycleCoordinator.startKanata() → kill orphans → KanataDaemonManager.register() → kickstart if needed → verify running
Stop Kanata
ServiceLifecycleCoordinator.stopKanata()AppContextService.stop()KanataDaemonService.stopIfRunning()
Config reload
ConfigReloadCoordinator.triggerConfigReload() → check health → TCP command to port 6174 → ReloadResult
Health check
ServiceHealthChecker.checkKanataServiceHealth() → launchctl status + TCP probe → KanataServiceRuntimeSnapshot
Crash recovery
RecoveryCoordinator.attemptKeyboardRecovery() → kill → wait → restart VirtualHID → wait → restart Kanata
TCP reconnect
EngineClient reconnects on next reload attempt. Health checker marks isResponding = false until TCP probe succeeds.

Design Rules

Five rules that keep the runtime layer predictable and safe.
1
All lifecycle goes through ServiceLifecycleCoordinator
Don't start, stop, or restart Kanata from anywhere else. Not from the view model, not from recovery code, not from config reload. One entry point.
2
Don't roll your own process checks
No pgrep, no direct launchctl calls to check status. Use ServiceHealthChecker.checkKanataServiceHealth(). It combines PID + TCP and caches results.
3
SMAppService.status is registration, not liveness
Treat SMAppService.status == .enabled as "the daemon is registered" not "the daemon is running." Only the health checker knows if the process is alive.
4
Cache SMAppService calls in hot paths
SMAppService.status is synchronous IPC that can block 10–30+ seconds under load. Cache within a validation cycle. ServiceLifecycleCoordinator uses a 5s TTL cache for pending checks.
5
Installer success requires verified runtime readiness
Don't return success from an installer action until the service is both running (launchctl) and responding (TCP). A registered-but-dead daemon is a failure.

Cross-References

How the runtime layer connects to the rest of KeyPath.