@hominis/fireforge 0.46.2 → 0.47.1
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 +39 -0
- package/dist/bin/fireforge.js +6 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/config.js +1 -2
- package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
- package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
- package/dist/src/commands/furnace/deploy.js +36 -3
- package/dist/src/commands/furnace/validation-output.d.ts +5 -0
- package/dist/src/commands/furnace/validation-output.js +35 -9
- package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
- package/dist/src/commands/patch/delete-gap-notice.js +46 -0
- package/dist/src/commands/patch/delete.js +11 -0
- package/dist/src/commands/re-export-lint.d.ts +26 -1
- package/dist/src/commands/re-export-lint.js +60 -20
- package/dist/src/commands/re-export-scan.d.ts +8 -0
- package/dist/src/commands/re-export-scan.js +12 -2
- package/dist/src/commands/re-export-single.js +24 -5
- package/dist/src/commands/run.js +42 -17
- package/dist/src/commands/test-harness-env.d.ts +24 -0
- package/dist/src/commands/test-harness-env.js +61 -0
- package/dist/src/commands/test-harness-teardown.d.ts +62 -0
- package/dist/src/commands/test-harness-teardown.js +128 -0
- package/dist/src/commands/test-register.js +8 -3
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +36 -7
- package/dist/src/commands/test-verdict.d.ts +21 -4
- package/dist/src/commands/test-verdict.js +37 -4
- package/dist/src/commands/test.js +27 -26
- package/dist/src/core/build-jar-escalation.d.ts +1 -4
- package/dist/src/core/build-jar-escalation.js +1 -4
- package/dist/src/core/build-prepare.js +72 -55
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate-test-toolchains.js +7 -0
- package/dist/src/core/file-lock.js +16 -9
- package/dist/src/core/firefox-cache.js +1 -2
- package/dist/src/core/furnace-apply-output.d.ts +10 -0
- package/dist/src/core/furnace-apply-output.js +19 -4
- package/dist/src/core/furnace-css-fragments.d.ts +7 -0
- package/dist/src/core/furnace-css-fragments.js +12 -3
- package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
- package/dist/src/core/furnace-fragment-includers.js +122 -0
- package/dist/src/core/git-diff-new-file.js +1 -2
- package/dist/src/core/git-file-ops.js +1 -2
- package/dist/src/core/git.js +1 -2
- package/dist/src/core/harness-helper-pids.d.ts +98 -0
- package/dist/src/core/harness-helper-pids.js +240 -0
- package/dist/src/core/harness-orphans.d.ts +53 -8
- package/dist/src/core/harness-orphans.js +125 -44
- package/dist/src/core/mach-build-artifacts.js +1 -2
- package/dist/src/core/mach.d.ts +16 -0
- package/dist/src/core/mach.js +6 -1
- package/dist/src/core/marionette-preflight.js +9 -3
- package/dist/src/core/mochitest-server-port.d.ts +43 -4
- package/dist/src/core/mochitest-server-port.js +64 -11
- package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
- package/dist/src/core/parent-exit-watchdog.js +69 -0
- package/dist/src/core/patch-lint-imports.js +4 -4
- package/dist/src/core/smoke-patterns.d.ts +24 -0
- package/dist/src/core/smoke-patterns.js +50 -5
- package/dist/src/types/commands/options.d.ts +17 -1
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/utils/process-group.d.ts +5 -1
- package/dist/src/utils/process-group.js +7 -3
- package/dist/src/utils/process.d.ts +18 -0
- package/dist/src/utils/process.js +14 -2
- package/docs/configuration.md +15 -1
- package/docs/exit-codes.md +19 -19
- package/docs/lifecycle-invariants.md +17 -1
- package/docs/machine-output.md +44 -19
- package/docs/testing.md +80 -4
- package/package.json +5 -5
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the death of FireForge's own parent while a harness runs.
|
|
3
|
+
*
|
|
4
|
+
* A supervisor that SIGKILLs the process above `fireforge test` (the
|
|
5
|
+
* 2026-09-13 incident: a gate killed its `npm` step at the 15-minute bound)
|
|
6
|
+
* signals nothing below it. FireForge is not told; its stdout pipe merely
|
|
7
|
+
* breaks, and the broken-pipe handler swallows EPIPE on purpose so `| head`
|
|
8
|
+
* works. The mach tree it spawned as a separate process group keeps running
|
|
9
|
+
* under nobody's supervision, and the httpd in that tree spun at 100% CPU
|
|
10
|
+
* for hours.
|
|
11
|
+
*
|
|
12
|
+
* SIGKILL cannot be caught, but reparenting can be observed: once the parent
|
|
13
|
+
* is gone, `process.ppid` changes (to launchd/init, or to a subreaper). This
|
|
14
|
+
* watchdog polls for that while a dispatch is in flight and fires once. It
|
|
15
|
+
* does not decide what to do about it; the test command tears down the
|
|
16
|
+
* harness tree and ends the run.
|
|
17
|
+
*
|
|
18
|
+
* Off on Windows: `process.ppid` is not refreshed there after the parent
|
|
19
|
+
* exits, and there is no process group to signal in response anyway.
|
|
20
|
+
*/
|
|
21
|
+
/** Options for {@link startParentExitWatchdog}. */
|
|
22
|
+
export interface ParentExitWatchdogOptions {
|
|
23
|
+
/** Probe interval, default {@link DEFAULT_INTERVAL_MS}. */
|
|
24
|
+
intervalMs?: number;
|
|
25
|
+
/** Called exactly once, the first time the parent is seen to be gone. */
|
|
26
|
+
onParentExit: (info: {
|
|
27
|
+
originalPpid: number;
|
|
28
|
+
currentPpid: number;
|
|
29
|
+
}) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Reads the current parent pid; default `process.ppid`. Injectable because
|
|
32
|
+
* `process.ppid` cannot be stubbed on every Node line: redefining the
|
|
33
|
+
* property is honoured on Node 26 and silently ignored on Node 22, so a
|
|
34
|
+
* test that stubs it passes on one and never observes a reparent on the
|
|
35
|
+
* other.
|
|
36
|
+
*/
|
|
37
|
+
readPpid?: () => number;
|
|
38
|
+
}
|
|
39
|
+
/** Handle for a running watchdog. */
|
|
40
|
+
export interface ParentExitWatchdog {
|
|
41
|
+
/** Stops probing. Idempotent. A stopped watchdog never fires. */
|
|
42
|
+
stop(): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* True once any watchdog in this process has seen the parent die. The
|
|
46
|
+
* dispatch loop reads it to end the run instead of classifying the killed
|
|
47
|
+
* harness as a crash and retrying it under a parent that no longer exists.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parentExited(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Forgets an observed parent exit.
|
|
52
|
+
*
|
|
53
|
+
* @internal Exported only so tests can reach it. It is not part of the
|
|
54
|
+
* public surface.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resetParentExitForTests(): void;
|
|
57
|
+
/**
|
|
58
|
+
* Starts probing `process.ppid`. The timer is unref'd so the watchdog can
|
|
59
|
+
* never be the thing keeping FireForge alive; while a child runs, the child
|
|
60
|
+
* does that.
|
|
61
|
+
*/
|
|
62
|
+
export declare function startParentExitWatchdog(options: ParentExitWatchdogOptions): ParentExitWatchdog;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Detects the death of FireForge's own parent while a harness runs.
|
|
4
|
+
*
|
|
5
|
+
* A supervisor that SIGKILLs the process above `fireforge test` (the
|
|
6
|
+
* 2026-09-13 incident: a gate killed its `npm` step at the 15-minute bound)
|
|
7
|
+
* signals nothing below it. FireForge is not told; its stdout pipe merely
|
|
8
|
+
* breaks, and the broken-pipe handler swallows EPIPE on purpose so `| head`
|
|
9
|
+
* works. The mach tree it spawned as a separate process group keeps running
|
|
10
|
+
* under nobody's supervision, and the httpd in that tree spun at 100% CPU
|
|
11
|
+
* for hours.
|
|
12
|
+
*
|
|
13
|
+
* SIGKILL cannot be caught, but reparenting can be observed: once the parent
|
|
14
|
+
* is gone, `process.ppid` changes (to launchd/init, or to a subreaper). This
|
|
15
|
+
* watchdog polls for that while a dispatch is in flight and fires once. It
|
|
16
|
+
* does not decide what to do about it; the test command tears down the
|
|
17
|
+
* harness tree and ends the run.
|
|
18
|
+
*
|
|
19
|
+
* Off on Windows: `process.ppid` is not refreshed there after the parent
|
|
20
|
+
* exits, and there is no process group to signal in response anyway.
|
|
21
|
+
*/
|
|
22
|
+
/** How often the parent is probed. Cheap (one syscall), so a short interval is fine. */
|
|
23
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
24
|
+
let parentExitObserved = false;
|
|
25
|
+
/**
|
|
26
|
+
* True once any watchdog in this process has seen the parent die. The
|
|
27
|
+
* dispatch loop reads it to end the run instead of classifying the killed
|
|
28
|
+
* harness as a crash and retrying it under a parent that no longer exists.
|
|
29
|
+
*/
|
|
30
|
+
export function parentExited() {
|
|
31
|
+
return parentExitObserved;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Forgets an observed parent exit.
|
|
35
|
+
*
|
|
36
|
+
* @internal Exported only so tests can reach it. It is not part of the
|
|
37
|
+
* public surface.
|
|
38
|
+
*/
|
|
39
|
+
export function resetParentExitForTests() {
|
|
40
|
+
parentExitObserved = false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Starts probing `process.ppid`. The timer is unref'd so the watchdog can
|
|
44
|
+
* never be the thing keeping FireForge alive; while a child runs, the child
|
|
45
|
+
* does that.
|
|
46
|
+
*/
|
|
47
|
+
export function startParentExitWatchdog(options) {
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
return { stop: () => undefined };
|
|
50
|
+
}
|
|
51
|
+
const readPpid = options.readPpid ?? (() => process.ppid);
|
|
52
|
+
const originalPpid = readPpid();
|
|
53
|
+
let timer = setInterval(() => {
|
|
54
|
+
const currentPpid = readPpid();
|
|
55
|
+
if (currentPpid === originalPpid)
|
|
56
|
+
return;
|
|
57
|
+
stop();
|
|
58
|
+
parentExitObserved = true;
|
|
59
|
+
options.onParentExit({ originalPpid, currentPpid });
|
|
60
|
+
}, options.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
61
|
+
timer.unref();
|
|
62
|
+
function stop() {
|
|
63
|
+
if (timer === undefined)
|
|
64
|
+
return;
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
timer = undefined;
|
|
67
|
+
}
|
|
68
|
+
return { stop };
|
|
69
|
+
}
|
|
@@ -54,14 +54,14 @@ export function hasRelativeImport(content) {
|
|
|
54
54
|
try {
|
|
55
55
|
return astHasRelativeImport(content, 'module');
|
|
56
56
|
}
|
|
57
|
-
catch
|
|
58
|
-
|
|
57
|
+
catch {
|
|
58
|
+
// Not parseable as an ES module; try classic-script mode next.
|
|
59
59
|
}
|
|
60
60
|
try {
|
|
61
61
|
return astHasRelativeImport(content, 'script');
|
|
62
62
|
}
|
|
63
|
-
catch
|
|
64
|
-
|
|
63
|
+
catch {
|
|
64
|
+
// Not parseable at all; fall back to stripped-text matching below.
|
|
65
65
|
}
|
|
66
66
|
return RELATIVE_IMPORT_FALLBACK_PATTERN.test(stripJsComments(content));
|
|
67
67
|
}
|
|
@@ -29,6 +29,12 @@ export interface CompiledAllowlistEntry {
|
|
|
29
29
|
source: string;
|
|
30
30
|
/** Human-readable origin: `allow.txt:12` or `--console-allow #2`. */
|
|
31
31
|
origin: string;
|
|
32
|
+
/**
|
|
33
|
+
* Ceiling from a `# max-hits: N` directive above the entry: how often the
|
|
34
|
+
* suppressed cause can physically fire in one boot. More hits than this
|
|
35
|
+
* is a flood wearing an allowlisted shape, and fails the run.
|
|
36
|
+
*/
|
|
37
|
+
maxHits?: number;
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* Returns the index of the FIRST entry in `allow` matching `line`, or -1.
|
|
@@ -43,8 +49,26 @@ export declare function matchAllowlist(line: string, allow: readonly CompiledAll
|
|
|
43
49
|
* compiled as a RegExp with its `<file>:<line>` origin retained. A bad
|
|
44
50
|
* pattern throws immediately: better to fail fast at CLI parse time than
|
|
45
51
|
* to silently let a typo match nothing.
|
|
52
|
+
*
|
|
53
|
+
* A `# max-hits: N` comment sets the ceiling of the entry that follows it
|
|
54
|
+
* (other comment lines may sit between). A blank line or end of file with
|
|
55
|
+
* no entry after the directive is an error rather than a silently dropped
|
|
56
|
+
* cap, since a cap the operator wrote and the run ignored is the exact
|
|
57
|
+
* shape of failure this directive exists to prevent.
|
|
46
58
|
*/
|
|
47
59
|
export declare function compileAllowlistFromFile(body: string, sourcePath: string): CompiledAllowlistEntry[];
|
|
60
|
+
/** An allowlist entry that matched more lines than its `# max-hits` ceiling. */
|
|
61
|
+
export interface OverCapAllowlistEntry {
|
|
62
|
+
entry: CompiledAllowlistEntry;
|
|
63
|
+
hits: number;
|
|
64
|
+
maxHits: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Entries whose per-entry hit count (every matched line, error-class or
|
|
68
|
+
* not: the `N×` number the attribution block prints and the operator tunes
|
|
69
|
+
* against) exceeds their ceiling. Entries without a ceiling never appear.
|
|
70
|
+
*/
|
|
71
|
+
export declare function findOverCapAllowlistEntries(allow: readonly CompiledAllowlistEntry[], hits: readonly number[]): OverCapAllowlistEntry[];
|
|
48
72
|
/**
|
|
49
73
|
* Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
|
|
50
74
|
* flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
|
|
@@ -56,6 +56,14 @@ export function matchesSmokeError(line) {
|
|
|
56
56
|
}
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
|
+
/** `# max-hits: N` on its own comment line, case-insensitive. */
|
|
60
|
+
const MAX_HITS_DIRECTIVE = /^#\s*max-hits:\s*(\S+)\s*$/i;
|
|
61
|
+
function parseMaxHitsDirective(raw, at) {
|
|
62
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
63
|
+
throw new Error(`Invalid allowlist max-hits directive at ${at}: expected a positive integer (got "${raw}")`);
|
|
64
|
+
}
|
|
65
|
+
return Number.parseInt(raw, 10);
|
|
66
|
+
}
|
|
59
67
|
/**
|
|
60
68
|
* Returns the index of the FIRST entry in `allow` matching `line`, or -1.
|
|
61
69
|
* First-match attribution is deterministic and cheap. A line matching
|
|
@@ -71,30 +79,67 @@ export function matchAllowlist(line, allow) {
|
|
|
71
79
|
* compiled as a RegExp with its `<file>:<line>` origin retained. A bad
|
|
72
80
|
* pattern throws immediately: better to fail fast at CLI parse time than
|
|
73
81
|
* to silently let a typo match nothing.
|
|
82
|
+
*
|
|
83
|
+
* A `# max-hits: N` comment sets the ceiling of the entry that follows it
|
|
84
|
+
* (other comment lines may sit between). A blank line or end of file with
|
|
85
|
+
* no entry after the directive is an error rather than a silently dropped
|
|
86
|
+
* cap, since a cap the operator wrote and the run ignored is the exact
|
|
87
|
+
* shape of failure this directive exists to prevent.
|
|
74
88
|
*/
|
|
75
89
|
export function compileAllowlistFromFile(body, sourcePath) {
|
|
76
90
|
const lines = body.split(/\r?\n/);
|
|
77
91
|
const compiled = [];
|
|
92
|
+
let pending;
|
|
93
|
+
const assertNoOrphanDirective = () => {
|
|
94
|
+
if (pending) {
|
|
95
|
+
throw new Error(`Invalid allowlist max-hits directive at ${pending.at}: no entry follows it (the ceiling applies to the next non-comment line)`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
78
98
|
lines.forEach((raw, index) => {
|
|
79
99
|
const line = raw.trim();
|
|
80
|
-
|
|
100
|
+
const at = `${sourcePath}:${index + 1}`;
|
|
101
|
+
if (!line) {
|
|
102
|
+
assertNoOrphanDirective();
|
|
81
103
|
return;
|
|
104
|
+
}
|
|
105
|
+
if (line.startsWith('#')) {
|
|
106
|
+
const directive = MAX_HITS_DIRECTIVE.exec(line);
|
|
107
|
+
if (directive)
|
|
108
|
+
pending = { maxHits: parseMaxHitsDirective(directive[1] ?? '', at), at };
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
82
111
|
try {
|
|
83
112
|
compiled.push({
|
|
84
113
|
pattern: new RegExp(line),
|
|
85
114
|
source: line,
|
|
86
|
-
origin:
|
|
115
|
+
origin: at,
|
|
116
|
+
...(pending ? { maxHits: pending.maxHits } : {}),
|
|
87
117
|
});
|
|
88
118
|
}
|
|
89
119
|
catch (error) {
|
|
90
120
|
const message = toError(error).message;
|
|
91
|
-
throw new Error(`Invalid allowlist regex at ${
|
|
92
|
-
cause: error,
|
|
93
|
-
});
|
|
121
|
+
throw new Error(`Invalid allowlist regex at ${at}: ${message}`, { cause: error });
|
|
94
122
|
}
|
|
123
|
+
pending = undefined;
|
|
95
124
|
});
|
|
125
|
+
assertNoOrphanDirective();
|
|
96
126
|
return compiled;
|
|
97
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Entries whose per-entry hit count (every matched line, error-class or
|
|
130
|
+
* not: the `N×` number the attribution block prints and the operator tunes
|
|
131
|
+
* against) exceeds their ceiling. Entries without a ceiling never appear.
|
|
132
|
+
*/
|
|
133
|
+
export function findOverCapAllowlistEntries(allow, hits) {
|
|
134
|
+
const over = [];
|
|
135
|
+
allow.forEach((entry, index) => {
|
|
136
|
+
const count = hits[index] ?? 0;
|
|
137
|
+
if (entry.maxHits !== undefined && count > entry.maxHits) {
|
|
138
|
+
over.push({ entry, hits: count, maxHits: entry.maxHits });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return over;
|
|
142
|
+
}
|
|
98
143
|
/**
|
|
99
144
|
* Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
|
|
100
145
|
* flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
|
|
@@ -612,9 +612,19 @@ export interface TestOptions extends EngineLockWaitable {
|
|
|
612
612
|
* project's objdir. The census itself runs on every test dispatch and is
|
|
613
613
|
* report-only. This flag opts into the kill. Survivors slow later runs
|
|
614
614
|
* without appearing in their output, so the visibility is the default and
|
|
615
|
-
* the termination is the choice.
|
|
615
|
+
* the termination is the choice. `test.reapOrphans: "reap"` in
|
|
616
|
+
* `fireforge.json` makes it the repo's default.
|
|
616
617
|
*/
|
|
617
618
|
reapOrphans?: boolean;
|
|
619
|
+
/**
|
|
620
|
+
* Path to write the harness process-group id to, rewritten on every mach
|
|
621
|
+
* spawn (each retry attempt and shard) and removed when the run ends under
|
|
622
|
+
* FireForge's control. mach runs as its own process-group leader, so a
|
|
623
|
+
* supervisor's kill of its own group never reaches it; a supervisor that
|
|
624
|
+
* had to SIGKILL FireForge takes the whole harness tree afterwards with
|
|
625
|
+
* `kill -- -<pgid>`. The file survives exactly that case, by design.
|
|
626
|
+
*/
|
|
627
|
+
pgidFile?: string;
|
|
618
628
|
/** Permit tests against packageable engine edits newer than the last successful build. */
|
|
619
629
|
allowStaleBuild?: boolean;
|
|
620
630
|
/**
|
|
@@ -655,6 +665,12 @@ export interface TestOptions extends EngineLockWaitable {
|
|
|
655
665
|
* checkers that consume a sample artifact after the run.
|
|
656
666
|
*/
|
|
657
667
|
perfSamples?: string;
|
|
668
|
+
/**
|
|
669
|
+
* Seeded file-order shuffle for mochitest runs. `true` draws a fresh seed;
|
|
670
|
+
* a number replays one. Forwarded as mach `--shuffle` with the seed
|
|
671
|
+
* exported as `FIREFORGE_SHUFFLE_SEED` and stamped on the verdict line.
|
|
672
|
+
*/
|
|
673
|
+
shuffle?: number | boolean;
|
|
658
674
|
}
|
|
659
675
|
/**
|
|
660
676
|
* Options for the furnace apply command.
|
|
@@ -47,6 +47,13 @@ export interface TestConfig {
|
|
|
47
47
|
canaryPath?: string;
|
|
48
48
|
/** Short no-output ceiling for `fireforge test --canary`, in seconds. */
|
|
49
49
|
canaryTimeoutSeconds?: number;
|
|
50
|
+
/**
|
|
51
|
+
* What the preflight orphan census does with a harness helper that
|
|
52
|
+
* survived an earlier run in this objdir: `report` (default) names it and
|
|
53
|
+
* proceeds; `reap` terminates it and stamps `orphans-reaped=<n>` on the
|
|
54
|
+
* verdict line. `--reap-orphans` is the per-invocation equivalent.
|
|
55
|
+
*/
|
|
56
|
+
reapOrphans?: 'report' | 'reap';
|
|
50
57
|
}
|
|
51
58
|
/** A single external executable required by a project-specific toolchain. */
|
|
52
59
|
export interface ExternalToolRequirement {
|
|
@@ -32,8 +32,12 @@ interface ProcessGroupSurvivor {
|
|
|
32
32
|
* about anything that still refuses to die. POSIX only (no-op on win32).
|
|
33
33
|
* The only kill target is `-pgid`, never anything outside the group.
|
|
34
34
|
* A healthy run costs exactly one `pgrep`.
|
|
35
|
+
*
|
|
36
|
+
* `context` names why the group is being reaped in the warning; the default
|
|
37
|
+
* is the post-close sweep. The parent-exit watchdog passes its own, because
|
|
38
|
+
* there the group is still running when the sweep begins.
|
|
35
39
|
*/
|
|
36
|
-
export declare function sweepProcessGroup(pgid: number, graceMs?: number): Promise<{
|
|
40
|
+
export declare function sweepProcessGroup(pgid: number, graceMs?: number, context?: string): Promise<{
|
|
37
41
|
survivors: ProcessGroupSurvivor[];
|
|
38
42
|
}>;
|
|
39
43
|
export {};
|
|
@@ -130,15 +130,19 @@ function describeSurvivors(list) {
|
|
|
130
130
|
* about anything that still refuses to die. POSIX only (no-op on win32).
|
|
131
131
|
* The only kill target is `-pgid`, never anything outside the group.
|
|
132
132
|
* A healthy run costs exactly one `pgrep`.
|
|
133
|
+
*
|
|
134
|
+
* `context` names why the group is being reaped in the warning; the default
|
|
135
|
+
* is the post-close sweep. The parent-exit watchdog passes its own, because
|
|
136
|
+
* there the group is still running when the sweep begins.
|
|
133
137
|
*/
|
|
134
|
-
export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS) {
|
|
138
|
+
export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS, context = 'after exit') {
|
|
135
139
|
if (process.platform === 'win32')
|
|
136
140
|
return { survivors: [] };
|
|
137
141
|
const survivors = await listGroupSurvivors(pgid);
|
|
138
142
|
if (survivors.length === 0)
|
|
139
143
|
return { survivors };
|
|
140
|
-
warn(`Harness process group ${pgid}
|
|
141
|
-
`process(es)
|
|
144
|
+
warn(`Harness process group ${pgid} has ${survivors.length} live ` +
|
|
145
|
+
`process(es) ${context} — reaping the group. ${describeSurvivors(survivors)}`);
|
|
142
146
|
try {
|
|
143
147
|
process.kill(-pgid, 'SIGTERM');
|
|
144
148
|
}
|
|
@@ -85,6 +85,24 @@ export interface ProcessGroupOptions {
|
|
|
85
85
|
* whenever this option is set.
|
|
86
86
|
*/
|
|
87
87
|
processGroup?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Receives the process-group id (the detached child's pid) right after a
|
|
90
|
+
* group-leader spawn. Lets a caller expose the group to a supervisor that
|
|
91
|
+
* cannot signal FireForge itself gracefully (`fireforge test --pgid-file`),
|
|
92
|
+
* or hold it for a teardown of its own. Not called when no group was
|
|
93
|
+
* created (`processGroup` unset, or win32).
|
|
94
|
+
*/
|
|
95
|
+
onProcessGroup?: (pgid: number) => void;
|
|
96
|
+
/**
|
|
97
|
+
* Extra teardown the caller owns, awaited after the child's `close` and
|
|
98
|
+
* the post-run group sweep, before the child's closure settles. Placed
|
|
99
|
+
* here rather than after the wrapper resolves so the bin signal handler's
|
|
100
|
+
* `waitForActiveChildShutdown` covers it too: a reap that ran after the
|
|
101
|
+
* wrapper resolved would race `process.exit` on a forwarded SIGTERM. Must
|
|
102
|
+
* never throw; a rejection is swallowed so it cannot mask the child's
|
|
103
|
+
* result.
|
|
104
|
+
*/
|
|
105
|
+
postCloseSweep?: () => Promise<void>;
|
|
88
106
|
}
|
|
89
107
|
/**
|
|
90
108
|
* Executes a command and returns its output.
|
|
@@ -154,6 +154,8 @@ async function spawnTracked(spec) {
|
|
|
154
154
|
detached: usesProcessGroup,
|
|
155
155
|
});
|
|
156
156
|
const groupPid = usesProcessGroup ? child.pid : undefined;
|
|
157
|
+
if (groupPid !== undefined)
|
|
158
|
+
options.onProcessGroup?.(groupPid);
|
|
157
159
|
spec.attach?.(child);
|
|
158
160
|
// Group-aware kills route to the whole tree. A non-group child is killed
|
|
159
161
|
// directly by the forwarder's own `child.kill`.
|
|
@@ -183,11 +185,21 @@ async function spawnTracked(spec) {
|
|
|
183
185
|
closure.settle();
|
|
184
186
|
resolve(spec.result(code, signal));
|
|
185
187
|
};
|
|
188
|
+
// Group sweep first (it takes the helpers that stayed in the group),
|
|
189
|
+
// then the caller's own sweep (the ones that did not), then settle.
|
|
190
|
+
const callerSweep = () => {
|
|
191
|
+
const sweep = options.postCloseSweep;
|
|
192
|
+
if (sweep === undefined) {
|
|
193
|
+
finish();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
sweep().then(finish, finish);
|
|
197
|
+
};
|
|
186
198
|
if (groupPid !== undefined) {
|
|
187
|
-
sweepProcessGroup(groupPid).then(
|
|
199
|
+
sweepProcessGroup(groupPid).then(callerSweep, callerSweep);
|
|
188
200
|
}
|
|
189
201
|
else {
|
|
190
|
-
|
|
202
|
+
callerSweep();
|
|
191
203
|
}
|
|
192
204
|
});
|
|
193
205
|
});
|
package/docs/configuration.md
CHANGED
|
@@ -113,7 +113,13 @@ gaps does not move a patch out of the band its category owns.
|
|
|
113
113
|
## `test`
|
|
114
114
|
|
|
115
115
|
`test.canaryPath` and `test.canaryTimeoutSeconds` supply the defaults for
|
|
116
|
-
`fireforge test --canary`.
|
|
116
|
+
`fireforge test --canary`.
|
|
117
|
+
|
|
118
|
+
`test.reapOrphans` is `"report"` (default) or `"reap"`: what the preflight
|
|
119
|
+
census does with a harness helper that survived an earlier run in this
|
|
120
|
+
objdir. `"reap"` terminates it and stamps `orphans-reaped=<n>` on the verdict
|
|
121
|
+
line; `--reap-orphans` does the same for one invocation. The objdir anchor
|
|
122
|
+
rule is the same under both. See [`testing.md`](testing.md).
|
|
117
123
|
|
|
118
124
|
## `buildAudit`
|
|
119
125
|
|
|
@@ -219,6 +225,14 @@ non-CI launches print a warning saying so. Exit codes 12 and 13 separate a
|
|
|
219
225
|
console regression from a launch failure. See
|
|
220
226
|
[`exit-codes.md`](exit-codes.md).
|
|
221
227
|
|
|
228
|
+
The allowlist (`--console-allow-file`) is count-blind by default: a line that
|
|
229
|
+
matches an entry never counts, however often it fires. A `# max-hits: N`
|
|
230
|
+
comment directly above an entry (other comments may sit between) caps how
|
|
231
|
+
many lines that entry may match in one run. The attribution block prints
|
|
232
|
+
capped entries as `hits×/N`, and an entry that fires past its ceiling is
|
|
233
|
+
reported and fails the run with exit 12, the same as an unallowed error. A
|
|
234
|
+
directive with no entry after it is refused rather than silently ignored.
|
|
235
|
+
|
|
222
236
|
## Lock waits
|
|
223
237
|
|
|
224
238
|
Every FireForge command that mutates the engine takes the engine-session
|
package/docs/exit-codes.md
CHANGED
|
@@ -7,25 +7,25 @@ branches on it, and CI uses it to decide whether to retry or escalate. The
|
|
|
7
7
|
values live in `src/errors/codes.ts`. This file says what they mean and
|
|
8
8
|
which error class produces each one.
|
|
9
9
|
|
|
10
|
-
| Code | Name | Meaning
|
|
11
|
-
| ---: | ---------------------- |
|
|
12
|
-
| 0 | `SUCCESS` | Completed. Also a run that prompted and the operator declined: declining is a successful run that chose not to proceed.
|
|
13
|
-
| 1 | `GENERAL_ERROR` | Unspecified failure the operator has to act on.
|
|
14
|
-
| 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid.
|
|
15
|
-
| 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source.
|
|
16
|
-
| 4 | `GIT_ERROR` | A git operation failed.
|
|
17
|
-
| 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red.
|
|
18
|
-
| 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated.
|
|
19
|
-
| 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found.
|
|
20
|
-
| 8 | `INVALID_ARGUMENT` | The flags were wrong. A usage problem, not an environment one.
|
|
21
|
-
| 9 | `FURNACE_ERROR` | A Furnace component operation failed.
|
|
22
|
-
| 10 | `RESOLUTION_ERROR` | Conflict resolution failed.
|
|
23
|
-
| 11 | `INTERNAL_ERROR` | A FireForge invariant did not hold. This is a bug in FireForge, not something the operator can fix.
|
|
24
|
-
| 12 | `SMOKE_EXIT_FAILURE` | `run --smoke-exit` saw console errors that were not allowed in the smoke window.
|
|
25
|
-
| 13 | `SMOKE_LAUNCH_FAILURE` | `run --smoke-exit` saw the browser exit uncleanly before the window elapsed.
|
|
26
|
-
| 14 | `INCONCLUSIVE` | A test run's verdict was thrown away because `engine/` moved while the harness ran. This is not red. Re-run it.
|
|
27
|
-
| 15 | `LOCK_TIMEOUT` | A lock wait expired, so the run never started. Re-queue it with a larger `--wait-lock`.
|
|
28
|
-
| 130 | `USER_CANCELLED` | The operator interrupted a prompt (Esc or Ctrl+C). 128 + SIGINT.
|
|
10
|
+
| Code | Name | Meaning | Produced by |
|
|
11
|
+
| ---: | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
12
|
+
| 0 | `SUCCESS` | Completed. Also a run that prompted and the operator declined: declining is a successful run that chose not to proceed. | (none) |
|
|
13
|
+
| 1 | `GENERAL_ERROR` | Unspecified failure the operator has to act on. | `GeneralError` (and `PreflightRefusalError`), `ParserFallbackError`, `ExecTimeoutError` |
|
|
14
|
+
| 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid. | `ConfigError` and subclasses |
|
|
15
|
+
| 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
|
|
16
|
+
| 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses, except `GitNotFoundError` |
|
|
17
|
+
| 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red. | `BuildError` and subclasses, `TestFailureError` |
|
|
18
|
+
| 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError`, `PatchManifestCorruptError`, `RebaseError` and subclasses |
|
|
19
|
+
| 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
|
|
20
|
+
| 8 | `INVALID_ARGUMENT` | The flags were wrong. A usage problem, not an environment one. | `InvalidArgumentError`, plus commander usage errors (unknown command or option, missing argument) |
|
|
21
|
+
| 9 | `FURNACE_ERROR` | A Furnace component operation failed. | `FurnaceError` |
|
|
22
|
+
| 10 | `RESOLUTION_ERROR` | Conflict resolution failed. | `ResolutionError` |
|
|
23
|
+
| 11 | `INTERNAL_ERROR` | A FireForge invariant did not hold. This is a bug in FireForge, not something the operator can fix. | `InternalInvariantError` |
|
|
24
|
+
| 12 | `SMOKE_EXIT_FAILURE` | `run --smoke-exit` saw console errors that were not allowed in the smoke window, or an allowlisted shape fired past its `# max-hits` ceiling. | `SmokeRunError` |
|
|
25
|
+
| 13 | `SMOKE_LAUNCH_FAILURE` | `run --smoke-exit` saw the browser exit uncleanly before the window elapsed. | `SmokeRunError` |
|
|
26
|
+
| 14 | `INCONCLUSIVE` | A test run's verdict was thrown away because `engine/` moved while the harness ran. This is not red. Re-run it. | `InconclusiveVerdictError` |
|
|
27
|
+
| 15 | `LOCK_TIMEOUT` | A lock wait expired, so the run never started. Re-queue it with a larger `--wait-lock`. | `LockContentionError` |
|
|
28
|
+
| 130 | `USER_CANCELLED` | The operator interrupted a prompt (Esc or Ctrl+C). 128 + SIGINT. | `CancellationError` |
|
|
29
29
|
|
|
30
30
|
Usage errors that commander detects itself (an unknown command, an unknown
|
|
31
31
|
or malformed option, a missing required argument) never reach a command
|
|
@@ -284,7 +284,10 @@ Wait ergonomics are opt-in: `pollMaxMs` (the poll interval doubles from
|
|
|
284
284
|
`pollMs` up to the cap) and `waitProgressMs` with `onWaitProgress` (a periodic
|
|
285
285
|
progress callback carrying the holder's PID, liveness, and owner-metadata
|
|
286
286
|
lines from the pid file). All three are inert when unset, so existing callers
|
|
287
|
-
keep byte-identical behavior.
|
|
287
|
+
keep byte-identical behavior. Within one poll iteration a due progress probe
|
|
288
|
+
runs before the deadline check, so a wait that times out has always reported
|
|
289
|
+
the holder at least once when its budget exceeds `waitProgressMs`, however
|
|
290
|
+
slow the host (`file-lock.test.ts`, "budget shorter than one iteration").
|
|
288
291
|
|
|
289
292
|
### `src/core/file-lock-owner.ts`
|
|
290
293
|
|
|
@@ -430,10 +433,23 @@ On SIGINT or SIGTERM, `bin/fireforge.ts`:
|
|
|
430
433
|
- `waitForActiveCriticalSections(5 s)`, which holds the exit for in-flight
|
|
431
434
|
"apply plus persist" pairs (invariant 4). It is bounded so that a stuck
|
|
432
435
|
write cannot postpone the exit the user asked for.
|
|
436
|
+
- `waitForActiveChildShutdown(12 s)`, which holds the exit until every
|
|
437
|
+
tracked child has closed. For a process-group dispatch (`mach` under
|
|
438
|
+
`test`, `build`, `run`) "closed" includes the exec layer's post-close
|
|
439
|
+
work: the group sweep and the caller's `postCloseSweep` (the harness
|
|
440
|
+
helper reap in `fireforge test`). Both run before the child's closure
|
|
441
|
+
settles, so a forwarded SIGTERM cannot race `process.exit` past them.
|
|
433
442
|
3. Force-releases the furnace lock directories of active operations, since
|
|
434
443
|
`withFileLock`'s `finally` will never run past `process.exit`.
|
|
435
444
|
4. Calls `process.exit(130 | 143)`.
|
|
436
445
|
|
|
446
|
+
A parent that dies without signalling FireForge (SIGKILL on the `npm` above
|
|
447
|
+
it) enters no handler here. `fireforge test` covers that case itself with
|
|
448
|
+
the parent-exit watchdog (`src/core/parent-exit-watchdog.ts`): it writes the
|
|
449
|
+
killed verdict, reaps the mach group and the tracked helpers, and lets the
|
|
450
|
+
dispatch loop end the run; the process then exits through the ordinary
|
|
451
|
+
error path, not through this pipeline.
|
|
452
|
+
|
|
437
453
|
## Choosing a primitive for a new mutation
|
|
438
454
|
|
|
439
455
|
| Your operation | Use |
|