@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.
- package/CHANGELOG.md +13 -0
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export.js +7 -0
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- package/dist/src/commands/re-export.js +8 -0
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test.js +75 -23
- package/dist/src/core/build-baseline-types.d.ts +24 -0
- package/dist/src/core/build-baseline.d.ts +5 -2
- package/dist/src/core/build-baseline.js +5 -1
- package/dist/src/core/furnace-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- package/dist/src/core/test-harness-crash.d.ts +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +29 -1
- package/dist/src/core/test-stale-check.js +59 -0
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +27 -4
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- 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
|
|
293
|
+
/** Add a staged dependency declaration. */
|
|
282
294
|
add?: boolean;
|
|
283
|
-
/** Remove matching
|
|
295
|
+
/** Remove matching staged dependency declarations. */
|
|
284
296
|
remove?: boolean;
|
|
285
297
|
/** Drop the stagedDependencies field entirely. */
|
|
286
298
|
clear?: boolean;
|
|
287
|
-
/**
|
|
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.
|