@hominis/fireforge 0.47.0 → 0.48.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 +65 -0
- package/dist/bin/fireforge.js +11 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
- package/dist/src/commands/doctor-macos-sdk.js +156 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +64 -3
- package/dist/src/commands/import.js +5 -2
- package/dist/src/commands/lint-per-patch.js +44 -6
- package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
- package/dist/src/commands/lint-per-run-checkjs.js +1 -0
- package/dist/src/commands/lint.js +10 -0
- package/dist/src/commands/re-export-lint.d.ts +17 -2
- package/dist/src/commands/re-export-lint.js +33 -4
- package/dist/src/commands/re-export.js +4 -2
- package/dist/src/commands/rebase/confirm.d.ts +12 -1
- package/dist/src/commands/rebase/confirm.js +6 -3
- package/dist/src/commands/rebase/index.js +77 -13
- package/dist/src/commands/run.js +43 -7
- package/dist/src/commands/status.js +5 -3
- 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 +2 -1
- 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 +19 -4
- package/dist/src/commands/test-verdict.js +22 -4
- package/dist/src/commands/test.js +18 -10
- package/dist/src/commands/typecheck.d.ts +5 -0
- package/dist/src/commands/typecheck.js +7 -1
- package/dist/src/core/build-prepare.d.ts +2 -0
- package/dist/src/core/build-prepare.js +67 -35
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-test-toolchains.js +7 -0
- package/dist/src/core/config-validate.js +7 -0
- package/dist/src/core/discard-baseline.js +2 -1
- package/dist/src/core/file-lock.js +16 -9
- package/dist/src/core/forward-registrations.d.ts +33 -0
- package/dist/src/core/forward-registrations.js +16 -0
- package/dist/src/core/git-file-ops.d.ts +21 -0
- package/dist/src/core/git-file-ops.js +75 -0
- package/dist/src/core/git-status.d.ts +14 -0
- package/dist/src/core/git-status.js +41 -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-mozconfig.d.ts +2 -1
- package/dist/src/core/mach-mozconfig.js +2 -0
- 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/parent-exit-watchdog.d.ts +62 -0
- package/dist/src/core/parent-exit-watchdog.js +69 -0
- package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
- package/dist/src/core/patch-apply-fuzz.js +27 -6
- package/dist/src/core/patch-apply.d.ts +13 -0
- package/dist/src/core/patch-apply.js +27 -2
- package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
- package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
- package/dist/src/core/patch-lint-checkjs.js +8 -1
- package/dist/src/core/patch-lint-cross.d.ts +7 -1
- package/dist/src/core/patch-lint-cross.js +13 -12
- package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
- package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
- package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
- package/dist/src/core/patch-lint-forward-registration.js +83 -37
- package/dist/src/core/patch-lint.d.ts +11 -0
- package/dist/src/core/patch-lint.js +20 -2
- package/dist/src/core/patch-transform.d.ts +14 -13
- package/dist/src/core/patch-transform.js +29 -17
- package/dist/src/core/rebase-dry-run.d.ts +48 -0
- package/dist/src/core/rebase-dry-run.js +105 -0
- package/dist/src/core/run-profile.d.ts +50 -0
- package/dist/src/core/run-profile.js +131 -0
- package/dist/src/core/status-binary.d.ts +1 -1
- package/dist/src/core/status-binary.js +3 -1
- package/dist/src/core/status-classify.js +29 -2
- package/dist/src/core/toolchain-preflight.d.ts +6 -0
- package/dist/src/core/toolchain-preflight.js +1 -1
- package/dist/src/core/typecheck.d.ts +11 -1
- package/dist/src/core/typecheck.js +85 -11
- package/dist/src/errors/rebase.d.ts +9 -0
- package/dist/src/errors/rebase.js +20 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +2 -1
- package/dist/src/types/commands/options.d.ts +26 -1
- package/dist/src/types/config.d.ts +17 -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-input.d.ts +27 -0
- package/dist/src/utils/process-input.js +49 -0
- package/dist/src/utils/process.d.ts +20 -0
- package/dist/src/utils/process.js +16 -3
- package/dist/src/utils/validation.d.ts +11 -0
- package/dist/src/utils/validation.js +43 -0
- package/docs/configuration.md +88 -1
- package/docs/exit-codes.md +1 -1
- package/docs/lifecycle-invariants.md +21 -4
- package/docs/machine-output.md +22 -9
- package/docs/patch-workflow.md +35 -1
- package/docs/testing.md +68 -4
- package/package.json +7 -3
|
@@ -22,10 +22,15 @@
|
|
|
22
22
|
* for the same reason. FireForge has no business reporting, let alone
|
|
23
23
|
* terminating, a process it cannot attribute to the harness.
|
|
24
24
|
*
|
|
25
|
-
* `--reap-orphans`
|
|
26
|
-
* report-only, exactly like the
|
|
27
|
-
* (which covers a different shape:
|
|
28
|
-
* workers, matched on PPID 1 and
|
|
25
|
+
* `--reap-orphans` (or `test.reapOrphans: "reap"` in `fireforge.json`) opts
|
|
26
|
+
* into termination. Without it the census is report-only, exactly like the
|
|
27
|
+
* `Orphaned harness workers` doctor check (which covers a different shape:
|
|
28
|
+
* reparented Python multiprocessing workers, matched on PPID 1 and
|
|
29
|
+
* accumulated CPU time).
|
|
30
|
+
*
|
|
31
|
+
* The anchoring and termination primitives are exported for the teardown
|
|
32
|
+
* reaper in `harness-helper-pids.ts`, which applies the same objdir rule to
|
|
33
|
+
* the helpers THIS run launched, after mach has exited.
|
|
29
34
|
*/
|
|
30
35
|
import { toError } from '../utils/errors.js';
|
|
31
36
|
import { info, verbose, warn } from '../utils/logger.js';
|
|
@@ -33,42 +38,91 @@ import { exec } from '../utils/process.js';
|
|
|
33
38
|
import { parsePsDuration } from '../utils/ps-duration.js';
|
|
34
39
|
import { sleep } from '../utils/sleep.js';
|
|
35
40
|
/**
|
|
36
|
-
* Harness helper
|
|
37
|
-
* mochitest/xpcshell harness and is expected to
|
|
38
|
-
* is a thing a developer runs by hand.
|
|
41
|
+
* Harness helper binaries, matched on the executable's basename. Every one
|
|
42
|
+
* of these is started by the mochitest/xpcshell harness and is expected to
|
|
43
|
+
* die with it. None of them is a thing a developer runs by hand.
|
|
44
|
+
*/
|
|
45
|
+
const HELPER_BINARY = /^(?:xpcshell|ssltunnel|http3server|moz-http2)(?:\.exe)?$/;
|
|
46
|
+
/** Interpreters the harness runs its helper scripts under. */
|
|
47
|
+
const HELPER_INTERPRETER = /^(?:python[\d.]*|Python|node)(?:\.exe)?$/;
|
|
48
|
+
/** Harness helper scripts, matched on the interpreter's script argument. */
|
|
49
|
+
const HELPER_SCRIPT = /(?:^|[/\\])(?:pywebsocket\w*\.py|websocket_server\.py|websocketprocessbridge\.py|moz-http2\.js|http2_server\.js|httpd\.js|server\.js|runtests\.py|runxpcshelltests\.py)$/;
|
|
50
|
+
function basename(token) {
|
|
51
|
+
const cut = Math.max(token.lastIndexOf('/'), token.lastIndexOf('\\'));
|
|
52
|
+
return cut === -1 ? token : token.slice(cut + 1);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True when `command` IS a harness helper: its executable is a helper
|
|
56
|
+
* binary, or an interpreter whose first argument is a helper script.
|
|
57
|
+
*
|
|
58
|
+
* Structural on purpose. An earlier version matched the helper names
|
|
59
|
+
* anywhere in the command line, which is right for the httpd (`xpcshell -g
|
|
60
|
+
* … -f …/server.js`) and wrong for everything that merely mentions such a
|
|
61
|
+
* path: an editor opened on `server.js`, a shell whose command text quotes
|
|
62
|
+
* it, a `grep` over the objdir. Report-only, that printed a misleading
|
|
63
|
+
* `kill`; under the reap posture it terminated the operator's shell.
|
|
39
64
|
*/
|
|
40
|
-
|
|
65
|
+
export function matchesHarnessHelper(command) {
|
|
66
|
+
const [exe, first] = command.trim().split(/\s+/);
|
|
67
|
+
if (exe === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
if (HELPER_BINARY.test(basename(exe)))
|
|
70
|
+
return true;
|
|
71
|
+
if (first === undefined || !HELPER_INTERPRETER.test(basename(exe)))
|
|
72
|
+
return false;
|
|
73
|
+
return HELPER_SCRIPT.test(first);
|
|
74
|
+
}
|
|
41
75
|
/** Objdir provenance: an explicit objdir path, a `obj-…` path segment, or `_tests/`. */
|
|
42
|
-
function isObjdirAnchored(command, objDir) {
|
|
76
|
+
export function isObjdirAnchored(command, objDir) {
|
|
43
77
|
if (objDir !== undefined && objDir.length > 0 && command.includes(objDir))
|
|
44
78
|
return true;
|
|
45
79
|
if (/[/\\]obj-[^/\\\s]*[/\\]/.test(command))
|
|
46
80
|
return true;
|
|
47
81
|
return /[/\\]_tests[/\\]/.test(command);
|
|
48
82
|
}
|
|
83
|
+
/** Pids of `selfPid` and every ancestor of it, as far as the listing reaches. */
|
|
84
|
+
function ancestorsOf(rows, selfPid) {
|
|
85
|
+
const parentOf = new Map(rows.map((r) => [r.pid, r.ppid]));
|
|
86
|
+
const chain = new Set();
|
|
87
|
+
let cursor = selfPid;
|
|
88
|
+
while (cursor !== undefined && cursor > 0 && !chain.has(cursor)) {
|
|
89
|
+
chain.add(cursor);
|
|
90
|
+
cursor = parentOf.get(cursor);
|
|
91
|
+
}
|
|
92
|
+
return chain;
|
|
93
|
+
}
|
|
49
94
|
/**
|
|
50
95
|
* Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
|
|
51
96
|
* helpers. Pure, so it is fixture-testable without spawning anything.
|
|
52
97
|
*
|
|
98
|
+
* FireForge's own process, its direct children and its ancestors are never
|
|
99
|
+
* candidates: the terminal shell FireForge runs under can carry the helper
|
|
100
|
+
* paths in its own command text, and must never be offered for a kill.
|
|
101
|
+
*
|
|
53
102
|
* @param psOutput - Raw `ps` output
|
|
54
103
|
* @param objDir - Absolute objdir of this project, when known. Widens the
|
|
55
104
|
* provenance test beyond the generic `obj-…` path segment
|
|
56
|
-
* @param selfPid - This process's pid
|
|
57
|
-
* itself
|
|
105
|
+
* @param selfPid - This process's pid
|
|
58
106
|
*/
|
|
59
107
|
export function findOrphanedHarnessProcesses(psOutput, objDir, selfPid = process.pid) {
|
|
60
|
-
const
|
|
108
|
+
const rows = [];
|
|
61
109
|
for (const line of psOutput.split('\n')) {
|
|
62
110
|
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
63
111
|
if (!match)
|
|
64
112
|
continue;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
113
|
+
rows.push({
|
|
114
|
+
pid: Number(match[1]),
|
|
115
|
+
ppid: Number(match[2]),
|
|
116
|
+
elapsed: match[3] ?? '',
|
|
117
|
+
command: (match[4] ?? '').trim(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const excluded = ancestorsOf(rows, selfPid);
|
|
121
|
+
const found = [];
|
|
122
|
+
for (const { pid, ppid, elapsed, command } of rows) {
|
|
123
|
+
if (excluded.has(pid) || ppid === selfPid)
|
|
70
124
|
continue;
|
|
71
|
-
if (!
|
|
125
|
+
if (!matchesHarnessHelper(command))
|
|
72
126
|
continue;
|
|
73
127
|
if (!isObjdirAnchored(command, objDir))
|
|
74
128
|
continue;
|
|
@@ -85,6 +139,8 @@ export function findOrphanedHarnessProcesses(psOutput, objDir, selfPid = process
|
|
|
85
139
|
}
|
|
86
140
|
/** Grace period between SIGTERM and SIGKILL when reaping. */
|
|
87
141
|
const REAP_GRACE_MS = 500;
|
|
142
|
+
/** Wait after SIGKILL before the final liveness probe, so the parent can collect the exit. */
|
|
143
|
+
const KILL_SETTLE_MS = 200;
|
|
88
144
|
/** Longest command excerpt carried into the report line. */
|
|
89
145
|
const COMMAND_EXCERPT_LIMIT = 160;
|
|
90
146
|
/**
|
|
@@ -102,7 +158,8 @@ export function formatOrphanReport(orphans) {
|
|
|
102
158
|
`it):\n${rows}\n` +
|
|
103
159
|
`Survivors like these slow every later run without appearing anywhere in its output — a ` +
|
|
104
160
|
`three-second suite taking minutes of wall clock is the usual symptom. Terminate them with ` +
|
|
105
|
-
`"kill ${pids}",
|
|
161
|
+
`"kill ${pids}", re-run with --reap-orphans, or set test.reapOrphans to "reap" in ` +
|
|
162
|
+
`fireforge.json to have FireForge do it at every preflight.`);
|
|
106
163
|
}
|
|
107
164
|
/** Thin exec wrapper for the process listing (tests mock `exec` instead). */
|
|
108
165
|
async function listSystemProcesses() {
|
|
@@ -125,63 +182,87 @@ async function listSystemProcesses() {
|
|
|
125
182
|
* @param objDir - Absolute objdir of this project, when known
|
|
126
183
|
* @param options - `reap` terminates each recognized survivor (SIGTERM,
|
|
127
184
|
* then SIGKILL for anything that stays)
|
|
128
|
-
* @returns The census
|
|
185
|
+
* @returns The census (empty when nothing was found or nothing could be
|
|
186
|
+
* probed) and how many of them are confirmed gone after reaping (0 in
|
|
187
|
+
* report-only mode)
|
|
129
188
|
*/
|
|
130
189
|
export async function reportOrphanedHarnessProcesses(objDir, options = {}) {
|
|
131
190
|
if (process.platform === 'win32')
|
|
132
|
-
return [];
|
|
191
|
+
return { orphans: [], reaped: 0 };
|
|
133
192
|
let psOutput;
|
|
134
193
|
try {
|
|
135
194
|
psOutput = await listSystemProcesses();
|
|
136
195
|
}
|
|
137
196
|
catch (error) {
|
|
138
197
|
verbose(`Orphan preflight: could not scan processes (${toError(error).message}); skipping.`);
|
|
139
|
-
return [];
|
|
198
|
+
return { orphans: [], reaped: 0 };
|
|
140
199
|
}
|
|
141
200
|
const orphans = findOrphanedHarnessProcesses(psOutput, objDir);
|
|
142
201
|
if (orphans.length === 0) {
|
|
143
202
|
verbose('Orphan preflight: no surviving harness helper processes.');
|
|
144
|
-
return [];
|
|
203
|
+
return { orphans: [], reaped: 0 };
|
|
145
204
|
}
|
|
146
205
|
warn(formatOrphanReport(orphans));
|
|
147
|
-
|
|
148
|
-
|
|
206
|
+
const reaped = options.reap === true ? await terminateHarnessProcesses(orphans, '--reap-orphans') : 0;
|
|
207
|
+
return { orphans, reaped };
|
|
208
|
+
}
|
|
209
|
+
/** Liveness probe: `kill(pid, 0)` throws for a pid that is gone. */
|
|
210
|
+
export function isPidAlive(pid) {
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
149
217
|
}
|
|
150
|
-
return orphans;
|
|
151
218
|
}
|
|
152
219
|
/**
|
|
153
|
-
* Terminates each
|
|
154
|
-
*
|
|
155
|
-
* between the
|
|
220
|
+
* Terminates each entry: SIGTERM first, then SIGKILL for anything still
|
|
221
|
+
* alive after the grace period. Failures are reported, never thrown. A
|
|
222
|
+
* process that exited between the listing and the signal is the common case
|
|
223
|
+
* rather than an error.
|
|
224
|
+
*
|
|
225
|
+
* @param targets - Processes to terminate, already attributed to the harness
|
|
226
|
+
* by the caller (objdir anchor rule)
|
|
227
|
+
* @param label - Prefix for the log lines, naming which path is reaping
|
|
228
|
+
* (`--reap-orphans` at preflight, `teardown` after mach exits)
|
|
229
|
+
* @returns How many targets are confirmed gone afterwards
|
|
156
230
|
*/
|
|
157
|
-
async function
|
|
158
|
-
|
|
231
|
+
export async function terminateHarnessProcesses(targets, label) {
|
|
232
|
+
let reaped = 0;
|
|
233
|
+
for (const target of targets) {
|
|
159
234
|
try {
|
|
160
|
-
process.kill(
|
|
235
|
+
process.kill(target.pid, 'SIGTERM');
|
|
161
236
|
}
|
|
162
237
|
catch (error) {
|
|
163
|
-
verbose(
|
|
238
|
+
verbose(`${label}: SIGTERM to ${target.pid} failed (${toError(error).message}).`);
|
|
164
239
|
continue;
|
|
165
240
|
}
|
|
166
241
|
// Grace period before escalating: the httpd shape in the field
|
|
167
242
|
// incident ignored SIGTERM while spinning, but an ordinary helper exits
|
|
168
243
|
// promptly and must not be SIGKILLed for being slow by a millisecond.
|
|
169
244
|
await sleep(REAP_GRACE_MS);
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
process.kill(orphan.pid, 0);
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
alive = false;
|
|
176
|
-
}
|
|
177
|
-
if (alive) {
|
|
245
|
+
if (isPidAlive(target.pid)) {
|
|
178
246
|
try {
|
|
179
|
-
process.kill(
|
|
247
|
+
process.kill(target.pid, 'SIGKILL');
|
|
180
248
|
}
|
|
181
249
|
catch (error) {
|
|
182
|
-
verbose(
|
|
250
|
+
verbose(`${label}: SIGKILL to ${target.pid} failed (${toError(error).message}).`);
|
|
183
251
|
}
|
|
252
|
+
// A killed process answers kill(0) until its parent (launchd, for a
|
|
253
|
+
// reparented survivor) has collected it, which takes a moment.
|
|
254
|
+
await sleep(KILL_SETTLE_MS);
|
|
255
|
+
}
|
|
256
|
+
// Counted only when the probe agrees: the verdict line will carry this
|
|
257
|
+
// number, and a SIGKILL that was sent is not the same as a process that
|
|
258
|
+
// is gone.
|
|
259
|
+
if (isPidAlive(target.pid)) {
|
|
260
|
+
warn(`${label}: PID ${target.pid} is still alive after SIGKILL; inspect it by hand.`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
reaped += 1;
|
|
264
|
+
info(`${label}: terminated PID ${target.pid}.`);
|
|
184
265
|
}
|
|
185
|
-
info(`--reap-orphans: terminated PID ${orphan.pid}.`);
|
|
186
266
|
}
|
|
267
|
+
return reaped;
|
|
187
268
|
}
|
|
@@ -52,5 +52,6 @@ export declare function assertBrandingMozconfigAgreement(engineDir: string, mozc
|
|
|
52
52
|
* @param configsDir - Path to the configs directory
|
|
53
53
|
* @param engineDir - Path to the engine directory
|
|
54
54
|
* @param config - FireForge configuration
|
|
55
|
+
* @returns Whether engine/mozconfig was rewritten (its content changed)
|
|
55
56
|
*/
|
|
56
|
-
export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<
|
|
57
|
+
export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<boolean>;
|
|
@@ -114,6 +114,7 @@ export async function assertBrandingMozconfigAgreement(engineDir, mozconfigPath,
|
|
|
114
114
|
* @param configsDir - Path to the configs directory
|
|
115
115
|
* @param engineDir - Path to the engine directory
|
|
116
116
|
* @param config - FireForge configuration
|
|
117
|
+
* @returns Whether engine/mozconfig was rewritten (its content changed)
|
|
117
118
|
*/
|
|
118
119
|
export async function generateMozconfig(configsDir, engineDir, config) {
|
|
119
120
|
const platform = getPlatform();
|
|
@@ -161,4 +162,5 @@ export async function generateMozconfig(configsDir, engineDir, config) {
|
|
|
161
162
|
// write, before anything consumes mozconfig) keeps `generateMozconfig`
|
|
162
163
|
// the single source of truth for both the render and the sanity-check.
|
|
163
164
|
await assertBrandingMozconfigAgreement(engineDir, outputPath, config);
|
|
165
|
+
return wrote;
|
|
164
166
|
}
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -37,6 +37,16 @@ export interface MachOptions {
|
|
|
37
37
|
* layer's {@link ExecOptions.envUnset}.
|
|
38
38
|
*/
|
|
39
39
|
envUnset?: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* Observes every raw output chunk of a capture dispatch, on either stream,
|
|
42
|
+
* before the echo filter. The test dispatchers feed the harness helper-pid
|
|
43
|
+
* tracker from it. Capture-only, like `annotateKnownTeardownNoise`.
|
|
44
|
+
*/
|
|
45
|
+
onOutputChunk?: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
46
|
+
/** Exec-layer hook, see {@link ProcessGroupOptions.onProcessGroup}. Capture-only. */
|
|
47
|
+
onProcessGroup?: (pgid: number) => void;
|
|
48
|
+
/** Exec-layer hook, see {@link ProcessGroupOptions.postCloseSweep}. Capture-only. */
|
|
49
|
+
postCloseSweep?: () => Promise<void>;
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* Result of running a mach command while capturing streamed output.
|
|
@@ -247,6 +257,12 @@ export interface MachTestSuiteOptions {
|
|
|
247
257
|
* markers that quiet it. See {@link CODING_AGENT_ENV_MARKERS}.
|
|
248
258
|
*/
|
|
249
259
|
fullOutput?: boolean | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Harness teardown hooks (`fireforge test`): the helper-pid tracker feed,
|
|
262
|
+
* the process-group announcement, and the post-close reap of the helpers
|
|
263
|
+
* the harness launched. See {@link MachOptions}.
|
|
264
|
+
*/
|
|
265
|
+
teardown?: Pick<MachOptions, 'onOutputChunk' | 'onProcessGroup' | 'postCloseSweep'> | undefined;
|
|
250
266
|
}
|
|
251
267
|
/**
|
|
252
268
|
* Runs one mach test command (`mach test` / `mach xpcshell-test` / `mach
|
package/dist/src/core/mach.js
CHANGED
|
@@ -96,7 +96,10 @@ export async function runMachCapture(args, engineDir, options = {}) {
|
|
|
96
96
|
// storybook) runs as a process-group leader and is group-reaped on
|
|
97
97
|
// exit/abort. See ExecOptions.processGroup.
|
|
98
98
|
processGroup: true,
|
|
99
|
+
...(options.onProcessGroup ? { onProcessGroup: options.onProcessGroup } : {}),
|
|
100
|
+
...(options.postCloseSweep ? { postCloseSweep: options.postCloseSweep } : {}),
|
|
99
101
|
onStdout: (data) => {
|
|
102
|
+
options.onOutputChunk?.('stdout', data);
|
|
100
103
|
stdout += data;
|
|
101
104
|
if (stdout.length > CAPTURE_TAIL_LIMIT) {
|
|
102
105
|
stdout = stdout.slice(-CAPTURE_TAIL_LIMIT);
|
|
@@ -110,6 +113,7 @@ export async function runMachCapture(args, engineDir, options = {}) {
|
|
|
110
113
|
process.stdout.write(stdoutFilter ? stdoutFilter.transform(data) : data);
|
|
111
114
|
},
|
|
112
115
|
onStderr: (data) => {
|
|
116
|
+
options.onOutputChunk?.('stderr', data);
|
|
113
117
|
stderr += data;
|
|
114
118
|
if (stderr.length > CAPTURE_TAIL_LIMIT) {
|
|
115
119
|
stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
|
|
@@ -407,12 +411,13 @@ function testVerbosityEnvUnset(fullOutput) {
|
|
|
407
411
|
* @param options - Engine checkout, test paths, extra args, env, verbosity
|
|
408
412
|
*/
|
|
409
413
|
export async function runMachTestSuite(kind, options) {
|
|
410
|
-
const { engineDir, testPaths = [], args = [], env, fullOutput } = options;
|
|
414
|
+
const { engineDir, testPaths = [], args = [], env, fullOutput, teardown } = options;
|
|
411
415
|
const guard = await installMachResourceGuard(engineDir);
|
|
412
416
|
const envUnset = testVerbosityEnvUnset(fullOutput);
|
|
413
417
|
return runMachCapture([kind, ...testPaths, ...args], engineDir, {
|
|
414
418
|
env: { ...guard.env, ...env },
|
|
415
419
|
...(envUnset ? { envUnset } : {}),
|
|
420
|
+
...teardown,
|
|
416
421
|
annotateKnownTeardownNoise: true,
|
|
417
422
|
});
|
|
418
423
|
}
|
|
@@ -154,9 +154,13 @@ export async function runMarionettePreflight(engineDir, options = {}) {
|
|
|
154
154
|
// (missing dylib, wrong CPU arch, corrupt profile) before the socket
|
|
155
155
|
// poll swallows the full overall budget waiting for bytes that will
|
|
156
156
|
// never come.
|
|
157
|
+
// Ref'd (sleep's default), like every wait in this probe: the browser is a
|
|
158
|
+
// detached child, so once it has exited nothing else holds the event
|
|
159
|
+
// loop, and an unref'd wait here let Node exit 0 mid-preflight with no
|
|
160
|
+
// `Marionette preflight:` line and no verdict at all.
|
|
157
161
|
const settleDeadline = Math.min(spawnSettleMs, Math.max(0, timeoutMs - elapsed()));
|
|
158
162
|
if (settleDeadline > 0) {
|
|
159
|
-
await sleep(settleDeadline
|
|
163
|
+
await sleep(settleDeadline);
|
|
160
164
|
}
|
|
161
165
|
if (hasChildExited(spawnedChild)) {
|
|
162
166
|
return fail('browser-spawns', `Browser process exited during spawn (exit code ${spawnedChild.exitCode}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
|
|
@@ -207,7 +211,9 @@ async function teardownPreflightChild(child, forwarder, closure, profileDir) {
|
|
|
207
211
|
killProcessTree(child, 'SIGTERM', process.platform !== 'win32');
|
|
208
212
|
// Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
|
|
209
213
|
// so we don't leave a ghost mach process around after a failed probe.
|
|
210
|
-
|
|
214
|
+
// Ref'd: an unref'd wait between a SIGTERM and its escalation lets Node
|
|
215
|
+
// exit mid-grace and skip the escalation.
|
|
216
|
+
await sleep(500);
|
|
211
217
|
if (!hasChildExited(child)) {
|
|
212
218
|
killProcessTree(child, 'SIGKILL', process.platform !== 'win32');
|
|
213
219
|
}
|
|
@@ -255,7 +261,7 @@ async function waitForMarionetteSocket(port, connectFn, keepTrying) {
|
|
|
255
261
|
if (result.ok) {
|
|
256
262
|
return { ok: true };
|
|
257
263
|
}
|
|
258
|
-
await sleep(400
|
|
264
|
+
await sleep(400);
|
|
259
265
|
}
|
|
260
266
|
return { ok: false };
|
|
261
267
|
}
|
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,18 @@ export interface FuzzyApplyResult {
|
|
|
26
26
|
/** List of `.rej` files created (when falling through to --reject). */
|
|
27
27
|
rejectFiles?: string[];
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The context-reduction ladder shared by the real apply and the dry-run
|
|
31
|
+
* replay: step 0 is an exact apply, step k passes `-C(3-k)`.
|
|
32
|
+
* @param maxFuzz - Maximum context-reduction steps (validated by callers)
|
|
33
|
+
* @returns One `git apply` argument list per step, in order
|
|
34
|
+
*/
|
|
35
|
+
export declare function contextReductionSteps(maxFuzz: number): string[][];
|
|
36
|
+
/**
|
|
37
|
+
* Validates a `maxFuzz` value.
|
|
38
|
+
* @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertValidMaxFuzz(maxFuzz: number): void;
|
|
29
41
|
/**
|
|
30
42
|
* Attempts to apply a patch with escalating context reduction.
|
|
31
43
|
*
|
|
@@ -23,6 +23,29 @@ import { exec } from '../utils/process.js';
|
|
|
23
23
|
import { ensureGit } from './git-base.js';
|
|
24
24
|
/** Git's default diff context width, the ceiling for reduction steps. */
|
|
25
25
|
const GIT_DEFAULT_CONTEXT = 3;
|
|
26
|
+
/**
|
|
27
|
+
* The context-reduction ladder shared by the real apply and the dry-run
|
|
28
|
+
* replay: step 0 is an exact apply, step k passes `-C(3-k)`.
|
|
29
|
+
* @param maxFuzz - Maximum context-reduction steps (validated by callers)
|
|
30
|
+
* @returns One `git apply` argument list per step, in order
|
|
31
|
+
*/
|
|
32
|
+
export function contextReductionSteps(maxFuzz) {
|
|
33
|
+
const maxSteps = Math.min(maxFuzz, GIT_DEFAULT_CONTEXT);
|
|
34
|
+
const steps = [];
|
|
35
|
+
for (let step = 0; step <= maxSteps; step++) {
|
|
36
|
+
steps.push(step > 0 ? [`-C${GIT_DEFAULT_CONTEXT - step}`] : []);
|
|
37
|
+
}
|
|
38
|
+
return steps;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validates a `maxFuzz` value.
|
|
42
|
+
* @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer
|
|
43
|
+
*/
|
|
44
|
+
export function assertValidMaxFuzz(maxFuzz) {
|
|
45
|
+
if (!Number.isInteger(maxFuzz) || maxFuzz < 0) {
|
|
46
|
+
throw new InvalidArgumentError(`maxFuzz must be a non-negative integer, got ${maxFuzz}.`, 'maxFuzz');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
26
49
|
// ── Implementation ──
|
|
27
50
|
/**
|
|
28
51
|
* Attempts to apply a patch with escalating context reduction.
|
|
@@ -41,14 +64,12 @@ const GIT_DEFAULT_CONTEXT = 3;
|
|
|
41
64
|
* (including the exact-match one) and fall straight through to `--reject`
|
|
42
65
|
*/
|
|
43
66
|
export async function applyPatchWithFuzz(patchPath, engineDir, maxFuzz = 3) {
|
|
44
|
-
|
|
45
|
-
throw new InvalidArgumentError(`maxFuzz must be a non-negative integer, got ${maxFuzz}.`, 'maxFuzz');
|
|
46
|
-
}
|
|
67
|
+
assertValidMaxFuzz(maxFuzz);
|
|
47
68
|
await ensureGit();
|
|
48
69
|
// Try exact match first, then escalate context reduction.
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
const ladder = contextReductionSteps(maxFuzz);
|
|
71
|
+
const maxSteps = ladder.length - 1;
|
|
72
|
+
for (const [step, contextArgs] of ladder.entries()) {
|
|
52
73
|
const check = await exec('git', ['apply', '--check', ...contextArgs, '--', patchPath], {
|
|
53
74
|
cwd: engineDir,
|
|
54
75
|
});
|
|
@@ -63,6 +63,19 @@ export interface PatchedContentContext {
|
|
|
63
63
|
getAffectingPatches: (filePath: string) => readonly PatchInfo[];
|
|
64
64
|
/** Patch body text, memoized per patch path across the whole batch. */
|
|
65
65
|
readPatchBody: (patch: PatchInfo) => Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Reads the HEAD content of every path in one `git cat-file --batch`
|
|
68
|
+
* and holds it for the next {@link computePatched} of each path, which
|
|
69
|
+
* otherwise spawns one `git show` per file. Call it before a loop.
|
|
70
|
+
*/
|
|
71
|
+
prefetchBase: (filePaths: readonly string[]) => Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Hashes every live file in one `git hash-object` per ARG_MAX chunk and
|
|
74
|
+
* holds the hashes for {@link liveBlobHash}.
|
|
75
|
+
*/
|
|
76
|
+
prefetchLiveBlobHashes: (fullPaths: readonly string[]) => Promise<void>;
|
|
77
|
+
/** The live blob hash of a file, from the prefetch or a single hash-object. */
|
|
78
|
+
liveBlobHash: (fullPath: string) => Promise<string | undefined>;
|
|
66
79
|
}
|
|
67
80
|
/**
|
|
68
81
|
* Builds a batched patched-content context that loads the manifest and
|
|
@@ -11,7 +11,7 @@ import { pathExists, readText, writeFileAtomic, writeText } from '../utils/fs.js
|
|
|
11
11
|
import { verbose } from '../utils/logger.js';
|
|
12
12
|
import { isContainedRelativePath } from '../utils/paths.js';
|
|
13
13
|
import { applyPatchIdempotent, reversePatch } from './git.js';
|
|
14
|
-
import { getFileContentAtRef } from './git-file-ops.js';
|
|
14
|
+
import { getFileContentAtRef, getFilesContentAtRef, hashObjectBatch } from './git-file-ops.js';
|
|
15
15
|
import { discoverPatches } from './patch-files.js';
|
|
16
16
|
import { loadPatchesManifest } from './patch-manifest.js';
|
|
17
17
|
import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch, parseDiffSections, } from './patch-parse.js';
|
|
@@ -395,12 +395,37 @@ export async function createPatchedContentContext(patchesDir, engineDir) {
|
|
|
395
395
|
return body;
|
|
396
396
|
};
|
|
397
397
|
const getAffectingPatches = (filePath) => affectingByFile.get(filePath) ?? [];
|
|
398
|
+
// Prefetched HEAD content, consumed on first use so a large scan does not
|
|
399
|
+
// hold every base file for the life of the context.
|
|
400
|
+
const baseContent = new Map();
|
|
401
|
+
const takeBase = async (filePath) => {
|
|
402
|
+
if (baseContent.has(filePath)) {
|
|
403
|
+
const content = baseContent.get(filePath) ?? null;
|
|
404
|
+
baseContent.delete(filePath);
|
|
405
|
+
return content;
|
|
406
|
+
}
|
|
407
|
+
return getFileContentAtRef(engineDir, filePath);
|
|
408
|
+
};
|
|
409
|
+
const liveHashes = new Map();
|
|
398
410
|
return {
|
|
399
411
|
manifestPatches: manifest?.patches ?? [],
|
|
400
412
|
getAffectingPatches,
|
|
401
413
|
readPatchBody,
|
|
414
|
+
prefetchBase: async (filePaths) => {
|
|
415
|
+
const wanted = filePaths.filter((file) => !baseContent.has(file));
|
|
416
|
+
for (const [file, content] of await getFilesContentAtRef(engineDir, wanted)) {
|
|
417
|
+
baseContent.set(file, content);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
prefetchLiveBlobHashes: async (fullPaths) => {
|
|
421
|
+
const wanted = fullPaths.filter((path) => !liveHashes.has(path));
|
|
422
|
+
for (const [path, hash] of await hashObjectBatch(engineDir, wanted)) {
|
|
423
|
+
liveHashes.set(path, hash);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
liveBlobHash: async (fullPath) => liveHashes.get(fullPath) ?? (await hashObjectBatch(engineDir, [fullPath])).get(fullPath),
|
|
402
427
|
computePatched: async (filePath) => {
|
|
403
|
-
let content = await
|
|
428
|
+
let content = await takeBase(filePath);
|
|
404
429
|
for (const patch of getAffectingPatches(filePath)) {
|
|
405
430
|
content = applyPatchTextToContent(content, await readPatchBody(patch), filePath);
|
|
406
431
|
}
|