@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/wait.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Poll one remote condition until it is true or the deadline passes.
|
|
2
|
+
//
|
|
3
|
+
// `presence_wait` has to answer "has the training run written its checkpoint?"
|
|
4
|
+
// without the model writing a sleep/tail loop. Every condition is a small,
|
|
5
|
+
// bounded probe of the machine that is already connected (one stat, one grep,
|
|
6
|
+
// one ps, one TCP probe per poll); nothing is installed on the far side and
|
|
7
|
+
// no polling framework is involved.
|
|
8
|
+
//
|
|
9
|
+
// A timeout is a normal answer, not an error: `met: false, timedOut: true`.
|
|
10
|
+
// Only an unusable condition (unknown kind, missing required field) throws.
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
13
|
+
const MAX_TIMEOUT_MS = 3_600_000;
|
|
14
|
+
const DEFAULT_POLL_MS = 1_000;
|
|
15
|
+
const MIN_POLL_MS = 10;
|
|
16
|
+
const MAX_POLL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
const sleep = (ms, signal) =>
|
|
19
|
+
new Promise((resolve) => {
|
|
20
|
+
if (!(ms > 0)) return resolve();
|
|
21
|
+
if (!signal) {
|
|
22
|
+
setTimeout(resolve, ms);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (signal.aborted) return resolve();
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve();
|
|
29
|
+
};
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
signal.removeEventListener('abort', onAbort);
|
|
32
|
+
resolve();
|
|
33
|
+
}, ms);
|
|
34
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** POSIX single-quote escaping for remote shell commands. */
|
|
38
|
+
function shellQuote(value) {
|
|
39
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function positiveInteger(value, fallback) {
|
|
43
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
44
|
+
const number = Number(value);
|
|
45
|
+
if (!Number.isSafeInteger(number) || number < 1) return fallback;
|
|
46
|
+
return number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `mtimeAfter` accepts the spellings a caller can actually produce: the epoch
|
|
51
|
+
* milliseconds from `presence_stat`, epoch seconds from `date +%s`, an ISO
|
|
52
|
+
* timestamp or a Date. Returns epoch milliseconds, or null when absent.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeTimestamp(value, label) {
|
|
55
|
+
if (value === undefined || value === null || value === '') return null;
|
|
56
|
+
if (value instanceof Date) {
|
|
57
|
+
const at = value.getTime();
|
|
58
|
+
if (!Number.isFinite(at)) throw new Error(`${label} is not a valid date`);
|
|
59
|
+
return at;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'number') {
|
|
62
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite timestamp`);
|
|
63
|
+
// Epoch seconds are ~1.7e9; anything smaller than 1e11 is seconds.
|
|
64
|
+
return value < 1e11 ? Math.floor(value * 1000) : Math.floor(value);
|
|
65
|
+
}
|
|
66
|
+
const text = String(value).trim();
|
|
67
|
+
const numeric = /^\d+(?:\.\d+)?$/.test(text) ? Number(text) : NaN;
|
|
68
|
+
if (Number.isFinite(numeric)) return numeric < 1e11 ? Math.floor(numeric * 1000) : Math.floor(numeric);
|
|
69
|
+
const parsed = Date.parse(text);
|
|
70
|
+
if (!Number.isFinite(parsed)) throw new Error(`${label} is not a valid timestamp: ${JSON.stringify(value)}`);
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requiredString(value, label) {
|
|
75
|
+
const text = typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
|
|
76
|
+
if (text === '') throw new Error(`${label} is required`);
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate and normalize the model-facing condition into the shape the probes
|
|
82
|
+
* below execute. Throws on a condition that can never become true, so a typo is
|
|
83
|
+
* reported at once instead of after a two-minute timeout.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeWaitCondition(input) {
|
|
86
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
87
|
+
throw new Error('a condition object is required (kind: file|grep|process|port|job)');
|
|
88
|
+
}
|
|
89
|
+
const kind = String(input.kind ?? input.type ?? '').trim().toLowerCase();
|
|
90
|
+
switch (kind) {
|
|
91
|
+
case 'file': {
|
|
92
|
+
const path = requiredString(input.path, 'condition.path');
|
|
93
|
+
const exists = input.exists === undefined ? true : input.exists === true;
|
|
94
|
+
let minBytes = null;
|
|
95
|
+
if (input.minBytes !== undefined && input.minBytes !== null && input.minBytes !== '') {
|
|
96
|
+
minBytes = Number(input.minBytes);
|
|
97
|
+
if (!Number.isFinite(minBytes) || minBytes < 0) {
|
|
98
|
+
throw new Error(`condition.minBytes must be a non-negative number, got ${JSON.stringify(input.minBytes)}`);
|
|
99
|
+
}
|
|
100
|
+
minBytes = Math.floor(minBytes);
|
|
101
|
+
}
|
|
102
|
+
const mtimeAfterMs = normalizeTimestamp(input.mtimeAfter, 'condition.mtimeAfter');
|
|
103
|
+
return { kind, path, exists, minBytes, mtimeAfterMs };
|
|
104
|
+
}
|
|
105
|
+
case 'grep': {
|
|
106
|
+
const path = requiredString(input.path, 'condition.path');
|
|
107
|
+
const pattern = requiredString(input.pattern, 'condition.pattern');
|
|
108
|
+
const count = positiveInteger(input.count, 1);
|
|
109
|
+
return { kind, path, pattern, count };
|
|
110
|
+
}
|
|
111
|
+
case 'process': {
|
|
112
|
+
const hasPid = input.pid !== undefined && input.pid !== null && input.pid !== '';
|
|
113
|
+
const hasPattern = input.pattern !== undefined && input.pattern !== null && input.pattern !== '';
|
|
114
|
+
if (hasPid === hasPattern) {
|
|
115
|
+
throw new Error('condition needs exactly one of pid or pattern');
|
|
116
|
+
}
|
|
117
|
+
const alive = input.alive === undefined ? true : input.alive === true;
|
|
118
|
+
if (hasPid) {
|
|
119
|
+
const pid = Number(input.pid);
|
|
120
|
+
if (!Number.isSafeInteger(pid) || pid < 1) {
|
|
121
|
+
throw new Error(`condition.pid must be a positive integer, got ${JSON.stringify(input.pid)}`);
|
|
122
|
+
}
|
|
123
|
+
return { kind, pid, pattern: null, alive };
|
|
124
|
+
}
|
|
125
|
+
return { kind, pid: null, pattern: requiredString(input.pattern, 'condition.pattern'), alive };
|
|
126
|
+
}
|
|
127
|
+
case 'port': {
|
|
128
|
+
const port = Number(input.port);
|
|
129
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
130
|
+
throw new Error(`condition.port must be 1-65535, got ${JSON.stringify(input.port)}`);
|
|
131
|
+
}
|
|
132
|
+
const state = String(input.state ?? 'open').toLowerCase();
|
|
133
|
+
if (state !== 'open' && state !== 'closed') {
|
|
134
|
+
throw new Error(`condition.state must be "open" or "closed", got ${JSON.stringify(input.state)}`);
|
|
135
|
+
}
|
|
136
|
+
const host = typeof input.host === 'string' && input.host.trim() ? input.host.trim() : '127.0.0.1';
|
|
137
|
+
return { kind, port, state, host };
|
|
138
|
+
}
|
|
139
|
+
case 'job': {
|
|
140
|
+
const jobId = requiredString(input.jobId ?? input.id, 'condition.jobId');
|
|
141
|
+
return { kind, jobId };
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
throw new Error(
|
|
145
|
+
`unknown condition kind ${JSON.stringify(kind)} (expected file, grep, process, port or job)`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One probe of a file condition. */
|
|
151
|
+
async function probeFile(connection, condition) {
|
|
152
|
+
const info = await connection.stat(condition.path);
|
|
153
|
+
const exists = Boolean(info?.exists);
|
|
154
|
+
const observed = {
|
|
155
|
+
path: condition.path,
|
|
156
|
+
exists,
|
|
157
|
+
size: exists ? info.size ?? null : null,
|
|
158
|
+
mtime: exists ? info.mtime ?? null : null,
|
|
159
|
+
isDir: exists ? info.isDir ?? null : null,
|
|
160
|
+
};
|
|
161
|
+
if (!condition.exists) return { met: !exists, observed };
|
|
162
|
+
if (!exists) return { met: false, observed };
|
|
163
|
+
let met = true;
|
|
164
|
+
if (condition.minBytes !== null) met = Number(info.size ?? 0) >= condition.minBytes;
|
|
165
|
+
if (met && condition.mtimeAfterMs !== null) met = Number(info.mtime ?? 0) > condition.mtimeAfterMs;
|
|
166
|
+
return { met, observed };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One `grep -c` of a grep condition; a missing file counts as zero matches. */
|
|
170
|
+
async function probeGrep(connection, condition) {
|
|
171
|
+
const result = await connection.exec(
|
|
172
|
+
`grep -c -E -- ${shellQuote(condition.pattern)} ${shellQuote(condition.path)} 2>/dev/null`,
|
|
173
|
+
{ timeoutMs: 15_000, maxOutputBytes: 4 * 1024 },
|
|
174
|
+
);
|
|
175
|
+
const text = String(result?.stdout ?? '').trim();
|
|
176
|
+
const matches = /^\d+$/.test(text) ? Number(text) : 0;
|
|
177
|
+
const observed = { path: condition.path, pattern: condition.pattern, matches };
|
|
178
|
+
// grep exits 2 on an unusable file or an invalid regexp; keep the reason in
|
|
179
|
+
// the snapshot so a timeout report explains itself instead of showing 0.
|
|
180
|
+
if (result?.code !== undefined && result.code >= 2) {
|
|
181
|
+
const error = String(result.stderr ?? '').trim().split('\n').filter(Boolean).pop();
|
|
182
|
+
if (error) observed.error = error.slice(0, 300);
|
|
183
|
+
}
|
|
184
|
+
return { met: matches >= condition.count, observed };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* POSIX shell text that evaluates to `value` without ever containing it as a
|
|
189
|
+
* contiguous string. `pgrep -f` matches full command lines, and the probe's own
|
|
190
|
+
* wrapper shell carries the command text in *its* command line, so a plain
|
|
191
|
+
* `pgrep -f -- 'PATTERN'` would match itself and report every pattern as alive.
|
|
192
|
+
* Splitting the value into concatenated quoted chunks keeps the literal out of
|
|
193
|
+
* the command line while the shell variable holds the exact text.
|
|
194
|
+
*/
|
|
195
|
+
function hiddenShellValue(value) {
|
|
196
|
+
const text = String(value);
|
|
197
|
+
if (text === '') return "''";
|
|
198
|
+
const size = text.length <= 4 ? 1 : text.length <= 16 ? 4 : 8;
|
|
199
|
+
const chunks = [];
|
|
200
|
+
for (let index = 0; index < text.length; index += size) chunks.push(text.slice(index, index + size));
|
|
201
|
+
return chunks.map((chunk) => shellQuote(chunk)).join('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One `kill -0` / `pgrep -f` of a process condition. */
|
|
205
|
+
async function probeProcess(connection, condition) {
|
|
206
|
+
if (condition.pid !== null) {
|
|
207
|
+
const result = await connection.exec(`kill -0 ${condition.pid} 2>/dev/null`, {
|
|
208
|
+
timeoutMs: 10_000,
|
|
209
|
+
maxOutputBytes: 1024,
|
|
210
|
+
});
|
|
211
|
+
const alive = result?.code === 0;
|
|
212
|
+
return { met: alive === condition.alive, observed: { pid: condition.pid, alive } };
|
|
213
|
+
}
|
|
214
|
+
// The pattern reaches pgrep through a shell variable whose literal is split
|
|
215
|
+
// across quoted chunks in the command line (see hiddenShellValue): without
|
|
216
|
+
// that, pgrep -f matches the probe's own wrapper shell and `alive:false`
|
|
217
|
+
// could never be met.
|
|
218
|
+
const patternVar = `P=${hiddenShellValue(condition.pattern)}`;
|
|
219
|
+
const result = await connection.exec(
|
|
220
|
+
`${patternVar}; pgrep -f -- "$P" 2>/dev/null | wc -l`,
|
|
221
|
+
{ timeoutMs: 10_000, maxOutputBytes: 1024 },
|
|
222
|
+
);
|
|
223
|
+
const text = String(result?.stdout ?? '').trim();
|
|
224
|
+
const matches = /^\d+$/.test(text) ? Number(text) : 0;
|
|
225
|
+
const alive = matches > 0;
|
|
226
|
+
return { met: alive === condition.alive, observed: { pattern: condition.pattern, matches, alive } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** One TCP probe of a port condition (the connection's own bash /dev/tcp probe). */
|
|
230
|
+
async function probePort(connection, condition) {
|
|
231
|
+
let ready;
|
|
232
|
+
if (typeof connection.waitForPort === 'function') {
|
|
233
|
+
const result = await connection.waitForPort({ port: condition.port, host: condition.host, timeoutMs: 0 });
|
|
234
|
+
ready = Boolean(result?.ready);
|
|
235
|
+
} else if (typeof connection.probePort === 'function') {
|
|
236
|
+
// Older transports have only the throwing probe; one attempt is the poll.
|
|
237
|
+
try {
|
|
238
|
+
const result = await connection.probePort(condition.host, condition.port, { timeoutMs: 2_000 });
|
|
239
|
+
ready = result?.checked !== false;
|
|
240
|
+
} catch {
|
|
241
|
+
ready = false;
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
throw new Error('this connection cannot probe remote ports');
|
|
245
|
+
}
|
|
246
|
+
const observed = { port: condition.port, host: condition.host, ready };
|
|
247
|
+
return { met: condition.state === 'open' ? ready : !ready, observed };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The job registry row for `jobId`, from the live pool or a fake in a probe. */
|
|
251
|
+
async function jobRow(pool, jobId) {
|
|
252
|
+
if (!pool) throw new Error('a job condition needs the background-job registry');
|
|
253
|
+
if (typeof pool.getJob === 'function') {
|
|
254
|
+
const row = await pool.getJob(jobId);
|
|
255
|
+
if (row) return row;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const jobs = pool.jobs;
|
|
259
|
+
if (jobs instanceof Map) {
|
|
260
|
+
const row = jobs.get(jobId);
|
|
261
|
+
if (row) return row;
|
|
262
|
+
} else if (Array.isArray(jobs)) {
|
|
263
|
+
const row = jobs.find((entry) => entry?.id === jobId);
|
|
264
|
+
if (row) return row;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
/* a pool that exposes no synchronous map falls through to listJobs */
|
|
268
|
+
}
|
|
269
|
+
if (typeof pool.listJobs === 'function') {
|
|
270
|
+
const rows = await pool.listJobs();
|
|
271
|
+
const row = (Array.isArray(rows) ? rows : []).find((entry) => entry?.id === jobId);
|
|
272
|
+
if (row) return row;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One registry probe of a job condition; unknown/missing keeps polling. */
|
|
278
|
+
async function probeJob(pool, condition) {
|
|
279
|
+
const row = await jobRow(pool, condition.jobId);
|
|
280
|
+
if (!row) return { met: false, observed: { jobId: condition.jobId, state: 'missing' } };
|
|
281
|
+
const observed = {
|
|
282
|
+
jobId: condition.jobId,
|
|
283
|
+
state: row.state ?? 'unknown',
|
|
284
|
+
exitCode: row.exitCode ?? null,
|
|
285
|
+
exitSignal: row.exitSignal ?? null,
|
|
286
|
+
host: row.host ?? null,
|
|
287
|
+
pid: row.pid ?? null,
|
|
288
|
+
};
|
|
289
|
+
return { met: observed.state === 'exited', observed };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Poll one condition until it is true, the deadline passes, or the caller
|
|
294
|
+
* aborts. Exactly one remote (or registry) probe runs per poll.
|
|
295
|
+
*
|
|
296
|
+
* @param options.pool - the background-job registry, for `job` conditions.
|
|
297
|
+
* @param options.connection - the host connection, for every other condition.
|
|
298
|
+
* @param options.condition - model-facing condition (`kind` plus its fields).
|
|
299
|
+
* @param options.timeoutMs - deadline, default 120000, capped at one hour.
|
|
300
|
+
* @param options.pollMs - gap between probes, default 1000.
|
|
301
|
+
* @param options.signal - caller cancellation; an abort returns `met: false`.
|
|
302
|
+
* @returns `{ met, timedOut, elapsedMs, observed, polls, condition }`; never
|
|
303
|
+
* rejects because of a timeout or an unreachable host.
|
|
304
|
+
*/
|
|
305
|
+
export async function waitUntil({ pool = null, connection = null, condition, timeoutMs, pollMs, signal } = {}) {
|
|
306
|
+
const normalized = normalizeWaitCondition(condition);
|
|
307
|
+
const budgetRaw = Number(timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
308
|
+
const budget = Number.isFinite(budgetRaw) ? Math.min(MAX_TIMEOUT_MS, Math.max(0, Math.floor(budgetRaw))) : DEFAULT_TIMEOUT_MS;
|
|
309
|
+
const gapRaw = Number(pollMs ?? DEFAULT_POLL_MS);
|
|
310
|
+
const gap = Number.isFinite(gapRaw) ? Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.floor(gapRaw))) : DEFAULT_POLL_MS;
|
|
311
|
+
|
|
312
|
+
let conn = connection;
|
|
313
|
+
if (normalized.kind !== 'job' && !conn) {
|
|
314
|
+
throw new Error(`a connection to the host is required for a ${normalized.kind} condition`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const started = Date.now();
|
|
318
|
+
const deadline = started + budget;
|
|
319
|
+
let polls = 0;
|
|
320
|
+
let observed = null;
|
|
321
|
+
|
|
322
|
+
for (;;) {
|
|
323
|
+
if (signal?.aborted) {
|
|
324
|
+
return { met: false, timedOut: false, aborted: true, elapsedMs: Date.now() - started, observed, polls, condition: normalized };
|
|
325
|
+
}
|
|
326
|
+
polls += 1;
|
|
327
|
+
try {
|
|
328
|
+
const result =
|
|
329
|
+
normalized.kind === 'file'
|
|
330
|
+
? await probeFile(conn, normalized)
|
|
331
|
+
: normalized.kind === 'grep'
|
|
332
|
+
? await probeGrep(conn, normalized)
|
|
333
|
+
: normalized.kind === 'process'
|
|
334
|
+
? await probeProcess(conn, normalized)
|
|
335
|
+
: normalized.kind === 'port'
|
|
336
|
+
? await probePort(conn, normalized)
|
|
337
|
+
: await probeJob(pool, normalized);
|
|
338
|
+
observed = result.observed;
|
|
339
|
+
if (result.met) {
|
|
340
|
+
return { met: true, timedOut: false, elapsedMs: Date.now() - started, observed, polls, condition: normalized };
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
// An unreachable host (or a transport hiccup) is not a failed condition:
|
|
344
|
+
// keep the last snapshot, note why this poll failed, and retry.
|
|
345
|
+
observed = { ...(observed ?? {}), error: String(error?.message ?? error).slice(0, 500) };
|
|
346
|
+
}
|
|
347
|
+
if (Date.now() >= deadline) {
|
|
348
|
+
return { met: false, timedOut: true, elapsedMs: Date.now() - started, observed, polls, condition: normalized };
|
|
349
|
+
}
|
|
350
|
+
await sleep(Math.min(gap, Math.max(0, deadline - Date.now())), signal);
|
|
351
|
+
}
|
|
352
|
+
}
|