-
Notifications
You must be signed in to change notification settings - Fork 9
fix: clean up benchmark MCP sessions #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import process from 'node:process'; | ||
|
|
||
| async function loadSdkClient() { | ||
| const [{ Client }, { StdioClientTransport }] = await Promise.all([ | ||
| import('@modelcontextprotocol/sdk/client/index.js'), | ||
| import('@modelcontextprotocol/sdk/client/stdio.js') | ||
| ]); | ||
|
|
||
| return { Client, StdioClientTransport }; | ||
| } | ||
|
|
||
| function createTimeoutError(timeoutMs) { | ||
| const seconds = Math.max(1, Math.round(timeoutMs / 1000)); | ||
| return new Error(`MCP client connect timed out after ${seconds}s`); | ||
| } | ||
|
|
||
| function withTimeout(promise, timeoutMs) { | ||
| if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { | ||
| return promise; | ||
| } | ||
|
|
||
| let timer; | ||
| const timeoutPromise = new Promise((_, reject) => { | ||
| timer = setTimeout(() => reject(createTimeoutError(timeoutMs)), timeoutMs); | ||
| timer.unref?.(); | ||
| }); | ||
|
|
||
| return Promise.race([promise, timeoutPromise]).finally(() => { | ||
| if (timer) { | ||
| clearTimeout(timer); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| function delay(timeoutMs) { | ||
| return new Promise((resolve) => { | ||
| const timer = setTimeout(resolve, timeoutMs); | ||
| timer.unref?.(); | ||
| }); | ||
| } | ||
|
|
||
| async function safeClose(client, transport, connected) { | ||
| const closeAttempts = []; | ||
|
|
||
| if (connected) { | ||
| closeAttempts.push(client.close().catch(() => undefined)); | ||
| } | ||
|
|
||
| closeAttempts.push(transport.close?.().catch(() => undefined)); | ||
| await Promise.all(closeAttempts); | ||
| } | ||
|
|
||
| export async function withManagedStdioClientSession(options, callback) { | ||
| const { | ||
| serverCommand, | ||
| serverArgs = [], | ||
| serverEnv = {}, | ||
| cwd, | ||
| clientInfo = { name: 'benchmark-runner', version: '1.0.0' }, | ||
| connectTimeoutMs = 10_000, | ||
| onSpawn | ||
| } = options; | ||
|
|
||
| const { Client, StdioClientTransport } = await loadSdkClient(); | ||
| const transport = new StdioClientTransport({ | ||
| command: serverCommand, | ||
| args: serverArgs, | ||
| env: { ...process.env, ...serverEnv }, | ||
| cwd | ||
| }); | ||
| const client = new Client(clientInfo); | ||
|
|
||
| let connected = false; | ||
| let settling = false; | ||
| const connectPromise = client.connect(transport); | ||
| const spawnNotification = (async () => { | ||
| if (typeof onSpawn !== 'function') { | ||
| return; | ||
| } | ||
|
|
||
| while (!settling) { | ||
| if (transport.pid !== null) { | ||
| onSpawn(transport.pid); | ||
| return; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| } | ||
|
|
||
| if (transport.pid !== null) { | ||
| onSpawn(transport.pid); | ||
| } | ||
| })(); | ||
|
Comment on lines
+73
to
+92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The polling loop exits the moment In practice, with a 200 ms timeout and a local process that spawns in < 50 ms, the window is very narrow. But under CI load or on slow machines the test can become flaky. A robust fix is to perform one final pid check after the loop exits: const spawnNotification = (async () => {
if (typeof onSpawn !== 'function') return;
while (!settling) {
if (transport.pid !== null) {
onSpawn(transport.pid);
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
// One final check: process may have spawned while we were asleep
if (transport.pid !== null) {
onSpawn(transport.pid);
}
})(); |
||
|
|
||
| try { | ||
| await withTimeout(connectPromise, connectTimeoutMs); | ||
| connected = true; | ||
| return await callback({ client, transport }); | ||
| } finally { | ||
| settling = true; | ||
| await safeClose(client, transport, connected); | ||
| await spawnNotification.catch(() => undefined); | ||
| await Promise.race([connectPromise, delay(5_000)]).catch(() => undefined); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
err.message.startsWith('Failed to connect to')can never be true here.withManagedStdioClientSessioneither throws"MCP client connect timed out after Xs"(timeout path) or re-throws errors from the callback, none of which start with"Failed to connect to". The guard is harmless but misleading; removing it makes the intent clearer: