@qlzt/dsh-subprocess-local 0.0.0-stage → 0.2.1-alpha.3
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 +70 -0
- package/README.md +167 -2
- package/README.zh.md +168 -0
- package/lib/index.js +1487 -0
- package/lib/output.js +239 -0
- package/lib/runner-launch-B2zsQ1Dz.js +1529 -0
- package/lib/runner.js +436 -0
- package/lib/types/bin.d.ts +7 -0
- package/lib/types/control-spawn.d.ts +19 -0
- package/lib/types/index.d.ts +52 -0
- package/lib/types/linux-execve.d.ts +9 -0
- package/lib/types/linux-scope.d.ts +91 -0
- package/lib/types/managed-owner.d.ts +36 -0
- package/lib/types/output.d.ts +137 -0
- package/lib/types/process-inspector.d.ts +138 -0
- package/lib/types/runner-launch.d.ts +68 -0
- package/lib/types/runner-protocol.d.ts +111 -0
- package/lib/types/shell-activity.d.ts +37 -0
- package/lib/types/spawn-runner.d.ts +36 -0
- package/lib/types/spawn.d.ts +86 -0
- package/lib/types/terminal.d.ts +80 -0
- package/lib/types/windows-inspector.d.ts +79 -0
- package/lib/types/windows-job.d.ts +30 -0
- package/package.json +60 -4
- package/scripts/ensure-spawn-helper.mjs +16 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import { C as bindManagedProcess, D as createProcessInspector, E as validateSubprocessSpec, O as controlPipe, S as loadLinuxExecve, T as spawnSubprocess, _ 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-B2zsQ1Dz.js";
|
|
2
|
+
import { logSpillFailure, prepareManagedProcessBinding } from "./output.js";
|
|
3
|
+
import { closeSync, constants, existsSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { access, stat } from "node:fs/promises";
|
|
5
|
+
import { constants as constants$1, devNull, tmpdir, userInfo } from "node:os";
|
|
6
|
+
import { basename, delimiter, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { createLazyRequire } from "@qlzt/dsh-lazy-require";
|
|
8
|
+
import { SubprocessExecutableNotFoundError, SubprocessRuntime } from "@qlzt/dsh-subprocess";
|
|
9
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { loadWin32ProcessBindings, probeCurrentTokenJobSupport } from "@qlzt/dsh-win32-process";
|
|
13
|
+
import { Buffer } from "node:buffer";
|
|
14
|
+
import { PassThrough } from "node:stream";
|
|
15
|
+
//#region lib/types/linux-scope.js
|
|
16
|
+
/** Linux user-systemd scope launch and managed-range ownership. */
|
|
17
|
+
const SYSTEMCTL_TIMEOUT_MS = 5e3;
|
|
18
|
+
const SCOPE_INITIAL_POLL_INTERVAL_MS = 50;
|
|
19
|
+
const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu;
|
|
20
|
+
function managerEnvironment() {
|
|
21
|
+
const environment = childEnv({ LC_ALL: "C" });
|
|
22
|
+
delete environment.SYSTEMD_LOG_TARGET;
|
|
23
|
+
return environment;
|
|
24
|
+
}
|
|
25
|
+
function quietSystemdEnvironment() {
|
|
26
|
+
return childEnv({
|
|
27
|
+
LC_ALL: "C",
|
|
28
|
+
SYSTEMD_LOG_TARGET: "null"
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function querySystemctl(command, args) {
|
|
32
|
+
return new Promise((resolveResult) => {
|
|
33
|
+
execFile(command, [...args], {
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
env: managerEnvironment(),
|
|
36
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
37
|
+
}, (error, stdout, stderr) => {
|
|
38
|
+
const code = error === null ? 0 : error.code;
|
|
39
|
+
resolveResult({
|
|
40
|
+
status: typeof code === "number" ? code : null,
|
|
41
|
+
stdout,
|
|
42
|
+
stderr,
|
|
43
|
+
...error === null ? {} : { error }
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function unitStem(prefix) {
|
|
49
|
+
return `${prefix}-${String(process.pid)}-${randomBytes(6).toString("hex")}`;
|
|
50
|
+
}
|
|
51
|
+
function sleepWithAbort(delayMs, signal) {
|
|
52
|
+
return setTimeout$1(delayMs, void 0, { signal });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Confirm this exact runner entry and libc execve binding without a probe mode.
|
|
56
|
+
* @param internals - optional runner and libc-binding seams used by tests.
|
|
57
|
+
* @returns whether the bootstrap can enter the final target.
|
|
58
|
+
*/
|
|
59
|
+
function probeLinuxBootstrap(internals = {}) {
|
|
60
|
+
try {
|
|
61
|
+
(internals.loadLinuxExecve ?? loadLinuxExecve)();
|
|
62
|
+
const invocation = internals.runnerInvocation ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)();
|
|
63
|
+
return (internals.runnerAvailable ?? runnerInvocationAvailable)(invocation);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Confirm current literal-argv transient-scope support before selecting native launch.
|
|
70
|
+
* @param internals - optional systemd command seams used by tests.
|
|
71
|
+
* @returns whether the current user manager supports the required scope invocation.
|
|
72
|
+
*/
|
|
73
|
+
function probeLinuxScope(internals = {}) {
|
|
74
|
+
const unitBase = unitStem("dsh-subprocess-probe");
|
|
75
|
+
const result = (internals.spawnSync ?? spawnSync)(internals.systemdRun ?? "systemd-run", [
|
|
76
|
+
"--user",
|
|
77
|
+
"--scope",
|
|
78
|
+
"--quiet",
|
|
79
|
+
"--collect",
|
|
80
|
+
"--expand-environment=no",
|
|
81
|
+
`--unit=${unitBase}`,
|
|
82
|
+
"--",
|
|
83
|
+
internals.systemctl ?? "systemctl",
|
|
84
|
+
"--user",
|
|
85
|
+
"show",
|
|
86
|
+
`${unitBase}.scope`,
|
|
87
|
+
"--property=ActiveState",
|
|
88
|
+
"--value"
|
|
89
|
+
], {
|
|
90
|
+
env: quietSystemdEnvironment(),
|
|
91
|
+
stdio: "ignore",
|
|
92
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
93
|
+
});
|
|
94
|
+
return result.error === void 0 && result.status === 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Confirm that the current user manager remains reachable after a positive deep probe.
|
|
98
|
+
* @param internals - optional systemctl seam used by tests.
|
|
99
|
+
* @returns whether one lightweight manager query succeeds.
|
|
100
|
+
*/
|
|
101
|
+
function probeLinuxManager(internals = {}) {
|
|
102
|
+
const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? "systemctl", [
|
|
103
|
+
"--user",
|
|
104
|
+
"show",
|
|
105
|
+
"--property=Version",
|
|
106
|
+
"--value"
|
|
107
|
+
], {
|
|
108
|
+
env: managerEnvironment(),
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
111
|
+
});
|
|
112
|
+
return result.error === void 0 && result.status === 0;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Re-check every Linux native prerequisite for one eligible spawn.
|
|
116
|
+
* @param internals - optional native capability seams used by tests.
|
|
117
|
+
* @returns whether the Linux native containment path is currently available.
|
|
118
|
+
*/
|
|
119
|
+
function probeLinuxNative(internals = {}) {
|
|
120
|
+
return probeLinuxBootstrap(internals) && probeLinuxScope(internals);
|
|
121
|
+
}
|
|
122
|
+
var LinuxScopeStartup = class {
|
|
123
|
+
files;
|
|
124
|
+
kind;
|
|
125
|
+
terminationSignals = /* @__PURE__ */ new Set();
|
|
126
|
+
constructor(files, kind) {
|
|
127
|
+
this.files = files;
|
|
128
|
+
this.kind = kind;
|
|
129
|
+
}
|
|
130
|
+
resolveOutcome(outcome) {
|
|
131
|
+
const startup = readLinuxStartupError(this.files.startupErrorPath);
|
|
132
|
+
if (startup !== void 0) throw deserializeRunnerError(startup.error);
|
|
133
|
+
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`);
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var SystemdScopeOwner = class {
|
|
138
|
+
unit;
|
|
139
|
+
startup;
|
|
140
|
+
direct;
|
|
141
|
+
systemctl;
|
|
142
|
+
runSync;
|
|
143
|
+
query;
|
|
144
|
+
sleep;
|
|
145
|
+
establishment = "pending";
|
|
146
|
+
stopped = false;
|
|
147
|
+
terminationRequested = false;
|
|
148
|
+
observation;
|
|
149
|
+
killFailure;
|
|
150
|
+
directKillSettlement;
|
|
151
|
+
wakeGeneration = 0;
|
|
152
|
+
wakeWaiter;
|
|
153
|
+
constructor(unit, startup, direct, systemctl, runSync, query, sleep) {
|
|
154
|
+
this.unit = unit;
|
|
155
|
+
this.startup = startup;
|
|
156
|
+
this.direct = direct;
|
|
157
|
+
this.systemctl = systemctl;
|
|
158
|
+
this.runSync = runSync;
|
|
159
|
+
this.query = query;
|
|
160
|
+
this.sleep = sleep;
|
|
161
|
+
}
|
|
162
|
+
inspectTaskCount() {
|
|
163
|
+
const result = this.runSync(this.systemctl, [
|
|
164
|
+
"--user",
|
|
165
|
+
"show",
|
|
166
|
+
"--property=LoadState",
|
|
167
|
+
"--property=ActiveState",
|
|
168
|
+
"--property=TasksCurrent",
|
|
169
|
+
this.unit
|
|
170
|
+
], {
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
env: managerEnvironment(),
|
|
173
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
174
|
+
});
|
|
175
|
+
if (result.error !== void 0 || result.status !== 0 || typeof result.stdout !== "string") return void 0;
|
|
176
|
+
const state = this.parseUnitState(result.stdout);
|
|
177
|
+
return state.loadState === "loaded" && state.activeState === "active" ? state.tasksCurrent : void 0;
|
|
178
|
+
}
|
|
179
|
+
signal(signal) {
|
|
180
|
+
if (this.stopped) return;
|
|
181
|
+
this.terminationRequested = true;
|
|
182
|
+
if (this.direct.running()) this.startup.terminationSignals.add(signal);
|
|
183
|
+
this.observeRequestConsumption();
|
|
184
|
+
const directFallbackRequired = this.establishment === "pending";
|
|
185
|
+
let directSignalled = false;
|
|
186
|
+
if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal);
|
|
187
|
+
const result = this.runSync(this.systemctl, [
|
|
188
|
+
"--user",
|
|
189
|
+
"kill",
|
|
190
|
+
"--kill-whom=all",
|
|
191
|
+
`--signal=${signal}`,
|
|
192
|
+
this.unit
|
|
193
|
+
], {
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
env: managerEnvironment(),
|
|
196
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
197
|
+
});
|
|
198
|
+
this.wakeObservation();
|
|
199
|
+
if (result.error === void 0 && result.status === 0) {
|
|
200
|
+
if (signal === "SIGKILL") {
|
|
201
|
+
this.killFailure = void 0;
|
|
202
|
+
this.directKillSettlement = void 0;
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal);
|
|
207
|
+
if (signal === "SIGKILL") {
|
|
208
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
209
|
+
if (!MISSING_UNIT.test(output)) {
|
|
210
|
+
this.killFailure = result.error ?? /* @__PURE__ */ new Error(`systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
|
|
211
|
+
this.directKillSettlement = directSignalled ? this.direct.settled.then(() => {}, () => {}) : void 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
terminateForHostExit() {
|
|
216
|
+
if (this.stopped) return;
|
|
217
|
+
try {
|
|
218
|
+
if (this.direct.running()) this.direct.signal("SIGKILL");
|
|
219
|
+
} catch {}
|
|
220
|
+
try {
|
|
221
|
+
this.runSync(this.systemctl, [
|
|
222
|
+
"--user",
|
|
223
|
+
"kill",
|
|
224
|
+
"--kill-whom=all",
|
|
225
|
+
"--signal=SIGKILL",
|
|
226
|
+
this.unit
|
|
227
|
+
], {
|
|
228
|
+
env: managerEnvironment(),
|
|
229
|
+
stdio: "ignore",
|
|
230
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
231
|
+
});
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
observeRequestConsumption() {
|
|
235
|
+
if (this.establishment === "pending" && !existsSync(this.startup.files.requestPath)) this.establishment = "established";
|
|
236
|
+
}
|
|
237
|
+
absentUnit() {
|
|
238
|
+
this.observeRequestConsumption();
|
|
239
|
+
if (this.establishment === "established") return false;
|
|
240
|
+
if (!this.direct.running() && existsSync(this.startup.files.requestPath)) return false;
|
|
241
|
+
if (this.killFailure !== void 0) throw this.killFailure;
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Prove an active unit with no processes is the empty managed range rather
|
|
246
|
+
* than a launch still placing its payload. systemd ends a scope only on the
|
|
247
|
+
* populated-to-empty transition, so a payload killed before it entered the
|
|
248
|
+
* cgroup leaves the unit active forever. A departed client cannot add another
|
|
249
|
+
* payload; a consumed request proves the payload already entered the scope,
|
|
250
|
+
* even while its direct-process exit notification is pending.
|
|
251
|
+
*/
|
|
252
|
+
emptyRange(tasksCurrent) {
|
|
253
|
+
return this.terminationRequested && tasksCurrent === 0 && (!this.direct.running() || !existsSync(this.startup.files.requestPath));
|
|
254
|
+
}
|
|
255
|
+
/** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
|
|
256
|
+
releaseEmptyRange() {
|
|
257
|
+
try {
|
|
258
|
+
this.runSync(this.systemctl, [
|
|
259
|
+
"--user",
|
|
260
|
+
"stop",
|
|
261
|
+
this.unit
|
|
262
|
+
], {
|
|
263
|
+
env: managerEnvironment(),
|
|
264
|
+
stdio: "ignore",
|
|
265
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
266
|
+
});
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
parseUnitState(stdout) {
|
|
270
|
+
const values = /* @__PURE__ */ new Map();
|
|
271
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
272
|
+
if (line === "") continue;
|
|
273
|
+
const separator = line.indexOf("=");
|
|
274
|
+
if (separator <= 0) throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`);
|
|
275
|
+
const name = line.slice(0, separator);
|
|
276
|
+
if (values.has(name)) throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`);
|
|
277
|
+
values.set(name, line.slice(separator + 1));
|
|
278
|
+
}
|
|
279
|
+
const loadState = values.get("LoadState");
|
|
280
|
+
const activeState = values.get("ActiveState");
|
|
281
|
+
const reportedTasks = values.get("TasksCurrent");
|
|
282
|
+
const tasksCurrent = reportedTasks === "[not set]" ? void 0 : reportedTasks;
|
|
283
|
+
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())}`);
|
|
284
|
+
if (tasksCurrent !== void 0 && !/^\d+$/u.test(tasksCurrent)) throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`);
|
|
285
|
+
return {
|
|
286
|
+
loadState,
|
|
287
|
+
activeState,
|
|
288
|
+
tasksCurrent: tasksCurrent === void 0 ? void 0 : Number(tasksCurrent)
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async rangeActive() {
|
|
292
|
+
this.observeRequestConsumption();
|
|
293
|
+
const generation = this.wakeGeneration;
|
|
294
|
+
const directRunning = this.direct.running();
|
|
295
|
+
const result = await this.query(this.systemctl, [
|
|
296
|
+
"--user",
|
|
297
|
+
"show",
|
|
298
|
+
this.unit,
|
|
299
|
+
"--property=LoadState",
|
|
300
|
+
"--property=ActiveState",
|
|
301
|
+
"--property=TasksCurrent"
|
|
302
|
+
]);
|
|
303
|
+
if (generation !== this.wakeGeneration) return true;
|
|
304
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
305
|
+
if (result.status === 0) {
|
|
306
|
+
const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout);
|
|
307
|
+
if (loadState === "not-found" && activeState === "inactive") return this.absentUnit();
|
|
308
|
+
if (loadState !== "loaded") throw new Error(`systemctl returned unknown state for ${this.unit}: ${JSON.stringify({
|
|
309
|
+
loadState,
|
|
310
|
+
activeState
|
|
311
|
+
})}`);
|
|
312
|
+
this.establishment = "established";
|
|
313
|
+
if (activeState === "inactive" || activeState === "failed") return false;
|
|
314
|
+
if (![
|
|
315
|
+
"active",
|
|
316
|
+
"activating",
|
|
317
|
+
"reloading",
|
|
318
|
+
"deactivating"
|
|
319
|
+
].includes(activeState)) throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`);
|
|
320
|
+
if (this.emptyRange(tasksCurrent)) {
|
|
321
|
+
this.releaseEmptyRange();
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (this.killFailure !== void 0) {
|
|
325
|
+
if (directRunning && this.directKillSettlement !== void 0) {
|
|
326
|
+
const settlement = this.directKillSettlement;
|
|
327
|
+
this.directKillSettlement = void 0;
|
|
328
|
+
await settlement;
|
|
329
|
+
return this.rangeActive();
|
|
330
|
+
}
|
|
331
|
+
throw this.killFailure;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (!MISSING_UNIT.test(output)) {
|
|
336
|
+
if (result.error !== void 0) throw result.error;
|
|
337
|
+
throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
|
|
338
|
+
}
|
|
339
|
+
return this.absentUnit();
|
|
340
|
+
}
|
|
341
|
+
wakeObservation() {
|
|
342
|
+
this.wakeGeneration += 1;
|
|
343
|
+
this.wakeWaiter?.resolve();
|
|
344
|
+
this.wakeWaiter = void 0;
|
|
345
|
+
}
|
|
346
|
+
async waitForPoll(delayMs, generation) {
|
|
347
|
+
if (generation !== this.wakeGeneration) return;
|
|
348
|
+
const wake = Promise.withResolvers();
|
|
349
|
+
const waiter = {
|
|
350
|
+
generation,
|
|
351
|
+
resolve: wake.resolve
|
|
352
|
+
};
|
|
353
|
+
const sleepController = new AbortController();
|
|
354
|
+
this.wakeWaiter = waiter;
|
|
355
|
+
try {
|
|
356
|
+
await Promise.race([this.sleep(delayMs, sleepController.signal), wake.promise]);
|
|
357
|
+
} finally {
|
|
358
|
+
sleepController.abort();
|
|
359
|
+
if (this.wakeWaiter === waiter) this.wakeWaiter = void 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async waitForExit() {
|
|
363
|
+
if (this.stopped) return;
|
|
364
|
+
this.observation ??= (async () => {
|
|
365
|
+
let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS;
|
|
366
|
+
let generation = this.wakeGeneration;
|
|
367
|
+
while (await this.rangeActive()) {
|
|
368
|
+
await this.waitForPoll(pollIntervalMs, generation);
|
|
369
|
+
generation = this.wakeGeneration;
|
|
370
|
+
if (this.establishment === "established") pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS);
|
|
371
|
+
}
|
|
372
|
+
this.stopped = true;
|
|
373
|
+
})().catch((error) => {
|
|
374
|
+
this.observation = void 0;
|
|
375
|
+
throw error;
|
|
376
|
+
});
|
|
377
|
+
await this.observation;
|
|
378
|
+
}
|
|
379
|
+
cleanup() {
|
|
380
|
+
cleanupLinuxLaunchFiles(this.startup.files);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
function scopeArgs(unitBase, invocation, argv) {
|
|
384
|
+
return [
|
|
385
|
+
"--user",
|
|
386
|
+
"--scope",
|
|
387
|
+
"--quiet",
|
|
388
|
+
"--collect",
|
|
389
|
+
"--expand-environment=no",
|
|
390
|
+
`--unit=${unitBase}`,
|
|
391
|
+
"--",
|
|
392
|
+
...invocation,
|
|
393
|
+
"--",
|
|
394
|
+
...argv
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
function directOutcome(child, startup) {
|
|
398
|
+
return new Promise((resolveOutcome, rejectOutcome) => {
|
|
399
|
+
let settled = false;
|
|
400
|
+
child.once("error", (error) => {
|
|
401
|
+
if (settled) return;
|
|
402
|
+
settled = true;
|
|
403
|
+
rejectOutcome(error);
|
|
404
|
+
});
|
|
405
|
+
child.once("exit", (exitCode, signal) => {
|
|
406
|
+
if (settled) return;
|
|
407
|
+
settled = true;
|
|
408
|
+
try {
|
|
409
|
+
resolveOutcome(startup.resolveOutcome({
|
|
410
|
+
exitCode,
|
|
411
|
+
signal
|
|
412
|
+
}));
|
|
413
|
+
} catch (error) {
|
|
414
|
+
rejectOutcome(error instanceof Error ? error : new Error(String(error)));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Send a direct-process signal, distinguishing an absent PID from failed delivery.
|
|
421
|
+
* @param pid - owned direct-process identity whose exit notification can still be pending.
|
|
422
|
+
* @param send - platform signal operation; true means the signal was submitted.
|
|
423
|
+
* @returns whether the signal was submitted or the owned PID is already absent.
|
|
424
|
+
*/
|
|
425
|
+
function signalLinuxDirectProcess(pid, send) {
|
|
426
|
+
try {
|
|
427
|
+
if (send()) return true;
|
|
428
|
+
} catch {}
|
|
429
|
+
try {
|
|
430
|
+
process.kill(pid, 0);
|
|
431
|
+
return false;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return error.code === "ESRCH";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function signalChildGroup(child, signal) {
|
|
437
|
+
let groupSignalled = false;
|
|
438
|
+
try {
|
|
439
|
+
groupSignalled = process.kill(-child.pid, signal);
|
|
440
|
+
} catch {}
|
|
441
|
+
if (groupSignalled && signal === "SIGTERM") return true;
|
|
442
|
+
return signalLinuxDirectProcess(child.pid, () => process.kill(child.pid, signal));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Prepare one Linux PTY scope using the same launch request and bootstrap core.
|
|
446
|
+
* @param spec - terminal target request.
|
|
447
|
+
* @param targetEnv - validated complete target environment.
|
|
448
|
+
* @param internals - optional runner and systemd seams used by tests.
|
|
449
|
+
* @returns invocation and ownership callbacks; requested termination preserves the observed signal even before bootstrap consumption.
|
|
450
|
+
*/
|
|
451
|
+
function prepareLinuxTerminalScope(spec, targetEnv, internals = {}) {
|
|
452
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
453
|
+
const files = createLinuxLaunchFiles({
|
|
454
|
+
cwd: spec.cwd,
|
|
455
|
+
env: targetEnv
|
|
456
|
+
});
|
|
457
|
+
const startup = new LinuxScopeStartup(files, "terminal");
|
|
458
|
+
const unitBase = unitStem("dsh-terminal");
|
|
459
|
+
return {
|
|
460
|
+
command: internals.systemdRun ?? "systemd-run",
|
|
461
|
+
args: scopeArgs(unitBase, invocation, spec.argv),
|
|
462
|
+
cwd: process.cwd(),
|
|
463
|
+
env: runnerEnvironment(files.requestPath, invocation),
|
|
464
|
+
bindOwner: (direct) => new SystemdScopeOwner(`${unitBase}.scope`, startup, direct, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort),
|
|
465
|
+
resolveOutcome: (outcome) => startup.resolveOutcome(outcome),
|
|
466
|
+
cleanup: () => {
|
|
467
|
+
cleanupLinuxLaunchFiles(files);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Launch one ordinary target inside a transient user scope.
|
|
473
|
+
* @param spec - ordinary target request.
|
|
474
|
+
* @param targetEnv - validated complete target environment.
|
|
475
|
+
* @param internals - optional runner and systemd seams used by tests.
|
|
476
|
+
* @returns streams, result, and scope owner; requested termination preserves the observed signal even before bootstrap consumption.
|
|
477
|
+
*/
|
|
478
|
+
function launchLinuxScope(spec, targetEnv, internals = {}) {
|
|
479
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
480
|
+
const files = createLinuxLaunchFiles({
|
|
481
|
+
cwd: spec.cwd,
|
|
482
|
+
env: targetEnv,
|
|
483
|
+
...spec.stdio.control === void 0 ? {} : { control: spec.stdio.control }
|
|
484
|
+
});
|
|
485
|
+
const startup = new LinuxScopeStartup(files, "subprocess");
|
|
486
|
+
const unitBase = unitStem("dsh-subprocess");
|
|
487
|
+
let child;
|
|
488
|
+
try {
|
|
489
|
+
child = (internals.spawn ?? spawn)(internals.systemdRun ?? "systemd-run", scopeArgs(unitBase, invocation, spec.argv), {
|
|
490
|
+
cwd: process.cwd(),
|
|
491
|
+
env: runnerEnvironment(files.requestPath, invocation),
|
|
492
|
+
stdio: runnerStdio(spec, false),
|
|
493
|
+
detached: true
|
|
494
|
+
});
|
|
495
|
+
} catch (error) {
|
|
496
|
+
cleanupLinuxLaunchFiles(files);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
const direct = directOutcome(child, startup);
|
|
500
|
+
const owner = new SystemdScopeOwner(`${unitBase}.scope`, startup, {
|
|
501
|
+
running: () => child.pid !== void 0 && child.exitCode === null && child.signalCode === null,
|
|
502
|
+
signal: (signal) => signalChildGroup(child, signal),
|
|
503
|
+
settled: direct
|
|
504
|
+
}, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort);
|
|
505
|
+
return {
|
|
506
|
+
stdin: child.stdin,
|
|
507
|
+
stdout: child.stdout,
|
|
508
|
+
stderr: child.stderr,
|
|
509
|
+
control: controlPipe(child, spec.stdio.control),
|
|
510
|
+
direct,
|
|
511
|
+
owner
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region lib/types/windows-job.js
|
|
516
|
+
/** Windows parent-side launch and ownership for the private Job runner. */
|
|
517
|
+
function isWindowsStartCancellationError(error) {
|
|
518
|
+
return error.name === "Error" && error.message === "subprocess target start was cancelled" && error.code === void 0 && error.syscall === void 0 && error.path === void 0;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Re-check the runner entry, bindings, and current Job capability for every spawn.
|
|
522
|
+
* @param internals - optional runner and Win32 capability seams used by tests.
|
|
523
|
+
* @returns whether the Windows native containment path is currently available.
|
|
524
|
+
*/
|
|
525
|
+
function probeWindowsJob(internals = {}) {
|
|
526
|
+
try {
|
|
527
|
+
const invocation = internals.runnerInvocation ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)();
|
|
528
|
+
if (!(internals.runnerAvailable ?? runnerInvocationAvailable)(invocation)) return false;
|
|
529
|
+
const api = (internals.loadWin32ProcessBindings ?? loadWin32ProcessBindings)();
|
|
530
|
+
(internals.probeCurrentTokenJobSupport ?? probeCurrentTokenJobSupport)(api);
|
|
531
|
+
return true;
|
|
532
|
+
} catch {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
var WindowsJobOwner = class {
|
|
537
|
+
runner;
|
|
538
|
+
exited;
|
|
539
|
+
directResultType;
|
|
540
|
+
failInfrastructure;
|
|
541
|
+
cancellationReason;
|
|
542
|
+
cancellationReasonSet = false;
|
|
543
|
+
terminationSent = false;
|
|
544
|
+
constructor(runner, exited, directResultType, failInfrastructure) {
|
|
545
|
+
this.runner = runner;
|
|
546
|
+
this.exited = exited;
|
|
547
|
+
this.directResultType = directResultType;
|
|
548
|
+
this.failInfrastructure = failInfrastructure;
|
|
549
|
+
this.exited.catch(() => {});
|
|
550
|
+
}
|
|
551
|
+
signal(_signal, cancellationReason) {
|
|
552
|
+
if (!this.cancellationReasonSet) {
|
|
553
|
+
this.cancellationReason = cancellationReason;
|
|
554
|
+
this.cancellationReasonSet = true;
|
|
555
|
+
}
|
|
556
|
+
if (this.terminationSent || !this.runner.connected) return;
|
|
557
|
+
this.terminationSent = true;
|
|
558
|
+
try {
|
|
559
|
+
this.runner.send?.({ type: "terminate" }, (error) => {
|
|
560
|
+
if (error === null || this.directResultType() !== void 0) return;
|
|
561
|
+
this.failInfrastructure(error);
|
|
562
|
+
this.terminateForHostExit();
|
|
563
|
+
});
|
|
564
|
+
} catch (error) {
|
|
565
|
+
this.failInfrastructure(error);
|
|
566
|
+
this.terminateForHostExit();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
mapStartFailure(failure, serialized) {
|
|
570
|
+
return this.cancellationReasonSet && isWindowsStartCancellationError(serialized) ? this.cancellationReason : failure;
|
|
571
|
+
}
|
|
572
|
+
async waitForExit() {
|
|
573
|
+
await this.exited;
|
|
574
|
+
}
|
|
575
|
+
terminateForHostExit() {
|
|
576
|
+
try {
|
|
577
|
+
this.runner.kill("SIGKILL");
|
|
578
|
+
} catch {}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
/**
|
|
582
|
+
* Launch one target through a runner that uniquely owns its Job handle.
|
|
583
|
+
* @param spec - ordinary target request.
|
|
584
|
+
* @param targetEnv - validated complete target environment.
|
|
585
|
+
* @param internals - optional runner launch seams used by tests.
|
|
586
|
+
* @returns direct streams, result, and runner-owned managed range.
|
|
587
|
+
*/
|
|
588
|
+
function launchWindowsJob(spec, targetEnv, internals = {}) {
|
|
589
|
+
const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
|
|
590
|
+
const [command, ...prefix] = invocation;
|
|
591
|
+
const ignoredStdinFd = spec.stdio.stdin === "ignore" ? openSync(devNull, "r") : void 0;
|
|
592
|
+
let child;
|
|
593
|
+
try {
|
|
594
|
+
child = (internals.spawn ?? spawn)(command, [
|
|
595
|
+
...prefix,
|
|
596
|
+
"--",
|
|
597
|
+
...spec.argv
|
|
598
|
+
], {
|
|
599
|
+
cwd: process.cwd(),
|
|
600
|
+
windowsHide: true,
|
|
601
|
+
env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
|
|
602
|
+
stdio: runnerStdio(spec, true, ignoredStdinFd ?? "pipe")
|
|
603
|
+
});
|
|
604
|
+
} finally {
|
|
605
|
+
if (ignoredStdinFd !== void 0) closeSync(ignoredStdinFd);
|
|
606
|
+
}
|
|
607
|
+
const targetStdin = child.stdio[4];
|
|
608
|
+
const direct = Promise.withResolvers();
|
|
609
|
+
const rangeExit = Promise.withResolvers();
|
|
610
|
+
let directResultType;
|
|
611
|
+
let runnerSpawned = false;
|
|
612
|
+
let runnerExit;
|
|
613
|
+
let ipcDisconnected = false;
|
|
614
|
+
const failInfrastructure = (error) => {
|
|
615
|
+
direct.reject(error);
|
|
616
|
+
rangeExit.reject(error);
|
|
617
|
+
};
|
|
618
|
+
const settleRange = () => {
|
|
619
|
+
if (!runnerSpawned || runnerExit === void 0) return;
|
|
620
|
+
const { exitCode, signal } = runnerExit;
|
|
621
|
+
if (exitCode === 0 && signal === null) {
|
|
622
|
+
if (directResultType !== void 0) {
|
|
623
|
+
rangeExit.resolve();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (!ipcDisconnected) return;
|
|
627
|
+
}
|
|
628
|
+
const status = signal !== null ? `signal ${signal}` : exitCode === null ? "without an exit status" : `exit code ${String(exitCode)}`;
|
|
629
|
+
failInfrastructure(/* @__PURE__ */ new Error(`subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`));
|
|
630
|
+
};
|
|
631
|
+
const owner = new WindowsJobOwner(child, rangeExit.promise, () => directResultType, failInfrastructure);
|
|
632
|
+
child.on("message", (value) => {
|
|
633
|
+
if (directResultType !== void 0) {
|
|
634
|
+
failInfrastructure(/* @__PURE__ */ new Error("subprocess-local: Windows runner emitted more than one direct result"));
|
|
635
|
+
owner.terminateForHostExit();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let result;
|
|
639
|
+
try {
|
|
640
|
+
result = parseWindowsRunnerResult(value);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
failInfrastructure(error);
|
|
643
|
+
owner.terminateForHostExit();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
directResultType = result.type;
|
|
647
|
+
if (result.type === "target-exit") direct.resolve({
|
|
648
|
+
exitCode: result.exitCode,
|
|
649
|
+
signal: null
|
|
650
|
+
});
|
|
651
|
+
else direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error), result.error));
|
|
652
|
+
settleRange();
|
|
653
|
+
});
|
|
654
|
+
child.once("spawn", () => {
|
|
655
|
+
runnerSpawned = true;
|
|
656
|
+
try {
|
|
657
|
+
if (child.send === void 0) throw new Error("subprocess-local: Windows runner has no IPC channel");
|
|
658
|
+
child.send({
|
|
659
|
+
type: "start",
|
|
660
|
+
cwd: spec.cwd,
|
|
661
|
+
env: targetEnv,
|
|
662
|
+
...spec.stdio.control === void 0 ? {} : { control: spec.stdio.control }
|
|
663
|
+
}, (error) => {
|
|
664
|
+
if (error === null) return;
|
|
665
|
+
failInfrastructure(error);
|
|
666
|
+
owner.terminateForHostExit();
|
|
667
|
+
});
|
|
668
|
+
} catch (error) {
|
|
669
|
+
failInfrastructure(error);
|
|
670
|
+
owner.terminateForHostExit();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
child.once("error", (error) => {
|
|
674
|
+
if (!runnerSpawned) {
|
|
675
|
+
direct.reject(error);
|
|
676
|
+
rangeExit.resolve();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
failInfrastructure(error);
|
|
680
|
+
});
|
|
681
|
+
child.once("exit", (exitCode, signal) => {
|
|
682
|
+
runnerExit = {
|
|
683
|
+
exitCode,
|
|
684
|
+
signal
|
|
685
|
+
};
|
|
686
|
+
settleRange();
|
|
687
|
+
});
|
|
688
|
+
child.once("disconnect", () => {
|
|
689
|
+
ipcDisconnected = true;
|
|
690
|
+
settleRange();
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
stdin: spec.stdio.stdin === "ignore" ? null : targetStdin,
|
|
694
|
+
stdout: child.stdio[5],
|
|
695
|
+
stderr: child.stdio[6],
|
|
696
|
+
control: controlPipe(child, spec.stdio.control),
|
|
697
|
+
direct: direct.promise,
|
|
698
|
+
owner
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region lib/types/terminal.js
|
|
703
|
+
/** Local node-pty terminal-process implementation for the subprocess seam. */
|
|
704
|
+
function delay(ms, signal) {
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
const finish = () => {
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
signal?.removeEventListener("abort", finish);
|
|
709
|
+
resolve();
|
|
710
|
+
};
|
|
711
|
+
const timer = setTimeout(finish, ms);
|
|
712
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
async function raceWithDelay(operation, ms, timeout) {
|
|
716
|
+
const controller = new AbortController();
|
|
717
|
+
try {
|
|
718
|
+
return await Promise.race([operation, delay(ms, controller.signal).then(() => timeout)]);
|
|
719
|
+
} finally {
|
|
720
|
+
controller.abort();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function signalName(number) {
|
|
724
|
+
if (number === void 0 || number === 0) return null;
|
|
725
|
+
for (const [name, value] of Object.entries(constants$1.signals)) if (value === number) return name;
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* A local terminal whose native managed range or fallback process-session
|
|
730
|
+
* ownership stays below the PTY backend.
|
|
731
|
+
* The seam's terminate() promise — no write, inspection, or signal in flight
|
|
732
|
+
* after settlement — holds here without operation tracking only because every
|
|
733
|
+
* handle call completes synchronously under the hood (node-pty write, ps-based
|
|
734
|
+
* inspection). A first genuinely asynchronous step in any handle call must add
|
|
735
|
+
* the tracking a remote provider needs.
|
|
736
|
+
*/
|
|
737
|
+
var LocalTerminalHandle = class {
|
|
738
|
+
terminal;
|
|
739
|
+
inspector;
|
|
740
|
+
graceMs;
|
|
741
|
+
platform;
|
|
742
|
+
managedOwner;
|
|
743
|
+
resolveManagedOutcome;
|
|
744
|
+
shellActivity;
|
|
745
|
+
onQuiescence;
|
|
746
|
+
observeShellExit;
|
|
747
|
+
pid;
|
|
748
|
+
output = new PassThrough();
|
|
749
|
+
done;
|
|
750
|
+
outcome = Promise.withResolvers();
|
|
751
|
+
dataDisposable;
|
|
752
|
+
exitDisposable;
|
|
753
|
+
cleanup;
|
|
754
|
+
managedOwnerCleaned = false;
|
|
755
|
+
exited = false;
|
|
756
|
+
outputPaused = false;
|
|
757
|
+
trackedDescendants = [];
|
|
758
|
+
activityRevision = 0;
|
|
759
|
+
activityKey = "";
|
|
760
|
+
quiescent = false;
|
|
761
|
+
managedRangeEmpty = false;
|
|
762
|
+
/** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
|
|
763
|
+
rootIdentity;
|
|
764
|
+
/**
|
|
765
|
+
* @param terminal - allocated node-pty process.
|
|
766
|
+
* @param inspector - platform process/session operations.
|
|
767
|
+
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
|
768
|
+
* @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
|
|
769
|
+
*/
|
|
770
|
+
constructor(terminal, inspector, graceMs, platform = process.platform, managedOwner, resolveManagedOutcome, shellActivity, onQuiescence, observeShellExit = false) {
|
|
771
|
+
this.terminal = terminal;
|
|
772
|
+
this.inspector = inspector;
|
|
773
|
+
this.graceMs = graceMs;
|
|
774
|
+
this.platform = platform;
|
|
775
|
+
this.managedOwner = managedOwner;
|
|
776
|
+
this.resolveManagedOutcome = resolveManagedOutcome;
|
|
777
|
+
this.shellActivity = shellActivity;
|
|
778
|
+
this.onQuiescence = onQuiescence;
|
|
779
|
+
this.observeShellExit = observeShellExit;
|
|
780
|
+
this.pid = terminal.pid;
|
|
781
|
+
try {
|
|
782
|
+
this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
|
|
783
|
+
} catch (_rootIdentityUnavailable) {
|
|
784
|
+
this.rootIdentity = void 0;
|
|
785
|
+
}
|
|
786
|
+
this.done = this.outcome.promise;
|
|
787
|
+
const resume = () => {
|
|
788
|
+
if (!this.outputPaused) return;
|
|
789
|
+
this.outputPaused = false;
|
|
790
|
+
if (!this.exited) terminal.resume();
|
|
791
|
+
};
|
|
792
|
+
this.output.on("drain", resume);
|
|
793
|
+
this.output.once("close", () => {
|
|
794
|
+
this.output.off("drain", resume);
|
|
795
|
+
});
|
|
796
|
+
this.dataDisposable = terminal.onData((data) => {
|
|
797
|
+
if (!this.output.write(Buffer.from(data, "utf8")) && this.cleanup === void 0 && !this.outputPaused) {
|
|
798
|
+
this.outputPaused = true;
|
|
799
|
+
terminal.pause();
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
|
|
803
|
+
if (this.exited) return;
|
|
804
|
+
this.exited = true;
|
|
805
|
+
if (this.managedOwner !== void 0 && this.observeShellExit) this.managedOwner.waitForExit().then(() => {
|
|
806
|
+
this.managedRangeEmpty = true;
|
|
807
|
+
}).catch(() => {});
|
|
808
|
+
this.output.end();
|
|
809
|
+
const outcome = {
|
|
810
|
+
exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
|
|
811
|
+
signal: signalName(exitSignal)
|
|
812
|
+
};
|
|
813
|
+
try {
|
|
814
|
+
this.outcome.resolve(this.resolveManagedOutcome?.(outcome) ?? outcome);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
this.outcome.reject(error);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
/** Whether node-pty has not yet published the top-level exit event. */
|
|
821
|
+
get running() {
|
|
822
|
+
return !this.exited;
|
|
823
|
+
}
|
|
824
|
+
async write(data) {
|
|
825
|
+
if (this.exited) throw new Error("terminal process has exited");
|
|
826
|
+
this.shellActivity?.invalidate();
|
|
827
|
+
this.terminal.write(data);
|
|
828
|
+
}
|
|
829
|
+
async resize(cols, rows) {
|
|
830
|
+
if (this.exited) throw new Error("terminal process has exited");
|
|
831
|
+
this.terminal.resize(cols, rows);
|
|
832
|
+
}
|
|
833
|
+
async inspectForeground() {
|
|
834
|
+
this.descendants(this.inspector.snapshot());
|
|
835
|
+
const processGroupId = this.inspector.foregroundPgid(this.pid);
|
|
836
|
+
if (processGroupId === void 0) return void 0;
|
|
837
|
+
return {
|
|
838
|
+
processGroupId,
|
|
839
|
+
inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
async inspectActivity() {
|
|
843
|
+
let state = this.quiescent ? "idle" : "unknown";
|
|
844
|
+
let revision = 0;
|
|
845
|
+
if (!this.quiescent) try {
|
|
846
|
+
const shell = this.shellActivity?.inspect(this.pid) ?? {
|
|
847
|
+
state: "unknown",
|
|
848
|
+
revision: 0
|
|
849
|
+
};
|
|
850
|
+
revision = shell.revision;
|
|
851
|
+
const observed = this.inspector.snapshot();
|
|
852
|
+
const descendants = this.descendants(observed);
|
|
853
|
+
const root = observed.tree(this.pid).find((member) => member.pid === this.pid);
|
|
854
|
+
if (this.exited && this.managedRangeEmpty) state = "idle";
|
|
855
|
+
else if (descendants.length > 0) state = "busy";
|
|
856
|
+
else if (this.exited && this.managedOwner === void 0 && this.platform === "linux" && observed.complete === true && root === void 0) state = observed.session(this.pid).some((member) => observed.alive(member)) ? "busy" : "idle";
|
|
857
|
+
else if (observed.complete === true && root?.started === this.rootIdentity?.started && root !== void 0) {
|
|
858
|
+
const foreground = this.inspector.foregroundPgid(this.pid);
|
|
859
|
+
state = foreground === void 0 ? "unknown" : foreground === this.pid ? shell.state : "busy";
|
|
860
|
+
if (state === "idle" && this.managedOwner !== void 0) {
|
|
861
|
+
const tasks = this.managedOwner.inspectTaskCount?.();
|
|
862
|
+
state = tasks === void 0 || tasks < 1 ? "unknown" : tasks === 1 ? "idle" : "busy";
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
} catch (_incompleteActivityObservation) {
|
|
866
|
+
state = "unknown";
|
|
867
|
+
}
|
|
868
|
+
const key = `${revision}:${state}`;
|
|
869
|
+
if (key !== this.activityKey) {
|
|
870
|
+
this.activityKey = key;
|
|
871
|
+
this.activityRevision++;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
state,
|
|
875
|
+
revision: this.activityRevision
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
async signalForeground(signal) {
|
|
879
|
+
this.shellActivity?.invalidate();
|
|
880
|
+
const foreground = await this.inspectForeground();
|
|
881
|
+
if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
|
|
882
|
+
if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
|
|
883
|
+
if (this.platform === "win32") {
|
|
884
|
+
if (signal === "SIGINT") {
|
|
885
|
+
this.terminal.write("");
|
|
886
|
+
return foreground.processGroupId;
|
|
887
|
+
}
|
|
888
|
+
if (signal === "SIGTSTP" || signal === "SIGHUP") throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`);
|
|
889
|
+
}
|
|
890
|
+
this.inspector.signalGroup(foreground.processGroupId, signal);
|
|
891
|
+
return foreground.processGroupId;
|
|
892
|
+
}
|
|
893
|
+
terminate() {
|
|
894
|
+
if (this.cleanup !== void 0) return this.cleanup;
|
|
895
|
+
if (this.outputPaused) {
|
|
896
|
+
this.outputPaused = false;
|
|
897
|
+
this.terminal.resume();
|
|
898
|
+
}
|
|
899
|
+
const cleanup = this.closeOnce().then(() => {
|
|
900
|
+
this.quiescent = true;
|
|
901
|
+
this.shellActivity?.dispose();
|
|
902
|
+
this.onQuiescence?.();
|
|
903
|
+
});
|
|
904
|
+
this.cleanup = cleanup;
|
|
905
|
+
cleanup.catch(() => {
|
|
906
|
+
this.cleanup = void 0;
|
|
907
|
+
});
|
|
908
|
+
return cleanup;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Force-terminate the observable session synchronously during Node's exit
|
|
912
|
+
* event. This does not claim quiescence and does not replace terminate().
|
|
913
|
+
*/
|
|
914
|
+
terminateForHostExit() {
|
|
915
|
+
this.forceStopDescendants();
|
|
916
|
+
this.forceStopShell();
|
|
917
|
+
this.forceStopDescendants();
|
|
918
|
+
this.managedOwner?.terminateForHostExit();
|
|
919
|
+
}
|
|
920
|
+
forceStopShell() {
|
|
921
|
+
if (this.exited) return;
|
|
922
|
+
if (this.rootIdentity !== void 0) {
|
|
923
|
+
try {
|
|
924
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
|
|
925
|
+
} catch (_rootExitedDuringHostExit) {}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
this.terminal.kill("SIGKILL");
|
|
930
|
+
} catch (_unidentifiedShellExitedDuringHostExit) {}
|
|
931
|
+
}
|
|
932
|
+
survivors(members, observed) {
|
|
933
|
+
return members.filter((member) => observed.alive(member));
|
|
934
|
+
}
|
|
935
|
+
descendants(observed) {
|
|
936
|
+
const tree = observed.tree(this.pid);
|
|
937
|
+
const root = tree.find((member) => member.pid === this.pid);
|
|
938
|
+
const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
|
|
939
|
+
this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, observed.session(this.pid)] : []).filter((member) => member.pid !== this.pid), observed);
|
|
940
|
+
return this.trackedDescendants;
|
|
941
|
+
}
|
|
942
|
+
async waitForMembers(members) {
|
|
943
|
+
if (members.length === 0) return [];
|
|
944
|
+
const until = Date.now() + this.graceMs;
|
|
945
|
+
let survivors = this.survivors(members, this.inspector.snapshot());
|
|
946
|
+
while (survivors.length > 0 && Date.now() < until) {
|
|
947
|
+
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
948
|
+
survivors = this.survivors(members, this.inspector.snapshot());
|
|
949
|
+
}
|
|
950
|
+
return survivors;
|
|
951
|
+
}
|
|
952
|
+
signalMembers(members, signal) {
|
|
953
|
+
for (const member of members) try {
|
|
954
|
+
this.inspector.signalProcess(member, signal);
|
|
955
|
+
} catch (_alreadyExitedDuringSignal) {}
|
|
956
|
+
}
|
|
957
|
+
forceStopDescendants() {
|
|
958
|
+
let members = this.trackedDescendants;
|
|
959
|
+
try {
|
|
960
|
+
members = this.descendants(this.inspector.snapshot());
|
|
961
|
+
} catch (_processTableUnavailableDuringHostExit) {}
|
|
962
|
+
this.signalMembers(members, "SIGKILL");
|
|
963
|
+
}
|
|
964
|
+
unionMembers(...groups) {
|
|
965
|
+
const members = [];
|
|
966
|
+
const seen = /* @__PURE__ */ new Set();
|
|
967
|
+
for (const group of groups) for (const member of group) {
|
|
968
|
+
const key = `${member.pid}:${member.started}`;
|
|
969
|
+
if (seen.has(key)) continue;
|
|
970
|
+
seen.add(key);
|
|
971
|
+
members.push(member);
|
|
972
|
+
}
|
|
973
|
+
return members;
|
|
974
|
+
}
|
|
975
|
+
async stopDescendants() {
|
|
976
|
+
const captured = this.descendants(this.inspector.snapshot());
|
|
977
|
+
this.signalMembers(captured, "SIGTERM");
|
|
978
|
+
const capturedSurvivors = await this.waitForMembers(captured);
|
|
979
|
+
const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()));
|
|
980
|
+
this.signalMembers(members, "SIGKILL");
|
|
981
|
+
const survivors = await this.waitForMembers(members);
|
|
982
|
+
const observed = this.inspector.snapshot();
|
|
983
|
+
return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed);
|
|
984
|
+
}
|
|
985
|
+
async stopShell() {
|
|
986
|
+
if (this.platform === "win32") {
|
|
987
|
+
await this.stopShellWindows();
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (!this.exited) {
|
|
991
|
+
try {
|
|
992
|
+
this.terminal.kill("SIGTERM");
|
|
993
|
+
} catch (_topLevelAlreadyExitedDuringTerm) {}
|
|
994
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
995
|
+
}
|
|
996
|
+
if (!this.exited) {
|
|
997
|
+
try {
|
|
998
|
+
this.terminal.kill("SIGKILL");
|
|
999
|
+
} catch (_topLevelAlreadyExitedDuringKill) {}
|
|
1000
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
1001
|
+
}
|
|
1002
|
+
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
1003
|
+
}
|
|
1004
|
+
async stopShellWindows() {
|
|
1005
|
+
const shellGone = () => this.exited || this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity);
|
|
1006
|
+
if (!shellGone() && this.rootIdentity !== void 0) {
|
|
1007
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGTERM");
|
|
1008
|
+
await this.waitForWindowsShellExit();
|
|
1009
|
+
}
|
|
1010
|
+
if (!shellGone() && this.rootIdentity === void 0) {
|
|
1011
|
+
try {
|
|
1012
|
+
this.terminal.kill();
|
|
1013
|
+
} catch (_topLevelAlreadyExitedDuringKill) {}
|
|
1014
|
+
await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
|
|
1015
|
+
}
|
|
1016
|
+
if (!shellGone() && this.rootIdentity !== void 0) {
|
|
1017
|
+
this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
|
|
1018
|
+
await this.waitForWindowsShellExit();
|
|
1019
|
+
}
|
|
1020
|
+
if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
1021
|
+
}
|
|
1022
|
+
async waitForWindowsShellExit() {
|
|
1023
|
+
const until = Date.now() + this.graceMs;
|
|
1024
|
+
while (!this.exited && Date.now() < until) {
|
|
1025
|
+
if (this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity)) return;
|
|
1026
|
+
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
async closeOnce() {
|
|
1030
|
+
if (this.managedOwner !== void 0) {
|
|
1031
|
+
try {
|
|
1032
|
+
await this.closeManagedRange(this.managedOwner);
|
|
1033
|
+
this.dataDisposable.dispose();
|
|
1034
|
+
this.exitDisposable.dispose();
|
|
1035
|
+
} finally {
|
|
1036
|
+
this.done.finally(() => {
|
|
1037
|
+
this.cleanupManagedOwner(this.managedOwner);
|
|
1038
|
+
}).catch(() => {});
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
let survivors = await this.stopDescendants();
|
|
1043
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
1044
|
+
await this.stopShell();
|
|
1045
|
+
survivors = await this.stopDescendants();
|
|
1046
|
+
if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
|
|
1047
|
+
this.settleExitIfGone();
|
|
1048
|
+
this.dataDisposable.dispose();
|
|
1049
|
+
this.exitDisposable.dispose();
|
|
1050
|
+
}
|
|
1051
|
+
cleanupManagedOwner(owner) {
|
|
1052
|
+
if (this.managedOwnerCleaned) return;
|
|
1053
|
+
this.managedOwnerCleaned = true;
|
|
1054
|
+
owner.cleanup?.();
|
|
1055
|
+
}
|
|
1056
|
+
async closeManagedRange(owner) {
|
|
1057
|
+
owner.signal("SIGTERM");
|
|
1058
|
+
const observation = owner.waitForExit();
|
|
1059
|
+
const first = await raceWithDelay(observation.then(() => ({ kind: "stopped" }), (error) => ({
|
|
1060
|
+
kind: "failed",
|
|
1061
|
+
error
|
|
1062
|
+
})), this.graceMs, { kind: "timeout" });
|
|
1063
|
+
if (first.kind !== "stopped") {
|
|
1064
|
+
owner.signal("SIGKILL");
|
|
1065
|
+
if (first.kind === "failed") {
|
|
1066
|
+
try {
|
|
1067
|
+
await owner.waitForExit();
|
|
1068
|
+
} catch (finalError) {
|
|
1069
|
+
throw new AggregateError([first.error, finalError], "terminal managed-range cleanup failed");
|
|
1070
|
+
}
|
|
1071
|
+
throw first.error;
|
|
1072
|
+
}
|
|
1073
|
+
await observation;
|
|
1074
|
+
}
|
|
1075
|
+
if (!this.exited) await raceWithDelay(this.done.then(() => void 0), this.graceMs, void 0);
|
|
1076
|
+
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
|
|
1077
|
+
}
|
|
1078
|
+
settleExitIfGone() {
|
|
1079
|
+
if (this.platform !== "win32") return;
|
|
1080
|
+
if (this.exited) return;
|
|
1081
|
+
/* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
|
|
1082
|
+
the identity re-check is a defensive fence for a future caller. */
|
|
1083
|
+
if (this.rootIdentity !== void 0 && this.inspector.isAlive(this.rootIdentity)) return;
|
|
1084
|
+
this.exited = true;
|
|
1085
|
+
this.output.end();
|
|
1086
|
+
this.outcome.resolve({
|
|
1087
|
+
exitCode: null,
|
|
1088
|
+
signal: null
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region lib/types/shell-activity.js
|
|
1094
|
+
/** Private shell lifecycle files; ordinary output never authorizes terminal reclamation. */
|
|
1095
|
+
function quote(value) {
|
|
1096
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1097
|
+
}
|
|
1098
|
+
/** Opt-in startup integration and revision tracking for one ordinary interactive shell. */
|
|
1099
|
+
var ShellActivity = class {
|
|
1100
|
+
directory;
|
|
1101
|
+
argv;
|
|
1102
|
+
env;
|
|
1103
|
+
revision = 0;
|
|
1104
|
+
observed = "";
|
|
1105
|
+
invalidated;
|
|
1106
|
+
state = "unknown";
|
|
1107
|
+
/**
|
|
1108
|
+
* @param directory - private startup and status file directory.
|
|
1109
|
+
* @param argv - shell launch preserving supported user startup files.
|
|
1110
|
+
* @param env - environment with any temporary startup redirect.
|
|
1111
|
+
*/
|
|
1112
|
+
constructor(directory, argv, env) {
|
|
1113
|
+
this.directory = directory;
|
|
1114
|
+
this.argv = argv;
|
|
1115
|
+
this.env = env;
|
|
1116
|
+
}
|
|
1117
|
+
/** Invalidate prompt evidence before delivering input or a foreground signal. */
|
|
1118
|
+
invalidate() {
|
|
1119
|
+
this.invalidated = this.read();
|
|
1120
|
+
this.revision++;
|
|
1121
|
+
this.state = "unknown";
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Read the latest top-level shell transition, fenced against input since that transition.
|
|
1125
|
+
* @param pid - original shell process id.
|
|
1126
|
+
* @returns lifecycle evidence; process ownership must be checked separately.
|
|
1127
|
+
*/
|
|
1128
|
+
inspect(pid) {
|
|
1129
|
+
const record = this.read();
|
|
1130
|
+
if (record !== this.observed) {
|
|
1131
|
+
this.observed = record;
|
|
1132
|
+
this.revision++;
|
|
1133
|
+
}
|
|
1134
|
+
const match = /^(\d+):(\d+):(idle|busy)\n?$/u.exec(record);
|
|
1135
|
+
this.state = record === this.invalidated || match?.[1] !== String(pid) ? "unknown" : match[3];
|
|
1136
|
+
return {
|
|
1137
|
+
state: this.state,
|
|
1138
|
+
revision: this.revision
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
/** Remove private startup and status files after process quiescence. */
|
|
1142
|
+
dispose() {
|
|
1143
|
+
rmSync(this.directory, {
|
|
1144
|
+
recursive: true,
|
|
1145
|
+
force: true
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
read() {
|
|
1149
|
+
try {
|
|
1150
|
+
return readFileSync(join(this.directory, "state"), "utf8");
|
|
1151
|
+
} catch (_unavailableShellObservation) {
|
|
1152
|
+
return "";
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
/**
|
|
1157
|
+
* Prepare optional Bash or Zsh integration for a plain, non-login interactive launch.
|
|
1158
|
+
* @param spec - terminal request; custom arguments and wrapped executables remain unmodified.
|
|
1159
|
+
* @param env - scrubbed target environment.
|
|
1160
|
+
* @param platform - execution platform.
|
|
1161
|
+
* @returns private integration, or undefined for unsupported launches.
|
|
1162
|
+
*/
|
|
1163
|
+
function prepareShellActivity(spec, env, platform) {
|
|
1164
|
+
if (spec.shellActivity !== true || platform === "win32" || spec.argv.length !== 2 || spec.argv[1] !== "-i") return void 0;
|
|
1165
|
+
const shell = basename(spec.argv[0]);
|
|
1166
|
+
if (shell !== "bash" && shell !== "zsh") return void 0;
|
|
1167
|
+
const directory = mkdtempSync(join(tmpdir(), "dsh-shell-"));
|
|
1168
|
+
const state = quote(join(directory, "state"));
|
|
1169
|
+
const guards = quote(join(directory, "guards"));
|
|
1170
|
+
try {
|
|
1171
|
+
if (shell === "bash") {
|
|
1172
|
+
const rc = join(directory, "bashrc");
|
|
1173
|
+
writeFileSync(rc, [
|
|
1174
|
+
"[[ ! -r ~/.bashrc ]] || builtin source ~/.bashrc",
|
|
1175
|
+
"if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )) && [[ ! $(declare -p PROMPT_COMMAND PS0 2>/dev/null) =~ declare\\ -[^[:space:]]*r ]]; then",
|
|
1176
|
+
" __dsh_shell_pid=$BASHPID; __dsh_shell_sequence=0",
|
|
1177
|
+
" __dsh_shell_idle() {",
|
|
1178
|
+
" local result=$?",
|
|
1179
|
+
" if [[ $BASHPID == \"$__dsh_shell_pid\" ]]; then",
|
|
1180
|
+
" (( ++__dsh_shell_sequence ))",
|
|
1181
|
+
" local activity=idle",
|
|
1182
|
+
` builtin trap -p >| ${guards}`,
|
|
1183
|
+
` [[ ! -s ${guards} ]] || activity=unknown`,
|
|
1184
|
+
` builtin printf '%s:%s:%s\\n' "$BASHPID" "$__dsh_shell_sequence" "$activity" >| ${state}`,
|
|
1185
|
+
" fi",
|
|
1186
|
+
" return \"$result\"",
|
|
1187
|
+
" }",
|
|
1188
|
+
" if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then",
|
|
1189
|
+
" PROMPT_COMMAND+=(__dsh_shell_idle)",
|
|
1190
|
+
" else",
|
|
1191
|
+
" PROMPT_COMMAND=\"${PROMPT_COMMAND}\"$'\\n'\"__dsh_shell_idle\"",
|
|
1192
|
+
" fi",
|
|
1193
|
+
` PS0+=${quote(`$(builtin printf '%s:%s:busy' "$__dsh_shell_pid" "$__dsh_shell_sequence" >| ${state})`)}`,
|
|
1194
|
+
"fi",
|
|
1195
|
+
""
|
|
1196
|
+
].join("\n"), {
|
|
1197
|
+
mode: 384,
|
|
1198
|
+
flag: "wx"
|
|
1199
|
+
});
|
|
1200
|
+
return new ShellActivity(directory, [
|
|
1201
|
+
spec.argv[0],
|
|
1202
|
+
"--rcfile",
|
|
1203
|
+
rc,
|
|
1204
|
+
"-i"
|
|
1205
|
+
], env);
|
|
1206
|
+
}
|
|
1207
|
+
writeFileSync(join(directory, ".zshenv"), [
|
|
1208
|
+
env.ZDOTDIR === void 0 ? "unset ZDOTDIR" : `ZDOTDIR=${quote(env.ZDOTDIR)}`,
|
|
1209
|
+
"[[ ! -r ${ZDOTDIR:-$HOME}/.zshenv ]] || builtin source \"${ZDOTDIR:-$HOME}/.zshenv\"",
|
|
1210
|
+
"typeset -g __dsh_shell_pid=$$ __dsh_shell_sequence=0",
|
|
1211
|
+
"__dsh_shell_activity() {",
|
|
1212
|
+
" (( ZSH_SUBSHELL == 0 && $$ == __dsh_shell_pid )) || return",
|
|
1213
|
+
" (( ++__dsh_shell_sequence ))",
|
|
1214
|
+
` builtin printf '%s:%s:%s\\n' "$$" "$__dsh_shell_sequence" "$1" >| ${state}`,
|
|
1215
|
+
" return 0",
|
|
1216
|
+
"}",
|
|
1217
|
+
"__dsh_shell_idle() {",
|
|
1218
|
+
` { builtin trap; zle -F; } >| ${guards}`,
|
|
1219
|
+
" if [[ $CONTEXT != start || -n $BUFFER ]]; then __dsh_shell_activity busy",
|
|
1220
|
+
` elif [[ -s ${guards} || -n \${(k)functions[(I)TRAP*]} ]]; then __dsh_shell_activity unknown`,
|
|
1221
|
+
" else __dsh_shell_activity idle; fi",
|
|
1222
|
+
"}",
|
|
1223
|
+
"__dsh_shell_busy() { __dsh_shell_activity busy }",
|
|
1224
|
+
"__dsh_shell_init() {",
|
|
1225
|
+
" autoload -Uz add-zle-hook-widget add-zsh-hook",
|
|
1226
|
+
" add-zle-hook-widget line-init __dsh_shell_idle",
|
|
1227
|
+
" add-zle-hook-widget line-finish __dsh_shell_busy",
|
|
1228
|
+
" add-zsh-hook preexec __dsh_shell_busy",
|
|
1229
|
+
" precmd_functions=(${precmd_functions:#__dsh_shell_init})",
|
|
1230
|
+
"}",
|
|
1231
|
+
"typeset -ga precmd_functions",
|
|
1232
|
+
"precmd_functions+=(__dsh_shell_init)",
|
|
1233
|
+
""
|
|
1234
|
+
].join("\n"), {
|
|
1235
|
+
mode: 384,
|
|
1236
|
+
flag: "wx"
|
|
1237
|
+
});
|
|
1238
|
+
return new ShellActivity(directory, spec.argv, {
|
|
1239
|
+
...env,
|
|
1240
|
+
ZDOTDIR: directory
|
|
1241
|
+
});
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
rmSync(directory, {
|
|
1244
|
+
recursive: true,
|
|
1245
|
+
force: true
|
|
1246
|
+
});
|
|
1247
|
+
throw error;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
//#endregion
|
|
1251
|
+
//#region lib/types/index.js
|
|
1252
|
+
/**
|
|
1253
|
+
* Local Service Provider for the subprocess capability seam. Each spawn owns a
|
|
1254
|
+
* platform-selected managed range with the spec's per-stream stdio dispositions.
|
|
1255
|
+
* Normal disposal terminates and joins live ranges; Node's synchronous exit
|
|
1256
|
+
* phase force-stops any ranges the service still owns. It has no config: every
|
|
1257
|
+
* disposition and limit arrives on the spec, so deployment-varying choices
|
|
1258
|
+
* stay with the caller's config (the bash executor's, the LSP host's, …).
|
|
1259
|
+
* @module @qlzt/dsh-subprocess-local
|
|
1260
|
+
*/
|
|
1261
|
+
const requireNodePty = createLazyRequire("node-pty", import.meta.url);
|
|
1262
|
+
/**
|
|
1263
|
+
* Local subprocess service: platform-selected managed ranges, Node-shaped stdio
|
|
1264
|
+
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
|
|
1265
|
+
* files), credential-scrubbed environment, and provider-owned range signalling.
|
|
1266
|
+
* POSIX paths stage TERM before KILL; Windows paths terminate immediately.
|
|
1267
|
+
* JavaScript-observable host exit also performs synchronous final termination.
|
|
1268
|
+
*/
|
|
1269
|
+
var LocalSubprocessRuntime = class extends SubprocessRuntime {
|
|
1270
|
+
/** Live handles retained for normal disposal and synchronous host-exit finalization. */
|
|
1271
|
+
live = /* @__PURE__ */ new Set();
|
|
1272
|
+
/** Live terminals retained through normal quiescence or host-exit finalization. */
|
|
1273
|
+
terminals = /* @__PURE__ */ new Set();
|
|
1274
|
+
/** Caller endpoints retained until close, independently of managed process lifetime. */
|
|
1275
|
+
controlChannels = /* @__PURE__ */ new Set();
|
|
1276
|
+
/** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
|
|
1277
|
+
internals = {};
|
|
1278
|
+
/** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
|
|
1279
|
+
fallbackWarningIssued = false;
|
|
1280
|
+
/** Positive-only cache for the expensive Linux bootstrap and scope probe. */
|
|
1281
|
+
linuxDeepProbePassed = false;
|
|
1282
|
+
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
|
|
1283
|
+
terminalInspector;
|
|
1284
|
+
constructor(ctx) {
|
|
1285
|
+
super(ctx);
|
|
1286
|
+
ctx.effect(() => {
|
|
1287
|
+
const onHostExit = () => {
|
|
1288
|
+
this.terminateForHostExit();
|
|
1289
|
+
};
|
|
1290
|
+
process.prependListener("exit", onHostExit);
|
|
1291
|
+
return async () => {
|
|
1292
|
+
await this.disposeManagedProcesses();
|
|
1293
|
+
process.off("exit", onHostExit);
|
|
1294
|
+
};
|
|
1295
|
+
}, "local subprocess teardown");
|
|
1296
|
+
}
|
|
1297
|
+
/** Spill failures reach the plugin logger; the log line is the only trace of why a result has no spill path. */
|
|
1298
|
+
reportSpillFailure = logSpillFailure(this.ctx.logger, "subprocess-local");
|
|
1299
|
+
terminateForHostExit() {
|
|
1300
|
+
for (const handle of this.live) try {
|
|
1301
|
+
handle.terminateForHostExit();
|
|
1302
|
+
} catch (_ordinaryRangeTerminationFailed) {}
|
|
1303
|
+
for (const terminal of this.terminals) try {
|
|
1304
|
+
terminal.terminateForHostExit();
|
|
1305
|
+
} catch (_terminalTerminationFailed) {}
|
|
1306
|
+
}
|
|
1307
|
+
async disposeManagedProcesses() {
|
|
1308
|
+
const pending = [];
|
|
1309
|
+
for (const handle of this.live) {
|
|
1310
|
+
handle.terminate();
|
|
1311
|
+
pending.push(Promise.all([handle.done.catch(() => {}), handle.waitForExit()]).then(() => {
|
|
1312
|
+
this.live.delete(handle);
|
|
1313
|
+
}));
|
|
1314
|
+
}
|
|
1315
|
+
for (const terminal of this.terminals) pending.push(terminal.terminate().then(() => {
|
|
1316
|
+
this.terminals.delete(terminal);
|
|
1317
|
+
}));
|
|
1318
|
+
const outcomes = await Promise.allSettled(pending);
|
|
1319
|
+
await Promise.all([...this.controlChannels].map((control) => new Promise((resolveClose) => {
|
|
1320
|
+
control.once("close", () => {
|
|
1321
|
+
resolveClose();
|
|
1322
|
+
});
|
|
1323
|
+
control.destroy();
|
|
1324
|
+
})));
|
|
1325
|
+
this.controlChannels.clear();
|
|
1326
|
+
const failures = [];
|
|
1327
|
+
for (const outcome of outcomes) if (outcome.status === "rejected") failures.push(outcome.reason);
|
|
1328
|
+
if (failures.length > 0) this.terminateForHostExit();
|
|
1329
|
+
if (failures.length === 1) throw failures[0];
|
|
1330
|
+
if (failures.length > 1) throw new AggregateError(failures, "local subprocess teardown failed");
|
|
1331
|
+
}
|
|
1332
|
+
async resolveExecutable(command, env, signal) {
|
|
1333
|
+
if (command.length === 0) throw new Error("subprocess-local: executable must be non-empty");
|
|
1334
|
+
signal?.throwIfAborted();
|
|
1335
|
+
const environment = childEnv(env);
|
|
1336
|
+
const absolute = isAbsolute(command);
|
|
1337
|
+
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`);
|
|
1338
|
+
const candidates = absolute ? [command] : this.executableCandidates(command, environment);
|
|
1339
|
+
for (const candidate of candidates) {
|
|
1340
|
+
signal?.throwIfAborted();
|
|
1341
|
+
try {
|
|
1342
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
1343
|
+
await access(candidate, constants.X_OK);
|
|
1344
|
+
signal?.throwIfAborted();
|
|
1345
|
+
return candidate;
|
|
1346
|
+
} catch {}
|
|
1347
|
+
}
|
|
1348
|
+
signal?.throwIfAborted();
|
|
1349
|
+
throw new SubprocessExecutableNotFoundError(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`);
|
|
1350
|
+
}
|
|
1351
|
+
executableCandidates(command, env) {
|
|
1352
|
+
const path = environmentValue(env, "PATH") ?? "";
|
|
1353
|
+
const extensions = process.platform === "win32" && extname(command) === "" ? (environmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
1354
|
+
return path.split(delimiter).flatMap((directory) => extensions.map((extension) => resolve(process.cwd(), directory, command + extension)));
|
|
1355
|
+
}
|
|
1356
|
+
spawn(spec) {
|
|
1357
|
+
validateSubprocessSpec(spec);
|
|
1358
|
+
const env = targetEnvironment(spec);
|
|
1359
|
+
const containmentMode = this.selectContainmentMode("ordinary");
|
|
1360
|
+
let handle;
|
|
1361
|
+
const internals = {
|
|
1362
|
+
...this.internals,
|
|
1363
|
+
onSpillFailure: this.reportSpillFailure
|
|
1364
|
+
};
|
|
1365
|
+
if (containmentMode === "fallback") handle = spawnSubprocess(spec, internals);
|
|
1366
|
+
else {
|
|
1367
|
+
const binding = prepareManagedProcessBinding(internals);
|
|
1368
|
+
handle = bindManagedProcess(spec, containmentMode === "linux-scope" ? launchLinuxScope(spec, env) : launchWindowsJob(spec, env), binding);
|
|
1369
|
+
}
|
|
1370
|
+
this.live.add(handle);
|
|
1371
|
+
const control = handle.control;
|
|
1372
|
+
if (control !== void 0) {
|
|
1373
|
+
this.controlChannels.add(control);
|
|
1374
|
+
control.once("close", () => {
|
|
1375
|
+
this.controlChannels.delete(control);
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
const release = () => handle.waitForExit().then(() => {
|
|
1379
|
+
this.live.delete(handle);
|
|
1380
|
+
});
|
|
1381
|
+
handle.done.then(release, release).catch(() => {});
|
|
1382
|
+
return handle;
|
|
1383
|
+
}
|
|
1384
|
+
selectContainmentMode(kind) {
|
|
1385
|
+
const platform = this.internals.platform ?? process.platform;
|
|
1386
|
+
let fallbackReason;
|
|
1387
|
+
if (platform === "linux") {
|
|
1388
|
+
const available = this.linuxDeepProbePassed ? probeLinuxManager() : probeLinuxNative();
|
|
1389
|
+
if (available) this.linuxDeepProbePassed = true;
|
|
1390
|
+
if (available) return "linux-scope";
|
|
1391
|
+
fallbackReason = "the current user-systemd scope or private bootstrap is unavailable";
|
|
1392
|
+
}
|
|
1393
|
+
if (kind === "ordinary" && platform === "win32") {
|
|
1394
|
+
if (probeWindowsJob()) return "windows-job";
|
|
1395
|
+
}
|
|
1396
|
+
this.warnFallback(platform, kind, fallbackReason);
|
|
1397
|
+
return "fallback";
|
|
1398
|
+
}
|
|
1399
|
+
warnFallback(platform, kind, selectedReason) {
|
|
1400
|
+
if (this.fallbackWarningIssued) return;
|
|
1401
|
+
this.fallbackWarningIssued = true;
|
|
1402
|
+
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`);
|
|
1403
|
+
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()`);
|
|
1404
|
+
}
|
|
1405
|
+
/** @inheritdoc */
|
|
1406
|
+
async terminalEnvironment(signal) {
|
|
1407
|
+
signal?.throwIfAborted();
|
|
1408
|
+
const platform = process.platform === "win32" ? "windows" : "posix";
|
|
1409
|
+
const defaultShell = platform === "windows" ? process.env.ComSpec || void 0 : process.env.SHELL || userInfo().shell || void 0;
|
|
1410
|
+
return {
|
|
1411
|
+
platform,
|
|
1412
|
+
...defaultShell === void 0 ? {} : { defaultShell }
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
async spawnTerminal(spec) {
|
|
1416
|
+
const file = spec.argv[0];
|
|
1417
|
+
if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
|
|
1418
|
+
spec.signal?.throwIfAborted();
|
|
1419
|
+
const inspector = this.terminalInspector ?? createProcessInspector();
|
|
1420
|
+
const containmentMode = this.selectContainmentMode("terminal");
|
|
1421
|
+
const env = targetEnvironment(spec);
|
|
1422
|
+
const platform = this.internals.platform ?? process.platform;
|
|
1423
|
+
const activity = prepareShellActivity(spec, env, platform);
|
|
1424
|
+
const launch = activity === void 0 ? spec : {
|
|
1425
|
+
...spec,
|
|
1426
|
+
argv: activity.argv,
|
|
1427
|
+
env: activity.env
|
|
1428
|
+
};
|
|
1429
|
+
const options = {
|
|
1430
|
+
name: spec.terminalType,
|
|
1431
|
+
rows: spec.rows,
|
|
1432
|
+
cols: spec.cols,
|
|
1433
|
+
cwd: spec.cwd,
|
|
1434
|
+
env: {
|
|
1435
|
+
...activity?.env ?? env,
|
|
1436
|
+
TERM: spec.terminalType
|
|
1437
|
+
},
|
|
1438
|
+
...platform === "win32" ? { useConptyDll: true } : {}
|
|
1439
|
+
};
|
|
1440
|
+
let scope;
|
|
1441
|
+
let terminal;
|
|
1442
|
+
try {
|
|
1443
|
+
scope = containmentMode === "linux-scope" ? prepareLinuxTerminalScope(launch, {
|
|
1444
|
+
...activity?.env ?? env,
|
|
1445
|
+
PWD: spec.cwd,
|
|
1446
|
+
TERM: spec.terminalType
|
|
1447
|
+
}) : void 0;
|
|
1448
|
+
if (scope !== void 0) {
|
|
1449
|
+
options.cwd = scope.cwd;
|
|
1450
|
+
options.env = scope.env;
|
|
1451
|
+
}
|
|
1452
|
+
terminal = requireNodePty().spawn(scope?.command ?? file, scope?.args ?? [...launch.argv.slice(1)], options);
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
scope?.cleanup();
|
|
1455
|
+
activity?.dispose();
|
|
1456
|
+
throw error;
|
|
1457
|
+
}
|
|
1458
|
+
let handle;
|
|
1459
|
+
const directSettlement = Promise.withResolvers();
|
|
1460
|
+
const owner = scope?.bindOwner({
|
|
1461
|
+
running: () => handle?.running ?? true,
|
|
1462
|
+
settled: directSettlement.promise,
|
|
1463
|
+
signal: (signal) => signalLinuxDirectProcess(terminal.pid, () => process.kill(terminal.pid, signal))
|
|
1464
|
+
});
|
|
1465
|
+
handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, platform, owner, scope?.resolveOutcome, activity, () => {
|
|
1466
|
+
this.terminals.delete(handle);
|
|
1467
|
+
}, spec.shellActivity === true);
|
|
1468
|
+
this.terminals.add(handle);
|
|
1469
|
+
const release = async () => {
|
|
1470
|
+
directSettlement.resolve();
|
|
1471
|
+
if (spec.shellActivity === true) return;
|
|
1472
|
+
await handle.terminate();
|
|
1473
|
+
this.terminals.delete(handle);
|
|
1474
|
+
};
|
|
1475
|
+
handle.done.then(release, release).catch(() => {});
|
|
1476
|
+
return handle;
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
/** Read a Windows environment key using the platform's case-insensitive semantics. */
|
|
1480
|
+
function environmentValue(env, name) {
|
|
1481
|
+
const exact = env[name];
|
|
1482
|
+
if (exact !== void 0 || process.platform !== "win32") return exact;
|
|
1483
|
+
const normalized = name.toUpperCase();
|
|
1484
|
+
return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1];
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
export { LocalSubprocessRuntime, LocalSubprocessRuntime as default };
|