@hominis/fireforge 0.47.0 → 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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/bin/fireforge.js +6 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/test-harness-teardown.d.ts +62 -0
  5. package/dist/src/commands/test-harness-teardown.js +128 -0
  6. package/dist/src/commands/test-register.js +2 -1
  7. package/dist/src/commands/test-run.d.ts +2 -0
  8. package/dist/src/commands/test-run.js +36 -7
  9. package/dist/src/commands/test-verdict.d.ts +19 -4
  10. package/dist/src/commands/test-verdict.js +22 -4
  11. package/dist/src/commands/test.js +18 -10
  12. package/dist/src/core/config-paths.d.ts +1 -1
  13. package/dist/src/core/config-paths.js +1 -0
  14. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  15. package/dist/src/core/file-lock.js +16 -9
  16. package/dist/src/core/harness-helper-pids.d.ts +98 -0
  17. package/dist/src/core/harness-helper-pids.js +240 -0
  18. package/dist/src/core/harness-orphans.d.ts +53 -8
  19. package/dist/src/core/harness-orphans.js +125 -44
  20. package/dist/src/core/mach.d.ts +16 -0
  21. package/dist/src/core/mach.js +6 -1
  22. package/dist/src/core/marionette-preflight.js +9 -3
  23. package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
  24. package/dist/src/core/parent-exit-watchdog.js +69 -0
  25. package/dist/src/types/commands/options.d.ts +11 -1
  26. package/dist/src/types/config.d.ts +7 -0
  27. package/dist/src/utils/process-group.d.ts +5 -1
  28. package/dist/src/utils/process-group.js +7 -3
  29. package/dist/src/utils/process.d.ts +18 -0
  30. package/dist/src/utils/process.js +14 -2
  31. package/docs/configuration.md +7 -1
  32. package/docs/lifecycle-invariants.md +17 -1
  33. package/docs/machine-output.md +22 -9
  34. package/docs/testing.md +68 -4
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -26,8 +26,19 @@
26
26
 
27
27
  ### Test harness
28
28
 
29
+ - `fireforge test` owns the kill path of every mach dispatch. The harness announces what it launches (`runtests.py | Server pid: <n>`, the websocket server, the SSL tunnel, the websocket/process bridge, the browser as `Application pid`); FireForge tracks those pids and, after mach has exited for any reason (clean finish, harness crash before a retry, no-output timeout, forwarded SIGTERM), terminates any still alive, after re-reading each from `ps` and requiring it to still be anchored to this objdir and still be the thing that was launched. One helper is never announced (mozserve starts `moz-http2` in its own session and logs no pid); for it FireForge snapshots the same-objdir helpers before the dispatch and reaps any helper-shaped process under this checkout's absolute objdir that is new since the snapshot and reparented, so an earlier run's survivor stays the census's call. The reap runs inside the exec layer's close path so the signal handler's child-shutdown wait covers it. Before this, a run killed at its bound could leave the mochitest httpd reparented to launchd at 100% CPU for hours (2026-09-13), and every later run on the objdir competed with it. The group sweep already existed but never reached the browser, which mozprocess puts in a group of its own.
30
+ - New parent-exit watchdog. A supervisor that SIGKILLs the process above `fireforge test` signals nothing below it: FireForge's stdout pipe merely breaks, the EPIPE handler swallows that on purpose, and the detached mach tree runs on unsupervised. That was the actual shape of the incident. FireForge now polls its own parent while a dispatch runs; when the parent is gone it writes `FIREFORGE-VERDICT: FAIL reason=killed signal=parent-exit`, reaps the mach process group and the tracked helpers, and ends the run instead of classifying the killed harness as a crash and retrying it. Off on Windows.
31
+ - `test.reapOrphans: "report" | "reap"` in `fireforge.json` sets the repo's posture for the preflight orphan census; `--reap-orphans` remains the per-invocation form. Default `report`, unchanged behaviour. The reaper counts only processes confirmed gone after its SIGTERM → grace → SIGKILL sequence, and warns about one that is still alive after SIGKILL.
32
+ - The census now matches a helper structurally (the executable is a helper binary, or an interpreter whose script argument is a helper script) and never considers an ancestor of FireForge's own process. It used to match the helper names anywhere in a command line, which also caught an editor opened on `server.js`, a `grep` over the objdir, or the operator's own shell when its command text quoted the path; report-only that printed a misleading `kill`, and under the new reap posture it terminated the shell running FireForge (found while proving the posture on 2026-09-13).
33
+ - Fixed `fireforge test --doctor` exiting 0 with no `Marionette preflight:` result and no verdict line. The preflight's waits (the spawn settle window, the 400 ms poll gap, the SIGTERM→SIGKILL escalation gap) were unref'd around a detached browser child, so whenever the child was the only other thing on the event loop Node exited mid-probe. Both the working tree and the pinned 0.47.0 reproduced it; the waits are now ref'd, all of them bounded.
34
+ - New `fireforge test --pgid-file <path>` publishes the harness process-group id (mach is the group leader) on every spawn and removes the file when the run ends under FireForge's control. A supervisor that had to SIGKILL FireForge takes mach and every helper in its group afterwards with `kill -- -$(cat <path>)`. `docs/testing.md` now states what was true but unwritten: mach runs in its own process group, so a supervisor killing its own group never reaches it.
35
+ - New additive verdict key `orphans-reaped=<n>`: the sum of what the preflight census and the teardown reaps terminated, across attempts and shards. Absent when nothing was reaped, so a green on a quiet machine prints the line it always did and a green after a reap is distinguishable from it. A line written by the signal handler or the parent-exit watchdog precedes the teardown reap and cannot carry it; that reap reports on stderr.
29
36
  - New `fireforge test --shuffle [seed]` runs a mochitest selection in a seeded random file order: it forwards mach's `--shuffle`, exports `FIREFORGE_SHUFFLE_SEED=<seed>` to the harness (a fresh seed when omitted), prints the seed, and stamps `shuffle=<seed>` on the `FIREFORGE-VERDICT` line as an additive key. Mach's own shuffle is an unseeded `Math.random` pass, so a red found by shuffling could not be replayed. Reordering tasks inside a file is harness code that reads the exported seed. Mochitest-only: an xpcshell-only or generic dispatch is refused.
30
37
 
38
+ ### Locks
39
+
40
+ - A contended wait writes its due progress line before it declares the timeout, not after. The deadline check ran first, so on a slow host (a cold Windows CI runner) whose first poll iteration alone outlasted a short budget, the wait timed out having reported nothing about the holder, and an advance that probe would have seen could not extend the deadline it was about to miss. `smoke (windows-latest)` failed on exactly this in `file-lock.test.ts`.
41
+
31
42
  ## 0.46.0
32
43
 
33
44
  ### Packaging audit
@@ -8,6 +8,7 @@
8
8
  * docs/lifecycle-invariants.md for who owns what.
9
9
  */
10
10
  import { installBrokenPipeHandler, main } from '../src/cli.js';
11
+ import { removeActivePgidFile } from '../src/commands/test-harness-teardown.js';
11
12
  import { emitKilledVerdict } from '../src/commands/test-verdict.js';
12
13
  import { forceReleaseHeldLocksForSignal } from '../src/core/file-lock.js';
13
14
  import { forceReleaseFurnaceLocksForActiveOperations, isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
@@ -108,6 +109,11 @@ function installFurnaceSignalHandler(signal, exitCode) {
108
109
  // racing this handler and usually loses. Best-effort like the log
109
110
  // itself: a diagnostic must never keep the process from exiting.
110
111
  .then(() => closeActiveRunLog().catch(() => undefined))
112
+ // `--pgid-file` names a group this handler has just signalled; after a
113
+ // normal exit `testCommand`'s finally removes it, but that finally
114
+ // loses the race to the exit below. Only a kill FireForge never saw
115
+ // may leave the file behind.
116
+ .then(() => removeActivePgidFile().catch(() => undefined))
111
117
  .finally(() => {
112
118
  exitAfterStdioFlush(exitCode);
113
119
  });
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.47.0",
4
- "commit": "2bffcb5fee31d398a521874851af09a62134ae10",
5
- "shortCommit": "2bffcb5fee31",
3
+ "version": "0.47.1",
4
+ "commit": "11b30621b46142df7a8035ae7b9e8a13936787bb",
5
+ "shortCommit": "11b30621b461",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-09-12T14:05:35.561Z"
8
+ "buildTime": "2026-09-13T19:20:10.389Z"
9
9
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Owns the kill path of one mach test dispatch for `fireforge test`.
3
+ *
4
+ * Three things the run must do around every `runMachTestSuite` call, kept
5
+ * together because each one exists for the same incident (a supervisor
6
+ * killed the process above FireForge at its bound and the harness httpd it
7
+ * had launched spun for hours):
8
+ *
9
+ * 1. Track the helper pids the harness announces, and after mach has
10
+ * exited terminate any that outlived it (`harness-helper-pids.ts`).
11
+ * 2. Publish the harness process group (`--pgid-file`), so a supervisor
12
+ * that can only SIGKILL FireForge can still take the whole tree with
13
+ * one `kill -- -<pgid>` afterwards.
14
+ * 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
15
+ * vanishes mid-run, write the killed verdict, reap the group, and let
16
+ * the dispatch loop end the run instead of retrying.
17
+ *
18
+ * Every reap adds to the `orphans-reaped=` verdict attribute.
19
+ */
20
+ import type { MachTestSuiteOptions } from '../core/mach.js';
21
+ /** Inputs for {@link createHarnessTeardown}. */
22
+ export interface HarnessTeardownInputs {
23
+ /** Absolute objdir of this project, when known: the anchor for every kill. */
24
+ objDir: string | undefined;
25
+ /** `--pgid-file`: where to publish the harness process group id. */
26
+ pgidFile: string | undefined;
27
+ }
28
+ /** One dispatch's teardown: the hooks to spread into `runMachTestSuite`, and its lifecycle. */
29
+ export interface HarnessTeardown {
30
+ /** Hooks for {@link MachTestSuiteOptions.teardown}. */
31
+ hooks: NonNullable<MachTestSuiteOptions['teardown']>;
32
+ /**
33
+ * Records the same-objdir helpers alive right now, before the dispatch
34
+ * spawns anything, so the post-close reap can tell this dispatch's
35
+ * unannounced helpers from an earlier run's survivors. Await it before
36
+ * `runMachTestSuite`.
37
+ */
38
+ prepare(): Promise<void>;
39
+ /** Stops the parent watchdog. Call after the dispatch resolved or threw. */
40
+ dispose(): void;
41
+ }
42
+ /**
43
+ * Builds the teardown for one dispatch (one retry attempt of one shard).
44
+ * The pid tracker and the group id are per dispatch; the parent watchdog
45
+ * runs only while that dispatch is in flight.
46
+ */
47
+ export declare function createHarnessTeardown(inputs: HarnessTeardownInputs): HarnessTeardown;
48
+ /** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
49
+ export declare function setActivePgidFile(path: string | undefined): void;
50
+ /**
51
+ * Removes the pgid file at the end of a run that ended under FireForge's
52
+ * control. A file left behind after a normal exit would name a group id
53
+ * the kernel may have reused; the file must only outlive FireForge when
54
+ * FireForge itself was killed without notice, which is what it is for.
55
+ */
56
+ export declare function removePgidFile(path: string | undefined): Promise<void>;
57
+ /**
58
+ * Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
59
+ * file, if one is registered. Called by the bin entry point after the child
60
+ * shutdown wait, so the group the file names has already been signalled.
61
+ */
62
+ export declare function removeActivePgidFile(): Promise<void>;
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Owns the kill path of one mach test dispatch for `fireforge test`.
4
+ *
5
+ * Three things the run must do around every `runMachTestSuite` call, kept
6
+ * together because each one exists for the same incident (a supervisor
7
+ * killed the process above FireForge at its bound and the harness httpd it
8
+ * had launched spun for hours):
9
+ *
10
+ * 1. Track the helper pids the harness announces, and after mach has
11
+ * exited terminate any that outlived it (`harness-helper-pids.ts`).
12
+ * 2. Publish the harness process group (`--pgid-file`), so a supervisor
13
+ * that can only SIGKILL FireForge can still take the whole tree with
14
+ * one `kill -- -<pgid>` afterwards.
15
+ * 3. Watch FireForge's own parent (`parent-exit-watchdog.ts`). When it
16
+ * vanishes mid-run, write the killed verdict, reap the group, and let
17
+ * the dispatch loop end the run instead of retrying.
18
+ *
19
+ * Every reap adds to the `orphans-reaped=` verdict attribute.
20
+ */
21
+ import { unlink, writeFile } from 'node:fs/promises';
22
+ import { createHelperPidTracker, reapTrackedHarnessPids, snapshotObjdirHelpers, } from '../core/harness-helper-pids.js';
23
+ import { startParentExitWatchdog } from '../core/parent-exit-watchdog.js';
24
+ import { toError } from '../utils/errors.js';
25
+ import { verbose, warn } from '../utils/logger.js';
26
+ import { sweepProcessGroup } from '../utils/process-group.js';
27
+ import { addVerdictRunCount, emitKilledVerdict } from './test-verdict.js';
28
+ /**
29
+ * Builds the teardown for one dispatch (one retry attempt of one shard).
30
+ * The pid tracker and the group id are per dispatch; the parent watchdog
31
+ * runs only while that dispatch is in flight.
32
+ */
33
+ export function createHarnessTeardown(inputs) {
34
+ const tracker = createHelperPidTracker();
35
+ let pgid;
36
+ let baseline;
37
+ const watchdog = startParentExitWatchdog({
38
+ onParentExit: ({ originalPpid, currentPpid }) => {
39
+ // Verdict first, before anything that can stall: the parent that would
40
+ // have read it is gone, but the run log and any tee of stdout are not.
41
+ emitKilledVerdict('parent-exit');
42
+ warn(`Parent process ${originalPpid} exited while the harness was running (this process now ` +
43
+ `reports parent ${currentPpid}); ending the run and terminating the harness tree so it ` +
44
+ 'cannot run on unsupervised.');
45
+ if (pgid !== undefined) {
46
+ void sweepProcessGroup(pgid, undefined, 'after the parent process exited').catch((error) => {
47
+ warn(`Reaping harness group ${pgid} after parent exit failed: ${toError(error).message}`);
48
+ });
49
+ }
50
+ },
51
+ });
52
+ return {
53
+ hooks: {
54
+ onOutputChunk: (stream, chunk) => {
55
+ tracker.feed(stream, chunk);
56
+ },
57
+ onProcessGroup: (id) => {
58
+ pgid = id;
59
+ verbose(`Harness process group: ${id} (mach is the group leader).`);
60
+ if (inputs.pgidFile !== undefined)
61
+ void publishPgid(inputs.pgidFile, id);
62
+ },
63
+ postCloseSweep: async () => {
64
+ const reaped = await reapTrackedHarnessPids(tracker.tracked(), inputs.objDir, baseline);
65
+ addVerdictRunCount('orphans-reaped', reaped);
66
+ },
67
+ },
68
+ prepare: async () => {
69
+ baseline = await snapshotObjdirHelpers(inputs.objDir);
70
+ },
71
+ dispose: () => {
72
+ watchdog.stop();
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Writes `<pgid>\n` to the pgid file. Best-effort: a supervisor that asked
78
+ * for the file and cannot get it is warned, and the run proceeds, because
79
+ * the file is a courtesy to the supervisor and not a precondition of the
80
+ * tests.
81
+ */
82
+ async function publishPgid(path, pgid) {
83
+ try {
84
+ await writeFile(path, `${pgid}\n`, 'utf8');
85
+ }
86
+ catch (error) {
87
+ warn(`Could not write --pgid-file ${path}: ${toError(error).message}`);
88
+ }
89
+ }
90
+ /**
91
+ * The pgid file of the run in flight, for the signal pipeline. `testCommand`'s
92
+ * own `finally` removes the file on every path it controls, but on SIGTERM
93
+ * that `finally` races the bin handler's `process.exit` and loses, and a
94
+ * SIGTERM is notice: the file must not outlive a run FireForge itself
95
+ * ended. Set when the run starts, cleared by {@link removePgidFile}.
96
+ */
97
+ let activePgidFile;
98
+ /** Records the run's pgid file so {@link removeActivePgidFile} can find it from the signal handler. */
99
+ export function setActivePgidFile(path) {
100
+ activePgidFile = path;
101
+ }
102
+ /**
103
+ * Removes the pgid file at the end of a run that ended under FireForge's
104
+ * control. A file left behind after a normal exit would name a group id
105
+ * the kernel may have reused; the file must only outlive FireForge when
106
+ * FireForge itself was killed without notice, which is what it is for.
107
+ */
108
+ export async function removePgidFile(path) {
109
+ if (path === undefined)
110
+ return;
111
+ if (activePgidFile === path)
112
+ activePgidFile = undefined;
113
+ try {
114
+ await unlink(path);
115
+ }
116
+ catch (error) {
117
+ // ENOENT is the usual case when no dispatch ever spawned.
118
+ verbose(`--pgid-file ${path} not removed: ${toError(error).message}`);
119
+ }
120
+ }
121
+ /**
122
+ * Signal-pipeline variant of {@link removePgidFile}: removes the run's pgid
123
+ * file, if one is registered. Called by the bin entry point after the child
124
+ * shutdown wait, so the group the file names has already been signalled.
125
+ */
126
+ export async function removeActivePgidFile() {
127
+ await removePgidFile(activePgidFile);
128
+ }
@@ -72,7 +72,8 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
72
72
  .option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
73
73
  .option('--allow-stale-components', 'Run tests despite components.conf changes that only a full "fireforge build" compiles in (the packaged child process will resolve the OLD StaticComponents table)')
74
74
  .option('--kill-stale-marionette', "Terminate a recognized stale browser from this objdir, one holding the Marionette port, or this checkout's own leftover mochitest httpd before running tests. Never touches another checkout's live harness.")
75
- .option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag opts into the kill.')
75
+ .option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag (or test.reapOrphans: "reap" in fireforge.json) opts into the kill. Reaps are stamped on the verdict line as orphans-reaped=<n>.')
76
+ .option('--pgid-file <path>', 'Write the harness process-group id (mach is the group leader) to this file on every spawn, so a supervisor that had to SIGKILL fireforge can still take the whole harness tree with "kill -- -$(cat <path>)". Removed when the run ends normally.')
76
77
  .option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
77
78
  .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
78
79
  .option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
@@ -45,6 +45,8 @@ export interface TestRunContext {
45
45
  * the coding-agent markers that quiet it. Off by default.
46
46
  */
47
47
  fullOutput?: boolean;
48
+ /** `--pgid-file`: where each dispatch publishes its harness process group id. */
49
+ pgidFile?: string | undefined;
48
50
  }
49
51
  /** Outcome of one (possibly retried) harness invocation. */
50
52
  export interface TestRunOutcome {
@@ -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,8 +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';
15
17
  /** Records an additive attribute for this run's verdict line. */
16
- export declare function setVerdictRunAttribute(key: 'shuffle', value: string): void;
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;
17
25
  /**
18
26
  * Re-arms the verdict sink for a new run. Called at `testCommand` entry so
19
27
  * repeated programmatic invocations (and tests) each get their own
@@ -49,7 +57,14 @@ export declare function emitPassVerdict(): void;
49
57
  */
50
58
  export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: string): void;
51
59
  /**
52
- * 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.
53
68
  *
54
69
  * Without it a killed run wrote no terminal line, so a log tail could not
55
70
  * distinguish "killed" from "still running" from "never started". Recovery
@@ -59,7 +74,7 @@ export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: s
59
74
  * line asserts a test run happened, and an interrupted `status` did not
60
75
  * have one.
61
76
  *
62
- * @param signal - The signal that terminated the run
77
+ * @param signal - The signal that terminated the run, or `parent-exit`
63
78
  * @returns True when a line was written
64
79
  */
65
- export declare function emitKilledVerdict(signal: NodeJS.Signals): boolean;
80
+ export declare function emitKilledVerdict(signal: KilledVerdictCause): boolean;
@@ -7,15 +7,32 @@ let emitted = false;
7
7
  * Additive `key=value` attributes of the run in flight, appended to
8
8
  * whichever verdict line ends it. `shuffle` is the seed a `--shuffle` run
9
9
  * exported to the harness, so a red found by shuffling carries its own
10
- * reproduction on the one line an unattended reader keeps. Cleared by
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
11
15
  * {@link resetVerdictEmission}, so a later run in the same process cannot
12
- * inherit it.
16
+ * inherit them.
13
17
  */
14
18
  const runAttributes = new Map();
15
19
  /** Records an additive attribute for this run's verdict line. */
16
20
  export function setVerdictRunAttribute(key, value) {
17
21
  runAttributes.set(key, value);
18
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
+ }
19
36
  /**
20
37
  * True once a test run has begun in this process.
21
38
  *
@@ -96,7 +113,8 @@ export function emitFailVerdict(reason, note) {
96
113
  writeVerdictLine(`FIREFORGE-VERDICT: FAIL reason=${reason}${suffix}`);
97
114
  }
98
115
  /**
99
- * 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.
100
118
  *
101
119
  * Without it a killed run wrote no terminal line, so a log tail could not
102
120
  * distinguish "killed" from "still running" from "never started". Recovery
@@ -106,7 +124,7 @@ export function emitFailVerdict(reason, note) {
106
124
  * line asserts a test run happened, and an interrupted `status` did not
107
125
  * have one.
108
126
  *
109
- * @param signal - The signal that terminated the run
127
+ * @param signal - The signal that terminated the run, or `parent-exit`
110
128
  * @returns True when a line was written
111
129
  */
112
130
  export function emitKilledVerdict(signal) {
@@ -22,9 +22,10 @@ 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
24
  import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
25
+ import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
25
26
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
26
27
  import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
27
- import { emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
28
+ import { addVerdictRunCount, emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
28
29
  async function assertTestPathsExist(engineDir, testPaths) {
29
30
  const missingPaths = [];
30
31
  for (const testPath of testPaths) {
@@ -227,7 +228,7 @@ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip
227
228
  }
228
229
  await assertMarionettePortAvailable(port, { binaryName });
229
230
  }
230
- async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, binaryName, options, objDir) {
231
+ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, projectConfig, options, objDir) {
231
232
  // A timed-out mochitest can leave the built app alive after its Marionette
232
233
  // listener has disappeared. The port probe cannot see that case, but the
233
234
  // survivor can still steal focus and wedge every later headed run.
@@ -249,14 +250,18 @@ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellO
249
250
  // Helpers that outlived an earlier run (httpd, pywebsocket, ssltunnel,
250
251
  // moz-http2) slow every later run without appearing in its output. Both
251
252
  // harnesses are affected, so this census is not gated on xpcshellOnly.
252
- await reportOrphanedHarnessProcesses(objDir, {
253
- 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',
254
258
  });
259
+ addVerdictRunCount('orphans-reaped', census.reaped);
255
260
  const forwardedPort = options.machArg
256
261
  ? extractForwardedMarionettePort(options.machArg)
257
262
  : undefined;
258
263
  const effectivePort = options.marionettePort ?? forwardedPort;
259
- await ensureTestMarionettePortAvailable(effectivePort, binaryName, options, {
264
+ await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
260
265
  xpcshellOnly,
261
266
  doctor: options.doctor === true,
262
267
  });
@@ -291,6 +296,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
291
296
  // exactly the runs whose only output a `tail` throws away. Closed in
292
297
  // `finally`, after the verdict line has already read the path.
293
298
  setActiveRunLog(await openRunLog(projectRoot, 'test'));
299
+ setActivePgidFile(options.pgidFile);
294
300
  try {
295
301
  await runTestCommandBody(projectRoot, testPaths, options);
296
302
  }
@@ -302,6 +308,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
302
308
  throw error;
303
309
  }
304
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);
305
314
  await closeActiveRunLog();
306
315
  }
307
316
  }
@@ -405,7 +414,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
405
414
  // documented `--mach-arg --marionette-port=NNNN` route still hits the
406
415
  // wrapper preflight refusing on 2828 before the forwarded arg reaches
407
416
  // mach.
408
- 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);
409
418
  if (options.doctor) {
410
419
  const doctorOutcome = await runDoctorPreflight({
411
420
  engineDir: paths.engine,
@@ -434,17 +443,15 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
434
443
  if (options.auto === true) {
435
444
  extraArgs.push('--auto');
436
445
  }
437
- if (canaryPath !== undefined) {
446
+ if (canaryPath !== undefined)
438
447
  extraArgs.push(`--timeout=${canaryTimeoutSeconds(projectConfig)}`);
439
- }
440
448
  // --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
441
449
  // flags FireForge does not model directly (see the xpcshell appdir hint
442
450
  // above for why). Appended after --headless so mach sees
443
451
  // the FireForge-managed flags first and the escape-valve ones last, which
444
452
  // keeps the override precedence predictable.
445
- if (forwardedMachArgs.length > 0) {
453
+ if (forwardedMachArgs.length > 0)
446
454
  extraArgs.push(...forwardedMachArgs);
447
- }
448
455
  appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
449
456
  // Directory arguments mean exactly that directory: mozbuild's test
450
457
  // resolver matches paths by string prefix, so a bare directory arg
@@ -482,6 +489,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
482
489
  headless: options.headless === true,
483
490
  ...(options.fullOutput === true ? { fullOutput: true } : {}),
484
491
  ...(harnessEnv ? { env: harnessEnv } : {}),
492
+ pgidFile: options.pgidFile,
485
493
  };
486
494
  const postRebuildContext = options.build
487
495
  ? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
@@ -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);