Skip to content

Commit 5d90728

Browse files
killaguclaude
andcommitted
feat(core,egg): add V8 startup snapshot lifecycle hooks and APIs
Add snapshotWillSerialize/snapshotDidDeserialize lifecycle hooks and snapshot-safe initialization patterns for V8 startup snapshot support. ## core changes - Add `snapshotWillSerialize()` / `snapshotDidDeserialize()` to ILifecycleBoot - triggerSnapshotWillSerialize: reverse order (LIFO, like beforeClose) - triggerSnapshotDidDeserialize: forward order (FIFO), resumes lifecycle from configDidLoad - Expose trigger methods on EggCore for build scripts ## egg changes - Agent keepalive timer moved to configDidLoad via object literal boot hook - Messenger/logger/unhandledRejection cleanup via protected snapshotWillSerialize/snapshotDidDeserialize methods - Zero `if (this.options.snapshot)` guards — all handled by lifecycle structure - New APIs: startEggForSnapshot(), buildSnapshot(), restoreSnapshot() - loadFinished promise for snapshot build scripts ## Tests - 16 core snapshot tests (hook ordering, async, error handling, lifecycle resume) - 10 egg snapshot tests (agent keepalive, messenger, logger, unhandledRejection, APIs) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 21b1a71 commit 5d90728

File tree

10 files changed

+1015
-5
lines changed

10 files changed

+1015
-5
lines changed

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: 90 additions & 0 deletions
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;
@@ -380,6 +397,79 @@ export class Lifecycle extends EventEmitter {
380397
this.ready(firstError ?? true);
381398
}
382399

400+
/**
401+
* Trigger snapshotWillSerialize on all boots in REVERSE order.
402+
* Called by the build script before V8 serializes the heap.
403+
*/
404+
async triggerSnapshotWillSerialize(): Promise<void> {
405+
debug('trigger snapshotWillSerialize start');
406+
const boots = [...this.#boots].reverse();
407+
for (const boot of boots) {
408+
if (typeof boot.snapshotWillSerialize !== 'function') {
409+
continue;
410+
}
411+
const fullPath = boot.fullPath ?? 'unknown';
412+
debug('trigger snapshotWillSerialize at %o', fullPath);
413+
const timingKey = `Snapshot Will Serialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`;
414+
this.timing.start(timingKey);
415+
try {
416+
await utils.callFn(boot.snapshotWillSerialize.bind(boot));
417+
} catch (err) {
418+
debug('trigger snapshotWillSerialize error at %o, error: %s', fullPath, err);
419+
this.emit('error', err);
420+
}
421+
this.timing.end(timingKey);
422+
}
423+
debug('trigger snapshotWillSerialize end');
424+
}
425+
426+
/**
427+
* Trigger snapshotDidDeserialize on all boots in FORWARD order.
428+
* Called by the restore entry after V8 deserializes the heap.
429+
* After all hooks complete, resets the ready state and resumes the normal
430+
* lifecycle from configDidLoad. The returned promise resolves when the
431+
* full lifecycle (configDidLoad → didLoad → willReady) has completed.
432+
*/
433+
async triggerSnapshotDidDeserialize(): Promise<void> {
434+
debug('trigger snapshotDidDeserialize start');
435+
for (const boot of this.#boots) {
436+
if (typeof boot.snapshotDidDeserialize !== 'function') {
437+
continue;
438+
}
439+
const fullPath = boot.fullPath ?? 'unknown';
440+
debug('trigger snapshotDidDeserialize at %o', fullPath);
441+
const timingKey = `Snapshot Did Deserialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`;
442+
this.timing.start(timingKey);
443+
try {
444+
await utils.callFn(boot.snapshotDidDeserialize.bind(boot));
445+
} catch (err) {
446+
debug('trigger snapshotDidDeserialize error at %o, error: %s', fullPath, err);
447+
this.emit('error', err);
448+
}
449+
this.timing.end(timingKey);
450+
}
451+
debug('trigger snapshotDidDeserialize end');
452+
453+
// Reset ready state for the resumed lifecycle.
454+
// In snapshot mode, ready(true) was called during triggerConfigWillLoad,
455+
// resolving the ready promise early. We need fresh ready objects so the
456+
// resumed lifecycle (didLoad → willReady → didReady) can track properly.
457+
// Note: keep options.snapshot = true so the constructor's stale ready
458+
// callback (which may fire asynchronously) correctly skips triggerDidReady.
459+
this.#readyObject = new ReadyObject();
460+
this.#initReady();
461+
this.ready((err) => {
462+
void this.triggerDidReady(err);
463+
debug('app ready after snapshot deserialize');
464+
});
465+
466+
// Resume the normal lifecycle from configDidLoad
467+
this.triggerConfigDidLoad();
468+
469+
// Wait for the full resumed lifecycle to complete
470+
await this.ready();
471+
}
472+
383473
#initReady(): void {
384474
debug('loadReady init');
385475
this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });

0 commit comments

Comments
 (0)