Skip to content

Commit bff313a

Browse files
authored
fix workers crash (#5370)
* fix workers crash * fix failed runner tests
1 parent 87fec45 commit bff313a

File tree

8 files changed

+191
-41
lines changed

8 files changed

+191
-41
lines changed

lib/command/run-workers.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,22 @@ export default async function (workerCount, selectedRuns, options) {
4040

4141
output.print(`CodeceptJS v${Codecept.version()} ${output.standWithUkraine()}`)
4242
output.print(`Running tests in ${output.styles.bold(numberOfWorkers)} workers...`)
43-
output.print()
4443
store.hasWorkers = true
4544

4645
const workers = new Workers(numberOfWorkers, config)
4746
workers.overrideConfig(overrideConfigs)
47+
48+
// Show test distribution after workers are initialized
49+
await workers.bootstrapAll()
50+
51+
const workerObjects = workers.getWorkers()
52+
output.print()
53+
output.print('Test distribution:')
54+
workerObjects.forEach((worker, index) => {
55+
const testCount = worker.tests.length
56+
output.print(` Worker ${index + 1}: ${testCount} test${testCount !== 1 ? 's' : ''}`)
57+
})
58+
output.print()
4859

4960
workers.on(event.test.failed, test => {
5061
output.test.failed(test)
@@ -68,7 +79,6 @@ export default async function (workerCount, selectedRuns, options) {
6879
if (options.verbose) {
6980
await getMachineInfo()
7081
}
71-
await workers.bootstrapAll()
7282
await workers.run()
7383
} catch (err) {
7484
output.error(err)

lib/command/workers/runTests.js

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,45 @@ const stderr = ''
1919

2020
const { options, tests, testRoot, workerIndex, poolMode } = workerData
2121

22+
// Global error handlers to catch critical errors but not test failures
23+
process.on('uncaughtException', (err) => {
24+
// Log to stderr to bypass stdout suppression
25+
process.stderr.write(`[Worker ${workerIndex}] UNCAUGHT EXCEPTION: ${err.message}\n`)
26+
process.stderr.write(`${err.stack}\n`)
27+
28+
// Don't exit on test assertion errors - those are handled by mocha
29+
if (err.name === 'AssertionError' || err.message?.includes('expected')) {
30+
return
31+
}
32+
process.exit(1)
33+
})
34+
35+
process.on('unhandledRejection', (reason, promise) => {
36+
// Log to stderr to bypass stdout suppression
37+
const msg = reason?.message || String(reason)
38+
process.stderr.write(`[Worker ${workerIndex}] UNHANDLED REJECTION: ${msg}\n`)
39+
if (reason?.stack) {
40+
process.stderr.write(`${reason.stack}\n`)
41+
}
42+
43+
// Don't exit on test-related rejections
44+
if (msg.includes('expected') || msg.includes('AssertionError')) {
45+
return
46+
}
47+
process.exit(1)
48+
})
49+
2250
// hide worker output
2351
// In pool mode, only suppress output if debug is NOT enabled
2452
// In regular mode, hide result output but allow step output in verbose/debug
2553
if (poolMode && !options.debug) {
2654
// In pool mode without debug, allow test names and important output but suppress verbose details
2755
const originalWrite = process.stdout.write
2856
process.stdout.write = string => {
57+
// Always allow Worker logs
58+
if (string.includes('[Worker')) {
59+
return originalWrite.call(process.stdout, string)
60+
}
2961
// Allow test names (✔ or ✖), Scenario Steps, failures, and important markers
3062
if (
3163
string.includes('✔') ||
@@ -45,7 +77,12 @@ if (poolMode && !options.debug) {
4577
return originalWrite.call(process.stdout, string)
4678
}
4779
} else if (!poolMode && !options.debug && !options.verbose) {
80+
const originalWrite = process.stdout.write
4881
process.stdout.write = string => {
82+
// Always allow Worker logs
83+
if (string.includes('[Worker')) {
84+
return originalWrite.call(process.stdout, string)
85+
}
4986
stdout += string
5087
return true
5188
}
@@ -82,6 +119,13 @@ let config
82119
// Load test and run
83120
initPromise = (async function () {
84121
try {
122+
// Add staggered delay at the very start to prevent resource conflicts
123+
// Longer delay for browser initialization conflicts
124+
const delay = (workerIndex - 1) * 2000 // 0ms, 2s, 4s, etc.
125+
if (delay > 0) {
126+
await new Promise(resolve => setTimeout(resolve, delay))
127+
}
128+
85129
// Import modules dynamically to avoid ES Module loader race conditions in Node 22.x
86130
const eventModule = await import('../../event.js')
87131
const containerModule = await import('../../container.js')
@@ -98,16 +142,32 @@ initPromise = (async function () {
98142

99143
const overrideConfigs = tryOrDefault(() => JSON.parse(options.override), {})
100144

101-
// IMPORTANT: await is required here since getConfig is async
102-
const baseConfig = await getConfig(options.config || testRoot)
145+
let baseConfig
146+
try {
147+
// IMPORTANT: await is required here since getConfig is async
148+
baseConfig = await getConfig(options.config || testRoot)
149+
} catch (configErr) {
150+
process.stderr.write(`[Worker ${workerIndex}] FAILED loading config: ${configErr.message}\n`)
151+
process.stderr.write(`${configErr.stack}\n`)
152+
await new Promise(resolve => setTimeout(resolve, 100))
153+
process.exit(1)
154+
}
103155

104156
// important deep merge so dynamic things e.g. functions on config are not overridden
105157
config = deepMerge(baseConfig, overrideConfigs)
106158

107159
// Pass workerIndex as child option for output.process() to display worker prefix
108160
const optsWithChild = { ...options, child: workerIndex }
109161
codecept = new Codecept(config, optsWithChild)
110-
await codecept.init(testRoot)
162+
163+
try {
164+
await codecept.init(testRoot)
165+
} catch (initErr) {
166+
process.stderr.write(`[Worker ${workerIndex}] FAILED during codecept.init(): ${initErr.message}\n`)
167+
process.stderr.write(`${initErr.stack}\n`)
168+
process.exit(1)
169+
}
170+
111171
codecept.loadTests()
112172
mocha = container.mocha()
113173

@@ -126,10 +186,12 @@ initPromise = (async function () {
126186
await runTests()
127187
} else {
128188
// No tests to run, close the worker
189+
console.error(`[Worker ${workerIndex}] ERROR: No tests found after filtering! Assigned ${tests.length} UIDs but none matched.`)
129190
parentPort?.close()
130191
}
131192
} catch (err) {
132-
console.error('Error in worker initialization:', err)
193+
process.stderr.write(`[Worker ${workerIndex}] FATAL ERROR: ${err.message}\n`)
194+
process.stderr.write(`${err.stack}\n`)
133195
process.exit(1)
134196
}
135197
})()
@@ -147,8 +209,14 @@ async function runTests() {
147209
disablePause()
148210
try {
149211
await codecept.run()
212+
} catch (err) {
213+
throw err
150214
} finally {
151-
await codecept.teardown()
215+
try {
216+
await codecept.teardown()
217+
} catch (err) {
218+
// Ignore teardown errors
219+
}
152220
}
153221
}
154222

@@ -336,8 +404,16 @@ function filterTests() {
336404
mocha.files = files
337405
mocha.loadFiles()
338406

339-
for (const suite of mocha.suite.suites) {
407+
// Recursively filter tests in all suites (including nested ones)
408+
const filterSuiteTests = (suite) => {
340409
suite.tests = suite.tests.filter(test => tests.indexOf(test.uid) >= 0)
410+
for (const childSuite of suite.suites) {
411+
filterSuiteTests(childSuite)
412+
}
413+
}
414+
415+
for (const suite of mocha.suite.suites) {
416+
filterSuiteTests(suite)
341417
}
342418
}
343419

lib/helper/Playwright.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,7 +912,7 @@ class Playwright extends Helper {
912912
}
913913

914914
async _finishTest() {
915-
if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
915+
if (this.isRunning) {
916916
try {
917917
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
918918
} catch (e) {

lib/listener/helpers.js

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,30 +73,18 @@ export default function () {
7373
})
7474

7575
event.dispatcher.on(event.all.result, () => {
76-
// Skip _finishTest for all helpers if any browser helper restarts to avoid double cleanup
77-
const hasBrowserRestart = Object.values(helpers).some(helper =>
78-
(helper.config && (helper.config.restart === 'browser' || helper.config.restart === 'context' || helper.config.restart === true)) ||
79-
(helper.options && (helper.options.restart === 'browser' || helper.options.restart === 'context' || helper.options.restart === true))
80-
)
81-
8276
Object.keys(helpers).forEach(key => {
8377
const helper = helpers[key]
84-
if (helper._finishTest && !hasBrowserRestart) {
78+
if (helper._finishTest) {
8579
recorder.add(`hook ${key}._finishTest()`, () => helper._finishTest(), true, false)
8680
}
8781
})
8882
})
8983

9084
event.dispatcher.on(event.all.after, () => {
91-
// Skip _cleanup for all helpers if any browser helper restarts to avoid double cleanup
92-
const hasBrowserRestart = Object.values(helpers).some(helper =>
93-
(helper.config && (helper.config.restart === 'browser' || helper.config.restart === 'context' || helper.config.restart === true)) ||
94-
(helper.options && (helper.options.restart === 'browser' || helper.options.restart === 'context' || helper.options.restart === true))
95-
)
96-
9785
Object.keys(helpers).forEach(key => {
9886
const helper = helpers[key]
99-
if (helper._cleanup && !hasBrowserRestart) {
87+
if (helper._cleanup) {
10088
recorder.add(`hook ${key}._cleanup()`, () => helper._cleanup(), true, false)
10189
}
10290
})

lib/utils/typescript.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const __dirname = __dirname_fn(__filename);
119119
let jsContent = transpileTS(filePath)
120120

121121
// Find all relative TypeScript imports in this file
122-
const importRegex = /from\s+['"](\..+?)(?:\.ts)?['"]/g
122+
const importRegex = /from\s+['"](\.[^'"]+?)(?:\.ts)?['"]/g
123123
let match
124124
const imports = []
125125

@@ -170,7 +170,7 @@ const __dirname = __dirname_fn(__filename);
170170

171171
// After all dependencies are transpiled, rewrite imports in this file
172172
jsContent = jsContent.replace(
173-
/from\s+['"](\..+?)(?:\.ts)?['"]/g,
173+
/from\s+['"](\.[^'"]+?)(?:\.ts)?['"]/g,
174174
(match, importPath) => {
175175
let resolvedPath = path.resolve(fileBaseDir, importPath)
176176
const originalExt = path.extname(importPath)

lib/workers.js

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,9 @@ class Workers extends EventEmitter {
370370
// If Codecept isn't initialized yet, return empty groups as a safe fallback
371371
if (!this.codecept) return populateGroups(numberOfWorkers)
372372
const files = this.codecept.testFiles
373+
374+
// Create a fresh mocha instance to avoid state pollution
375+
Container.createMocha(this.codecept.config.mocha || {}, this.options)
373376
const mocha = Container.mocha()
374377
mocha.files = files
375378
mocha.loadFiles()
@@ -384,6 +387,10 @@ class Workers extends EventEmitter {
384387
groupCounter++
385388
}
386389
})
390+
391+
// Clean up after collecting test UIDs
392+
mocha.unloadFiles()
393+
387394
return groups
388395
}
389396

@@ -452,9 +459,12 @@ class Workers extends EventEmitter {
452459
const files = this.codecept.testFiles
453460
const groups = populateGroups(numberOfWorkers)
454461

462+
// Create a fresh mocha instance to avoid state pollution
463+
Container.createMocha(this.codecept.config.mocha || {}, this.options)
455464
const mocha = Container.mocha()
456465
mocha.files = files
457466
mocha.loadFiles()
467+
458468
mocha.suite.suites.forEach(suite => {
459469
const i = indexOfSmallestElement(groups)
460470
suite.tests.forEach(test => {
@@ -463,6 +473,10 @@ class Workers extends EventEmitter {
463473
}
464474
})
465475
})
476+
477+
// Clean up after collecting test UIDs
478+
mocha.unloadFiles()
479+
466480
return groups
467481
}
468482

@@ -504,8 +518,24 @@ class Workers extends EventEmitter {
504518
// Workers are already running, this is just a placeholder step
505519
})
506520

521+
// Add overall timeout to prevent infinite hanging
522+
const overallTimeout = setTimeout(() => {
523+
console.error('[Main] Overall timeout reached (10 minutes). Force terminating remaining workers...')
524+
workerThreads.forEach(w => {
525+
try {
526+
w.terminate()
527+
} catch (e) {
528+
// ignore
529+
}
530+
})
531+
this._finishRun()
532+
}, 600000) // 10 minutes
533+
507534
return new Promise(resolve => {
508-
this.on('end', resolve)
535+
this.on('end', () => {
536+
clearTimeout(overallTimeout)
537+
resolve()
538+
})
509539
})
510540
}
511541

@@ -528,8 +558,32 @@ class Workers extends EventEmitter {
528558
if (this.isPoolMode) {
529559
this.activeWorkers.set(worker, { available: true, workerIndex: null })
530560
}
561+
562+
// Track last activity time to detect hanging workers
563+
let lastActivity = Date.now()
564+
let currentTest = null
565+
const workerTimeout = 300000 // 5 minutes
566+
567+
const timeoutChecker = setInterval(() => {
568+
const elapsed = Date.now() - lastActivity
569+
if (elapsed > workerTimeout) {
570+
console.error(`[Main] Worker appears to be hanging (no activity for ${Math.floor(elapsed/1000)}s). Terminating...`)
571+
if (currentTest) {
572+
console.error(`[Main] Last test: ${currentTest}`)
573+
}
574+
clearInterval(timeoutChecker)
575+
worker.terminate()
576+
}
577+
}, 30000) // Check every 30 seconds
531578

532579
worker.on('message', message => {
580+
lastActivity = Date.now() // Update activity timestamp
581+
582+
// Track current test
583+
if (message.event === event.test.started && message.data) {
584+
currentTest = message.data.title || message.data.fullTitle
585+
}
586+
533587
output.process(message.workerIndex)
534588

535589
// Handle test requests for pool mode
@@ -646,11 +700,25 @@ class Workers extends EventEmitter {
646700
})
647701

648702
worker.on('error', err => {
703+
console.error(`[Main] Worker error:`, err.message || err)
704+
if (currentTest) {
705+
console.error(`[Main] Failed during test: ${currentTest}`)
706+
}
649707
this.errors.push(err)
650708
})
651709

652-
worker.on('exit', () => {
710+
worker.on('exit', (code) => {
711+
clearInterval(timeoutChecker)
653712
this.closedWorkers += 1
713+
714+
if (code !== 0) {
715+
console.error(`[Main] Worker exited with code ${code}`)
716+
if (currentTest) {
717+
console.error(`[Main] Last test running: ${currentTest}`)
718+
}
719+
// Mark as failed
720+
process.exitCode = 1
721+
}
654722

655723
if (this.isPoolMode) {
656724
// Pool mode: finish when all workers have exited and no more tests
@@ -666,7 +734,7 @@ class Workers extends EventEmitter {
666734

667735
_finishRun() {
668736
event.dispatcher.emit(event.workers.after, { tests: this.workers.map(worker => worker.tests) })
669-
if (Container.result().hasFailed) {
737+
if (Container.result().hasFailed || this.errors.length > 0) {
670738
process.exitCode = 1
671739
} else {
672740
process.exitCode = 0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeceptjs",
3-
"version": "4.0.1-beta.9",
3+
"version": "4.0.2-beta.17",
44
"type": "module",
55
"description": "Supercharged End 2 End Testing Framework for NodeJS",
66
"keywords": [

0 commit comments

Comments
 (0)