@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
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/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
) {
|
|
92
|
-
throw new Error(`SSH connection '${alias}' is
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|