# Phase 2: Service Infrastructure - Research **Researched:** 2026-02-12 **Domain:** Service lifecycle management, multi-context architecture, IPC routing **Confidence:** HIGH ## Summary Phase 2 establishes the infrastructure for managing multiple independent service contexts (local + N SSH connections) with proper lifecycle management, cleanup, and IPC routing. The core challenge is transforming a single-mode application into a multi-context system where the local context is always alive and each SSH connection gets its own isolated service instances. **Key insight:** The codebase already has all the building blocks needed — FileSystemProvider abstraction (Phase 1), EventEmitter-based services with cleanup paths, and module-level IPC handler references that support re-initialization. The registry pattern will coordinate these existing pieces rather than introducing fundamentally new mechanisms. **Primary recommendation:** Build ServiceContextRegistry as a Map-based coordinator that creates/destroys service bundles, route IPC requests using context ID stamping, and implement bulletproof dispose() methods on all EventEmitter-based services. ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Node.js EventEmitter | Built-in | Event-driven service communication | Already used by FileWatcher, NotificationManager, SshConnectionManager | | Map | Built-in | Context registry storage | Fast O(1) lookups, iteration support, built-in size tracking | | Electron IPC (ipcMain/ipcRenderer) | 28.x | Process communication | Existing IPC infrastructure | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | ssh2 | Current (Phase 1) | SSH connections | Already integrated for SFTP | | fs.FSWatcher | Built-in | File watching | Already used in FileWatcher service | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Map | WeakMap | Would enable GC of contexts, but need explicit lifecycle control for cleanup | | IPC context stamping | Separate IPC channels per context | Would explode channel count (5-10 channels × N contexts), harder to manage | | Module-level service refs | Dependency injection container | More complex, unnecessary when re-initialization pattern already works | **Installation:** No new dependencies required — using existing stack. ## Architecture Patterns ### Recommended Project Structure ``` src/main/ ├── services/ │ ├── infrastructure/ │ │ ├── ServiceContext.ts # NEW: Context bundle class │ │ ├── ServiceContextRegistry.ts # NEW: Registry coordinator │ │ └── [existing services...] # MODIFY: Add dispose() methods │ └── [domain services...] ├── ipc/ │ ├── handlers.ts # MODIFY: Add context routing │ ├── context.ts # NEW: Context management IPC │ └── [domain handlers...] # MODIFY: Route via context ID └── index.ts # MODIFY: Use registry instead of globals ``` ### Pattern 1: Service Context Bundle **What:** A ServiceContext class encapsulates all service instances for a single context (local or SSH). **When to use:** For each workspace context that needs independent service lifecycle. **Example:** ```typescript // Source: Based on existing service initialization in src/main/index.ts (lines 78-92) export interface ServiceContextConfig { id: string; type: 'local' | 'ssh'; fsProvider: FileSystemProvider; projectsDir?: string; todosDir?: string; } export class ServiceContext { readonly id: string; readonly type: 'local' | 'ssh'; // Service instances readonly projectScanner: ProjectScanner; readonly sessionParser: SessionParser; readonly subagentResolver: SubagentResolver; readonly chunkBuilder: ChunkBuilder; readonly dataCache: DataCache; readonly fileWatcher: FileWatcher; constructor(config: ServiceContextConfig) { this.id = config.id; this.type = config.type; // Initialize services with provider this.projectScanner = new ProjectScanner( config.projectsDir, config.todosDir, config.fsProvider ); this.sessionParser = new SessionParser(this.projectScanner); this.subagentResolver = new SubagentResolver(this.projectScanner); this.chunkBuilder = new ChunkBuilder(); this.dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES); // FileWatcher with provider this.fileWatcher = new FileWatcher( this.dataCache, config.projectsDir, config.todosDir, config.fsProvider ); } // Start active services start(): void { this.fileWatcher.start(); this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES); } // Critical: Proper cleanup dispose(): void { this.fileWatcher.stop(); this.dataCache.clear(); // FileSystemProvider cleanup handled by caller } } ``` ### Pattern 2: Registry with Active Context Tracking **What:** ServiceContextRegistry manages Map of contexts and tracks which one is currently active. **When to use:** Single registry instance in main process coordinates all context operations. **Example:** ```typescript // Source: Adapted from existing SshConnectionManager state management pattern export class ServiceContextRegistry { private contexts = new Map(); private activeContextId: string = 'local'; constructor() { // Local context is always alive const localContext = this.createContext({ id: 'local', type: 'local', fsProvider: new LocalFileSystemProvider(), }); this.contexts.set('local', localContext); localContext.start(); } getActive(): ServiceContext { const context = this.contexts.get(this.activeContextId); if (!context) { throw new Error(`Active context ${this.activeContextId} not found`); } return context; } switch(contextId: string): void { if (!this.contexts.has(contextId)) { throw new Error(`Context ${contextId} does not exist`); } this.activeContextId = contextId; } createSshContext( id: string, fsProvider: FileSystemProvider, projectsDir: string ): ServiceContext { if (this.contexts.has(id)) { throw new Error(`Context ${id} already exists`); } const context = this.createContext({ id, type: 'ssh', fsProvider, projectsDir, }); this.contexts.set(id, context); context.start(); return context; } destroy(contextId: string): void { if (contextId === 'local') { throw new Error('Cannot destroy local context'); } const context = this.contexts.get(contextId); if (context) { context.dispose(); this.contexts.delete(contextId); } } private createContext(config: ServiceContextConfig): ServiceContext { return new ServiceContext(config); } } ``` ### Pattern 3: IPC Context Routing **What:** IPC handlers read context ID from event args and route to correct service context. **When to use:** All session-data IPC handlers (projects, sessions, search, subagents). **Example:** ```typescript // Source: Based on existing IPC handler pattern in src/main/ipc/sessions.ts let contextRegistry: ServiceContextRegistry; export function initializeContextHandlers(registry: ServiceContextRegistry): void { contextRegistry = registry; } // Modified handler with context routing ipcMain.handle('get-projects', async (event, contextId?: string) => { const context = contextId ? contextRegistry.get(contextId) : contextRegistry.getActive(); if (!context) { return []; } return context.projectScanner.scan(); }); ``` ### Pattern 4: EventEmitter Dispose Pattern **What:** Services extending EventEmitter must remove all listeners and clear resources in dispose(). **When to use:** FileWatcher, NotificationManager, and any EventEmitter-based service. **Example:** ```typescript // Source: Best practices from Node.js EventEmitter cleanup research export class FileWatcher extends EventEmitter { private projectsWatcher: fs.FSWatcher | null = null; private todosWatcher: fs.FSWatcher | null = null; private debounceTimers = new Map(); private catchUpTimer: NodeJS.Timeout | null = null; private pollingTimer: NodeJS.Timeout | null = null; dispose(): void { // Stop watchers first this.stop(); // Clear all timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); if (this.catchUpTimer) { clearInterval(this.catchUpTimer); this.catchUpTimer = null; } if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } // CRITICAL: Remove ALL listeners to prevent memory leaks this.removeAllListeners(); // Clear tracking state this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); } } ``` ### Anti-Patterns to Avoid - **Global service instances:** Don't keep services as global module variables when using registry — use registry.getActive() instead - **Async dispose:** Keep dispose() synchronous — cleanup should be immediate and deterministic - **Partial cleanup:** Missing even one timer or listener causes memory leaks — use comprehensive checklists - **Shared caches across contexts:** Each context must have its own DataCache instance to prevent cross-contamination ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Service lifecycle management | Custom dependency injection container | Map-based registry with explicit create/dispose | DI containers add complexity; explicit lifecycle is easier to debug | | IPC routing | Separate channels per context | Context ID stamping on existing channels | Channel explosion (N contexts × M channels); harder to manage | | Event cleanup | Manual tracking of each listener | removeAllListeners() in dispose() | Guaranteed cleanup; prevents missed listeners | | Context switching race conditions | Custom mutex/lock | Node.js single-threaded execution + synchronous switch | IPC handlers run sequentially; no parallelism within main process | **Key insight:** The main process is single-threaded, so context switching is naturally serialized. No need for complex synchronization primitives. ## Common Pitfalls ### Pitfall 1: Memory Leaks from Orphaned EventEmitter Listeners **What goes wrong:** Services extend EventEmitter but don't call removeAllListeners() in dispose(), causing listeners to persist after context destruction. **Why it happens:** EventEmitter automatically manages a listeners array, but it never auto-clears. Each listener closure captures references to the service instance and any data it touches. **How to avoid:** - Add dispose() method to ALL services extending EventEmitter - Call removeAllListeners() at the END of dispose() (after stopping watchers/timers) - Test disposal by creating/destroying context 100+ times and monitoring memory **Warning signs:** - MaxListenersExceededWarning after multiple context switches - Memory usage climbs 50-100MB per switch without leveling off - DevTools heap snapshot shows growing EventEmitter listener arrays **Example fix:** ```typescript // Source: Verified pattern from FileWatcher.ts stop() method dispose(): void { // 1. Stop active operations if (this.projectsWatcher) { this.projectsWatcher.close(); this.projectsWatcher = null; } // 2. Clear timers/intervals if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } // 3. Clear data structures this.debounceTimers.clear(); this.lastProcessedLineCount.clear(); // 4. CRITICAL: Remove ALL listeners LAST this.removeAllListeners(); } ``` ### Pitfall 2: File Watcher Cross-Context Pollution **What goes wrong:** FileWatcher in background SSH context emits events that trigger IPC sends for wrong context, causing UI to show stale data. **Why it happens:** FileWatcher.on('file-change') forwards to renderer via mainWindow.webContents.send(). If watcher stays alive in background context, it keeps emitting for inactive context. **How to avoid:** - Option A: Only start FileWatcher for active context, stop on switch - Option B: Scope events with contextId, filter in renderer - **Recommended:** Option A — simpler, no renderer filtering needed **Warning signs:** - Switching from SSH to local shows SSH file change notifications - Project list updates with data from inactive context - Cache invalidation affects wrong context **Implementation:** ```typescript // In ServiceContextRegistry.switch() switch(newContextId: string): void { const oldContext = this.getActive(); // Stop file watcher in old context oldContext.fileWatcher.stop(); // Switch active this.activeContextId = newContextId; const newContext = this.getActive(); // Start file watcher in new context newContext.fileWatcher.start(); } ``` ### Pitfall 3: IPC Handler Re-initialization Timing **What goes wrong:** Switching contexts before re-initializing IPC handlers causes next IPC call to use old service instances from previous context. **Why it happens:** Module-level variables in ipc/sessions.ts etc. hold service references. Calling registry.switch() doesn't update those refs until reinitializeServiceHandlers() is called. **How to avoid:** - Always call reinitializeServiceHandlers() IMMEDIATELY after registry.switch() - Make switch() method handle re-init internally, don't rely on caller - Verify with integration test: switch context, call IPC, check which projectsDir was scanned **Warning signs:** - Switching to SSH shows local projects for first query - Race condition where sometimes switch works, sometimes shows stale data - Logs show scans hitting wrong directory path **Implementation:** ```typescript // In ServiceContextRegistry switch(newContextId: string): void { this.activeContextId = newContextId; const newContext = this.getActive(); // CRITICAL: Re-init IPC handlers with new context's services reinitializeServiceHandlers( newContext.projectScanner, newContext.sessionParser, newContext.subagentResolver, newContext.chunkBuilder, newContext.dataCache ); } ``` ### Pitfall 4: SSH Connection Manager Lifecycle Confusion **What goes wrong:** SshConnectionManager owns the SSH client and SftpWrapper, but ServiceContext also needs to dispose its SshFileSystemProvider. Double-dispose or leaked connections result. **Why it happens:** Ownership unclear — does SshConnectionManager own the connection, or does ServiceContext? **How to avoid:** - **SshConnectionManager owns:** Client, SFTP channel, SshFileSystemProvider instance - **ServiceContext receives:** Pre-created FileSystemProvider (interface, not lifecycle) - **On context destroy:** ServiceContext calls fsProvider.dispose() to end SFTP, SshConnectionManager tracks this and cleans up Client - **Clean separation:** Context disposal -> provider disposal -> connection manager cleanup chain **Warning signs:** - SFTP channel stays open after context destroy - Multiple SFTP channels open for same SSH connection - "Channel already closed" errors on reconnect **Implementation:** ```typescript // ServiceContext.dispose() dispose(): void { this.fileWatcher.stop(); this.dataCache.clear(); // Dispose FileSystemProvider (closes SFTP if SSH) if (this.fsProvider.type === 'ssh') { this.fsProvider.dispose(); } } // SshConnectionManager watches for provider disposal connect(config: SshConnectionConfig): Promise { // Create SFTP const sftp = await this.openSftp(); const provider = new SshFileSystemProvider(sftp); // Track this provider this.activeProviders.add(provider); return provider; } // When provider.dispose() is called, it ends SFTP // Connection manager can detect and clean up client ``` ## Code Examples Verified patterns from existing codebase and research: ### Service Bundle Creation ```typescript // Source: Adapted from src/main/index.ts initializeServices() pattern export class ServiceContext { constructor(config: ServiceContextConfig) { this.id = config.id; this.type = config.type; // Chain dependencies: ProjectScanner -> SessionParser/SubagentResolver this.projectScanner = new ProjectScanner( config.projectsDir, config.todosDir, config.fsProvider ); this.sessionParser = new SessionParser(this.projectScanner); this.subagentResolver = new SubagentResolver(this.projectScanner); this.chunkBuilder = new ChunkBuilder(); // Isolated cache per context this.dataCache = new DataCache( MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, true // enabled ); // FileWatcher with context-specific provider this.fileWatcher = new FileWatcher( this.dataCache, config.projectsDir, config.todosDir, config.fsProvider ); } } ``` ### Registry Initialization in Main Process ```typescript // Source: New pattern replacing src/main/index.ts service initialization let contextRegistry: ServiceContextRegistry; function initializeServices(): void { logger.info('Initializing service context registry...'); // Registry creates local context internally contextRegistry = new ServiceContextRegistry(); // Initialize IPC with registry initializeIpcHandlers(contextRegistry, sshConnectionManager); logger.info('Service context registry initialized'); } ``` ### IPC Handler with Context Routing ```typescript // Source: Pattern for src/main/ipc/sessions.ts modification let registry: ServiceContextRegistry; export function initializeSessionHandlers(reg: ServiceContextRegistry): void { registry = reg; } export function registerSessionHandlers(ipcMain: IpcMain): void { ipcMain.handle('get-session-detail', async (event, projectId, sessionId, contextId?) => { try { const context = contextId ? registry.get(contextId) : registry.getActive(); if (!context) { return null; } const sessionPath = context.projectScanner.getSessionPath(projectId, sessionId); const messages = await parseJsonlFile( sessionPath, context.projectScanner.getFileSystemProvider() ); // ... rest of handler using context services } catch (error) { logger.error('Error getting session detail:', error); return null; } }); } ``` ### Context Creation Flow ```typescript // Source: Integration of SshConnectionManager with ServiceContextRegistry async function handleSshConnect(config: SshConnectionConfig): Promise { // 1. Connect SSH (creates provider) await sshConnectionManager.connect(config); const provider = sshConnectionManager.getProvider(); const projectsPath = sshConnectionManager.getRemoteProjectsPath(); // 2. Create context with SSH provider const contextId = `ssh-${config.host}`; const context = contextRegistry.createSshContext( contextId, provider, projectsPath ); // 3. Switch to new context contextRegistry.switch(contextId); // 4. Notify renderer if (mainWindow) { mainWindow.webContents.send('context-changed', contextId); } return contextId; } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Global service instances in main/index.ts | ServiceContext bundles in registry | Phase 2 (this phase) | Enables multiple contexts, proper isolation | | Mode switching destroys/recreates all services | Local context always alive, SSH contexts independent | Phase 2 | Local data never lost, faster switching | | Single FileSystemProvider swapped on mode change | Provider per context, passed to services at creation | Phase 1 (completed) | Foundation for multi-context | | IPC handlers hold direct service refs | IPC handlers route via contextId to registry | Phase 2 | Enables context-aware routing | | FileWatcher events global | FileWatcher scoped to active context | Phase 2 | Prevents cross-context pollution | **Deprecated/outdated:** - **handleModeSwitch callback in main/index.ts:** Will be replaced by registry.switch() method - **Global projectScanner/sessionParser variables:** Will be replaced by registry.getActive().projectScanner - **reinitializeServiceHandlers() called manually:** Will be called automatically by registry.switch() ## Open Questions 1. **Should inactive SSH contexts keep FileWatcher running?** - What we know: FileWatcher can run in background (SSH polling mode exists) - What's unclear: Performance impact of N watchers polling simultaneously - Recommendation: Start with "only active context watches" approach (simpler), measure performance, add background watching if users request it 2. **How to handle context switching during active IPC request?** - What we know: Node.js event loop serializes IPC handlers - What's unclear: Can registry.switch() be called mid-request? - Recommendation: Make switch() synchronous and immediate — in-flight requests complete with old context, next request uses new context. Document this behavior. 3. **Should DataCache be shared across contexts or isolated?** - What we know: Each SessionDetail is context-specific (local vs SSH paths differ) - What's unclear: Could shared cache with composite keys work? - Recommendation: Isolate caches — simpler, avoids key collision risks, allows per-context TTL tuning 4. **How to persist context metadata across app restarts?** - What we know: Need to restore SSH contexts on app restart - What's unclear: Where to persist (ConfigManager? Separate state file?) - Recommendation: Add sshContexts array to ConfigManager schema, store connection profiles + last active context ID ## Sources ### Primary (HIGH confidence) - Codebase analysis: src/main/index.ts, src/main/services/infrastructure/FileWatcher.ts, src/main/ipc/handlers.ts - FileSystemProvider abstraction (Phase 1): src/main/services/infrastructure/FileSystemProvider.ts - Existing EventEmitter usage: FileWatcher, NotificationManager, SshConnectionManager ### Secondary (MEDIUM confidence) - [Process Model | Electron](https://www.electronjs.org/docs/latest/tutorial/process-model) — Electron main process lifecycle - [Inter-Process Communication | Electron](https://www.electronjs.org/docs/latest/tutorial/ipc) — IPC patterns and channel management - [How to fix possible EventEmitter memory leak detected](https://cri.dev/posts/2020-07-16-How-to-fix-possible-EventEmitter-memory-leak-detected/) — EventEmitter cleanup patterns - [Dependency injection in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) — Service lifetime and disposal patterns - [Scaling 1M lines of TypeScript: Registries](https://puzzles.slash.com/blog/scaling-1m-lines-of-typescript-registries) — Registry pattern for large codebases ### Tertiary (LOW confidence) - [Advanced Electron.js architecture - LogRocket Blog](https://blog.logrocket.com/advanced-electron-js-architecture/) — General Electron architecture patterns - [How to Profile Node.js Applications for Memory Leaks](https://oneuptime.com/blog/post/2026-01-26-nodejs-memory-leak-profiling/view) — Memory leak detection techniques ## Metadata **Confidence breakdown:** - Standard stack: HIGH - All components already in codebase (EventEmitter, Map, IPC) - Architecture: HIGH - Patterns verified against existing services and IPC handlers - Pitfalls: HIGH - Identified from EventEmitter research + codebase analysis of FileWatcher/SshConnectionManager **Research date:** 2026-02-12 **Valid until:** 60 days (2026-04-12) — stable domain, established patterns