@ebowwa/terminal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pool.ts ADDED
@@ -0,0 +1,662 @@
1
+ /**
2
+ * SSH Connection Pool Manager
3
+ * Maintains persistent SSH connections for reuse across commands
4
+ */
5
+
6
+ import { NodeSSH } from 'node-ssh'
7
+ import path from 'node:path'
8
+ import type { SSHOptions } from './types.js'
9
+
10
+ interface PooledConnection {
11
+ ssh: NodeSSH
12
+ lastUsed: number
13
+ host: string
14
+ port: number
15
+ user: string
16
+ id: string // Unique identifier for this connection instance
17
+ }
18
+
19
+ /**
20
+ * Connection pool configuration
21
+ */
22
+ interface PoolConfig {
23
+ /** Maximum number of connections to keep alive across all hosts */
24
+ maxConnections: number
25
+ /** Maximum number of connections per host (for parallel execution) */
26
+ maxConnectionsPerHost: number
27
+ /** Idle timeout in milliseconds (default: 5 minutes) */
28
+ idleTimeout: number
29
+ /** Connection timeout in milliseconds (default: 10 seconds) */
30
+ connectionTimeout: number
31
+ /** Keep alive interval in milliseconds (default: 30 seconds) */
32
+ keepAliveInterval: number
33
+ }
34
+
35
+ const DEFAULT_CONFIG: PoolConfig = {
36
+ maxConnections: 50,
37
+ maxConnectionsPerHost: 5, // Allow up to 5 connections per host for parallel ops
38
+ idleTimeout: 5 * 60 * 1000, // 5 minutes
39
+ connectionTimeout: 10 * 1000, // 10 seconds
40
+ keepAliveInterval: 30 * 1000, // 30 seconds
41
+ }
42
+
43
+ /**
44
+ * SSH Connection Pool Class
45
+ */
46
+ export class SSHConnectionPool {
47
+ private connections = new Map<string, PooledConnection[]>()
48
+ private config: PoolConfig
49
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null
50
+ private nextId = 0 // Counter for generating unique connection IDs
51
+
52
+ constructor(config: Partial<PoolConfig> = {}) {
53
+ this.config = { ...DEFAULT_CONFIG, ...config }
54
+ this.startCleanup()
55
+ }
56
+
57
+ /**
58
+ * Generate a unique key for the connection (host-based)
59
+ */
60
+ private getKey(host: string, port: number, user: string): string {
61
+ return `${user}@${host}:${port}`
62
+ }
63
+
64
+ /**
65
+ * Get all connections for a given host
66
+ */
67
+ private getConnectionsList(key: string): PooledConnection[] {
68
+ let list = this.connections.get(key)
69
+ if (!list) {
70
+ list = []
71
+ this.connections.set(key, list)
72
+ }
73
+ return list
74
+ }
75
+
76
+ /**
77
+ * Get or create a connection (returns least recently used connection)
78
+ */
79
+ async getConnection(options: SSHOptions): Promise<NodeSSH> {
80
+ const connections = await this.getConnections(options, 1)
81
+ return connections[0]
82
+ }
83
+
84
+ /**
85
+ * Get or create a connection using password authentication
86
+ */
87
+ async getConnectionWithPassword(
88
+ host: string,
89
+ user: string,
90
+ password: string,
91
+ port: number = 22,
92
+ ): Promise<NodeSSH> {
93
+ return this.getConnection({ host, user, password, port })
94
+ }
95
+
96
+ /**
97
+ * Get or create multiple connections for parallel execution
98
+ * @param options - SSH connection options
99
+ * @param count - Number of connections to retrieve
100
+ * @returns Array of SSH connections
101
+ */
102
+ async getConnections(options: SSHOptions, count: number): Promise<NodeSSH[]> {
103
+ const { host, user = 'root', port = 22, keyPath, password } = options
104
+ const key = this.getKey(host, port, user)
105
+ const list = this.getConnectionsList(key)
106
+ const now = Date.now()
107
+ const result: NodeSSH[] = []
108
+
109
+ // Try to reuse existing connections that are alive and recently used
110
+ for (let i = list.length - 1; i >= 0 && result.length < count; i--) {
111
+ const existing = list[i]
112
+
113
+ // If used recently (within 30 seconds), reuse without verification
114
+ if (now - existing.lastUsed < 30000) {
115
+ existing.lastUsed = now
116
+ result.push(existing.ssh)
117
+ list.splice(i, 1)
118
+ continue
119
+ }
120
+
121
+ // Verify connection is still alive (for idle connections)
122
+ try {
123
+ await existing.ssh.exec('echo', ['ok'])
124
+ existing.lastUsed = now
125
+ result.push(existing.ssh)
126
+ list.splice(i, 1)
127
+ } catch {
128
+ // Connection is dead, remove and dispose
129
+ list.splice(i, 1)
130
+ try {
131
+ await existing.ssh.dispose()
132
+ } catch {
133
+ // Ignore dispose errors
134
+ }
135
+ }
136
+ }
137
+
138
+ // Put back the connections we're reusing at the end (most recently used)
139
+ for (const conn of result) {
140
+ const pooled = list.find(c => c.ssh === conn)
141
+ if (pooled) {
142
+ // Move to end of list (most recently used)
143
+ list.splice(list.indexOf(pooled), 1)
144
+ list.push(pooled)
145
+ }
146
+ }
147
+
148
+ // Create new connections if needed
149
+ while (result.length < count) {
150
+ // Check pool size limit
151
+ if (this.getTotalConnectionCount() >= this.config.maxConnections) {
152
+ await this.evictOldest()
153
+ }
154
+
155
+ // Check per-host limit
156
+ const currentHostCount = list.length + result.length
157
+ if (currentHostCount >= this.config.maxConnectionsPerHost) {
158
+ // Can't create more connections for this host
159
+ break
160
+ }
161
+
162
+ // Create new connection
163
+ const ssh = await this.createConnection(host, port, user, keyPath, password)
164
+
165
+ // Add to result and pool
166
+ result.push(ssh)
167
+ list.push({
168
+ ssh,
169
+ lastUsed: now,
170
+ host,
171
+ port,
172
+ user,
173
+ id: `${key}-${this.nextId++}`,
174
+ })
175
+ }
176
+
177
+ return result
178
+ }
179
+
180
+ /**
181
+ * Create a new SSH connection
182
+ * Tries key-based auth first, then password auth, then SSH agent
183
+ */
184
+ private async createConnection(
185
+ host: string,
186
+ port: number,
187
+ user: string,
188
+ keyPath?: string,
189
+ password?: string,
190
+ ): Promise<NodeSSH> {
191
+ const ssh = new NodeSSH()
192
+
193
+ const baseConfig: any = {
194
+ host,
195
+ port,
196
+ username: user,
197
+ readyTimeout: this.config.connectionTimeout,
198
+ keepaliveInterval: this.config.keepAliveInterval,
199
+ }
200
+
201
+ // Try authentication methods in order: key, password, agent
202
+ const authMethods: Array<{ name: string; config: any }> = []
203
+
204
+ // 1. Key-based authentication (if keyPath provided)
205
+ if (keyPath) {
206
+ // Resolve relative paths to absolute to ensure key is found from any working directory
207
+ let resolvedKeyPath = keyPath
208
+ if (!path.isAbsolute(keyPath)) {
209
+ // Legacy relative paths like "../.ssh-keys/..." - resolve from project root
210
+ const projectRoot = path.resolve(import.meta.dir, '../../com.hetzner.codespaces')
211
+ if (keyPath.includes('.ssh-keys')) {
212
+ // Extract just the filename from the path and look in project .ssh-keys
213
+ const keyName = path.basename(keyPath)
214
+ resolvedKeyPath = path.join(projectRoot, '.ssh-keys', keyName)
215
+ } else {
216
+ resolvedKeyPath = path.resolve(keyPath)
217
+ }
218
+ }
219
+ authMethods.push({
220
+ name: 'key',
221
+ config: { ...baseConfig, privateKeyPath: resolvedKeyPath },
222
+ })
223
+ }
224
+
225
+ // 2. Password authentication (if password provided)
226
+ if (password) {
227
+ authMethods.push({
228
+ name: 'password',
229
+ config: { ...baseConfig, password },
230
+ })
231
+ }
232
+
233
+ // 3. SSH agent (fallback if available)
234
+ if (process.env.SSH_AUTH_SOCK) {
235
+ authMethods.push({
236
+ name: 'agent',
237
+ config: { ...baseConfig, agent: process.env.SSH_AUTH_SOCK },
238
+ })
239
+ }
240
+
241
+ // If no auth methods available, try with empty config (will fail)
242
+ if (authMethods.length === 0) {
243
+ authMethods.push({
244
+ name: 'default',
245
+ config: baseConfig,
246
+ })
247
+ }
248
+
249
+ // Try each authentication method
250
+ const errors: string[] = []
251
+ for (const method of authMethods) {
252
+ try {
253
+ await ssh.connect(method.config)
254
+
255
+ // Attach error handler to underlying ssh2 client to catch keepalive and other errors
256
+ // The NodeSSH instance exposes the underlying ssh2 Client via the 'ssh' property
257
+ if ((ssh as any).ssh) {
258
+ const underlyingClient = (ssh as any).ssh;
259
+ const connectionKey = this.getKey(host, port, user);
260
+
261
+ underlyingClient.on('error', (err: Error) => {
262
+ // Handle keepalive timeouts gracefully - these are network issues, not fatal errors
263
+ const isKeepalive = err.message.includes('Keepalive') || (err as any).level === 'client-timeout';
264
+
265
+ if (isKeepalive) {
266
+ console.warn(`[SSH Pool] Keepalive timeout for ${user}@${host}:${port} - removing stale connection`);
267
+ } else {
268
+ console.error(`[SSH Pool] Connection error for ${user}@${host}:${port}:`, err.message);
269
+ }
270
+
271
+ // Remove failed connection from pool
272
+ const list = this.connections.get(connectionKey);
273
+ if (list) {
274
+ const index = list.findIndex(c => c.ssh === ssh);
275
+ if (index !== -1) {
276
+ const removed = list.splice(index, 1)[0];
277
+ // Dispose connection
278
+ try {
279
+ ssh.dispose();
280
+ } catch {
281
+ // Ignore dispose errors
282
+ }
283
+ console.log(`[SSH Pool] Removed errored connection ${removed.id} from pool (will reconnect on next use)`);
284
+ // Clean up empty lists
285
+ if (list.length === 0) {
286
+ this.connections.delete(connectionKey);
287
+ }
288
+ }
289
+ }
290
+ });
291
+
292
+ // Also handle 'close' event for unexpected disconnections
293
+ underlyingClient.on('close', () => {
294
+ const list = this.connections.get(connectionKey);
295
+ if (list) {
296
+ const index = list.findIndex(c => c.ssh === ssh);
297
+ if (index !== -1) {
298
+ const removed = list.splice(index, 1)[0];
299
+ console.log(`[SSH Pool] Connection ${removed.id} closed unexpectedly (will reconnect on next use)`);
300
+ if (list.length === 0) {
301
+ this.connections.delete(connectionKey);
302
+ }
303
+ }
304
+ }
305
+ });
306
+ }
307
+
308
+ return ssh
309
+ } catch (error) {
310
+ const errMsg = error instanceof Error ? error.message : String(error)
311
+ errors.push(`${method.name}: ${errMsg}`)
312
+ // Try next method
313
+ }
314
+ }
315
+
316
+ // All methods failed
317
+ throw new Error(`SSH connection failed to ${host} (tried: ${authMethods.map(m => m.name).join(', ')}): ${errors.join('; ')}`)
318
+ }
319
+
320
+ /**
321
+ * Get total number of connections across all hosts
322
+ */
323
+ private getTotalConnectionCount(): number {
324
+ let count = 0
325
+ for (const list of this.connections.values()) {
326
+ count += list.length
327
+ }
328
+ return count
329
+ }
330
+
331
+ /**
332
+ * Execute a command using a pooled connection
333
+ *
334
+ * ERROR HANDLING BEHAVIOR:
335
+ * =========================
336
+ * If result.stderr exists AND result.stdout is empty, we throw an error.
337
+ * This is intentional - commands that fail should return fallback values
338
+ * via shell redirection (e.g., `|| echo "0"` or `2>/dev/null`).
339
+ *
340
+ * Example of proper fallback handling:
341
+ * `type nvidia-smi 2>/dev/null && nvidia-smi ... || echo NOGPU`
342
+ *
343
+ * This ensures commands don't silently fail - they must handle their own
344
+ * error cases and return sensible defaults.
345
+ */
346
+ async exec(command: string, options: SSHOptions): Promise<string> {
347
+ const ssh = await this.getConnection(options)
348
+
349
+ try {
350
+ const result = await ssh.execCommand(command, {
351
+ execOptions: {
352
+ timeout: (options.timeout || 5) * 1000,
353
+ },
354
+ })
355
+
356
+ // If we have stderr but no stdout, the command failed
357
+ // Commands should handle their own fallbacks (|| echo fallback, 2>/dev/null, etc)
358
+ if (result.stderr && !result.stdout) {
359
+ // Include both stderr and stdout (if any) for better debugging
360
+ const output = result.stdout ? `${result.stderr}\n${result.stdout}` : result.stderr;
361
+ throw new Error(output);
362
+ }
363
+
364
+ return result.stdout.trim()
365
+ } catch (error) {
366
+ // Remove failed connection from pool (find and remove specific connection)
367
+ const { host, user = 'root', port = 22 } = options
368
+ const key = this.getKey(host, port, user)
369
+ const list = this.connections.get(key)
370
+ if (list) {
371
+ const index = list.findIndex(c => c.ssh === ssh)
372
+ if (index !== -1) {
373
+ const removed = list.splice(index, 1)[0]
374
+ try {
375
+ await removed.ssh.dispose()
376
+ } catch {
377
+ // Ignore dispose errors
378
+ }
379
+ }
380
+ // Clean up empty lists
381
+ if (list.length === 0) {
382
+ this.connections.delete(key)
383
+ }
384
+ }
385
+
386
+ throw error
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Check if a connection exists and is alive for a given host
392
+ */
393
+ async hasConnection(options: SSHOptions): Promise<boolean> {
394
+ const { host, user = 'root', port = 22 } = options
395
+ const key = this.getKey(host, port, user)
396
+ const list = this.connections.get(key)
397
+
398
+ if (!list || list.length === 0) {
399
+ return false
400
+ }
401
+
402
+ // Check if any connection is alive
403
+ for (let i = list.length - 1; i >= 0; i--) {
404
+ const conn = list[i]
405
+ try {
406
+ await conn.ssh.exec('echo', ['ok'])
407
+ return true
408
+ } catch {
409
+ // Remove dead connection
410
+ list.splice(i, 1)
411
+ try {
412
+ await conn.ssh.dispose()
413
+ } catch {
414
+ // Ignore dispose errors
415
+ }
416
+ }
417
+ }
418
+
419
+ // Clean up empty list
420
+ if (list.length === 0) {
421
+ this.connections.delete(key)
422
+ }
423
+
424
+ return false
425
+ }
426
+
427
+ /**
428
+ * Close a specific connection by SSH instance
429
+ */
430
+ private async closeConnectionInstance(ssh: NodeSSH): Promise<void> {
431
+ for (const [key, list] of this.connections.entries()) {
432
+ const index = list.findIndex(c => c.ssh === ssh)
433
+ if (index !== -1) {
434
+ const removed = list.splice(index, 1)[0]
435
+ try {
436
+ await removed.ssh.dispose()
437
+ } catch {
438
+ // Ignore dispose errors
439
+ }
440
+ // Clean up empty lists
441
+ if (list.length === 0) {
442
+ this.connections.delete(key)
443
+ }
444
+ return
445
+ }
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Close all connections for a specific host
451
+ */
452
+ async closeConnection(options: SSHOptions): Promise<void> {
453
+ const { host, user = 'root', port = 22 } = options
454
+ const key = this.getKey(host, port, user)
455
+ const list = this.connections.get(key)
456
+
457
+ if (list) {
458
+ this.connections.delete(key)
459
+ for (const conn of list) {
460
+ try {
461
+ await conn.ssh.dispose()
462
+ } catch {
463
+ // Ignore dispose errors
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Evict the oldest connection from the pool
471
+ */
472
+ private async evictOldest(): Promise<void> {
473
+ let oldest: { key: string; conn: PooledConnection; listIndex: number } | null = null
474
+
475
+ for (const [key, list] of this.connections.entries()) {
476
+ for (let i = 0; i < list.length; i++) {
477
+ const conn = list[i]
478
+ if (!oldest || conn.lastUsed < oldest.conn.lastUsed) {
479
+ oldest = { key, conn, listIndex: i }
480
+ }
481
+ }
482
+ }
483
+
484
+ if (oldest) {
485
+ const list = this.connections.get(oldest.key)
486
+ if (list) {
487
+ list.splice(oldest.listIndex, 1)
488
+ // Clean up empty lists
489
+ if (list.length === 0) {
490
+ this.connections.delete(oldest.key)
491
+ }
492
+ }
493
+ try {
494
+ await oldest.conn.ssh.dispose()
495
+ } catch {
496
+ // Ignore dispose errors
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Clean up idle connections
503
+ */
504
+ private async cleanupIdle(): Promise<void> {
505
+ const now = Date.now()
506
+
507
+ for (const [key, list] of this.connections.entries()) {
508
+ const toRemove: number[] = []
509
+
510
+ for (let i = 0; i < list.length; i++) {
511
+ if (now - list[i].lastUsed > this.config.idleTimeout) {
512
+ toRemove.push(i)
513
+ }
514
+ }
515
+
516
+ // Remove from end to preserve indices
517
+ for (const index of toRemove.reverse()) {
518
+ const conn = list.splice(index, 1)[0]
519
+ try {
520
+ await conn.ssh.dispose()
521
+ } catch {
522
+ // Ignore dispose errors
523
+ }
524
+ }
525
+
526
+ // Clean up empty lists
527
+ if (list.length === 0) {
528
+ this.connections.delete(key)
529
+ }
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Start periodic cleanup
535
+ */
536
+ private startCleanup(): void {
537
+ // Run cleanup every minute
538
+ this.cleanupInterval = setInterval(() => {
539
+ this.cleanupIdle().catch(console.error)
540
+ }, 60 * 1000)
541
+ }
542
+
543
+ /**
544
+ * Stop cleanup interval
545
+ */
546
+ private stopCleanup(): void {
547
+ if (this.cleanupInterval) {
548
+ clearInterval(this.cleanupInterval)
549
+ this.cleanupInterval = null
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Close all connections and stop cleanup
555
+ */
556
+ async closeAll(): Promise<void> {
557
+ this.stopCleanup()
558
+
559
+ const closePromises: Promise<void>[] = []
560
+ for (const list of this.connections.values()) {
561
+ for (const conn of list) {
562
+ closePromises.push(
563
+ (async () => {
564
+ try {
565
+ await conn.ssh.dispose()
566
+ } catch {
567
+ // Ignore dispose errors
568
+ }
569
+ })()
570
+ )
571
+ }
572
+ }
573
+
574
+ await Promise.all(closePromises)
575
+ this.connections.clear()
576
+ }
577
+
578
+ /**
579
+ * Get pool statistics
580
+ */
581
+ getStats(): {
582
+ totalConnections: number
583
+ connections: Array<{
584
+ host: string
585
+ port: number
586
+ user: string
587
+ lastUsed: Date
588
+ idleMs: number
589
+ id: string
590
+ }>
591
+ } {
592
+ const now = Date.now()
593
+ const allConnections: Array<{
594
+ host: string
595
+ port: number
596
+ user: string
597
+ lastUsed: Date
598
+ idleMs: number
599
+ id: string
600
+ }> = []
601
+
602
+ for (const list of this.connections.values()) {
603
+ for (const conn of list) {
604
+ allConnections.push({
605
+ host: conn.host,
606
+ port: conn.port,
607
+ user: conn.user,
608
+ lastUsed: new Date(conn.lastUsed),
609
+ idleMs: now - conn.lastUsed,
610
+ id: conn.id,
611
+ })
612
+ }
613
+ }
614
+
615
+ return {
616
+ totalConnections: allConnections.length,
617
+ connections: allConnections,
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Check if a host has an active connection
623
+ */
624
+ isConnected(host: string, user: string = 'root', port: number = 22): boolean {
625
+ const key = this.getKey(host, port, user)
626
+ const list = this.connections.get(key)
627
+ return list !== undefined && list.length > 0
628
+ }
629
+ }
630
+
631
+ // Global singleton instance
632
+ let globalPool: SSHConnectionPool | null = null
633
+
634
+ /**
635
+ * Get the global connection pool instance
636
+ */
637
+ export function getSSHPool(config?: Partial<PoolConfig>): SSHConnectionPool {
638
+ if (!globalPool) {
639
+ globalPool = new SSHConnectionPool(config)
640
+ }
641
+ return globalPool
642
+ }
643
+
644
+ /**
645
+ * Close the global pool (for cleanup/shutdown)
646
+ */
647
+ export async function closeGlobalSSHPool(): Promise<void> {
648
+ if (globalPool) {
649
+ await globalPool.closeAll()
650
+ globalPool = null
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Get active SSH connections (for monitoring)
656
+ */
657
+ export function getActiveSSHConnections(): ReturnType<SSHConnectionPool['getStats']> {
658
+ if (!globalPool) {
659
+ return { totalConnections: 0, connections: [] }
660
+ }
661
+ return globalPool.getStats()
662
+ }