@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,275 +1,341 @@
1
- import type { Client } from 'ssh2'
2
- import type { ClientLease, LeaseKind } from './lease.ts'
3
-
4
- export interface AcquireLeaseOptions {
5
- kind: LeaseKind
6
- signal?: AbortSignal
7
- }
8
-
9
- /** The connection-ownership service (replaces pinned+inFlight bookkeeping). */
10
- export interface SshConnectionService {
11
- acquire(alias: string, options: AcquireLeaseOptions): Promise<ClientLease>
12
- invalidate(
13
- alias: string,
14
- options?: {
15
- includeDependents?: boolean
16
- mode?: 'drain' | 'force'
17
- },
18
- ): void
19
- invalidateAll(): void
20
- /** Aliases currently holding a live pooled transport. */
21
- liveAliases(): string[]
22
- }
23
-
24
- export interface ConnectionPoolOptions {
25
- idleTimeoutMs: number
26
- /** Open a fresh SSH connection (with jump chain) for one alias. */
27
- connect(alias: string): Promise<{ client: Client; hops: Client[] }>
28
- /** Called when a pooled connection is torn down (e.g. drop SFTP cache). */
29
- onDispose?(client: Client): void
30
- }
31
-
32
- interface PoolRecord {
33
- client: Client
34
- hops: Client[]
35
- idleAt: number
36
- broken: boolean
37
- generation: number
38
- /** The single source of truth for whether the connection is in use. */
39
- leases: Set<ClientLease>
40
- draining: boolean
41
- closed: boolean
42
- }
43
-
44
- function createAbortError(): Error {
45
- const error = new Error('SSH connection lease acquisition aborted')
46
- error.name = 'AbortError'
47
- return error
48
- }
49
-
50
- /**
51
- * Per-alias connection pool with explicit leases. A connection is swept only
52
- * when it has zero leases AND it is idle past the threshold (or draining);
53
- * `invalidate` bumps the generation so a half-open handshake can never
54
- * re-enter the pool after a config change.
55
- */
56
- export class ConnectionPool implements SshConnectionService {
57
- private readonly records = new Map<string, PoolRecord>()
58
- private readonly acquireQueue = new Map<string, Promise<PoolRecord>>()
59
- private readonly generations = new Map<string, number>()
60
- private readonly options: ConnectionPoolOptions
61
- private readonly sweepTimer: NodeJS.Timeout
62
-
63
- constructor(options: ConnectionPoolOptions) {
64
- this.options = options
65
- this.sweepTimer = setInterval(
66
- () => this.sweep(),
67
- Math.max(10_000, options.idleTimeoutMs / 4),
68
- )
69
- this.sweepTimer.unref?.()
70
- }
71
-
72
- async acquire(alias: string, options: AcquireLeaseOptions): Promise<ClientLease> {
73
- if (options.signal !== undefined && options.signal.aborted) throw createAbortError()
74
-
75
- let record = this.records.get(alias)
76
- if (record?.draining === true || record?.broken === true) {
77
- throw new Error(`SSH connection '${alias}' is draining`)
78
- }
79
-
80
- if (record === undefined) {
81
- record = await this.acquireRecord(alias)
82
- }
83
-
84
- if (options.signal !== undefined && options.signal.aborted) throw createAbortError()
85
-
86
- if (
87
- record.draining
88
- || record.broken
89
- || record.closed
90
- || this.records.get(alias) !== record
91
- ) {
92
- throw new Error(`SSH connection '${alias}' is unavailable`)
93
- }
94
-
95
- const lease = this.createLease(alias, record, options.kind)
96
- record.leases.add(lease)
97
- record.idleAt = Date.now()
98
- return lease
99
- }
100
-
101
- invalidate(alias: string, options?: { includeDependents?: boolean; mode?: 'drain' | 'force' }): void {
102
- // Today every target owns its full jump chain (no shared hop records), so
103
- // there are no dependents to cascade to; reserved for shared-hop pools.
104
- void options?.includeDependents
105
-
106
- this.generations.set(alias, (this.generations.get(alias) ?? 0) + 1)
107
-
108
- const record = this.records.get(alias)
109
- if (record === undefined) return
110
-
111
- record.draining = true
112
-
113
- if (options?.mode === 'force') {
114
- this.closeTransport(record)
115
- }
116
-
117
- if (record.leases.size === 0) {
118
- this.disposeRecord(alias, record)
119
- }
120
- }
121
-
122
- invalidateAll(): void {
123
- clearInterval(this.sweepTimer)
124
-
125
- const aliases = new Set([...this.records.keys(), ...this.acquireQueue.keys()])
126
- for (const alias of aliases) {
127
- this.invalidate(alias, { mode: 'force' })
128
- }
129
- }
130
-
131
- /** Aliases currently holding a live pooled transport. A record that is
132
- * closed / broken / draining is retired and does not count as connected. */
133
- liveAliases(): string[] {
134
- const aliases: string[] = []
135
- for (const [alias, record] of this.records) {
136
- if (!record.closed && !record.broken && !record.draining) aliases.push(alias)
137
- }
138
- return aliases
139
- }
140
-
141
- private async acquireRecord(alias: string): Promise<PoolRecord> {
142
- const pending = this.acquireQueue.get(alias)
143
- if (pending !== undefined) return pending
144
-
145
- const task = this.connectRecord(alias)
146
- this.acquireQueue.set(alias, task)
147
-
148
- try {
149
- return await task
150
- } finally {
151
- if (this.acquireQueue.get(alias) === task) {
152
- this.acquireQueue.delete(alias)
153
- }
154
- }
155
- }
156
-
157
- private async connectRecord(alias: string): Promise<PoolRecord> {
158
- const generation = this.generations.get(alias) ?? 0
159
- const { client, hops } = await this.options.connect(alias)
160
-
161
- const record: PoolRecord = {
162
- client,
163
- hops,
164
- idleAt: Date.now(),
165
- broken: false,
166
- generation,
167
- leases: new Set<ClientLease>(),
168
- draining: false,
169
- closed: false,
170
- }
171
-
172
- const breakRecord = (): void => {
173
- record.broken = true
174
- record.draining = true
175
-
176
- for (const lease of [...record.leases]) {
177
- lease.markBroken()
178
- }
179
-
180
- if (record.leases.size === 0) {
181
- this.disposeRecord(alias, record)
182
- }
183
- }
184
-
185
- client.on('error', breakRecord)
186
- client.on('close', breakRecord)
187
-
188
- // An invalidate may land while the handshake is in flight; a stale
189
- // generation connection must not re-enter the pool afterwards.
190
- if ((this.generations.get(alias) ?? 0) !== generation) {
191
- this.closeTransport(record)
192
- throw new Error(`SSH connection '${alias}' was invalidated while connecting`)
193
- }
194
-
195
- this.records.set(alias, record)
196
- return record
197
- }
198
-
199
- private createLease(alias: string, record: PoolRecord, kind: LeaseKind): ClientLease {
200
- let released = false
201
- let broken = false
202
-
203
- const lease: ClientLease = {
204
- alias,
205
- client: record.client,
206
- generation: record.generation,
207
- kind,
208
-
209
- get released(): boolean {
210
- return released
211
- },
212
-
213
- markBroken: (_error?: unknown): void => {
214
- if (released || broken) return
215
-
216
- broken = true
217
- record.broken = true
218
- record.draining = true
219
- },
220
-
221
- release: (): void => {
222
- if (released) return
223
-
224
- released = true
225
- record.leases.delete(lease)
226
- record.idleAt = Date.now()
227
-
228
- if (record.leases.size === 0 && (record.draining || record.broken)) {
229
- this.disposeRecord(alias, record)
230
- }
231
- },
232
- }
233
-
234
- return lease
235
- }
236
-
237
- private sweep(): void {
238
- const cutoff = Date.now() - this.options.idleTimeoutMs
239
-
240
- for (const [alias, record] of this.records) {
241
- if (record.leases.size === 0 && (record.draining || record.idleAt < cutoff)) {
242
- this.disposeRecord(alias, record)
243
- }
244
- }
245
- }
246
-
247
- private disposeRecord(alias: string, record: PoolRecord): void {
248
- if (this.records.get(alias) === record) {
249
- this.records.delete(alias)
250
- }
251
-
252
- this.closeTransport(record)
253
- }
254
-
255
- private closeTransport(record: PoolRecord): void {
256
- if (record.closed) return
257
- record.closed = true
258
-
259
- this.options.onDispose?.(record.client)
260
-
261
- try {
262
- record.client.end()
263
- } catch {
264
- // already closed
265
- }
266
-
267
- for (const hop of record.hops) {
268
- try {
269
- hop.end()
270
- } catch {
271
- // already closed
272
- }
273
- }
274
- }
275
- }
1
+ import type { Client } from 'ssh2'
2
+ import type { ClientLease, LeaseKind } from './lease.ts'
3
+
4
+ export interface AcquireLeaseOptions {
5
+ kind: LeaseKind
6
+ signal?: AbortSignal
7
+ }
8
+
9
+ /** The connection-ownership service (replaces pinned+inFlight bookkeeping). */
10
+ export interface SshConnectionService {
11
+ acquire(alias: string, options: AcquireLeaseOptions): Promise<ClientLease>
12
+ invalidate(
13
+ alias: string,
14
+ options?: {
15
+ includeDependents?: boolean
16
+ mode?: 'drain' | 'force'
17
+ },
18
+ ): void
19
+ invalidateAll(): void
20
+ /** Aliases currently holding a live pooled transport. */
21
+ liveAliases(): string[]
22
+ }
23
+
24
+ export interface ConnectionPoolOptions {
25
+ idleTimeoutMs: number
26
+ /** Open a fresh SSH connection (with jump chain) for one alias. */
27
+ connect(alias: string, signal?: AbortSignal): Promise<{ client: Client; hops: Client[] }>
28
+ /** Called when a pooled connection is torn down (e.g. drop SFTP cache). */
29
+ onDispose?(client: Client): void
30
+ /**
31
+ * Called once when an alias's pooled transport is retired (idle sweep,
32
+ * invalidation, shutdown). Session-scoped credentials are tied to the pooled
33
+ * connection's lifetime, so this is where they are dropped — otherwise a
34
+ * secret entered for one connection would outlive it for the whole process.
35
+ */
36
+ onRetire?(alias: string): void
37
+ }
38
+
39
+ interface PendingConnect {
40
+ promise: Promise<PoolRecord>
41
+ controller: AbortController
42
+ waiters: number
43
+ settled: boolean
44
+ }
45
+
46
+ interface PoolRecord {
47
+ alias: string
48
+ client: Client
49
+ hops: Client[]
50
+ idleAt: number
51
+ broken: boolean
52
+ generation: number
53
+ /** The single source of truth for whether the connection is in use. */
54
+ leases: Set<ClientLease>
55
+ draining: boolean
56
+ closed: boolean
57
+ }
58
+
59
+ function createAbortError(): Error {
60
+ const error = new Error('SSH connection lease acquisition aborted')
61
+ error.name = 'AbortError'
62
+ return error
63
+ }
64
+
65
+ /**
66
+ * Per-alias connection pool with explicit leases. A connection is swept only
67
+ * when it has zero leases AND it is idle past the threshold (or draining);
68
+ * `invalidate` bumps the generation so a half-open handshake can never
69
+ * re-enter the pool after a config change.
70
+ */
71
+ export class ConnectionPool implements SshConnectionService {
72
+ private readonly records = new Map<string, PoolRecord>()
73
+ private readonly acquireQueue = new Map<string, PendingConnect>()
74
+ private readonly generations = new Map<string, number>()
75
+ private readonly options: ConnectionPoolOptions
76
+ private readonly sweepTimer: NodeJS.Timeout
77
+
78
+ constructor(options: ConnectionPoolOptions) {
79
+ this.options = options
80
+ this.sweepTimer = setInterval(
81
+ () => this.sweep(),
82
+ Math.max(10_000, options.idleTimeoutMs / 4),
83
+ )
84
+ this.sweepTimer.unref?.()
85
+ }
86
+
87
+ async acquire(alias: string, options: AcquireLeaseOptions): Promise<ClientLease> {
88
+ if (options.signal !== undefined && options.signal.aborted) throw createAbortError()
89
+
90
+ let record = this.records.get(alias)
91
+ if (record?.draining === true || record?.broken === true) {
92
+ throw new Error(`SSH connection '${alias}' is draining`)
93
+ }
94
+
95
+ if (record === undefined) record = await this.acquireRecord(alias, options.signal)
96
+
97
+ if (options.signal !== undefined && options.signal.aborted) throw createAbortError()
98
+
99
+ if (
100
+ record.draining
101
+ || record.broken
102
+ || record.closed
103
+ || this.records.get(alias) !== record
104
+ ) {
105
+ throw new Error(`SSH connection '${alias}' is unavailable`)
106
+ }
107
+
108
+ const lease = this.createLease(alias, record, options.kind)
109
+ record.leases.add(lease)
110
+ record.idleAt = Date.now()
111
+ return lease
112
+ }
113
+
114
+ invalidate(alias: string, options?: { includeDependents?: boolean; mode?: 'drain' | 'force' }): void {
115
+ // Today every target owns its full jump chain (no shared hop records), so
116
+ // there are no dependents to cascade to; reserved for shared-hop pools.
117
+ void options?.includeDependents
118
+
119
+ this.generations.set(alias, (this.generations.get(alias) ?? 0) + 1)
120
+ this.acquireQueue.get(alias)?.controller.abort()
121
+
122
+ const record = this.records.get(alias)
123
+ if (record === undefined) {
124
+ // No pooled transport to tear down — but the alias's session credential
125
+ // is still dead (a config change may have replaced the host, or the
126
+ // connection was already reaped). Notifying here is what keeps the
127
+ // "secret lives for the connection's lifetime" promise true for an alias
128
+ // that is currently disconnected; returning early left it process-lifetime.
129
+ this.options.onRetire?.(alias)
130
+ return
131
+ }
132
+
133
+ // Remove the retired generation from the active slot immediately. Its
134
+ // existing leases still own the record object and drain normally, while a
135
+ // subsequent acquire can install the new generation under this alias.
136
+ // disposeRecord's identity guard prevents the old record from deleting the
137
+ // replacement when its final lease eventually releases.
138
+ this.records.delete(alias)
139
+ record.draining = true
140
+
141
+ if (options?.mode === 'force') {
142
+ this.closeTransport(record)
143
+ }
144
+
145
+ if (record.leases.size === 0) {
146
+ this.disposeRecord(alias, record)
147
+ }
148
+ }
149
+
150
+ invalidateAll(): void {
151
+ clearInterval(this.sweepTimer)
152
+
153
+ const aliases = new Set([...this.records.keys(), ...this.acquireQueue.keys()])
154
+ for (const alias of aliases) {
155
+ this.invalidate(alias, { mode: 'force' })
156
+ }
157
+ }
158
+
159
+ /** Aliases currently holding a live pooled transport. A record that is
160
+ * closed / broken / draining is retired and does not count as connected. */
161
+ liveAliases(): string[] {
162
+ const aliases: string[] = []
163
+ for (const [alias, record] of this.records) {
164
+ if (!record.closed && !record.broken && !record.draining) aliases.push(alias)
165
+ }
166
+ return aliases
167
+ }
168
+
169
+ private async acquireRecord(alias: string, signal?: AbortSignal): Promise<PoolRecord> {
170
+ let pending = this.acquireQueue.get(alias)
171
+ if (pending === undefined) {
172
+ const controller = new AbortController()
173
+ pending = { promise: Promise.resolve(undefined as never), controller, waiters: 0, settled: false }
174
+ pending.promise = this.connectRecord(alias, controller.signal)
175
+ this.acquireQueue.set(alias, pending)
176
+ const owned = pending
177
+ void owned.promise.then(
178
+ () => { owned.settled = true; if (this.acquireQueue.get(alias) === owned) this.acquireQueue.delete(alias) },
179
+ () => { owned.settled = true; if (this.acquireQueue.get(alias) === owned) this.acquireQueue.delete(alias) },
180
+ )
181
+ }
182
+
183
+ pending.waiters += 1
184
+ try {
185
+ if (signal === undefined) return await pending.promise
186
+ return await new Promise<PoolRecord>((resolve, reject) => {
187
+ let settled = false
188
+ const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
189
+ const onAbort = (): void => {
190
+ if (settled) return
191
+ settled = true
192
+ cleanup()
193
+ reject(createAbortError())
194
+ }
195
+ signal.addEventListener('abort', onAbort, { once: true })
196
+ if (signal.aborted) {
197
+ onAbort()
198
+ return
199
+ }
200
+ pending!.promise.then(
201
+ value => { if (!settled) { settled = true; cleanup(); resolve(value) } },
202
+ error => { if (!settled) { settled = true; cleanup(); reject(error) } },
203
+ )
204
+ })
205
+ } finally {
206
+ pending.waiters -= 1
207
+ if (pending.waiters === 0 && !pending.settled) pending.controller.abort()
208
+ }
209
+ }
210
+
211
+ private async connectRecord(alias: string, signal?: AbortSignal): Promise<PoolRecord> {
212
+ const generation = this.generations.get(alias) ?? 0
213
+ const { client, hops } = await this.options.connect(alias, signal)
214
+
215
+ const record: PoolRecord = {
216
+ alias,
217
+ client,
218
+ hops,
219
+ idleAt: Date.now(),
220
+ broken: false,
221
+ generation,
222
+ leases: new Set<ClientLease>(),
223
+ draining: false,
224
+ closed: false,
225
+ }
226
+
227
+ const breakRecord = (): void => {
228
+ record.broken = true
229
+ record.draining = true
230
+
231
+ for (const lease of [...record.leases]) {
232
+ lease.markBroken()
233
+ }
234
+
235
+ if (record.leases.size === 0) {
236
+ this.disposeRecord(alias, record)
237
+ }
238
+ }
239
+
240
+ client.on('error', breakRecord)
241
+ client.on('close', breakRecord)
242
+ // Jump hops carry the same lifetime handlers: a hop dying (network
243
+ // switch, server stop) must mark the record broken and tear the whole
244
+ // chain down — never let a hop emit an unattended 'error' (or 'close')
245
+ // that would either crash the process or leave a zombie pool entry.
246
+ for (const hop of hops) {
247
+ hop.on('error', breakRecord)
248
+ hop.on('close', breakRecord)
249
+ }
250
+
251
+ // An invalidate may land while the handshake is in flight; a stale
252
+ // generation connection must not re-enter the pool afterwards.
253
+ if ((this.generations.get(alias) ?? 0) !== generation) {
254
+ this.closeTransport(record)
255
+ throw new Error(`SSH connection '${alias}' was invalidated while connecting`)
256
+ }
257
+
258
+ this.records.set(alias, record)
259
+ return record
260
+ }
261
+
262
+ private createLease(alias: string, record: PoolRecord, kind: LeaseKind): ClientLease {
263
+ let released = false
264
+ let broken = false
265
+
266
+ const lease: ClientLease = {
267
+ alias,
268
+ client: record.client,
269
+ generation: record.generation,
270
+ kind,
271
+
272
+ get released(): boolean {
273
+ return released
274
+ },
275
+
276
+ holdsOnlyLease: (): boolean => record.leases.size <= 1,
277
+
278
+ markBroken: (_error?: unknown): void => {
279
+ if (released || broken) return
280
+
281
+ broken = true
282
+ record.broken = true
283
+ record.draining = true
284
+ },
285
+
286
+ release: (): void => {
287
+ if (released) return
288
+
289
+ released = true
290
+ record.leases.delete(lease)
291
+ record.idleAt = Date.now()
292
+
293
+ if (record.leases.size === 0 && (record.draining || record.broken)) {
294
+ this.disposeRecord(alias, record)
295
+ }
296
+ },
297
+ }
298
+
299
+ return lease
300
+ }
301
+
302
+ private sweep(): void {
303
+ const cutoff = Date.now() - this.options.idleTimeoutMs
304
+
305
+ for (const [alias, record] of this.records) {
306
+ if (record.leases.size === 0 && (record.draining || record.idleAt < cutoff)) {
307
+ this.disposeRecord(alias, record)
308
+ }
309
+ }
310
+ }
311
+
312
+ private disposeRecord(alias: string, record: PoolRecord): void {
313
+ if (this.records.get(alias) === record) {
314
+ this.records.delete(alias)
315
+ }
316
+
317
+ this.closeTransport(record)
318
+ }
319
+
320
+ private closeTransport(record: PoolRecord): void {
321
+ if (record.closed) return
322
+ record.closed = true
323
+
324
+ this.options.onRetire?.(record.alias)
325
+ this.options.onDispose?.(record.client)
326
+
327
+ try {
328
+ record.client.end()
329
+ } catch {
330
+ // already closed
331
+ }
332
+
333
+ for (const hop of record.hops) {
334
+ try {
335
+ hop.end()
336
+ } catch {
337
+ // already closed
338
+ }
339
+ }
340
+ }
341
+ }