@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
@@ -10,8 +10,10 @@
10
10
  * cost startup time but make results reproducible. The combined invocation
11
11
  * stays available via `--no-shard`.
12
12
  */
13
+ import { join } from 'node:path';
13
14
  import { probeDisplaySleepState } from '../core/display-state.js';
14
15
  import { runMachTestSuite } from '../core/mach.js';
16
+ import { parentExited } from '../core/parent-exit-watchdog.js';
15
17
  import { getActiveRunLogPath } from '../core/run-log.js';
16
18
  import { changedPrefNoiseVerdictNote } from '../core/test-changed-prefs.js';
17
19
  import { buildHarnessCrashMessage, classifyHarnessRun, headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
@@ -21,6 +23,7 @@ import { TestFailureError } from '../errors/build.js';
21
23
  import { info, note, warn } from '../utils/logger.js';
22
24
  import { getPlatform } from '../utils/platform.js';
23
25
  import { maybeInjectAppdirArg } from './test-appdir.js';
26
+ import { createHarnessTeardown } from './test-harness-teardown.js';
24
27
  import { emitHarnessVerdict } from './test-verdict.js';
25
28
  /** Default bounded retry budget for recognized harness crashes. */
26
29
  export const DEFAULT_HARNESS_RETRIES = 2;
@@ -61,13 +64,14 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
61
64
  let verdict;
62
65
  for (;;) {
63
66
  attempts += 1;
64
- result = await runMachTestSuite(kind, {
65
- engineDir: ctx.engineDir,
66
- testPaths: paths,
67
- args: extraArgs,
68
- env,
69
- fullOutput: ctx.fullOutput,
70
- });
67
+ result = await runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env);
68
+ // The parent that was reading this run is gone and the watchdog has
69
+ // already killed the harness. Classifying the corpse would call it a
70
+ // harness crash and retry it under nobody's supervision.
71
+ if (parentExited()) {
72
+ throw new TestFailureError('The parent process exited while the harness was running; the harness tree was ' +
73
+ 'terminated and the run ended without a result.', `mach ${kind}`, undefined, getActiveRunLogPath());
74
+ }
71
75
  result = await retryAfterXpcshellSymlinkRepair({
72
76
  engineDir: ctx.engineDir,
73
77
  objDir: ctx.objDir,
@@ -107,6 +111,31 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
107
111
  }
108
112
  return { result, verdict, attempts, appdirInjectionAttempted, displayState };
109
113
  }
114
+ /**
115
+ * One mach dispatch with its kill path attached: helper-pid tracking and
116
+ * post-close reaping, the process-group announcement, and the parent-exit
117
+ * watchdog for exactly as long as mach runs.
118
+ */
119
+ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
120
+ const teardown = createHarnessTeardown({
121
+ objDir: ctx.objDir === undefined ? undefined : join(ctx.engineDir, ctx.objDir),
122
+ pgidFile: ctx.pgidFile,
123
+ });
124
+ try {
125
+ await teardown.prepare();
126
+ return await runMachTestSuite(kind, {
127
+ engineDir: ctx.engineDir,
128
+ testPaths: paths,
129
+ args: extraArgs,
130
+ env,
131
+ fullOutput: ctx.fullOutput,
132
+ teardown: teardown.hooks,
133
+ });
134
+ }
135
+ finally {
136
+ teardown.dispose();
137
+ }
138
+ }
110
139
  /**
111
140
  * Probes the display's power state for a headed no-output stall, and only for
112
141
  * that shape. Every other outcome (a passing run, a real test failure, a
@@ -12,6 +12,16 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
12
12
  * of the others.
13
13
  */
14
14
  export type FireforgeVerdictReason = 'crash' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
+ /** Additive keys a run can stamp on its verdict line. */
16
+ export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped';
17
+ /** Records an additive attribute for this run's verdict line. */
18
+ export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
19
+ /**
20
+ * Adds to a counting attribute. The key appears only once the total is
21
+ * positive, so a run that reaped nothing prints the line it always did.
22
+ * Sums across the preflight census, every retry attempt and every shard.
23
+ */
24
+ export declare function addVerdictRunCount(key: 'orphans-reaped', count: number): void;
15
25
  /**
16
26
  * Re-arms the verdict sink for a new run. Called at `testCommand` entry so
17
27
  * repeated programmatic invocations (and tests) each get their own
@@ -34,7 +44,7 @@ export declare function emitPassVerdict(): void;
34
44
  * Emits a FAIL verdict carrying an emission-layer reason code, and
35
45
  * optionally the refusal class that produced it.
36
46
  *
37
- * `note=` is additive, like the `log=`, `signal=` and `shards=` keys already
47
+ * `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
38
48
  * on this line: `reason=` is unchanged and still comes from the closed set
39
49
  * in `docs/machine-output.md`, so a consumer that tokenises `key=value`
40
50
  * pairs is unaffected. It exists because `reason=preflight` covers every
@@ -47,7 +57,14 @@ export declare function emitPassVerdict(): void;
47
57
  */
48
58
  export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: string): void;
49
59
  /**
50
- * Emits the terminal verdict for a run killed by a signal.
60
+ * What ended a killed run: a signal FireForge received, or `parent-exit`,
61
+ * the parent-death watchdog's finding that the process above FireForge
62
+ * vanished (typically SIGKILLed by a supervisor) while a harness ran.
63
+ */
64
+ export type KilledVerdictCause = NodeJS.Signals | 'parent-exit';
65
+ /**
66
+ * Emits the terminal verdict for a run killed by a signal, or ended by
67
+ * FireForge itself because its parent died.
51
68
  *
52
69
  * Without it a killed run wrote no terminal line, so a log tail could not
53
70
  * distinguish "killed" from "still running" from "never started". Recovery
@@ -57,7 +74,7 @@ export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: s
57
74
  * line asserts a test run happened, and an interrupted `status` did not
58
75
  * have one.
59
76
  *
60
- * @param signal - The signal that terminated the run
77
+ * @param signal - The signal that terminated the run, or `parent-exit`
61
78
  * @returns True when a line was written
62
79
  */
63
- export declare function emitKilledVerdict(signal: NodeJS.Signals): boolean;
80
+ export declare function emitKilledVerdict(signal: KilledVerdictCause): boolean;
@@ -3,6 +3,36 @@ import { getActiveRunLogPath } from '../core/run-log.js';
3
3
  import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
4
4
  import { setStdoutSealed } from '../utils/logger.js';
5
5
  let emitted = false;
6
+ /**
7
+ * Additive `key=value` attributes of the run in flight, appended to
8
+ * whichever verdict line ends it. `shuffle` is the seed a `--shuffle` run
9
+ * exported to the harness, so a red found by shuffling carries its own
10
+ * reproduction on the one line an unattended reader keeps. `orphans-reaped`
11
+ * is how many harness processes FireForge had to terminate itself, at
12
+ * preflight (survivors of an earlier run) or at teardown (helpers this run
13
+ * launched that outlived mach): a green after a reap is not the same
14
+ * finding as a green on a quiet machine. Cleared by
15
+ * {@link resetVerdictEmission}, so a later run in the same process cannot
16
+ * inherit them.
17
+ */
18
+ const runAttributes = new Map();
19
+ /** Records an additive attribute for this run's verdict line. */
20
+ export function setVerdictRunAttribute(key, value) {
21
+ runAttributes.set(key, value);
22
+ }
23
+ /**
24
+ * Adds to a counting attribute. The key appears only once the total is
25
+ * positive, so a run that reaped nothing prints the line it always did.
26
+ * Sums across the preflight census, every retry attempt and every shard.
27
+ */
28
+ export function addVerdictRunCount(key, count) {
29
+ // Written as a negated `> 0` so a NaN (a stubbed census, a bad parse) is
30
+ // dropped rather than stamped on the line as `orphans-reaped=NaN`.
31
+ if (!(count > 0))
32
+ return;
33
+ const current = Number(runAttributes.get(key) ?? '0');
34
+ runAttributes.set(key, String(current + count));
35
+ }
6
36
  /**
7
37
  * True once a test run has begun in this process.
8
38
  *
@@ -20,6 +50,7 @@ let armed = false;
20
50
  export function resetVerdictEmission() {
21
51
  emitted = false;
22
52
  armed = true;
53
+ runAttributes.clear();
23
54
  setStdoutSealed(false);
24
55
  }
25
56
  /** True once this run has written its `FIREFORGE-VERDICT:` line. */
@@ -45,9 +76,10 @@ function writeVerdictLine(line) {
45
76
  // break that contract, and one before it is the first thing a `tail` cuts.
46
77
  // Appended as an additive `key=value`, so consumers that tokenize the line
47
78
  // are unaffected and a truncated tail still says where the full output is.
79
+ const attributes = [...runAttributes].map(([key, value]) => ` ${key}=${value}`).join('');
48
80
  const logPath = getActiveRunLogPath();
49
81
  const suffix = logPath === undefined ? '' : ` log=${logPath}`;
50
- process.stdout.write(`${line}${suffix}\n`);
82
+ process.stdout.write(`${line}${attributes}${suffix}\n`);
51
83
  setStdoutSealed(true);
52
84
  }
53
85
  /**
@@ -65,7 +97,7 @@ export function emitPassVerdict() {
65
97
  * Emits a FAIL verdict carrying an emission-layer reason code, and
66
98
  * optionally the refusal class that produced it.
67
99
  *
68
- * `note=` is additive, like the `log=`, `signal=` and `shards=` keys already
100
+ * `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
69
101
  * on this line: `reason=` is unchanged and still comes from the closed set
70
102
  * in `docs/machine-output.md`, so a consumer that tokenises `key=value`
71
103
  * pairs is unaffected. It exists because `reason=preflight` covers every
@@ -81,7 +113,8 @@ export function emitFailVerdict(reason, note) {
81
113
  writeVerdictLine(`FIREFORGE-VERDICT: FAIL reason=${reason}${suffix}`);
82
114
  }
83
115
  /**
84
- * Emits the terminal verdict for a run killed by a signal.
116
+ * Emits the terminal verdict for a run killed by a signal, or ended by
117
+ * FireForge itself because its parent died.
85
118
  *
86
119
  * Without it a killed run wrote no terminal line, so a log tail could not
87
120
  * distinguish "killed" from "still running" from "never started". Recovery
@@ -91,7 +124,7 @@ export function emitFailVerdict(reason, note) {
91
124
  * line asserts a test run happened, and an interrupted `status` did not
92
125
  * have one.
93
126
  *
94
- * @param signal - The signal that terminated the run
127
+ * @param signal - The signal that terminated the run, or `parent-exit`
95
128
  * @returns True when a line was written
96
129
  */
97
130
  export function emitKilledVerdict(signal) {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join, resolve } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { assertEngineExists } from '../core/engine-precondition.js';
5
5
  import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
@@ -21,9 +21,11 @@ import { info, intro, notice, outro, verbose } from '../utils/logger.js';
21
21
  import { stripEnginePrefix } from '../utils/paths.js';
22
22
  import { runTestBuildPhase } from './test-build-phase.js';
23
23
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
24
+ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
25
+ import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
24
26
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
25
27
  import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
26
- import { emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
28
+ import { addVerdictRunCount, emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
27
29
  async function assertTestPathsExist(engineDir, testPaths) {
28
30
  const missingPaths = [];
29
31
  for (const testPath of testPaths) {
@@ -226,7 +228,7 @@ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip
226
228
  }
227
229
  await assertMarionettePortAvailable(port, { binaryName });
228
230
  }
229
- async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, binaryName, options, objDir) {
231
+ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, projectConfig, options, objDir) {
230
232
  // A timed-out mochitest can leave the built app alive after its Marionette
231
233
  // listener has disappeared. The port probe cannot see that case, but the
232
234
  // survivor can still steal focus and wedge every later headed run.
@@ -241,20 +243,25 @@ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellO
241
243
  // does not use the httpd, so an xpcshell-only run is never blocked by it.
242
244
  if (!xpcshellOnly) {
243
245
  await ensureMochitestServerPortAvailable(undefined, {
246
+ engineDir,
244
247
  killStaleServer: options.killStaleMarionette === true,
245
248
  });
246
249
  }
247
250
  // Helpers that outlived an earlier run (httpd, pywebsocket, ssltunnel,
248
251
  // moz-http2) slow every later run without appearing in its output. Both
249
252
  // harnesses are affected, so this census is not gated on xpcshellOnly.
250
- await reportOrphanedHarnessProcesses(objDir, {
251
- reap: options.reapOrphans === true,
253
+ // Reaping is opt-in per invocation (--reap-orphans) or per repo
254
+ // (test.reapOrphans: "reap"); a reap is stamped on the verdict line so a
255
+ // green after one is not mistaken for a green on a quiet machine.
256
+ const census = await reportOrphanedHarnessProcesses(objDir, {
257
+ reap: options.reapOrphans === true || projectConfig.test?.reapOrphans === 'reap',
252
258
  });
259
+ addVerdictRunCount('orphans-reaped', census.reaped);
253
260
  const forwardedPort = options.machArg
254
261
  ? extractForwardedMarionettePort(options.machArg)
255
262
  : undefined;
256
263
  const effectivePort = options.marionettePort ?? forwardedPort;
257
- await ensureTestMarionettePortAvailable(effectivePort, binaryName, options, {
264
+ await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
258
265
  xpcshellOnly,
259
266
  doctor: options.doctor === true,
260
267
  });
@@ -289,6 +296,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
289
296
  // exactly the runs whose only output a `tail` throws away. Closed in
290
297
  // `finally`, after the verdict line has already read the path.
291
298
  setActiveRunLog(await openRunLog(projectRoot, 'test'));
299
+ setActivePgidFile(options.pgidFile);
292
300
  try {
293
301
  await runTestCommandBody(projectRoot, testPaths, options);
294
302
  }
@@ -300,6 +308,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
300
308
  throw error;
301
309
  }
302
310
  finally {
311
+ // A run that ends under FireForge's control owns no harness group any
312
+ // more; the file only outlives FireForge when FireForge was killed.
313
+ await removePgidFile(options.pgidFile);
303
314
  await closeActiveRunLog();
304
315
  }
305
316
  }
@@ -403,7 +414,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
403
414
  // documented `--mach-arg --marionette-port=NNNN` route still hits the
404
415
  // wrapper preflight refusing on 2828 before the forwarded arg reaches
405
416
  // mach.
406
- const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig.binaryName, options, buildCheck.objDir);
417
+ const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options, buildCheck.objDir);
407
418
  if (options.doctor) {
408
419
  const doctorOutcome = await runDoctorPreflight({
409
420
  engineDir: paths.engine,
@@ -418,6 +429,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
418
429
  }
419
430
  await assertTestPathsExist(paths.engine, normalizedPaths);
420
431
  const suite = resolveTestSuite(classification, options.genericMachTest === true);
432
+ const shuffleSeed = resolveShuffleSeed(options.shuffle, suite);
421
433
  const forwardedMachArgs = options.machArg && options.machArg.length > 0
422
434
  ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
423
435
  : [];
@@ -425,20 +437,21 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
425
437
  if (options.headless) {
426
438
  extraArgs.push('--headless');
427
439
  }
440
+ if (shuffleSeed !== undefined) {
441
+ extraArgs.push('--shuffle');
442
+ }
428
443
  if (options.auto === true) {
429
444
  extraArgs.push('--auto');
430
445
  }
431
- if (canaryPath !== undefined) {
446
+ if (canaryPath !== undefined)
432
447
  extraArgs.push(`--timeout=${canaryTimeoutSeconds(projectConfig)}`);
433
- }
434
448
  // --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
435
449
  // flags FireForge does not model directly (see the xpcshell appdir hint
436
450
  // above for why). Appended after --headless so mach sees
437
451
  // the FireForge-managed flags first and the escape-valve ones last, which
438
452
  // keeps the override precedence predictable.
439
- if (forwardedMachArgs.length > 0) {
453
+ if (forwardedMachArgs.length > 0)
440
454
  extraArgs.push(...forwardedMachArgs);
441
- }
442
455
  appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
443
456
  // Directory arguments mean exactly that directory: mozbuild's test
444
457
  // resolver matches paths by string prefix, so a bare directory arg
@@ -465,7 +478,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
465
478
  // the manifest for each file individually. See src/core/xpcshell-appdir.ts
466
479
  // for the full motivation.
467
480
  logTestSelection(scopes);
468
- const perfSampleEnv = buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples);
481
+ const harnessEnv = mergeHarnessEnv(buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples), shuffleSeed === undefined ? undefined : { FIREFORGE_SHUFFLE_SEED: String(shuffleSeed) });
469
482
  const runCtx = {
470
483
  engineDir: paths.engine,
471
484
  objDir: buildCheck.objDir,
@@ -475,7 +488,8 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
475
488
  harnessRetries,
476
489
  headless: options.headless === true,
477
490
  ...(options.fullOutput === true ? { fullOutput: true } : {}),
478
- ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
491
+ ...(harnessEnv ? { env: harnessEnv } : {}),
492
+ pgidFile: options.pgidFile,
479
493
  };
480
494
  const postRebuildContext = options.build
481
495
  ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
@@ -527,16 +541,3 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
527
541
  }
528
542
  finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
529
543
  }
530
- /**
531
- * Builds the perf-sample env contract for the harness run:
532
- * `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
533
- * artifact file a budget checker consumes after the run.
534
- */
535
- function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
536
- if (!perfSamples)
537
- return undefined;
538
- const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
539
- const artifactPath = resolve(projectRoot, perfSamples);
540
- info(`Perf sample contract: ${envName}=${artifactPath}`);
541
- return { [envName]: artifactPath };
542
- }
@@ -28,7 +28,6 @@
28
28
  * parse a manifest escalates: this narrows a conservative rule, so its
29
29
  * error direction stays the slow-but-correct one.
30
30
  */
31
- import type { BuildBaseline } from './build-baseline-types.js';
32
31
  /** Why one changed `jar.mn` still forces a full build. */
33
32
  export interface JarEscalationCause {
34
33
  /** Engine-relative manifest path. */
@@ -51,11 +50,9 @@ export interface JarEscalationDecision {
51
50
  * @param engineDir - Absolute engine directory
52
51
  * @param changedPaths - Engine-relative paths changed since the last
53
52
  * successful build (non-`jar.mn` entries are ignored)
54
- * @param baseline - Last successful build's baseline (reserved: the
55
- * fingerprint map already gated which paths reach here)
56
53
  * @returns The escalation decision with per-manifest causes
57
54
  */
58
- export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[], baseline: BuildBaseline | undefined): Promise<JarEscalationDecision>;
55
+ export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[]): Promise<JarEscalationDecision>;
59
56
  /**
60
57
  * Renders the operator-facing escalation notice. Names the manifest and the
61
58
  * reason: "a jar.mn changed" is the sentence that made the previous rule
@@ -68,12 +68,9 @@ async function isNewJarManifest(engineDir, path) {
68
68
  * @param engineDir - Absolute engine directory
69
69
  * @param changedPaths - Engine-relative paths changed since the last
70
70
  * successful build (non-`jar.mn` entries are ignored)
71
- * @param baseline - Last successful build's baseline (reserved: the
72
- * fingerprint map already gated which paths reach here)
73
71
  * @returns The escalation decision with per-manifest causes
74
72
  */
75
- export async function evaluateJarManifestEscalation(engineDir, changedPaths, baseline) {
76
- void baseline;
73
+ export async function evaluateJarManifestEscalation(engineDir, changedPaths) {
77
74
  const causes = [];
78
75
  const cleared = [];
79
76
  for (const path of changedPaths.filter(isJarManifestPath)) {
@@ -16,6 +16,7 @@ import { evaluateJarManifestEscalation, formatJarEscalationNotice, } from './bui
16
16
  import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
17
17
  import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
18
18
  import { applyAllComponents } from './furnace-apply.js';
19
+ import { logApplyWarnings } from './furnace-apply-output.js';
19
20
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
20
21
  import { runFurnaceMutation } from './furnace-operation.js';
21
22
  import { countEntriesWithBlockingStepErrors } from './furnace-step-errors.js';
@@ -114,15 +115,14 @@ function buildConfigureFailureError(captured) {
114
115
  *
115
116
  * @param engineDir - Absolute engine directory
116
117
  * @param changed - Build inputs changed since the last successful build
117
- * @param baseline - Last successful build's baseline
118
118
  */
119
- async function decideJarEscalation(engineDir, changed, baseline) {
119
+ async function decideJarEscalation(engineDir, changed) {
120
120
  // Packaging manifests whose graph/destination directories may need a full
121
121
  // build. Path-shape only: `evaluateJarManifestEscalation` decides whether a
122
122
  // given changed manifest actually forces one (a new manifest, or one that
123
123
  // redirects its install base directory), so an entry added to an existing
124
124
  // `dist/bin` manifest no longer costs a ~10-minute build.
125
- const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(isJarManifestPath), baseline);
125
+ const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(isJarManifestPath));
126
126
  if (decision.cleared.length > 0) {
127
127
  verbose(`jar escalation: ${decision.cleared.length} changed jar.mn add entries to an existing dist/bin manifest; not escalating for ${decision.cleared.join(', ')}.`);
128
128
  }
@@ -164,6 +164,73 @@ async function reportUnexportedDriftBeforeOverwrite(projectRoot, config, options
164
164
  }
165
165
  notice(message);
166
166
  }
167
+ /**
168
+ * Applies Furnace components before the build when `furnace.json` exists.
169
+ * Prints every warning the apply computed, then the loud sync banner.
170
+ *
171
+ * @param projectRoot - Project root
172
+ * @returns Number of components written to engine/
173
+ */
174
+ async function applyFurnaceBeforeBuild(projectRoot) {
175
+ if (!(await furnaceConfigExists(projectRoot)))
176
+ return 0;
177
+ const furnaceConfig = await loadFurnaceConfig(projectRoot);
178
+ const hasComponents = Object.keys(furnaceConfig.overrides).length > 0 || Object.keys(furnaceConfig.custom).length > 0;
179
+ if (!hasComponents)
180
+ return 0;
181
+ const furnaceSpinner = spinner('Applying Furnace components...');
182
+ let result;
183
+ try {
184
+ result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
185
+ }
186
+ catch (error) {
187
+ furnaceSpinner.error('Failed to apply Furnace components');
188
+ throw error;
189
+ }
190
+ const furnaceApplied = result.applied.length;
191
+ // Count entries that were "applied" but recorded step-level errors
192
+ // mid-apply (e.g. a post-step failure after file writes succeeded).
193
+ // These are distinct from `result.errors`, which captures
194
+ // components that failed before reaching the applied list at all.
195
+ // The sum of the two is the total count of failed components.
196
+ const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
197
+ const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
198
+ if (totalApplyFailures > 0) {
199
+ furnaceSpinner.error('Failed to apply Furnace components');
200
+ for (const err of result.errors) {
201
+ warn(`Furnace: ${err.name} — ${err.error}`);
202
+ }
203
+ for (const applied of result.applied) {
204
+ if (applied.stepErrors && applied.stepErrors.length > 0) {
205
+ for (const stepErr of applied.stepErrors) {
206
+ warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
207
+ }
208
+ }
209
+ }
210
+ throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
211
+ }
212
+ // The apply computed these (patch-owned engine files whose content
213
+ // differed from components/ and were replaced). `furnace apply`
214
+ // prints them; this path used to drop them, so a negative control
215
+ // that edited a deployed Furnace file ran against the shipped code
216
+ // with a green result and only a checksum could tell.
217
+ const overwriteCount = logApplyWarnings(result.warnings);
218
+ if (overwriteCount > 0) {
219
+ notice(`Furnace: ${overwriteCount} Furnace-managed engine file${overwriteCount === 1 ? '' : 's'} differed from components/ and ${overwriteCount === 1 ? 'was' : 'were'} REPLACED before this build. An engine-side edit to a Furnace-managed file (for example a negative control) did not run; edit the component source under components/ instead, or restore the edit after the build.`);
220
+ }
221
+ if (furnaceApplied > 0) {
222
+ const appliedNames = result.applied.map((entry) => entry.name).join(', ');
223
+ furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
224
+ // Loud banner: the build operator needs to see that engine/ was
225
+ // updated before this build, otherwise a silent re-apply is
226
+ // indistinguishable from a build that shipped stale components.
227
+ notice(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
228
+ }
229
+ else {
230
+ furnaceSpinner.stop('Components up to date');
231
+ }
232
+ return furnaceApplied;
233
+ }
167
234
  /**
168
235
  * Runs the shared pre-flight steps for build and package commands:
169
236
  * 1. Warns (or refuses) about unexported engine drift the writes below
@@ -202,7 +269,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
202
269
  if (options.previousBaseline) {
203
270
  const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
204
271
  const invalidating = changed.filter(isBackendInvalidatingFile);
205
- const jarEscalation = await decideJarEscalation(paths.engine, changed, options.previousBaseline);
272
+ const jarEscalation = await decideJarEscalation(paths.engine, changed);
206
273
  fullBuildRequired = jarEscalation !== undefined;
207
274
  fullBuildReason = jarEscalation;
208
275
  if (invalidating.length > 0) {
@@ -263,57 +330,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
263
330
  throw error;
264
331
  }
265
332
  }
266
- // Apply Furnace components if furnace.json exists
267
- let furnaceApplied = 0;
268
- if (await furnaceConfigExists(projectRoot)) {
269
- const furnaceConfig = await loadFurnaceConfig(projectRoot);
270
- const hasComponents = Object.keys(furnaceConfig.overrides).length > 0 ||
271
- Object.keys(furnaceConfig.custom).length > 0;
272
- if (hasComponents) {
273
- const furnaceSpinner = spinner('Applying Furnace components...');
274
- let result;
275
- try {
276
- result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
277
- }
278
- catch (error) {
279
- furnaceSpinner.error('Failed to apply Furnace components');
280
- throw error;
281
- }
282
- furnaceApplied = result.applied.length;
283
- // Count entries that were "applied" but recorded step-level errors
284
- // mid-apply (e.g. a post-step failure after file writes succeeded).
285
- // These are distinct from `result.errors`, which captures
286
- // components that failed before reaching the applied list at all.
287
- // The sum of the two is the total count of failed components.
288
- const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
289
- const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
290
- if (totalApplyFailures > 0) {
291
- furnaceSpinner.error('Failed to apply Furnace components');
292
- for (const err of result.errors) {
293
- warn(`Furnace: ${err.name} — ${err.error}`);
294
- }
295
- for (const applied of result.applied) {
296
- if (applied.stepErrors && applied.stepErrors.length > 0) {
297
- for (const stepErr of applied.stepErrors) {
298
- warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
299
- }
300
- }
301
- }
302
- throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
303
- }
304
- if (furnaceApplied > 0) {
305
- const appliedNames = result.applied.map((entry) => entry.name).join(', ');
306
- furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
307
- // Loud banner: the build operator needs to see that engine/ was
308
- // updated before this build, otherwise a silent re-apply is
309
- // indistinguishable from a build that shipped stale components.
310
- notice(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
311
- }
312
- else {
313
- furnaceSpinner.stop('Components up to date');
314
- }
315
- }
316
- }
333
+ const furnaceApplied = await applyFurnaceBeforeBuild(projectRoot);
317
334
  // Generate mozconfig
318
335
  const mozconfigSpinner = spinner('Generating mozconfig...');
319
336
  try {
@@ -7,7 +7,7 @@ export declare const FIREFORGE_DIR = ".fireforge";
7
7
  /** Supported top-level fireforge.json keys backed by the current schema. */
8
8
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "buildAudit", "test", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
9
9
  /** Supported config paths that can be read or set without --force. */
10
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
10
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
11
11
  /**
12
12
  * Gets all project paths based on a root directory.
13
13
  * @param root - Root directory of the project
@@ -53,6 +53,7 @@ export const SUPPORTED_CONFIG_PATHS = [
53
53
  'test',
54
54
  'test.canaryPath',
55
55
  'test.canaryTimeoutSeconds',
56
+ 'test.reapOrphans',
56
57
  'externalToolchains',
57
58
  'wire',
58
59
  'wire.subscriptDir',
@@ -45,6 +45,13 @@ export function parseTestBlock(rec, config) {
45
45
  }
46
46
  out.canaryTimeoutSeconds = canaryTimeout;
47
47
  }
48
+ const reapOrphans = testRec.raw('reapOrphans');
49
+ if (reapOrphans !== undefined) {
50
+ if (reapOrphans !== 'report' && reapOrphans !== 'reap') {
51
+ throw new ConfigError('Config field "test.reapOrphans" must be "report" or "reap"');
52
+ }
53
+ out.reapOrphans = reapOrphans;
54
+ }
48
55
  config.test = out;
49
56
  }
50
57
  /** Parses optional project-specific external toolchain doctor probes. */
@@ -433,15 +433,13 @@ export async function withFileLock(lockPath, operation, options = {}) {
433
433
  continue;
434
434
  }
435
435
  }
436
- if (Date.now() >= deadline) {
437
- // The queue is read here, not carried over from the last progress
438
- // probe: the position the wait reached is the one number that says
439
- // whether a larger budget would have helped, and it must not depend
440
- // on a probe having happened to land inside the budget. We are still
441
- // registered at this point. `deregisterWaiter` runs in the `finally`
442
- // below, which is only entered once this refusal has been built.
443
- throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
444
- }
436
+ // The probe runs before the deadline check, so a due progress line is
437
+ // written before the refusal that follows it, never lost to it. On a
438
+ // slow host (a cold Windows CI runner) the first iteration alone, with
439
+ // its waiter registration and stale probe, can outlast a short budget;
440
+ // checked the other way round, the wait timed out having reported
441
+ // nothing, and an advance that probe would have seen could not extend
442
+ // the deadline it was about to miss.
445
443
  if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
446
444
  lastProbeAt = Date.now();
447
445
  // Report the budget that is currently in force, not the one
@@ -473,6 +471,15 @@ export async function withFileLock(lockPath, operation, options = {}) {
473
471
  }
474
472
  }
475
473
  }
474
+ if (Date.now() >= deadline) {
475
+ // The queue is read here, not carried over from the last progress
476
+ // probe: the position the wait reached is the one number that says
477
+ // whether a larger budget would have helped, and it must not depend
478
+ // on a probe having happened to land inside the budget. We are still
479
+ // registered at this point. `deregisterWaiter` runs in the `finally`
480
+ // below, which is only entered once this refusal has been built.
481
+ throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
482
+ }
476
483
  await sleep(currentPollMs);
477
484
  if (options.pollMaxMs !== undefined) {
478
485
  currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);