@rjfan/dsh-presence 0.18.0 → 0.19.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/README.md +94 -9
- package/README.zh-CN.md +47 -9
- package/client.js +1 -1
- package/index.js +7 -1
- package/package.json +1 -1
- package/src/core/connection.mjs +125 -31
- package/src/destructive.js +265 -12
- package/src/pool.js +136 -11
- package/src/tools.js +646 -137
package/src/pool.js
CHANGED
|
@@ -15,9 +15,44 @@ const JOB_EARLY_TAIL_LINES = 20;
|
|
|
15
15
|
const JOB_PRUNE_INTERVAL_MS = 60_000;
|
|
16
16
|
const JOB_PRUNE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
17
17
|
const JOB_PRUNE_MAX_ENTRIES = 100;
|
|
18
|
+
// A transient connect failure is retried twice, with these gaps, and a dead
|
|
19
|
+
// cached connection is dropped before each retry.
|
|
20
|
+
const CONNECT_RETRY_BACKOFF_MS = [200, 600];
|
|
21
|
+
// How long a failure keeps its edge on the host state: recent enough to matter,
|
|
22
|
+
// old enough to clear itself once the connection has been healthy again.
|
|
23
|
+
const HOST_DEGRADED_WINDOW_MS = 60_000;
|
|
18
24
|
|
|
19
25
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
26
|
|
|
27
|
+
// Auth, permission and unknown-host failures are configuration problems a retry
|
|
28
|
+
// cannot fix; everything network-shaped is worth another attempt.
|
|
29
|
+
const FATAL_CONNECTION_ERROR =
|
|
30
|
+
/no host "|authentication failed|authentication methods|passphrase-protected|password requested|permission denied|access denied|not authorized/i;
|
|
31
|
+
const TRANSIENT_CONNECTION_ERROR =
|
|
32
|
+
/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH|connection refused|could not be resolved|timed out|handshake|channel|closed|socket hang up|no route to host/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a failed connection attempt is worth retrying.
|
|
36
|
+
*
|
|
37
|
+
* `transient` covers the network-shaped failures (DNS, refused, timeout,
|
|
38
|
+
* dropped channel, reset); `fatal` covers configuration errors a second attempt
|
|
39
|
+
* cannot fix (authentication, permissions, an alias that is not configured) and
|
|
40
|
+
* is also the default for an unrecognised error, so a retry never masks a real
|
|
41
|
+
* problem.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} error
|
|
44
|
+
* @returns {'transient'|'fatal'}
|
|
45
|
+
*/
|
|
46
|
+
export function classifyConnectionError(error) {
|
|
47
|
+
const message = String(error?.message ?? error ?? '');
|
|
48
|
+
const code = String(error?.code ?? '');
|
|
49
|
+
// ssh2 tags authentication failures with this level even before describeSshError.
|
|
50
|
+
if (String(error?.level ?? '') === 'client-authentication') return 'fatal';
|
|
51
|
+
if (FATAL_CONNECTION_ERROR.test(message)) return 'fatal';
|
|
52
|
+
if (TRANSIENT_CONNECTION_ERROR.test(`${code} ${message}`)) return 'transient';
|
|
53
|
+
return 'fatal';
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
export class PresencePool {
|
|
22
57
|
#settings;
|
|
23
58
|
#connections = new Map();
|
|
@@ -29,6 +64,7 @@ export class PresencePool {
|
|
|
29
64
|
#lastPruneAt = 0;
|
|
30
65
|
#secrets = new Map();
|
|
31
66
|
#connectionHandlers = new Set();
|
|
67
|
+
#hostStates = new Map();
|
|
32
68
|
#hostsCache;
|
|
33
69
|
#reaper;
|
|
34
70
|
#counter = 0;
|
|
@@ -50,20 +86,51 @@ export class PresencePool {
|
|
|
50
86
|
this.#hostsCache = { at: now, list: listHosts() };
|
|
51
87
|
}
|
|
52
88
|
return this.#hostsCache.list.map((entry) => {
|
|
53
|
-
const
|
|
89
|
+
const state = this.hostState(entry.alias);
|
|
54
90
|
return {
|
|
55
91
|
alias: entry.alias,
|
|
56
92
|
hostname: entry.hostname,
|
|
57
93
|
port: entry.port,
|
|
58
94
|
user: entry.user,
|
|
59
95
|
source: entry.source ?? 'config',
|
|
60
|
-
connected:
|
|
96
|
+
connected: state.connected,
|
|
97
|
+
state: state.state,
|
|
98
|
+
lastError: state.lastError,
|
|
61
99
|
identityFile: entry.identityFile?.[0],
|
|
62
100
|
terminals: [...this.#terminals.values()].filter((terminal) => terminal.host === entry.alias).length,
|
|
63
101
|
};
|
|
64
102
|
});
|
|
65
103
|
}
|
|
66
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Current trust state of one machine.
|
|
107
|
+
*
|
|
108
|
+
* `connected` is the cached connection; `degraded` means that connection is
|
|
109
|
+
* usable but a failure happened in the last minute; `error` means the last
|
|
110
|
+
* attempt failed and nothing is cached; `idle` means no connection and no
|
|
111
|
+
* recorded failure. `retries` is how many retries the last attempt needed.
|
|
112
|
+
*/
|
|
113
|
+
hostState(alias) {
|
|
114
|
+
const name = String(alias ?? '');
|
|
115
|
+
const connection = this.#connections.get(name);
|
|
116
|
+
const connected = Boolean(connection && !connection.closed);
|
|
117
|
+
const record = this.#hostStates.get(name) ?? null;
|
|
118
|
+
const failedAt = record?.lastErrorAt ?? null;
|
|
119
|
+
const recent = failedAt !== null && Date.now() - failedAt <= HOST_DEGRADED_WINDOW_MS;
|
|
120
|
+
let state;
|
|
121
|
+
if (connected) state = recent ? 'degraded' : 'connected';
|
|
122
|
+
else if (failedAt !== null) state = 'error';
|
|
123
|
+
else state = 'idle';
|
|
124
|
+
return {
|
|
125
|
+
alias: name,
|
|
126
|
+
connected,
|
|
127
|
+
state,
|
|
128
|
+
lastError: record?.lastError ?? null,
|
|
129
|
+
lastErrorAt: record?.lastErrorAt ?? null,
|
|
130
|
+
retries: record?.retries ?? 0,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
67
134
|
/** Resolve a live connection, opening one if needed. */
|
|
68
135
|
async connection(host, { password, passphrase } = {}) {
|
|
69
136
|
const existing = this.#connections.get(host);
|
|
@@ -71,6 +138,9 @@ export class PresencePool {
|
|
|
71
138
|
this.#lastUsed.set(host, Date.now());
|
|
72
139
|
return existing;
|
|
73
140
|
}
|
|
141
|
+
// A cached connection that died between calls is not reusable: drop it (and
|
|
142
|
+
// anything that hung off it) before dialling again.
|
|
143
|
+
if (existing) this.#dropConnection(host);
|
|
74
144
|
if (this.#connecting.has(host)) return this.#connecting.get(host);
|
|
75
145
|
|
|
76
146
|
const entry = readSshConfig().get(host);
|
|
@@ -92,20 +162,72 @@ export class PresencePool {
|
|
|
92
162
|
if (password || passphrase) this.#secrets.set(host, { password, passphrase });
|
|
93
163
|
const secret = this.#secrets.get(host) ?? (envPassword ? { password: envPassword } : {});
|
|
94
164
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
165
|
+
// The whole retry sequence is one shared promise, so concurrent callers do
|
|
166
|
+
// not dial the same machine three times over.
|
|
167
|
+
const pending = this.#connectWithRetry(host, entry, secret).finally(() => this.#connecting.delete(host));
|
|
168
|
+
this.#connecting.set(host, pending);
|
|
169
|
+
return pending;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Open one connection, retrying transient failures. Auth/permission/unknown
|
|
174
|
+
* host errors are configuration problems and fail on the first attempt; a
|
|
175
|
+
* network-shaped failure is retried (200 ms, 600 ms apart) after dropping the
|
|
176
|
+
* dead cached connection, and the final error is the last one observed.
|
|
177
|
+
*/
|
|
178
|
+
async #connectWithRetry(host, entry, secret) {
|
|
179
|
+
const previous = this.#hostStates.get(host) ?? {};
|
|
180
|
+
const state = {
|
|
181
|
+
lastError: previous.lastError ?? null,
|
|
182
|
+
lastErrorAt: previous.lastErrorAt ?? null,
|
|
183
|
+
retries: 0,
|
|
184
|
+
};
|
|
185
|
+
let lastError = null;
|
|
186
|
+
for (let attempt = 0; attempt <= CONNECT_RETRY_BACKOFF_MS.length; attempt += 1) {
|
|
187
|
+
if (attempt > 0) {
|
|
188
|
+
this.#dropConnection(host);
|
|
189
|
+
state.retries = attempt;
|
|
190
|
+
await sleep(CONNECT_RETRY_BACKOFF_MS[attempt - 1]);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const connection = await PresenceConnection.connect(
|
|
194
|
+
{ ...entry, maxChannels: this.#settings.maxChannels },
|
|
195
|
+
{ password: secret.password, passphrase: secret.passphrase, timeoutMs: this.#settings.connectTimeoutMs },
|
|
196
|
+
);
|
|
100
197
|
this.#connections.set(host, connection);
|
|
101
198
|
this.#lastUsed.set(host, Date.now());
|
|
199
|
+
// Keep the failure that led here: a host that needed a retry is usable
|
|
200
|
+
// but degraded for the next minute.
|
|
201
|
+
this.#hostStates.set(host, state);
|
|
102
202
|
this.#notifyConnectionEstablished(host, connection);
|
|
103
203
|
return connection;
|
|
104
|
-
})
|
|
105
|
-
|
|
204
|
+
} catch (error) {
|
|
205
|
+
lastError = error;
|
|
206
|
+
state.lastError = String(error?.message ?? error);
|
|
207
|
+
state.lastErrorAt = Date.now();
|
|
208
|
+
if (classifyConnectionError(error) !== 'transient' || attempt === CONNECT_RETRY_BACKOFF_MS.length) {
|
|
209
|
+
this.#hostStates.set(host, state);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.#hostStates.set(host, state);
|
|
215
|
+
throw lastError ?? new Error(`could not connect to ${host}`);
|
|
216
|
+
}
|
|
106
217
|
|
|
107
|
-
|
|
108
|
-
|
|
218
|
+
/** Forget a cached connection and close anything that hung off it. */
|
|
219
|
+
#dropConnection(host) {
|
|
220
|
+
const connection = this.#connections.get(host);
|
|
221
|
+
this.#connections.delete(host);
|
|
222
|
+
this.#lastUsed.delete(host);
|
|
223
|
+
if (!connection) return;
|
|
224
|
+
try {
|
|
225
|
+
connection.end();
|
|
226
|
+
} catch {
|
|
227
|
+
/* already gone */
|
|
228
|
+
}
|
|
229
|
+
// A forward whose SSH connection is gone still owns a local listener.
|
|
230
|
+
this.#closeTunnelsForHost(host).catch(() => {});
|
|
109
231
|
}
|
|
110
232
|
|
|
111
233
|
/**
|
|
@@ -141,6 +263,8 @@ export class PresencePool {
|
|
|
141
263
|
const connection = this.#connections.get(host);
|
|
142
264
|
this.#connections.delete(host);
|
|
143
265
|
this.#lastUsed.delete(host);
|
|
266
|
+
// A deliberate disconnect is not a failure: the host goes back to `idle`.
|
|
267
|
+
this.#hostStates.delete(host);
|
|
144
268
|
for (const terminal of [...this.#terminals.values()]) {
|
|
145
269
|
if (terminal.host === host) this.#closeTerminal(terminal.id);
|
|
146
270
|
}
|
|
@@ -814,5 +938,6 @@ export class PresencePool {
|
|
|
814
938
|
this.#jobs.clear();
|
|
815
939
|
for (const connection of this.#connections.values()) connection.end();
|
|
816
940
|
this.#connections.clear();
|
|
941
|
+
this.#hostStates.clear();
|
|
817
942
|
}
|
|
818
943
|
}
|