| 264 | } |
| 265 | |
| 266 | export class SSH2ConnectionPool { |
| 267 | private health = new Map<string, ConnectionHealth>(); |
| 268 | private inflight = new Map<string, Promise<SSH2ConnectionEntry>>(); |
| 269 | private connections = new Map<string, SSH2ConnectionEntry>(); |
| 270 | |
| 271 | async acquireConnection( |
| 272 | config: SSHConnectionConfig, |
| 273 | options: AcquireConnectionOptions = {} |
| 274 | ): Promise<SSH2ConnectionEntry> { |
| 275 | const key = makeConnectionKey(config); |
| 276 | const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; |
| 277 | const sleep = options.sleep ?? sleepWithAbort; |
| 278 | const maxWaitMs = options.maxWaitMs ?? DEFAULT_SSH_MAX_WAIT_MS; |
| 279 | const shouldWait = maxWaitMs > 0; |
| 280 | const startTime = Date.now(); |
| 281 | |
| 282 | while (true) { |
| 283 | if (options.abortSignal?.aborted) { |
| 284 | throw new Error(SSH2_OPERATION_ABORTED_ERROR); |
| 285 | } |
| 286 | |
| 287 | const existing = this.connections.get(key); |
| 288 | if (existing) { |
| 289 | this.touchConnection(existing, key); |
| 290 | this.markHealthy(config); |
| 291 | return existing; |
| 292 | } |
| 293 | |
| 294 | const health = this.health.get(key); |
| 295 | if (health?.backoffUntil && health.backoffUntil > new Date()) { |
| 296 | const remainingMs = health.backoffUntil.getTime() - Date.now(); |
| 297 | const remainingSecs = Math.ceil(remainingMs / 1000); |
| 298 | |
| 299 | if (!shouldWait) { |
| 300 | throw new Error( |
| 301 | `SSH connection to ${config.host} is in backoff for ${remainingSecs}s. ` + |
| 302 | `Last error: ${health.lastError ?? "unknown"}` |
| 303 | ); |
| 304 | } |
| 305 | |
| 306 | const elapsedMs = Date.now() - startTime; |
| 307 | const budgetMs = Math.max(0, maxWaitMs - elapsedMs); |
| 308 | if (budgetMs <= 0) { |
| 309 | throw new Error( |
| 310 | `SSH connection to ${config.host} is in backoff and maxWaitMs exceeded. ` + |
| 311 | `Last error: ${health.lastError ?? "unknown"}` |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | const waitMs = Math.min(remainingMs, budgetMs); |
| 316 | options.onWait?.(waitMs); |
| 317 | await sleep(waitMs, options.abortSignal); |
| 318 | continue; |
| 319 | } |
| 320 | |
| 321 | let inflight = this.inflight.get(key); |
| 322 | if (!inflight) { |
| 323 | inflight = this.connect(config, timeoutMs, options.abortSignal); |
nothing calls this directly
no outgoing calls
no test coverage detected