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));
}
}