@deepseek-ai/dsh-subprocess-local 0.0.1-rc.1
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 +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +33 -0
- package/README.zh.md +33 -0
- package/lib/index.js +961 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/process-inspector.d.ts +64 -0
- package/lib/types/spawn.d.ts +121 -0
- package/lib/types/terminal.d.ts +48 -0
- package/package.json +53 -0
- package/scripts/ensure-spawn-helper.mjs +16 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, 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 { SubprocessService, scrubbedParentEnv } from "@deepseek-ai/dsh-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 "@deepseek-ai/dsh-timeout";
|
|
11
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
12
|
+
import { PassThrough } from "node:stream";
|
|
13
|
+
//#region lib/types/process-inspector.js
|
|
14
|
+
/** Platform process-table inspection for terminal readiness, signals, and teardown. */
|
|
15
|
+
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
|
|
16
|
+
const DEFAULT_INTERNALS = {
|
|
17
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
18
|
+
readDir: (path) => readdirSync(path),
|
|
19
|
+
open: (path) => openSync(path, "r"),
|
|
20
|
+
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
|
|
21
|
+
close: closeSync,
|
|
22
|
+
exec: (file, args) => execFileSync(file, args, { encoding: "utf8" }),
|
|
23
|
+
kill: (pid, signal) => process.kill(pid, signal)
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
|
|
27
|
+
* @param text - complete stat line.
|
|
28
|
+
* @returns Parsed identity/group fields, or undefined for malformed input.
|
|
29
|
+
*/
|
|
30
|
+
function parseProcStat(text) {
|
|
31
|
+
const open = text.indexOf("(");
|
|
32
|
+
const close = text.lastIndexOf(")");
|
|
33
|
+
if (open <= 0 || close <= open) return void 0;
|
|
34
|
+
const pid = Number(text.slice(0, open).trim());
|
|
35
|
+
const rest = text.slice(close + 2).trim().split(/\s+/);
|
|
36
|
+
const state = rest[0] || "";
|
|
37
|
+
const parentPid = Number(rest[1]);
|
|
38
|
+
const pgrp = Number(rest[2]);
|
|
39
|
+
const session = Number(rest[3]);
|
|
40
|
+
const tpgid = Number(rest[5]);
|
|
41
|
+
const started = rest[19];
|
|
42
|
+
if (![
|
|
43
|
+
pid,
|
|
44
|
+
parentPid,
|
|
45
|
+
pgrp,
|
|
46
|
+
session,
|
|
47
|
+
tpgid
|
|
48
|
+
].every(Number.isSafeInteger) || state.length !== 1 || started === void 0) return void 0;
|
|
49
|
+
return {
|
|
50
|
+
pid,
|
|
51
|
+
parentPid,
|
|
52
|
+
pgrp,
|
|
53
|
+
session,
|
|
54
|
+
state,
|
|
55
|
+
tpgid,
|
|
56
|
+
started
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function readLinuxStat(internals, pid) {
|
|
60
|
+
try {
|
|
61
|
+
return parseProcStat(internals.readFile(`/proc/${pid}/stat`));
|
|
62
|
+
} catch (_unreadableProcEntry) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Report whether a Linux process group has an executing member. `false`
|
|
68
|
+
* means the group contains only zombie/dead entries; `undefined` means the
|
|
69
|
+
* process table could not prove either outcome.
|
|
70
|
+
* @param processGroupId - POSIX process-group id to inspect.
|
|
71
|
+
* @param internals - injectable process-table operations.
|
|
72
|
+
* @returns Live-member presence, or `undefined` when unavailable/absent.
|
|
73
|
+
*/
|
|
74
|
+
function linuxProcessGroupHasLiveMembers(processGroupId, internals = DEFAULT_INTERNALS) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = internals.readDir("/proc");
|
|
78
|
+
} catch (_unreadableProcDirectory) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let matched = false;
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
84
|
+
const stat = readLinuxStat(internals, Number(entry));
|
|
85
|
+
if (stat?.pgrp !== processGroupId) continue;
|
|
86
|
+
matched = true;
|
|
87
|
+
if (!/^[ZXx]$/.test(stat.state)) return true;
|
|
88
|
+
}
|
|
89
|
+
return matched ? false : void 0;
|
|
90
|
+
}
|
|
91
|
+
function numericEntries(internals, path) {
|
|
92
|
+
try {
|
|
93
|
+
return internals.readDir(path).filter((entry) => /^\d+$/.test(entry)).map(Number);
|
|
94
|
+
} catch (_unreadableProcDirectory) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function readSyscall(internals, pid, tid) {
|
|
99
|
+
try {
|
|
100
|
+
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim();
|
|
101
|
+
if (text === "running" || text.startsWith("-1 ")) return void 0;
|
|
102
|
+
const fields = text.split(/\s+/);
|
|
103
|
+
const number = Number(fields[0]);
|
|
104
|
+
const args = fields.slice(1, 7).map((field) => Number.parseInt(field, 16));
|
|
105
|
+
if (!Number.isSafeInteger(number) || args.some((value) => !Number.isSafeInteger(value))) return void 0;
|
|
106
|
+
return {
|
|
107
|
+
number,
|
|
108
|
+
args
|
|
109
|
+
};
|
|
110
|
+
} catch (_unreadableSyscall) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function readMemory(internals, pid, address, length) {
|
|
115
|
+
let fd;
|
|
116
|
+
try {
|
|
117
|
+
fd = internals.open(`/proc/${pid}/mem`);
|
|
118
|
+
const buffer = Buffer.alloc(length);
|
|
119
|
+
const count = internals.read(fd, buffer, length, address);
|
|
120
|
+
return buffer.subarray(0, count);
|
|
121
|
+
} catch (_unreadableProcessMemory) {
|
|
122
|
+
return;
|
|
123
|
+
} finally {
|
|
124
|
+
if (fd !== void 0) internals.close(fd);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function fdSetHasStdin(internals, pid, address) {
|
|
128
|
+
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1;
|
|
129
|
+
}
|
|
130
|
+
function pollHasStdin(internals, pid, address, count) {
|
|
131
|
+
if (address === 0 || count <= 0) return false;
|
|
132
|
+
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8);
|
|
133
|
+
if (memory === void 0) return false;
|
|
134
|
+
for (let offset = 0; offset + 8 <= memory.length; offset += 8) if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 1) !== 0) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
function epollHasStdin(internals, pid, epfd) {
|
|
138
|
+
try {
|
|
139
|
+
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
|
|
140
|
+
} catch (_unreadableFdInfo) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const SYSCALLS = {
|
|
145
|
+
x64: {
|
|
146
|
+
read: 0,
|
|
147
|
+
select: 23,
|
|
148
|
+
pselect: 270,
|
|
149
|
+
poll: 7,
|
|
150
|
+
ppoll: 271,
|
|
151
|
+
epollWait: 232,
|
|
152
|
+
epollPwait: 281
|
|
153
|
+
},
|
|
154
|
+
arm64: {
|
|
155
|
+
read: 63,
|
|
156
|
+
pselect: 72,
|
|
157
|
+
ppoll: 73,
|
|
158
|
+
epollPwait: 22
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function syscallWaitsOnStdin(internals, pid, syscall, table) {
|
|
162
|
+
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args;
|
|
163
|
+
if (syscall.number === table.read) return a0 === 0;
|
|
164
|
+
if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
|
|
165
|
+
if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
|
|
166
|
+
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, a0);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
var PosixProcessInspector = class {
|
|
170
|
+
internals;
|
|
171
|
+
constructor(internals) {
|
|
172
|
+
this.internals = internals;
|
|
173
|
+
}
|
|
174
|
+
signalGroup(pgid, signal) {
|
|
175
|
+
this.internals.kill(-pgid, signal);
|
|
176
|
+
}
|
|
177
|
+
signalProcess(identity, signal) {
|
|
178
|
+
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
function processTree(entries, rootPid) {
|
|
182
|
+
const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
|
|
183
|
+
if (root === void 0) return [];
|
|
184
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
const children = byParent.get(entry.parentPid) ?? [];
|
|
187
|
+
children.push(entry);
|
|
188
|
+
byParent.set(entry.parentPid, children);
|
|
189
|
+
}
|
|
190
|
+
const visited = /* @__PURE__ */ new Set();
|
|
191
|
+
const result = [];
|
|
192
|
+
const visit = (entry) => {
|
|
193
|
+
if (visited.has(entry.pid)) return;
|
|
194
|
+
visited.add(entry.pid);
|
|
195
|
+
for (const child of byParent.get(entry.pid) ?? []) visit(child);
|
|
196
|
+
result.push({
|
|
197
|
+
pid: entry.pid,
|
|
198
|
+
started: entry.started
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
visit(root);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
var LinuxProcessInspector = class extends PosixProcessInspector {
|
|
205
|
+
arch;
|
|
206
|
+
constructor(arch, internals) {
|
|
207
|
+
super(internals);
|
|
208
|
+
this.arch = arch;
|
|
209
|
+
}
|
|
210
|
+
foregroundPgid(shellPid) {
|
|
211
|
+
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid;
|
|
212
|
+
return tpgid !== void 0 && tpgid > 0 ? tpgid : void 0;
|
|
213
|
+
}
|
|
214
|
+
isStdinWaiting(pgid) {
|
|
215
|
+
const table = SYSCALLS[this.arch];
|
|
216
|
+
if (table === void 0) return false;
|
|
217
|
+
for (const pid of numericEntries(this.internals, "/proc")) {
|
|
218
|
+
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue;
|
|
219
|
+
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
|
|
220
|
+
const syscall = readSyscall(this.internals, pid, tid);
|
|
221
|
+
if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
processTree(rootPid) {
|
|
227
|
+
return processTree(numericEntries(this.internals, "/proc").flatMap((pid) => {
|
|
228
|
+
const stat = readLinuxStat(this.internals, pid);
|
|
229
|
+
return stat === void 0 ? [] : [{
|
|
230
|
+
pid,
|
|
231
|
+
parentPid: stat.parentPid,
|
|
232
|
+
started: stat.started
|
|
233
|
+
}];
|
|
234
|
+
}), rootPid);
|
|
235
|
+
}
|
|
236
|
+
processSession(sessionId) {
|
|
237
|
+
return numericEntries(this.internals, "/proc").flatMap((pid) => {
|
|
238
|
+
const stat = readLinuxStat(this.internals, pid);
|
|
239
|
+
return stat?.session === sessionId ? [{
|
|
240
|
+
pid,
|
|
241
|
+
started: stat.started
|
|
242
|
+
}] : [];
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
isAlive(identity) {
|
|
246
|
+
const stat = readLinuxStat(this.internals, identity.pid);
|
|
247
|
+
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
function macProcessTable(internals) {
|
|
251
|
+
return internals.exec("/bin/ps", ["-axo", "pid=,ppid=,lstart="]).split("\n").flatMap((line) => {
|
|
252
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
|
|
253
|
+
if (match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0) return [];
|
|
254
|
+
return [{
|
|
255
|
+
pid: Number(match[1]),
|
|
256
|
+
parentPid: Number(match[2]),
|
|
257
|
+
started: match[3]
|
|
258
|
+
}];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
var MacProcessInspector = class extends PosixProcessInspector {
|
|
262
|
+
foregroundPgid(shellPid) {
|
|
263
|
+
try {
|
|
264
|
+
const value = Number(this.internals.exec("/bin/ps", [
|
|
265
|
+
"-o",
|
|
266
|
+
"tpgid=",
|
|
267
|
+
"-p",
|
|
268
|
+
String(shellPid)
|
|
269
|
+
]).trim());
|
|
270
|
+
return Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
271
|
+
} catch (_missingProcess) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
isStdinWaiting(_pgid) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
processTree(rootPid) {
|
|
279
|
+
return processTree(macProcessTable(this.internals), rootPid);
|
|
280
|
+
}
|
|
281
|
+
processSession(_sessionId) {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
isAlive(identity) {
|
|
285
|
+
return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Create the supported platform inspector or fail at plugin load.
|
|
290
|
+
* @param platform - target Node platform.
|
|
291
|
+
* @param arch - target CPU architecture for Linux syscall numbers.
|
|
292
|
+
* @param internals - filesystem/process boundary, injectable for deterministic tests.
|
|
293
|
+
* @returns Platform process inspector.
|
|
294
|
+
*/
|
|
295
|
+
function createProcessInspector(platform = process.platform, arch = process.arch, internals = DEFAULT_INTERNALS) {
|
|
296
|
+
if (platform === "linux") return new LinuxProcessInspector(arch, internals);
|
|
297
|
+
if (platform === "darwin") return new MacProcessInspector(internals);
|
|
298
|
+
throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`);
|
|
299
|
+
}
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region lib/types/spawn.js
|
|
302
|
+
/**
|
|
303
|
+
* Process plumbing for the local subprocess service: detached process-tree
|
|
304
|
+
* spawn with per-stream stdio dispositions, tail-keep collection with spill
|
|
305
|
+
* files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
|
|
306
|
+
* SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
|
|
307
|
+
* own deadlines, teardown ladders, and cause classification.
|
|
308
|
+
* @module dsh-subprocess-local/spawn
|
|
309
|
+
*/
|
|
310
|
+
/**
|
|
311
|
+
* Build a child environment: explicit caller entries override the scrubbed
|
|
312
|
+
* parent base using the target platform's environment-key semantics. A string
|
|
313
|
+
* deliberately restores or overrides an entry; an explicit `undefined`
|
|
314
|
+
* tombstone removes an ordinary ambient entry.
|
|
315
|
+
* @param extra - explicit caller entries and tombstones, merged after the scrub.
|
|
316
|
+
* @returns the environment to hand to `spawn` for the child process.
|
|
317
|
+
*/
|
|
318
|
+
function childEnv(extra) {
|
|
319
|
+
const env = scrubbedParentEnv();
|
|
320
|
+
if (process.platform !== "win32") return {
|
|
321
|
+
...env,
|
|
322
|
+
...extra
|
|
323
|
+
};
|
|
324
|
+
let entries = Object.entries(env);
|
|
325
|
+
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
326
|
+
const normalized = key.toUpperCase();
|
|
327
|
+
entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized);
|
|
328
|
+
entries.push([key, value]);
|
|
329
|
+
}
|
|
330
|
+
return Object.fromEntries(entries);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
|
|
334
|
+
* awaited teardown must keep the event loop alive until the tree really
|
|
335
|
+
* exits, or the parent can exit while claiming quiescence and orphan the
|
|
336
|
+
* survivors it promised to reap.
|
|
337
|
+
*/
|
|
338
|
+
function sleepTick() {
|
|
339
|
+
return setTimeout$1(15);
|
|
340
|
+
}
|
|
341
|
+
let spillCounter = 0;
|
|
342
|
+
let defaultSpillDir;
|
|
343
|
+
/**
|
|
344
|
+
* The default spill location: a private (0700) per-process directory under
|
|
345
|
+
* the OS tmpdir, created lazily. Predictable world-readable paths would let
|
|
346
|
+
* other local users read command output or pre-create symlinks.
|
|
347
|
+
*/
|
|
348
|
+
function privateSpillDir() {
|
|
349
|
+
defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
|
|
350
|
+
return defaultSpillDir;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Collects one stream with a bounded in-memory tail. With a spill cap, on
|
|
354
|
+
* first overflow a spill file is created and every chunk (including those
|
|
355
|
+
* already collected) is appended there while the full stream remains within
|
|
356
|
+
* the cap; without one, only the in-memory tail is ever retained (the
|
|
357
|
+
* diagnostic-tail shape — a language server's stderr).
|
|
358
|
+
*
|
|
359
|
+
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
|
360
|
+
* end of command output; the spill file covers the head.
|
|
361
|
+
*/
|
|
362
|
+
var OutputCollector = class {
|
|
363
|
+
maxBytes;
|
|
364
|
+
maxSpillBytes;
|
|
365
|
+
label;
|
|
366
|
+
spillDir;
|
|
367
|
+
chunks = [];
|
|
368
|
+
bytes = 0;
|
|
369
|
+
dropped = false;
|
|
370
|
+
spillFd;
|
|
371
|
+
spillFile;
|
|
372
|
+
spillDisabled;
|
|
373
|
+
/** Total bytes ever pushed (not just retained). */
|
|
374
|
+
total = 0;
|
|
375
|
+
constructor(maxBytes, maxSpillBytes, label, spillDir) {
|
|
376
|
+
this.maxBytes = maxBytes;
|
|
377
|
+
this.maxSpillBytes = maxSpillBytes;
|
|
378
|
+
this.label = label;
|
|
379
|
+
this.spillDir = spillDir;
|
|
380
|
+
this.spillDisabled = maxSpillBytes === void 0;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Ingest one stream chunk, counting it toward the whole-stream total. On
|
|
384
|
+
* first overflow of the in-memory cap a spill file is opened (when spilling
|
|
385
|
+
* is enabled) and every chunk (already-collected ones included) is appended
|
|
386
|
+
* there from then on; the in-memory tail then drops whole chunks from its
|
|
387
|
+
* head (or the head of a single over-cap chunk) until it fits the cap again.
|
|
388
|
+
* @param chunk - the raw bytes from one stream 'data' event.
|
|
389
|
+
*/
|
|
390
|
+
push(chunk) {
|
|
391
|
+
this.total += chunk.length;
|
|
392
|
+
const overflows = this.bytes + chunk.length > this.maxBytes;
|
|
393
|
+
if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
|
|
394
|
+
this.chunks.push(chunk);
|
|
395
|
+
this.bytes += chunk.length;
|
|
396
|
+
while (this.bytes > this.maxBytes) {
|
|
397
|
+
const head = this.chunks[0];
|
|
398
|
+
const excess = this.bytes - this.maxBytes;
|
|
399
|
+
if (head.length <= excess) {
|
|
400
|
+
this.chunks.shift();
|
|
401
|
+
this.bytes -= head.length;
|
|
402
|
+
} else {
|
|
403
|
+
this.chunks[0] = head.subarray(excess);
|
|
404
|
+
this.bytes -= excess;
|
|
405
|
+
}
|
|
406
|
+
this.dropped = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
|
410
|
+
spillAll(chunk) {
|
|
411
|
+
if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
|
|
412
|
+
this.discardSpill();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (this.spillFd === void 0) {
|
|
416
|
+
this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
|
|
417
|
+
this.spillFd = openSync(this.spillFile, "wx", 384);
|
|
418
|
+
for (const prior of this.chunks) writeSync(this.spillFd, prior);
|
|
419
|
+
}
|
|
420
|
+
writeSync(this.spillFd, chunk);
|
|
421
|
+
}
|
|
422
|
+
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
|
|
423
|
+
discardSpill() {
|
|
424
|
+
const fd = this.spillFd;
|
|
425
|
+
const file = this.spillFile;
|
|
426
|
+
this.spillFd = void 0;
|
|
427
|
+
this.spillFile = void 0;
|
|
428
|
+
this.spillDisabled = true;
|
|
429
|
+
if (fd !== void 0) try {
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
} catch {
|
|
432
|
+
this.spillFd = fd;
|
|
433
|
+
}
|
|
434
|
+
if (file !== void 0) try {
|
|
435
|
+
unlinkSync(file);
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Incremental read in whole-stream byte coordinates: returns everything
|
|
440
|
+
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
|
441
|
+
* in-memory tail window, the read is `lossy` — it returns the whole
|
|
442
|
+
* retained tail and the gap is only recoverable from the spill file.
|
|
443
|
+
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
|
444
|
+
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
|
|
445
|
+
*/
|
|
446
|
+
readFrom(fromByte) {
|
|
447
|
+
const windowStart = this.total - this.bytes;
|
|
448
|
+
const buffer = Buffer.concat(this.chunks);
|
|
449
|
+
const lossy = fromByte < windowStart;
|
|
450
|
+
return {
|
|
451
|
+
text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
|
|
452
|
+
nextOffset: this.total,
|
|
453
|
+
lossy,
|
|
454
|
+
...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Close the spill file once the stream has ended. A failed close (delayed
|
|
459
|
+
* writeback fault) stops advertising the spill path — the file may be
|
|
460
|
+
* missing its tail — while every in-memory read keeps working. Idempotent;
|
|
461
|
+
* the spawn path seals both collectors at settlement so reads after exit
|
|
462
|
+
* never point at a still-open file.
|
|
463
|
+
*/
|
|
464
|
+
seal() {
|
|
465
|
+
if (this.spillFd === void 0) return;
|
|
466
|
+
try {
|
|
467
|
+
closeSync(this.spillFd);
|
|
468
|
+
} catch {
|
|
469
|
+
this.spillFile = void 0;
|
|
470
|
+
}
|
|
471
|
+
this.spillFd = void 0;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Seal the spill file and return the final output.
|
|
475
|
+
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
|
|
476
|
+
*/
|
|
477
|
+
finalize() {
|
|
478
|
+
this.seal();
|
|
479
|
+
return {
|
|
480
|
+
text: Buffer.concat(this.chunks).toString("utf8"),
|
|
481
|
+
truncated: this.dropped,
|
|
482
|
+
...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Terminate one Windows process tree with `taskkill /T /F`. Contained like
|
|
488
|
+
* POSIX group signalling — delivery races tree exit, so an absent tree, a
|
|
489
|
+
* nonzero status, or a missing taskkill binary must not break idempotent
|
|
490
|
+
* teardown.
|
|
491
|
+
* @param pid - root process id; non-positive is a no-op.
|
|
492
|
+
*/
|
|
493
|
+
function taskkillProcessTree(pid) {
|
|
494
|
+
if (pid <= 0) return;
|
|
495
|
+
spawnSync("taskkill", [
|
|
496
|
+
"/PID",
|
|
497
|
+
String(pid),
|
|
498
|
+
"/T",
|
|
499
|
+
"/F"
|
|
500
|
+
], { stdio: "ignore" });
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Signal a detached process tree with platform-correct semantics: POSIX
|
|
504
|
+
* signals the negative process-group id and falls back to the direct child
|
|
505
|
+
* when the group is gone; Windows terminates the tree via taskkill (any
|
|
506
|
+
* signal value force-terminates — Node maps signals to TerminateProcess).
|
|
507
|
+
*/
|
|
508
|
+
function signalTree(platform, pid, sig, child, taskkill) {
|
|
509
|
+
if (platform === "win32") {
|
|
510
|
+
taskkill(pid);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
/* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
|
|
514
|
+
if (pid <= 0) return;
|
|
515
|
+
try {
|
|
516
|
+
process.kill(-pid, sig);
|
|
517
|
+
} catch {
|
|
518
|
+
/* v8 ignore start -- the fallback needs a live child whose group signal fails
|
|
519
|
+
(EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
|
|
520
|
+
try {
|
|
521
|
+
child.kill(sig);
|
|
522
|
+
} catch {}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Spawn one isolated detached process tree with the spec's per-stream stdio
|
|
527
|
+
* dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome};
|
|
528
|
+
* only spawn failures reject.
|
|
529
|
+
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
|
|
530
|
+
* @param internals - test-only spill-directory, platform, and taskkill overrides.
|
|
531
|
+
* @returns live subprocess handle.
|
|
532
|
+
* @throws when `graceMs` cannot be represented by one Node timer.
|
|
533
|
+
*/
|
|
534
|
+
function spawnSubprocess(spec, internals = {}) {
|
|
535
|
+
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}`);
|
|
536
|
+
const spillDir = internals.spillDir ?? privateSpillDir();
|
|
537
|
+
const platform = internals.platform ?? process.platform;
|
|
538
|
+
const taskkill = internals.taskkill ?? taskkillProcessTree;
|
|
539
|
+
const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers;
|
|
540
|
+
if (spec.signal?.aborted) throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? "aborted")}`);
|
|
541
|
+
const [program, ...args] = spec.argv;
|
|
542
|
+
if (program === void 0 || program.length === 0) throw new Error("invalid argv: expected a non-empty program name at argv[0]");
|
|
543
|
+
const isCollect = (mode) => mode !== "pipe" && mode !== "inherit";
|
|
544
|
+
const outMode = spec.stdio.stdout;
|
|
545
|
+
const errMode = spec.stdio.stderr;
|
|
546
|
+
const stdinMode = spec.stdio.stdin;
|
|
547
|
+
const env = childEnv(spec.env);
|
|
548
|
+
const child = spawn(program, args, {
|
|
549
|
+
cwd: spec.cwd,
|
|
550
|
+
env,
|
|
551
|
+
stdio: [
|
|
552
|
+
stdinMode === "ignore" ? "ignore" : "pipe",
|
|
553
|
+
outMode === "inherit" ? "inherit" : "pipe",
|
|
554
|
+
errMode === "inherit" ? "inherit" : "pipe"
|
|
555
|
+
],
|
|
556
|
+
detached: platform !== "win32"
|
|
557
|
+
});
|
|
558
|
+
const collectStream = (mode, stream, label) => {
|
|
559
|
+
if (!isCollect(mode) || stream === null) return void 0;
|
|
560
|
+
const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir);
|
|
561
|
+
stream.on("data", (chunk) => {
|
|
562
|
+
collector.push(chunk);
|
|
563
|
+
});
|
|
564
|
+
return collector;
|
|
565
|
+
};
|
|
566
|
+
const stdoutCollector = collectStream(outMode, child.stdout, "stdout");
|
|
567
|
+
const stderrCollector = collectStream(errMode, child.stderr, "stderr");
|
|
568
|
+
let graceTimer;
|
|
569
|
+
let treeExitObserved = false;
|
|
570
|
+
let treeExitObservation;
|
|
571
|
+
let settled = false;
|
|
572
|
+
const pid = child.pid ?? -1;
|
|
573
|
+
/** Whether the detached tree's root (or POSIX group) is still alive. */
|
|
574
|
+
const treeAlive = () => {
|
|
575
|
+
/* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
|
|
576
|
+
the guard is the final defense against probing an id after its tree was confirmed absent. */
|
|
577
|
+
if (treeExitObserved) return false;
|
|
578
|
+
if (pid <= 0) return false;
|
|
579
|
+
if (platform === "win32") return child.exitCode === null && child.signalCode === null;
|
|
580
|
+
try {
|
|
581
|
+
process.kill(-pid, 0);
|
|
582
|
+
if (settled && platform === "linux" && linuxGroupHasLiveMembers(pid) === false) return false;
|
|
583
|
+
return true;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
const code = error.code;
|
|
586
|
+
/* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing
|
|
587
|
+
makes observing the other arm platform-dependent. */
|
|
588
|
+
if (code === "ESRCH") return false;
|
|
589
|
+
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
|
|
590
|
+
tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
|
|
591
|
+
if (code === "EPERM") return true;
|
|
592
|
+
return child.exitCode === null && child.signalCode === null;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/**
|
|
596
|
+
* Start or reuse the handle's single whole-tree exit observer. The first
|
|
597
|
+
* confirmed absence is a permanent no-more-signals boundary: it cancels a
|
|
598
|
+
* pending escalation before this process-group id can be reused.
|
|
599
|
+
*/
|
|
600
|
+
const observeTreeExit = () => {
|
|
601
|
+
treeExitObservation ??= (async () => {
|
|
602
|
+
while (treeAlive()) await sleepTick();
|
|
603
|
+
treeExitObserved = true;
|
|
604
|
+
if (graceTimer !== void 0) clearTimeout(graceTimer);
|
|
605
|
+
graceTimer = void 0;
|
|
606
|
+
})();
|
|
607
|
+
return treeExitObservation;
|
|
608
|
+
};
|
|
609
|
+
const kill = (sig) => {
|
|
610
|
+
/* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
|
|
611
|
+
this remains the timer/death race guard and cannot be staged deterministically. */
|
|
612
|
+
if (!treeAlive()) return;
|
|
613
|
+
signalTree(platform, pid, sig, child, taskkill);
|
|
614
|
+
};
|
|
615
|
+
const terminate = () => {
|
|
616
|
+
if (treeExitObserved || graceTimer !== void 0) return;
|
|
617
|
+
observeTreeExit();
|
|
618
|
+
if (treeExitObserved) return;
|
|
619
|
+
kill("SIGTERM");
|
|
620
|
+
graceTimer = setTimeout(() => {
|
|
621
|
+
kill("SIGKILL");
|
|
622
|
+
}, spec.graceMs);
|
|
623
|
+
};
|
|
624
|
+
const onAbort = () => {
|
|
625
|
+
terminate();
|
|
626
|
+
};
|
|
627
|
+
spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
628
|
+
if (typeof stdinMode === "object" && child.stdin !== null) {
|
|
629
|
+
child.stdin.on("error", () => {});
|
|
630
|
+
child.stdin.end(stdinMode.data);
|
|
631
|
+
}
|
|
632
|
+
const done = new Promise((resolve, reject) => {
|
|
633
|
+
let pipeDrainTimer;
|
|
634
|
+
const settle = (exitCode, signal) => {
|
|
635
|
+
if (settled) return;
|
|
636
|
+
settled = true;
|
|
637
|
+
if (stdoutCollector !== void 0) child.stdout?.destroy();
|
|
638
|
+
if (stderrCollector !== void 0) child.stderr?.destroy();
|
|
639
|
+
stdoutCollector?.seal();
|
|
640
|
+
stderrCollector?.seal();
|
|
641
|
+
cleanup();
|
|
642
|
+
resolve({
|
|
643
|
+
exitCode,
|
|
644
|
+
signal
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
child.on("error", (error) => {
|
|
648
|
+
settled = true;
|
|
649
|
+
cleanup();
|
|
650
|
+
reject(error);
|
|
651
|
+
});
|
|
652
|
+
child.on("exit", (exitCode, signal) => {
|
|
653
|
+
pipeDrainTimer = setTimeout(() => {
|
|
654
|
+
settle(exitCode, signal);
|
|
655
|
+
}, spec.graceMs);
|
|
656
|
+
});
|
|
657
|
+
child.on("close", settle);
|
|
658
|
+
function cleanup() {
|
|
659
|
+
if (pipeDrainTimer !== void 0) clearTimeout(pipeDrainTimer);
|
|
660
|
+
spec.signal?.removeEventListener("abort", onAbort);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
const waitForExit = async (signal) => {
|
|
664
|
+
const observed = observeTreeExit();
|
|
665
|
+
if (treeExitObserved) return true;
|
|
666
|
+
if (signal?.aborted) return false;
|
|
667
|
+
if (signal === void 0) {
|
|
668
|
+
await observed;
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
const aborted = Promise.withResolvers();
|
|
672
|
+
const onAbort = () => {
|
|
673
|
+
aborted.resolve(false);
|
|
674
|
+
};
|
|
675
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
676
|
+
/* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
|
|
677
|
+
if (signal.aborted) onAbort();
|
|
678
|
+
try {
|
|
679
|
+
return await Promise.race([observed.then(() => true), aborted.promise]);
|
|
680
|
+
} finally {
|
|
681
|
+
signal.removeEventListener("abort", onAbort);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
return {
|
|
685
|
+
pid,
|
|
686
|
+
/* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
|
|
687
|
+
stdin: stdinMode === "pipe" ? child.stdin ?? void 0 : void 0,
|
|
688
|
+
stdout: outMode === "pipe" ? child.stdout ?? void 0 : void 0,
|
|
689
|
+
stderr: errMode === "pipe" ? child.stderr ?? void 0 : void 0,
|
|
690
|
+
/* v8 ignore stop */
|
|
691
|
+
collected: {
|
|
692
|
+
...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
|
|
693
|
+
...stderrCollector !== void 0 ? { stderr: stderrCollector } : {}
|
|
694
|
+
},
|
|
695
|
+
done,
|
|
696
|
+
terminate,
|
|
697
|
+
waitForExit
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region lib/types/terminal.js
|
|
702
|
+
/** Local node-pty terminal-process implementation for the subprocess seam. */
|
|
703
|
+
function delay(ms) {
|
|
704
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
705
|
+
}
|
|
706
|
+
function signalName(number) {
|
|
707
|
+
if (number === void 0 || number === 0) return null;
|
|
708
|
+
for (const [name, value] of Object.entries(constants$1.signals)) if (value === number) return name;
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* A local terminal whose process-session ownership stays below the PTY backend.
|
|
713
|
+
* The seam's terminate() promise — no write, inspection, or signal in flight
|
|
714
|
+
* after settlement — holds here without operation tracking only because every
|
|
715
|
+
* handle call completes synchronously under the hood (node-pty write, ps-based
|
|
716
|
+
* inspection). A first genuinely asynchronous step in any handle call must add
|
|
717
|
+
* the tracking a remote provider needs.
|
|
718
|
+
*/
|
|
719
|
+
var LocalTerminalHandle = class {
|
|
720
|
+
terminal;
|
|
721
|
+
inspector;
|
|
722
|
+
graceMs;
|
|
723
|
+
pid;
|
|
724
|
+
output = new PassThrough();
|
|
725
|
+
done;
|
|
726
|
+
outcome = Promise.withResolvers();
|
|
727
|
+
dataDisposable;
|
|
728
|
+
exitDisposable;
|
|
729
|
+
cleanup;
|
|
730
|
+
exited = false;
|
|
731
|
+
trackedDescendants = [];
|
|
732
|
+
/** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
|
|
733
|
+
rootIdentity;
|
|
734
|
+
/**
|
|
735
|
+
* @param terminal - allocated node-pty process.
|
|
736
|
+
* @param inspector - platform process/session operations.
|
|
737
|
+
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
|
738
|
+
*/
|
|
739
|
+
constructor(terminal, inspector, graceMs) {
|
|
740
|
+
this.terminal = terminal;
|
|
741
|
+
this.inspector = inspector;
|
|
742
|
+
this.graceMs = graceMs;
|
|
743
|
+
this.pid = terminal.pid;
|
|
744
|
+
this.rootIdentity = inspector.processTree(this.pid).find((member) => member.pid === this.pid);
|
|
745
|
+
this.done = this.outcome.promise;
|
|
746
|
+
this.dataDisposable = terminal.onData((data) => {
|
|
747
|
+
this.output.write(Buffer$1.from(data, "utf8"));
|
|
748
|
+
});
|
|
749
|
+
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
|
|
750
|
+
if (this.exited) return;
|
|
751
|
+
this.exited = true;
|
|
752
|
+
this.output.end();
|
|
753
|
+
this.outcome.resolve({
|
|
754
|
+
exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
|
|
755
|
+
signal: signalName(exitSignal)
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
async write(data) {
|
|
760
|
+
if (this.exited) throw new Error("terminal process has exited");
|
|
761
|
+
this.terminal.write(data);
|
|
762
|
+
}
|
|
763
|
+
async inspectForeground() {
|
|
764
|
+
this.descendants();
|
|
765
|
+
const processGroupId = this.inspector.foregroundPgid(this.pid);
|
|
766
|
+
if (processGroupId === void 0) return void 0;
|
|
767
|
+
return {
|
|
768
|
+
processGroupId,
|
|
769
|
+
inputWaiting: this.inspector.isStdinWaiting(processGroupId)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
async signalForeground(signal) {
|
|
773
|
+
const foreground = await this.inspectForeground();
|
|
774
|
+
if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
|
|
775
|
+
if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
|
|
776
|
+
this.inspector.signalGroup(foreground.processGroupId, signal);
|
|
777
|
+
return foreground.processGroupId;
|
|
778
|
+
}
|
|
779
|
+
terminate() {
|
|
780
|
+
if (this.cleanup !== void 0) return this.cleanup;
|
|
781
|
+
const cleanup = this.closeOnce();
|
|
782
|
+
this.cleanup = cleanup;
|
|
783
|
+
cleanup.catch(() => {
|
|
784
|
+
this.cleanup = void 0;
|
|
785
|
+
});
|
|
786
|
+
return cleanup;
|
|
787
|
+
}
|
|
788
|
+
survivors(members) {
|
|
789
|
+
return members.filter((member) => this.inspector.isAlive(member));
|
|
790
|
+
}
|
|
791
|
+
descendants() {
|
|
792
|
+
const tree = this.inspector.processTree(this.pid);
|
|
793
|
+
const root = tree.find((member) => member.pid === this.pid);
|
|
794
|
+
const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
|
|
795
|
+
this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : []).filter((member) => member.pid !== this.pid));
|
|
796
|
+
return this.trackedDescendants;
|
|
797
|
+
}
|
|
798
|
+
async waitForMembers(members) {
|
|
799
|
+
const until = Date.now() + this.graceMs;
|
|
800
|
+
let survivors = this.survivors(members);
|
|
801
|
+
while (survivors.length > 0 && Date.now() < until) {
|
|
802
|
+
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
803
|
+
survivors = this.survivors(members);
|
|
804
|
+
}
|
|
805
|
+
return survivors;
|
|
806
|
+
}
|
|
807
|
+
signalMembers(members, signal) {
|
|
808
|
+
for (const member of members) try {
|
|
809
|
+
this.inspector.signalProcess(member, signal);
|
|
810
|
+
} catch (_alreadyExitedDuringSignal) {}
|
|
811
|
+
}
|
|
812
|
+
unionMembers(...groups) {
|
|
813
|
+
const members = [];
|
|
814
|
+
const seen = /* @__PURE__ */ new Set();
|
|
815
|
+
for (const group of groups) for (const member of group) {
|
|
816
|
+
const key = `${member.pid}:${member.started}`;
|
|
817
|
+
if (seen.has(key)) continue;
|
|
818
|
+
seen.add(key);
|
|
819
|
+
members.push(member);
|
|
820
|
+
}
|
|
821
|
+
return members;
|
|
822
|
+
}
|
|
823
|
+
async stopDescendants() {
|
|
824
|
+
const captured = this.descendants();
|
|
825
|
+
this.signalMembers(captured, "SIGTERM");
|
|
826
|
+
const capturedSurvivors = await this.waitForMembers(captured);
|
|
827
|
+
const members = this.unionMembers(capturedSurvivors, this.descendants());
|
|
828
|
+
this.signalMembers(members, "SIGKILL");
|
|
829
|
+
const survivors = await this.waitForMembers(members);
|
|
830
|
+
return this.survivors(this.unionMembers(survivors, this.descendants()));
|
|
831
|
+
}
|
|
832
|
+
async stopShell() {
|
|
833
|
+
if (!this.exited) {
|
|
834
|
+
try {
|
|
835
|
+
this.terminal.kill("SIGTERM");
|
|
836
|
+
} catch (_topLevelAlreadyExitedDuringTerm) {}
|
|
837
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
838
|
+
}
|
|
839
|
+
if (!this.exited) {
|
|
840
|
+
try {
|
|
841
|
+
this.terminal.kill("SIGKILL");
|
|
842
|
+
} catch (_topLevelAlreadyExitedDuringKill) {}
|
|
843
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
844
|
+
}
|
|
845
|
+
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
846
|
+
}
|
|
847
|
+
async closeOnce() {
|
|
848
|
+
let survivors = await this.stopDescendants();
|
|
849
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
850
|
+
await this.stopShell();
|
|
851
|
+
survivors = await this.stopDescendants();
|
|
852
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
853
|
+
this.dataDisposable.dispose();
|
|
854
|
+
this.exitDisposable.dispose();
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region lib/types/index.js
|
|
859
|
+
/**
|
|
860
|
+
* Local Service provider for the subprocess capability seam. Each spawn is a detached
|
|
861
|
+
* process tree with the spec's per-stream stdio dispositions; disposal
|
|
862
|
+
* terminates and joins live trees. It has no config: every disposition and
|
|
863
|
+
* limit arrives on the spec, so the deployment-varying choices stay with the
|
|
864
|
+
* caller's config (the bash executor's, the LSP host's, …).
|
|
865
|
+
* @module @deepseek-ai/dsh-subprocess-local
|
|
866
|
+
*/
|
|
867
|
+
/**
|
|
868
|
+
* Local subprocess service: detached process trees, Node-shaped stdio
|
|
869
|
+
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
|
|
870
|
+
* files), credential-scrubbed environment, and tree-scoped signalling with
|
|
871
|
+
* SIGTERM→grace→SIGKILL escalation.
|
|
872
|
+
*/
|
|
873
|
+
var LocalSubprocessService = class extends SubprocessService {
|
|
874
|
+
/** Live handles retained only so disposal can terminate and join them. */
|
|
875
|
+
live = /* @__PURE__ */ new Set();
|
|
876
|
+
/** Live terminal sessions retained through whole-session quiescence. */
|
|
877
|
+
terminals = /* @__PURE__ */ new Set();
|
|
878
|
+
/** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
|
|
879
|
+
internals = {};
|
|
880
|
+
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
|
|
881
|
+
terminalInspector;
|
|
882
|
+
constructor(ctx) {
|
|
883
|
+
super(ctx);
|
|
884
|
+
ctx.effect(() => async () => {
|
|
885
|
+
const pending = [];
|
|
886
|
+
for (const handle of this.live) {
|
|
887
|
+
handle.terminate();
|
|
888
|
+
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()));
|
|
889
|
+
}
|
|
890
|
+
for (const terminal of this.terminals) pending.push(terminal.terminate());
|
|
891
|
+
this.live.clear();
|
|
892
|
+
this.terminals.clear();
|
|
893
|
+
const failures = (await Promise.allSettled(pending)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
|
|
894
|
+
if (failures.length === 1) throw failures[0];
|
|
895
|
+
if (failures.length > 1) throw new AggregateError(failures, "local subprocess teardown failed");
|
|
896
|
+
}, "local subprocess teardown");
|
|
897
|
+
}
|
|
898
|
+
async resolveExecutable(command, env, signal) {
|
|
899
|
+
if (command.length === 0) throw new Error("subprocess-local: executable must be non-empty");
|
|
900
|
+
signal?.throwIfAborted();
|
|
901
|
+
const environment = childEnv(env);
|
|
902
|
+
const absolute = isAbsolute(command);
|
|
903
|
+
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`);
|
|
904
|
+
const candidates = absolute ? [command] : this.executableCandidates(command, environment);
|
|
905
|
+
for (const candidate of candidates) {
|
|
906
|
+
signal?.throwIfAborted();
|
|
907
|
+
try {
|
|
908
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
909
|
+
await access(candidate, constants.X_OK);
|
|
910
|
+
signal?.throwIfAborted();
|
|
911
|
+
return candidate;
|
|
912
|
+
} catch {}
|
|
913
|
+
}
|
|
914
|
+
signal?.throwIfAborted();
|
|
915
|
+
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`);
|
|
916
|
+
}
|
|
917
|
+
executableCandidates(command, env) {
|
|
918
|
+
const path = environmentValue(env, "PATH") ?? "";
|
|
919
|
+
const extensions = process.platform === "win32" && extname(command) === "" ? (environmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
920
|
+
return path.split(delimiter).flatMap((directory) => extensions.map((extension) => resolve(process.cwd(), directory, command + extension)));
|
|
921
|
+
}
|
|
922
|
+
spawn(spec) {
|
|
923
|
+
const handle = spawnSubprocess(spec, this.internals);
|
|
924
|
+
this.live.add(handle);
|
|
925
|
+
const release = () => handle.waitForExit().then(() => {
|
|
926
|
+
this.live.delete(handle);
|
|
927
|
+
});
|
|
928
|
+
handle.done.then(release, release);
|
|
929
|
+
return handle;
|
|
930
|
+
}
|
|
931
|
+
async spawnTerminal(spec) {
|
|
932
|
+
const file = spec.argv[0];
|
|
933
|
+
if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
|
|
934
|
+
spec.signal?.throwIfAborted();
|
|
935
|
+
const options = {
|
|
936
|
+
name: "dumb",
|
|
937
|
+
rows: spec.rows,
|
|
938
|
+
cols: spec.cols,
|
|
939
|
+
cwd: spec.cwd,
|
|
940
|
+
env: childEnv(spec.env)
|
|
941
|
+
};
|
|
942
|
+
const inspector = this.terminalInspector ?? createProcessInspector();
|
|
943
|
+
const handle = new LocalTerminalHandle(nodePty.spawn(file, [...spec.argv.slice(1)], options), inspector, spec.graceMs);
|
|
944
|
+
this.terminals.add(handle);
|
|
945
|
+
const release = async () => {
|
|
946
|
+
await handle.terminate();
|
|
947
|
+
this.terminals.delete(handle);
|
|
948
|
+
};
|
|
949
|
+
handle.done.then(release, release).catch(() => {});
|
|
950
|
+
return handle;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
/** Read a Windows environment key using the platform's case-insensitive semantics. */
|
|
954
|
+
function environmentValue(env, name) {
|
|
955
|
+
const exact = env[name];
|
|
956
|
+
if (exact !== void 0 || process.platform !== "win32") return exact;
|
|
957
|
+
const normalized = name.toUpperCase();
|
|
958
|
+
return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1];
|
|
959
|
+
}
|
|
960
|
+
//#endregion
|
|
961
|
+
export { LocalSubprocessService, LocalSubprocessService as default };
|