export interface ReconnectConfig { maxAttempts: number; baseDelay: number; maxDelay: number; } export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { maxAttempts: 10, baseDelay: 1000, maxDelay: 30000, }; export function nextLifecycleEpoch(currentEpoch: number): number { return currentEpoch + 1; } export function isLifecycleSuperseded(expectedEpoch: number, currentEpoch: number): boolean { return expectedEpoch !== currentEpoch; } export interface ReconnectAttemptContext { scheduledEpoch: number; currentEpoch: number; shouldReconnect: boolean; } export function getReconnectSkipReason(context: ReconnectAttemptContext): string | null { if (!context.shouldReconnect) { return 'auto-reconnect disabled'; } if (isLifecycleSuperseded(context.scheduledEpoch, context.currentEpoch)) { return `stale reconnect callback (scheduledEpoch=${context.scheduledEpoch}, currentEpoch=${context.currentEpoch})`; } return null; } export interface ReconnectScheduleContext { shouldReconnect: boolean; hasReconnectTimer: boolean; reconnectAttempts: number; maxAttempts: number; baseDelay: number; maxDelay: number; } export type ReconnectScheduleDecision = | { action: 'skip'; reason: string } | { action: 'already-scheduled' } | { action: 'fail'; attempts: number; maxAttempts: number } | { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number }; export function getReconnectScheduleDecision( context: ReconnectScheduleContext, ): ReconnectScheduleDecision { if (!context.shouldReconnect) { return { action: 'skip', reason: 'auto-reconnect disabled' }; } if (context.hasReconnectTimer) { return { action: 'already-scheduled' }; } if (context.reconnectAttempts >= context.maxAttempts) { return { action: 'fail', attempts: context.reconnectAttempts, maxAttempts: context.maxAttempts, }; } const delay = Math.min( context.baseDelay * Math.pow(2, context.reconnectAttempts), context.maxDelay, ); return { action: 'schedule', nextAttempt: context.reconnectAttempts + 1, maxAttempts: context.maxAttempts, delay, }; } export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; export interface RestartDeferralContext { state: GatewayLifecycleState; startLock: boolean; } /** * Restart requests should not interrupt an in-flight startup/reconnect flow. * Doing so can kill a just-spawned process and leave the manager stopped. */ export function shouldDeferRestart(context: RestartDeferralContext): boolean { return context.startLock || context.state === 'starting' || context.state === 'reconnecting'; } export interface DeferredRestartActionContext extends RestartDeferralContext { hasPendingRestart: boolean; shouldReconnect: boolean; } export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute'; /** * Decide what to do with a pending deferred restart once lifecycle changes. * * A deferred restart is an explicit restart() call that was postponed because * the manager was mid-startup/reconnect. When the in-flight operation settles * we must honour the request — even if the gateway is now running — because * the caller may have changed config (e.g. provider switch) that the current * process hasn't picked up. */ export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction { if (!context.hasPendingRestart) return 'none'; if (shouldDeferRestart(context)) return 'wait'; if (!context.shouldReconnect) return 'drop'; return 'execute'; }