diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index 59ffea584f..ee8a302b02 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -405,7 +405,22 @@ export class Lifecycle extends EventEmitter { /** * Trigger snapshotWillSerialize on all boots in REVERSE order. - * Called by the build script before V8 serializes the heap. + * + * This is a general-purpose "clean up non-serializable resources" hook + * intended to run before a heap serialization mechanism captures application + * state. Each boot can implement `snapshotWillSerialize` to close file + * descriptors, drop process listeners, or release other resources that + * would otherwise prevent the heap from being serialized cleanly. + * + * NOTE: There is currently no caller that drives this hook under + * `node --build-snapshot`. A previous `buildSnapshot()` facade was removed + * because Node 22's mksnapshot blocks userland `require()` + * (MODULE_NOT_FOUND on any non-builtin) and rejects heap state containing + * non-zero async_hooks stacks — and egg's loader inherently creates async + * contexts that the event loop drain inside `SpinEventLoopInternal` cannot + * flush to depth 0. The hook API is kept as a stable abstraction for a + * future non-mksnapshot serialization mechanism (e.g. forked-worker + * snapshots) that does not have the same restrictions. */ async triggerSnapshotWillSerialize(): Promise { if (!this.options.snapshot) { diff --git a/packages/egg/src/index.ts b/packages/egg/src/index.ts index 876602d244..5e5c0c1ae8 100644 --- a/packages/egg/src/index.ts +++ b/packages/egg/src/index.ts @@ -41,9 +41,6 @@ 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/snapshot.ts b/packages/egg/src/lib/snapshot.ts deleted file mode 100644 index 093bfb2a21..0000000000 --- a/packages/egg/src/lib/snapshot.ts +++ /dev/null @@ -1,90 +0,0 @@ -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), triggers snapshotWillSerialize hooks to clean up - * non-serializable resources (file handles, timers, process listeners), - * then registers a V8 deserialize callback to stash the app for later restore. - * - * Example snapshot entry script: - * ```ts - * import { buildSnapshot } from 'egg'; - * await buildSnapshot({ baseDir: __dirname }); - * ``` - * - * Example restoring from snapshot: - * ```ts - * import { restoreSnapshot } from 'egg'; - * const app = await restoreSnapshot(); - * // app is fully restored with resources recreated, 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. - * - * Triggers the snapshotDidDeserialize lifecycle hooks to recreate - * non-serializable resources (messenger, loggers, process listeners) - * and resumes the lifecycle from configDidLoad through didReady. - * - * Returns the fully restored Application instance with all metadata - * pre-loaded (plugins, configs, extensions, services, controllers, router). - */ -export async function restoreSnapshot(): Promise { - const app = globalThis.__egg_snapshot_app as SingleModeApplication | undefined; - if (!app) { - throw new Error( - 'No egg application found in snapshot. ' + - 'Ensure the process was started from a snapshot built with buildSnapshot().', - ); - } - - // Trigger deserialize hooks to restore non-serializable resources - // (messenger, loggers, process listeners) and resume lifecycle. - if (app.agent) { - await app.agent.triggerSnapshotDidDeserialize(); - } - await app.triggerSnapshotDidDeserialize(); - - return app; -} - -interface SnapshotData { - app: SingleModeApplication; -} - -declare global { - // eslint-disable-next-line no-var - var __egg_snapshot_app: unknown; -} diff --git a/packages/egg/test/__snapshots__/index.test.ts.snap b/packages/egg/test/__snapshots__/index.test.ts.snap index c5501f38fe..996d63b34f 100644 --- a/packages/egg/test/__snapshots__/index.test.ts.snap +++ b/packages/egg/test/__snapshots__/index.test.ts.snap @@ -89,12 +89,10 @@ 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], diff --git a/packages/egg/test/snapshot.test.ts b/packages/egg/test/snapshot.test.ts index 2dc2968296..a7dbc26894 100644 --- a/packages/egg/test/snapshot.test.ts +++ b/packages/egg/test/snapshot.test.ts @@ -1,3 +1,12 @@ +// These tests exercise the snapshotWillSerialize / snapshotDidDeserialize +// lifecycle hooks under a normal Node.js process — NOT under +// `node --build-snapshot`. The facade that would invoke these hooks from a +// real V8 snapshot build (`buildSnapshot`/`restoreSnapshot`) was removed +// because it could not work under Node's mksnapshot constraints: userland +// `require()` is blocked (MODULE_NOT_FOUND), and egg's async-heavy loader +// corrupts the async_hooks stack before `SpinEventLoopInternal` can finish. +// The hooks remain as a general-purpose resource-cleanup abstraction that +// a future non-mksnapshot serialization mechanism could drive. import { strict as assert } from 'node:assert'; import path from 'node:path'; @@ -5,7 +14,6 @@ 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 } from '../src/lib/start.ts'; const fixtures = path.join(import.meta.dirname, 'fixtures'); @@ -216,13 +224,4 @@ describe('test/snapshot.test.ts', () => { await app.agent.close(); }); }); - - describe('restoreSnapshot', () => { - it('should throw when no snapshot app exists', async () => { - // Ensure no global snapshot app - globalThis.__egg_snapshot_app = undefined; - - await assert.rejects(() => restoreSnapshot(), /No egg application found in snapshot/); - }); - }); });