@@ -18,16 +18,12 @@ import { USER_SETTINGS_PATH } from '../../shared/paths.js';
1818import path from 'path' ;
1919import os from 'os' ;
2020import fs from 'fs' ;
21- import { execSync , execFileSync } from 'child_process' ;
22- import { parseElapsedTime } from '../infrastructure/ProcessManager.js' ;
21+ import { execSync } from 'child_process' ;
2322
2423// Version injected at build time by esbuild define
2524declare const __DEFAULT_PACKAGE_VERSION__ : string ;
2625const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev' ;
2726
28- // Maximum allowed chroma-mcp processes before pre-spawn guard kills excess
29- const MAX_CHROMA_PROCESSES = 2 ; // 1 active + 1 starting
30-
3127interface ChromaDocument {
3228 id : string ;
3329 document : string ;
@@ -94,16 +90,6 @@ export class ChromaSync {
9490 // MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited.
9591 private readonly disabled : boolean = false ;
9692
97- // Layer 0: Connection mutex — coalesces concurrent callers onto single spawn
98- private connectionPromise : Promise < void > | null = null ;
99-
100- // Layer 4: Circuit breaker — stops retry storms after repeated failures
101- private consecutiveFailures : number = 0 ;
102- private lastFailureTime : number = 0 ;
103- private static readonly MAX_FAILURES = 3 ;
104- private static readonly BACKOFF_BASE_MS = 2000 ;
105- private static readonly CIRCUIT_OPEN_MS = 60000 ; // 1 minute cooldown
106-
10793 constructor ( project : string ) {
10894 this . project = project ;
10995 this . collectionName = `cm__${ project } ` ;
@@ -192,113 +178,13 @@ export class ChromaSync {
192178 }
193179
194180 /**
195- * Ensure MCP client is connected to Chroma server (mutex wrapper).
196- * Coalesces concurrent callers onto a single connection attempt.
197- * This prevents N concurrent calls from each spawning a chroma-mcp subprocess.
181+ * Ensure MCP client is connected to Chroma server
182+ * Throws error if connection fails
198183 */
199184 private async ensureConnection ( ) : Promise < void > {
200- if ( this . connected && this . client ) return ;
201-
202- // Layer 0: Coalesce concurrent callers onto a single connection attempt
203- if ( this . connectionPromise ) {
204- return this . connectionPromise ;
205- }
206-
207- this . connectionPromise = this . _doConnect ( ) ;
208- try {
209- await this . connectionPromise ;
210- } finally {
211- this . connectionPromise = null ;
212- }
213- }
214-
215- /**
216- * Layer 4: Circuit breaker — refuse to spawn after repeated failures.
217- * After MAX_FAILURES consecutive connection failures, stops all spawn
218- * attempts for CIRCUIT_OPEN_MS to prevent process accumulation storms.
219- */
220- private checkCircuitBreaker ( ) : void {
221- if ( this . consecutiveFailures >= ChromaSync . MAX_FAILURES ) {
222- const elapsed = Date . now ( ) - this . lastFailureTime ;
223- if ( elapsed < ChromaSync . CIRCUIT_OPEN_MS ) {
224- throw new Error (
225- `Chroma circuit breaker open: ${ this . consecutiveFailures } consecutive failures. ` +
226- `Retry in ${ Math . ceil ( ( ChromaSync . CIRCUIT_OPEN_MS - elapsed ) / 1000 ) } s`
227- ) ;
228- }
229- // Cooldown expired, allow retry
230- logger . info ( 'CHROMA_SYNC' , 'Circuit breaker cooldown expired, allowing retry' , {
231- consecutiveFailures : this . consecutiveFailures ,
232- cooldownMs : ChromaSync . CIRCUIT_OPEN_MS
233- } ) ;
234- }
235- }
236-
237- /**
238- * Layer 1: Pre-spawn process count guard.
239- * Kills excess chroma-mcp processes before spawning a new one.
240- * Uses execFileSync (no shell) to list processes, filters in JavaScript.
241- */
242- private killExcessChromaProcesses ( ) : void {
243- if ( process . platform === 'win32' ) return ; // Windows has Chroma disabled entirely
244-
245- try {
246- // Use execFileSync to avoid shell injection — filter and sort in JavaScript
247- // Include etime column for reliable age-based sorting (PID order is unreliable)
248- const output = execFileSync ( 'ps' , [ '-eo' , 'pid,etime,command' ] , {
249- encoding : 'utf8' ,
250- timeout : 5000 ,
251- stdio : [ 'pipe' , 'pipe' , 'pipe' ]
252- } ) ;
253-
254- // Filter for chroma-mcp, parse elapsed time, sort by actual age
255- const processes = output . split ( '\n' )
256- . filter ( l => l . includes ( 'chroma-mcp' ) )
257- . map ( l => {
258- const parts = l . trim ( ) . split ( / \s + / ) ;
259- const pid = parseInt ( parts [ 0 ] , 10 ) ;
260- const etime = parts [ 1 ] || '' ;
261- const ageMinutes = parseElapsedTime ( etime ) ;
262- return { pid, ageMinutes } ;
263- } )
264- . filter ( p => p . pid > 0 && p . pid !== process . pid && p . ageMinutes >= 0 )
265- . sort ( ( a , b ) => a . ageMinutes - b . ageMinutes ) ; // Ascending: newest (lowest age) first
266-
267- if ( processes . length < MAX_CHROMA_PROCESSES ) return ;
268-
269- // Keep newest MAX_CHROMA_PROCESSES - 1 (making room for the one we're about to spawn)
270- const toKill = processes . slice ( MAX_CHROMA_PROCESSES - 1 ) ;
271- for ( const { pid } of toKill ) {
272- try {
273- process . kill ( pid , 'SIGTERM' ) ;
274- } catch {
275- // Process may already be dead
276- }
277- }
278-
279- if ( toKill . length > 0 ) {
280- logger . warn ( 'CHROMA_SYNC' , 'Killed excess chroma-mcp processes before spawning' , {
281- found : processes . length ,
282- killed : toKill . length ,
283- maxAllowed : MAX_CHROMA_PROCESSES
284- } ) ;
285- }
286- } catch {
287- // ps may fail — don't block connection
185+ if ( this . connected && this . client ) {
186+ return ;
288187 }
289- }
290-
291- /**
292- * Internal connection logic — called only via ensureConnection() mutex.
293- * Implements circuit breaker (Layer 4), pre-spawn guard (Layer 1),
294- * and actual connection setup.
295- */
296- private async _doConnect ( ) : Promise < void > {
297- // Layer 4: Circuit breaker check — refuse if too many recent failures
298- this . checkCircuitBreaker ( ) ;
299-
300- // Layer 1: Kill excess processes before spawning a new one
301- this . killExcessChromaProcesses ( ) ;
302188
303189 logger . info ( 'CHROMA_SYNC' , 'Connecting to Chroma MCP server...' , { project : this . project } ) ;
304190
@@ -352,20 +238,9 @@ export class ChromaSync {
352238 await this . client . connect ( this . transport ) ;
353239 this . connected = true ;
354240
355- // Layer 4: Reset circuit breaker on success
356- this . consecutiveFailures = 0 ;
357-
358241 logger . info ( 'CHROMA_SYNC' , 'Connected to Chroma MCP server' , { project : this . project } ) ;
359242 } catch ( error ) {
360- // Layer 4: Track failure for circuit breaker
361- this . consecutiveFailures ++ ;
362- this . lastFailureTime = Date . now ( ) ;
363-
364- logger . error ( 'CHROMA_SYNC' , 'Failed to connect to Chroma MCP server' , {
365- project : this . project ,
366- consecutiveFailures : this . consecutiveFailures ,
367- circuitBreakerThreshold : ChromaSync . MAX_FAILURES
368- } , error as Error ) ;
243+ logger . error ( 'CHROMA_SYNC' , 'Failed to connect to Chroma MCP server' , { project : this . project } , error as Error ) ;
369244 throw new Error ( `Chroma connection failed: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
370245 }
371246 }
@@ -416,7 +291,6 @@ export class ChromaSync {
416291 this . connected = false ;
417292 this . client = null ;
418293 this . transport = null ;
419- this . connectionPromise = null ;
420294 logger . error ( 'CHROMA_SYNC' , 'Connection lost during collection check' ,
421295 { collection : this . collectionName } , error as Error ) ;
422296 throw new Error ( `Chroma connection lost: ${ errorMessage } ` ) ;
@@ -1086,7 +960,6 @@ export class ChromaSync {
1086960 this . connected = false ;
1087961 this . client = null ;
1088962 this . transport = null ;
1089- this . connectionPromise = null ;
1090963 logger . error ( 'CHROMA_SYNC' , 'Connection lost during query' ,
1091964 { project : this . project , query } , error as Error ) ;
1092965 throw new Error ( `Chroma query failed - connection lost: ${ errorMessage } ` ) ;
@@ -1144,37 +1017,28 @@ export class ChromaSync {
11441017 }
11451018
11461019 /**
1147- * Close the Chroma client connection and cleanup subprocess.
1148- * Uses try-finally to guarantee state reset even if close() throws.
1149- * Individual close calls use .catch() to prevent one failure from
1150- * blocking the other (e.g., client.close() failing shouldn't prevent
1151- * transport.close() from killing the subprocess).
1020+ * Close the Chroma client connection and cleanup subprocess
11521021 */
11531022 async close ( ) : Promise < void > {
11541023 if ( ! this . connected && ! this . client && ! this . transport ) {
11551024 return ;
11561025 }
11571026
1158- try {
1159- // Close client first, then transport — catch individual errors
1160- if ( this . client ) {
1161- await this . client . close ( ) . catch ( ( err : Error ) => {
1162- logger . debug ( 'CHROMA_SYNC' , 'Client close error (may already be disconnected)' , { } , err ) ;
1163- } ) ;
1164- }
1165- if ( this . transport ) {
1166- await this . transport . close ( ) . catch ( ( err : Error ) => {
1167- logger . debug ( 'CHROMA_SYNC' , 'Transport close error (may already be dead)' , { } , err ) ;
1168- } ) ;
1169- }
1170- } finally {
1171- // Always reset state, even if close throws
1172- this . connected = false ;
1173- this . client = null ;
1174- this . transport = null ;
1175- this . connectionPromise = null ;
1027+ // Close client first
1028+ if ( this . client ) {
1029+ await this . client . close ( ) ;
1030+ }
1031+
1032+ // Explicitly close transport to kill subprocess
1033+ if ( this . transport ) {
1034+ await this . transport . close ( ) ;
11761035 }
11771036
11781037 logger . info ( 'CHROMA_SYNC' , 'Chroma client and subprocess closed' , { project : this . project } ) ;
1038+
1039+ // Always reset state
1040+ this . connected = false ;
1041+ this . client = null ;
1042+ this . transport = null ;
11791043 }
11801044}
0 commit comments