From a5c3816ef97a6487caf2faa0b025fbf995b46487 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 6 May 2026 13:52:11 +0300 Subject: [PATCH 1/4] fix(mcp): timeout aborts Mocha runner so next run_test isn't blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the run_test / run_step_by_step timeout was just a setTimeout that rejected the race promise — the Mocha runner kept going, the recorder chain stayed queued, listeners stayed attached, and pause sessions kept trapping. Subsequent run_test calls hit "Mocha instance is currently running". cancel didn't help because pendingRunPromise was only assigned in the paused branch, so it saw nothing to cancel. - Assign pendingRunPromise immediately after runPromise creation in both run_test and run_step_by_step (was set only on pause). - Wrap the Promise.race in try/catch + finally; clear the setTimeout and route timeout rejections through cancelRun(); return a clean status: "failed" payload to the client. - Make cancelRun() actually abort: look up the Mocha runner via mocha._runner / _previousRunner / runner and call runner.abort(); recorder.reset() to drop any queued tasks. Existing abortRun + pause release stay in place for stuck-on-pause cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mcp-server.js | 54 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/bin/mcp-server.js b/bin/mcp-server.js index 66b770eb7..e70296a50 100755 --- a/bin/mcp-server.js +++ b/bin/mcp-server.js @@ -401,6 +401,14 @@ 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 + const runner = mocha?._runner || mocha?._previousRunner || mocha?.runner + if (runner && typeof runner.abort === 'function') { + try { runner.abort() } catch {} + } + try { recorder.reset() } catch {} + if (pendingRunPromise) { try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {} } @@ -1025,18 +1033,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw err } })() + pendingRunPromise = runPromise const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) const completedPromise = runPromise.then(() => 'completed', () => 'completed') - const which = await Promise.race([ - completedPromise, - pausedPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)), - ]) + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) + }) + + let which + try { + which = await Promise.race([completedPromise, pausedPromise, timeoutPromise]) + } catch (err) { + await cancelRun() + await startShellSession() + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] } + } finally { + clearTimeout(timeoutId) + } if (which === 'paused') { - pendingRunPromise = runPromise const page = await gatherPageBrief() return { content: [{ @@ -1046,6 +1064,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + pendingRunPromise = null const final = collectRunCompletion(runError?.message) await startShellSession() return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] } @@ -1121,18 +1140,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw err } })() + pendingRunPromise = runPromise const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) const completedPromise = runPromise.then(() => 'completed', () => 'completed') - const which = await Promise.race([ - completedPromise, - pausedPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)), - ]) + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) + }) + + let which + try { + which = await Promise.race([completedPromise, pausedPromise, timeoutPromise]) + } catch (err) { + await cancelRun() + await startShellSession() + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] } + } finally { + clearTimeout(timeoutId) + } if (which === 'paused') { - pendingRunPromise = runPromise const page = await gatherPageBrief() return { content: [{ @@ -1142,6 +1171,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + pendingRunPromise = null const final = collectRunCompletion(runError?.message) await startShellSession() return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] } From 7c56ecc92c6b21449dfcce0f34e2ff7b12bbe291 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 6 May 2026 22:45:46 +0300 Subject: [PATCH 2/4] fix(mcp): drop unused runner.abort + recorder.reset hacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pause-and-abortRun chain alone is enough: resolveContinue releases the current pauseSession, abortRun causes the next pauseNow-queued pauseSession to reject inside setPauseHandler, the rejection propagates through the recorder to codecept.run, and Mocha's runningNow clears naturally. Reaching into mocha._runner / _previousRunner / .runner and calling recorder.reset() were speculative — the timeout repro still clears Mocha state without them. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mcp-server.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bin/mcp-server.js b/bin/mcp-server.js index e70296a50..0a2b61021 100755 --- a/bin/mcp-server.js +++ b/bin/mcp-server.js @@ -401,14 +401,6 @@ 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 - const runner = mocha?._runner || mocha?._previousRunner || mocha?.runner - if (runner && typeof runner.abort === 'function') { - try { runner.abort() } catch {} - } - try { recorder.reset() } catch {} - if (pendingRunPromise) { try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {} } From 736bbbfca360476056d68d182d94eba00f5af504 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 6 May 2026 23:39:36 +0300 Subject: [PATCH 3/4] refactor(mcp): extract raceRunOutcome helper to dedupe run_test/run_step_by_step Both tools had the same Promise.race + try/catch + finally + cancelRun + failure-response block. Extracted into raceRunOutcome(runPromise, timeout) which returns a tagged outcome ({outcome:'paused'|'completed'} or {outcome:'aborted', error}) so the caller branches once. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mcp-server.js | 59 ++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/bin/mcp-server.js b/bin/mcp-server.js index 0a2b61021..89200af98 100755 --- a/bin/mcp-server.js +++ b/bin/mcp-server.js @@ -412,6 +412,23 @@ async function cancelRun() { return true } +async function raceRunOutcome(runPromise, timeout) { + const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) + const completedPromise = runPromise.then(() => 'completed', () => 'completed') + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) + }) + try { + return { outcome: await Promise.race([completedPromise, pausedPromise, timeoutPromise]) } + } catch (err) { + await cancelRun() + return { outcome: 'aborted', error: err.message } + } finally { + clearTimeout(timeoutId) + } +} + async function closeBrowser() { if (!containerInitialized) return await cancelRun() @@ -1027,26 +1044,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { })() pendingRunPromise = runPromise - const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) - const completedPromise = runPromise.then(() => 'completed', () => 'completed') - - let timeoutId - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) - }) - - let which - try { - which = await Promise.race([completedPromise, pausedPromise, timeoutPromise]) - } catch (err) { - await cancelRun() + const { outcome, error: abortError } = await raceRunOutcome(runPromise, timeout) + if (outcome === 'aborted') { await startShellSession() - return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] } - } finally { - clearTimeout(timeoutId) + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: abortError }, null, 2) }] } } - if (which === 'paused') { + if (outcome === 'paused') { const page = await gatherPageBrief() return { content: [{ @@ -1134,26 +1138,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { })() pendingRunPromise = runPromise - const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) - const completedPromise = runPromise.then(() => 'completed', () => 'completed') - - let timeoutId - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) - }) - - let which - try { - which = await Promise.race([completedPromise, pausedPromise, timeoutPromise]) - } catch (err) { - await cancelRun() + const { outcome, error: abortError } = await raceRunOutcome(runPromise, timeout) + if (outcome === 'aborted') { await startShellSession() - return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] } - } finally { - clearTimeout(timeoutId) + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: abortError }, null, 2) }] } } - if (which === 'paused') { + if (outcome === 'paused') { const page = await gatherPageBrief() return { content: [{ From 3c5e2a3273ab5f21ed619c6935702e01f524acf5 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 6 May 2026 23:48:27 +0300 Subject: [PATCH 4/4] =?UTF-8?q?refactor(mcp):=20rename=20raceRunOutcome=20?= =?UTF-8?q?=E2=86=92=20waitForTestResult,=20outcome=20=E2=86=92=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mcp-server.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bin/mcp-server.js b/bin/mcp-server.js index 89200af98..8f526ca3c 100755 --- a/bin/mcp-server.js +++ b/bin/mcp-server.js @@ -412,7 +412,7 @@ async function cancelRun() { return true } -async function raceRunOutcome(runPromise, timeout) { +async function waitForTestResult(runPromise, timeout) { const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused'))) const completedPromise = runPromise.then(() => 'completed', () => 'completed') let timeoutId @@ -420,10 +420,10 @@ async function raceRunOutcome(runPromise, timeout) { timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) }) try { - return { outcome: await Promise.race([completedPromise, pausedPromise, timeoutPromise]) } + return { status: await Promise.race([completedPromise, pausedPromise, timeoutPromise]) } } catch (err) { await cancelRun() - return { outcome: 'aborted', error: err.message } + return { status: 'aborted', error: err.message } } finally { clearTimeout(timeoutId) } @@ -1044,13 +1044,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { })() pendingRunPromise = runPromise - const { outcome, error: abortError } = await raceRunOutcome(runPromise, timeout) - if (outcome === 'aborted') { + const result = await waitForTestResult(runPromise, timeout) + if (result.status === 'aborted') { await startShellSession() - return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: abortError }, null, 2) }] } + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] } } - if (outcome === 'paused') { + if (result.status === 'paused') { const page = await gatherPageBrief() return { content: [{ @@ -1138,13 +1138,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { })() pendingRunPromise = runPromise - const { outcome, error: abortError } = await raceRunOutcome(runPromise, timeout) - if (outcome === 'aborted') { + const result = await waitForTestResult(runPromise, timeout) + if (result.status === 'aborted') { await startShellSession() - return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: abortError }, null, 2) }] } + return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] } } - if (outcome === 'paused') { + if (result.status === 'paused') { const page = await gatherPageBrief() return { content: [{