Files
CherryHQ-cherry-studio/docs/references/lifecycle/lifecycle-usage.md

540 lines
24 KiB
Markdown

# 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<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](../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`.
```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<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.
```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<T>` instead.
### Usage
```typescript
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 `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.