-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(core,egg): add V8 startup snapshot lifecycle hooks and APIs #5856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,23 @@ export interface ILifecycleBoot { | |
| * when the application is started with metadataOnly: true. | ||
| */ | ||
| loadMetadata?(): Promise<void> | void; | ||
|
|
||
| /** | ||
| * Called before V8 serializes the heap for startup snapshot. | ||
| * Clean up non-serializable resources: close file handles, clear timers, | ||
| * remove process listeners, close network connections. | ||
| * Executed in REVERSE registration order (like beforeClose). | ||
| */ | ||
| snapshotWillSerialize?(): Promise<void> | void; | ||
|
|
||
| /** | ||
| * Called after V8 deserializes the heap from a startup snapshot. | ||
| * Restore non-serializable resources: reopen file handles, recreate timers, | ||
| * re-register process listeners, reinitialize connections. | ||
| * Executed in FORWARD registration order (like configWillLoad). | ||
| * After all hooks complete, the normal lifecycle resumes from configDidLoad. | ||
| */ | ||
| snapshotDidDeserialize?(): Promise<void> | void; | ||
| } | ||
|
|
||
| export type BootImplClass<T = ILifecycleBoot> = new (...args: any[]) => T; | ||
|
|
@@ -89,6 +106,7 @@ export class Lifecycle extends EventEmitter { | |
| #boots: ILifecycleBoot[]; | ||
| #isClosed: boolean; | ||
| #metadataOnly: boolean; | ||
| #snapshotBuilding: boolean; | ||
| #closeFunctionSet: Set<FunWithFullPath>; | ||
| loadReady: Ready; | ||
| bootReady: Ready; | ||
|
|
@@ -105,6 +123,7 @@ export class Lifecycle extends EventEmitter { | |
| this.#closeFunctionSet = new Set(); | ||
| this.#isClosed = false; | ||
| this.#metadataOnly = false; | ||
| this.#snapshotBuilding = false; | ||
| this.#init = false; | ||
|
|
||
| this.timing.start(`${this.options.app.type} Start`); | ||
|
|
@@ -264,8 +283,12 @@ export class Lifecycle extends EventEmitter { | |
| // Snapshot mode: stop AFTER configWillLoad, BEFORE configDidLoad. | ||
| // SDKs typically execute during configDidLoad hooks — these open connections | ||
| // and start timers which are not serializable in V8 startup snapshots. | ||
| // Start loadReady so registerBeforeStart callbacks (e.g. load()) can complete | ||
| // and drive readiness — callers can simply `await app.ready()` without | ||
| // needing to distinguish snapshot from normal mode. | ||
| debug('snapshot mode: stopping after configWillLoad, skipping configDidLoad and later phases'); | ||
| this.ready(true); | ||
| this.#snapshotBuilding = true; | ||
| this.loadReady.start(); | ||
| return; | ||
| } | ||
| this.triggerConfigDidLoad(); | ||
|
|
@@ -380,6 +403,80 @@ export class Lifecycle extends EventEmitter { | |
| this.ready(firstError ?? true); | ||
| } | ||
|
|
||
| /** | ||
| * Trigger snapshotWillSerialize on all boots in REVERSE order. | ||
| * Called by the build script before V8 serializes the heap. | ||
| */ | ||
| async triggerSnapshotWillSerialize(): Promise<void> { | ||
| debug('trigger snapshotWillSerialize start'); | ||
| const boots = [...this.#boots].reverse(); | ||
| for (const boot of boots) { | ||
| if (typeof boot.snapshotWillSerialize !== 'function') { | ||
| continue; | ||
| } | ||
| const fullPath = boot.fullPath ?? 'unknown'; | ||
| debug('trigger snapshotWillSerialize at %o', fullPath); | ||
| const timingKey = `Snapshot Will Serialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`; | ||
| this.timing.start(timingKey); | ||
| try { | ||
| await utils.callFn(boot.snapshotWillSerialize.bind(boot)); | ||
| } catch (err) { | ||
| debug('trigger snapshotWillSerialize error at %o, error: %s', fullPath, err); | ||
| this.emit('error', err); | ||
| } | ||
| this.timing.end(timingKey); | ||
| } | ||
| debug('trigger snapshotWillSerialize end'); | ||
|
Comment on lines
+410
to
+429
|
||
| } | ||
|
|
||
| /** | ||
| * Trigger snapshotDidDeserialize on all boots in FORWARD order. | ||
| * Called by the restore entry after V8 deserializes the heap. | ||
| * After all hooks complete, resets the ready state and resumes the normal | ||
| * lifecycle from configDidLoad. The returned promise resolves when the | ||
| * full lifecycle (configDidLoad → didLoad → willReady) has completed. | ||
| */ | ||
| async triggerSnapshotDidDeserialize(): Promise<void> { | ||
| debug('trigger snapshotDidDeserialize start'); | ||
| for (const boot of this.#boots) { | ||
| if (typeof boot.snapshotDidDeserialize !== 'function') { | ||
| continue; | ||
| } | ||
| const fullPath = boot.fullPath ?? 'unknown'; | ||
| debug('trigger snapshotDidDeserialize at %o', fullPath); | ||
| const timingKey = `Snapshot Did Deserialize in ${utils.getResolvedFilename(fullPath, this.app.baseDir)}`; | ||
| this.timing.start(timingKey); | ||
| try { | ||
| await utils.callFn(boot.snapshotDidDeserialize.bind(boot)); | ||
| } catch (err) { | ||
| debug('trigger snapshotDidDeserialize error at %o, error: %s', fullPath, err); | ||
| this.emit('error', err); | ||
| } | ||
| this.timing.end(timingKey); | ||
| } | ||
| debug('trigger snapshotDidDeserialize end'); | ||
|
|
||
| // Reset ready state for the resumed lifecycle. | ||
| // In snapshot mode, ready(true) was called when loadReady completed, | ||
| // resolving the ready promise early. We need fresh ready objects so the | ||
| // resumed lifecycle (didLoad → willReady → didReady) can track properly. | ||
| // Note: keep options.snapshot = true so the constructor's stale ready | ||
| // callback (which may fire asynchronously) correctly skips triggerDidReady. | ||
| this.#snapshotBuilding = false; | ||
| this.#readyObject = new ReadyObject(); | ||
| this.#initReady(); | ||
| this.ready((err) => { | ||
| void this.triggerDidReady(err); | ||
| debug('app ready after snapshot deserialize'); | ||
| }); | ||
|
|
||
| // Resume the normal lifecycle from configDidLoad | ||
| this.triggerConfigDidLoad(); | ||
|
|
||
| // Wait for the full resumed lifecycle to complete | ||
| await this.ready(); | ||
|
Comment on lines
+410
to
+477
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard these entrypoints so they only run in the snapshot flow.
🤖 Prompt for AI Agents |
||
| } | ||
|
Comment on lines
+439
to
+478
|
||
|
|
||
| #initReady(): void { | ||
| debug('loadReady init'); | ||
| this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true }); | ||
|
|
@@ -389,6 +486,9 @@ export class Lifecycle extends EventEmitter { | |
| debug('trigger didLoad end'); | ||
| if (err) { | ||
| this.ready(err); | ||
| } else if (this.#snapshotBuilding) { | ||
| // Snapshot build: skip willReady/bootReady phases, signal ready directly | ||
| this.ready(true); | ||
| } else { | ||
| this.triggerWillReady(); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject snapshot hook failures instead of only emitting them.
Both snapshot paths emit the error and keep going. That lets snapshot build/restore continue with half-torn-down or half-restored process state, even though these hooks are the only place non-serializable resources get cleaned up and recreated. Please propagate the first failure after emitting it so callers can abort the snapshot operation.
Also applies to: 433-449
🤖 Prompt for AI Agents