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 usesetupIpcHandlers,registerIpcHandler(singular), or other variants.
Cleanup Guarantees
- On stop: All tracked handlers are removed after
onStop()returns, so the service can still use IPC during its own shutdown if needed. - On stop failure: If
onStop()throws, IPC cleanup still executes (via try/finally). - On destroy: Safety-net cleanup runs in
_doDestroy()for edge cases where a service is destroyed without being stopped first (e.g., init failure). - On restart: Disposables array is reset after cleanup, so
onInit()can re-register handlers cleanly. - Backward compatible: Safe to mix with manual
ipcMain.removeHandler()inonStop()— double-remove is a no-op. - 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 supplement — LifecycleManager.allReady() does not await it. Two consequences shape how the hook should be used:
_allReadyCalledis at-most-once. Each service instance'sonAllReadyfires exactly once.restart()does not re-trigger it. Code that needs to run on every (re)start belongs inonInit/onReady, notonAllReady.- Hook return value is not observed by the framework. If you
awaitlong-running business work insideonAllReady, 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:
_isShuttingDownis 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_cleanupDisposableseven if the service stops before the quiet window elapses. onStopjoin: assigning the flow'sPromisetothis._workDoneand awaiting it fromonStopgives 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
awaitthis 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 beawaited 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 pendingawaitwill 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 callingonStop()(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.