import { describe, it, expect, beforeEach, mock } from 'bun:test'; // Singleton enforcement regression coverage for issue #2313. // // Hypothesis under test: prior to the fix, ChromaMcpManager could leak its // chroma-mcp subprocess tree on every reconnect / transport error, accumulating // 20+ instances per session on Linux because the MCP SDK's transport.close() // only signals the direct child (uvx). The fix routes every "abandon current // transport" path through disposeCurrentSubprocess(), which tree-kills via // killProcessTree() before nulling the handles. let transportCount = 0; const transportInstances: Array = []; interface FakeChildProcess { pid: number; once: (event: string, _cb: (...args: unknown[]) => void) => FakeChildProcess; on: (event: string, _cb: (...args: unknown[]) => void) => FakeChildProcess; } class FakeTransport { static nextPid = 100_000; onclose: (() => void) | null = null; closed = false; // Mimic StdioClientTransport's internal `_process` field that the manager // pokes into via `(this.transport as unknown as { _process })._process`. _process: FakeChildProcess; constructor(_opts: { command: string; args: string[] }) { transportCount += 1; const pid = FakeTransport.nextPid++; const child: FakeChildProcess = { pid, once: function (this: FakeChildProcess) { return this; }, on: function (this: FakeChildProcess) { return this; }, }; this._process = child; transportInstances.push(this); } async close(): Promise { this.closed = true; } } mock.module('@modelcontextprotocol/sdk/client/stdio.js', () => ({ StdioClientTransport: FakeTransport, })); let connectImpl: () => Promise = async () => {}; let callToolImpl: () => Promise = async () => ({ content: [{ type: 'text', text: '{}' }], }); class FakeClient { closed = false; async connect(): Promise { await connectImpl(); } async callTool(): Promise { return await callToolImpl(); } async close(): Promise { this.closed = true; } } mock.module('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: FakeClient, })); mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({ SettingsDefaultsManager: { get: () => '', getInt: () => 0, loadFromFile: () => ({}), }, })); mock.module('../../../src/shared/paths.js', () => ({ USER_SETTINGS_PATH: '/tmp/fake-settings.json', paths: { chroma: () => '/tmp/fake-chroma', combinedCerts: () => '/tmp/fake-combined-certs.pem', }, })); mock.module('../../../src/utils/logger.js', () => ({ logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {}, failure: () => {}, }, })); // Track tree-kill invocations and the transport whose subprocess was killed. const killTreeCalls: number[] = []; mock.module('../../../src/supervisor/index.ts', () => ({ getSupervisor: () => ({ assertCanSpawn: () => {}, registerProcess: () => {}, unregisterProcess: () => {}, }), })); mock.module('../../../src/supervisor/env-sanitizer.js', () => ({ sanitizeEnv: (env: NodeJS.ProcessEnv) => env, })); // Replace child_process.execFile so the static killProcessTree implementation // can be observed without actually shelling out. We feed pgrep an empty stdout // (no descendants) so the only signal target is the root pid. mock.module('child_process', () => { const original = require('node:child_process'); return { ...original, execFile: ( cmd: string, args: string[], _opts: unknown, cb: (err: Error | null, stdout: { stdout: string; stderr: string }) => void ) => { // Bun's promisify path will call this as if it were a Node-style callback. if (cmd === 'pgrep') { cb(null, { stdout: '', stderr: '' } as any); } else { cb(null, { stdout: '', stderr: '' } as any); } }, execSync: () => '', }; }); // Stub process.kill so the tree-kill path can record targets without crashing // the test runner if the synthetic PID happens to collide with a real one. const realProcessKill = process.kill.bind(process); const stubbedProcessKill = ((pid: number, _signal?: string | number) => { killTreeCalls.push(pid); return true; }) as typeof process.kill; process.kill = stubbedProcessKill; import { ChromaMcpManager } from '../../../src/services/sync/ChromaMcpManager.js'; function resetState(): void { transportCount = 0; transportInstances.length = 0; killTreeCalls.length = 0; connectImpl = async () => {}; callToolImpl = async () => ({ content: [{ type: 'text', text: '{}' }] }); } describe('ChromaMcpManager singleton enforcement (#2313)', () => { beforeEach(async () => { await ChromaMcpManager.reset(); resetState(); }); it('serializes concurrent ensureConnected() calls into one spawn', async () => { const mgr = ChromaMcpManager.getInstance(); // Five parallel callers race ensureConnected via callTool — only one // chroma-mcp subprocess (one transport) should be spawned. await Promise.all( Array.from({ length: 5 }, () => mgr.callTool('chroma_list_collections', { limit: 1 }) ) ); expect(transportCount).toBe(1); }); it('kills the prior subprocess tree before a reconnect spawn', async () => { const mgr = ChromaMcpManager.getInstance(); // First call: opens transport #1. await mgr.callTool('chroma_list_collections', { limit: 1 }); expect(transportInstances.length).toBe(1); const firstPid = transportInstances[0]._process.pid; // Second call: rig callTool to throw a transport error on the FIRST attempt // so the manager runs its reconnect-and-retry path. The retry should // dispose the prior subprocess tree (firstPid) before spawning a new one. let invocations = 0; callToolImpl = async () => { invocations += 1; if (invocations === 1) { throw new Error('Connection closed'); } return { content: [{ type: 'text', text: '{}' }] }; }; await mgr.callTool('chroma_list_collections', { limit: 1 }); expect(transportInstances.length).toBe(2); // The first transport's pid must have been signaled by killProcessTree // before the second transport spawned. expect(killTreeCalls).toContain(firstPid); }); it('stop() disposes state including any pending connecting promise', async () => { const mgr = ChromaMcpManager.getInstance(); await mgr.callTool('chroma_list_collections', { limit: 1 }); expect(transportInstances.length).toBe(1); const subprocessPid = transportInstances[0]._process.pid; await mgr.stop(); // After stop(), every internal handle should be cleared and the prior // subprocess tree must have been signaled. expect(killTreeCalls).toContain(subprocessPid); // A subsequent ensureConnected must spawn a fresh transport (not reuse // a stale one). await mgr.callTool('chroma_list_collections', { limit: 1 }); expect(transportInstances.length).toBe(2); }); }); // Restore the real process.kill once the test module finishes evaluating any // late-arriving microtasks. process.on('exit', () => { process.kill = realProcessKill; });