diff --git a/packages/core/src/egg.ts b/packages/core/src/egg.ts index c7cbe18e88..3cfccc0bcf 100644 --- a/packages/core/src/egg.ts +++ b/packages/core/src/egg.ts @@ -412,6 +412,24 @@ export class EggCore extends KoaApplication { this.lifecycle.registerBeforeClose(fn, name); } + /** + * Trigger snapshotWillSerialize lifecycle hooks on all boots in reverse order. + * Called by the build script before V8 serializes the heap. + * Cleans up non-serializable resources: file handles, timers, listeners, connections. + */ + async triggerSnapshotWillSerialize(): Promise { + return this.lifecycle.triggerSnapshotWillSerialize(); + } + + /** + * Trigger snapshotDidDeserialize lifecycle hooks on all boots in forward order. + * Called by the restore entry after V8 deserializes the heap. + * Restores non-serializable resources and resumes the lifecycle from configDidLoad. + */ + async triggerSnapshotDidDeserialize(): Promise { + return this.lifecycle.triggerSnapshotDidDeserialize(); + } + /** * Close all, it will close * - callbacks registered by beforeClose diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index 3facbe3eb5..f18a02c370 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -61,6 +61,23 @@ export interface ILifecycleBoot { * when the application is started with metadataOnly: true. */ loadMetadata?(): Promise | 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; + + /** + * 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; } export type BootImplClass = new (...args: any[]) => T; @@ -89,6 +106,7 @@ export class Lifecycle extends EventEmitter { #boots: ILifecycleBoot[]; #isClosed: boolean; #metadataOnly: boolean; + #snapshotBuilding: boolean; #closeFunctionSet: Set; 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 { + 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'); + } + + /** + * 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 { + 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(); + } + #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(); } diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index e584046f7a..9f6547d226 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -190,6 +190,461 @@ describe('test/snapshot.test.ts', () => { }); }); + describe('snapshotWillSerialize / snapshotDidDeserialize lifecycle hooks', () => { + it('should call snapshotWillSerialize in reverse registration order', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class BootA { + configWillLoad(): void { + callOrder.push('configWillLoad:A'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize:A'); + } + }, + ); + + lifecycle.addBootHook( + class BootB { + configWillLoad(): void { + callOrder.push('configWillLoad:B'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize:B'); + } + }, + ); + + lifecycle.addBootHook( + class BootC { + configWillLoad(): void { + callOrder.push('configWillLoad:C'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize:C'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + // configWillLoad runs in forward order + assert.deepEqual(callOrder, ['configWillLoad:A', 'configWillLoad:B', 'configWillLoad:C']); + + // Now trigger serialize — should be in REVERSE order + await lifecycle.triggerSnapshotWillSerialize(); + + assert.deepEqual(callOrder, [ + 'configWillLoad:A', + 'configWillLoad:B', + 'configWillLoad:C', + 'snapshotWillSerialize:C', + 'snapshotWillSerialize:B', + 'snapshotWillSerialize:A', + ]); + + await lifecycle.close(); + }); + + it('should call snapshotDidDeserialize in forward registration order', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class BootA { + configWillLoad(): void { + callOrder.push('configWillLoad:A'); + } + async snapshotDidDeserialize(): Promise { + callOrder.push('snapshotDidDeserialize:A'); + } + configDidLoad(): void { + callOrder.push('configDidLoad:A'); + } + }, + ); + + lifecycle.addBootHook( + class BootB { + configWillLoad(): void { + callOrder.push('configWillLoad:B'); + } + async snapshotDidDeserialize(): Promise { + callOrder.push('snapshotDidDeserialize:B'); + } + configDidLoad(): void { + callOrder.push('configDidLoad:B'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + // Only configWillLoad should have run (snapshot mode) + assert.deepEqual(callOrder, ['configWillLoad:A', 'configWillLoad:B']); + + // Trigger deserialize — should be in FORWARD order + // and then resume lifecycle from configDidLoad. + // triggerSnapshotDidDeserialize waits for the full lifecycle internally. + await lifecycle.triggerSnapshotDidDeserialize(); + + assert.deepEqual(callOrder, [ + 'configWillLoad:A', + 'configWillLoad:B', + 'snapshotDidDeserialize:A', + 'snapshotDidDeserialize:B', + 'configDidLoad:A', + 'configDidLoad:B', + ]); + + await lifecycle.close(); + }); + + it('should resume full lifecycle after snapshotDidDeserialize', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + callOrder.push('configWillLoad'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize'); + } + async snapshotDidDeserialize(): Promise { + callOrder.push('snapshotDidDeserialize'); + } + configDidLoad(): void { + callOrder.push('configDidLoad'); + } + async didLoad(): Promise { + callOrder.push('didLoad'); + } + async willReady(): Promise { + callOrder.push('willReady'); + } + async didReady(): Promise { + callOrder.push('didReady'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + // Phase 1: snapshot build — only configWillLoad ran + assert.deepEqual(callOrder, ['configWillLoad']); + + // Phase 2: serialize + await lifecycle.triggerSnapshotWillSerialize(); + assert.deepEqual(callOrder, ['configWillLoad', 'snapshotWillSerialize']); + + // Phase 3: deserialize — resumes lifecycle. + // triggerSnapshotDidDeserialize resets ready state, resumes from + // configDidLoad, and waits for the full lifecycle to complete. + await lifecycle.triggerSnapshotDidDeserialize(); + + // didReady fires asynchronously after ready resolves (non-blocking), + // so give it a tick to complete. + await new Promise((resolve) => process.nextTick(resolve)); + + assert.deepEqual(callOrder, [ + 'configWillLoad', + 'snapshotWillSerialize', + 'snapshotDidDeserialize', + 'configDidLoad', + 'didLoad', + 'willReady', + 'didReady', + ]); + + await lifecycle.close(); + }); + + it('should handle async hooks correctly', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + callOrder.push('configWillLoad'); + } + async snapshotWillSerialize(): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); + callOrder.push('snapshotWillSerialize:async'); + } + async snapshotDidDeserialize(): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); + callOrder.push('snapshotDidDeserialize:async'); + } + configDidLoad(): void { + callOrder.push('configDidLoad'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + await lifecycle.triggerSnapshotWillSerialize(); + assert.ok(callOrder.includes('snapshotWillSerialize:async'), 'async serialize hook should have completed'); + + await lifecycle.triggerSnapshotDidDeserialize(); + assert.ok(callOrder.includes('snapshotDidDeserialize:async'), 'async deserialize hook should have completed'); + assert.ok(callOrder.includes('configDidLoad'), 'configDidLoad should have been called after deserialize'); + + await lifecycle.close(); + }); + + it('should handle sync hooks correctly', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + callOrder.push('configWillLoad'); + } + snapshotWillSerialize(): void { + callOrder.push('snapshotWillSerialize:sync'); + } + snapshotDidDeserialize(): void { + callOrder.push('snapshotDidDeserialize:sync'); + } + configDidLoad(): void { + callOrder.push('configDidLoad'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + await lifecycle.triggerSnapshotWillSerialize(); + assert.ok(callOrder.includes('snapshotWillSerialize:sync')); + + await lifecycle.triggerSnapshotDidDeserialize(); + assert.ok(callOrder.includes('snapshotDidDeserialize:sync')); + assert.ok(callOrder.includes('configDidLoad')); + + await lifecycle.close(); + }); + + it('should emit error when snapshotWillSerialize hook throws', async () => { + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + const errors: Error[] = []; + lifecycle.on('error', (err: Error) => { + errors.push(err); + }); + + lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + // noop + } + async snapshotWillSerialize(): Promise { + throw new Error('serialize failed'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + await lifecycle.triggerSnapshotWillSerialize(); + assert.equal(errors.length, 1); + assert.equal(errors[0].message, 'serialize failed'); + + await lifecycle.close(); + }); + + it('should emit error when snapshotDidDeserialize hook throws', async () => { + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + const errors: Error[] = []; + lifecycle.on('error', (err: Error) => { + errors.push(err); + }); + + lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + // noop + } + async snapshotDidDeserialize(): Promise { + throw new Error('deserialize failed'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + await lifecycle.triggerSnapshotDidDeserialize(); + assert.ok(errors.some((e) => e.message === 'deserialize failed')); + + await lifecycle.close(); + }); + + it('should work with EggCore.triggerSnapshotWillSerialize/triggerSnapshotDidDeserialize', async () => { + const callOrder: string[] = []; + + app = new EggCore({ snapshot: true }); + + app.lifecycle.addBootHook( + class Boot { + configWillLoad(): void { + callOrder.push('configWillLoad'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize'); + } + async snapshotDidDeserialize(): Promise { + callOrder.push('snapshotDidDeserialize'); + } + configDidLoad(): void { + callOrder.push('configDidLoad'); + } + async didLoad(): Promise { + callOrder.push('didLoad'); + } + async willReady(): Promise { + callOrder.push('willReady'); + } + }, + ); + + app.lifecycle.init(); + app.lifecycle.triggerConfigWillLoad(); + await app.ready(); + + // Snapshot build phase + assert.deepEqual(callOrder, ['configWillLoad']); + + // Serialize via EggCore method + await app.triggerSnapshotWillSerialize(); + assert.deepEqual(callOrder, ['configWillLoad', 'snapshotWillSerialize']); + + // Deserialize via EggCore method — resumes lifecycle + await app.triggerSnapshotDidDeserialize(); + + assert.deepEqual(callOrder, [ + 'configWillLoad', + 'snapshotWillSerialize', + 'snapshotDidDeserialize', + 'configDidLoad', + 'didLoad', + 'willReady', + ]); + }); + + it('should skip boots that do not implement snapshot hooks', async () => { + const callOrder: string[] = []; + + const lifecycle = new Lifecycle({ + baseDir: '.', + app: new EggCore(), + snapshot: true, + }); + + lifecycle.addBootHook( + class BootWithHooks { + configWillLoad(): void { + callOrder.push('configWillLoad:with'); + } + async snapshotWillSerialize(): Promise { + callOrder.push('snapshotWillSerialize:with'); + } + async snapshotDidDeserialize(): Promise { + callOrder.push('snapshotDidDeserialize:with'); + } + configDidLoad(): void { + callOrder.push('configDidLoad:with'); + } + }, + ); + + lifecycle.addBootHook( + class BootWithoutHooks { + configWillLoad(): void { + callOrder.push('configWillLoad:without'); + } + configDidLoad(): void { + callOrder.push('configDidLoad:without'); + } + }, + ); + + lifecycle.init(); + lifecycle.triggerConfigWillLoad(); + await lifecycle.ready(); + + await lifecycle.triggerSnapshotWillSerialize(); + // Only BootWithHooks should appear in snapshot hooks + assert.deepEqual(callOrder, ['configWillLoad:with', 'configWillLoad:without', 'snapshotWillSerialize:with']); + + await lifecycle.triggerSnapshotDidDeserialize(); + + assert.deepEqual(callOrder, [ + 'configWillLoad:with', + 'configWillLoad:without', + 'snapshotWillSerialize:with', + 'snapshotDidDeserialize:with', + 'configDidLoad:with', + 'configDidLoad:without', + ]); + + await lifecycle.close(); + }); + }); + describe('EggCore snapshot option', () => { it('should pass snapshot option to lifecycle', () => { app = new EggCore({ snapshot: true }); diff --git a/packages/egg/src/index.ts b/packages/egg/src/index.ts index 5e5c0c1ae8..876602d244 100644 --- a/packages/egg/src/index.ts +++ b/packages/egg/src/index.ts @@ -41,6 +41,9 @@ export type { export * from './lib/start.ts'; +// export snapshot utilities +export * from './lib/snapshot.ts'; + // export singleton export { Singleton, type SingletonCreateMethod, type SingletonOptions } from '@eggjs/core'; diff --git a/packages/egg/src/lib/agent.ts b/packages/egg/src/lib/agent.ts index e17413fc26..1bc03ff21d 100644 --- a/packages/egg/src/lib/agent.ts +++ b/packages/egg/src/lib/agent.ts @@ -8,7 +8,7 @@ import { AgentWorkerLoader } from './loader/index.ts'; * @augments EggApplicationCore */ export class Agent extends EggApplicationCore { - readonly #agentAliveHandler: NodeJS.Timeout; + #agentAliveHandler?: NodeJS.Timeout; /** * @class @@ -20,7 +20,20 @@ export class Agent extends EggApplicationCore { type: 'agent', }); - // keep agent alive even it doesn't have any io tasks + // Register keepalive timer in configDidLoad so it is naturally skipped + // in snapshot mode (configDidLoad is not called during snapshot build). + this.lifecycle.addBootHook({ + configDidLoad: () => this.startKeepAlive(), + }); + } + + /** + * Start the keepalive timer that prevents the agent process from exiting + * when it has no pending I/O. Called from configDidLoad so that the timer + * is not created during snapshot build (configDidLoad is skipped in snapshot mode). + */ + startKeepAlive(): void { + if (this.#agentAliveHandler) return; this.#agentAliveHandler = setInterval( () => { this.coreLogger.info('[]'); @@ -52,7 +65,10 @@ export class Agent extends EggApplicationCore { } async close(): Promise { - clearInterval(this.#agentAliveHandler); + if (this.#agentAliveHandler) { + clearInterval(this.#agentAliveHandler); + this.#agentAliveHandler = undefined; + } await super.close(); } } diff --git a/packages/egg/src/lib/egg.ts b/packages/egg/src/lib/egg.ts index d6a6ed21c2..a9b2660070 100644 --- a/packages/egg/src/lib/egg.ts +++ b/packages/egg/src/lib/egg.ts @@ -131,8 +131,17 @@ export class EggApplicationCore extends EggCore { #httpClient?: HttpClient; #loggers?: EggLoggers; #clusterClients: any[] = []; + #loadFinishedResolve!: () => void; + #loadFinishedReject!: (err: unknown) => void; - readonly messenger: IMessenger; + /** + * Promise that resolves when the `load()` method has finished. + * In snapshot mode the lifecycle becomes ready before loading completes, + * so callers (e.g. `startEgg({ snapshot: true })`) must await this instead of `ready()`. + */ + readonly loadFinished: Promise; + + messenger: IMessenger; agent?: Agent; application?: Application; declare loader: EggApplicationLoader; @@ -153,6 +162,12 @@ export class EggApplicationCore extends EggCore { ...options, }; super(options); + + this.loadFinished = new Promise((resolve, reject) => { + this.#loadFinishedResolve = resolve; + this.#loadFinishedReject = reject; + }); + /** * messenger instance * @member {Messenger} @@ -165,8 +180,26 @@ export class EggApplicationCore extends EggCore { this.messenger.once('egg-ready', () => { this.lifecycle.triggerServerDidReady(); }); + + // Register snapshot lifecycle hooks for non-serializable resources: + // - Messenger: holds process listeners (IPC) that cannot survive serialization + // - Loggers: hold file descriptors + // - unhandledRejection handler: process-level listener + // All are cleaned up during serialize and restored during deserialize, + // avoiding scattered `if (snapshot)` guards throughout the codebase. + this.lifecycle.addBootHook({ + snapshotWillSerialize: () => this.snapshotWillSerialize(), + snapshotDidDeserialize: () => this.snapshotDidDeserialize(), + }); + this.lifecycle.registerBeforeStart(async () => { - await this.load(); + try { + await this.load(); + this.#loadFinishedResolve(); + } catch (err) { + this.#loadFinishedReject(err); + throw err; + } }, 'load files'); } @@ -465,6 +498,37 @@ export class EggApplicationCore extends EggCore { this.coreLogger.error(err); } + /** + * Clean up non-serializable resources before V8 heap serialization. + * Closes messenger (IPC listeners), loggers (file descriptors), + * and removes the process-level unhandledRejection listener. + */ + protected snapshotWillSerialize(): void { + this.messenger.close(); + if (this.#loggers) { + for (const logger of this.#loggers.values()) { + logger.close(); + } + this.#loggers = undefined; + } + process.removeListener('unhandledRejection', this._unhandledRejectionHandler); + } + + /** + * Restore non-serializable resources after V8 heap deserialization. + * Recreates messenger, re-registers the egg-ready listener, + * and re-attaches the process-level unhandledRejection listener. + * Loggers are lazily re-created via the `loggers` getter. + */ + protected snapshotDidDeserialize(): void { + this.messenger = createMessenger(this); + this.messenger.once('egg-ready', () => { + this.lifecycle.triggerServerDidReady(); + }); + this._unhandledRejectionHandler = this._unhandledRejectionHandler.bind(this); + process.on('unhandledRejection', this._unhandledRejectionHandler); + } + /** * dump out the config and meta object * @private diff --git a/packages/egg/src/lib/snapshot.ts b/packages/egg/src/lib/snapshot.ts new file mode 100644 index 0000000000..f36415f1e6 --- /dev/null +++ b/packages/egg/src/lib/snapshot.ts @@ -0,0 +1,79 @@ +import v8 from 'node:v8'; + +import type { Application } from './application.ts'; +import { startEgg, type StartEggOptions, type SingleModeApplication } from './start.ts'; + +/** + * Build a V8 startup snapshot of an egg application. + * + * Call this from the snapshot entry script passed to + * `node --snapshot-blob=snapshot.blob --build-snapshot snapshot_entry.js`. + * + * It loads all metadata (plugins, configs, extensions, services, controllers, + * router, tegg modules) without creating servers, timers, or connections, + * then registers serialize/deserialize callbacks with the V8 snapshot API. + * + * Example snapshot entry script: + * ```ts + * import { buildSnapshot } from 'egg'; + * await buildSnapshot({ baseDir: __dirname }); + * ``` + * + * Example restoring from snapshot: + * ```ts + * import { restoreSnapshot } from 'egg'; + * const app = restoreSnapshot(); + * // app is fully loaded with metadata, ready for server creation + * ``` + */ +export async function buildSnapshot( + options: Pick = {}, +): Promise { + const app = await startEgg({ ...options, snapshot: true }); + + // Use lifecycle hooks to clean up non-serializable resources (file handles, + // timers, process listeners) before snapshot and restore them after deserialize. + // The hooks are registered internally by Agent and EggApplicationCore constructors. + if (app.agent) { + await app.agent.triggerSnapshotWillSerialize(); + } + await app.triggerSnapshotWillSerialize(); + + v8.startupSnapshot.setDeserializeMainFunction( + (snapshotData: SnapshotData) => { + // This function runs when restoring from snapshot. + // The application object is available via snapshotData. + // Users should call restoreSnapshot() to get it. + globalThis.__egg_snapshot_app = snapshotData.app; + }, + { app } as SnapshotData, + ); +} + +/** + * Restore an egg application from a V8 startup snapshot. + * + * Returns the Application instance that was captured during snapshot + * construction. The application has all metadata pre-loaded (plugins, + * configs, extensions, services, controllers, router). Loggers and + * messenger have been automatically re-created by the deserialize callbacks. + */ +export function restoreSnapshot(): Application { + const app = globalThis.__egg_snapshot_app; + if (!app) { + throw new Error( + 'No egg application found in snapshot. ' + + 'Ensure the process was started from a snapshot built with buildSnapshot().', + ); + } + return app as Application; +} + +interface SnapshotData { + app: SingleModeApplication; +} + +declare global { + // eslint-disable-next-line no-var + var __egg_snapshot_app: unknown; +} diff --git a/packages/egg/src/lib/start.ts b/packages/egg/src/lib/start.ts index 4ed492f66a..eb727bcaf9 100644 --- a/packages/egg/src/lib/start.ts +++ b/packages/egg/src/lib/start.ts @@ -20,8 +20,17 @@ export interface StartEggOptions { plugins?: EggPlugin; /** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */ metadataOnly?: boolean; + /** + * When true, load application metadata for V8 startup snapshot construction. + * The lifecycle stops after configWillLoad (no servers, timers, or connections) + * and skips `egg-ready` broadcast. `ready()` resolves when loading completes. + */ + snapshot?: boolean; } +/** @deprecated Use `StartEggOptions` with `snapshot: true` instead. */ +export type SnapshotEggOptions = Pick; + export interface SingleModeApplication extends Application { agent: SingleModeAgent; } @@ -30,15 +39,12 @@ export interface SingleModeAgent extends Agent { app: SingleModeApplication; } -/** - * Start egg with single process - */ -export async function startEgg(options: StartEggOptions = {}): Promise { - options.baseDir = options.baseDir ?? process.cwd(); - options.mode = 'single'; - ManifestStore.enableCompileCache(options.baseDir); +interface FrameworkClasses { + AgentClass: typeof Agent; + ApplicationClass: typeof Application; +} - // get agent from options.framework and package.egg.framework +async function resolveFrameworkClasses(options: { framework?: string; baseDir: string }): Promise { if (!options.framework) { try { const pkg = await readJSON(path.join(options.baseDir, 'package.json')); @@ -47,8 +53,8 @@ export async function startEgg(options: StartEggOptions = {}): Promise { + options.baseDir = options.baseDir ?? process.cwd(); + options.mode = 'single'; + + if (!options.snapshot) { + ManifestStore.enableCompileCache(options.baseDir); + } + + const { AgentClass, ApplicationClass } = await resolveFrameworkClasses( + options as { framework?: string; baseDir: string }, + ); // In metadataOnly mode, skip agent entirely — only app metadata is needed let agent: SingleModeAgent | undefined; @@ -73,11 +100,21 @@ export async function startEgg(options: StartEggOptions = {}): Promise { + return startEgg({ ...options, snapshot: true }); +} diff --git a/packages/egg/test/__snapshots__/index.test.ts.snap b/packages/egg/test/__snapshots__/index.test.ts.snap index 996d63b34f..ac0e0c3733 100644 --- a/packages/egg/test/__snapshots__/index.test.ts.snap +++ b/packages/egg/test/__snapshots__/index.test.ts.snap @@ -89,12 +89,15 @@ exports[`should expose properties 1`] = ` "Singleton": [Function], "SingletonProto": [Function], "Subscription": [Function], + "buildSnapshot": [Function], "createTransparentProxy": [Function], "defineConfig": [Function], "defineConfigFactory": [Function], "definePluginFactory": [Function], + "restoreSnapshot": [Function], "start": [Function], "startCluster": [Function], "startEgg": [Function], + "startEggForSnapshot": [Function], } `; diff --git a/packages/egg/test/snapshot.test.ts b/packages/egg/test/snapshot.test.ts new file mode 100644 index 0000000000..5452712d8d --- /dev/null +++ b/packages/egg/test/snapshot.test.ts @@ -0,0 +1,240 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { describe, it, afterEach } from 'vitest'; + +import { Agent } from '../src/lib/agent.ts'; +import { Application } from '../src/lib/application.ts'; +import { restoreSnapshot } from '../src/lib/snapshot.ts'; +import { startEgg, startEggForSnapshot } from '../src/lib/start.ts'; + +const fixtures = path.join(import.meta.dirname, 'fixtures'); +const demoApp = path.join(fixtures, 'apps/demo'); + +describe('test/snapshot.test.ts', () => { + describe('Agent keepalive timer', () => { + let agent: Agent | undefined; + + afterEach(async () => { + if (agent) { + await agent.close(); + agent = undefined; + } + }); + + it('should NOT start keepalive timer in snapshot mode (configDidLoad skipped)', async () => { + agent = new Agent({ + baseDir: demoApp, + snapshot: true, + }); + await agent.ready(); + + // In snapshot mode, configDidLoad is skipped, so the boot hook that + // calls startKeepAlive() never fires. close() should work cleanly. + await agent.close(); + agent = undefined; + }); + + it('should start keepalive timer in normal mode (configDidLoad runs)', async () => { + agent = new Agent({ + baseDir: demoApp, + mode: 'single', + }); + await agent.ready(); + + // In normal mode, configDidLoad runs, so startKeepAlive() is called. + // Calling startKeepAlive() again should be a no-op (idempotent guard). + agent.startKeepAlive(); + + // close() should clear the interval without error + await agent.close(); + agent = undefined; + }); + + it('should make startKeepAlive idempotent', async () => { + agent = new Agent({ + baseDir: demoApp, + snapshot: true, + }); + await agent.ready(); + + // Manually call startKeepAlive multiple times — should not create multiple timers + agent.startKeepAlive(); + agent.startKeepAlive(); + agent.startKeepAlive(); + + // close() clears only one interval + await agent.close(); + agent = undefined; + }); + }); + + describe('Snapshot lifecycle hooks', () => { + let app: Application | undefined; + + afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + }); + + it('should close messenger on snapshotWillSerialize', async () => { + app = new Application({ + baseDir: demoApp, + mode: 'single', + snapshot: true, + }); + await app.ready(); + + const originalMessenger = app.messenger; + + // Trigger serialize — should close the messenger + await app.triggerSnapshotWillSerialize(); + + // After serialize, the messenger is closed (removeAllListeners was called). + assert.equal(originalMessenger.listenerCount('egg-ready'), 0); + }); + + it('should recreate messenger on snapshotDidDeserialize', async () => { + app = new Application({ + baseDir: demoApp, + mode: 'single', + snapshot: true, + }); + await app.ready(); + + const originalMessenger = app.messenger; + + // Serialize + await app.triggerSnapshotWillSerialize(); + + // Deserialize — should recreate messenger and resume lifecycle + await app.triggerSnapshotDidDeserialize(); + await new Promise((resolve) => process.nextTick(resolve)); + + // Messenger should be a new instance + assert.notStrictEqual(app.messenger, originalMessenger); + // New messenger should have egg-ready listener + assert.ok(app.messenger.listenerCount('egg-ready') >= 1, 'new messenger should have egg-ready listener'); + }); + + it('should clean up loggers on snapshotWillSerialize', async () => { + app = new Application({ + baseDir: demoApp, + mode: 'single', + snapshot: true, + }); + await app.ready(); + + // Access loggers to force lazy creation + const _loggers = app.loggers; + assert.ok(_loggers, 'loggers should exist'); + + await app.triggerSnapshotWillSerialize(); + + // After serialize, loggers are cleared (set to undefined internally). + // Accessing loggers again would re-create them lazily. + // We can't directly check the private #loggers field, but we can verify + // that new loggers are created after deserialize. + await app.triggerSnapshotDidDeserialize(); + await new Promise((resolve) => process.nextTick(resolve)); + + // After deserialize, loggers should be lazily re-created on access + assert.ok(app.loggers, 'loggers should be lazily re-created'); + }); + + it('should remove unhandledRejection handler on serialize and restore on deserialize', async () => { + app = new Application({ + baseDir: demoApp, + mode: 'single', + snapshot: true, + }); + await app.ready(); + // load() runs in background via registerBeforeStart, wait for it + await app.loadFinished; + + // After load(), unhandledRejection handler is registered + const handlersBefore = process.listeners('unhandledRejection'); + const hasBoundHandler = handlersBefore.some((fn) => fn === app!._unhandledRejectionHandler); + assert.ok(hasBoundHandler, 'unhandledRejection handler should be registered after load'); + + await app.triggerSnapshotWillSerialize(); + + // After serialize, handler should be removed + const handlersAfterSerialize = process.listeners('unhandledRejection'); + const stillHasHandler = handlersAfterSerialize.some((fn) => fn === app!._unhandledRejectionHandler); + assert.ok(!stillHasHandler, 'unhandledRejection handler should be removed after serialize'); + + await app.triggerSnapshotDidDeserialize(); + await new Promise((resolve) => process.nextTick(resolve)); + + // After deserialize, handler should be re-registered + const handlersAfterDeserialize = process.listeners('unhandledRejection'); + const hasRestoredHandler = handlersAfterDeserialize.some((fn) => fn === app!._unhandledRejectionHandler); + assert.ok(hasRestoredHandler, 'unhandledRejection handler should be restored after deserialize'); + }); + }); + + describe('loadFinished promise', () => { + it('should resolve after load() completes', async () => { + const app = new Application({ + baseDir: demoApp, + mode: 'single', + snapshot: true, + }); + // In snapshot mode, ready() resolves before load() finishes + await app.ready(); + + // loadFinished should resolve when load() fully completes + await app.loadFinished; + + // After loadFinished, config should be loaded + assert.ok(app.config, 'config should be available after loadFinished'); + assert.ok(app.config.logger, 'logger config should be available'); + + await app.close(); + }); + }); + + describe('startEgg with snapshot option', () => { + it('should load app and agent in snapshot mode via startEgg', async () => { + const app = await startEgg({ baseDir: demoApp, snapshot: true }); + + // Application and agent should exist + assert.ok(app, 'application should exist'); + assert.ok(app.agent, 'agent should exist'); + + // Config should be loaded (load() completed via loadFinished) + assert.ok(app.config, 'config should be available'); + assert.ok(app.config.env, 'env should be set'); + + // Both should be in snapshot mode + assert.equal(app.options.snapshot, true); + assert.equal(app.agent.options.snapshot, true); + + await app.close(); + await app.agent.close(); + }); + + it('should work via deprecated startEggForSnapshot wrapper', async () => { + const app = await startEggForSnapshot({ baseDir: demoApp }); + + assert.ok(app, 'application should exist'); + assert.ok(app.agent, 'agent should exist'); + assert.equal(app.options.snapshot, true); + + await app.close(); + await app.agent.close(); + }); + }); + + describe('restoreSnapshot', () => { + it('should throw when no snapshot app exists', () => { + // Ensure no global snapshot app + globalThis.__egg_snapshot_app = undefined; + + assert.throws(() => restoreSnapshot(), /No egg application found in snapshot/); + }); + }); +});