Skip to content

Commit fb28db2

Browse files
DavertMikDavertMikclaude
authored
Fix/refactor autoexit (#5556)
* update docs * updated docs, added browser plugin * fix(mcp): kill zombie Mocha state when timeout fires before test reaches pause() When run_test's timeout was shorter than the time the test needed to reach pause() (e.g. 50ms timeout vs 30s Playwright helper timeout), Mocha kept running in the background, eventually entered paused state forever (because cancelRun had reset abortRun=false), and every subsequent run_test threw "Mocha instance is currently running tests". The only recovery was killing the MCP process. Three changes: - lib/codecept.js: capture the Runner returned by mocha.run() as mocha.runner, so callers have a clean handle to abort. Previously the return value was discarded. - cancelRun(): call mocha.runner.abort() to actually stop Mocha (sets the runner's _abort flag, makes the run callback fire fast). Drop the 5s race against pendingRunPromise — with runner.abort() the promise settles quickly; relying on a short race meant a 30s Playwright step would outlive the cancel and Mocha state stayed RUNNING. - abortRun lifecycle: stop resetting it inside cancelRun. Reset it at the start of each new run_test / run_step_by_step instead. This way if a late pause() fires after cancelRun returns (test reached pause asynchronously after the timeout), setPauseHandler still rejects it instead of trapping forever. Repro fixed: run_test({timeout: 50}) → Timeout after 50ms run_test({timeout: 60000}) → completed, stats: {tests:1, passes:1} (was: second call permanently failed with "Mocha is already running") Refs: testomatio/e2e-tests#103 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixed autoexit --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0c07a9c commit fb28db2

7 files changed

Lines changed: 39 additions & 39 deletions

File tree

bin/mcp-server.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,14 +401,17 @@ async function cancelRun() {
401401
abortRun = true
402402
if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} }
403403
if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null }
404+
405+
const mocha = typeof container.mocha === 'function' ? container.mocha() : container.mocha
406+
try { mocha?.runner?.abort?.() } catch {}
407+
404408
if (pendingRunPromise) {
405-
try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {}
409+
try { await pendingRunPromise.catch(() => {}) } catch {}
406410
}
407411
pendingRunPromise = null
408412
pendingRunResults = null
409413
pendingTestFile = null
410414
pendingStepInfo = null
411-
abortRun = false
412415
return true
413416
}
414417

@@ -1032,6 +1035,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10321035
pendingRunCleanup = null
10331036
}
10341037

1038+
abortRun = false
10351039
let runError = null
10361040
const runPromise = (async () => {
10371041
try {
@@ -1126,6 +1130,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
11261130
pendingRunCleanup = null
11271131
}
11281132

1133+
abortRun = false
11291134
let runError = null
11301135
const runPromise = (async () => {
11311136
try {

lib/codecept.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ class Codecept {
316316

317317
try {
318318
event.emit(event.all.before, this)
319-
mocha.run(async (failures) => await done(failures))
319+
mocha.runner = mocha.run(async (failures) => await done(failures))
320320
} catch (e) {
321321
output.error(e.stack)
322322
reject(e)

lib/command/run-workers.js

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,5 @@ export default async function (workerCount, selectedRuns, options) {
8787
process.exitCode = 1
8888
} finally {
8989
await workers.teardownAll()
90-
91-
// Force exit if event loop doesn't clear naturally
92-
// This is needed because worker threads may leave handles open
93-
// even after proper cleanup, preventing natural process termination
94-
if (!options.noExit) {
95-
// Use beforeExit to ensure we run after all other exit handlers
96-
// have set the correct exit code
97-
process.once('beforeExit', (code) => {
98-
// Give cleanup a moment to complete, then force exit with the correct code
99-
setTimeout(() => {
100-
process.exit(code || process.exitCode || 0)
101-
}, 100)
102-
})
103-
}
10490
}
10591
}

lib/command/run.js

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { getConfig, printError, getTestRoot, createOutputDir } from './utils.js'
1+
import { getConfig, printError, getTestRoot, createOutputDir, autoExit } from './utils.js'
22
import Config from '../config.js'
33
import store from '../store.js'
44
import Codecept from '../codecept.js'
5-
import container from '../container.js'
65

76
export default async function (test, options) {
87
// registering options globally to use in config
@@ -43,19 +42,6 @@ export default async function (test, options) {
4342
process.exitCode = 1
4443
} finally {
4544
await codecept.teardown()
46-
47-
// Schedule a delayed exit to prevent process hanging due to browser helper event loops
48-
// Only needed for Playwright/Puppeteer which keep the event loop alive
49-
// Wait 1 second to allow final cleanup and output to complete
50-
if (!process.env.CODECEPT_DISABLE_AUTO_EXIT) {
51-
const helpers = container.helpers()
52-
const hasBrowserHelper = helpers && (helpers.Playwright || helpers.Puppeteer || helpers.WebDriver)
53-
54-
if (hasBrowserHelper) {
55-
setTimeout(() => {
56-
process.exit(process.exitCode || 0)
57-
}, 1000).unref()
58-
}
59-
}
45+
await autoExit()
6046
}
6147
}

lib/command/utils.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ export const createOutputDir = (config, testRoot) => {
107107
}
108108
}
109109

110+
export async function autoExit() {
111+
const timeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10)
112+
if (timeout === 0) return
113+
const exitTimeout = timeout || 2000
114+
115+
const { default: container } = await import('../container.js')
116+
const helpers = container.helpers()
117+
if (!helpers || !Object.values(helpers).some(h => typeof h._cleanup === 'function')) return
118+
119+
const { default: recorder } = await import('../recorder.js')
120+
await Promise.race([recorder.promise(), new Promise(resolve => setTimeout(resolve, exitTimeout))])
121+
process.exit(process.exitCode || 0)
122+
}
123+
110124
export const findConfigFile = testsPath => {
111125
const extensions = ['js', 'ts']
112126
for (const ext of extensions) {

lib/plugin/retryFailedStep.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import debugModule from 'debug'
12
import event from '../event.js'
23
import recorder from '../recorder.js'
34
import store from '../store.js'
45

6+
const debug = debugModule('codeceptjs:retryFailedStep')
7+
58
const defaultConfig = {
69
retries: 3,
710
defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'],
@@ -147,9 +150,7 @@ export default function (config) {
147150
test.opts.conditionalRetries = config.retries
148151
test.opts.stepRetryPriority = stepRetryPriority
149152

150-
if (process.env.DEBUG_RETRY_PLUGIN) {
151-
console.log('[retryFailedStep] applying retries =', config.retries, 'for test', test.title)
152-
}
153+
debug('applying retries = %d for test %s', config.retries, test.title)
153154
recorder.retry(config)
154155
})
155156

lib/workers.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,11 @@ class Workers extends EventEmitter {
547547
if (this.isPoolMode) {
548548
this.activeWorkers.set(worker, { available: true, workerIndex: null })
549549
}
550-
550+
551551
// Track last activity time to detect hanging workers
552552
let lastActivity = Date.now()
553553
let currentTest = null
554+
let autoTerminated = false
554555
const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m')
555556

556557
const timeoutChecker = setInterval(() => {
@@ -611,6 +612,13 @@ class Workers extends EventEmitter {
611612
})
612613
}
613614

615+
const exitTimeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10)
616+
if (exitTimeout === 0) break
617+
setTimeout(() => {
618+
autoTerminated = true
619+
worker.terminate()
620+
}, exitTimeout || 2000)
621+
614622
break
615623
case event.suite.before:
616624
{
@@ -741,8 +749,8 @@ class Workers extends EventEmitter {
741749
worker.on('exit', (code) => {
742750
clearInterval(timeoutChecker)
743751
this.closedWorkers += 1
744-
745-
if (code !== 0) {
752+
753+
if (code !== 0 && !autoTerminated) {
746754
console.error(`[Main] Worker exited with code ${code}`)
747755
if (currentTest) {
748756
console.error(`[Main] Last test running: ${currentTest}`)

0 commit comments

Comments
 (0)