@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared derived seam state: ONE ledger-derived routing snapshot
|
|
3
|
+
* (records + normalized anchor index) plus the per-record fs/subprocess
|
|
4
|
+
* instances, owned by the hardssh core and consumed by BOTH switch rows
|
|
5
|
+
* (./fs and ./subprocess). Because both seams read the SAME state, they can
|
|
6
|
+
* never disagree with each other or with the ledger's own index for a given
|
|
7
|
+
* session cwd — eliminating the split-brain window of the old per-seam
|
|
8
|
+
* async refresh caches.
|
|
9
|
+
*
|
|
10
|
+
* The state re-applies SYNCHRONOUSLY on every ledger commit: ledger
|
|
11
|
+
* listeners run synchronously AFTER the commit already swapped the
|
|
12
|
+
* in-memory records, so a synchronous re-apply is always current — no async
|
|
13
|
+
* refresh pipeline, no stale-snapshot race, no revision-guard interleaving.
|
|
14
|
+
* The initial file load is awaited before the state is marked ready; until
|
|
15
|
+
* then routing degrades to local with a one-time warning from the rows.
|
|
16
|
+
*
|
|
17
|
+
* Per-record instances are built lazily on first route and kept alive
|
|
18
|
+
* across refreshes (connections survive); removing a workspace drops its
|
|
19
|
+
* instances so stale target keys resolve to NOTHING (fail closed), never to
|
|
20
|
+
* the local backend.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-hardssh/seam-state
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { SshWorkspaceRecord } from './protocol.ts'
|
|
26
|
+
import { isPathUnderAnchor, normalizeAnchorPath, type SshWorkspaceLedger } from './ledger.ts'
|
|
27
|
+
import { REMOTE_PREFIX, type WorkspaceWorld } from './switch/switch-fs.ts'
|
|
28
|
+
import type { SshSubprocessRuntime } from './remote/remote-subprocess.ts'
|
|
29
|
+
|
|
30
|
+
/** Per-record seam instances, built lazily by the owning rows. */
|
|
31
|
+
interface SeamInstances {
|
|
32
|
+
fs?: WorkspaceWorld
|
|
33
|
+
subprocess?: SshSubprocessRuntime
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One anchor-index entry: a normalized anchor plus its lowercased record id. */
|
|
37
|
+
interface AnchorEntry {
|
|
38
|
+
anchor: string
|
|
39
|
+
id: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Shared routing state for the fs/subprocess switch rows. */
|
|
43
|
+
export class WorkspaceSeamState {
|
|
44
|
+
private readonly records = new Map<string, SshWorkspaceRecord>()
|
|
45
|
+
private readonly instances = new Map<string, SeamInstances>()
|
|
46
|
+
private anchors: AnchorEntry[] = []
|
|
47
|
+
private appliedRevision = -1
|
|
48
|
+
private ready = false
|
|
49
|
+
private readyResolvers: Array<() => void> = []
|
|
50
|
+
private fsFactory: ((record: SshWorkspaceRecord) => WorkspaceWorld) | undefined
|
|
51
|
+
private subFactory: ((record: SshWorkspaceRecord) => SshSubprocessRuntime) | undefined
|
|
52
|
+
|
|
53
|
+
constructor(private readonly ledger: SshWorkspaceLedger) {}
|
|
54
|
+
|
|
55
|
+
/** Bind the fs-row's per-record backend builder (called once at apply). */
|
|
56
|
+
bindFs(factory: (record: SshWorkspaceRecord) => WorkspaceWorld): void {
|
|
57
|
+
this.fsFactory = factory
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bind the subprocess-row's per-record runtime builder (called once at apply). */
|
|
61
|
+
bindSub(factory: (record: SshWorkspaceRecord) => SshSubprocessRuntime): void {
|
|
62
|
+
this.subFactory = factory
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True once the initial ledger snapshot has been applied. */
|
|
66
|
+
isReady(): boolean {
|
|
67
|
+
return this.ready
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolves once the initial ledger snapshot has been applied. */
|
|
71
|
+
whenReady(): Promise<void> {
|
|
72
|
+
if (this.ready) return Promise.resolve()
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
this.readyResolvers.push(resolve)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Subscribe to ledger commits and kick the initial load. Returns the
|
|
80
|
+
* subscription disposer (register it through ctx.effect for HMR cleanup).
|
|
81
|
+
*/
|
|
82
|
+
attach(): () => void {
|
|
83
|
+
const unsubscribe = this.ledger.subscribe(() => {
|
|
84
|
+
this.applySync()
|
|
85
|
+
})
|
|
86
|
+
void this.ledger.load()
|
|
87
|
+
.then(() => {
|
|
88
|
+
this.applySync()
|
|
89
|
+
this.markReady()
|
|
90
|
+
})
|
|
91
|
+
.catch((error: unknown) => {
|
|
92
|
+
console.warn('[dsh-hardssh] failed to load the workspace ledger:', error instanceof Error ? error.message : String(error))
|
|
93
|
+
})
|
|
94
|
+
return unsubscribe
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-apply the ledger's current in-memory state (idempotent, guarded). */
|
|
98
|
+
applySync(): void {
|
|
99
|
+
const snapshot = this.ledger.snapshotSync()
|
|
100
|
+
if (snapshot.revision < this.appliedRevision) return
|
|
101
|
+
|
|
102
|
+
const next = new Map<string, SshWorkspaceRecord>()
|
|
103
|
+
for (const record of snapshot.records) next.set(record.id.toLowerCase(), record)
|
|
104
|
+
// Drop instances of removed workspaces so stale routing can never reach them.
|
|
105
|
+
for (const id of this.records.keys()) {
|
|
106
|
+
if (!next.has(id)) this.instances.delete(id)
|
|
107
|
+
}
|
|
108
|
+
this.records.clear()
|
|
109
|
+
for (const [id, record] of next) this.records.set(id, record)
|
|
110
|
+
this.anchors = [...this.records.entries()]
|
|
111
|
+
.map(([id, record]) => ({ anchor: normalizeAnchorPath(record.anchorPath), id }))
|
|
112
|
+
.sort((a, b) => b.anchor.length - a.anchor.length) // longest prefix first
|
|
113
|
+
this.appliedRevision = snapshot.revision
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The record owning a session cwd, or undefined when the cwd is local. */
|
|
117
|
+
private recordFor(cwd: string | undefined): SshWorkspaceRecord | undefined {
|
|
118
|
+
if (cwd === undefined || cwd === '') return undefined
|
|
119
|
+
for (const { anchor, id } of this.anchors) {
|
|
120
|
+
if (isPathUnderAnchor(anchor, cwd)) {
|
|
121
|
+
const record = this.records.get(id)
|
|
122
|
+
if (record !== undefined) return record
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private ensureFs(record: SshWorkspaceRecord): WorkspaceWorld | undefined {
|
|
129
|
+
if (this.fsFactory === undefined) return undefined
|
|
130
|
+
const id = record.id.toLowerCase()
|
|
131
|
+
let entry = this.instances.get(id)
|
|
132
|
+
if (entry === undefined) {
|
|
133
|
+
entry = {}
|
|
134
|
+
this.instances.set(id, entry)
|
|
135
|
+
}
|
|
136
|
+
if (entry.fs === undefined) entry.fs = this.fsFactory(record)
|
|
137
|
+
return entry.fs
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private ensureSubprocess(record: SshWorkspaceRecord): SshSubprocessRuntime | undefined {
|
|
141
|
+
if (this.subFactory === undefined) return undefined
|
|
142
|
+
const id = record.id.toLowerCase()
|
|
143
|
+
let entry = this.instances.get(id)
|
|
144
|
+
if (entry === undefined) {
|
|
145
|
+
entry = {}
|
|
146
|
+
this.instances.set(id, entry)
|
|
147
|
+
}
|
|
148
|
+
if (entry.subprocess === undefined) entry.subprocess = this.subFactory(record)
|
|
149
|
+
return entry.subprocess
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The fs world owning a session cwd, or undefined (local). */
|
|
153
|
+
worldForFs(cwd: string | undefined): WorkspaceWorld | undefined {
|
|
154
|
+
const record = this.recordFor(cwd)
|
|
155
|
+
return record === undefined ? undefined : this.ensureFs(record)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The fs world for one namespaced target key (`wfs://<id>/…` or legacy
|
|
159
|
+
* `ssh:<id>:`), or undefined when the owning workspace no longer exists
|
|
160
|
+
* (stale key → fail closed). */
|
|
161
|
+
worldForFsNamespace(namespace: string): WorkspaceWorld | undefined {
|
|
162
|
+
let id: string | undefined
|
|
163
|
+
if (namespace.startsWith('wfs://')) {
|
|
164
|
+
id = namespace.slice('wfs://'.length, -1) // strip trailing '/'
|
|
165
|
+
} else if (namespace.startsWith(REMOTE_PREFIX)) {
|
|
166
|
+
id = namespace.slice(REMOTE_PREFIX.length, -1)
|
|
167
|
+
}
|
|
168
|
+
if (id === undefined || id === '') return undefined
|
|
169
|
+
const record = this.records.get(id)
|
|
170
|
+
return record === undefined ? undefined : this.ensureFs(record)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The subprocess runtime owning a session cwd, or undefined (local). */
|
|
174
|
+
runtimeForSub(cwd: string | undefined): SshSubprocessRuntime | undefined {
|
|
175
|
+
const record = this.recordFor(cwd)
|
|
176
|
+
return record === undefined ? undefined : this.ensureSubprocess(record)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private markReady(): void {
|
|
180
|
+
this.ready = true
|
|
181
|
+
const resolvers = this.readyResolvers
|
|
182
|
+
this.readyResolvers = []
|
|
183
|
+
for (const resolve of resolvers) resolve()
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical POSIX shell single-quoting for remote command construction.
|
|
3
|
+
* Replaces the three near-identical copies that used to live in backend.ts,
|
|
4
|
+
* tools.ts and remote/environment.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Quote one argument for a POSIX login shell (single-quote, `'\''` for embedded quotes). */
|
|
8
|
+
export function shellQuote(value: string): string {
|
|
9
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Client } from 'ssh2'
|
|
2
|
+
|
|
3
|
+
/** What kind of resource owns a lease (drives diagnostics and teardown). */
|
|
4
|
+
export type LeaseKind = 'operation' | 'stream' | 'session' | 'tunnel'
|
|
5
|
+
|
|
6
|
+
/** One unit of ownership over a pooled SSH connection. */
|
|
7
|
+
export interface ClientLease {
|
|
8
|
+
readonly alias: string
|
|
9
|
+
readonly client: Client
|
|
10
|
+
readonly generation: number
|
|
11
|
+
readonly kind: LeaseKind
|
|
12
|
+
readonly released: boolean
|
|
13
|
+
/** Mark the underlying connection broken (surfaces to every holder). */
|
|
14
|
+
markBroken(error?: unknown): void
|
|
15
|
+
/** Idempotent: release this holder's ownership of the connection. */
|
|
16
|
+
release(): void
|
|
17
|
+
}
|
|
@@ -0,0 +1,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): 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
|
+
}
|