@rjfan/dsh-presence 0.18.0 → 0.20.1
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 +170 -16
- package/README.zh-CN.md +114 -15
- package/client.js +1 -1
- package/index.js +139 -84
- package/package.json +1 -1
- package/src/api.js +6 -1
- package/src/core/connection.mjs +125 -31
- package/src/destructive.js +265 -12
- package/src/intercept.js +150 -0
- package/src/job-store.js +292 -0
- package/src/observed-store.js +136 -19
- package/src/pool.js +444 -97
- package/src/tools.js +1458 -258
- package/src/wait.js +352 -0
package/src/pool.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The pool the plugin owns: one live connection per machine, plus the
|
|
2
2
|
// terminals that belong to it.
|
|
3
3
|
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { listHosts, readSshConfig } from './core/ssh-config.mjs';
|
|
5
6
|
import { PresenceConnection } from './core/connection.mjs';
|
|
6
7
|
|
|
@@ -9,15 +10,50 @@ const MAX_TERMINAL_BUFFER = 256 * 1024;
|
|
|
9
10
|
// started: give the wrapper a moment, then check the process group.
|
|
10
11
|
const JOB_EARLY_CHECK_MS = 800;
|
|
11
12
|
const JOB_EARLY_TAIL_LINES = 20;
|
|
12
|
-
// Finished jobs pile up in the local registry
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// Finished jobs pile up in the local registry and in the JSON store. Prune at
|
|
14
|
+
// most once a minute from `listJobs`; the 0.20 store keeps a month of finished
|
|
15
|
+
// jobs (a run is worth coming back to after a restart), bounded to 500 rows.
|
|
15
16
|
const JOB_PRUNE_INTERVAL_MS = 60_000;
|
|
16
|
-
const JOB_PRUNE_MAX_AGE_MS =
|
|
17
|
-
const JOB_PRUNE_MAX_ENTRIES =
|
|
17
|
+
const JOB_PRUNE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
|
+
const JOB_PRUNE_MAX_ENTRIES = 500;
|
|
19
|
+
// A transient connect failure is retried twice, with these gaps, and a dead
|
|
20
|
+
// cached connection is dropped before each retry.
|
|
21
|
+
const CONNECT_RETRY_BACKOFF_MS = [200, 600];
|
|
22
|
+
// How long a failure keeps its edge on the host state: recent enough to matter,
|
|
23
|
+
// old enough to clear itself once the connection has been healthy again.
|
|
24
|
+
const HOST_DEGRADED_WINDOW_MS = 60_000;
|
|
18
25
|
|
|
19
26
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
27
|
|
|
28
|
+
// Auth, permission and unknown-host failures are configuration problems a retry
|
|
29
|
+
// cannot fix; everything network-shaped is worth another attempt.
|
|
30
|
+
const FATAL_CONNECTION_ERROR =
|
|
31
|
+
/no host "|authentication failed|authentication methods|passphrase-protected|password requested|permission denied|access denied|not authorized/i;
|
|
32
|
+
const TRANSIENT_CONNECTION_ERROR =
|
|
33
|
+
/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;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a failed connection attempt is worth retrying.
|
|
37
|
+
*
|
|
38
|
+
* `transient` covers the network-shaped failures (DNS, refused, timeout,
|
|
39
|
+
* dropped channel, reset); `fatal` covers configuration errors a second attempt
|
|
40
|
+
* cannot fix (authentication, permissions, an alias that is not configured) and
|
|
41
|
+
* is also the default for an unrecognised error, so a retry never masks a real
|
|
42
|
+
* problem.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} error
|
|
45
|
+
* @returns {'transient'|'fatal'}
|
|
46
|
+
*/
|
|
47
|
+
export function classifyConnectionError(error) {
|
|
48
|
+
const message = String(error?.message ?? error ?? '');
|
|
49
|
+
const code = String(error?.code ?? '');
|
|
50
|
+
// ssh2 tags authentication failures with this level even before describeSshError.
|
|
51
|
+
if (String(error?.level ?? '') === 'client-authentication') return 'fatal';
|
|
52
|
+
if (FATAL_CONNECTION_ERROR.test(message)) return 'fatal';
|
|
53
|
+
if (TRANSIENT_CONNECTION_ERROR.test(`${code} ${message}`)) return 'transient';
|
|
54
|
+
return 'fatal';
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
export class PresencePool {
|
|
22
58
|
#settings;
|
|
23
59
|
#connections = new Map();
|
|
@@ -25,18 +61,34 @@ export class PresencePool {
|
|
|
25
61
|
#terminals = new Map();
|
|
26
62
|
#tunnels = new Map();
|
|
27
63
|
#jobs = new Map();
|
|
64
|
+
#jobStore = null;
|
|
28
65
|
#lastUsed = new Map();
|
|
29
66
|
#lastPruneAt = 0;
|
|
30
67
|
#secrets = new Map();
|
|
31
68
|
#connectionHandlers = new Set();
|
|
69
|
+
#hostStates = new Map();
|
|
32
70
|
#hostsCache;
|
|
33
71
|
#reaper;
|
|
34
72
|
#counter = 0;
|
|
35
73
|
|
|
36
|
-
|
|
74
|
+
/**
|
|
75
|
+
* @param settings - resolved plugin config.
|
|
76
|
+
* @param options.jobStore - optional persistent job registry (`JobStore`, or
|
|
77
|
+
* any object with `list/upsert/remove/prune`). It is captured once and
|
|
78
|
+
* never replaced, so persistence cannot be attached halfway through a run.
|
|
79
|
+
*/
|
|
80
|
+
constructor(settings, { jobStore = null } = {}) {
|
|
37
81
|
this.#settings = settings;
|
|
82
|
+
this.#jobStore = jobStore;
|
|
83
|
+
this.#loadPersistedJobs();
|
|
38
84
|
this.#reaper = setInterval(() => this.#reapIdle(), 60_000);
|
|
39
85
|
this.#reaper.unref?.();
|
|
86
|
+
// A job started before a restart is `unknown` until the machine is asked;
|
|
87
|
+
// the first successful connection is exactly that chance. Fire-and-forget:
|
|
88
|
+
// a slow probe must never delay or fail `connection()`.
|
|
89
|
+
this.onConnectionEstablished((host) => {
|
|
90
|
+
this.#refreshPersistedJobs(host).catch(() => {});
|
|
91
|
+
});
|
|
40
92
|
}
|
|
41
93
|
|
|
42
94
|
get settings() {
|
|
@@ -50,20 +102,51 @@ export class PresencePool {
|
|
|
50
102
|
this.#hostsCache = { at: now, list: listHosts() };
|
|
51
103
|
}
|
|
52
104
|
return this.#hostsCache.list.map((entry) => {
|
|
53
|
-
const
|
|
105
|
+
const state = this.hostState(entry.alias);
|
|
54
106
|
return {
|
|
55
107
|
alias: entry.alias,
|
|
56
108
|
hostname: entry.hostname,
|
|
57
109
|
port: entry.port,
|
|
58
110
|
user: entry.user,
|
|
59
111
|
source: entry.source ?? 'config',
|
|
60
|
-
connected:
|
|
112
|
+
connected: state.connected,
|
|
113
|
+
state: state.state,
|
|
114
|
+
lastError: state.lastError,
|
|
61
115
|
identityFile: entry.identityFile?.[0],
|
|
62
116
|
terminals: [...this.#terminals.values()].filter((terminal) => terminal.host === entry.alias).length,
|
|
63
117
|
};
|
|
64
118
|
});
|
|
65
119
|
}
|
|
66
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Current trust state of one machine.
|
|
123
|
+
*
|
|
124
|
+
* `connected` is the cached connection; `degraded` means that connection is
|
|
125
|
+
* usable but a failure happened in the last minute; `error` means the last
|
|
126
|
+
* attempt failed and nothing is cached; `idle` means no connection and no
|
|
127
|
+
* recorded failure. `retries` is how many retries the last attempt needed.
|
|
128
|
+
*/
|
|
129
|
+
hostState(alias) {
|
|
130
|
+
const name = String(alias ?? '');
|
|
131
|
+
const connection = this.#connections.get(name);
|
|
132
|
+
const connected = Boolean(connection && !connection.closed);
|
|
133
|
+
const record = this.#hostStates.get(name) ?? null;
|
|
134
|
+
const failedAt = record?.lastErrorAt ?? null;
|
|
135
|
+
const recent = failedAt !== null && Date.now() - failedAt <= HOST_DEGRADED_WINDOW_MS;
|
|
136
|
+
let state;
|
|
137
|
+
if (connected) state = recent ? 'degraded' : 'connected';
|
|
138
|
+
else if (failedAt !== null) state = 'error';
|
|
139
|
+
else state = 'idle';
|
|
140
|
+
return {
|
|
141
|
+
alias: name,
|
|
142
|
+
connected,
|
|
143
|
+
state,
|
|
144
|
+
lastError: record?.lastError ?? null,
|
|
145
|
+
lastErrorAt: record?.lastErrorAt ?? null,
|
|
146
|
+
retries: record?.retries ?? 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
67
150
|
/** Resolve a live connection, opening one if needed. */
|
|
68
151
|
async connection(host, { password, passphrase } = {}) {
|
|
69
152
|
const existing = this.#connections.get(host);
|
|
@@ -71,6 +154,9 @@ export class PresencePool {
|
|
|
71
154
|
this.#lastUsed.set(host, Date.now());
|
|
72
155
|
return existing;
|
|
73
156
|
}
|
|
157
|
+
// A cached connection that died between calls is not reusable: drop it (and
|
|
158
|
+
// anything that hung off it) before dialling again.
|
|
159
|
+
if (existing) this.#dropConnection(host);
|
|
74
160
|
if (this.#connecting.has(host)) return this.#connecting.get(host);
|
|
75
161
|
|
|
76
162
|
const entry = readSshConfig().get(host);
|
|
@@ -92,20 +178,72 @@ export class PresencePool {
|
|
|
92
178
|
if (password || passphrase) this.#secrets.set(host, { password, passphrase });
|
|
93
179
|
const secret = this.#secrets.get(host) ?? (envPassword ? { password: envPassword } : {});
|
|
94
180
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
181
|
+
// The whole retry sequence is one shared promise, so concurrent callers do
|
|
182
|
+
// not dial the same machine three times over.
|
|
183
|
+
const pending = this.#connectWithRetry(host, entry, secret).finally(() => this.#connecting.delete(host));
|
|
184
|
+
this.#connecting.set(host, pending);
|
|
185
|
+
return pending;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Open one connection, retrying transient failures. Auth/permission/unknown
|
|
190
|
+
* host errors are configuration problems and fail on the first attempt; a
|
|
191
|
+
* network-shaped failure is retried (200 ms, 600 ms apart) after dropping the
|
|
192
|
+
* dead cached connection, and the final error is the last one observed.
|
|
193
|
+
*/
|
|
194
|
+
async #connectWithRetry(host, entry, secret) {
|
|
195
|
+
const previous = this.#hostStates.get(host) ?? {};
|
|
196
|
+
const state = {
|
|
197
|
+
lastError: previous.lastError ?? null,
|
|
198
|
+
lastErrorAt: previous.lastErrorAt ?? null,
|
|
199
|
+
retries: 0,
|
|
200
|
+
};
|
|
201
|
+
let lastError = null;
|
|
202
|
+
for (let attempt = 0; attempt <= CONNECT_RETRY_BACKOFF_MS.length; attempt += 1) {
|
|
203
|
+
if (attempt > 0) {
|
|
204
|
+
this.#dropConnection(host);
|
|
205
|
+
state.retries = attempt;
|
|
206
|
+
await sleep(CONNECT_RETRY_BACKOFF_MS[attempt - 1]);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const connection = await PresenceConnection.connect(
|
|
210
|
+
{ ...entry, maxChannels: this.#settings.maxChannels },
|
|
211
|
+
{ password: secret.password, passphrase: secret.passphrase, timeoutMs: this.#settings.connectTimeoutMs },
|
|
212
|
+
);
|
|
100
213
|
this.#connections.set(host, connection);
|
|
101
214
|
this.#lastUsed.set(host, Date.now());
|
|
215
|
+
// Keep the failure that led here: a host that needed a retry is usable
|
|
216
|
+
// but degraded for the next minute.
|
|
217
|
+
this.#hostStates.set(host, state);
|
|
102
218
|
this.#notifyConnectionEstablished(host, connection);
|
|
103
219
|
return connection;
|
|
104
|
-
})
|
|
105
|
-
|
|
220
|
+
} catch (error) {
|
|
221
|
+
lastError = error;
|
|
222
|
+
state.lastError = String(error?.message ?? error);
|
|
223
|
+
state.lastErrorAt = Date.now();
|
|
224
|
+
if (classifyConnectionError(error) !== 'transient' || attempt === CONNECT_RETRY_BACKOFF_MS.length) {
|
|
225
|
+
this.#hostStates.set(host, state);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.#hostStates.set(host, state);
|
|
231
|
+
throw lastError ?? new Error(`could not connect to ${host}`);
|
|
232
|
+
}
|
|
106
233
|
|
|
107
|
-
|
|
108
|
-
|
|
234
|
+
/** Forget a cached connection and close anything that hung off it. */
|
|
235
|
+
#dropConnection(host) {
|
|
236
|
+
const connection = this.#connections.get(host);
|
|
237
|
+
this.#connections.delete(host);
|
|
238
|
+
this.#lastUsed.delete(host);
|
|
239
|
+
if (!connection) return;
|
|
240
|
+
try {
|
|
241
|
+
connection.end();
|
|
242
|
+
} catch {
|
|
243
|
+
/* already gone */
|
|
244
|
+
}
|
|
245
|
+
// A forward whose SSH connection is gone still owns a local listener.
|
|
246
|
+
this.#closeTunnelsForHost(host).catch(() => {});
|
|
109
247
|
}
|
|
110
248
|
|
|
111
249
|
/**
|
|
@@ -141,6 +279,8 @@ export class PresencePool {
|
|
|
141
279
|
const connection = this.#connections.get(host);
|
|
142
280
|
this.#connections.delete(host);
|
|
143
281
|
this.#lastUsed.delete(host);
|
|
282
|
+
// A deliberate disconnect is not a failure: the host goes back to `idle`.
|
|
283
|
+
this.#hostStates.delete(host);
|
|
144
284
|
for (const terminal of [...this.#terminals.values()]) {
|
|
145
285
|
if (terminal.host === host) this.#closeTerminal(terminal.id);
|
|
146
286
|
}
|
|
@@ -249,12 +389,19 @@ export class PresencePool {
|
|
|
249
389
|
if (!command) throw new Error('command is required');
|
|
250
390
|
const connection = await this.connection(host);
|
|
251
391
|
const started = await connection.startBackground(command, { cwd, logPath });
|
|
252
|
-
const id =
|
|
392
|
+
const id = this.#nextJobId();
|
|
393
|
+
const pid = Number(started.pid);
|
|
253
394
|
const job = {
|
|
254
395
|
id,
|
|
255
396
|
host,
|
|
256
|
-
pid
|
|
397
|
+
pid,
|
|
398
|
+
pgid: Number.isFinite(Number(started.pgid)) ? Number(started.pgid) : pid,
|
|
257
399
|
logPath: started.logPath,
|
|
400
|
+
// The 0.20 factory returns a distinct command-output log when the
|
|
401
|
+
// wrapper exposes one; the current kernel returns only `logPath`, which
|
|
402
|
+
// IS the command's stdout/stderr log. Keep the public field non-null and
|
|
403
|
+
// truthful either way (it equals logPath on today's kernels).
|
|
404
|
+
commandLogPath: started.commandLogPath ?? started.logPath ?? null,
|
|
258
405
|
statusPath: started.statusPath,
|
|
259
406
|
command,
|
|
260
407
|
cwd,
|
|
@@ -268,13 +415,29 @@ export class PresencePool {
|
|
|
268
415
|
logSize: null,
|
|
269
416
|
logTail: '',
|
|
270
417
|
stopped: false,
|
|
418
|
+
persisted: false,
|
|
419
|
+
lastPersistedJson: null,
|
|
271
420
|
};
|
|
272
421
|
this.#jobs.set(id, job);
|
|
422
|
+
// Persist before the early-death check: a job that dies instantly must
|
|
423
|
+
// still leave a record, otherwise a restart cannot explain it.
|
|
424
|
+
this.#syncJob(job);
|
|
273
425
|
|
|
274
426
|
await sleep(JOB_EARLY_CHECK_MS);
|
|
275
427
|
const alive = await this.#groupAlive(connection, job.pid);
|
|
276
428
|
if (alive !== false) {
|
|
277
|
-
|
|
429
|
+
this.#syncJob(job);
|
|
430
|
+
return {
|
|
431
|
+
id,
|
|
432
|
+
host,
|
|
433
|
+
pid: job.pid,
|
|
434
|
+
pgid: job.pgid,
|
|
435
|
+
logPath: job.logPath,
|
|
436
|
+
commandLogPath: job.commandLogPath,
|
|
437
|
+
statusPath: job.statusPath,
|
|
438
|
+
label: job.label,
|
|
439
|
+
sessionId: job.sessionId,
|
|
440
|
+
};
|
|
278
441
|
}
|
|
279
442
|
const status = await connection.backgroundStatus(job.statusPath);
|
|
280
443
|
const output = await this.#readOutput(connection, job, JOB_EARLY_TAIL_LINES);
|
|
@@ -283,11 +446,14 @@ export class PresencePool {
|
|
|
283
446
|
job.exitedAt = Date.now();
|
|
284
447
|
job.logSize = output.logSize;
|
|
285
448
|
job.logTail = output.logTail;
|
|
449
|
+
this.#syncJob(job);
|
|
286
450
|
return {
|
|
287
451
|
id,
|
|
288
452
|
host,
|
|
289
453
|
pid: job.pid,
|
|
454
|
+
pgid: job.pgid,
|
|
290
455
|
logPath: job.logPath,
|
|
456
|
+
commandLogPath: job.commandLogPath,
|
|
291
457
|
statusPath: job.statusPath,
|
|
292
458
|
label: job.label,
|
|
293
459
|
sessionId: job.sessionId,
|
|
@@ -334,6 +500,7 @@ export class PresencePool {
|
|
|
334
500
|
job.exitedAt ??= Date.now();
|
|
335
501
|
job.logSize = output.logSize ?? job.logSize;
|
|
336
502
|
job.logTail = output.logTail;
|
|
503
|
+
this.#syncJob(job);
|
|
337
504
|
return this.#jobResult(job, {
|
|
338
505
|
state: 'exited',
|
|
339
506
|
exitCode: status.exitCode,
|
|
@@ -354,6 +521,7 @@ export class PresencePool {
|
|
|
354
521
|
job.exitedAt ??= Date.now();
|
|
355
522
|
job.logSize = output.logSize ?? job.logSize;
|
|
356
523
|
job.logTail = output.logTail;
|
|
524
|
+
this.#syncJob(job);
|
|
357
525
|
return this.#jobResult(job, {
|
|
358
526
|
state: 'exited',
|
|
359
527
|
exitCode: null,
|
|
@@ -396,7 +564,9 @@ export class PresencePool {
|
|
|
396
564
|
id: job?.id ?? null,
|
|
397
565
|
host: target,
|
|
398
566
|
pid: numeric,
|
|
567
|
+
pgid: job?.pgid ?? numeric,
|
|
399
568
|
logPath: job?.logPath ?? null,
|
|
569
|
+
commandLogPath: job?.commandLogPath ?? null,
|
|
400
570
|
stopped: result.stopped,
|
|
401
571
|
wasAlive: result.wasAlive,
|
|
402
572
|
reason: result.reason ?? null,
|
|
@@ -412,7 +582,9 @@ export class PresencePool {
|
|
|
412
582
|
id,
|
|
413
583
|
host: job.host,
|
|
414
584
|
pid: job.pid,
|
|
585
|
+
pgid: job.pgid ?? job.pid ?? null,
|
|
415
586
|
logPath: job.logPath,
|
|
587
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
416
588
|
stopped: result.stopped,
|
|
417
589
|
wasAlive: result.wasAlive,
|
|
418
590
|
reason: result.reason ?? null,
|
|
@@ -454,6 +626,7 @@ export class PresencePool {
|
|
|
454
626
|
job.exitedAt ??= Date.now();
|
|
455
627
|
job.exitCode = result.exitCode ?? job.exitCode ?? null;
|
|
456
628
|
}
|
|
629
|
+
this.#syncJob(job);
|
|
457
630
|
}
|
|
458
631
|
return {
|
|
459
632
|
host,
|
|
@@ -469,10 +642,11 @@ export class PresencePool {
|
|
|
469
642
|
}
|
|
470
643
|
|
|
471
644
|
/**
|
|
472
|
-
* Drop
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
645
|
+
* Drop bookkeeping for jobs that are finished and past their welcome: older
|
|
646
|
+
* than `maxAgeMs`, or beyond the newest `maxEntries`. Only EXITED jobs are
|
|
647
|
+
* dropped — a running (or still unknown) job keeps its slot no matter how old
|
|
648
|
+
* it is, and the detached process on the machine is never touched. The
|
|
649
|
+
* persisted store is bounded by the same limits.
|
|
476
650
|
* Returns the ids that were forgotten.
|
|
477
651
|
*/
|
|
478
652
|
async pruneJobs({ maxAgeMs = JOB_PRUNE_MAX_AGE_MS, maxEntries = JOB_PRUNE_MAX_ENTRIES } = {}) {
|
|
@@ -492,15 +666,42 @@ export class PresencePool {
|
|
|
492
666
|
if (job.state === 'exited') dropped.add(job.id);
|
|
493
667
|
}
|
|
494
668
|
for (const id of dropped) this.#jobs.delete(id);
|
|
669
|
+
if (this.#jobStore) {
|
|
670
|
+
// The store applies the same age/count bounds; a record it drops beyond
|
|
671
|
+
// the local view is forgotten here too, so the two cannot drift apart.
|
|
672
|
+
let pruned = { removed: 0, ids: [] };
|
|
673
|
+
try {
|
|
674
|
+
pruned = this.#jobStore.prune({ maxAgeMs: age, maxEntries: limit }) ?? pruned;
|
|
675
|
+
} catch {
|
|
676
|
+
/* persistence is an optimization; a broken store must not fail a prune */
|
|
677
|
+
}
|
|
678
|
+
for (const id of Array.isArray(pruned.ids) ? pruned.ids : []) {
|
|
679
|
+
this.#jobs.delete(id);
|
|
680
|
+
dropped.add(id);
|
|
681
|
+
}
|
|
682
|
+
// Only records the store's own bound did not already drop need a second
|
|
683
|
+
// write; normally this set is empty (one save, not one per dropped job).
|
|
684
|
+
const prunedIds = new Set(Array.isArray(pruned.ids) ? pruned.ids : []);
|
|
685
|
+
for (const id of [...dropped]) {
|
|
686
|
+
if (prunedIds.has(id)) continue;
|
|
687
|
+
try {
|
|
688
|
+
this.#jobStore.remove(id);
|
|
689
|
+
} catch {
|
|
690
|
+
/* already gone */
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
495
694
|
this.#lastPruneAt = now;
|
|
496
695
|
return { removed: dropped.size, ids: [...dropped] };
|
|
497
696
|
}
|
|
498
697
|
|
|
499
698
|
/**
|
|
500
|
-
* Jobs this pool started,
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
699
|
+
* Jobs this pool started, plus records inherited from the persisted store,
|
|
700
|
+
* optionally narrowed to one session, with liveness read from the machines
|
|
701
|
+
* connected right now. A record loaded from disk is `unknown` until a pid
|
|
702
|
+
* probe says otherwise: a detached job outlives its SSH connection, so no
|
|
703
|
+
* answer is not the same as a dead job. A record says `persisted: true` when
|
|
704
|
+
* it came from the JSON store rather than from this process.
|
|
504
705
|
*/
|
|
505
706
|
async listJobs({ sessionId = undefined } = {}) {
|
|
506
707
|
// Local bookkeeping only, so no round trip; at most once a minute so a
|
|
@@ -508,6 +709,9 @@ export class PresencePool {
|
|
|
508
709
|
if (Date.now() - this.#lastPruneAt >= JOB_PRUNE_INTERVAL_MS) {
|
|
509
710
|
await this.pruneJobs().catch(() => {});
|
|
510
711
|
}
|
|
712
|
+
// A record written by another process (or by a previous run) joins the view
|
|
713
|
+
// as `unknown`; the per-host liveness pass below may resolve it.
|
|
714
|
+
this.#mergePersistedJobs();
|
|
511
715
|
const jobs = [...this.#jobs.values()].filter((job) => sessionId === undefined || job.sessionId === sessionId);
|
|
512
716
|
if (jobs.length === 0) return [];
|
|
513
717
|
|
|
@@ -516,90 +720,228 @@ export class PresencePool {
|
|
|
516
720
|
if (!byHost.has(job.host)) byHost.set(job.host, []);
|
|
517
721
|
byHost.get(job.host).push(job);
|
|
518
722
|
}
|
|
723
|
+
await Promise.all([...byHost.entries()].map(([host, hostJobs]) => this.#probeHostJobs(host, hostJobs)));
|
|
724
|
+
return jobs.map((job) => this.#jobRow(job));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ------------------------------------------------- job registry internals
|
|
519
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Ask one host about a group of jobs: seed its process registry, read the
|
|
731
|
+
* whole process table once and every status file once. Live jobs keep their
|
|
732
|
+
* state; a record loaded from disk is only promoted to `running` when `ps`
|
|
733
|
+
* actually finds its process group.
|
|
734
|
+
*/
|
|
735
|
+
async #probeHostJobs(host, hostJobs) {
|
|
736
|
+
const connection = this.#connections.get(host);
|
|
737
|
+
if (!connection || connection.closed) {
|
|
738
|
+
for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
|
|
739
|
+
for (const job of hostJobs) this.#syncJob(job);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// A job started before a reconnect is not in this connection's registry;
|
|
743
|
+
// its process group still exists on the machine, so seed it back in.
|
|
744
|
+
for (const job of hostJobs) {
|
|
745
|
+
if (job.stopped || job.state === 'exited' || job.pid === null || job.pid === undefined) continue;
|
|
746
|
+
connection.trackBackground({
|
|
747
|
+
pid: job.pid,
|
|
748
|
+
command: job.command,
|
|
749
|
+
cwd: job.cwd,
|
|
750
|
+
logPath: job.logPath,
|
|
751
|
+
statusPath: job.statusPath,
|
|
752
|
+
startedAt: job.startedAt,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
let running;
|
|
756
|
+
try {
|
|
757
|
+
running = new Set((await connection.listBackground()).map((entry) => entry.pid));
|
|
758
|
+
} catch {
|
|
759
|
+
// Liveness is a convenience; a failed `ps` must not hide the job list.
|
|
760
|
+
running = null;
|
|
761
|
+
}
|
|
762
|
+
if (!running) {
|
|
763
|
+
for (const job of hostJobs) if (job.state !== 'exited') job.state = 'unknown';
|
|
764
|
+
for (const job of hostJobs) this.#syncJob(job);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
// One round trip for every status file on this host: the command may have
|
|
768
|
+
// finished while a child it spawned kept the group alive.
|
|
769
|
+
let statuses = null;
|
|
770
|
+
try {
|
|
771
|
+
statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath ?? ''));
|
|
772
|
+
} catch {
|
|
773
|
+
// Liveness from `ps` is still worth reporting.
|
|
774
|
+
}
|
|
520
775
|
await Promise.all(
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
let running;
|
|
541
|
-
try {
|
|
542
|
-
running = new Set((await connection.listBackground()).map((entry) => entry.pid));
|
|
543
|
-
} catch {
|
|
544
|
-
// Liveness is a convenience; a failed `ps` must not hide the job list.
|
|
545
|
-
running = null;
|
|
776
|
+
hostJobs.map(async (job, index) => {
|
|
777
|
+
const status = statuses?.[index] ?? null;
|
|
778
|
+
if (job.stopped) {
|
|
779
|
+
job.state = 'exited';
|
|
780
|
+
job.exitedAt ??= Date.now();
|
|
781
|
+
} else if (status?.found) {
|
|
782
|
+
job.state = 'exited';
|
|
783
|
+
job.exitCode = status.exitCode;
|
|
784
|
+
job.exitedAt ??= Date.now();
|
|
785
|
+
} else if (job.state === 'exited') {
|
|
786
|
+
// Already reported finished; a recycled pgid must not resurrect it.
|
|
787
|
+
job.exitedAt ??= Date.now();
|
|
788
|
+
} else if (job.pid !== null && job.pid !== undefined && running.has(job.pid)) {
|
|
789
|
+
job.state = 'running';
|
|
790
|
+
} else {
|
|
791
|
+
// No wrapper left to write a status: it was killed.
|
|
792
|
+
job.state = 'exited';
|
|
793
|
+
job.exitCode = null;
|
|
794
|
+
job.exitedAt ??= Date.now();
|
|
546
795
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
// One round trip for every status file on this host: the command may
|
|
552
|
-
// have finished while a child it spawned kept the group alive.
|
|
553
|
-
let statuses = null;
|
|
554
|
-
try {
|
|
555
|
-
statuses = await connection.backgroundStatuses(hostJobs.map((job) => job.statusPath));
|
|
556
|
-
} catch {
|
|
557
|
-
// Liveness from `ps` is still worth reporting.
|
|
558
|
-
}
|
|
559
|
-
await Promise.all(
|
|
560
|
-
hostJobs.map(async (job, index) => {
|
|
561
|
-
const status = statuses?.[index] ?? null;
|
|
562
|
-
if (job.stopped) {
|
|
563
|
-
job.state = 'exited';
|
|
564
|
-
job.exitedAt ??= Date.now();
|
|
565
|
-
} else if (status?.found) {
|
|
566
|
-
job.state = 'exited';
|
|
567
|
-
job.exitCode = status.exitCode;
|
|
568
|
-
job.exitedAt ??= Date.now();
|
|
569
|
-
} else if (job.state === 'exited') {
|
|
570
|
-
// Already reported finished; a recycled pgid must not resurrect it.
|
|
571
|
-
job.exitedAt ??= Date.now();
|
|
572
|
-
} else if (running.has(job.pid)) {
|
|
573
|
-
job.state = 'running';
|
|
574
|
-
} else {
|
|
575
|
-
// No wrapper left to write a status: it was killed.
|
|
576
|
-
job.state = 'exited';
|
|
577
|
-
job.exitCode = null;
|
|
578
|
-
job.exitedAt ??= Date.now();
|
|
579
|
-
}
|
|
580
|
-
const info = await connection.stat(job.logPath).catch(() => null);
|
|
581
|
-
if (info?.exists) job.logSize = info.size;
|
|
582
|
-
}),
|
|
583
|
-
);
|
|
796
|
+
const info = job.logPath ? await connection.stat(job.logPath).catch(() => null) : null;
|
|
797
|
+
if (info?.exists) job.logSize = info.size;
|
|
798
|
+
this.#syncJob(job);
|
|
584
799
|
}),
|
|
585
800
|
);
|
|
801
|
+
}
|
|
586
802
|
|
|
587
|
-
|
|
803
|
+
/** One public job row (live view, no remote probe). */
|
|
804
|
+
#jobRow(job) {
|
|
805
|
+
return {
|
|
588
806
|
id: job.id,
|
|
589
807
|
host: job.host,
|
|
590
|
-
pid: job.pid,
|
|
808
|
+
pid: job.pid ?? null,
|
|
809
|
+
pgid: job.pgid ?? job.pid ?? null,
|
|
591
810
|
state: job.state ?? 'unknown',
|
|
592
811
|
exitCode: job.exitCode ?? null,
|
|
593
812
|
exitSignal: job.exitSignal ?? null,
|
|
813
|
+
exitedAt: job.exitedAt ?? null,
|
|
594
814
|
label: job.label ?? null,
|
|
595
815
|
sessionId: job.sessionId ?? null,
|
|
596
|
-
startedAt: job.startedAt,
|
|
597
|
-
logPath: job.logPath,
|
|
816
|
+
startedAt: job.startedAt ?? null,
|
|
817
|
+
logPath: job.logPath ?? null,
|
|
818
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
598
819
|
logSize: job.logSize ?? null,
|
|
820
|
+
persisted: job.persisted === true,
|
|
599
821
|
// Kept for the existing jobs view, which still prints the command line.
|
|
600
|
-
command: job.command,
|
|
601
|
-
cwd: job.cwd,
|
|
602
|
-
}
|
|
822
|
+
command: job.command ?? null,
|
|
823
|
+
cwd: job.cwd ?? null,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Next job id. The plain `jN` counter keeps ids readable; when a store is
|
|
829
|
+
* attached a short random suffix keeps two Harness windows that share the
|
|
830
|
+
* same jobs.json from overwriting each other's records.
|
|
831
|
+
*/
|
|
832
|
+
#nextJobId() {
|
|
833
|
+
this.#counter += 1;
|
|
834
|
+
const base = `j${this.#counter}`;
|
|
835
|
+
return this.#jobStore ? `${base}-${randomUUID().slice(0, 4)}` : base;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#numericSuffix(id) {
|
|
839
|
+
const match = /^j(\d+)/u.exec(String(id ?? ''));
|
|
840
|
+
return match ? Number(match[1]) : 0;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Turn one stored record into an in-memory job; never assumes it is running. */
|
|
844
|
+
#adoptPersistedRecord(record) {
|
|
845
|
+
if (!record || typeof record !== 'object' || !record.id || this.#jobs.has(record.id)) return null;
|
|
846
|
+
const pid = Number.isFinite(Number(record.pid)) ? Number(record.pid) : null;
|
|
847
|
+
const pgid = Number.isFinite(Number(record.pgid)) ? Number(record.pgid) : pid;
|
|
848
|
+
const job = {
|
|
849
|
+
id: record.id,
|
|
850
|
+
host: record.host,
|
|
851
|
+
pid,
|
|
852
|
+
pgid,
|
|
853
|
+
logPath: record.logPath ?? null,
|
|
854
|
+
commandLogPath: record.commandLogPath ?? null,
|
|
855
|
+
statusPath: record.statusPath ?? null,
|
|
856
|
+
command: record.command ?? null,
|
|
857
|
+
cwd: record.cwd ?? null,
|
|
858
|
+
label: record.label ?? null,
|
|
859
|
+
sessionId: record.sessionId ?? null,
|
|
860
|
+
startedAt: record.startedAt ?? null,
|
|
861
|
+
// A finished record stays finished; anything else is `unknown` until a
|
|
862
|
+
// liveness probe proves the process group still exists.
|
|
863
|
+
state: record.state === 'exited' ? 'exited' : 'unknown',
|
|
864
|
+
exitCode: record.exitCode ?? null,
|
|
865
|
+
exitSignal: record.exitSignal ?? null,
|
|
866
|
+
exitedAt: record.exitedAt ?? null,
|
|
867
|
+
logSize: null,
|
|
868
|
+
logTail: '',
|
|
869
|
+
stopped: false,
|
|
870
|
+
persisted: true,
|
|
871
|
+
lastPersistedJson: null,
|
|
872
|
+
};
|
|
873
|
+
this.#jobs.set(job.id, job);
|
|
874
|
+
this.#counter = Math.max(this.#counter, this.#numericSuffix(job.id));
|
|
875
|
+
return job;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#storeRecords() {
|
|
879
|
+
const store = this.#jobStore;
|
|
880
|
+
if (!store || typeof store.list !== 'function') return [];
|
|
881
|
+
try {
|
|
882
|
+
const records = store.list();
|
|
883
|
+
return Array.isArray(records) ? records : [];
|
|
884
|
+
} catch {
|
|
885
|
+
return [];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Load the registry at startup; records are visible but not trusted as live. */
|
|
890
|
+
#loadPersistedJobs() {
|
|
891
|
+
for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Pick up records a second process (or an earlier prune) wrote meanwhile. */
|
|
895
|
+
#mergePersistedJobs() {
|
|
896
|
+
for (const record of this.#storeRecords()) this.#adoptPersistedRecord(record);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* First connection to a host after a restart: resolve that machine's
|
|
901
|
+
* persisted jobs instead of leaving them `unknown` until the next listing.
|
|
902
|
+
* Called fire-and-forget from the connection handler; never affects dialling.
|
|
903
|
+
*/
|
|
904
|
+
async #refreshPersistedJobs(host) {
|
|
905
|
+
const pending = [...this.#jobs.values()].filter(
|
|
906
|
+
(job) => job.host === host && job.persisted === true && job.state !== 'exited',
|
|
907
|
+
);
|
|
908
|
+
if (pending.length === 0) return;
|
|
909
|
+
await this.#probeHostJobs(host, pending);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Write one job through to the JSON store, skipping unchanged records: the
|
|
914
|
+
* state poll would otherwise rewrite the file every couple of seconds.
|
|
915
|
+
*/
|
|
916
|
+
#syncJob(job) {
|
|
917
|
+
const store = this.#jobStore;
|
|
918
|
+
if (!store || typeof store.upsert !== 'function') return;
|
|
919
|
+
const record = {
|
|
920
|
+
id: job.id,
|
|
921
|
+
host: job.host,
|
|
922
|
+
label: job.label ?? null,
|
|
923
|
+
command: job.command ?? null,
|
|
924
|
+
sessionId: job.sessionId ?? null,
|
|
925
|
+
pid: job.pid ?? null,
|
|
926
|
+
pgid: job.pgid ?? job.pid ?? null,
|
|
927
|
+
logPath: job.logPath ?? null,
|
|
928
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
929
|
+
statusPath: job.statusPath ?? null,
|
|
930
|
+
cwd: job.cwd ?? null,
|
|
931
|
+
startedAt: job.startedAt ?? null,
|
|
932
|
+
exitedAt: job.exitedAt ?? null,
|
|
933
|
+
exitCode: job.exitCode ?? null,
|
|
934
|
+
exitSignal: job.exitSignal ?? null,
|
|
935
|
+
state: job.state ?? 'unknown',
|
|
936
|
+
};
|
|
937
|
+
const json = JSON.stringify(record);
|
|
938
|
+
if (json === job.lastPersistedJson) return;
|
|
939
|
+
try {
|
|
940
|
+
store.upsert(record);
|
|
941
|
+
job.lastPersistedJson = json;
|
|
942
|
+
} catch {
|
|
943
|
+
/* a read-only home must never fail a tool; the live job still works */
|
|
944
|
+
}
|
|
603
945
|
}
|
|
604
946
|
|
|
605
947
|
/**
|
|
@@ -632,7 +974,11 @@ export class PresencePool {
|
|
|
632
974
|
id: job.id,
|
|
633
975
|
host: job.host,
|
|
634
976
|
pid: job.pid,
|
|
977
|
+
pgid: job.pgid ?? job.pid ?? null,
|
|
978
|
+
// The wrapper log (exit status) and, when the kernel exposes one, the
|
|
979
|
+
// command's own stdout/stderr log. Older kernels only have `logPath`.
|
|
635
980
|
logPath: job.logPath,
|
|
981
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
636
982
|
statusPath: job.statusPath,
|
|
637
983
|
state,
|
|
638
984
|
exitCode,
|
|
@@ -814,5 +1160,6 @@ export class PresencePool {
|
|
|
814
1160
|
this.#jobs.clear();
|
|
815
1161
|
for (const connection of this.#connections.values()) connection.end();
|
|
816
1162
|
this.#connections.clear();
|
|
1163
|
+
this.#hostStates.clear();
|
|
817
1164
|
}
|
|
818
1165
|
}
|