@c9up/helix 0.1.4 → 0.1.6

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 (65) hide show
  1. package/dist/cli/coverage/diff/overlay.d.ts.map +1 -1
  2. package/dist/cli/coverage/diff/overlay.js +19 -6
  3. package/dist/cli/coverage/diff/overlay.js.map +1 -1
  4. package/dist/cli/pool.js +10 -0
  5. package/dist/cli/pool.js.map +1 -1
  6. package/dist/runtime/equals.d.ts.map +1 -1
  7. package/dist/runtime/equals.js +6 -2
  8. package/dist/runtime/equals.js.map +1 -1
  9. package/dist/runtime/suite.d.ts +26 -0
  10. package/dist/runtime/suite.d.ts.map +1 -1
  11. package/dist/runtime/suite.js +21 -18
  12. package/dist/runtime/suite.js.map +1 -1
  13. package/index.darwin-arm64.node +0 -0
  14. package/index.darwin-x64.node +0 -0
  15. package/index.linux-arm64-gnu.node +0 -0
  16. package/index.linux-x64-gnu.node +0 -0
  17. package/index.win32-x64-msvc.node +0 -0
  18. package/package.json +2 -2
  19. package/src/cli/coverage/aggregate.ts +0 -231
  20. package/src/cli/coverage/collect.ts +0 -63
  21. package/src/cli/coverage/diff/base.ts +0 -46
  22. package/src/cli/coverage/diff/index.ts +0 -160
  23. package/src/cli/coverage/diff/overlay.ts +0 -62
  24. package/src/cli/coverage/diff/parse.ts +0 -121
  25. package/src/cli/coverage/diff/reporters.ts +0 -82
  26. package/src/cli/coverage/diff/types.ts +0 -46
  27. package/src/cli/coverage/filter.ts +0 -71
  28. package/src/cli/coverage/glob.ts +0 -0
  29. package/src/cli/coverage/index.ts +0 -126
  30. package/src/cli/coverage/reporters/json.ts +0 -40
  31. package/src/cli/coverage/reporters/lcov.ts +0 -54
  32. package/src/cli/coverage/reporters/text.ts +0 -48
  33. package/src/cli/coverage/thresholds.ts +0 -73
  34. package/src/cli/coverage/types.ts +0 -93
  35. package/src/cli/discover.ts +0 -174
  36. package/src/cli/native.ts +0 -104
  37. package/src/cli/pool.ts +0 -486
  38. package/src/cli/reporter.ts +0 -155
  39. package/src/cli/run.ts +0 -440
  40. package/src/cli/summary.ts +0 -42
  41. package/src/cli/watch/loop.ts +0 -159
  42. package/src/cli/watch/types.ts +0 -22
  43. package/src/cli/watch/watcher.ts +0 -145
  44. package/src/container/index.ts +0 -16
  45. package/src/container/override.ts +0 -86
  46. package/src/container/spy.ts +0 -25
  47. package/src/index.ts +0 -42
  48. package/src/runtime/assertion-error.ts +0 -38
  49. package/src/runtime/cli-worker.ts +0 -140
  50. package/src/runtime/equals.ts +0 -400
  51. package/src/runtime/expect.ts +0 -173
  52. package/src/runtime/index.ts +0 -50
  53. package/src/runtime/lifecycle.ts +0 -17
  54. package/src/runtime/matchers.ts +0 -452
  55. package/src/runtime/run.ts +0 -573
  56. package/src/runtime/suite.ts +0 -310
  57. package/src/runtime/test-context.ts +0 -59
  58. package/src/runtime/vi/fake-timers.ts +0 -410
  59. package/src/runtime/vi/index.ts +0 -254
  60. package/src/runtime/vi/spy.ts +0 -224
  61. package/src/runtime/vi/spyOn.ts +0 -155
  62. package/src/runtime/vi/system-time.ts +0 -121
  63. package/src/runtime/worker.ts +0 -239
  64. package/src/time/freeze.ts +0 -229
  65. package/src/time/index.ts +0 -16
package/src/cli/native.ts DELETED
@@ -1,104 +0,0 @@
1
- /**
2
- * Loads the native `ream-test-napi` binary built by `scripts/copy-napi.mjs`
3
- * and exposes the Rust orchestrator `run(config)` to the TS CLI.
4
- *
5
- * Per the orchestrator design (42-N-orchestrator), the Rust NAPI engine is the
6
- * canonical discovery + worker-pool + reporter + summary path; the TS `runOnce`
7
- * delegates to it whenever no TS-only layer (coverage / diff-cov / watch / a
8
- * pluggable reporter instance) is in play. There is NO JS fallback for a failed
9
- * load — the caller gets a typed error pointing at `build:napi`.
10
- *
11
- * Field names are camelCase: napi-rs converts the Rust struct's snake_case
12
- * fields automatically (`timeout_ms` → `timeoutMs`, etc.).
13
- */
14
-
15
- import { createRequire } from "node:module";
16
- import { arch, platform } from "node:process";
17
- import { fileURLToPath } from "node:url";
18
-
19
- const SUFFIX_MAP: Readonly<Record<string, string>> = {
20
- "linux-x64": "linux-x64-gnu",
21
- "linux-arm64": "linux-arm64-gnu",
22
- "darwin-x64": "darwin-x64",
23
- "darwin-arm64": "darwin-arm64",
24
- "win32-x64": "win32-x64-msvc",
25
- };
26
-
27
- function platformSuffix(): string {
28
- const key = `${platform}-${arch}`;
29
- const suffix = SUFFIX_MAP[key];
30
- if (typeof suffix !== "string") {
31
- throw new Error(
32
- `Unsupported platform/arch '${key}' for @c9up/helix native binary. Supported: ${Object.keys(SUFFIX_MAP).join(", ")}.`,
33
- );
34
- }
35
- return suffix;
36
- }
37
-
38
- /** Mirror of the Rust `RunConfig` (camelCase). */
39
- export interface NativeRunConfig {
40
- readonly root: string;
41
- readonly files?: readonly string[];
42
- readonly threads?: number;
43
- readonly timeoutMs?: number;
44
- readonly reporter?: string;
45
- readonly workerEntry: string;
46
- readonly nodeBin?: string;
47
- readonly nodeArgs?: readonly string[];
48
- readonly useColors?: boolean;
49
- }
50
-
51
- /** Mirror of the Rust `SummaryPayload` (camelCase). */
52
- export interface NativeSummaryPayload {
53
- readonly pass: number;
54
- readonly fail: number;
55
- readonly skip: number;
56
- readonly todo: number;
57
- readonly fileErrors: number;
58
- readonly durationMs: number;
59
- readonly exitCode: number;
60
- /** Full `Summary` serialized as JSON (same shape as TS `Summary`). */
61
- readonly json: string;
62
- }
63
-
64
- interface NativeExports {
65
- readonly run: (config: NativeRunConfig) => Promise<NativeSummaryPayload>;
66
- }
67
-
68
- function isNativeExports(value: unknown): value is NativeExports {
69
- if (value === null || typeof value !== "object") return false;
70
- return typeof Reflect.get(value, "run") === "function";
71
- }
72
-
73
- let cachedNative: NativeExports | undefined;
74
-
75
- export function getNative(): NativeExports {
76
- if (cachedNative !== undefined) return cachedNative;
77
-
78
- const require = createRequire(import.meta.url);
79
- const here = fileURLToPath(import.meta.url);
80
- // `here` is `…/packages/helix/{src,dist}/cli/native.ts|js`. The `.node` lives
81
- // two levels up at `…/packages/helix/index.<suffix>.node`.
82
- const suffix = platformSuffix();
83
- const candidate = `../../index.${suffix}.node`;
84
- let loaded: unknown;
85
- try {
86
- loaded = require(candidate);
87
- } catch (err) {
88
- const cause = err instanceof Error ? err.message : String(err);
89
- const muslHint = suffix.endsWith("-gnu")
90
- ? " If you are on Alpine/musl, note the prebuilt binaries target glibc (musl is not a supported target)."
91
- : "";
92
- throw new Error(
93
- `@c9up/helix native binary 'index.${suffix}.node' not found or failed to load near ${here} — run 'pnpm --filter @c9up/helix build:napi' to build it.${muslHint} Cause: ${cause}`,
94
- { cause: err },
95
- );
96
- }
97
- if (!isNativeExports(loaded)) {
98
- throw new Error(
99
- "@c9up/helix native binary loaded but missing the expected 'run' export. Rebuild with 'pnpm --filter @c9up/helix build:napi'.",
100
- );
101
- }
102
- cachedNative = loaded;
103
- return cachedNative;
104
- }
package/src/cli/pool.ts DELETED
@@ -1,486 +0,0 @@
1
- /**
2
- * Worker pool — spawns a Node child process per file and collects the
3
- * framed result from the worker's stderr.
4
- *
5
- * Robustness features (2026-04-24 review pass):
6
- * - UTF-8 safe chunking via `node:string_decoder`
7
- * - Drains stdout so chatty `console.log` can't fill the pipe buffer
8
- * - SIGTERM → SIGKILL escalation after a grace window
9
- * - Process groups (`detached: true`) so grand-children die with the parent
10
- * - SIGINT handler that kills every in-flight worker cleanly
11
- * - Nonce in the frame instruction prevents a test file from spoofing results
12
- * - Reporter callbacks wrapped in try/catch so a buggy reporter can't crash the run
13
- * - Argv / config validation (threads, timeout) happens upstream; pool normalises
14
- */
15
-
16
- import { spawn } from "node:child_process";
17
- import os from "node:os";
18
- import path from "node:path";
19
- import process from "node:process";
20
- import { StringDecoder } from "node:string_decoder";
21
- import type { FileResult } from "../runtime/run.js";
22
- import type { Reporter } from "./reporter.js";
23
-
24
- export interface PoolConfig {
25
- /** Absolute path to the CLI worker entry (cli-worker.ts). */
26
- workerEntry: string;
27
- /** Node executable to spawn. Default: `process.execPath`. */
28
- nodeBin?: string;
29
- /** Extra args before the worker entry, e.g. `["--import", "<tsx-loader>"]`. */
30
- nodeArgs?: string[];
31
- /** Concurrent workers. Default: `os.cpus().length`. */
32
- threads?: number;
33
- /** Per-file timeout (ms). Default 60 000. */
34
- timeoutMs?: number;
35
- /**
36
- * Grace period (ms) between SIGTERM and SIGKILL when killing a hanging
37
- * worker. Default 2 000.
38
- */
39
- killGraceMs?: number;
40
- /**
41
- * Extra environment variables merged into every spawned worker's env.
42
- * Used by coverage to forward `NODE_V8_COVERAGE=<tmp-dir>`.
43
- */
44
- extraEnv?: NodeJS.ProcessEnv;
45
- }
46
-
47
- export interface WorkerErrorMessage {
48
- file: string | undefined;
49
- message: string;
50
- stack?: string;
51
- }
52
-
53
- export type FileOutcome =
54
- | { kind: "result"; result: FileResult }
55
- | { kind: "error"; error: WorkerErrorMessage };
56
-
57
- const FRAME_PREFIX = "__HELIX_RESULT__";
58
- /** Maximum stderr buffering before we give up and mark the file errored. */
59
- const MAX_STDERR_BUFFER_BYTES = 4 * 1024 * 1024; // 4 MiB
60
- /**
61
- * Magic nonce the worker uses for errors it emits BEFORE receiving the
62
- * instruction (so before it knows the real nonce). Accepted by the parent
63
- * only for `type === "error"` frames so a fixture can't spoof results.
64
- */
65
- const PRE_HANDSHAKE_NONCE = "__helix_pre_handshake__";
66
-
67
- interface ActiveChild {
68
- kill(): void;
69
- }
70
-
71
- /** Tracks every spawned worker so SIGINT can tear them all down. */
72
- const activeChildren = new Set<ActiveChild>();
73
- let sigintInstalled = false;
74
-
75
- function installSigintHandlerOnce(): void {
76
- if (sigintInstalled) return;
77
- sigintInstalled = true;
78
- const tearDown = (signal: NodeJS.Signals): void => {
79
- for (const child of activeChildren) {
80
- try {
81
- child.kill();
82
- } catch {
83
- /* ignore */
84
- }
85
- }
86
- process.off(signal, tearDown);
87
- process.kill(process.pid, signal);
88
- };
89
- process.once("SIGINT", tearDown);
90
- process.once("SIGTERM", tearDown);
91
- }
92
-
93
- /**
94
- * FIFO counting semaphore. Waiters resolve in the order they called
95
- * `acquire()` when a permit is released.
96
- */
97
- class Semaphore {
98
- #permits: number;
99
- readonly #waiters: Array<() => void> = [];
100
- constructor(initial: number) {
101
- if (!Number.isFinite(initial) || initial < 1) {
102
- throw new Error(
103
- `Semaphore: initial permits must be a finite number >= 1, got ${initial}`,
104
- );
105
- }
106
- this.#permits = Math.floor(initial);
107
- }
108
- async acquire(): Promise<() => void> {
109
- if (this.#permits > 0) {
110
- this.#permits -= 1;
111
- return this.#makeRelease();
112
- }
113
- return new Promise((resolve) => {
114
- this.#waiters.push(() => resolve(this.#makeRelease()));
115
- });
116
- }
117
- #makeRelease(): () => void {
118
- let released = false;
119
- return () => {
120
- if (released) return;
121
- released = true;
122
- const next = this.#waiters.shift();
123
- if (next) {
124
- next();
125
- } else {
126
- this.#permits += 1;
127
- }
128
- };
129
- }
130
- }
131
-
132
- function safeCall<T>(fn: () => T, context: string): T | undefined {
133
- try {
134
- return fn();
135
- } catch (err) {
136
- process.stderr.write(
137
- `helix: reporter.${context} threw — ${err instanceof Error ? err.message : String(err)}\n`,
138
- );
139
- return undefined;
140
- }
141
- }
142
-
143
- const LOADER_FLAG_PREFIXES = [
144
- "--import",
145
- "--loader",
146
- "--experimental-loader",
147
- "--experimental-vm-modules",
148
- "--experimental-specifier-resolution",
149
- "--conditions",
150
- ];
151
-
152
- function inheritedLoaderArgs(): string[] {
153
- const out: string[] = [];
154
- const argv = process.execArgv;
155
- for (let i = 0; i < argv.length; i += 1) {
156
- const arg = argv[i];
157
- if (arg === undefined) continue;
158
- const prefix = LOADER_FLAG_PREFIXES.find(
159
- (p) => arg === p || arg.startsWith(`${p}=`),
160
- );
161
- if (prefix === undefined) continue;
162
- out.push(arg);
163
- // Flags written as `--import tsx` (space-separated) consume the next argv entry.
164
- if (arg === prefix) {
165
- const next = argv[i + 1];
166
- if (next !== undefined) {
167
- out.push(next);
168
- i += 1;
169
- }
170
- }
171
- }
172
- return out;
173
- }
174
-
175
- export async function runPool(
176
- files: string[],
177
- cfg: PoolConfig,
178
- reporter: Reporter,
179
- ): Promise<{ results: FileResult[]; errors: WorkerErrorMessage[] }> {
180
- installSigintHandlerOnce();
181
-
182
- const effectiveThreads = Math.max(
183
- 1,
184
- Math.min(
185
- cfg.threads ?? os.cpus().length,
186
- files.length > 0 ? files.length : 1,
187
- ),
188
- );
189
- const sem = new Semaphore(effectiveThreads);
190
- const results: FileResult[] = [];
191
- const errors: WorkerErrorMessage[] = [];
192
-
193
- await Promise.all(
194
- files.map(async (file) => {
195
- const absFile = path.isAbsolute(file) ? file : path.resolve(file);
196
- const release = await sem.acquire();
197
- try {
198
- safeCall(() => reporter.onFileStart(absFile), "onFileStart");
199
- const outcome = await runOne(absFile, cfg);
200
- if (outcome.kind === "result") {
201
- safeCall(() => reporter.onFileResult(outcome.result), "onFileResult");
202
- results.push(outcome.result);
203
- } else {
204
- safeCall(() => reporter.onFileError(outcome.error), "onFileError");
205
- errors.push(outcome.error);
206
- }
207
- } finally {
208
- release();
209
- }
210
- }),
211
- );
212
-
213
- return { results, errors };
214
- }
215
-
216
- function runOne(file: string, cfg: PoolConfig): Promise<FileOutcome> {
217
- return new Promise((resolve) => {
218
- const nodeBin = cfg.nodeBin ?? process.execPath;
219
- // Inherit the parent process's --import / --loader / --experimental-*
220
- // flags by default. This is what keeps the spawned worker able to
221
- // execute the .ts worker entry when the parent runs under a TS loader
222
- // (@swc-node/register, tsx, ts-node/esm, …). Callers that want the
223
- // child to run with a clean argv pass `nodeArgs: []` explicitly.
224
- const nodeArgs = cfg.nodeArgs ?? inheritedLoaderArgs();
225
- const timeoutMs =
226
- Number.isFinite(cfg.timeoutMs) && (cfg.timeoutMs ?? 0) > 0
227
- ? Math.floor(cfg.timeoutMs as number)
228
- : 60_000;
229
- const killGraceMs =
230
- Number.isFinite(cfg.killGraceMs) && (cfg.killGraceMs ?? 0) > 0
231
- ? Math.floor(cfg.killGraceMs as number)
232
- : 2_000;
233
- // Per-invocation nonce — the worker echoes it inside every frame so
234
- // malicious or confused test code can't spoof a `__HELIX_RESULT__` line.
235
- const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
236
-
237
- const child = spawn(nodeBin, [...nodeArgs, cfg.workerEntry], {
238
- stdio: ["pipe", "pipe", "pipe"],
239
- // `detached: true` on POSIX creates a new process group. On
240
- // termination we kill the whole group so grand-children die too.
241
- detached: process.platform !== "win32",
242
- env: cfg.extraEnv ? { ...process.env, ...cfg.extraEnv } : process.env,
243
- });
244
-
245
- const registration: ActiveChild = {
246
- kill: () => killChild(child, killGraceMs),
247
- };
248
- activeChildren.add(registration);
249
-
250
- let settled = false;
251
- let pendingOutcome: FileOutcome | undefined;
252
- const settle = (outcome: FileOutcome): void => {
253
- if (settled) return;
254
- settled = true;
255
- clearTimeout(watchdog);
256
- activeChildren.delete(registration);
257
- resolve(outcome);
258
- };
259
- /**
260
- * Schedule resolution. On a `result` or `error` frame from the
261
- * worker (`forceKill = false`) we wait for `child.on("exit")` so
262
- * `atExit` hooks (e.g. V8 coverage writer) finish flushing before
263
- * we let downstream code read the coverage dir. On a timeout or
264
- * spawn error (`forceKill = true`) we kill it now and resolve
265
- * immediately — there's no useful flush to wait for.
266
- */
267
- const finish = (outcome: FileOutcome, forceKill = false): void => {
268
- if (settled) return;
269
- if (forceKill) {
270
- killChild(child, killGraceMs);
271
- settle(outcome);
272
- return;
273
- }
274
- // Defer resolution until exit — but if the child has already
275
- // exited (race), settle now.
276
- if (child.exitCode !== null || child.signalCode !== null) {
277
- settle(outcome);
278
- return;
279
- }
280
- pendingOutcome = outcome;
281
- };
282
-
283
- const watchdog = setTimeout(() => {
284
- finish(
285
- {
286
- kind: "error",
287
- error: {
288
- file,
289
- message: `worker timed out after ${timeoutMs}ms`,
290
- },
291
- },
292
- true,
293
- );
294
- }, timeoutMs + killGraceMs);
295
- watchdog.unref?.();
296
-
297
- // UTF-8-safe accumulator for stderr frame parsing.
298
- const decoder = new StringDecoder("utf8");
299
- let stderrBuffer = "";
300
-
301
- const processLine = (line: string): void => {
302
- if (!line.startsWith(FRAME_PREFIX)) {
303
- if (process.env.HELIX_DEBUG_POOL) {
304
- process.stderr.write(`[helix-debug] worker stderr: ${line}\n`);
305
- }
306
- return;
307
- }
308
- const payload = line.slice(FRAME_PREFIX.length);
309
- let msg: unknown;
310
- try {
311
- msg = JSON.parse(payload);
312
- } catch {
313
- return;
314
- }
315
- if (!msg || typeof msg !== "object") return;
316
- const m = msg as {
317
- nonce?: unknown;
318
- type?: unknown;
319
- result?: FileResult;
320
- file?: string;
321
- message?: string;
322
- stack?: string;
323
- };
324
- // Reject frames without the matching nonce — prevents a fixture
325
- // that writes `__HELIX_RESULT__...` on stderr from spoofing.
326
- // Exception: pre-handshake error frames (worker couldn't parse
327
- // its own instruction and doesn't know the real nonce yet) are
328
- // accepted only for `type === "error"` so a fixture still can't
329
- // claim a fake success.
330
- const nonceOk =
331
- m.nonce === nonce ||
332
- (m.nonce === PRE_HANDSHAKE_NONCE && m.type === "error");
333
- if (!nonceOk) return;
334
- if (m.type === "result" && m.result) {
335
- finish({ kind: "result", result: m.result });
336
- } else if (m.type === "error") {
337
- finish({
338
- kind: "error",
339
- error: {
340
- file: m.file ?? file,
341
- message: m.message ?? "unknown worker error",
342
- stack: m.stack,
343
- },
344
- });
345
- }
346
- };
347
-
348
- child.stderr?.on("data", (chunk: Buffer) => {
349
- const text = decoder.write(chunk);
350
- stderrBuffer += text;
351
- if (stderrBuffer.length > MAX_STDERR_BUFFER_BYTES) {
352
- finish(
353
- {
354
- kind: "error",
355
- error: {
356
- file,
357
- message: `worker stderr exceeded ${MAX_STDERR_BUFFER_BYTES} bytes without emitting a frame`,
358
- },
359
- },
360
- true,
361
- );
362
- return;
363
- }
364
- let nl = stderrBuffer.indexOf("\n");
365
- while (nl >= 0 && !settled) {
366
- const line = stderrBuffer.slice(0, nl);
367
- stderrBuffer = stderrBuffer.slice(nl + 1);
368
- processLine(line);
369
- nl = stderrBuffer.indexOf("\n");
370
- }
371
- });
372
-
373
- // Drain stdout so a chatty `console.log` can't fill the 64 KB pipe
374
- // buffer and deadlock the worker. We don't currently surface it to
375
- // the reporter (Phase 2 feature).
376
- child.stdout?.on("data", () => {});
377
-
378
- child.on("error", (err) => {
379
- finish(
380
- {
381
- kind: "error",
382
- error: { file, message: `spawn failed: ${err.message}` },
383
- },
384
- true,
385
- );
386
- });
387
-
388
- child.on("exit", () => {
389
- // If we already have an outcome waiting on the natural exit,
390
- // resolve it now — coverage hooks have flushed.
391
- if (pendingOutcome) {
392
- settle(pendingOutcome);
393
- return;
394
- }
395
- if (settled) return;
396
- // Flush any partial trailing line from the decoder before giving up.
397
- const trailing = decoder.end();
398
- if (trailing) {
399
- stderrBuffer += trailing;
400
- const nl = stderrBuffer.indexOf("\n");
401
- if (nl >= 0) processLine(stderrBuffer.slice(0, nl));
402
- }
403
- if (pendingOutcome) {
404
- settle(pendingOutcome);
405
- return;
406
- }
407
- if (settled) return;
408
- settle({
409
- kind: "error",
410
- error: {
411
- file,
412
- message: "worker exited without emitting a framed result",
413
- },
414
- });
415
- });
416
-
417
- // Instruction line.
418
- const instr = JSON.stringify({
419
- type: "run",
420
- file,
421
- timeoutMs,
422
- nonce,
423
- });
424
- // `child.stdin` may be closed by the time we get here (rare spawn race).
425
- // Attach an error listener so an EPIPE doesn't crash the parent.
426
- child.stdin?.on("error", (err) => {
427
- finish(
428
- {
429
- kind: "error",
430
- error: { file, message: `stdin write failed: ${err.message}` },
431
- },
432
- true,
433
- );
434
- });
435
- child.stdin?.write(`${instr}\n`, () => {
436
- child.stdin?.end();
437
- });
438
- });
439
- }
440
-
441
- /**
442
- * Kill `child` with SIGTERM, escalate to SIGKILL after `graceMs`. On POSIX
443
- * we target the process group (negative pid) so grand-children die too.
444
- *
445
- * Idempotent: further calls after the first are no-ops. If the child has
446
- * already exited (cleanly or via an earlier kill), no signals are sent and
447
- * no escalation timer is armed — which matters for large suites where a
448
- * per-file 2s timer retains the `child` closure and creates GC pressure,
449
- * and for Linux systems where pid reuse could deliver SIGKILL to the wrong
450
- * process.
451
- */
452
- const killedChildren = new WeakSet<ReturnType<typeof spawn>>();
453
-
454
- function killChild(child: ReturnType<typeof spawn>, graceMs: number): void {
455
- if (killedChildren.has(child)) return;
456
- killedChildren.add(child);
457
- // If the child already exited on its own, nothing to do — no SIGTERM,
458
- // no escalation timer.
459
- if (child.killed || child.exitCode !== null || child.signalCode !== null) {
460
- return;
461
- }
462
- const pid = child.pid;
463
- const posix = process.platform !== "win32";
464
- const trySignal = (signal: NodeJS.Signals): void => {
465
- // Re-check exit status on each signal to avoid pid-reuse races: the
466
- // child may have exited between our initial check and this send.
467
- if (child.exitCode !== null || child.signalCode !== null) return;
468
- try {
469
- if (posix && typeof pid === "number") {
470
- process.kill(-pid, signal);
471
- } else {
472
- child.kill(signal);
473
- }
474
- } catch {
475
- /* already dead, permission denied, or process-group invalid */
476
- }
477
- };
478
- trySignal("SIGTERM");
479
- // Escalate after the grace window if the child is still alive. Cleared
480
- // by `child.on("exit")` so a clean exit drops the timer immediately.
481
- const escalation = setTimeout(() => {
482
- trySignal("SIGKILL");
483
- }, graceMs);
484
- escalation.unref?.();
485
- child.once("exit", () => clearTimeout(escalation));
486
- }