@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
@@ -77,6 +77,27 @@ export declare function hashObjectBatch(repoDir: string, fullPaths: string[]): P
77
77
  * @returns File content or null if file doesn't exist at that ref
78
78
  */
79
79
  export declare function getFileContentAtRef(repoDir: string, filePath: string, ref?: string): Promise<string | null>;
80
+ /**
81
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
82
+ * `ref` through one `git cat-file --batch` process instead of one
83
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
84
+ * about 18 ms each and almost all of it spawn overhead.
85
+ *
86
+ * The first request line is `ref` itself, so an unresolvable ref throws a
87
+ * {@link GitError} exactly as the per-file helper does, rather than being
88
+ * read as "every path missing". A path reported `missing` maps to `null`,
89
+ * as the per-file helper returns for a path absent at that ref. Anything
90
+ * the batch cannot answer as a plain blob (a path containing a newline,
91
+ * which the line protocol cannot carry, or an object that is not a blob)
92
+ * is fetched through {@link getFileContentAtRef}, so the result for every
93
+ * path is what that helper returns.
94
+ *
95
+ * @param repoDir - Repository directory
96
+ * @param filePaths - Paths relative to the repository root
97
+ * @param ref - Git ref to read from. Defaults to HEAD.
98
+ * @returns Content (or `null` when absent at `ref`) for every input path
99
+ */
100
+ export declare function getFilesContentAtRef(repoDir: string, filePaths: readonly string[], ref?: string): Promise<Map<string, string | null>>;
80
101
  /**
81
102
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
82
103
  * Uses the same heuristic as git.
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { GitError } from '../errors/git.js';
5
5
  import { removeFile } from '../utils/fs.js';
6
6
  import { exec } from '../utils/process.js';
7
+ import { execWithInput } from '../utils/process-input.js';
7
8
  import { chunkPathspecs, ensureGit, git } from './git-base.js';
8
9
  /**
9
10
  * Restores a tracked path from HEAD, including staged changes.
@@ -175,6 +176,80 @@ export async function getFileContentAtRef(repoDir, filePath, ref = 'HEAD') {
175
176
  }
176
177
  return result.stdout;
177
178
  }
179
+ /**
180
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
181
+ * `ref` through one `git cat-file --batch` process instead of one
182
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
183
+ * about 18 ms each and almost all of it spawn overhead.
184
+ *
185
+ * The first request line is `ref` itself, so an unresolvable ref throws a
186
+ * {@link GitError} exactly as the per-file helper does, rather than being
187
+ * read as "every path missing". A path reported `missing` maps to `null`,
188
+ * as the per-file helper returns for a path absent at that ref. Anything
189
+ * the batch cannot answer as a plain blob (a path containing a newline,
190
+ * which the line protocol cannot carry, or an object that is not a blob)
191
+ * is fetched through {@link getFileContentAtRef}, so the result for every
192
+ * path is what that helper returns.
193
+ *
194
+ * @param repoDir - Repository directory
195
+ * @param filePaths - Paths relative to the repository root
196
+ * @param ref - Git ref to read from. Defaults to HEAD.
197
+ * @returns Content (or `null` when absent at `ref`) for every input path
198
+ */
199
+ export async function getFilesContentAtRef(repoDir, filePaths, ref = 'HEAD') {
200
+ const contents = new Map();
201
+ const unique = [...new Set(filePaths)];
202
+ const batchable = unique.filter((path) => !path.includes('\n'));
203
+ const fallback = unique.filter((path) => path.includes('\n'));
204
+ if (batchable.length > 0) {
205
+ await ensureGit();
206
+ const requests = [ref, ...batchable.map((path) => `${ref}:${path}`)];
207
+ const result = await execWithInput('git', ['cat-file', '--batch'], requests.join('\n') + '\n', {
208
+ cwd: repoDir,
209
+ });
210
+ if (result.exitCode !== 0) {
211
+ throw new GitError(result.stderr.trim() || 'git cat-file --batch failed', 'cat-file --batch');
212
+ }
213
+ const out = result.stdout;
214
+ let offset = 0;
215
+ for (const [index, request] of requests.entries()) {
216
+ const newline = out.indexOf(0x0a, offset);
217
+ if (newline === -1) {
218
+ throw new GitError(`Truncated cat-file --batch output at ${request}`, 'cat-file --batch');
219
+ }
220
+ const header = out.toString('utf8', offset, newline);
221
+ offset = newline + 1;
222
+ const path = index === 0 ? undefined : batchable[index - 1];
223
+ if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
224
+ if (path === undefined) {
225
+ throw new GitError(`Cannot resolve ${ref}: ${header}`, `cat-file --batch ${ref}`);
226
+ }
227
+ if (header.endsWith(' missing'))
228
+ contents.set(path, null);
229
+ else
230
+ fallback.push(path);
231
+ continue;
232
+ }
233
+ const match = /^[0-9a-f]+ (\S+) (\d+)$/.exec(header);
234
+ if (!match) {
235
+ throw new GitError(`Unexpected cat-file --batch header: ${header}`, 'cat-file --batch');
236
+ }
237
+ const size = Number(match[2]);
238
+ const body = out.subarray(offset, offset + size);
239
+ offset += size + 1;
240
+ if (path === undefined)
241
+ continue;
242
+ if (match[1] === 'blob')
243
+ contents.set(path, body.toString('utf8'));
244
+ else
245
+ fallback.push(path);
246
+ }
247
+ }
248
+ for (const path of fallback) {
249
+ contents.set(path, await getFileContentAtRef(repoDir, path, ref));
250
+ }
251
+ return contents;
252
+ }
178
253
  /**
179
254
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
180
255
  * Uses the same heuristic as git.
@@ -39,6 +39,20 @@ export declare function resolveMaxUntrackedFilesPerDir(): number;
39
39
  * files
40
40
  */
41
41
  export declare function expandUntrackedDirectoryEntries(repoDir: string, entries: GitStatusEntry[]): Promise<GitStatusEntry[]>;
42
+ /**
43
+ * Lists the untracked files under each collapsed `dir/` status entry with
44
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
45
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
46
+ * requested directory to its files in git's order, which is exactly what
47
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
48
+ * keeps non-ASCII names literal: the newline form C-quotes them under
49
+ * git's default `core.quotePath`, and a quoted path names no file.
50
+ *
51
+ * @param repoDir - Repository directory
52
+ * @param dirs - Collapsed untracked directories, each ending in `/`
53
+ * @returns Files per directory (every input key present, possibly empty)
54
+ */
55
+ export declare function listUntrackedFilesInDirs(repoDir: string, dirs: readonly string[]): Promise<Map<string, string[]>>;
42
56
  /**
43
57
  * Gets all untracked files (including files inside untracked directories).
44
58
  * @param repoDir - Repository directory
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { warn } from '../utils/logger.js';
3
- import { git } from './git-base.js';
3
+ import { chunkPathspecs, git } from './git-base.js';
4
4
  /**
5
5
  * True when a porcelain status column carries a rename or copy, i.e. the
6
6
  * record is followed by a second NUL-terminated field holding the original
@@ -95,12 +95,16 @@ export function resolveMaxUntrackedFilesPerDir() {
95
95
  export async function expandUntrackedDirectoryEntries(repoDir, entries) {
96
96
  const expanded = [];
97
97
  const maxPerDir = resolveMaxUntrackedFilesPerDir();
98
+ const collapsed = entries
99
+ .filter((entry) => entry.isUntracked && entry.file.endsWith('/'))
100
+ .map((entry) => entry.file);
101
+ const untrackedByDir = await listUntrackedFilesInDirs(repoDir, collapsed);
98
102
  for (const entry of entries) {
99
103
  if (!entry.isUntracked || !entry.file.endsWith('/')) {
100
104
  expanded.push(entry);
101
105
  continue;
102
106
  }
103
- const individualFiles = await getUntrackedFilesInDir(repoDir, entry.file);
107
+ const individualFiles = untrackedByDir.get(entry.file) ?? [];
104
108
  if (individualFiles.length > maxPerDir) {
105
109
  warn(`Untracked directory ${entry.file} contains ${individualFiles.length} files — only the first ${maxPerDir} are listed. Add a .gitignore entry or clean the directory.`);
106
110
  }
@@ -118,6 +122,41 @@ export async function expandUntrackedDirectoryEntries(repoDir, entries) {
118
122
  }
119
123
  return expanded;
120
124
  }
125
+ /**
126
+ * Lists the untracked files under each collapsed `dir/` status entry with
127
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
128
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
129
+ * requested directory to its files in git's order, which is exactly what
130
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
131
+ * keeps non-ASCII names literal: the newline form C-quotes them under
132
+ * git's default `core.quotePath`, and a quoted path names no file.
133
+ *
134
+ * @param repoDir - Repository directory
135
+ * @param dirs - Collapsed untracked directories, each ending in `/`
136
+ * @returns Files per directory (every input key present, possibly empty)
137
+ */
138
+ export async function listUntrackedFilesInDirs(repoDir, dirs) {
139
+ const byDir = new Map(dirs.map((dir) => [dir, []]));
140
+ if (byDir.size === 0)
141
+ return byDir;
142
+ for (const chunk of chunkPathspecs([...byDir.keys()])) {
143
+ const output = await git(['ls-files', '--others', '--exclude-standard', '-z', '--', ...chunk], repoDir);
144
+ for (const file of output.split('\0')) {
145
+ if (file.length === 0)
146
+ continue;
147
+ // Collapsed status directories never nest, so the first matching
148
+ // ancestor is the only one.
149
+ for (let slash = file.indexOf('/'); slash !== -1; slash = file.indexOf('/', slash + 1)) {
150
+ const bucket = byDir.get(file.slice(0, slash + 1));
151
+ if (bucket !== undefined) {
152
+ bucket.push(file);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return byDir;
159
+ }
121
160
  /**
122
161
  * Gets all untracked files (including files inside untracked directories).
123
162
  * @param repoDir - Repository directory
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Teardown reaper for the helper processes one mach test dispatch launched.
3
+ *
4
+ * The mochitest harness starts its httpd (`xpcshell … server.js`), the
5
+ * websocket server, ssltunnel and the websocket/process bridge as plain
6
+ * children, and the browser through mozprocess, which puts it in a process
7
+ * group of its own. It logs each launch as `runtests.py | <what> pid: <n>`.
8
+ * When mach dies before its own cleanup runs (a forwarded SIGTERM, a
9
+ * no-output timeout, a harness crash, a supervisor kill), the helpers do not
10
+ * necessarily die with it: the 2026-09-13 incident left the httpd reparented
11
+ * to launchd at 100% CPU for hours after a gate killed the run at its bound.
12
+ *
13
+ * The exec layer already sweeps the dispatch's process group after close.
14
+ * That reaches every helper that stayed in the group, and nothing else: the
15
+ * browser is in its own group, and a helper that re-grouped itself is not.
16
+ * This module closes that gap in two passes:
17
+ *
18
+ * - {@link createHelperPidTracker} collects the announced pids from the
19
+ * streamed output. {@link reapTrackedHarnessPids} runs after mach has
20
+ * exited; a tracked pid still alive at that point is by definition a
21
+ * survivor (its parent is gone), so no age heuristic is needed. Before
22
+ * signalling, each pid is re-read from `ps` and must still be anchored to
23
+ * the objdir and still look like the thing the harness launched: a pid
24
+ * recycled onto an unrelated process between the launch line and
25
+ * teardown is skipped.
26
+ * - Not every helper is announced. mozserve starts `moz-http2` with
27
+ * `start_new_session=True` and logs no pid, so it is in neither the
28
+ * group nor the tracker, and it was the other survivor of the incident.
29
+ * {@link snapshotObjdirHelpers} records which same-objdir helpers existed
30
+ * before the dispatch spawned; after mach exits, a helper-shaped process
31
+ * under THIS checkout's absolute objdir that is not in that baseline and
32
+ * is reparented (its parent, the harness, is gone) is one this dispatch
33
+ * launched, and is reaped too. The absolute-path anchor keeps a sibling
34
+ * checkout's live harness out of it; the baseline keeps a survivor of an
35
+ * earlier run out of it, so the preflight census's report-only posture
36
+ * still decides that one's fate.
37
+ */
38
+ /**
39
+ * What the harness said it launched, as it names it in the log line, or
40
+ * `unannounced` for a survivor found by the post-close objdir scan.
41
+ */
42
+ export type TrackedHarnessKind = 'Server' | 'Websocket server' | 'SSL tunnel' | 'websocket/process bridge' | 'Application' | 'unannounced';
43
+ /** One helper the harness launched. */
44
+ export interface TrackedHarnessPid {
45
+ pid: number;
46
+ kind: TrackedHarnessKind;
47
+ /** Command line, when the entry came from `ps` rather than a log line. */
48
+ command?: string;
49
+ }
50
+ /** Which child stream a chunk came from. */
51
+ export type OutputStreamName = 'stdout' | 'stderr';
52
+ /** Collects harness launch announcements from streamed output chunks. */
53
+ export interface HelperPidTracker {
54
+ /** Feed one chunk. Chunk boundaries need not align with lines. */
55
+ feed(stream: OutputStreamName, chunk: string): void;
56
+ /** Every pid announced so far, in announcement order, without duplicates. */
57
+ tracked(): TrackedHarnessPid[];
58
+ }
59
+ /**
60
+ * Creates a tracker for one dispatch. The two streams are buffered apart so
61
+ * a half-line on one cannot be glued to a half-line on the other.
62
+ */
63
+ export declare function createHelperPidTracker(): HelperPidTracker;
64
+ /**
65
+ * Decides whether a `ps` row still describes the process the harness
66
+ * announced. Exported for fixture tests.
67
+ *
68
+ * The helpers must match the census's helper pattern and the objdir anchor.
69
+ * The browser has no helper-shaped name, so the anchor alone attributes it:
70
+ * its binary lives under the objdir (`<objdir>/dist/<App>.app/…`).
71
+ */
72
+ export declare function isStillTrackedProcess(tracked: TrackedHarnessPid, command: string, objDir: string | undefined): boolean;
73
+ /**
74
+ * Records the same-objdir helpers alive before a dispatch spawns anything,
75
+ * so the post-close pass can tell this dispatch's unannounced helpers from
76
+ * an earlier run's survivors. Undefined when `ps` is unusable or no objdir
77
+ * is known; the post-close pass then reaps no unannounced process at all.
78
+ *
79
+ * @param objDir - This project's absolute objdir
80
+ * @returns The pids to leave alone at teardown, or undefined for "no baseline"
81
+ */
82
+ export declare function snapshotObjdirHelpers(objDir: string | undefined): Promise<Set<number> | undefined>;
83
+ /**
84
+ * Terminates every process this dispatch launched that is still alive after
85
+ * mach exited: the announced pids still attributable to the dispatch, and
86
+ * the unannounced same-objdir survivors that appeared since the baseline.
87
+ *
88
+ * Best-effort like the census: a host without `ps` logs at verbose and
89
+ * reaps nothing rather than signalling on the strength of a pid alone.
90
+ *
91
+ * @param tracked - The pids the harness announced during the dispatch
92
+ * @param objDir - This project's absolute objdir, for the anchor rule
93
+ * @param baseline - Same-objdir helpers alive before the dispatch spawned
94
+ * ({@link snapshotObjdirHelpers}); never touched here. Undefined disables
95
+ * the unannounced pass entirely
96
+ * @returns How many survivors are confirmed gone afterwards
97
+ */
98
+ export declare function reapTrackedHarnessPids(tracked: readonly TrackedHarnessPid[], objDir: string | undefined, baseline?: ReadonlySet<number>): Promise<number>;
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Teardown reaper for the helper processes one mach test dispatch launched.
4
+ *
5
+ * The mochitest harness starts its httpd (`xpcshell … server.js`), the
6
+ * websocket server, ssltunnel and the websocket/process bridge as plain
7
+ * children, and the browser through mozprocess, which puts it in a process
8
+ * group of its own. It logs each launch as `runtests.py | <what> pid: <n>`.
9
+ * When mach dies before its own cleanup runs (a forwarded SIGTERM, a
10
+ * no-output timeout, a harness crash, a supervisor kill), the helpers do not
11
+ * necessarily die with it: the 2026-09-13 incident left the httpd reparented
12
+ * to launchd at 100% CPU for hours after a gate killed the run at its bound.
13
+ *
14
+ * The exec layer already sweeps the dispatch's process group after close.
15
+ * That reaches every helper that stayed in the group, and nothing else: the
16
+ * browser is in its own group, and a helper that re-grouped itself is not.
17
+ * This module closes that gap in two passes:
18
+ *
19
+ * - {@link createHelperPidTracker} collects the announced pids from the
20
+ * streamed output. {@link reapTrackedHarnessPids} runs after mach has
21
+ * exited; a tracked pid still alive at that point is by definition a
22
+ * survivor (its parent is gone), so no age heuristic is needed. Before
23
+ * signalling, each pid is re-read from `ps` and must still be anchored to
24
+ * the objdir and still look like the thing the harness launched: a pid
25
+ * recycled onto an unrelated process between the launch line and
26
+ * teardown is skipped.
27
+ * - Not every helper is announced. mozserve starts `moz-http2` with
28
+ * `start_new_session=True` and logs no pid, so it is in neither the
29
+ * group nor the tracker, and it was the other survivor of the incident.
30
+ * {@link snapshotObjdirHelpers} records which same-objdir helpers existed
31
+ * before the dispatch spawned; after mach exits, a helper-shaped process
32
+ * under THIS checkout's absolute objdir that is not in that baseline and
33
+ * is reparented (its parent, the harness, is gone) is one this dispatch
34
+ * launched, and is reaped too. The absolute-path anchor keeps a sibling
35
+ * checkout's live harness out of it; the baseline keeps a survivor of an
36
+ * earlier run out of it, so the preflight census's report-only posture
37
+ * still decides that one's fate.
38
+ */
39
+ import { toError } from '../utils/errors.js';
40
+ import { dispatchCompleteLines } from '../utils/line-dispatch.js';
41
+ import { info, verbose } from '../utils/logger.js';
42
+ import { exec } from '../utils/process.js';
43
+ import { isObjdirAnchored, isPidAlive, matchesHarnessHelper, terminateHarnessProcesses, } from './harness-orphans.js';
44
+ /**
45
+ * The launch announcements in `testing/mochitest/runtests.py`. mozlog puts a
46
+ * timestamp and level in front, so the match is unanchored.
47
+ */
48
+ const HARNESS_PID_LINE = /runtests\.py \| (Server|Websocket server|SSL tunnel|websocket\/process bridge|Application) pid: (\d+)\b/;
49
+ /**
50
+ * Creates a tracker for one dispatch. The two streams are buffered apart so
51
+ * a half-line on one cannot be glued to a half-line on the other.
52
+ */
53
+ export function createHelperPidTracker() {
54
+ const seen = new Map();
55
+ const partial = { stdout: '', stderr: '' };
56
+ const onLine = (line) => {
57
+ const match = HARNESS_PID_LINE.exec(line);
58
+ if (!match)
59
+ return;
60
+ const pid = Number(match[2]);
61
+ if (!seen.has(pid)) {
62
+ seen.set(pid, { pid, kind: match[1] });
63
+ }
64
+ };
65
+ return {
66
+ feed(stream, chunk) {
67
+ partial[stream] = dispatchCompleteLines(partial[stream] + chunk, onLine);
68
+ },
69
+ tracked() {
70
+ return [...seen.values()];
71
+ },
72
+ };
73
+ }
74
+ /** Log prefix for every line this reaper writes. */
75
+ const TEARDOWN_LABEL = 'harness teardown';
76
+ /**
77
+ * Decides whether a `ps` row still describes the process the harness
78
+ * announced. Exported for fixture tests.
79
+ *
80
+ * The helpers must match the census's helper pattern and the objdir anchor.
81
+ * The browser has no helper-shaped name, so the anchor alone attributes it:
82
+ * its binary lives under the objdir (`<objdir>/dist/<App>.app/…`).
83
+ */
84
+ export function isStillTrackedProcess(tracked, command, objDir) {
85
+ if (!isObjdirAnchored(command, objDir))
86
+ return false;
87
+ return tracked.kind === 'Application' || matchesHarnessHelper(command);
88
+ }
89
+ /** Lists every process. Throws when `ps` is unusable, like the census. */
90
+ async function listAllProcesses() {
91
+ const result = await exec('ps', ['-axo', 'pid=,ppid=,command='], { timeout: 10000 });
92
+ if (result.exitCode !== 0)
93
+ throw new Error(`ps exited ${result.exitCode}`);
94
+ const rows = [];
95
+ for (const line of result.stdout.split('\n')) {
96
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
97
+ if (match) {
98
+ rows.push({
99
+ pid: Number(match[1]),
100
+ ppid: Number(match[2]),
101
+ command: (match[3] ?? '').trim(),
102
+ });
103
+ }
104
+ }
105
+ return rows;
106
+ }
107
+ /**
108
+ * A helper-shaped process under this checkout's objdir. The anchor here is
109
+ * the absolute objdir path alone, on purpose: the census's generic `obj-…`
110
+ * fallback would also match a sibling checkout's live harness.
111
+ */
112
+ function isThisObjdirHelper(command, objDir) {
113
+ return command.includes(objDir) && matchesHarnessHelper(command);
114
+ }
115
+ /**
116
+ * Records the same-objdir helpers alive before a dispatch spawns anything,
117
+ * so the post-close pass can tell this dispatch's unannounced helpers from
118
+ * an earlier run's survivors. Undefined when `ps` is unusable or no objdir
119
+ * is known; the post-close pass then reaps no unannounced process at all.
120
+ *
121
+ * @param objDir - This project's absolute objdir
122
+ * @returns The pids to leave alone at teardown, or undefined for "no baseline"
123
+ */
124
+ export async function snapshotObjdirHelpers(objDir) {
125
+ if (process.platform === 'win32' || objDir === undefined)
126
+ return undefined;
127
+ try {
128
+ const rows = await listAllProcesses();
129
+ return new Set(rows.filter((r) => isThisObjdirHelper(r.command, objDir)).map((r) => r.pid));
130
+ }
131
+ catch (error) {
132
+ verbose(`${TEARDOWN_LABEL}: could not snapshot helpers (${toError(error).message}).`);
133
+ return undefined;
134
+ }
135
+ }
136
+ /**
137
+ * Helpers this dispatch launched without announcing them: helper-shaped,
138
+ * under this checkout's objdir, not in the pre-dispatch baseline, and
139
+ * reparented to pid 1 because the harness that started them is gone.
140
+ * Returns nothing when `ps` is unusable rather than guessing. Without a
141
+ * baseline (a snapshot that failed) it also returns nothing: an earlier
142
+ * run's survivor must stay the census's call.
143
+ */
144
+ async function findUnannouncedSurvivors(objDir, baseline, exclude) {
145
+ if (baseline === undefined)
146
+ return [];
147
+ let rows;
148
+ try {
149
+ rows = await listAllProcesses();
150
+ }
151
+ catch (error) {
152
+ verbose(`${TEARDOWN_LABEL}: could not scan for unannounced helpers (${toError(error).message}).`);
153
+ return [];
154
+ }
155
+ return rows
156
+ .filter((r) => r.ppid === 1 &&
157
+ r.pid !== process.pid &&
158
+ !baseline.has(r.pid) &&
159
+ !exclude.has(r.pid) &&
160
+ isThisObjdirHelper(r.command, objDir))
161
+ .map((r) => ({ pid: r.pid, kind: 'unannounced', command: r.command }));
162
+ }
163
+ /** Reads `pid command` rows for the given pids. Missing pids simply produce no row. */
164
+ async function listCommands(pids) {
165
+ const result = await exec('ps', ['-o', 'pid=,command=', '-p', pids.join(',')], {
166
+ timeout: 10000,
167
+ });
168
+ const rows = new Map();
169
+ // `ps -p` exits 1 when any listed pid is gone, while still printing the
170
+ // rest, so the exit code is not a failure signal here.
171
+ for (const line of result.stdout.split('\n')) {
172
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
173
+ if (match)
174
+ rows.set(Number(match[1]), (match[2] ?? '').trim());
175
+ }
176
+ return rows;
177
+ }
178
+ /**
179
+ * Re-reads the announced pids that are still alive and keeps the ones still
180
+ * attributable to this dispatch. Empty when `ps` is unusable.
181
+ */
182
+ async function survivingTrackedPids(tracked, objDir) {
183
+ const alive = tracked.filter((entry) => isPidAlive(entry.pid));
184
+ if (alive.length === 0)
185
+ return [];
186
+ let commands;
187
+ try {
188
+ commands = await listCommands(alive.map((entry) => entry.pid));
189
+ }
190
+ catch (error) {
191
+ verbose(`${TEARDOWN_LABEL}: could not read ps for tracked helpers (${toError(error).message}); skipping.`);
192
+ return [];
193
+ }
194
+ const targets = [];
195
+ for (const entry of alive) {
196
+ const command = commands.get(entry.pid);
197
+ if (command === undefined)
198
+ continue; // exited between the probe and ps
199
+ if (!isStillTrackedProcess(entry, command, objDir)) {
200
+ verbose(`${TEARDOWN_LABEL}: PID ${entry.pid} (announced as ${entry.kind}) is now "${command.slice(0, 120)}", ` +
201
+ 'not attributable to this run; left alone.');
202
+ continue;
203
+ }
204
+ targets.push(entry);
205
+ }
206
+ return targets;
207
+ }
208
+ function describeTarget(target) {
209
+ const excerpt = target.command === undefined ? '' : ` "${target.command.slice(0, 80)}"`;
210
+ return `${target.kind} ${target.pid}${excerpt}`;
211
+ }
212
+ /**
213
+ * Terminates every process this dispatch launched that is still alive after
214
+ * mach exited: the announced pids still attributable to the dispatch, and
215
+ * the unannounced same-objdir survivors that appeared since the baseline.
216
+ *
217
+ * Best-effort like the census: a host without `ps` logs at verbose and
218
+ * reaps nothing rather than signalling on the strength of a pid alone.
219
+ *
220
+ * @param tracked - The pids the harness announced during the dispatch
221
+ * @param objDir - This project's absolute objdir, for the anchor rule
222
+ * @param baseline - Same-objdir helpers alive before the dispatch spawned
223
+ * ({@link snapshotObjdirHelpers}); never touched here. Undefined disables
224
+ * the unannounced pass entirely
225
+ * @returns How many survivors are confirmed gone afterwards
226
+ */
227
+ export async function reapTrackedHarnessPids(tracked, objDir, baseline) {
228
+ if (process.platform === 'win32')
229
+ return 0;
230
+ const targets = await survivingTrackedPids(tracked, objDir);
231
+ if (objDir !== undefined) {
232
+ const known = new Set(tracked.map((t) => t.pid));
233
+ targets.push(...(await findUnannouncedSurvivors(objDir, baseline, known)));
234
+ }
235
+ if (targets.length === 0)
236
+ return 0;
237
+ info(`${TEARDOWN_LABEL}: ${targets.length} harness process(es) outlived mach ` +
238
+ `(${targets.map(describeTarget).join(', ')}); terminating.`);
239
+ return terminateHarnessProcesses(targets, TEARDOWN_LABEL);
240
+ }
@@ -21,10 +21,15 @@
21
21
  * for the same reason. FireForge has no business reporting, let alone
22
22
  * terminating, a process it cannot attribute to the harness.
23
23
  *
24
- * `--reap-orphans` opts into termination. Without it the census is
25
- * report-only, exactly like the `Orphaned harness workers` doctor check
26
- * (which covers a different shape: reparented Python multiprocessing
27
- * workers, matched on PPID 1 and accumulated CPU time).
24
+ * `--reap-orphans` (or `test.reapOrphans: "reap"` in `fireforge.json`) opts
25
+ * into termination. Without it the census is report-only, exactly like the
26
+ * `Orphaned harness workers` doctor check (which covers a different shape:
27
+ * reparented Python multiprocessing workers, matched on PPID 1 and
28
+ * accumulated CPU time).
29
+ *
30
+ * The anchoring and termination primitives are exported for the teardown
31
+ * reaper in `harness-helper-pids.ts`, which applies the same objdir rule to
32
+ * the helpers THIS run launched, after mach has exited.
28
33
  */
29
34
  /** One surviving harness helper process. */
30
35
  export interface OrphanedHarnessProcess {
@@ -36,15 +41,32 @@ export interface OrphanedHarnessProcess {
36
41
  elapsedSeconds: number;
37
42
  command: string;
38
43
  }
44
+ /**
45
+ * True when `command` IS a harness helper: its executable is a helper
46
+ * binary, or an interpreter whose first argument is a helper script.
47
+ *
48
+ * Structural on purpose. An earlier version matched the helper names
49
+ * anywhere in the command line, which is right for the httpd (`xpcshell -g
50
+ * … -f …/server.js`) and wrong for everything that merely mentions such a
51
+ * path: an editor opened on `server.js`, a shell whose command text quotes
52
+ * it, a `grep` over the objdir. Report-only, that printed a misleading
53
+ * `kill`; under the reap posture it terminated the operator's shell.
54
+ */
55
+ export declare function matchesHarnessHelper(command: string): boolean;
56
+ /** Objdir provenance: an explicit objdir path, a `obj-…` path segment, or `_tests/`. */
57
+ export declare function isObjdirAnchored(command: string, objDir: string | undefined): boolean;
39
58
  /**
40
59
  * Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
41
60
  * helpers. Pure, so it is fixture-testable without spawning anything.
42
61
  *
62
+ * FireForge's own process, its direct children and its ancestors are never
63
+ * candidates: the terminal shell FireForge runs under can carry the helper
64
+ * paths in its own command text, and must never be offered for a kill.
65
+ *
43
66
  * @param psOutput - Raw `ps` output
44
67
  * @param objDir - Absolute objdir of this project, when known. Widens the
45
68
  * provenance test beyond the generic `obj-…` path segment
46
- * @param selfPid - This process's pid, excluded so FireForge cannot report
47
- * itself
69
+ * @param selfPid - This process's pid
48
70
  */
49
71
  export declare function findOrphanedHarnessProcesses(psOutput: string, objDir?: string, selfPid?: number): OrphanedHarnessProcess[];
50
72
  /**
@@ -66,8 +88,31 @@ export declare function formatOrphanReport(orphans: readonly OrphanedHarnessProc
66
88
  * @param objDir - Absolute objdir of this project, when known
67
89
  * @param options - `reap` terminates each recognized survivor (SIGTERM,
68
90
  * then SIGKILL for anything that stays)
69
- * @returns The census, empty when nothing was found or nothing could be probed
91
+ * @returns The census (empty when nothing was found or nothing could be
92
+ * probed) and how many of them are confirmed gone after reaping (0 in
93
+ * report-only mode)
70
94
  */
71
95
  export declare function reportOrphanedHarnessProcesses(objDir: string | undefined, options?: {
72
96
  reap?: boolean;
73
- }): Promise<OrphanedHarnessProcess[]>;
97
+ }): Promise<OrphanCensus>;
98
+ /** Result of {@link reportOrphanedHarnessProcesses}. */
99
+ export interface OrphanCensus {
100
+ orphans: OrphanedHarnessProcess[];
101
+ /** Survivors confirmed gone after the reap. Always 0 in report-only mode. */
102
+ reaped: number;
103
+ }
104
+ /** Liveness probe: `kill(pid, 0)` throws for a pid that is gone. */
105
+ export declare function isPidAlive(pid: number): boolean;
106
+ /**
107
+ * Terminates each entry: SIGTERM first, then SIGKILL for anything still
108
+ * alive after the grace period. Failures are reported, never thrown. A
109
+ * process that exited between the listing and the signal is the common case
110
+ * rather than an error.
111
+ *
112
+ * @param targets - Processes to terminate, already attributed to the harness
113
+ * by the caller (objdir anchor rule)
114
+ * @param label - Prefix for the log lines, naming which path is reaping
115
+ * (`--reap-orphans` at preflight, `teardown` after mach exits)
116
+ * @returns How many targets are confirmed gone afterwards
117
+ */
118
+ export declare function terminateHarnessProcesses(targets: readonly Pick<OrphanedHarnessProcess, 'pid'>[], label: string): Promise<number>;