@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/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 };