@optique/testing 1.3.0-dev.2554 → 1.3.0-dev.2559

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.
Files changed (3) hide show
  1. package/dist/cli.cjs +277 -140
  2. package/dist/cli.js +278 -141
  3. package/package.json +4 -4
package/dist/cli.cjs CHANGED
@@ -1,11 +1,263 @@
1
1
  const require_chunk = require('./chunk-CUT6urMc.cjs');
2
- const node_child_process = require_chunk.__toESM(require("node:child_process"));
3
2
  const node_path = require_chunk.__toESM(require("node:path"));
4
3
  const node_process = require_chunk.__toESM(require("node:process"));
5
4
  const node_url = require_chunk.__toESM(require("node:url"));
5
+ const node_child_process = require_chunk.__toESM(require("node:child_process"));
6
6
  const node_events = require_chunk.__toESM(require("node:events"));
7
7
  const node_stream = require_chunk.__toESM(require("node:stream"));
8
8
 
9
+ //#region src/cli-cleanup.ts
10
+ const systemClock = {
11
+ now: () => performance.now(),
12
+ schedule(callback, delay) {
13
+ const timer = setTimeout(callback, delay);
14
+ return () => clearTimeout(timer);
15
+ }
16
+ };
17
+ /**
18
+ * Creates deadline waits driven by a monotonic clock and lifecycle notifications.
19
+ * @param clock The clock and cancellable timer scheduler.
20
+ * @returns A waiter that owns and disposes its outstanding timers.
21
+ */
22
+ function createCleanupWaiter(clock = systemClock) {
23
+ const pending = /* @__PURE__ */ new Set();
24
+ let disposed = false;
25
+ return {
26
+ now: () => clock.now(),
27
+ notify() {
28
+ for (const wait of [...pending]) wait.wake();
29
+ },
30
+ waitFor(predicate, deadline) {
31
+ if (predicate()) return Promise.resolve(true);
32
+ if (disposed || deadline <= clock.now()) return Promise.resolve(false);
33
+ return new Promise((resolve$1) => {
34
+ let cancel = () => {};
35
+ let finished = false;
36
+ const wait = {
37
+ wake() {
38
+ if (predicate()) wait.finish(true);
39
+ },
40
+ finish(value) {
41
+ if (finished) return;
42
+ finished = true;
43
+ cancel();
44
+ pending.delete(wait);
45
+ resolve$1(value);
46
+ }
47
+ };
48
+ pending.add(wait);
49
+ cancel = clock.schedule(() => wait.finish(false), Math.max(0, deadline - clock.now()));
50
+ });
51
+ },
52
+ async observeClose() {
53
+ await Promise.resolve();
54
+ if (disposed) return;
55
+ await new Promise((resolve$1) => {
56
+ let cancel = () => {};
57
+ const wait = {
58
+ wake() {},
59
+ finish() {
60
+ cancel();
61
+ pending.delete(wait);
62
+ resolve$1();
63
+ }
64
+ };
65
+ pending.add(wait);
66
+ cancel = clock.schedule(() => wait.finish(), 0);
67
+ });
68
+ },
69
+ dispose() {
70
+ disposed = true;
71
+ for (const wait of [...pending]) wait.finish(false);
72
+ }
73
+ };
74
+ }
75
+ function errorCode$1(error) {
76
+ return error !== null && typeof error === "object" && "code" in error ? error.code : void 0;
77
+ }
78
+ /**
79
+ * Terminates a failed invocation and closes its owned process streams.
80
+ * @param context Live invocation state and cleanup error sink.
81
+ * @param dependencies Internal platform operations and measurement overrides.
82
+ * @returns Completion after bounded cleanup attempts.
83
+ */
84
+ async function cleanupCliProcess(context, dependencies = {}) {
85
+ const { child, getState, waiter } = context;
86
+ if (child === void 0) return;
87
+ const platform = dependencies.platform ?? node_process.default.platform;
88
+ const deadline = waiter.now() + (dependencies.budget ?? 2e3);
89
+ let active = true;
90
+ let helper;
91
+ let helperClosed = false;
92
+ let helperExited = false;
93
+ let helperFailed = false;
94
+ let helperSpawned = false;
95
+ let helperCode = null;
96
+ let diagnostic = "";
97
+ function record(stage, cause) {
98
+ if (!active) return;
99
+ context.recordError(new Error(stage, { cause }));
100
+ }
101
+ function attempt(stage, action) {
102
+ try {
103
+ action();
104
+ } catch (error) {
105
+ record(stage, error);
106
+ }
107
+ }
108
+ function killChild(signal) {
109
+ if (child?.pid === void 0 || getState().exited || getState().closed) return;
110
+ attempt("The CLI process could not be signaled during cleanup.", () => {
111
+ child.kill(signal);
112
+ });
113
+ }
114
+ function groupSignal(signal) {
115
+ if (child?.pid === void 0) return false;
116
+ try {
117
+ if (dependencies.signalGroup !== void 0) dependencies.signalGroup(-child.pid, signal);
118
+ else node_process.default.kill(-child.pid, signal);
119
+ return true;
120
+ } catch (error) {
121
+ if (errorCode$1(error) !== "ESRCH") record("The CLI process group could not be signaled during cleanup.", error);
122
+ return false;
123
+ }
124
+ }
125
+ function destroyStreams(target, name) {
126
+ for (const stream of [
127
+ "stdin",
128
+ "stdout",
129
+ "stderr"
130
+ ]) attempt(`${name} ${stream} could not be closed during cleanup.`, () => {
131
+ target[stream].destroy();
132
+ });
133
+ }
134
+ function retireHelper() {
135
+ const target = helper;
136
+ if (target === void 0) return;
137
+ if (!helperExited && !helperClosed) attempt("The tree cleanup command could not be terminated.", () => {
138
+ target.kill("SIGKILL");
139
+ });
140
+ destroyStreams(target, "Tree cleanup command");
141
+ }
142
+ async function taskkill(pid) {
143
+ const root = dependencies.systemRoot?.() ?? (dependencies.systemRoot === void 0 ? node_process.default.env.SystemRoot ?? node_process.default.env.windir : void 0);
144
+ if (root === void 0 || !node_path.win32.isAbsolute(root)) {
145
+ record("Windows system directory is unavailable for tree cleanup.");
146
+ killChild("SIGKILL");
147
+ return;
148
+ }
149
+ try {
150
+ const command = node_path.win32.join(root, "System32", "taskkill.exe");
151
+ const args = [
152
+ "/PID",
153
+ String(pid),
154
+ "/T",
155
+ "/F"
156
+ ];
157
+ helper = dependencies.spawnHelper?.(command, args) ?? (0, node_child_process.spawn)(command, args, {
158
+ shell: false,
159
+ windowsHide: true,
160
+ stdio: "pipe"
161
+ });
162
+ } catch (error) {
163
+ record("The tree cleanup command could not be launched.", error);
164
+ killChild("SIGKILL");
165
+ return;
166
+ }
167
+ helper.on("spawn", () => {
168
+ helperSpawned = true;
169
+ });
170
+ helper.on("exit", () => {
171
+ helperExited = true;
172
+ waiter.notify();
173
+ });
174
+ helper.on("close", (code) => {
175
+ helperClosed = true;
176
+ helperCode = code;
177
+ waiter.notify();
178
+ });
179
+ helper.on("error", (error) => {
180
+ helperFailed = true;
181
+ record(helperSpawned ? "The tree cleanup command failed." : "The tree cleanup command could not be launched.", error);
182
+ waiter.notify();
183
+ });
184
+ helper.stdin.on("error", () => {});
185
+ helper.stdout.on("error", (error) => {
186
+ record("Tree cleanup command stdout could not be read.", error);
187
+ });
188
+ helper.stderr.on("error", (error) => {
189
+ record("Tree cleanup command stderr could not be read.", error);
190
+ });
191
+ helper.stderr.setEncoding("utf8");
192
+ helper.stderr.on("data", (text) => {
193
+ if (active) diagnostic += text;
194
+ });
195
+ helper.stdout.resume();
196
+ helper.stdin.end();
197
+ const completed = await waiter.waitFor(() => helperClosed || helperFailed, deadline);
198
+ if (!completed) {
199
+ record("The tree cleanup command timed out.");
200
+ retireHelper();
201
+ killChild("SIGKILL");
202
+ } else if (helperFailed) {
203
+ retireHelper();
204
+ killChild("SIGKILL");
205
+ } else if (helperCode !== 0) {
206
+ record(`Tree cleanup command exited with code ${helperCode}: ${diagnostic}`);
207
+ killChild("SIGKILL");
208
+ }
209
+ }
210
+ const exited = () => getState().exited || getState().closed;
211
+ const targetClosed = () => {
212
+ const state = getState();
213
+ return state.closed && state.stdinClosed && state.stdoutClosed && state.stderrClosed;
214
+ };
215
+ const allClosed = () => targetClosed() && (helper === void 0 || helperClosed);
216
+ try {
217
+ attempt("CLI stdin could not be closed during cleanup.", () => {
218
+ child.stdin.destroy();
219
+ });
220
+ if (child.pid !== void 0) if (platform === "win32") if (context.mode === "tree") if (exited()) record("Cannot trace a Windows process tree after its root exits.");
221
+ else await taskkill(child.pid);
222
+ else killChild("SIGKILL");
223
+ else if (context.mode === "tree") {
224
+ const found = groupSignal("SIGTERM");
225
+ if (!found && !exited()) record("The child process group is unavailable.");
226
+ killChild("SIGTERM");
227
+ if (found && !await waiter.waitFor(() => exited() && !groupSignal(0), Math.min(deadline, waiter.now() + 1e3))) groupSignal("SIGKILL");
228
+ killChild("SIGKILL");
229
+ } else {
230
+ killChild("SIGTERM");
231
+ if (!await waiter.waitFor(exited, Math.min(deadline, waiter.now() + 1e3))) killChild("SIGKILL");
232
+ }
233
+ if (!await waiter.waitFor(exited, deadline)) {
234
+ record("The CLI process did not exit during cleanup.");
235
+ killChild("SIGKILL");
236
+ }
237
+ await waiter.waitFor(() => getState().closed, Math.min(deadline, waiter.now() + 250));
238
+ } catch (error) {
239
+ record("Process termination failed during cleanup.", error);
240
+ retireHelper();
241
+ killChild("SIGKILL");
242
+ }
243
+ destroyStreams(child, "CLI process");
244
+ if (!await waiter.waitFor(allClosed, deadline)) await waiter.observeClose();
245
+ if (helper !== void 0 && !helperClosed) {
246
+ record("The tree cleanup command did not close.");
247
+ attempt("The tree cleanup command could not be unreferenced.", () => {
248
+ helper?.unref();
249
+ });
250
+ }
251
+ if (!targetClosed()) {
252
+ record("The CLI process streams did not close during cleanup.");
253
+ attempt("The CLI process could not be unreferenced during cleanup.", () => {
254
+ child.unref();
255
+ });
256
+ }
257
+ active = false;
258
+ }
259
+
260
+ //#endregion
9
261
  //#region src/cli-process.ts
10
262
  function isDenoRuntime(value) {
11
263
  return typeof value === "object" && value !== null && "Command" in value && typeof value.Command === "function";
@@ -274,7 +526,7 @@ function runProcess(command, args, options) {
274
526
  let failure;
275
527
  const cleanupErrors = [];
276
528
  let timer;
277
- const waiters = /* @__PURE__ */ new Set();
529
+ const waiter = createCleanupWaiter();
278
530
  const snapshot = () => ({
279
531
  stdout: stdout.join(""),
280
532
  stderr: stderr.join(""),
@@ -288,6 +540,7 @@ function runProcess(command, args, options) {
288
540
  };
289
541
  function release() {
290
542
  clearInvocation();
543
+ if (settled) waiter.dispose();
291
544
  child?.stdout.removeListener("data", onOut);
292
545
  child?.stderr.removeListener("data", onErr);
293
546
  if (closed) child?.removeListener("error", onError);
@@ -296,7 +549,7 @@ function runProcess(command, args, options) {
296
549
  if (inClose) child?.stdin.removeListener("error", onInputError);
297
550
  }
298
551
  function update() {
299
- for (const wake of [...waiters]) wake();
552
+ waiter.notify();
300
553
  if (settled) {
301
554
  release();
302
555
  return;
@@ -307,21 +560,6 @@ function runProcess(command, args, options) {
307
560
  resolveResult(snapshot());
308
561
  }
309
562
  }
310
- function waitFor(predicate, milliseconds) {
311
- if (predicate()) return Promise.resolve(true);
312
- return new Promise((done) => {
313
- const finish = (value) => {
314
- clearTimeout(timeout);
315
- waiters.delete(wake);
316
- done(value);
317
- };
318
- const wake = () => {
319
- if (predicate()) finish(true);
320
- };
321
- const timeout = setTimeout(() => finish(false), Math.max(0, milliseconds));
322
- waiters.add(wake);
323
- });
324
- }
325
563
  function fail(reason, message, cause) {
326
564
  if (failure || settled) return;
327
565
  failure = {
@@ -334,129 +572,22 @@ function runProcess(command, args, options) {
334
572
  cleanup();
335
573
  });
336
574
  }
337
- function killChild(kind) {
338
- if (!child?.pid || exited || closed) return;
339
- try {
340
- child.kill(kind);
341
- } catch (error) {
342
- cleanupErrors.push(error);
343
- }
344
- }
345
- function groupSignal(kind) {
346
- if (!child?.pid) return false;
347
- try {
348
- node_process.default.kill(-child.pid, kind);
349
- return true;
350
- } catch (error) {
351
- if (errorCode(error) !== "ESRCH") cleanupErrors.push(error);
352
- return false;
353
- }
354
- }
355
- async function taskkill(deadline, pid) {
356
- let helper;
357
- const root = node_process.default.env.SystemRoot ?? node_process.default.env.windir;
358
- if (root === void 0 || !(0, node_path.isAbsolute)(root)) {
359
- cleanupErrors.push(/* @__PURE__ */ new Error("Windows system directory is unavailable."));
360
- killChild("SIGKILL");
361
- return;
362
- }
363
- try {
364
- helper = (0, node_child_process.spawn)((0, node_path.join)(root, "System32", "taskkill.exe"), [
365
- "/PID",
366
- String(pid),
367
- "/T",
368
- "/F"
369
- ], {
370
- shell: false,
371
- windowsHide: true,
372
- stdio: "pipe"
373
- });
374
- } catch (error) {
375
- cleanupErrors.push(error);
376
- killChild("SIGKILL");
377
- return;
378
- }
379
- let helperClosed = false;
380
- let helperError;
381
- let helperCode = null;
382
- let diagnostic = "";
383
- helper.stdout.resume();
384
- helper.stderr.setEncoding("utf8");
385
- helper.stderr.on("data", (text) => {
386
- diagnostic += text;
387
- });
388
- helper.stdin.on("error", () => {});
389
- helper.stdout.on("error", (error) => {
390
- helperError = error;
391
- });
392
- helper.stderr.on("error", (error) => {
393
- helperError = error;
394
- });
395
- helper.on("error", (error) => {
396
- helperError = error;
397
- killChild("SIGKILL");
398
- });
399
- helper.on("close", (code) => {
400
- helperClosed = true;
401
- helperCode = code;
402
- update();
403
- });
404
- helper.stdin.end();
405
- if (!await waitFor(() => helperClosed, Math.min(1e3, deadline - Date.now()))) {
406
- cleanupErrors.push(/* @__PURE__ */ new Error("Tree cleanup command timed out."));
407
- try {
408
- helper.kill("SIGKILL");
409
- } catch (error) {
410
- cleanupErrors.push(error);
411
- }
412
- killChild("SIGKILL");
413
- helper.stdin.destroy();
414
- helper.stdout.destroy();
415
- helper.stderr.destroy();
416
- if (!await waitFor(() => helperClosed, deadline - Date.now())) {
417
- cleanupErrors.push(/* @__PURE__ */ new Error("Tree cleanup command did not close."));
418
- helper.unref();
419
- }
420
- } else if (helperError !== void 0 || helperCode !== 0) {
421
- cleanupErrors.push(helperError ?? /* @__PURE__ */ new Error(`Tree cleanup exited with code ${helperCode}: ${diagnostic}`));
422
- killChild("SIGKILL");
423
- }
424
- }
425
575
  async function cleanup() {
426
- const deadline = Date.now() + 2e3;
427
- try {
428
- if (child !== void 0) {
429
- child.stdin.destroy();
430
- if (child.pid !== void 0) if (node_process.default.platform === "win32") if (options.cleanup === "tree") if (exited || closed) cleanupErrors.push(/* @__PURE__ */ new Error("Cannot trace a Windows process tree after its root exits."));
431
- else await taskkill(deadline, child.pid);
432
- else killChild("SIGKILL");
433
- else if (options.cleanup === "tree") {
434
- const found = groupSignal("SIGTERM");
435
- if (!found && !exited && !closed) cleanupErrors.push(/* @__PURE__ */ new Error("The child process group is unavailable."));
436
- killChild("SIGTERM");
437
- if (found && !await waitFor(() => (exited || closed) && !groupSignal(0), 1e3)) groupSignal("SIGKILL");
438
- killChild("SIGKILL");
439
- } else {
440
- killChild("SIGTERM");
441
- if (!await waitFor(() => exited || closed, 1e3)) killChild("SIGKILL");
442
- }
443
- if (!await waitFor(() => exited || closed, deadline - Date.now())) cleanupErrors.push(/* @__PURE__ */ new Error("The CLI process did not exit during cleanup."));
444
- await waitFor(() => closed, Math.min(250, deadline - Date.now()));
445
- child.stdout.destroy();
446
- child.stderr.destroy();
447
- if (!await waitFor(() => closed && outClose && errClose && inClose, deadline - Date.now())) {
448
- cleanupErrors.push(/* @__PURE__ */ new Error("The CLI process streams did not close during cleanup."));
449
- child.unref();
450
- }
576
+ await cleanupCliProcess({
577
+ child,
578
+ mode: options.cleanup,
579
+ getState: () => ({
580
+ exited,
581
+ closed,
582
+ stdinClosed: inClose,
583
+ stdoutClosed: outClose,
584
+ stderrClosed: errClose
585
+ }),
586
+ waiter,
587
+ recordError: (error) => {
588
+ if (!settled) cleanupErrors.push(error);
451
589
  }
452
- } catch (error) {
453
- cleanupErrors.push(error);
454
- killChild("SIGKILL");
455
- child?.stdin.destroy();
456
- child?.stdout.destroy();
457
- child?.stderr.destroy();
458
- child?.unref();
459
- }
590
+ });
460
591
  if (failure === void 0 || settled) return;
461
592
  const result = snapshot();
462
593
  const primary = new CliInvocationError(failure.reason, failure.message, result, { cause: failure.cause });
@@ -471,10 +602,16 @@ function runProcess(command, args, options) {
471
602
  stderr.push(text);
472
603
  }
473
604
  function onError(error) {
474
- if (failure) cleanupErrors.push(error);
605
+ if (settled) return;
606
+ if (failure) cleanupErrors.push(new Error("The CLI process failed during cleanup.", { cause: error }));
475
607
  else fail(spawned ? "io" : "spawn", "Could not execute the CLI.", error);
476
608
  }
477
609
  function onIoError(error) {
610
+ if (settled) return;
611
+ if (failure) {
612
+ cleanupErrors.push(new Error("CLI output could not be read during cleanup.", { cause: error }));
613
+ return;
614
+ }
478
615
  fail("io", "Could not capture CLI output.", error);
479
616
  }
480
617
  function onInputError(error) {
package/dist/cli.js CHANGED
@@ -1,10 +1,262 @@
1
- import { spawn } from "node:child_process";
2
- import { isAbsolute, join, resolve } from "node:path";
1
+ import { resolve, win32 } from "node:path";
3
2
  import process from "node:process";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { spawn } from "node:child_process";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { Readable, Writable } from "node:stream";
7
7
 
8
+ //#region src/cli-cleanup.ts
9
+ const systemClock = {
10
+ now: () => performance.now(),
11
+ schedule(callback, delay) {
12
+ const timer = setTimeout(callback, delay);
13
+ return () => clearTimeout(timer);
14
+ }
15
+ };
16
+ /**
17
+ * Creates deadline waits driven by a monotonic clock and lifecycle notifications.
18
+ * @param clock The clock and cancellable timer scheduler.
19
+ * @returns A waiter that owns and disposes its outstanding timers.
20
+ */
21
+ function createCleanupWaiter(clock = systemClock) {
22
+ const pending = /* @__PURE__ */ new Set();
23
+ let disposed = false;
24
+ return {
25
+ now: () => clock.now(),
26
+ notify() {
27
+ for (const wait of [...pending]) wait.wake();
28
+ },
29
+ waitFor(predicate, deadline) {
30
+ if (predicate()) return Promise.resolve(true);
31
+ if (disposed || deadline <= clock.now()) return Promise.resolve(false);
32
+ return new Promise((resolve$1) => {
33
+ let cancel = () => {};
34
+ let finished = false;
35
+ const wait = {
36
+ wake() {
37
+ if (predicate()) wait.finish(true);
38
+ },
39
+ finish(value) {
40
+ if (finished) return;
41
+ finished = true;
42
+ cancel();
43
+ pending.delete(wait);
44
+ resolve$1(value);
45
+ }
46
+ };
47
+ pending.add(wait);
48
+ cancel = clock.schedule(() => wait.finish(false), Math.max(0, deadline - clock.now()));
49
+ });
50
+ },
51
+ async observeClose() {
52
+ await Promise.resolve();
53
+ if (disposed) return;
54
+ await new Promise((resolve$1) => {
55
+ let cancel = () => {};
56
+ const wait = {
57
+ wake() {},
58
+ finish() {
59
+ cancel();
60
+ pending.delete(wait);
61
+ resolve$1();
62
+ }
63
+ };
64
+ pending.add(wait);
65
+ cancel = clock.schedule(() => wait.finish(), 0);
66
+ });
67
+ },
68
+ dispose() {
69
+ disposed = true;
70
+ for (const wait of [...pending]) wait.finish(false);
71
+ }
72
+ };
73
+ }
74
+ function errorCode$1(error) {
75
+ return error !== null && typeof error === "object" && "code" in error ? error.code : void 0;
76
+ }
77
+ /**
78
+ * Terminates a failed invocation and closes its owned process streams.
79
+ * @param context Live invocation state and cleanup error sink.
80
+ * @param dependencies Internal platform operations and measurement overrides.
81
+ * @returns Completion after bounded cleanup attempts.
82
+ */
83
+ async function cleanupCliProcess(context, dependencies = {}) {
84
+ const { child, getState, waiter } = context;
85
+ if (child === void 0) return;
86
+ const platform = dependencies.platform ?? process.platform;
87
+ const deadline = waiter.now() + (dependencies.budget ?? 2e3);
88
+ let active = true;
89
+ let helper;
90
+ let helperClosed = false;
91
+ let helperExited = false;
92
+ let helperFailed = false;
93
+ let helperSpawned = false;
94
+ let helperCode = null;
95
+ let diagnostic = "";
96
+ function record(stage, cause) {
97
+ if (!active) return;
98
+ context.recordError(new Error(stage, { cause }));
99
+ }
100
+ function attempt(stage, action) {
101
+ try {
102
+ action();
103
+ } catch (error) {
104
+ record(stage, error);
105
+ }
106
+ }
107
+ function killChild(signal) {
108
+ if (child?.pid === void 0 || getState().exited || getState().closed) return;
109
+ attempt("The CLI process could not be signaled during cleanup.", () => {
110
+ child.kill(signal);
111
+ });
112
+ }
113
+ function groupSignal(signal) {
114
+ if (child?.pid === void 0) return false;
115
+ try {
116
+ if (dependencies.signalGroup !== void 0) dependencies.signalGroup(-child.pid, signal);
117
+ else process.kill(-child.pid, signal);
118
+ return true;
119
+ } catch (error) {
120
+ if (errorCode$1(error) !== "ESRCH") record("The CLI process group could not be signaled during cleanup.", error);
121
+ return false;
122
+ }
123
+ }
124
+ function destroyStreams(target, name) {
125
+ for (const stream of [
126
+ "stdin",
127
+ "stdout",
128
+ "stderr"
129
+ ]) attempt(`${name} ${stream} could not be closed during cleanup.`, () => {
130
+ target[stream].destroy();
131
+ });
132
+ }
133
+ function retireHelper() {
134
+ const target = helper;
135
+ if (target === void 0) return;
136
+ if (!helperExited && !helperClosed) attempt("The tree cleanup command could not be terminated.", () => {
137
+ target.kill("SIGKILL");
138
+ });
139
+ destroyStreams(target, "Tree cleanup command");
140
+ }
141
+ async function taskkill(pid) {
142
+ const root = dependencies.systemRoot?.() ?? (dependencies.systemRoot === void 0 ? process.env.SystemRoot ?? process.env.windir : void 0);
143
+ if (root === void 0 || !win32.isAbsolute(root)) {
144
+ record("Windows system directory is unavailable for tree cleanup.");
145
+ killChild("SIGKILL");
146
+ return;
147
+ }
148
+ try {
149
+ const command = win32.join(root, "System32", "taskkill.exe");
150
+ const args = [
151
+ "/PID",
152
+ String(pid),
153
+ "/T",
154
+ "/F"
155
+ ];
156
+ helper = dependencies.spawnHelper?.(command, args) ?? spawn(command, args, {
157
+ shell: false,
158
+ windowsHide: true,
159
+ stdio: "pipe"
160
+ });
161
+ } catch (error) {
162
+ record("The tree cleanup command could not be launched.", error);
163
+ killChild("SIGKILL");
164
+ return;
165
+ }
166
+ helper.on("spawn", () => {
167
+ helperSpawned = true;
168
+ });
169
+ helper.on("exit", () => {
170
+ helperExited = true;
171
+ waiter.notify();
172
+ });
173
+ helper.on("close", (code) => {
174
+ helperClosed = true;
175
+ helperCode = code;
176
+ waiter.notify();
177
+ });
178
+ helper.on("error", (error) => {
179
+ helperFailed = true;
180
+ record(helperSpawned ? "The tree cleanup command failed." : "The tree cleanup command could not be launched.", error);
181
+ waiter.notify();
182
+ });
183
+ helper.stdin.on("error", () => {});
184
+ helper.stdout.on("error", (error) => {
185
+ record("Tree cleanup command stdout could not be read.", error);
186
+ });
187
+ helper.stderr.on("error", (error) => {
188
+ record("Tree cleanup command stderr could not be read.", error);
189
+ });
190
+ helper.stderr.setEncoding("utf8");
191
+ helper.stderr.on("data", (text) => {
192
+ if (active) diagnostic += text;
193
+ });
194
+ helper.stdout.resume();
195
+ helper.stdin.end();
196
+ const completed = await waiter.waitFor(() => helperClosed || helperFailed, deadline);
197
+ if (!completed) {
198
+ record("The tree cleanup command timed out.");
199
+ retireHelper();
200
+ killChild("SIGKILL");
201
+ } else if (helperFailed) {
202
+ retireHelper();
203
+ killChild("SIGKILL");
204
+ } else if (helperCode !== 0) {
205
+ record(`Tree cleanup command exited with code ${helperCode}: ${diagnostic}`);
206
+ killChild("SIGKILL");
207
+ }
208
+ }
209
+ const exited = () => getState().exited || getState().closed;
210
+ const targetClosed = () => {
211
+ const state = getState();
212
+ return state.closed && state.stdinClosed && state.stdoutClosed && state.stderrClosed;
213
+ };
214
+ const allClosed = () => targetClosed() && (helper === void 0 || helperClosed);
215
+ try {
216
+ attempt("CLI stdin could not be closed during cleanup.", () => {
217
+ child.stdin.destroy();
218
+ });
219
+ if (child.pid !== void 0) if (platform === "win32") if (context.mode === "tree") if (exited()) record("Cannot trace a Windows process tree after its root exits.");
220
+ else await taskkill(child.pid);
221
+ else killChild("SIGKILL");
222
+ else if (context.mode === "tree") {
223
+ const found = groupSignal("SIGTERM");
224
+ if (!found && !exited()) record("The child process group is unavailable.");
225
+ killChild("SIGTERM");
226
+ if (found && !await waiter.waitFor(() => exited() && !groupSignal(0), Math.min(deadline, waiter.now() + 1e3))) groupSignal("SIGKILL");
227
+ killChild("SIGKILL");
228
+ } else {
229
+ killChild("SIGTERM");
230
+ if (!await waiter.waitFor(exited, Math.min(deadline, waiter.now() + 1e3))) killChild("SIGKILL");
231
+ }
232
+ if (!await waiter.waitFor(exited, deadline)) {
233
+ record("The CLI process did not exit during cleanup.");
234
+ killChild("SIGKILL");
235
+ }
236
+ await waiter.waitFor(() => getState().closed, Math.min(deadline, waiter.now() + 250));
237
+ } catch (error) {
238
+ record("Process termination failed during cleanup.", error);
239
+ retireHelper();
240
+ killChild("SIGKILL");
241
+ }
242
+ destroyStreams(child, "CLI process");
243
+ if (!await waiter.waitFor(allClosed, deadline)) await waiter.observeClose();
244
+ if (helper !== void 0 && !helperClosed) {
245
+ record("The tree cleanup command did not close.");
246
+ attempt("The tree cleanup command could not be unreferenced.", () => {
247
+ helper?.unref();
248
+ });
249
+ }
250
+ if (!targetClosed()) {
251
+ record("The CLI process streams did not close during cleanup.");
252
+ attempt("The CLI process could not be unreferenced during cleanup.", () => {
253
+ child.unref();
254
+ });
255
+ }
256
+ active = false;
257
+ }
258
+
259
+ //#endregion
8
260
  //#region src/cli-process.ts
9
261
  function isDenoRuntime(value) {
10
262
  return typeof value === "object" && value !== null && "Command" in value && typeof value.Command === "function";
@@ -273,7 +525,7 @@ function runProcess(command, args, options) {
273
525
  let failure;
274
526
  const cleanupErrors = [];
275
527
  let timer;
276
- const waiters = /* @__PURE__ */ new Set();
528
+ const waiter = createCleanupWaiter();
277
529
  const snapshot = () => ({
278
530
  stdout: stdout.join(""),
279
531
  stderr: stderr.join(""),
@@ -287,6 +539,7 @@ function runProcess(command, args, options) {
287
539
  };
288
540
  function release() {
289
541
  clearInvocation();
542
+ if (settled) waiter.dispose();
290
543
  child?.stdout.removeListener("data", onOut);
291
544
  child?.stderr.removeListener("data", onErr);
292
545
  if (closed) child?.removeListener("error", onError);
@@ -295,7 +548,7 @@ function runProcess(command, args, options) {
295
548
  if (inClose) child?.stdin.removeListener("error", onInputError);
296
549
  }
297
550
  function update() {
298
- for (const wake of [...waiters]) wake();
551
+ waiter.notify();
299
552
  if (settled) {
300
553
  release();
301
554
  return;
@@ -306,21 +559,6 @@ function runProcess(command, args, options) {
306
559
  resolveResult(snapshot());
307
560
  }
308
561
  }
309
- function waitFor(predicate, milliseconds) {
310
- if (predicate()) return Promise.resolve(true);
311
- return new Promise((done) => {
312
- const finish = (value) => {
313
- clearTimeout(timeout);
314
- waiters.delete(wake);
315
- done(value);
316
- };
317
- const wake = () => {
318
- if (predicate()) finish(true);
319
- };
320
- const timeout = setTimeout(() => finish(false), Math.max(0, milliseconds));
321
- waiters.add(wake);
322
- });
323
- }
324
562
  function fail(reason, message, cause) {
325
563
  if (failure || settled) return;
326
564
  failure = {
@@ -333,129 +571,22 @@ function runProcess(command, args, options) {
333
571
  cleanup();
334
572
  });
335
573
  }
336
- function killChild(kind) {
337
- if (!child?.pid || exited || closed) return;
338
- try {
339
- child.kill(kind);
340
- } catch (error) {
341
- cleanupErrors.push(error);
342
- }
343
- }
344
- function groupSignal(kind) {
345
- if (!child?.pid) return false;
346
- try {
347
- process.kill(-child.pid, kind);
348
- return true;
349
- } catch (error) {
350
- if (errorCode(error) !== "ESRCH") cleanupErrors.push(error);
351
- return false;
352
- }
353
- }
354
- async function taskkill(deadline, pid) {
355
- let helper;
356
- const root = process.env.SystemRoot ?? process.env.windir;
357
- if (root === void 0 || !isAbsolute(root)) {
358
- cleanupErrors.push(/* @__PURE__ */ new Error("Windows system directory is unavailable."));
359
- killChild("SIGKILL");
360
- return;
361
- }
362
- try {
363
- helper = spawn(join(root, "System32", "taskkill.exe"), [
364
- "/PID",
365
- String(pid),
366
- "/T",
367
- "/F"
368
- ], {
369
- shell: false,
370
- windowsHide: true,
371
- stdio: "pipe"
372
- });
373
- } catch (error) {
374
- cleanupErrors.push(error);
375
- killChild("SIGKILL");
376
- return;
377
- }
378
- let helperClosed = false;
379
- let helperError;
380
- let helperCode = null;
381
- let diagnostic = "";
382
- helper.stdout.resume();
383
- helper.stderr.setEncoding("utf8");
384
- helper.stderr.on("data", (text) => {
385
- diagnostic += text;
386
- });
387
- helper.stdin.on("error", () => {});
388
- helper.stdout.on("error", (error) => {
389
- helperError = error;
390
- });
391
- helper.stderr.on("error", (error) => {
392
- helperError = error;
393
- });
394
- helper.on("error", (error) => {
395
- helperError = error;
396
- killChild("SIGKILL");
397
- });
398
- helper.on("close", (code) => {
399
- helperClosed = true;
400
- helperCode = code;
401
- update();
402
- });
403
- helper.stdin.end();
404
- if (!await waitFor(() => helperClosed, Math.min(1e3, deadline - Date.now()))) {
405
- cleanupErrors.push(/* @__PURE__ */ new Error("Tree cleanup command timed out."));
406
- try {
407
- helper.kill("SIGKILL");
408
- } catch (error) {
409
- cleanupErrors.push(error);
410
- }
411
- killChild("SIGKILL");
412
- helper.stdin.destroy();
413
- helper.stdout.destroy();
414
- helper.stderr.destroy();
415
- if (!await waitFor(() => helperClosed, deadline - Date.now())) {
416
- cleanupErrors.push(/* @__PURE__ */ new Error("Tree cleanup command did not close."));
417
- helper.unref();
418
- }
419
- } else if (helperError !== void 0 || helperCode !== 0) {
420
- cleanupErrors.push(helperError ?? /* @__PURE__ */ new Error(`Tree cleanup exited with code ${helperCode}: ${diagnostic}`));
421
- killChild("SIGKILL");
422
- }
423
- }
424
574
  async function cleanup() {
425
- const deadline = Date.now() + 2e3;
426
- try {
427
- if (child !== void 0) {
428
- child.stdin.destroy();
429
- if (child.pid !== void 0) if (process.platform === "win32") if (options.cleanup === "tree") if (exited || closed) cleanupErrors.push(/* @__PURE__ */ new Error("Cannot trace a Windows process tree after its root exits."));
430
- else await taskkill(deadline, child.pid);
431
- else killChild("SIGKILL");
432
- else if (options.cleanup === "tree") {
433
- const found = groupSignal("SIGTERM");
434
- if (!found && !exited && !closed) cleanupErrors.push(/* @__PURE__ */ new Error("The child process group is unavailable."));
435
- killChild("SIGTERM");
436
- if (found && !await waitFor(() => (exited || closed) && !groupSignal(0), 1e3)) groupSignal("SIGKILL");
437
- killChild("SIGKILL");
438
- } else {
439
- killChild("SIGTERM");
440
- if (!await waitFor(() => exited || closed, 1e3)) killChild("SIGKILL");
441
- }
442
- if (!await waitFor(() => exited || closed, deadline - Date.now())) cleanupErrors.push(/* @__PURE__ */ new Error("The CLI process did not exit during cleanup."));
443
- await waitFor(() => closed, Math.min(250, deadline - Date.now()));
444
- child.stdout.destroy();
445
- child.stderr.destroy();
446
- if (!await waitFor(() => closed && outClose && errClose && inClose, deadline - Date.now())) {
447
- cleanupErrors.push(/* @__PURE__ */ new Error("The CLI process streams did not close during cleanup."));
448
- child.unref();
449
- }
575
+ await cleanupCliProcess({
576
+ child,
577
+ mode: options.cleanup,
578
+ getState: () => ({
579
+ exited,
580
+ closed,
581
+ stdinClosed: inClose,
582
+ stdoutClosed: outClose,
583
+ stderrClosed: errClose
584
+ }),
585
+ waiter,
586
+ recordError: (error) => {
587
+ if (!settled) cleanupErrors.push(error);
450
588
  }
451
- } catch (error) {
452
- cleanupErrors.push(error);
453
- killChild("SIGKILL");
454
- child?.stdin.destroy();
455
- child?.stdout.destroy();
456
- child?.stderr.destroy();
457
- child?.unref();
458
- }
589
+ });
459
590
  if (failure === void 0 || settled) return;
460
591
  const result = snapshot();
461
592
  const primary = new CliInvocationError(failure.reason, failure.message, result, { cause: failure.cause });
@@ -470,10 +601,16 @@ function runProcess(command, args, options) {
470
601
  stderr.push(text);
471
602
  }
472
603
  function onError(error) {
473
- if (failure) cleanupErrors.push(error);
604
+ if (settled) return;
605
+ if (failure) cleanupErrors.push(new Error("The CLI process failed during cleanup.", { cause: error }));
474
606
  else fail(spawned ? "io" : "spawn", "Could not execute the CLI.", error);
475
607
  }
476
608
  function onIoError(error) {
609
+ if (settled) return;
610
+ if (failure) {
611
+ cleanupErrors.push(new Error("CLI output could not be read during cleanup.", { cause: error }));
612
+ return;
613
+ }
477
614
  fail("io", "Could not capture CLI output.", error);
478
615
  }
479
616
  function onInputError(error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/testing",
3
- "version": "1.3.0-dev.2554",
3
+ "version": "1.3.0-dev.2559",
4
4
  "description": "Testing support for Optique command-line interfaces",
5
5
  "keywords": [
6
6
  "CLI",
@@ -86,9 +86,9 @@
86
86
  },
87
87
  "sideEffects": false,
88
88
  "dependencies": {
89
- "@optique/core": "1.3.0-dev.2554+5fdcbbad",
90
- "@optique/discover": "1.3.0-dev.2554+5fdcbbad",
91
- "@optique/run": "1.3.0-dev.2554+5fdcbbad"
89
+ "@optique/core": "1.3.0-dev.2559+02079a47",
90
+ "@optique/discover": "1.3.0-dev.2559+02079a47",
91
+ "@optique/run": "1.3.0-dev.2559+02079a47"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/node": "^24.0.0",