@jmcombs/pi-steward 0.0.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/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the operator's declared start/stop/restart commands — the Node body
|
|
3
|
+
* behind {@link ServiceController}.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the host collector (a long-lived stream), control is one-shot. The
|
|
6
|
+
* argv is passed as an array and NEVER through `shell: true` — the config is a
|
|
7
|
+
* code-execution surface already, and a shell would add word-splitting and
|
|
8
|
+
* metacharacter expansion on top of it. Only commands that passed the config's
|
|
9
|
+
* ownership check and the per-command consent gate ever reach here.
|
|
10
|
+
*
|
|
11
|
+
* It never throws, never rejects, and — the guarantee the whole dashboard leans
|
|
12
|
+
* on — always settles. The deadline is this module's own (SIGTERM, then SIGKILL
|
|
13
|
+
* after a grace, then an answer either way) rather than `execFile`'s single
|
|
14
|
+
* SIGTERM, which a command that traps it survives indefinitely. A non-zero exit,
|
|
15
|
+
* a timeout, a missing binary, or an action with no consented command all
|
|
16
|
+
* resolve as a failure with a readable detail, because "launchctl: permission
|
|
17
|
+
* denied" on screen is worth more to an operator than a stack trace in a
|
|
18
|
+
* terminal they are not reading. And a success is only the command's own
|
|
19
|
+
* verdict: the caller re-polls, because a `KeepAlive` job exits 0 from a stop
|
|
20
|
+
* and comes straight back.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
24
|
+
import type { ServiceController, ServiceControlResult } from "../core/llama-source.js";
|
|
25
|
+
import type { ServiceAction } from "../core/types.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long a control command may run before it is killed. Generous next to the
|
|
29
|
+
* probe's 1.5s: `launchctl bootout` waits on the job it is tearing down, and a
|
|
30
|
+
* `systemctl restart` blocks until the unit settles.
|
|
31
|
+
*/
|
|
32
|
+
const CONTROL_TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Grace between the SIGTERM at the deadline and the SIGKILL that follows it,
|
|
36
|
+
* and the point at which this module answers whatever the child does. A command
|
|
37
|
+
* that traps or ignores SIGTERM would otherwise never settle: `execFile`'s own
|
|
38
|
+
* `timeout` option signals once and then waits forever on a child that survives
|
|
39
|
+
* it, and one unresolved promise here would hang the API handler, the browser's
|
|
40
|
+
* fetch, and the control row with it.
|
|
41
|
+
*/
|
|
42
|
+
const KILL_ESCALATION_MS = 750;
|
|
43
|
+
|
|
44
|
+
/** Output past this is a runaway, not a message an operator wants to read. */
|
|
45
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
46
|
+
|
|
47
|
+
/** Longest failure detail we surface; the rest is noise on a rail 260px wide. */
|
|
48
|
+
const MAX_DETAIL_LENGTH = 160;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Longest program name inside a detail. A launcher can live behind a very long
|
|
52
|
+
* absolute path, and the reason ("permission denied") must survive the clamp —
|
|
53
|
+
* so the path gives up its head, not the message its tail.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_PROGRAM_LENGTH = 48;
|
|
56
|
+
|
|
57
|
+
/** The consented commands, keyed by action. A missing action cannot be run. */
|
|
58
|
+
export type ServiceControlCommands = Partial<Record<ServiceAction, string[]>>;
|
|
59
|
+
|
|
60
|
+
export interface ServiceControlOptions {
|
|
61
|
+
/** Ceiling on one command's runtime, ms. Defaults to {@link CONTROL_TIMEOUT_MS}. */
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The actions offered, in the order the dashboard renders them. */
|
|
66
|
+
const ACTION_ORDER: readonly ServiceAction[] = ["start", "stop", "restart"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drops the control characters a terminal-shaped tool sprays into its output —
|
|
70
|
+
* ANSI colour escapes above all. They are invisible on screen but a screen
|
|
71
|
+
* reader reads them aloud, and this string ends up in a `role="alert"` region.
|
|
72
|
+
*/
|
|
73
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
74
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]+/gu;
|
|
75
|
+
|
|
76
|
+
function sanitize(text: string): string {
|
|
77
|
+
return text.replace(CONTROL_CHARACTERS, " ").replace(/\s+/gu, " ").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The first non-empty line of a command's output, cleaned, or null. */
|
|
81
|
+
function firstLine(output: unknown): string | null {
|
|
82
|
+
if (typeof output !== "string") return null;
|
|
83
|
+
for (const line of output.split("\n")) {
|
|
84
|
+
const clean = sanitize(line);
|
|
85
|
+
if (clean !== "") return clean;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The program as it reads in a notice. The reason is the part an operator acts
|
|
92
|
+
* on, so a very long path gives up its head (`…/bin/launchctl`) rather than
|
|
93
|
+
* pushing the reason past the clamp.
|
|
94
|
+
*/
|
|
95
|
+
function programLabel(program: string): string {
|
|
96
|
+
const clean = sanitize(program);
|
|
97
|
+
if (clean.length <= MAX_PROGRAM_LENGTH) return clean;
|
|
98
|
+
return `…${clean.slice(clean.length - (MAX_PROGRAM_LENGTH - 1))}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The finished detail: bounded as a whole, not merely in its fragments. */
|
|
102
|
+
function detail(program: string, reason: string): string {
|
|
103
|
+
const text = `${programLabel(program)}: ${reason}`;
|
|
104
|
+
return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1)}…` : text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Everything one finished run knows about itself. `timedOut` and `overflowed`
|
|
109
|
+
* are this module's own verdicts, not the child's: only we know the deadline
|
|
110
|
+
* passed or the output cap was breached, and a child killed for either reason
|
|
111
|
+
* must not be described by whatever it happened to print on the way out.
|
|
112
|
+
*/
|
|
113
|
+
interface CommandOutcome {
|
|
114
|
+
/** A spawn-level errno (`ENOENT`, `EACCES`), when the program never ran. */
|
|
115
|
+
errorCode?: string;
|
|
116
|
+
/** The exit status, or `null` when a signal ended it. */
|
|
117
|
+
exitCode?: number | null;
|
|
118
|
+
signal?: string | null;
|
|
119
|
+
stdout?: string;
|
|
120
|
+
stderr?: string;
|
|
121
|
+
timedOut?: boolean;
|
|
122
|
+
overflowed?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Turns a finished run into something an operator can act on. The interesting
|
|
127
|
+
* cases are the ones an operator hits in practice: a command that is not
|
|
128
|
+
* installed, one the user may not run, one that hung, and one that ran and
|
|
129
|
+
* refused — the last of which usually explains itself on stderr (`launchctl`'s
|
|
130
|
+
* "Load failed: 5: Input/output error"), so that line is preferred over a bare
|
|
131
|
+
* exit status.
|
|
132
|
+
*/
|
|
133
|
+
function describeFailure(program: string, timeoutMs: number, outcome: CommandOutcome): string {
|
|
134
|
+
if (outcome.errorCode === "ENOENT") return detail(program, "command not found");
|
|
135
|
+
if (outcome.errorCode === "EACCES" || outcome.errorCode === "EPERM") {
|
|
136
|
+
return detail(program, "permission denied");
|
|
137
|
+
}
|
|
138
|
+
if (outcome.timedOut === true) return detail(program, `timed out after ${timeoutMs}ms`);
|
|
139
|
+
// An overflowing child is killed mid-run, and its truncated output is NOT the
|
|
140
|
+
// reason it failed — reporting that output would invent a failure message out
|
|
141
|
+
// of a chatty command's ordinary chatter.
|
|
142
|
+
if (outcome.overflowed === true) {
|
|
143
|
+
return detail(
|
|
144
|
+
program,
|
|
145
|
+
`produced more than ${MAX_OUTPUT_BYTES / 1024} KB of output and was killed`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const line = firstLine(outcome.stderr) ?? firstLine(outcome.stdout);
|
|
150
|
+
if (line !== null) return detail(program, line);
|
|
151
|
+
if (typeof outcome.signal === "string" && outcome.signal !== "") {
|
|
152
|
+
return detail(program, `killed by ${outcome.signal}`);
|
|
153
|
+
}
|
|
154
|
+
if (typeof outcome.exitCode === "number") {
|
|
155
|
+
return detail(program, `exited with status ${outcome.exitCode}`);
|
|
156
|
+
}
|
|
157
|
+
if (outcome.errorCode !== undefined) return detail(program, sanitize(outcome.errorCode));
|
|
158
|
+
return detail(program, "failed");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Runs one command under a deadline this module owns.
|
|
163
|
+
*
|
|
164
|
+
* `spawn` rather than `execFile`, for two reasons that both cost the operator
|
|
165
|
+
* dearly otherwise. First, `execFile`'s `timeout` (and `promisify`'s) sends a
|
|
166
|
+
* single SIGTERM and then waits on the child forever, so a command that traps
|
|
167
|
+
* it — a wrapper script with `trap '' TERM` — leaves the promise pending, and
|
|
168
|
+
* with it the API request, the browser's fetch, and the control row, which
|
|
169
|
+
* stays disabled until the page is reloaded. Second, `execFile` does not
|
|
170
|
+
* forward `detached`, so there is no process GROUP to signal: killing the
|
|
171
|
+
* direct child of a wrapper leaves the `launchctl` (or `sleep`) it was waiting
|
|
172
|
+
* on running, while the dashboard reports the command killed.
|
|
173
|
+
*
|
|
174
|
+
* So this owns the whole lifecycle — a detached group, bounded output, SIGTERM
|
|
175
|
+
* at the deadline, SIGKILL after a grace, and an answer from our own timer
|
|
176
|
+
* regardless of whether the child ever exits. It is the host collector's lesson
|
|
177
|
+
* applied to a one-shot command.
|
|
178
|
+
*/
|
|
179
|
+
function runCommand(
|
|
180
|
+
program: string,
|
|
181
|
+
args: string[],
|
|
182
|
+
timeoutMs: number,
|
|
183
|
+
): Promise<ServiceControlResult> {
|
|
184
|
+
return new Promise<ServiceControlResult>((resolve) => {
|
|
185
|
+
let settled = false;
|
|
186
|
+
let timedOut = false;
|
|
187
|
+
let overflowed = false;
|
|
188
|
+
let stdout = "";
|
|
189
|
+
let stderr = "";
|
|
190
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
191
|
+
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
192
|
+
|
|
193
|
+
const finish = (result: ServiceControlResult): void => {
|
|
194
|
+
if (settled) return;
|
|
195
|
+
settled = true;
|
|
196
|
+
// Both timers are cleared on every path, so neither outlives the call.
|
|
197
|
+
clearTimeout(deadline);
|
|
198
|
+
clearTimeout(escalation);
|
|
199
|
+
resolve(result);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const fail = (outcome: CommandOutcome): void => {
|
|
203
|
+
finish({ ok: false, detail: describeFailure(program, timeoutMs, outcome) });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let child: ChildProcess;
|
|
207
|
+
try {
|
|
208
|
+
child = spawn(program, args, {
|
|
209
|
+
// Its own process group, so the escalation below reaches a wrapper's
|
|
210
|
+
// children too. Never `unref`'d: this run waits for it.
|
|
211
|
+
detached: true,
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
windowsHide: true,
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// A synchronous throw (an invalid program string) is just a failure.
|
|
217
|
+
fail({ errorCode: error instanceof Error ? error.message : String(error) });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Signals the child's whole process group, falling back to the child alone
|
|
223
|
+
* where there is no group (already reaped, or a platform without negative
|
|
224
|
+
* pids). Every path is guarded: signalling a process that has just exited
|
|
225
|
+
* is a race, not an error.
|
|
226
|
+
*/
|
|
227
|
+
const signal = (name: NodeJS.Signals): void => {
|
|
228
|
+
const pid = child.pid;
|
|
229
|
+
if (pid === undefined) return;
|
|
230
|
+
try {
|
|
231
|
+
process.kill(-pid, name);
|
|
232
|
+
} catch {
|
|
233
|
+
try {
|
|
234
|
+
child.kill(name);
|
|
235
|
+
} catch {
|
|
236
|
+
// Already gone — nothing to signal.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Keeps at most {@link MAX_OUTPUT_BYTES} of a stream. A command that floods
|
|
243
|
+
* its output is killed rather than buffered without bound, and the flood is
|
|
244
|
+
* never mistaken for its reason.
|
|
245
|
+
*/
|
|
246
|
+
const collect = (chunk: string, held: string): string => {
|
|
247
|
+
if (held.length + chunk.length <= MAX_OUTPUT_BYTES) return held + chunk;
|
|
248
|
+
if (!overflowed) {
|
|
249
|
+
overflowed = true;
|
|
250
|
+
signal("SIGKILL");
|
|
251
|
+
}
|
|
252
|
+
return held;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
child.stdout?.setEncoding("utf8");
|
|
256
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
257
|
+
stdout = collect(chunk, stdout);
|
|
258
|
+
});
|
|
259
|
+
child.stderr?.setEncoding("utf8");
|
|
260
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
261
|
+
stderr = collect(chunk, stderr);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// A listener is required or an 'error' would throw as unhandled. It fires
|
|
265
|
+
// for a program that could not be started at all (ENOENT, EACCES).
|
|
266
|
+
child.once("error", (error: NodeJS.ErrnoException) => {
|
|
267
|
+
fail({ errorCode: error.code ?? error.message, stdout, stderr });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// 'close' rather than 'exit': it lands once the pipes are drained, so the
|
|
271
|
+
// reason on stderr is complete by the time it is read.
|
|
272
|
+
child.once("close", (code, signalName) => {
|
|
273
|
+
if (timedOut) {
|
|
274
|
+
fail({ timedOut: true });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (overflowed) {
|
|
278
|
+
fail({ overflowed: true });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (code === 0) {
|
|
282
|
+
finish({ ok: true, detail: null });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
fail({ exitCode: code, signal: signalName, stdout, stderr });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
deadline = setTimeout(() => {
|
|
289
|
+
timedOut = true;
|
|
290
|
+
// Ask first: a control command mid-teardown deserves the chance to finish
|
|
291
|
+
// what it started, and most tools exit promptly on SIGTERM.
|
|
292
|
+
signal("SIGTERM");
|
|
293
|
+
escalation = setTimeout(() => {
|
|
294
|
+
signal("SIGKILL");
|
|
295
|
+
// Answer regardless of what the child does next. A SIGKILL cannot be
|
|
296
|
+
// trapped, but a process can be unkillable (uninterruptible sleep) or
|
|
297
|
+
// hold its stdio open through a grandchild, and neither may hold the
|
|
298
|
+
// dashboard.
|
|
299
|
+
fail({ timedOut: true });
|
|
300
|
+
}, KILL_ESCALATION_MS);
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A controller over the given consented commands. Building one costs nothing
|
|
307
|
+
* and holds nothing: each {@link ServiceController.run} is a single one-shot
|
|
308
|
+
* exec, so the instance can be shared and needs no close.
|
|
309
|
+
*/
|
|
310
|
+
export function createServiceController(
|
|
311
|
+
commands: ServiceControlCommands,
|
|
312
|
+
options: ServiceControlOptions = {},
|
|
313
|
+
): ServiceController {
|
|
314
|
+
const timeoutMs = options.timeoutMs ?? CONTROL_TIMEOUT_MS;
|
|
315
|
+
const actions = ACTION_ORDER.filter((action) => {
|
|
316
|
+
const command = commands[action];
|
|
317
|
+
return command !== undefined && command.length > 0;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
actions,
|
|
322
|
+
|
|
323
|
+
run(action: ServiceAction): Promise<ServiceControlResult> {
|
|
324
|
+
const command = commands[action];
|
|
325
|
+
const program = command?.[0];
|
|
326
|
+
if (command === undefined || program === undefined) {
|
|
327
|
+
// Reachable when a client POSTs an action the dashboard never offered.
|
|
328
|
+
return Promise.resolve({
|
|
329
|
+
ok: false,
|
|
330
|
+
detail: `no consented ${action} command for this machine — run /steward_initialize`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return runCommand(program, command.slice(1), timeoutMs);
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the local process serving a host:port, for the SERVICE panel's real
|
|
3
|
+
* pid and uptime — facts `llama-server` does not report over HTTP.
|
|
4
|
+
*
|
|
5
|
+
* It shells out to `lsof` (the listener's pid) and `ps` (its start time), so it
|
|
6
|
+
* is macOS/Linux only and best-effort: anything it cannot determine degrades to
|
|
7
|
+
* n/a, never an error. This is exactly why it is injected into the otherwise
|
|
8
|
+
* Node-free {@link LlamaSource} rather than living in `core/`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import type { ServiceProbe, ServiceProcess } from "../core/llama-source.js";
|
|
14
|
+
|
|
15
|
+
const run = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
/** No single probe command may hang the metrics poll. */
|
|
18
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
19
|
+
|
|
20
|
+
/** The first pid listening on the TCP port, or null when none is found. */
|
|
21
|
+
async function listenerPid(port: number): Promise<number | null> {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
24
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
25
|
+
});
|
|
26
|
+
for (const line of stdout.split("\n")) {
|
|
27
|
+
const pid = Number(line.trim());
|
|
28
|
+
if (Number.isInteger(pid) && pid > 0) return pid;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
} catch {
|
|
32
|
+
// lsof missing, no match (exit 1), or timed out: no pid to report.
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A process's start time as epoch ms, or null when it cannot be read. */
|
|
38
|
+
async function startedAt(pid: number): Promise<number | null> {
|
|
39
|
+
try {
|
|
40
|
+
// `ps -o lstart=` prints an absolute, locale-parseable timestamp on both
|
|
41
|
+
// macOS and Linux (unlike `etimes`, which is Linux-only).
|
|
42
|
+
const { stdout } = await run("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
43
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
const parsed = Date.parse(stdout.trim());
|
|
46
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A probe with a per-pid start-time cache: the listener pid is cheap to re-read
|
|
54
|
+
* each snapshot, but a process's start time never changes, so `ps` runs once per
|
|
55
|
+
* pid rather than on every poll. A new (or absent) pid drops the cache.
|
|
56
|
+
*/
|
|
57
|
+
export function createListenerProbe(): ServiceProbe {
|
|
58
|
+
const startCache = new Map<number, number | null>();
|
|
59
|
+
return async (_host: string, port: number): Promise<ServiceProcess | null> => {
|
|
60
|
+
const pid = await listenerPid(port);
|
|
61
|
+
if (pid === null) {
|
|
62
|
+
startCache.clear();
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!startCache.has(pid)) {
|
|
66
|
+
startCache.clear();
|
|
67
|
+
startCache.set(pid, await startedAt(pid));
|
|
68
|
+
}
|
|
69
|
+
return { pid, startedAt: startCache.get(pid) ?? null };
|
|
70
|
+
};
|
|
71
|
+
}
|