@tangle-network/sdk-cli-runner 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/dist/index.d.mts +573 -0
- package/dist/index.mjs +2275 -0
- package/package.json +35 -3
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2275 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
8
|
+
import { open } from "node:fs/promises";
|
|
9
|
+
//#region src/attachment-error.ts
|
|
10
|
+
/** An attachment cannot be delivered to a native CLI. */
|
|
11
|
+
var InvalidAttachmentError = class extends Error {
|
|
12
|
+
code = "invalid_attachment";
|
|
13
|
+
statusCode = 400;
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "InvalidAttachmentError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/spawn-env.ts
|
|
21
|
+
/**
|
|
22
|
+
* Merge adapter environment overrides onto a base environment for `spawn`.
|
|
23
|
+
*
|
|
24
|
+
* An adapter scrubs an inherited variable by setting it to `undefined` (the
|
|
25
|
+
* claude-code and codex adapters strip a router key under OAuth so the CLI
|
|
26
|
+
* keeps its stored tokens). A plain spread keeps such a key in the object,
|
|
27
|
+
* and what the child then sees depends on the spawner. Deleting the key makes
|
|
28
|
+
* the scrub unconditional.
|
|
29
|
+
*/
|
|
30
|
+
function mergeSpawnEnv(base, overrides) {
|
|
31
|
+
const merged = { ...base };
|
|
32
|
+
if (!overrides) return merged;
|
|
33
|
+
for (const [key, value] of Object.entries(overrides)) if (value === void 0) delete merged[key];
|
|
34
|
+
else merged[key] = value;
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/child-env.ts
|
|
39
|
+
const DEFAULT_CHILD_PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
40
|
+
function resolveSandboxToolBinDirs(env) {
|
|
41
|
+
const value = env.SANDBOX_TOOL_BIN_DIRS?.trim();
|
|
42
|
+
if (!value) return [];
|
|
43
|
+
const segments = value.split(":");
|
|
44
|
+
for (const segment of segments) if (!segment || !isAbsolute(segment)) throw new Error(`SANDBOX_TOOL_BIN_DIRS must be a colon-separated list of absolute paths with no empty segments, got: ${value}`);
|
|
45
|
+
return segments;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Make bare harness command names (`claude`, `codex`, `kimi`) resolve the way
|
|
49
|
+
* every spawned CLI expects, and let the CLI's own children inherit the same
|
|
50
|
+
* resolution. Thin sandbox images deliver toolchain binaries through a
|
|
51
|
+
* read-only Nix profile exposed as NIX_BIN_PATH, deliberately NOT on the
|
|
52
|
+
* container's PATH (PATH-wide scans slow sidecar startup), so it is prepended
|
|
53
|
+
* here; SANDBOX_TOOL_BIN_DIRS is appended. Both spawn paths call this: the
|
|
54
|
+
* exec runner and the JSON-RPC bridge (rpc-stdio-process.ts), whose app-server spawn
|
|
55
|
+
* skipped it and died with ENOENT on a thin image (GTM's codex synthetic
|
|
56
|
+
* customer, 40/40 turns).
|
|
57
|
+
*/
|
|
58
|
+
function applyChildToolPath(processEnv) {
|
|
59
|
+
const nixBinPath = processEnv.NIX_BIN_PATH?.trim();
|
|
60
|
+
if (nixBinPath) addPathEntries(processEnv, nixBinPath.split(":"), "prepend");
|
|
61
|
+
addPathEntries(processEnv, resolveSandboxToolBinDirs(processEnv), "append");
|
|
62
|
+
}
|
|
63
|
+
/** Add the entries PATH lacks, each once, before or after the current PATH. */
|
|
64
|
+
function addPathEntries(processEnv, entries, position) {
|
|
65
|
+
const currentPath = processEnv.PATH ?? DEFAULT_CHILD_PATH;
|
|
66
|
+
const seen = new Set(currentPath.split(":"));
|
|
67
|
+
const added = entries.filter((entry) => {
|
|
68
|
+
if (!entry || seen.has(entry)) return false;
|
|
69
|
+
seen.add(entry);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
if (added.length === 0) return;
|
|
73
|
+
processEnv.PATH = position === "prepend" ? `${added.join(":")}:${currentPath}` : `${currentPath}:${added.join(":")}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The environment a spawned CLI runs with: the parent's (unless
|
|
77
|
+
* `inheritEnv: false`) merged with the adapter's overrides, a PWD that agrees
|
|
78
|
+
* with the working directory, and the sandbox tool path.
|
|
79
|
+
*/
|
|
80
|
+
function buildChildEnv(config) {
|
|
81
|
+
const { cwd, env, inheritEnv, spawnOptions } = config;
|
|
82
|
+
const processEnv = mergeSpawnEnv(inheritEnv === false ? {} : process.env, env);
|
|
83
|
+
const spawnCwd = spawnOptions && "cwd" in spawnOptions ? spawnOptions.cwd : cwd;
|
|
84
|
+
const childCwd = typeof spawnCwd === "string" ? spawnCwd : spawnCwd instanceof URL && spawnCwd.protocol === "file:" ? fileURLToPath(spawnCwd) : void 0;
|
|
85
|
+
if (!(!!env && "PWD" in env && env.PWD === void 0) && childCwd !== void 0 && childCwd.length > 0) processEnv.PWD = resolve(childCwd);
|
|
86
|
+
applyChildToolPath(processEnv);
|
|
87
|
+
return processEnv;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/child-killer.ts
|
|
91
|
+
const SIGKILL_ESCALATION_MS = 1e3;
|
|
92
|
+
/**
|
|
93
|
+
* Signals a spawned CLI, its whole process group when it leads one. A
|
|
94
|
+
* consumer launch's `terminate` also runs, once, on the first kill: it owns
|
|
95
|
+
* what the local child cannot signal (a container, a scope).
|
|
96
|
+
*/
|
|
97
|
+
var ChildKiller = class {
|
|
98
|
+
escalationTimer;
|
|
99
|
+
termination;
|
|
100
|
+
constructor(child, detached, terminate) {
|
|
101
|
+
this.child = child;
|
|
102
|
+
this.detached = detached;
|
|
103
|
+
this.terminate = terminate;
|
|
104
|
+
}
|
|
105
|
+
kill(killSignal) {
|
|
106
|
+
if (this.terminate && !this.termination) this.termination = this.terminate().then(() => void 0, (error) => error instanceof Error ? error.message : String(error));
|
|
107
|
+
if (this.detached && this.child.pid && process.platform !== "win32") try {
|
|
108
|
+
process.kill(-this.child.pid, killSignal);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {}
|
|
111
|
+
return this.child.kill(killSignal);
|
|
112
|
+
}
|
|
113
|
+
/** The launch's termination failure, once it settles; undefined when none ran or it succeeded. */
|
|
114
|
+
terminated() {
|
|
115
|
+
return this.termination ?? Promise.resolve(void 0);
|
|
116
|
+
}
|
|
117
|
+
get escalating() {
|
|
118
|
+
return this.escalationTimer !== void 0;
|
|
119
|
+
}
|
|
120
|
+
/** SIGKILL after a grace period unless `stillRunning` says the child left. */
|
|
121
|
+
escalate(stillRunning) {
|
|
122
|
+
this.escalationTimer = setTimeout(() => {
|
|
123
|
+
if (stillRunning()) this.kill("SIGKILL");
|
|
124
|
+
}, SIGKILL_ESCALATION_MS);
|
|
125
|
+
this.escalationTimer.unref();
|
|
126
|
+
}
|
|
127
|
+
clearEscalation() {
|
|
128
|
+
if (this.escalationTimer) clearTimeout(this.escalationTimer);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/cli-deadlines.ts
|
|
133
|
+
/**
|
|
134
|
+
* The optional fixed wall-clock bound of one CLI process. Callers that bound a
|
|
135
|
+
* turn by its abort signal leave `timeoutMs` unset and arm nothing here.
|
|
136
|
+
*/
|
|
137
|
+
var CliDeadlines = class {
|
|
138
|
+
timedOut = false;
|
|
139
|
+
timeoutReason;
|
|
140
|
+
timeoutDiagnostic;
|
|
141
|
+
fixedTimerId;
|
|
142
|
+
constructor(config, child, killer, spawnedAt) {
|
|
143
|
+
this.config = config;
|
|
144
|
+
this.child = child;
|
|
145
|
+
this.killer = killer;
|
|
146
|
+
this.spawnedAt = spawnedAt;
|
|
147
|
+
}
|
|
148
|
+
arm() {
|
|
149
|
+
const { timeoutMs } = this.config;
|
|
150
|
+
if (!(timeoutMs && timeoutMs > 0)) return;
|
|
151
|
+
this.fixedTimerId = setTimeout(() => this.killOnTimeout(timeoutMs), timeoutMs);
|
|
152
|
+
}
|
|
153
|
+
clearAll() {
|
|
154
|
+
if (this.fixedTimerId) clearTimeout(this.fixedTimerId);
|
|
155
|
+
this.killer.clearEscalation();
|
|
156
|
+
}
|
|
157
|
+
killOnTimeout(budgetMs) {
|
|
158
|
+
if (this.timedOut) return;
|
|
159
|
+
this.timedOut = true;
|
|
160
|
+
this.timeoutReason = "fixed";
|
|
161
|
+
this.timeoutDiagnostic = `"${this.config.command}" ran past its deadline of ${budgetMs}ms; killed after ${Date.now() - this.spawnedAt}ms`;
|
|
162
|
+
this.killer.kill("SIGTERM");
|
|
163
|
+
this.killer.escalate(() => this.child.exitCode === null);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/raw-capture.ts
|
|
168
|
+
/** Hard encoded-byte bound across canonical JSON spool files, including live writers.
|
|
169
|
+
* Temporary trim copies and filesystem allocation are separate from this budget.
|
|
170
|
+
*/
|
|
171
|
+
const RAW_CAPTURE_TOTAL_CAP_BYTES = 64 * 1024 * 1024;
|
|
172
|
+
const RAW_CAPTURE_FRAME_BYTES = 64 * 1024;
|
|
173
|
+
const RAW_CAPTURE_TAIL_BYTES = 32 * 1024;
|
|
174
|
+
const RAW_ROOT = "/root/.sidecar/raw-evidence";
|
|
175
|
+
/** The root-private spool: one `<sha256(HOME)>/process-io.frames` per runtime HOME. */
|
|
176
|
+
const RAW_CAPTURE_ROOT = RAW_ROOT;
|
|
177
|
+
const CAPTURE_FLAGS = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
|
|
178
|
+
const REGISTRY_KEY = Symbol.for("tangle.sdk-cli-runner.raw-capture-writers");
|
|
179
|
+
const shared = globalThis;
|
|
180
|
+
const registry = shared[REGISTRY_KEY] ?? {
|
|
181
|
+
liveWriters: /* @__PURE__ */ new Map(),
|
|
182
|
+
generations: /* @__PURE__ */ new Map(),
|
|
183
|
+
losses: /* @__PURE__ */ new Map(),
|
|
184
|
+
trims: /* @__PURE__ */ new Map()
|
|
185
|
+
};
|
|
186
|
+
shared[REGISTRY_KEY] = registry;
|
|
187
|
+
const { liveWriters, generations } = registry;
|
|
188
|
+
registry.losses ??= /* @__PURE__ */ new Map();
|
|
189
|
+
const losses = registry.losses;
|
|
190
|
+
registry.trims ??= /* @__PURE__ */ new Map();
|
|
191
|
+
const trims = registry.trims;
|
|
192
|
+
registry.retained ??= /* @__PURE__ */ new Set();
|
|
193
|
+
const retainedCaptures = registry.retained;
|
|
194
|
+
const activeAttempt = new AsyncLocalStorage();
|
|
195
|
+
/** Bind Sidecar-owned attempt identity before entering a provider adapter. */
|
|
196
|
+
function withRawCaptureAttempt(attempt, run) {
|
|
197
|
+
if (!attempt.executionId || !Number.isSafeInteger(attempt.ordinal) || attempt.ordinal < 1 || !attempt.providerSessionId) throw new Error("Invalid raw capture attempt identity");
|
|
198
|
+
return activeAttempt.run(attempt, run);
|
|
199
|
+
}
|
|
200
|
+
/** The sidecar owns this directory; the CLI's writable HOME cannot contain its evidence. */
|
|
201
|
+
function resolveRawCapturePath(runtimeHome) {
|
|
202
|
+
if (!isAbsolute(runtimeHome)) throw new Error("runtimeHome must be absolute for raw capture");
|
|
203
|
+
return join(RAW_ROOT, createHash("sha256").update(resolve(runtimeHome)).digest("hex"), "process-io.frames");
|
|
204
|
+
}
|
|
205
|
+
function assertProtectedCaptureDirectory(directory) {
|
|
206
|
+
if (process.getuid?.() !== 0) throw new Error("sidecar raw capture requires root ownership");
|
|
207
|
+
for (const part of [
|
|
208
|
+
"/root",
|
|
209
|
+
"/root/.sidecar",
|
|
210
|
+
RAW_ROOT,
|
|
211
|
+
directory
|
|
212
|
+
]) {
|
|
213
|
+
const entry = lstatSync(part);
|
|
214
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== 0 || (entry.mode & 63) !== 0 || realpathSync(part) !== part) throw new Error(`raw capture parent is not protected: ${part}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Opt one runtime HOME in to full capture, inside the shared byte budget, when
|
|
219
|
+
* the platform default is off. The Sidecar calls this for a session created with retention
|
|
220
|
+
* "workspace", whose retained evidence must include process I/O. An explicit
|
|
221
|
+
* SIDECAR_RAW_CAPTURE_MODE=none or PHI redaction still disables it.
|
|
222
|
+
*/
|
|
223
|
+
function allowRetainedRawCapture(runtimeHome) {
|
|
224
|
+
retainedCaptures.add(resolve(resolveRawCapturePath(runtimeHome)));
|
|
225
|
+
}
|
|
226
|
+
/** Delete a session's private spool only after its evidence has been archived. */
|
|
227
|
+
function removeRawCapture(runtimeHome) {
|
|
228
|
+
retainedCaptures.delete(resolve(resolveRawCapturePath(runtimeHome)));
|
|
229
|
+
const directory = dirname(resolveRawCapturePath(runtimeHome));
|
|
230
|
+
try {
|
|
231
|
+
assertProtectedCaptureDirectory(directory);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error.code === "ENOENT") return;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
rmSync(directory, {
|
|
237
|
+
recursive: true,
|
|
238
|
+
force: true
|
|
239
|
+
});
|
|
240
|
+
pruneClosedEmptyCapture(join(directory, "process-io.frames"));
|
|
241
|
+
}
|
|
242
|
+
function captureCapacityError(cause) {
|
|
243
|
+
return ["ENOSPC", "EDQUOT"].includes(cause.code ?? "");
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Retained evidence must be complete: a tail that drops bytes reports the
|
|
247
|
+
* capture incomplete, so a retained session gets full capture, inside the
|
|
248
|
+
* shared byte budget, unless the operator pinned the platform to tail.
|
|
249
|
+
*/
|
|
250
|
+
function resolveRawCaptureMode(file, protectedPath, requested) {
|
|
251
|
+
const trustedMode = process.env.SIDECAR_RAW_CAPTURE_MODE;
|
|
252
|
+
const retained = retainedCaptures.has(resolve(file));
|
|
253
|
+
return protectedPath ? trustedMode === "none" || process.env.TELEMETRY_REDACT === "phi" || requested === "none" ? "none" : (trustedMode === "full" || trustedMode !== "tail" && retained) && requested !== "tail" ? "full" : trustedMode === "tail" || trustedMode === "full" || retained ? "tail" : "none" : requested ?? "full";
|
|
254
|
+
}
|
|
255
|
+
function openCaptureTarget(path, protectedPath) {
|
|
256
|
+
const opened = openSync(path, CAPTURE_FLAGS, 384);
|
|
257
|
+
const target = fstatSync(opened);
|
|
258
|
+
if (!target.isFile() || target.nlink !== 1 || (target.mode & 63) !== 0 || protectedPath && target.uid !== 0) {
|
|
259
|
+
closeSync(opened);
|
|
260
|
+
throw new Error("raw capture target must be a private regular file");
|
|
261
|
+
}
|
|
262
|
+
return opened;
|
|
263
|
+
}
|
|
264
|
+
function markCaptureIncomplete(state, record) {
|
|
265
|
+
state.captureIncomplete = true;
|
|
266
|
+
const terminal = record.terminal;
|
|
267
|
+
if (typeof terminal === "object" && terminal !== null) {
|
|
268
|
+
const result = terminal;
|
|
269
|
+
result.captureError ??= "diagnostic_capture_truncated";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Reopen the path when a trim replaced the file. Returns false when the
|
|
274
|
+
* reopen hit a full filesystem and the capture has failed.
|
|
275
|
+
*/
|
|
276
|
+
function reopenReplacedCapture(state, record) {
|
|
277
|
+
const current = generations.get(state.path) ?? 0;
|
|
278
|
+
if (current === state.generation) return true;
|
|
279
|
+
closeSync(state.fd);
|
|
280
|
+
state.fd = -1;
|
|
281
|
+
try {
|
|
282
|
+
state.fd = openCaptureTarget(state.path, state.protectedPath);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!captureCapacityError(error)) throw error;
|
|
285
|
+
state.captureFailed = true;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
state.generation = current;
|
|
289
|
+
const currentLoss = losses.get(state.path) ?? 0;
|
|
290
|
+
if (state.loss !== currentLoss) {
|
|
291
|
+
state.loss = currentLoss;
|
|
292
|
+
state.sequence = 0;
|
|
293
|
+
markCaptureIncomplete(state, record);
|
|
294
|
+
Object.assign(record, { captureDropped: true });
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
function encodeCaptureFrame(metadata, payload, attempt) {
|
|
299
|
+
const frame = { ...metadata };
|
|
300
|
+
for (const key of [
|
|
301
|
+
"executionId",
|
|
302
|
+
"ordinal",
|
|
303
|
+
"providerSessionId"
|
|
304
|
+
]) delete frame[key];
|
|
305
|
+
return Buffer.from(`${JSON.stringify({
|
|
306
|
+
...frame,
|
|
307
|
+
...payload ? { base64Bytes: payload.toString("base64") } : {},
|
|
308
|
+
...attempt ? {
|
|
309
|
+
executionId: attempt.executionId,
|
|
310
|
+
ordinal: attempt.ordinal,
|
|
311
|
+
providerSessionId: attempt.providerSessionId
|
|
312
|
+
} : {}
|
|
313
|
+
})}\n`);
|
|
314
|
+
}
|
|
315
|
+
function writeCaptureLine(state, record, metadata, payload) {
|
|
316
|
+
let line = encodeCaptureFrame(metadata, payload, state.attempt);
|
|
317
|
+
let recordStart;
|
|
318
|
+
try {
|
|
319
|
+
if (state.protectedPath) {
|
|
320
|
+
if (reserveCaptureBytes(state.captureRoot, state.path, line.length)) {
|
|
321
|
+
state.generation = generations.get(state.path) ?? 0;
|
|
322
|
+
state.loss = losses.get(state.path) ?? 0;
|
|
323
|
+
state.sequence = 1;
|
|
324
|
+
metadata.sequence = 0;
|
|
325
|
+
markCaptureIncomplete(state, record);
|
|
326
|
+
Object.assign(metadata, { captureDropped: true });
|
|
327
|
+
line = encodeCaptureFrame(metadata, payload, state.attempt);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
recordStart = fstatSync(state.fd).size;
|
|
331
|
+
for (let offset = 0; offset < line.length;) {
|
|
332
|
+
const written = writeSync(state.fd, line, offset, line.length - offset);
|
|
333
|
+
if (written <= 0) throw new Error("raw capture write made no progress");
|
|
334
|
+
offset += written;
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (!captureCapacityError(error)) throw error;
|
|
338
|
+
if (recordStart !== void 0) try {
|
|
339
|
+
ftruncateSync(state.fd, recordStart);
|
|
340
|
+
} catch {
|
|
341
|
+
process.emitWarning("Raw capture rollback failed; diagnostic capture is incomplete.", { code: "RAW_CAPTURE_ROLLBACK_FAILED" });
|
|
342
|
+
}
|
|
343
|
+
state.captureFailed = true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function appendCaptureRecord(state, record, payload) {
|
|
347
|
+
if (state.closed) throw new Error("raw capture is closed");
|
|
348
|
+
if (state.captureFailed) return;
|
|
349
|
+
if (!reopenReplacedCapture(state, record)) return;
|
|
350
|
+
const attempt = state.attempt;
|
|
351
|
+
writeCaptureLine(state, record, {
|
|
352
|
+
processId: state.processId,
|
|
353
|
+
sequence: state.sequence++,
|
|
354
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
355
|
+
...record,
|
|
356
|
+
...attempt ? {
|
|
357
|
+
executionId: attempt.executionId,
|
|
358
|
+
ordinal: attempt.ordinal,
|
|
359
|
+
providerSessionId: attempt.providerSessionId
|
|
360
|
+
} : {}
|
|
361
|
+
}, payload);
|
|
362
|
+
}
|
|
363
|
+
function frameCapture(state, mode, stream, bytes, metadata) {
|
|
364
|
+
if (mode === "tail") {
|
|
365
|
+
const previous = state.tails.get(stream);
|
|
366
|
+
state.tails.set(stream, {
|
|
367
|
+
bytes: Buffer.concat([previous?.bytes ?? Buffer.alloc(0), bytes.subarray(-RAW_CAPTURE_TAIL_BYTES)]).subarray(-RAW_CAPTURE_TAIL_BYTES),
|
|
368
|
+
total: (previous?.total ?? 0) + bytes.length,
|
|
369
|
+
...metadata ? { metadata } : {}
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
for (let offset = 0; offset < bytes.length || bytes.length === 0 && offset === 0; offset += RAW_CAPTURE_FRAME_BYTES) {
|
|
374
|
+
const payload = bytes.subarray(offset, offset + RAW_CAPTURE_FRAME_BYTES);
|
|
375
|
+
appendCaptureRecord(state, {
|
|
376
|
+
stream,
|
|
377
|
+
sha256: createHash("sha256").update(payload).digest("hex"),
|
|
378
|
+
sizeBytes: payload.length,
|
|
379
|
+
...metadata ? { metadata } : {}
|
|
380
|
+
}, payload);
|
|
381
|
+
if (!bytes.length) break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function closeCapture(state, result) {
|
|
385
|
+
if (state.closed) return;
|
|
386
|
+
try {
|
|
387
|
+
for (const [stream, tail] of state.tails) {
|
|
388
|
+
if (tail.total > tail.bytes.length) state.captureIncomplete = true;
|
|
389
|
+
appendCaptureRecord(state, {
|
|
390
|
+
stream,
|
|
391
|
+
sizeBytes: tail.bytes.length,
|
|
392
|
+
sha256: createHash("sha256").update(tail.bytes).digest("hex"),
|
|
393
|
+
omittedBytes: tail.total - tail.bytes.length,
|
|
394
|
+
captureMode: "tail",
|
|
395
|
+
...tail.metadata ? { metadata: tail.metadata } : {}
|
|
396
|
+
}, tail.bytes);
|
|
397
|
+
}
|
|
398
|
+
state.tails.clear();
|
|
399
|
+
appendCaptureRecord(state, { terminal: {
|
|
400
|
+
code: null,
|
|
401
|
+
signal: null,
|
|
402
|
+
timedOut: false,
|
|
403
|
+
timeoutReason: null,
|
|
404
|
+
captureError: null,
|
|
405
|
+
...result,
|
|
406
|
+
...state.captureIncomplete ? { captureError: result.captureError ?? "diagnostic_capture_truncated" } : {}
|
|
407
|
+
} });
|
|
408
|
+
if (!state.captureFailed) try {
|
|
409
|
+
fsyncSync(state.fd);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (!captureCapacityError(error)) throw error;
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
state.closed = true;
|
|
415
|
+
state.release();
|
|
416
|
+
if (state.fd >= 0) closeSync(state.fd);
|
|
417
|
+
pruneClosedEmptyCapture(state.path);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function registerCaptureWriter(path, processId) {
|
|
421
|
+
const writers = liveWriters.get(path) ?? /* @__PURE__ */ new Set();
|
|
422
|
+
writers.add(processId);
|
|
423
|
+
liveWriters.set(path, writers);
|
|
424
|
+
return () => {
|
|
425
|
+
writers.delete(processId);
|
|
426
|
+
if (writers.size === 0 && liveWriters.get(path) === writers) liveWriters.delete(path);
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/** One process appends exact byte frames to a sidecar-owned session file. */
|
|
430
|
+
function openRawCapture(file, options) {
|
|
431
|
+
if (!isAbsolute(file)) throw new Error("raw capture path must be absolute");
|
|
432
|
+
const directory = dirname(file);
|
|
433
|
+
const protectedPath = resolve(file).startsWith(`${RAW_ROOT}/`);
|
|
434
|
+
const mode = resolveRawCaptureMode(file, protectedPath, options?.mode);
|
|
435
|
+
const disabled = {
|
|
436
|
+
frame(_stream, _bytes, _metadata) {},
|
|
437
|
+
close(_result) {}
|
|
438
|
+
};
|
|
439
|
+
if (mode === "none") return disabled;
|
|
440
|
+
try {
|
|
441
|
+
mkdirSync(directory, {
|
|
442
|
+
recursive: true,
|
|
443
|
+
mode: 448
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (protectedPath && captureCapacityError(error)) return disabled;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
if (protectedPath) assertProtectedCaptureDirectory(directory);
|
|
450
|
+
const parent = lstatSync(directory);
|
|
451
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 63) !== 0) throw new Error("raw capture directory must be a private real directory");
|
|
452
|
+
const path = resolve(file);
|
|
453
|
+
let fd;
|
|
454
|
+
try {
|
|
455
|
+
fd = openCaptureTarget(path, protectedPath);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (protectedPath && captureCapacityError(error)) return disabled;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
const generation = generations.get(path) ?? 0;
|
|
461
|
+
const loss = losses.get(path) ?? 0;
|
|
462
|
+
const processId = randomUUID();
|
|
463
|
+
const state = {
|
|
464
|
+
path,
|
|
465
|
+
protectedPath,
|
|
466
|
+
captureRoot: protectedPath ? RAW_ROOT : directory,
|
|
467
|
+
fd,
|
|
468
|
+
captureFailed: false,
|
|
469
|
+
captureIncomplete: false,
|
|
470
|
+
tails: /* @__PURE__ */ new Map(),
|
|
471
|
+
generation,
|
|
472
|
+
loss,
|
|
473
|
+
processId,
|
|
474
|
+
release: registerCaptureWriter(path, processId),
|
|
475
|
+
attempt: options?.unbound === true ? void 0 : activeAttempt.getStore(),
|
|
476
|
+
sequence: 0,
|
|
477
|
+
closed: false
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
frame(stream, bytes, metadata) {
|
|
481
|
+
frameCapture(state, mode, stream, bytes, metadata);
|
|
482
|
+
},
|
|
483
|
+
close(result) {
|
|
484
|
+
closeCapture(state, result);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/** Empty closed evidence has no payload to preserve; never prune caller-owned paths. */
|
|
489
|
+
function pruneClosedEmptyCapture(path, writing) {
|
|
490
|
+
if (path === writing || !path.startsWith(`${RAW_ROOT}/`) || liveWriters.get(path)?.size || trims.get(path)) return false;
|
|
491
|
+
const directory = dirname(path);
|
|
492
|
+
if (dirname(directory) !== RAW_ROOT || !/^[a-f0-9]{64}$/.test(directory.slice(28))) return false;
|
|
493
|
+
try {
|
|
494
|
+
assertProtectedCaptureDirectory(directory);
|
|
495
|
+
const before = lstatSync(path);
|
|
496
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size !== 0 || before.nlink !== 1 || before.uid !== 0 || (before.mode & 63) !== 0) return false;
|
|
497
|
+
const handle = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
498
|
+
try {
|
|
499
|
+
const opened = fstatSync(handle);
|
|
500
|
+
if (!opened.isFile() || opened.size !== 0 || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) return false;
|
|
501
|
+
} finally {
|
|
502
|
+
closeSync(handle);
|
|
503
|
+
}
|
|
504
|
+
unlinkSync(path);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error.code !== "ENOENT") return false;
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
rmdirSync(directory);
|
|
510
|
+
} catch {}
|
|
511
|
+
generations.delete(path);
|
|
512
|
+
losses.delete(path);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
/** Reclaim oldest whole spools before writing, even when an attempt is live. */
|
|
516
|
+
function reserveCaptureBytes(root, writing, bytes) {
|
|
517
|
+
const files = [];
|
|
518
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
519
|
+
const candidate = entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name) ? join(root, entry.name, "process-io.frames") : entry.isFile() && entry.name === "process-io.frames" ? join(root, entry.name) : void 0;
|
|
520
|
+
if (!candidate) continue;
|
|
521
|
+
try {
|
|
522
|
+
const stat = lstatSync(candidate);
|
|
523
|
+
if (stat.size === 0 && pruneClosedEmptyCapture(candidate, writing)) continue;
|
|
524
|
+
if (stat.isFile() && !stat.isSymbolicLink()) files.push({
|
|
525
|
+
path: candidate,
|
|
526
|
+
size: stat.size,
|
|
527
|
+
modified: stat.mtimeMs,
|
|
528
|
+
dev: stat.dev,
|
|
529
|
+
ino: stat.ino
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error.code !== "ENOENT") throw error;
|
|
533
|
+
pruneClosedEmptyCapture(candidate, writing);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
let total = files.reduce((sum, item) => sum + item.size, 0);
|
|
537
|
+
let reset = false;
|
|
538
|
+
for (const item of files.sort((a, b) => a.modified - b.modified)) {
|
|
539
|
+
if (total + bytes + 128 <= 67108864) break;
|
|
540
|
+
const handle = openSync(item.path, constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
541
|
+
try {
|
|
542
|
+
const opened = fstatSync(handle);
|
|
543
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== item.dev || opened.ino !== item.ino) throw new Error("Raw capture spool changed during capacity admission");
|
|
544
|
+
ftruncateSync(handle, 0);
|
|
545
|
+
} finally {
|
|
546
|
+
closeSync(handle);
|
|
547
|
+
}
|
|
548
|
+
generations.set(item.path, (generations.get(item.path) ?? 0) + 1);
|
|
549
|
+
losses.set(item.path, (losses.get(item.path) ?? 0) + 1);
|
|
550
|
+
total -= item.size;
|
|
551
|
+
if (item.path === writing) reset = true;
|
|
552
|
+
else pruneClosedEmptyCapture(item.path, writing);
|
|
553
|
+
}
|
|
554
|
+
if (total + bytes + 128 > 67108864) throw Object.assign(/* @__PURE__ */ new Error("Raw capture capacity unavailable"), { code: "EDQUOT" });
|
|
555
|
+
return reset;
|
|
556
|
+
}
|
|
557
|
+
const LINE_HEAD = /^\{"processId":"([a-f0-9-]{36})","sequence":(\d+),"at":"([^"]+)"/u;
|
|
558
|
+
const LINE_ATTEMPT = /"executionId":("(?:[^"\\]|\\.)*"),"ordinal":(\d+),"providerSessionId":"(?:[^"\\]|\\.)*"\}$/u;
|
|
559
|
+
/** Read complete lines from `start` to the current end, without loading the file. */
|
|
560
|
+
async function* lines(path) {
|
|
561
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
562
|
+
try {
|
|
563
|
+
const chunk = Buffer.allocUnsafe(1 << 20);
|
|
564
|
+
let carry = Buffer.alloc(0);
|
|
565
|
+
let carryOffset = 0;
|
|
566
|
+
for (let position = 0;;) {
|
|
567
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
568
|
+
if (bytesRead === 0) break;
|
|
569
|
+
position += bytesRead;
|
|
570
|
+
let data = Buffer.concat([carry, chunk.subarray(0, bytesRead)]);
|
|
571
|
+
let dataOffset = carryOffset;
|
|
572
|
+
for (;;) {
|
|
573
|
+
const newline = data.indexOf(10);
|
|
574
|
+
if (newline < 0) break;
|
|
575
|
+
yield {
|
|
576
|
+
line: data.subarray(0, newline + 1),
|
|
577
|
+
offset: dataOffset
|
|
578
|
+
};
|
|
579
|
+
data = data.subarray(newline + 1);
|
|
580
|
+
dataOffset += newline + 1;
|
|
581
|
+
}
|
|
582
|
+
carry = Buffer.from(data);
|
|
583
|
+
carryOffset = dataOffset;
|
|
584
|
+
}
|
|
585
|
+
} finally {
|
|
586
|
+
await handle.close();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** The processes in one capture file, in file order, with their sizes. */
|
|
590
|
+
async function summarizeRawCapture(file) {
|
|
591
|
+
const path = resolve(file);
|
|
592
|
+
const processes = /* @__PURE__ */ new Map();
|
|
593
|
+
let sizeBytes = 0;
|
|
594
|
+
for await (const { line, offset } of lines(path)) {
|
|
595
|
+
sizeBytes = offset + line.length;
|
|
596
|
+
const text = line.toString("utf8", 0, Math.min(line.length, 160));
|
|
597
|
+
const head = LINE_HEAD.exec(text);
|
|
598
|
+
if (!head) continue;
|
|
599
|
+
const [, processId, , at] = head;
|
|
600
|
+
let entry = processes.get(processId);
|
|
601
|
+
if (!entry) {
|
|
602
|
+
const tail = line.toString("utf8", Math.max(0, line.length - 600), line.length).trimEnd();
|
|
603
|
+
const attempt = LINE_ATTEMPT.exec(tail);
|
|
604
|
+
entry = {
|
|
605
|
+
processId,
|
|
606
|
+
...attempt ? {
|
|
607
|
+
executionId: JSON.parse(attempt[1]),
|
|
608
|
+
ordinal: Number(attempt[2])
|
|
609
|
+
} : {},
|
|
610
|
+
bytes: 0,
|
|
611
|
+
firstAt: at,
|
|
612
|
+
firstOffset: offset,
|
|
613
|
+
closed: false,
|
|
614
|
+
live: false
|
|
615
|
+
};
|
|
616
|
+
processes.set(processId, entry);
|
|
617
|
+
}
|
|
618
|
+
entry.bytes += line.length;
|
|
619
|
+
if (line.includes("\"terminal\":{")) entry.closed = true;
|
|
620
|
+
}
|
|
621
|
+
const writers = liveWriters.get(path);
|
|
622
|
+
for (const entry of processes.values()) if (writers?.has(entry.processId)) entry.live = true;
|
|
623
|
+
return {
|
|
624
|
+
sizeBytes,
|
|
625
|
+
processes: [...processes.values()]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Remove whole processes from a capture file. A process is dropped with all
|
|
630
|
+
* of its lines or not at all, so every remaining process still reads as a
|
|
631
|
+
* complete sequence. A process this sidecar is still writing is never
|
|
632
|
+
* dropped. Frames written while the file is rewritten are carried into the
|
|
633
|
+
* replacement before it takes the path.
|
|
634
|
+
*/
|
|
635
|
+
async function trimRawCapture(file, drop) {
|
|
636
|
+
const path = resolve(file);
|
|
637
|
+
const writers = liveWriters.get(path);
|
|
638
|
+
const dropping = new Set([...drop].filter((id) => !writers?.has(id)));
|
|
639
|
+
const beforeBytes = lstatSync(path).size;
|
|
640
|
+
const originalLoss = losses.get(path) ?? 0;
|
|
641
|
+
if (dropping.size === 0) return {
|
|
642
|
+
beforeBytes,
|
|
643
|
+
afterBytes: beforeBytes,
|
|
644
|
+
droppedBytes: 0
|
|
645
|
+
};
|
|
646
|
+
trims.set(path, (trims.get(path) ?? 0) + 1);
|
|
647
|
+
try {
|
|
648
|
+
const temporary = `${path}.trim-${process.pid}-${randomUUID()}`;
|
|
649
|
+
const output = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
650
|
+
try {
|
|
651
|
+
let scanned = 0;
|
|
652
|
+
for await (const { line, offset } of lines(path)) {
|
|
653
|
+
scanned = offset + line.length;
|
|
654
|
+
const head = LINE_HEAD.exec(line.toString("utf8", 0, Math.min(line.length, 160)));
|
|
655
|
+
if (head && dropping.has(head[1])) continue;
|
|
656
|
+
for (let written = 0; written < line.length;) written += writeSync(output, line, written, line.length - written);
|
|
657
|
+
}
|
|
658
|
+
if ((losses.get(path) ?? 0) !== originalLoss) {
|
|
659
|
+
rmSync(temporary, { force: true });
|
|
660
|
+
const afterBytes = statSync(path).size;
|
|
661
|
+
return {
|
|
662
|
+
beforeBytes,
|
|
663
|
+
afterBytes,
|
|
664
|
+
droppedBytes: Math.max(0, beforeBytes - afterBytes)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const source = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
668
|
+
try {
|
|
669
|
+
const end = fstatSync(source).size;
|
|
670
|
+
const tail = Buffer.allocUnsafe(1 << 20);
|
|
671
|
+
for (let position = scanned; position < end;) {
|
|
672
|
+
const read = readSync(source, tail, 0, Math.min(tail.length, end - position), position);
|
|
673
|
+
if (read <= 0) break;
|
|
674
|
+
for (let written = 0; written < read;) written += writeSync(output, tail, written, read - written);
|
|
675
|
+
position += read;
|
|
676
|
+
}
|
|
677
|
+
} finally {
|
|
678
|
+
closeSync(source);
|
|
679
|
+
}
|
|
680
|
+
fsyncSync(output);
|
|
681
|
+
renameSync(temporary, path);
|
|
682
|
+
generations.set(path, (generations.get(path) ?? 0) + 1);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
rmSync(temporary, { force: true });
|
|
685
|
+
throw error;
|
|
686
|
+
} finally {
|
|
687
|
+
closeSync(output);
|
|
688
|
+
}
|
|
689
|
+
const afterBytes = statSync(path).size;
|
|
690
|
+
return {
|
|
691
|
+
beforeBytes,
|
|
692
|
+
afterBytes,
|
|
693
|
+
droppedBytes: Math.max(0, beforeBytes - afterBytes)
|
|
694
|
+
};
|
|
695
|
+
} finally {
|
|
696
|
+
const active = (trims.get(path) ?? 1) - 1;
|
|
697
|
+
if (active > 0) trims.set(path, active);
|
|
698
|
+
else trims.delete(path);
|
|
699
|
+
pruneClosedEmptyCapture(path);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/process-capture.ts
|
|
704
|
+
/**
|
|
705
|
+
* Bytes a process with a deferred raw capture may print before the capture
|
|
706
|
+
* opens. A held CLI waits on stdin and prints next to nothing; one that prints
|
|
707
|
+
* more is not a quiet spare, and it is stopped rather than held unbounded.
|
|
708
|
+
*/
|
|
709
|
+
const DEFERRED_CAPTURE_PENDING_BYTES = 1024 * 1024;
|
|
710
|
+
/**
|
|
711
|
+
* The raw byte capture of one CLI process. A deferred capture opens in
|
|
712
|
+
* `begin`, inside the turn that takes the process; until then its frames wait
|
|
713
|
+
* here, in order. A capture failure stops the process through `onFailure`.
|
|
714
|
+
*/
|
|
715
|
+
var ProcessCapture = class {
|
|
716
|
+
error;
|
|
717
|
+
rawCapture;
|
|
718
|
+
pendingFrames;
|
|
719
|
+
pendingBytes = 0;
|
|
720
|
+
constructor(path, deferred, onFailure) {
|
|
721
|
+
this.path = path;
|
|
722
|
+
this.onFailure = onFailure;
|
|
723
|
+
this.rawCapture = path && !deferred ? openRawCapture(path) : void 0;
|
|
724
|
+
this.pendingFrames = path && deferred ? [] : void 0;
|
|
725
|
+
}
|
|
726
|
+
/** The process failed to start: close the capture with the cause. */
|
|
727
|
+
closeAfterSpawnFailure(error) {
|
|
728
|
+
this.rawCapture?.close({ spawnError: error instanceof Error ? error.message : String(error) });
|
|
729
|
+
}
|
|
730
|
+
frame(stream, chunk) {
|
|
731
|
+
if (!this.rawCapture && !this.pendingFrames || this.error) return;
|
|
732
|
+
try {
|
|
733
|
+
if (stream !== "stdin" && !Buffer.isBuffer(chunk)) throw new Error(`${stream} was decoded before byte capture`);
|
|
734
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
735
|
+
if (this.pendingFrames) {
|
|
736
|
+
this.pendingBytes += bytes.length;
|
|
737
|
+
if (this.pendingBytes > DEFERRED_CAPTURE_PENDING_BYTES) throw new Error(`printed more than ${DEFERRED_CAPTURE_PENDING_BYTES} bytes before its capture opened`);
|
|
738
|
+
this.pendingFrames.push({
|
|
739
|
+
stream,
|
|
740
|
+
bytes
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this.rawCapture?.frame(stream, bytes);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
747
|
+
this.onFailure();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
finish(result) {
|
|
751
|
+
this.pendingFrames = void 0;
|
|
752
|
+
try {
|
|
753
|
+
this.rawCapture?.close({
|
|
754
|
+
...result,
|
|
755
|
+
captureError: this.error ?? null
|
|
756
|
+
});
|
|
757
|
+
} catch (error) {
|
|
758
|
+
this.error ??= error instanceof Error ? error.message : String(error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** Open a deferred capture in the caller's attempt and flush held frames. */
|
|
762
|
+
begin() {
|
|
763
|
+
if (this.error) throw new Error(`raw capture failed before a turn took the process: ${this.error}`);
|
|
764
|
+
if (!this.pendingFrames || !this.path) return;
|
|
765
|
+
const held = this.pendingFrames;
|
|
766
|
+
this.pendingFrames = void 0;
|
|
767
|
+
this.rawCapture = openRawCapture(this.path);
|
|
768
|
+
for (const frame of held) this.frame(frame.stream, frame.bytes);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/owned-process-tree.ts
|
|
773
|
+
function identity(pid) {
|
|
774
|
+
try {
|
|
775
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
776
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
777
|
+
const group = Number(fields[2]);
|
|
778
|
+
if (!Number.isSafeInteger(group) || !fields[19]) throw new Error(`Cannot read process identity for ${pid}`);
|
|
779
|
+
return {
|
|
780
|
+
pid,
|
|
781
|
+
parent: Number(fields[1]),
|
|
782
|
+
group,
|
|
783
|
+
start: fields[19],
|
|
784
|
+
state: fields[0]
|
|
785
|
+
};
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (["ENOENT", "ESRCH"].includes(error.code ?? "")) return void 0;
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** Observed Linux descendants retain their identity across process-group changes. */
|
|
792
|
+
var OwnedProcessTree = class {
|
|
793
|
+
processes = /* @__PURE__ */ new Map();
|
|
794
|
+
groups = /* @__PURE__ */ new Map();
|
|
795
|
+
root;
|
|
796
|
+
constructor(rootPid) {
|
|
797
|
+
const root = identity(rootPid);
|
|
798
|
+
this.root = root;
|
|
799
|
+
if (root) {
|
|
800
|
+
this.processes.set(rootPid, root);
|
|
801
|
+
if (root.group === rootPid) this.groups.set(rootPid, root.start);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
capture() {
|
|
805
|
+
const queue = [...this.processes.values()];
|
|
806
|
+
const seen = /* @__PURE__ */ new Set();
|
|
807
|
+
for (let index = 0; index < queue.length; index++) {
|
|
808
|
+
const expected = queue[index];
|
|
809
|
+
const key = `${expected.pid}:${expected.start}`;
|
|
810
|
+
if (seen.has(key)) continue;
|
|
811
|
+
seen.add(key);
|
|
812
|
+
const current = identity(expected.pid);
|
|
813
|
+
if (!current || current.start !== expected.start || current.state === "Z" || current.state === "X") {
|
|
814
|
+
if (this.processes.get(expected.pid)?.start === expected.start) this.processes.delete(expected.pid);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
this.processes.set(current.pid, current);
|
|
818
|
+
if (current.group === current.pid) this.groups.set(current.group, current.start);
|
|
819
|
+
let children;
|
|
820
|
+
try {
|
|
821
|
+
children = readFileSync(`/proc/${current.pid}/task/${current.pid}/children`, "utf8");
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (["ENOENT", "ESRCH"].includes(error.code ?? "")) continue;
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
for (const value of children.trim().split(/\s+/).filter(Boolean)) {
|
|
827
|
+
const pid = Number(value);
|
|
828
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Invalid child process identity");
|
|
829
|
+
const child = identity(pid);
|
|
830
|
+
if (!child || child.parent !== current.pid) continue;
|
|
831
|
+
this.processes.set(pid, child);
|
|
832
|
+
queue.push(child);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const liveGroups = new Set([...this.processes.values()].map((member) => member.group));
|
|
836
|
+
for (const group of this.groups.keys()) if (!liveGroups.has(group)) this.groups.delete(group);
|
|
837
|
+
}
|
|
838
|
+
live() {
|
|
839
|
+
return [...this.processes.values()].flatMap((expected) => {
|
|
840
|
+
const current = identity(expected.pid);
|
|
841
|
+
return current && current.start === expected.start && current.state !== "Z" && current.state !== "X" ? [current] : [];
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
hasLiveProcesses() {
|
|
845
|
+
this.capture();
|
|
846
|
+
if (this.live().length > 0) return true;
|
|
847
|
+
const root = this.root;
|
|
848
|
+
if (!root || !this.ownsRootGroup()) return false;
|
|
849
|
+
try {
|
|
850
|
+
process.kill(-root.group, 0);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (error.code === "ESRCH") return false;
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
for (const entry of readdirSync("/proc")) {
|
|
856
|
+
if (!/^[1-9][0-9]*$/.test(entry)) continue;
|
|
857
|
+
const member = identity(Number(entry));
|
|
858
|
+
if (member && member.group === root.group && member.state !== "Z" && member.state !== "X") this.processes.set(member.pid, member);
|
|
859
|
+
}
|
|
860
|
+
return this.live().length > 0;
|
|
861
|
+
}
|
|
862
|
+
ownsRootGroup() {
|
|
863
|
+
if (!this.root || this.root.group !== this.root.pid) return false;
|
|
864
|
+
const current = identity(this.root.pid);
|
|
865
|
+
return !current || current.start === this.root.start;
|
|
866
|
+
}
|
|
867
|
+
signal(signal) {
|
|
868
|
+
this.capture();
|
|
869
|
+
const signaledGroups = /* @__PURE__ */ new Set();
|
|
870
|
+
if (this.root && this.ownsRootGroup()) try {
|
|
871
|
+
process.kill(-this.root.group, signal);
|
|
872
|
+
signaledGroups.add(-this.root.group);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (error.code !== "ESRCH") throw error;
|
|
875
|
+
}
|
|
876
|
+
for (const member of this.live()) {
|
|
877
|
+
const groupStart = this.groups.get(member.group);
|
|
878
|
+
const leader = groupStart === void 0 ? void 0 : identity(member.group);
|
|
879
|
+
const target = groupStart !== void 0 && (!leader || leader.start === groupStart) ? -member.group : member.pid;
|
|
880
|
+
if (target < 0 && signaledGroups.has(target)) continue;
|
|
881
|
+
try {
|
|
882
|
+
process.kill(target, signal);
|
|
883
|
+
if (target < 0) signaledGroups.add(target);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (error.code !== "ESRCH") throw error;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/process-group.ts
|
|
892
|
+
const TERM_GRACE_MS = 250;
|
|
893
|
+
const KILL_CONFIRM_MS = 1e3;
|
|
894
|
+
/**
|
|
895
|
+
* The process group a detached CLI leads. It outlives its leader when the CLI
|
|
896
|
+
* leaves children behind (MCP servers, a backgrounded shell), so a run is
|
|
897
|
+
* finished only once every member is gone. On Linux the members are read by
|
|
898
|
+
* identity, so a reused pid or a zombie never counts as live.
|
|
899
|
+
*/
|
|
900
|
+
var CliProcessGroup = class {
|
|
901
|
+
tree;
|
|
902
|
+
constructor(leader) {
|
|
903
|
+
this.leader = leader;
|
|
904
|
+
this.tree = process.platform === "linux" ? ownedTree(leader) : void 0;
|
|
905
|
+
}
|
|
906
|
+
alive() {
|
|
907
|
+
if (this.tree) return this.tree.hasLiveProcesses();
|
|
908
|
+
try {
|
|
909
|
+
process.kill(-this.leader, 0);
|
|
910
|
+
return true;
|
|
911
|
+
} catch (error) {
|
|
912
|
+
return error.code !== "ESRCH";
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
signal(signal) {
|
|
916
|
+
if (this.tree) {
|
|
917
|
+
this.tree.signal(signal);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
process.kill(-this.leader, signal);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
if (error.code !== "ESRCH") throw error;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/** SIGTERM, a short grace, SIGKILL, then wait until no member is left. */
|
|
927
|
+
async stop() {
|
|
928
|
+
if (!this.alive()) return;
|
|
929
|
+
this.signal("SIGTERM");
|
|
930
|
+
const graceEnd = Date.now() + TERM_GRACE_MS;
|
|
931
|
+
while (this.alive() && Date.now() < graceEnd) await pause();
|
|
932
|
+
if (!this.alive()) return;
|
|
933
|
+
this.signal("SIGKILL");
|
|
934
|
+
const confirmEnd = Date.now() + KILL_CONFIRM_MS;
|
|
935
|
+
while (this.alive() && Date.now() < confirmEnd) await pause();
|
|
936
|
+
if (this.alive()) throw new Error(`process group ${this.leader} survived SIGKILL`);
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
function ownedTree(leader) {
|
|
940
|
+
try {
|
|
941
|
+
return new OwnedProcessTree(leader);
|
|
942
|
+
} catch {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function pause() {
|
|
947
|
+
return new Promise((resolve) => setTimeout(resolve, 10));
|
|
948
|
+
}
|
|
949
|
+
/** The newest `maxBytes` of a stream, decoded once at the end. */
|
|
950
|
+
var RollingByteCapture = class {
|
|
951
|
+
chunks = [];
|
|
952
|
+
bytes = 0;
|
|
953
|
+
constructor(maxBytes) {
|
|
954
|
+
this.maxBytes = maxBytes;
|
|
955
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("maxOutputBytes must be a non-negative safe integer");
|
|
956
|
+
}
|
|
957
|
+
append(chunk) {
|
|
958
|
+
if (this.maxBytes === 0) return;
|
|
959
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
960
|
+
this.chunks.push(buffer);
|
|
961
|
+
this.bytes += buffer.length;
|
|
962
|
+
while (this.bytes > this.maxBytes) {
|
|
963
|
+
const first = this.chunks[0];
|
|
964
|
+
const excess = this.bytes - this.maxBytes;
|
|
965
|
+
if (first.length <= excess) {
|
|
966
|
+
this.chunks.shift();
|
|
967
|
+
this.bytes -= first.length;
|
|
968
|
+
} else {
|
|
969
|
+
this.chunks[0] = first.subarray(excess);
|
|
970
|
+
this.bytes -= excess;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
text() {
|
|
975
|
+
return Buffer.concat(this.chunks, this.bytes).toString("utf8");
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/stream-parser.ts
|
|
980
|
+
/**
|
|
981
|
+
* Creates a stdout stream parser. Three modes:
|
|
982
|
+
*
|
|
983
|
+
* - "json" (default): newline-delimited JSON, JSON.parse per line.
|
|
984
|
+
* - "lines": plaintext, raw trimmed line per `transform` call.
|
|
985
|
+
* - "raw": no per-line splitting — accumulate the entire stream into a
|
|
986
|
+
* single buffer and deliver it via `onComplete(buffer)`.
|
|
987
|
+
*
|
|
988
|
+
* See StreamParserConfig docstring for the full contract per mode.
|
|
989
|
+
*/
|
|
990
|
+
function createStreamParser(config) {
|
|
991
|
+
let buffer = "";
|
|
992
|
+
const { parseAs = "json", transform, onEvent, onError, onComplete } = config;
|
|
993
|
+
if (parseAs === "raw") return {
|
|
994
|
+
push(chunk) {
|
|
995
|
+
buffer += chunk;
|
|
996
|
+
},
|
|
997
|
+
end() {
|
|
998
|
+
onComplete?.(buffer);
|
|
999
|
+
buffer = "";
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
function processLine(line) {
|
|
1003
|
+
const trimmed = line.trim();
|
|
1004
|
+
if (!trimmed) return;
|
|
1005
|
+
if (parseAs === "lines") {
|
|
1006
|
+
const event = transform ? transform(trimmed) : trimmed;
|
|
1007
|
+
if (event !== null) onEvent(event);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
const parsed = JSON.parse(trimmed);
|
|
1012
|
+
const event = transform ? transform(parsed) : parsed;
|
|
1013
|
+
if (event !== null) onEvent(event);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
onError?.(error instanceof Error ? error : new Error(String(error)), trimmed);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
push(chunk) {
|
|
1020
|
+
buffer += chunk;
|
|
1021
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
1022
|
+
while (newlineIndex !== -1) {
|
|
1023
|
+
const line = buffer.slice(0, newlineIndex);
|
|
1024
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
1025
|
+
processLine(line);
|
|
1026
|
+
newlineIndex = buffer.indexOf("\n");
|
|
1027
|
+
}
|
|
1028
|
+
},
|
|
1029
|
+
end() {
|
|
1030
|
+
if (buffer.trim()) processLine(buffer);
|
|
1031
|
+
buffer = "";
|
|
1032
|
+
onComplete?.();
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
//#endregion
|
|
1037
|
+
//#region src/cli-process.ts
|
|
1038
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
1039
|
+
/**
|
|
1040
|
+
* Spawns a CLI process with streaming support.
|
|
1041
|
+
*
|
|
1042
|
+
* @param config - Configuration for the process
|
|
1043
|
+
* @param parserConfig - Optional streaming parser for stdout
|
|
1044
|
+
* @returns Handle to the running process
|
|
1045
|
+
*/
|
|
1046
|
+
function spawnCliProcess(config, parserConfig) {
|
|
1047
|
+
if (config.launcher) throw new Error("A launcher may wait for capacity: use launchCliProcess");
|
|
1048
|
+
assertSpawnable(config);
|
|
1049
|
+
const processEnv = buildChildEnv(config);
|
|
1050
|
+
const detached = isDetached(config);
|
|
1051
|
+
assertRawCaptureStdio(config);
|
|
1052
|
+
let killer;
|
|
1053
|
+
const capture = openProcessCapture(config, () => killer?.kill("SIGTERM"));
|
|
1054
|
+
let child;
|
|
1055
|
+
try {
|
|
1056
|
+
child = spawnChild(config, processEnv, detached);
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
capture.closeAfterSpawnFailure(error);
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
killer = new ChildKiller(child, detached);
|
|
1062
|
+
return attachCliProcess(config, parserConfig, {
|
|
1063
|
+
launch: { child },
|
|
1064
|
+
killer,
|
|
1065
|
+
capture,
|
|
1066
|
+
detached
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Start a CLI through the config's `launcher` (or `spawn` without one) and
|
|
1071
|
+
* attach the same capture, deadlines, abort and exit handling as
|
|
1072
|
+
* `spawnCliProcess`. The launcher receives the fully assembled environment.
|
|
1073
|
+
*/
|
|
1074
|
+
async function launchCliProcess(config, parserConfig) {
|
|
1075
|
+
const { launcher } = config;
|
|
1076
|
+
if (!launcher) return spawnCliProcess(config, parserConfig);
|
|
1077
|
+
if (config.isolatedIdentity) throw new Error("A consumer launcher owns the child's identity");
|
|
1078
|
+
assertSpawnable(config);
|
|
1079
|
+
const detached = isDetached(config);
|
|
1080
|
+
assertRawCaptureStdio(config);
|
|
1081
|
+
let killer;
|
|
1082
|
+
const capture = openProcessCapture(config, () => killer?.kill("SIGTERM"));
|
|
1083
|
+
let launch;
|
|
1084
|
+
try {
|
|
1085
|
+
const launched = launcher({
|
|
1086
|
+
command: config.command,
|
|
1087
|
+
args: config.args,
|
|
1088
|
+
cwd: config.cwd,
|
|
1089
|
+
env: buildChildEnv(config),
|
|
1090
|
+
stdio: config.spawnOptions?.stdio ?? [
|
|
1091
|
+
"pipe",
|
|
1092
|
+
"pipe",
|
|
1093
|
+
"pipe"
|
|
1094
|
+
],
|
|
1095
|
+
detached,
|
|
1096
|
+
...config.signal ? { signal: config.signal } : {}
|
|
1097
|
+
});
|
|
1098
|
+
launch = launched instanceof Promise ? await launched : launched;
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
capture.closeAfterSpawnFailure(error);
|
|
1101
|
+
throw error;
|
|
1102
|
+
}
|
|
1103
|
+
killer = new ChildKiller(launch.child, detached, launch.terminate?.bind(launch));
|
|
1104
|
+
return attachCliProcess(config, parserConfig, {
|
|
1105
|
+
launch,
|
|
1106
|
+
killer,
|
|
1107
|
+
capture,
|
|
1108
|
+
detached
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
function isDetached(config) {
|
|
1112
|
+
return config.spawnOptions?.detached ?? process.platform !== "win32";
|
|
1113
|
+
}
|
|
1114
|
+
function openProcessCapture(config, onFailure) {
|
|
1115
|
+
return new ProcessCapture(config.rawCapturePath, config.rawCaptureDeferred, onFailure);
|
|
1116
|
+
}
|
|
1117
|
+
function attachCliProcess(config, parserConfig, run) {
|
|
1118
|
+
const { launch, killer, capture, detached } = run;
|
|
1119
|
+
const child = launch.child;
|
|
1120
|
+
const spawnedAt = Date.now();
|
|
1121
|
+
const stdin = watchStdinFailure(child, killer);
|
|
1122
|
+
const group = config.ownProcessGroup && detached && process.platform !== "win32" && child.pid ? new CliProcessGroup(child.pid) : void 0;
|
|
1123
|
+
child.stdout?.on("data", (chunk) => capture.frame("stdout", chunk));
|
|
1124
|
+
child.stderr?.on("data", (chunk) => capture.frame("stderr", chunk));
|
|
1125
|
+
const deadlines = new CliDeadlines(config, child, killer, spawnedAt);
|
|
1126
|
+
deadlines.arm();
|
|
1127
|
+
const abort = killOnAbort(config.signal, child, killer);
|
|
1128
|
+
const exitPromise = createExitPromise(child, {
|
|
1129
|
+
deadlines,
|
|
1130
|
+
capture,
|
|
1131
|
+
stdin,
|
|
1132
|
+
endParser: attachStdoutParser(child, parserConfig),
|
|
1133
|
+
abort,
|
|
1134
|
+
group,
|
|
1135
|
+
killer,
|
|
1136
|
+
release: launch.release?.bind(launch)
|
|
1137
|
+
});
|
|
1138
|
+
return {
|
|
1139
|
+
pid: child.pid,
|
|
1140
|
+
process: child,
|
|
1141
|
+
write(data) {
|
|
1142
|
+
if (!child.stdin || child.stdin.destroyed) throw new Error("Cannot write to closed stdin");
|
|
1143
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
1144
|
+
capture.frame("stdin", text);
|
|
1145
|
+
if (capture.error) throw new Error(`stdin capture failed: ${capture.error}`);
|
|
1146
|
+
child.stdin.write(text);
|
|
1147
|
+
if (typeof data === "object") {
|
|
1148
|
+
capture.frame("stdin", "\n");
|
|
1149
|
+
if (capture.error) throw new Error(`stdin capture failed: ${capture.error}`);
|
|
1150
|
+
child.stdin.write("\n");
|
|
1151
|
+
}
|
|
1152
|
+
},
|
|
1153
|
+
closeInput() {
|
|
1154
|
+
child.stdin?.end();
|
|
1155
|
+
},
|
|
1156
|
+
kill(killSignal = "SIGTERM") {
|
|
1157
|
+
return killer.kill(killSignal);
|
|
1158
|
+
},
|
|
1159
|
+
beginRawCapture() {
|
|
1160
|
+
capture.begin();
|
|
1161
|
+
},
|
|
1162
|
+
exitPromise
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function assertSpawnable(config) {
|
|
1166
|
+
if (config.signal?.aborted) {
|
|
1167
|
+
const error = /* @__PURE__ */ new Error("CLI process aborted before spawn");
|
|
1168
|
+
error.name = "AbortError";
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function assertRawCaptureStdio(config) {
|
|
1173
|
+
if (!config.rawCapturePath || config.spawnOptions?.stdio === void 0) return;
|
|
1174
|
+
const io = config.spawnOptions.stdio;
|
|
1175
|
+
if (!Array.isArray(io) || io.length !== 3 || !["pipe", "ignore"].includes(String(io[0])) || io[1] !== "pipe" || io[2] !== "pipe") throw new Error("raw capture requires piped stdout and stderr, with pipe or ignored stdin");
|
|
1176
|
+
}
|
|
1177
|
+
function spawnChild(config, processEnv, detached) {
|
|
1178
|
+
const { command, args, cwd, spawnOptions, isolatedIdentity } = config;
|
|
1179
|
+
const isolated = isolatedIdentity !== void 0;
|
|
1180
|
+
if (isolated && (process.platform !== "linux" || process.getuid?.() !== 0 || !Number.isSafeInteger(isolatedIdentity.uid) || isolatedIdentity.uid <= 0 || !Number.isSafeInteger(isolatedIdentity.gid) || isolatedIdentity.gid <= 0 || spawnOptions?.uid !== isolatedIdentity.uid || spawnOptions?.gid !== isolatedIdentity.gid)) throw new Error("Isolated CLI identity requires a matching root-owned Linux launch");
|
|
1181
|
+
const { uid: _uid, gid: _gid, ...safeSpawnOptions } = spawnOptions ?? {};
|
|
1182
|
+
return spawn(isolated ? "/usr/bin/setpriv" : command, isolated ? [
|
|
1183
|
+
"--reuid",
|
|
1184
|
+
String(isolatedIdentity.uid),
|
|
1185
|
+
"--regid",
|
|
1186
|
+
String(isolatedIdentity.gid),
|
|
1187
|
+
"--clear-groups",
|
|
1188
|
+
"--no-new-privs",
|
|
1189
|
+
"--",
|
|
1190
|
+
command,
|
|
1191
|
+
...args
|
|
1192
|
+
] : args, {
|
|
1193
|
+
cwd,
|
|
1194
|
+
env: processEnv,
|
|
1195
|
+
stdio: [
|
|
1196
|
+
"pipe",
|
|
1197
|
+
"pipe",
|
|
1198
|
+
"pipe"
|
|
1199
|
+
],
|
|
1200
|
+
detached,
|
|
1201
|
+
...isolated ? safeSpawnOptions : spawnOptions
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
function watchStdinFailure(child, killer) {
|
|
1205
|
+
const failure = { error: void 0 };
|
|
1206
|
+
child.stdin?.on("error", (error) => {
|
|
1207
|
+
if (failure.error !== void 0) return;
|
|
1208
|
+
failure.error = error.message;
|
|
1209
|
+
killer.kill("SIGTERM");
|
|
1210
|
+
if (!killer.escalating) killer.escalate(() => child.exitCode === null && child.signalCode === null);
|
|
1211
|
+
});
|
|
1212
|
+
return failure;
|
|
1213
|
+
}
|
|
1214
|
+
function killOnAbort(signal, child, killer) {
|
|
1215
|
+
const abort = { aborted: false };
|
|
1216
|
+
if (!signal) return abort;
|
|
1217
|
+
const abortHandler = () => {
|
|
1218
|
+
abort.aborted = true;
|
|
1219
|
+
killer.kill("SIGTERM");
|
|
1220
|
+
if (!killer.escalating) killer.escalate(() => child.exitCode === null && child.signalCode === null);
|
|
1221
|
+
};
|
|
1222
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
1223
|
+
if (signal.aborted) abortHandler();
|
|
1224
|
+
child.once("close", () => {
|
|
1225
|
+
signal.removeEventListener("abort", abortHandler);
|
|
1226
|
+
});
|
|
1227
|
+
return abort;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Feed stdout to a streaming parser. Returns the function that ends it, or
|
|
1231
|
+
* undefined when there is no parser or no stdout.
|
|
1232
|
+
*/
|
|
1233
|
+
function attachStdoutParser(child, parserConfig) {
|
|
1234
|
+
if (!(parserConfig && child.stdout)) return void 0;
|
|
1235
|
+
const parser = createStreamParser(parserConfig);
|
|
1236
|
+
let parserEnded = false;
|
|
1237
|
+
const endParser = () => {
|
|
1238
|
+
if (parserEnded) return;
|
|
1239
|
+
parserEnded = true;
|
|
1240
|
+
parser.end();
|
|
1241
|
+
};
|
|
1242
|
+
const decoder = new StringDecoder("utf8");
|
|
1243
|
+
child.stdout.on("data", (chunk) => {
|
|
1244
|
+
parser.push(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
1245
|
+
});
|
|
1246
|
+
child.stdout.on("end", () => {
|
|
1247
|
+
const tail = decoder.end();
|
|
1248
|
+
if (tail) parser.push(tail);
|
|
1249
|
+
endParser();
|
|
1250
|
+
});
|
|
1251
|
+
return endParser;
|
|
1252
|
+
}
|
|
1253
|
+
function createExitPromise(child, run) {
|
|
1254
|
+
const { deadlines, capture, stdin, endParser, abort, group, killer } = run;
|
|
1255
|
+
let released = false;
|
|
1256
|
+
const release = () => {
|
|
1257
|
+
if (released) return;
|
|
1258
|
+
released = true;
|
|
1259
|
+
run.release?.();
|
|
1260
|
+
};
|
|
1261
|
+
return new Promise((resolve) => {
|
|
1262
|
+
child.once("close", async (code, exitSignal) => {
|
|
1263
|
+
deadlines.clearAll();
|
|
1264
|
+
endParser?.();
|
|
1265
|
+
const processGroupError = await group?.stop().then(() => void 0, (error) => error instanceof Error ? error.message : String(error));
|
|
1266
|
+
const terminateError = await killer.terminated();
|
|
1267
|
+
release();
|
|
1268
|
+
capture.finish({
|
|
1269
|
+
code,
|
|
1270
|
+
signal: exitSignal,
|
|
1271
|
+
timedOut: deadlines.timedOut,
|
|
1272
|
+
timeoutReason: deadlines.timeoutReason ?? null,
|
|
1273
|
+
stdinError: stdin.error ?? null
|
|
1274
|
+
});
|
|
1275
|
+
resolve({
|
|
1276
|
+
code,
|
|
1277
|
+
signal: exitSignal,
|
|
1278
|
+
success: code === 0 && capture.error === void 0 && stdin.error === void 0 && processGroupError === void 0,
|
|
1279
|
+
timedOut: deadlines.timedOut,
|
|
1280
|
+
timeoutReason: deadlines.timeoutReason,
|
|
1281
|
+
timeoutDiagnostic: deadlines.timeoutDiagnostic,
|
|
1282
|
+
stdinError: stdin.error,
|
|
1283
|
+
captureError: capture.error,
|
|
1284
|
+
...abort.aborted ? { aborted: true } : {},
|
|
1285
|
+
...processGroupError === void 0 ? {} : { processGroupError },
|
|
1286
|
+
...terminateError === void 0 ? {} : { terminateError }
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
child.once("error", (error) => {
|
|
1290
|
+
deadlines.clearAll();
|
|
1291
|
+
release();
|
|
1292
|
+
capture.finish({
|
|
1293
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
1294
|
+
stdinError: stdin.error ?? null
|
|
1295
|
+
});
|
|
1296
|
+
resolve({
|
|
1297
|
+
code: null,
|
|
1298
|
+
signal: null,
|
|
1299
|
+
success: false,
|
|
1300
|
+
timedOut: false,
|
|
1301
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
1302
|
+
stdinError: stdin.error,
|
|
1303
|
+
captureError: capture.error,
|
|
1304
|
+
...abort.aborted ? { aborted: true } : {}
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Executes a CLI command and waits for completion. Without a parser it
|
|
1311
|
+
* returns stdout; stdout and stderr keep their newest `maxOutputBytes`.
|
|
1312
|
+
*/
|
|
1313
|
+
async function execCli(config, parserConfig, options = {}) {
|
|
1314
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1315
|
+
const stderr = new RollingByteCapture(maxOutputBytes);
|
|
1316
|
+
const stdout = new RollingByteCapture(maxOutputBytes);
|
|
1317
|
+
const events = [];
|
|
1318
|
+
const startedAt = Date.now();
|
|
1319
|
+
const cliProcess = await launchCliProcess(config, parserConfig ? {
|
|
1320
|
+
...parserConfig,
|
|
1321
|
+
onEvent: (event) => {
|
|
1322
|
+
events.push(event);
|
|
1323
|
+
parserConfig.onEvent(event);
|
|
1324
|
+
}
|
|
1325
|
+
} : void 0);
|
|
1326
|
+
cliProcess.process.stderr?.on("data", (chunk) => stderr.append(chunk));
|
|
1327
|
+
if (!parserConfig) cliProcess.process.stdout?.on("data", (chunk) => stdout.append(chunk));
|
|
1328
|
+
return {
|
|
1329
|
+
exit: await cliProcess.exitPromise,
|
|
1330
|
+
stderr: stderr.text(),
|
|
1331
|
+
durationMs: Date.now() - startedAt,
|
|
1332
|
+
...parserConfig ? { events } : { stdout: stdout.text() }
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
//#endregion
|
|
1336
|
+
//#region src/held-spare.ts
|
|
1337
|
+
/**
|
|
1338
|
+
* A parser that holds the spare's output until a turn adopts the process, then
|
|
1339
|
+
* hands the turn's own parser everything the CLI printed so far, in order.
|
|
1340
|
+
*/
|
|
1341
|
+
function createDeferredParser() {
|
|
1342
|
+
const held = [];
|
|
1343
|
+
let target;
|
|
1344
|
+
const deliver = (entry, to) => {
|
|
1345
|
+
if (entry.kind === "error") to.onError?.(entry.error, entry.line);
|
|
1346
|
+
else if (entry.kind === "complete") to.onComplete?.(entry.raw);
|
|
1347
|
+
else try {
|
|
1348
|
+
const event = to.transform ? to.transform(entry.raw) : entry.raw;
|
|
1349
|
+
if (event !== null) to.onEvent(event);
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
to.onError?.(error instanceof Error ? error : new Error(String(error)), JSON.stringify(entry.raw));
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
const take = (entry) => {
|
|
1355
|
+
if (target) deliver(entry, target);
|
|
1356
|
+
else held.push(entry);
|
|
1357
|
+
};
|
|
1358
|
+
return {
|
|
1359
|
+
config: {
|
|
1360
|
+
transform: (raw) => raw,
|
|
1361
|
+
onEvent: (raw) => take({
|
|
1362
|
+
kind: "event",
|
|
1363
|
+
raw
|
|
1364
|
+
}),
|
|
1365
|
+
onError: (error, line) => take({
|
|
1366
|
+
kind: "error",
|
|
1367
|
+
error,
|
|
1368
|
+
line
|
|
1369
|
+
}),
|
|
1370
|
+
onComplete: (raw) => take({
|
|
1371
|
+
kind: "complete",
|
|
1372
|
+
raw
|
|
1373
|
+
})
|
|
1374
|
+
},
|
|
1375
|
+
attach(next) {
|
|
1376
|
+
target = next;
|
|
1377
|
+
for (const entry of held.splice(0)) deliver(entry, next);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
/** How long a spare no turn adopted keeps its process. */
|
|
1382
|
+
const HELD_SPARE_IDLE_MS = 12e4;
|
|
1383
|
+
/**
|
|
1384
|
+
* How long a stopped spare has to exit after SIGTERM before it is killed.
|
|
1385
|
+
* Its HOME stays taken, and a turn that waits for it waits, until it exits.
|
|
1386
|
+
*/
|
|
1387
|
+
const HELD_SPARE_STOP_GRACE_MS = 2e3;
|
|
1388
|
+
function createHeldSpareRegistry() {
|
|
1389
|
+
const spares = /* @__PURE__ */ new Map();
|
|
1390
|
+
const stop = (spare) => {
|
|
1391
|
+
if (spare.state === "claimed" || spare.state === "stopped") return;
|
|
1392
|
+
spare.state = "stopped";
|
|
1393
|
+
if (spare.idleTimer) clearTimeout(spare.idleTimer);
|
|
1394
|
+
const forget = () => {
|
|
1395
|
+
if (spares.get(spare.home) === spare) spares.delete(spare.home);
|
|
1396
|
+
};
|
|
1397
|
+
const force = setTimeout(() => spare.process.kill("SIGKILL"), HELD_SPARE_STOP_GRACE_MS);
|
|
1398
|
+
force.unref();
|
|
1399
|
+
const exited = () => {
|
|
1400
|
+
clearTimeout(force);
|
|
1401
|
+
forget();
|
|
1402
|
+
};
|
|
1403
|
+
spare.process.exitPromise.then(exited, exited);
|
|
1404
|
+
spare.controller.abort();
|
|
1405
|
+
spare.process.kill();
|
|
1406
|
+
};
|
|
1407
|
+
const claim = (spare) => {
|
|
1408
|
+
spare.state = "claimed";
|
|
1409
|
+
if (spare.idleTimer) clearTimeout(spare.idleTimer);
|
|
1410
|
+
if (spares.get(spare.home) === spare) spares.delete(spare.home);
|
|
1411
|
+
};
|
|
1412
|
+
return {
|
|
1413
|
+
spareFor: (home) => home ? spares.get(home) : void 0,
|
|
1414
|
+
register: (spare) => {
|
|
1415
|
+
spares.set(spare.home, spare);
|
|
1416
|
+
},
|
|
1417
|
+
has: (home) => spares.has(home),
|
|
1418
|
+
stop,
|
|
1419
|
+
claim,
|
|
1420
|
+
adopt: (spare, turn) => {
|
|
1421
|
+
if (spare.state === "stopped" || spare.state === "claimed") throw new Error(`A ${spare.state} spare cannot be adopted`);
|
|
1422
|
+
try {
|
|
1423
|
+
spare.process.beginRawCapture?.();
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
stop(spare);
|
|
1426
|
+
throw error;
|
|
1427
|
+
}
|
|
1428
|
+
claim(spare);
|
|
1429
|
+
spare.parser.attach(turn.parser);
|
|
1430
|
+
const kill = () => spare.process.kill();
|
|
1431
|
+
if (turn.signal.aborted) kill();
|
|
1432
|
+
else turn.signal.addEventListener("abort", kill, { once: true });
|
|
1433
|
+
return {
|
|
1434
|
+
...spare.process,
|
|
1435
|
+
exitPromise: spare.process.exitPromise.finally(() => turn.signal.removeEventListener("abort", kill))
|
|
1436
|
+
};
|
|
1437
|
+
},
|
|
1438
|
+
discard: async () => {
|
|
1439
|
+
const stopping = [];
|
|
1440
|
+
for (const spare of [...spares.values()]) {
|
|
1441
|
+
stop(spare);
|
|
1442
|
+
stopping.push(spare.process.exitPromise.catch(() => void 0));
|
|
1443
|
+
}
|
|
1444
|
+
await Promise.all(stopping);
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
/** The argument after `flag`, if any. */
|
|
1449
|
+
function argAfter(args, flag) {
|
|
1450
|
+
const at = args.indexOf(flag);
|
|
1451
|
+
return at >= 0 ? args[at + 1] : void 0;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Whether two launches are the same process. `comparable` maps an environment
|
|
1455
|
+
* value to what must match (a harness drops the lines it allows to differ);
|
|
1456
|
+
* the default compares values as they are. Returns the names of what differs
|
|
1457
|
+
* (never values: environments hold credentials), empty when equal.
|
|
1458
|
+
*/
|
|
1459
|
+
function launchDifferences(spare, turn, comparable = (_key, value) => value) {
|
|
1460
|
+
const differences = [];
|
|
1461
|
+
if (spare.args.length !== turn.args.length || spare.args.some((arg, index) => arg !== turn.args[index])) differences.push("args");
|
|
1462
|
+
const keys = new Set([...Object.keys(spare.env), ...Object.keys(turn.env)]);
|
|
1463
|
+
for (const key of keys) if (comparable(key, spare.env[key]) !== comparable(key, turn.env[key])) differences.push(`env:${key}`);
|
|
1464
|
+
return differences;
|
|
1465
|
+
}
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/rpc-bridge-errors.ts
|
|
1468
|
+
var JsonRpcError = class extends Error {
|
|
1469
|
+
constructor(code, message, data) {
|
|
1470
|
+
super(message);
|
|
1471
|
+
this.code = code;
|
|
1472
|
+
this.data = data;
|
|
1473
|
+
this.name = "JsonRpcError";
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
var BridgeClosedError = class extends Error {
|
|
1477
|
+
/** The child's last stderr, bounded; kept off the message, which callers log. */
|
|
1478
|
+
stderrTail;
|
|
1479
|
+
constructor(reason, options) {
|
|
1480
|
+
super(`RpcBridge closed: ${reason}`, options?.cause ? { cause: options.cause } : void 0);
|
|
1481
|
+
this.name = "BridgeClosedError";
|
|
1482
|
+
if (options?.stderrTail) this.stderrTail = options.stderrTail;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
const STDERR_TAIL_CHARS = 2e3;
|
|
1486
|
+
/**
|
|
1487
|
+
* What ended the child, in words a log reader can act on: the spawn errno
|
|
1488
|
+
* (ENOENT: command not found; EACCES; EAGAIN), the exit code and the signal.
|
|
1489
|
+
* "process-error before response to initialize" alone named none of them.
|
|
1490
|
+
*/
|
|
1491
|
+
function describeClose(reason) {
|
|
1492
|
+
const errno = reason.error?.code;
|
|
1493
|
+
const parts = [
|
|
1494
|
+
reason.error ? `${errno ? `${errno}: ` : ""}${reason.error.message}` : void 0,
|
|
1495
|
+
reason.code === null ? void 0 : `exit=${reason.code}`,
|
|
1496
|
+
reason.signal ? `signal=${reason.signal}` : void 0
|
|
1497
|
+
].filter((part) => part !== void 0);
|
|
1498
|
+
return parts.length ? ` [${parts.join("; ")}]` : "";
|
|
1499
|
+
}
|
|
1500
|
+
var BridgeBackpressureError = class extends Error {
|
|
1501
|
+
constructor(pending, limit) {
|
|
1502
|
+
super(`RpcBridge backpressure: ${pending} pending requests at limit ${limit}`);
|
|
1503
|
+
this.pending = pending;
|
|
1504
|
+
this.limit = limit;
|
|
1505
|
+
this.name = "BridgeBackpressureError";
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
var RpcRequestTimeoutError = class extends Error {
|
|
1509
|
+
constructor(method, timeoutMs) {
|
|
1510
|
+
super(`RpcBridge request "${method}" timed out after ${timeoutMs}ms`);
|
|
1511
|
+
this.method = method;
|
|
1512
|
+
this.timeoutMs = timeoutMs;
|
|
1513
|
+
this.name = "RpcRequestTimeoutError";
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/rpc-byte-capture.ts
|
|
1518
|
+
/**
|
|
1519
|
+
* Exact byte capture of an RPC bridge's process I/O: one capture for the
|
|
1520
|
+
* process lifetime, or one per admitted turn on a retained process. A capture
|
|
1521
|
+
* failure stops the child through `killChild`.
|
|
1522
|
+
*/
|
|
1523
|
+
var RpcByteCapture = class {
|
|
1524
|
+
error;
|
|
1525
|
+
lifetime;
|
|
1526
|
+
turn;
|
|
1527
|
+
turnFrameMetadata;
|
|
1528
|
+
processExit;
|
|
1529
|
+
constructor(path, perTurn, killChild, beforeFrame, spawnErrorMessage) {
|
|
1530
|
+
this.perTurn = perTurn;
|
|
1531
|
+
this.killChild = killChild;
|
|
1532
|
+
this.beforeFrame = beforeFrame;
|
|
1533
|
+
this.spawnErrorMessage = spawnErrorMessage;
|
|
1534
|
+
this.lifetime = path ? openRawCapture(path, perTurn ? { unbound: true } : void 0) : void 0;
|
|
1535
|
+
}
|
|
1536
|
+
closeAfterSpawnFailure(error) {
|
|
1537
|
+
this.lifetime?.close({ spawnError: error instanceof Error ? error.message : String(error) });
|
|
1538
|
+
}
|
|
1539
|
+
frame(stream, bytes) {
|
|
1540
|
+
this.beforeFrame();
|
|
1541
|
+
const capture = this.turn ?? this.lifetime;
|
|
1542
|
+
if (!capture || this.error) return;
|
|
1543
|
+
try {
|
|
1544
|
+
capture.frame(stream, bytes, this.turn ? this.turnFrameMetadata : void 0);
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
1547
|
+
this.killChild("SIGTERM");
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/** Capture the child's stdout and stderr bytes and its exit. */
|
|
1551
|
+
attach(child) {
|
|
1552
|
+
child.stdout?.on("data", (chunk) => this.frameChunk("stdout", chunk));
|
|
1553
|
+
child.stderr?.on("data", (chunk) => this.frameChunk("stderr", chunk));
|
|
1554
|
+
child.once("close", (code, exitSignal) => {
|
|
1555
|
+
this.processExit = {
|
|
1556
|
+
code,
|
|
1557
|
+
signal: exitSignal
|
|
1558
|
+
};
|
|
1559
|
+
try {
|
|
1560
|
+
this.lifetime?.close({
|
|
1561
|
+
code,
|
|
1562
|
+
signal: exitSignal,
|
|
1563
|
+
timedOut: false,
|
|
1564
|
+
timeoutReason: null,
|
|
1565
|
+
captureError: this.error ?? null,
|
|
1566
|
+
spawnError: this.spawnErrorMessage()
|
|
1567
|
+
});
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
this.error ??= error instanceof Error ? error.message : String(error);
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Bind a capture to one serialized turn. Returns the function that closes it
|
|
1575
|
+
* with the turn's result and throws if capture failed.
|
|
1576
|
+
*/
|
|
1577
|
+
beginTurn(path, metadata, pid, bridgeClosed) {
|
|
1578
|
+
if (bridgeClosed || this.turn || this.lifetime && !this.perTurn) throw new Error("RPC turn capture requires a live unbound bridge");
|
|
1579
|
+
const capture = openRawCapture(path);
|
|
1580
|
+
this.turn = capture;
|
|
1581
|
+
this.turnFrameMetadata = {
|
|
1582
|
+
...metadata,
|
|
1583
|
+
pid
|
|
1584
|
+
};
|
|
1585
|
+
return (result) => {
|
|
1586
|
+
try {
|
|
1587
|
+
capture.close({
|
|
1588
|
+
...result,
|
|
1589
|
+
...this.processExit ? { processExit: this.processExit } : {},
|
|
1590
|
+
captureError: this.error ?? null,
|
|
1591
|
+
spawnError: this.spawnErrorMessage()
|
|
1592
|
+
});
|
|
1593
|
+
if (this.error) throw new Error(this.error);
|
|
1594
|
+
} finally {
|
|
1595
|
+
if (this.turn === capture) {
|
|
1596
|
+
this.turn = void 0;
|
|
1597
|
+
this.turnFrameMetadata = void 0;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
frameChunk(stream, chunk) {
|
|
1603
|
+
if (!Buffer.isBuffer(chunk)) {
|
|
1604
|
+
this.error = `${stream} was decoded before byte capture`;
|
|
1605
|
+
this.killChild("SIGTERM");
|
|
1606
|
+
} else this.frame(stream, chunk);
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
//#endregion
|
|
1610
|
+
//#region src/rpc-dispatcher.ts
|
|
1611
|
+
/**
|
|
1612
|
+
* JSON-RPC traffic of one bridge: outbound requests awaiting responses,
|
|
1613
|
+
* inbound notifications and reverse requests routed to their handlers, and the
|
|
1614
|
+
* NDJSON framing of stdout.
|
|
1615
|
+
*/
|
|
1616
|
+
var RpcDispatcher = class {
|
|
1617
|
+
pending = /* @__PURE__ */ new Map();
|
|
1618
|
+
notificationHandlers = /* @__PURE__ */ new Map();
|
|
1619
|
+
requestHandlers = /* @__PURE__ */ new Map();
|
|
1620
|
+
nextId = 1;
|
|
1621
|
+
stdoutBuffer = "";
|
|
1622
|
+
constructor(host) {
|
|
1623
|
+
this.host = host;
|
|
1624
|
+
}
|
|
1625
|
+
get pendingCount() {
|
|
1626
|
+
return this.pending.size;
|
|
1627
|
+
}
|
|
1628
|
+
request(method, params, options) {
|
|
1629
|
+
const { maxPendingRequests, defaultRequestTimeoutMs } = this.host;
|
|
1630
|
+
if (this.host.isClosed()) return Promise.reject(new BridgeClosedError(`request(${method}) after close`));
|
|
1631
|
+
if (this.pending.size >= maxPendingRequests) return Promise.reject(new BridgeBackpressureError(this.pending.size, maxPendingRequests));
|
|
1632
|
+
const id = String(this.nextId++);
|
|
1633
|
+
const timeoutMs = options?.timeoutMs ?? defaultRequestTimeoutMs;
|
|
1634
|
+
return new Promise((resolve, reject) => {
|
|
1635
|
+
const entry = {
|
|
1636
|
+
resolve,
|
|
1637
|
+
reject,
|
|
1638
|
+
method,
|
|
1639
|
+
startedAt: Date.now()
|
|
1640
|
+
};
|
|
1641
|
+
if (timeoutMs > 0) {
|
|
1642
|
+
entry.timer = setTimeout(() => {
|
|
1643
|
+
if (this.pending.delete(id)) reject(new RpcRequestTimeoutError(method, timeoutMs));
|
|
1644
|
+
}, timeoutMs);
|
|
1645
|
+
entry.timer.unref();
|
|
1646
|
+
}
|
|
1647
|
+
this.pending.set(id, entry);
|
|
1648
|
+
try {
|
|
1649
|
+
this.host.writeFrame({
|
|
1650
|
+
jsonrpc: "2.0",
|
|
1651
|
+
id,
|
|
1652
|
+
method,
|
|
1653
|
+
params
|
|
1654
|
+
});
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
this.pending.delete(id);
|
|
1657
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
1658
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
notify(method, params) {
|
|
1663
|
+
if (this.host.isClosed()) throw new BridgeClosedError(`notify(${method}) after close`);
|
|
1664
|
+
this.host.writeFrame({
|
|
1665
|
+
jsonrpc: "2.0",
|
|
1666
|
+
method,
|
|
1667
|
+
params
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
onNotification(method, handler) {
|
|
1671
|
+
let set = this.notificationHandlers.get(method);
|
|
1672
|
+
if (!set) {
|
|
1673
|
+
set = /* @__PURE__ */ new Set();
|
|
1674
|
+
this.notificationHandlers.set(method, set);
|
|
1675
|
+
}
|
|
1676
|
+
set.add(handler);
|
|
1677
|
+
return () => {
|
|
1678
|
+
set?.delete(handler);
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
onRequest(method, handler) {
|
|
1682
|
+
if (this.requestHandlers.has(method)) throw new Error(`RpcBridge: request handler for "${method}" already registered. Unregister the existing handler first.`);
|
|
1683
|
+
this.requestHandlers.set(method, handler);
|
|
1684
|
+
return () => {
|
|
1685
|
+
if (this.requestHandlers.get(method) === handler) this.requestHandlers.delete(method);
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
/** Split decoded stdout into NDJSON lines and dispatch each envelope. */
|
|
1689
|
+
consumeStdoutChunk(chunk) {
|
|
1690
|
+
this.stdoutBuffer += chunk;
|
|
1691
|
+
let nl = this.stdoutBuffer.indexOf("\n");
|
|
1692
|
+
while (nl !== -1) {
|
|
1693
|
+
const line = this.stdoutBuffer.slice(0, nl).trim();
|
|
1694
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1);
|
|
1695
|
+
if (line.length > 0) try {
|
|
1696
|
+
const env = JSON.parse(line);
|
|
1697
|
+
this.handleEnvelope(env);
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
this.host.logger.warn("stdout JSON parse error — line dropped", {
|
|
1700
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1701
|
+
sample: line.slice(0, 200)
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
nl = this.stdoutBuffer.indexOf("\n");
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/** Reject every pending request — fail loud, never hang. */
|
|
1708
|
+
rejectAll(reason, stderrBuffer) {
|
|
1709
|
+
for (const [id, req] of this.pending) {
|
|
1710
|
+
if (req.timer) clearTimeout(req.timer);
|
|
1711
|
+
req.reject(new BridgeClosedError(`${reason.reason} before response to ${req.method} (id=${id})${describeClose(reason)}`, {
|
|
1712
|
+
...reason.error ? { cause: reason.error } : {},
|
|
1713
|
+
stderrTail: stderrBuffer.slice(-STDERR_TAIL_CHARS)
|
|
1714
|
+
}));
|
|
1715
|
+
}
|
|
1716
|
+
this.pending.clear();
|
|
1717
|
+
}
|
|
1718
|
+
handleEnvelope(envelope) {
|
|
1719
|
+
if (envelope.id !== void 0 && envelope.id !== null && envelope.method === void 0) {
|
|
1720
|
+
this.handleResponse(envelope);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (envelope.method && (envelope.id === void 0 || envelope.id === null)) {
|
|
1724
|
+
this.handleNotification(envelope.method, envelope.params);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (envelope.method && envelope.id !== void 0 && envelope.id !== null) {
|
|
1728
|
+
this.handleRequest(envelope.method, envelope.id, envelope.params);
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
this.host.logger.warn("malformed envelope", { envelope });
|
|
1732
|
+
}
|
|
1733
|
+
handleResponse(envelope) {
|
|
1734
|
+
const key = String(envelope.id);
|
|
1735
|
+
const req = this.pending.get(key);
|
|
1736
|
+
if (!req) {
|
|
1737
|
+
this.host.logger.warn("unmatched response id", { id: envelope.id });
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
this.pending.delete(key);
|
|
1741
|
+
if (req.timer) clearTimeout(req.timer);
|
|
1742
|
+
if (envelope.error) req.reject(new JsonRpcError(envelope.error.code, envelope.error.message, envelope.error.data));
|
|
1743
|
+
else req.resolve(envelope.result);
|
|
1744
|
+
}
|
|
1745
|
+
handleNotification(method, params) {
|
|
1746
|
+
const { logger } = this.host;
|
|
1747
|
+
const handlers = this.notificationHandlers.get(method);
|
|
1748
|
+
if (!handlers || handlers.size === 0) {
|
|
1749
|
+
logger.debug("no handler for notification", { method });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
for (const handler of handlers) try {
|
|
1753
|
+
const result = handler(params);
|
|
1754
|
+
if (result instanceof Promise) result.catch((err) => {
|
|
1755
|
+
logger.warn("notification handler rejected", {
|
|
1756
|
+
method,
|
|
1757
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
logger.warn("notification handler threw", {
|
|
1762
|
+
method,
|
|
1763
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
handleRequest(method, id, params) {
|
|
1768
|
+
const handler = this.requestHandlers.get(method);
|
|
1769
|
+
if (!handler) {
|
|
1770
|
+
this.sendErrorResponse(id, -32601, `Method not found: ${method}`);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
Promise.resolve().then(() => handler(params)).then((result) => this.sendResponse(id, result), (err) => {
|
|
1774
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1775
|
+
const code = err instanceof JsonRpcError ? err.code : -32e3;
|
|
1776
|
+
const data = err instanceof JsonRpcError ? err.data : void 0;
|
|
1777
|
+
this.sendErrorResponse(id, code, message, data);
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
sendResponse(id, result) {
|
|
1781
|
+
if (this.host.isClosed()) return;
|
|
1782
|
+
try {
|
|
1783
|
+
this.host.writeFrame({
|
|
1784
|
+
jsonrpc: "2.0",
|
|
1785
|
+
id,
|
|
1786
|
+
result
|
|
1787
|
+
});
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
this.host.logger.warn("failed to write response", {
|
|
1790
|
+
id,
|
|
1791
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
sendErrorResponse(id, code, message, data) {
|
|
1796
|
+
if (this.host.isClosed()) return;
|
|
1797
|
+
try {
|
|
1798
|
+
this.host.writeFrame({
|
|
1799
|
+
jsonrpc: "2.0",
|
|
1800
|
+
id,
|
|
1801
|
+
error: {
|
|
1802
|
+
code,
|
|
1803
|
+
message,
|
|
1804
|
+
data
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
this.host.logger.warn("failed to write error response", {
|
|
1809
|
+
id,
|
|
1810
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
//#endregion
|
|
1816
|
+
//#region src/rpc-process-owner.ts
|
|
1817
|
+
function reportCleanupFailure(logger, message, error) {
|
|
1818
|
+
try {
|
|
1819
|
+
logger.error(message, { error: String(error) });
|
|
1820
|
+
} catch {}
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Signals and reaps the child of an RPC bridge, plus the descendants it owns
|
|
1824
|
+
* when the bridge owns a process group.
|
|
1825
|
+
*/
|
|
1826
|
+
var RpcProcessOwner = class {
|
|
1827
|
+
ownsProcessGroup;
|
|
1828
|
+
ownedTree;
|
|
1829
|
+
treeCaptureTimer;
|
|
1830
|
+
treeCaptureError;
|
|
1831
|
+
ownedTermination;
|
|
1832
|
+
closeDeadline;
|
|
1833
|
+
constructor(child, isolatedLaunch, detached, hardCloseDeadlineMs, logger, onTerminationFailure) {
|
|
1834
|
+
this.child = child;
|
|
1835
|
+
this.isolatedLaunch = isolatedLaunch;
|
|
1836
|
+
this.hardCloseDeadlineMs = hardCloseDeadlineMs;
|
|
1837
|
+
this.logger = logger;
|
|
1838
|
+
this.onTerminationFailure = onTerminationFailure;
|
|
1839
|
+
this.ownsProcessGroup = detached && process.platform !== "win32";
|
|
1840
|
+
try {
|
|
1841
|
+
if (this.ownsProcessGroup && process.platform === "linux" && child.pid) this.ownedTree = new OwnedProcessTree(child.pid);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
this.kill("SIGKILL");
|
|
1844
|
+
throw error;
|
|
1845
|
+
}
|
|
1846
|
+
if (this.ownedTree) {
|
|
1847
|
+
this.captureTree();
|
|
1848
|
+
this.treeCaptureTimer = setInterval(() => this.captureTree(), 50);
|
|
1849
|
+
this.treeCaptureTimer.unref();
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
/** Wall-clock instant by which a close must finish; fixed by the first caller. */
|
|
1853
|
+
deadline() {
|
|
1854
|
+
this.closeDeadline ??= Date.now() + this.hardCloseDeadlineMs;
|
|
1855
|
+
return this.closeDeadline;
|
|
1856
|
+
}
|
|
1857
|
+
kill(killSignal) {
|
|
1858
|
+
const child = this.child;
|
|
1859
|
+
if (this.ownedTree) try {
|
|
1860
|
+
this.ownedTree.signal(killSignal);
|
|
1861
|
+
return true;
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
this.treeCaptureError ??= error instanceof Error ? error : new Error(String(error));
|
|
1864
|
+
if (child.exitCode === null && child.signalCode === null) return child.kill(killSignal);
|
|
1865
|
+
return false;
|
|
1866
|
+
}
|
|
1867
|
+
if (this.ownsProcessGroup && child.pid) try {
|
|
1868
|
+
process.kill(-child.pid, killSignal);
|
|
1869
|
+
return true;
|
|
1870
|
+
} catch {}
|
|
1871
|
+
return this.isolatedLaunch ? this.isolatedLaunch.kill(killSignal) : child.kill(killSignal);
|
|
1872
|
+
}
|
|
1873
|
+
isRunning() {
|
|
1874
|
+
const child = this.child;
|
|
1875
|
+
if (this.ownedTree) return this.ownedTree.hasLiveProcesses();
|
|
1876
|
+
if (this.ownsProcessGroup && child.pid) try {
|
|
1877
|
+
process.kill(-child.pid, 0);
|
|
1878
|
+
return true;
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
return error.code !== "ESRCH";
|
|
1881
|
+
}
|
|
1882
|
+
return child.exitCode === null && child.signalCode === null;
|
|
1883
|
+
}
|
|
1884
|
+
captureTree() {
|
|
1885
|
+
try {
|
|
1886
|
+
this.ownedTree?.capture();
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
this.treeCaptureError ??= error instanceof Error ? error : new Error(String(error));
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
/** Terminate and reap every owned process; rejects on a survivor or fault. */
|
|
1892
|
+
finish() {
|
|
1893
|
+
if (!this.ownsProcessGroup) return Promise.resolve();
|
|
1894
|
+
this.ownedTermination ??= this.terminateOwned();
|
|
1895
|
+
this.ownedTermination.catch((error) => {
|
|
1896
|
+
reportCleanupFailure(this.logger, "Owned process cleanup failed", error);
|
|
1897
|
+
this.onTerminationFailure(error);
|
|
1898
|
+
});
|
|
1899
|
+
return this.ownedTermination;
|
|
1900
|
+
}
|
|
1901
|
+
async terminateOwned() {
|
|
1902
|
+
const deadline = this.deadline();
|
|
1903
|
+
if (this.treeCaptureTimer) clearInterval(this.treeCaptureTimer);
|
|
1904
|
+
this.captureTree();
|
|
1905
|
+
this.kill("SIGTERM");
|
|
1906
|
+
try {
|
|
1907
|
+
if (this.isRunning()) {
|
|
1908
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
|
|
1909
|
+
this.kill("SIGKILL");
|
|
1910
|
+
const reapDeadline = Math.min(Date.now() + 900, deadline);
|
|
1911
|
+
while (this.isRunning() && Date.now() < reapDeadline) await new Promise((resolve) => setTimeout(resolve, Math.min(10, Math.max(0, reapDeadline - Date.now()))));
|
|
1912
|
+
if (this.isRunning()) throw new Error("RpcBridge owned processes survived forced shutdown");
|
|
1913
|
+
}
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
this.kill("SIGKILL");
|
|
1916
|
+
throw error;
|
|
1917
|
+
}
|
|
1918
|
+
if (this.treeCaptureError) throw this.treeCaptureError;
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/rpc-lifecycle.ts
|
|
1923
|
+
/**
|
|
1924
|
+
* When an RPC bridge is open, closing or closed: the single settlement of the
|
|
1925
|
+
* close reason, and the drain, EOF, SIGTERM and SIGKILL sequence behind
|
|
1926
|
+
* `close()`.
|
|
1927
|
+
*/
|
|
1928
|
+
var RpcLifecycle = class {
|
|
1929
|
+
closed = false;
|
|
1930
|
+
exitReason;
|
|
1931
|
+
childError;
|
|
1932
|
+
closeHandlers = /* @__PURE__ */ new Set();
|
|
1933
|
+
childClosed = false;
|
|
1934
|
+
closing;
|
|
1935
|
+
resolveChildClose = () => {};
|
|
1936
|
+
childClosePromise = new Promise((resolve) => {
|
|
1937
|
+
this.resolveChildClose = resolve;
|
|
1938
|
+
});
|
|
1939
|
+
constructor(child, owner, dispatcher, policy, logger, stderrText) {
|
|
1940
|
+
this.child = child;
|
|
1941
|
+
this.owner = owner;
|
|
1942
|
+
this.dispatcher = dispatcher;
|
|
1943
|
+
this.policy = policy;
|
|
1944
|
+
this.logger = logger;
|
|
1945
|
+
this.stderrText = stderrText;
|
|
1946
|
+
}
|
|
1947
|
+
/** Watch the child and abort signal. */
|
|
1948
|
+
attach(child, signal) {
|
|
1949
|
+
child.once("close", (code, exitSignal) => {
|
|
1950
|
+
this.onChildClose(code, exitSignal);
|
|
1951
|
+
});
|
|
1952
|
+
child.once("error", (error) => {
|
|
1953
|
+
this.childError = error;
|
|
1954
|
+
});
|
|
1955
|
+
if (this.owner.ownsProcessGroup) child.once("exit", () => {
|
|
1956
|
+
this.closed = true;
|
|
1957
|
+
this.owner.finish();
|
|
1958
|
+
});
|
|
1959
|
+
if (signal) {
|
|
1960
|
+
const onAbort = () => {
|
|
1961
|
+
this.logger.warn("AbortSignal fired — closing bridge");
|
|
1962
|
+
this.close().catch((error) => reportCleanupFailure(this.logger, "RPC abort cleanup failed", error));
|
|
1963
|
+
};
|
|
1964
|
+
if (signal.aborted) onAbort();
|
|
1965
|
+
else {
|
|
1966
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1967
|
+
child.once("exit", () => signal.removeEventListener("abort", onAbort));
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
onClose(handler) {
|
|
1972
|
+
if (this.exitReason) {
|
|
1973
|
+
try {
|
|
1974
|
+
handler(this.exitReason);
|
|
1975
|
+
} catch {}
|
|
1976
|
+
return () => {};
|
|
1977
|
+
}
|
|
1978
|
+
this.closeHandlers.add(handler);
|
|
1979
|
+
return () => this.closeHandlers.delete(handler);
|
|
1980
|
+
}
|
|
1981
|
+
/** Settle the close reason once: reject pending requests, notify handlers. */
|
|
1982
|
+
settleClose(reason) {
|
|
1983
|
+
if (this.exitReason) return;
|
|
1984
|
+
this.exitReason = reason;
|
|
1985
|
+
this.closed = true;
|
|
1986
|
+
this.dispatcher.rejectAll(reason, this.stderrText());
|
|
1987
|
+
for (const handler of this.closeHandlers) try {
|
|
1988
|
+
handler(reason);
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
this.logger.warn("close handler threw", { error: err instanceof Error ? err.message : String(err) });
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
settleProcessError(error) {
|
|
1994
|
+
this.settleClose({
|
|
1995
|
+
code: this.child.exitCode,
|
|
1996
|
+
signal: this.child.signalCode,
|
|
1997
|
+
reason: "process-error",
|
|
1998
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
close(graceMs = this.policy.defaultCloseGraceMs) {
|
|
2002
|
+
if (!this.owner.ownsProcessGroup) return this.closeOperation(graceMs);
|
|
2003
|
+
this.closing ??= this.closeOperation(graceMs).catch((error) => {
|
|
2004
|
+
this.settleProcessError(error);
|
|
2005
|
+
throw error;
|
|
2006
|
+
});
|
|
2007
|
+
return this.closing;
|
|
2008
|
+
}
|
|
2009
|
+
onChildClose(code, exitSignal) {
|
|
2010
|
+
this.childClosed = true;
|
|
2011
|
+
this.resolveChildClose();
|
|
2012
|
+
const reason = {
|
|
2013
|
+
code,
|
|
2014
|
+
signal: exitSignal,
|
|
2015
|
+
...this.childError ? { error: this.childError } : {},
|
|
2016
|
+
reason: this.childError ? "process-error" : "process-exit"
|
|
2017
|
+
};
|
|
2018
|
+
if (this.owner.ownsProcessGroup) this.owner.finish().then(() => this.settleClose(reason), (error) => this.settleClose({
|
|
2019
|
+
...reason,
|
|
2020
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2021
|
+
reason: "process-error"
|
|
2022
|
+
}));
|
|
2023
|
+
else this.settleClose(reason);
|
|
2024
|
+
}
|
|
2025
|
+
async closeOperation(graceMs) {
|
|
2026
|
+
const { hardCloseDeadlineMs } = this.policy;
|
|
2027
|
+
if (this.closed) {
|
|
2028
|
+
await this.closeSettledBridge();
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
const nativeDeadline = this.owner.deadline() - (this.owner.ownsProcessGroup ? 1e3 : 0);
|
|
2032
|
+
const remaining = () => Math.max(0, nativeDeadline - Date.now());
|
|
2033
|
+
this.closed = true;
|
|
2034
|
+
this.owner.captureTree();
|
|
2035
|
+
await this.drainPending(graceMs, remaining);
|
|
2036
|
+
this.endStdin();
|
|
2037
|
+
await this.awaitStdinEof(remaining);
|
|
2038
|
+
this.terminateChild(graceMs, remaining);
|
|
2039
|
+
await Promise.race([this.childClosePromise, new Promise((resolve) => setTimeout(resolve, remaining()).unref())]);
|
|
2040
|
+
await this.owner.finish();
|
|
2041
|
+
if (!this.exitReason) {
|
|
2042
|
+
this.logger.error("close() hard deadline hit — child not reaped", {
|
|
2043
|
+
hardCloseDeadlineMs,
|
|
2044
|
+
pid: this.child.pid
|
|
2045
|
+
});
|
|
2046
|
+
this.settleClose({
|
|
2047
|
+
code: null,
|
|
2048
|
+
signal: null,
|
|
2049
|
+
reason: "graceful"
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/** A second close() on a bridge whose close was already settled. */
|
|
2054
|
+
async closeSettledBridge() {
|
|
2055
|
+
const child = this.child;
|
|
2056
|
+
if (this.childClosed) {
|
|
2057
|
+
await this.owner.finish();
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (this.exitReason?.reason === "graceful" && child.exitCode === null && child.signalCode === null) this.owner.kill("SIGKILL");
|
|
2061
|
+
await Promise.race([this.childClosePromise, new Promise((resolve) => setTimeout(resolve, this.policy.hardCloseDeadlineMs).unref())]);
|
|
2062
|
+
await this.owner.finish();
|
|
2063
|
+
}
|
|
2064
|
+
/** Drain: give in-flight requests up to graceMs to complete naturally. */
|
|
2065
|
+
async drainPending(graceMs, remaining) {
|
|
2066
|
+
const drainBudget = Math.min(graceMs, remaining());
|
|
2067
|
+
if (this.dispatcher.pendingCount > 0 && drainBudget > 0) await Promise.race([new Promise((resolve) => {
|
|
2068
|
+
const check = () => {
|
|
2069
|
+
if (this.dispatcher.pendingCount === 0 || remaining() === 0) resolve();
|
|
2070
|
+
else setTimeout(check, 25).unref();
|
|
2071
|
+
};
|
|
2072
|
+
check();
|
|
2073
|
+
}), new Promise((resolve) => setTimeout(resolve, drainBudget).unref())]);
|
|
2074
|
+
}
|
|
2075
|
+
/** Close stdin to signal end-of-input — well-behaved agents exit on EOF. */
|
|
2076
|
+
endStdin() {
|
|
2077
|
+
try {
|
|
2078
|
+
this.child.stdin?.end();
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
this.logger.debug("stdin.end() threw", { error: err instanceof Error ? err.message : String(err) });
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
async awaitStdinEof(remaining) {
|
|
2084
|
+
const eofBudget = Math.min(Math.max(this.policy.stdinCloseGraceMs, 0), remaining());
|
|
2085
|
+
if (eofBudget > 0 && !this.exitReason) {
|
|
2086
|
+
let eofTimer;
|
|
2087
|
+
try {
|
|
2088
|
+
await Promise.race([this.childClosePromise, new Promise((resolve) => {
|
|
2089
|
+
eofTimer = setTimeout(resolve, eofBudget);
|
|
2090
|
+
})]);
|
|
2091
|
+
} finally {
|
|
2092
|
+
if (eofTimer) clearTimeout(eofTimer);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
/** SIGTERM if still alive, then SIGKILL after another grace window. */
|
|
2097
|
+
terminateChild(graceMs, remaining) {
|
|
2098
|
+
const stillOwned = () => this.owner.ownsProcessGroup || this.child.exitCode === null && this.child.signalCode === null;
|
|
2099
|
+
if (!(stillOwned() && remaining() > 0)) return;
|
|
2100
|
+
this.owner.kill("SIGTERM");
|
|
2101
|
+
const sigkillDelay = Math.min(Math.max(graceMs, 100), remaining());
|
|
2102
|
+
const sigkillTimer = setTimeout(() => {
|
|
2103
|
+
if (stillOwned()) {
|
|
2104
|
+
this.logger.warn("SIGKILL — close grace expired");
|
|
2105
|
+
this.owner.kill("SIGKILL");
|
|
2106
|
+
}
|
|
2107
|
+
}, sigkillDelay);
|
|
2108
|
+
sigkillTimer.unref();
|
|
2109
|
+
this.childClosePromise.then(() => {
|
|
2110
|
+
clearTimeout(sigkillTimer);
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
//#endregion
|
|
2115
|
+
//#region src/rpc-stdio-process.ts
|
|
2116
|
+
const DEFAULT_MAX_PENDING = 1024;
|
|
2117
|
+
const DEFAULT_MAX_STDERR_BYTES = 65536;
|
|
2118
|
+
const DEFAULT_CLOSE_GRACE_MS = 2e3;
|
|
2119
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
|
|
2120
|
+
const DEFAULT_HARD_CLOSE_DEADLINE_MS = 1e4;
|
|
2121
|
+
const noopLogger = {
|
|
2122
|
+
debug: () => {},
|
|
2123
|
+
warn: () => {},
|
|
2124
|
+
error: () => {}
|
|
2125
|
+
};
|
|
2126
|
+
/**
|
|
2127
|
+
* Spawn an RpcBridge over a long-running stdio child process.
|
|
2128
|
+
*
|
|
2129
|
+
* The bridge is usable immediately after this call — outbound writes
|
|
2130
|
+
* before the child has booted are buffered by Node's stdin pipe. Inbound
|
|
2131
|
+
* dispatch starts as soon as the child writes its first line.
|
|
2132
|
+
*/
|
|
2133
|
+
function spawnRpcBridge(config) {
|
|
2134
|
+
const { maxPendingRequests = DEFAULT_MAX_PENDING, maxStderrBytes = DEFAULT_MAX_STDERR_BYTES, defaultRequestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, defaultCloseGraceMs = DEFAULT_CLOSE_GRACE_MS, hardCloseDeadlineMs = DEFAULT_HARD_CLOSE_DEADLINE_MS, logger = noopLogger, rawCapturePath, rawCapturePerTurn = false, detached = false, stdinCloseGraceMs = 0 } = config;
|
|
2135
|
+
const processEnv = mergeSpawnEnv(process.env, config.env);
|
|
2136
|
+
applyChildToolPath(processEnv);
|
|
2137
|
+
const capture = new RpcByteCapture(rawCapturePath, rawCapturePerTurn, (killSignal) => owner.kill(killSignal), () => owner.captureTree(), () => lifecycle.childError?.message ?? null);
|
|
2138
|
+
let child;
|
|
2139
|
+
let isolatedLaunch;
|
|
2140
|
+
try {
|
|
2141
|
+
({child, isolatedLaunch} = spawnBridgeChild(config, processEnv));
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
capture.closeAfterSpawnFailure(error);
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
const owner = new RpcProcessOwner(child, isolatedLaunch, detached, hardCloseDeadlineMs, logger, (error) => lifecycle.settleProcessError(error));
|
|
2147
|
+
capture.attach(child);
|
|
2148
|
+
const stderr = new StderrTail(maxStderrBytes);
|
|
2149
|
+
const dispatcher = new RpcDispatcher({
|
|
2150
|
+
logger,
|
|
2151
|
+
maxPendingRequests,
|
|
2152
|
+
defaultRequestTimeoutMs,
|
|
2153
|
+
isClosed: () => lifecycle.closed,
|
|
2154
|
+
writeFrame: (envelope) => writeFrame(envelope)
|
|
2155
|
+
});
|
|
2156
|
+
const lifecycle = new RpcLifecycle(child, owner, dispatcher, {
|
|
2157
|
+
defaultCloseGraceMs,
|
|
2158
|
+
hardCloseDeadlineMs,
|
|
2159
|
+
stdinCloseGraceMs
|
|
2160
|
+
}, logger, () => stderr.text);
|
|
2161
|
+
const writeFrame = (envelope) => {
|
|
2162
|
+
if (lifecycle.closed) throw new BridgeClosedError("write after close");
|
|
2163
|
+
if (!child.stdin || child.stdin.destroyed) throw new BridgeClosedError("stdin destroyed");
|
|
2164
|
+
const bytes = Buffer.from(`${JSON.stringify(envelope)}\n`);
|
|
2165
|
+
capture.frame("stdin", bytes);
|
|
2166
|
+
if (capture.error) throw new Error(`stdin capture failed: ${capture.error}`);
|
|
2167
|
+
child.stdin.write(bytes);
|
|
2168
|
+
};
|
|
2169
|
+
attachOutputStreams(child, dispatcher, stderr);
|
|
2170
|
+
lifecycle.attach(child, config.signal);
|
|
2171
|
+
return {
|
|
2172
|
+
captureTurn(path, metadata) {
|
|
2173
|
+
return capture.beginTurn(path, metadata, child.pid, lifecycle.closed);
|
|
2174
|
+
},
|
|
2175
|
+
get pid() {
|
|
2176
|
+
return child.pid;
|
|
2177
|
+
},
|
|
2178
|
+
get closed() {
|
|
2179
|
+
return lifecycle.closed;
|
|
2180
|
+
},
|
|
2181
|
+
get process() {
|
|
2182
|
+
return child;
|
|
2183
|
+
},
|
|
2184
|
+
request(method, params, options) {
|
|
2185
|
+
return dispatcher.request(method, params, options);
|
|
2186
|
+
},
|
|
2187
|
+
notify(method, params) {
|
|
2188
|
+
dispatcher.notify(method, params);
|
|
2189
|
+
},
|
|
2190
|
+
onNotification(method, handler) {
|
|
2191
|
+
return dispatcher.onNotification(method, handler);
|
|
2192
|
+
},
|
|
2193
|
+
onRequest(method, handler) {
|
|
2194
|
+
return dispatcher.onRequest(method, handler);
|
|
2195
|
+
},
|
|
2196
|
+
onClose: (handler) => lifecycle.onClose(handler),
|
|
2197
|
+
close: (graceMs) => lifecycle.close(graceMs),
|
|
2198
|
+
getStderrTail: () => stderr.text
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
/** Start the bridge's child: through the runner for an isolated identity. */
|
|
2202
|
+
function spawnBridgeChild(config, processEnv) {
|
|
2203
|
+
const { command, args, cwd, env, uid, gid, isolatedIdentity } = config;
|
|
2204
|
+
if (isolatedIdentity) {
|
|
2205
|
+
const isolatedLaunch = spawnCliProcess({
|
|
2206
|
+
command,
|
|
2207
|
+
args,
|
|
2208
|
+
cwd,
|
|
2209
|
+
env,
|
|
2210
|
+
isolatedIdentity,
|
|
2211
|
+
spawnOptions: {
|
|
2212
|
+
uid: uid ?? isolatedIdentity.uid,
|
|
2213
|
+
gid: gid ?? isolatedIdentity.gid
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
return {
|
|
2217
|
+
child: isolatedLaunch.process,
|
|
2218
|
+
isolatedLaunch
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
return {
|
|
2222
|
+
child: spawn(command, args, {
|
|
2223
|
+
cwd,
|
|
2224
|
+
env: processEnv,
|
|
2225
|
+
stdio: [
|
|
2226
|
+
"pipe",
|
|
2227
|
+
"pipe",
|
|
2228
|
+
"pipe"
|
|
2229
|
+
],
|
|
2230
|
+
...uid === void 0 ? {} : { uid },
|
|
2231
|
+
...gid === void 0 ? {} : { gid },
|
|
2232
|
+
detached: (config.detached ?? false) && process.platform !== "win32"
|
|
2233
|
+
}),
|
|
2234
|
+
isolatedLaunch: void 0
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
/** The last `maxBytes` characters of a child's stderr. */
|
|
2238
|
+
var StderrTail = class {
|
|
2239
|
+
text = "";
|
|
2240
|
+
constructor(maxBytes) {
|
|
2241
|
+
this.maxBytes = maxBytes;
|
|
2242
|
+
}
|
|
2243
|
+
/** The decoder's final flush is kept whole, past the cap. */
|
|
2244
|
+
appendUntrimmed(chunk) {
|
|
2245
|
+
this.text += chunk;
|
|
2246
|
+
}
|
|
2247
|
+
append(chunk) {
|
|
2248
|
+
this.text += chunk;
|
|
2249
|
+
if (this.text.length > this.maxBytes) this.text = this.text.slice(this.text.length - this.maxBytes);
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
function attachOutputStreams(child, dispatcher, stderr) {
|
|
2253
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
2254
|
+
child.stdout?.on("data", (chunk) => dispatcher.consumeStdoutChunk(stdoutDecoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
|
|
2255
|
+
child.stdout?.on("end", () => {
|
|
2256
|
+
const tail = stdoutDecoder.end();
|
|
2257
|
+
if (tail) dispatcher.consumeStdoutChunk(tail);
|
|
2258
|
+
});
|
|
2259
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
2260
|
+
child.stderr?.on("data", (chunk) => {
|
|
2261
|
+
stderr.append(stderrDecoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
2262
|
+
});
|
|
2263
|
+
child.stderr?.on("end", () => {
|
|
2264
|
+
stderr.appendUntrimmed(stderrDecoder.end());
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/shell-quote.ts
|
|
2269
|
+
function shellQuote(value) {
|
|
2270
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
2271
|
+
}
|
|
2272
|
+
//#endregion
|
|
2273
|
+
export { BridgeClosedError, HELD_SPARE_IDLE_MS, InvalidAttachmentError, JsonRpcError, RAW_CAPTURE_ROOT, RAW_CAPTURE_TOTAL_CAP_BYTES, RpcRequestTimeoutError, allowRetainedRawCapture, argAfter, createDeferredParser, createHeldSpareRegistry, createStreamParser, execCli, launchCliProcess, launchDifferences, mergeSpawnEnv, openRawCapture, removeRawCapture, resolveRawCapturePath, shellQuote, spawnCliProcess, spawnRpcBridge, summarizeRawCapture, trimRawCapture, withRawCaptureAttempt };
|
|
2274
|
+
|
|
2275
|
+
//# sourceMappingURL=index.mjs.map
|