diff --git a/lib/workers.js b/lib/workers.js index d97304a69..28feb7c23 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -27,6 +27,8 @@ import { createRuns } from './command/run-multiple/collection.js' const pathToWorker = path.join(__dirname, 'command', 'workers', 'runTests.js') +const WORKER_TIMEOUT_MINUTES = 10 + const initializeCodecept = async (configPath, options = {}) => { const config = await mainConfig.load(configPath || '.') const codecept = new Codecept(config, { ...options, skipDefaultListeners: true }) @@ -501,6 +503,26 @@ class Workers extends EventEmitter { return runHook(this.codecept.config.teardownAll, 'teardownAll') } + /** + * Resolves the overall `run-workers` timeout in milliseconds. + * + * Resolution order: + * 1. `CODECEPT_WORKERS_TIMEOUT` env var (numeric, ms) + * 2. `workersTimeout` in codecept.conf (ms) + * 3. Default: 600000 (10 minutes) + * + * Set the value to `0` (or any non-positive number) to disable the timeout entirely. + * + * @returns {number} timeout in milliseconds + */ + _getWorkersTimeoutMs() { + const envTimeout = parseInt(process.env.CODECEPT_WORKERS_TIMEOUT, 10) + if (Number.isFinite(envTimeout)) return envTimeout + const configTimeout = this.codecept?.config?.workersTimeout + if (Number.isFinite(configTimeout)) return configTimeout + return WORKER_TIMEOUT_MINUTES * 60 * 1000 + } + async run() { await this._ensureInitialized() recorder.startUnlessRunning() @@ -521,22 +543,27 @@ class Workers extends EventEmitter { // Workers are already running, this is just a placeholder step }) - // Add overall timeout to prevent infinite hanging - const overallTimeout = setTimeout(() => { - console.error('[Main] Overall timeout reached (10 minutes). Force terminating remaining workers...') - workerThreads.forEach(w => { - try { - w.terminate() - } catch (e) { - // ignore - } - }) - this._finishRun() - }, 600000) // 10 minutes + // Overall timeout to prevent infinite hanging. See _getWorkersTimeoutMs() for resolution rules. + const overallTimeoutMs = this._getWorkersTimeoutMs() + let overallTimeout + if (overallTimeoutMs > 0) { + overallTimeout = setTimeout(() => { + const minutes = (overallTimeoutMs / 60000).toFixed(1) + console.error(`[Main] Overall timeout reached (${minutes} minutes). Force terminating remaining workers...`) + workerThreads.forEach(w => { + try { + w.terminate() + } catch (e) { + // ignore + } + }) + this._finishRun() + }, overallTimeoutMs) + } return new Promise(resolve => { this.on('end', () => { - clearTimeout(overallTimeout) + if (overallTimeout) clearTimeout(overallTimeout) resolve() }) }) diff --git a/test/unit/worker_test.js b/test/unit/worker_test.js index 635f00b72..2d6abc017 100644 --- a/test/unit/worker_test.js +++ b/test/unit/worker_test.js @@ -382,4 +382,55 @@ describe('Workers', function () { workers.run() }) + + describe('_getWorkersTimeoutMs', () => { + let savedEnv + let workers + + beforeEach(() => { + savedEnv = process.env.CODECEPT_WORKERS_TIMEOUT + delete process.env.CODECEPT_WORKERS_TIMEOUT + workers = new Workers(1, { by: 'test', testConfig: './test/data/sandbox/codecept.workers.conf.js' }) + // Stub the codecept config so we don't need to await initialization for this pure-logic test + workers.codecept = { config: {} } + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.CODECEPT_WORKERS_TIMEOUT + } else { + process.env.CODECEPT_WORKERS_TIMEOUT = savedEnv + } + }) + + it('returns default 10 minutes when nothing is configured', () => { + expect(workers._getWorkersTimeoutMs()).to.equal(600000) + }) + + it('uses CODECEPT_WORKERS_TIMEOUT env var when set', () => { + process.env.CODECEPT_WORKERS_TIMEOUT = '90000' + expect(workers._getWorkersTimeoutMs()).to.equal(90000) + }) + + it('uses workersTimeout from config when env is not set', () => { + workers.codecept.config.workersTimeout = 120000 + expect(workers._getWorkersTimeoutMs()).to.equal(120000) + }) + + it('env var takes precedence over config', () => { + process.env.CODECEPT_WORKERS_TIMEOUT = '15000' + workers.codecept.config.workersTimeout = 999999 + expect(workers._getWorkersTimeoutMs()).to.equal(15000) + }) + + it('returns 0 when env var is "0" so the timeout can be disabled in run()', () => { + process.env.CODECEPT_WORKERS_TIMEOUT = '0' + expect(workers._getWorkersTimeoutMs()).to.equal(0) + }) + + it('falls back to default when env var is non-numeric', () => { + process.env.CODECEPT_WORKERS_TIMEOUT = 'not-a-number' + expect(workers._getWorkersTimeoutMs()).to.equal(600000) + }) + }) }) diff --git a/typings/index.d.ts b/typings/index.d.ts index de54e9001..9e89b212c 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -360,6 +360,13 @@ declare namespace CodeceptJS { */ teardownAll?: (() => Promise) | boolean | string + /** + * Overall timeout for `run-workers` (milliseconds). After this time, any worker + * still running is force-terminated and the run finishes. Default: `600000` (10 min). + * Set to `0` to disable. Can also be overridden via `CODECEPT_WORKERS_TIMEOUT` env var. + */ + workersTimeout?: number + /** Enable [localized test commands](https://codecept.io/translation/) */ translation?: string