Skip to content

Commit a5c3816

Browse files
DavertMikclaude
andcommitted
fix(mcp): timeout aborts Mocha runner so next run_test isn't blocked
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) <noreply@anthropic.com>
1 parent 4f0fa49 commit a5c3816

1 file changed

Lines changed: 42 additions & 12 deletions

File tree

bin/mcp-server.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ 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+
const runner = mocha?._runner || mocha?._previousRunner || mocha?.runner
407+
if (runner && typeof runner.abort === 'function') {
408+
try { runner.abort() } catch {}
409+
}
410+
try { recorder.reset() } catch {}
411+
404412
if (pendingRunPromise) {
405413
try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {}
406414
}
@@ -1025,18 +1033,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10251033
throw err
10261034
}
10271035
})()
1036+
pendingRunPromise = runPromise
10281037

10291038
const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
10301039
const completedPromise = runPromise.then(() => 'completed', () => 'completed')
10311040

1032-
const which = await Promise.race([
1033-
completedPromise,
1034-
pausedPromise,
1035-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
1036-
])
1041+
let timeoutId
1042+
const timeoutPromise = new Promise((_, reject) => {
1043+
timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)
1044+
})
1045+
1046+
let which
1047+
try {
1048+
which = await Promise.race([completedPromise, pausedPromise, timeoutPromise])
1049+
} catch (err) {
1050+
await cancelRun()
1051+
await startShellSession()
1052+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] }
1053+
} finally {
1054+
clearTimeout(timeoutId)
1055+
}
10371056

10381057
if (which === 'paused') {
1039-
pendingRunPromise = runPromise
10401058
const page = await gatherPageBrief()
10411059
return {
10421060
content: [{
@@ -1046,6 +1064,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10461064
}
10471065
}
10481066

1067+
pendingRunPromise = null
10491068
const final = collectRunCompletion(runError?.message)
10501069
await startShellSession()
10511070
return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
@@ -1121,18 +1140,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
11211140
throw err
11221141
}
11231142
})()
1143+
pendingRunPromise = runPromise
11241144

11251145
const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
11261146
const completedPromise = runPromise.then(() => 'completed', () => 'completed')
11271147

1128-
const which = await Promise.race([
1129-
completedPromise,
1130-
pausedPromise,
1131-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
1132-
])
1148+
let timeoutId
1149+
const timeoutPromise = new Promise((_, reject) => {
1150+
timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)
1151+
})
1152+
1153+
let which
1154+
try {
1155+
which = await Promise.race([completedPromise, pausedPromise, timeoutPromise])
1156+
} catch (err) {
1157+
await cancelRun()
1158+
await startShellSession()
1159+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: err.message }, null, 2) }] }
1160+
} finally {
1161+
clearTimeout(timeoutId)
1162+
}
11331163

11341164
if (which === 'paused') {
1135-
pendingRunPromise = runPromise
11361165
const page = await gatherPageBrief()
11371166
return {
11381167
content: [{
@@ -1142,6 +1171,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
11421171
}
11431172
}
11441173

1174+
pendingRunPromise = null
11451175
const final = collectRunCompletion(runError?.message)
11461176
await startShellSession()
11471177
return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }

0 commit comments

Comments
 (0)