@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/bin/fireforge.js +6 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/config.js +1 -2
  5. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
  6. package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
  7. package/dist/src/commands/furnace/deploy.js +36 -3
  8. package/dist/src/commands/furnace/validation-output.d.ts +5 -0
  9. package/dist/src/commands/furnace/validation-output.js +35 -9
  10. package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
  11. package/dist/src/commands/patch/delete-gap-notice.js +46 -0
  12. package/dist/src/commands/patch/delete.js +11 -0
  13. package/dist/src/commands/re-export-lint.d.ts +26 -1
  14. package/dist/src/commands/re-export-lint.js +60 -20
  15. package/dist/src/commands/re-export-scan.d.ts +8 -0
  16. package/dist/src/commands/re-export-scan.js +12 -2
  17. package/dist/src/commands/re-export-single.js +24 -5
  18. package/dist/src/commands/run.js +42 -17
  19. package/dist/src/commands/test-harness-env.d.ts +24 -0
  20. package/dist/src/commands/test-harness-env.js +61 -0
  21. package/dist/src/commands/test-harness-teardown.d.ts +62 -0
  22. package/dist/src/commands/test-harness-teardown.js +128 -0
  23. package/dist/src/commands/test-register.js +8 -3
  24. package/dist/src/commands/test-run.d.ts +2 -0
  25. package/dist/src/commands/test-run.js +36 -7
  26. package/dist/src/commands/test-verdict.d.ts +21 -4
  27. package/dist/src/commands/test-verdict.js +37 -4
  28. package/dist/src/commands/test.js +27 -26
  29. package/dist/src/core/build-jar-escalation.d.ts +1 -4
  30. package/dist/src/core/build-jar-escalation.js +1 -4
  31. package/dist/src/core/build-prepare.js +72 -55
  32. package/dist/src/core/config-paths.d.ts +1 -1
  33. package/dist/src/core/config-paths.js +1 -0
  34. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  35. package/dist/src/core/file-lock.js +16 -9
  36. package/dist/src/core/firefox-cache.js +1 -2
  37. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  38. package/dist/src/core/furnace-apply-output.js +19 -4
  39. package/dist/src/core/furnace-css-fragments.d.ts +7 -0
  40. package/dist/src/core/furnace-css-fragments.js +12 -3
  41. package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
  42. package/dist/src/core/furnace-fragment-includers.js +122 -0
  43. package/dist/src/core/git-diff-new-file.js +1 -2
  44. package/dist/src/core/git-file-ops.js +1 -2
  45. package/dist/src/core/git.js +1 -2
  46. package/dist/src/core/harness-helper-pids.d.ts +98 -0
  47. package/dist/src/core/harness-helper-pids.js +240 -0
  48. package/dist/src/core/harness-orphans.d.ts +53 -8
  49. package/dist/src/core/harness-orphans.js +125 -44
  50. package/dist/src/core/mach-build-artifacts.js +1 -2
  51. package/dist/src/core/mach.d.ts +16 -0
  52. package/dist/src/core/mach.js +6 -1
  53. package/dist/src/core/marionette-preflight.js +9 -3
  54. package/dist/src/core/mochitest-server-port.d.ts +43 -4
  55. package/dist/src/core/mochitest-server-port.js +64 -11
  56. package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
  57. package/dist/src/core/parent-exit-watchdog.js +69 -0
  58. package/dist/src/core/patch-lint-imports.js +4 -4
  59. package/dist/src/core/smoke-patterns.d.ts +24 -0
  60. package/dist/src/core/smoke-patterns.js +50 -5
  61. package/dist/src/types/commands/options.d.ts +17 -1
  62. package/dist/src/types/config.d.ts +7 -0
  63. package/dist/src/utils/process-group.d.ts +5 -1
  64. package/dist/src/utils/process-group.js +7 -3
  65. package/dist/src/utils/process.d.ts +18 -0
  66. package/dist/src/utils/process.js +14 -2
  67. package/docs/configuration.md +15 -1
  68. package/docs/exit-codes.md +19 -19
  69. package/docs/lifecycle-invariants.md +17 -1
  70. package/docs/machine-output.md +44 -19
  71. package/docs/testing.md +80 -4
  72. package/package.json +5 -5
@@ -21,10 +21,15 @@
21
21
  * for the same reason. FireForge has no business reporting, let alone
22
22
  * terminating, a process it cannot attribute to the harness.
23
23
  *
24
- * `--reap-orphans` opts into termination. Without it the census is
25
- * report-only, exactly like the `Orphaned harness workers` doctor check
26
- * (which covers a different shape: reparented Python multiprocessing
27
- * workers, matched on PPID 1 and accumulated CPU time).
24
+ * `--reap-orphans` (or `test.reapOrphans: "reap"` in `fireforge.json`) opts
25
+ * into termination. Without it the census is report-only, exactly like the
26
+ * `Orphaned harness workers` doctor check (which covers a different shape:
27
+ * reparented Python multiprocessing workers, matched on PPID 1 and
28
+ * accumulated CPU time).
29
+ *
30
+ * The anchoring and termination primitives are exported for the teardown
31
+ * reaper in `harness-helper-pids.ts`, which applies the same objdir rule to
32
+ * the helpers THIS run launched, after mach has exited.
28
33
  */
29
34
  /** One surviving harness helper process. */
30
35
  export interface OrphanedHarnessProcess {
@@ -36,15 +41,32 @@ export interface OrphanedHarnessProcess {
36
41
  elapsedSeconds: number;
37
42
  command: string;
38
43
  }
44
+ /**
45
+ * True when `command` IS a harness helper: its executable is a helper
46
+ * binary, or an interpreter whose first argument is a helper script.
47
+ *
48
+ * Structural on purpose. An earlier version matched the helper names
49
+ * anywhere in the command line, which is right for the httpd (`xpcshell -g
50
+ * … -f …/server.js`) and wrong for everything that merely mentions such a
51
+ * path: an editor opened on `server.js`, a shell whose command text quotes
52
+ * it, a `grep` over the objdir. Report-only, that printed a misleading
53
+ * `kill`; under the reap posture it terminated the operator's shell.
54
+ */
55
+ export declare function matchesHarnessHelper(command: string): boolean;
56
+ /** Objdir provenance: an explicit objdir path, a `obj-…` path segment, or `_tests/`. */
57
+ export declare function isObjdirAnchored(command: string, objDir: string | undefined): boolean;
39
58
  /**
40
59
  * Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
41
60
  * helpers. Pure, so it is fixture-testable without spawning anything.
42
61
  *
62
+ * FireForge's own process, its direct children and its ancestors are never
63
+ * candidates: the terminal shell FireForge runs under can carry the helper
64
+ * paths in its own command text, and must never be offered for a kill.
65
+ *
43
66
  * @param psOutput - Raw `ps` output
44
67
  * @param objDir - Absolute objdir of this project, when known. Widens the
45
68
  * provenance test beyond the generic `obj-…` path segment
46
- * @param selfPid - This process's pid, excluded so FireForge cannot report
47
- * itself
69
+ * @param selfPid - This process's pid
48
70
  */
49
71
  export declare function findOrphanedHarnessProcesses(psOutput: string, objDir?: string, selfPid?: number): OrphanedHarnessProcess[];
50
72
  /**
@@ -66,8 +88,31 @@ export declare function formatOrphanReport(orphans: readonly OrphanedHarnessProc
66
88
  * @param objDir - Absolute objdir of this project, when known
67
89
  * @param options - `reap` terminates each recognized survivor (SIGTERM,
68
90
  * then SIGKILL for anything that stays)
69
- * @returns The census, empty when nothing was found or nothing could be probed
91
+ * @returns The census (empty when nothing was found or nothing could be
92
+ * probed) and how many of them are confirmed gone after reaping (0 in
93
+ * report-only mode)
70
94
  */
71
95
  export declare function reportOrphanedHarnessProcesses(objDir: string | undefined, options?: {
72
96
  reap?: boolean;
73
- }): Promise<OrphanedHarnessProcess[]>;
97
+ }): Promise<OrphanCensus>;
98
+ /** Result of {@link reportOrphanedHarnessProcesses}. */
99
+ export interface OrphanCensus {
100
+ orphans: OrphanedHarnessProcess[];
101
+ /** Survivors confirmed gone after the reap. Always 0 in report-only mode. */
102
+ reaped: number;
103
+ }
104
+ /** Liveness probe: `kill(pid, 0)` throws for a pid that is gone. */
105
+ export declare function isPidAlive(pid: number): boolean;
106
+ /**
107
+ * Terminates each entry: SIGTERM first, then SIGKILL for anything still
108
+ * alive after the grace period. Failures are reported, never thrown. A
109
+ * process that exited between the listing and the signal is the common case
110
+ * rather than an error.
111
+ *
112
+ * @param targets - Processes to terminate, already attributed to the harness
113
+ * by the caller (objdir anchor rule)
114
+ * @param label - Prefix for the log lines, naming which path is reaping
115
+ * (`--reap-orphans` at preflight, `teardown` after mach exits)
116
+ * @returns How many targets are confirmed gone afterwards
117
+ */
118
+ export declare function terminateHarnessProcesses(targets: readonly Pick<OrphanedHarnessProcess, 'pid'>[], label: string): Promise<number>;
@@ -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` opts into termination. Without it the census is
26
- * report-only, exactly like the `Orphaned harness workers` doctor check
27
- * (which covers a different shape: reparented Python multiprocessing
28
- * workers, matched on PPID 1 and accumulated CPU time).
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 executables/scripts. Every one of these is started by the
37
- * mochitest/xpcshell harness and is expected to die with it. None of them
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
- const HARNESS_HELPER_PATTERN = /\b(?:xpcshell|pywebsocket\w*|websocket_server\.py|ssltunnel|moz-http2|http2_server|httpd\.js|server\.js|runtests\.py|runxpcshelltests\.py)\b/;
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, excluded so FireForge cannot report
57
- * itself
105
+ * @param selfPid - This process's pid
58
106
  */
59
107
  export function findOrphanedHarnessProcesses(psOutput, objDir, selfPid = process.pid) {
60
- const found = [];
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
- const pid = Number(match[1]);
66
- const ppid = Number(match[2]);
67
- const elapsed = match[3] ?? '';
68
- const command = (match[4] ?? '').trim();
69
- if (pid === selfPid || ppid === selfPid)
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 (!HARNESS_HELPER_PATTERN.test(command))
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}", or re-run with --reap-orphans to have FireForge do it.`);
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, empty when nothing was found or nothing could be probed
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
- if (options.reap === true) {
148
- await reapOrphanedHarnessProcesses(orphans);
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 census entry: SIGTERM first, then SIGKILL for anything
154
- * still alive. Failures are reported, never thrown. A process that exited
155
- * between the census and the signal is the common case rather than an error.
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 reapOrphanedHarnessProcesses(orphans) {
158
- for (const orphan of orphans) {
231
+ export async function terminateHarnessProcesses(targets, label) {
232
+ let reaped = 0;
233
+ for (const target of targets) {
159
234
  try {
160
- process.kill(orphan.pid, 'SIGTERM');
235
+ process.kill(target.pid, 'SIGTERM');
161
236
  }
162
237
  catch (error) {
163
- verbose(`--reap-orphans: SIGTERM to ${orphan.pid} failed (${toError(error).message}).`);
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
- let alive = true;
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(orphan.pid, 'SIGKILL');
247
+ process.kill(target.pid, 'SIGKILL');
180
248
  }
181
249
  catch (error) {
182
- verbose(`--reap-orphans: SIGKILL to ${orphan.pid} failed (${toError(error).message}).`);
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
  }
@@ -140,8 +140,7 @@ export async function hasBuildArtifacts(engineDir) {
140
140
  }
141
141
  return { exists: true, objDir: selectedObjDir };
142
142
  }
143
- catch (error) {
144
- void error;
143
+ catch {
145
144
  return { exists: false };
146
145
  }
147
146
  }
@@ -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
@@ -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, { unref: true });
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
- await sleep(500, { unref: true });
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, { unref: true });
264
+ await sleep(400);
259
265
  }
260
266
  return { ok: false };
261
267
  }
@@ -12,6 +12,36 @@ export declare const DEFAULT_MOCHITEST_SERVER_PORT = 8888;
12
12
  * harness's httpd has.
13
13
  */
14
14
  export declare function isMochitestServerHolder(holder: MarionettePortHolder): boolean;
15
+ /**
16
+ * How a recognized harness httpd relates to the checkout about to run.
17
+ *
18
+ * `this-checkout` is debris from one of OUR interrupted runs and may be
19
+ * offered for termination. `foreign-checkout` is the same harness serving a
20
+ * LIVE run in a sibling worktree: `server.js` plus objdir provenance is
21
+ * exactly what a peer's in-flight suite looks like, and the only thing
22
+ * separating the two is whose objdir the command line points into.
23
+ * `unrecognized` is not the harness at all.
24
+ */
25
+ export type MochitestServerHolderClass = 'this-checkout' | 'foreign-checkout' | 'unrecognized';
26
+ /**
27
+ * Classifies the holder against this project's engine directory. Every
28
+ * absolute path on the command line is tested with {@link isPathInsideRoot}
29
+ * (a token test, not a substring test, so `/a/hominis/engine` does not claim
30
+ * a holder living under `/a/hominis-2/engine`). On POSIX the command line is
31
+ * the full `ps -o args=` output, so the objdir path is always present for a
32
+ * harness httpd; a holder whose paths all lie elsewhere belongs to another
33
+ * checkout.
34
+ *
35
+ * @param holder - The listener found on the port
36
+ * @param engineDir - Absolute engine directory of THIS project
37
+ */
38
+ export declare function classifyMochitestServerHolder(holder: MarionettePortHolder, engineDir: string): MochitestServerHolderClass;
39
+ /**
40
+ * The objdir a foreign holder is serving from, for the refusal text: the
41
+ * first absolute command-line token with an `obj-…` segment, cut at that
42
+ * segment. Falls back to the first absolute token, then to a placeholder.
43
+ */
44
+ export declare function describeHolderObjdir(commandLine: string): string;
15
45
  /**
16
46
  * Refuses a mochitest dispatch when the harness's server port is held. It
17
47
  * terminates the holder first only for a recognized stale harness httpd,
@@ -26,10 +56,19 @@ export declare function isMochitestServerHolder(holder: MarionettePortHolder): b
26
56
  * Best effort: {@link probeMarionettePort} reports an
27
57
  * unprobeable port as free, so a host without `lsof` runs exactly as before.
28
58
  *
59
+ * A recognized httpd from ANOTHER checkout is a refusal and never a kill
60
+ * either, flag or no flag: it is a peer's live suite, not our debris. The
61
+ * browser-side probe draws the same line for a bare launch it cannot
62
+ * attribute; this one can attribute, because the objdir is on the command
63
+ * line, so it names the other checkout instead of offering `kill -9`.
64
+ *
29
65
  * @param port - Port to probe (default {@link DEFAULT_MOCHITEST_SERVER_PORT})
30
- * @param options - `killStaleServer` opts into terminating a recognized holder
66
+ * @param options - `engineDir` is this project's absolute engine directory,
67
+ * the ownership boundary; `killStaleServer` opts into terminating a holder
68
+ * recognized as THIS checkout's debris
31
69
  */
32
- export declare function ensureMochitestServerPortAvailable(port?: number, options?: {
70
+ export declare function ensureMochitestServerPortAvailable(port: number | undefined, options: {
71
+ engineDir: string;
33
72
  killStaleServer?: boolean;
34
73
  }): Promise<void>;
35
74
  /**
@@ -37,6 +76,6 @@ export declare function ensureMochitestServerPortAvailable(port?: number, option
37
76
  *
38
77
  * @param port - The probed port
39
78
  * @param holder - The listener found on it
40
- * @param recognized - Whether it is the harness's own `server.js`
79
+ * @param holderClass - Whose harness `server.js` it is, if it is one at all
41
80
  */
42
- export declare function describeMochitestServerRefusal(port: number, holder: MarionettePortHolder, recognized: boolean): string;
81
+ export declare function describeMochitestServerRefusal(port: number, holder: MarionettePortHolder, holderClass: MochitestServerHolderClass): string;
@@ -23,6 +23,7 @@
23
23
  * port-generic, rather than growing a second probe.
24
24
  */
25
25
  import { GeneralError } from '../errors/base.js';
26
+ import { isExplicitAbsolutePath, isPathInsideRoot } from '../utils/paths.js';
26
27
  import { exec } from '../utils/process.js';
27
28
  import { probeMarionettePort } from './marionette-port.js';
28
29
  /** Default port the mochitest harness binds its httpd to. */
@@ -43,6 +44,40 @@ export function isMochitestServerHolder(holder) {
43
44
  return false;
44
45
  return /\bxpcshell\b/.test(line) || /\/obj-[^/\s]*\//.test(line) || /\b_tests\b/.test(line);
45
46
  }
47
+ /**
48
+ * Classifies the holder against this project's engine directory. Every
49
+ * absolute path on the command line is tested with {@link isPathInsideRoot}
50
+ * (a token test, not a substring test, so `/a/hominis/engine` does not claim
51
+ * a holder living under `/a/hominis-2/engine`). On POSIX the command line is
52
+ * the full `ps -o args=` output, so the objdir path is always present for a
53
+ * harness httpd; a holder whose paths all lie elsewhere belongs to another
54
+ * checkout.
55
+ *
56
+ * @param holder - The listener found on the port
57
+ * @param engineDir - Absolute engine directory of THIS project
58
+ */
59
+ export function classifyMochitestServerHolder(holder, engineDir) {
60
+ if (!isMochitestServerHolder(holder))
61
+ return 'unrecognized';
62
+ const absoluteTokens = holder.commandLine.split(/\s+/).filter(isExplicitAbsolutePath);
63
+ return absoluteTokens.some((token) => isPathInsideRoot(engineDir, token))
64
+ ? 'this-checkout'
65
+ : 'foreign-checkout';
66
+ }
67
+ /**
68
+ * The objdir a foreign holder is serving from, for the refusal text: the
69
+ * first absolute command-line token with an `obj-…` segment, cut at that
70
+ * segment. Falls back to the first absolute token, then to a placeholder.
71
+ */
72
+ export function describeHolderObjdir(commandLine) {
73
+ const tokens = commandLine.split(/\s+/).filter(isExplicitAbsolutePath);
74
+ for (const token of tokens) {
75
+ const match = /^(.*?[/\\]obj-[^/\\]*)(?:[/\\]|$)/.exec(token);
76
+ if (match?.[1])
77
+ return match[1];
78
+ }
79
+ return tokens[0] ?? '(no path visible on the command line)';
80
+ }
46
81
  /**
47
82
  * Refuses a mochitest dispatch when the harness's server port is held. It
48
83
  * terminates the holder first only for a recognized stale harness httpd,
@@ -57,16 +92,24 @@ export function isMochitestServerHolder(holder) {
57
92
  * Best effort: {@link probeMarionettePort} reports an
58
93
  * unprobeable port as free, so a host without `lsof` runs exactly as before.
59
94
  *
95
+ * A recognized httpd from ANOTHER checkout is a refusal and never a kill
96
+ * either, flag or no flag: it is a peer's live suite, not our debris. The
97
+ * browser-side probe draws the same line for a bare launch it cannot
98
+ * attribute; this one can attribute, because the objdir is on the command
99
+ * line, so it names the other checkout instead of offering `kill -9`.
100
+ *
60
101
  * @param port - Port to probe (default {@link DEFAULT_MOCHITEST_SERVER_PORT})
61
- * @param options - `killStaleServer` opts into terminating a recognized holder
102
+ * @param options - `engineDir` is this project's absolute engine directory,
103
+ * the ownership boundary; `killStaleServer` opts into terminating a holder
104
+ * recognized as THIS checkout's debris
62
105
  */
63
- export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITEST_SERVER_PORT, options = {}) {
106
+ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITEST_SERVER_PORT, options) {
64
107
  const probe = await probeMarionettePort(port);
65
108
  if (!probe.inUse || !probe.holder)
66
109
  return;
67
110
  const holder = probe.holder;
68
- const recognized = isMochitestServerHolder(holder);
69
- if (recognized && options.killStaleServer === true) {
111
+ const holderClass = classifyMochitestServerHolder(holder, options.engineDir);
112
+ if (holderClass === 'this-checkout' && options.killStaleServer === true) {
70
113
  try {
71
114
  if (process.platform === 'win32') {
72
115
  await exec('powershell.exe', [
@@ -87,16 +130,16 @@ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITES
87
130
  // pretending the port is now free.
88
131
  }
89
132
  }
90
- throw new GeneralError(describeMochitestServerRefusal(port, holder, recognized));
133
+ throw new GeneralError(describeMochitestServerRefusal(port, holder, holderClass));
91
134
  }
92
135
  /**
93
136
  * Builds the operator-facing refusal. Exported for direct unit testing.
94
137
  *
95
138
  * @param port - The probed port
96
139
  * @param holder - The listener found on it
97
- * @param recognized - Whether it is the harness's own `server.js`
140
+ * @param holderClass - Whose harness `server.js` it is, if it is one at all
98
141
  */
99
- export function describeMochitestServerRefusal(port, holder, recognized) {
142
+ export function describeMochitestServerRefusal(port, holder, holderClass) {
100
143
  const killHint = process.platform === 'win32'
101
144
  ? `Stop-Process -Id ${holder.pid} -Force`
102
145
  : `kill -9 ${holder.pid}`;
@@ -106,11 +149,21 @@ export function describeMochitestServerRefusal(port, holder, recognized) {
106
149
  " A browser launched now would connect to THAT server, which cannot serve this run's " +
107
150
  'manifest — the run would stall before TEST_START and die on the no-output timeout with ' +
108
151
  '"Ran 0 checks".\n';
109
- if (recognized) {
152
+ if (holderClass === 'this-checkout') {
153
+ return (evidence +
154
+ " This is the mochitest harness's own server.js from THIS checkout's objdir, so it is " +
155
+ 'debris from an interrupted run. Retry with "--kill-stale-marionette" to have FireForge ' +
156
+ `stop it, or stop it yourself with "${killHint}" (a wedged httpd can ignore SIGTERM).`);
157
+ }
158
+ if (holderClass === 'foreign-checkout') {
159
+ // No kill hint on this branch, on purpose: the PID is a peer's live
160
+ // suite, and "kill -9 <pid>" is exactly the instruction that would have
161
+ // destroyed it.
110
162
  return (evidence +
111
- " This is the mochitest harness's own server.js, so it is debris from an interrupted run. " +
112
- 'Retry with "--kill-stale-marionette" to have FireForge stop it, or stop it yourself with ' +
113
- `"${killHint}" (a wedged httpd can ignore SIGTERM).`);
163
+ ` This is a mochitest httpd from ANOTHER checkout: ${describeHolderObjdir(holder.commandLine)}. ` +
164
+ 'It is serving a live run there, so FireForge will not stop it and "--kill-stale-marionette" ' +
165
+ 'will not either. Wait for that run to finish or free the port yourself, then retry. ' +
166
+ `Diagnose with "lsof -nP -iTCP:${port}".`);
114
167
  }
115
168
  return (evidence +
116
169
  " This is NOT the mochitest harness's server.js, so FireForge will not offer to kill it. " +