@hominis/fireforge 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/bin/fireforge.js +11 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  5. package/dist/src/commands/doctor-macos-sdk.js +156 -0
  6. package/dist/src/commands/doctor.js +2 -0
  7. package/dist/src/commands/download.js +64 -3
  8. package/dist/src/commands/import.js +5 -2
  9. package/dist/src/commands/lint-per-patch.js +44 -6
  10. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  11. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  12. package/dist/src/commands/lint.js +10 -0
  13. package/dist/src/commands/re-export-lint.d.ts +17 -2
  14. package/dist/src/commands/re-export-lint.js +33 -4
  15. package/dist/src/commands/re-export.js +4 -2
  16. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  17. package/dist/src/commands/rebase/confirm.js +6 -3
  18. package/dist/src/commands/rebase/index.js +77 -13
  19. package/dist/src/commands/run.js +43 -7
  20. package/dist/src/commands/status.js +5 -3
  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 +2 -1
  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 +19 -4
  27. package/dist/src/commands/test-verdict.js +22 -4
  28. package/dist/src/commands/test.js +18 -10
  29. package/dist/src/commands/typecheck.d.ts +5 -0
  30. package/dist/src/commands/typecheck.js +7 -1
  31. package/dist/src/core/build-prepare.d.ts +2 -0
  32. package/dist/src/core/build-prepare.js +67 -35
  33. package/dist/src/core/config-paths.d.ts +1 -1
  34. package/dist/src/core/config-paths.js +2 -0
  35. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  36. package/dist/src/core/config-validate.js +7 -0
  37. package/dist/src/core/discard-baseline.js +2 -1
  38. package/dist/src/core/file-lock.js +16 -9
  39. package/dist/src/core/forward-registrations.d.ts +33 -0
  40. package/dist/src/core/forward-registrations.js +16 -0
  41. package/dist/src/core/git-file-ops.d.ts +21 -0
  42. package/dist/src/core/git-file-ops.js +75 -0
  43. package/dist/src/core/git-status.d.ts +14 -0
  44. package/dist/src/core/git-status.js +41 -2
  45. package/dist/src/core/harness-helper-pids.d.ts +98 -0
  46. package/dist/src/core/harness-helper-pids.js +240 -0
  47. package/dist/src/core/harness-orphans.d.ts +53 -8
  48. package/dist/src/core/harness-orphans.js +125 -44
  49. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  50. package/dist/src/core/mach-mozconfig.js +2 -0
  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/parent-exit-watchdog.d.ts +62 -0
  55. package/dist/src/core/parent-exit-watchdog.js +69 -0
  56. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  57. package/dist/src/core/patch-apply-fuzz.js +27 -6
  58. package/dist/src/core/patch-apply.d.ts +13 -0
  59. package/dist/src/core/patch-apply.js +27 -2
  60. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  61. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  62. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  63. package/dist/src/core/patch-lint-checkjs.js +8 -1
  64. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  65. package/dist/src/core/patch-lint-cross.js +13 -12
  66. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  67. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  68. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  69. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  70. package/dist/src/core/patch-lint.d.ts +11 -0
  71. package/dist/src/core/patch-lint.js +20 -2
  72. package/dist/src/core/patch-transform.d.ts +14 -13
  73. package/dist/src/core/patch-transform.js +29 -17
  74. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  75. package/dist/src/core/rebase-dry-run.js +105 -0
  76. package/dist/src/core/run-profile.d.ts +50 -0
  77. package/dist/src/core/run-profile.js +131 -0
  78. package/dist/src/core/status-binary.d.ts +1 -1
  79. package/dist/src/core/status-binary.js +3 -1
  80. package/dist/src/core/status-classify.js +29 -2
  81. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  82. package/dist/src/core/toolchain-preflight.js +1 -1
  83. package/dist/src/core/typecheck.d.ts +11 -1
  84. package/dist/src/core/typecheck.js +85 -11
  85. package/dist/src/errors/rebase.d.ts +9 -0
  86. package/dist/src/errors/rebase.js +20 -0
  87. package/dist/src/index.d.ts +3 -1
  88. package/dist/src/index.js +2 -1
  89. package/dist/src/types/commands/options.d.ts +26 -1
  90. package/dist/src/types/config.d.ts +17 -0
  91. package/dist/src/utils/process-group.d.ts +5 -1
  92. package/dist/src/utils/process-group.js +7 -3
  93. package/dist/src/utils/process-input.d.ts +27 -0
  94. package/dist/src/utils/process-input.js +49 -0
  95. package/dist/src/utils/process.d.ts +20 -0
  96. package/dist/src/utils/process.js +16 -3
  97. package/dist/src/utils/validation.d.ts +11 -0
  98. package/dist/src/utils/validation.js +43 -0
  99. package/docs/configuration.md +88 -1
  100. package/docs/exit-codes.md +1 -1
  101. package/docs/lifecycle-invariants.md +21 -4
  102. package/docs/machine-output.md +22 -9
  103. package/docs/patch-workflow.md +35 -1
  104. package/docs/testing.md +68 -4
  105. package/package.json +7 -3
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Stdin-fed command execution with byte-exact stdout.
4
+ *
5
+ * Its own module rather than a function in `process.ts` only because that
6
+ * file sits on a 500-line budget. The logic belongs to the exec layer.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { buildChildEnv } from './child-env.js';
10
+ import { exitCodeFromClose } from './process.js';
11
+ /**
12
+ * Runs a command, writes `input` to its stdin, and returns stdout as raw
13
+ * bytes. For batch protocols (`git cat-file --batch`) whose framing counts
14
+ * bytes: the decoded, 50 MB-capped collector `exec` uses would corrupt
15
+ * both. The caller bounds the input, and so the output.
16
+ * @param command - Command to execute
17
+ * @param args - Command arguments
18
+ * @param input - Bytes written to stdin, which is then closed
19
+ * @param options - Execution options
20
+ */
21
+ export async function execWithInput(command, args, input, options = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(command, args, {
24
+ cwd: options.cwd,
25
+ env: buildChildEnv(options),
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ });
28
+ const out = [];
29
+ const err = [];
30
+ child.stdout.on('data', (chunk) => {
31
+ out.push(chunk);
32
+ });
33
+ child.stderr.on('data', (chunk) => {
34
+ err.push(chunk);
35
+ });
36
+ // A child that exits before reading all of stdin (a bad ref, say) makes
37
+ // the write fail with EPIPE. The close handler reports the real outcome.
38
+ child.stdin.on('error', () => undefined);
39
+ child.stdin.end(input);
40
+ child.on('error', reject);
41
+ child.on('close', (code, signal) => {
42
+ resolve({
43
+ stdout: Buffer.concat(out),
44
+ stderr: Buffer.concat(err).toString('utf8'),
45
+ exitCode: exitCodeFromClose(code, signal),
46
+ });
47
+ });
48
+ });
49
+ }
@@ -85,7 +85,27 @@ export interface ProcessGroupOptions {
85
85
  * whenever this option is set.
86
86
  */
87
87
  processGroup?: boolean;
88
+ /**
89
+ * Receives the process-group id (the detached child's pid) right after a
90
+ * group-leader spawn. Lets a caller expose the group to a supervisor that
91
+ * cannot signal FireForge itself gracefully (`fireforge test --pgid-file`),
92
+ * or hold it for a teardown of its own. Not called when no group was
93
+ * created (`processGroup` unset, or win32).
94
+ */
95
+ onProcessGroup?: (pgid: number) => void;
96
+ /**
97
+ * Extra teardown the caller owns, awaited after the child's `close` and
98
+ * the post-run group sweep, before the child's closure settles. Placed
99
+ * here rather than after the wrapper resolves so the bin signal handler's
100
+ * `waitForActiveChildShutdown` covers it too: a reap that ran after the
101
+ * wrapper resolved would race `process.exit` on a forwarded SIGTERM. Must
102
+ * never throw; a rejection is swallowed so it cannot mask the child's
103
+ * result.
104
+ */
105
+ postCloseSweep?: () => Promise<void>;
88
106
  }
107
+ /** A child's exit code, with a signal death mapped to 128 + its number. */
108
+ export declare function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null): number;
89
109
  /**
90
110
  * Executes a command and returns its output.
91
111
  * @param command - Command to execute
@@ -77,7 +77,8 @@ function collectedOutput(out, err) {
77
77
  truncated: out.wasTruncated() || err.wasTruncated(),
78
78
  };
79
79
  }
80
- function exitCodeFromClose(code, signal) {
80
+ /** A child's exit code, with a signal death mapped to 128 + its number. */
81
+ export function exitCodeFromClose(code, signal) {
81
82
  if (code !== null) {
82
83
  return code;
83
84
  }
@@ -154,6 +155,8 @@ async function spawnTracked(spec) {
154
155
  detached: usesProcessGroup,
155
156
  });
156
157
  const groupPid = usesProcessGroup ? child.pid : undefined;
158
+ if (groupPid !== undefined)
159
+ options.onProcessGroup?.(groupPid);
157
160
  spec.attach?.(child);
158
161
  // Group-aware kills route to the whole tree. A non-group child is killed
159
162
  // directly by the forwarder's own `child.kill`.
@@ -183,11 +186,21 @@ async function spawnTracked(spec) {
183
186
  closure.settle();
184
187
  resolve(spec.result(code, signal));
185
188
  };
189
+ // Group sweep first (it takes the helpers that stayed in the group),
190
+ // then the caller's own sweep (the ones that did not), then settle.
191
+ const callerSweep = () => {
192
+ const sweep = options.postCloseSweep;
193
+ if (sweep === undefined) {
194
+ finish();
195
+ return;
196
+ }
197
+ sweep().then(finish, finish);
198
+ };
186
199
  if (groupPid !== undefined) {
187
- sweepProcessGroup(groupPid).then(finish, finish);
200
+ sweepProcessGroup(groupPid).then(callerSweep, callerSweep);
188
201
  }
189
202
  else {
190
- finish();
203
+ callerSweep();
191
204
  }
192
205
  });
193
206
  });
@@ -68,6 +68,17 @@ export declare function isJsonObject(value: JsonValue | undefined): value is Jso
68
68
  * Accepts formats like "140.9.0", "140.9.1", "140.9.0esr", "147.0b1"
69
69
  */
70
70
  export declare function isValidFirefoxVersion(version: string): boolean;
71
+ /**
72
+ * Orders two Firefox version strings semantically: major, minor and patch
73
+ * numerically, with a beta (`153.0b7`) before the release it precedes
74
+ * (`153.0`). An `esr` suffix does not change the order: `153.3.0esr` and
75
+ * `153.3.0` compare equal, since the product says which line it is. A
76
+ * lexical sort, which this replaces in `rebase`, put `153.10.0esr` before
77
+ * `153.2.0esr` and `99.0` after `140.0esr`. A string that is not a Firefox
78
+ * version sorts after every one that is, then lexically.
79
+ * @returns Negative when `a` is older, positive when newer, 0 when equal
80
+ */
81
+ export declare function compareFirefoxVersions(a: string, b: string): number;
71
82
  /**
72
83
  * Validates a release-candidate build directory name.
73
84
  * Accepts formats like "build1", "build2", "build12" (no leading zero).
@@ -100,6 +100,49 @@ export function isValidFirefoxVersion(version) {
100
100
  // Beta: 147.0b1, 147.0b2
101
101
  return /^[1-9]\d{0,2}\.\d+(?:b[1-9]\d*|\.\d+(?:esr)?|esr)?$/.test(version);
102
102
  }
103
+ /**
104
+ * Orders two Firefox version strings semantically: major, minor and patch
105
+ * numerically, with a beta (`153.0b7`) before the release it precedes
106
+ * (`153.0`). An `esr` suffix does not change the order: `153.3.0esr` and
107
+ * `153.3.0` compare equal, since the product says which line it is. A
108
+ * lexical sort, which this replaces in `rebase`, put `153.10.0esr` before
109
+ * `153.2.0esr` and `99.0` after `140.0esr`. A string that is not a Firefox
110
+ * version sorts after every one that is, then lexically.
111
+ * @returns Negative when `a` is older, positive when newer, 0 when equal
112
+ */
113
+ export function compareFirefoxVersions(a, b) {
114
+ const left = parseFirefoxVersionOrder(a);
115
+ const right = parseFirefoxVersionOrder(b);
116
+ if (left === undefined || right === undefined) {
117
+ if (left !== undefined)
118
+ return -1;
119
+ if (right !== undefined)
120
+ return 1;
121
+ return a < b ? -1 : a > b ? 1 : 0;
122
+ }
123
+ for (let i = 0; i < left.length; i++) {
124
+ const delta = (left[i] ?? 0) - (right[i] ?? 0);
125
+ if (delta !== 0)
126
+ return delta;
127
+ }
128
+ return 0;
129
+ }
130
+ /**
131
+ * `[major, minor, patch, betaRank]`, where a release ranks above every beta
132
+ * of the same version.
133
+ */
134
+ function parseFirefoxVersionOrder(version) {
135
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:b(\d+))?(?:esr)?$/i.exec(version.trim());
136
+ if (!match)
137
+ return undefined;
138
+ const beta = match[4];
139
+ return [
140
+ Number(match[1]),
141
+ Number(match[2]),
142
+ Number(match[3] ?? 0),
143
+ beta === undefined ? Number.MAX_SAFE_INTEGER : Number(beta),
144
+ ];
145
+ }
103
146
  /**
104
147
  * Validates a release-candidate build directory name.
105
148
  * Accepts formats like "build1", "build2", "build12" (no leading zero).
@@ -59,6 +59,19 @@ tool at
59
59
  `/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool`,
60
60
  plus `actool` via `xcrun`, `sips`, and `iconutil`.
61
61
 
62
+ On macOS, `fireforge doctor` also checks that the bootstrapped clang
63
+ (`~/.mozbuild/clang`, or under `MOZBUILD_STATE_PATH`) can link against the
64
+ SDK the build will use. That is the SDK `engine/mozconfig` selects with
65
+ `--with-macos-sdk`, resolved by sourcing the mozconfig the way mach does,
66
+ or `xcrun --show-sdk-path` when it names none. The check links a trivial C
67
+ program through the toolchain's lld in a temp directory. When that fails, it
68
+ reports a warning with clang's first error and lists the SDKs installed
69
+ under the mozbuild state directory, the Command Line Tools and the active
70
+ Xcode. An Xcode update can ship an SDK the pinned toolchain cannot read, and
71
+ `mach configure` then dies at its linker probe with nothing but `config.log`
72
+ saying why. The check does nothing on other platforms and when no clang has
73
+ been bootstrapped.
74
+
62
75
  ## `patchLint`
63
76
 
64
77
  Per-patch lint type-checks plain Firefox JS against a built-in
@@ -84,6 +97,28 @@ Firefox-globals shim that tracks upstream WebIDL additions per release.
84
97
  project's, then `npx --no-install`. Left at `'off'`, formatting is out of
85
98
  scope for the per-patch tier, and no other tier checks `.sys.mjs`
86
99
  formatting either.
100
+ - `patchLint.forwardRegistration` (`'support-files'` or `'extended'`,
101
+ default `'support-files'`) picks which carriers the `forward-registration`
102
+ queue rule checks for a line naming a file that only a later patch
103
+ creates. The default checks test-manifest `support-files`. `'extended'`
104
+ also checks:
105
+ - jar.mn pair lines (the source in parentheses, relative to the jar.mn);
106
+ - moz.build path tokens (a quoted string with a `.` or `/`, relative to
107
+ the moz.build);
108
+ - test-manifest section headers and `head`;
109
+ - `customElements.js` `chrome://<pkg>/content/…` URLs, resolved through
110
+ the jar.mn lines the same queue adds.
111
+
112
+ A line whose target cannot be resolved without the build system is left
113
+ alone: a `%` or `@AB_CD@` substitution, `**`, `!`, a path that leaves the
114
+ tree, or a chrome URL no queue jar.mn line packages. Each finding prints
115
+ the same paste-and-run `patch staged-dependency --add --kind registration`
116
+ command as the `support-files` arm. The setting is opt-in because a queue
117
+ that predates it can carry many such edges, and a new error would red a
118
+ `--max-warnings 0` gate on install. The same census is on the public API as
119
+ `findForwardRegistrations(patchesDir, { scope })`, which defaults to
120
+ `'extended'`, for a consumer that keeps its own ratchet over these edges.
121
+
87
122
  - `patchLint.fileSizeThresholds` tunes the `file-too-large` line counts per
88
123
  file class:
89
124
 
@@ -105,6 +140,27 @@ Firefox-globals shim that tracks upstream WebIDL additions per release.
105
140
  is a hard failure rather than a soft limit, which is what these thresholds
106
141
  exist to let you move.
107
142
 
143
+ ## `typecheck`
144
+
145
+ `typecheck.projects` lists the jsconfig files `fireforge typecheck` checks
146
+ as whole projects. `typecheck.extraShim` and `typecheck.projectOverrides`
147
+ add declarations to the Firefox-globals shim, for every project or for one.
148
+ When `patchLint.checkJs` is on, the same command also runs the per-patch
149
+ checkJs pass that `export` enforces, so a green `typecheck` means
150
+ export-clean types.
151
+
152
+ Each project keeps incremental build info in `.fireforge/typecheck/`, one
153
+ file per project. A warm run re-checks only the files whose content changed
154
+ since the last run. The file name carries a key over everything outside the
155
+ program's own files that can change a verdict: the composed shim, the
156
+ TypeScript version, the compiler options, `typecheck.undefinedIdentifiers`
157
+ and the FireForge version. A change to any of them selects a new file, so a
158
+ stale verdict is never replayed, and the project's other build-info files are
159
+ deleted. `fireforge typecheck --no-cache` checks every project from scratch
160
+ and neither reads nor writes the build info. A jsconfig's own `incremental`
161
+ and `tsBuildInfoFile` are still ignored, so nothing is written under
162
+ `engine/`.
163
+
108
164
  ## `patchPolicy`
109
165
 
110
166
  `patchPolicy.ranges` makes `patch compact` range-aware, so closing ordinal
@@ -113,7 +169,13 @@ gaps does not move a patch out of the band its category owns.
113
169
  ## `test`
114
170
 
115
171
  `test.canaryPath` and `test.canaryTimeoutSeconds` supply the defaults for
116
- `fireforge test --canary`. See [`testing.md`](testing.md).
172
+ `fireforge test --canary`.
173
+
174
+ `test.reapOrphans` is `"report"` (default) or `"reap"`: what the preflight
175
+ census does with a harness helper that survived an earlier run in this
176
+ objdir. `"reap"` terminates it and stamps `orphans-reaped=<n>` on the verdict
177
+ line; `--reap-orphans` does the same for one invocation. The objdir anchor
178
+ rule is the same under both. See [`testing.md`](testing.md).
117
179
 
118
180
  ## `buildAudit`
119
181
 
@@ -219,6 +281,31 @@ non-CI launches print a warning saying so. Exit codes 12 and 13 separate a
219
281
  console regression from a launch failure. See
220
282
  [`exit-codes.md`](exit-codes.md).
221
283
 
284
+ A smoke run starts on a fresh temporary profile by default (since 0.48.0).
285
+ The step exists to prove what the build does on a clean start. The
286
+ developer's `obj-*/tmp/profile-default` carries whatever the last session
287
+ left (a pre-reset database, a restored hand-made applet), and those reds
288
+ say nothing about the tree. FireForge creates the directory under the OS
289
+ temp dir, seeds the two prefs `mach run` writes into profiles it makes
290
+ itself (`browser.shell.checkDefaultBrowser` and
291
+ `browser.aboutConfig.showWarning`), and passes it to Firefox as
292
+ `-profile`. The directory is removed when the run ends. That includes a
293
+ failed run and a SIGINT/SIGTERM, which the signal handler removes after
294
+ the browser tree has shut down. A FireForge that was SIGKILLed cannot clean
295
+ up after itself, so the next `run` removes any temporary profile whose
296
+ owning process is gone.
297
+
298
+ - `--profile <path>` launches with a named profile directory and leaves it
299
+ in place. Under `--smoke-exit` it replaces the fresh default.
300
+ - `--temp-profile` gives a plain `run` the same fresh, removed-afterwards
301
+ profile.
302
+
303
+ A plain `run` without either flag keeps mach's `profile-default`. `mach run`'s
304
+ desktop parser has no `--profile` option (its `--profile/-P` belongs to the
305
+ Android parser), so the profile is always passed as a program parameter.
306
+ With an explicit profile, mach neither writes its `runprefs` from
307
+ `~/.mozbuild/machrc` nor accepts `--setpref`.
308
+
222
309
  The allowlist (`--console-allow-file`) is count-blind by default: a line that
223
310
  matches an entry never counts, however often it fires. A `# max-hits: N`
224
311
  comment directly above an entry (other comments may sit between) caps how
@@ -15,7 +15,7 @@ which error class produces each one.
15
15
  | 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
16
16
  | 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses, except `GitNotFoundError` |
17
17
  | 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red. | `BuildError` and subclasses, `TestFailureError` |
18
- | 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError`, `PatchManifestCorruptError`, `RebaseError` and subclasses |
18
+ | 6 | `PATCH_ERROR` | A patch failed to apply (including a patch `rebase --dry-run` predicts will reject), or the patch queue could not be mutated. | `PatchError`, `PatchManifestCorruptError`, `RebaseError` and subclasses |
19
19
  | 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
20
20
  | 8 | `INVALID_ARGUMENT` | The flags were wrong. A usage problem, not an environment one. | `InvalidArgumentError`, plus commander usage errors (unknown command or option, missing argument) |
21
21
  | 9 | `FURNACE_ERROR` | A Furnace component operation failed. | `FurnaceError` |
@@ -190,9 +190,10 @@ command, read the decision table at the bottom first.
190
190
  stages and unstages for real. Each of those commands therefore runs its
191
191
  whole body inside `withPrivateGitIndex`, which seeds a temp
192
192
  `GIT_INDEX_FILE` from the repository's own index so that every refresh
193
- lands there and is discarded. `typecheck` additionally forces
194
- `incremental: false` and drops `tsBuildInfoFile`, so no sidecar is written
195
- under `engine/`. The scope fails open, meaning an unresolvable git dir
193
+ lands there and is discarded. `typecheck` additionally ignores a
194
+ jsconfig's own `incremental` and `tsBuildInfoFile`, so no sidecar is
195
+ written under `engine/`. Its incremental build info (0.48.0) lives in the
196
+ project's `.fireforge/typecheck/`, outside the engine. The scope fails open, meaning an unresolvable git dir
196
197
  simply does not install it, and it is not reentrant. This exists so that
197
198
  invariant 8 stays evidential: a concurrent `fireforge test` must not be
198
199
  invalidated by FireForge's own read-only lane. Enforced by
@@ -284,7 +285,10 @@ Wait ergonomics are opt-in: `pollMaxMs` (the poll interval doubles from
284
285
  `pollMs` up to the cap) and `waitProgressMs` with `onWaitProgress` (a periodic
285
286
  progress callback carrying the holder's PID, liveness, and owner-metadata
286
287
  lines from the pid file). All three are inert when unset, so existing callers
287
- keep byte-identical behavior.
288
+ keep byte-identical behavior. Within one poll iteration a due progress probe
289
+ runs before the deadline check, so a wait that times out has always reported
290
+ the holder at least once when its budget exceeds `waitProgressMs`, however
291
+ slow the host (`file-lock.test.ts`, "budget shorter than one iteration").
288
292
 
289
293
  ### `src/core/file-lock-owner.ts`
290
294
 
@@ -430,10 +434,23 @@ On SIGINT or SIGTERM, `bin/fireforge.ts`:
430
434
  - `waitForActiveCriticalSections(5 s)`, which holds the exit for in-flight
431
435
  "apply plus persist" pairs (invariant 4). It is bounded so that a stuck
432
436
  write cannot postpone the exit the user asked for.
437
+ - `waitForActiveChildShutdown(12 s)`, which holds the exit until every
438
+ tracked child has closed. For a process-group dispatch (`mach` under
439
+ `test`, `build`, `run`) "closed" includes the exec layer's post-close
440
+ work: the group sweep and the caller's `postCloseSweep` (the harness
441
+ helper reap in `fireforge test`). Both run before the child's closure
442
+ settles, so a forwarded SIGTERM cannot race `process.exit` past them.
433
443
  3. Force-releases the furnace lock directories of active operations, since
434
444
  `withFileLock`'s `finally` will never run past `process.exit`.
435
445
  4. Calls `process.exit(130 | 143)`.
436
446
 
447
+ A parent that dies without signalling FireForge (SIGKILL on the `npm` above
448
+ it) enters no handler here. `fireforge test` covers that case itself with
449
+ the parent-exit watchdog (`src/core/parent-exit-watchdog.ts`): it writes the
450
+ killed verdict, reaps the mach group and the tracked helpers, and lets the
451
+ dispatch loop end the run; the process then exits through the ordinary
452
+ error path, not through this pipeline.
453
+
437
454
  ## Choosing a primitive for a new mutation
438
455
 
439
456
  | Your operation | Use |
@@ -86,15 +86,15 @@ moved.
86
86
  `FIREFORGE-VERDICT: FAIL reason=<reason>` uses a closed set
87
87
  (`FireforgeVerdictReason`, `src/commands/test-verdict.ts`):
88
88
 
89
- | Reason | Meaning |
90
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
91
- | `crash` | The harness or the browser died, so there is no trustworthy suite result. |
92
- | `no-tests` | The run dispatched but nothing ran. |
93
- | `test-failures` | The suite ran and reported unexpected results. |
94
- | `preflight` | The run was refused before the harness was reached. |
95
- | `inconclusive` | A result exists, but `engine/` moved under it, so the result was discarded. |
96
- | `lock-timeout` | The run never started: the engine session lock stayed contended. |
97
- | `killed` | A signal terminated the run. Written from the signal handler so that a log tail always describes itself. A killed run must never be silent, or "killed", "still running" and "never started" all look alike. |
89
+ | Reason | Meaning |
90
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `crash` | The harness or the browser died, so there is no trustworthy suite result. |
92
+ | `no-tests` | The run dispatched but nothing ran. |
93
+ | `test-failures` | The suite ran and reported unexpected results. |
94
+ | `preflight` | The run was refused before the harness was reached. |
95
+ | `inconclusive` | A result exists, but `engine/` moved under it, so the result was discarded. |
96
+ | `lock-timeout` | The run never started: the engine session lock stayed contended. |
97
+ | `killed` | A signal terminated the run, or FireForge ended it because its parent process vanished. Written from the signal handler (or the parent-exit watchdog) so that a log tail always describes itself. A killed run must never be silent, or "killed", "still running" and "never started" all look alike. `signal=` names the cause: a signal name, or `parent-exit`. |
98
98
 
99
99
  ### The additive `note=` key
100
100
 
@@ -124,6 +124,19 @@ tasks _inside_ a file is harness code that reads the exported seed; FireForge
124
124
  owns the seed, the plumbing and the record. Absent when the run did not
125
125
  shuffle.
126
126
 
127
+ ### The additive `orphans-reaped=` key
128
+
129
+ How many harness processes FireForge terminated itself during the run: at
130
+ preflight, survivors of an earlier run in this objdir under `--reap-orphans`
131
+ or `test.reapOrphans: "reap"`; at teardown, helpers this run's harness
132
+ launched (httpd, websocket server, ssltunnel, process bridge, the browser,
133
+ `moz-http2`) that were still alive after mach exited. Summed across the census, every
134
+ retry attempt and every shard. Absent when nothing was reaped, so a green on
135
+ a quiet machine prints the line it always did, and a green after a reap is
136
+ distinguishable from it. A run ended by SIGTERM or by the parent-exit
137
+ watchdog writes its verdict before the teardown reap runs, so that line never
138
+ carries the key; the reap is reported on stderr instead.
139
+
127
140
  A preflight refusal's own text is written to stdout before the verdict line,
128
141
  and into the run log, so the verdict stays the last stdout write while the
129
142
  reason survives both a redirect and a `tail`. See
@@ -81,11 +81,23 @@ slugs, and bare slugs.
81
81
  `lint --per-patch --max-warnings 0` is the warning-clean form for a release
82
82
  gate.
83
83
 
84
+ `lint --per-patch --notices summary` prints one line per check in place of
85
+ every NOTICE row, for example `NOTICE [large-patch-lines]: 180 across 172
86
+ patch(es), 150 of them suppressed by lintIgnore (see --report)`. On a green
87
+ queue those rows are reviewed waivers and under-threshold measurements, and
88
+ there can be hundreds of them. The default, `--notices full`, prints them
89
+ as before. Errors and warnings always print in full, and the `--report`
90
+ JSON is the same in both modes.
91
+
84
92
  `lint --per-patch --report <path>` writes a machine-readable JSON report with
85
93
  each patch's line count, tier, active size thresholds, issues, and issues
86
94
  suppressed by `lintIgnore`. The size metrics (`countNonBinaryDiffLines`,
87
95
  `resolvePatchSizeTier`, `getPatchSizeThresholds`) are also exported on the
88
- programmatic API.
96
+ programmatic API. So is `filesMeasuredByFileTooLarge(patchText)`, which
97
+ returns the files `file-too-large` measures in a patch: the JS files it
98
+ creates, and never a modified file or a new file of another kind. The rule
99
+ selects its files through that same function, so an audit that uses it
100
+ cannot drift from the rule.
89
101
 
90
102
  Per-patch lint type-checks plain Firefox JS against a built-in
91
103
  Firefox-globals shim that tracks upstream WebIDL additions per release. See
@@ -116,6 +128,28 @@ npx fireforge download --force
116
128
  npx fireforge rebase
117
129
  ```
118
130
 
131
+ `download --force` is the destructive step of the three, not `rebase`. It
132
+ replaces `engine/` wholesale, so applied patches, unexported edits and
133
+ commits made in `engine/` are gone once the new tree is active. When the
134
+ engine it is about to replace has changed paths against HEAD, or HEAD has
135
+ moved off the recorded base commit, it names the loss (how many changed
136
+ paths match the patch queue and how many do not) and asks first. A
137
+ non-interactive run refuses unless `--yes` is given. A clean engine is
138
+ replaced without a prompt.
139
+
140
+ `npx fireforge rebase --dry-run` replays the queue onto the engine's HEAD
141
+ without touching `engine/`. It seeds a private index from HEAD and applies
142
+ each patch there with `git apply --cached`, in queue order, walking the same
143
+ context-reduction ladder as the real run (exact, then `-C2`, `-C1`, `-C0`,
144
+ bounded by `--max-fuzz`). Each patch is reported as `clean`, `reduced
145
+ context` (with the step it needed) or `reject` (with the files git named).
146
+ It exits 6 when anything rejects. A reject after an earlier one is marked
147
+ as possibly cascading from it, since the replay continues without the
148
+ rejected patch. Run it after the download, when HEAD is the new source. The
149
+ header names the oldest patch stamp as "from", compared as versions rather
150
+ than strings, prints the spread when the queue carries several stamps, and
151
+ warns when `engine/` is not yet on the pinned version.
152
+
119
153
  If a patch fails, fix the reject inside `engine/`, then run
120
154
  `npx fireforge rebase --continue`. A rebase leaves the engine mutated on
121
155
  purpose, so its apply-and-persist pairs are held across a signal rather than
package/docs/testing.md CHANGED
@@ -87,21 +87,85 @@ a slow build for a silently wrong test.
87
87
  earlier run in this project's objdir. It runs at preflight, before this run
88
88
  spawns anything, so every hit is necessarily a survivor. A match has to be
89
89
  objdir-anchored, since `xpcshell` and `server.js` are far too generic to
90
- report on their own. Survivors slow every later run without appearing in
90
+ report on their own, and structural: the process's executable is a helper
91
+ binary, or an interpreter whose script argument is a helper script. A
92
+ process that merely mentions such a path (an editor, a `grep`, the shell
93
+ FireForge runs under) is not a helper, and FireForge's own ancestors are
94
+ never candidates. Survivors slow every later run without appearing in
91
95
  its output. The usual symptom is a three-second suite taking minutes of
92
- wall clock. The census is report-only by default, and `--reap-orphans`
93
- terminates what it finds. It never refuses a run. A different shape,
96
+ wall clock. The census is report-only by default; `--reap-orphans`, or
97
+ `test.reapOrphans: "reap"` in `fireforge.json`, terminates what it finds
98
+ (SIGTERM, a 500 ms grace, SIGKILL). It never refuses a run. Every reap is
99
+ stamped on the verdict line as `orphans-reaped=<n>`. A different shape,
94
100
  reparented Python `multiprocessing` workers, is covered by the
95
101
  `Orphaned harness workers` doctor check.
102
+ - Every dispatch owns its own kill path. The harness announces what it
103
+ launches (`runtests.py | Server pid: <n>`, the websocket server, the SSL
104
+ tunnel, the websocket/process bridge, and the browser as `Application
105
+ pid`); FireForge tracks those pids and, after mach has exited for any
106
+ reason (a clean finish, a harness crash before a retry, a no-output
107
+ timeout, a forwarded SIGTERM, the parent-exit watchdog below), terminates
108
+ any of them still alive. A tracked pid is re-read from `ps` first and must
109
+ still be anchored to this objdir and still look like the thing that was
110
+ launched, so a recycled pid is never signalled. One helper is never
111
+ announced: mozserve starts `moz-http2` in a session of its own and logs no
112
+ pid, so it is in neither the group nor the announcements. For it, FireForge
113
+ snapshots the same-objdir helpers before the dispatch spawns and, after
114
+ mach exits, reaps any helper-shaped process under this checkout's absolute
115
+ objdir that is new since that snapshot and reparented to launchd. An
116
+ earlier run's survivor is in the snapshot and stays the census's call. This
117
+ reap runs inside the exec layer's close path, so the signal handler's
118
+ bounded child-shutdown wait covers it. The count joins `orphans-reaped=`.
119
+ - FireForge watches its own parent while a dispatch runs. A supervisor that
120
+ SIGKILLs the process above `fireforge test` (an `npm run` step killed at
121
+ its bound) signals nothing below it; FireForge notices the reparenting
122
+ within about two seconds, writes `FIREFORGE-VERDICT: FAIL reason=killed
123
+ signal=parent-exit`, reaps the harness process group and the tracked
124
+ helpers, and ends the run without classifying or retrying.
96
125
  - `--perf-samples <path>` publishes a perf-sample artifact path to the
97
126
  harness (exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
98
127
 
128
+ ## Process groups and supervisors
129
+
130
+ `fireforge test` spawns mach as the leader of its **own** process group
131
+ (`detached: true`), so that FireForge can signal the whole harness tree with
132
+ one negative-pid kill and sweep the group after mach exits. Two consequences
133
+ for anything that supervises `fireforge test`:
134
+
135
+ - A supervisor that kills _its own_ process group (`kill -- -<npm pid>`) does
136
+ not reach mach: mach is not in that group. Killing the `npm`/`node` chain
137
+ above FireForge leaves the harness tree running.
138
+ - SIGTERM to FireForge is the right signal. FireForge forwards it to the
139
+ mach group, escalates to SIGKILL after a grace period, sweeps the group,
140
+ reaps the tracked helpers, and writes `FAIL reason=killed signal=SIGTERM`.
141
+ (That line, like the watchdog's `signal=parent-exit` line, is written
142
+ before the teardown reap runs, so it cannot carry `orphans-reaped=`; the
143
+ reap reports to stderr.)
144
+ - A supervisor that has to SIGKILL FireForge should pass
145
+ `--pgid-file <path>`. FireForge rewrites the file with the harness group id
146
+ on every mach spawn (each retry attempt and shard) and removes it when the
147
+ run ends under its own control. After a SIGKILL the file is still there:
148
+ `kill -TERM -- -$(cat <path>)`, then `-KILL`, takes mach and every helper
149
+ that shares its group. Two processes are outside it: the browser
150
+ (mozprocess puts it in a group of its own) and `moz-http2` (mozserve
151
+ starts it in its own session). A SIGTERM to the group gives `runtests.py`
152
+ the chance to run its own cleanup, which normally takes the browser with
153
+ it; nothing takes `moz-http2`, so after a SIGKILL of FireForge it stays
154
+ until the next run's census, which is why that run should carry the reap
155
+ posture. A browser that does survive is the Marionette preflight's and
156
+ `--kill-stale-marionette`'s to remove.
157
+
158
+ The mochitest helpers (httpd, websocket server, ssltunnel, process bridge)
159
+ are plain children of `runtests.py` and share mach's group. The browser and
160
+ `moz-http2` do not, which is why the teardown reap above exists alongside the
161
+ group sweep.
162
+
99
163
  ## The verdict line
100
164
 
101
165
  Every test run ends with one machine-readable line:
102
166
 
103
167
  ```
104
- FIREFORGE-VERDICT: PASS|FAIL reason=… [note=<class>] [shuffle=<seed>] [log=<path>]
168
+ FIREFORGE-VERDICT: PASS|FAIL reason=… [note=<class>] [shuffle=<seed>] [orphans-reaped=<n>] [log=<path>]
105
169
  ```
106
170
 
107
171
  Automation should branch on this line rather than on the raw process code,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -75,9 +75,9 @@
75
75
  "eslint-plugin-simple-import-sort": "^14.0.0",
76
76
  "fast-check": "^4.6.0",
77
77
  "husky": "^9.1.7",
78
- "knip": "6.35.1",
78
+ "knip": "6.37.0",
79
79
  "lint-staged": "^17.0.4",
80
- "prettier": "3.9.6",
80
+ "prettier": "3.9.8",
81
81
  "tsx": "^4.7.0",
82
82
  "typescript": "~6.0.0",
83
83
  "typescript-eslint": "^8.0.0",
@@ -108,5 +108,9 @@
108
108
  "publishConfig": {
109
109
  "access": "public",
110
110
  "provenance": true
111
+ },
112
+ "allowScripts": {
113
+ "esbuild@0.28.1": true,
114
+ "fsevents@2.3.3": true
111
115
  }
112
116
  }