From 34c94e1871da1c89b428485c96dab3aeca292a47 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 7 Oct 2026 15:33:28 +0800 Subject: [PATCH 1/2] fix(mock): create HTTP server after app is ready --- plugins/mock/src/lib/app.ts | 8 ++---- plugins/mock/src/lib/mock_http_server.ts | 8 ++---- plugins/mock/src/lib/parallel/app.ts | 6 ++-- plugins/mock/test/app.test.ts | 28 ++++++++++++++----- .../test/fixtures/parallel-framework/index.js | 6 +++- .../test/fixtures/snapshot-framework/index.js | 17 +++++++++++ 6 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 plugins/mock/test/fixtures/snapshot-framework/index.js diff --git a/plugins/mock/src/lib/app.ts b/plugins/mock/src/lib/app.ts index 8c3ff02f9a..047c99f779 100644 --- a/plugins/mock/src/lib/app.ts +++ b/plugins/mock/src/lib/app.ts @@ -99,13 +99,11 @@ class MockApplicationWorker extends Base { this[APP_INIT] = true; debug('this[APP_INIT] = true'); this.#bindEvent(); + // Wait for middleware loading before app.callback() composes the request handler. + await app.ready(); debug('http server instantiate'); createServer(app); - await app.ready(); - // emit `server` after ready: egg core registers its `once('server', ...)` - // listener inside `Application.load()` (during `app.ready()` above), and - // `onServer` reads loaded config, so the event must be emitted now rather - // than at createServer() time. Mirrors @eggjs/cluster's post-ready emit. + // Application.load() registers the onServer listener during app.ready(). if (app.server) { app.emit('server', app.server); } diff --git a/plugins/mock/src/lib/mock_http_server.ts b/plugins/mock/src/lib/mock_http_server.ts index efb0d1fd14..126d15ee03 100644 --- a/plugins/mock/src/lib/mock_http_server.ts +++ b/plugins/mock/src/lib/mock_http_server.ts @@ -12,11 +12,7 @@ export function createServer(app: any): Server { app.server = server; } } - // NOTE: don't emit the `server` event here. egg core registers its - // `once('server', ...)` listener inside `Application.load()` (during - // `app.ready()`), so emitting at server-creation time (before ready) would be - // missed and `onServer` (clientError logging / graceful / timeout / websocket) - // never runs. The caller emits `server` after `app.ready()` instead — just - // like @eggjs/cluster, which emits it after the app is ready. + // Mock startup emits `server` once after app.ready(). HTTP requests also use + // this helper to get the cached server and must not emit the event again. return server; } diff --git a/plugins/mock/src/lib/parallel/app.ts b/plugins/mock/src/lib/parallel/app.ts index 969369e77f..64086d0cf6 100644 --- a/plugins/mock/src/lib/parallel/app.ts +++ b/plugins/mock/src/lib/parallel/app.ts @@ -53,11 +53,11 @@ export class MockParallelApplication extends Base { this.__APP_INIT__ = true; debug('this[APP_INIT] = true'); this.#bindEvents(); + // Wait for middleware loading before app.callback() composes the request handler. + await app.ready(); debug('http server instantiate'); createServer(app); - await app.ready(); - // emit `server` after ready so egg core's onServer listener (registered in - // Application.load()) is wired up; createServer no longer emits it. + // Application.load() registers the onServer listener during app.ready(). if (app.server) { app.emit('server', app.server); } diff --git a/plugins/mock/test/app.test.ts b/plugins/mock/test/app.test.ts index ca1c2462b5..e0cad1f6d5 100644 --- a/plugins/mock/test/app.test.ts +++ b/plugins/mock/test/app.test.ts @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert'; +import { request } from '@eggjs/supertest'; import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; import mm, { type MockApplication } from '../src/index.ts'; @@ -81,12 +82,8 @@ describe('test/app.test.ts', { concurrent: false }, () => { }); it('should run onServer after ready (server event not lost)', async () => { - // @eggjs/mock emits the `server` event before `app.ready()`, while egg core - // registers its `once("server", ...)` listener inside Application.load() - // (during app.ready()). egg core re-emits `server` after the listener is - // registered so onServer still runs and wires up the `clientError` handler - // (plus graceful shutdown / server timeout / websocket). Regression guard: - // assert the clientError listener is wired after ready. + // Application.load() registers the server listener during app.ready(). + // Mock startup must emit server afterward so onServer attaches clientError. const baseDir = getFixtures('server'); const app = mm.app({ baseDir, @@ -104,7 +101,23 @@ describe('test/app.test.ts', { concurrent: false }, () => { } }); - it('should emit server after ready in parallel app', async () => { + it('should compose HTTP middleware after app is ready', async () => { + const app = mm.app({ + baseDir: getFixtures('app'), + framework: getFixtures('snapshot-framework'), + cache: false, + }); + try { + await app.ready(); + assert(app.server, 'app.server not exists'); + await request(app.server).get('/').expect(200, 'foo'); + await app.httpRequest().get('/').expect(200, 'foo'); + } finally { + await app.close(); + } + }); + + it('should create and emit server after ready in parallel app', async () => { // cover the parallel app worker's post-ready `server` emit (lib/parallel/app.ts) const baseDir = getFixtures('server'); let emittedServer: unknown; @@ -127,6 +140,7 @@ describe('test/app.test.ts', { concurrent: false }, () => { assert(app.server, 'app.server not exists'); assert.equal(emittedServer, app.server); assert.equal(emittedAfterReady, true); + await request(app.server).get('/').expect(200, 'ok'); } finally { await app.close(); } diff --git a/plugins/mock/test/fixtures/parallel-framework/index.js b/plugins/mock/test/fixtures/parallel-framework/index.js index 098c5dd5f8..337d679254 100644 --- a/plugins/mock/test/fixtures/parallel-framework/index.js +++ b/plugins/mock/test/fixtures/parallel-framework/index.js @@ -13,7 +13,11 @@ class Application extends EventEmitter { } callback() { - return (_req, res) => res.end('ok'); + const ready = this.readyAt; + return (_req, res) => { + res.statusCode = ready ? 200 : 503; + res.end(ready ? 'ok' : 'not ready'); + }; } async ready() { diff --git a/plugins/mock/test/fixtures/snapshot-framework/index.js b/plugins/mock/test/fixtures/snapshot-framework/index.js new file mode 100644 index 0000000000..c49b0f2ced --- /dev/null +++ b/plugins/mock/test/fixtures/snapshot-framework/index.js @@ -0,0 +1,17 @@ +const egg = require('egg'); + +class Application extends egg.Application { + callback() { + // Model middleware composition that captures the current list, as koa-compose 4.2 does. + const middleware = this.middleware; + this.middleware = [...middleware]; + try { + return super.callback(); + } finally { + this.middleware = middleware; + } + } +} + +exports.Application = Application; +exports.Agent = egg.Agent; From 5da22b589be6deb1c549d458e6115e5368293d53 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 7 Oct 2026 15:47:27 +0800 Subject: [PATCH 2/2] fix(mock): preserve error handling during startup --- plugins/mock/src/lib/app.ts | 4 +++ plugins/mock/src/lib/parallel/app.ts | 4 +++ plugins/mock/test/app.test.ts | 25 +++++++++++++++++++ .../test/fixtures/parallel-framework/index.js | 9 +++++++ 4 files changed, 42 insertions(+) diff --git a/plugins/mock/src/lib/app.ts b/plugins/mock/src/lib/app.ts index 047c99f779..6710ab4aeb 100644 --- a/plugins/mock/src/lib/app.ts +++ b/plugins/mock/src/lib/app.ts @@ -99,6 +99,10 @@ class MockApplicationWorker extends Base { this[APP_INIT] = true; debug('this[APP_INIT] = true'); this.#bindEvent(); + // Koa normally installs this in callback(), but startup can emit errors before then. + if (!app.listenerCount('error')) { + app.on('error', (app as any).onerror.bind(app)); + } // Wait for middleware loading before app.callback() composes the request handler. await app.ready(); debug('http server instantiate'); diff --git a/plugins/mock/src/lib/parallel/app.ts b/plugins/mock/src/lib/parallel/app.ts index 64086d0cf6..e09d1794fa 100644 --- a/plugins/mock/src/lib/parallel/app.ts +++ b/plugins/mock/src/lib/parallel/app.ts @@ -53,6 +53,10 @@ export class MockParallelApplication extends Base { this.__APP_INIT__ = true; debug('this[APP_INIT] = true'); this.#bindEvents(); + // Koa normally installs this in callback(), but startup can emit errors before then. + if (!app.listenerCount('error')) { + app.on('error', (app as any).onerror.bind(app)); + } // Wait for middleware loading before app.callback() composes the request handler. await app.ready(); debug('http server instantiate'); diff --git a/plugins/mock/test/app.test.ts b/plugins/mock/test/app.test.ts index e0cad1f6d5..3d948f69f1 100644 --- a/plugins/mock/test/app.test.ts +++ b/plugins/mock/test/app.test.ts @@ -173,6 +173,31 @@ describe('test/app.test.ts', { concurrent: false }, () => { await app.close(); }); + it.each([false, true])('should handle parallel startup errors (custom listener: %s)', async (customListener) => { + const error = new Error('parallel startup failed'); + const errors: Error[] = []; + const app = createParallelApp({ + baseDir: getFixtures('server'), + framework: getFixtures('parallel-framework'), + cache: false, + clean: false, + beforeInit: async (parallelApp) => { + parallelApp.options.clusterPort = 1; + parallelApp.options.readyError = error; + }, + }); + if (customListener) { + app.on('error', (err: Error) => errors.push(err)); + } + try { + await assert.rejects(app.ready(), (err) => err === error); + assert.deepEqual((app as any).errors, customListener ? [] : [error]); + assert.deepEqual(errors, customListener ? [error] : []); + } finally { + await app.close(); + } + }); + // Node.js v20: SyntaxError: Unexpected identifier 'SingleModeApplication' it.skipIf(process.version.startsWith('v20.'))('should FrameworkErrorformater work during app boot', async () => { // let logMsg = ''; diff --git a/plugins/mock/test/fixtures/parallel-framework/index.js b/plugins/mock/test/fixtures/parallel-framework/index.js index 337d679254..45f8b491df 100644 --- a/plugins/mock/test/fixtures/parallel-framework/index.js +++ b/plugins/mock/test/fixtures/parallel-framework/index.js @@ -6,6 +6,7 @@ class Application extends EventEmitter { this.options = options; this.context = {}; this.config = {}; + this.errors = []; this.messenger = { messages: [], onMessage: (msg) => this.messenger.messages.push(msg), @@ -21,9 +22,17 @@ class Application extends EventEmitter { } async ready() { + if (this.options.readyError) { + this.emit('error', this.options.readyError); + throw this.options.readyError; + } this.readyAt = true; } + onerror(err) { + this.errors.push(err); + } + async close() {} }