@hominis/fireforge 0.36.0 → 0.37.0

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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/src/commands/build.js +1 -1
  3. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  4. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  5. package/dist/src/commands/doctor.js +2 -0
  6. package/dist/src/commands/export.js +7 -0
  7. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  8. package/dist/src/commands/patch/staged-dependency.js +113 -51
  9. package/dist/src/commands/re-export-files.js +4 -0
  10. package/dist/src/commands/re-export.js +8 -0
  11. package/dist/src/commands/test-diagnose.js +20 -1
  12. package/dist/src/commands/test.js +75 -23
  13. package/dist/src/core/build-baseline-types.d.ts +24 -0
  14. package/dist/src/core/build-baseline.d.ts +5 -2
  15. package/dist/src/core/build-baseline.js +5 -1
  16. package/dist/src/core/furnace-config-custom.js +10 -0
  17. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  18. package/dist/src/core/furnace-stale-export.js +123 -0
  19. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  20. package/dist/src/core/furnace-validate-structure.js +3 -2
  21. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  22. package/dist/src/core/mach-known-noise-filter.js +193 -0
  23. package/dist/src/core/mach.d.ts +8 -0
  24. package/dist/src/core/mach.js +36 -3
  25. package/dist/src/core/patch-lint-cross.js +80 -2
  26. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  27. package/dist/src/core/patch-manifest-io.js +27 -16
  28. package/dist/src/core/patch-manifest-validate.js +24 -0
  29. package/dist/src/core/test-harness-crash.d.ts +16 -0
  30. package/dist/src/core/test-harness-crash.js +55 -0
  31. package/dist/src/core/test-stale-check.d.ts +29 -1
  32. package/dist/src/core/test-stale-check.js +59 -0
  33. package/dist/src/types/commands/index.d.ts +1 -1
  34. package/dist/src/types/commands/options.d.ts +27 -4
  35. package/dist/src/types/commands/patches.d.ts +29 -0
  36. package/dist/src/types/furnace.d.ts +13 -0
  37. package/dist/src/utils/process-group.d.ts +33 -0
  38. package/dist/src/utils/process-group.js +161 -0
  39. package/dist/src/utils/process.d.ts +15 -0
  40. package/dist/src/utils/process.js +88 -44
  41. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import type { BuildBaseline } from './build-baseline-types.js';
1
+ import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
2
2
  /** Result of the stale-build preflight probe. */
3
3
  export interface StaleBuildResult {
4
4
  /** True when at least one packageable engine file changed since the baseline. */
@@ -40,3 +40,31 @@ export declare function checkStaleBuildForTest(projectRoot: string, engineDir: s
40
40
  * result without matching the rendered copy.
41
41
  */
42
42
  export declare function formatStaleBuildWarning(result: StaleBuildResult): string;
43
+ /**
44
+ * Sentinel returned by {@link findUncoveredRequestPaths} when a full-suite
45
+ * request (no paths) is checked against a scoped coverage record.
46
+ */
47
+ export declare const FULL_SUITE_REQUEST = "(entire suite)";
48
+ /**
49
+ * Compares the requested test paths against the packaged runtime's coverage
50
+ * claim recorded in the baseline. Returns the requested paths the recorded
51
+ * packaging does NOT cover — the runs that would dispatch against missing
52
+ * `_tests/` support fixtures and hang rather than fail.
53
+ *
54
+ * Coverage semantics: `undefined` (pre-0.37.0 baseline) and `'full'` cover
55
+ * everything. A scoped list covers a request path when the request equals a
56
+ * covered entry or sits beneath a covered directory entry. Both sides are
57
+ * normalized to forward slashes so Windows-style CLI input cannot defeat
58
+ * the prefix rule (baseline paths are POSIX by convention). A request with
59
+ * no paths is a full-suite run and is never covered by a scoped list — the
60
+ * {@link FULL_SUITE_REQUEST} sentinel is returned so the refusal can name it.
61
+ */
62
+ export declare function findUncoveredRequestPaths(coverage: TestPackagingCoverage | undefined, requestedPaths: readonly string[]): string[];
63
+ /**
64
+ * Formats the refusal shown when a non-`--build` run requests paths the
65
+ * packaged runtime's scoped `test --build` coverage does not include —
66
+ * enforced on every such run, with or without `--allow-stale-build`. Kept
67
+ * separate from the matcher so tests can pin structure and copy
68
+ * independently (same split as {@link formatStaleBuildWarning}).
69
+ */
70
+ export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[]): string;
@@ -112,3 +112,62 @@ export function formatStaleBuildWarning(result) {
112
112
  'packaged chrome / jar.mn resources, rerun with "fireforge test --build" (or ' +
113
113
  '"fireforge build --ui") first. Passing --build skips this check.');
114
114
  }
115
+ /**
116
+ * Sentinel returned by {@link findUncoveredRequestPaths} when a full-suite
117
+ * request (no paths) is checked against a scoped coverage record.
118
+ */
119
+ export const FULL_SUITE_REQUEST = '(entire suite)';
120
+ /**
121
+ * Compares the requested test paths against the packaged runtime's coverage
122
+ * claim recorded in the baseline. Returns the requested paths the recorded
123
+ * packaging does NOT cover — the runs that would dispatch against missing
124
+ * `_tests/` support fixtures and hang rather than fail.
125
+ *
126
+ * Coverage semantics: `undefined` (pre-0.37.0 baseline) and `'full'` cover
127
+ * everything. A scoped list covers a request path when the request equals a
128
+ * covered entry or sits beneath a covered directory entry. Both sides are
129
+ * normalized to forward slashes so Windows-style CLI input cannot defeat
130
+ * the prefix rule (baseline paths are POSIX by convention). A request with
131
+ * no paths is a full-suite run and is never covered by a scoped list — the
132
+ * {@link FULL_SUITE_REQUEST} sentinel is returned so the refusal can name it.
133
+ */
134
+ export function findUncoveredRequestPaths(coverage, requestedPaths) {
135
+ if (coverage === undefined || coverage === 'full') {
136
+ return [];
137
+ }
138
+ const covered = coverage.map(normalizeCoveragePath).filter((p) => p.length > 0);
139
+ if (requestedPaths.length === 0) {
140
+ return [FULL_SUITE_REQUEST];
141
+ }
142
+ return requestedPaths.filter((requested) => {
143
+ const path = normalizeCoveragePath(requested);
144
+ return !covered.some((c) => path === c || path.startsWith(`${c}/`));
145
+ });
146
+ }
147
+ /** Normalizes a path for coverage comparison: forward slashes, no trailing slash. */
148
+ function normalizeCoveragePath(path) {
149
+ return path.trim().replace(/\\/g, '/').replace(/\/+$/, '');
150
+ }
151
+ /**
152
+ * Formats the refusal shown when a non-`--build` run requests paths the
153
+ * packaged runtime's scoped `test --build` coverage does not include —
154
+ * enforced on every such run, with or without `--allow-stale-build`. Kept
155
+ * separate from the matcher so tests can pin structure and copy
156
+ * independently (same split as {@link formatStaleBuildWarning}).
157
+ */
158
+ export function formatTestCoverageRefusal(uncovered, coverage) {
159
+ const cap = (paths) => {
160
+ const head = paths.slice(0, STALE_PATHS_LIMIT);
161
+ const truncated = paths.length - head.length;
162
+ return head.join(', ') + (truncated > 0 ? `, … (+${truncated} more)` : '');
163
+ };
164
+ const rebuildTargets = uncovered.filter((p) => p !== FULL_SUITE_REQUEST);
165
+ const rebuildHint = rebuildTargets.length > 0
166
+ ? `Rerun "fireforge test --build ${rebuildTargets.slice(0, STALE_PATHS_LIMIT).join(' ')}" to package them, or run "fireforge build" for full coverage.`
167
+ : 'Run "fireforge build" (or a path-less "fireforge test --build") for full coverage first.';
168
+ return (`The packaged test runtime was produced by a scoped "fireforge test --build" covering only: ${cap(coverage)}.\n` +
169
+ `The requested run needs ${cap(uncovered)}, which that packaging does not cover — support ` +
170
+ 'fixtures for those manifests may be missing from obj-*/_tests/, and the run can hang ' +
171
+ `rather than fail. ${rebuildHint} ` +
172
+ '(--allow-stale-build does not bypass this check — it accepts stale content, not missing coverage.)');
173
+ }
@@ -2,5 +2,5 @@
2
2
  * Re-exports all command-related types from focused sub-modules.
3
3
  */
4
4
  export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, LintCommandOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchMoveFilesOptions, PatchRenameOptions, PatchReorderOptions, PatchSplitOptions, PatchStagedDependencyOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, SourceSetOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
- export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, PatchStagedDependencies, PatchStagedForwardImport, } from './patches.js';
5
+ export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, PatchStagedDependencies, PatchStagedForwardImport, PatchStagedRegistration, } from './patches.js';
6
6
  export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';
@@ -99,6 +99,12 @@ export interface ExportOptions {
99
99
  forceUnsafe?: boolean;
100
100
  /** Exclude furnace-managed file paths from the export. */
101
101
  excludeFurnace?: boolean;
102
+ /**
103
+ * Export the deployed engine copy even when the `components/` source
104
+ * changed since the last furnace apply. Without this flag the export is
105
+ * refused so a stale deployed copy cannot silently land in the patch.
106
+ */
107
+ allowStaleFurnace?: boolean;
102
108
  /**
103
109
  * Acknowledge that the export will create cross-patch ownership overlap
104
110
  * with existing non-superseded patches. Without this flag, `export`
@@ -210,6 +216,12 @@ export interface ReExportOptions {
210
216
  * before the interactive/`--yes` confirmation path.
211
217
  */
212
218
  allowShrink?: boolean;
219
+ /**
220
+ * Export the deployed engine copy even when the `components/` source
221
+ * changed since the last furnace apply. Without this flag the re-export
222
+ * is refused so a stale deployed copy cannot silently land in the patch.
223
+ */
224
+ allowStaleFurnace?: boolean;
213
225
  /** Bypass cross-patch lint refusal on projected shrink state */
214
226
  forceUnsafe?: boolean;
215
227
  /**
@@ -278,16 +290,27 @@ export interface PatchLintIgnoreOptions {
278
290
  * declarations, or clear all staged dependencies from the patch.
279
291
  */
280
292
  export interface PatchStagedDependencyOptions {
281
- /** Add a forward-import staged dependency. */
293
+ /** Add a staged dependency declaration. */
282
294
  add?: boolean;
283
- /** Remove matching forward-import staged dependency declarations. */
295
+ /** Remove matching staged dependency declarations. */
284
296
  remove?: boolean;
285
297
  /** Drop the stagedDependencies field entirely. */
286
298
  clear?: boolean;
287
- /** Importing file path relative to engine/. */
299
+ /**
300
+ * Declaration shape: `import` (forward import, the default when unset) or
301
+ * `registration` (jar.mn packaging line, customElements or actor
302
+ * registration). Registration entries use `line` instead of `specifier`.
303
+ */
304
+ kind?: string;
305
+ /** Declaring file path relative to engine/. */
288
306
  file?: string;
289
- /** Exact import specifier as it appears in source. */
307
+ /** Exact import specifier as it appears in source (`kind: import`). */
290
308
  specifier?: string;
309
+ /**
310
+ * Registration/packaging line as the patch adds it, compared
311
+ * whitespace-trimmed (`kind: registration`).
312
+ */
313
+ line?: string;
291
314
  /** Later-created file path relative to engine/. */
292
315
  creates?: string;
293
316
  /** Optional exact patch filename expected to create `creates`. */
@@ -127,6 +127,15 @@ export interface PatchMetadata {
127
127
  export interface PatchStagedDependencies {
128
128
  /** Exact forward-import declarations allowed for this patch. */
129
129
  forwardImports?: PatchStagedForwardImport[];
130
+ /**
131
+ * Registration-shaped forward dependencies: packaging or registration
132
+ * LINES (a jar.mn entry, a customElements registration, an actor
133
+ * registration) this patch adds that reference a file a later patch
134
+ * creates. Unlike `forwardImports`, these are validated by matching the
135
+ * declared line against the patch's added content, not by finding an
136
+ * import specifier — packaging lines have no import to match.
137
+ */
138
+ registrations?: PatchStagedRegistration[];
130
139
  }
131
140
  /** A single intentional forward import to a later-created file. */
132
141
  export interface PatchStagedForwardImport {
@@ -141,6 +150,26 @@ export interface PatchStagedForwardImport {
141
150
  /** Optional human-readable rationale for the staged dependency. */
142
151
  reason?: string;
143
152
  }
153
+ /**
154
+ * A single intentional registration/packaging line referencing a
155
+ * later-created file.
156
+ */
157
+ export interface PatchStagedRegistration {
158
+ /** Declaring file path relative to engine/ (e.g. `toolkit/content/jar.mn`). */
159
+ file: string;
160
+ /**
161
+ * The registration/packaging line exactly as the patch adds it. Compared
162
+ * whitespace-trimmed against the patch's added lines in `file`, so
163
+ * indentation differences do not break the match.
164
+ */
165
+ line: string;
166
+ /** Later-created file path relative to engine/. */
167
+ creates: string;
168
+ /** Optional exact patch filename expected to create `creates`. */
169
+ owner?: string;
170
+ /** Optional human-readable rationale for the staged dependency. */
171
+ reason?: string;
172
+ }
144
173
  /**
145
174
  * Schema for patches/patches.json file.
146
175
  */
@@ -46,6 +46,19 @@ export interface CustomComponentConfig {
46
46
  targetPath: string;
47
47
  /** Whether to register in customElements.js */
48
48
  register: boolean;
49
+ /**
50
+ * What the component IS. `'element'` (the default when unset) is a custom
51
+ * element with its own tag. `'library'` is a base class + helpers module
52
+ * that defines no element of its own (e.g. a shared MozLitElement
53
+ * subclass other components extend): `furnace validate` skips the
54
+ * `no-custom-element-define` / `not-moz-lit-element` compatibility checks
55
+ * and the `missing-css` structural advisory for it, while headers, module
56
+ * shape, `relative-import`, and (when a `.css` exists) the CSS
57
+ * compatibility rules still apply. A library exports no element, so
58
+ * `kind: "library"` requires `register: false` — the combination with
59
+ * `register: true` is rejected at config parse time.
60
+ */
61
+ kind?: 'element' | 'library';
49
62
  /** Whether this component uses Fluent l10n */
50
63
  localized: boolean;
51
64
  /** Stock component tag names composed internally by this component */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Process-group kill and post-run sweep helpers for the exec layer
3
+ * (0.37.0 item 9a). Split out of `process.ts` to keep that file within the
4
+ * per-file line budget; deliberately spawn-based (not `exec`-based) so the
5
+ * two modules do not import each other cyclically.
6
+ */
7
+ import { type ChildProcess } from 'node:child_process';
8
+ /**
9
+ * Sends `signal` to the child's whole tree: the process GROUP on POSIX
10
+ * (negative-PID kill — reaches mach → python → firefox → content-process
11
+ * chains), or a `taskkill /T /F` tree kill plus a direct `child.kill`
12
+ * fallback on Windows. No-ops once the direct child has exited (group
13
+ * survivors after exit are the post-run sweep's job, not this function's).
14
+ */
15
+ export declare function killProcessTree(child: ChildProcess, signal: NodeJS.Signals, usesProcessGroup: boolean): void;
16
+ /** One process still alive in a swept group. */
17
+ export interface ProcessGroupSurvivor {
18
+ /** PID, or -1 when pgrep was unavailable and only a liveness probe ran. */
19
+ pid: number;
20
+ /** Command line (pgrep -lf output), best-effort. */
21
+ command: string;
22
+ }
23
+ /**
24
+ * Post-run reaper for a process group FireForge itself spawned (`pgid` is
25
+ * the PID of a child started with `detached: true`): lists survivors,
26
+ * SIGTERMs the group, waits a short grace, escalates to SIGKILL, and warns
27
+ * about anything that still refuses to die. POSIX only (no-op on win32).
28
+ * The only kill target is `-pgid` — never anything outside the group.
29
+ * A healthy run costs exactly one `pgrep`.
30
+ */
31
+ export declare function sweepProcessGroup(pgid: number, graceMs?: number): Promise<{
32
+ survivors: ProcessGroupSurvivor[];
33
+ }>;
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Process-group kill and post-run sweep helpers for the exec layer
4
+ * (0.37.0 item 9a). Split out of `process.ts` to keep that file within the
5
+ * per-file line budget; deliberately spawn-based (not `exec`-based) so the
6
+ * two modules do not import each other cyclically.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { verbose, warn } from './logger.js';
10
+ /**
11
+ * Sends `signal` to the child's whole tree: the process GROUP on POSIX
12
+ * (negative-PID kill — reaches mach → python → firefox → content-process
13
+ * chains), or a `taskkill /T /F` tree kill plus a direct `child.kill`
14
+ * fallback on Windows. No-ops once the direct child has exited (group
15
+ * survivors after exit are the post-run sweep's job, not this function's).
16
+ */
17
+ export function killProcessTree(child, signal, usesProcessGroup) {
18
+ if (child.exitCode !== null || child.signalCode !== null)
19
+ return;
20
+ const targetPid = child.pid;
21
+ if (targetPid === undefined)
22
+ return;
23
+ try {
24
+ if (usesProcessGroup) {
25
+ // Negative PID routes to the process group, killing the Python
26
+ // wrapper, the firefox it forked, and every content process
27
+ // that inherited the group.
28
+ process.kill(-targetPid, signal);
29
+ }
30
+ else {
31
+ // No process group on Windows — taskkill /T walks the descendant
32
+ // tree instead. Always forced (/F): there is no SIGTERM analogue,
33
+ // so the grace window only exists on POSIX.
34
+ spawn('taskkill', ['/pid', String(targetPid), '/T', '/F'], {
35
+ stdio: 'ignore',
36
+ }).on('error', () => {
37
+ // taskkill unavailable — nothing more we can do beyond the
38
+ // direct-child kill below.
39
+ });
40
+ child.kill(signal);
41
+ }
42
+ }
43
+ catch {
44
+ // Already gone. Nothing to do.
45
+ }
46
+ }
47
+ const SWEEP_GRACE_MS = 2000;
48
+ const MULTIPROCESSING_WORKER_PATTERN = /multiprocessing\.(?:spawn|forkserver)|resource_tracker/;
49
+ function sweepDelay(ms) {
50
+ // Deliberately ref'd (no unref()): this promise is AWAITED between the
51
+ // group SIGTERM and the post-grace re-list/SIGKILL escalation, from a
52
+ // child 'close' handler after the signal forwarder is disposed — with an
53
+ // unref'd timer nothing kept the event loop alive, so Node could exit
54
+ // mid-grace and skip the escalation entirely. Healthy runs never reach
55
+ // this function (sweepProcessGroup early-returns on zero survivors), so
56
+ // the ref never holds a clean exit open.
57
+ return new Promise((resolve) => {
58
+ setTimeout(resolve, ms);
59
+ });
60
+ }
61
+ /**
62
+ * Minimal spawn-based pgrep runner. exitCode -1 means pgrep itself was
63
+ * unavailable or errored (distinct from exit 1 = "no matches").
64
+ */
65
+ async function runPgrep(args) {
66
+ return new Promise((resolve) => {
67
+ const child = spawn('pgrep', args, { stdio: ['ignore', 'pipe', 'ignore'] });
68
+ let stdout = '';
69
+ child.stdout.on('data', (data) => {
70
+ stdout += data.toString('utf8');
71
+ });
72
+ child.on('error', () => {
73
+ resolve({ exitCode: -1, stdout: '' });
74
+ });
75
+ child.on('close', (code) => {
76
+ resolve({ exitCode: code ?? -1, stdout });
77
+ });
78
+ });
79
+ }
80
+ /** Lists processes still in `pgid` via `pgrep -g -lf`, with a kill(0) fallback. */
81
+ async function listGroupSurvivors(pgid) {
82
+ const result = await runPgrep(['-g', String(pgid), '-lf']);
83
+ if (result.exitCode === 0) {
84
+ return result.stdout
85
+ .split('\n')
86
+ .map((line) => line.trim())
87
+ .filter((line) => line.length > 0)
88
+ .map((line) => {
89
+ const match = /^(\d+)\s+(.*)$/.exec(line);
90
+ return match
91
+ ? { pid: Number(match[1]), command: match[2] ?? '' }
92
+ : { pid: -1, command: line };
93
+ });
94
+ }
95
+ if (result.exitCode === 1)
96
+ return []; // no matches
97
+ // pgrep unavailable/broken: fall back to a group liveness probe.
98
+ try {
99
+ process.kill(-pgid, 0);
100
+ return [{ pid: -1, command: 'unknown (pgrep unavailable; group still has live members)' }];
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ function describeSurvivors(list) {
107
+ return list
108
+ .map((s) => {
109
+ const tag = MULTIPROCESSING_WORKER_PATTERN.test(s.command)
110
+ ? ' [multiprocessing worker — the known busy-spin orphan shape]'
111
+ : '';
112
+ return `PID ${String(s.pid)}: ${s.command}${tag}`;
113
+ })
114
+ .join('; ');
115
+ }
116
+ /**
117
+ * Post-run reaper for a process group FireForge itself spawned (`pgid` is
118
+ * the PID of a child started with `detached: true`): lists survivors,
119
+ * SIGTERMs the group, waits a short grace, escalates to SIGKILL, and warns
120
+ * about anything that still refuses to die. POSIX only (no-op on win32).
121
+ * The only kill target is `-pgid` — never anything outside the group.
122
+ * A healthy run costs exactly one `pgrep`.
123
+ */
124
+ export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS) {
125
+ if (process.platform === 'win32')
126
+ return { survivors: [] };
127
+ const survivors = await listGroupSurvivors(pgid);
128
+ if (survivors.length === 0)
129
+ return { survivors };
130
+ warn(`Harness process group ${String(pgid)} left ${String(survivors.length)} surviving ` +
131
+ `process(es) after exit — reaping the group. ${describeSurvivors(survivors)}`);
132
+ try {
133
+ process.kill(-pgid, 'SIGTERM');
134
+ }
135
+ catch {
136
+ return { survivors };
137
+ }
138
+ await sweepDelay(graceMs);
139
+ let remaining = await listGroupSurvivors(pgid);
140
+ if (remaining.length > 0) {
141
+ try {
142
+ process.kill(-pgid, 'SIGKILL');
143
+ }
144
+ catch {
145
+ return { survivors };
146
+ }
147
+ await sweepDelay(Math.min(200, graceMs));
148
+ remaining = await listGroupSurvivors(pgid);
149
+ if (remaining.length > 0) {
150
+ warn(`Process group ${String(pgid)} still has survivors after SIGKILL: ${describeSurvivors(remaining)}. ` +
151
+ 'Inspect manually (ps -axo pid,ppid,time,command) and kill by PID.');
152
+ }
153
+ else {
154
+ verbose(`Process group ${String(pgid)} reaped after SIGKILL escalation.`);
155
+ }
156
+ }
157
+ else {
158
+ verbose(`Process group ${String(pgid)} reaped cleanly with SIGTERM.`);
159
+ }
160
+ return { survivors };
161
+ }
@@ -29,6 +29,21 @@ export interface ExecOptions {
29
29
  env?: Record<string, string>;
30
30
  /** Timeout in milliseconds */
31
31
  timeout?: number;
32
+ /**
33
+ * POSIX: spawn the child as a process-group leader and route every kill
34
+ * (parent-signal forwarding, abort, escalation) to the whole GROUP, then
35
+ * sweep the group for survivors after close — so a harness that dies at
36
+ * startup cannot strand spinning `multiprocessing` workers (field
37
+ * incident: an orphaned Python spawn worker reparented to launchd and
38
+ * busy-spun at 100% CPU for ~26 days). Win32: tree-kill via
39
+ * `taskkill /T /F` on abort/signals only, no post-run sweep
40
+ * (best-effort). Default false — non-mach consumers are unaffected.
41
+ *
42
+ * NOTE for callers: a detached group leader does NOT receive terminal
43
+ * Ctrl+C; the exec layer installs its own group-aware signal forwarder
44
+ * whenever this option is set.
45
+ */
46
+ processGroup?: boolean;
32
47
  }
33
48
  /**
34
49
  * Executes a command and returns its output.