@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/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 connection = this.#connections.get(entry.alias);
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: Boolean(connection && !connection.closed),
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
- const pending = PresenceConnection.connect(
96
- { ...entry, maxChannels: this.#settings.maxChannels },
97
- { password: secret.password, passphrase: secret.passphrase, timeoutMs: this.#settings.connectTimeoutMs },
98
- )
99
- .then((connection) => {
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
- .finally(() => this.#connecting.delete(host));
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
- this.#connecting.set(host, pending);
108
- return pending;
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
  }