Skip to content

Commit 09222b7

Browse files
killaguclaude
andauthored
feat(core,egg): add V8 startup snapshot lifecycle hooks and APIs (#5856)
## Summary Add V8 startup snapshot support to the egg framework package: - **Metadata-only loading**: `snapshot: true` option triggers lifecycle cutoff after `configWillLoad` (via PR2 core), then awaits `loadFinished` for loader completion - **`buildSnapshot()` / `restoreSnapshot()` APIs**: Public entry points for snapshot construction and restoration - **`startEggForSnapshot()`**: Creates Agent + Application in single/snapshot mode, awaits full loader completion via `loadFinished` promise - **`registerSnapshotCallbacks()`**: Serialize/deserialize hooks for loggers, messenger, agent keep-alive timer, unhandledRejection handler, HttpClient prototype - **Snapshot guards**: Skip runtime-only init (timers, event listeners, config dumping) when `snapshot: true` This is **PR4 of 6** in the V8 startup snapshot series. Depends on PR1 (koa), PR2 (core), PR3 (utils). ## Changes (7 files, +294/-47) - `packages/egg/src/lib/egg.ts` — Snapshot option, loadFinished promise, registerSnapshotCallbacks(), guards - `packages/egg/src/lib/agent.ts` — Conditional keepalive, registerSnapshotCallbacks() override - `packages/egg/src/lib/application.ts` — Skip bindEvents in snapshot mode - `packages/egg/src/lib/snapshot.ts` — NEW: buildSnapshot/restoreSnapshot APIs - `packages/egg/src/lib/start.ts` — NEW: startEggForSnapshot() - `packages/egg/src/index.ts` — Export snapshot utilities - `packages/egg/test/lib/snapshot.test.ts` — NEW: 18 comprehensive tests ## Test plan - [x] 18 snapshot-specific tests (buildSnapshot, restoreSnapshot, startEggForSnapshot, registerSnapshotCallbacks, loadFinished, guards) - [x] All existing egg tests pass (54 files) - [x] oxlint --type-aware clean - [x] /simplify reviewed 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * V8 startup snapshot support with build/restore utilities and a convenience snapshot startup flow. * Lifecycle hooks and app/agent resume behavior for serialize/deserialize, plus public entry points to trigger them. * Improved startup semantics: app/agent expose a load-completion promise and agent keep‑alive is started on demand. * **Tests** * Comprehensive snapshot-mode tests covering hook ordering, resource cleanup/restore, startup/resume sequencing, and restore errors. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f7d4f4 commit 09222b7

10 files changed

Lines changed: 1028 additions & 29 deletions

File tree

packages/core/src/egg.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,24 @@ export class EggCore extends KoaApplication {
412412
this.lifecycle.registerBeforeClose(fn, name);
413413
}
414414

415+
/**
416+
* Trigger snapshotWillSerialize lifecycle hooks on all boots in reverse order.
417+
* Called by the build script before V8 serializes the heap.
418+
* Cleans up non-serializable resources: file handles, timers, listeners, connections.
419+
*/
420+
async triggerSnapshotWillSerialize(): Promise<void> {
421+
return this.lifecycle.triggerSnapshotWillSerialize();
422+
}
423+
424+
/**
425+
* Trigger snapshotDidDeserialize lifecycle hooks on all boots in forward order.
426+
* Called by the restore entry after V8 deserializes the heap.
427+
* Restores non-serializable resources and resumes the lifecycle from configDidLoad.
428+
*/
429+
async triggerSnapshotDidDeserialize(): Promise<void> {
430+
return this.lifecycle.triggerSnapshotDidDeserialize();
431+
}
432+
415433
/**
416434
* Close all, it will close
417435
* - callbacks registered by beforeClose

packages/core/src/lifecycle.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export interface ILifecycleBoot {
6161
* when the application is started with metadataOnly: true.
6262
*/
6363
loadMetadata?(): Promise<void> | void;
64+
65+
/**
66+
* Called before V8 serializes the heap for startup snapshot.
67+
* Clean up non-serializable resources: close file handles, clear timers,
68+
* remove process listeners, close network connections.
69+
* Executed in REVERSE registration order (like beforeClose).
70+
*/
71+
snapshotWillSerialize?(): Promise<void> | void;
72+
73+
/**
74+
* Called after V8 deserializes the heap from a startup snapshot.
75+
* Restore non-serializable resources: reopen file handles, recreate timers,
76+
* re-register process listeners, reinitialize connections.
77+
* Executed in FORWARD registration order (like configWillLoad).
78+
* After all hooks complete, the normal lifecycle resumes from configDidLoad.
79+
*/
80+
snapshotDidDeserialize?(): Promise<void> | void;
6481
}
6582

6683
export type BootImplClass<T = ILifecycleBoot> = new (...args: any[]) => T;
@@ -89,6 +106,7 @@ export class Lifecycle extends EventEmitter {
89106
#boots: ILifecycleBoot[];
90107
#isClosed: boolean;
91108
#metadataOnly: boolean;
109+
#snapshotBuilding: boolean;
92110
#closeFunctionSet: Set<FunWithFullPath>;
93111
loadReady: Ready;
94112
bootReady: Ready;
@@ -105,6 +123,7 @@ export class Lifecycle extends EventEmitter {
105123
this.#closeFunctionSet = new Set();
106124
this.#isClosed = false;
107125
this.#metadataOnly = false;
126+
this.#snapshotBuilding = false;
108127
this.#init = false;
109128

110129
this.timing.start(`${this.options.app.type} Start`);
@@ -264,8 +283,12 @@ export class Lifecycle extends EventEmitter {
264283
// Snapshot mode: stop AFTER configWillLoad, BEFORE configDidLoad.
265284
// SDKs typically execute during configDidLoad hooks — these open connections
266285
// and start timers which are not serializable in V8 startup snapshots.
286+
// Start loadReady so registerBeforeStart callbacks (e.g. load()) can complete
287+
// and drive readiness — callers can simply `await app.ready()` without
288+
// needing to distinguish snapshot from normal mode.
267289
debug('snapshot mode: stopping after configWillLoad, skipping configDidLoad and later phases');
268-
this.ready(true);
290+
this.#snapshotBuilding = true;
291+
this.loadReady.start();
269292
return;
270293
}
271294
this.triggerConfigDidLoad();
@@ -380,6 +403,88 @@ export class Lifecycle extends EventEmitter {
380403
this.ready(firstError ?? true);
381404
}
382405

406+
/**
407+
* Trigger snapshotWillSerialize on all boots in REVERSE order.
408+
* Called by the build script before V8 serializes the heap.
409+
*/
410+
async triggerSnapshotWillSerialize(): Promise<void> {
411+
if (!this.options.snapshot) {
412+
throw new Error('triggerSnapshotWillSerialize() can only be called on a snapshot-mode lifecycle');
413+
}
414+
debug('trigger snapshotWillSerialize start');
415+
const boots = [...this.#boots].reverse();
416+
for (const boot of boots) {
417+
if (typeof boot.snapshotWillSerialize !== 'function') {
418+
continue;
419+
}
420+
const fullPath = boot.fullPath ?? 'unknown';
421+
debug('trigger snapshotWillSerialize at %o', fullPath);
422+
const timingKey = `Snapshot Will Serialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`;
423+
this.timing.start(timingKey);
424+
try {
425+
await utils.callFn(boot.snapshotWillSerialize.bind(boot));
426+
} catch (err) {
427+
debug('trigger snapshotWillSerialize error at %o, error: %s', fullPath, err);
428+
this.timing.end(timingKey);
429+
throw err;
430+
}
431+
this.timing.end(timingKey);
432+
}
433+
debug('trigger snapshotWillSerialize end');
434+
}
435+
436+
/**
437+
* Trigger snapshotDidDeserialize on all boots in FORWARD order.
438+
* Called by the restore entry after V8 deserializes the heap.
439+
* After all hooks complete, resets the ready state and resumes the normal
440+
* lifecycle from configDidLoad. The returned promise resolves when the
441+
* full lifecycle (configDidLoad → didLoad → willReady) has completed.
442+
*/
443+
async triggerSnapshotDidDeserialize(): Promise<void> {
444+
if (!this.options.snapshot) {
445+
throw new Error('triggerSnapshotDidDeserialize() can only be called on a snapshot-mode lifecycle');
446+
}
447+
debug('trigger snapshotDidDeserialize start');
448+
for (const boot of this.#boots) {
449+
if (typeof boot.snapshotDidDeserialize !== 'function') {
450+
continue;
451+
}
452+
const fullPath = boot.fullPath ?? 'unknown';
453+
debug('trigger snapshotDidDeserialize at %o', fullPath);
454+
const timingKey = `Snapshot Did Deserialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`;
455+
this.timing.start(timingKey);
456+
try {
457+
await utils.callFn(boot.snapshotDidDeserialize.bind(boot));
458+
} catch (err) {
459+
debug('trigger snapshotDidDeserialize error at %o, error: %s', fullPath, err);
460+
this.timing.end(timingKey);
461+
throw err;
462+
}
463+
this.timing.end(timingKey);
464+
}
465+
debug('trigger snapshotDidDeserialize end');
466+
467+
// Reset ready state for the resumed lifecycle.
468+
// In snapshot mode, ready(true) was called when loadReady completed,
469+
// resolving the ready promise early. We need fresh ready objects so the
470+
// resumed lifecycle (didLoad → willReady → didReady) can track properly.
471+
// Note: keep options.snapshot = true so the constructor's stale ready
472+
// callback (which may fire asynchronously) correctly skips triggerDidReady.
473+
this.#snapshotBuilding = false;
474+
this.#readyObject = new ReadyObject();
475+
this.#initReady();
476+
this.ready((err) => {
477+
void this.triggerDidReady(err);
478+
debug('app ready after snapshot deserialize');
479+
});
480+
481+
// Resume the normal lifecycle from configDidLoad
482+
this.triggerConfigDidLoad();
483+
484+
// Wait for the full resumed lifecycle to complete
485+
await this.ready();
486+
}
487+
383488
#initReady(): void {
384489
debug('loadReady init');
385490
this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });
@@ -389,6 +494,9 @@ export class Lifecycle extends EventEmitter {
389494
debug('trigger didLoad end');
390495
if (err) {
391496
this.ready(err);
497+
} else if (this.#snapshotBuilding) {
498+
// Snapshot build: skip willReady/bootReady phases, signal ready directly
499+
this.ready(true);
392500
} else {
393501
this.triggerWillReady();
394502
}

0 commit comments

Comments
 (0)