sentinelayer-cli 0.46.3 → 0.46.4
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/package.json +1 -1
- package/src/commands/session.js +21 -25
- package/src/session/listener-process.js +427 -57
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -78,7 +78,6 @@ import {
|
|
|
78
78
|
getListenerProcessStatus,
|
|
79
79
|
LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
|
|
80
80
|
removeListenerPidRecord,
|
|
81
|
-
requestListenerProcessStop,
|
|
82
81
|
summarizeLocalListenerProcesses,
|
|
83
82
|
stopMatchingListenerProcesses,
|
|
84
83
|
writeListenerPidRecord,
|
|
@@ -5515,33 +5514,30 @@ export function registerSessionCommand(program) {
|
|
|
5515
5514
|
);
|
|
5516
5515
|
}
|
|
5517
5516
|
if (existingListener.running && options.force) {
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
`
|
|
5526
|
-
),
|
|
5517
|
+
// --force never stops a process this command runs under, and never on a guess:
|
|
5518
|
+
// it refuses, and stops and starts nothing, unless the takeover scan confirms
|
|
5519
|
+
// what it stops.
|
|
5520
|
+
const refuseTakeover = ({ pid, reason }) =>
|
|
5521
|
+
new Error(
|
|
5522
|
+
reason === "ancestor_listener"
|
|
5523
|
+
? `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${pid}), and this command runs under it, so --force will not stop it. No listener was stopped or started. Stop that listener first, or use --allow-duplicate if you intentionally need more than one.`
|
|
5524
|
+
: `Could not confirm that pid ${pid} is a session listener --force can replace for session ${normalizedSessionId} as ${agentId} (${reason}). No listener was stopped or started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener).`,
|
|
5527
5525
|
);
|
|
5526
|
+
if (existingListener.ancestor) {
|
|
5527
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: "ancestor_listener" });
|
|
5528
5528
|
}
|
|
5529
|
-
const
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5529
|
+
const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId, {
|
|
5530
|
+
listener: existingListener,
|
|
5531
|
+
});
|
|
5532
|
+
if (!stopMatchesResult.ok) {
|
|
5533
|
+
// What the status check found may no longer hold, and an unread table cannot
|
|
5534
|
+
// show which processes this command runs under, so nothing is stopped.
|
|
5535
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: stopMatchesResult.reason });
|
|
5536
|
+
}
|
|
5537
|
+
if (stopMatchesResult.refused) {
|
|
5538
|
+
throw refuseTakeover(stopMatchesResult.refused);
|
|
5538
5539
|
}
|
|
5539
|
-
const failedStops =
|
|
5540
|
-
...scannedStops.filter((result) => !result.stopped),
|
|
5541
|
-
...(explicitStopResult && !explicitStopResult.stopped
|
|
5542
|
-
? [{ pid: existingListener.pid, ...explicitStopResult }]
|
|
5543
|
-
: []),
|
|
5544
|
-
];
|
|
5540
|
+
const failedStops = stopMatchesResult.results.filter((result) => !result.stopped);
|
|
5545
5541
|
if (failedStops.length > 0) {
|
|
5546
5542
|
const failed = failedStops[0];
|
|
5547
5543
|
throw new Error(
|
|
@@ -114,22 +114,44 @@ export function listenerCommandLineMatches(
|
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function optionalPositiveNumber(value) {
|
|
118
|
+
if (value === null || value === undefined || value === "") return null;
|
|
119
|
+
const number = Number(value);
|
|
120
|
+
return Number.isFinite(number) && number > 0 ? number : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A row is { pid, ppid, startedAtMs, name, commandLine }, one for every process the scan
|
|
124
|
+
// lists, so a parent chain can be followed through processes that are not this CLI. `ppid`
|
|
125
|
+
// (the parent's pid) and `startedAtMs` (when the process was created) are null, and `name`
|
|
126
|
+
// (the executable's file name) is "", when the scan did not give them. `commandLine` is ""
|
|
127
|
+
// when the scan left it out: the Windows scan keeps only the ones that name a CLI entry.
|
|
117
128
|
function normalizeProcessRows(rows = []) {
|
|
118
129
|
if (!Array.isArray(rows)) return [];
|
|
119
130
|
return rows
|
|
120
131
|
.map((row) => ({
|
|
121
132
|
pid: Number(row?.pid ?? row?.ProcessId ?? row?.processId),
|
|
133
|
+
ppid: optionalPositiveNumber(row?.ppid ?? row?.ParentProcessId),
|
|
134
|
+
startedAtMs: optionalPositiveNumber(row?.startedAtMs ?? row?.CreationMs),
|
|
135
|
+
name: normalizeString(row?.name ?? row?.Name),
|
|
122
136
|
commandLine: normalizeString(row?.commandLine ?? row?.CommandLine ?? row?.command ?? row?.args),
|
|
123
137
|
}))
|
|
124
|
-
.filter((row) => Number.isInteger(row.pid) && row.pid > 0
|
|
138
|
+
.filter((row) => Number.isInteger(row.pid) && row.pid > 0);
|
|
125
139
|
}
|
|
126
140
|
|
|
141
|
+
// Win32_Process.CreationDate as milliseconds since the epoch, for a PowerShell expression.
|
|
142
|
+
const WINDOWS_CREATION_MS = "if ($_.CreationDate) { ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }";
|
|
143
|
+
|
|
144
|
+
// Every process, so the parent chain is complete, with its command line only when that names
|
|
145
|
+
// a CLI entry point, which keeps the output small: about 600 processes make some 60 KB of
|
|
146
|
+
// JSON and take under a second, as the filtered scan did.
|
|
127
147
|
async function listWindowsProcesses({ timeoutMs }) {
|
|
148
|
+
const namesCliEntry = `$_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}')`;
|
|
128
149
|
const command = [
|
|
129
150
|
"$ErrorActionPreference = 'Stop';",
|
|
130
151
|
"$rows = Get-CimInstance Win32_Process |",
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
"Select-Object ProcessId,ParentProcessId,Name,",
|
|
153
|
+
`@{n='CreationMs';e={ ${WINDOWS_CREATION_MS} }},`,
|
|
154
|
+
`@{n='CommandLine';e={ if (${namesCliEntry}) { $_.CommandLine } else { $null } }};`,
|
|
133
155
|
"if ($rows) { $rows | ConvertTo-Json -Compress }",
|
|
134
156
|
].join(" ");
|
|
135
157
|
const { stdout } = await execFileAsync(
|
|
@@ -143,20 +165,248 @@ async function listWindowsProcesses({ timeoutMs }) {
|
|
|
143
165
|
return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
|
|
144
166
|
}
|
|
145
167
|
|
|
168
|
+
// ps(1) prints elapsed time as [[dd-]hh:]mm:ss on Linux, macOS and busybox alike.
|
|
169
|
+
function parseElapsedSeconds(value) {
|
|
170
|
+
const match = normalizeString(value).match(/^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/);
|
|
171
|
+
if (!match) return null;
|
|
172
|
+
const [days, hours, minutes, seconds] = match.slice(1).map((part) => Number(part || 0));
|
|
173
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Start times come from ps's whole seconds of elapsed time, counted from just after
|
|
177
|
+
// `scannedAtMs`, so each is good to about a second (startTimeSlackMs).
|
|
146
178
|
async function listPosixProcesses({ timeoutMs }) {
|
|
147
|
-
const
|
|
179
|
+
const scannedAtMs = Date.now();
|
|
180
|
+
const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,etime=,command="], {
|
|
148
181
|
timeout: timeoutMs,
|
|
149
182
|
maxBuffer: 10 * 1024 * 1024,
|
|
150
183
|
});
|
|
151
184
|
return String(stdout || "")
|
|
152
185
|
.split(/\r?\n/)
|
|
153
186
|
.map((line) => {
|
|
154
|
-
const match = line.match(/^\s*(\d+)\s+(.*)
|
|
155
|
-
|
|
187
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/);
|
|
188
|
+
if (!match) return null;
|
|
189
|
+
const elapsedSeconds = parseElapsedSeconds(match[3]);
|
|
190
|
+
return {
|
|
191
|
+
pid: Number(match[1]),
|
|
192
|
+
ppid: Number(match[2]),
|
|
193
|
+
startedAtMs: elapsedSeconds === null ? null : scannedAtMs - elapsedSeconds * 1000,
|
|
194
|
+
commandLine: normalizeString(match[4]),
|
|
195
|
+
};
|
|
156
196
|
})
|
|
157
197
|
.filter(Boolean);
|
|
158
198
|
}
|
|
159
199
|
|
|
200
|
+
// How far apart two readings of one start time can be. Windows gives creation times to the
|
|
201
|
+
// millisecond. A POSIX reading can be up to a second late (whole seconds of elapsed time)
|
|
202
|
+
// and slightly early (the time ps takes to start), so 2 s covers it.
|
|
203
|
+
const POSIX_START_TIME_SLACK_MS = 2_000;
|
|
204
|
+
|
|
205
|
+
function startTimeSlackMs(platform = process.platform) {
|
|
206
|
+
return platform === "win32" ? 0 : POSIX_START_TIME_SLACK_MS;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// How far up the parent chain the walk goes. Real launch chains are a handful deep.
|
|
210
|
+
const MAX_ANCESTOR_DEPTH = 64;
|
|
211
|
+
|
|
212
|
+
// Whether `parent` (the row at `child`'s recorded parent pid) is still the process that
|
|
213
|
+
// started `child`. A parent is always created before its child, so a "parent" created after
|
|
214
|
+
// it holds a reused pid. On POSIX a recorded parent is always the live parent, because an
|
|
215
|
+
// exiting process's children are re-parented to init or a subreaper; so with no start times
|
|
216
|
+
// the link holds there. Windows never re-parents: the parent pid of a child whose parent
|
|
217
|
+
// exited can name any process started since, so there a link holds only when both creation
|
|
218
|
+
// times are known and in order.
|
|
219
|
+
function parentLinkHolds(parent, child, platform) {
|
|
220
|
+
if (Number.isFinite(parent.startedAtMs) && Number.isFinite(child.startedAtMs)) {
|
|
221
|
+
return parent.startedAtMs <= child.startedAtMs + startTimeSlackMs(platform);
|
|
222
|
+
}
|
|
223
|
+
return platform !== "win32";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The pids of a process's ancestors in one scan of the process table: its parent, that
|
|
228
|
+
* process's parent, and so on, while each parent is in the table, still the process that
|
|
229
|
+
* started its child (parentLinkHolds), not seen before (a cycle), and within
|
|
230
|
+
* MAX_ANCESTOR_DEPTH. The walk starts at the parent the table records for `pid`, or at
|
|
231
|
+
* `parentPid` when the table records none. Every row counts, with or without a command line,
|
|
232
|
+
* so the chain runs through processes that are not this CLI (cmd.exe, a service host).
|
|
233
|
+
*
|
|
234
|
+
* The walk ends early under Git Bash (MSYS) on Windows: an MSYS program such as bash, nohup or
|
|
235
|
+
* timeout is started from a forked copy of the shell that exits once the program runs, so its
|
|
236
|
+
* parent pid names no live process. Counting does not rest on the walk (a launcher is never
|
|
237
|
+
* counted, wherever it sits: readListenerScan); the walk marks the listeners --force must not
|
|
238
|
+
* stop, and a listener above such a break is not marked.
|
|
239
|
+
*/
|
|
240
|
+
export function listenerAncestorPids(
|
|
241
|
+
rows,
|
|
242
|
+
{ pid = process.pid, parentPid = null, platform = process.platform } = {},
|
|
243
|
+
) {
|
|
244
|
+
const table = new Map(normalizeProcessRows(rows).map((row) => [row.pid, row]));
|
|
245
|
+
const selfPid = Number(pid);
|
|
246
|
+
let child = table.get(selfPid) || { pid: selfPid, ppid: null, startedAtMs: null };
|
|
247
|
+
let next = child.ppid ?? optionalPositiveNumber(parentPid);
|
|
248
|
+
const ancestors = new Set();
|
|
249
|
+
while (next !== null && next !== selfPid && !ancestors.has(next) && ancestors.size < MAX_ANCESTOR_DEPTH) {
|
|
250
|
+
const parent = table.get(next);
|
|
251
|
+
if (!parent || !parentLinkHolds(parent, child, platform)) break;
|
|
252
|
+
ancestors.add(parent.pid);
|
|
253
|
+
child = parent;
|
|
254
|
+
next = parent.ppid;
|
|
255
|
+
}
|
|
256
|
+
return ancestors;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The checking process's ancestors in a scan. When the checker is this process and its row
|
|
260
|
+
// records no parent, its parent is process.ppid.
|
|
261
|
+
function checkerAncestors(rows, selfPid) {
|
|
262
|
+
return listenerAncestorPids(rows, {
|
|
263
|
+
pid: selfPid,
|
|
264
|
+
parentPid: selfPid === process.pid ? process.ppid : null,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An executable's name as the process kinds below compare it: its file name, without the
|
|
270
|
+
* directory, lower-cased, and without a trailing ".exe". So `C:\Program Files\nodejs\node.exe`,
|
|
271
|
+
* `/usr/bin/node` and `NODE` all read as "node".
|
|
272
|
+
*/
|
|
273
|
+
export function normalizeImageName(value) {
|
|
274
|
+
const base = normalizeString(value).split(/[\\/]/).pop().toLowerCase();
|
|
275
|
+
return base.endsWith(".exe") ? base.slice(0, -4) : base;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Executables that run this CLI as node. Also the checker's own node, whatever it is named.
|
|
279
|
+
const NODE_IMAGE_NAMES = ["node", "nodejs"];
|
|
280
|
+
// Executables that only start other programs: a command line naming the CLI there is the
|
|
281
|
+
// command they run, not this CLI running.
|
|
282
|
+
const LAUNCHER_IMAGE_NAMES = new Set([
|
|
283
|
+
"bash", "sh", "dash", "zsh", "cmd", "powershell", "pwsh", "nohup", "timeout", "env",
|
|
284
|
+
"screen", "tmux", "script", "setsid", "nice",
|
|
285
|
+
]);
|
|
286
|
+
// Node options known to take no value, so the next argument is not theirs.
|
|
287
|
+
const NODE_FLAGS = new Set([
|
|
288
|
+
"--inspect", "--inspect-brk", "--inspect-wait", "--no-warnings", "--no-deprecation",
|
|
289
|
+
"--trace-warnings", "--trace-deprecation", "--trace-uncaught", "--throw-deprecation",
|
|
290
|
+
"--pending-deprecation", "--enable-source-maps", "--expose-gc", "--preserve-symlinks",
|
|
291
|
+
"--preserve-symlinks-main", "--abort-on-uncaught-exception", "--experimental-vm-modules",
|
|
292
|
+
]);
|
|
293
|
+
// Of those, the ones that may also be given a value after "=" (--inspect=127.0.0.1:9229).
|
|
294
|
+
const NODE_FLAGS_WITH_OPTIONAL_VALUE = new Set(["--inspect", "--inspect-brk", "--inspect-wait"]);
|
|
295
|
+
// Node options known to take a value, as the next argument or after "=".
|
|
296
|
+
const NODE_OPTIONS_WITH_VALUE = new Set([
|
|
297
|
+
"--inspect-port", "--title", "-C", "--conditions", "--input-type", "--env-file",
|
|
298
|
+
"--disable-warning", "--redirect-warnings", "--max-old-space-size", "--stack-size",
|
|
299
|
+
]);
|
|
300
|
+
// Node options that load a module before the script; that module may be this CLI.
|
|
301
|
+
const NODE_PRELOAD_OPTIONS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader"]);
|
|
302
|
+
// Node options that run code given on the command line instead of a script.
|
|
303
|
+
const NODE_INLINE_CODE_OPTIONS = new Set(["-e", "--eval", "-p", "--print", "-pe", "-ep"]);
|
|
304
|
+
|
|
305
|
+
// A command line's arguments, in order, and whether they were read with certainty.
|
|
306
|
+
// - Windows quotes an argument with spaces, or an empty one, in double quotes. An empty
|
|
307
|
+
// argument ("") stays an argument, so a value option never takes the next one in its
|
|
308
|
+
// place. A quote left open, or one escaped with a backslash, is not read with certainty.
|
|
309
|
+
// - ps(1) prints POSIX arguments joined by single spaces with no quoting. An argument with a
|
|
310
|
+
// space reads as two (listenerProcessKind allows for that), and a run of spaces may hide an
|
|
311
|
+
// empty argument or an argument made of spaces, so it is not read with certainty. A quote
|
|
312
|
+
// character there is part of an argument, not quoting: it is kept as it is, and a command line
|
|
313
|
+
// that has one is not read with certainty, so no file name is recognised by removing it.
|
|
314
|
+
function commandLineArguments(commandLine, platform) {
|
|
315
|
+
const text = normalizeString(commandLine);
|
|
316
|
+
if (platform === "win32") {
|
|
317
|
+
const certain = (text.match(/"/g) || []).length % 2 === 0 && !/\\"/.test(text);
|
|
318
|
+
const parts = text.match(/(?:"[^"]*"?|[^\s"]+)+/g) || [];
|
|
319
|
+
return { args: parts.map((part) => part.replace(/"/g, "")), certain };
|
|
320
|
+
}
|
|
321
|
+
const parts = text.split(/\s+/);
|
|
322
|
+
return { args: parts.filter(Boolean), certain: !/\s{2,}/.test(text) && !/["']/.test(text) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What a process whose command line matches a listen command is. Both kinds are allow-listed;
|
|
327
|
+
* anything else is "unknown", which the listener checks treat as possibly a listener, so
|
|
328
|
+
* starting and --force refuse.
|
|
329
|
+
* - "listener": this CLI running it. The executable is node (node, nodejs, or the name of the
|
|
330
|
+
* checker's own node) and node's first argument that is not a node option is a CLI entry
|
|
331
|
+
* point, read with certainty.
|
|
332
|
+
* - "wrapper": a launcher, which carries the command for the process it starts: an executable
|
|
333
|
+
* in LAUNCHER_IMAGE_NAMES (bash, sh, dash, zsh, cmd, powershell, pwsh, nohup, timeout, env,
|
|
334
|
+
* screen, tmux, script, setsid, nice); or node whose first non-option argument is certainly
|
|
335
|
+
* another script, or node running inline code (-e, -p) with no CLI entry among its arguments.
|
|
336
|
+
* - "unknown": anything else, including node whose arguments cannot be read with certainty: a
|
|
337
|
+
* preload option (-r, --require, --import, --loader), inline code next to a CLI entry, an
|
|
338
|
+
* option this does not know (it may take the next argument), a quote left open, or, on
|
|
339
|
+
* POSIX, a first argument that is not an entry (it may be the first piece of an entry path
|
|
340
|
+
* with a space, which ps prints unquoted).
|
|
341
|
+
* The executable is Win32_Process.Name on Windows and the first argument on POSIX, compared
|
|
342
|
+
* as normalizeImageName gives it.
|
|
343
|
+
*/
|
|
344
|
+
export function listenerProcessKind(
|
|
345
|
+
row,
|
|
346
|
+
{ platform = process.platform, nodeExecutable = process.execPath } = {},
|
|
347
|
+
) {
|
|
348
|
+
const { args, certain } = commandLineArguments(row?.commandLine, platform);
|
|
349
|
+
const image = normalizeImageName(normalizeString(row?.name) || args[0]);
|
|
350
|
+
if (LAUNCHER_IMAGE_NAMES.has(image)) return "wrapper";
|
|
351
|
+
const nodeImages = new Set([...NODE_IMAGE_NAMES, normalizeImageName(nodeExecutable)].filter(Boolean));
|
|
352
|
+
if (!nodeImages.has(image)) return "unknown";
|
|
353
|
+
if (!certain) return "unknown";
|
|
354
|
+
const entries =
|
|
355
|
+
platform === "win32" ? CLI_ENTRY_SCRIPTS : [...CLI_ENTRY_SCRIPTS, ...CLI_COMMAND_NAMES];
|
|
356
|
+
const entryPath = new RegExp(`[\\\\/](?:${entries.map(escapeRegExp).join("|")})$`);
|
|
357
|
+
const isEntry = (arg) => entryPath.test(normalizeString(arg).toLowerCase());
|
|
358
|
+
let index = 1;
|
|
359
|
+
for (; index < args.length; index += 1) {
|
|
360
|
+
const arg = args[index];
|
|
361
|
+
if (arg === "--") {
|
|
362
|
+
index += 1;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!arg.startsWith("-") || arg === "-") break;
|
|
366
|
+
// Every spelling is judged by the option's name: --name, --name value, --name=value.
|
|
367
|
+
const equals = arg.indexOf("=");
|
|
368
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
369
|
+
const valueAttached = equals !== -1;
|
|
370
|
+
if (NODE_INLINE_CODE_OPTIONS.has(name)) {
|
|
371
|
+
return args.slice(1).some(isEntry) ? "unknown" : "wrapper";
|
|
372
|
+
}
|
|
373
|
+
if (NODE_PRELOAD_OPTIONS.has(name)) return "unknown";
|
|
374
|
+
if (NODE_OPTIONS_WITH_VALUE.has(name)) {
|
|
375
|
+
if (!valueAttached) index += 1;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (NODE_FLAGS.has(name) && (!valueAttached || NODE_FLAGS_WITH_OPTIONAL_VALUE.has(name))) continue;
|
|
379
|
+
return "unknown";
|
|
380
|
+
}
|
|
381
|
+
const script = args[index];
|
|
382
|
+
if (!script) return "unknown";
|
|
383
|
+
if (isEntry(script)) return "listener";
|
|
384
|
+
return platform === "win32" ? "wrapper" : "unknown";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* One scan read for one session and agent from the checking process's side. Every process
|
|
389
|
+
* whose command line matches, other than the checker, is judged by listenerProcessKind:
|
|
390
|
+
* `listeners` are the listeners, `unconfirmed` the processes that may be one, and launchers
|
|
391
|
+
* are in neither, wherever they sit. Ancestry is the second layer: a listener or possible
|
|
392
|
+
* listener that is an ancestor of the checker is marked `ancestor: true`, and the takeover
|
|
393
|
+
* never stops one.
|
|
394
|
+
*/
|
|
395
|
+
function readListenerScan(rows, { sessionId, agentId, selfPid, ancestors }) {
|
|
396
|
+
const listeners = [];
|
|
397
|
+
const unconfirmed = [];
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
if (row.pid === selfPid || !listenerCommandLineMatches(row.commandLine, { sessionId, agentId })) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const kind = listenerProcessKind(row);
|
|
403
|
+
if (kind === "wrapper") continue;
|
|
404
|
+
const marked = ancestors.has(row.pid) ? { ...row, ancestor: true } : row;
|
|
405
|
+
(kind === "listener" ? listeners : unconfirmed).push(marked);
|
|
406
|
+
}
|
|
407
|
+
return { listeners, unconfirmed };
|
|
408
|
+
}
|
|
409
|
+
|
|
160
410
|
// Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
|
|
161
411
|
// missing tool, unreadable output). It never turns a failed read into an empty table.
|
|
162
412
|
async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
|
|
@@ -203,7 +453,10 @@ async function scanProcessTable(listProcesses, timeoutMs) {
|
|
|
203
453
|
/**
|
|
204
454
|
* Listener processes for this session and agent: { ok: true, matches } when the process
|
|
205
455
|
* table was read, { ok: false, reason } when it could not be. A failed scan has no
|
|
206
|
-
* `matches`, so it can never be read as "no listener".
|
|
456
|
+
* `matches`, so it can never be read as "no listener". The matches are the listeners and the
|
|
457
|
+
* processes that may be one (readListenerScan); the checking process (`excludePid`) and
|
|
458
|
+
* launchers never are, and a match that is an ancestor of the checker is marked
|
|
459
|
+
* `ancestor: true`.
|
|
207
460
|
*/
|
|
208
461
|
export async function listMatchingListenerProcesses(
|
|
209
462
|
sessionId,
|
|
@@ -216,14 +469,10 @@ export async function listMatchingListenerProcesses(
|
|
|
216
469
|
) {
|
|
217
470
|
const scan = await scanProcessTable(_listProcesses, timeoutMs);
|
|
218
471
|
if (!scan.ok) return scan;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
row.pid !== excluded &&
|
|
224
|
-
listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
|
|
225
|
-
),
|
|
226
|
-
};
|
|
472
|
+
const selfPid = Number(excludePid);
|
|
473
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
474
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
|
|
475
|
+
return { ok: true, matches: [...listeners, ...unconfirmed] };
|
|
227
476
|
}
|
|
228
477
|
|
|
229
478
|
export async function summarizeLocalListenerProcesses(
|
|
@@ -243,16 +492,16 @@ export async function summarizeLocalListenerProcesses(
|
|
|
243
492
|
if (!scan.ok) {
|
|
244
493
|
return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
|
|
245
494
|
}
|
|
246
|
-
const
|
|
247
|
-
const
|
|
495
|
+
const selfPid = Number(excludePid);
|
|
496
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
248
497
|
const agents = uniqueAgentIds.map((agentId) => {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const pids =
|
|
498
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, {
|
|
499
|
+
sessionId: normalizedSessionId,
|
|
500
|
+
agentId,
|
|
501
|
+
selfPid,
|
|
502
|
+
ancestors,
|
|
503
|
+
});
|
|
504
|
+
const pids = [...listeners, ...unconfirmed].map((match) => match.pid).sort((a, b) => a - b);
|
|
256
505
|
return {
|
|
257
506
|
agentId,
|
|
258
507
|
processCount: pids.length,
|
|
@@ -493,11 +742,50 @@ async function listenerStatusFromRecord(
|
|
|
493
742
|
};
|
|
494
743
|
}
|
|
495
744
|
|
|
745
|
+
/**
|
|
746
|
+
* What the process at a pid record's pid is, judged on one scan (`row` is its row there, or
|
|
747
|
+
* null; `commandLine` is the one read for the record, used when the row has none). A
|
|
748
|
+
* listener writes its record after it starts, so the process that wrote it started no later
|
|
749
|
+
* than the record's `startedAt`, within the platform's precision (startTimeSlackMs).
|
|
750
|
+
* - "confirmed": a listener (listenerProcessKind) that started by then: the one that wrote it.
|
|
751
|
+
* - "ancestor": one of the checking process's ancestors (`ancestors`). The scan judges those
|
|
752
|
+
* (readListenerScan), so the record adds nothing.
|
|
753
|
+
* - "wrapper": a launcher, which never wrote a listener's record.
|
|
754
|
+
* - "reused": a listener started after the record was written, so not the one that wrote it.
|
|
755
|
+
* - "gone": no longer in the process table.
|
|
756
|
+
* - "unconfirmed": a process that may be a listener, or a start time that is unknown, so the
|
|
757
|
+
* record proves nothing either way.
|
|
758
|
+
*/
|
|
759
|
+
export function pidRecordHolder(
|
|
760
|
+
record,
|
|
761
|
+
row,
|
|
762
|
+
{ ancestors = new Set(), commandLine = "", platform = process.platform } = {},
|
|
763
|
+
) {
|
|
764
|
+
if (!row) return "gone";
|
|
765
|
+
if (ancestors.has(row.pid)) return "ancestor";
|
|
766
|
+
const kind = listenerProcessKind(
|
|
767
|
+
{ name: row.name, commandLine: row.commandLine || commandLine },
|
|
768
|
+
{ platform },
|
|
769
|
+
);
|
|
770
|
+
if (kind === "wrapper") return "wrapper";
|
|
771
|
+
if (kind === "unknown") return "unconfirmed";
|
|
772
|
+
const recordedAtMs = Date.parse(normalizeString(record?.startedAt));
|
|
773
|
+
if (!Number.isFinite(row.startedAtMs) || !Number.isFinite(recordedAtMs)) return "unconfirmed";
|
|
774
|
+
return row.startedAtMs > recordedAtMs + startTimeSlackMs(platform) ? "reused" : "confirmed";
|
|
775
|
+
}
|
|
776
|
+
|
|
496
777
|
/**
|
|
497
778
|
* Whether a listener for this session and agent is running on this machine. `state` is
|
|
498
|
-
* "running", "not_running" or "unknown".
|
|
499
|
-
*
|
|
500
|
-
*
|
|
779
|
+
* "running", "not_running" or "unknown".
|
|
780
|
+
*
|
|
781
|
+
* A pid record counts when its pid is alive with a matching command line and one scan of the
|
|
782
|
+
* process table confirms it (pidRecordHolder). The same scan finds untracked listeners
|
|
783
|
+
* (readListenerScan). A running answer carries the process's `startedAtMs`, and `ancestor:
|
|
784
|
+
* true` when the listener is an ancestor of this process, which --force must never stop.
|
|
785
|
+
*
|
|
786
|
+
* Anything uncertain makes the answer "unknown" (`running: null`, with `reason`), never
|
|
787
|
+
* "not_running": a scan that could not be read, a record whose process cannot be confirmed
|
|
788
|
+
* ("pid_record_unconfirmed"), or a process that may be a listener ("listener_unconfirmed").
|
|
501
789
|
*/
|
|
502
790
|
export async function getListenerProcessStatus(
|
|
503
791
|
sessionId,
|
|
@@ -510,6 +798,7 @@ export async function getListenerProcessStatus(
|
|
|
510
798
|
_listProcesses = activeListProcessTable,
|
|
511
799
|
} = {},
|
|
512
800
|
) {
|
|
801
|
+
const listenerKey = normalizeListenerProcessKey(agentId);
|
|
513
802
|
const records = [
|
|
514
803
|
{
|
|
515
804
|
record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
|
|
@@ -520,6 +809,7 @@ export async function getListenerProcessStatus(
|
|
|
520
809
|
recordScope: "local",
|
|
521
810
|
},
|
|
522
811
|
];
|
|
812
|
+
const tracked = [];
|
|
523
813
|
const staleStatuses = [];
|
|
524
814
|
for (const entry of records) {
|
|
525
815
|
if (!entry.record) continue;
|
|
@@ -529,38 +819,74 @@ export async function getListenerProcessStatus(
|
|
|
529
819
|
recordScope: entry.recordScope,
|
|
530
820
|
_readProcessCommandLine,
|
|
531
821
|
});
|
|
532
|
-
|
|
533
|
-
|
|
822
|
+
(status.running ? tracked : staleStatuses).push(status);
|
|
823
|
+
}
|
|
824
|
+
const unknown = (reason, record) => ({
|
|
825
|
+
state: "unknown",
|
|
826
|
+
running: null,
|
|
827
|
+
pid: null,
|
|
828
|
+
reason,
|
|
829
|
+
record: record || tracked[0]?.record || staleStatuses[0]?.record || null,
|
|
830
|
+
listenerKey,
|
|
831
|
+
});
|
|
832
|
+
const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
|
|
833
|
+
if (!scan.ok) return unknown(scan.reason);
|
|
834
|
+
|
|
835
|
+
const ancestors = checkerAncestors(scan.rows, process.pid);
|
|
836
|
+
const table = new Map(scan.rows.map((row) => [row.pid, row]));
|
|
837
|
+
const confirmed = [];
|
|
838
|
+
const unconfirmedRecords = [];
|
|
839
|
+
for (const status of tracked) {
|
|
840
|
+
const row = table.get(Number(status.pid)) || null;
|
|
841
|
+
const holder = pidRecordHolder(status.record, row, { ancestors, commandLine: status.commandLine });
|
|
842
|
+
if (holder === "confirmed") {
|
|
843
|
+
confirmed.push({ ...status, startedAtMs: row.startedAtMs, ancestor: false });
|
|
844
|
+
} else if (holder === "unconfirmed") {
|
|
845
|
+
unconfirmedRecords.push(status);
|
|
846
|
+
} else {
|
|
847
|
+
staleStatuses.push({
|
|
848
|
+
state: "not_running",
|
|
849
|
+
running: false,
|
|
850
|
+
pid: null,
|
|
851
|
+
stale: true,
|
|
852
|
+
reused: holder !== "gone",
|
|
853
|
+
holder,
|
|
854
|
+
record: status.record,
|
|
855
|
+
recordScope: status.recordScope,
|
|
856
|
+
listenerKey,
|
|
857
|
+
commandLine: status.commandLine,
|
|
858
|
+
});
|
|
534
859
|
}
|
|
535
|
-
staleStatuses.push(status);
|
|
536
860
|
}
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
861
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, {
|
|
862
|
+
sessionId,
|
|
863
|
+
agentId,
|
|
864
|
+
selfPid: process.pid,
|
|
865
|
+
ancestors,
|
|
540
866
|
});
|
|
541
|
-
if (
|
|
542
|
-
return
|
|
543
|
-
state: "unknown",
|
|
544
|
-
running: null,
|
|
545
|
-
pid: null,
|
|
546
|
-
reason: scan.reason,
|
|
547
|
-
record: staleStatuses[0]?.record || null,
|
|
548
|
-
listenerKey: normalizeListenerProcessKey(agentId),
|
|
549
|
-
};
|
|
867
|
+
if (unconfirmedRecords.length > 0) {
|
|
868
|
+
return unknown("pid_record_unconfirmed", unconfirmedRecords[0].record);
|
|
550
869
|
}
|
|
551
|
-
|
|
552
|
-
|
|
870
|
+
if (unconfirmed.length > 0) return unknown("listener_unconfirmed");
|
|
871
|
+
// A listener that is an ancestor of this process is the one reported, so that --force
|
|
872
|
+
// refuses rather than stop it.
|
|
873
|
+
const ancestorListener = listeners.find((row) => row.ancestor);
|
|
874
|
+
if (confirmed.length > 0 && !ancestorListener) return confirmed[0];
|
|
875
|
+
const reported = ancestorListener || listeners[0];
|
|
876
|
+
if (reported) {
|
|
553
877
|
return {
|
|
554
878
|
state: "running",
|
|
555
879
|
running: true,
|
|
556
|
-
pid:
|
|
880
|
+
pid: reported.pid,
|
|
881
|
+
startedAtMs: reported.startedAtMs,
|
|
882
|
+
ancestor: Boolean(reported.ancestor),
|
|
557
883
|
stale: false,
|
|
558
884
|
record: null,
|
|
559
885
|
recordScope: "process_scan",
|
|
560
886
|
untracked: true,
|
|
561
|
-
listenerKey
|
|
562
|
-
commandLine:
|
|
563
|
-
matchingProcesses,
|
|
887
|
+
listenerKey,
|
|
888
|
+
commandLine: reported.commandLine,
|
|
889
|
+
matchingProcesses: listeners,
|
|
564
890
|
};
|
|
565
891
|
}
|
|
566
892
|
if (staleStatuses.length === 0) {
|
|
@@ -570,34 +896,78 @@ export async function getListenerProcessStatus(
|
|
|
570
896
|
pid: null,
|
|
571
897
|
stale: false,
|
|
572
898
|
record: null,
|
|
573
|
-
listenerKey
|
|
899
|
+
listenerKey,
|
|
574
900
|
};
|
|
575
901
|
}
|
|
576
902
|
return staleStatuses[0];
|
|
577
903
|
}
|
|
578
904
|
|
|
905
|
+
// Why the takeover's own scan does not bear out `listener`, the status check's answer, or
|
|
906
|
+
// null when it does. `row` is the scan's row for that pid (null when it is gone, which
|
|
907
|
+
// leaves nothing to stop for it), and `isListener` says whether the scan counts that row as
|
|
908
|
+
// a current listener for this session and agent (readListenerScan). The pid must name the
|
|
909
|
+
// same process the status check saw, by start time (exact on Windows, within 2 s on POSIX:
|
|
910
|
+
// startTimeSlackMs): otherwise it changed hands ("reused"), even when the new holder is a
|
|
911
|
+
// listener too. And it must still be a current listener; a start time that cannot be
|
|
912
|
+
// compared, or the same process no longer counted as a listener, is "unconfirmed".
|
|
913
|
+
function takeoverTargetRefusal(listener, row, { isListener, platform = process.platform }) {
|
|
914
|
+
if (!row) return null;
|
|
915
|
+
if (!Number.isFinite(listener?.startedAtMs) || !Number.isFinite(row.startedAtMs)) return "unconfirmed";
|
|
916
|
+
if (Math.abs(row.startedAtMs - listener.startedAtMs) > startTimeSlackMs(platform)) return "reused";
|
|
917
|
+
return isListener ? null : "unconfirmed";
|
|
918
|
+
}
|
|
919
|
+
|
|
579
920
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
921
|
+
* The --force takeover. --force stops only current listeners for this session and agent, and
|
|
922
|
+
* refuses with no stops if the listener the status check saw has changed hands or can no
|
|
923
|
+
* longer be confirmed. One scan is taken, and the stop set is exactly that scan's listeners
|
|
924
|
+
* for this session and agent (readListenerScan): never the checker, never a launcher, and
|
|
925
|
+
* never the pid the status check found unless this scan counts it as a listener. Every
|
|
926
|
+
* refusal is decided before the first stop: a listener that is an ancestor of the checker
|
|
927
|
+
* ("ancestor_listener"), a process that may be a listener ("listener_unconfirmed"), or the
|
|
928
|
+
* status check's listener changed hands or unconfirmed (takeoverTargetRefusal). It returns
|
|
929
|
+
* { ok: true, refused: { pid, reason } } then. When the process table cannot be read it stops
|
|
930
|
+
* nothing and returns { ok: false, reason }; the caller then stops nothing either, since what
|
|
931
|
+
* it found earlier may no longer hold.
|
|
582
932
|
*/
|
|
583
933
|
export async function stopMatchingListenerProcesses(
|
|
584
934
|
sessionId,
|
|
585
935
|
agentId,
|
|
586
936
|
{
|
|
587
937
|
excludePid = process.pid,
|
|
938
|
+
listener = null,
|
|
588
939
|
timeoutMs = 3000,
|
|
589
940
|
pollIntervalMs = 100,
|
|
590
941
|
scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
591
942
|
_listProcesses = activeListProcessTable,
|
|
592
943
|
} = {},
|
|
593
944
|
) {
|
|
594
|
-
const scan = await
|
|
595
|
-
excludePid,
|
|
596
|
-
timeoutMs: scanTimeoutMs,
|
|
597
|
-
_listProcesses,
|
|
598
|
-
});
|
|
945
|
+
const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
|
|
599
946
|
if (!scan.ok) return scan;
|
|
600
|
-
const
|
|
947
|
+
const selfPid = Number(excludePid);
|
|
948
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
949
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
|
|
950
|
+
const refuse = (pid, reason) => ({
|
|
951
|
+
ok: true,
|
|
952
|
+
refused: { pid, reason },
|
|
953
|
+
matches: listeners,
|
|
954
|
+
results: [],
|
|
955
|
+
stoppedCount: 0,
|
|
956
|
+
failedCount: 0,
|
|
957
|
+
failed: [],
|
|
958
|
+
});
|
|
959
|
+
const ancestorListener = listeners.find((row) => row.ancestor);
|
|
960
|
+
if (ancestorListener) return refuse(ancestorListener.pid, "ancestor_listener");
|
|
961
|
+
if (unconfirmed.length > 0) return refuse(unconfirmed[0].pid, "listener_unconfirmed");
|
|
962
|
+
// Decide everything, then act: every refusal above and here comes before the first stop.
|
|
963
|
+
const matches = [...listeners];
|
|
964
|
+
const listenerPid = Number(listener?.pid);
|
|
965
|
+
if (Number.isInteger(listenerPid) && listenerPid > 0) {
|
|
966
|
+
const row = scan.rows.find((entry) => entry.pid === listenerPid) || null;
|
|
967
|
+
const isListener = matches.some((match) => match.pid === listenerPid);
|
|
968
|
+
const refusal = takeoverTargetRefusal(listener, row, { isListener });
|
|
969
|
+
if (refusal) return refuse(listenerPid, refusal);
|
|
970
|
+
}
|
|
601
971
|
const results = [];
|
|
602
972
|
for (const match of matches) {
|
|
603
973
|
const result = await requestListenerProcessStop(match.pid, {
|