Skip to main content
Glama
ooples

MCP Console Automation Server

SessionReconnection.ts20.8 kB
import { EventEmitter } from 'events'; import { Logger } from '../utils/logger.js'; import { ConsoleSession, SessionState, SSHConnectionOptions, } from '../types/index.js'; import { PersistentSessionStorage, PersistentSessionData, } from './PersistentSessionStorage.js'; export interface ReconnectionConfig { enabled: boolean; maxRetries: number; baseDelay: number; maxDelay: number; backoffMultiplier: number; healthCheckInterval: number; connectionTimeout: number; keepAliveInterval: number; autoReconnect: boolean; preserveState: boolean; } export interface ReconnectionAttempt { sessionId: string; attemptNumber: number; startTime: Date; endTime?: Date; success: boolean; error?: string; strategy: ReconnectionStrategy; duration: number; metadata?: Record<string, any>; } export type ReconnectionStrategy = | 'simple-reconnect' | 'session-restore' | 'process-restart' | 'ssh-reconnect' | 'state-recovery' | 'force-reconnect'; export interface ReconnectionState { sessionId: string; isReconnecting: boolean; attempts: ReconnectionAttempt[]; lastSuccessfulConnection: Date | null; lastFailure: Date | null; nextRetryTime: Date | null; strategy: ReconnectionStrategy; preservedState?: { workingDirectory: string; environment: Record<string, string>; lastCommand?: string; }; } /** * Automatic Session Reconnection System * Handles network disconnections and provides seamless session restoration */ export class SessionReconnection extends EventEmitter { private config: ReconnectionConfig; private logger: Logger; private storage: PersistentSessionStorage; private reconnectionStates: Map<string, ReconnectionState> = new Map(); private healthCheckTimer: NodeJS.Timeout | null = null; private activeReconnections: Set<string> = new Set(); // Statistics private stats = { totalAttempts: 0, successfulReconnections: 0, failedReconnections: 0, averageReconnectionTime: 0, networkDisconnections: 0, processRestarts: 0, lastReconnection: null as Date | null, }; constructor( storage: PersistentSessionStorage, config?: Partial<ReconnectionConfig> ) { super(); this.logger = new Logger('SessionReconnection'); this.storage = storage; this.config = { enabled: config?.enabled ?? true, maxRetries: config?.maxRetries || 5, baseDelay: config?.baseDelay || 2000, maxDelay: config?.maxDelay || 30000, backoffMultiplier: config?.backoffMultiplier || 1.5, healthCheckInterval: config?.healthCheckInterval || 30000, connectionTimeout: config?.connectionTimeout || 15000, keepAliveInterval: config?.keepAliveInterval || 60000, autoReconnect: config?.autoReconnect ?? true, preserveState: config?.preserveState ?? true, }; this.logger.info( 'SessionReconnection initialized with config:', this.config ); } /** * Start the reconnection monitoring system */ async start(): Promise<void> { if (!this.config.enabled) { this.logger.info('Session reconnection is disabled'); return; } this.logger.info('Starting session reconnection monitoring...'); // Start health check monitoring this.startHealthCheck(); // Load existing reconnection states await this.loadReconnectionStates(); this.emit('started'); this.logger.info('Session reconnection monitoring started'); } /** * Stop the reconnection system */ async stop(): Promise<void> { this.logger.info('Stopping session reconnection...'); if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; } // Wait for active reconnections to complete if (this.activeReconnections.size > 0) { this.logger.info( `Waiting for ${this.activeReconnections.size} active reconnections to complete...` ); const timeout = setTimeout(() => { this.logger.warn('Timeout waiting for active reconnections'); }, 30000); while (this.activeReconnections.size > 0) { await this.delay(1000); } clearTimeout(timeout); } this.emit('stopped'); this.logger.info('Session reconnection stopped'); } /** * Register a session for reconnection monitoring */ async registerSession( sessionId: string, session: ConsoleSession ): Promise<void> { if (!this.config.enabled) { return; } this.logger.info( `Registering session ${sessionId} for reconnection monitoring` ); const reconnectionState: ReconnectionState = { sessionId, isReconnecting: false, attempts: [], lastSuccessfulConnection: new Date(), lastFailure: null, nextRetryTime: null, strategy: 'simple-reconnect', preservedState: this.config.preserveState ? { workingDirectory: session.cwd, environment: session.env || {}, lastCommand: session.command, } : undefined, }; this.reconnectionStates.set(sessionId, reconnectionState); this.emit('session-registered', { sessionId, state: reconnectionState }); } /** * Unregister a session from reconnection monitoring */ async unregisterSession(sessionId: string): Promise<void> { this.logger.info( `Unregistering session ${sessionId} from reconnection monitoring` ); const state = this.reconnectionStates.get(sessionId); if (state?.isReconnecting) { this.logger.warn( `Session ${sessionId} is currently reconnecting - marking for cleanup` ); } this.reconnectionStates.delete(sessionId); this.activeReconnections.delete(sessionId); this.emit('session-unregistered', { sessionId }); } /** * Handle session disconnection */ async handleDisconnection( sessionId: string, reason: string, error?: Error ): Promise<void> { if (!this.config.enabled || !this.config.autoReconnect) { return; } const state = this.reconnectionStates.get(sessionId); if (!state) { this.logger.warn(`No reconnection state found for session ${sessionId}`); return; } if (state.isReconnecting) { this.logger.debug(`Session ${sessionId} is already reconnecting`); return; } this.logger.warn(`Session ${sessionId} disconnected: ${reason}`); state.lastFailure = new Date(); this.stats.networkDisconnections++; // Determine reconnection strategy based on disconnect reason const strategy = this.determineReconnectionStrategy(reason, error); state.strategy = strategy; this.emit('session-disconnected', { sessionId, reason, error: error?.message, strategy, }); // Start reconnection process await this.attemptReconnection(sessionId); } /** * Manually trigger reconnection for a session */ async forceReconnection( sessionId: string, strategy?: ReconnectionStrategy ): Promise<boolean> { const state = this.reconnectionStates.get(sessionId); if (!state) { throw new Error(`No reconnection state found for session ${sessionId}`); } if (strategy) { state.strategy = strategy; } this.logger.info( `Forcing reconnection for session ${sessionId} using strategy: ${state.strategy}` ); return await this.attemptReconnection(sessionId); } /** * Get reconnection state for a session */ getReconnectionState(sessionId: string): ReconnectionState | undefined { return this.reconnectionStates.get(sessionId); } /** * Get reconnection statistics */ getReconnectionStats() { return { ...this.stats, activeReconnections: this.activeReconnections.size, registeredSessions: this.reconnectionStates.size, successRate: this.stats.totalAttempts > 0 ? (this.stats.successfulReconnections / this.stats.totalAttempts) * 100 : 0, }; } /** * Get all reconnection states */ getAllReconnectionStates(): Record<string, ReconnectionState> { const result: Record<string, ReconnectionState> = {}; for (const [sessionId, state] of this.reconnectionStates) { result[sessionId] = { ...state }; } return result; } // Private methods private async attemptReconnection(sessionId: string): Promise<boolean> { const state = this.reconnectionStates.get(sessionId); if (!state || state.isReconnecting) { return false; } // Check if max retries exceeded if (state.attempts.length >= this.config.maxRetries) { this.logger.warn( `Max reconnection attempts (${this.config.maxRetries}) exceeded for session ${sessionId}` ); this.emit('reconnection-max-attempts', { sessionId, attempts: state.attempts.length, }); return false; } state.isReconnecting = true; this.activeReconnections.add(sessionId); const attemptNumber = state.attempts.length + 1; this.logger.info( `Starting reconnection attempt ${attemptNumber}/${this.config.maxRetries} for session ${sessionId}` ); const attempt: ReconnectionAttempt = { sessionId, attemptNumber, startTime: new Date(), success: false, strategy: state.strategy, duration: 0, }; try { this.emit('reconnection-started', { sessionId, attemptNumber, strategy: state.strategy, }); // Execute reconnection strategy const success = await this.executeReconnectionStrategy( sessionId, state.strategy ); attempt.success = success; attempt.endTime = new Date(); attempt.duration = attempt.endTime.getTime() - attempt.startTime.getTime(); if (success) { state.lastSuccessfulConnection = new Date(); state.nextRetryTime = null; this.stats.successfulReconnections++; this.stats.lastReconnection = new Date(); this.updateAverageReconnectionTime(attempt.duration); this.logger.info( `Successfully reconnected session ${sessionId} on attempt ${attemptNumber}` ); this.emit('reconnection-success', { sessionId, attemptNumber, duration: attempt.duration, strategy: state.strategy, }); } else { this.stats.failedReconnections++; this.logger.warn( `Failed to reconnect session ${sessionId} on attempt ${attemptNumber}` ); // Schedule next retry with exponential backoff if (attemptNumber < this.config.maxRetries) { const delay = Math.min( this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attemptNumber - 1), this.config.maxDelay ); state.nextRetryTime = new Date(Date.now() + delay); this.logger.info( `Scheduling next reconnection attempt for session ${sessionId} in ${delay}ms` ); setTimeout(() => { this.attemptReconnection(sessionId); }, delay); } } state.attempts.push(attempt); this.stats.totalAttempts++; return success; } catch (error) { attempt.success = false; attempt.error = error instanceof Error ? error.message : String(error); attempt.endTime = new Date(); attempt.duration = attempt.endTime.getTime() - attempt.startTime.getTime(); state.attempts.push(attempt); this.stats.totalAttempts++; this.stats.failedReconnections++; this.logger.error( `Reconnection attempt ${attemptNumber} failed for session ${sessionId}:`, error ); this.emit('reconnection-error', { sessionId, attemptNumber, error: error instanceof Error ? error.message : String(error), }); return false; } finally { state.isReconnecting = false; this.activeReconnections.delete(sessionId); } } private async executeReconnectionStrategy( sessionId: string, strategy: ReconnectionStrategy ): Promise<boolean> { switch (strategy) { case 'simple-reconnect': return await this.executeSimpleReconnect(sessionId); case 'session-restore': return await this.executeSessionRestore(sessionId); case 'process-restart': return await this.executeProcessRestart(sessionId); case 'ssh-reconnect': return await this.executeSSHReconnect(sessionId); case 'state-recovery': return await this.executeStateRecovery(sessionId); case 'force-reconnect': return await this.executeForceReconnect(sessionId); default: this.logger.warn(`Unknown reconnection strategy: ${strategy}`); return false; } } private async executeSimpleReconnect(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'simple-reconnect', message: 'Attempting simple reconnection', }); // Request simple reconnection from session manager this.emit('reconnection-request', { sessionId, type: 'simple-reconnect', }); return true; // Assume success - actual verification would come from session manager } catch (error) { this.logger.error( `Simple reconnect failed for session ${sessionId}:`, error ); return false; } } private async executeSessionRestore(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'session-restore', message: 'Restoring session from persistent storage', }); // Get persistent session data const sessionData = await this.storage.retrieveSession(sessionId); if (!sessionData) { this.logger.warn(`No persistent data found for session ${sessionId}`); return false; } // Request session restoration this.emit('session-restore-request', { sessionId, sessionData, preserveState: this.config.preserveState, }); return true; } catch (error) { this.logger.error( `Session restore failed for session ${sessionId}:`, error ); return false; } } private async executeProcessRestart(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'process-restart', message: 'Restarting process with preserved state', }); const state = this.reconnectionStates.get(sessionId); const sessionData = await this.storage.retrieveSession(sessionId); // Request process restart this.emit('process-restart-request', { sessionId, sessionData, preservedState: state?.preservedState, }); this.stats.processRestarts++; return true; } catch (error) { this.logger.error( `Process restart failed for session ${sessionId}:`, error ); return false; } } private async executeSSHReconnect(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'ssh-reconnect', message: 'Reconnecting SSH session', }); const sessionData = await this.storage.retrieveSession(sessionId); if (!sessionData?.originalSession.sshOptions) { return false; // Not an SSH session } // Request SSH reconnection this.emit('ssh-reconnect-request', { sessionId, sshOptions: sessionData.originalSession.sshOptions, preservedState: this.reconnectionStates.get(sessionId)?.preservedState, }); return true; } catch (error) { this.logger.error( `SSH reconnect failed for session ${sessionId}:`, error ); return false; } } private async executeStateRecovery(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'state-recovery', message: 'Recovering session state and context', }); const sessionData = await this.storage.retrieveSession(sessionId); const state = this.reconnectionStates.get(sessionId); if (!sessionData) { return false; } // Request comprehensive state recovery this.emit('state-recovery-request', { sessionId, sessionData, preservedState: state?.preservedState, outputHistory: this.storage.getSessionHistory(sessionId), }); return true; } catch (error) { this.logger.error( `State recovery failed for session ${sessionId}:`, error ); return false; } } private async executeForceReconnect(sessionId: string): Promise<boolean> { try { this.emit('reconnection-strategy-attempt', { sessionId, strategy: 'force-reconnect', message: 'Forcing reconnection with aggressive recovery', }); // Force reconnection by trying multiple strategies const strategies: ReconnectionStrategy[] = [ 'simple-reconnect', 'session-restore', 'process-restart', ]; for (const strategy of strategies) { const success = await this.executeReconnectionStrategy( sessionId, strategy ); if (success) { return true; } // Wait between attempts await this.delay(1000); } return false; } catch (error) { this.logger.error( `Force reconnect failed for session ${sessionId}:`, error ); return false; } } private determineReconnectionStrategy( reason: string, error?: Error ): ReconnectionStrategy { const reasonLower = reason.toLowerCase(); const errorMessage = error?.message?.toLowerCase() || ''; // Network-related disconnections if ( reasonLower.includes('network') || reasonLower.includes('connection') || errorMessage.includes('econnreset') || errorMessage.includes('enotfound') ) { return 'simple-reconnect'; } // SSH-specific issues if (reasonLower.includes('ssh') || errorMessage.includes('ssh')) { return 'ssh-reconnect'; } // Process-related issues if ( reasonLower.includes('process') || reasonLower.includes('killed') || errorMessage.includes('spawn') || errorMessage.includes('exit') ) { return 'process-restart'; } // Timeout issues if (reasonLower.includes('timeout') || errorMessage.includes('timeout')) { return 'session-restore'; } // State corruption if (reasonLower.includes('state') || reasonLower.includes('corrupt')) { return 'state-recovery'; } // Default strategy return 'simple-reconnect'; } private startHealthCheck(): void { if (this.config.healthCheckInterval <= 0) { return; } this.healthCheckTimer = setInterval(async () => { await this.performHealthCheck(); }, this.config.healthCheckInterval); } private async performHealthCheck(): Promise<void> { const now = Date.now(); for (const [sessionId, state] of this.reconnectionStates.entries()) { // Check for pending retries if ( state.nextRetryTime && now >= state.nextRetryTime.getTime() && !state.isReconnecting ) { this.logger.debug( `Triggering scheduled reconnection for session ${sessionId}` ); await this.attemptReconnection(sessionId); } // Request health check from external systems this.emit('health-check-request', { sessionId, state }); } } private async loadReconnectionStates(): Promise<void> { // Load any persistent reconnection states if needed // This could be implemented to restore reconnection states across process restarts this.logger.debug('Loading reconnection states...'); } private updateAverageReconnectionTime(duration: number): void { if (this.stats.successfulReconnections === 1) { this.stats.averageReconnectionTime = duration; } else { this.stats.averageReconnectionTime = (this.stats.averageReconnectionTime * (this.stats.successfulReconnections - 1) + duration) / this.stats.successfulReconnections; } } private delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ooples/mcp-console-automation'

If you have feedback or need assistance with the MCP directory API, please join our Discord server