# Lifecycle Usage Guide Practical guide for using the lifecycle system. For architecture details, see [Lifecycle Overview](./lifecycle-overview.md). For deciding whether to use lifecycle at all, see [Decision Guide](./lifecycle-decision-guide.md). ## Quick Start ```typescript // 1. Define a service with decorators import { BaseService, Injectable, ServicePhase, DependsOn, Phase } from '@main/core/lifecycle' @Injectable('DbService') @ServicePhase(Phase.WhenReady) class DbService extends BaseService { protected async onInit() { await this.connectToDatabase() } protected async onDestroy() { await this.disconnect() } } @Injectable('PreferenceService') @DependsOn(['DbService']) class PreferenceService extends BaseService { protected async onInit() { // DbService is guaranteed to be ready await this.loadPreferences() } } // 2. Register in serviceRegistry.ts and bootstrap via Application // See: docs/references/lifecycle/application-overview.md import { application } from '@application' await application.bootstrap() // 3. Access service instance const dbService = application.get('DbService') ``` ## Decorators | Decorator | Description | Default | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | | `@Injectable('Name')` | Mark class as injectable singleton service. Name is **required** because bundlers mangle class names. Must match the key in `serviceRegistry.ts`. | Required | | `@ServicePhase(Phase.X)` | Set bootstrap phase | `Phase.WhenReady` | | `@DependsOn([...])` | Declare dependencies by service name | `[]` | | `@Priority(n)` | Initialization priority within layer (lower = earlier) | `100` | | `@ErrorHandling(strategy)` | Error handling strategy | `'graceful'` | | `@Conditional(...)` | Activate service only when all conditions are met (see [Conditional Activation](#conditional-activation)) | Always active | **Note:** All services are singletons. Attempting to instantiate a service class directly (via `new`) after it has been created will throw an error. Use `application.get('ServiceName')` to access service instances (see [Application Overview](./application-overview.md)). ## Error Handling Strategies | Strategy | Behavior | | -------------------- | ------------------------------------------------------ | | `graceful` (default) | Log the error and continue bootstrap. | | `fail-fast` | Throw `ServiceInitError`, abort startup. | | `custom` | Delegate to `lifecycle:service:error` event listeners. | ```typescript @Injectable('DbService') @ErrorHandling('fail-fast') class DbService extends BaseService { protected async onInit() { // If this fails, the entire bootstrap is aborted await this.connect() } } ``` ## Conditional Activation Use `@Conditional` to declare activation conditions for a service. Services whose conditions are not met are silently skipped during registration. ```typescript // Platform-specific: macOS only @Injectable('AppMenuService') @Conditional(onPlatform('darwin')) class AppMenuService extends BaseService { ... } // Multiple conditions (AND logic): Windows + Intel CPU @Injectable('OvmsService') @Conditional(onPlatform('win32'), onCpuVendor('intel')) class OvmsService extends BaseService { ... } // Environment variable driven @Injectable('DebugService') @Conditional(onEnvVar('DEBUG', 'true')) class DebugService extends BaseService { ... } // Custom function @Injectable('GpuService') @Conditional(when((ctx) => checkNvidiaGpu(), 'requires NVIDIA GPU')) class GpuService extends BaseService { ... } // Complex boolean: OR(AND(x1, x2), AND(y1, y2)) @Conditional(anyOf(allOf(onPlatform('win32'), onArch('x64')), allOf(onPlatform('linux'), onArch('arm64')))) ``` ### Built-in Conditions | Factory | Description | Example | |---------|-------------|---------| | `onPlatform(...platforms)` | Match platform | `onPlatform('darwin')` | | `onArch(...archs)` | Match architecture | `onArch('x64', 'arm64')` | | `onCpuVendor(vendor)` | Match CPU vendor (case-insensitive substring of CPU model) | `onCpuVendor('intel')` | | `onEnvVar(name, value?)` | Match environment variable | `onEnvVar('DEBUG', 'true')` | | `when(fn, desc)` | Custom predicate function | `when((ctx) => check(), 'desc')` | | `not(cond)` | Negate a condition | `not(onPlatform('linux'))` | | `anyOf(...conds)` | OR: any condition matches | `anyOf(onPlatform('darwin'), onPlatform('win32'))` | | `allOf(...conds)` | AND: all conditions match | `allOf(onPlatform('win32'), onCpuVendor('intel'))` | **Transitive exclusion**: If ServiceA is excluded and ServiceB depends on ServiceA, ServiceB is automatically excluded too. ### Accessing Conditional Services Conditional services must be accessed via `getOptional()`, not `get()`. The two methods are mutually exclusive: | Method | Unconditional service | Conditional service (active) | Conditional service (excluded) | |--------|----------------------|------------------------------|-------------------------------| | `get()` | ✅ Returns `T` | ❌ Throws | ❌ Throws | | `getOptional()` | ❌ Throws | ✅ Returns `T` | ✅ Returns `undefined` | ```typescript // Unconditional service — always use get() const db = application.get('DbService') // Conditional service — always use getOptional() const ovms = application.getOptional('OvmsService') ovms?.start() ``` Access conditional services in `onAllReady()` or later (e.g., IPC handlers) to ensure all services are initialized. ## IPC Handler Management When a lifecycle service registers IPC handlers, it should use BaseService's built-in tracking instead of calling `ipcMain` directly. This ensures handlers are automatically cleaned up when the service stops, restarts, or is destroyed — eliminating the need for manual `unregisterIpcHandlers()` methods. ### API | Method | Wraps | Auto-cleanup via | Returns | |--------|-------|------------------|---------| | `this.ipcHandle(channel, listener)` | `ipcMain.handle()` | `ipcMain.removeHandler()` | `Disposable` | | `this.ipcOn(channel, listener)` | `ipcMain.on()` | `ipcMain.removeListener()` | `Disposable` | | `this.registerInterval(callback, intervalMs)` | `setInterval()` + `unref()` | `clearInterval()` | `Disposable` | > `ipcOnce()` is intentionally not provided — once-listeners fire once and auto-remove, so they do not need lifecycle tracking. > `registerTimeout()` is intentionally not provided — single-shot timers fire once and auto-clear, so they do not need lifecycle tracking. ### Convention Extract all IPC registrations into a **`private registerIpcHandlers()`** method and call it from `onInit()` (or `onReady()`). This keeps the lifecycle hook focused on orchestration and makes the IPC surface easy to locate and review. ```typescript @Injectable('MainWindowService') @ServicePhase(Phase.WhenReady) export class MainWindowService extends BaseService { protected async onInit() { this.registerIpcHandlers() } private registerIpcHandlers() { this.ipcHandle(IpcChannel.Windows_Minimize, () => this.mainWindow!.minimize()) this.ipcHandle(IpcChannel.Windows_Maximize, () => this.mainWindow!.maximize()) } protected async onStop() { // Only service-specific cleanup here // IPC handlers are removed automatically after onStop() returns } } ``` > **Naming**: Always use `registerIpcHandlers` (plural). Do not use `setupIpcHandlers`, `registerIpcHandler` (singular), or other variants. ### Cleanup Guarantees 1. **On stop**: All tracked handlers are removed **after** `onStop()` returns, so the service can still use IPC during its own shutdown if needed. 2. **On stop failure**: If `onStop()` throws, IPC cleanup still executes (via try/finally). 3. **On destroy**: Safety-net cleanup runs in `_doDestroy()` for edge cases where a service is destroyed without being stopped first (e.g., init failure). 4. **On restart**: Disposables array is reset after cleanup, so `onInit()` can re-register handlers cleanly. 5. **Backward compatible**: Safe to mix with manual `ipcMain.removeHandler()` in `onStop()` — double-remove is a no-op. 6. **Unified cleanup**: IPC handlers and other disposables (event subscriptions, cleanup functions) are tracked through a single `registerDisposable()` mechanism and cleaned up together. ### Phase Behavior `this.ipcHandle()` and `this.ipcOn()` work in any phase (`BeforeReady`, `WhenReady`, `Background`). The helpers are thin wrappers around `ipcMain` — the phase system controls *when* `onInit()` runs (and thus when handlers get registered), not whether the registration API is available. ## Recurring Timers `this.registerInterval(callback, intervalMs)` for periodic work scoped to the service lifecycle (GC, polls, heartbeats). Started immediately, `unref`'d, exception-isolated (every tick's throw is caught and logged independently, so one failure cannot stop the loop), auto-cleared on `onStop()`. Returns a `Disposable`. ```typescript private gcInterval: Disposable | null = null protected async onStop() { this.gcInterval = null // auto-disposed; null'd so a restart re-arms it } private startGc() { if (this.gcInterval) return this.gcInterval = this.registerInterval(() => this.gc(), 10 * 60 * 1000) } ``` If the field is never read (e.g., fire-and-forget from `onInit`), drop it entirely. **Do not use for**: activation-scoped timers (manage manually in `onActivate`/`onDeactivate`), one-shot delays (use `setTimeout`), connection-scoped heartbeats (manage in the connection). ## onAllReady Business Work Pattern `onAllReady` is invoked once after every service across every phase has finished `onInit` / `onReady`, and is a [post-bootstrap supplement](./lifecycle-overview.md#onallready-system-wide-readiness) — `LifecycleManager.allReady()` does **not** await it. Two consequences shape how the hook should be used: 1. **`_allReadyCalled` is at-most-once.** Each service instance's `onAllReady` fires exactly once. `restart()` does not re-trigger it. Code that needs to run on every (re)start belongs in `onInit` / `onReady`, not `onAllReady`. 2. **Hook return value is not observed by the framework.** If you `await` long-running business work inside `onAllReady`, the framework neither waits nor knows. Bootstrap proceeds immediately. The hook is essentially "fire-and-forget" from the framework's perspective. If a service needs deferred work that should run *after* the system is ready (a quiet window, a one-shot recovery sweep, etc.), the supplement hook is the right place to **schedule** it, not to **run** it: ```typescript @Injectable('DeferredWorkExampleService') class DeferredWorkExampleService extends BaseService { private _isShuttingDown = false private _workDone: Promise | undefined protected override onAllReady(): void { // Schedule the deferred work via setTimeout, return synchronously. const handle = setTimeout(() => { if (this._isShuttingDown) return this._workDone = this.runDeferredWork() }, 60_000) // Hand the timer to BaseService so onStop's _cleanupDisposables clears it. this.registerDisposable(() => clearTimeout(handle)) } private async runDeferredWork(): Promise { // Check the shutdown flag between every IO step so a teardown arriving // mid-flight short-circuits the remainder. if (this._isShuttingDown) return await this.stepOne() if (this._isShuttingDown) return await this.stepTwo() } protected override async onStop(): Promise { this._isShuttingDown = true // Join the deferred work if it had already started. if (this._workDone) { try { await this._workDone } catch { // Errors are already logged inside runDeferredWork. } } } } ``` Three invariants keep this safe: - **Shutdown flag**: `_isShuttingDown` is checked at the timer callback entry and between every IO step inside the deferred flow, so a teardown arriving in either window short-circuits cleanly. - **Disposable timer**: `registerDisposable(() => clearTimeout(handle))` guarantees the timer is cleared by `_cleanupDisposables` even if the service stops before the quiet window elapses. - **`onStop` join**: assigning the flow's `Promise` to `this._workDone` and awaiting it from `onStop` gives the framework a way to wait out a mid-flight step before tearing down dependent resources. Real-world example: `JobManager.onAllReady` registers a `setTimeout` that fires ~60 seconds later and then runs the recovery flow. See [job-and-scheduler/overview.md — Startup Recovery](../job-and-scheduler/overview.md#startup-recovery). ## Service Events (Emitter / Event) ### Problem `@DependsOn` guarantees initialization order, but some services need to react to work completed by other services at **runtime** — after `onInit()`. For example, `ShortcutService` needs to bind shortcuts when `MainWindowService` creates the main window, which happens after all services have initialized. The window can also be recreated (macOS activate), so the notification must be repeatable. ### When to Use - A service completes async work that other services need to react to - The work may happen multiple times during the app lifecycle (repeatable) - Multiple consumers may need to react (one-to-many broadcast) **Do NOT use** for telling a specific service to do something — just call its method directly via `application.get()`. ### Producer Pattern The producer owns a private `Emitter` and exposes its public `Event`. Follow the naming convention: private `_onXxx`, public `onXxx`. ```typescript import { BaseService, Emitter, type Event, Injectable, Phase, ServicePhase } from '@main/core/lifecycle' @Injectable('MainWindowService') @ServicePhase(Phase.WhenReady) export class MainWindowService extends BaseService { // Private: only this service can fire private readonly _onMainWindowCreated = new Emitter() // Public: consumers subscribe to this public readonly onMainWindowCreated: Event = this._onMainWindowCreated.event public createMainWindow(): BrowserWindow { // ...create window... this._onMainWindowCreated.fire(this.mainWindow) return this.mainWindow } // Emitter is owned infrastructure — dispose only on destroy, not stop protected async onDestroy() { this._onMainWindowCreated.dispose() } } ``` **Important**: Do NOT `registerDisposable()` owned Emitters. They live with the service instance and are only disposed in `onDestroy()` (not `onStop()`), so the service can be restarted without losing the Emitter. ### Consumer Pattern Consumers subscribe via the public `Event` and register the subscription for automatic cleanup. ```typescript @Injectable('ShortcutService') @DependsOn(['MainWindowService']) export class ShortcutService extends BaseService { protected async onInit() { const windowService = application.get('MainWindowService') this.registerDisposable( windowService.onMainWindowCreated((window) => this.bindShortcuts(window)) ) } // No manual cleanup needed in onStop() — registerDisposable handles it } ``` ### Error Isolation `Emitter.fire()` isolates listener errors — if one listener throws, all other listeners still receive the event. The snapshot of listeners is taken before iteration, so listeners can safely unsubscribe during a fire cycle. ## Signal (One-shot Completion) ### Problem Some services complete a piece of work **exactly once** that other services need to wait for or react to. For example, a database migration that runs during initialization — once done, it's done forever. Unlike `Emitter` events which fire multiple times, this needs a one-shot notification where late subscribers still get the value. ### When to Use - One-time initialization work that happens asynchronously (DB migration, store hydration) - Other services need to `await` this completion before proceeding - Late subscribers (services that start after the signal resolves) should still get the value **Do NOT use** for repeatable events (window creation, config changes) — use `Emitter` instead. ### Usage ```typescript import { BaseService, Injectable, Signal } from '@main/core/lifecycle' // Producer @Injectable('DbService') export class DbService extends BaseService { readonly migrationComplete = new Signal() protected async onInit() { this.registerDisposable(this.migrationComplete) await this.runMigrations() this.migrationComplete.resolve() } } // Consumer — await style @Injectable('UserService') @DependsOn(['DbService']) export class UserService extends BaseService { protected async onInit() { await application.get('DbService').migrationComplete // migration is guaranteed complete here } } // Consumer — callback style @Injectable('AuditService') @DependsOn(['DbService']) export class AuditService extends BaseService { protected async onInit() { this.registerDisposable( application.get('DbService').migrationComplete.onResolved(() => { this.logMigrationEvent() }) ) } } ``` ### Key Behaviors - Implements `PromiseLike` — can be `await`ed directly - `resolve()` can only be called once — double-resolve throws an error - Late subscribers receive the resolved value immediately via `onResolved` - If disposed before `resolve()`, any pending `await` will hang indefinitely (services are stopped in reverse dependency order, so consumers stop before producers) ## Pause/Resume (Optional) Services can implement the `Pausable` interface to support pause/resume operations: ```typescript import { BaseService, Injectable, type Pausable } from '@main/core/lifecycle' @Injectable('RealTimeService') class RealTimeService extends BaseService implements Pausable { private intervalId: NodeJS.Timeout | null = null protected onInit() { this.startPolling() } onPause() { clearInterval(this.intervalId!) this.intervalId = null } onResume() { this.startPolling() } private startPolling() { this.intervalId = setInterval(() => { /* ... */ }, 1000) } } ``` ## Stop/Start/Restart All services support stop/start operations (no special interface needed): ```typescript import { application } from '@application' await application.stop('HeavyComputeService') // calls onStop() await application.start('HeavyComputeService') // calls onInit() again await application.restart('HeavyComputeService') // stop + start ``` ## Activatable (Optional — On-Demand Resource Loading) Services can implement the `Activatable` interface to defer loading heavy resources (native modules, windows, caches, file I/O) until a condition is met at runtime. Unlike `@Conditional` (which excludes a service entirely at boot), activatable services are always registered and initialized — their IPC handlers remain available regardless of activation state. Only the heavy resources are loaded/released on demand. Unlike `Pausable` (which temporarily suspends execution), `Activatable` controls whether resources are allocated at all. Activation state is orthogonal to `LifecycleState` — a Ready service can be activated or inactive. ### Interface ```typescript import { application } from '@application' import { BaseService, Injectable, type Activatable } from '@main/core/lifecycle' @Injectable('SelectionService') class SelectionService extends BaseService implements Activatable { protected onInit() { this.registerIpcHandlers() // Set up trigger: subscribe to preference changes // Note: PreferenceService is Phase.BeforeReady — guaranteed ready before WhenReady services const prefService = application.get('PreferenceService') this.registerDisposable( prefService.subscribeChange('feature.selection.enabled', async (enabled) => { if (enabled) await this.activate() else await this.deactivate() }) ) } protected async onReady() { // Initial activation check (state is Ready, so activate() works) if (application.get('PreferenceService').get('feature.selection.enabled')) { await this.activate() } } onActivate() { // Load native module, create windows, etc. } onDeactivate() { // Release native module, close windows, etc. } } ``` ### Hook Responsibilities (Five-Phase Model) | Hook | Responsibility | Example | |------|---------------|---------| | `onInit()` | Infrastructure: IPC handlers, event subscriptions, trigger setup, recurring timers | `registerIpcHandlers()`, `registerDisposable(...)`, `registerInterval(...)` | | `onReady()` | Initial activation check (state = Ready, `activate()` works) | `if (enabled) await this.activate()` | | `onActivate()` | Load heavy resources | Native modules, windows, caches | | `onDeactivate()` | Release heavy resources | Close windows, clear caches | | `onStop()` | Lifecycle cleanup (`_doStop()` auto-deactivates before this) | Clean up non-activation subscriptions | ### Two Activation Paths Both paths share the same base state checks in `_doActivate()` (Ready state, idempotency, concurrency guard). The difference is what wraps them: - **Self-activation** (within the service): `this.activate()` / `this.deactivate()` — calls `_doActivate()` directly, no lifecycle events or logging - **External activation** (from other code): `application.activate('ServiceName')` / `application.deactivate('ServiceName')` — adds LifecycleManager validation, logging, and lifecycle event emission ### Method-Level Guard Pattern For methods called externally (e.g., by other services or via IPC), use `isActivated` as a guard: ```typescript createSpan(span: ReadableSpan) { if (!this.isActivated) return // ... heavy work only when activated } ``` ### `onActivate()` Failure Contract If `onActivate()` throws after partially allocating resources, it **must** clean up those resources before throwing. Since `isActivated` remains `false` on failure, activation may be retried — partial state must not leak. ### Automatic Deactivation - `_doStop()` auto-deactivates before calling `onStop()` (failure does not block stop) - `_doDestroy()` auto-deactivates as a safety net (for destroy-without-stop scenarios) ### Fast Toggling — When You Need a Reconciler `_doActivate()`'s concurrency guard is **drop-style**: a call that arrives while an activation is in flight is short-circuited, not queued. That is correct for the common case, but if a service is toggled at runtime and its `onActivate`/`onDeactivate` is **async**, a quick opposite toggle that lands mid-transition is **dropped** — the running state can settle diverged from the latest intent. This is **not** a `BaseService` change (the guard and the `onActivate` failure contract above stay as documented). Instead, an affected service **self-holds** a [`createLatestReconciler`](../../../src/main/core/concurrency/README.md) and routes its toggles through it (`getSnapshot: () => ({ desired, actual: this.isActivated })`, `apply: ({ desired }) => desired ? this.activate() : this.deactivate()`). See that README's judgment table for exactly when it is needed (async activate/deactivate **and** a runtime toggle source; a fully-synchronous or startup-only service does not need it). `ApiGatewayService` is the reference.