mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# Lifecycle Decision Guide
|
|
|
|
**Lifecycle manages resources, not logic.** Being named "Service" does not mean it belongs here. The question is: does it **own resources or side effects that outlive a single method call and need cleanup on shutdown**?
|
|
|
|
## Use Lifecycle if (either condition)
|
|
|
|
**1. Owns long-lived resources** — created at init, survive across calls, need explicit cleanup:
|
|
|
|
| Category | Examples |
|
|
| --------------------- | -------------------------------------------------------- |
|
|
| DB connections | SQLite / better-sqlite3, Drizzle ORM |
|
|
| Network services | HTTP server, mDNS browser, WebSocket server |
|
|
| Native / OS resources | `SelectionHook` (system thread), `Tray`, `BrowserWindow` |
|
|
| File system | `chokidar` watcher, Winston DailyRotateFile transport |
|
|
| Timers | `setInterval` (GC, polling) |
|
|
| Child processes | Long-running gateway / worker (not one-shot scripts) |
|
|
| Stateful stores | In-memory caches needing flush on shutdown |
|
|
|
|
**2. Registers persistent side effects** — modifies global state at init, persists for lifetime, needs undo:
|
|
|
|
| Category | Examples |
|
|
| -------------------- | ------------------------------------------------------------------ |
|
|
| Event listeners | `nativeTheme.on()`, `powerMonitor.on()`, `autoUpdater.on()` |
|
|
| Global shortcuts | `globalShortcut.register()` |
|
|
| Subscriptions | `preferenceService.subscribeChange()`, `configManager.subscribe()` |
|
|
| Session interceptors | `session.webRequest.onHeadersReceived()` |
|
|
| IPC handlers | `ipcMain.handle()` registration (see below) |
|
|
| Global API mutations | Monkey-patching global APIs |
|
|
|
|
#### When should IPC handlers live inside a service?
|
|
|
|
**Placement, not promotion.** This table assumes the service is *already* lifecycle (it owns resources or stateful handlers) and only decides whether a handler lives *inside* it. None of these rows promotes a class into lifecycle on its own — least of all row 3: "belongs to the domain" means *co-locate into the existing domain service*, never *create a lifecycle service just to host IPC registration*.
|
|
|
|
A lifecycle service should self-contain its IPC handlers when **any** of the following is true:
|
|
|
|
| Condition | Why |
|
|
|-----------|-----|
|
|
| Handler accesses service instance state (`this.xxx`) | Handler is coupled to the service's lifecycle — if the service stops, the handler must stop too |
|
|
| Service needs `stop()` / `start()` / `restart()` support | Orphaned handlers would reference stale state after restart |
|
|
| Handler semantically belongs to the service's domain | Co-location improves maintainability and discoverability |
|
|
|
|
If the handler is purely stateless (e.g., returns `app.getVersion()`), it does not require lifecycle management — a class whose only job is registering stateless IPC is **not** a lifecycle service. Fold the handler into its domain service, or register it from a direct-import singleton.
|
|
|
|
BaseService provides built-in IPC tracking for self-contained handlers — see [IPC Handler Management](./lifecycle-usage.md#ipc-handler-management).
|
|
|
|
## Do NOT Use Lifecycle if
|
|
|
|
- **Stateless orchestration** — calls other services, combines results, owns nothing.
|
|
- **DataApi business-logic services** — repositories / data-access wrappers that query `DbService` (e.g. `MessageRepository`, `TopicService`). The DB connection is managed by `DbService`; these just encapsulate queries. Use a direct-import singleton.
|
|
- **Request-scoped resources** — resources created and released within a single method call (e.g. S3 connections in `BackupManager.backup()`).
|
|
- **No init, no cleanup** — would inherit `BaseService` but never override `onInit()` / `onStop()`.
|
|
- **Pure utility** — functions or SDK wrappers with no runtime state.
|
|
|
|
## Decision Flowchart
|
|
|
|
```
|
|
┌───────────────────────────────────┐
|
|
│ Owns long-lived resources? │
|
|
│ (connections, timers, native │
|
|
│ modules, servers, processes) │
|
|
└─────┬────────────────┬────────────┘
|
|
yes │ │ no
|
|
▼ ▼
|
|
┌───────────┐ ┌──────────────────────────┐
|
|
│ Lifecycle │ │ Registers persistent │
|
|
└───────────┘ │ side effects? │
|
|
│ (listeners, shortcuts, │
|
|
│ subscriptions, etc.) │
|
|
└─────┬───────────┬────────┘
|
|
yes │ │ no
|
|
▼ ▼
|
|
┌───────────┐ ┌────────────────┐
|
|
│ Lifecycle │ │ Direct-import │
|
|
└───────────┘ │ singleton │
|
|
└────────────────┘
|
|
```
|
|
|
|
## Quick Reference
|
|
|
|
| | Lifecycle | Direct-import singleton |
|
|
| ----------------------- | -------------------------------------------- | ---------------------------------------------- |
|
|
| Examples | `DbService`, `CacheService`, `MainWindowService` | `ExportService`, `BackupManager` |
|
|
| Long-lived resources | Yes | No (or request-scoped) |
|
|
| Persistent side effects | Yes | No |
|
|
| `onInit` / `onStop` | Meaningful | Would be empty |
|
|
| Pattern | `@Injectable` + `application.get()` | `export const x = new X()` |
|
|
|
|
## Examples
|
|
|
|
**Belongs in lifecycle** — owns timer, needs cleanup:
|
|
|
|
```typescript
|
|
@Injectable('CacheService')
|
|
export class CacheService extends BaseService {
|
|
private gcTimer: NodeJS.Timeout | null = null
|
|
|
|
protected onInit() {
|
|
this.gcTimer = setInterval(() => this.gc(), 600_000)
|
|
}
|
|
|
|
protected onStop() {
|
|
clearInterval(this.gcTimer!)
|
|
this.cache.clear()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Does NOT belong** — all work inside methods, nothing to clean up:
|
|
|
|
```typescript
|
|
export class ExportService {
|
|
private md = new MarkdownIt()
|
|
|
|
async exportToDocx(messages: Message[]) {
|
|
const doc = new Document({ sections: this.buildSections(messages) })
|
|
const buffer = await Packer.toBuffer(doc)
|
|
await dialog.showSaveDialog(/* ... */)
|
|
}
|
|
}
|
|
export const exportService = new ExportService()
|
|
```
|
|
|
|
## Choosing Between @Conditional, Pausable, and Activatable
|
|
|
|
Once a service belongs in lifecycle, it may need optional behaviors:
|
|
|
|
| Scenario | Use | Reason |
|
|
|----------|-----|--------|
|
|
| Service only runs on specific platform/arch | `@Conditional` | Excluded at boot, zero overhead |
|
|
| Service needs temporary suspend/resume (e.g., window inactive) | `Pausable` | Keeps instance and resources, just pauses execution |
|
|
| Service always needs IPC, but heavy resources load on demand | `Activatable` | IPC always available, resources allocated only when needed |
|
|
| Service has a runtime toggle (preference, feature flag) controlling on/off | `Activatable` | Unified activate/deactivate pattern, even for lightweight resources |
|
|
| Service runs unconditionally with all resources | None | Default behavior |
|
|
|
|
### Decision Flow
|
|
|
|
```
|
|
Does the service need to be entirely excluded on some platforms?
|
|
├─ Yes, condition is known at boot and immutable
|
|
│ → @Conditional (platform, arch, env var, etc.)
|
|
└─ No
|
|
Does the service have heavy resources OR a runtime toggle controlling on/off?
|
|
├─ Yes → Activatable
|
|
│ IPC registered in onInit() (always available)
|
|
│ Resources in onActivate()/onDeactivate()
|
|
│ Service decides trigger (preference, event, IPC, etc.)
|
|
└─ No
|
|
Does the service need temporary pause/resume?
|
|
├─ Yes → Pausable
|
|
└─ No → No extra interface needed
|
|
```
|
|
|
|
### Activatable vs Pausable
|
|
|
|
| | Activatable | Pausable |
|
|
|---|------------|---------|
|
|
| Purpose | On-demand resource loading/release | Temporary execution suspension |
|
|
| State dimension | Orthogonal to LifecycleState | Changes LifecycleState |
|
|
| IPC handlers | Always available (registered in onInit) | Retained while paused (removed on stop) |
|
|
| Resources | Not allocated when inactive | Retained while paused |
|
|
| Trigger | Service decides (self or external via `application.activate`) | LifecycleManager with cascade |
|
|
| Cascade | No cascade | Cascades to dependents |
|
|
| Cycles | Supports repeated activate/deactivate | Supports repeated pause/resume |
|
|
|
|
### When Activatable is NOT appropriate
|
|
|
|
- **Lightweight resources with no runtime toggle** (Map, simple state that is always needed) — not worth the split, load in `onInit()`
|
|
- **No IPC needed when inactive** — consider `@Conditional` to exclude entirely
|
|
- **Resources need coordinated release across services** — consider `Pausable` (supports cascade)
|
|
|
|
## Common Mistakes
|
|
|
|
1. **Empty hooks** — `extends BaseService` but no `onInit()` / `onStop()` override. If both would be empty, don't use lifecycle.
|
|
2. **Request-scoped ≠ long-lived** — `BackupManager` creates S3 connections inside `backup()` and releases on return. That's request-scoped. No lifecycle needed.
|
|
3. **"Depends on PreferenceService"** — not a lifecycle concern. Any code can call `application.get('PreferenceService')`. Only register if the service itself owns resources.
|
|
4. **Using `@Conditional` for runtime conditions** — `@Conditional` is evaluated once at boot. For conditions that change at runtime (user preferences, events), use `Activatable` instead.
|
|
5. **Redundant cross-phase `@DependsOn`** — WhenReady services do not need `@DependsOn('PreferenceService')` or `@DependsOn('DbService')`. Phase ordering is enforced by the container; BeforeReady is always ready before WhenReady starts. Only declare `@DependsOn` for same-phase services.
|
|
|
|
```typescript
|
|
// ❌ Redundant — PreferenceService is BeforeReady, guaranteed ready
|
|
@Injectable('MainWindowService')
|
|
@ServicePhase(Phase.WhenReady)
|
|
@DependsOn('PreferenceService') // <-- remove this
|
|
export class MainWindowService extends BaseService { ... }
|
|
|
|
// ✅ Correct — only declare same-phase deps
|
|
@Injectable('AgentBootstrapService')
|
|
@ServicePhase(Phase.WhenReady)
|
|
@DependsOn('ApiServerService') // ApiServerService is also WhenReady
|
|
export class AgentBootstrapService extends BaseService { ... }
|
|
```
|
|
|
|
6. **Awaiting business work inside `onAllReady`** — `onAllReady` is a post-bootstrap supplement, not part of initialization. The framework invokes every service's hook in parallel and **does not await completion** (fire-and-forget). An `await someLongRunning()` inside `onAllReady` becomes silent background work; bootstrap proceeds without it. If the service truly needs deferred business work (e.g. a quiet window then recovery), schedule it via `setTimeout`, track the Promise on the instance, and join it from `onStop`. See [Lifecycle Usage — onAllReady patterns](./lifecycle-usage.md#onallready-business-work-pattern) for the template.
|
|
|
|
7. **Treating `ALL_SERVICES_READY` as "all side effects done"** — the event fires immediately after every `onAllReady` hook has been **invoked**, not after they complete. A listener that needs to wait on a specific service's deferred work must coordinate with that service directly (e.g. a `Signal` emitted by the service when its work finishes), not subscribe to `ALL_SERVICES_READY`.
|
|
|
|
8. **"Lifecycle service as an IPC bucket"** — a class that exists only to register IPC handlers is not lifecycle by default. Registration is a side effect, but a *stateless* handler needs no shutdown undo, and "belongs to the domain" (the IPC table's row 3) only decides placement inside an *already-lifecycle* service — it never promotes an IPC-only class. Fold such handlers into the owning domain service, or use a direct-import singleton.
|