`LifecycleManager.allReady()` was holding `await Promise.allSettled(_doAllReady)`,
making every `onAllReady` body a synchronous bootstrap dependency. This
contradicted the hook's JSDoc ("post-bootstrap supplement, not a critical
initialization gate") and gave any in-hook deferred work an oversize blast
radius — a 60 s wait inside one service stalled `ALL_SERVICES_READY` and
delayed bootstrap completion.
Align the implementation with the JSDoc:
- `allReady()` becomes `void`. It synchronously invokes every initialized
service's `_doAllReady()`, attaches an async `.catch` that re-emits
`SERVICE_ERROR`, then emits `ALL_SERVICES_READY` immediately.
- `Application.bootstrap()` drops its `await` on `allReady()`.
- `LifecycleManager` tests adjusted: drop redundant `await`s, rewrite
`resolves.toBeUndefined()` as `not.toThrow()`, drain microtasks before
asserting on the now-async `SERVICE_ERROR` emit, and add a test
exercising the fire-and-forget contract with a never-resolving hook.
`ALL_SERVICES_READY` now fires when hooks are *invoked*, not when they
complete. Docs reflect the contract change: a "Hook vs Event" comparison
in `lifecycle-overview.md`, two new Common Mistakes in
`lifecycle-decision-guide.md`, and an `onAllReady` business-work pattern
template in `lifecycle-usage.md` showing the \`setTimeout\` + signal +
\`onStop\` join model used by JobManager.
18 KiB
Lifecycle Overview
IoC container + service lifecycle management with phased bootstrap and parallel initialization.
For the user-facing API (registration, bootstrap, service access, runtime control), see Application Overview. Application delegates to lifecycle internally — you should rarely need to use
ServiceContainerorLifecycleManagerdirectly.
Bootstrap Phases
Services are initialized in three phases:
| Phase | Description | Timing | Await |
|---|---|---|---|
BeforeReady |
Services not requiring Electron API | Before app.whenReady() |
Yes |
Background |
Independent services, fire-and-forget | Immediately | No |
WhenReady |
Services requiring Electron API (default) | After app.whenReady() |
Yes |
Bootstrap Timeline
|--Background (fire-and-forget)------------|
|--BeforeReady--------| |
|--app.whenReady()--------| |
|--WhenReady--| |
isBootstrapped = true
|--await Background--|
allReady (fire-and-forget) → ALL_SERVICES_READY
After all three phases complete (including Background), LifecycleManager.allReady() invokes onAllReady() on every initialized service in parallel and immediately emits ALL_SERVICES_READY — it does not await the hooks. onAllReady is a post-bootstrap supplement (see Hook Descriptions), so bootstrap does not block on services running deferred work inside it.
Phase Selection Guide
How Phases are Bootstrapped
1 Background starts (fire-and-forget) ──────────────────────────────────┐
2 BeforeReady starts ──────────┐ │
2 app.whenReady() ─────────────┤ │
├─ both complete │
▼ │
3 WhenReady starts ────────────┐ │
├─ complete → isBootstrapped = true │
▼ │
4 await Background ◄────────────────────────────────────────────────────┘
5 onAllReady() called on ALL services
→ ALL_SERVICES_READY emitted
Key points:
- BeforeReady runs in parallel with Electron's own initialization (
app.whenReady()), providing "free time" — work here doesn't add to startup latency as long as it finishes before Electron is ready. - WhenReady runs only after both BeforeReady and Electron are ready — the only phase where Electron APIs are safe to use.
- Background runs completely independently. It does not block any other phase, and no other phase can depend on it.
Choosing the Right Phase
┌──────────────────────┐
│ Does it use Electron │
│ APIs directly? │
└──────┬─────────┬─────┘
yes │ │ no
▼ ▼
┌───────────┐ ┌───────────────────────────┐
│ WhenReady │ │ Is it on the critical │
└───────────┘ │ startup path? (other │
│ services depend on it) │
└─────┬──────────┬──────────┘
yes │ │ no
▼ ▼
┌─────────────┐ ┌────────────┐
│ BeforeReady │ │ Background │
└─────────────┘ └────────────┘
BeforeReady — Maximize parallelism with Electron init
- Runs in parallel with
app.whenReady(), so initialization here is essentially "free" if it completes before Electron is ready. - Best for: database connections, config loading, data migrations, schema validation — anything that WhenReady services will depend on.
- Cannot use any Electron API (the app is not ready yet).
- Can only depend on other BeforeReady services.
WhenReady — The safe default
- Runs after both BeforeReady and
app.whenReady()have completed. - Full access to Electron APIs (
BrowserWindow,Tray,screen,nativeTheme,dialog,globalShortcut, etc.). - Can depend on other WhenReady services.
- Best for: window management, tray, system shortcuts, theme management, IPC handlers that need Electron APIs.
- This is the default phase — if you omit
@ServicePhase, the service is placed here.
⚠️ Cross-phase dependencies are automatic. BeforeReady services (
PreferenceService,DbService,CacheService,DataApiService) are guaranteed to finish before WhenReady starts. Do not declare@DependsOn('PreferenceService')(or similar) on a WhenReady service — it is redundant and misleading. Only use@DependsOnfor same-phase coupling.
Background — Fire-and-forget
- Starts immediately but runs completely independently, never blocking other phases.
- Other phases' services cannot depend on Background services (and vice versa).
- Background errors are caught and logged but never abort bootstrap.
- Best for: telemetry reporting, non-critical data pre-fetching, background cleanup tasks.
- Use
onAllReady()if a Background service needs to interact with services from other phases after bootstrap.
Dependency Rules
| Phase | Can Depend On | Cannot Depend On |
|---|---|---|
| BeforeReady | BeforeReady | Background, WhenReady |
| Background | Background | BeforeReady, WhenReady |
| WhenReady | BeforeReady, WhenReady | Background |
Cross-phase dependencies are implicit — the "Can Depend On" column means those services are guaranteed to be ready, not that you should declare them via @DependsOn. Reserve @DependsOn for same-phase ordering; cross-phase readiness is enforced automatically by LifecycleManager.startPhase().
Invalid dependencies are auto-corrected with a warning log:
[WARN] Service 'X' declared as Background but depends on BeforeReady service 'Y', adjusted to BeforeReady
Parallel Initialization
Services within the same phase that have no inter-dependencies are initialized in parallel:
Phase: WhenReady
Layer 1: [DbService, ConfigService] <- parallel (no inter-dependency)
Layer 2: [PreferenceService] <- sequential (depends on layer 1)
Layer 3: [MainWindowService] <- sequential (depends on layer 2)
Lifecycle Hooks
Created → Initializing → Ready ⇄ Paused
↓ ↓ ↓
onInit() onReady() onPause()/onResume()
↑ ↓
│ Stopping → Stopped → Destroyed
│ ↓ ↓ ↓
│ onStop() [restart] onDestroy()
└───────────────────────┘
After all phases complete:
Ready ──── onAllReady() (called once, no state change)
Hook Descriptions
| Hook | When Called | Can Override |
|---|---|---|
onInit() |
During initialization (and re-initialization on restart) | Yes |
onReady() |
Immediately after onInit() completes |
Yes |
onAllReady() |
Once after ALL services across ALL phases are ready | Yes |
onStop() |
When the service is being stopped | Yes |
onDestroy() |
Final cleanup, service cannot be reused | Yes |
onPause() |
When the service is being paused (requires Pausable) |
Yes |
onResume() |
When the service is being resumed (requires Pausable) |
Yes |
Automatic Resource Cleanup
BaseService uses a single unified Disposable tracking mechanism. All resources — IPC handlers, event subscriptions, recurring timers, signals, cleanup functions — are tracked as Disposables and cleaned up together during the stop lifecycle.
registerDisposable() accepts both Disposable objects and plain () => void cleanup functions:
this.registerDisposable(someEmitter.on('event', handler)) // Disposable object
this.registerDisposable(() => externalBus.off('topic', fn)) // Cleanup function
ipcHandle(), ipcOn(), and registerInterval() all return a Disposable registered through this same channel — IPC handlers and recurring timers are not separate cleanup categories.
Cleanup flow:
onStop() → all disposables disposed → state = Stopped
_doDestroy is idempotent — calling it on an already-destroyed service is a safe no-op.
See IPC Handler Management and Service Events for usage details.
onAllReady (System-wide Readiness)
Called once after all services across all bootstrap phases have completed initialization. Unlike onReady() (which fires when the individual service is ready), onAllReady() fires when the entire system is ready — safe to access any service regardless of @DependsOn declarations.
@Injectable('BackgroundReporterService')
class BackgroundReporterService extends BaseService {
protected onAllReady() {
// Safe to access any service — the entire system is ready
const preferenceService = application.get('PreferenceService')
}
}
Key behaviors:
onAllReadyis a post-bootstrap supplement, not part of initialization. It does not changeLifecycleState— the service stays inReadythroughout.LifecycleManager.allReady()invokes every service's hook in parallel and does not await completion (fire-and-forget). Bootstrap proceeds as soon as every hook has been invoked.ALL_SERVICES_READYis emitted immediately after all hooks have been invoked, not after they finish. Listeners MUST NOT assumeonAllReadyside effects have completed when this event fires.- Called at most once per service instance —
restart()does not re-trigger it (guarded by_allReadyCalled). - Errors thrown synchronously or via the returned Promise are caught by an async
.catchin the framework, logged, and emitted asSERVICE_ERROR(in a microtask) — they never propagate to bootstrap. - Do not
awaitlong-running business work directly inonAllReady. Because the framework no longer awaits the hook, in-hookawaits become silent background work. If a service needs deferred business work (e.g. a quiet window then recovery), schedule it viasetTimeout, track the resulting Promise on the instance, and join it fromonStop. See Lifecycle Usage — onAllReady patterns.
onAllReady Hook vs ALL_SERVICES_READY Event
Same readiness moment, two delivery channels. Both fire from one synchronous LifecycleManager.allReady() call — the framework first invokes every service's onAllReady, then emits ALL_SERVICES_READY. They are microseconds apart on the same JS tick.
onAllReady hook |
ALL_SERVICES_READY event |
|
|---|---|---|
| Mechanism | Push — framework calls every service once | Pub/sub — only .on(...) subscribers receive |
| Audience | The service itself, via method override | Anyone with a LifecycleManager reference |
| Failure handling | Caught by framework, re-emitted as SERVICE_ERROR |
Standard EventEmitter behaviour |
Rule of thumb: a service reacts via its own onAllReady; non-service code (diagnostics, telemetry, ad-hoc listeners) subscribes to the event. Neither signals when a specific service's deferred work finishes — for that, expose a per-service Signal.
Service States
| State | Description |
|---|---|
Created |
Instance created, not initialized |
Initializing |
Currently running onInit() |
Ready |
Fully initialized and operational |
Pausing |
Currently running onPause() |
Paused |
Temporarily suspended |
Resuming |
Currently running onResume() |
Stopping |
Currently running onStop() |
Stopped |
Stopped, can be restarted via start() |
Destroyed |
Released, cannot be reused |
Lifecycle Events (Internal API)
For most use cases, prefer the
onAllReady()hook orapplication.get()over raw event listening. These events are primarily for infrastructure code (e.g., diagnostics, logging). For the hook vs event tradeoff, seeonAllReadyHook vsALL_SERVICES_READYEvent.
Listen to lifecycle events via the LifecycleManager (extends EventEmitter):
import { LifecycleEvents, LifecycleManager } from '@main/core/lifecycle'
const manager = LifecycleManager.getInstance()
manager.on(LifecycleEvents.SERVICE_READY, (payload) => {
logger.info(`${payload.name} is ready`)
})
manager.on(LifecycleEvents.ALL_SERVICES_READY, () => {
logger.info('All services ready')
})
| Event | Payload | Description |
|---|---|---|
SERVICE_INITIALIZING |
{ name, state } |
Service is starting initialization |
SERVICE_READY |
{ name, state } |
Service completed initialization |
SERVICE_PAUSING |
{ name, state } |
Service is being paused |
SERVICE_PAUSED |
{ name, state } |
Service is paused |
SERVICE_RESUMING |
{ name, state } |
Service is being resumed |
SERVICE_RESUMED |
{ name, state } |
Service is resumed |
SERVICE_STOPPING |
{ name, state } |
Service is being stopped |
SERVICE_STOPPED |
{ name, state } |
Service is stopped |
SERVICE_DESTROYED |
{ name, state } |
Service is destroyed |
SERVICE_ERROR |
{ name, state, error } |
Service encountered an error |
ALL_SERVICES_READY |
(none) | All onAllReady hooks have been invoked (NOT necessarily completed — see onAllReady) |
Inter-Service Communication
@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 know when MainWindowService creates the main window — which happens after all services have initialized.
The lifecycle system provides two typed primitives for this, avoiding ad-hoc EventEmitter patterns (no type safety, magic strings, manual cleanup):
| Communication Pattern | Mechanism | Example |
|---|---|---|
| "Service B must init after Service A" | @DependsOn |
PreferenceService depends on DbService |
| "Service A completed runtime work, others react" (repeatable) | Emitter<T> / Event<T> |
MainWindowService fires onMainWindowCreated |
| "Service A completed runtime work, others react" (one-shot) | Signal<T> |
DbService signals migrationComplete |
| "Tell a specific service to do something" | Direct method call via application.get() |
windowService.showMainWindow() |
Emitter / Event (Repeatable)
A producer service owns an Emitter<T> (private) and exposes its Event<T> (public). Consumers subscribe and get a Disposable for automatic cleanup via registerDisposable().
Signal (One-shot)
A Signal<T> resolves exactly once. It implements PromiseLike<T> so consumers can await it directly. Late subscribers receive the resolved value immediately.
For full usage patterns and code examples, see Service Events and Signal.
File Structure
lifecycle/
├── types.ts # Phase, LifecycleState, ServiceMetadata, Pausable, errors
├── decorators.ts # @Injectable, @ServicePhase, @DependsOn, @Priority, etc.
├── BaseService.ts # Abstract base class with lifecycle hooks
├── event.ts # Emitter<T>, Event<T>, Disposable — typed inter-service events
├── signal.ts # Signal<T> — one-shot deferred value (PromiseLike)
├── ServiceContainer.ts # IoC container with DI and conditional activation
├── DependencyResolver.ts # Topological sort, layered parallel resolution
├── LifecycleManager.ts # Phased bootstrap, shutdown, pause/resume/stop/start
├── index.ts # Barrel export
└── __tests__/ # Unit tests for all components