@deepseek-ai/dsh-subprocess-local 0.1.2-alpha.5 → 0.1.3-alpha.2

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