@x1a0f3n9/dsh-subprocess-local 0.1.5-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +151 -0
- package/README.zh.md +151 -0
- package/lib/index.js +1124 -0
- package/lib/runner-launch-Bcl_FRjd.js +1623 -0
- package/lib/runner.js +425 -0
- package/lib/types/bin.d.ts +7 -0
- package/lib/types/index.d.ts +46 -0
- package/lib/types/linux-execve.d.ts +9 -0
- package/lib/types/linux-scope.d.ts +81 -0
- package/lib/types/managed-owner.d.ts +30 -0
- package/lib/types/process-inspector.d.ts +135 -0
- package/lib/types/runner-launch.d.ts +66 -0
- package/lib/types/runner-protocol.d.ts +109 -0
- package/lib/types/spawn-runner.d.ts +36 -0
- package/lib/types/spawn.d.ts +156 -0
- package/lib/types/terminal.d.ts +69 -0
- package/lib/types/windows-inspector.d.ts +79 -0
- package/lib/types/windows-job.d.ts +30 -0
- package/package.json +55 -0
- package/scripts/ensure-spawn-helper.mjs +16 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import { C as bindManagedProcess, D as validateSubprocessSpec, E as spawnSubprocess, O as createProcessInspector, S as loadLinuxExecve, T as prepareManagedProcessBinding, _ as parseWindowsRunnerResult, c as runnerStdio, d as cleanupLinuxLaunchFiles, l as spawnRunnerInvocation, m as deserializeRunnerError, n as WINDOWS_RUNNER_SELECTION, o as runnerEnvironment, p as createLinuxLaunchFiles, s as runnerInvocationAvailable, u as targetEnvironment, w as childEnv, y as readLinuxStartupError } from "./runner-launch-Bcl_FRjd.js";
|
|
2
|
+
import { closeSync, constants, existsSync, openSync } from "node:fs";
|
|
3
|
+
import { access, stat } from "node:fs/promises";
|
|
4
|
+
import { delimiter, extname, isAbsolute, resolve } from "node:path";
|
|
5
|
+
import * as nodePty from "node-pty";
|
|
6
|
+
import { SubprocessRuntime } from "@x1a0f3n9/dsh-subprocess";
|
|
7
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { constants as constants$1, devNull } from "node:os";
|
|
10
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
11
|
+
import { loadWin32ProcessBindings, probeCurrentTokenJobSupport } from "@x1a0f3n9/dsh-win32-process";
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import { PassThrough } from "node:stream";
|
|
14
|
+
//#region lib/types/linux-scope.js
|
|
15
|
+
/** Linux user-systemd scope launch and managed-range ownership. */
|
|
16
|
+
const SYSTEMCTL_TIMEOUT_MS = 5e3;
|
|
17
|
+
const SCOPE_INITIAL_POLL_INTERVAL_MS = 50;
|
|
18
|
+
const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu;
|
|
19
|
+
function managerEnvironment() {
|
|
20
|
+
const environment = childEnv({ LC_ALL: "C" });
|
|
21
|
+
delete environment.SYSTEMD_LOG_TARGET;
|
|
22
|
+
return environment;
|
|
23
|
+
}
|
|
24
|
+
function quietSystemdEnvironment() {
|
|
25
|
+
return childEnv({
|
|
26
|
+
LC_ALL: "C",
|
|
27
|
+
SYSTEMD_LOG_TARGET: "null"
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function querySystemctl(command, args) {
|
|
31
|
+
return new Promise((resolveResult) => {
|
|
32
|
+
execFile(command, [...args], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: managerEnvironment(),
|
|
35
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
36
|
+
}, (error, stdout, stderr) => {
|
|
37
|
+
const code = error === null ? 0 : error.code;
|
|
38
|
+
resolveResult({
|
|
39
|
+
status: typeof code === "number" ? code : null,
|
|
40
|
+
stdout,
|
|
41
|
+
stderr,
|
|
42
|
+
...error === null ? {} : { error }
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function unitStem(prefix) {
|
|
48
|
+
return `${prefix}-${String(process.pid)}-${randomBytes(6).toString("hex")}`;
|
|
49
|
+
}
|
|
50
|
+
function sleepWithAbort(delayMs, signal) {
|
|
51
|
+
return setTimeout$1(delayMs, void 0, { signal });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Confirm this exact runner entry and libc execve binding without a probe mode.
|
|
55
|
+
* @param internals - optional runner and libc-binding seams used by tests.
|
|
56
|
+
* @returns whether the bootstrap can enter the final target.
|
|
57
|
+
*/
|
|
58
|
+
function probeLinuxBootstrap(internals = {}) {
|
|
59
|
+
try {
|
|
60
|
+
(internals.loadLinuxExecve ?? loadLinuxExecve)();
|
|
61
|
+
const invocation = internals.runnerInvocation ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)();
|
|
62
|
+
return (internals.runnerAvailable ?? runnerInvocationAvailable)(invocation);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Confirm current literal-argv transient-scope support before selecting native launch.
|
|
69
|
+
* @param internals - optional systemd command seams used by tests.
|
|
70
|
+
* @returns whether the current user manager supports the required scope invocation.
|
|
71
|
+
*/
|
|
72
|
+
function probeLinuxScope(internals = {}) {
|
|
73
|
+
const unitBase = unitStem("dsh-subprocess-probe");
|
|
74
|
+
const result = (internals.spawnSync ?? spawnSync)(internals.systemdRun ?? "systemd-run", [
|
|
75
|
+
"--user",
|
|
76
|
+
"--scope",
|
|
77
|
+
"--quiet",
|
|
78
|
+
"--collect",
|
|
79
|
+
"--expand-environment=no",
|
|
80
|
+
`--unit=${unitBase}`,
|
|
81
|
+
"--",
|
|
82
|
+
internals.systemctl ?? "systemctl",
|
|
83
|
+
"--user",
|
|
84
|
+
"show",
|
|
85
|
+
`${unitBase}.scope`,
|
|
86
|
+
"--property=ActiveState",
|
|
87
|
+
"--value"
|
|
88
|
+
], {
|
|
89
|
+
env: quietSystemdEnvironment(),
|
|
90
|
+
stdio: "ignore",
|
|
91
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
92
|
+
});
|
|
93
|
+
return result.error === void 0 && result.status === 0;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Confirm that the current user manager remains reachable after a positive deep probe.
|
|
97
|
+
* @param internals - optional systemctl seam used by tests.
|
|
98
|
+
* @returns whether one lightweight manager query succeeds.
|
|
99
|
+
*/
|
|
100
|
+
function probeLinuxManager(internals = {}) {
|
|
101
|
+
const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? "systemctl", [
|
|
102
|
+
"--user",
|
|
103
|
+
"show",
|
|
104
|
+
"--property=Version",
|
|
105
|
+
"--value"
|
|
106
|
+
], {
|
|
107
|
+
env: managerEnvironment(),
|
|
108
|
+
stdio: "ignore",
|
|
109
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
110
|
+
});
|
|
111
|
+
return result.error === void 0 && result.status === 0;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Re-check every Linux native prerequisite for one eligible spawn.
|
|
115
|
+
* @param internals - optional native capability seams used by tests.
|
|
116
|
+
* @returns whether the Linux native containment path is currently available.
|
|
117
|
+
*/
|
|
118
|
+
function probeLinuxNative(internals = {}) {
|
|
119
|
+
return probeLinuxBootstrap(internals) && probeLinuxScope(internals);
|
|
120
|
+
}
|
|
121
|
+
var LinuxScopeStartup = class {
|
|
122
|
+
files;
|
|
123
|
+
kind;
|
|
124
|
+
terminationSignals = /* @__PURE__ */ new Set();
|
|
125
|
+
constructor(files, kind) {
|
|
126
|
+
this.files = files;
|
|
127
|
+
this.kind = kind;
|
|
128
|
+
}
|
|
129
|
+
resolveOutcome(outcome) {
|
|
130
|
+
const startup = readLinuxStartupError(this.files.startupErrorPath);
|
|
131
|
+
if (startup !== void 0) throw deserializeRunnerError(startup.error);
|
|
132
|
+
if (existsSync(this.files.requestPath) && !(outcome.signal !== null && this.terminationSignals.has(outcome.signal))) throw new Error(`${this.kind} scope exited before its bootstrap consumed the launch request`);
|
|
133
|
+
return outcome;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var SystemdScopeOwner = class {
|
|
137
|
+
unit;
|
|
138
|
+
startup;
|
|
139
|
+
direct;
|
|
140
|
+
systemctl;
|
|
141
|
+
runSync;
|
|
142
|
+
query;
|
|
143
|
+
sleep;
|
|
144
|
+
establishment = "pending";
|
|
145
|
+
stopped = false;
|
|
146
|
+
terminationRequested = false;
|
|
147
|
+
observation;
|
|
148
|
+
killFailure;
|
|
149
|
+
wakeGeneration = 0;
|
|
150
|
+
wakeWaiter;
|
|
151
|
+
constructor(unit, startup, direct, systemctl, runSync, query, sleep) {
|
|
152
|
+
this.unit = unit;
|
|
153
|
+
this.startup = startup;
|
|
154
|
+
this.direct = direct;
|
|
155
|
+
this.systemctl = systemctl;
|
|
156
|
+
this.runSync = runSync;
|
|
157
|
+
this.query = query;
|
|
158
|
+
this.sleep = sleep;
|
|
159
|
+
}
|
|
160
|
+
signal(signal) {
|
|
161
|
+
if (this.stopped) return;
|
|
162
|
+
this.terminationRequested = true;
|
|
163
|
+
if (this.direct.running()) this.startup.terminationSignals.add(signal);
|
|
164
|
+
this.observeRequestConsumption();
|
|
165
|
+
const directFallbackRequired = this.establishment === "pending";
|
|
166
|
+
if (directFallbackRequired && this.direct.running()) this.direct.signal(signal);
|
|
167
|
+
const result = this.runSync(this.systemctl, [
|
|
168
|
+
"--user",
|
|
169
|
+
"kill",
|
|
170
|
+
"--kill-whom=all",
|
|
171
|
+
`--signal=${signal}`,
|
|
172
|
+
this.unit
|
|
173
|
+
], {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
env: managerEnvironment(),
|
|
176
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
177
|
+
});
|
|
178
|
+
this.wakeObservation();
|
|
179
|
+
if (result.error === void 0 && result.status === 0) {
|
|
180
|
+
if (signal === "SIGKILL") this.killFailure = void 0;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!directFallbackRequired && this.direct.running()) this.direct.signal(signal);
|
|
184
|
+
if (signal === "SIGKILL") {
|
|
185
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
186
|
+
if (!MISSING_UNIT.test(output)) this.killFailure = result.error ?? /* @__PURE__ */ new Error(`systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
terminateForHostExit() {
|
|
190
|
+
if (this.stopped) return;
|
|
191
|
+
try {
|
|
192
|
+
if (this.direct.running()) this.direct.signal("SIGKILL");
|
|
193
|
+
} catch {}
|
|
194
|
+
try {
|
|
195
|
+
this.runSync(this.systemctl, [
|
|
196
|
+
"--user",
|
|
197
|
+
"kill",
|
|
198
|
+
"--kill-whom=all",
|
|
199
|
+
"--signal=SIGKILL",
|
|
200
|
+
this.unit
|
|
201
|
+
], {
|
|
202
|
+
env: managerEnvironment(),
|
|
203
|
+
stdio: "ignore",
|
|
204
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
205
|
+
});
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
observeRequestConsumption() {
|
|
209
|
+
if (this.establishment === "pending" && !existsSync(this.startup.files.requestPath)) this.establishment = "established";
|
|
210
|
+
}
|
|
211
|
+
absentUnit() {
|
|
212
|
+
this.observeRequestConsumption();
|
|
213
|
+
if (this.establishment === "established") return false;
|
|
214
|
+
if (!this.direct.running() && existsSync(this.startup.files.requestPath)) return false;
|
|
215
|
+
if (this.killFailure !== void 0) throw this.killFailure;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Prove an active unit with no processes is the empty managed range rather
|
|
220
|
+
* than a launch still placing its payload. systemd ends a scope only on the
|
|
221
|
+
* populated-to-empty transition, so a payload killed before it entered the
|
|
222
|
+
* cgroup leaves the unit active forever. Requested termination plus a
|
|
223
|
+
* departed client makes that leftover conclusive: the client forked every
|
|
224
|
+
* process it will ever fork.
|
|
225
|
+
*/
|
|
226
|
+
emptyRange(tasksCurrent) {
|
|
227
|
+
return this.terminationRequested && tasksCurrent === 0 && !this.direct.running();
|
|
228
|
+
}
|
|
229
|
+
/** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
|
|
230
|
+
releaseEmptyRange() {
|
|
231
|
+
try {
|
|
232
|
+
this.runSync(this.systemctl, [
|
|
233
|
+
"--user",
|
|
234
|
+
"stop",
|
|
235
|
+
this.unit
|
|
236
|
+
], {
|
|
237
|
+
env: managerEnvironment(),
|
|
238
|
+
stdio: "ignore",
|
|
239
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
240
|
+
});
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
parseUnitState(stdout) {
|
|
244
|
+
const values = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
246
|
+
if (line === "") continue;
|
|
247
|
+
const separator = line.indexOf("=");
|
|
248
|
+
if (separator <= 0) throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`);
|
|
249
|
+
const name = line.slice(0, separator);
|
|
250
|
+
if (values.has(name)) throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`);
|
|
251
|
+
values.set(name, line.slice(separator + 1));
|
|
252
|
+
}
|
|
253
|
+
const loadState = values.get("LoadState");
|
|
254
|
+
const activeState = values.get("ActiveState");
|
|
255
|
+
const reportedTasks = values.get("TasksCurrent");
|
|
256
|
+
const tasksCurrent = reportedTasks === "[not set]" ? void 0 : reportedTasks;
|
|
257
|
+
if (values.size !== (reportedTasks === void 0 ? 2 : 3) || loadState === void 0 || activeState === void 0) throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`);
|
|
258
|
+
if (tasksCurrent !== void 0 && !/^\d+$/u.test(tasksCurrent)) throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`);
|
|
259
|
+
return {
|
|
260
|
+
loadState,
|
|
261
|
+
activeState,
|
|
262
|
+
tasksCurrent: tasksCurrent === void 0 ? void 0 : Number(tasksCurrent)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async rangeActive() {
|
|
266
|
+
this.observeRequestConsumption();
|
|
267
|
+
const result = await this.query(this.systemctl, [
|
|
268
|
+
"--user",
|
|
269
|
+
"show",
|
|
270
|
+
this.unit,
|
|
271
|
+
"--property=LoadState",
|
|
272
|
+
"--property=ActiveState",
|
|
273
|
+
"--property=TasksCurrent"
|
|
274
|
+
]);
|
|
275
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
276
|
+
if (result.status === 0) {
|
|
277
|
+
const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout);
|
|
278
|
+
if (loadState === "not-found" && activeState === "inactive") return this.absentUnit();
|
|
279
|
+
if (loadState !== "loaded") throw new Error(`systemctl returned unknown state for ${this.unit}: ${JSON.stringify({
|
|
280
|
+
loadState,
|
|
281
|
+
activeState
|
|
282
|
+
})}`);
|
|
283
|
+
this.establishment = "established";
|
|
284
|
+
if (activeState === "inactive" || activeState === "failed") return false;
|
|
285
|
+
if (![
|
|
286
|
+
"active",
|
|
287
|
+
"activating",
|
|
288
|
+
"reloading",
|
|
289
|
+
"deactivating"
|
|
290
|
+
].includes(activeState)) throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`);
|
|
291
|
+
if (this.killFailure !== void 0) throw this.killFailure;
|
|
292
|
+
if (this.emptyRange(tasksCurrent)) {
|
|
293
|
+
this.releaseEmptyRange();
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
if (!MISSING_UNIT.test(output)) {
|
|
299
|
+
if (result.error !== void 0) throw result.error;
|
|
300
|
+
throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
|
|
301
|
+
}
|
|
302
|
+
return this.absentUnit();
|
|
303
|
+
}
|
|
304
|
+
wakeObservation() {
|
|
305
|
+
this.wakeGeneration += 1;
|
|
306
|
+
this.wakeWaiter?.resolve();
|
|
307
|
+
this.wakeWaiter = void 0;
|
|
308
|
+
}
|
|
309
|
+
async waitForPoll(delayMs, generation) {
|
|
310
|
+
if (generation !== this.wakeGeneration) return;
|
|
311
|
+
const wake = Promise.withResolvers();
|
|
312
|
+
const waiter = {
|
|
313
|
+
generation,
|
|
314
|
+
resolve: wake.resolve
|
|
315
|
+
};
|
|
316
|
+
const sleepController = new AbortController();
|
|
317
|
+
this.wakeWaiter = waiter;
|
|
318
|
+
try {
|
|
319
|
+
await Promise.race([this.sleep(delayMs, sleepController.signal), wake.promise]);
|
|
320
|
+
} finally {
|
|
321
|
+
sleepController.abort();
|
|
322
|
+
if (this.wakeWaiter === waiter) this.wakeWaiter = void 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async waitForExit() {
|
|
326
|
+
if (this.stopped) return;
|
|
327
|
+
this.observation ??= (async () => {
|
|
328
|
+
let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS;
|
|
329
|
+
let generation = this.wakeGeneration;
|
|
330
|
+
while (await this.rangeActive()) {
|
|
331
|
+
await this.waitForPoll(pollIntervalMs, generation);
|
|
332
|
+
generation = this.wakeGeneration;
|
|
333
|
+
if (this.establishment === "established") pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS);
|
|
334
|
+
}
|
|
335
|
+
this.stopped = true;
|
|
336
|
+
})().catch((error) => {
|
|
337
|
+
this.observation = void 0;
|
|
338
|
+
throw error;
|
|
339
|
+
});
|
|
340
|
+
await this.observation;
|
|
341
|
+
}
|
|
342
|
+
cleanup() {
|
|
343
|
+
cleanupLinuxLaunchFiles(this.startup.files);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
function scopeArgs(unitBase, invocation, argv) {
|
|
347
|
+
return [
|
|
348
|
+
"--user",
|
|
349
|
+
"--scope",
|
|
350
|
+
"--quiet",
|
|
351
|
+
"--collect",
|
|
352
|
+
"--expand-environment=no",
|
|
353
|
+
`--unit=${unitBase}`,
|
|
354
|
+
"--",
|
|
355
|
+
...invocation,
|
|
356
|
+
"--",
|
|
357
|
+
...argv
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
function directOutcome(child, startup) {
|
|
361
|
+
return new Promise((resolveOutcome, rejectOutcome) => {
|
|
362
|
+
let settled = false;
|
|
363
|
+
child.once("error", (error) => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
rejectOutcome(error);
|
|
367
|
+
});
|
|
368
|
+
child.once("exit", (exitCode, signal) => {
|
|
369
|
+
if (settled) return;
|
|
370
|
+
settled = true;
|
|
371
|
+
try {
|
|
372
|
+
resolveOutcome(startup.resolveOutcome({
|
|
373
|
+
exitCode,
|
|
374
|
+
signal
|
|
375
|
+
}));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
rejectOutcome(error instanceof Error ? error : new Error(String(error)));
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function signalChildGroup(child, signal) {
|
|
383
|
+
try {
|
|
384
|
+
process.kill(-child.pid, signal);
|
|
385
|
+
} catch {
|
|
386
|
+
try {
|
|
387
|
+
child.kill(signal);
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Prepare one Linux PTY scope using the same launch request and bootstrap core.
|
|
393
|
+
* @param spec - terminal target request.
|
|
394
|
+
* @param targetEnv - validated complete target environment.
|
|
395
|
+
* @param internals - optional runner and systemd seams used by tests.
|
|
396
|
+
* @returns invocation and ownership callbacks; requested termination preserves the observed signal even before bootstrap consumption.
|
|
397
|
+
*/
|
|
398
|
+
function prepareLinuxTerminalScope(spec, targetEnv, internals = {}) {
|
|
399
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
400
|
+
const files = createLinuxLaunchFiles({
|
|
401
|
+
cwd: spec.cwd,
|
|
402
|
+
env: targetEnv
|
|
403
|
+
});
|
|
404
|
+
const startup = new LinuxScopeStartup(files, "terminal");
|
|
405
|
+
const unitBase = unitStem("dsh-terminal");
|
|
406
|
+
return {
|
|
407
|
+
command: internals.systemdRun ?? "systemd-run",
|
|
408
|
+
args: scopeArgs(unitBase, invocation, spec.argv),
|
|
409
|
+
cwd: process.cwd(),
|
|
410
|
+
env: runnerEnvironment(files.requestPath, invocation),
|
|
411
|
+
bindOwner: (direct) => new SystemdScopeOwner(`${unitBase}.scope`, startup, direct, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort),
|
|
412
|
+
resolveOutcome: (outcome) => startup.resolveOutcome(outcome),
|
|
413
|
+
cleanup: () => {
|
|
414
|
+
cleanupLinuxLaunchFiles(files);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Launch one ordinary target inside a transient user scope.
|
|
420
|
+
* @param spec - ordinary target request.
|
|
421
|
+
* @param targetEnv - validated complete target environment.
|
|
422
|
+
* @param internals - optional runner and systemd seams used by tests.
|
|
423
|
+
* @returns streams, result, and scope owner; requested termination preserves the observed signal even before bootstrap consumption.
|
|
424
|
+
*/
|
|
425
|
+
function launchLinuxScope(spec, targetEnv, internals = {}) {
|
|
426
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
427
|
+
const files = createLinuxLaunchFiles({
|
|
428
|
+
cwd: spec.cwd,
|
|
429
|
+
env: targetEnv
|
|
430
|
+
});
|
|
431
|
+
const startup = new LinuxScopeStartup(files, "subprocess");
|
|
432
|
+
const unitBase = unitStem("dsh-subprocess");
|
|
433
|
+
let child;
|
|
434
|
+
try {
|
|
435
|
+
child = (internals.spawn ?? spawn)(internals.systemdRun ?? "systemd-run", scopeArgs(unitBase, invocation, spec.argv), {
|
|
436
|
+
cwd: process.cwd(),
|
|
437
|
+
env: runnerEnvironment(files.requestPath, invocation),
|
|
438
|
+
stdio: runnerStdio(spec, false),
|
|
439
|
+
detached: true
|
|
440
|
+
});
|
|
441
|
+
} catch (error) {
|
|
442
|
+
cleanupLinuxLaunchFiles(files);
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
const owner = new SystemdScopeOwner(`${unitBase}.scope`, startup, {
|
|
446
|
+
running: () => child.pid !== void 0 && child.exitCode === null && child.signalCode === null,
|
|
447
|
+
signal: (signal) => {
|
|
448
|
+
signalChildGroup(child, signal);
|
|
449
|
+
}
|
|
450
|
+
}, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort);
|
|
451
|
+
return {
|
|
452
|
+
stdin: child.stdin,
|
|
453
|
+
stdout: child.stdout,
|
|
454
|
+
stderr: child.stderr,
|
|
455
|
+
direct: directOutcome(child, startup),
|
|
456
|
+
owner
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region lib/types/windows-job.js
|
|
461
|
+
/** Windows parent-side launch and ownership for the private Job runner. */
|
|
462
|
+
function isWindowsStartCancellationError(error) {
|
|
463
|
+
return error.name === "Error" && error.message === "subprocess target start was cancelled" && error.code === void 0 && error.syscall === void 0 && error.path === void 0;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Re-check the runner entry, bindings, and current Job capability for every spawn.
|
|
467
|
+
* @param internals - optional runner and Win32 capability seams used by tests.
|
|
468
|
+
* @returns whether the Windows native containment path is currently available.
|
|
469
|
+
*/
|
|
470
|
+
function probeWindowsJob(internals = {}) {
|
|
471
|
+
try {
|
|
472
|
+
const invocation = internals.runnerInvocation ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)();
|
|
473
|
+
if (!(internals.runnerAvailable ?? runnerInvocationAvailable)(invocation)) return false;
|
|
474
|
+
const api = (internals.loadWin32ProcessBindings ?? loadWin32ProcessBindings)();
|
|
475
|
+
(internals.probeCurrentTokenJobSupport ?? probeCurrentTokenJobSupport)(api);
|
|
476
|
+
return true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
var WindowsJobOwner = class {
|
|
482
|
+
runner;
|
|
483
|
+
exited;
|
|
484
|
+
directResultType;
|
|
485
|
+
failInfrastructure;
|
|
486
|
+
cancellationReason;
|
|
487
|
+
cancellationReasonSet = false;
|
|
488
|
+
terminationSent = false;
|
|
489
|
+
constructor(runner, exited, directResultType, failInfrastructure) {
|
|
490
|
+
this.runner = runner;
|
|
491
|
+
this.exited = exited;
|
|
492
|
+
this.directResultType = directResultType;
|
|
493
|
+
this.failInfrastructure = failInfrastructure;
|
|
494
|
+
this.exited.catch(() => {});
|
|
495
|
+
}
|
|
496
|
+
signal(_signal, cancellationReason) {
|
|
497
|
+
if (!this.cancellationReasonSet) {
|
|
498
|
+
this.cancellationReason = cancellationReason;
|
|
499
|
+
this.cancellationReasonSet = true;
|
|
500
|
+
}
|
|
501
|
+
if (this.terminationSent || !this.runner.connected) return;
|
|
502
|
+
this.terminationSent = true;
|
|
503
|
+
try {
|
|
504
|
+
this.runner.send?.({ type: "terminate" }, (error) => {
|
|
505
|
+
if (error === null || this.directResultType() !== void 0) return;
|
|
506
|
+
this.failInfrastructure(error);
|
|
507
|
+
this.terminateForHostExit();
|
|
508
|
+
});
|
|
509
|
+
} catch (error) {
|
|
510
|
+
this.failInfrastructure(error);
|
|
511
|
+
this.terminateForHostExit();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
mapStartFailure(failure, serialized) {
|
|
515
|
+
return this.cancellationReasonSet && isWindowsStartCancellationError(serialized) ? this.cancellationReason : failure;
|
|
516
|
+
}
|
|
517
|
+
async waitForExit() {
|
|
518
|
+
await this.exited;
|
|
519
|
+
}
|
|
520
|
+
terminateForHostExit() {
|
|
521
|
+
try {
|
|
522
|
+
this.runner.kill("SIGKILL");
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* Launch one target through a runner that uniquely owns its Job handle.
|
|
528
|
+
* @param spec - ordinary target request.
|
|
529
|
+
* @param targetEnv - validated complete target environment.
|
|
530
|
+
* @param internals - optional runner launch seams used by tests.
|
|
531
|
+
* @returns direct streams, result, and runner-owned managed range.
|
|
532
|
+
*/
|
|
533
|
+
function launchWindowsJob(spec, targetEnv, internals = {}) {
|
|
534
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
535
|
+
const [command, ...prefix] = invocation;
|
|
536
|
+
const ignoredStdinFd = spec.stdio.stdin === "ignore" ? openSync(devNull, "r") : void 0;
|
|
537
|
+
let child;
|
|
538
|
+
try {
|
|
539
|
+
child = (internals.spawn ?? spawn)(command, [
|
|
540
|
+
...prefix,
|
|
541
|
+
"--",
|
|
542
|
+
...spec.argv
|
|
543
|
+
], {
|
|
544
|
+
cwd: process.cwd(),
|
|
545
|
+
env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
|
|
546
|
+
stdio: runnerStdio(spec, true, ignoredStdinFd ?? "pipe")
|
|
547
|
+
});
|
|
548
|
+
} finally {
|
|
549
|
+
if (ignoredStdinFd !== void 0) closeSync(ignoredStdinFd);
|
|
550
|
+
}
|
|
551
|
+
const targetStdin = child.stdio[4];
|
|
552
|
+
const direct = Promise.withResolvers();
|
|
553
|
+
const rangeExit = Promise.withResolvers();
|
|
554
|
+
let directResultType;
|
|
555
|
+
let runnerSpawned = false;
|
|
556
|
+
const failInfrastructure = (error) => {
|
|
557
|
+
direct.reject(error);
|
|
558
|
+
rangeExit.reject(error);
|
|
559
|
+
};
|
|
560
|
+
const owner = new WindowsJobOwner(child, rangeExit.promise, () => directResultType, failInfrastructure);
|
|
561
|
+
child.on("message", (value) => {
|
|
562
|
+
if (directResultType !== void 0) {
|
|
563
|
+
failInfrastructure(/* @__PURE__ */ new Error("subprocess-local: Windows runner emitted more than one direct result"));
|
|
564
|
+
owner.terminateForHostExit();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
let result;
|
|
568
|
+
try {
|
|
569
|
+
result = parseWindowsRunnerResult(value);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
failInfrastructure(error);
|
|
572
|
+
owner.terminateForHostExit();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
directResultType = result.type;
|
|
576
|
+
if (result.type === "target-exit") direct.resolve({
|
|
577
|
+
exitCode: result.exitCode,
|
|
578
|
+
signal: null
|
|
579
|
+
});
|
|
580
|
+
else direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error), result.error));
|
|
581
|
+
});
|
|
582
|
+
child.once("spawn", () => {
|
|
583
|
+
runnerSpawned = true;
|
|
584
|
+
try {
|
|
585
|
+
if (child.send === void 0) throw new Error("subprocess-local: Windows runner has no IPC channel");
|
|
586
|
+
child.send({
|
|
587
|
+
type: "start",
|
|
588
|
+
cwd: spec.cwd,
|
|
589
|
+
env: targetEnv
|
|
590
|
+
}, (error) => {
|
|
591
|
+
if (error === null) return;
|
|
592
|
+
failInfrastructure(error);
|
|
593
|
+
owner.terminateForHostExit();
|
|
594
|
+
});
|
|
595
|
+
} catch (error) {
|
|
596
|
+
failInfrastructure(error);
|
|
597
|
+
owner.terminateForHostExit();
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
child.once("error", (error) => {
|
|
601
|
+
if (!runnerSpawned) {
|
|
602
|
+
direct.reject(error);
|
|
603
|
+
rangeExit.resolve();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
failInfrastructure(error);
|
|
607
|
+
});
|
|
608
|
+
child.once("close", (exitCode, signal) => {
|
|
609
|
+
if (!runnerSpawned) return;
|
|
610
|
+
if (exitCode === 0 && signal === null && directResultType !== void 0) {
|
|
611
|
+
rangeExit.resolve();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const status = signal !== null ? `signal ${signal}` : exitCode === null ? "without an exit status" : `exit code ${String(exitCode)}`;
|
|
615
|
+
failInfrastructure(/* @__PURE__ */ new Error(`subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`));
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
stdin: spec.stdio.stdin === "ignore" ? null : targetStdin,
|
|
619
|
+
stdout: child.stdio[5],
|
|
620
|
+
stderr: child.stdio[6],
|
|
621
|
+
direct: direct.promise,
|
|
622
|
+
owner
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region lib/types/terminal.js
|
|
627
|
+
/** Local node-pty terminal-process implementation for the subprocess seam. */
|
|
628
|
+
function delay(ms, signal) {
|
|
629
|
+
return new Promise((resolve) => {
|
|
630
|
+
const finish = () => {
|
|
631
|
+
clearTimeout(timer);
|
|
632
|
+
signal?.removeEventListener("abort", finish);
|
|
633
|
+
resolve();
|
|
634
|
+
};
|
|
635
|
+
const timer = setTimeout(finish, ms);
|
|
636
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
async function raceWithDelay(operation, ms, timeout) {
|
|
640
|
+
const controller = new AbortController();
|
|
641
|
+
try {
|
|
642
|
+
return await Promise.race([operation, delay(ms, controller.signal).then(() => timeout)]);
|
|
643
|
+
} finally {
|
|
644
|
+
controller.abort();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function signalName(number) {
|
|
648
|
+
if (number === void 0 || number === 0) return null;
|
|
649
|
+
for (const [name, value] of Object.entries(constants$1.signals)) if (value === number) return name;
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* A local terminal whose native managed range or fallback process-session
|
|
654
|
+
* ownership stays below the PTY backend.
|
|
655
|
+
* The seam's terminate() promise — no write, inspection, or signal in flight
|
|
656
|
+
* after settlement — holds here without operation tracking only because every
|
|
657
|
+
* handle call completes synchronously under the hood (node-pty write, ps-based
|
|
658
|
+
* inspection). A first genuinely asynchronous step in any handle call must add
|
|
659
|
+
* the tracking a remote provider needs.
|
|
660
|
+
*/
|
|
661
|
+
var LocalTerminalHandle = class {
|
|
662
|
+
terminal;
|
|
663
|
+
inspector;
|
|
664
|
+
graceMs;
|
|
665
|
+
platform;
|
|
666
|
+
managedOwner;
|
|
667
|
+
resolveManagedOutcome;
|
|
668
|
+
pid;
|
|
669
|
+
output = new PassThrough();
|
|
670
|
+
done;
|
|
671
|
+
outcome = Promise.withResolvers();
|
|
672
|
+
dataDisposable;
|
|
673
|
+
exitDisposable;
|
|
674
|
+
cleanup;
|
|
675
|
+
managedOwnerCleaned = false;
|
|
676
|
+
exited = false;
|
|
677
|
+
trackedDescendants = [];
|
|
678
|
+
/** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
|
|
679
|
+
rootIdentity;
|
|
680
|
+
/**
|
|
681
|
+
* @param terminal - allocated node-pty process.
|
|
682
|
+
* @param inspector - platform process/session operations.
|
|
683
|
+
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
|
684
|
+
* @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
|
|
685
|
+
*/
|
|
686
|
+
constructor(terminal, inspector, graceMs, platform = process.platform, managedOwner, resolveManagedOutcome) {
|
|
687
|
+
this.terminal = terminal;
|
|
688
|
+
this.inspector = inspector;
|
|
689
|
+
this.graceMs = graceMs;
|
|
690
|
+
this.platform = platform;
|
|
691
|
+
this.managedOwner = managedOwner;
|
|
692
|
+
this.resolveManagedOutcome = resolveManagedOutcome;
|
|
693
|
+
this.pid = terminal.pid;
|
|
694
|
+
this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
|
|
695
|
+
this.done = this.outcome.promise;
|
|
696
|
+
this.dataDisposable = terminal.onData((data) => {
|
|
697
|
+
this.output.write(Buffer.from(data, "utf8"));
|
|
698
|
+
});
|
|
699
|
+
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
|
|
700
|
+
if (this.exited) return;
|
|
701
|
+
this.exited = true;
|
|
702
|
+
this.output.end();
|
|
703
|
+
const outcome = {
|
|
704
|
+
exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
|
|
705
|
+
signal: signalName(exitSignal)
|
|
706
|
+
};
|
|
707
|
+
try {
|
|
708
|
+
this.outcome.resolve(this.resolveManagedOutcome?.(outcome) ?? outcome);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
this.outcome.reject(error);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/** Whether node-pty has not yet published the top-level exit event. */
|
|
715
|
+
get running() {
|
|
716
|
+
return !this.exited;
|
|
717
|
+
}
|
|
718
|
+
async write(data) {
|
|
719
|
+
if (this.exited) throw new Error("terminal process has exited");
|
|
720
|
+
this.terminal.write(data);
|
|
721
|
+
}
|
|
722
|
+
async inspectForeground() {
|
|
723
|
+
this.descendants(this.inspector.snapshot());
|
|
724
|
+
const processGroupId = this.inspector.foregroundPgid(this.pid);
|
|
725
|
+
if (processGroupId === void 0) return void 0;
|
|
726
|
+
return {
|
|
727
|
+
processGroupId,
|
|
728
|
+
inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
async signalForeground(signal) {
|
|
732
|
+
const foreground = await this.inspectForeground();
|
|
733
|
+
if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
|
|
734
|
+
if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
|
|
735
|
+
if (this.platform === "win32") {
|
|
736
|
+
if (signal === "SIGINT") {
|
|
737
|
+
this.terminal.write("");
|
|
738
|
+
return foreground.processGroupId;
|
|
739
|
+
}
|
|
740
|
+
if (signal === "SIGTSTP" || signal === "SIGHUP") throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`);
|
|
741
|
+
}
|
|
742
|
+
this.inspector.signalGroup(foreground.processGroupId, signal);
|
|
743
|
+
return foreground.processGroupId;
|
|
744
|
+
}
|
|
745
|
+
terminate() {
|
|
746
|
+
if (this.cleanup !== void 0) return this.cleanup;
|
|
747
|
+
const cleanup = this.closeOnce();
|
|
748
|
+
this.cleanup = cleanup;
|
|
749
|
+
cleanup.catch(() => {
|
|
750
|
+
this.cleanup = void 0;
|
|
751
|
+
});
|
|
752
|
+
return cleanup;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Force-terminate the observable session synchronously during Node's exit
|
|
756
|
+
* event. This does not claim quiescence and does not replace terminate().
|
|
757
|
+
*/
|
|
758
|
+
terminateForHostExit() {
|
|
759
|
+
this.forceStopDescendants();
|
|
760
|
+
this.forceStopShell();
|
|
761
|
+
this.forceStopDescendants();
|
|
762
|
+
this.managedOwner?.terminateForHostExit();
|
|
763
|
+
}
|
|
764
|
+
forceStopShell() {
|
|
765
|
+
if (this.exited) return;
|
|
766
|
+
if (this.rootIdentity !== void 0) {
|
|
767
|
+
try {
|
|
768
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
|
|
769
|
+
} catch (_rootExitedDuringHostExit) {}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
this.terminal.kill("SIGKILL");
|
|
774
|
+
} catch (_unidentifiedShellExitedDuringHostExit) {}
|
|
775
|
+
}
|
|
776
|
+
survivors(members, observed) {
|
|
777
|
+
return members.filter((member) => observed.alive(member));
|
|
778
|
+
}
|
|
779
|
+
descendants(observed) {
|
|
780
|
+
const tree = observed.tree(this.pid);
|
|
781
|
+
const root = tree.find((member) => member.pid === this.pid);
|
|
782
|
+
const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
|
|
783
|
+
this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, observed.session(this.pid)] : []).filter((member) => member.pid !== this.pid), observed);
|
|
784
|
+
return this.trackedDescendants;
|
|
785
|
+
}
|
|
786
|
+
async waitForMembers(members) {
|
|
787
|
+
if (members.length === 0) return [];
|
|
788
|
+
const until = Date.now() + this.graceMs;
|
|
789
|
+
let survivors = this.survivors(members, this.inspector.snapshot());
|
|
790
|
+
while (survivors.length > 0 && Date.now() < until) {
|
|
791
|
+
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
792
|
+
survivors = this.survivors(members, this.inspector.snapshot());
|
|
793
|
+
}
|
|
794
|
+
return survivors;
|
|
795
|
+
}
|
|
796
|
+
signalMembers(members, signal) {
|
|
797
|
+
for (const member of members) try {
|
|
798
|
+
this.inspector.signalProcess(member, signal);
|
|
799
|
+
} catch (_alreadyExitedDuringSignal) {}
|
|
800
|
+
}
|
|
801
|
+
forceStopDescendants() {
|
|
802
|
+
let members = this.trackedDescendants;
|
|
803
|
+
try {
|
|
804
|
+
members = this.descendants(this.inspector.snapshot());
|
|
805
|
+
} catch (_processTableUnavailableDuringHostExit) {}
|
|
806
|
+
this.signalMembers(members, "SIGKILL");
|
|
807
|
+
}
|
|
808
|
+
unionMembers(...groups) {
|
|
809
|
+
const members = [];
|
|
810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
811
|
+
for (const group of groups) for (const member of group) {
|
|
812
|
+
const key = `${member.pid}:${member.started}`;
|
|
813
|
+
if (seen.has(key)) continue;
|
|
814
|
+
seen.add(key);
|
|
815
|
+
members.push(member);
|
|
816
|
+
}
|
|
817
|
+
return members;
|
|
818
|
+
}
|
|
819
|
+
async stopDescendants() {
|
|
820
|
+
const captured = this.descendants(this.inspector.snapshot());
|
|
821
|
+
this.signalMembers(captured, "SIGTERM");
|
|
822
|
+
const capturedSurvivors = await this.waitForMembers(captured);
|
|
823
|
+
const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()));
|
|
824
|
+
this.signalMembers(members, "SIGKILL");
|
|
825
|
+
const survivors = await this.waitForMembers(members);
|
|
826
|
+
const observed = this.inspector.snapshot();
|
|
827
|
+
return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed);
|
|
828
|
+
}
|
|
829
|
+
async stopShell() {
|
|
830
|
+
if (this.platform === "win32") {
|
|
831
|
+
await this.stopShellWindows();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!this.exited) {
|
|
835
|
+
try {
|
|
836
|
+
this.terminal.kill("SIGTERM");
|
|
837
|
+
} catch (_topLevelAlreadyExitedDuringTerm) {}
|
|
838
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
839
|
+
}
|
|
840
|
+
if (!this.exited) {
|
|
841
|
+
try {
|
|
842
|
+
this.terminal.kill("SIGKILL");
|
|
843
|
+
} catch (_topLevelAlreadyExitedDuringKill) {}
|
|
844
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
845
|
+
}
|
|
846
|
+
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
847
|
+
}
|
|
848
|
+
async stopShellWindows() {
|
|
849
|
+
const shellGone = () => this.exited || this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity);
|
|
850
|
+
if (!shellGone() && this.rootIdentity !== void 0) {
|
|
851
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGTERM");
|
|
852
|
+
await this.waitForWindowsShellExit();
|
|
853
|
+
}
|
|
854
|
+
if (!shellGone() && this.rootIdentity === void 0) {
|
|
855
|
+
try {
|
|
856
|
+
this.terminal.kill();
|
|
857
|
+
} catch (_topLevelAlreadyExitedDuringKill) {}
|
|
858
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
859
|
+
}
|
|
860
|
+
if (!shellGone() && this.rootIdentity !== void 0) {
|
|
861
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
|
|
862
|
+
await this.waitForWindowsShellExit();
|
|
863
|
+
}
|
|
864
|
+
if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
865
|
+
}
|
|
866
|
+
async waitForWindowsShellExit() {
|
|
867
|
+
const until = Date.now() + this.graceMs;
|
|
868
|
+
while (!this.exited && Date.now() < until) {
|
|
869
|
+
if (this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity)) return;
|
|
870
|
+
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async closeOnce() {
|
|
874
|
+
if (this.managedOwner !== void 0) {
|
|
875
|
+
try {
|
|
876
|
+
await this.closeManagedRange(this.managedOwner);
|
|
877
|
+
this.dataDisposable.dispose();
|
|
878
|
+
this.exitDisposable.dispose();
|
|
879
|
+
} finally {
|
|
880
|
+
this.done.finally(() => {
|
|
881
|
+
this.cleanupManagedOwner(this.managedOwner);
|
|
882
|
+
}).catch(() => {});
|
|
883
|
+
}
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
let survivors = await this.stopDescendants();
|
|
887
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
888
|
+
await this.stopShell();
|
|
889
|
+
survivors = await this.stopDescendants();
|
|
890
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
891
|
+
this.settleExitIfGone();
|
|
892
|
+
this.dataDisposable.dispose();
|
|
893
|
+
this.exitDisposable.dispose();
|
|
894
|
+
}
|
|
895
|
+
cleanupManagedOwner(owner) {
|
|
896
|
+
if (this.managedOwnerCleaned) return;
|
|
897
|
+
this.managedOwnerCleaned = true;
|
|
898
|
+
owner.cleanup?.();
|
|
899
|
+
}
|
|
900
|
+
async closeManagedRange(owner) {
|
|
901
|
+
owner.signal("SIGTERM");
|
|
902
|
+
const observation = owner.waitForExit();
|
|
903
|
+
const first = await raceWithDelay(observation.then(() => ({ kind: "stopped" }), (error) => ({
|
|
904
|
+
kind: "failed",
|
|
905
|
+
error
|
|
906
|
+
})), this.graceMs, { kind: "timeout" });
|
|
907
|
+
if (first.kind !== "stopped") {
|
|
908
|
+
owner.signal("SIGKILL");
|
|
909
|
+
if (first.kind === "failed") {
|
|
910
|
+
try {
|
|
911
|
+
await owner.waitForExit();
|
|
912
|
+
} catch (finalError) {
|
|
913
|
+
throw new AggregateError([first.error, finalError], "terminal managed-range cleanup failed");
|
|
914
|
+
}
|
|
915
|
+
throw first.error;
|
|
916
|
+
}
|
|
917
|
+
await observation;
|
|
918
|
+
}
|
|
919
|
+
if (!this.exited) await raceWithDelay(this.done.then(() => void 0), this.graceMs, void 0);
|
|
920
|
+
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
921
|
+
}
|
|
922
|
+
settleExitIfGone() {
|
|
923
|
+
if (this.platform !== "win32") return;
|
|
924
|
+
if (this.exited) return;
|
|
925
|
+
/* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
|
|
926
|
+
the identity re-check is a defensive fence for a future caller. */
|
|
927
|
+
if (this.rootIdentity !== void 0 && this.inspector.isAlive(this.rootIdentity)) return;
|
|
928
|
+
this.exited = true;
|
|
929
|
+
this.output.end();
|
|
930
|
+
this.outcome.resolve({
|
|
931
|
+
exitCode: null,
|
|
932
|
+
signal: null
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region lib/types/index.js
|
|
938
|
+
/**
|
|
939
|
+
* Local Service Provider for the subprocess capability seam. Each spawn owns a
|
|
940
|
+
* platform-selected managed range with the spec's per-stream stdio dispositions.
|
|
941
|
+
* Normal disposal terminates and joins live ranges; Node's synchronous exit
|
|
942
|
+
* phase force-stops any ranges the service still owns. It has no config: every
|
|
943
|
+
* disposition and limit arrives on the spec, so deployment-varying choices
|
|
944
|
+
* stay with the caller's config (the bash executor's, the LSP host's, …).
|
|
945
|
+
* @module @x1a0f3n9/dsh-subprocess-local
|
|
946
|
+
*/
|
|
947
|
+
/**
|
|
948
|
+
* Local subprocess service: platform-selected managed ranges, Node-shaped stdio
|
|
949
|
+
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
|
|
950
|
+
* files), credential-scrubbed environment, and provider-owned range signalling.
|
|
951
|
+
* POSIX paths stage TERM before KILL; Windows paths terminate immediately.
|
|
952
|
+
* JavaScript-observable host exit also performs synchronous final termination.
|
|
953
|
+
*/
|
|
954
|
+
var LocalSubprocessRuntime = class extends SubprocessRuntime {
|
|
955
|
+
/** Live handles retained for normal disposal and synchronous host-exit finalization. */
|
|
956
|
+
live = /* @__PURE__ */ new Set();
|
|
957
|
+
/** Live terminals retained through normal quiescence or host-exit finalization. */
|
|
958
|
+
terminals = /* @__PURE__ */ new Set();
|
|
959
|
+
/** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
|
|
960
|
+
internals = {};
|
|
961
|
+
/** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
|
|
962
|
+
fallbackWarningIssued = false;
|
|
963
|
+
/** Positive-only cache for the expensive Linux bootstrap and scope probe. */
|
|
964
|
+
linuxDeepProbePassed = false;
|
|
965
|
+
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
|
|
966
|
+
terminalInspector;
|
|
967
|
+
constructor(ctx) {
|
|
968
|
+
super(ctx);
|
|
969
|
+
ctx.effect(() => {
|
|
970
|
+
const onHostExit = () => {
|
|
971
|
+
this.terminateForHostExit();
|
|
972
|
+
};
|
|
973
|
+
process.prependListener("exit", onHostExit);
|
|
974
|
+
return async () => {
|
|
975
|
+
await this.disposeManagedProcesses();
|
|
976
|
+
process.off("exit", onHostExit);
|
|
977
|
+
};
|
|
978
|
+
}, "local subprocess teardown");
|
|
979
|
+
}
|
|
980
|
+
terminateForHostExit() {
|
|
981
|
+
for (const handle of this.live) try {
|
|
982
|
+
handle.terminateForHostExit();
|
|
983
|
+
} catch (_ordinaryRangeTerminationFailed) {}
|
|
984
|
+
for (const terminal of this.terminals) try {
|
|
985
|
+
terminal.terminateForHostExit();
|
|
986
|
+
} catch (_terminalTerminationFailed) {}
|
|
987
|
+
}
|
|
988
|
+
async disposeManagedProcesses() {
|
|
989
|
+
const pending = [];
|
|
990
|
+
for (const handle of this.live) {
|
|
991
|
+
handle.terminate();
|
|
992
|
+
pending.push(Promise.all([handle.done.catch(() => {}), handle.waitForExit()]).then(() => {
|
|
993
|
+
this.live.delete(handle);
|
|
994
|
+
}));
|
|
995
|
+
}
|
|
996
|
+
for (const terminal of this.terminals) pending.push(terminal.terminate().then(() => {
|
|
997
|
+
this.terminals.delete(terminal);
|
|
998
|
+
}));
|
|
999
|
+
const outcomes = await Promise.allSettled(pending);
|
|
1000
|
+
const failures = [];
|
|
1001
|
+
for (const outcome of outcomes) if (outcome.status === "rejected") failures.push(outcome.reason);
|
|
1002
|
+
if (failures.length > 0) this.terminateForHostExit();
|
|
1003
|
+
if (failures.length === 1) throw failures[0];
|
|
1004
|
+
if (failures.length > 1) throw new AggregateError(failures, "local subprocess teardown failed");
|
|
1005
|
+
}
|
|
1006
|
+
async resolveExecutable(command, env, signal) {
|
|
1007
|
+
if (command.length === 0) throw new Error("subprocess-local: executable must be non-empty");
|
|
1008
|
+
signal?.throwIfAborted();
|
|
1009
|
+
const environment = childEnv(env);
|
|
1010
|
+
const absolute = isAbsolute(command);
|
|
1011
|
+
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`);
|
|
1012
|
+
const candidates = absolute ? [command] : this.executableCandidates(command, environment);
|
|
1013
|
+
for (const candidate of candidates) {
|
|
1014
|
+
signal?.throwIfAborted();
|
|
1015
|
+
try {
|
|
1016
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
1017
|
+
await access(candidate, constants.X_OK);
|
|
1018
|
+
signal?.throwIfAborted();
|
|
1019
|
+
return candidate;
|
|
1020
|
+
} catch {}
|
|
1021
|
+
}
|
|
1022
|
+
signal?.throwIfAborted();
|
|
1023
|
+
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`);
|
|
1024
|
+
}
|
|
1025
|
+
executableCandidates(command, env) {
|
|
1026
|
+
const path = environmentValue(env, "PATH") ?? "";
|
|
1027
|
+
const extensions = process.platform === "win32" && extname(command) === "" ? (environmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
1028
|
+
return path.split(delimiter).flatMap((directory) => extensions.map((extension) => resolve(process.cwd(), directory, command + extension)));
|
|
1029
|
+
}
|
|
1030
|
+
spawn(spec) {
|
|
1031
|
+
validateSubprocessSpec(spec);
|
|
1032
|
+
const env = targetEnvironment(spec);
|
|
1033
|
+
const containmentMode = this.selectContainmentMode("ordinary");
|
|
1034
|
+
let handle;
|
|
1035
|
+
if (containmentMode === "fallback") handle = spawnSubprocess(spec, this.internals);
|
|
1036
|
+
else {
|
|
1037
|
+
const binding = prepareManagedProcessBinding(this.internals);
|
|
1038
|
+
handle = bindManagedProcess(spec, containmentMode === "linux-scope" ? launchLinuxScope(spec, env) : launchWindowsJob(spec, env), binding);
|
|
1039
|
+
}
|
|
1040
|
+
this.live.add(handle);
|
|
1041
|
+
const release = () => handle.waitForExit().then(() => {
|
|
1042
|
+
this.live.delete(handle);
|
|
1043
|
+
});
|
|
1044
|
+
handle.done.then(release, release).catch(() => {});
|
|
1045
|
+
return handle;
|
|
1046
|
+
}
|
|
1047
|
+
selectContainmentMode(kind) {
|
|
1048
|
+
const platform = this.internals.platform ?? process.platform;
|
|
1049
|
+
let fallbackReason;
|
|
1050
|
+
if (platform === "linux") {
|
|
1051
|
+
const available = this.linuxDeepProbePassed ? probeLinuxManager() : probeLinuxNative();
|
|
1052
|
+
if (available) this.linuxDeepProbePassed = true;
|
|
1053
|
+
if (available) return "linux-scope";
|
|
1054
|
+
fallbackReason = "the current user-systemd scope or private bootstrap is unavailable";
|
|
1055
|
+
}
|
|
1056
|
+
if (kind === "ordinary" && platform === "win32") {
|
|
1057
|
+
if (probeWindowsJob()) return "windows-job";
|
|
1058
|
+
}
|
|
1059
|
+
this.warnFallback(platform, kind, fallbackReason);
|
|
1060
|
+
return "fallback";
|
|
1061
|
+
}
|
|
1062
|
+
warnFallback(platform, kind, selectedReason) {
|
|
1063
|
+
if (this.fallbackWarningIssued) return;
|
|
1064
|
+
this.fallbackWarningIssued = true;
|
|
1065
|
+
const reason = selectedReason ?? (platform === "darwin" ? "macOS has no supported persistent process-range owner" : platform === "win32" ? kind === "terminal" ? "Windows ConPTY remains outside Job containment" : "the Win32 Job runner is unavailable" : `platform ${platform} has no native managed range`);
|
|
1066
|
+
this.ctx.logger.warn(`subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`);
|
|
1067
|
+
}
|
|
1068
|
+
async spawnTerminal(spec) {
|
|
1069
|
+
const file = spec.argv[0];
|
|
1070
|
+
if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
|
|
1071
|
+
spec.signal?.throwIfAborted();
|
|
1072
|
+
const env = targetEnvironment(spec);
|
|
1073
|
+
const options = {
|
|
1074
|
+
name: "dumb",
|
|
1075
|
+
rows: spec.rows,
|
|
1076
|
+
cols: spec.cols,
|
|
1077
|
+
cwd: spec.cwd,
|
|
1078
|
+
env
|
|
1079
|
+
};
|
|
1080
|
+
const inspector = this.terminalInspector ?? createProcessInspector();
|
|
1081
|
+
const scope = this.selectContainmentMode("terminal") === "linux-scope" ? prepareLinuxTerminalScope(spec, {
|
|
1082
|
+
...env,
|
|
1083
|
+
PWD: spec.cwd,
|
|
1084
|
+
TERM: "dumb"
|
|
1085
|
+
}) : void 0;
|
|
1086
|
+
if (scope !== void 0) {
|
|
1087
|
+
options.cwd = scope.cwd;
|
|
1088
|
+
options.env = scope.env;
|
|
1089
|
+
}
|
|
1090
|
+
let terminal;
|
|
1091
|
+
try {
|
|
1092
|
+
terminal = nodePty.spawn(scope?.command ?? file, scope?.args ?? [...spec.argv.slice(1)], options);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
scope?.cleanup();
|
|
1095
|
+
throw error;
|
|
1096
|
+
}
|
|
1097
|
+
let handle;
|
|
1098
|
+
const owner = scope?.bindOwner({
|
|
1099
|
+
running: () => handle?.running ?? true,
|
|
1100
|
+
signal: (signal) => {
|
|
1101
|
+
try {
|
|
1102
|
+
terminal.kill(signal);
|
|
1103
|
+
} catch {}
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, this.internals.platform ?? process.platform, owner, scope?.resolveOutcome);
|
|
1107
|
+
this.terminals.add(handle);
|
|
1108
|
+
const release = async () => {
|
|
1109
|
+
await handle.terminate();
|
|
1110
|
+
this.terminals.delete(handle);
|
|
1111
|
+
};
|
|
1112
|
+
handle.done.then(release, release).catch(() => {});
|
|
1113
|
+
return handle;
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
/** Read a Windows environment key using the platform's case-insensitive semantics. */
|
|
1117
|
+
function environmentValue(env, name) {
|
|
1118
|
+
const exact = env[name];
|
|
1119
|
+
if (exact !== void 0 || process.platform !== "win32") return exact;
|
|
1120
|
+
const normalized = name.toUpperCase();
|
|
1121
|
+
return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1];
|
|
1122
|
+
}
|
|
1123
|
+
//#endregion
|
|
1124
|
+
export { LocalSubprocessRuntime, LocalSubprocessRuntime as default };
|