@agent-device/proxy 0.0.0-stage → 0.21.24
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 +107 -2
- package/dist/errors.mjs +310 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.mjs +464 -0
- package/dist/node-http.mjs +68 -0
- package/dist/process.mjs +1312 -0
- package/dist/version.mjs +179 -0
- package/package.json +32 -3
package/dist/process.mjs
ADDED
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
import { i as redactDiagnosticData, n as createRequestCanceledError, t as AppError } from "./errors.mjs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
//#region ../host-kit/src/internal/diagnostics.ts
|
|
10
|
+
const diagnosticsStorage = new AsyncLocalStorage();
|
|
11
|
+
function updateDiagnosticsScope(options) {
|
|
12
|
+
const scope = diagnosticsStorage.getStore();
|
|
13
|
+
if (!scope) return;
|
|
14
|
+
Object.assign(scope, options);
|
|
15
|
+
}
|
|
16
|
+
function getDiagnosticsMeta() {
|
|
17
|
+
const scope = diagnosticsStorage.getStore();
|
|
18
|
+
if (!scope) return {};
|
|
19
|
+
const { diagnosticId, requestId, session, command, debug, flushOnSuccess } = scope;
|
|
20
|
+
return {
|
|
21
|
+
diagnosticId,
|
|
22
|
+
requestId,
|
|
23
|
+
session,
|
|
24
|
+
command,
|
|
25
|
+
debug,
|
|
26
|
+
flushOnSuccess
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function emitDiagnostic(event) {
|
|
30
|
+
const scope = diagnosticsStorage.getStore();
|
|
31
|
+
if (!scope) return;
|
|
32
|
+
const payload = {
|
|
33
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34
|
+
level: event.level ?? "info",
|
|
35
|
+
phase: event.phase,
|
|
36
|
+
session: scope.session,
|
|
37
|
+
requestId: scope.requestId,
|
|
38
|
+
command: scope.command,
|
|
39
|
+
durationMs: event.durationMs,
|
|
40
|
+
data: event.data ? redactScopeData(scope, event.data) : void 0
|
|
41
|
+
};
|
|
42
|
+
scope.events.push(payload);
|
|
43
|
+
scope.phaseCounts.set(event.phase, (scope.phaseCounts.get(event.phase) ?? 0) + 1);
|
|
44
|
+
if (!scope.debug && !scope.traceLogPath) return;
|
|
45
|
+
const fileLine = `${JSON.stringify(payload)}\n`;
|
|
46
|
+
try {
|
|
47
|
+
if (scope.debug && scope.logPath) {
|
|
48
|
+
appendDiagnosticLine(scope, scope.logPath, fileLine);
|
|
49
|
+
scope.liveWrittenEventCount = scope.events.length;
|
|
50
|
+
}
|
|
51
|
+
if (scope.traceLogPath) appendDiagnosticLine(scope, scope.traceLogPath, fileLine);
|
|
52
|
+
if (scope.debug && !scope.logPath && !scope.traceLogPath) process.stderr.write(`[agent-device][diag] ${fileLine}`);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
/** Longest-first so an overlapping shorter literal cannot truncate a longer one. */
|
|
56
|
+
function sortedScopeSensitiveValues(scope) {
|
|
57
|
+
scope.sortedSensitiveValues ??= [...scope.sensitiveValues].sort((left, right) => right.length - left.length);
|
|
58
|
+
return scope.sortedSensitiveValues;
|
|
59
|
+
}
|
|
60
|
+
function redactScopeData(scope, input) {
|
|
61
|
+
return replaceSensitiveValues(redactDiagnosticData(input), sortedScopeSensitiveValues(scope));
|
|
62
|
+
}
|
|
63
|
+
function replaceSensitiveValues(value, sensitiveValues) {
|
|
64
|
+
if (typeof value === "string") return sensitiveValues.reduce((output, sensitive) => output.replaceAll(sensitive, "[REDACTED]"), value);
|
|
65
|
+
if (Array.isArray(value)) return value.map((entry) => replaceSensitiveValues(entry, sensitiveValues));
|
|
66
|
+
if (!value || typeof value !== "object") return value;
|
|
67
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, replaceSensitiveValues(entry, sensitiveValues)]));
|
|
68
|
+
}
|
|
69
|
+
function appendDiagnosticLine(scope, logPath, line) {
|
|
70
|
+
const dir = path.dirname(logPath);
|
|
71
|
+
if (!scope.ensuredDirectories.has(dir)) {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
scope.ensuredDirectories.add(dir);
|
|
74
|
+
}
|
|
75
|
+
fs.appendFileSync(logPath, line, "utf8");
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region ../kernel/src/source-value.ts
|
|
79
|
+
const BOOLEAN_TRUE_VALUES = /* @__PURE__ */ new Set([
|
|
80
|
+
"1",
|
|
81
|
+
"true",
|
|
82
|
+
"yes",
|
|
83
|
+
"on"
|
|
84
|
+
]);
|
|
85
|
+
const BOOLEAN_FALSE_VALUES = /* @__PURE__ */ new Set([
|
|
86
|
+
"0",
|
|
87
|
+
"false",
|
|
88
|
+
"no",
|
|
89
|
+
"off"
|
|
90
|
+
]);
|
|
91
|
+
function parseBooleanLiteral(value) {
|
|
92
|
+
const normalized = value.trim().toLowerCase();
|
|
93
|
+
if (BOOLEAN_TRUE_VALUES.has(normalized)) return true;
|
|
94
|
+
if (BOOLEAN_FALSE_VALUES.has(normalized)) return false;
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region ../host-kit/src/internal/exec.ts
|
|
98
|
+
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
99
|
+
const EXEC_DIAGNOSTIC_ARG_LIMIT = 6;
|
|
100
|
+
const commandExecutorOverrideScope = new AsyncLocalStorage();
|
|
101
|
+
const requestCommandEnvScope = new AsyncLocalStorage();
|
|
102
|
+
/** Effective toolchain selection; an explicitly empty request value selects `xcode-select`. */
|
|
103
|
+
function commandDeveloperDir() {
|
|
104
|
+
const requestEnv = requestCommandEnvScope.getStore();
|
|
105
|
+
if (requestEnv && Object.hasOwn(requestEnv, "DEVELOPER_DIR")) return requestEnv.DEVELOPER_DIR || void 0;
|
|
106
|
+
return process.env.DEVELOPER_DIR || void 0;
|
|
107
|
+
}
|
|
108
|
+
function resolveSpawnEnv(env) {
|
|
109
|
+
const requestEnv = requestCommandEnvScope.getStore();
|
|
110
|
+
if (!requestEnv) return env;
|
|
111
|
+
return {
|
|
112
|
+
...env ?? process.env,
|
|
113
|
+
...requestEnv
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function runCmd(cmd, args, options = {}) {
|
|
117
|
+
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
|
118
|
+
if (overrideResult) return coerceExecResult(await overrideResult);
|
|
119
|
+
return await runSpawnedCommand(cmd, args, options);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Normalize an exec result produced outside this module. Tool providers,
|
|
123
|
+
* executor overrides, and SDK-supplied adb executors are plain callbacks whose
|
|
124
|
+
* results cross an unchecked boundary; coercing once here lets downstream code
|
|
125
|
+
* trust the ExecResult types instead of re-wrapping fields defensively at every
|
|
126
|
+
* use. A non-number exitCode coerces to 1 — the same failure branch such a
|
|
127
|
+
* result already landed in at every `exitCode !== 0` guard.
|
|
128
|
+
*/
|
|
129
|
+
function coerceExecResult(result) {
|
|
130
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
|
131
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
|
|
132
|
+
const exitCode = typeof result.exitCode === "number" ? result.exitCode : 1;
|
|
133
|
+
if (stdout === result.stdout && stderr === result.stderr && exitCode === result.exitCode) return result;
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
stdout,
|
|
137
|
+
stderr,
|
|
138
|
+
exitCode
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function runSpawnedCommand(cmd, args, options = {}) {
|
|
142
|
+
const executable = normalizeExecutableCommand(cmd);
|
|
143
|
+
const execTrace = createExecTraceContext();
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const child = spawn(executable, args, {
|
|
146
|
+
cwd: options.cwd,
|
|
147
|
+
env: resolveSpawnEnv(options.env),
|
|
148
|
+
stdio: [
|
|
149
|
+
"pipe",
|
|
150
|
+
"pipe",
|
|
151
|
+
"pipe"
|
|
152
|
+
],
|
|
153
|
+
detached: options.detached,
|
|
154
|
+
windowsHide: true,
|
|
155
|
+
shell: false
|
|
156
|
+
});
|
|
157
|
+
options.onSpawn?.(child);
|
|
158
|
+
let stdout = "";
|
|
159
|
+
const stdoutChunks = options.binaryStdout ? [] : void 0;
|
|
160
|
+
let stderr = "";
|
|
161
|
+
let didTimeout = false;
|
|
162
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
163
|
+
let timeoutHandle = null;
|
|
164
|
+
let settled = false;
|
|
165
|
+
function finish() {
|
|
166
|
+
if (settled) return false;
|
|
167
|
+
settled = true;
|
|
168
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
169
|
+
abort.dispose();
|
|
170
|
+
destroyCommandStreams(child);
|
|
171
|
+
execTrace.emitForegroundCompletion(cmd, args);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
function fail(error) {
|
|
175
|
+
if (finish()) reject(error);
|
|
176
|
+
}
|
|
177
|
+
function settle(code) {
|
|
178
|
+
if (!finish()) return;
|
|
179
|
+
const finalExitCode = code ?? 1;
|
|
180
|
+
if (!abort.didAbort && didTimeout && timeoutMs) {
|
|
181
|
+
reject(createTimeoutError(executable, cmd, args, timeoutMs, finalExitCode, stdout, stderr));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const failure = commandCloseFailure(abort, executable, cmd, args, finalExitCode, options.allowFailure, stdout, stderr);
|
|
185
|
+
if (failure) {
|
|
186
|
+
reject(failure);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
resolve({
|
|
190
|
+
stdout,
|
|
191
|
+
stderr,
|
|
192
|
+
exitCode: finalExitCode,
|
|
193
|
+
stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : void 0
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const settlement = createCommandKillSettlement({
|
|
197
|
+
killProcessTree: () => killProcessTree(child, options),
|
|
198
|
+
settle
|
|
199
|
+
});
|
|
200
|
+
const abort = watchCommandAbort(options, settlement.requestKill);
|
|
201
|
+
timeoutHandle = timeoutMs ? setTimeout(() => {
|
|
202
|
+
didTimeout = true;
|
|
203
|
+
settlement.requestKill();
|
|
204
|
+
}, timeoutMs) : null;
|
|
205
|
+
if (!options.binaryStdout) child.stdout.setEncoding("utf8");
|
|
206
|
+
child.stderr.setEncoding("utf8");
|
|
207
|
+
writeChildStdin(child, options.stdin).catch((error) => {
|
|
208
|
+
if (abort.didAbort || didTimeout) return;
|
|
209
|
+
if (isEpipeError(error)) return;
|
|
210
|
+
fail(createStdinError(executable, cmd, args, error));
|
|
211
|
+
killProcessTree(child, options);
|
|
212
|
+
});
|
|
213
|
+
child.stdout.on("data", (chunk) => {
|
|
214
|
+
if (options.binaryStdout) {
|
|
215
|
+
stdoutChunks?.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const text = String(chunk);
|
|
219
|
+
stdout += text;
|
|
220
|
+
options.onStdoutChunk?.(text);
|
|
221
|
+
});
|
|
222
|
+
child.stderr.on("data", (chunk) => {
|
|
223
|
+
const text = String(chunk);
|
|
224
|
+
stderr += text;
|
|
225
|
+
options.onStderrChunk?.(text);
|
|
226
|
+
});
|
|
227
|
+
child.on("error", (err) => {
|
|
228
|
+
fail(spawnRejectionError(abort, executable, cmd, args, err));
|
|
229
|
+
});
|
|
230
|
+
child.once("exit", settlement.recordExit);
|
|
231
|
+
child.once("close", settle);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function runCmdSync(cmd, args, options = {}) {
|
|
235
|
+
const executable = normalizeExecutableCommand(cmd);
|
|
236
|
+
const result = spawnSync(executable, args, {
|
|
237
|
+
cwd: options.cwd,
|
|
238
|
+
env: resolveSpawnEnv(options.env),
|
|
239
|
+
stdio: [
|
|
240
|
+
"pipe",
|
|
241
|
+
"pipe",
|
|
242
|
+
"pipe"
|
|
243
|
+
],
|
|
244
|
+
encoding: options.binaryStdout ? void 0 : "utf8",
|
|
245
|
+
input: options.stdin,
|
|
246
|
+
timeout: normalizeTimeoutMs(options.timeoutMs),
|
|
247
|
+
windowsHide: true,
|
|
248
|
+
shell: false,
|
|
249
|
+
...options.maxBuffer !== void 0 ? { maxBuffer: options.maxBuffer } : {}
|
|
250
|
+
});
|
|
251
|
+
if (result.error) {
|
|
252
|
+
const code = result.error.code;
|
|
253
|
+
if (code === "ETIMEDOUT") throw new AppError("COMMAND_FAILED", `${executable} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, {
|
|
254
|
+
cmd,
|
|
255
|
+
args,
|
|
256
|
+
timeoutMs: normalizeTimeoutMs(options.timeoutMs)
|
|
257
|
+
}, result.error);
|
|
258
|
+
if (code === "ENOENT") throw createMissingToolError(executable, cmd, result.error);
|
|
259
|
+
throw createCommandFailedError(executable, cmd, args, result.error);
|
|
260
|
+
}
|
|
261
|
+
const stdoutBuffer = options.binaryStdout ? Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "") : void 0;
|
|
262
|
+
const stdout = options.binaryStdout ? "" : typeof result.stdout === "string" ? result.stdout : (result.stdout ?? "").toString();
|
|
263
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : (result.stderr ?? "").toString();
|
|
264
|
+
const exitCode = result.status ?? 1;
|
|
265
|
+
if (exitCode !== 0 && !options.allowFailure) throw createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
266
|
+
return {
|
|
267
|
+
stdout,
|
|
268
|
+
stderr,
|
|
269
|
+
exitCode,
|
|
270
|
+
stdoutBuffer
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function createExecTraceContext() {
|
|
274
|
+
const diagnosticsMeta = getDiagnosticsMeta();
|
|
275
|
+
const diagnosticsDebugEnabled = diagnosticsMeta.debug === true;
|
|
276
|
+
const envTraceEnabled = parseBooleanLiteral(process.env.AGENT_DEVICE_EXEC_TRACE ?? "") === true;
|
|
277
|
+
if (!diagnosticsDebugEnabled && !envTraceEnabled) return createDisabledExecTraceContext();
|
|
278
|
+
if (envTraceEnabled && diagnosticsMeta.flushOnSuccess !== true) updateDiagnosticsScope({ flushOnSuccess: true });
|
|
279
|
+
const startedAtMs = Date.now();
|
|
280
|
+
let completionEmitted = false;
|
|
281
|
+
return {
|
|
282
|
+
emitForegroundCompletion: (cmd, args) => {
|
|
283
|
+
if (completionEmitted) return;
|
|
284
|
+
completionEmitted = true;
|
|
285
|
+
emitExecCommandDiagnostic({
|
|
286
|
+
cmd,
|
|
287
|
+
args,
|
|
288
|
+
startedAtMs
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
emitBackgroundSpawn: (cmd, args) => {
|
|
292
|
+
emitExecCommandDiagnostic({
|
|
293
|
+
cmd,
|
|
294
|
+
args,
|
|
295
|
+
data: { event: "spawn" }
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
emitBackgroundCompletion: (cmd, args, event) => {
|
|
299
|
+
if (completionEmitted) return;
|
|
300
|
+
completionEmitted = true;
|
|
301
|
+
emitExecCommandDiagnostic({
|
|
302
|
+
cmd,
|
|
303
|
+
args,
|
|
304
|
+
startedAtMs,
|
|
305
|
+
data: { event }
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function createDisabledExecTraceContext() {
|
|
311
|
+
return {
|
|
312
|
+
emitForegroundCompletion: () => {},
|
|
313
|
+
emitBackgroundSpawn: () => {},
|
|
314
|
+
emitBackgroundCompletion: () => {}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function emitExecCommandDiagnostic(params) {
|
|
318
|
+
const argsPrefix = params.args.slice(0, EXEC_DIAGNOSTIC_ARG_LIMIT);
|
|
319
|
+
emitDiagnostic({
|
|
320
|
+
level: "debug",
|
|
321
|
+
phase: "exec_command",
|
|
322
|
+
durationMs: params.startedAtMs === void 0 ? void 0 : Math.max(0, Date.now() - params.startedAtMs),
|
|
323
|
+
data: {
|
|
324
|
+
command: params.cmd,
|
|
325
|
+
argsPrefix,
|
|
326
|
+
...params.args.length > argsPrefix.length ? { omittedArgCount: params.args.length - argsPrefix.length } : {},
|
|
327
|
+
...params.data ?? {}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function normalizeExecutableCommand(cmd) {
|
|
332
|
+
const candidate = normalizeExecutableLookup(cmd);
|
|
333
|
+
if (!candidate) throw new AppError("INVALID_ARGS", `Invalid executable command: ${JSON.stringify(cmd)}`, {
|
|
334
|
+
cmd,
|
|
335
|
+
hint: "Use a bare command name from PATH or an absolute executable path."
|
|
336
|
+
});
|
|
337
|
+
return candidate;
|
|
338
|
+
}
|
|
339
|
+
function createSpawnError(executable, cmd, args, err) {
|
|
340
|
+
if (err.code === "ENOENT") return createMissingToolError(executable, cmd, err);
|
|
341
|
+
return createCommandFailedError(executable, cmd, args, err);
|
|
342
|
+
}
|
|
343
|
+
function createMissingToolError(executable, cmd, cause) {
|
|
344
|
+
return new AppError("TOOL_MISSING", `${executable} not found in PATH`, { cmd }, cause);
|
|
345
|
+
}
|
|
346
|
+
function createCommandFailedError(executable, cmd, args, cause) {
|
|
347
|
+
return new AppError("COMMAND_FAILED", `Failed to run ${executable}`, {
|
|
348
|
+
cmd,
|
|
349
|
+
args
|
|
350
|
+
}, cause);
|
|
351
|
+
}
|
|
352
|
+
function createStdinError(executable, cmd, args, cause) {
|
|
353
|
+
return new AppError("COMMAND_FAILED", `Failed to write stdin for ${executable}`, {
|
|
354
|
+
cmd,
|
|
355
|
+
args
|
|
356
|
+
}, cause instanceof Error ? cause : void 0);
|
|
357
|
+
}
|
|
358
|
+
function createTimeoutError(executable, cmd, args, timeoutMs, exitCode, stdout, stderr) {
|
|
359
|
+
return new AppError("COMMAND_FAILED", `${executable} timed out after ${timeoutMs}ms`, {
|
|
360
|
+
cmd,
|
|
361
|
+
args,
|
|
362
|
+
stdout,
|
|
363
|
+
stderr,
|
|
364
|
+
exitCode,
|
|
365
|
+
timeoutMs
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function createExitError(executable, cmd, args, exitCode, stdout, stderr) {
|
|
369
|
+
return new AppError("COMMAND_FAILED", `${executable} exited with code ${exitCode}`, execFailureDetails({
|
|
370
|
+
stdout,
|
|
371
|
+
stderr,
|
|
372
|
+
exitCode
|
|
373
|
+
}, {
|
|
374
|
+
cmd,
|
|
375
|
+
args
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
const XCRUN_TOOL_NOT_FOUND_STDERR = /xcrun: error: unable to find utility "([^"]+)"/;
|
|
379
|
+
function xcrunToolNotFoundDetails(stderr) {
|
|
380
|
+
const tool = XCRUN_TOOL_NOT_FOUND_STDERR.exec(stderr)?.[1];
|
|
381
|
+
if (!tool) return {};
|
|
382
|
+
const developerDir = commandDeveloperDir();
|
|
383
|
+
const source = developerDir ? `DEVELOPER_DIR ${developerDir}` : "the developer dir selected by xcode-select (see xcode-select -p)";
|
|
384
|
+
return {
|
|
385
|
+
reason: "xcrun-tool-not-found",
|
|
386
|
+
developerDir: developerDir ?? null,
|
|
387
|
+
hint: `xcrun could not find ${tool} in ${source}. Export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer (or your Xcode's path) before running agent-device, or run sudo xcode-select -s /Applications/Xcode.app.`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Exit-failure details for normalizeError, including null exit codes from signalled processes.
|
|
392
|
+
*/
|
|
393
|
+
function execFailureDetails(result, extra) {
|
|
394
|
+
return {
|
|
395
|
+
stdout: result.stdout,
|
|
396
|
+
stderr: result.stderr,
|
|
397
|
+
exitCode: result.exitCode,
|
|
398
|
+
processExitError: true,
|
|
399
|
+
...extra,
|
|
400
|
+
...xcrunToolNotFoundDetails(result.stderr)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function spawnRejectionError(abort, executable, cmd, args, err) {
|
|
404
|
+
return abort.didAbort ? createRequestCanceledError({
|
|
405
|
+
cmd,
|
|
406
|
+
args,
|
|
407
|
+
executable
|
|
408
|
+
}) : createSpawnError(executable, cmd, args, err);
|
|
409
|
+
}
|
|
410
|
+
function commandCloseFailure(abort, executable, cmd, args, exitCode, allowFailure, stdout, stderr) {
|
|
411
|
+
if (abort.didAbort) return createRequestCanceledError({
|
|
412
|
+
cmd,
|
|
413
|
+
args,
|
|
414
|
+
executable
|
|
415
|
+
});
|
|
416
|
+
if (exitCode !== 0 && !allowFailure) return createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
function normalizeExecutableLookup(cmd) {
|
|
420
|
+
const candidate = cmd.trim();
|
|
421
|
+
if (!candidate || candidate.includes("\0")) return null;
|
|
422
|
+
if (path.isAbsolute(candidate)) return candidate;
|
|
423
|
+
if (candidate.includes("/") || candidate.includes("\\")) return null;
|
|
424
|
+
return BARE_COMMAND_RE.test(candidate) ? candidate : null;
|
|
425
|
+
}
|
|
426
|
+
function normalizeTimeoutMs(value) {
|
|
427
|
+
if (!Number.isFinite(value)) return void 0;
|
|
428
|
+
const timeout = Math.floor(value);
|
|
429
|
+
if (timeout <= 0) return void 0;
|
|
430
|
+
return timeout;
|
|
431
|
+
}
|
|
432
|
+
function createCommandKillSettlement(input) {
|
|
433
|
+
let killRequested = false;
|
|
434
|
+
let exited = false;
|
|
435
|
+
let exitCode = null;
|
|
436
|
+
const settleIfKilledAndGone = () => {
|
|
437
|
+
if (killRequested && exited) input.settle(exitCode);
|
|
438
|
+
};
|
|
439
|
+
return {
|
|
440
|
+
requestKill: () => {
|
|
441
|
+
killRequested = true;
|
|
442
|
+
input.killProcessTree();
|
|
443
|
+
settleIfKilledAndGone();
|
|
444
|
+
},
|
|
445
|
+
recordExit: (code) => {
|
|
446
|
+
exited = true;
|
|
447
|
+
exitCode = code ?? 1;
|
|
448
|
+
settleIfKilledAndGone();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function watchCommandAbort(options, onKill) {
|
|
453
|
+
let didAbort = false;
|
|
454
|
+
const onAbort = () => {
|
|
455
|
+
didAbort = true;
|
|
456
|
+
onKill();
|
|
457
|
+
};
|
|
458
|
+
if (options.signal?.aborted) onAbort();
|
|
459
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
460
|
+
return {
|
|
461
|
+
get didAbort() {
|
|
462
|
+
return didAbort;
|
|
463
|
+
},
|
|
464
|
+
dispose: () => {
|
|
465
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Best-effort process-group signalling, shared by host-kit callers. This lives below
|
|
471
|
+
* host-process.ts to avoid an import cycle. Only positive integer pids are accepted:
|
|
472
|
+
* zero targets this process's group, and negative values can target unrelated processes.
|
|
473
|
+
*/
|
|
474
|
+
function signalProcessGroupBestEffort(pid, signal) {
|
|
475
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
476
|
+
try {
|
|
477
|
+
process.kill(-pid, signal);
|
|
478
|
+
return true;
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function killProcessTree(child, options) {
|
|
484
|
+
if (!options.kill) {
|
|
485
|
+
signalProcessTree(child, options.detached, "SIGKILL");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
signalProcessTree(child, options.detached, options.kill.signal);
|
|
489
|
+
const escalation = setTimeout(() => signalProcessTree(child, options.detached, "SIGKILL"), options.kill.graceMs);
|
|
490
|
+
escalation.unref();
|
|
491
|
+
child.once("exit", () => clearTimeout(escalation));
|
|
492
|
+
}
|
|
493
|
+
function signalProcessTree(child, detached, signal) {
|
|
494
|
+
if (detached && child.pid && process.platform !== "win32") {
|
|
495
|
+
signalProcessGroupBestEffort(child.pid, signal);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
499
|
+
child.kill(signal);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* A kill that cannot reach an inherited-pipe holder must at least stop this process
|
|
503
|
+
* from holding the other end of those pipes open after it has settled.
|
|
504
|
+
*/
|
|
505
|
+
function destroyCommandStreams(child) {
|
|
506
|
+
child.stdin?.destroy();
|
|
507
|
+
child.stdout?.destroy();
|
|
508
|
+
child.stderr?.destroy();
|
|
509
|
+
}
|
|
510
|
+
async function writeChildStdin(child, stdin) {
|
|
511
|
+
if (!child.stdin) return;
|
|
512
|
+
if (stdin === void 0) {
|
|
513
|
+
child.stdin?.end();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
await pipeline(Readable.from([stdin]), child.stdin);
|
|
517
|
+
}
|
|
518
|
+
function isEpipeError(error) {
|
|
519
|
+
return error instanceof Error && "code" in error && error.code === "EPIPE";
|
|
520
|
+
}
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region ../host-kit/src/internal/retry.ts
|
|
523
|
+
function sleep(ms, signal) {
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
if (signal?.aborted) {
|
|
526
|
+
resolve();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
let settled = false;
|
|
530
|
+
const finish = () => {
|
|
531
|
+
if (settled) return;
|
|
532
|
+
settled = true;
|
|
533
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
534
|
+
resolve();
|
|
535
|
+
};
|
|
536
|
+
const timer = setTimeout(finish, ms);
|
|
537
|
+
function onAbort() {
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
finish();
|
|
540
|
+
}
|
|
541
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region ../host-kit/src/internal/host-process.ts
|
|
546
|
+
const PS_TIMEOUT_MS = 1e3;
|
|
547
|
+
const HOST_PS_COMMAND = "/bin/ps";
|
|
548
|
+
const WINDOWS_PS_TIMEOUT_MS = 2e3;
|
|
549
|
+
const WINDOWS_PROCESS_TOOL = "powershell.exe";
|
|
550
|
+
function hostEnvironment() {
|
|
551
|
+
return process.env;
|
|
552
|
+
}
|
|
553
|
+
function readHostEnvironmentVariable(name) {
|
|
554
|
+
return hostEnvironment()[name];
|
|
555
|
+
}
|
|
556
|
+
function writeHostStderr(value) {
|
|
557
|
+
process.stderr.write(value);
|
|
558
|
+
}
|
|
559
|
+
function hostCurrentWorkingDirectory() {
|
|
560
|
+
return process.cwd();
|
|
561
|
+
}
|
|
562
|
+
function hostNodeExecutablePath() {
|
|
563
|
+
return process.execPath;
|
|
564
|
+
}
|
|
565
|
+
function hostNodeVersion() {
|
|
566
|
+
return process.version;
|
|
567
|
+
}
|
|
568
|
+
function hostPlatform() {
|
|
569
|
+
return process.platform;
|
|
570
|
+
}
|
|
571
|
+
function hostProcessId() {
|
|
572
|
+
return process.pid;
|
|
573
|
+
}
|
|
574
|
+
function isProcessAlive(pid) {
|
|
575
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
576
|
+
try {
|
|
577
|
+
process.kill(pid, 0);
|
|
578
|
+
return true;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
return error.code === "EPERM";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function isProcessGroupAlive(pid) {
|
|
584
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
585
|
+
try {
|
|
586
|
+
process.kill(-pid, 0);
|
|
587
|
+
return true;
|
|
588
|
+
} catch (error) {
|
|
589
|
+
return error.code === "EPERM";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Reads who owns a pid, off the event loop, with a budget the caller names. The three fields
|
|
594
|
+
* are read concurrently so a loaded host pays one budget for the set rather than one per
|
|
595
|
+
* field; on Windows one CIM query answers the whole set. Use this rather than the synchronous
|
|
596
|
+
* probes when the answer decides whether a process is signaled: waiting is reversible and
|
|
597
|
+
* signaling the wrong process is not.
|
|
598
|
+
*/
|
|
599
|
+
async function readProcessIdentityFacts(pid, timeoutMs = PS_TIMEOUT_MS) {
|
|
600
|
+
if (isWindowsHostPlatform()) return await readWindowsProcessIdentityFacts(pid, timeoutMs);
|
|
601
|
+
const [startTime, command, state] = await Promise.all([
|
|
602
|
+
readProcessFieldAsync(pid, "lstart=", timeoutMs),
|
|
603
|
+
readProcessFieldAsync(pid, "command=", timeoutMs),
|
|
604
|
+
readProcessFieldAsync(pid, "state=", timeoutMs)
|
|
605
|
+
]);
|
|
606
|
+
return {
|
|
607
|
+
startTime,
|
|
608
|
+
command,
|
|
609
|
+
zombie: state === null ? null : state.startsWith("Z")
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function readProcessStartTime(pid) {
|
|
613
|
+
return readProcessField(pid, "lstart=");
|
|
614
|
+
}
|
|
615
|
+
function readProcessCommand(pid) {
|
|
616
|
+
return readProcessField(pid, "command=");
|
|
617
|
+
}
|
|
618
|
+
function isProcessZombie(pid) {
|
|
619
|
+
if (isWindowsHostPlatform()) return false;
|
|
620
|
+
return readProcessField(pid, "state=")?.startsWith("Z") ?? false;
|
|
621
|
+
}
|
|
622
|
+
function readHostProcessIdentityObservations(pids) {
|
|
623
|
+
const observations = /* @__PURE__ */ new Map();
|
|
624
|
+
const selected = uniquePositivePids(pids);
|
|
625
|
+
if (selected.length === 0) return observations;
|
|
626
|
+
if (isWindowsHostPlatform()) {
|
|
627
|
+
for (const row of readWindowsProcessRows(selected, WINDOWS_PS_TIMEOUT_MS)) {
|
|
628
|
+
if (row.startTime === null) continue;
|
|
629
|
+
observations.set(row.pid, {
|
|
630
|
+
state: "R",
|
|
631
|
+
startTime: row.startTime
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return observations;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const result = runCmdSync(HOST_PS_COMMAND, [
|
|
638
|
+
"-p",
|
|
639
|
+
selected.join(","),
|
|
640
|
+
"-o",
|
|
641
|
+
"pid=,state=,lstart="
|
|
642
|
+
], {
|
|
643
|
+
allowFailure: true,
|
|
644
|
+
timeoutMs: PS_TIMEOUT_MS
|
|
645
|
+
});
|
|
646
|
+
if (result.exitCode !== 0) return observations;
|
|
647
|
+
for (const line of result.stdout.split("\n")) {
|
|
648
|
+
const match = /^\s*(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
649
|
+
if (!match) continue;
|
|
650
|
+
const pid = Number.parseInt(match[1], 10);
|
|
651
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
652
|
+
observations.set(pid, {
|
|
653
|
+
state: match[2],
|
|
654
|
+
startTime: match[3]
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
} catch {}
|
|
658
|
+
return observations;
|
|
659
|
+
}
|
|
660
|
+
function readProcessField(pid, field) {
|
|
661
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
662
|
+
if (isWindowsHostPlatform()) return readWindowsProcessField(pid, field);
|
|
663
|
+
try {
|
|
664
|
+
return processFieldValue(runCmdSync(HOST_PS_COMMAND, [
|
|
665
|
+
"-p",
|
|
666
|
+
String(pid),
|
|
667
|
+
"-o",
|
|
668
|
+
field
|
|
669
|
+
], {
|
|
670
|
+
allowFailure: true,
|
|
671
|
+
timeoutMs: PS_TIMEOUT_MS
|
|
672
|
+
}));
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async function readProcessFieldAsync(pid, field, timeoutMs) {
|
|
678
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
679
|
+
try {
|
|
680
|
+
return processFieldValue(await runCmd(HOST_PS_COMMAND, [
|
|
681
|
+
"-p",
|
|
682
|
+
String(pid),
|
|
683
|
+
"-o",
|
|
684
|
+
field
|
|
685
|
+
], {
|
|
686
|
+
allowFailure: true,
|
|
687
|
+
timeoutMs
|
|
688
|
+
}));
|
|
689
|
+
} catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function processFieldValue(result) {
|
|
694
|
+
if (result.exitCode !== 0) return null;
|
|
695
|
+
const value = result.stdout.trim();
|
|
696
|
+
return value.length > 0 ? value : null;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Walks process-table stdout line by line, keeping rows whose first two
|
|
700
|
+
* capture groups are a positive pid and an optional positive ppid. Every
|
|
701
|
+
* process-table format (POSIX `ps`, Windows CIM) feeds its own row pattern
|
|
702
|
+
* and row builder through this single walk.
|
|
703
|
+
*/
|
|
704
|
+
function parseProcessTableRows(stdout, rowPattern, build) {
|
|
705
|
+
const rows = [];
|
|
706
|
+
for (const line of stdout.replace(/^\uFEFF/, "").split("\n")) {
|
|
707
|
+
const match = rowPattern.exec(line);
|
|
708
|
+
if (!match) continue;
|
|
709
|
+
const pid = Number.parseInt(match[1], 10);
|
|
710
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
711
|
+
const ppid = Number.parseInt(match[2], 10);
|
|
712
|
+
rows.push(build(match, pid, Number.isInteger(ppid) && ppid > 0 ? ppid : void 0));
|
|
713
|
+
}
|
|
714
|
+
return rows;
|
|
715
|
+
}
|
|
716
|
+
function parseHostProcessList(stdout) {
|
|
717
|
+
return parseProcessTableRows(stdout, /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/, (match, pid, ppid) => ({
|
|
718
|
+
pid,
|
|
719
|
+
ppid,
|
|
720
|
+
command: match[3]
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
function isWindowsHostPlatform() {
|
|
724
|
+
return hostPlatform() === "win32";
|
|
725
|
+
}
|
|
726
|
+
const WINDOWS_PROCESS_ROW_BODY = " | ForEach-Object { $birth = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssfffffff', [System.Globalization.CultureInfo]::InvariantCulture) } else { '' }; '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.ParentProcessId, $birth," + String.raw` ($_.CommandLine -replace '[\r\n]+', ' ') }`;
|
|
727
|
+
function windowsProcessQueryArgs(pids) {
|
|
728
|
+
return [
|
|
729
|
+
"-NoProfile",
|
|
730
|
+
"-NonInteractive",
|
|
731
|
+
"-Command",
|
|
732
|
+
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ${pids.length > 0 ? `Get-CimInstance -ClassName Win32_Process -Filter '${pids.map((pid) => `ProcessId=${pid}`).join(" OR ")}'` : "Get-CimInstance -ClassName Win32_Process"}${WINDOWS_PROCESS_ROW_BODY}`
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
function parseWindowsProcessRows(stdout) {
|
|
736
|
+
return parseProcessTableRows(stdout, /^(\d+)\|(\d+)\|([^|]*)\|(.*)\r?$/, (match, pid, ppid) => ({
|
|
737
|
+
pid,
|
|
738
|
+
...ppid !== void 0 ? { ppid } : {},
|
|
739
|
+
startTime: match[3].length > 0 ? match[3] : null,
|
|
740
|
+
command: match[4].length > 0 ? match[4] : null
|
|
741
|
+
}));
|
|
742
|
+
}
|
|
743
|
+
function readWindowsProcessRows(pids, timeoutMs) {
|
|
744
|
+
try {
|
|
745
|
+
const result = runCmdSync(WINDOWS_PROCESS_TOOL, windowsProcessQueryArgs(pids), {
|
|
746
|
+
allowFailure: true,
|
|
747
|
+
timeoutMs
|
|
748
|
+
});
|
|
749
|
+
if (result.exitCode !== 0) return [];
|
|
750
|
+
return parseWindowsProcessRows(result.stdout);
|
|
751
|
+
} catch {
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function readWindowsProcessField(pid, field) {
|
|
756
|
+
const row = readWindowsProcessRows([pid], WINDOWS_PS_TIMEOUT_MS).find((candidate) => candidate.pid === pid);
|
|
757
|
+
if (!row) return null;
|
|
758
|
+
if (field === "lstart=") return row.startTime;
|
|
759
|
+
if (field === "command=") return row.command;
|
|
760
|
+
return "R";
|
|
761
|
+
}
|
|
762
|
+
function windowsBudgetMs(timeoutMs) {
|
|
763
|
+
return Math.max(timeoutMs, WINDOWS_PS_TIMEOUT_MS);
|
|
764
|
+
}
|
|
765
|
+
async function readWindowsProcessIdentityFacts(pid, timeoutMs) {
|
|
766
|
+
if (!Number.isInteger(pid) || pid <= 0) return {
|
|
767
|
+
startTime: null,
|
|
768
|
+
command: null,
|
|
769
|
+
zombie: null
|
|
770
|
+
};
|
|
771
|
+
try {
|
|
772
|
+
const result = await runCmd(WINDOWS_PROCESS_TOOL, windowsProcessQueryArgs([pid]), {
|
|
773
|
+
allowFailure: true,
|
|
774
|
+
timeoutMs: windowsBudgetMs(timeoutMs)
|
|
775
|
+
});
|
|
776
|
+
if (result.exitCode !== 0) return {
|
|
777
|
+
startTime: null,
|
|
778
|
+
command: null,
|
|
779
|
+
zombie: null
|
|
780
|
+
};
|
|
781
|
+
const row = parseWindowsProcessRows(result.stdout).find((candidate) => candidate.pid === pid);
|
|
782
|
+
if (!row) return {
|
|
783
|
+
startTime: null,
|
|
784
|
+
command: null,
|
|
785
|
+
zombie: null
|
|
786
|
+
};
|
|
787
|
+
return {
|
|
788
|
+
startTime: row.startTime,
|
|
789
|
+
command: row.command,
|
|
790
|
+
zombie: false
|
|
791
|
+
};
|
|
792
|
+
} catch {
|
|
793
|
+
return {
|
|
794
|
+
startTime: null,
|
|
795
|
+
command: null,
|
|
796
|
+
zombie: null
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async function listWindowsHostProcesses(options) {
|
|
801
|
+
const result = await (options.runCommand ?? runCmd)(WINDOWS_PROCESS_TOOL, windowsProcessQueryArgs([]), {
|
|
802
|
+
allowFailure: true,
|
|
803
|
+
timeoutMs: windowsBudgetMs(options.timeoutMs)
|
|
804
|
+
});
|
|
805
|
+
if (result.exitCode !== 0) return [];
|
|
806
|
+
return parseWindowsProcessRows(result.stdout).map((row) => ({
|
|
807
|
+
pid: row.pid,
|
|
808
|
+
...row.ppid !== void 0 ? { ppid: row.ppid } : {},
|
|
809
|
+
command: row.command ?? ""
|
|
810
|
+
}));
|
|
811
|
+
}
|
|
812
|
+
async function listHostProcesses(options) {
|
|
813
|
+
if (isWindowsHostPlatform()) return await listWindowsHostProcesses(options);
|
|
814
|
+
const result = await (options.runCommand ?? runCmd)(options.runCommand ? "ps" : HOST_PS_COMMAND, [
|
|
815
|
+
"-ax",
|
|
816
|
+
"-o",
|
|
817
|
+
"pid=,ppid=,command="
|
|
818
|
+
], {
|
|
819
|
+
allowFailure: true,
|
|
820
|
+
timeoutMs: options.timeoutMs
|
|
821
|
+
});
|
|
822
|
+
if (result.exitCode !== 0) return [];
|
|
823
|
+
return parseHostProcessList(result.stdout);
|
|
824
|
+
}
|
|
825
|
+
function expandProcessTree(rootPids, processes) {
|
|
826
|
+
const selected = new Set(uniquePositivePids(rootPids));
|
|
827
|
+
let changed = true;
|
|
828
|
+
while (changed) {
|
|
829
|
+
changed = false;
|
|
830
|
+
for (const processInfo of processes) {
|
|
831
|
+
if (processInfo.ppid === void 0 || !selected.has(processInfo.ppid)) continue;
|
|
832
|
+
if (selected.has(processInfo.pid)) continue;
|
|
833
|
+
selected.add(processInfo.pid);
|
|
834
|
+
changed = true;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return processes.filter((processInfo) => selected.has(processInfo.pid));
|
|
838
|
+
}
|
|
839
|
+
function uniquePositivePids(values, options = {}) {
|
|
840
|
+
return [...new Set(values)].filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== options.excludePid);
|
|
841
|
+
}
|
|
842
|
+
function signalPidsBestEffort(pidsToSignal, signal) {
|
|
843
|
+
const pids = uniquePositivePids(pidsToSignal, { excludePid: process.pid });
|
|
844
|
+
let signaled = 0;
|
|
845
|
+
for (const pid of pids) try {
|
|
846
|
+
process.kill(pid, signal);
|
|
847
|
+
signaled += 1;
|
|
848
|
+
} catch {}
|
|
849
|
+
return signaled;
|
|
850
|
+
}
|
|
851
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
852
|
+
if (!isProcessAlive(pid)) return true;
|
|
853
|
+
const start = Date.now();
|
|
854
|
+
while (Date.now() - start < timeoutMs) {
|
|
855
|
+
await sleep(50);
|
|
856
|
+
if (!isProcessAlive(pid)) return true;
|
|
857
|
+
}
|
|
858
|
+
return !isProcessAlive(pid);
|
|
859
|
+
}
|
|
860
|
+
async function stopPidsWithEscalation(options) {
|
|
861
|
+
const pids = uniquePositivePids(options.pids, { excludePid: process.pid });
|
|
862
|
+
if (pids.length === 0) return;
|
|
863
|
+
signalPidsBestEffort(pids, "SIGTERM");
|
|
864
|
+
await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.termTimeoutMs)));
|
|
865
|
+
signalPidsBestEffort(pids.filter((pid) => isProcessAlive(pid)), "SIGKILL");
|
|
866
|
+
await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.killTimeoutMs)));
|
|
867
|
+
}
|
|
868
|
+
const SYSCTL_TIMEOUT_MS = 1e3;
|
|
869
|
+
const APPLE_SILICON_SYSCTL = ["-n", "hw.optional.arm64"];
|
|
870
|
+
let pendingHostCpuArch;
|
|
871
|
+
let settledHostCpuArch;
|
|
872
|
+
/**
|
|
873
|
+
* The machine's native CPU architecture in Apple naming (`arm64`, `x86_64`), which is what
|
|
874
|
+
* simulators on a Mac run by default. Other CPUs keep Node's `process.arch` name. Resolved once
|
|
875
|
+
* per process.
|
|
876
|
+
*/
|
|
877
|
+
function readHostCpuArch() {
|
|
878
|
+
if (settledHostCpuArch !== void 0) return Promise.resolve(settledHostCpuArch);
|
|
879
|
+
pendingHostCpuArch ??= resolveHostCpuArch(process.platform, process.arch).then(settleHostCpuArch);
|
|
880
|
+
return pendingHostCpuArch;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* {@link readHostCpuArch} for a caller that cannot await. It shares the same per-process value,
|
|
884
|
+
* resolving it with a blocking `sysctl` only when nothing has resolved it yet.
|
|
885
|
+
*/
|
|
886
|
+
function readHostCpuArchSync() {
|
|
887
|
+
return settledHostCpuArch ?? settleHostCpuArch(hostCpuArchName(process.platform === "darwin" && isAppleSiliconMacSync(), process.arch));
|
|
888
|
+
}
|
|
889
|
+
function settleHostCpuArch(arch) {
|
|
890
|
+
settledHostCpuArch ??= arch;
|
|
891
|
+
return settledHostCpuArch;
|
|
892
|
+
}
|
|
893
|
+
async function resolveHostCpuArch(platform, nodeArch) {
|
|
894
|
+
return hostCpuArchName(platform === "darwin" && await isAppleSiliconMac(), nodeArch);
|
|
895
|
+
}
|
|
896
|
+
function hostCpuArchName(appleSilicon, nodeArch) {
|
|
897
|
+
if (appleSilicon) return "arm64";
|
|
898
|
+
return nodeArch === "x64" ? "x86_64" : nodeArch;
|
|
899
|
+
}
|
|
900
|
+
async function isAppleSiliconMac() {
|
|
901
|
+
try {
|
|
902
|
+
const result = await runCmd("/usr/sbin/sysctl", APPLE_SILICON_SYSCTL, {
|
|
903
|
+
allowFailure: true,
|
|
904
|
+
timeoutMs: SYSCTL_TIMEOUT_MS
|
|
905
|
+
});
|
|
906
|
+
return result.exitCode === 0 && result.stdout.trim() === "1";
|
|
907
|
+
} catch {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function isAppleSiliconMacSync() {
|
|
912
|
+
try {
|
|
913
|
+
const result = runCmdSync("/usr/sbin/sysctl", APPLE_SILICON_SYSCTL, {
|
|
914
|
+
allowFailure: true,
|
|
915
|
+
timeoutMs: SYSCTL_TIMEOUT_MS
|
|
916
|
+
});
|
|
917
|
+
return result.exitCode === 0 && result.stdout.trim() === "1";
|
|
918
|
+
} catch {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region ../host-kit/src/internal/owned-process-reaper.ts
|
|
924
|
+
const DEFAULT_TERM_TIMEOUT_MS = 1500;
|
|
925
|
+
const DEFAULT_KILL_TIMEOUT_MS = 1e3;
|
|
926
|
+
async function reapOwnedProcessRecordsAtStartup(store, options = {}) {
|
|
927
|
+
const summary = createReapSummary();
|
|
928
|
+
const policy = createReapPolicy(options);
|
|
929
|
+
for (const entry of store.read()) {
|
|
930
|
+
if (!isDecodedRecord(entry)) continue;
|
|
931
|
+
const retained = await reapDecodedEntry(entry, policy, summary);
|
|
932
|
+
if (retained.length === 0) store.clear(entry.scope);
|
|
933
|
+
else if (retained.length !== entry.records.length) store.replace(entry.scope, retained);
|
|
934
|
+
}
|
|
935
|
+
return Object.freeze(summary);
|
|
936
|
+
}
|
|
937
|
+
function isDecodedRecord(entry) {
|
|
938
|
+
return entry.status === "decoded";
|
|
939
|
+
}
|
|
940
|
+
function createReapSummary() {
|
|
941
|
+
return {
|
|
942
|
+
inspected: 0,
|
|
943
|
+
inspectedPids: [],
|
|
944
|
+
terminated: 0,
|
|
945
|
+
terminatedPids: [],
|
|
946
|
+
missing: 0,
|
|
947
|
+
missingPids: [],
|
|
948
|
+
ownershipLost: 0,
|
|
949
|
+
ownershipLostPids: [],
|
|
950
|
+
skippedForOpenWebSession: false
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function createReapPolicy(options) {
|
|
954
|
+
return {
|
|
955
|
+
openWebSession: (options.openWebSessionNames ?? []).length > 0,
|
|
956
|
+
purposes: options.purposes ? new Set(options.purposes) : void 0,
|
|
957
|
+
termTimeoutMs: options.termTimeoutMs ?? DEFAULT_TERM_TIMEOUT_MS,
|
|
958
|
+
killTimeoutMs: options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
async function reapDecodedEntry(entry, policy, summary) {
|
|
962
|
+
const retained = [];
|
|
963
|
+
for (const record of entry.records) {
|
|
964
|
+
if (shouldRetainWithoutInspection(entry.scope.kind, record, policy)) {
|
|
965
|
+
retained.push(record);
|
|
966
|
+
if (isOpenWebSessionSkip(entry.scope.kind, record, policy)) summary.skippedForOpenWebSession = true;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
summary.inspected += 1;
|
|
970
|
+
summary.inspectedPids.push(record.pid);
|
|
971
|
+
appendReapOutcome(summary, record, await reapOwnedProcess(record, policy), retained);
|
|
972
|
+
}
|
|
973
|
+
return retained;
|
|
974
|
+
}
|
|
975
|
+
function shouldRetainWithoutInspection(scope, record, policy) {
|
|
976
|
+
return !isSelectedPurpose(record, policy) || isOpenWebSessionSkip(scope, record, policy);
|
|
977
|
+
}
|
|
978
|
+
function isSelectedPurpose(record, policy) {
|
|
979
|
+
return policy.purposes === void 0 || policy.purposes.has(record.purpose);
|
|
980
|
+
}
|
|
981
|
+
function isOpenWebSessionSkip(scope, record, policy) {
|
|
982
|
+
return scope === "daemon" && record.purpose === "managed-web-browser" && policy.openWebSession;
|
|
983
|
+
}
|
|
984
|
+
function appendReapOutcome(summary, record, outcome, retained) {
|
|
985
|
+
if (outcome === "terminated") {
|
|
986
|
+
summary.terminated += 1;
|
|
987
|
+
summary.terminatedPids.push(record.pid);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (outcome === "missing") {
|
|
991
|
+
summary.missing += 1;
|
|
992
|
+
summary.missingPids.push(record.pid);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
summary.ownershipLost += 1;
|
|
996
|
+
summary.ownershipLostPids.push(record.pid);
|
|
997
|
+
retained.push(record);
|
|
998
|
+
}
|
|
999
|
+
async function reapOwnedProcess(record, policy) {
|
|
1000
|
+
const initial = await inspectOwnedProcess(record);
|
|
1001
|
+
if (initial !== void 0) return initial;
|
|
1002
|
+
signalBestEffort(record.pid, initialSignalFor(record));
|
|
1003
|
+
return await escalateOwnedProcess(record, policy);
|
|
1004
|
+
}
|
|
1005
|
+
async function inspectOwnedProcess(record) {
|
|
1006
|
+
if (record.pid === process.pid || !isProcessAlive(record.pid)) return "missing";
|
|
1007
|
+
const facts = await readProcessIdentityFacts(record.pid);
|
|
1008
|
+
if (facts.zombie) return "missing";
|
|
1009
|
+
return matchesFacts(record, facts) ? void 0 : "ownership-lost";
|
|
1010
|
+
}
|
|
1011
|
+
function initialSignalFor(record) {
|
|
1012
|
+
return record.purpose === "simctl-screen-recording" ? "SIGINT" : "SIGTERM";
|
|
1013
|
+
}
|
|
1014
|
+
async function escalateOwnedProcess(record, policy) {
|
|
1015
|
+
const termOutcome = await waitForOwnedProcessExit(record, policy.termTimeoutMs);
|
|
1016
|
+
if (termOutcome === "exited") return "terminated";
|
|
1017
|
+
if (termOutcome === "ownership-lost") return "ownership-lost";
|
|
1018
|
+
signalBestEffort(record.pid, "SIGKILL");
|
|
1019
|
+
return await waitForOwnedProcessExit(record, policy.killTimeoutMs) === "exited" ? "terminated" : "ownership-lost";
|
|
1020
|
+
}
|
|
1021
|
+
async function waitForOwnedProcessExit(record, timeoutMs) {
|
|
1022
|
+
await waitForProcessExit(record.pid, timeoutMs);
|
|
1023
|
+
if (!isProcessAlive(record.pid)) return "exited";
|
|
1024
|
+
const facts = await readProcessIdentityFacts(record.pid);
|
|
1025
|
+
if (facts.zombie) return "exited";
|
|
1026
|
+
return matchesFacts(record, facts) ? "owned-alive" : "ownership-lost";
|
|
1027
|
+
}
|
|
1028
|
+
function matchesFacts(record, facts) {
|
|
1029
|
+
return facts.startTime === record.startTime && facts.command === record.command;
|
|
1030
|
+
}
|
|
1031
|
+
function signalBestEffort(pid, signal) {
|
|
1032
|
+
try {
|
|
1033
|
+
process.kill(pid, signal);
|
|
1034
|
+
} catch {}
|
|
1035
|
+
}
|
|
1036
|
+
//#endregion
|
|
1037
|
+
//#region ../host-kit/src/internal/atomic-file.ts
|
|
1038
|
+
/**
|
|
1039
|
+
* Gives a specialized durable publisher a canonical temp path and cleanup
|
|
1040
|
+
* ownership while it performs its own open/fsync/safety protocol.
|
|
1041
|
+
*/
|
|
1042
|
+
function withAtomicPublishTempPathSync(destination, publish) {
|
|
1043
|
+
const temporaryPath = createAtomicPublishTempPath(destination);
|
|
1044
|
+
try {
|
|
1045
|
+
return publish(temporaryPath);
|
|
1046
|
+
} finally {
|
|
1047
|
+
try {
|
|
1048
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
emitDiagnostic({
|
|
1051
|
+
level: "warn",
|
|
1052
|
+
phase: "atomic_file_cleanup_failed",
|
|
1053
|
+
data: {
|
|
1054
|
+
destination,
|
|
1055
|
+
temporaryPath,
|
|
1056
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
/** Returns the canonical same-directory temp path used by atomic publishers. */
|
|
1063
|
+
function createAtomicPublishTempPath(destination) {
|
|
1064
|
+
return path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}-${crypto.randomUUID()}.tmp`);
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region ../host-kit/src/internal/owned-process-record.ts
|
|
1068
|
+
const RECORD_FILE_NAME = "owned-processes.json";
|
|
1069
|
+
const RECORD_VERSION = 1;
|
|
1070
|
+
function createOwnedProcessRecordStore(options) {
|
|
1071
|
+
const stateDir = path.resolve(options.stateDir);
|
|
1072
|
+
const sessionsDir = path.resolve(options.sessionsDir);
|
|
1073
|
+
const daemonPath = path.join(stateDir, RECORD_FILE_NAME);
|
|
1074
|
+
const resolvePath = (scope) => {
|
|
1075
|
+
if (scope.kind === "daemon") return daemonPath;
|
|
1076
|
+
const sessionDir = path.resolve(options.resolveSessionDir(scope.sessionId));
|
|
1077
|
+
if (path.dirname(sessionDir) !== sessionsDir) throw new TypeError("owned process session scope must resolve directly under the sessions dir");
|
|
1078
|
+
return path.join(sessionDir, RECORD_FILE_NAME);
|
|
1079
|
+
};
|
|
1080
|
+
return Object.freeze({
|
|
1081
|
+
replace(scope, records) {
|
|
1082
|
+
const normalized = normalizeRecords(records);
|
|
1083
|
+
const recordPath = resolvePath(scope);
|
|
1084
|
+
if (normalized.length === 0) {
|
|
1085
|
+
clearRecordPath(recordPath);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const directory = path.dirname(recordPath);
|
|
1089
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
1090
|
+
withAtomicPublishTempPathSync(recordPath, (temporaryPath) => {
|
|
1091
|
+
const descriptor = fs.openSync(temporaryPath, "wx", 384);
|
|
1092
|
+
try {
|
|
1093
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({
|
|
1094
|
+
version: RECORD_VERSION,
|
|
1095
|
+
processes: normalized
|
|
1096
|
+
})}\n`, "utf8");
|
|
1097
|
+
fs.fsyncSync(descriptor);
|
|
1098
|
+
} finally {
|
|
1099
|
+
fs.closeSync(descriptor);
|
|
1100
|
+
}
|
|
1101
|
+
assertSafeRecordDestination(recordPath);
|
|
1102
|
+
fs.renameSync(temporaryPath, recordPath);
|
|
1103
|
+
});
|
|
1104
|
+
},
|
|
1105
|
+
clear(scope) {
|
|
1106
|
+
clearRecordPath(resolvePath(scope));
|
|
1107
|
+
},
|
|
1108
|
+
read() {
|
|
1109
|
+
return readRecordFiles(sessionsDir, daemonPath);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
function readOwnedProcessRecordFile(recordPath, scope) {
|
|
1114
|
+
let value;
|
|
1115
|
+
try {
|
|
1116
|
+
assertSafeRecordDestination(recordPath);
|
|
1117
|
+
value = JSON.parse(fs.readFileSync(recordPath, "utf8"));
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
return {
|
|
1120
|
+
scope,
|
|
1121
|
+
path: recordPath,
|
|
1122
|
+
status: "invalid",
|
|
1123
|
+
records: [],
|
|
1124
|
+
message: errorMessage(error)
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
if (!isRecordFile(value)) return {
|
|
1128
|
+
scope,
|
|
1129
|
+
path: recordPath,
|
|
1130
|
+
status: "invalid",
|
|
1131
|
+
records: [],
|
|
1132
|
+
message: "owned process record must contain version 1 and a processes array"
|
|
1133
|
+
};
|
|
1134
|
+
try {
|
|
1135
|
+
return {
|
|
1136
|
+
scope,
|
|
1137
|
+
path: recordPath,
|
|
1138
|
+
status: "decoded",
|
|
1139
|
+
records: normalizeRecords(value.processes)
|
|
1140
|
+
};
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
return {
|
|
1143
|
+
scope,
|
|
1144
|
+
path: recordPath,
|
|
1145
|
+
status: "invalid",
|
|
1146
|
+
records: [],
|
|
1147
|
+
message: errorMessage(error)
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
function readRecordFiles(sessionsDir, daemonPath) {
|
|
1152
|
+
const entries = [];
|
|
1153
|
+
if (fileExists(daemonPath)) entries.push(readOwnedProcessRecordFile(daemonPath, { kind: "daemon" }));
|
|
1154
|
+
if (!fs.existsSync(sessionsDir)) return entries;
|
|
1155
|
+
for (const entry of fs.readdirSync(sessionsDir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
1156
|
+
if (!entry.isDirectory()) continue;
|
|
1157
|
+
const recordPath = path.join(sessionsDir, entry.name, RECORD_FILE_NAME);
|
|
1158
|
+
if (fileExists(recordPath)) entries.push(readOwnedProcessRecordFile(recordPath, {
|
|
1159
|
+
kind: "session",
|
|
1160
|
+
sessionId: entry.name
|
|
1161
|
+
}));
|
|
1162
|
+
}
|
|
1163
|
+
return entries;
|
|
1164
|
+
}
|
|
1165
|
+
function normalizeRecords(records) {
|
|
1166
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1167
|
+
const normalized = [];
|
|
1168
|
+
for (const record of records) {
|
|
1169
|
+
if (!isValidOwnedProcessRecord(record)) throw new TypeError("owned process record has an invalid identity or purpose");
|
|
1170
|
+
if (seen.has(record.pid)) throw new TypeError(`duplicate owned process pid ${record.pid}`);
|
|
1171
|
+
seen.add(record.pid);
|
|
1172
|
+
normalized.push(Object.freeze({
|
|
1173
|
+
pid: record.pid,
|
|
1174
|
+
startTime: record.startTime,
|
|
1175
|
+
command: record.command,
|
|
1176
|
+
purpose: record.purpose
|
|
1177
|
+
}));
|
|
1178
|
+
}
|
|
1179
|
+
return normalized.sort((left, right) => left.pid - right.pid);
|
|
1180
|
+
}
|
|
1181
|
+
function isValidOwnedProcessRecord(record) {
|
|
1182
|
+
return Number.isInteger(record.pid) && record.pid > 0 && typeof record.startTime === "string" && record.startTime.length > 0 && typeof record.command === "string" && record.command.length > 0 && typeof record.purpose === "string" && record.purpose.length > 0;
|
|
1183
|
+
}
|
|
1184
|
+
function isRecordFile(value) {
|
|
1185
|
+
return typeof value === "object" && value !== null && "version" in value && value.version === RECORD_VERSION && "processes" in value && Array.isArray(value.processes);
|
|
1186
|
+
}
|
|
1187
|
+
function clearRecordPath(recordPath) {
|
|
1188
|
+
try {
|
|
1189
|
+
assertSafeRecordDestination(recordPath);
|
|
1190
|
+
fs.unlinkSync(recordPath);
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
if (!isMissingFile(error)) throw error;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function assertSafeRecordDestination(recordPath) {
|
|
1196
|
+
try {
|
|
1197
|
+
const stats = fs.lstatSync(recordPath);
|
|
1198
|
+
if (stats.isSymbolicLink()) throw new Error("Refusing to follow an owned process record symlink");
|
|
1199
|
+
if (!stats.isFile()) throw new Error("Owned process record is not a regular file");
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (!isMissingFile(error)) throw error;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
function fileExists(filePath) {
|
|
1205
|
+
try {
|
|
1206
|
+
return fs.lstatSync(filePath).isFile();
|
|
1207
|
+
} catch {
|
|
1208
|
+
return false;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function isMissingFile(error) {
|
|
1212
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
1213
|
+
}
|
|
1214
|
+
function errorMessage(error) {
|
|
1215
|
+
return error instanceof Error ? error.message : "owned process record is invalid";
|
|
1216
|
+
}
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region ../host-kit/src/internal/owner-identity.ts
|
|
1219
|
+
/** A process id accepted by Node's native signal API. */
|
|
1220
|
+
function isProcessPid(value) {
|
|
1221
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 2147483647;
|
|
1222
|
+
}
|
|
1223
|
+
function readCurrentOwnerIdentity() {
|
|
1224
|
+
return {
|
|
1225
|
+
pid: process.pid,
|
|
1226
|
+
startTime: readProcessStartTime(process.pid)
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function ownerIdentityMatches(left, right) {
|
|
1230
|
+
return left.pid === right.pid && left.startTime === right.startTime;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Positive proof that two records name DIFFERENT processes. Deliberately not
|
|
1234
|
+
* the negation of {@link ownerIdentityMatches}: equal pids with an unreadable
|
|
1235
|
+
* start time on either side are unproven rather than different, so a caller
|
|
1236
|
+
* that acts on this can never mistake one owner for two and hand its resource
|
|
1237
|
+
* away.
|
|
1238
|
+
*/
|
|
1239
|
+
function ownerIdentityDiffers(left, right) {
|
|
1240
|
+
if (left.pid !== right.pid) return true;
|
|
1241
|
+
return Boolean(left.startTime && right.startTime && left.startTime !== right.startTime);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* This is deliberately proof-oriented, in both directions. A filesystem read
|
|
1245
|
+
* error is not proof that an owner state directory disappeared, so callers
|
|
1246
|
+
* must surface it as an unknown owner rather than treating the resource as
|
|
1247
|
+
* free. Likewise a failed `ps` read (it shells out with a short timeout and
|
|
1248
|
+
* loses under CPU contention) is not proof the owner died, so it never
|
|
1249
|
+
* condemns a pid that kill(pid, 0) says is alive. Death is only concluded
|
|
1250
|
+
* from positive evidence: the pid is gone or the process is a zombie (already
|
|
1251
|
+
* terminated, merely unreaped). A different readable start time proves PID
|
|
1252
|
+
* reuse, but remains a distinct result so each ownership policy must decide
|
|
1253
|
+
* explicitly whether that proof permits recovery. An unreadable start time is
|
|
1254
|
+
* not proof of reuse. An owner recorded without a start time stays fail-closed
|
|
1255
|
+
* while its pid is alive: there is no same-clock-domain proof of birth order (wall-clock
|
|
1256
|
+
* arithmetic over `ps etime` shifts under clock steps), and misreading a live
|
|
1257
|
+
* owner as recycled would let a waiter steal a held resource.
|
|
1258
|
+
*/
|
|
1259
|
+
function classifyOwnerLiveness(params) {
|
|
1260
|
+
const guarded = guardOwnerPid(params.owner);
|
|
1261
|
+
if (guarded) return guarded;
|
|
1262
|
+
return classifyLiveOwnerFromObservation(params, readHostProcessIdentityObservations([params.owner.pid]).get(params.owner.pid) ?? null);
|
|
1263
|
+
}
|
|
1264
|
+
function classifyOwnerLivenessFromObservation(params, observation) {
|
|
1265
|
+
const guarded = guardOwnerPid(params.owner);
|
|
1266
|
+
if (guarded) return guarded;
|
|
1267
|
+
return classifyLiveOwnerFromObservation(params, observation);
|
|
1268
|
+
}
|
|
1269
|
+
/** The verdicts provable from the pid alone; undefined means the caller may probe on. */
|
|
1270
|
+
function guardOwnerPid(owner) {
|
|
1271
|
+
if (!isProcessPid(owner.pid)) return "unknown";
|
|
1272
|
+
if (!isProcessAlive(owner.pid)) return "owner-process-dead";
|
|
1273
|
+
}
|
|
1274
|
+
/** Judges an owner the caller has established as a live, valid pid; probes no liveness. */
|
|
1275
|
+
function classifyLiveOwnerFromObservation(params, observation) {
|
|
1276
|
+
const { owner, stateDir } = params;
|
|
1277
|
+
if (hostSaysZombie(owner, observation)) return "owner-process-dead";
|
|
1278
|
+
if (owner.startTime && provesPidReused(owner, observation)) return "owner-process-reused";
|
|
1279
|
+
return stateDir ? classifyOwnerStateDirectory(stateDir) : "live";
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Whether the host calls this owner a zombie. An `undefined` observation means
|
|
1283
|
+
* no snapshot was taken and the field probe answers; a `null` observation is a
|
|
1284
|
+
* completed snapshot that did not list the pid, which is unknown evidence and
|
|
1285
|
+
* never a zombie claim.
|
|
1286
|
+
*/
|
|
1287
|
+
function hostSaysZombie(owner, observation) {
|
|
1288
|
+
if (observation === void 0) return isProcessZombie(owner.pid);
|
|
1289
|
+
return observation?.state.startsWith("Z") ?? false;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Whether the recorded birth time proves the pid changed hands. Only a
|
|
1293
|
+
* readable current start time that differs from the record proves reuse; an
|
|
1294
|
+
* `undefined` observation falls back to the field probe, and a host that did
|
|
1295
|
+
* not answer is unknown evidence, never proof.
|
|
1296
|
+
*/
|
|
1297
|
+
function provesPidReused(owner, observation) {
|
|
1298
|
+
const currentStartTime = observation === void 0 ? readProcessStartTime(owner.pid) : observation?.startTime ?? null;
|
|
1299
|
+
return currentStartTime !== null && currentStartTime !== owner.startTime;
|
|
1300
|
+
}
|
|
1301
|
+
function classifyOwnerStateDirectory(stateDir) {
|
|
1302
|
+
try {
|
|
1303
|
+
fs.statSync(stateDir);
|
|
1304
|
+
return "live";
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
const code = error?.code;
|
|
1307
|
+
if (code === "ENOENT" || code === "ENOTDIR") return "owner-state-dir-gone";
|
|
1308
|
+
return "unknown";
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
//#endregion
|
|
1312
|
+
export { classifyOwnerLiveness, classifyOwnerLivenessFromObservation, createOwnedProcessRecordStore, expandProcessTree, hostCurrentWorkingDirectory, hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, hostProcessId, isProcessAlive, isProcessGroupAlive, isProcessPid, isProcessZombie, listHostProcesses, ownerIdentityDiffers, ownerIdentityMatches, readCurrentOwnerIdentity, readHostCpuArch, readHostCpuArchSync, readHostEnvironmentVariable, readHostProcessIdentityObservations, readOwnedProcessRecordFile, readProcessCommand, readProcessIdentityFacts, readProcessStartTime, reapOwnedProcessRecordsAtStartup, signalPidsBestEffort, signalProcessGroupBestEffort, stopPidsWithEscalation, uniquePositivePids, waitForProcessExit, writeHostStderr };
|