diff --git a/.eslintrc b/.eslintrc index 9441b72..9bcdb46 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { - "extends": "eslint-config-egg", - "parserOptions": { - "ecmaVersion": 13 - } + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 48f9944..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,24 +0,0 @@ - - -##### Checklist - - -- [ ] `npm test` passes -- [ ] tests and/or benchmarks are included -- [ ] documentation is changed or added -- [ ] commit message follows commit guidelines - -##### Affected core subsystem(s) - - - -##### Description of change - diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c8630f5..8186fa7 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -12,6 +12,6 @@ jobs: uses: node-modules/github-actions/.github/workflows/node-test.yml@master with: os: 'ubuntu-latest, macos-latest' - version: '14, 16, 18, 20, 22' + version: '18.19.0, 18, 20, 22, 23' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml new file mode 100644 index 0000000..bac3fac --- /dev/null +++ b/.github/workflows/pkg.pr.new.yml @@ -0,0 +1,23 @@ +name: Publish Any Commit +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run prepublishOnly --if-present + + - run: npx pkg-pr-new publish diff --git a/.gitignore b/.gitignore index 7f075b6..e1e7ef8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ run .nyc_output package-lock.json .package-lock.json +.tshy* +.eslintcache +dist +coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 40246ff..01083b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## [3.0.1](https://github.com/eggjs/cluster/compare/v3.0.0...v3.0.1) (2024-12-30) + + +### Bug Fixes + +* require support paths ([#118](https://github.com/eggjs/cluster/issues/118)) ([b74872c](https://github.com/eggjs/cluster/commit/b74872c1625e7a6c3ee58a3cc468fdae43a9b000)) + +## [3.0.0](https://github.com/eggjs/cluster/compare/v2.4.0...v3.0.0) (2024-12-28) + + +### ⚠ BREAKING CHANGES + +* drop Node.js < 18.19.0 support + +part of https://github.com/eggjs/egg/issues/3644 + + +## Summary by CodeRabbit + +## Release Notes for @eggjs/cluster v3.0.0-beta.4 + +- **New Features** + - Migrated project to TypeScript. + - Added support for Node.js 18.19.0, 20, 22, and 23. + - Enhanced type safety and module exports. + - Improved worker thread and process management. + - Introduced new error handling classes for better debugging. + +- **Breaking Changes** + - Renamed package from `egg-cluster` to `@eggjs/cluster`. + - Updated import/export syntax to ES modules. + - Minimum Node.js version is now 18.19.0. + +- **Performance Improvements** + - Refactored cluster and worker management. + - Optimized error handling and logging. + +- **Bug Fixes** + - Resolved various edge cases in worker initialization. + - Improved graceful shutdown mechanisms. + +- **Documentation** + - Updated README with new package name and usage examples. + - Added TypeScript and ESM import examples. + + +### Features + +* support cjs and esm both by tshy ([#117](https://github.com/eggjs/cluster/issues/117)) ([e15a4bf](https://github.com/eggjs/cluster/commit/e15a4bf45682609f9362eef485e9fc87d916d2a0)) + ## [2.4.0](https://github.com/eggjs/egg-cluster/compare/v2.3.0...v2.4.0) (2024-12-09) diff --git a/README.md b/README.md index 2d800f0..4a2597f 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,62 @@ # egg-cluster [![NPM version][npm-image]][npm-url] -[![CI](https://github.com/eggjs/egg-cluster/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/egg-cluster/actions/workflows/nodejs.yml) +[![CI](https://github.com/eggjs/cluster/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/cluster/actions/workflows/nodejs.yml) [![Test coverage][codecov-image]][codecov-url] [![Known Vulnerabilities][snyk-image]][snyk-url] [![npm download][download-image]][download-url] +[![Node.js Version](https://img.shields.io/node/v/@eggjs/cluster.svg?style=flat)](https://nodejs.org/en/download/) -[npm-image]: https://img.shields.io/npm/v/egg-cluster.svg?style=flat-square -[npm-url]: https://npmjs.org/package/egg-cluster -[codecov-image]: https://codecov.io/github/eggjs/egg-cluster/coverage.svg?branch=master -[codecov-url]: https://codecov.io/github/eggjs/egg-cluster?branch=master -[snyk-image]: https://snyk.io/test/npm/egg-cluster/badge.svg?style=flat-square -[snyk-url]: https://snyk.io/test/npm/egg-cluster -[download-image]: https://img.shields.io/npm/dm/egg-cluster.svg?style=flat-square -[download-url]: https://npmjs.org/package/egg-cluster +[npm-image]: https://img.shields.io/npm/v/@eggjs/cluster.svg?style=flat-square +[npm-url]: https://npmjs.org/package/@eggjs/cluster +[codecov-image]: https://codecov.io/github/eggjs/cluster/coverage.svg?branch=master +[codecov-url]: https://codecov.io/github/eggjs/cluster?branch=master +[snyk-image]: https://snyk.io/test/npm/@eggjs/cluster/badge.svg?style=flat-square +[snyk-url]: https://snyk.io/test/npm/@eggjs/cluster +[download-image]: https://img.shields.io/npm/dm/@eggjs/cluster.svg?style=flat-square +[download-url]: https://npmjs.org/package/@eggjs/cluster Cluster Manager for EggJS ---- - ## Install ```bash -npm i egg-cluster --save +npm i @eggjs/cluster ``` ## Usage +CommonJS + ```js -const startCluster = require('egg-cluster').startCluster; +const { startCluster } = require('@eggjs/cluster'); + startCluster({ baseDir: '/path/to/app', framework: '/path/to/framework', }); ``` -You can specify a callback that will be invoked when application has started. However, master process will exit when catch an error. +You can specify a callback that will be invoked when application has started. +However, master process will exit when catch an error. ```js -startCluster(options, () => { +startCluster(options).then(() => { console.log('started'); }); ``` +ESM and TypeScript + +```ts +import { startCluster } from '@eggjs/cluster'; + +startCluster({ + baseDir: '/path/to/app', + framework: '/path/to/framework', +}); +``` + ## Options | Param | Type | Description | @@ -63,9 +77,9 @@ startCluster(options, () => { ## Env -EGG_APP_CLOSE_TIMEOUT: app worker boot timeout value +`EGG_APP_CLOSE_TIMEOUT`: app worker boot timeout value -EGG_AGENT_CLOSE_TIMEOUT: agent worker boot timeout value +`EGG_AGENT_CLOSE_TIMEOUT`: agent worker boot timeout value ## License @@ -73,6 +87,6 @@ EGG_AGENT_CLOSE_TIMEOUT: agent worker boot timeout value ## Contributors -[![Contributors](https://contrib.rocks/image?repo=eggjs/egg-cluster)](https://github.com/eggjs/egg-cluster/graphs/contributors) +[![Contributors](https://contrib.rocks/image?repo=eggjs/cluster)](https://github.com/eggjs/cluster/graphs/contributors) Made with [contributors-img](https://contrib.rocks). diff --git a/index.js b/index.js deleted file mode 100644 index b355ff6..0000000 --- a/index.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const Master = require('./lib/master'); - -/** - * cluster start flow: - * - * [startCluster] -> master -> agent_worker -> new [Agent] -> agentWorkerLoader - * `-> app_worker -> new [Application] -> appWorkerLoader - * - */ - -/** - * start egg app - * @function Egg#startCluster - * @param {Object} options {@link Master} - * @param {Function} callback start success callback - */ -exports.startCluster = function(options, callback) { - new Master(options).ready(callback); -}; diff --git a/lib/agent_worker.js b/lib/agent_worker.js deleted file mode 100644 index 678e70b..0000000 --- a/lib/agent_worker.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -/** - * agent worker is child_process forked by master. - * - * agent worker only exit in two cases: - * - receive signal SIGTERM, exit code 0 (exit gracefully) - * - receive disconnect event, exit code 110 (maybe master exit in accident) - */ - -// $ node agent_worker.js options -const options = JSON.parse(process.argv[2]); -if (options.require) { - // inject - options.require.forEach(mod => { - require(mod); - }); -} - -let AgentWorker; -if (options.startMode === 'worker_threads') { - AgentWorker = require('./utils/mode/impl/worker_threads/agent').AgentWorker; -} else { - AgentWorker = require('./utils/mode/impl/process/agent').AgentWorker; -} - -const debug = require('util').debuglog('egg-cluster'); -const ConsoleLogger = require('egg-logger').EggConsoleLogger; -const consoleLogger = new ConsoleLogger({ level: process.env.EGG_AGENT_WORKER_LOGGER_LEVEL }); - -const Agent = require(options.framework).Agent; -debug('new Agent with options %j', options); -let agent; -try { - agent = new Agent(options); -} catch (err) { - consoleLogger.error(err); - throw err; -} - -function startErrorHandler(err) { - consoleLogger.error(err); - consoleLogger.error('[agent_worker] start error, exiting with code:1'); - AgentWorker.kill(); -} - -agent.ready(err => { - // don't send started message to master when start error - if (err) return; - - agent.removeListener('error', startErrorHandler); - AgentWorker.send({ action: 'agent-start', to: 'master' }); -}); - -// exit if agent start error -agent.once('error', startErrorHandler); - -AgentWorker.gracefulExit({ - logger: consoleLogger, - label: 'agent_worker', - beforeExit: () => agent.close(), -}); diff --git a/lib/app_worker.js b/lib/app_worker.js deleted file mode 100644 index dabbbbc..0000000 --- a/lib/app_worker.js +++ /dev/null @@ -1,146 +0,0 @@ -'use strict'; - -// $ node app_worker.js options -const options = JSON.parse(process.argv[2]); -if (options.require) { - // inject - options.require.forEach(mod => { - require(mod); - }); -} - -let AppWorker; -if (options.startMode === 'worker_threads') { - AppWorker = require('./utils/mode/impl/worker_threads/app').AppWorker; -} else { - AppWorker = require('./utils/mode/impl/process/app').AppWorker; -} - -const fs = require('fs'); -const debug = require('util').debuglog('egg-cluster'); -const ConsoleLogger = require('egg-logger').EggConsoleLogger; -const consoleLogger = new ConsoleLogger({ - level: process.env.EGG_APP_WORKER_LOGGER_LEVEL, -}); -const Application = require(options.framework).Application; -debug('new Application with options %j', options); -let app; -try { - app = new Application(options); -} catch (err) { - consoleLogger.error(err); - throw err; -} -const clusterConfig = app.config.cluster || /* istanbul ignore next */ {}; -const listenConfig = clusterConfig.listen || /* istanbul ignore next */ {}; -const httpsOptions = Object.assign({}, clusterConfig.https, options.https); -const port = options.port = options.port || listenConfig.port; -const debugPort = options.debugPort; -const protocol = (httpsOptions.key && httpsOptions.cert) ? 'https' : 'http'; - -AppWorker.send({ - to: 'master', - action: 'realport', - data: { - port, - protocol, - }, -}); - -app.ready(startServer); - -function exitProcess() { - // Use SIGTERM kill process, ensure trigger the gracefulExit - AppWorker.kill(); -} - -// exit if worker start timeout -app.once('startTimeout', startTimeoutHandler); - -function startTimeoutHandler() { - consoleLogger.error('[app_worker] start timeout, exiting with code:1'); - exitProcess(); -} - -function startServer(err) { - if (err) { - consoleLogger.error(err); - consoleLogger.error('[app_worker] start error, exiting with code:1'); - exitProcess(); - return; - } - - app.removeListener('startTimeout', startTimeoutHandler); - - let server; - let debugPortServer; - - // https config - if (httpsOptions.key && httpsOptions.cert) { - httpsOptions.key = fs.readFileSync(httpsOptions.key); - httpsOptions.cert = fs.readFileSync(httpsOptions.cert); - httpsOptions.ca = httpsOptions.ca && fs.readFileSync(httpsOptions.ca); - server = require('https').createServer(httpsOptions, app.callback()); - if (debugPort) { - debugPortServer = require('http').createServer(app.callback()); - } - } else { - server = require('http').createServer(app.callback()); - if (debugPort) { - debugPortServer = server; - } - } - - server.once('error', err => { - consoleLogger.error('[app_worker] server got error: %s, code: %s', err.message, err.code); - exitProcess(); - }); - - // emit `server` event in app - app.emit('server', server); - - if (options.sticky) { - server.listen(options.stickyWorkerPort, '127.0.0.1'); - // Listen to messages sent from the master. Ignore everything else. - AppWorker.on('message', (message, connection) => { - if (message !== 'sticky-session:connection') { - return; - } - - // Emulate a connection event on the server by emitting the - // event with the connection the master sent us. - server.emit('connection', connection); - connection.resume(); - }); - } else { - if (listenConfig.path) { - server.listen(listenConfig.path); - } else { - if (typeof port !== 'number') { - consoleLogger.error('[app_worker] port should be number, but got %s(%s)', port, typeof port); - exitProcess(); - return; - } - const args = [ port ]; - if (listenConfig.hostname) args.push(listenConfig.hostname); - debug('listen options %s', args); - server.listen(...args); - } - if (debugPortServer) { - debug('listen on debug port: %s', debugPort); - debugPortServer.listen(debugPort); - } - } - - AppWorker.send({ - to: 'master', - action: 'listening', - data: server.address() || { port }, - }); -} - -AppWorker.gracefulExit({ - logger: consoleLogger, - label: 'app_worker', - beforeExit: () => app.close(), -}); diff --git a/lib/utils/mode/base/agent.js b/lib/utils/mode/base/agent.js deleted file mode 100644 index 8e95499..0000000 --- a/lib/utils/mode/base/agent.js +++ /dev/null @@ -1,79 +0,0 @@ -/* istanbul ignore file */ -'use strict'; - -const path = require('path'); -const EventEmitter = require('events').EventEmitter; - -class BaseAgentWorker { - constructor(instance) { - this.instance = instance; - } - - get workerId() { - throw new Error('BaseAgentWorker should implement getter workerId.'); - } - - get id() { - return this.instance.id; - } - - get status() { - return this.instance.status; - } - - set id(id) { - this.instance.id = id; - } - - set status(status) { - this.instance.status = status; - } - - send() { - throw new Error('BaseAgentWorker should implement send.'); - } - - static send() { - throw new Error('BaseAgentWorker should implement send.'); - } - - static kill() { - throw new Error('BaseAgentWorker should implement kill.'); - } - - static gracefulExit() { - throw new Error('BaseAgentWorker should implement gracefulExit.'); - } -} - -class BaseAgentUtils extends EventEmitter { - constructor(options, { log, logger, messenger }) { - super(); - this.options = options; - this.log = log; - this.logger = logger; - this.messenger = messenger; - - // public attrs - this.startTime = 0; - this.instance = null; - } - - getAgentWorkerFile() { - return path.join(__dirname, '../../../agent_worker.js'); - } - - fork() { - throw new Error('BaseAgent should implement fork.'); - } - - clean() { - throw new Error('BaseAgent should implement clean.'); - } - - kill() { - throw new Error('BaseAgent should implement kill.'); - } -} - -module.exports = { BaseAgentWorker, BaseAgentUtils }; diff --git a/lib/utils/mode/base/app.js b/lib/utils/mode/base/app.js deleted file mode 100644 index 464f313..0000000 --- a/lib/utils/mode/base/app.js +++ /dev/null @@ -1,101 +0,0 @@ -/* istanbul ignore file */ -'use strict'; - -const path = require('path'); -const EventEmitter = require('events').EventEmitter; - -class BaseAppWorker { - constructor(instance) { - this.instance = instance; - } - - get id() { - throw new Error('BaseAppWorker should implement getter id.'); - } - - get workerId() { - throw new Error('BaseAppWorker should implement getter workerId.'); - } - - get state() { - throw new Error('BaseAppWorker should implement getter state.'); - } - - get exitedAfterDisconnect() { - throw new Error('BaseAppWorker should implement getter exitedAfterDisconnect.'); - } - - get exitCode() { - throw new Error('BaseAppWorker should implement getter exitCode.'); - } - - get disableRefork() { - return this.instance.disableRefork; - } - - get isDevReload() { - return this.instance.isDevReload; - } - - set disableRefork(status) { - this.instance.disableRefork = status; - } - - set isDevReload(status) { - this.instance.isDevReload = status; - } - - send() { - throw new Error('BaseAppWorker should implement send.'); - } - - clean() { - throw new Error('BaseAppWorker should implement clean.'); - } - - static on() { - throw new Error('BaseAppWorker should implement on.'); - } - - static send() { - throw new Error('BaseAppWorker should implement send.'); - } - - static kill() { - throw new Error('BaseAppWorker should implement kill.'); - } - - static gracefulExit() { - throw new Error('BaseAppWorker should implement gracefulExit.'); - } -} - -class BaseAppUtils extends EventEmitter { - constructor(options, { log, logger, messenger, isProduction }) { - super(); - this.options = options; - this.log = log; - this.logger = logger; - this.messenger = messenger; - this.isProduction = isProduction; - - // public attrs - this.startTime = 0; - this.startSuccessCount = 0; - this.isAllWorkerStarted = false; - } - - getAppWorkerFile() { - return path.join(__dirname, '../../../app_worker.js'); - } - - fork() { - throw new Error('BaseApp should implement fork.'); - } - - kill() { - throw new Error('BaseApp should implement kill.'); - } -} - -module.exports = { BaseAppWorker, BaseAppUtils }; diff --git a/lib/utils/mode/impl/process/agent.js b/lib/utils/mode/impl/process/agent.js deleted file mode 100644 index ad39656..0000000 --- a/lib/utils/mode/impl/process/agent.js +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -const childprocess = require('child_process'); -const sendmessage = require('sendmessage'); -const gracefulExit = require('graceful-process'); - -const { BaseAgentWorker, BaseAgentUtils } = require('../../base/agent'); -const terminate = require('../../../terminate'); - -class AgentWorker extends BaseAgentWorker { - get workerId() { - return this.instance.pid; - } - - send(...args) { - sendmessage(this.instance, ...args); - } - - static send(data) { - process.send(data); - } - - static kill() { - process.exitCode = 1; - process.kill(process.pid); - } - - static gracefulExit(options) { - gracefulExit(options); - } -} - -class AgentUtils extends BaseAgentUtils { - #worker = null; - #id = 0; - - fork() { - this.startTime = Date.now(); - - const args = [ JSON.stringify(this.options) ]; - const opt = {}; - - if (process.platform === 'win32') opt.windowsHide = true; - - // add debug execArgv - const debugPort = process.env.EGG_AGENT_DEBUG_PORT || 5800; - if (this.options.isDebug) { - opt.execArgv = process.execArgv.concat([ `--inspect-port=${debugPort}` ]); - } - - const worker = this.#worker = childprocess.fork(this.getAgentWorkerFile(), args, opt); - const agentWorker = this.instance = new AgentWorker(worker); - this.emit('agent_forked', agentWorker); - agentWorker.status = 'starting'; - agentWorker.id = ++this.#id; - this.log('[master] agent_worker#%s:%s start with clusterPort:%s', - agentWorker.id, agentWorker.workerId, this.options.clusterPort); - - // send debug message - if (this.options.isDebug) { - this.messenger.send({ - to: 'parent', - from: 'agent', - action: 'debug', - data: { - debugPort, - pid: agentWorker.workerId, - }, - }); - } - // forwarding agent' message to messenger - worker.on('message', msg => { - if (typeof msg === 'string') { - msg = { - action: msg, - data: msg, - }; - } - msg.from = 'agent'; - this.messenger.send(msg); - }); - worker.on('error', err => { - err.name = 'AgentWorkerError'; - err.id = worker.id; - err.pid = agentWorker.workerId; - this.logger.error(err); - }); - // agent exit message - worker.once('exit', (code, signal) => { - this.messenger.send({ - action: 'agent-exit', - data: { - code, - signal, - }, - to: 'master', - from: 'agent', - }); - }); - - return this; - } - - clean() { - this.#worker.removeAllListeners(); - } - - async kill(timeout) { - const worker = this.#worker; - if (worker) { - this.log('[master] kill agent worker with signal SIGTERM'); - this.clean(); - await terminate(worker, timeout); - } - } -} - -module.exports = { AgentWorker, AgentUtils }; diff --git a/lib/utils/mode/impl/process/app.js b/lib/utils/mode/impl/process/app.js deleted file mode 100644 index 61b8a2e..0000000 --- a/lib/utils/mode/impl/process/app.js +++ /dev/null @@ -1,150 +0,0 @@ -'use strict'; - -const cluster = require('cluster'); -const cfork = require('cfork'); -const sendmessage = require('sendmessage'); -const gracefulExit = require('graceful-process'); - -const { BaseAppWorker, BaseAppUtils } = require('../../base/app'); -const terminate = require('../../../terminate'); - -class AppWorker extends BaseAppWorker { - get id() { - return this.instance.id; - } - - get workerId() { - return this.instance.process.pid; - } - - get state() { - return this.instance.state; - } - - get exitedAfterDisconnect() { - return this.instance.exitedAfterDisconnect; - } - - get exitCode() { - return this.instance.exitCode; - } - - send(...args) { - sendmessage(this.instance, ...args); - } - - clean() { - this.instance.removeAllListeners(); - } - - static on(event, callback) { - process.on(event, callback); - } - - static send(data) { - process.send(data); - } - - static kill() { - process.exitCode = 1; - process.kill(process.pid); - } - - static gracefulExit(options) { - gracefulExit(options); - } -} - -class AppUtils extends BaseAppUtils { - fork() { - this.startTime = Date.now(); - this.startSuccessCount = 0; - - const args = [ JSON.stringify(this.options) ]; - this.log('[master] start appWorker with args %j (process)', args); - cfork({ - exec: this.getAppWorkerFile(), - args, - silent: false, - count: this.options.workers, - // don't refork in local env - refork: this.isProduction, - windowsHide: process.platform === 'win32', - }); - - let debugPort = process.debugPort; - cluster.on('fork', worker => { - const appWorker = new AppWorker(worker); - this.emit('worker_forked', appWorker); - appWorker.disableRefork = true; - worker.on('message', msg => { - if (typeof msg === 'string') { - msg = { - action: msg, - data: msg, - }; - } - msg.from = 'app'; - this.messenger.send(msg); - }); - this.log('[master] app_worker#%s:%s start, state: %s, current workers: %j', - appWorker.id, appWorker.workerId, appWorker.state, Object.keys(cluster.workers)); - - // send debug message, due to `brk` scence, send here instead of app_worker.js - if (this.options.isDebug) { - debugPort++; - this.messenger.send({ - to: 'parent', - from: 'app', - action: 'debug', - data: { - debugPort, - pid: appWorker.workerId, - }, - }); - } - }); - cluster.on('disconnect', worker => { - const appWorker = new AppWorker(worker); - this.logger.info('[master] app_worker#%s:%s disconnect, suicide: %s, state: %s, current workers: %j', - appWorker.id, appWorker.workerId, appWorker.exitedAfterDisconnect, appWorker.state, Object.keys(cluster.workers)); - }); - cluster.on('exit', (worker, code, signal) => { - const appWorker = new AppWorker(worker); - this.messenger.send({ - action: 'app-exit', - data: { - workerId: appWorker.workerId, - code, - signal, - }, - to: 'master', - from: 'app', - }); - }); - cluster.on('listening', (worker, address) => { - const appWorker = new AppWorker(worker); - this.messenger.send({ - action: 'app-start', - data: { - workerId: appWorker.workerId, - address, - }, - to: 'master', - from: 'app', - }); - }); - - return this; - } - - async kill(timeout) { - await Promise.all(Object.keys(cluster.workers).map(id => { - const worker = cluster.workers[id]; - worker.disableRefork = true; - return terminate(worker, timeout); - })); - } -} - -module.exports = { AppWorker, AppUtils }; diff --git a/lib/utils/options.js b/lib/utils/options.js deleted file mode 100644 index 3fff0f6..0000000 --- a/lib/utils/options.js +++ /dev/null @@ -1,83 +0,0 @@ -'use strict'; - -const os = require('os'); -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const utils = require('egg-utils'); -const is = require('is-type-of'); - -module.exports = function(options) { - const defaults = { - framework: '', - baseDir: process.cwd(), - port: options.https ? 8443 : null, - workers: null, - plugins: null, - https: false, - startMode: 'process', - ports: [], - env: null, - }; - options = extend(defaults, options); - if (!options.workers) { - options.workers = os.cpus().length; - } - if (!options.env && process.env.EGG_SERVER_ENV) { - options.env = process.env.EGG_SERVER_ENV; - } - - const pkgPath = path.join(options.baseDir, 'package.json'); - assert(fs.existsSync(pkgPath), `${pkgPath} should exist`); - - options.framework = utils.getFrameworkPath({ - baseDir: options.baseDir, - // compatible customEgg only when call startCluster directly without framework - framework: options.framework || options.customEgg, - }); - - const egg = require(options.framework); - assert(egg.Application, `should define Application in ${options.framework}`); - assert(egg.Agent, `should define Agent in ${options.framework}`); - - // https - if (options.https) { - if (is.boolean(options.https)) { - // TODO: compatible options.key, options.cert, will remove at next major - /* istanbul ignore next */ - console.warn('[egg-cluster:deprecated] [master] Please use `https: { key, cert, ca }` instead of `https: true`'); - options.https = { - key: options.key, - cert: options.cert, - }; - } - assert(options.https.key && fs.existsSync(options.https.key), 'options.https.key should exists'); - assert(options.https.cert && fs.existsSync(options.https.cert), 'options.https.cert should exists'); - assert(!options.https.ca || fs.existsSync(options.https.ca), 'options.https.ca should exists'); - } - - options.port = parseInt(options.port, 10) || undefined; - options.workers = parseInt(options.workers, 10); - if (options.require) options.require = [].concat(options.require); - - // don't print deprecated message in production env. - // it will print to stderr. - if (process.env.NODE_ENV === 'production') { - process.env.NO_DEPRECATION = '*'; - } - - const isDebug = process.execArgv.some(argv => argv.includes('--debug') || argv.includes('--inspect')); - if (isDebug) options.isDebug = isDebug; - - return options; -}; - -function extend(target, src) { - const keys = Object.keys(src); - for (const key of keys) { - if (src[key] != null) { - target[key] = src[key]; - } - } - return target; -} diff --git a/lib/utils/terminate.js b/lib/utils/terminate.js deleted file mode 100644 index 0045a96..0000000 --- a/lib/utils/terminate.js +++ /dev/null @@ -1,81 +0,0 @@ -'use strict'; - -const { sleep } = require('./timer'); -const awaitEvent = require('await-event'); -const pstree = require('ps-tree'); - -module.exports = async function(subProcess, timeout) { - const pid = subProcess.process ? subProcess.process.pid : subProcess.pid; - const childPids = await getChildPids(pid); - await Promise.all([ - killProcess(subProcess, timeout), - killChildren(childPids, timeout), - ]); -}; - -// kill process, if SIGTERM not work, try SIGKILL -async function killProcess(subProcess, timeout) { - // https://github.com/nodejs/node/pull/34312 - (subProcess.process || subProcess).kill('SIGTERM'); - await Promise.race([ - awaitEvent(subProcess, 'exit'), - sleep(timeout), - ]); - if (subProcess.killed) return; - // SIGKILL: http://man7.org/linux/man-pages/man7/signal.7.html - // worker: https://github.com/nodejs/node/blob/master/lib/internal/cluster/worker.js#L22 - // subProcess.kill is wrapped to subProcess.destroy, it will wait to disconnected. - (subProcess.process || subProcess).kill('SIGKILL'); -} - -// kill all children processes, if SIGTERM not work, try SIGKILL -async function killChildren(children, timeout) { - if (!children.length) return; - kill(children, 'SIGTERM'); - - const start = Date.now(); - // if timeout is 1000, it will check twice. - const checkInterval = 400; - let unterminated = []; - - while (Date.now() - start < timeout - checkInterval) { - await sleep(checkInterval); - unterminated = getUnterminatedProcesses(children); - if (!unterminated.length) return; - } - kill(unterminated, 'SIGKILL'); -} - -function getChildPids(pid) { - return new Promise(resolve => { - pstree(pid, (err, children) => { - // if get children error, just ignore it - if (err) children = []; - resolve(children.map(children => parseInt(children.PID))); - }); - }); -} - -function kill(pids, signal) { - for (const pid of pids) { - try { - process.kill(pid, signal); - } catch (_) { - // ignore - } - } -} - -function getUnterminatedProcesses(pids) { - return pids.filter(pid => { - try { - // success means it's still alive - process.kill(pid, 0); - return true; - } catch (err) { - // error means it's dead - return false; - } - }); -} - diff --git a/lib/utils/timer.js b/lib/utils/timer.js deleted file mode 100644 index 46cf972..0000000 --- a/lib/utils/timer.js +++ /dev/null @@ -1,5 +0,0 @@ -exports.sleep = ms => { - return new Promise(resolve => { - setTimeout(resolve, ms); - }); -}; diff --git a/package.json b/package.json index a8e0849..437bd42 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,25 @@ { - "name": "egg-cluster", - "version": "2.4.0", + "name": "@eggjs/cluster", + "version": "3.0.1", + "publishConfig": { + "access": "public" + }, "description": "cluster manager for egg", - "main": "index.js", "scripts": { - "lint": "eslint .", - "test": "npm run lint -- --fix && npm run test-local", - "test-local": "egg-bin test --ts false", - "cov": "egg-bin cov --prerequire --timeout 100000 --ts false", - "ci": "npm run lint && npm run cov" + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run lint -- --fix && npm run prepublishOnly", + "test": "egg-bin test", + "preci": "npm run lint && npm run prepublishOnly", + "ci": "egg-bin test", + "prepublishOnly": "tshy && tshy-after && attw --pack" }, "files": [ - "index.js", - "lib" + "dist", + "src" ], "repository": { "type": "git", - "url": "git+https://github.com/eggjs/egg-cluster.git" + "url": "git+https://github.com/eggjs/cluster.git" }, "keywords": [ "egg", @@ -26,40 +29,67 @@ "author": "dead-horse ", "license": "MIT", "bugs": { - "url": "https://github.com/eggjs/egg-cluster/issues" + "url": "https://github.com/eggjs/cluster/issues" }, - "homepage": "https://github.com/eggjs/egg-cluster#readme", + "homepage": "https://github.com/eggjs/cluster#readme", "dependencies": { - "await-event": "^2.1.0", - "cfork": "^1.7.1", - "cluster-reload": "^1.0.2", + "@eggjs/utils": "^4.2.1", + "@fengmk2/ps-tree": "^2.0.1", + "cfork": "^2.0.0", + "cluster-reload": "^2.0.0", "detect-port": "^2.0.1", - "egg-logger": "^3.3.0", - "egg-utils": "^2.4.1", - "get-ready": "^2.0.1", - "graceful-process": "^1.2.0", - "is-type-of": "^1.2.1", - "ps-tree": "^1.2.0", - "sendmessage": "^1.1.0", + "egg-logger": "^3.6.0", + "get-ready": "^3.2.0", + "graceful-process": "^2.0.0", + "sendmessage": "^3.0.1", "terminal-link": "^2.1.1", - "utility": "^1.15.0" + "utility": "^2.2.0", + "onelogger": "^1.0.1" }, "devDependencies": { - "address": "^1.0.3", - "coffee": "^5.2.1", - "egg": "^3.9.0", - "egg-bin": "^6.4.0", + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/bin": "^7.0.0", + "@eggjs/mock": "beta", + "@eggjs/supertest": "^8.1.1", + "@eggjs/tsconfig": "1", + "@types/mocha": "10", + "@types/node": "22", + "address": "^2.0.3", + "coffee": "^5.5.1", + "egg": "beta", "egg-errors": "^2.2.0", - "egg-mock": "^5.4.0", - "eslint": "^8.26.0", - "eslint-config-egg": "^12.1.0", - "pedding": "^1.1.0", - "supertest": "^4.0.0", + "eslint": "8", + "eslint-config-egg": "14", "ts-node": "^10.9.1", - "typescript": "^5.0.4", - "urllib": "^3.17.1" + "tshy": "3", + "tshy-after": "1", + "typescript": "5", + "urllib": "^4.6.8" }, "engines": { - "node": ">= 14.0.0" - } + "node": ">= 18.19.0" + }, + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/src/agent_worker.ts b/src/agent_worker.ts new file mode 100644 index 0000000..34d73af --- /dev/null +++ b/src/agent_worker.ts @@ -0,0 +1,80 @@ +import { debuglog } from 'node:util'; +import { EggConsoleLogger as ConsoleLogger } from 'egg-logger'; +import { importModule } from '@eggjs/utils'; +import { BaseAgentWorker } from './utils/mode/base/agent.js'; +import { AgentThreadWorker } from './utils/mode/impl/worker_threads/agent.js'; +import { AgentProcessWorker } from './utils/mode/impl/process/agent.js'; + +const debug = debuglog('@eggjs/cluster/agent_worker'); + +/** + * agent worker is child_process forked by master. + * + * agent worker only exit in two cases: + * - receive signal SIGTERM, exit code 0 (exit gracefully) + * - receive disconnect event, exit code 110 (maybe master exit in accident) + */ +async function main() { + // $ node agent_worker.js options + const options = JSON.parse(process.argv[2]) as { + framework: string; + baseDir: string; + require?: string[]; + startMode?: 'process' | 'worker_threads'; + }; + if (options.require) { + // inject + for (const mod of options.require) { + await importModule(mod, { + paths: [ options.baseDir ], + }); + } + } + + let AgentWorker: typeof BaseAgentWorker; + if (options.startMode === 'worker_threads') { + AgentWorker = AgentThreadWorker as any; + } else { + AgentWorker = AgentProcessWorker as any; + } + + const consoleLogger = new ConsoleLogger({ level: process.env.EGG_AGENT_WORKER_LOGGER_LEVEL }); + const { Agent } = await importModule(options.framework, { + paths: [ options.baseDir ], + }); + debug('new Agent with options %j', options); + let agent: any; + try { + agent = new Agent(options); + } catch (err) { + consoleLogger.error(err); + throw err; + } + + function startErrorHandler(err: Error) { + consoleLogger.error(err); + consoleLogger.error('[agent_worker] start error, exiting with code:1'); + AgentWorker.kill(); + } + + agent.ready((err?: Error) => { + // don't send started message to master when start error + if (err) { + return; + } + + agent.removeListener('error', startErrorHandler); + AgentWorker.send({ action: 'agent-start', to: 'master' }); + }); + + // exit if agent start error + agent.once('error', startErrorHandler); + + AgentWorker.gracefulExit({ + logger: consoleLogger, + label: 'agent_worker', + beforeExit: () => agent.close(), + }); +} + +main(); diff --git a/src/app_worker.ts b/src/app_worker.ts new file mode 100644 index 0000000..e25a2d6 --- /dev/null +++ b/src/app_worker.ts @@ -0,0 +1,220 @@ +import fs from 'node:fs'; +import { createServer as createHttpServer, type Server } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import type { Socket } from 'node:net'; +import { debuglog } from 'node:util'; +import { EggConsoleLogger as ConsoleLogger } from 'egg-logger'; +import { importModule } from '@eggjs/utils'; +import { BaseAppWorker } from './utils/mode/base/app.js'; +import { AppThreadWorker } from './utils/mode/impl/worker_threads/app.js'; +import { AppProcessWorker } from './utils/mode/impl/process/app.js'; +import { ipcLogger, formatIpcMessage, internalIpcLogEnabled } from './utils/ipc_logger.js'; + +const debug = debuglog('@eggjs/cluster/app_worker'); + +async function main() { + // $ node app_worker.js options-json-string + const options = JSON.parse(process.argv[2]) as { + framework: string; + baseDir: string; + require?: string[]; + startMode?: 'process' | 'worker_threads'; + port: number; + debugPort?: number; + https?: object; + sticky?: boolean; + stickyWorkerPort?: number; + }; + if (options.require) { + // inject + for (const mod of options.require) { + await importModule(mod, { + paths: [ options.baseDir ], + }); + } + } + + let AppWorker: typeof BaseAppWorker; + if (options.startMode === 'worker_threads') { + AppWorker = AppThreadWorker as any; + } else { + AppWorker = AppProcessWorker as any; + // D. master -> app (recv): log every IPC message delivered to this worker via the cluster channel. + // Handle is present when master forwards a `net.Socket` (sticky-session mode). + // This listener is read-only; other `process.on('message')` listeners (framework, sticky handler, + // etc.) are unaffected. + process.on('message', (msg: any, handle: any) => { + const body = typeof msg === 'string' ? { action: msg } : msg; + ipcLogger.info(formatIpcMessage(`app#${process.pid}<-master`, body, handle)); + }); + + // F. master -> app internal NODE_CLUSTER messages (newconn with fd, disconnect, suicide, ...). + // `internalMessage` is an undocumented but stable Node.js event. + // Opt-in via EGG_CLUSTER_IPC_LOG because `newconn` fires once per HTTP request. + if (internalIpcLogEnabled) { + process.on('internalMessage', (msg: { cmd?: string; act?: string; ack?: number }, handle: unknown) => { + if (!msg || msg.cmd !== 'NODE_CLUSTER') return; + const label = msg.act ? `cluster:${msg.act}` : `cluster:ack#${msg.ack ?? '?'}`; + ipcLogger.info(formatIpcMessage( + `app#${process.pid}<-master`, + { action: label, data: msg }, + handle, + )); + }); + } + } + + const consoleLogger = new ConsoleLogger({ + level: process.env.EGG_APP_WORKER_LOGGER_LEVEL, + }); + const { Application } = await importModule(options.framework, { + paths: [ options.baseDir ], + }); + debug('[app_worker:%s] new Application with options %j', process.pid, options); + let app: any; + try { + app = new Application(options); + } catch (err) { + consoleLogger.error(err); + throw err; + } + + app.ready(startServer); + + function exitProcess() { + // Use SIGTERM kill process, ensure trigger the gracefulExit + AppWorker.kill(); + } + + // exit if worker start timeout + app.once('startTimeout', startTimeoutHandler); + + function startTimeoutHandler() { + consoleLogger.error('[app_worker] start timeout, exiting with code:1'); + exitProcess(); + } + + function startServer(err?: Error) { + if (err) { + consoleLogger.error(err); + consoleLogger.error('[app_worker] start error, exiting with code:1'); + exitProcess(); + return; + } + + const clusterConfig = app.config.cluster ?? {}; + const listenConfig = clusterConfig.listen ?? {}; + const httpsOptions = { + ...clusterConfig.https, + ...options.https, + }; + const port = app.options.port = options.port || listenConfig.port; + const debugPort = options.debugPort; + const protocol = (httpsOptions.key && httpsOptions.cert) ? 'https' : 'http'; + debug('[app_worker:%s] listenConfig: %j, real port: %o, protocol: %o, debugPort: %o', + process.pid, listenConfig, port, protocol, debugPort); + + AppWorker.send({ + to: 'master', + action: 'realport', + data: { + port, + protocol, + }, + }); + + app.removeListener('startTimeout', startTimeoutHandler); + + let server: Server; + let debugPortServer: Server | undefined; + + // https config + if (protocol === 'https') { + httpsOptions.key = fs.readFileSync(httpsOptions.key); + httpsOptions.cert = fs.readFileSync(httpsOptions.cert); + httpsOptions.ca = httpsOptions.ca && fs.readFileSync(httpsOptions.ca); + server = createHttpsServer(httpsOptions, app.callback()); + if (debugPort) { + debugPortServer = createHttpServer(app.callback()); + } + } else { + server = createHttpServer(app.callback()); + if (debugPort) { + debugPortServer = server; + } + } + + server.once('error', (err: any) => { + consoleLogger.error('[app_worker] server got error: %s, code: %s', err.message, err.code); + exitProcess(); + }); + + // emit `server` event in app + app.emit('server', server); + + if (options.sticky && options.stickyWorkerPort) { + // only allow connection from localhost + server.listen(options.stickyWorkerPort, '127.0.0.1'); + // Listen to messages was sent from the master. Ignore everything else. + AppWorker.on('message', (message: string, connection: Socket) => { + if (message !== 'sticky-session:connection') { + return; + } + // Emulate a connection event on the server by emitting the + // event with the connection the master sent us. + server.emit('connection', connection); + connection.resume(); + }); + } else { + if (listenConfig.path) { + server.listen(listenConfig.path); + } else { + if (typeof port !== 'number') { + consoleLogger.error('[app_worker:%s] port should be number, but got %s(%s)', + process.pid, port, typeof port); + exitProcess(); + return; + } + const args = [ port ]; + if (listenConfig.hostname) { + args.push(listenConfig.hostname); + } + debug('listen options %j', args); + server.listen(...args); + } + if (debugPortServer) { + debug('listen on debug port: %s', debugPort); + debugPortServer.listen(debugPort); + } + } + + server.once('listening', () => { + let address: any = server.address() || { port }; + if (typeof address === 'string') { + // https://nodejs.org/api/cluster.html#cluster_event_listening_1 + // Unix domain socket + address = { + address, + addressType: -1, + }; + } + debug('[app_worker:%s] listening at %j', process.pid, address); + AppWorker.send({ + to: 'master', + action: 'app-start', + data: { + address, + workerId: AppWorker.workerId, + }, + }); + }); + } + + AppWorker.gracefulExit({ + logger: consoleLogger, + label: 'app_worker', + beforeExit: () => app.close(), + }); +} + +main(); diff --git a/src/dirname.ts b/src/dirname.ts new file mode 100644 index 0000000..48b7810 --- /dev/null +++ b/src/dirname.ts @@ -0,0 +1,11 @@ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export function getSrcDirname() { + if (typeof __dirname !== 'undefined') { + return __dirname; + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return path.dirname(fileURLToPath(import.meta.url)); +} diff --git a/src/error/ClusterAgentWorkerError.ts b/src/error/ClusterAgentWorkerError.ts new file mode 100644 index 0000000..392c588 --- /dev/null +++ b/src/error/ClusterAgentWorkerError.ts @@ -0,0 +1,19 @@ +export class ClusterAgentWorkerError extends Error { + id: number; + /** + * pid in process mode + * tid in worker_threads mode + */ + workerId: number; + status: string; + + constructor(id: number, workerId: number, status: string, error: Error) { + const message = `Got agent worker error: ${error.message}`; + super(message, { cause: error }); + this.name = this.constructor.name; + this.id = id; + this.workerId = workerId; + this.status = status; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/error/ClusterWorkerExceptionError.ts b/src/error/ClusterWorkerExceptionError.ts new file mode 100644 index 0000000..85191a2 --- /dev/null +++ b/src/error/ClusterWorkerExceptionError.ts @@ -0,0 +1,17 @@ +export class ClusterWorkerExceptionError extends Error { + count: { + agent: number; + worker: number; + }; + + constructor(agent: number, worker: number) { + const message = `[master] ${agent} agent and ${worker} worker(s) alive, exit to avoid unknown state`; + super(message); + this.name = this.constructor.name; + this.count = { + agent, + worker, + }; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/error/index.ts b/src/error/index.ts new file mode 100644 index 0000000..c0c1e82 --- /dev/null +++ b/src/error/index.ts @@ -0,0 +1,2 @@ +export * from './ClusterAgentWorkerError.js'; +export * from './ClusterWorkerExceptionError.js'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c34f8e5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +import { Master, MasterOptions } from './master.js'; +import { ClusterOptions, ClusterHTTPSSecureOptions, ClusterStartMode } from './utils/options.js'; + +/** + * cluster start flow: + * + * [startCluster] -> master -> agent_worker -> new [Agent] -> agentWorkerLoader + * `-> app_worker -> new [Application] -> appWorkerLoader + * + */ + +/** + * start egg app + * @function Egg#startCluster + * @param {Object} options {@link Master} + */ +export async function startCluster(options: ClusterOptions) { + await new Master(options).ready(); +} + +export { + Master, MasterOptions, + ClusterOptions, ClusterHTTPSSecureOptions, ClusterStartMode, +}; + +export * from './error/index.js'; diff --git a/lib/master.js b/src/master.ts similarity index 64% rename from lib/master.js rename to src/master.ts index 9e5ee98..8c9b8c8 100644 --- a/lib/master.js +++ b/src/master.ts @@ -1,70 +1,80 @@ -'use strict'; - -const os = require('os'); -const v8 = require('v8'); -const util = require('util'); -const path = require('path'); -const fs = require('fs'); -const EventEmitter = require('events'); -const ready = require('get-ready'); -const { detectPort } = require('detect-port'); -const ConsoleLogger = require('egg-logger').EggConsoleLogger; -const utility = require('utility'); -const terminalLink = require('terminal-link'); - -const Manager = require('./utils/manager'); -const parseOptions = require('./utils/options'); -const Messenger = require('./utils/messenger'); -const { AgentUtils: ProcessAgentWorker } = require('./utils/mode/impl/process/agent'); -const { AppUtils: ProcessAppWorker } = require('./utils/mode/impl/process/app'); -const { AgentUtils: WorkerThreadsAgentWorker } = require('./utils/mode/impl/worker_threads/agent'); -const { AppUtils: WorkerThreadsAppWorker } = require('./utils/mode/impl/worker_threads/app'); - -const PROTOCOL = Symbol('Master#protocol'); -const REAL_PORT = Symbol('Master#real_port'); -const APP_ADDRESS = Symbol('Master#appAddress'); - -class Master extends EventEmitter { +import os from 'node:os'; +import v8 from 'node:v8'; +import util from 'node:util'; +import path from 'node:path'; +import fs from 'node:fs'; +import net from 'node:net'; +import { debuglog } from 'node:util'; +import { ReadyEventEmitter } from 'get-ready'; +import { detectPort } from 'detect-port'; +import { reload } from 'cluster-reload'; +import { EggConsoleLogger as ConsoleLogger } from 'egg-logger'; +import { readJSONSync } from 'utility'; +import terminalLink from 'terminal-link'; +import { parseOptions, ClusterOptions, ParsedClusterOptions } from './utils/options.js'; +import { WorkerManager } from './utils/worker_manager.js'; +import { Messenger } from './utils/messenger.js'; +import { + AgentProcessWorker, AgentProcessUtils as ProcessAgentWorker, +} from './utils/mode/impl/process/agent.js'; +import { AppProcessWorker, AppProcessUtils as ProcessAppWorker } from './utils/mode/impl/process/app.js'; +import { + AgentThreadWorker, AgentThreadUtils as WorkerThreadsAgentWorker, +} from './utils/mode/impl/worker_threads/agent.js'; +import { AppThreadWorker, AppThreadUtils as WorkerThreadsAppWorker } from './utils/mode/impl/worker_threads/app.js'; +import { ClusterWorkerExceptionError } from './error/ClusterWorkerExceptionError.js'; +import { ipcLogger, formatIpcMessage } from './utils/ipc_logger.js'; + +const debug = debuglog('@eggjs/cluster/master'); + +export interface MasterOptions extends ParsedClusterOptions { + clusterPort?: number; + stickyWorkerPort?: number; +} - /** - * @class - * @param {Object} options - * - {String} [framework] - specify framework that can be absolute path or npm package - * - {String} [baseDir] directory of application, default to `process.cwd()` - * - {Object} [plugins] - customized plugins, for unittest - * - {Number} [workers] numbers of app workers, default to `os.cpus().length` - * - {Number} [port] listening port, default to 7001(http) or 8443(https) - * - {Number} [debugPort] listening a debug port on http protocol - * - {Object} [https] https options, { key, cert, ca }, full path - * - {Array|String} [require] will inject into worker/agent process - * - {String} [pidFile] will save master pid to this file - * - {String} [env] custom env, default is process.env.EGG_SERVER_ENV - */ - constructor(options) { +export class Master extends ReadyEventEmitter { + options: MasterOptions; + isStarted = false; + workerManager: WorkerManager; + messenger: Messenger; + isProduction: boolean; + agentWorkerIndex = 0; + closed = false; + logger: ConsoleLogger; + agentWorker: ProcessAgentWorker | WorkerThreadsAgentWorker; + appWorker: ProcessAppWorker | WorkerThreadsAppWorker; + #logMethod: 'info' | 'debug'; + #realPort?: number; + #protocol: string; + #appAddress: string; + + constructor(options?: ClusterOptions) { super(); - this.options = parseOptions(options); - this.workerManager = new Manager(); - this.messenger = new Messenger(this, this.workerManager); - - ready.mixin(this); + this.#start(options) + .catch(err => { + this.ready(err); + }); + } + async #start(options?: ClusterOptions) { + this.options = await parseOptions(options); + this.workerManager = new WorkerManager(); + this.messenger = new Messenger(this, this.workerManager); this.isProduction = isProduction(this.options); - this.agentWorkerIndex = 0; - this.closed = false; - this[REAL_PORT] = this.options.port; - this[PROTOCOL] = this.options.https ? 'https' : 'http'; + this.#realPort = this.options.port; + this.#protocol = this.options.https ? 'https' : 'http'; // app started or not this.isStarted = false; - this.logger = new ConsoleLogger({ level: process.env.EGG_MASTER_LOGGER_LEVEL || 'INFO' }); - this.logMethod = 'info'; + this.logger = new ConsoleLogger({ level: process.env.EGG_MASTER_LOGGER_LEVEL ?? 'INFO' }); + this.#logMethod = 'info'; if (this.options.env === 'local' || process.env.NODE_ENV === 'development') { - this.logMethod = 'debug'; + this.#logMethod = 'debug'; } // get the real framework info const frameworkPath = this.options.framework; - const frameworkPkg = utility.readJSONSync(path.join(frameworkPath, 'package.json')); + const frameworkPkg = readJSONSync(path.join(frameworkPath, 'package.json')); // set app & agent worker impl if (this.options.startMode === 'worker_threads') { @@ -73,10 +83,12 @@ class Master extends EventEmitter { this.startByProcess(); } - this.log(`[master] =================== ${frameworkPkg.name} start =====================`); + this.log(`[master] =================== ${frameworkPkg.name} start 🥚🥚🥚🥚 =====================`); this.logger.info(`[master] node version ${process.version}`); /* istanbul ignore next */ - if (process.alinode) this.logger.info(`[master] alinode version ${process.alinode}`); + if ('alinode' in process) { + this.logger.info(`[master] alinode version ${process.alinode}`); + } this.logger.info(`[master] ${frameworkPkg.name} version ${frameworkPkg.version}`); if (this.isProduction) { @@ -93,13 +105,16 @@ class Master extends EventEmitter { this.ready(() => { this.isStarted = true; const stickyMsg = this.options.sticky ? ' with STICKY MODE!' : ''; - const startedURL = terminalLink(this[APP_ADDRESS], this[APP_ADDRESS], { fallback: false }); + const startedURL = terminalLink(this.#appAddress, this.#appAddress, { fallback: false }); this.logger.info('[master] %s started on %s (%sms)%s', frameworkPkg.name, startedURL, Date.now() - startTime, stickyMsg); if (this.options.debugPort) { - const url = getAddress({ port: this.options.debugPort, protocol: 'http' }); + const url = getAddress({ + port: this.options.debugPort, + protocol: 'http', + }); const debugPortURL = terminalLink(url, url, { fallback: false }); - this.logger.info('[master] %s started on %s', frameworkPkg.name, debugPortURL); + this.logger.info('[master] %s started debug port on %s', frameworkPkg.name, debugPortURL); } const action = 'egg-ready'; @@ -107,10 +122,10 @@ class Master extends EventEmitter { action, to: 'parent', data: { - port: this[REAL_PORT], + port: this.#realPort, debugPort: this.options.debugPort, - address: this[APP_ADDRESS], - protocol: this[PROTOCOL], + address: this.#appAddress, + protocol: this.#protocol, }, }); this.messenger.send({ @@ -142,8 +157,13 @@ class Master extends EventEmitter { // get the real port from options and app.config // app worker will send after loading this.on('realport', ({ port, protocol }) => { - if (port) this[REAL_PORT] = port; - if (protocol) this[PROTOCOL] = protocol; + // this.logger.info('[master] got realport: %s, protocol: %s', port, protocol); + if (port) { + this.#realPort = port; + } + if (protocol) { + this.#protocol = protocol; + } }); // https://nodejs.org/api/process.html#process_signal_events @@ -169,16 +189,11 @@ class Master extends EventEmitter { }); // exit when agent or worker exception - this.workerManager.on('exception', ({ - agent, - worker, + this.workerManager.on('exception', (count: { + agent: number; + worker: number; }) => { - const err = new Error(`[master] ${agent} agent and ${worker} worker(s) alive, exit to avoid unknown state`); - err.name = 'ClusterWorkerExceptionError'; - err.count = { - agent, - worker, - }; + const err = new ClusterWorkerExceptionError(count.agent, count.worker); this.logger.error(err); process.exit(1); }); @@ -214,35 +229,29 @@ class Master extends EventEmitter { }); } - detectPorts() { + async detectPorts() { // Detect cluster client port - return detectPort() - .then(port => { - this.options.clusterPort = port; - // If sticky mode, detect worker port - if (this.options.sticky) { - return detectPort(); - } - }) - .then(port => { - if (this.options.sticky) { - this.options.stickyWorkerPort = port; - } - }) - .catch(/* istanbul ignore next */ err => { - this.logger.error(err); - process.exit(1); - }); + try { + const clusterPort = await detectPort(); + this.options.clusterPort = clusterPort; + // If sticky mode, detect worker port + if (this.options.sticky) { + const stickyWorkerPort = await detectPort(); + this.options.stickyWorkerPort = stickyWorkerPort; + } + } catch (err) { + this.logger.error(err); + process.exit(1); + } } - - log(...args) { - this.logger[this.logMethod](...args); + log(msg: string, ...args: any[]) { + this.logger[this.#logMethod](msg, ...args); } - startMasterSocketServer(cb) { + startMasterSocketServer(cb: (err?: Error) => void) { // Create the outside facing server listening on our port. - require('net').createServer({ + net.createServer({ pauseOnConnect: true, }, connection => { // We received a connection and need to pass it to the appropriate @@ -256,34 +265,42 @@ class Master extends EventEmitter { // Read https://en.wikipedia.org/wiki/TCP_reset_attack for more details. connection.destroy(); } else { - const worker = this.stickyWorker(connection.remoteAddress); + const worker = this.stickyWorker(connection.remoteAddress) as AppProcessWorker; + ipcLogger.info(formatIpcMessage( + `master->app#${worker.workerId}`, + { action: 'sticky-session:connection' }, + connection, + )); worker.instance.send('sticky-session:connection', connection); } - }).listen(this[REAL_PORT], cb); + }).listen(this.#realPort, cb); } - stickyWorker(ip) { + stickyWorker(ip: string) { const workerNumbers = this.options.workers; const ws = this.workerManager.listWorkerIds(); let s = ''; for (let i = 0; i < ip.length; i++) { - if (!isNaN(ip[i])) { + if (!isNaN(parseInt(ip[i]))) { s += ip[i]; } } - s = Number(s); - const pid = ws[s % workerNumbers]; - return this.workerManager.getWorker(pid); + const pid = ws[Number(s) % workerNumbers]; + return this.workerManager.getWorker(pid)!; } forkAgentWorker() { - this.agentWorker.on('agent_forked', agent => this.workerManager.setAgent(agent)); + this.agentWorker.on('agent_forked', (agent: AgentProcessWorker | AgentThreadWorker) => { + this.workerManager.setAgent(agent); + }); this.agentWorker.fork(); } forkAppWorkers() { - this.appWorker.on('worker_forked', worker => this.workerManager.setWorker(worker)); + this.appWorker.on('worker_forked', (worker: AppProcessWorker | AppThreadWorker) => { + this.workerManager.setWorker(worker); + }); this.appWorker.fork(); } @@ -296,22 +313,24 @@ class Master extends EventEmitter { * @param {number} timeout - kill agent timeout * @return {Promise} - */ - async killAgentWorker(timeout) { + async killAgentWorker(timeout: number) { await this.agentWorker.kill(timeout); } - async killAppWorkers(timeout) { + async killAppWorkers(timeout: number) { await this.appWorker.kill(timeout); } /** * Agent Worker exit handler * Will exit during startup, and refork during running. - * @param {Object} data - * - {Number} code - exit code - * - {String} signal - received signal */ - onAgentExit(data) { + onAgentExit(data: { + /** exit code */ + code: number; + /** received signal */ + signal: string; + }) { if (this.closed) return; this.messenger.send({ @@ -320,7 +339,7 @@ class Master extends EventEmitter { data: [], }); const agentWorker = this.agentWorker; - this.workerManager.deleteAgent(agentWorker); + this.workerManager.deleteAgent(); const err = new Error(util.format('[master] agent_worker#%s:%s died (code: %s, signal: %s)', agentWorker.instance.id, agentWorker.instance.workerId, data.code, data.signal)); @@ -378,28 +397,28 @@ class Master extends EventEmitter { to: 'app', }); this.logger.info('[master] agent_worker#%s:%s started (%sms)', - this.agentWorker.instance.id, this.agentWorker.instance.workerId, Date.now() - this.agentWorker.startTime); + this.agentWorker.instance.id, this.agentWorker.instance.workerId, + Date.now() - this.agentWorker.startTime); } /** * App Worker exit handler - * @param {Object} data - * - {String} workerId - worker id - * - {Number} code - exit code - * - {String} signal - received signal */ - onAppExit(data) { + onAppExit(data: { + workerId: number; + code: number; + signal: string; + }) { if (this.closed) return; - const worker = this.workerManager.getWorker(data.workerId); - + const worker = this.workerManager.getWorker(data.workerId)!; if (!worker.isDevReload) { const signal = data.signal; const message = util.format( '[master] app_worker#%s:%s died (code: %s, signal: %s, suicide: %s, state: %s), current workers: %j', worker.id, worker.workerId, worker.exitCode, signal, worker.exitedAfterDisconnect, worker.state, - this.workerManager.getWorkers() + this.workerManager.listWorkerIds(), ); if (this.options.isDebug && signal === 'SIGKILL') { // exit if died during debug @@ -429,7 +448,6 @@ class Master extends EventEmitter { action: 'app-worker-died', to: 'parent', }); - } else { // exit if died during startup this.logger.error('[master] app_worker#%s:%s start fail, exiting with code:1', @@ -440,14 +458,15 @@ class Master extends EventEmitter { /** * after app worker - * @param {Object} data - * - {String} workerId - worker id - * - {Object} address - server address */ - onAppStart(data) { - const worker = this.workerManager.getWorker(data.workerId); - const address = data.address; + onAppStart(data: { + workerId: number; + address: ListeningAddress; + }) { + const worker = this.workerManager.getWorker(data.workerId)!; + debug('got app_worker#%s:%s app-start event, data: %j', worker.id, worker.workerId, data); + const address = data.address; // worker should listen stickyWorkerPort when sticky mode if (this.options.sticky) { if (String(address.port) !== String(this.options.stickyWorkerPort)) { @@ -456,9 +475,10 @@ class Master extends EventEmitter { // worker should listen REALPORT when not sticky mode } else if (this.options.startMode !== 'worker_threads' && !isUnixSock(address) && - (String(address.port) !== String(this[REAL_PORT]))) { + (String(address.port) !== String(this.#realPort))) { return; } + worker.state = 'listening'; // send message to agent with alive workers this.messenger.send({ @@ -466,12 +486,20 @@ class Master extends EventEmitter { to: 'agent', data: this.workerManager.getListeningWorkerIds(), }); + // send message to app with current agent worker id + this.messenger.send({ + action: 'egg-pids', + to: 'app', + data: [ this.agentWorker.instance.workerId ], + receiverWorkerId: String(worker.workerId), + receiverPid: String(worker.workerId), + }); this.appWorker.startSuccessCount++; - const remain = this.appWorker.isAllWorkerStarted ? 0 : this.options.workers - this.appWorker.startSuccessCount; this.log('[master] app_worker#%s:%s started at %s, remain %s (%sms)', - worker.id, worker.workerId, address.port, remain, Date.now() - this.appWorker.startTime); + worker.id, worker.workerId, address.port, remain, + Date.now() - this.appWorker.startTime); // Send egg-ready when app is started after launched if (this.appWorker.isAllWorkerStarted) { @@ -494,18 +522,19 @@ class Master extends EventEmitter { this.appWorker.isAllWorkerStarted = true; // enable all workers when app started - for (const id of this.workerManager.getWorkers()) { - const worker = this.workerManager.getWorker(id); + for (const worker of this.workerManager.listWorkers()) { worker.disableRefork = false; } - address.protocol = this[PROTOCOL]; - address.port = this.options.sticky ? this[REAL_PORT] : address.port; - this[APP_ADDRESS] = getAddress(address); + address.protocol = this.#protocol; + address.port = this.options.sticky ? this.#realPort! : address.port; + this.#appAddress = getAddress(address); if (this.options.sticky) { this.startMasterSocketServer(err => { - if (err) return this.ready(err); + if (err) { + return this.ready(err); + } this.ready(true); }); } else { @@ -516,14 +545,13 @@ class Master extends EventEmitter { /** * master exit handler */ - - onExit(code) { + onExit(code: number) { if (this.options.pidFile && fs.existsSync(this.options.pidFile)) { try { fs.unlinkSync(this.options.pidFile); - } catch (err) { + } catch (err: any) { /* istanbul ignore next */ - this.logger.error('[master] delete pidfile %s fail with %s', this.options.pidFile, err.message); + this.logger.error('[master] delete pidFile %s fail with %s', this.options.pidFile, err.message); } } // istanbul can't cover here @@ -532,11 +560,10 @@ class Master extends EventEmitter { this.logger[level]('[master] exit with code:%s', code); } - onSignal(signal) { + onSignal(signal: string) { if (this.closed) return; this.logger.info('[master] master is killed by signal %s, closing', signal); - // logger more info const { used_heap_size, heap_size_limit } = v8.getHeapStatistics(); this.logger.info('[master] system memory: total %s, free %s', os.totalmem(), os.freemem()); @@ -549,12 +576,11 @@ class Master extends EventEmitter { * reload workers, for develop purpose */ onReload() { - this.log('[master] reload workers...'); - for (const id of this.workerManager.getWorkers()) { - const worker = this.workerManager.getWorker(id); + this.log('[master] reload %s workers...', this.options.workers); + for (const worker of this.workerManager.listWorkers()) { worker.isDevReload = true; } - require('cluster-reload')(this.options.workers); + reload(this.options.workers); } async close() { @@ -563,7 +589,7 @@ class Master extends EventEmitter { await this._doClose(); this.log('[master] close done, exiting with code:0'); process.exit(0); - } catch (e) /* istanbul ignore next */ { + } catch (e) { this.logger.error('[master] close with error: ', e); process.exit(1); } @@ -573,14 +599,14 @@ class Master extends EventEmitter { // kill app workers // kill agent worker // exit itself - const legacyTimeout = process.env.EGG_MASTER_CLOSE_TIMEOUT || 5000; - const appTimeout = process.env.EGG_APP_CLOSE_TIMEOUT || legacyTimeout; - const agentTimeout = process.env.EGG_AGENT_CLOSE_TIMEOUT || legacyTimeout; + const legacyTimeout = process.env.EGG_MASTER_CLOSE_TIMEOUT || '5000'; + const appTimeout = parseInt(process.env.EGG_APP_CLOSE_TIMEOUT || legacyTimeout); + const agentTimeout = parseInt(process.env.EGG_AGENT_CLOSE_TIMEOUT || legacyTimeout); this.logger.info('[master] send kill SIGTERM to app workers, will exit with code:0 after %sms', appTimeout); this.logger.info('[master] wait %sms', appTimeout); try { await this.killAppWorkers(appTimeout); - } catch (e) /* istanbul ignore next */ { + } catch (e) { this.logger.error('[master] app workers exit error: ', e); } this.logger.info('[master] send kill SIGTERM to agent worker, will exit with code:0 after %sms', agentTimeout); @@ -593,35 +619,46 @@ class Master extends EventEmitter { } } -module.exports = Master; - -function isProduction(options) { +function isProduction(options: ClusterOptions) { if (options.env) { return options.env !== 'local' && options.env !== 'unittest'; } return process.env.NODE_ENV === 'production'; } +interface ListeningAddress { + port: number; + protocol: string; + address?: string; + // https://nodejs.org/api/cluster.html#cluster_event_listening_1 + addressType?: number; +} + function getAddress({ addressType, address, port, protocol, -}) { +}: ListeningAddress) { // unix sock // https://nodejs.org/api/cluster.html#cluster_event_listening_1 - if (addressType === -1) return address; + if (addressType === -1) { + return address!; + } - let hostname = address; - if (!hostname && process.env.HOST && process.env.HOST !== '0.0.0.0') { - hostname = process.env.HOST; + // {"address":"::","family":"IPv6","port":17001} + if (address === '::') { + address = ''; + } + if (!address && process.env.HOST && process.env.HOST !== '0.0.0.0') { + address = process.env.HOST; } - if (!hostname) { - hostname = '127.0.0.1'; + if (!address) { + address = '127.0.0.1'; } - return `${protocol}://${hostname}:${port}`; + return `${protocol}://${address}:${port}`; } -function isUnixSock(address) { +function isUnixSock(address: ListeningAddress) { return address.addressType === -1; } diff --git a/src/utils/ipc_logger.ts b/src/utils/ipc_logger.ts new file mode 100644 index 0000000..e6e0f8b --- /dev/null +++ b/src/utils/ipc_logger.ts @@ -0,0 +1,84 @@ +import net from 'node:net'; +import { getLogger } from 'onelogger'; +import type { MessageBody } from './messenger.js'; + +/** + * onelogger instance for Node.js cluster module IPC traffic between master and app workers. + * Users can override the underlying sink via `onelogger.setLogger()` / `setCustomLogger()`. + */ +export const ipcLogger = getLogger('@eggjs/cluster:ipc'); + +/** + * Whether internal-level cluster IPC logs (NODE_CLUSTER newconn/accepted/listening/...) are enabled. + * These are very verbose (one `cluster:newconn` per HTTP request), so they are opt-in + * via the `EGG_CLUSTER_IPC_LOG` environment variable (any truthy value enables). + */ +export const internalIpcLogEnabled = !!process.env.EGG_CLUSTER_IPC_LOG; + +const MAX_STRING_LEN = 200; +const MAX_TOTAL_LEN = 1024; + +function describeHandle(handle: unknown): string { + if (handle == null) return ''; + if (handle instanceof net.Socket) { + const fd = (handle as unknown as { _handle?: { fd?: number } })._handle?.fd; + return fd != null ? `` : ''; + } + if (handle instanceof net.Server) { + return ''; + } + const ctor = (handle as { constructor?: { name?: string } })?.constructor?.name; + return ctor ? `<${ctor}>` : ''; +} + +function makeReplacer() { + const seen = new WeakSet(); + return function replacer(_key: string, value: unknown) { + if (value && typeof value === 'object') { + if (seen.has(value as object)) return ''; + seen.add(value as object); + if (value instanceof net.Socket) return describeHandle(value); + if (value instanceof net.Server) return ''; + if (Buffer.isBuffer(value)) return ``; + } + if (typeof value === 'string' && value.length > MAX_STRING_LEN) { + return `${value.slice(0, MAX_STRING_LEN)}...(truncated)`; + } + return value; + }; +} + +function stringifyData(data: unknown): string { + let out: string; + try { + out = JSON.stringify(data, makeReplacer()); + } catch (err) { + return ``; + } + if (out && out.length > MAX_TOTAL_LEN) { + out = `${out.slice(0, MAX_TOTAL_LEN)}...(truncated)`; + } + return out; +} + +/** + * Format a single IPC message into a one-line log string. + * @param direction e.g. 'master->app#12345' / 'app#12345<-master' + * @param msg the message body (supports cluster internal msgs via `action: 'cluster:'`) + * @param handle optional handle (net.Socket / net.Server) attached to the IPC message + */ +export function formatIpcMessage( + direction: string, + msg: Partial & { action?: string; data?: unknown }, + handle?: unknown, +): string { + const action = msg?.action ?? ''; + let out = `[${direction}] action=${action}`; + if (msg && msg.data !== undefined) { + out += ` data=${stringifyData(msg.data)}`; + } + if (handle) { + out += ` +handle=${describeHandle(handle)}`; + } + return out; +} diff --git a/lib/utils/messenger.js b/src/utils/messenger.ts similarity index 52% rename from lib/utils/messenger.js rename to src/utils/messenger.ts index 5b72e98..d4ad321 100644 --- a/lib/utils/messenger.js +++ b/src/utils/messenger.ts @@ -1,11 +1,27 @@ -'use strict'; +import { debuglog } from 'node:util'; +import workerThreads from 'node:worker_threads'; +import type { Master } from '../master.js'; +import type { WorkerManager } from './worker_manager.js'; -const debug = require('util').debuglog('egg-cluster:messenger'); -const workerThreads = require('worker_threads'); +const debug = debuglog('@eggjs/cluster/messenger'); +export type MessageCharacter = 'agent' | 'app' | 'master' | 'parent'; + +export interface MessageBody { + action: string; + data?: unknown; + to?: MessageCharacter; + from?: MessageCharacter; + /** + * @deprecated Keep compatible, please use receiverWorkerId instead + */ + receiverPid?: string; + receiverWorkerId?: string; + senderWorkerId?: string; +} /** - * master messenger,provide communication between parent, master, agent and app. + * master messenger, provide communication between parent, master, agent and app. * * ┌────────┐ * │ parent │ @@ -26,7 +42,7 @@ const workerThreads = require('worker_threads'); * process.send({ * action: 'xxx', * data: '', - * to: 'agent/master/parent', // default to app + * to: 'agent/master/parent', // default to agent * }); * ``` * @@ -36,7 +52,7 @@ const workerThreads = require('worker_threads'); * process.send({ * action: 'xxx', * data: '', - * to: 'app/master/parent', // default to agent + * to: 'app/master/parent', // default to app * }); * ``` * @@ -46,22 +62,25 @@ const workerThreads = require('worker_threads'); * process.send({ * action: 'xxx', * data: '', - * to: 'app/agent/master', // default to be ignore + * to: 'app/agent/master', // default to master * }); * ``` */ -class Messenger { - - constructor(master, workerManager) { - this.master = master; - this.workerManager = workerManager; - this.hasParent = !!workerThreads.parentPort || !!process.send; - process.on('message', msg => { +export class Messenger { + #master: Master; + #workerManager: WorkerManager; + #hasParent: boolean; + + constructor(master: Master, workerManager: WorkerManager) { + this.#master = master; + this.#workerManager = workerManager; + this.#hasParent = !!workerThreads.parentPort || !!process.send; + process.on('message', (msg: MessageBody) => { msg.from = 'parent'; this.send(msg); }); process.once('disconnect', () => { - this.hasParent = false; + this.#hasParent = false; }); } @@ -71,16 +90,18 @@ class Messenger { * - {String} from from who * - {String} to to who */ - send(data) { + send(data: MessageBody) { if (!data.from) { data.from = 'master'; } - // recognise receiverPid is to who - if (data.receiverPid) { - if (data.receiverPid === String(process.pid)) { + // https://github.com/eggjs/egg/blob/b6861f1c7548f05a281386050dfeaeb30f236558/lib/core/messenger/ipc.js#L56 + // recognize receiverWorkerId is to who + const receiverWorkerId = data.receiverWorkerId ?? data.receiverPid; + if (receiverWorkerId) { + if (receiverWorkerId === String(process.pid)) { data.to = 'master'; - } else if (data.receiverPid === String(this.workerManager.getAgent().workerId)) { + } else if (receiverWorkerId === String(this.#workerManager.getAgent()!.workerId)) { data.to = 'agent'; } else { data.to = 'app'; @@ -89,9 +110,15 @@ class Messenger { // default from -> to rules if (!data.to) { - if (data.from === 'agent') data.to = 'app'; - if (data.from === 'app') data.to = 'agent'; - if (data.from === 'parent') data.to = 'master'; + if (data.from === 'agent') { + data.to = 'app'; + } + if (data.from === 'app') { + data.to = 'agent'; + } + if (data.from === 'parent') { + data.to = 'master'; + } } // app -> master @@ -133,34 +160,34 @@ class Messenger { * send message to master self * @param {Object} data message body */ - sendToMaster(data) { - this.master.emit(data.action, data.data); + sendToMaster(data: MessageBody) { + // e.g: master.on('app-start', data => {}) + this.#master.emit(data.action, data.data); } /** * send message to parent process * @param {Object} data message body */ - sendToParent(data) { - if (!this.hasParent) { + sendToParent(data: MessageBody) { + if (!this.#hasParent) { return; } - process.send(data); + process.send!(data); } /** * send message to app worker * @param {Object} data message body */ - sendToAppWorker(data) { - const workerManager = this.workerManager; - for (const id of workerManager.listWorkerIds()) { - const worker = workerManager.getWorker(id); + sendToAppWorker(data: MessageBody) { + for (const worker of this.#workerManager.listWorkers()) { if (worker.state === 'disconnected') { continue; } - // check receiverPid - if (data.receiverPid && data.receiverPid !== String(worker.workerId)) { + // check receiverWorkerId + const receiverWorkerId = data.receiverWorkerId ?? data.receiverPid; + if (receiverWorkerId && receiverWorkerId !== String(worker.workerId)) { continue; } worker.send(data); @@ -171,13 +198,10 @@ class Messenger { * send message to agent worker * @param {Object} data message body */ - sendToAgentWorker(data) { - const agent = this.workerManager.getAgent(); + sendToAgentWorker(data: MessageBody) { + const agent = this.#workerManager.getAgent(); if (agent) { agent.send(data); } } - } - -module.exports = Messenger; diff --git a/src/utils/mode/base/agent.ts b/src/utils/mode/base/agent.ts new file mode 100644 index 0000000..3404515 --- /dev/null +++ b/src/utils/mode/base/agent.ts @@ -0,0 +1,90 @@ +import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import type { Worker } from 'node:worker_threads'; +import type { Logger } from 'egg-logger'; +import type { MasterOptions } from '../../../master.js'; +import type { MessageBody, Messenger } from '../../messenger.js'; +import { getSrcDirname } from '../../../dirname.js'; + +export abstract class BaseAgentWorker { + instance: T; + #instanceId: number; + #instanceStatus: string; + + constructor(instance: T) { + this.instance = instance; + } + + abstract get workerId(): number; + + get id() { + return this.#instanceId; + } + + set id(id) { + this.#instanceId = id; + } + + get status() { + return this.#instanceStatus; + } + + set status(status) { + this.#instanceStatus = status; + } + + abstract send(message: MessageBody): void; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static send(_message: MessageBody) { + throw new Error('BaseAgentWorker should implement send.'); + } + + static kill() { + throw new Error('BaseAgentWorker should implement kill.'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static gracefulExit(_options: any) { + throw new Error('BaseAgentWorker should implement gracefulExit.'); + } +} + +type LogFun = (msg: any, ...args: any[]) => void; + +export abstract class BaseAgentUtils extends EventEmitter { + protected options: MasterOptions; + protected messenger: Messenger; + protected log: LogFun; + protected logger: Logger; + // public attrs + startTime = 0; + + constructor(options: MasterOptions, { log, logger, messenger }: { + log: LogFun; + logger: Logger; + messenger: Messenger; + }) { + super(); + this.options = options; + this.log = log; + this.logger = logger; + this.messenger = messenger; + // this.instance = null; + } + + getAgentWorkerFile() { + return path.join(getSrcDirname(), 'agent_worker.js'); + } + + fork() { + throw new Error('BaseAgent should implement fork.'); + } + + clean() { + throw new Error('BaseAgent should implement clean.'); + } + + abstract kill(timeout: number): Promise; +} diff --git a/src/utils/mode/base/app.ts b/src/utils/mode/base/app.ts new file mode 100644 index 0000000..c9a9ef6 --- /dev/null +++ b/src/utils/mode/base/app.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import type { Worker as ClusterProcessWorker } from 'node:cluster'; +import type { Worker as ThreadWorker } from 'node:worker_threads'; +import type { Logger } from 'egg-logger'; +import type { MessageBody, Messenger } from '../../messenger.js'; +import type { MasterOptions } from '../../../master.js'; +import { getSrcDirname } from '../../../dirname.js'; + +export abstract class BaseAppWorker { + instance: T; + + constructor(instance: T) { + this.instance = instance; + } + + abstract get workerId(): number; + + abstract get id(): number; + + get state(): string { + return Reflect.get(this.instance!, 'state') as string; + } + + set state(state: string) { + Reflect.set(this.instance!, 'state', state); + } + + abstract get exitedAfterDisconnect(): boolean; + + abstract get exitCode(): number; + + get disableRefork(): boolean { + return Reflect.get(this.instance!, 'disableRefork') as boolean; + } + + set disableRefork(disableRefork: boolean) { + Reflect.set(this.instance!, 'disableRefork', disableRefork); + } + + get isDevReload(): boolean { + return Reflect.get(this.instance!, 'isDevReload') as boolean; + } + + set isDevReload(isDevReload: boolean) { + Reflect.set(this.instance!, 'isDevReload', isDevReload); + } + + abstract send(data: MessageBody): void; + + clean() { + throw new Error('BaseAppWorker should implement clean.'); + } + + // static methods use on src/app_worker.ts + + static get workerId(): number { + throw new Error('BaseAppWorker should implement workerId.'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static on(..._args: any[]) { + throw new Error('BaseAppWorker should implement on.'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static send(_message: MessageBody) { + throw new Error('BaseAgentWorker should implement send.'); + } + + static kill() { + throw new Error('BaseAppWorker should implement kill.'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static gracefulExit(_options: any) { + throw new Error('BaseAgentWorker should implement gracefulExit.'); + } +} + +type LogFun = (msg: any, ...args: any[]) => void; + +export abstract class BaseAppUtils extends EventEmitter { + options: MasterOptions; + protected messenger: Messenger; + protected log: LogFun; + protected logger: Logger; + protected isProduction: boolean; + // public attrs + startTime = 0; + startSuccessCount = 0; + isAllWorkerStarted = false; + + constructor(options: MasterOptions, { + log, logger, messenger, isProduction, + }: { + log: LogFun; + logger: Logger; + messenger: Messenger; + isProduction: boolean; + }) { + super(); + this.options = options; + this.log = log; + this.logger = logger; + this.messenger = messenger; + this.isProduction = isProduction; + } + + getAppWorkerFile() { + return path.join(getSrcDirname(), 'app_worker.js'); + } + + fork() { + throw new Error('BaseApp should implement fork.'); + } + + abstract kill(timeout: number): Promise; +} diff --git a/src/utils/mode/impl/process/agent.ts b/src/utils/mode/impl/process/agent.ts new file mode 100644 index 0000000..7bd1161 --- /dev/null +++ b/src/utils/mode/impl/process/agent.ts @@ -0,0 +1,119 @@ +import { fork, type ChildProcess, type ForkOptions } from 'node:child_process'; +import { sendmessage } from 'sendmessage'; +import { graceful as gracefulExit, type Options as gracefulExitOptions } from 'graceful-process'; +import { BaseAgentWorker, BaseAgentUtils } from '../../base/agent.js'; +import { terminate } from '../../../terminate.js'; +import type { MessageBody } from '../../../messenger.js'; +import { ClusterAgentWorkerError } from '../../../../error/ClusterAgentWorkerError.js'; + +export class AgentProcessWorker extends BaseAgentWorker { + get workerId() { + return this.instance.pid!; + } + + send(message: MessageBody) { + sendmessage(this.instance, message); + } + + static send(message: MessageBody) { + message.senderWorkerId = String(process.pid); + process.send!(message); + } + + static kill() { + process.exitCode = 1; + process.kill(process.pid); + } + + static gracefulExit(options: gracefulExitOptions) { + gracefulExit(options); + } +} + +export class AgentProcessUtils extends BaseAgentUtils { + #agentProcess: ChildProcess; + #id = 0; + instance: AgentProcessWorker; + + fork() { + this.startTime = Date.now(); + + const args = [ JSON.stringify(this.options) ]; + const forkOptions: ForkOptions & { windowsHide?: boolean } = {}; + + if (process.platform === 'win32') { + forkOptions.windowsHide = true; + } + + // add debug execArgv + const debugPort = process.env.EGG_AGENT_DEBUG_PORT ?? 5800; + if (this.options.isDebug) { + forkOptions.execArgv = process.execArgv.concat([ `--inspect-port=${debugPort}` ]); + } + + const agentProcess = this.#agentProcess = fork(this.getAgentWorkerFile(), args, forkOptions); + const agentWorker = this.instance = new AgentProcessWorker(agentProcess); + agentWorker.status = 'starting'; + agentWorker.id = ++this.#id; + this.emit('agent_forked', agentWorker); + this.log('[master] agent_worker#%s:%s start with clusterPort:%s', + agentWorker.id, agentWorker.workerId, this.options.clusterPort); + + // send debug message + if (this.options.isDebug) { + this.messenger.send({ + to: 'parent', + from: 'agent', + action: 'debug', + data: { + debugPort, + // keep compatibility, should use workerId instead + pid: agentWorker.workerId, + workerId: agentWorker.workerId, + }, + }); + } + // forwarding agent' message to messenger + agentProcess.on('message', (msg: MessageBody | string) => { + if (typeof msg === 'string') { + msg = { + action: msg, + data: msg, + }; + } + msg.from = 'agent'; + this.messenger.send(msg); + }); + // logger error event + agentProcess.on('error', err => { + err.name = 'AgentWorkerError'; + this.logger.error(new ClusterAgentWorkerError(agentWorker.id, agentWorker.workerId, agentWorker.status, err)); + }); + // agent exit message + agentProcess.once('exit', (code, signal) => { + this.messenger.send({ + action: 'agent-exit', + data: { + code, + signal, + }, + to: 'master', + from: 'agent', + }); + }); + + return this; + } + + clean() { + this.#agentProcess.removeAllListeners(); + } + + async kill(timeout: number) { + if (this.#agentProcess) { + this.log('[master] kill agent worker with signal SIGTERM'); + this.clean(); + await terminate(this.#agentProcess, timeout); + } + } +} diff --git a/src/utils/mode/impl/process/app.ts b/src/utils/mode/impl/process/app.ts new file mode 100644 index 0000000..b72765f --- /dev/null +++ b/src/utils/mode/impl/process/app.ts @@ -0,0 +1,164 @@ +import cluster, { type Worker as ClusterProcessWorker } from 'node:cluster'; +import { cfork } from 'cfork'; +import { sendmessage } from 'sendmessage'; +import { graceful as gracefulExit, type Options as gracefulExitOptions } from 'graceful-process'; +import { BaseAppWorker, BaseAppUtils } from '../../base/app.js'; +import { terminate } from '../../../terminate.js'; +import type { MessageBody } from '../../../messenger.js'; +import { ipcLogger, formatIpcMessage, internalIpcLogEnabled } from '../../../ipc_logger.js'; + +export class AppProcessWorker extends BaseAppWorker { + get id() { + return this.instance.id; + } + + get workerId() { + return this.instance.process.pid!; + } + + get exitedAfterDisconnect() { + return this.instance.exitedAfterDisconnect; + } + + get exitCode() { + return this.instance.process.exitCode!; + } + + send(message: MessageBody) { + ipcLogger.info(formatIpcMessage(`master->app#${this.workerId}`, message)); + sendmessage(this.instance, message); + } + + clean() { + this.instance.removeAllListeners(); + } + + // static methods use on src/app_worker.ts + + static get workerId() { + return process.pid; + } + + static on(event: string, listener: (...args: any[]) => void) { + process.on(event, listener); + } + + static send(message: MessageBody) { + message.senderWorkerId = String(process.pid); + ipcLogger.info(formatIpcMessage(`app#${process.pid}->master`, message)); + process.send!(message); + } + + static kill() { + process.exitCode = 1; + process.kill(process.pid); + } + + static gracefulExit(options: gracefulExitOptions) { + gracefulExit(options); + } +} + +export class AppProcessUtils extends BaseAppUtils { + fork() { + this.startTime = Date.now(); + this.startSuccessCount = 0; + + const args = [ JSON.stringify(this.options) ]; + this.log('[master] start appWorker with args %j (process)', args); + cfork({ + exec: this.getAppWorkerFile(), + args, + silent: false, + count: this.options.workers, + // don't refork in local env + refork: this.isProduction, + windowsHide: process.platform === 'win32', + }); + + let debugPort = process.debugPort; + cluster.on('fork', worker => { + const appWorker = new AppProcessWorker(worker); + this.emit('worker_forked', appWorker); + appWorker.disableRefork = true; + worker.on('message', (msg, handle) => { + if (typeof msg === 'string') { + msg = { + action: msg, + data: msg, + }; + } + msg.from = 'app'; + ipcLogger.info(formatIpcMessage( + `master<-app#${worker.process.pid}`, + msg, + handle, + )); + this.messenger.send(msg); + }); + + // cluster internal NODE_CLUSTER messages (listening / online / queryServer / accepted (fd ack) / close / ...) + // Must hook on `worker.process` (ChildProcess) — `cluster.Worker` doesn't forward `internalMessage`. + // Note: `internalMessage` is not a documented Node.js event but has been stable across major versions. + // Opt-in via EGG_CLUSTER_IPC_LOG because this is very verbose under load. + if (internalIpcLogEnabled) { + worker.process.on('internalMessage', (msg: { cmd?: string; act?: string; ack?: number }, handle: unknown) => { + if (!msg || msg.cmd !== 'NODE_CLUSTER') return; + const label = msg.act ? `cluster:${msg.act}` : `cluster:ack#${msg.ack ?? '?'}`; + ipcLogger.info(formatIpcMessage( + `master<-app#${worker.process.pid}`, + { action: label, data: msg }, + handle, + )); + }); + } + this.log('[master] app_worker#%s:%s start, state: %s, current workers: %j', + appWorker.id, appWorker.workerId, appWorker.state, + Object.keys(cluster.workers!)); + + // send debug message, due to `brk` scene, send here instead of app_worker.js + if (this.options.isDebug) { + debugPort++; + this.messenger.send({ + to: 'parent', + from: 'app', + action: 'debug', + data: { + debugPort, + // keep compatibility, should use workerId instead + pid: appWorker.workerId, + workerId: appWorker.workerId, + }, + }); + } + }); + cluster.on('disconnect', worker => { + const appWorker = new AppProcessWorker(worker); + this.log('[master] app_worker#%s:%s disconnect, suicide: %s, state: %s, current workers: %j', + appWorker.id, appWorker.workerId, appWorker.exitedAfterDisconnect, appWorker.state, + Object.keys(cluster.workers!)); + }); + cluster.on('exit', (worker, code, signal) => { + const appWorker = new AppProcessWorker(worker); + this.messenger.send({ + action: 'app-exit', + data: { + workerId: appWorker.workerId, + code, + signal, + }, + to: 'master', + from: 'app', + }); + }); + return this; + } + + async kill(timeout: number) { + await Promise.all(Object.keys(cluster.workers!).map(id => { + const worker = cluster.workers![id]!; + Reflect.set(worker, 'disableRefork', true); + return terminate(worker.process, timeout); + })); + } +} diff --git a/lib/utils/mode/impl/worker_threads/agent.js b/src/utils/mode/impl/worker_threads/agent.ts similarity index 59% rename from lib/utils/mode/impl/worker_threads/agent.js rename to src/utils/mode/impl/worker_threads/agent.ts index 3309d1e..b100362 100644 --- a/lib/utils/mode/impl/worker_threads/agent.js +++ b/src/utils/mode/impl/worker_threads/agent.ts @@ -1,19 +1,21 @@ -'use strict'; +import workerThreads, { type Worker } from 'node:worker_threads'; +import { type Options as gracefulExitOptions } from 'graceful-process'; +import { BaseAgentUtils, BaseAgentWorker } from '../../base/agent.js'; +import type { MessageBody } from '../../../messenger.js'; +import { ClusterAgentWorkerError } from '../../../../error/ClusterAgentWorkerError.js'; -const workerThreads = require('worker_threads'); -const { BaseAgentUtils, BaseAgentWorker } = require('../../base/agent'); - -class AgentWorker extends BaseAgentWorker { +export class AgentThreadWorker extends BaseAgentWorker { get workerId() { return this.instance.threadId; } - send(...args) { - this.instance.postMessage(...args); + send(message: MessageBody) { + this.instance.postMessage(message); } - static send(data) { - workerThreads.parentPort.postMessage(data); + static send(message: MessageBody) { + message.senderWorkerId = String(workerThreads.threadId); + workerThreads.parentPort!.postMessage(message); } static kill() { @@ -22,7 +24,7 @@ class AgentWorker extends BaseAgentWorker { process.exit(1); } - static gracefulExit(options) { + static gracefulExit(options: gracefulExitOptions) { const { beforeExit } = options; process.on('exit', async code => { if (typeof beforeExit === 'function') { @@ -33,9 +35,10 @@ class AgentWorker extends BaseAgentWorker { } } -class AgentUtils extends BaseAgentUtils { - #worker = null; +export class AgentThreadUtils extends BaseAgentUtils { + #worker: Worker; #id = 0; + instance: AgentThreadWorker; fork() { this.startTime = Date.now(); @@ -46,7 +49,7 @@ class AgentUtils extends BaseAgentUtils { const worker = this.#worker = new workerThreads.Worker(agentPath, { argv }); // wrap agent worker - const agentWorker = this.instance = new AgentWorker(worker); + const agentWorker = this.instance = new AgentThreadWorker(worker); this.emit('agent_forked', agentWorker); agentWorker.status = 'starting'; agentWorker.id = ++this.#id; @@ -65,14 +68,11 @@ class AgentUtils extends BaseAgentUtils { }); worker.on('error', err => { - err.name = 'AgentWorkerError'; - err.id = worker.id; - err.pid = agentWorker.workerId; - this.logger.error(err); + this.logger.error(new ClusterAgentWorkerError(agentWorker.id, agentWorker.workerId, agentWorker.status, err)); }); // agent exit message - worker.once('exit', (code, signal) => { + worker.once('exit', (code: number, signal: string) => { this.messenger.send({ action: 'agent-exit', data: { @@ -90,13 +90,10 @@ class AgentUtils extends BaseAgentUtils { } async kill() { - const worker = this.#worker; - if (worker) { + if (this.#worker) { this.log(`[master] kill agent worker#${this.#id} (worker_threads) by worker.terminate()`); this.clean(); - worker.terminate(); + await this.#worker.terminate(); } } } - -module.exports = { AgentWorker, AgentUtils }; diff --git a/lib/utils/mode/impl/worker_threads/app.js b/src/utils/mode/impl/worker_threads/app.ts similarity index 56% rename from lib/utils/mode/impl/worker_threads/app.js rename to src/utils/mode/impl/worker_threads/app.ts index bf6404e..91c2191 100644 --- a/lib/utils/mode/impl/worker_threads/app.js +++ b/src/utils/mode/impl/worker_threads/app.ts @@ -1,18 +1,16 @@ -'use strict'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { Worker as ThreadWorker, threadId, parentPort, type WorkerOptions } from 'node:worker_threads'; +import type { Options as gracefulExitOptions } from 'graceful-process'; +import { BaseAppWorker, BaseAppUtils } from '../../base/app.js'; +import type { MessageBody } from '../../../messenger.js'; -const { sleep } = require('../../../timer'); -const workerThreads = require('worker_threads'); -const { BaseAppWorker, BaseAppUtils } = require('../../base/app'); - -class AppWorker extends BaseAppWorker { - #id = 0; - #threadId = -1; +export class AppThreadWorker extends BaseAppWorker { #state = 'none'; + #id: number; - constructor(instance, id) { + constructor(instance: ThreadWorker, id: number) { super(instance); this.#id = id; - this.#threadId = instance.threadId; } get id() { @@ -20,7 +18,7 @@ class AppWorker extends BaseAppWorker { } get workerId() { - return this.#threadId; + return this.instance.threadId; } get state() { @@ -36,53 +34,60 @@ class AppWorker extends BaseAppWorker { } get exitCode() { - return this.instance.exitCode; + return 0; + // return this.instance.exitCode; } - send(...args) { - this.instance.postMessage(...args); + send(message: MessageBody) { + this.instance.postMessage(message); } clean() { this.instance.removeAllListeners(); } - static on(event, callback) { - workerThreads.parentPort.on(event, callback); + // static methods use on src/app_worker.ts + + static get workerId() { + return threadId; } - static send(data) { - workerThreads.parentPort.postMessage(data); + static on(event: string, listener: (...args: any[]) => void) { + parentPort!.on(event, listener); + } + + static send(message: MessageBody) { + message.senderWorkerId = String(threadId); + parentPort!.postMessage(message); } static kill() { process.exit(1); } - static gracefulExit(options) { - const { beforeExit } = options; + static gracefulExit(options: gracefulExitOptions) { process.on('exit', async code => { - if (typeof beforeExit === 'function') { - await beforeExit(); + if (typeof options.beforeExit === 'function') { + await options.beforeExit(); } process.exit(code); }); } } -class AppUtils extends BaseAppUtils { - #workers = []; +export class AppThreadUtils extends BaseAppUtils { + #workers: ThreadWorker[] = []; - #forkSingle(appPath, options, id) { + #forkSingle(appPath: string, options: WorkerOptions, id: number) { // start app worker - const worker = new workerThreads.Worker(appPath, options); + const worker = new ThreadWorker(appPath, options); this.#workers.push(worker); // wrap app worker - const appWorker = new AppWorker(worker, id); + const appWorker = new AppThreadWorker(worker, id); this.emit('worker_forked', appWorker); appWorker.disableRefork = true; - worker.on('message', msg => { + worker.on('message', (msg: MessageBody) => { if (typeof msg === 'string') { msg = { action: msg, @@ -94,7 +99,7 @@ class AppUtils extends BaseAppUtils { }); this.log('[master] app_worker#%s (tid:%s) start', appWorker.id, appWorker.workerId); - // send debug message, due to `brk` scence, send here instead of app_worker.js + // send debug message, due to `brk` scene, send here instead of app_worker.js let debugPort = process.debugPort; if (this.options.isDebug) { debugPort++; @@ -105,32 +110,10 @@ class AppUtils extends BaseAppUtils { data: { debugPort, pid: appWorker.workerId, - }, - }); - } - - // handle worker listening - worker.on('message', ({ action, data: address }) => { - if (action !== 'listening') { - return; - } - - if (!address) { - return; - } - - appWorker.state = 'listening'; - this.messenger.send({ - action: 'app-start', - data: { workerId: appWorker.workerId, - address, }, - to: 'master', - from: 'app', }); - - }); + } // handle worker exit worker.on('exit', async code => { @@ -155,9 +138,9 @@ class AppUtils extends BaseAppUtils { this.startTime = Date.now(); this.startSuccessCount = 0; - const ports = this.options.ports; + const ports = this.options.ports ?? []; if (!ports.length) { - ports.push(this.options.port); + ports.push(this.options.port!); } this.options.workers = ports.length; let i = 0; @@ -172,11 +155,10 @@ class AppUtils extends BaseAppUtils { async kill() { for (const worker of this.#workers) { - this.log(`[master] kill app worker#${worker.id} (worker_threads) by worker.terminate()`); + const id = Reflect.get(worker, 'id'); + this.log(`[master] kill app worker#${id} (worker_threads) by worker.terminate()`); worker.removeAllListeners(); worker.terminate(); } } } - -module.exports = { AppWorker, AppUtils }; diff --git a/src/utils/options.ts b/src/utils/options.ts new file mode 100644 index 0000000..dbf575a --- /dev/null +++ b/src/utils/options.ts @@ -0,0 +1,171 @@ +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert'; +import { SecureContextOptions } from 'node:tls'; +import { getFrameworkPath, importModule } from '@eggjs/utils'; + +export interface ClusterHTTPSSecureOptions { + key: SecureContextOptions['key']; + cert: SecureContextOptions['cert']; + ca?: SecureContextOptions['ca']; + passphrase?: SecureContextOptions['passphrase']; +} + +export type ClusterStartMode = 'process' | 'worker_threads'; + +/** Cluster start options */ +export interface ClusterOptions { + /** + * specify framework that can be absolute path or npm package + */ + framework?: string; + /** + * @deprecated please use framework instead + */ + customEgg?: string; + /** directory of application, default to `process.cwd()` */ + baseDir?: string; + /** + * numbers of app workers, default to `os.cpus().length` + */ + workers?: number | string; + /** + * listening port, default to `7001`(http) or `8443`(https) + */ + port?: number | string | null; + /** + * listening a debug port on http protocol + */ + debugPort?: number; + /** + * https options, { key, cert, ca }, full path + */ + https?: ClusterHTTPSSecureOptions | boolean; + /** + * @deprecated please use `options.https.key` instead + */ + key?: ClusterHTTPSSecureOptions['key']; + /** + * @deprecated please use `options.https.cert` instead + */ + cert?: ClusterHTTPSSecureOptions['cert']; + /** + * will inject into worker/agent process + */ + require?: string | string[]; + /** + * will save master pid to this file + */ + pidFile?: string; + /** + * custom env, default is `process.env.EGG_SERVER_ENV` + */ + env?: string; + /** + * default is `'process'`, use `'worker_threads'` to start the app & agent worker by worker_threads + */ + startMode?: ClusterStartMode; + /** + * startup port of each app worker, such as: `[7001, 7002, 7003]`, only effects when the startMode is `'worker_threads'` + */ + ports?: number[]; + /** + * sticky mode server + */ + sticky?: boolean; + /** customized plugins, for unittest */ + plugins?: object; + isDebug?: boolean; +} + +export interface ParsedClusterOptions extends ClusterOptions { + port?: number; + baseDir: string; + workers: number; + framework: string; + startMode: ClusterStartMode; +} + +export async function parseOptions(options?: ClusterOptions) { + options = { + baseDir: process.cwd(), + port: options?.https ? 8443 : undefined, + startMode: 'process', + // ports: [], + env: process.env.EGG_SERVER_ENV, + ...options, + }; + + const pkgPath = path.join(options.baseDir!, 'package.json'); + assert(fs.existsSync(pkgPath), `${pkgPath} should exist`); + + options.framework = getFrameworkPath({ + baseDir: options.baseDir!, + // compatible customEgg only when call startCluster directly without framework + framework: options.framework ?? options.customEgg, + }); + + const egg = await importModule(options.framework, { + paths: [ options.baseDir! ], + }); + assert(egg.Application, `should define Application in ${options.framework}`); + assert(egg.Agent, `should define Agent in ${options.framework}`); + + if (options.https === true) { + // Keep compatible options.key, options.cert + console.warn('[@eggjs/cluster:deprecated] [master] Please use `https: { key, cert, ca }` instead of `https: true`'); + options.https = { + key: options.key, + cert: options.cert, + }; + } + + // https + if (options.https) { + assert(options.https.key, 'options.https.key should exists'); + if (typeof options.https.key === 'string') { + assert(fs.existsSync(options.https.key), 'options.https.key file should exists'); + } + assert(options.https.cert, 'options.https.cert should exists'); + if (typeof options.https.cert === 'string') { + assert(fs.existsSync(options.https.cert), 'options.https.cert file should exists'); + } + if (typeof options.https.ca === 'string') { + assert(fs.existsSync(options.https.ca), 'options.https.ca file should exists'); + } + } + + if (options.port && typeof options.port === 'string') { + options.port = parseInt(options.port); + } + if (options.port === null) { + options.port = undefined; + } + + if (options.workers && typeof options.workers === 'string') { + options.workers = parseInt(options.workers); + } + if (!options.workers) { + options.workers = os.cpus().length; + } + + if (options.require) { + if (typeof options.require === 'string') { + options.require = [ options.require ]; + } + } + + // don't print deprecated message in production env. + // it will print to stderr. + if (process.env.NODE_ENV === 'production') { + process.env.NO_DEPRECATION = '*'; + } + + const isDebug = process.execArgv.some(argv => argv.includes('--debug') || argv.includes('--inspect')); + if (isDebug) { + options.isDebug = isDebug; + } + + return options as ParsedClusterOptions; +} diff --git a/src/utils/terminate.ts b/src/utils/terminate.ts new file mode 100644 index 0000000..e4f0b00 --- /dev/null +++ b/src/utils/terminate.ts @@ -0,0 +1,97 @@ +import { debuglog } from 'node:util'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { once } from 'node:events'; +import { ChildProcess } from 'node:child_process'; +import { pstree } from '@fengmk2/ps-tree'; + +const debug = debuglog('@eggjs/cluster/utils/terminate'); + +interface SubProcess extends ChildProcess { + process?: ChildProcess; +} + +export async function terminate(subProcess: SubProcess, timeout: number) { + const pid = subProcess.process?.pid ?? subProcess.pid; + const childPids = await getChildPids(pid!); + await Promise.all([ + killProcess(subProcess, timeout), + killChildren(childPids, timeout), + ]); +} + +// kill process, if SIGTERM not work, try SIGKILL +async function killProcess(subProcess: SubProcess, timeout: number) { + // https://github.com/nodejs/node/pull/34312 + (subProcess.process ?? subProcess).kill('SIGTERM'); + await Promise.race([ + once(subProcess, 'exit'), + sleep(timeout), + ]); + if (subProcess.killed) { + return; + } + // SIGKILL: http://man7.org/linux/man-pages/man7/signal.7.html + // worker: https://github.com/nodejs/node/blob/master/lib/internal/cluster/worker.js#L22 + // subProcess.kill is wrapped to subProcess.destroy, it will wait to disconnected. + (subProcess.process ?? subProcess).kill('SIGKILL'); +} + +// kill all children processes, if SIGTERM not work, try SIGKILL +async function killChildren(childrenPids: number[], timeout: number) { + if (childrenPids.length === 0) { + return; + } + kill(childrenPids, 'SIGTERM'); + + const start = Date.now(); + // if timeout is 1000, it will check twice. + const checkInterval = 400; + let unterminated: number[] = []; + + while (Date.now() - start < timeout - checkInterval) { + await sleep(checkInterval); + unterminated = getUnterminatedProcesses(childrenPids); + if (unterminated.length === 0) { + return; + } + } + kill(unterminated, 'SIGKILL'); +} + +async function getChildPids(pid: number) { + let childrenPids: number[] = []; + try { + const children = await pstree(pid); + childrenPids = children!.map(c => parseInt(c.PID)); + } catch (err) { + // if get children error, just ignore it + debug('pstree %s error: %s, ignore it', pid, err); + } + return childrenPids; +} + +function kill(pids: number[], signal: string) { + for (const pid of pids) { + try { + process.kill(pid, signal); + } catch (err) { + // ignore + debug('kill %s error: %s, signal: %s, ignore it', pid, err, signal); + } + } +} + +function getUnterminatedProcesses(pids: number[]) { + return pids.filter(pid => { + try { + // success means it's still alive + process.kill(pid, 0); + return true; + } catch (err) { + // error means it's dead + debug('kill %s error: %s, it still alive', pid, err); + return false; + } + }); +} + diff --git a/lib/utils/manager.js b/src/utils/worker_manager.ts similarity index 61% rename from lib/utils/manager.js rename to src/utils/worker_manager.ts index eb753d9..11e6640 100644 --- a/lib/utils/manager.js +++ b/src/utils/worker_manager.ts @@ -1,13 +1,17 @@ -'use strict'; - -const EventEmitter = require('events'); +import { EventEmitter } from 'node:events'; +import { BaseAgentWorker } from './mode/base/agent.js'; +import { BaseAppWorker } from './mode/base/app.js'; // worker manager to record agent and worker forked by egg-cluster // can do some check stuff here to monitor the healthy -class Manager extends EventEmitter { +export class WorkerManager extends EventEmitter { + agent: BaseAgentWorker | null; + workers = new Map(); + exception = 0; + timer: NodeJS.Timeout; + constructor() { super(); - this.workers = new Map(); this.agent = null; } @@ -15,7 +19,7 @@ class Manager extends EventEmitter { return Array.from(this.workers.keys()); } - setAgent(agent) { + setAgent(agent: BaseAgentWorker) { this.agent = agent; } @@ -27,15 +31,15 @@ class Manager extends EventEmitter { this.agent = null; } - setWorker(worker) { + setWorker(worker: BaseAppWorker) { this.workers.set(worker.workerId, worker); } - getWorker(workerId) { + getWorker(workerId: number) { return this.workers.get(workerId); } - deleteWorker(workerId) { + deleteWorker(workerId: number) { this.workers.delete(workerId); } @@ -43,10 +47,14 @@ class Manager extends EventEmitter { return Array.from(this.workers.keys()); } + listWorkers() { + return Array.from(this.workers.values()); + } + getListeningWorkerIds() { const keys = []; - for (const id of this.workers.keys()) { - if (this.getWorker(id).state === 'listening') { + for (const [ id, worker ] of this.workers.entries()) { + if (worker.state === 'listening') { keys.push(id); } } @@ -55,7 +63,7 @@ class Manager extends EventEmitter { count() { return { - agent: (this.agent && this.agent.status === 'started') ? 1 : 0, + agent: this.agent?.status === 'started' ? 1 : 0, worker: this.listWorkerIds().length, }; } @@ -63,10 +71,9 @@ class Manager extends EventEmitter { // check agent and worker must both alive // if exception appear 3 times, emit an exception event startCheck() { - this.exception = 0; this.timer = setInterval(() => { const count = this.count(); - if (count.agent && count.worker) { + if (count.agent > 0 && count.worker > 0) { this.exception = 0; return; } @@ -78,5 +85,3 @@ class Manager extends EventEmitter { }, 10000); } } - -module.exports = Manager; diff --git a/test/agent_worker.test.js b/test/agent_worker.test.ts similarity index 73% rename from test/agent_worker.test.js rename to test/agent_worker.test.ts index 91fcd24..0b9df09 100644 --- a/test/agent_worker.test.js +++ b/test/agent_worker.test.ts @@ -1,34 +1,38 @@ -const assert = require('assert'); -const path = require('path'); -const coffee = require('coffee'); -const mm = require('egg-mock'); -const { readFile } = require('fs/promises'); -const { sleep } = require('../lib/utils/timer'); -const utils = require('./utils'); +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { scheduler } from 'node:timers/promises'; +import coffee from 'coffee'; +import { mm, MockApplication } from '@eggjs/mock'; +import { cluster, getFilepath } from './utils.js'; -describe('test/agent_worker.test.js', () => { - let app; +describe('test/agent_worker.test.ts', () => { + let app: MockApplication; + + afterEach(mm.restore); describe('Fork Agent', () => { afterEach(() => app && app.close()); it('support config agent debug port', () => { mm(process.env, 'EGG_AGENT_DEBUG_PORT', '15800'); - app = utils.cluster('apps/agent-debug-port', { isDebug: true }); + app = cluster('apps/agent-debug-port', { isDebug: true, require: [ './inject1.js' ] } as any); return app - .expect('stdout', /15800/) + // .debug() + .expect('stdout', /@@inject1\.js run/) + .expect('stdout', /=15800/) .end(); }); it('agent debug port default 5800', () => { - app = utils.cluster('apps/agent-debug-port', { isDebug: true }); + app = cluster('apps/agent-debug-port', { isDebug: true } as any); return app - .expect('stdout', /5800/) + // .debug() + .expect('stdout', /=5800/) .end(); }); it('should exist when error happened during boot', () => { - app = utils.cluster('apps/agent-die-onboot'); + app = cluster('apps/agent-die-onboot'); return app // .debug() .expect('code', 1) @@ -38,7 +42,7 @@ describe('test/agent_worker.test.js', () => { }); it('should not start app when error happened during agent starting', () => { - app = utils.cluster('apps/agent-die-onboot'); + app = cluster('apps/agent-die-onboot'); return app .expect('code', 1) .expect('stderr', /\[master\] agent_worker#1:\d+ start fail, exiting with code:1/) @@ -48,7 +52,7 @@ describe('test/agent_worker.test.js', () => { }); it('should refork new agent_worker after app started', async () => { - app = utils.cluster('apps/agent-die'); + app = cluster('apps/agent-die'); await app // .debug() .expect('stdout', /\[master\] egg started on http:\/\/127.0.0.1:\d+/) @@ -59,7 +63,7 @@ describe('test/agent_worker.test.js', () => { action: 'kill-agent', }); - await sleep(20000); + await scheduler.wait(5000); app.expect('stderr', /\[master\] agent_worker#1:\d+ died/); app.expect('stdout', /\[master\] try to start a new agent_worker after 1s .../); @@ -68,7 +72,7 @@ describe('test/agent_worker.test.js', () => { }); it('should exit agent_worker when master die in accident', async () => { - app = utils.cluster('apps/agent-die'); + app = cluster('apps/agent-die'); await app // .debug() .expect('stdout', /\[master\] egg started on http:\/\/127.0.0.1:\d+/) @@ -76,14 +80,14 @@ describe('test/agent_worker.test.js', () => { // kill -9 master app.process.kill('SIGKILL'); - await sleep(5000); + await scheduler.wait(5000); app.expect('stderr', /\[app_worker\] receive disconnect event in cluster fork mode, exitedAfterDisconnect:false/) .expect('stderr', /\[agent_worker\] receive disconnect event on child_process fork mode, exiting with code:110/) .expect('stderr', /\[agent_worker\] exit with code:110/); }); it('should master exit when agent exit during app worker boot', () => { - app = utils.cluster('apps/agent-die-on-forkapp'); + app = cluster('apps/agent-die-on-forkapp'); return app // .debug() @@ -97,7 +101,7 @@ describe('test/agent_worker.test.js', () => { }); it('should exit when emit error during agent worker boot', () => { - app = utils.cluster('apps/agent-start-error'); + app = cluster('apps/agent-start-error'); return app // .debug() .expect('code', 1) @@ -108,7 +112,7 @@ describe('test/agent_worker.test.js', () => { }); it('should FrameworkErrorformater work during agent boot', () => { - app = utils.cluster('apps/agent-start-framework-error'); + app = cluster('apps/agent-start-framework-error'); return app // .debug() .expect('code', 1) @@ -117,7 +121,7 @@ describe('test/agent_worker.test.js', () => { }); it('should FrameworkErrorformater work during agent boot ready', () => { - app = utils.cluster('apps/agent-start-framework-ready-error'); + app = cluster('apps/agent-start-framework-ready-error'); return app // .debug() .expect('code', 1) @@ -127,11 +131,11 @@ describe('test/agent_worker.test.js', () => { // process.send is not exist if started by spawn it('master should not die if spawn error', async () => { - app = coffee.spawn('node', [ utils.getFilepath('apps/agent-die/start.js') ]); + app = coffee.spawn('node', [ getFilepath('apps/agent-die/start.js') ]) as any; // app.debug(); - app.close = () => app.proc.kill(); + app.close = async () => app.proc.kill(); - await sleep(3000); + await scheduler.wait(3000); app.emit('close', 0); app.expect('stderr', /Error: Cannot find module/); app.notExpect('stderr', /TypeError: process.send is not a function/); @@ -140,15 +144,15 @@ describe('test/agent_worker.test.js', () => { describe('agent custom loggers', () => { before(() => { - app = utils.cluster('apps/custom-logger'); + app = cluster('apps/custom-logger'); return app.ready(); }); after(() => app.close()); it('should support custom logger in agent', async () => { - await sleep(1500); + await scheduler.wait(1500); const content = await readFile( - path.join(__dirname, 'fixtures/apps/custom-logger/logs/monitor.log'), 'utf8'); + getFilepath('apps/custom-logger/logs/monitor.log'), 'utf8'); assert(content === 'hello monitor!\n'); }); }); diff --git a/test/app_worker.test.js b/test/app_worker.test.ts similarity index 69% rename from test/app_worker.test.js rename to test/app_worker.test.ts index 12db96b..d72b284 100644 --- a/test/app_worker.test.js +++ b/test/app_worker.test.ts @@ -1,19 +1,20 @@ -const assert = require('assert'); -const { rm } = require('fs/promises'); -const mm = require('egg-mock'); -const request = require('supertest'); -const urllib = require('urllib'); -const address = require('address'); -const utils = require('./utils'); -const { sleep } = require('../lib/utils/timer'); - -describe('test/app_worker.test.js', () => { - let app; +import { strict as assert } from 'node:assert'; +import { rm } from 'node:fs/promises'; +import { scheduler } from 'node:timers/promises'; +import { mm, MockApplication } from '@eggjs/mock'; +import { request } from '@eggjs/supertest'; +import urllib from 'urllib'; +import { ip } from 'address'; +import { cluster, getFilepath } from './utils.js'; + +describe('test/app_worker.test.ts', () => { + let app: MockApplication; afterEach(() => app && app.close()); + afterEach(mm.restore); describe('app worker', () => { before(() => { - app = utils.cluster('apps/app-server'); + app = cluster('apps/app-server'); return app.ready(); }); it('should emit `server`', () => { @@ -25,7 +26,7 @@ describe('test/app_worker.test.js', () => { describe('app worker error', () => { it('should exit when app worker error during boot', () => { - app = utils.cluster('apps/worker-die'); + app = cluster('apps/worker-die'); return app // .debug() @@ -34,7 +35,7 @@ describe('test/app_worker.test.js', () => { }); it('should exit when emit error during app worker boot', () => { - app = utils.cluster('apps/app-start-error', { + app = cluster('apps/app-start-error', { opt: { env: Object.assign({}, process.env, { EGG_APP_WORKER_LOGGER_LEVEL: 'INFO', @@ -50,7 +51,7 @@ describe('test/app_worker.test.js', () => { }); it('should FrameworkErrorformater work during app boot', () => { - app = utils.cluster('apps/app-start-framework-error', { + app = cluster('apps/app-start-framework-error', { opt: { env: Object.assign({}, process.env, { EGG_APP_WORKER_LOGGER_LEVEL: 'INFO', @@ -59,14 +60,15 @@ describe('test/app_worker.test.js', () => { }); return app - // .debug() + .debug() .expect('code', 1) - .expect('stderr', /CustomError: mock error \[ https\:\/\/eggjs\.org\/zh-cn\/faq\/customPlugin_99 \]/) + .expect('stderr', /CustomError: mock error/) + // .expect('stderr', /CustomError: mock error \[ https\:\/\/eggjs\.org\/zh-cn\/faq\/customPlugin_99 \]/) .end(); }); it('should FrameworkErrorformater work during app boot ready', () => { - app = utils.cluster('apps/app-start-framework-ready-error', { + app = cluster('apps/app-start-framework-ready-error', { opt: { env: Object.assign({}, process.env, { EGG_APP_WORKER_LOGGER_LEVEL: 'INFO', @@ -77,12 +79,13 @@ describe('test/app_worker.test.js', () => { return app // .debug() .expect('code', 1) - .expect('stderr', /CustomError: mock error \[ https\:\/\/eggjs\.org\/zh-cn\/faq\/customPlugin_99 \]/) + .expect('stderr', /CustomError: mock error/) + // .expect('stderr', /CustomError: mock error \[ https\:\/\/eggjs\.org\/zh-cn\/faq\/customPlugin_99 \]/) .end(); }); it('should remove error listener after ready', async () => { - app = utils.cluster('apps/app-error-listeners'); + app = cluster('apps/app-error-listeners'); await app.ready(); await app.httpRequest() .get('/') @@ -94,7 +97,7 @@ describe('test/app_worker.test.js', () => { }); it('should ignore listen to other port', done => { - app = utils.cluster('apps/other-port'); + app = cluster('apps/other-port'); // app.debug(); app.notExpect('stdout', /started at 7002/).end(done); }); @@ -103,7 +106,7 @@ describe('test/app_worker.test.js', () => { describe('app worker error in env === "default"', () => { before(() => { mm.env('default'); - app = utils.cluster('apps/app-die'); + app = cluster('apps/app-die'); // app.debug(); return app.ready(); }); @@ -115,7 +118,7 @@ describe('test/app_worker.test.js', () => { .expect(200); // wait app worker restart - await sleep(10000); + await scheduler.wait(5000); app.expect('stdout', /app_worker#1:\d+ disconnect/); app.expect('stdout', /app_worker#2:\d+ started/); @@ -125,13 +128,16 @@ describe('test/app_worker.test.js', () => { describe('app worker error when env === "local"', () => { before(() => { mm.env('local'); - app = utils.cluster('apps/app-die'); + app = cluster('apps/app-die'); // app.debug(); return app.ready(); }); - after(mm.restore); + after(async () => { + await app.close(); + await mm.restore(); + }); - it('should restart', async () => { + it('should restart disable on local env', async () => { try { await app.httpRequest() .get('/exit'); @@ -139,18 +145,17 @@ describe('test/app_worker.test.js', () => { // ignore } - // wait app worker restart - await sleep(10000); + await scheduler.wait(1000); - app.expect('stdout', /app_worker#1:\d+ disconnect/); - app.expect('stderr', /don't fork new work/); + app.expect('stderr', /worker:\d+ disconnect/); + app.expect('stderr', /don't fork new work \(refork: false, reforkCount: 0\)/); }); }); describe('app worker kill when env === "local"', () => { before(() => { mm.env('local'); - app = utils.cluster('apps/app-kill'); + app = cluster('apps/app-kill'); // app.debug(); return app.ready(); }); @@ -165,16 +170,16 @@ describe('test/app_worker.test.js', () => { } // wait app worker restart - await sleep(10000); + await scheduler.wait(1000); - app.expect('stdout', /app_worker#1:\d+ disconnect/); + app.expect('stderr', /worker:\d+ disconnect/); app.expect('stderr', /don't fork new work/); }); }); describe('app start timeout', () => { it('should exit', () => { - app = utils.cluster('apps/app-start-timeout'); + app = cluster('apps/app-start-timeout'); return app // .debug() .expect('code', 1) @@ -187,24 +192,28 @@ describe('test/app_worker.test.js', () => { }); describe('listen config', () => { - const sockFile = utils.getFilepath('apps/app-listen-path/my.sock'); + const sockFile = getFilepath('apps/app-listen-path/my.sock'); beforeEach(() => { mm.env('default'); }); - afterEach(mm.restore); + afterEach(async () => { + await app.close(); + await mm.restore(); + }); afterEach(() => rm(sockFile, { force: true, recursive: true })); - it('should error then port is not specified', async () => { - app = utils.cluster('apps/app-listen-without-port'); + it('should set default port 170xx then config.listen.port is null', async () => { + app = cluster('apps/app-listen-without-port'); // app.debug(); await app.ready(); - app.expect('code', 1); - app.expect('stderr', /port should be number, but got null/); + app.expect('code', 0); + app.expect('stdout', /egg started on http:\/\/127.0.0.1:\d+/); + // app.expect('stderr', /port should be number, but got null/); }); it('should use port in config', async () => { - app = utils.cluster('apps/app-listen-port'); + app = cluster('apps/app-listen-port', { port: 0 }); // app.debug(); await app.ready(); @@ -230,12 +239,22 @@ describe('test/app_worker.test.js', () => { .get('/port') .expect('17010') .expect(200); + + // ipv6 + // await request('http://[::1]:17010') + // .get('/') + // .expect('done') + // .expect(200); + // await request('http://[::1]:17010') + // .get('/port') + // .expect('17010') + // .expect(200); }); it('should use hostname in config', async () => { - const url = address.ip() + ':17010'; + const url = ip() + ':17010'; - app = utils.cluster('apps/app-listen-hostname'); + app = cluster('apps/app-listen-hostname', { port: 0 }); // app.debug(); await app.ready(); @@ -252,13 +271,13 @@ describe('test/app_worker.test.js', () => { assert(response.status === 200); assert(response.data === 'done'); throw new Error('should not run'); - } catch (err) { + } catch (err: any) { assert(/ECONNREFUSED/.test(err.message)); } }); it('should use path in config', async () => { - app = utils.cluster('apps/app-listen-path'); + app = cluster('apps/app-listen-path'); // app.debug(); await app.ready(); @@ -276,13 +295,13 @@ describe('test/app_worker.test.js', () => { it('should exit when EADDRINUSE', async () => { mm.env('default'); - app = utils.cluster('apps/app-server', { port: 17001 }); + app = cluster('apps/app-server', { port: 17001 }); // app.debug(); await app.ready(); let app2; try { - app2 = utils.cluster('apps/app-server', { port: 17001 }); + app2 = cluster('apps/app-server', { port: 17001 }); app2.debug(); await app2.ready(); @@ -300,7 +319,7 @@ describe('test/app_worker.test.js', () => { }); it('should refork when app_worker exit', async () => { - app = utils.cluster('apps/app-die'); + app = cluster('apps/app-die'); // app.debug(); await app.ready(); @@ -308,7 +327,7 @@ describe('test/app_worker.test.js', () => { .get('/exit') .expect(200); - await sleep(10000); + await scheduler.wait(10000); app.expect('stdout', /app_worker#1:\d+ started at \d+/); app.expect('stderr', /new worker:\d+ fork/); @@ -319,13 +338,13 @@ describe('test/app_worker.test.js', () => { .get('/exit') .expect(200); - await sleep(10000); + await scheduler.wait(10000); app.expect('stdout', /app_worker#3:\d+ started at \d+/); }); it('should not refork when starting', async () => { - app = utils.cluster('apps/app-start-error'); + app = cluster('apps/app-start-error'); // app.debug(); await app.ready(); diff --git a/test/fixtures/apps/agent-debug-port/agent.js b/test/fixtures/apps/agent-debug-port/agent.js index 52274e8..b1fec4b 100644 --- a/test/fixtures/apps/agent-debug-port/agent.js +++ b/test/fixtures/apps/agent-debug-port/agent.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = () => { console.log('agent argv: ', process.execArgv); }; diff --git a/test/fixtures/apps/agent-debug-port/inject1.js b/test/fixtures/apps/agent-debug-port/inject1.js new file mode 100644 index 0000000..bed6f80 --- /dev/null +++ b/test/fixtures/apps/agent-debug-port/inject1.js @@ -0,0 +1 @@ +console.log('@@inject1.js run'); diff --git a/test/fixtures/apps/app-listen-port/app/router.js b/test/fixtures/apps/app-listen-port/app/router.js index 26e4658..cc9d2b9 100644 --- a/test/fixtures/apps/app-listen-port/app/router.js +++ b/test/fixtures/apps/app-listen-port/app/router.js @@ -4,6 +4,6 @@ module.exports = app => { }); app.get('/port', ctx => { - ctx.body = ctx.app._options.port; + ctx.body = ctx.app.options.port; }); }; diff --git a/test/fixtures/apps/app-listen-port/config/config.default.js b/test/fixtures/apps/app-listen-port/config/config.default.js index 31ceedd..dafee9c 100644 --- a/test/fixtures/apps/app-listen-port/config/config.default.js +++ b/test/fixtures/apps/app-listen-port/config/config.default.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = { keys: '123', cluster: { diff --git a/test/fixtures/apps/app-listen-without-port/config/config.default.js b/test/fixtures/apps/app-listen-without-port/config/config.default.js index 97f0392..0705763 100644 --- a/test/fixtures/apps/app-listen-without-port/config/config.default.js +++ b/test/fixtures/apps/app-listen-without-port/config/config.default.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = { cluster: { listen: { diff --git a/test/fixtures/apps/before-close/agent.js b/test/fixtures/apps/before-close/agent.js index 1342e23..f969100 100644 --- a/test/fixtures/apps/before-close/agent.js +++ b/test/fixtures/apps/before-close/agent.js @@ -1,9 +1,9 @@ -const { sleep } = require('../../../../lib/utils/timer'); +const { scheduler } = require('node:timers/promises'); module.exports = agent => { agent.beforeClose(async () => { console.log('agent closing'); - await sleep(10); + await scheduler.wait(10); console.log('agent closed'); }); }; diff --git a/test/fixtures/apps/before-close/app.js b/test/fixtures/apps/before-close/app.js index 2dc0855..83aa00f 100644 --- a/test/fixtures/apps/before-close/app.js +++ b/test/fixtures/apps/before-close/app.js @@ -1,9 +1,9 @@ -const { sleep } = require('../../../../lib/utils/timer'); +const { scheduler } = require('node:timers/promises'); module.exports = app => { app.beforeClose(async () => { console.log('app closing'); - await sleep(10); + await scheduler.wait(10); console.log('app closed'); }); }; diff --git a/test/fixtures/apps/framework/index.js b/test/fixtures/apps/framework/index.js index e1e4abe..7a5103e 100644 --- a/test/fixtures/apps/framework/index.js +++ b/test/fixtures/apps/framework/index.js @@ -1,7 +1,5 @@ -'use strict'; +const { startCluster } = require('egg'); -const egg = require('egg'); - -module.exports = egg; -module.exports.Application = require('./lib/framework'); -module.exports.Agent = require('./lib/agent'); +exports.startCluster = startCluster; +exports.Application = require('./lib/framework'); +exports.Agent = require('./lib/agent'); diff --git a/test/fixtures/apps/framework/lib/agent.js b/test/fixtures/apps/framework/lib/agent.js index 411e25d..5305222 100644 --- a/test/fixtures/apps/framework/lib/agent.js +++ b/test/fixtures/apps/framework/lib/agent.js @@ -1,8 +1,5 @@ -'use strict'; - const path = require('path'); -const egg = require('egg'); -const Agent = egg.Agent; +const { Agent } = require('egg'); class FrameworkAgent extends Agent { get [Symbol.for('egg#eggPath')]() { diff --git a/test/fixtures/apps/framework/lib/framework.js b/test/fixtures/apps/framework/lib/framework.js index 5a5bb59..814b95a 100644 --- a/test/fixtures/apps/framework/lib/framework.js +++ b/test/fixtures/apps/framework/lib/framework.js @@ -1,14 +1,12 @@ -'use strict'; - const path = require('path'); const egg = require('egg'); const Application = egg.Application; const AppWorkerLoader = egg.AppWorkerLoader; class Loader extends AppWorkerLoader { - loadConfig() { + async loadConfig() { this.loadServerConf(); - super.loadConfig(); + await super.loadConfig(); } loadServerConf() {} diff --git a/test/fixtures/apps/options/agent.js b/test/fixtures/apps/options/agent.js index 0ca529c..5bfaf84 100644 --- a/test/fixtures/apps/options/agent.js +++ b/test/fixtures/apps/options/agent.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = agent => { console.log('agent options foo: %s', agent._options.foo); }; diff --git a/test/fixtures/apps/options/app.js b/test/fixtures/apps/options/app.js index 33d5014..28ae7b7 100644 --- a/test/fixtures/apps/options/app.js +++ b/test/fixtures/apps/options/app.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = app => { console.log('app options foo: %s', app._options.foo); }; diff --git a/test/fixtures/apps/options/config/plugin.js b/test/fixtures/apps/options/config/plugin.js new file mode 100644 index 0000000..9f18dbd --- /dev/null +++ b/test/fixtures/apps/options/config/plugin.js @@ -0,0 +1,2 @@ +exports.schedule = false; +exports.logrotator = false; diff --git a/test/fixtures/apps/script-start/start-server.js b/test/fixtures/apps/script-start/start-server.js index b51ad58..70fbbc7 100644 --- a/test/fixtures/apps/script-start/start-server.js +++ b/test/fixtures/apps/script-start/start-server.js @@ -1,4 +1,4 @@ -const { sleep } = require('../../../../lib/utils/timer'); +const { scheduler } = require('node:timers/promises'); const utils = require('../../../utils'); (async () => { @@ -17,5 +17,5 @@ const utils = require('../../../utils'); }); }); - await sleep(3000); + await scheduler.wait(3000); })(); diff --git a/test/fixtures/apps/worker-close-timeout/agent.js b/test/fixtures/apps/worker-close-timeout/agent.js index b12a6b7..1cb8dd4 100644 --- a/test/fixtures/apps/worker-close-timeout/agent.js +++ b/test/fixtures/apps/worker-close-timeout/agent.js @@ -1,11 +1,11 @@ -const { sleep } = require('../../../../lib/utils/timer'); +const { scheduler } = require('node:timers/promises'); module.exports = app => { const timeout = process.env.EGG_MASTER_CLOSE_TIMEOUT || 5000; app.beforeClose(async () => { app.logger.info('agent worker start close: ' + Date.now()); - await sleep(timeout * 2); + await scheduler.wait(timeout * 2); app.logger.info('agent worker: never called after timeout'); }); }; diff --git a/test/fixtures/apps/worker-close-timeout/app.js b/test/fixtures/apps/worker-close-timeout/app.js index 9daf114..57a78ae 100644 --- a/test/fixtures/apps/worker-close-timeout/app.js +++ b/test/fixtures/apps/worker-close-timeout/app.js @@ -1,11 +1,11 @@ -const { sleep } = require('../../../../lib/utils/timer'); +const { scheduler } = require('node:timers/promises'); module.exports = app => { const timeout = process.env.EGG_MASTER_CLOSE_TIMEOUT || 5000; app.beforeClose(async () => { app.logger.info('app worker start close', Date.now(), timeout); - await sleep(timeout * 2); + await scheduler.wait(timeout * 2); app.logger.info('app worker never called after timeout'); }); }; diff --git a/test/fixtures/egg/index.js b/test/fixtures/egg/index.js index b789105..6ea5409 100644 --- a/test/fixtures/egg/index.js +++ b/test/fixtures/egg/index.js @@ -1,4 +1,6 @@ -'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ +const egg = require('egg'); -module.exports = require('egg'); -module.exports.startCluster = require('../../..').startCluster; +exports.startCluster = require('../../..').startCluster; +exports.Application = egg.Application; +exports.Agent = egg.Agent; diff --git a/test/https.test.js b/test/https.test.ts similarity index 60% rename from test/https.test.js rename to test/https.test.ts index 8d3c596..bfea124 100644 --- a/test/https.test.js +++ b/test/https.test.ts @@ -1,35 +1,33 @@ -const assert = require('assert'); -const path = require('path'); -const mm = require('egg-mock'); -const urllib = require('urllib'); -const utils = require('./utils'); +import assert from 'node:assert'; +import { mm, MockApplication } from '@eggjs/mock'; +import { HttpClient } from 'urllib'; +import { getFilepath, cluster } from './utils.js'; -const httpclient = new urllib.HttpClient({ connect: { rejectUnauthorized: false } }); +const httpclient = new HttpClient({ connect: { rejectUnauthorized: false } }); -describe('test/https.test.js', () => { - let app; +describe('test/https.test.ts', () => { + let app: MockApplication; afterEach(mm.restore); describe('start https server with cluster options', () => { afterEach(() => app && app.close()); it('should success with status 200', async () => { - const baseDir = path.join(__dirname, 'fixtures/apps/https-server'); + const baseDir = getFilepath('apps/https-server'); const options = { baseDir, port: 8443, https: { - key: utils.getFilepath('server.key'), - cert: utils.getFilepath('server.cert'), - ca: utils.getFilepath('server.ca'), + key: getFilepath('server.key'), + cert: getFilepath('server.cert'), + ca: getFilepath('server.ca'), }, }; - app = utils.cluster('apps/https-server', options); + app = cluster('apps/https-server', options); await app.ready(); const response = await httpclient.request('https://127.0.0.1:8443', { dataType: 'text', - rejectUnauthorized: false, }); assert(response.status === 200); @@ -37,18 +35,18 @@ describe('test/https.test.js', () => { }); it('should listen https and http at the same time', async () => { - const baseDir = path.join(__dirname, 'fixtures/apps/https-server'); + const baseDir = getFilepath('apps/https-server'); const options = { baseDir, debugPort: 7001, port: 8443, https: { - key: utils.getFilepath('server.key'), - cert: utils.getFilepath('server.cert'), - ca: utils.getFilepath('server.ca'), + key: getFilepath('server.key'), + cert: getFilepath('server.cert'), + ca: getFilepath('server.ca'), }, }; - app = utils.cluster('apps/https-server', options); + app = cluster('apps/https-server', options); await app.ready(); let response = await httpclient.request('https://127.0.0.1:8443', { @@ -69,18 +67,17 @@ describe('test/https.test.js', () => { afterEach(() => app && app.close()); it('should success with status 200', async () => { - const baseDir = path.join(__dirname, 'fixtures/apps/https-server-config'); + const baseDir = getFilepath('apps/https-server-config'); const options = { baseDir, port: 8443, }; - app = utils.cluster('apps/https-server-config', options); + app = cluster('apps/https-server-config', options); await app.ready(); const response = await httpclient.request('https://127.0.0.1:8443', { dataType: 'text', - rejectUnauthorized: false, }); assert(response.status === 200); diff --git a/test/master.test.js b/test/master.test.ts similarity index 80% rename from test/master.test.js rename to test/master.test.ts index abf32c6..d1ac664 100644 --- a/test/master.test.js +++ b/test/master.test.ts @@ -1,17 +1,15 @@ -const path = require('path'); -const assert = require('assert'); -const fs = require('fs'); -const { rm } = require('fs/promises'); -const cp = require('child_process'); -const pedding = require('pedding'); -const mm = require('egg-mock'); -const request = require('supertest'); -const awaitEvent = require('await-event'); -const utils = require('./utils'); -const { sleep } = require('../lib/utils/timer'); - -describe('test/master.test.js', () => { - let app; +import path from 'node:path'; +import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { scheduler } from 'node:timers/promises'; +import { once } from 'node:events'; +import { request } from '@eggjs/supertest'; +import { mm, MockApplication } from '@eggjs/mock'; +import { cluster, getFilepath } from './utils.js'; + +describe('test/master.test.ts', () => { + let app: MockApplication; afterEach(mm.restore); @@ -20,7 +18,7 @@ describe('test/master.test.js', () => { it('start success in local env', done => { mm.env('local'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); app.expect('stdout', /egg start/) .expect('stdout', /egg started/) @@ -31,12 +29,13 @@ describe('test/master.test.js', () => { it('start success in prod env', done => { mm.env('prod'); - app = utils.cluster('apps/mock-production-app').debug(false); + app = cluster('apps/mock-production-app') + .debug(false); app.expect('stdout', /egg start/) .expect('stdout', /egg started/) .expect('code', 0) - .end(err => { + .end((err: unknown) => { assert.ifError(err); console.log(app.stdout); console.log(app.stderr); @@ -47,12 +46,13 @@ describe('test/master.test.js', () => { it('should print process.on.HOST while egg started', done => { mm.env('prod'); mm(process.env, 'HOST', 'xxx.com'); - app = utils.cluster('apps/mock-production-app').debug(false); + app = cluster('apps/mock-production-app') + .debug(false); app.expect('stdout', /egg start/) .expect('stdout', /egg started on http:\/\/xxx\.com:/) .expect('code', 0) - .end(err => { + .end((err: unknown) => { assert.ifError(err); console.log(app.stdout); console.log(app.stderr); @@ -63,12 +63,13 @@ describe('test/master.test.js', () => { it('should not print process.on.HOST if it equals 0.0.0.0', done => { mm.env('prod'); mm(process.env, 'HOST', '0.0.0.0'); - app = utils.cluster('apps/mock-production-app').debug(false); + app = cluster('apps/mock-production-app') + .debug(false); app.expect('stdout', /egg start/) .expect('stdout', /egg started on http:\/\/127\.0\.0\.1:/) .expect('code', 0) - .end(err => { + .end((err: unknown) => { assert.ifError(err); console.log(app.stdout); console.log(app.stderr); @@ -85,7 +86,7 @@ describe('test/master.test.js', () => { mm(process.env, 'EGG_APP_WORKER_LOGGER_LEVEL', 'INFO'); mm(process.env, 'EGG_AGENT_WORKER_LOGGER_LEVEL', 'INFO'); mm(process.env, 'EGG_MASTER_LOGGER_LEVEL', 'DEBUG'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app.expect('stdout', /egg start/) @@ -101,7 +102,7 @@ describe('test/master.test.js', () => { // 2017-05-27 21:24:38,106 INFO 59066 [agent_worker] receive signal SIGTERM, exiting with code:0 // 2017-05-27 21:24:38,107 INFO 59066 [agent_worker] exit with code:0 app.proc.kill('SIGTERM'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.expect('stdout', /INFO \d+ \[master\] master is killed by signal SIGTERM, closing/); app.expect('stdout', /\[master\] system memory: total \d+, free \d+/); @@ -119,7 +120,7 @@ describe('test/master.test.js', () => { it('master kill by SIGKILL and agent, app worker exit too', async () => { mm.env('local'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app.expect('stdout', /egg start/) @@ -133,7 +134,7 @@ describe('test/master.test.js', () => { // 2017-05-28 00:08:19,109 ERROR 59501 [agent_worker] exit with code:110 app.proc.kill('SIGKILL'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.notExpect('stdout', /\[master\] master is killed by signal SIGTERM, closing/); app.notExpect('stdout', /\[master\] close done, exiting with code:0/); @@ -145,7 +146,7 @@ describe('test/master.test.js', () => { it('master kill by SIGKILL and exit multi workers', async () => { mm.env('local'); - app = utils.cluster('apps/master-worker-started', { workers: 4 }); + app = cluster('apps/master-worker-started', { workers: 4 }); // app.debug(); await app.expect('stdout', /egg start/) @@ -159,7 +160,7 @@ describe('test/master.test.js', () => { // 2017-05-28 00:08:19,109 ERROR 59501 [agent_worker] exit with code:110 app.proc.kill('SIGKILL'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.notExpect('stdout', /\[master\] master is killed by signal SIGTERM, closing/); app.notExpect('stdout', /\[master\] close done, exiting with code:0/); @@ -171,7 +172,7 @@ describe('test/master.test.js', () => { it('use SIGTERM close master', async () => { mm.env('local'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app.expect('stdout', /egg start/) @@ -188,7 +189,7 @@ describe('test/master.test.js', () => { // 2017-05-28 00:14:33,047 INFO 59715 [agent_worker] receive signal SIGTERM, exiting with code:0 // 2017-05-28 00:14:33,048 INFO 59715 [agent_worker] exit with code:0 app.proc.kill('SIGTERM'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.expect('stdout', /\[master\] master is killed by signal SIGTERM, closing/); app.expect('stdout', /\[master\] system memory: total \d+, free \d+/); @@ -198,7 +199,7 @@ describe('test/master.test.js', () => { it('use SIGQUIT close master', async () => { mm.env('local'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app.expect('stdout', /egg start/) @@ -207,7 +208,7 @@ describe('test/master.test.js', () => { .end(); app.proc.kill('SIGQUIT'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.expect('stdout', /\[master\] master is killed by signal SIGQUIT, closing/); @@ -218,7 +219,7 @@ describe('test/master.test.js', () => { it('use SIGINT close master', async () => { mm.env('local'); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app @@ -228,7 +229,7 @@ describe('test/master.test.js', () => { .end(); app.proc.kill('SIGINT'); - await sleep(6000); + await scheduler.wait(6000); assert(app.proc.killed === true); app.expect('stdout', /\[master\] master is killed by signal SIGINT, closing/); @@ -243,7 +244,7 @@ describe('test/master.test.js', () => { mm(process.env, 'EGG_AGENT_WORKER_LOGGER_LEVEL', 'INFO'); mm(process.env, 'EGG_MASTER_LOGGER_LEVEL', 'DEBUG'); mm(process.env, 'EGG_MASTER_CLOSE_TIMEOUT', 1000); - app = utils.cluster('apps/master-worker-started'); + app = cluster('apps/master-worker-started'); // app.debug(); await app.expect('stdout', /egg start/) @@ -252,7 +253,7 @@ describe('test/master.test.js', () => { .end(); app.proc.kill('SIGTERM'); - await sleep(2000); + await scheduler.wait(2000); assert(app.proc.killed === true); app.expect('stdout', /INFO \d+ \[master\] exit with code:0/); app.expect('stdout', /INFO \d+ \[master\] wait 1000ms/); @@ -265,7 +266,7 @@ describe('test/master.test.js', () => { mm(process.env, 'EGG_MASTER_LOGGER_LEVEL', 'DEBUG'); mm(process.env, 'EGG_APP_CLOSE_TIMEOUT', 1000); mm(process.env, 'EGG_AGENT_CLOSE_TIMEOUT', 1000); - app = utils.cluster('apps/worker-close-timeout'); + app = cluster('apps/worker-close-timeout'); await app.expect('stdout', /egg start/) .expect('stdout', /egg started/) @@ -273,7 +274,7 @@ describe('test/master.test.js', () => { .end(); app.proc.kill('SIGTERM'); - await awaitEvent(app.proc, 'exit'); + await once(app.proc, 'exit'); app.expect('stdout', /INFO \d+ \[master\] exit with code:0/); app.expect('stdout', /INFO \d+ \[master\] wait 1000ms/); @@ -291,7 +292,7 @@ describe('test/master.test.js', () => { it('close master will terminate all sub processes', async () => { mm.env('local'); - app = utils.cluster('apps/sub-process'); + app = cluster('apps/sub-process'); await app.expect('stdout', /egg start/) // .debug() @@ -299,9 +300,9 @@ describe('test/master.test.js', () => { .expect('code', 0) .end(); - await sleep(3000); + await scheduler.wait(3000); app.proc.kill('SIGTERM'); - await sleep(5000); + await scheduler.wait(5000); assert(app.proc.killed === true); app.expect('stdout', /worker1 \[\d+\] started/); app.expect('stdout', /worker2 \[\d+\] started/); @@ -324,7 +325,7 @@ describe('test/master.test.js', () => { it('close master will terminate all sub processes with sigkill', async () => { mm.env('local'); - app = utils.cluster('apps/sub-process-sigkill'); + app = cluster('apps/sub-process-sigkill'); await app.expect('stdout', /egg start/) // .debug() @@ -332,9 +333,9 @@ describe('test/master.test.js', () => { .expect('code', 0) .end(); - await sleep(5000); + await scheduler.wait(5000); app.proc.kill('SIGTERM'); - await sleep(8000); + await scheduler.wait(8000); assert(app.proc.killed === true); app.expect('stdout', /worker1 \[\d+\] started/); app.expect('stdout', /worker2 \[\d+\] started/); @@ -358,14 +359,14 @@ describe('test/master.test.js', () => { }); describe('pid file', () => { - const runDir = path.join(__dirname, './fixtures/apps/master-worker-started/run'); + const runDir = getFilepath('apps/master-worker-started/run'); const pidFile = path.join(runDir, './pid'); beforeEach(() => rm(runDir, { force: true, recursive: true })); afterEach(() => app.close()); it('master should write pid file and delete', async () => { - app = utils.cluster('apps/master-worker-started', { pidFile }); + app = cluster('apps/master-worker-started', { pidFile } as any); // app.debug(); await app.expect('stdout', /egg start/) @@ -378,13 +379,13 @@ describe('test/master.test.js', () => { assert(pid === String(app.process.pid)); app.proc.kill('SIGTERM'); - await sleep(6000); + await scheduler.wait(6000); app.expect('stdout', /\[master\] exit with code:0/); assert(!fs.existsSync(pidFile)); }); it('master should ignore fail when delete pid file ', async () => { - app = utils.cluster('apps/master-worker-started', { pidFile }); + app = cluster('apps/master-worker-started', { pidFile } as any); // app.debug(); await app.expect('stdout', /egg start/) @@ -400,7 +401,7 @@ describe('test/master.test.js', () => { fs.unlinkSync(pidFile); app.proc.kill('SIGTERM'); - await sleep(6000); + await scheduler.wait(6000); app.expect('stdout', /\[master\] exit with code:0/); assert(!fs.existsSync(pidFile)); }); @@ -410,10 +411,10 @@ describe('test/master.test.js', () => { afterEach(() => app.close()); it('parent -> app/agent', async () => { - app = utils.cluster('apps/messenger'); + app = cluster('apps/messenger'); // app.debug(); - await app.end(); + await app.ready(); app.proc.send({ action: 'parent2app', @@ -426,31 +427,17 @@ describe('test/master.test.js', () => { to: 'agent', }); - await sleep(1000); + await scheduler.wait(1000); app.expect('stdout', /parent -> agent/); app.expect('stdout', /parent -> app/); }); - it('app/agent -> parent', done => { - done = pedding(3, done); - app = utils.cluster('apps/messenger'); - // app.debug(); - app.end(done); - - setTimeout(() => { - app.proc.on('message', msg => { - if (msg.action === 'app2parent') done(); - if (msg.action === 'agent2parent') done(); - }); - }, 1); - }); - it('should app <-> agent', async () => { - app = utils.cluster('apps/messenger'); + app = cluster('apps/messenger'); // app.debug(); - await app.end(); + await app.ready(); - await sleep(10000); + await scheduler.wait(1000); app.expect('stdout', /app -> agent/); app.expect('stdout', /agent -> app/); app.expect('stdout', /app: agent2appbystring/); @@ -458,56 +445,58 @@ describe('test/master.test.js', () => { }); it('should send multi app worker', async () => { - app = utils.cluster('apps/send-to-multiapp', { workers: 4 }); + app = cluster('apps/send-to-multiapp', { workers: 4 }); // app.debug(); - await app.end(); - await sleep(1000); + await app.ready(); + await scheduler.wait(1000); app.expect('stdout', /\d+ '?got'?/); }); it('sendTo should work', async () => { - app = utils.cluster('apps/messenger'); + app = cluster('apps/messenger'); // app.debug(); - await app.end(); + await app.ready(); app.proc.on('message', console.log); - await sleep(1000); + await scheduler.wait(1000); app.expect('stdout', /app sendTo agent done/); app.expect('stdout', /agent sendTo agent done/); app.expect('stdout', /app sendTo app done/); app.expect('stdout', /agent sendTo app done/); }); - it('egg-script exit', async () => { - app = { - close: () => {}, - }; - const appDir = path.join(__dirname, 'fixtures/apps/script-start'); - const errLogPath = path.join(appDir, 'stderr.log'); - const errFd = fs.openSync(errLogPath, 'w+'); - const p = cp.fork(path.join(appDir, 'start-server.js'), { - stdio: [ - 'ignore', - 'ignore', - errFd, - 'ipc', - ], - }); - let masterPid; - p.on('message', msg => { - masterPid = msg; - }); - await sleep(10000); - process.kill(masterPid); - process.kill(p.pid); - fs.closeSync(errFd); - const stderr = fs.readFileSync(errLogPath).toString(); - assert(!/channel closed/.test(stderr)); - }); + // it('egg-script exit', async () => { + // app = { + // close: async () => { + // await scheduler.wait(1); + // }, + // } as any; + // const appDir = path.join(__dirname, 'fixtures/apps/script-start'); + // const errLogPath = path.join(appDir, 'stderr.log'); + // const errFd = fs.openSync(errLogPath, 'w+'); + // const p = cp.fork(path.join(appDir, 'start-server.js'), { + // stdio: [ + // 'ignore', + // 'ignore', + // errFd, + // 'ipc', + // ], + // }); + // let masterPid; + // p.on('message', msg => { + // masterPid = msg; + // }); + // await scheduler.wait(10000); + // process.kill(masterPid); + // process.kill(p.pid); + // fs.closeSync(errFd); + // const stderr = fs.readFileSync(errLogPath).toString(); + // assert(!/channel closed/.test(stderr)); + // }); }); describe('--cluster', () => { before(() => { - app = utils.cluster('apps/cluster_mod_app'); + app = cluster('apps/cluster_mod_app'); return app.ready(); }); after(() => app.close()); @@ -521,13 +510,14 @@ describe('test/master.test.js', () => { }); describe('framework start', () => { - let app; + let app: MockApplication; afterEach(() => app.close()); before(() => { - app = utils.cluster('apps/frameworkapp', { - customEgg: utils.getFilepath('apps/frameworkbiz'), + mm.env('prod'); + app = cluster('apps/frameworkapp', { + framework: getFilepath('apps/frameworkbiz'), }); return app.ready(); }); @@ -545,12 +535,12 @@ describe('test/master.test.js', () => { }); describe('reload worker', () => { - let app; + let app: MockApplication; after(() => app.close()); before(() => { - app = utils.cluster('apps/reload-worker', { + app = cluster('apps/reload-worker', { workers: 4, }); // app.debug(); @@ -562,22 +552,22 @@ describe('test/master.test.js', () => { to: 'master', action: 'reload-worker', }); - await sleep(20000); + await scheduler.wait(5000); app.expect('stdout', /app_worker#4:\d+ disconnect/); app.expect('stdout', /app_worker#8:\d+ started/); }); }); describe('after started', () => { - let app; - let readyMsg; + let app: MockApplication; + let readyMsg: string; before(() => { mm.env('default'); - app = utils.cluster('apps/egg-ready'); + app = cluster('apps/egg-ready'); // app.debug(); setTimeout(() => { - app.proc.on('message', msg => { + app.proc.on('message', (msg: any) => { if (msg.to === 'parent' && msg.action === 'egg-ready') { readyMsg = `parent: port=${msg.data.port}, address=${msg.data.address}`; } @@ -587,48 +577,47 @@ describe('test/master.test.js', () => { }); after(() => app.close()); - it('app/agent should recieve egg-ready', async () => { + it('app/agent should receive egg-ready', async () => { // work for message sent - await sleep(5000); + await scheduler.wait(5000); assert(readyMsg.match(/parent: port=\d+, address=http:\/\/127.0.0.1:\d+/)); app.expect('stdout', /agent receive egg-ready, with 1 workers/); app.expect('stdout', /app receive egg-ready, worker 1/); }); - it('should recieve egg-ready when app restart', async () => { - await request(app.callback()) + it('should receive egg-ready when app restart', async () => { + await app.httpRequest() .get('/exception-app') .expect(200); - await sleep(5000); - + await scheduler.wait(5000); app.expect('stdout', /app receive egg-ready, worker 2/); }); - it('should recieve egg-ready when agent restart', async () => { - await request(app.callback()) + it('should receive egg-ready when agent restart', async () => { + await app.httpRequest() .get('/exception-agent') .expect(200); - await sleep(5000); + await scheduler.wait(5000); const matched = app.stdout.match(/agent receive egg-ready/g); assert(matched.length === 2); }); }); - describe('agent should recieve app worker nums', () => { - let app; + describe('agent should receive app worker numbers', () => { + let app: MockApplication; before(() => { mm.env('default'); - app = utils.cluster('apps/pid', { workers: 2 }); + app = cluster('apps/pid', { workers: 2 }); // app.debug(); return app.ready(); }); after(() => app.close()); it('should every app worker will get message', async () => { - await sleep(1000); + await scheduler.wait(1000); // start two workers app.expect('stdout', /#1 agent get 1 workers \[ \d+ \]/); app.expect('stdout', /#2 agent get 2 workers \[ \d+, \d+ \]/); @@ -642,7 +631,7 @@ describe('test/master.test.js', () => { // ignore } - await sleep(9000); + await scheduler.wait(9000); // oh, one worker dead app.expect('stdout', /#3 agent get 1 workers \[ \d+ \]/); // never mind, fork new worker @@ -655,16 +644,16 @@ describe('test/master.test.js', () => { action: 'kill-agent', }); - await sleep(9000); + await scheduler.wait(9000); app.expect('stdout', /#1 agent get 2 workers \[ \d+, \d+ \]/); }); }); - describe('app should recieve agent worker nums', () => { - let app; + describe('app should receive agent worker numbers', () => { + let app: MockApplication; before(() => { mm.env('default'); - app = utils.cluster('apps/pid'); + app = cluster('apps/pid'); app.coverage(false); // app.debug(); return app.ready(); @@ -677,21 +666,24 @@ describe('test/master.test.js', () => { action: 'kill-agent', }); - await sleep(9000); - app.expect('stdout', /#1 app get 0 workers \[\]/); - app.expect('stdout', /#2 app get 1 workers \[ \d+ \]/); + await scheduler.wait(5000); + app.expect('stdout', /#1 app get 1 workers \[/); + app.expect('stdout', /#2 app get 0 workers \[/); }); }); describe('debug', () => { - let app; + let app: MockApplication; afterEach(() => app.close()); // Debugger listening on ws://127.0.0.1:9229/221caad4-e2d0-4630-b0bb-f7fb27b81ff6 const debugProtocol = 'inspect'; it('should debug', () => { - app = utils.cluster('apps/debug-port', { workers: 2, opt: { execArgv: [ `--${debugProtocol}` ] } }); + app = cluster('apps/debug-port', { + workers: 2, + opt: { execArgv: [ `--${debugProtocol}` ] }, + }); return app // .debug() @@ -711,7 +703,10 @@ describe('test/master.test.js', () => { }); it('should debug with port', () => { - app = utils.cluster('apps/debug-port', { workers: 2, opt: { execArgv: [ `--${debugProtocol}=9000` ] } }); + app = cluster('apps/debug-port', { + workers: 2, + opt: { execArgv: [ `--${debugProtocol}=9000` ] }, + }); return app // .debug() @@ -731,15 +726,18 @@ describe('test/master.test.js', () => { }); describe('debug message', () => { - const result = { app: [], agent: {} }; + const result: any = { app: [], agent: {} }; after(() => app.close()); before(() => { - app = utils.cluster('apps/egg-ready', { workers: 2, opt: { execArgv: [ `--${debugProtocol}` ] } }); + app = cluster('apps/egg-ready', { + workers: 2, + opt: { execArgv: [ `--${debugProtocol}` ] }, + }); // app.debug(); setTimeout(() => { - app.proc.on('message', msg => { + app.proc.on('message', (msg: any) => { if (msg.to === 'parent' && msg.action === 'debug') { if (msg.from === 'agent') { result.agent = msg.data; @@ -752,9 +750,9 @@ describe('test/master.test.js', () => { return app.ready(); }); - it('parent should recieve debug', async () => { + it('parent should receive debug', async () => { // work for message sent - await sleep(5000); + await scheduler.wait(5000); app.expect('stdout', /agent receive egg-ready, with 2 workers/); app.expect('stdout', /app receive egg-ready/); assert(result.agent.debugPort === 5800); @@ -766,15 +764,15 @@ describe('test/master.test.js', () => { }); describe('debug message with port', () => { - const result = { app: [], agent: {} }; + const result: any = { app: [], agent: {} }; after(() => app.close()); before(() => { - app = utils.cluster('apps/egg-ready', { workers: 2, opt: { execArgv: [ `--${debugProtocol}=9000` ] } }); + app = cluster('apps/egg-ready', { workers: 2, opt: { execArgv: [ `--${debugProtocol}=9000` ] } }); // app.debug(); setTimeout(() => { - app.proc.on('message', msg => { + app.proc.on('message', (msg: any) => { if (msg.to === 'parent' && msg.action === 'debug') { if (msg.from === 'agent') { result.agent = msg.data; @@ -787,9 +785,9 @@ describe('test/master.test.js', () => { return app.ready(); }); - it('parent should recieve debug', async () => { + it('parent should receive debug', async () => { // work for message sent - await sleep(5000); + await scheduler.wait(5000); app.expect('stdout', /agent receive egg-ready, with 2 workers/); app.expect('stdout', /app receive egg-ready/); assert(result.agent.debugPort === 5800); @@ -801,15 +799,15 @@ describe('test/master.test.js', () => { }); describe('should not debug message', () => { - let result; + let result: boolean; after(() => app.close()); before(() => { - app = utils.cluster('apps/egg-ready'); + app = cluster('apps/egg-ready'); // app.debug(); setTimeout(() => { - app.proc.on('message', msg => { + app.proc.on('message', (msg: any) => { if (msg.to === 'parent' && msg.action === 'debug') { result = true; } @@ -818,9 +816,9 @@ describe('test/master.test.js', () => { return app.ready(); }); - it('parent should not recieve debug', async () => { + it('parent should not receive debug', async () => { // work for message sent - await sleep(5000); + await scheduler.wait(5000); app.expect('stdout', /agent receive egg-ready, with 1 workers/); app.expect('stdout', /app receive egg-ready/); assert(!result); @@ -828,15 +826,15 @@ describe('test/master.test.js', () => { }); describe('kill at debug', () => { - let workerPid; + let workerPid: number; after(() => app.close()); before(() => { - app = utils.cluster('apps/egg-ready', { workers: 1, opt: { execArgv: [ `--${debugProtocol}` ] } }); + app = cluster('apps/egg-ready', { workers: 1, opt: { execArgv: [ `--${debugProtocol}` ] } }); // app.debug(); setTimeout(() => { - app.proc.on('message', msg => { + app.proc.on('message', (msg: any) => { if (msg.to === 'parent' && msg.action === 'debug' && msg.from === 'app') { workerPid = msg.data.pid; } @@ -848,9 +846,9 @@ describe('test/master.test.js', () => { return app.ready(); }); - it('should not log err', async () => { + it('should not log error', async () => { // work for message sent - await sleep(6000); + await scheduler.wait(6000); app.expect('stderr', /\[master] app_worker#.*signal: SIGKILL/); app.expect('stderr', /\[master] worker kill by debugger, exiting/); app.expect('stdout', /\[master] exit with code:0/); @@ -861,8 +859,8 @@ describe('test/master.test.js', () => { describe('--sticky', () => { before(() => { - app = utils.cluster('apps/cluster_mod_sticky', { sticky: true }); - // app.debug(); + app = cluster('apps/cluster_mod_sticky', { sticky: true, port: 17010 } as any); + app.debug(); return app.ready(); }); after(() => app.close()); @@ -880,12 +878,12 @@ describe('test/master.test.js', () => { describe('agent and worker exception', () => { it('should not exit when local env', async () => { mm.env('local'); - app = utils.cluster('apps/check-status'); + app = cluster('apps/check-status'); // app.debug(); await app.ready(); fs.writeFileSync(path.join(app.baseDir, 'logs/started'), ''); - await sleep(30000); + await scheduler.wait(5000); // process should exist assert(app.process.exitCode === null); @@ -894,7 +892,7 @@ describe('test/master.test.js', () => { it('should exit when no agent after check 3 times', async () => { mm.env('prod'); - app = utils.cluster('apps/check-status'); + app = cluster('apps/check-status'); // app.debug(); await app.ready(); fs.mkdirSync(path.join(app.baseDir, 'logs'), { recursive: true }); @@ -903,7 +901,7 @@ describe('test/master.test.js', () => { // kill agent worker and will exit when start app.process.send({ to: 'agent', action: 'kill' }); - await awaitEvent(app.proc, 'exit'); + await once(app.proc, 'exit'); assert(app.stderr.includes('nodejs.ClusterWorkerExceptionError: [master] 0 agent and 1 worker(s) alive, exit to avoid unknown state')); assert(app.stderr.includes('[master] exit with code:1')); @@ -911,7 +909,7 @@ describe('test/master.test.js', () => { it('should exit when no app after check 3 times', async () => { mm.env('prod'); - app = utils.cluster('apps/check-status'); + app = cluster('apps/check-status'); // app.debug(); await app.ready(); fs.mkdirSync(path.join(app.baseDir, 'logs'), { recursive: true }); @@ -920,7 +918,7 @@ describe('test/master.test.js', () => { // kill app worker and wait checking app.process.send({ to: 'app', action: 'kill' }); - await awaitEvent(app.proc, 'exit'); + await once(app.proc, 'exit'); assert(app.stderr.includes('nodejs.ClusterWorkerExceptionError: [master] 1 agent and 0 worker(s) alive, exit to avoid unknown state')); assert(app.stderr.includes('[master] exit with code:1')); @@ -930,12 +928,12 @@ describe('test/master.test.js', () => { describe('beforeClose', () => { it('should wait app close', async () => { mm.env('local'); - app = utils.cluster('apps/before-close'); + app = cluster('apps/before-close'); // app.debug(); await app.ready(); await app.close(); - await sleep(5000); + await scheduler.wait(5000); app.expect('stdout', /app closing/); app.expect('stdout', /app closed/); @@ -947,9 +945,9 @@ describe('test/master.test.js', () => { describe('--require', () => { describe('one', () => { before(() => { - app = utils.cluster('apps/options-require', { - require: path.join(__dirname, './fixtures/apps/options-require/inject.js'), - }); + app = cluster('apps/options-require', { + require: getFilepath('apps/options-require/inject.js'), + } as any); // app.debug(); return app.ready(); }); @@ -962,12 +960,12 @@ describe('test/master.test.js', () => { }); describe('array', () => { before(() => { - app = utils.cluster('apps/options-require', { + app = cluster('apps/options-require', { require: [ - path.join(__dirname, './fixtures/apps/options-require/inject.js'), + getFilepath('apps/options-require/inject.js'), 'ts-node/register', ], - }); + } as any); // app.debug(); return app.ready(); }); @@ -983,7 +981,7 @@ describe('test/master.test.js', () => { }); }); -function alive(pid) { +function alive(pid: number) { try { // success means it's still alive process.kill(pid, 0); diff --git a/test/options.test.js b/test/options.test.js deleted file mode 100644 index 08568a8..0000000 --- a/test/options.test.js +++ /dev/null @@ -1,221 +0,0 @@ -const path = require('path'); -const { strict: assert } = require('assert'); -const os = require('os'); -const mm = require('egg-mock'); -const parseOptions = require('../lib/utils/options'); -const utils = require('./utils'); - -describe('test/options.test.js', () => { - afterEach(mm.restore); - - it('should return undefined by port as default', () => { - const options = parseOptions({}); - assert(options.port === undefined); - }); - - it('should start with https and listen 8443', () => { - const options = parseOptions({ - https: { - key: utils.getFilepath('server.key'), - cert: utils.getFilepath('server.cert'), - }, - }); - assert(options.port === 8443); - assert(options.https.key); - assert(options.https.cert); - }); - - it('should start with httpsOptions and listen 8443', () => { - const options = parseOptions({ - https: { - passphrase: '123456', - key: utils.getFilepath('server.key'), - cert: utils.getFilepath('server.cert'), - ca: utils.getFilepath('server.ca'), - }, - }); - assert(options.port === 8443); - assert(options.https.key); - assert(options.https.cert); - assert(options.https.ca); - assert(options.https.passphrase); - }); - - it('should listen custom port 6001', () => { - const options = parseOptions({ - port: '6001', - }); - assert(options.port === 6001); - }); - - it('should set NO_DEPRECATION on production env', () => { - mm(process.env, 'NODE_ENV', 'production'); - const options = parseOptions({ - workers: 1, - }); - assert(options.workers === 1); - assert(process.env.NO_DEPRECATION === '*'); - }); - - it('should not extend when port is null/undefined', () => { - let options = parseOptions({ - port: null, - }); - assert(options.port === undefined); - - options = parseOptions({ - port: undefined, - }); - assert(options.port === undefined); - }); - - it('should not call os.cpus when specify workers', () => { - mm.syncError(os, 'cpus', 'should not call os.cpus'); - const options = parseOptions({ - workers: 1, - }); - assert(options.workers === 1); - }); - - describe('debug', () => { - it('empty', () => { - mm(process, 'execArgv', []); - const options = parseOptions({}); - assert(options.isDebug === undefined); - }); - it('--inspect', () => { - mm(process, 'execArgv', [ '--inspect=9229' ]); - const options = parseOptions({}); - assert(options.isDebug === true); - }); - it('--debug', () => { - mm(process, 'execArgv', [ '--debug=5858' ]); - const options = parseOptions({}); - assert(options.isDebug === true); - }); - }); - - describe('env', () => { - it('default env is null', () => { - const options = parseOptions({}); - assert.equal(options.env, null); - }); - - it('custom env = prod', () => { - const options = parseOptions({ env: 'prod' }); - assert.equal(options.env, 'prod'); - }); - - it('default env set to process.env.EGG_SERVER_ENV', () => { - mm(process.env, 'EGG_SERVER_ENV', 'prod'); - const options = parseOptions({}); - assert.equal(options.env, 'prod'); - }); - }); - - describe('options', () => { - let app; - before(() => { - app = utils.cluster('apps/options', { - foo: true, - framework: path.dirname(require.resolve('egg')), - }); - return app.ready(); - }); - after(() => app.close()); - it('should be passed through', () => { - app.expect('stdout', /app options foo: true/); - app.expect('stdout', /agent options foo: true/); - }); - }); - - describe('framework', () => { - - it('should get from absolite path', () => { - const frameworkPath = path.dirname(require.resolve('egg')); - const options = parseOptions({ - framework: frameworkPath, - }); - assert(options.framework === frameworkPath); - }); - - it('should get from absolite path but not exist', () => { - const frameworkPath = path.join(__dirname, 'noexist'); - try { - parseOptions({ - framework: frameworkPath, - }); - throw new Error('should not run'); - } catch (err) { - assert(err.message === `${frameworkPath} should exist`); - } - }); - - it('should get from npm package', () => { - const frameworkPath = path.join(__dirname, '../node_modules/egg'); - const options = parseOptions({ - framework: 'egg', - }); - assert(options.framework === frameworkPath); - }); - - it('should get from npm package but not exist', () => { - try { - parseOptions({ - framework: 'noexist', - }); - throw new Error('should not run'); - } catch (err) { - const frameworkPath = path.join(__dirname, '../node_modules'); - assert(err.message === `noexist is not found in ${frameworkPath}`); - } - }); - - it('should get from pkg.egg.framework', () => { - const baseDir = path.join(__dirname, 'fixtures/apps/framework-pkg-egg'); - const options = parseOptions({ - baseDir, - }); - assert(options.framework === path.join(baseDir, 'node_modules/yadan')); - }); - - it('should get from pkg.egg.framework but not exist', () => { - const baseDir = path.join(__dirname, 'fixtures/apps/framework-pkg-egg-noexist'); - try { - parseOptions({ - baseDir, - }); - throw new Error('should not run'); - } catch (err) { - const frameworkPaths = [ - path.join(baseDir, 'node_modules'), - path.join(process.cwd(), 'node_modules'), - ].join(','); - assert(err.message === `noexist is not found in ${frameworkPaths}`); - } - }); - - it('should get egg by default', () => { - const baseDir = path.join(__dirname, 'fixtures/apps/framework-egg-default'); - const options = parseOptions({ - baseDir, - }); - assert(options.framework === path.join(baseDir, 'node_modules/egg')); - }); - - }); - - it('should exist when specify baseDir', () => { - it('should get egg by default but not exist', () => { - const baseDir = path.join(__dirname, 'noexist'); - try { - parseOptions({ - baseDir, - }); - throw new Error('should not run'); - } catch (err) { - assert(err.message === `${path.join(baseDir, 'package.json')} should exist`); - } - }); - }); -}); diff --git a/test/options.test.ts b/test/options.test.ts new file mode 100644 index 0000000..dde4def --- /dev/null +++ b/test/options.test.ts @@ -0,0 +1,234 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { strict as assert } from 'node:assert'; +import os from 'node:os'; +import { mm } from '@eggjs/mock'; +import { importResolve } from '@eggjs/utils'; +import { parseOptions } from '../src/utils/options.js'; +import { getFilepath, cluster } from './utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/options.test.ts', () => { + afterEach(mm.restore); + + it('should return undefined by port as default', async () => { + let options = await parseOptions({}); + assert.equal(options.port, undefined); + options = await parseOptions(); + assert.equal(options.port, undefined); + }); + + it('should start with https and listen 8443', async () => { + const options = await parseOptions({ + https: { + key: getFilepath('server.key'), + cert: getFilepath('server.cert'), + }, + }); + assert.equal(options.port, 8443); + assert.equal(typeof options.https, 'object'); + assert(options.https instanceof Object); + assert.equal(typeof options.https.key, 'string'); + assert(options.https.cert); + }); + + it('should start with httpsOptions and listen 8443', async () => { + const options = await parseOptions({ + https: { + passphrase: '123456', + key: getFilepath('server.key'), + cert: getFilepath('server.cert'), + ca: getFilepath('server.ca'), + }, + }); + assert.equal(options.port, 8443); + assert(options.https instanceof Object); + assert(options.https.key); + assert(options.https.cert); + assert(options.https.ca); + assert(options.https.passphrase); + }); + + it('should listen custom port 6001', async () => { + const options = await parseOptions({ + port: '6001', + }); + assert.equal(options.port, 6001); + }); + + it('should set NO_DEPRECATION on production env', async () => { + mm(process.env, 'NODE_ENV', 'production'); + let options = await parseOptions({ + workers: 1, + }); + assert.equal(options.workers, 1); + options = await parseOptions({ + workers: '101', + }); + assert.equal(options.workers, 101); + assert.equal(process.env.NO_DEPRECATION, '*'); + }); + + it('should not extend when port is null/undefined', async () => { + let options = await parseOptions({ + port: null, + }); + assert.equal(options.port, undefined); + options = await parseOptions({ + port: undefined, + }); + assert.equal(options.port, undefined); + options = await parseOptions(); + assert.equal(options.port, undefined); + }); + + it('should not call os.cpus when specify workers', async () => { + mm.syncError(os, 'cpus', 'should not call os.cpus'); + const options = await parseOptions({ + workers: 1, + }); + assert.equal(options.workers, 1); + }); + + describe('debug', () => { + it('empty', async () => { + mm(process, 'execArgv', []); + const options = await parseOptions({}); + assert(options.isDebug === undefined); + }); + it('--inspect', async () => { + mm(process, 'execArgv', [ '--inspect=9229' ]); + const options = await parseOptions({}); + assert(options.isDebug === true); + }); + it('--debug', async () => { + mm(process, 'execArgv', [ '--debug=5858' ]); + const options = await parseOptions({}); + assert(options.isDebug === true); + }); + }); + + describe('env', () => { + it('default env is undefined', async () => { + const options = await parseOptions({}); + assert.equal(options.env, undefined); + }); + + it('custom env = prod', async () => { + const options = await parseOptions({ env: 'prod' }); + assert.equal(options.env, 'prod'); + }); + + it('default env set to process.env.EGG_SERVER_ENV', async () => { + mm(process.env, 'EGG_SERVER_ENV', 'prod'); + const options = await parseOptions({}); + assert.equal(options.env, 'prod'); + }); + }); + + describe('options', () => { + let app: any; + before(() => { + app = cluster('apps/options', { + foo: true, + } as any).debug(); + return app.ready(); + }); + after(() => app.close()); + + it('should be passed through', () => { + app.expect('stdout', /app options foo: true/); + app.expect('stdout', /agent options foo: true/); + }); + }); + + describe('framework', () => { + it('should get from absolute path', async () => { + const frameworkPath = path.dirname(importResolve('egg')); + const options = await parseOptions({ + framework: frameworkPath, + }); + assert.equal(options.framework, frameworkPath); + }); + + it('should get from absolute path but not exist', async () => { + const frameworkPath = path.join(__dirname, 'noexist'); + try { + await parseOptions({ + framework: frameworkPath, + }); + throw new Error('should not run'); + } catch (err: any) { + assert.equal(err.message, `${frameworkPath} should exist`); + } + }); + + it('should get from npm package', async () => { + const frameworkPath = path.join(__dirname, '../node_modules/egg'); + const options = await parseOptions({ + framework: 'egg', + }); + assert.equal(options.framework, frameworkPath); + }); + + it('should get from npm package but not exist', async () => { + try { + await parseOptions({ + framework: 'noexist', + }); + throw new Error('should not run'); + } catch (err: any) { + const frameworkPath = path.join(__dirname, '../node_modules'); + assert.equal(err.message, `noexist is not found in ${frameworkPath}`); + } + }); + + it('should get from pkg.egg.framework', async () => { + const baseDir = path.join(__dirname, 'fixtures/apps/framework-pkg-egg'); + const options = await parseOptions({ + baseDir, + }); + assert.equal(options.framework, path.join(baseDir, 'node_modules/yadan')); + }); + + it('should get from pkg.egg.framework but not exist', async () => { + const baseDir = path.join(__dirname, 'fixtures/apps/framework-pkg-egg-noexist'); + try { + await parseOptions({ + baseDir, + }); + throw new Error('should not run'); + } catch (err: any) { + const frameworkPaths = [ + path.join(baseDir, 'node_modules'), + path.join(process.cwd(), 'node_modules'), + ].join(','); + assert.equal(err.message, `noexist is not found in ${frameworkPaths}`); + } + }); + + it('should get egg by default', async () => { + const baseDir = path.join(__dirname, 'fixtures/apps/framework-egg-default'); + const options = await parseOptions({ + baseDir, + }); + assert.equal(options.framework, path.join(baseDir, 'node_modules/egg')); + }); + }); + + // it('should exist when specify baseDir', () => { + // it('should get egg by default but not exist', () => { + // const baseDir = path.join(__dirname, 'noexist'); + // try { + // parseOptions({ + // baseDir, + // }); + // throw new Error('should not run'); + // } catch (err) { + // assert(err.message === `${path.join(baseDir, 'package.json')} should exist`); + // } + // }); + // }); +}); diff --git a/test/utils.js b/test/utils.js deleted file mode 100644 index 59328c4..0000000 --- a/test/utils.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -const path = require('path'); -const mm = require('egg-mock'); - -exports.cluster = function(name, options) { - options = Object.assign({}, { - baseDir: name, - customEgg: path.join(__dirname, './fixtures/egg'), - eggPath: path.dirname(require.resolve('egg')), - cache: false, - opt: { - // clear execArgv from egg-bin - execArgv: [], - }, - }, options); - return mm.cluster(options); -}; - -exports.getFilepath = function(name) { - return path.join(__dirname, 'fixtures', name); -}; diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..8a75e55 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mm, MockClusterOptions } from '@eggjs/mock'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export function cluster(baseDir: string, options: MockClusterOptions = {}) { + return mm.cluster({ + baseDir, + framework: path.join(__dirname, 'fixtures/egg'), + // eggPath: path.join(__dirname, '../node_modules/egg'), + cache: false, + opt: { + // clear execArgv from egg-bin + execArgv: [], + }, + // override @eggjs/mock default port 17001 + ...options, + }); +} + +export function getFilepath(name: string) { + return path.join(__dirname, 'fixtures', name); +} diff --git a/test/worker_threads.test.js b/test/worker_threads.test.ts similarity index 61% rename from test/worker_threads.test.js rename to test/worker_threads.test.ts index cba6c49..fa0a518 100644 --- a/test/worker_threads.test.js +++ b/test/worker_threads.test.ts @@ -1,23 +1,22 @@ -'use strict'; +import { MockApplication } from '@eggjs/mock'; +import { cluster } from './utils.js'; -const utils = require('./utils'); - -describe('worker_threads', () => { - let app; +describe('test/worker_threads.test.ts', () => { + let app: MockApplication; describe('Fork Agent', () => { afterEach(() => app && app.close()); it('support config agent debug port', async () => { - app = utils.cluster('apps/agent-worker-threads', { startMode: 'worker_threads' }); + app = cluster('apps/agent-worker-threads', { startMode: 'worker_threads' } as any); app.debug(); return app - .expect('stdout', /workerId: 1/) + .expect('stdout', /workerId: \d+/) .end(); }); it('should exit when emit error during agent worker boot', () => { - app = utils.cluster('apps/agent-worker-threads-error'); + app = cluster('apps/agent-worker-threads-error'); app.debug(); return app .debug() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +}