@hydraharness/harness-subprocess-local 0.1.1-rc.6

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/lib/index.js ADDED
@@ -0,0 +1,1337 @@
1
+ import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, unlinkSync, writeSync } from "node:fs";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { delimiter, extname, isAbsolute, join, resolve } from "node:path";
4
+ import * as nodePty from "node-pty";
5
+ import { SubprocessRuntime, scrubbedParentEnv } from "@hydraharness/harness-subprocess";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
8
+ import { constants as constants$1, tmpdir } from "node:os";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
+ import { MAX_TIMER_DELAY_MS } from "@hydraharness/harness-timeout";
11
+ import koffi from "koffi";
12
+ import { Buffer as Buffer$1 } from "node:buffer";
13
+ import { PassThrough } from "node:stream";
14
+ //#region lib/types/windows-inspector.js
15
+ /**
16
+ * Windows process-table operations for terminal readiness, signalling, and
17
+ * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
18
+ * identity and process-handle wait-state liveness, the shell pid as a pseudo
19
+ * process group (Windows has no POSIX groups), and taskkill tree signalling.
20
+ * The koffi bindings load lazily so
21
+ * non-Windows processes never touch Win32 libraries; all decision logic takes
22
+ * an injectable internals boundary so suites can pin it on any host.
23
+ * @module hydra-subprocess-local/windows-inspector
24
+ */
25
+ /**
26
+ * Walk a process table from one root in children-first order, retaining only
27
+ * members whose start identity is readable (unreadable members are detector
28
+ * misses, exactly like an unreadable `/proc` entry on Linux).
29
+ * @param entries - the process table snapshot.
30
+ * @param rootPid - the tree root to descend from.
31
+ * @param started - creation-time identity resolver for one member.
32
+ * @returns the root and its current transitive descendants, children first.
33
+ */
34
+ function windowsProcessTree(entries, rootPid, started) {
35
+ const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
36
+ if (root === void 0) return [];
37
+ const byParent = /* @__PURE__ */ new Map();
38
+ for (const entry of entries) {
39
+ const children = byParent.get(entry.parentPid) ?? [];
40
+ children.push(entry);
41
+ byParent.set(entry.parentPid, children);
42
+ }
43
+ const visited = /* @__PURE__ */ new Set();
44
+ const result = [];
45
+ const visit = (entry) => {
46
+ if (visited.has(entry.pid)) return;
47
+ visited.add(entry.pid);
48
+ for (const child of byParent.get(entry.pid) ?? []) visit(child);
49
+ const identity = started(entry.pid);
50
+ if (identity !== void 0) result.push({
51
+ pid: entry.pid,
52
+ started: identity
53
+ });
54
+ };
55
+ visit(root);
56
+ return result;
57
+ }
58
+ /**
59
+ * Windows {@link ProcessInspector}. The shell pid stands in for a foreground
60
+ * process group: it is a stable pseudo-group that lets the prompt-marker
61
+ * readiness path compare foreground identities, while every actual signal
62
+ * targets the console-wide tree through taskkill (SIGINT is delivered by the
63
+ * terminal handle as a `\x03` input write and never reaches this layer).
64
+ */
65
+ var WindowsProcessInspector = class {
66
+ internals;
67
+ constructor(internals = defaultWindowsProcessInternals()) {
68
+ this.internals = internals;
69
+ }
70
+ foregroundPgid(shellPid) {
71
+ return shellPid;
72
+ }
73
+ isStdinWaiting(_pgid, _shellPid) {
74
+ return false;
75
+ }
76
+ processTree(rootPid) {
77
+ return windowsProcessTree(this.internals.snapshot(), rootPid, (pid) => this.internals.processState(pid)?.started);
78
+ }
79
+ processSession(_sessionId) {
80
+ return [];
81
+ }
82
+ isAlive(identity) {
83
+ const state = this.internals.processState(identity.pid);
84
+ return state?.active === true && state.started === identity.started;
85
+ }
86
+ signalGroup(pgid, signal) {
87
+ this.internals.taskkill(pgid, signal === "SIGKILL");
88
+ }
89
+ signalProcess(identity, signal) {
90
+ if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === "SIGKILL");
91
+ }
92
+ };
93
+ /**
94
+ * Create the Windows process inspector.
95
+ * @param internals - injectable process operations; defaults to the koffi-backed table.
96
+ * @returns the Windows inspector.
97
+ */
98
+ function createWindowsProcessInspector(internals = defaultWindowsProcessInternals()) {
99
+ return new WindowsProcessInspector(internals);
100
+ }
101
+ /** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */
102
+ function taskkillTree(pid, force) {
103
+ if (pid <= 0) return;
104
+ spawnSync("taskkill", [
105
+ "/PID",
106
+ String(pid),
107
+ "/T",
108
+ ...force ? ["/F"] : []
109
+ ], {
110
+ stdio: "ignore",
111
+ windowsHide: true
112
+ });
113
+ }
114
+ /**
115
+ * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
116
+ * @param value - a handle as koffi may hand it back (pointer, null, or 0n).
117
+ * @returns whether the value signals an invalid handle.
118
+ */
119
+ function isInvalidHandle(value) {
120
+ if (value === null || value === void 0) return true;
121
+ const asBigInt = value;
122
+ return asBigInt === 0n || asBigInt === 18446744073709551615n || asBigInt === -1n;
123
+ }
124
+ const PVOID = koffi.pointer("void");
125
+ /**
126
+ * Resolve the koffi Win32 struct types once. Registration is lazy and cached
127
+ * because koffi's type registry is global per process: test runners that
128
+ * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
129
+ * re-register the names.
130
+ */
131
+ function win32Structs() {
132
+ if (cachedStructs !== void 0) return cachedStructs;
133
+ const PROCESSENTRY32W = koffi.struct("PROCESSENTRY32W", {
134
+ dwSize: "uint32",
135
+ cntUsage: "uint32",
136
+ th32ProcessID: "uint32",
137
+ th32DefaultHeapID: PVOID,
138
+ th32ModuleID: "uint32",
139
+ cCntThreads: "uint32",
140
+ th32ParentProcessID: "uint32",
141
+ pcPriClassBase: "int32",
142
+ dwFlags: "uint32",
143
+ szExeFile: koffi.array("char16", 260)
144
+ });
145
+ const FILETIME = koffi.struct("FILETIME", {
146
+ dwLowDateTime: "uint32",
147
+ dwHighDateTime: "uint32"
148
+ });
149
+ /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */
150
+ if (PROCESSENTRY32W.size !== 568) throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`);
151
+ /* v8 ignore stop */
152
+ cachedStructs = {
153
+ PROCESSENTRY32W,
154
+ FILETIME
155
+ };
156
+ return cachedStructs;
157
+ }
158
+ let cachedStructs;
159
+ const TH32CS_SNAPPROCESS = 2;
160
+ const WAIT_OBJECT_0 = 0;
161
+ const WAIT_TIMEOUT = 258;
162
+ let cachedBindings;
163
+ /**
164
+ * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
165
+ * @returns the cached binding table.
166
+ */
167
+ function win32Bindings() {
168
+ if (cachedBindings !== void 0) return cachedBindings;
169
+ const { PROCESSENTRY32W, FILETIME } = win32Structs();
170
+ const kernel32 = koffi.load("kernel32.dll");
171
+ const bind = (name, result, args) => kernel32.func("__stdcall", name, result, args);
172
+ cachedBindings = {
173
+ createToolhelp32Snapshot: bind("CreateToolhelp32Snapshot", PVOID, ["uint32", "uint32"]),
174
+ process32FirstW: bind("Process32FirstW", "int", [PVOID, koffi.pointer(PROCESSENTRY32W)]),
175
+ process32NextW: bind("Process32NextW", "int", [PVOID, koffi.pointer(PROCESSENTRY32W)]),
176
+ openProcess: bind("OpenProcess", PVOID, [
177
+ "uint32",
178
+ "int",
179
+ "uint32"
180
+ ]),
181
+ getProcessTimes: bind("GetProcessTimes", "int", [
182
+ PVOID,
183
+ koffi.pointer(FILETIME),
184
+ koffi.pointer(FILETIME),
185
+ koffi.pointer(FILETIME),
186
+ koffi.pointer(FILETIME)
187
+ ]),
188
+ waitForSingleObject: bind("WaitForSingleObject", "uint32", [PVOID, "uint32"]),
189
+ closeHandle: bind("CloseHandle", "int", [PVOID])
190
+ };
191
+ return cachedBindings;
192
+ }
193
+ /**
194
+ * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are
195
+ * `any`, so the cast goes through `unknown` to keep the unsafe surface here.
196
+ * @param type - the koffi type to allocate.
197
+ * @param count - element count.
198
+ * @returns the branded allocation pointer.
199
+ */
200
+ function allocNative(type, count) {
201
+ return koffi.alloc(type, count);
202
+ }
203
+ /** Enumerate the current process table through Toolhelp32. */
204
+ function snapshotWindowsProcesses(bindings) {
205
+ const { PROCESSENTRY32W } = win32Structs();
206
+ const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
207
+ /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
208
+ the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */
209
+ if (isInvalidHandle(snapshot)) return [];
210
+ const entries = [];
211
+ try {
212
+ const entry = allocNative(PROCESSENTRY32W, 1);
213
+ koffi.encode(entry, "uint32", PROCESSENTRY32W.size);
214
+ let ok = bindings.process32FirstW(snapshot, entry);
215
+ while (ok !== 0) {
216
+ const record = koffi.decode(entry, PROCESSENTRY32W);
217
+ entries.push({
218
+ pid: record.th32ProcessID,
219
+ parentPid: record.th32ParentProcessID
220
+ });
221
+ ok = bindings.process32NextW(snapshot, entry);
222
+ }
223
+ } finally {
224
+ bindings.closeHandle(snapshot);
225
+ }
226
+ return entries;
227
+ }
228
+ /** Read one process's creation identity and current wait state. */
229
+ function windowsProcessState(bindings, pid) {
230
+ const { FILETIME } = win32Structs();
231
+ const handle = bindings.openProcess(1052672, 0, pid);
232
+ if (isInvalidHandle(handle)) return void 0;
233
+ try {
234
+ const creation = allocNative(FILETIME, 1);
235
+ const exit = allocNative(FILETIME, 1);
236
+ const kernel = allocNative(FILETIME, 1);
237
+ const user = allocNative(FILETIME, 1);
238
+ /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and
239
+ cannot be staged deterministically; the absent-process path is covered and the caller
240
+ treats undefined as a detector miss. */
241
+ if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return void 0;
242
+ const record = koffi.decode(creation, FILETIME);
243
+ const wait = bindings.waitForSingleObject(handle, 0);
244
+ /* v8 ignore next -- an opened process handle has exactly one of these two
245
+ zero-time wait states; an unexpected Win32 failure is an unreadable process. */
246
+ if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return void 0;
247
+ return {
248
+ started: `${record.dwHighDateTime}:${record.dwLowDateTime}`,
249
+ active: wait === WAIT_TIMEOUT
250
+ };
251
+ } finally {
252
+ bindings.closeHandle(handle);
253
+ }
254
+ }
255
+ /** The koffi-backed default internals; bindings resolve lazily on first use. */
256
+ function defaultWindowsProcessInternals() {
257
+ return {
258
+ snapshot: () => snapshotWindowsProcesses(win32Bindings()),
259
+ processState: (pid) => windowsProcessState(win32Bindings(), pid),
260
+ taskkill: taskkillTree
261
+ };
262
+ }
263
+ //#endregion
264
+ //#region lib/types/process-inspector.js
265
+ /** Platform process-table inspection for terminal readiness, signals, and teardown. */
266
+ /* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
267
+ const DEFAULT_INTERNALS = {
268
+ readFile: (path) => readFileSync(path, "utf8"),
269
+ readDir: (path) => readdirSync(path),
270
+ readLink: (path) => readlinkSync(path, "utf8"),
271
+ open: (path) => openSync(path, "r"),
272
+ read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
273
+ close: closeSync,
274
+ exec: (file, args) => execFileSync(file, args, { encoding: "utf8" }),
275
+ kill: (pid, signal) => process.kill(pid, signal)
276
+ };
277
+ /**
278
+ * Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
279
+ * @param text - complete stat line.
280
+ * @returns Parsed identity/group fields, or undefined for malformed input.
281
+ */
282
+ function parseProcStat(text) {
283
+ const open = text.indexOf("(");
284
+ const close = text.lastIndexOf(")");
285
+ if (open <= 0 || close <= open) return void 0;
286
+ const pid = Number(text.slice(0, open).trim());
287
+ const rest = text.slice(close + 2).trim().split(/\s+/);
288
+ const state = rest[0] || "";
289
+ const parentPid = Number(rest[1]);
290
+ const pgrp = Number(rest[2]);
291
+ const session = Number(rest[3]);
292
+ const tpgid = Number(rest[5]);
293
+ const started = rest[19];
294
+ if (![
295
+ pid,
296
+ parentPid,
297
+ pgrp,
298
+ session,
299
+ tpgid
300
+ ].every(Number.isSafeInteger) || state.length !== 1 || started === void 0) return void 0;
301
+ return {
302
+ pid,
303
+ parentPid,
304
+ pgrp,
305
+ session,
306
+ state,
307
+ tpgid,
308
+ started
309
+ };
310
+ }
311
+ function readLinuxStat(internals, pid) {
312
+ try {
313
+ return parseProcStat(internals.readFile(`/proc/${pid}/stat`));
314
+ } catch (_unreadableProcEntry) {
315
+ return;
316
+ }
317
+ }
318
+ function readLinuxStdinTarget(internals, pid) {
319
+ try {
320
+ return internals.readLink(`/proc/${pid}/fd/0`);
321
+ } catch (_unreadableStdinTarget) {
322
+ return;
323
+ }
324
+ }
325
+ /**
326
+ * Report whether a Linux process group has an executing member. `false`
327
+ * means the group contains only zombie/dead entries; `undefined` means the
328
+ * process table could not prove either outcome.
329
+ * @param processGroupId - POSIX process-group id to inspect.
330
+ * @param internals - injectable process-table operations.
331
+ * @returns Live-member presence, or `undefined` when unavailable/absent.
332
+ */
333
+ function linuxProcessGroupHasLiveMembers(processGroupId, internals = DEFAULT_INTERNALS) {
334
+ let entries;
335
+ try {
336
+ entries = internals.readDir("/proc");
337
+ } catch (_unreadableProcDirectory) {
338
+ return;
339
+ }
340
+ let matched = false;
341
+ for (const entry of entries) {
342
+ if (!/^\d+$/.test(entry)) continue;
343
+ const stat = readLinuxStat(internals, Number(entry));
344
+ if (stat?.pgrp !== processGroupId) continue;
345
+ matched = true;
346
+ if (!/^[ZXx]$/.test(stat.state)) return true;
347
+ }
348
+ return matched ? false : void 0;
349
+ }
350
+ function numericEntries(internals, path) {
351
+ try {
352
+ return internals.readDir(path).filter((entry) => /^\d+$/.test(entry)).map(Number);
353
+ } catch (_unreadableProcDirectory) {
354
+ return [];
355
+ }
356
+ }
357
+ function readSyscall(internals, pid, tid) {
358
+ try {
359
+ const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim();
360
+ if (text === "running" || text.startsWith("-1 ")) return void 0;
361
+ const fields = text.split(/\s+/);
362
+ const number = Number(fields[0]);
363
+ const args = fields.slice(1, 7).map((field) => Number.parseInt(field, 16));
364
+ if (!Number.isSafeInteger(number) || args.some((value) => !Number.isSafeInteger(value))) return void 0;
365
+ return {
366
+ number,
367
+ args
368
+ };
369
+ } catch (_unreadableSyscall) {
370
+ return;
371
+ }
372
+ }
373
+ function readMemory(internals, pid, address, length) {
374
+ let fd;
375
+ try {
376
+ fd = internals.open(`/proc/${pid}/mem`);
377
+ const buffer = Buffer.alloc(length);
378
+ const count = internals.read(fd, buffer, length, address);
379
+ return buffer.subarray(0, count);
380
+ } catch (_unreadableProcessMemory) {
381
+ return;
382
+ } finally {
383
+ if (fd !== void 0) internals.close(fd);
384
+ }
385
+ }
386
+ function fdSetHasStdin(internals, pid, address) {
387
+ return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1;
388
+ }
389
+ function pollHasStdin(internals, pid, address, count) {
390
+ if (address === 0 || count <= 0) return false;
391
+ const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8);
392
+ if (memory === void 0) return false;
393
+ for (let offset = 0; offset + 8 <= memory.length; offset += 8) if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 1) !== 0) return true;
394
+ return false;
395
+ }
396
+ function epollHasStdin(internals, pid, epfd) {
397
+ try {
398
+ return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
399
+ } catch (_unreadableFdInfo) {
400
+ return false;
401
+ }
402
+ }
403
+ const SYSCALLS = {
404
+ x64: {
405
+ read: 0,
406
+ select: 23,
407
+ pselect: 270,
408
+ poll: 7,
409
+ ppoll: 271,
410
+ epollWait: 232,
411
+ epollPwait: 281
412
+ },
413
+ arm64: {
414
+ read: 63,
415
+ pselect: 72,
416
+ ppoll: 73,
417
+ epollPwait: 22
418
+ }
419
+ };
420
+ function syscallWaitsOnStdin(internals, pid, syscall, table) {
421
+ const [a0 = 0, a1 = 0, a2 = 0] = syscall.args;
422
+ if (syscall.number === table.read) return a0 === 0;
423
+ if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
424
+ if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
425
+ if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, a0);
426
+ return false;
427
+ }
428
+ var PosixProcessInspector = class {
429
+ internals;
430
+ constructor(internals) {
431
+ this.internals = internals;
432
+ }
433
+ signalGroup(pgid, signal) {
434
+ this.internals.kill(-pgid, signal);
435
+ }
436
+ signalProcess(identity, signal) {
437
+ if (this.isAlive(identity)) this.internals.kill(identity.pid, signal);
438
+ }
439
+ };
440
+ function processTree(entries, rootPid) {
441
+ const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
442
+ if (root === void 0) return [];
443
+ const byParent = /* @__PURE__ */ new Map();
444
+ for (const entry of entries) {
445
+ const children = byParent.get(entry.parentPid) ?? [];
446
+ children.push(entry);
447
+ byParent.set(entry.parentPid, children);
448
+ }
449
+ const visited = /* @__PURE__ */ new Set();
450
+ const result = [];
451
+ const visit = (entry) => {
452
+ if (visited.has(entry.pid)) return;
453
+ visited.add(entry.pid);
454
+ for (const child of byParent.get(entry.pid) ?? []) visit(child);
455
+ result.push({
456
+ pid: entry.pid,
457
+ started: entry.started
458
+ });
459
+ };
460
+ visit(root);
461
+ return result;
462
+ }
463
+ var LinuxProcessInspector = class extends PosixProcessInspector {
464
+ arch;
465
+ constructor(arch, internals) {
466
+ super(internals);
467
+ this.arch = arch;
468
+ }
469
+ foregroundPgid(shellPid) {
470
+ const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid;
471
+ return tpgid !== void 0 && tpgid > 0 ? tpgid : void 0;
472
+ }
473
+ isStdinWaiting(pgid, shellPid) {
474
+ const table = SYSCALLS[this.arch];
475
+ if (table === void 0) return false;
476
+ const terminalStdinTarget = readLinuxStdinTarget(this.internals, shellPid);
477
+ if (terminalStdinTarget === void 0) return false;
478
+ for (const pid of numericEntries(this.internals, "/proc")) {
479
+ if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue;
480
+ for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
481
+ const syscall = readSyscall(this.internals, pid, tid);
482
+ if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, syscall, table) && readLinuxStdinTarget(this.internals, pid) === terminalStdinTarget) return true;
483
+ }
484
+ }
485
+ return false;
486
+ }
487
+ processTree(rootPid) {
488
+ return processTree(numericEntries(this.internals, "/proc").flatMap((pid) => {
489
+ const stat = readLinuxStat(this.internals, pid);
490
+ return stat === void 0 ? [] : [{
491
+ pid,
492
+ parentPid: stat.parentPid,
493
+ started: stat.started
494
+ }];
495
+ }), rootPid);
496
+ }
497
+ processSession(sessionId) {
498
+ return numericEntries(this.internals, "/proc").flatMap((pid) => {
499
+ const stat = readLinuxStat(this.internals, pid);
500
+ return stat?.session === sessionId ? [{
501
+ pid,
502
+ started: stat.started
503
+ }] : [];
504
+ });
505
+ }
506
+ isAlive(identity) {
507
+ const stat = readLinuxStat(this.internals, identity.pid);
508
+ return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state);
509
+ }
510
+ };
511
+ function macProcessTable(internals) {
512
+ return internals.exec("/bin/ps", ["-axo", "pid=,ppid=,lstart="]).split("\n").flatMap((line) => {
513
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
514
+ if (match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0) return [];
515
+ return [{
516
+ pid: Number(match[1]),
517
+ parentPid: Number(match[2]),
518
+ started: match[3]
519
+ }];
520
+ });
521
+ }
522
+ var MacProcessInspector = class extends PosixProcessInspector {
523
+ foregroundPgid(shellPid) {
524
+ try {
525
+ const value = Number(this.internals.exec("/bin/ps", [
526
+ "-o",
527
+ "tpgid=",
528
+ "-p",
529
+ String(shellPid)
530
+ ]).trim());
531
+ return Number.isSafeInteger(value) && value > 0 ? value : void 0;
532
+ } catch (_missingProcess) {
533
+ return;
534
+ }
535
+ }
536
+ isStdinWaiting(_pgid, _shellPid) {
537
+ return false;
538
+ }
539
+ processTree(rootPid) {
540
+ return processTree(macProcessTable(this.internals), rootPid);
541
+ }
542
+ processSession(_sessionId) {
543
+ return [];
544
+ }
545
+ isAlive(identity) {
546
+ return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
547
+ }
548
+ };
549
+ /**
550
+ * Create the supported platform inspector or fail at plugin load.
551
+ * @param platform - target Node platform.
552
+ * @param arch - target CPU architecture for Linux syscall numbers.
553
+ * @param internals - filesystem/process boundary, injectable for deterministic tests.
554
+ * @returns Platform process inspector.
555
+ */
556
+ function createProcessInspector(platform = process.platform, arch = process.arch, internals = DEFAULT_INTERNALS) {
557
+ if (platform === "linux") return new LinuxProcessInspector(arch, internals);
558
+ if (platform === "darwin") return new MacProcessInspector(internals);
559
+ if (platform === "win32") return createWindowsProcessInspector();
560
+ throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`);
561
+ }
562
+ //#endregion
563
+ //#region lib/types/spawn.js
564
+ /**
565
+ * Process plumbing for the local subprocess service: detached process-tree
566
+ * spawn with per-stream stdio dispositions, tail-keep collection with spill
567
+ * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
568
+ * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
569
+ * own deadlines, teardown ladders, and cause classification.
570
+ * @module hydra-subprocess-local/spawn
571
+ */
572
+ /**
573
+ * Build a child environment: explicit caller entries override the scrubbed
574
+ * parent base using the target platform's environment-key semantics. A string
575
+ * deliberately restores or overrides an entry; an explicit `undefined`
576
+ * tombstone removes an ordinary ambient entry.
577
+ * @param extra - explicit caller entries and tombstones, merged after the scrub.
578
+ * @returns the environment to hand to `spawn` for the child process.
579
+ */
580
+ function childEnv(extra) {
581
+ const env = scrubbedParentEnv();
582
+ if (process.versions.electron !== void 0 && env.ELECTRON_RUN_AS_NODE === void 0) env.ELECTRON_RUN_AS_NODE = "1";
583
+ if (process.platform !== "win32") return {
584
+ ...env,
585
+ ...extra
586
+ };
587
+ let entries = Object.entries(env);
588
+ for (const [key, value] of Object.entries(extra ?? {})) {
589
+ const normalized = key.toUpperCase();
590
+ entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized);
591
+ entries.push([key, value]);
592
+ }
593
+ return Object.fromEntries(entries);
594
+ }
595
+ /**
596
+ * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
597
+ * awaited teardown must keep the event loop alive until the tree really
598
+ * exits, or the parent can exit while claiming quiescence and orphan the
599
+ * survivors it promised to reap.
600
+ */
601
+ function sleepTick() {
602
+ return setTimeout$1(15);
603
+ }
604
+ let spillCounter = 0;
605
+ let defaultSpillDir;
606
+ /**
607
+ * The default spill location: a private (0700) per-process directory under
608
+ * the OS tmpdir, created lazily. Predictable world-readable paths would let
609
+ * other local users read command output or pre-create symlinks.
610
+ */
611
+ function privateSpillDir() {
612
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "hydra-subprocess-"));
613
+ return defaultSpillDir;
614
+ }
615
+ /**
616
+ * Collects one stream with a bounded in-memory tail. With a spill cap, on
617
+ * first overflow a spill file is created and every chunk (including those
618
+ * already collected) is appended there while the full stream remains within
619
+ * the cap; without one, only the in-memory tail is ever retained (the
620
+ * diagnostic-tail shape — a language server's stderr).
621
+ *
622
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
623
+ * end of command output; the spill file covers the head.
624
+ */
625
+ var OutputCollector = class {
626
+ maxBytes;
627
+ maxSpillBytes;
628
+ label;
629
+ spillDir;
630
+ chunks = [];
631
+ bytes = 0;
632
+ dropped = false;
633
+ spillFd;
634
+ spillFile;
635
+ spillDisabled;
636
+ /** Total bytes ever pushed (not just retained). */
637
+ total = 0;
638
+ constructor(maxBytes, maxSpillBytes, label, spillDir) {
639
+ this.maxBytes = maxBytes;
640
+ this.maxSpillBytes = maxSpillBytes;
641
+ this.label = label;
642
+ this.spillDir = spillDir;
643
+ this.spillDisabled = maxSpillBytes === void 0;
644
+ }
645
+ /**
646
+ * Ingest one stream chunk, counting it toward the whole-stream total. On
647
+ * first overflow of the in-memory cap a spill file is opened (when spilling
648
+ * is enabled) and every chunk (already-collected ones included) is appended
649
+ * there from then on; the in-memory tail then drops whole chunks from its
650
+ * head (or the head of a single over-cap chunk) until it fits the cap again.
651
+ * @param chunk - the raw bytes from one stream 'data' event.
652
+ */
653
+ push(chunk) {
654
+ this.total += chunk.length;
655
+ const overflows = this.bytes + chunk.length > this.maxBytes;
656
+ if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
657
+ this.chunks.push(chunk);
658
+ this.bytes += chunk.length;
659
+ while (this.bytes > this.maxBytes) {
660
+ const head = this.chunks[0];
661
+ const excess = this.bytes - this.maxBytes;
662
+ if (head.length <= excess) {
663
+ this.chunks.shift();
664
+ this.bytes -= head.length;
665
+ } else {
666
+ this.chunks[0] = head.subarray(excess);
667
+ this.bytes -= excess;
668
+ }
669
+ this.dropped = true;
670
+ }
671
+ }
672
+ /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
673
+ spillAll(chunk) {
674
+ if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
675
+ this.discardSpill();
676
+ return;
677
+ }
678
+ if (this.spillFd === void 0) {
679
+ this.spillFile = join(this.spillDir, `hydra-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
680
+ this.spillFd = openSync(this.spillFile, "wx", 384);
681
+ for (const prior of this.chunks) writeSync(this.spillFd, prior);
682
+ }
683
+ writeSync(this.spillFd, chunk);
684
+ }
685
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
686
+ discardSpill() {
687
+ const fd = this.spillFd;
688
+ const file = this.spillFile;
689
+ this.spillFd = void 0;
690
+ this.spillFile = void 0;
691
+ this.spillDisabled = true;
692
+ if (fd !== void 0) try {
693
+ closeSync(fd);
694
+ } catch {
695
+ this.spillFd = fd;
696
+ }
697
+ if (file !== void 0) try {
698
+ unlinkSync(file);
699
+ } catch {}
700
+ }
701
+ /**
702
+ * Incremental read in whole-stream byte coordinates: returns everything
703
+ * pushed since `fromByte`. When `fromByte` has already slid out of the
704
+ * in-memory tail window, the read is `lossy` — it returns the whole
705
+ * retained tail and the gap is only recoverable from the spill file.
706
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
707
+ * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
708
+ */
709
+ readFrom(fromByte) {
710
+ const windowStart = this.total - this.bytes;
711
+ const buffer = Buffer.concat(this.chunks);
712
+ const lossy = fromByte < windowStart;
713
+ return {
714
+ text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
715
+ nextOffset: this.total,
716
+ lossy,
717
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
718
+ };
719
+ }
720
+ /**
721
+ * Close the spill file once the stream has ended. A failed close (delayed
722
+ * writeback fault) stops advertising the spill path — the file may be
723
+ * missing its tail — while every in-memory read keeps working. Idempotent;
724
+ * the spawn path seals both collectors at settlement so reads after exit
725
+ * never point at a still-open file.
726
+ */
727
+ seal() {
728
+ if (this.spillFd === void 0) return;
729
+ try {
730
+ closeSync(this.spillFd);
731
+ } catch {
732
+ this.spillFile = void 0;
733
+ }
734
+ this.spillFd = void 0;
735
+ }
736
+ /**
737
+ * Seal the spill file and return the final output.
738
+ * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
739
+ */
740
+ finalize() {
741
+ this.seal();
742
+ return {
743
+ text: Buffer.concat(this.chunks).toString("utf8"),
744
+ truncated: this.dropped,
745
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
746
+ };
747
+ }
748
+ };
749
+ /**
750
+ * Terminate one Windows process tree with `taskkill /T /F`. Contained like
751
+ * POSIX group signalling — delivery races tree exit, so an absent tree, a
752
+ * nonzero status, or a missing taskkill binary must not break idempotent
753
+ * teardown.
754
+ * @param pid - root process id; non-positive is a no-op.
755
+ */
756
+ function taskkillProcessTree(pid) {
757
+ if (pid <= 0) return;
758
+ spawnSync("taskkill", [
759
+ "/PID",
760
+ String(pid),
761
+ "/T",
762
+ "/F"
763
+ ], {
764
+ stdio: "ignore",
765
+ windowsHide: true
766
+ });
767
+ }
768
+ /**
769
+ * Signal a detached process tree with platform-correct semantics: POSIX
770
+ * signals the negative process-group id and falls back to the direct child
771
+ * when the group is gone; Windows terminates the tree via taskkill (any
772
+ * signal value force-terminates — Node maps signals to TerminateProcess).
773
+ */
774
+ function signalTree(platform, pid, sig, child, taskkill) {
775
+ if (platform === "win32") {
776
+ taskkill(pid);
777
+ return;
778
+ }
779
+ /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
780
+ if (pid <= 0) return;
781
+ try {
782
+ process.kill(-pid, sig);
783
+ } catch {
784
+ /* v8 ignore start -- the fallback needs a live child whose group signal fails
785
+ (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
786
+ try {
787
+ child.kill(sig);
788
+ } catch {}
789
+ }
790
+ }
791
+ /**
792
+ * Spawn one isolated detached process tree with the spec's per-stream stdio
793
+ * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome};
794
+ * only spawn failures reject.
795
+ * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
796
+ * @param internals - test-only spill-directory, platform, and taskkill overrides.
797
+ * @returns live subprocess handle.
798
+ * @throws when `graceMs` cannot be represented by one Node timer.
799
+ */
800
+ function spawnSubprocess(spec, internals = {}) {
801
+ if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
802
+ const spillDir = internals.spillDir ?? privateSpillDir();
803
+ const platform = internals.platform ?? process.platform;
804
+ const taskkill = internals.taskkill ?? taskkillProcessTree;
805
+ const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers;
806
+ if (spec.signal?.aborted) throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? "aborted")}`);
807
+ const [program, ...args] = spec.argv;
808
+ if (program === void 0 || program.length === 0) throw new Error("invalid argv: expected a non-empty program name at argv[0]");
809
+ const isCollect = (mode) => mode !== "pipe" && mode !== "inherit";
810
+ const outMode = spec.stdio.stdout;
811
+ const errMode = spec.stdio.stderr;
812
+ const stdinMode = spec.stdio.stdin;
813
+ const env = childEnv(spec.env);
814
+ const child = spawn(program, args, {
815
+ cwd: spec.cwd,
816
+ env,
817
+ stdio: [
818
+ stdinMode === "ignore" ? "ignore" : "pipe",
819
+ outMode === "inherit" ? "inherit" : "pipe",
820
+ errMode === "inherit" ? "inherit" : "pipe"
821
+ ],
822
+ detached: platform !== "win32",
823
+ windowsHide: true
824
+ });
825
+ const collectStream = (mode, stream, label) => {
826
+ if (!isCollect(mode) || stream === null) return void 0;
827
+ const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir);
828
+ stream.on("data", (chunk) => {
829
+ collector.push(chunk);
830
+ });
831
+ return collector;
832
+ };
833
+ const stdoutCollector = collectStream(outMode, child.stdout, "stdout");
834
+ const stderrCollector = collectStream(errMode, child.stderr, "stderr");
835
+ let graceTimer;
836
+ let treeExitObserved = false;
837
+ let treeExitObservation;
838
+ let settled = false;
839
+ const pid = child.pid ?? -1;
840
+ /** Whether the detached tree's root (or POSIX group) is still alive. */
841
+ const treeAlive = () => {
842
+ /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
843
+ the guard is the final defense against probing an id after its tree was confirmed absent. */
844
+ if (treeExitObserved) return false;
845
+ if (pid <= 0) return false;
846
+ if (platform === "win32") return child.exitCode === null && child.signalCode === null;
847
+ try {
848
+ process.kill(-pid, 0);
849
+ if (settled && platform === "linux" && linuxGroupHasLiveMembers(pid) === false) return false;
850
+ return true;
851
+ } catch (error) {
852
+ const code = error.code;
853
+ /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing
854
+ makes observing the other arm platform-dependent. */
855
+ if (code === "ESRCH") return false;
856
+ /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
857
+ tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
858
+ if (code === "EPERM") return true;
859
+ return child.exitCode === null && child.signalCode === null;
860
+ }
861
+ };
862
+ /**
863
+ * Start or reuse the handle's single whole-tree exit observer. The first
864
+ * confirmed absence is a permanent no-more-signals boundary: it cancels a
865
+ * pending escalation before this process-group id can be reused.
866
+ */
867
+ const observeTreeExit = () => {
868
+ treeExitObservation ??= (async () => {
869
+ while (treeAlive()) await sleepTick();
870
+ treeExitObserved = true;
871
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
872
+ graceTimer = void 0;
873
+ })();
874
+ return treeExitObservation;
875
+ };
876
+ const kill = (sig) => {
877
+ /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
878
+ this remains the timer/death race guard and cannot be staged deterministically. */
879
+ if (!treeAlive()) return;
880
+ signalTree(platform, pid, sig, child, taskkill);
881
+ };
882
+ const terminate = () => {
883
+ if (treeExitObserved || graceTimer !== void 0) return;
884
+ observeTreeExit();
885
+ if (treeExitObserved) return;
886
+ kill("SIGTERM");
887
+ graceTimer = setTimeout(() => {
888
+ kill("SIGKILL");
889
+ }, spec.graceMs);
890
+ };
891
+ const terminateForHostExit = () => {
892
+ kill("SIGKILL");
893
+ };
894
+ const onAbort = () => {
895
+ terminate();
896
+ };
897
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
898
+ if (typeof stdinMode === "object" && child.stdin !== null) {
899
+ child.stdin.on("error", () => {});
900
+ child.stdin.end(stdinMode.data);
901
+ }
902
+ const done = new Promise((resolve, reject) => {
903
+ let pipeDrainTimer;
904
+ const settle = (exitCode, signal) => {
905
+ if (settled) return;
906
+ settled = true;
907
+ if (stdoutCollector !== void 0) child.stdout?.destroy();
908
+ if (stderrCollector !== void 0) child.stderr?.destroy();
909
+ stdoutCollector?.seal();
910
+ stderrCollector?.seal();
911
+ cleanup();
912
+ resolve({
913
+ exitCode,
914
+ signal
915
+ });
916
+ };
917
+ child.on("error", (error) => {
918
+ settled = true;
919
+ cleanup();
920
+ reject(error);
921
+ });
922
+ child.on("exit", (exitCode, signal) => {
923
+ pipeDrainTimer = setTimeout(() => {
924
+ settle(exitCode, signal);
925
+ }, spec.graceMs);
926
+ });
927
+ child.on("close", settle);
928
+ function cleanup() {
929
+ if (pipeDrainTimer !== void 0) clearTimeout(pipeDrainTimer);
930
+ spec.signal?.removeEventListener("abort", onAbort);
931
+ }
932
+ });
933
+ const waitForExit = async (signal) => {
934
+ const observed = observeTreeExit();
935
+ if (treeExitObserved) return true;
936
+ if (signal?.aborted) return false;
937
+ if (signal === void 0) {
938
+ await observed;
939
+ return true;
940
+ }
941
+ const aborted = Promise.withResolvers();
942
+ const onAbort = () => {
943
+ aborted.resolve(false);
944
+ };
945
+ signal.addEventListener("abort", onAbort, { once: true });
946
+ /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
947
+ if (signal.aborted) onAbort();
948
+ try {
949
+ return await Promise.race([observed.then(() => true), aborted.promise]);
950
+ } finally {
951
+ signal.removeEventListener("abort", onAbort);
952
+ }
953
+ };
954
+ return {
955
+ pid,
956
+ /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
957
+ stdin: stdinMode === "pipe" ? child.stdin ?? void 0 : void 0,
958
+ stdout: outMode === "pipe" ? child.stdout ?? void 0 : void 0,
959
+ stderr: errMode === "pipe" ? child.stderr ?? void 0 : void 0,
960
+ /* v8 ignore stop */
961
+ collected: {
962
+ ...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
963
+ ...stderrCollector !== void 0 ? { stderr: stderrCollector } : {}
964
+ },
965
+ done,
966
+ terminate,
967
+ terminateForHostExit,
968
+ waitForExit
969
+ };
970
+ }
971
+ //#endregion
972
+ //#region lib/types/terminal.js
973
+ /** Local node-pty terminal-process implementation for the subprocess seam. */
974
+ function delay(ms) {
975
+ return new Promise((resolve) => setTimeout(resolve, ms));
976
+ }
977
+ function signalName(number) {
978
+ if (number === void 0 || number === 0) return null;
979
+ for (const [name, value] of Object.entries(constants$1.signals)) if (value === number) return name;
980
+ return null;
981
+ }
982
+ /**
983
+ * A local terminal whose process-session ownership stays below the PTY backend.
984
+ * The seam's terminate() promise — no write, inspection, or signal in flight
985
+ * after settlement — holds here without operation tracking only because every
986
+ * handle call completes synchronously under the hood (node-pty write, ps-based
987
+ * inspection). A first genuinely asynchronous step in any handle call must add
988
+ * the tracking a remote provider needs.
989
+ */
990
+ var LocalTerminalHandle = class {
991
+ terminal;
992
+ inspector;
993
+ graceMs;
994
+ platform;
995
+ pid;
996
+ output = new PassThrough();
997
+ done;
998
+ outcome = Promise.withResolvers();
999
+ dataDisposable;
1000
+ exitDisposable;
1001
+ cleanup;
1002
+ exited = false;
1003
+ trackedDescendants = [];
1004
+ /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
1005
+ rootIdentity;
1006
+ /**
1007
+ * @param terminal - allocated node-pty process.
1008
+ * @param inspector - platform process/session operations.
1009
+ * @param graceMs - TERM-to-KILL and exit-wait grace.
1010
+ * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
1011
+ */
1012
+ constructor(terminal, inspector, graceMs, platform = process.platform) {
1013
+ this.terminal = terminal;
1014
+ this.inspector = inspector;
1015
+ this.graceMs = graceMs;
1016
+ this.platform = platform;
1017
+ this.pid = terminal.pid;
1018
+ this.rootIdentity = inspector.processTree(this.pid).find((member) => member.pid === this.pid);
1019
+ this.done = this.outcome.promise;
1020
+ this.dataDisposable = terminal.onData((data) => {
1021
+ this.output.write(Buffer$1.from(data, "utf8"));
1022
+ });
1023
+ this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
1024
+ if (this.exited) return;
1025
+ this.exited = true;
1026
+ this.output.end();
1027
+ this.outcome.resolve({
1028
+ exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
1029
+ signal: signalName(exitSignal)
1030
+ });
1031
+ });
1032
+ }
1033
+ async write(data) {
1034
+ if (this.exited) throw new Error("terminal process has exited");
1035
+ this.terminal.write(data);
1036
+ }
1037
+ async inspectForeground() {
1038
+ this.descendants();
1039
+ const processGroupId = this.inspector.foregroundPgid(this.pid);
1040
+ if (processGroupId === void 0) return void 0;
1041
+ return {
1042
+ processGroupId,
1043
+ inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
1044
+ };
1045
+ }
1046
+ async signalForeground(signal) {
1047
+ const foreground = await this.inspectForeground();
1048
+ if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
1049
+ if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
1050
+ if (this.platform === "win32") {
1051
+ if (signal === "SIGINT") {
1052
+ this.terminal.write("");
1053
+ return foreground.processGroupId;
1054
+ }
1055
+ if (signal === "SIGTSTP" || signal === "SIGHUP") throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`);
1056
+ }
1057
+ this.inspector.signalGroup(foreground.processGroupId, signal);
1058
+ return foreground.processGroupId;
1059
+ }
1060
+ terminate() {
1061
+ if (this.cleanup !== void 0) return this.cleanup;
1062
+ const cleanup = this.closeOnce();
1063
+ this.cleanup = cleanup;
1064
+ cleanup.catch(() => {
1065
+ this.cleanup = void 0;
1066
+ });
1067
+ return cleanup;
1068
+ }
1069
+ /**
1070
+ * Force-terminate the observable session synchronously during Node's exit
1071
+ * event. This does not claim quiescence and does not replace terminate().
1072
+ */
1073
+ terminateForHostExit() {
1074
+ this.forceStopDescendants();
1075
+ this.forceStopShell();
1076
+ this.forceStopDescendants();
1077
+ }
1078
+ forceStopShell() {
1079
+ if (this.exited) return;
1080
+ if (this.rootIdentity !== void 0) {
1081
+ try {
1082
+ this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
1083
+ } catch (_rootExitedDuringHostExit) {}
1084
+ return;
1085
+ }
1086
+ try {
1087
+ this.terminal.kill("SIGKILL");
1088
+ } catch (_unidentifiedShellExitedDuringHostExit) {}
1089
+ }
1090
+ survivors(members) {
1091
+ return members.filter((member) => this.inspector.isAlive(member));
1092
+ }
1093
+ descendants() {
1094
+ const tree = this.inspector.processTree(this.pid);
1095
+ const root = tree.find((member) => member.pid === this.pid);
1096
+ const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
1097
+ this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : []).filter((member) => member.pid !== this.pid));
1098
+ return this.trackedDescendants;
1099
+ }
1100
+ async waitForMembers(members) {
1101
+ const until = Date.now() + this.graceMs;
1102
+ let survivors = this.survivors(members);
1103
+ while (survivors.length > 0 && Date.now() < until) {
1104
+ await delay(Math.min(25, Math.max(1, until - Date.now())));
1105
+ survivors = this.survivors(members);
1106
+ }
1107
+ return survivors;
1108
+ }
1109
+ signalMembers(members, signal) {
1110
+ for (const member of members) try {
1111
+ this.inspector.signalProcess(member, signal);
1112
+ } catch (_alreadyExitedDuringSignal) {}
1113
+ }
1114
+ forceStopDescendants() {
1115
+ let members = this.trackedDescendants;
1116
+ try {
1117
+ members = this.descendants();
1118
+ } catch (_processTableUnavailableDuringHostExit) {}
1119
+ this.signalMembers(members, "SIGKILL");
1120
+ }
1121
+ unionMembers(...groups) {
1122
+ const members = [];
1123
+ const seen = /* @__PURE__ */ new Set();
1124
+ for (const group of groups) for (const member of group) {
1125
+ const key = `${member.pid}:${member.started}`;
1126
+ if (seen.has(key)) continue;
1127
+ seen.add(key);
1128
+ members.push(member);
1129
+ }
1130
+ return members;
1131
+ }
1132
+ async stopDescendants() {
1133
+ const captured = this.descendants();
1134
+ this.signalMembers(captured, "SIGTERM");
1135
+ const capturedSurvivors = await this.waitForMembers(captured);
1136
+ const members = this.unionMembers(capturedSurvivors, this.descendants());
1137
+ this.signalMembers(members, "SIGKILL");
1138
+ const survivors = await this.waitForMembers(members);
1139
+ return this.survivors(this.unionMembers(survivors, this.descendants()));
1140
+ }
1141
+ async stopShell() {
1142
+ if (this.platform === "win32") {
1143
+ await this.stopShellWindows();
1144
+ return;
1145
+ }
1146
+ if (!this.exited) {
1147
+ try {
1148
+ this.terminal.kill("SIGTERM");
1149
+ } catch (_topLevelAlreadyExitedDuringTerm) {}
1150
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
1151
+ }
1152
+ if (!this.exited) {
1153
+ try {
1154
+ this.terminal.kill("SIGKILL");
1155
+ } catch (_topLevelAlreadyExitedDuringKill) {}
1156
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
1157
+ }
1158
+ if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
1159
+ }
1160
+ async stopShellWindows() {
1161
+ const shellGone = () => this.exited || this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity);
1162
+ if (!shellGone() && this.rootIdentity !== void 0) {
1163
+ this.inspector.signalProcess(this.rootIdentity, "SIGTERM");
1164
+ await this.waitForWindowsShellExit();
1165
+ }
1166
+ if (!shellGone() && this.rootIdentity === void 0) {
1167
+ try {
1168
+ this.terminal.kill();
1169
+ } catch (_topLevelAlreadyExitedDuringKill) {}
1170
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
1171
+ }
1172
+ if (!shellGone() && this.rootIdentity !== void 0) {
1173
+ this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
1174
+ await this.waitForWindowsShellExit();
1175
+ }
1176
+ if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
1177
+ }
1178
+ async waitForWindowsShellExit() {
1179
+ const until = Date.now() + this.graceMs;
1180
+ while (!this.exited && Date.now() < until) {
1181
+ if (this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity)) return;
1182
+ await delay(Math.min(25, Math.max(1, until - Date.now())));
1183
+ }
1184
+ }
1185
+ async closeOnce() {
1186
+ let survivors = await this.stopDescendants();
1187
+ if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
1188
+ await this.stopShell();
1189
+ survivors = await this.stopDescendants();
1190
+ if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
1191
+ this.settleExitIfGone();
1192
+ this.dataDisposable.dispose();
1193
+ this.exitDisposable.dispose();
1194
+ }
1195
+ settleExitIfGone() {
1196
+ if (this.platform !== "win32") return;
1197
+ if (this.exited) return;
1198
+ /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
1199
+ the identity re-check is a defensive fence for a future caller. */
1200
+ if (this.rootIdentity !== void 0 && this.inspector.isAlive(this.rootIdentity)) return;
1201
+ this.exited = true;
1202
+ this.output.end();
1203
+ this.outcome.resolve({
1204
+ exitCode: null,
1205
+ signal: null
1206
+ });
1207
+ }
1208
+ };
1209
+ //#endregion
1210
+ //#region lib/types/index.js
1211
+ /**
1212
+ * Local Service Provider for the subprocess capability seam. Each spawn is a detached
1213
+ * process tree with the spec's per-stream stdio dispositions. Normal disposal
1214
+ * terminates and joins live trees; Node's synchronous exit phase force-stops
1215
+ * any trees the service still owns. It has no config: every disposition and
1216
+ * limit arrives on the spec, so the deployment-varying choices stay with the
1217
+ * caller's config (the bash executor's, the LSP host's, …).
1218
+ * @module @hydraharness/harness-subprocess-local
1219
+ */
1220
+ /**
1221
+ * Local subprocess service: detached process trees, Node-shaped stdio
1222
+ * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
1223
+ * files), credential-scrubbed environment, and tree-scoped signalling with
1224
+ * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during
1225
+ * JavaScript-observable host exit.
1226
+ */
1227
+ var LocalSubprocessRuntime = class extends SubprocessRuntime {
1228
+ /** Live handles retained for normal disposal and synchronous host-exit finalization. */
1229
+ live = /* @__PURE__ */ new Set();
1230
+ /** Live terminals retained through normal quiescence or host-exit finalization. */
1231
+ terminals = /* @__PURE__ */ new Set();
1232
+ /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
1233
+ internals = {};
1234
+ /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
1235
+ terminalInspector;
1236
+ constructor(ctx) {
1237
+ super(ctx);
1238
+ ctx.effect(() => {
1239
+ const onHostExit = () => {
1240
+ this.terminateForHostExit();
1241
+ };
1242
+ process.prependListener("exit", onHostExit);
1243
+ return async () => {
1244
+ try {
1245
+ await this.disposeManagedProcesses();
1246
+ } finally {
1247
+ process.off("exit", onHostExit);
1248
+ }
1249
+ };
1250
+ }, "local subprocess teardown");
1251
+ }
1252
+ terminateForHostExit() {
1253
+ for (const handle of this.live) try {
1254
+ handle.terminateForHostExit();
1255
+ } catch (_ordinaryTreeTerminationFailed) {}
1256
+ for (const terminal of this.terminals) try {
1257
+ terminal.terminateForHostExit();
1258
+ } catch (_terminalTerminationFailed) {}
1259
+ }
1260
+ async disposeManagedProcesses() {
1261
+ const pending = [];
1262
+ for (const handle of this.live) {
1263
+ handle.terminate();
1264
+ pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()));
1265
+ }
1266
+ for (const terminal of this.terminals) pending.push(terminal.terminate());
1267
+ const failures = (await Promise.allSettled(pending)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
1268
+ if (failures.length > 0) this.terminateForHostExit();
1269
+ this.live.clear();
1270
+ this.terminals.clear();
1271
+ if (failures.length === 1) throw failures[0];
1272
+ if (failures.length > 1) throw new AggregateError(failures, "local subprocess teardown failed");
1273
+ }
1274
+ async resolveExecutable(command, env, signal) {
1275
+ if (command.length === 0) throw new Error("subprocess-local: executable must be non-empty");
1276
+ signal?.throwIfAborted();
1277
+ const environment = childEnv(env);
1278
+ const absolute = isAbsolute(command);
1279
+ if (!absolute && (command.includes("/") || process.platform === "win32" && command.includes("\\"))) throw new Error(`subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`);
1280
+ const candidates = absolute ? [command] : this.executableCandidates(command, environment);
1281
+ for (const candidate of candidates) {
1282
+ signal?.throwIfAborted();
1283
+ try {
1284
+ if (!(await stat(candidate)).isFile()) continue;
1285
+ await access(candidate, constants.X_OK);
1286
+ signal?.throwIfAborted();
1287
+ return candidate;
1288
+ } catch {}
1289
+ }
1290
+ signal?.throwIfAborted();
1291
+ throw new Error(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`);
1292
+ }
1293
+ executableCandidates(command, env) {
1294
+ const path = environmentValue(env, "PATH") ?? "";
1295
+ const extensions = process.platform === "win32" && extname(command) === "" ? (environmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
1296
+ return path.split(delimiter).flatMap((directory) => extensions.map((extension) => resolve(process.cwd(), directory, command + extension)));
1297
+ }
1298
+ spawn(spec) {
1299
+ const handle = spawnSubprocess(spec, this.internals);
1300
+ this.live.add(handle);
1301
+ const release = () => handle.waitForExit().then(() => {
1302
+ this.live.delete(handle);
1303
+ });
1304
+ handle.done.then(release, release);
1305
+ return handle;
1306
+ }
1307
+ async spawnTerminal(spec) {
1308
+ const file = spec.argv[0];
1309
+ if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
1310
+ spec.signal?.throwIfAborted();
1311
+ const options = {
1312
+ name: "dumb",
1313
+ rows: spec.rows,
1314
+ cols: spec.cols,
1315
+ cwd: spec.cwd,
1316
+ env: childEnv(spec.env)
1317
+ };
1318
+ const inspector = this.terminalInspector ?? createProcessInspector();
1319
+ const handle = new LocalTerminalHandle(nodePty.spawn(file, [...spec.argv.slice(1)], options), inspector, spec.graceMs);
1320
+ this.terminals.add(handle);
1321
+ const release = async () => {
1322
+ await handle.terminate();
1323
+ this.terminals.delete(handle);
1324
+ };
1325
+ handle.done.then(release, release).catch(() => {});
1326
+ return handle;
1327
+ }
1328
+ };
1329
+ /** Read a Windows environment key using the platform's case-insensitive semantics. */
1330
+ function environmentValue(env, name) {
1331
+ const exact = env[name];
1332
+ if (exact !== void 0 || process.platform !== "win32") return exact;
1333
+ const normalized = name.toUpperCase();
1334
+ return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1];
1335
+ }
1336
+ //#endregion
1337
+ export { LocalSubprocessRuntime, LocalSubprocessRuntime as default };