Skip to content

Commit c691907

Browse files
fengmk2coderabbitai[bot]Copilot
authored
feat: support reusePort on server listen (#115)
closes eggjs/egg#5365 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added reusePort support to allow multiple sockets to listen on the same port where supported. * **Improvements** * Improved agent startup error handling and clearer debug logs. * CI now tests Node.js 24; package metadata and project links updated; version bumped. * **Tests** * Added unit/integration and cluster tests exercising reusePort and related startup flows. * **Documentation** * README updated with reusePort details. * **Other** * .gitignore updated. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: MK (fengmk2) <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Copilot <[email protected]>
1 parent 7c6ebfa commit c691907

File tree

17 files changed

+303
-49
lines changed

17 files changed

+303
-49
lines changed

.github/workflows/nodejs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ jobs:
1212
uses: node-modules/github-actions/.github/workflows/node-test.yml@master
1313
with:
1414
os: 'ubuntu-latest, macos-latest'
15-
version: '14, 16, 18, 20, 22'
15+
version: '14, 16, 18, 20, 22, 24'
1616
secrets:
1717
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ run
99
.nyc_output
1010
package-lock.json
1111
.package-lock.json
12+
pnpm-lock.yaml

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
[npm-image]: https://img.shields.io/npm/v/egg-cluster.svg?style=flat-square
1010
[npm-url]: https://npmjs.org/package/egg-cluster
11-
[codecov-image]: https://codecov.io/github/eggjs/egg-cluster/coverage.svg?branch=master
12-
[codecov-url]: https://codecov.io/github/eggjs/egg-cluster?branch=master
11+
[codecov-image]: https://codecov.io/github/eggjs/cluster/coverage.svg?branch=master
12+
[codecov-url]: https://codecov.io/github/eggjs/cluster?branch=master
1313
[snyk-image]: https://snyk.io/test/npm/egg-cluster/badge.svg?style=flat-square
1414
[snyk-url]: https://snyk.io/test/npm/egg-cluster
1515
[download-image]: https://img.shields.io/npm/dm/egg-cluster.svg?style=flat-square
@@ -53,12 +53,13 @@ startCluster(options, () => {
5353
| workers | `Number` | numbers of app workers |
5454
| sticky | `Boolean` | sticky mode server |
5555
| port | `Number` | port |
56+
| reusePort | `Boolean` | (Required Node.js >= 22.12.0) allows multiple sockets on the same host to bind to the same port. Incoming connections are distributed by the operating system to listening sockets. This option is available only on some platforms, such as Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. **Default:** `false` |
5657
| debugPort | `Number` | the debug port only listen on http protocol |
5758
| https | `Object` | start a https server, note: `key` / `cert` / `ca` should be full path to file |
5859
| require | `Array\|String` | will inject into worker/agent process |
5960
| pidFile | `String` | will save master pid to this file |
6061
| startMode | `String` | default is 'process', use 'worker_threads' to start the app & agent worker by worker_threads |
61-
| ports | `Array` | startup port of each app worker, such as: [7001, 7002, 7003], only effects when the startMode is 'worker_threads' |
62+
| ports | `Array` | startup port of each app worker, such as: [7001, 7002, 7003], only effects when the `startMode` is `'worker_threads'` and `reusePort` is `false` |
6263
| env | `String` | custom env, default is process.env.EGG_SERVER_ENV |
6364

6465
## Env

lib/agent_worker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ if (options.startMode === 'worker_threads') {
2424
AgentWorker = require('./utils/mode/impl/process/agent').AgentWorker;
2525
}
2626

27-
const debug = require('util').debuglog('egg-cluster');
27+
const debug = require('util').debuglog('egg-cluster:agent_worker');
2828
const ConsoleLogger = require('egg-logger').EggConsoleLogger;
2929
const consoleLogger = new ConsoleLogger({ level: process.env.EGG_AGENT_WORKER_LOGGER_LEVEL });
3030

lib/app_worker.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ if (options.startMode === 'worker_threads') {
1616
AppWorker = require('./utils/mode/impl/process/app').AppWorker;
1717
}
1818

19+
const os = require('os');
1920
const fs = require('fs');
20-
const debug = require('util').debuglog('egg-cluster');
21+
const debug = require('util').debuglog('egg-cluster:app_worker');
2122
const ConsoleLogger = require('egg-logger').EggConsoleLogger;
23+
2224
const consoleLogger = new ConsoleLogger({
2325
level: process.env.EGG_APP_WORKER_LOGGER_LEVEL,
2426
});
@@ -38,6 +40,18 @@ const port = options.port = options.port || listenConfig.port;
3840
const debugPort = options.debugPort;
3941
const protocol = (httpsOptions.key && httpsOptions.cert) ? 'https' : 'http';
4042

43+
// https://nodejs.org/api/net.html#serverlistenoptions-callback
44+
// https://github.com/nodejs/node/blob/main/node.gypi#L310
45+
// https://docs.python.org/3/library/sys.html#sys.platform
46+
// This option is available only on some platforms, such as Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+.
47+
const supportedPlatforms = [ 'linux', 'freebsd', 'sunos', 'aix' ];
48+
let reusePort = options.reusePort = options.reusePort || listenConfig.reusePort;
49+
if (reusePort && !supportedPlatforms.includes(os.platform())) {
50+
reusePort = false;
51+
options.reusePort = false;
52+
debug('platform %s is not supported currently, set reusePort to false', os.platform());
53+
}
54+
4155
AppWorker.send({
4256
to: 'master',
4357
action: 'realport',
@@ -121,21 +135,36 @@ function startServer(err) {
121135
exitProcess();
122136
return;
123137
}
124-
const args = [ port ];
125-
if (listenConfig.hostname) args.push(listenConfig.hostname);
126-
debug('listen options %s', args);
127-
server.listen(...args);
138+
if (reusePort) {
139+
// https://nodejs.org/api/net.html#serverlistenoptions-callback
140+
const listenOptions = { port, reusePort };
141+
if (listenConfig.hostname) {
142+
listenOptions.host = listenConfig.hostname;
143+
}
144+
debug('listen options %j', listenOptions);
145+
server.listen(listenOptions);
146+
} else {
147+
const args = [ port ];
148+
if (listenConfig.hostname) {
149+
args.push(listenConfig.hostname);
150+
}
151+
debug('listen options %s', args);
152+
server.listen(...args);
153+
}
128154
}
129155
if (debugPortServer) {
130156
debug('listen on debug port: %s', debugPort);
131157
debugPortServer.listen(debugPort);
132158
}
133159
}
134160

135-
AppWorker.send({
136-
to: 'master',
137-
action: 'listening',
138-
data: server.address() || { port },
161+
server.once('listening', () => {
162+
AppWorker.send({
163+
to: 'master',
164+
action: 'listening',
165+
data: server.address() || { port },
166+
reusePort,
167+
});
139168
});
140169
}
141170

lib/master.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Master extends EventEmitter {
3434
* - {Object} [plugins] - customized plugins, for unittest
3535
* - {Number} [workers] numbers of app workers, default to `os.cpus().length`
3636
* - {Number} [port] listening port, default to 7001(http) or 8443(https)
37+
* - {Boolean} [reusePort] setting `reusePort` to `true` allows multiple sockets on the same host to bind to the same port. Incoming connections are distributed by the operating system to listening sockets. This option is available only on some platforms, such as Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. **Default:** `false`.
3738
* - {Number} [debugPort] listening a debug port on http protocol
3839
* - {Object} [https] https options, { key, cert, ca }, full path
3940
* - {Array|String} [require] will inject into worker/agent process

lib/utils/mode/impl/process/app.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,15 @@ class AppWorker extends BaseAppWorker {
4141
process.on(event, callback);
4242
}
4343

44-
static send(data) {
45-
process.send(data);
44+
static send(message) {
45+
if (message && message.action === 'listening' && message.reusePort) {
46+
// cluster won't get `listening` event when reusePort is true, use cluster `message` event instead
47+
// rewrite message.action to 'reuse-port-listening'
48+
message.action = 'reuse-port-listening';
49+
cluster.worker.send(message);
50+
return;
51+
}
52+
process.send(message);
4653
}
4754

4855
static kill() {
@@ -124,6 +131,25 @@ class AppUtils extends BaseAppUtils {
124131
});
125132
cluster.on('listening', (worker, address) => {
126133
const appWorker = new AppWorker(worker);
134+
this.logger.info('[master] app_worker#%s:%s listening on %j', appWorker.id, appWorker.workerId, address);
135+
this.messenger.send({
136+
action: 'app-start',
137+
data: {
138+
workerId: appWorker.workerId,
139+
address,
140+
},
141+
to: 'master',
142+
from: 'app',
143+
});
144+
});
145+
// handle 'reuse-port-listening' message: { action: 'reuse-port-listening', data: { port: 3000 } }
146+
cluster.on('message', (worker, message) => {
147+
if (!message || message.action !== 'reuse-port-listening') {
148+
return;
149+
}
150+
const address = message.data;
151+
const appWorker = new AppWorker(worker);
152+
this.logger.info('[master] app_worker#%s:%s reuse-port listening on %j', appWorker.id, appWorker.workerId, address);
127153
this.messenger.send({
128154
action: 'app-start',
129155
data: {

lib/utils/mode/impl/worker_threads/app.js

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,14 @@ class AppWorker extends BaseAppWorker {
7171
}
7272

7373
class AppUtils extends BaseAppUtils {
74-
#workers = [];
74+
#appWorkers = [];
7575

7676
#forkSingle(appPath, options, id) {
7777
// start app worker
7878
const worker = new workerThreads.Worker(appPath, options);
79-
this.#workers.push(worker);
80-
8179
// wrap app worker
8280
const appWorker = new AppWorker(worker, id);
81+
this.#appWorkers.push(appWorker);
8382
this.emit('worker_forked', appWorker);
8483
appWorker.disableRefork = true;
8584
worker.on('message', msg => {
@@ -129,23 +128,40 @@ class AppUtils extends BaseAppUtils {
129128
to: 'master',
130129
from: 'app',
131130
});
132-
133131
});
134132

135133
// handle worker exit
136134
worker.on('exit', async code => {
135+
this.log('[master] app_worker#%s (tid:%s) exit with code: %s', appWorker.id, appWorker.workerId, code);
136+
// remove worker from workers array
137+
const idx = this.#appWorkers.indexOf(appWorker);
138+
if (idx !== -1) {
139+
this.#appWorkers.splice(idx, 1);
140+
}
141+
// remove all listeners to avoid memory leak
142+
worker.removeAllListeners();
143+
137144
appWorker.state = 'dead';
138-
this.messenger.send({
139-
action: 'app-exit',
140-
data: {
141-
workerId: appWorker.workerId,
142-
code,
143-
},
144-
to: 'master',
145-
from: 'app',
146-
});
145+
try {
146+
this.messenger.send({
147+
action: 'app-exit',
148+
data: {
149+
workerId: appWorker.workerId,
150+
code,
151+
},
152+
to: 'master',
153+
from: 'app',
154+
});
155+
} catch (err) {
156+
this.log('[master][warning] app_worker#%s (tid:%s) send "app-exit" message error: %s',
157+
appWorker.id, appWorker.workerId, err);
158+
}
147159

160+
if (appWorker.disableRefork) {
161+
return;
162+
}
148163
// refork app worker
164+
this.log('[master] app_worker#%s (tid:%s) refork after 1s', appWorker.id, appWorker.workerId);
149165
await sleep(1000);
150166
this.#forkSingle(appPath, options, id);
151167
});
@@ -155,26 +171,38 @@ class AppUtils extends BaseAppUtils {
155171
this.startTime = Date.now();
156172
this.startSuccessCount = 0;
157173

158-
const ports = this.options.ports;
159-
if (!ports.length) {
160-
ports.push(this.options.port);
174+
if (this.options.reusePort) {
175+
if (!this.options.port) {
176+
throw new Error('options.port must be specified when reusePort is enabled');
177+
}
178+
for (let i = 0; i < this.options.workers; i++) {
179+
const argv = [ JSON.stringify(this.options) ];
180+
const appWorkerId = i + 1;
181+
this.#forkSingle(this.getAppWorkerFile(), { argv }, appWorkerId);
182+
}
183+
} else {
184+
const ports = this.options.ports;
185+
if (!ports.length) {
186+
ports.push(this.options.port);
187+
}
188+
this.options.workers = ports.length;
189+
let i = 0;
190+
do {
191+
const options = Object.assign({}, this.options, { port: ports[i] });
192+
const argv = [ JSON.stringify(options) ];
193+
this.#forkSingle(this.getAppWorkerFile(), { argv }, ++i);
194+
} while (i < ports.length);
161195
}
162-
this.options.workers = ports.length;
163-
let i = 0;
164-
do {
165-
const options = Object.assign({}, this.options, { port: ports[i] });
166-
const argv = [ JSON.stringify(options) ];
167-
this.#forkSingle(this.getAppWorkerFile(), { argv }, ++i);
168-
} while (i < ports.length);
169196

170197
return this;
171198
}
172199

173200
async kill() {
174-
for (const worker of this.#workers) {
175-
this.log(`[master] kill app worker#${worker.id} (worker_threads) by worker.terminate()`);
176-
worker.removeAllListeners();
177-
worker.terminate();
201+
for (const appWorker of this.#appWorkers) {
202+
this.log('[master] kill app_worker#%s (tid:%s) (worker_threads) by worker.terminate()', appWorker.id, appWorker.workerId);
203+
appWorker.disableRefork = true;
204+
appWorker.instance.removeAllListeners();
205+
appWorker.instance.terminate();
178206
}
179207
}
180208
}

lib/utils/options.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = function(options) {
1212
framework: '',
1313
baseDir: process.cwd(),
1414
port: options.https ? 8443 : null,
15+
reusePort: false,
1516
workers: null,
1617
plugins: null,
1718
https: false,

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
{
22
"name": "egg-cluster",
3-
"version": "2.4.0",
3+
"version": "2.5.0-beta.3",
44
"description": "cluster manager for egg",
55
"main": "index.js",
66
"scripts": {
77
"lint": "eslint .",
88
"test": "npm run lint -- --fix && npm run test-local",
99
"test-local": "egg-bin test --ts false",
1010
"cov": "egg-bin cov --prerequire --timeout 100000 --ts false",
11-
"ci": "npm run lint && npm run cov"
11+
"ci": "npm run lint && node test/reuseport_cluster.js && npm run cov"
1212
},
1313
"files": [
1414
"index.js",
1515
"lib"
1616
],
1717
"repository": {
1818
"type": "git",
19-
"url": "git+https://github.com/eggjs/egg-cluster.git"
19+
"url": "git+https://github.com/eggjs/cluster.git"
2020
},
2121
"keywords": [
2222
"egg",
@@ -26,9 +26,9 @@
2626
"author": "dead-horse <[email protected]>",
2727
"license": "MIT",
2828
"bugs": {
29-
"url": "https://github.com/eggjs/egg-cluster/issues"
29+
"url": "https://github.com/eggjs/cluster/issues"
3030
},
31-
"homepage": "https://github.com/eggjs/egg-cluster#readme",
31+
"homepage": "https://github.com/eggjs/cluster#readme",
3232
"dependencies": {
3333
"await-event": "^2.1.0",
3434
"cfork": "^1.7.1",

0 commit comments

Comments
 (0)