Files

24 KiB

Lifecycle Usage Guide

Practical guide for using the lifecycle system. For architecture details, see Lifecycle Overview. For deciding whether to use lifecycle at all, see Decision Guide.

Quick Start

// 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) 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).

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.
@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.

// 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
// 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.

@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.

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 supplementLifecycleManager.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:

@Injectable('DeferredWorkExampleService')
class DeferredWorkExampleService extends BaseService {
  private _isShuttingDown = false
  private _workDone: Promise<void> | 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<void> {
    // 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<void> {
    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.

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<T> and exposes its public Event<T>. Follow the naming convention: private _onXxx, public onXxx.

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<BrowserWindow>()
  // Public: consumers subscribe to this
  public readonly onMainWindowCreated: Event<BrowserWindow> = 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<T> and register the subscription for automatic cleanup.

@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<T> instead.

Usage

import { BaseService, Injectable, Signal } from '@main/core/lifecycle'

// Producer
@Injectable('DbService')
export class DbService extends BaseService {
  readonly migrationComplete = new Signal<void>()

  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<T> — can be awaited 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:

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):

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

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:

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 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.