| 176 | * 3. Enforcing backoff after failures |
| 177 | */ |
| 178 | export class SSHConnectionPool { |
| 179 | private health = new Map<string, ConnectionHealth>(); |
| 180 | private readyControlPaths = new Map<string, Set<string>>(); |
| 181 | private inflight = new Map<string, Promise<void>>(); |
| 182 | |
| 183 | /** |
| 184 | * Ensure connection is healthy before proceeding. |
| 185 | * |
| 186 | * By default, acquireConnection waits through backoff (bounded) so user-facing |
| 187 | * actions don’t immediately fail during transient SSH outages. |
| 188 | * |
| 189 | * Callers can opt into fail-fast behavior by passing `{ maxWaitMs: 0 }`. |
| 190 | */ |
| 191 | async acquireConnection(config: SSHConnectionConfig, timeoutMs?: number): Promise<void>; |
| 192 | async acquireConnection( |
| 193 | config: SSHConnectionConfig, |
| 194 | options?: AcquireConnectionOptions |
| 195 | ): Promise<void>; |
| 196 | async acquireConnection( |
| 197 | config: SSHConnectionConfig, |
| 198 | timeoutMsOrOptions: number | AcquireConnectionOptions = DEFAULT_PROBE_TIMEOUT_MS |
| 199 | ): Promise<void> { |
| 200 | const options: AcquireConnectionOptions = |
| 201 | typeof timeoutMsOrOptions === "number" |
| 202 | ? { timeoutMs: timeoutMsOrOptions } |
| 203 | : (timeoutMsOrOptions ?? {}); |
| 204 | |
| 205 | const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; |
| 206 | const sleep = options.sleep ?? sleepWithAbort; |
| 207 | |
| 208 | const maxWaitMs = options.maxWaitMs ?? DEFAULT_SSH_MAX_WAIT_MS; |
| 209 | const shouldWait = maxWaitMs > 0; |
| 210 | |
| 211 | const key = makeConnectionKey(config); |
| 212 | const requestedControlPath = options.controlPath ?? getControlPath(config); |
| 213 | const startTime = Date.now(); |
| 214 | const getRemainingWaitBudgetMs = (): number => |
| 215 | Math.max(0, maxWaitMs - (Date.now() - startTime)); |
| 216 | const createWaitBudgetExceededError = (lastError?: string): Error => |
| 217 | new Error( |
| 218 | `SSH connection to ${config.host} did not become healthy within ${maxWaitMs}ms. ` + |
| 219 | `Last error: ${lastError ?? "unknown"}` |
| 220 | ); |
| 221 | |
| 222 | while (true) { |
| 223 | if (options.abortSignal?.aborted) { |
| 224 | throw new Error(SSH_OPERATION_ABORTED_ERROR); |
| 225 | } |
| 226 | |
| 227 | const health = this.health.get(key); |
| 228 | |
| 229 | // If in backoff: either fail fast or wait (bounded). |
| 230 | if (health?.backoffUntil && health.backoffUntil > new Date()) { |
| 231 | const remainingMs = health.backoffUntil.getTime() - Date.now(); |
| 232 | const remainingSecs = Math.ceil(remainingMs / 1000); |
| 233 | |
| 234 | if (!shouldWait) { |
| 235 | throw new Error( |
nothing calls this directly
no outgoing calls
no test coverage detected