diff --git a/bin/mcp-server.js b/bin/mcp-server.js index 8f526ca3c..3a955416e 100755 --- a/bin/mcp-server.js +++ b/bin/mcp-server.js @@ -401,14 +401,17 @@ async function cancelRun() { abortRun = true if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} } if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null } + + const mocha = typeof container.mocha === 'function' ? container.mocha() : container.mocha + try { mocha?.runner?.abort?.() } catch {} + if (pendingRunPromise) { - try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {} + try { await pendingRunPromise.catch(() => {}) } catch {} } pendingRunPromise = null pendingRunResults = null pendingTestFile = null pendingStepInfo = null - abortRun = false return true } @@ -1032,6 +1035,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { pendingRunCleanup = null } + abortRun = false let runError = null const runPromise = (async () => { try { @@ -1126,6 +1130,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { pendingRunCleanup = null } + abortRun = false let runError = null const runPromise = (async () => { try { diff --git a/lib/codecept.js b/lib/codecept.js index 81ddce456..0574e04d7 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -316,7 +316,7 @@ class Codecept { try { event.emit(event.all.before, this) - mocha.run(async (failures) => await done(failures)) + mocha.runner = mocha.run(async (failures) => await done(failures)) } catch (e) { output.error(e.stack) reject(e) diff --git a/lib/command/run-workers.js b/lib/command/run-workers.js index b010a3128..00c343532 100644 --- a/lib/command/run-workers.js +++ b/lib/command/run-workers.js @@ -87,19 +87,5 @@ export default async function (workerCount, selectedRuns, options) { process.exitCode = 1 } finally { await workers.teardownAll() - - // Force exit if event loop doesn't clear naturally - // This is needed because worker threads may leave handles open - // even after proper cleanup, preventing natural process termination - if (!options.noExit) { - // Use beforeExit to ensure we run after all other exit handlers - // have set the correct exit code - process.once('beforeExit', (code) => { - // Give cleanup a moment to complete, then force exit with the correct code - setTimeout(() => { - process.exit(code || process.exitCode || 0) - }, 100) - }) - } } } diff --git a/lib/command/run.js b/lib/command/run.js index 3061aa8de..8a54c10d1 100644 --- a/lib/command/run.js +++ b/lib/command/run.js @@ -1,8 +1,7 @@ -import { getConfig, printError, getTestRoot, createOutputDir } from './utils.js' +import { getConfig, printError, getTestRoot, createOutputDir, autoExit } from './utils.js' import Config from '../config.js' import store from '../store.js' import Codecept from '../codecept.js' -import container from '../container.js' export default async function (test, options) { // registering options globally to use in config @@ -43,19 +42,6 @@ export default async function (test, options) { process.exitCode = 1 } finally { await codecept.teardown() - - // Schedule a delayed exit to prevent process hanging due to browser helper event loops - // Only needed for Playwright/Puppeteer which keep the event loop alive - // Wait 1 second to allow final cleanup and output to complete - if (!process.env.CODECEPT_DISABLE_AUTO_EXIT) { - const helpers = container.helpers() - const hasBrowserHelper = helpers && (helpers.Playwright || helpers.Puppeteer || helpers.WebDriver) - - if (hasBrowserHelper) { - setTimeout(() => { - process.exit(process.exitCode || 0) - }, 1000).unref() - } - } + await autoExit() } } diff --git a/lib/command/utils.js b/lib/command/utils.js index dfcce4d1c..49b1467a7 100644 --- a/lib/command/utils.js +++ b/lib/command/utils.js @@ -107,6 +107,20 @@ export const createOutputDir = (config, testRoot) => { } } +export async function autoExit() { + const timeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10) + if (timeout === 0) return + const exitTimeout = timeout || 2000 + + const { default: container } = await import('../container.js') + const helpers = container.helpers() + if (!helpers || !Object.values(helpers).some(h => typeof h._cleanup === 'function')) return + + const { default: recorder } = await import('../recorder.js') + await Promise.race([recorder.promise(), new Promise(resolve => setTimeout(resolve, exitTimeout))]) + process.exit(process.exitCode || 0) +} + export const findConfigFile = testsPath => { const extensions = ['js', 'ts'] for (const ext of extensions) { diff --git a/lib/plugin/retryFailedStep.js b/lib/plugin/retryFailedStep.js index e9bcac52e..caa902891 100644 --- a/lib/plugin/retryFailedStep.js +++ b/lib/plugin/retryFailedStep.js @@ -1,7 +1,10 @@ +import debugModule from 'debug' import event from '../event.js' import recorder from '../recorder.js' import store from '../store.js' +const debug = debugModule('codeceptjs:retryFailedStep') + const defaultConfig = { retries: 3, defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'], @@ -147,9 +150,7 @@ export default function (config) { test.opts.conditionalRetries = config.retries test.opts.stepRetryPriority = stepRetryPriority - if (process.env.DEBUG_RETRY_PLUGIN) { - console.log('[retryFailedStep] applying retries =', config.retries, 'for test', test.title) - } + debug('applying retries = %d for test %s', config.retries, test.title) recorder.retry(config) }) diff --git a/lib/workers.js b/lib/workers.js index 332def0b3..9aa04bad6 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -547,10 +547,11 @@ class Workers extends EventEmitter { if (this.isPoolMode) { this.activeWorkers.set(worker, { available: true, workerIndex: null }) } - + // Track last activity time to detect hanging workers let lastActivity = Date.now() let currentTest = null + let autoTerminated = false const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m') const timeoutChecker = setInterval(() => { @@ -611,6 +612,13 @@ class Workers extends EventEmitter { }) } + const exitTimeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10) + if (exitTimeout === 0) break + setTimeout(() => { + autoTerminated = true + worker.terminate() + }, exitTimeout || 2000) + break case event.suite.before: { @@ -741,8 +749,8 @@ class Workers extends EventEmitter { worker.on('exit', (code) => { clearInterval(timeoutChecker) this.closedWorkers += 1 - - if (code !== 0) { + + if (code !== 0 && !autoTerminated) { console.error(`[Main] Worker exited with code ${code}`) if (currentTest) { console.error(`[Main] Last test running: ${currentTest}`)