emailengine-app 2.68.1 → 2.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy.yml +8 -3
- package/.github/workflows/release.yaml +6 -0
- package/CHANGELOG.md +59 -0
- package/Gruntfile.js +3 -1
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/getswagger.sh +40 -4
- package/gettext-extract.js +163 -0
- package/lib/account.js +135 -72
- package/lib/api-routes/account-routes.js +684 -106
- package/lib/api-routes/blocklist-routes.js +344 -0
- package/lib/api-routes/chat-routes.js +32 -14
- package/lib/api-routes/delivery-test-routes.js +346 -0
- package/lib/api-routes/export-routes.js +28 -14
- package/lib/api-routes/gateway-routes.js +427 -0
- package/lib/api-routes/license-routes.js +156 -0
- package/lib/api-routes/mailbox-routes.js +344 -0
- package/lib/api-routes/message-routes.js +221 -187
- package/lib/api-routes/oauth2-app-routes.js +697 -0
- package/lib/api-routes/outbox-routes.js +185 -0
- package/lib/api-routes/pubsub-routes.js +102 -0
- package/lib/api-routes/route-helpers.js +58 -0
- package/lib/api-routes/settings-routes.js +357 -0
- package/lib/api-routes/stats-routes.js +111 -0
- package/lib/api-routes/submit-routes.js +461 -0
- package/lib/api-routes/template-routes.js +60 -75
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +181 -0
- package/lib/autodetect-imap-settings.js +0 -2
- package/lib/consts.js +5 -0
- package/lib/email-client/base-client.js +28 -6
- package/lib/email-client/gmail-client.js +133 -112
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -13
- package/lib/email-client/imap/sync-operations.js +131 -3
- package/lib/email-client/imap-client.js +152 -75
- package/lib/email-client/notification-handler.js +1 -4
- package/lib/email-client/outlook-client.js +134 -75
- package/lib/export.js +97 -20
- package/lib/feature-flags.js +2 -2
- package/lib/gateway.js +4 -9
- package/lib/get-raw-email.js +5 -5
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/logger.js +24 -21
- package/lib/message-port-stream.js +113 -16
- package/lib/metrics-collector.js +0 -2
- package/lib/oauth2-apps.js +13 -4
- package/lib/outbox.js +24 -40
- package/lib/redis-operations.js +1 -1
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +429 -84
- package/lib/sentry.js +139 -0
- package/lib/settings.js +9 -3
- package/lib/stream-encrypt.js +1 -1
- package/lib/templates.js +1 -1
- package/lib/tokens.js +5 -3
- package/lib/tools.js +70 -4
- package/lib/ui-routes/account-routes.js +45 -212
- package/lib/ui-routes/admin-config-routes.js +928 -489
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
- package/lib/ui-routes/route-helpers.js +314 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +232 -0
- package/lib/webhook-request.js +36 -0
- package/lib/webhooks.js +8 -4
- package/package.json +13 -12
- package/sbom.json +1 -1
- package/server.js +222 -39
- package/static/licenses.html +160 -300
- package/translations/messages.pot +112 -132
- package/update-info.sh +19 -1
- package/views/config/logging.hbs +48 -0
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +200 -4424
- package/workers/documents.js +2 -22
- package/workers/export.js +103 -104
- package/workers/imap-proxy.js +3 -23
- package/workers/imap.js +32 -36
- package/workers/smtp.js +2 -22
- package/workers/submit.js +26 -35
- package/workers/webhooks.js +9 -43
package/server.js
CHANGED
|
@@ -72,7 +72,8 @@ const {
|
|
|
72
72
|
getRedisStats,
|
|
73
73
|
threadStats,
|
|
74
74
|
httpAgent,
|
|
75
|
-
reloadHttpProxyAgent
|
|
75
|
+
reloadHttpProxyAgent,
|
|
76
|
+
maybeReloadHttpProxyAgent
|
|
76
77
|
} = require('./lib/tools');
|
|
77
78
|
const MetricsCollector = require('./lib/metrics-collector');
|
|
78
79
|
|
|
@@ -106,29 +107,9 @@ const bounceClassifier = require('@postalsys/bounce-classifier');
|
|
|
106
107
|
|
|
107
108
|
const v8 = require('node:v8');
|
|
108
109
|
|
|
109
|
-
// Initialize
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
Bugsnag.start({
|
|
113
|
-
apiKey: readEnvValue('BUGSNAG_API_KEY'),
|
|
114
|
-
appVersion: packageData.version,
|
|
115
|
-
logger: {
|
|
116
|
-
debug(...args) {
|
|
117
|
-
logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
|
|
118
|
-
},
|
|
119
|
-
info(...args) {
|
|
120
|
-
logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
|
|
121
|
-
},
|
|
122
|
-
warn(...args) {
|
|
123
|
-
logger.warn({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
|
|
124
|
-
},
|
|
125
|
-
error(...args) {
|
|
126
|
-
logger.error({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
logger.notifyError = Bugsnag.notify.bind(Bugsnag);
|
|
131
|
-
}
|
|
110
|
+
// Initialize Sentry error tracking if a DSN is provided
|
|
111
|
+
const { initSentry } = require('./lib/sentry');
|
|
112
|
+
initSentry('main');
|
|
132
113
|
|
|
133
114
|
// Import additional dependencies
|
|
134
115
|
const pathlib = require('path');
|
|
@@ -148,6 +129,8 @@ const { QueueEvents } = require('bullmq');
|
|
|
148
129
|
|
|
149
130
|
const getSecret = require('./lib/get-secret');
|
|
150
131
|
|
|
132
|
+
const { rejectWorkerCalls } = require('./lib/reject-worker-calls');
|
|
133
|
+
|
|
151
134
|
const msgpack = require('msgpack5')();
|
|
152
135
|
|
|
153
136
|
// Initialize default configuration values if not set
|
|
@@ -220,6 +203,10 @@ config.dbs.redis = readEnvValue('EENGINE_REDIS') || readEnvValue('REDIS_URL') ||
|
|
|
220
203
|
config.workers.imap = getWorkerCount(readEnvValue('EENGINE_WORKERS') || config.workers.imap) || 4;
|
|
221
204
|
config.workers.webhooks = Number(readEnvValue('EENGINE_WORKERS_WEBHOOKS')) || config.workers.webhooks || 1;
|
|
222
205
|
config.workers.submit = Number(readEnvValue('EENGINE_WORKERS_SUBMIT')) || config.workers.submit || 1;
|
|
206
|
+
// API worker count. Values >1 require SO_REUSEPORT (Linux); on unsupported platforms it falls back to 1 at startup.
|
|
207
|
+
// Uses getWorkerCount() for parity with EENGINE_WORKERS (supports "cpus"); Math.floor avoids a fractional
|
|
208
|
+
// count over-spawning, Math.max keeps at least one API worker.
|
|
209
|
+
config.workers.api = Math.max(1, Math.floor(getWorkerCount(readEnvValue('EENGINE_WORKERS_API') || config.workers.api)));
|
|
223
210
|
|
|
224
211
|
config.api.port =
|
|
225
212
|
(hasEnvValue('EENGINE_PORT') && Number(readEnvValue('EENGINE_PORT'))) || (hasEnvValue('PORT') && Number(readEnvValue('PORT'))) || config.api.port;
|
|
@@ -275,6 +262,12 @@ const NO_ACTIVE_HANDLER_RESP_ERR = new Error('No active handler for requested ac
|
|
|
275
262
|
NO_ACTIVE_HANDLER_RESP_ERR.statusCode = 503;
|
|
276
263
|
NO_ACTIVE_HANDLER_RESP_ERR.code = 'WorkerNotAvailable';
|
|
277
264
|
|
|
265
|
+
// Shared rejection for in-flight calls whose target worker terminated mid-request.
|
|
266
|
+
// Reused across concurrent rejections - never attach per-call fields to this instance.
|
|
267
|
+
const WORKER_DIED_RESP_ERR = new Error('Worker handling the request terminated before completion. Try again.');
|
|
268
|
+
WORKER_DIED_RESP_ERR.statusCode = 503;
|
|
269
|
+
WORKER_DIED_RESP_ERR.code = 'WorkerNotAvailable';
|
|
270
|
+
|
|
278
271
|
// Update check intervals
|
|
279
272
|
const UPGRADE_CHECK_TIMEOUT = 1 * 24 * 3600 * 1000; // 24 hours
|
|
280
273
|
const LICENSE_CHECK_TIMEOUT = 20 * 60 * 1000; // 20 minutes
|
|
@@ -315,6 +308,7 @@ const THREAD_NAMES = {
|
|
|
315
308
|
*/
|
|
316
309
|
const THREAD_CONFIG_VALUES = {
|
|
317
310
|
imap: { key: 'EENGINE_WORKERS', value: config.workers.imap },
|
|
311
|
+
api: { key: 'EENGINE_WORKERS_API', value: config.workers.api },
|
|
318
312
|
submit: { key: 'EENGINE_WORKERS_SUBMIT', value: config.workers.submit },
|
|
319
313
|
webhooks: { key: 'EENGINE_WORKERS_WEBHOOKS', value: config.workers.webhooks },
|
|
320
314
|
export: { key: 'EENGINE_WORKERS_EXPORT', value: config.workers.export || 1 }
|
|
@@ -326,6 +320,97 @@ const queueEvents = {};
|
|
|
326
320
|
// Unique run index for this server instance
|
|
327
321
|
let runIndex;
|
|
328
322
|
|
|
323
|
+
// Whether multiple API workers may share the listen port via SO_REUSEPORT.
|
|
324
|
+
// Determined once at startup by probeReusePort(); false means a single API worker.
|
|
325
|
+
let apiWorkerReusePort = false;
|
|
326
|
+
|
|
327
|
+
// Requested API worker count (EENGINE_WORKERS_API), surfaced on /admin/internals so operators can
|
|
328
|
+
// see when more workers were requested than could be started. Whether we fell back to a single
|
|
329
|
+
// worker is derived: `apiWorkersRequested > 1 && !apiWorkerReusePort`.
|
|
330
|
+
let apiWorkersRequested = 1;
|
|
331
|
+
|
|
332
|
+
// Why a multi-worker request fell back to a single API worker, surfaced on /admin/internals so the
|
|
333
|
+
// banner can state the real cause. Set during probeReusePort():
|
|
334
|
+
// - 'platform': SO_REUSEPORT is unsupported on this OS/Node build (Linux with Node < 23.1, macOS, Windows)
|
|
335
|
+
// - 'port': the probe could not test the port (transient conflict / permission), so support is unknown
|
|
336
|
+
// Stays null when SO_REUSEPORT is supported or only one worker was requested.
|
|
337
|
+
let apiWorkerReusePortFallbackReason = null;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Probe whether SO_REUSEPORT can load-balance across multiple sockets on this
|
|
341
|
+
* platform. Binds two listeners to the same host:port with reusePort enabled;
|
|
342
|
+
* both must succeed for load balancing to work. It is unsupported on macOS,
|
|
343
|
+
* Windows, and Node < 23.1, where either the first bind fails outright (e.g.
|
|
344
|
+
* ENOTSUP) or - when the reusePort option is silently ignored (Node < 23.1) -
|
|
345
|
+
* the first bind succeeds as a plain bind and the second fails with EADDRINUSE.
|
|
346
|
+
* The `stage` field tells the caller where the probe failed so it can separate a
|
|
347
|
+
* genuine platform/capability limitation from a transient port conflict:
|
|
348
|
+
* - 'probe': the first bind failed, so the port could not even be tested. An
|
|
349
|
+
* EADDRINUSE/EACCES code here means the port is unavailable (transient); any
|
|
350
|
+
* other code means the platform rejected reusePort.
|
|
351
|
+
* - 'reuse': the first bind succeeded but the second did not, so reusePort is
|
|
352
|
+
* not honored on this platform/Node - a capability limitation, even though
|
|
353
|
+
* the failing code is EADDRINUSE.
|
|
354
|
+
* - null: SO_REUSEPORT is supported.
|
|
355
|
+
* @param {string} host - Bind address
|
|
356
|
+
* @param {number} port - Listen port
|
|
357
|
+
* @returns {Promise<{supported: boolean, code: (string|null), stage: (string|null)}>}
|
|
358
|
+
* Whether SO_REUSEPORT load balancing works, the failing bind's error code (or
|
|
359
|
+
* null), and which bind failed ('probe', 'reuse', or null when supported)
|
|
360
|
+
*/
|
|
361
|
+
async function probeReusePort(host, port) {
|
|
362
|
+
const net = require('net');
|
|
363
|
+
|
|
364
|
+
const open = () =>
|
|
365
|
+
new Promise(resolve => {
|
|
366
|
+
let srv = net.createServer();
|
|
367
|
+
srv.once('error', err => resolve({ srv: null, code: (err && err.code) || null }));
|
|
368
|
+
try {
|
|
369
|
+
srv.listen({ host, port, reusePort: true }, () => resolve({ srv, code: null }));
|
|
370
|
+
} catch (err) {
|
|
371
|
+
resolve({ srv: null, code: (err && err.code) || null });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const close = srv =>
|
|
376
|
+
new Promise(resolve => {
|
|
377
|
+
if (!srv) {
|
|
378
|
+
return resolve();
|
|
379
|
+
}
|
|
380
|
+
srv.close(() => resolve());
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
let first = await open();
|
|
384
|
+
if (!first.srv) {
|
|
385
|
+
// Could not even bind the first probe socket, so the port itself could not be tested.
|
|
386
|
+
return { supported: false, code: first.code, stage: 'probe' };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let second = await open();
|
|
390
|
+
await close(first.srv);
|
|
391
|
+
await close(second.srv);
|
|
392
|
+
|
|
393
|
+
if (second.srv) {
|
|
394
|
+
return { supported: true, code: null, stage: null };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The first bind succeeded but a second listener on the same host:port did not, so the kernel is
|
|
398
|
+
// not load-balancing via SO_REUSEPORT - the option was accepted/ignored but not honored (e.g.
|
|
399
|
+
// Linux with Node < 23.1, where reusePort is an unrecognized listen() option). This is a platform
|
|
400
|
+
// limitation, not a port conflict, even though the failing code is EADDRINUSE.
|
|
401
|
+
return { supported: false, code: second.code, stage: 'reuse' };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Compute per-worker spawn options. API workers receive their index and whether to
|
|
406
|
+
* bind with SO_REUSEPORT; other worker types need no extra workerData. Keeps the
|
|
407
|
+
* startup and respawn paths deriving spawn options from a single place.
|
|
408
|
+
* @param {string} type - Worker type
|
|
409
|
+
* @param {number} workerIndex - Zero-based index within the worker type
|
|
410
|
+
* @returns {Object|undefined} Spawn options for spawnWorker(), or undefined
|
|
411
|
+
*/
|
|
412
|
+
const workerSpawnOpts = (type, workerIndex) => (type === 'api' ? { workerData: { workerIndex, reusePort: apiWorkerReusePort } } : undefined);
|
|
413
|
+
|
|
329
414
|
// Prepared configuration handling
|
|
330
415
|
let preparedSettings = false;
|
|
331
416
|
const preparedSettingsString = readEnvValue('EENGINE_SETTINGS') || config.settings;
|
|
@@ -682,7 +767,7 @@ let updateServerState = async (type, state, payload) => {
|
|
|
682
767
|
for (let worker of workers.get('api')) {
|
|
683
768
|
let callPayload = {
|
|
684
769
|
cmd: 'change',
|
|
685
|
-
type:
|
|
770
|
+
type: `${type}ServerState`,
|
|
686
771
|
key: state,
|
|
687
772
|
payload: payload || null
|
|
688
773
|
};
|
|
@@ -930,9 +1015,11 @@ async function sendWebhook(account, event, data) {
|
|
|
930
1015
|
/**
|
|
931
1016
|
* Spawn a new worker thread of the specified type
|
|
932
1017
|
* @param {string} type - Worker type (imap, api, webhooks, submit, documents, smtp, imapProxy)
|
|
1018
|
+
* @param {Object} [opts] - Optional spawn options
|
|
1019
|
+
* @param {Object} [opts.workerData] - Data passed to the worker thread (e.g. API worker index)
|
|
933
1020
|
* @returns {Promise<number|void>} Thread ID if successful
|
|
934
1021
|
*/
|
|
935
|
-
let spawnWorker = async type => {
|
|
1022
|
+
let spawnWorker = async (type, opts) => {
|
|
936
1023
|
// Don't spawn workers during shutdown
|
|
937
1024
|
if (isClosing) {
|
|
938
1025
|
return;
|
|
@@ -966,6 +1053,7 @@ let spawnWorker = async type => {
|
|
|
966
1053
|
let worker = new WorkerThread(pathlib.join(__dirname, 'workers', `${type.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.js`), {
|
|
967
1054
|
argv,
|
|
968
1055
|
env: SHARE_ENV,
|
|
1056
|
+
workerData: opts && opts.workerData,
|
|
969
1057
|
trackUnmanagedFds: true
|
|
970
1058
|
});
|
|
971
1059
|
metrics.threadStarts.inc();
|
|
@@ -1013,6 +1101,13 @@ let spawnWorker = async type => {
|
|
|
1013
1101
|
onlineWorkers.delete(worker);
|
|
1014
1102
|
metrics.threadStops.inc();
|
|
1015
1103
|
|
|
1104
|
+
// Fail any in-flight calls routed to this worker right away, so callers
|
|
1105
|
+
// get a fast retryable error instead of hanging until their own timeout.
|
|
1106
|
+
let rejectedCalls = rejectWorkerCalls(callQueue, worker, WORKER_DIED_RESP_ERR);
|
|
1107
|
+
if (rejectedCalls) {
|
|
1108
|
+
logger.info({ msg: 'Rejected in-flight calls for exited worker', type, threadId: worker.threadId, rejectedCalls });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1016
1111
|
workers.get(type).delete(worker);
|
|
1017
1112
|
|
|
1018
1113
|
// Update server state for proxy servers
|
|
@@ -1117,9 +1212,9 @@ let spawnWorker = async type => {
|
|
|
1117
1212
|
logger.error({ msg: 'Worker unexpectedly exited', exitCode, type });
|
|
1118
1213
|
}
|
|
1119
1214
|
|
|
1120
|
-
// Respawn worker after delay
|
|
1215
|
+
// Respawn worker after delay, preserving any spawn options (e.g. API worker index)
|
|
1121
1216
|
await new Promise(r => setTimeout(r, 1000));
|
|
1122
|
-
await spawnWorker(type);
|
|
1217
|
+
await spawnWorker(type, opts);
|
|
1123
1218
|
};
|
|
1124
1219
|
|
|
1125
1220
|
// Handle worker exit
|
|
@@ -1325,10 +1420,8 @@ let spawnWorker = async type => {
|
|
|
1325
1420
|
}
|
|
1326
1421
|
|
|
1327
1422
|
case 'settings':
|
|
1328
|
-
// Reload HTTP proxy agent in the main thread
|
|
1329
|
-
|
|
1330
|
-
reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
|
|
1331
|
-
}
|
|
1423
|
+
// Reload the HTTP proxy agent in the main thread when proxy settings change
|
|
1424
|
+
maybeReloadHttpProxyAgent(message.data);
|
|
1332
1425
|
|
|
1333
1426
|
// Forward settings changes to all IMAP workers
|
|
1334
1427
|
availableIMAPWorkers.forEach(worker => {
|
|
@@ -1339,8 +1432,8 @@ let spawnWorker = async type => {
|
|
|
1339
1432
|
}
|
|
1340
1433
|
});
|
|
1341
1434
|
|
|
1342
|
-
// Forward settings changes to webhooks, submit, and export workers
|
|
1343
|
-
for (let type of ['webhooks', 'submit', 'export']) {
|
|
1435
|
+
// Forward settings changes to API, webhooks, submit, and export workers
|
|
1436
|
+
for (let type of ['api', 'webhooks', 'submit', 'export']) {
|
|
1344
1437
|
let typeWorkers = workers.get(type);
|
|
1345
1438
|
if (typeWorkers) {
|
|
1346
1439
|
typeWorkers.forEach(worker => {
|
|
@@ -1499,7 +1592,8 @@ async function call(worker, message, transferList) {
|
|
|
1499
1592
|
reject(err);
|
|
1500
1593
|
}, ttl);
|
|
1501
1594
|
|
|
1502
|
-
// Store callback info
|
|
1595
|
+
// Store callback info. `worker` lets us reject this entry immediately if
|
|
1596
|
+
// the target worker terminates, instead of waiting out the timeout.
|
|
1503
1597
|
callQueue.set(mid, {
|
|
1504
1598
|
resolve: result => {
|
|
1505
1599
|
clearTimeout(timer);
|
|
@@ -1509,7 +1603,8 @@ async function call(worker, message, transferList) {
|
|
|
1509
1603
|
clearTimeout(timer);
|
|
1510
1604
|
reject(err);
|
|
1511
1605
|
},
|
|
1512
|
-
timer
|
|
1606
|
+
timer,
|
|
1607
|
+
worker
|
|
1513
1608
|
});
|
|
1514
1609
|
|
|
1515
1610
|
try {
|
|
@@ -1850,11 +1945,16 @@ let licenseCheckHandler = async opts => {
|
|
|
1850
1945
|
default:
|
|
1851
1946
|
if (config.workers && config.workers[type]) {
|
|
1852
1947
|
for (let i = 0; i < config.workers[type]; i++) {
|
|
1853
|
-
await spawnWorker(type);
|
|
1948
|
+
await spawnWorker(type, workerSpawnOpts(type, i));
|
|
1854
1949
|
}
|
|
1855
1950
|
}
|
|
1856
1951
|
}
|
|
1857
1952
|
}
|
|
1953
|
+
|
|
1954
|
+
// Workers were respawned after license activation. Assign any accounts that
|
|
1955
|
+
// accumulated in the unassigned set while workers were suspended, as the
|
|
1956
|
+
// worker ready handler only reassigns after crashes (reassignmentPending)
|
|
1957
|
+
assignAccounts().catch(err => logger.error({ msg: 'Unable to assign accounts after license activation', err }));
|
|
1858
1958
|
}
|
|
1859
1959
|
} finally {
|
|
1860
1960
|
checkingLicense = false;
|
|
@@ -2588,6 +2688,16 @@ async function onCommand(worker, message) {
|
|
|
2588
2688
|
return await getThreadsInfo();
|
|
2589
2689
|
}
|
|
2590
2690
|
|
|
2691
|
+
case 'apiWorkerScaling': {
|
|
2692
|
+
// Reported on /admin/internals so operators can see when EENGINE_WORKERS_API > 1
|
|
2693
|
+
// was requested but only one worker started, and why (reason: 'platform' | 'port').
|
|
2694
|
+
return {
|
|
2695
|
+
requested: apiWorkersRequested,
|
|
2696
|
+
fallback: apiWorkersRequested > 1 && !apiWorkerReusePort,
|
|
2697
|
+
reason: apiWorkerReusePortFallbackReason
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2591
2701
|
case 'worker-accounts': {
|
|
2592
2702
|
// Get accounts assigned to a specific worker thread
|
|
2593
2703
|
const { threadId, page = 1, pageSize = 20 } = message;
|
|
@@ -3192,8 +3302,81 @@ const startApplication = async () => {
|
|
|
3192
3302
|
|
|
3193
3303
|
// -- START WORKER THREADS
|
|
3194
3304
|
|
|
3195
|
-
// Start API server first for health checks
|
|
3196
|
-
|
|
3305
|
+
// Start API server first for health checks.
|
|
3306
|
+
// Optionally run several API workers that share the listen port via SO_REUSEPORT.
|
|
3307
|
+
apiWorkersRequested = config.workers.api;
|
|
3308
|
+
// Effective count we actually start; drops to 1 if SO_REUSEPORT is unavailable. Kept as a local
|
|
3309
|
+
// so we don't mutate the parsed config object that is read elsewhere.
|
|
3310
|
+
let effectiveApiWorkers = config.workers.api;
|
|
3311
|
+
if (config.workers.api > 1) {
|
|
3312
|
+
const probe = await probeReusePort(config.api.host, config.api.port);
|
|
3313
|
+
apiWorkerReusePort = probe.supported;
|
|
3314
|
+
if (!apiWorkerReusePort) {
|
|
3315
|
+
// Only a first-bind EADDRINUSE/EACCES (stage 'probe') means we genuinely could not test
|
|
3316
|
+
// the port - a transient conflict or permission issue, which the single API worker we
|
|
3317
|
+
// start next will surface. A failed second bind (stage 'reuse') instead means reusePort
|
|
3318
|
+
// is accepted but not honored on this platform/Node (e.g. Node < 23.1), which is a
|
|
3319
|
+
// capability limitation rather than a port conflict, so it falls through to the
|
|
3320
|
+
// "not available on this platform" message below.
|
|
3321
|
+
if (probe.stage === 'probe' && (probe.code === 'EADDRINUSE' || probe.code === 'EACCES')) {
|
|
3322
|
+
apiWorkerReusePortFallbackReason = 'port';
|
|
3323
|
+
logger.warn({
|
|
3324
|
+
msg: 'Could not probe SO_REUSEPORT because the API port is unavailable; starting a single API worker',
|
|
3325
|
+
requested: apiWorkersRequested,
|
|
3326
|
+
code: probe.code,
|
|
3327
|
+
host: config.api.host,
|
|
3328
|
+
port: config.api.port
|
|
3329
|
+
});
|
|
3330
|
+
} else {
|
|
3331
|
+
apiWorkerReusePortFallbackReason = 'platform';
|
|
3332
|
+
logger.warn({
|
|
3333
|
+
msg: 'Multiple API workers requested but SO_REUSEPORT is not available on this platform; starting a single API worker',
|
|
3334
|
+
requested: apiWorkersRequested,
|
|
3335
|
+
code: probe.code
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
effectiveApiWorkers = 1;
|
|
3339
|
+
// The /admin/internals warning banner (via the apiWorkerScaling command) tells the
|
|
3340
|
+
// operator only one worker started; the thread-config popover keeps showing the
|
|
3341
|
+
// configured EENGINE_WORKERS_API value rather than being rewritten here.
|
|
3342
|
+
} else {
|
|
3343
|
+
logger.info({ msg: 'SO_REUSEPORT is available; starting multiple API workers', workers: effectiveApiWorkers });
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
let apiPromises = [];
|
|
3348
|
+
for (let i = 0; i < effectiveApiWorkers; i++) {
|
|
3349
|
+
apiPromises.push(spawnWorker('api', workerSpawnOpts('api', i)));
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
// Tolerate partial API-worker startup: proceed as long as at least one worker reached 'ready'
|
|
3353
|
+
// (the spawnWorker exit handler respawns any that failed). Only abort startup if EVERY API
|
|
3354
|
+
// worker failed, preserving the original single-worker "no API worker => exit" behavior.
|
|
3355
|
+
let apiResults = await Promise.allSettled(apiPromises);
|
|
3356
|
+
let apiReady = apiResults.filter(result => result.status === 'fulfilled').length;
|
|
3357
|
+
let apiFailed = apiResults.length - apiReady;
|
|
3358
|
+
if (apiFailed) {
|
|
3359
|
+
logger.error({
|
|
3360
|
+
msg: 'Some API workers failed to start; they will be respawned',
|
|
3361
|
+
requested: apiWorkersRequested,
|
|
3362
|
+
attempted: apiResults.length,
|
|
3363
|
+
ready: apiReady,
|
|
3364
|
+
failed: apiFailed,
|
|
3365
|
+
errors: apiResults
|
|
3366
|
+
.filter(result => result.status === 'rejected')
|
|
3367
|
+
.map(result => ({
|
|
3368
|
+
message: result.reason && result.reason.message,
|
|
3369
|
+
exitCode: result.reason && result.reason.exitCode,
|
|
3370
|
+
threadId: result.reason && result.reason.threadId
|
|
3371
|
+
}))
|
|
3372
|
+
});
|
|
3373
|
+
}
|
|
3374
|
+
if (!apiReady) {
|
|
3375
|
+
// No API worker came up at all - fatal, same as the original single-worker behavior. This
|
|
3376
|
+
// rejects startApplication(), which logs fatal and exits the process.
|
|
3377
|
+
throw new Error('No API worker thread could be started');
|
|
3378
|
+
}
|
|
3379
|
+
logger.info({ msg: 'API workers started', requested: apiWorkersRequested, ready: apiReady });
|
|
3197
3380
|
|
|
3198
3381
|
// Small delay to allow API to start
|
|
3199
3382
|
await new Promise(r => setTimeout(r, 100));
|