@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
@@ -32,15 +32,39 @@ import { reportPatchLintOutcome } from './export-shared.js';
32
32
  import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
33
33
  import { projectEntryBody } from './patch/entry-projection.js';
34
34
  import { formatForwardImportRemedy } from './re-export-scan.js';
35
- /** Builds the once-per-invocation lint context. */
36
- export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
35
+ /**
36
+ * Builds the once-per-invocation lint context.
37
+ *
38
+ * The checkJs program is rooted at the patches being re-exported, exactly as
39
+ * `lint --per-patch --patches` roots it at its subset: the rest of the
40
+ * queue stays resolvable, so cross-patch imports type-check identically,
41
+ * but nothing else is parsed or checked. Unscoped, a one-patch re-export
42
+ * that missed the lint cache type-checked the whole queue (76 s on a
43
+ * 401-patch queue) to report on one patch.
44
+ *
45
+ * @param projectRoot - FireForge project root
46
+ * @param paths - Resolved project paths
47
+ * @param config - Loaded configuration
48
+ * @param noCache - Skip the per-patch lint result cache (`--no-cache`)
49
+ * @param scopePatches - Filenames of the patches this run re-exports
50
+ */
51
+ export async function buildReExportLintContext(projectRoot, paths, config, noCache, scopePatches) {
37
52
  const patchQueueCtx = (await pathExists(paths.patches))
38
53
  ? await buildPatchQueueContext(paths.patches, config)
39
54
  : undefined;
40
55
  const checkJs = patchQueueCtx
41
- ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx)
56
+ ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx, scopePatches)
42
57
  : undefined;
43
58
  const cache = noCache ? undefined : await loadPerPatchLintCache(projectRoot);
59
+ if (patchQueueCtx) {
60
+ // The queue rules and the projection below read every patch, so the
61
+ // wait is named rather than left behind one spinner line.
62
+ info(`Lint scope: queue rules over all ${patchQueueCtx.entries.length} patch(es); ` +
63
+ `checkJs rooted at the ${scopePatches.size} re-exported patch(es); ` +
64
+ (cache === undefined
65
+ ? 'lint cache off (--no-cache).'
66
+ : `lint cache ${Object.keys(cache.entries).length === 0 ? 'empty' : 'loaded'}.`));
67
+ }
44
68
  const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
45
69
  return {
46
70
  projectRoot,
@@ -123,7 +147,12 @@ export async function lintReExportedPatch(args) {
123
147
  lintOptions.onSuppressed = (suppressed) => suppressedIssues.push(...suppressed);
124
148
  if (checkJs) {
125
149
  const created = checkJsFilesInDiff(diffContent, checkJs);
126
- const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f));
150
+ if (created.size > 0) {
151
+ info(`checkJs: type-checking ${created.size} file(s) of ${patch.filename} (lint cache ${cache ? 'miss' : 'off'}).`);
152
+ }
153
+ // A file the scoped program does not root at would be resolvable but
154
+ // never checked, so it takes the per-patch build too.
155
+ const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f) && (checkJs.rootScope?.has(f) ?? true));
127
156
  if (allKnown) {
128
157
  // Slice this patch's findings out of the one queue-wide program.
129
158
  lintOptions.precomputedCheckJs = await checkJs.sliceFor(created);
@@ -132,12 +132,14 @@ export async function reExportCommand(projectRoot, patches, options) {
132
132
  const driftCtx = buildForeignDriftContext(options);
133
133
  // Hoisted lint context, one per run: queue context + checkJs
134
134
  // program + per-patch result cache, shared across every loop iteration.
135
- const lintCtx = await buildReExportLintContext(projectRoot, paths, config, options.noCache === true);
135
+ // Taken before the context build, so the elapsed time the progress line
136
+ // reports includes it.
137
+ const startedAt = Date.now();
138
+ const lintCtx = await buildReExportLintContext(projectRoot, paths, config, options.noCache === true, new Set(selectedPatches.map((patch) => patch.filename)));
136
139
  let reExported = 0;
137
140
  const reExportedFilenames = [];
138
141
  const failedFilenames = [];
139
142
  const progress = spinner('Preparing re-export...');
140
- const startedAt = Date.now();
141
143
  await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, async () => {
142
144
  for (const [index, patch] of selectedPatches.entries()) {
143
145
  const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
@@ -22,10 +22,21 @@ export interface DirtyEngineConfirmationOptions {
22
22
  warningMessage: string;
23
23
  promptMessage: string;
24
24
  cancelMessage: string;
25
+ /**
26
+ * Precomputed dirtiness. A caller that already measured what would be
27
+ * lost (and may count more than the working tree, such as commits made
28
+ * on top of the base) passes it here instead of the `hasChanges` probe.
29
+ */
30
+ dirty?: boolean;
31
+ /**
32
+ * Replaces the generic "Engine has uncommitted changes" opening of the
33
+ * non-interactive refusal, so the refusal names what would be lost.
34
+ */
35
+ refusalDetail?: string;
25
36
  }
26
37
  /**
27
38
  * Checks if the engine has uncommitted changes and prompts for confirmation.
28
39
  * Returns true if safe to proceed, false if the user cancelled.
29
40
  * Throws in non-interactive mode without --yes.
30
41
  */
31
- export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
42
+ export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, dirty, refusalDetail, }: DirtyEngineConfirmationOptions): Promise<boolean>;
@@ -12,13 +12,16 @@ import { cancel, isCancel, warn } from '../../utils/logger.js';
12
12
  * Returns true if safe to proceed, false if the user cancelled.
13
13
  * Throws in non-interactive mode without --yes.
14
14
  */
15
- export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }) {
16
- if (!(await hasChanges(engineDir)) || yes) {
15
+ export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, dirty, refusalDetail, }) {
16
+ if (yes || !(dirty ?? (await hasChanges(engineDir)))) {
17
17
  return true;
18
18
  }
19
19
  const isInteractive = stdioIsInteractive();
20
20
  if (!isInteractive) {
21
- throw new InvalidArgumentError(`Engine has uncommitted changes and interactive confirmation is not available. Run: ${nonInteractiveCommand}`, argumentName);
21
+ const opening = refusalDetail !== undefined
22
+ ? `${refusalDetail} Interactive confirmation is not available.`
23
+ : 'Engine has uncommitted changes and interactive confirmation is not available.';
24
+ throw new InvalidArgumentError(`${opening} Run: ${nonInteractiveCommand}`, argumentName);
22
25
  }
23
26
  warn(warningMessage);
24
27
  const confirmed = await confirm({
@@ -11,19 +11,20 @@
11
11
  * Supports `--continue` (resume after manual fix) and `--abort` (cancel).
12
12
  */
13
13
  import { Command } from 'commander';
14
- import { getProjectPaths, loadConfig } from '../../core/config.js';
14
+ import { getProjectPaths, loadConfig, loadState } from '../../core/config.js';
15
15
  import { assertEngineGitReady } from '../../core/engine-precondition.js';
16
16
  import { clearAppliedFurnaceState } from '../../core/furnace-config.js';
17
17
  import { getHead, resetChanges } from '../../core/git.js';
18
18
  import { discoverPatches } from '../../core/patch-files.js';
19
19
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
20
20
  import { getPatchSourceVersion } from '../../core/patch-source-metadata.js';
21
+ import { replayQueueIndexOnly } from '../../core/rebase-dry-run.js';
21
22
  import { getRebaseSessionPath, readRebaseSession, saveRebaseSession, } from '../../core/rebase-session.js';
22
23
  import { GeneralError } from '../../errors/base.js';
23
- import { CorruptRebaseSessionError, RebaseSessionExistsError } from '../../errors/rebase.js';
24
- import { info, intro, outro, spinner } from '../../utils/logger.js';
24
+ import { CorruptRebaseSessionError, RebaseDryRunRejectError, RebaseSessionExistsError, } from '../../errors/rebase.js';
25
+ import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
25
26
  import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../../utils/options.js';
26
- import { parsePositiveIntegerFlag } from '../../utils/validation.js';
27
+ import { compareFirefoxVersions, parsePositiveIntegerFlag } from '../../utils/validation.js';
27
28
  import { handleAbort } from './abort.js';
28
29
  import { confirmDirtyEngineReset } from './confirm.js';
29
30
  import { handleContinue } from './continue.js';
@@ -57,23 +58,51 @@ async function handleFreshStart(projectRoot, options) {
57
58
  if (!manifest || manifest.patches.length === 0) {
58
59
  throw new GeneralError('No patches found in manifest. Nothing to rebase.');
59
60
  }
60
- // Determine the "from" version from the patches
61
- const patchVersions = new Set(manifest.patches.map((p) => getPatchSourceVersion(p)));
62
- const patchProducts = new Set(manifest.patches.map((p) => p.sourceProduct).filter(Boolean));
63
- const sortedVersions = [...patchVersions].sort();
61
+ // Determine the "from" version from the patch stamps. A queue can carry
62
+ // several: patches re-exported since the last hop are stamped newer than
63
+ // untouched ones. The oldest stamp is "from", compared semantically (a
64
+ // lexical sort put 153.10.0esr before 153.2.0esr).
65
+ const versionCounts = new Map();
66
+ for (const patch of manifest.patches) {
67
+ const version = getPatchSourceVersion(patch);
68
+ versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
69
+ }
70
+ const sortedVersions = [...versionCounts.keys()].sort(compareFirefoxVersions);
64
71
  const fromVersion = sortedVersions[0] ?? currentVersion;
65
- const fromProduct = [...patchProducts].sort()[0] ?? config.firefox.product;
66
- if (patchVersions.size === 1 && fromVersion === currentVersion) {
72
+ const fromProduct = manifest.patches.find((p) => getPatchSourceVersion(p) === fromVersion)?.sourceProduct ??
73
+ config.firefox.product;
74
+ if (versionCounts.size === 1 && fromVersion === currentVersion) {
67
75
  info('All patches already match the current Firefox version. Nothing to rebase.');
68
76
  outro('Rebase not needed');
69
77
  return;
70
78
  }
71
79
  info(`Rebasing patches: ${fromVersion} → ${currentVersion}`);
80
+ if (versionCounts.size > 1) {
81
+ info(`Patch stamps: ${sortedVersions.map((v) => `${versionCounts.get(v)} at ${v}`).join(', ')}`);
82
+ }
83
+ // The rebase replays onto whatever engine/ holds. The pin is the target,
84
+ // so an engine still on another version means the download step has not
85
+ // run yet and every verdict below would be against the wrong tree.
86
+ const engineVersion = (await loadState(projectRoot)).downloadedVersion;
87
+ if (engineVersion !== undefined && engineVersion !== currentVersion) {
88
+ warn(`engine/ holds ${engineVersion}, not the pinned target ${currentVersion}. ` +
89
+ 'Run "fireforge download --force" first, or the rebase applies onto the old source.');
90
+ }
72
91
  info(`Found ${manifest.patches.length} patch(es)`);
73
92
  info(`Max context-reduction steps (fuzz-like): ${maxFuzz}`);
74
93
  if (isDryRun) {
75
- info('[dry-run] Would reset engine, apply patches with context reduction, and re-export.');
76
- outro('Dry run complete');
94
+ const patches = await discoverPatches(paths.patches);
95
+ const replaySpinner = spinner(`Replaying ${patches.length} patch(es) on a private index...`);
96
+ let replay;
97
+ try {
98
+ replay = await replayQueueIndexOnly(paths.engine, patches, maxFuzz);
99
+ replaySpinner.stop('Replay finished; engine/ untouched');
100
+ }
101
+ catch (error) {
102
+ replaySpinner.error('Replay failed');
103
+ throw error;
104
+ }
105
+ reportDryRunReplay(replay);
77
106
  return;
78
107
  }
79
108
  if (!(await confirmDirtyEngineReset({
@@ -114,6 +143,41 @@ async function handleFreshStart(projectRoot, options) {
114
143
  // Run the patch loop
115
144
  await runPatchLoop(projectRoot, session, paths, maxFuzz, resolveWaitLockSeconds(options.waitLock));
116
145
  }
146
+ /**
147
+ * Prints one line per patch and a tally, then fails the run when any patch
148
+ * rejects: a dry run that exits 0 must mean the real rebase would apply the
149
+ * whole queue.
150
+ */
151
+ function reportDryRunReplay(replay) {
152
+ let reduced = 0;
153
+ const rejected = [];
154
+ for (const [index, verdict] of replay.verdicts.entries()) {
155
+ const cascade = replay.firstRejectIndex !== undefined && index > replay.firstRejectIndex
156
+ ? ' (after an earlier reject; may cascade from it)'
157
+ : '';
158
+ switch (verdict.outcome) {
159
+ case 'clean':
160
+ info(` clean ${verdict.filename}`);
161
+ break;
162
+ case 'reduced-context':
163
+ reduced += 1;
164
+ info(` reduced context ${verdict.filename} (step ${verdict.step}, ${verdict.contextArg})`);
165
+ break;
166
+ case 'reject': {
167
+ rejected.push(verdict.filename);
168
+ const files = verdict.files.length > 0 ? verdict.files.join(', ') : 'see git output';
169
+ warn(` reject ${verdict.filename}: ${files}${cascade}`);
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ const clean = replay.verdicts.length - reduced - rejected.length;
175
+ info(`[dry-run] ${replay.verdicts.length} patch(es): ${clean} clean, ${reduced} with reduced context, ${rejected.length} rejected.`);
176
+ if (rejected.length > 0) {
177
+ throw new RebaseDryRunRejectError(rejected);
178
+ }
179
+ outro('Dry run complete: every patch applies');
180
+ }
117
181
  // ── Public API ──
118
182
  /**
119
183
  * Runs the rebase command to orchestrate a Firefox source version upgrade.
@@ -136,7 +200,7 @@ export function registerRebase(program, { getProjectRoot, withErrorHandling }) {
136
200
  .description('Semi-automated Firefox source version upgrade — apply patches with drift tolerance and re-export')
137
201
  .option('--continue', 'Resume after manually resolving a failed patch')
138
202
  .option('--abort', 'Cancel the rebase and restore engine to pre-rebase state')
139
- .option('--dry-run', 'Show what would happen without modifying anything')
203
+ .option('--dry-run', 'Replay the queue onto the engine base commit in a private index and report each patch as clean, reduced-context or reject (exit 6 on any reject); engine/ is not touched')
140
204
  .option('--max-fuzz <n>', 'Maximum context-reduction steps for git apply -C<n> (fuzz-like drift tolerance; default: 3)', commanderArgParser((v) => parsePositiveIntegerFlag('--max-fuzz', v)))
141
205
  .option('-y, --yes', 'Skip dirty-tree confirmation prompt');
142
206
  addWaitLockOption(command).action(withErrorHandling(async (options) => {
@@ -8,6 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
8
8
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
9
9
  import { hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke } from '../core/mach.js';
10
10
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
11
+ import { resolveRunProfile, sweepAbandonedRunProfiles, } from '../core/run-profile.js';
11
12
  import { compileAllowlistFromFile, compileAllowlistFromStrings, findOverCapAllowlistEntries, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
12
13
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
14
  import { BuildError } from '../errors/build.js';
@@ -97,12 +98,38 @@ export async function runCommand(projectRoot, options = {}) {
97
98
  await warnIfFurnaceStale(projectRoot);
98
99
  // Clean stale profile state to prevent silent startup failures
99
100
  await cleanDevProfile(paths.engine);
100
- if (options.smokeExit !== undefined) {
101
- await runSmokeExit(paths.engine, options);
102
- return;
101
+ // Reclaim temporary profiles a SIGKILLed FireForge could not remove.
102
+ const swept = await sweepAbandonedRunProfiles();
103
+ if (swept > 0)
104
+ verbose(`Removed ${swept} abandoned temporary run profile(s).`);
105
+ const profile = await resolveRunProfile({
106
+ profile: options.profile,
107
+ tempProfile: options.tempProfile,
108
+ smoke: options.smokeExit !== undefined,
109
+ });
110
+ try {
111
+ if (options.smokeExit !== undefined) {
112
+ await runSmokeExit(paths.engine, options, profile);
113
+ return;
114
+ }
115
+ info('Launching browser...\n');
116
+ await runInteractive(paths.engine, options, profile);
117
+ }
118
+ finally {
119
+ await profile.dispose();
103
120
  }
104
- info('Launching browser...\n');
105
- const exitCode = await run(paths.engine, options.headless ? ['--headless'] : []);
121
+ }
122
+ /**
123
+ * Launches the browser under `mach run` for an interactive session.
124
+ * @param engineDir - Path to the engine directory
125
+ * @param options - Run options
126
+ * @param profile - The profile this run launches with
127
+ */
128
+ async function runInteractive(engineDir, options, profile) {
129
+ const exitCode = await run(engineDir, [
130
+ ...(options.headless ? ['--headless'] : []),
131
+ ...profile.args,
132
+ ]);
106
133
  // Exit-code whitelist:
107
134
  // 0: clean shutdown
108
135
  // 130: SIGINT (Ctrl+C), user-initiated termination
@@ -121,7 +148,7 @@ export async function runCommand(projectRoot, options = {}) {
121
148
  * exit contract. The deadline-fires-SIGTERM path is treated as a clean
122
149
  * window iff no unallowed errors were observed.
123
150
  */
124
- async function runSmokeExit(engineDir, options) {
151
+ async function runSmokeExit(engineDir, options, profile) {
125
152
  // Windows lacks the POSIX process-group primitives --smoke-exit leans on to
126
153
  // SIGTERM the whole mach → python → firefox tree. Running through anyway
127
154
  // would only kill the top-level wrapper and orphan Firefox content
@@ -194,10 +221,17 @@ async function runSmokeExit(engineDir, options) {
194
221
  };
195
222
  warnIfHeadedOnDesktop(options.headless === true);
196
223
  info(`Launching browser (smoke-exit after ${smokeExit}s)...\n`);
224
+ if (profile.temporary) {
225
+ info(`Profile: fresh temporary profile (removed after the run; --profile <path> to use a named one)`);
226
+ }
227
+ else if (profile.dir !== undefined) {
228
+ info(`Profile: ${profile.dir}`);
229
+ }
197
230
  const startedAt = Date.now();
198
231
  let result;
199
232
  try {
200
- result = await runMachSmoke(options.headless ? ['run', '--headless'] : ['run'], engineDir, {
233
+ const machArgs = ['run', ...(options.headless ? ['--headless'] : []), ...profile.args];
234
+ result = await runMachSmoke(machArgs, engineDir, {
201
235
  smokeTimeoutMs,
202
236
  onStdoutLine: (line) => {
203
237
  handleLine('stdout', line);
@@ -358,6 +392,8 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
358
392
  .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored. A "# max-hits: N" comment directly above an entry caps how many lines it may match in one run; more than that fails the run with exit 12.')
359
393
  .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
360
394
  .option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
395
+ .option('--profile <path>', 'Launch with this profile directory (passed to Firefox as -profile). Left in place after the run. With --smoke-exit it replaces the default fresh profile.')
396
+ .option('--temp-profile', 'Launch with a fresh temporary profile that is removed when the run ends. The default for --smoke-exit.')
361
397
  .action(withErrorHandling(
362
398
  // `args` is the variadic positional, not a Commander flag, so it
363
399
  // never appears in this object.
@@ -6,7 +6,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
6
6
  import { formatEngineSessionLockStatus, readEngineSessionLockStatus, } from '../core/engine-session-lock.js';
7
7
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
8
8
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
9
- import { getUntrackedFilesInDir, resolveMaxUntrackedFilesPerDir } from '../core/git-status.js';
9
+ import { listUntrackedFilesInDirs, resolveMaxUntrackedFilesPerDir } from '../core/git-status.js';
10
10
  import { renderOwnershipTable } from '../core/ownership-table.js';
11
11
  import { loadPatchesManifest } from '../core/patch-manifest.js';
12
12
  import { readProcessCpuSeconds } from '../core/process-cpu-time.js';
@@ -49,9 +49,11 @@ function renderTruncationBanner(truncations) {
49
49
  async function expandDirectoryEntries(files, engineDir) {
50
50
  const expanded = [];
51
51
  const truncations = [];
52
+ const isCollapsedUntracked = (entry) => entry.file.endsWith('/') && entry.status.includes('?');
53
+ const untrackedByDir = await listUntrackedFilesInDirs(engineDir, files.filter(isCollapsedUntracked).map((entry) => entry.file));
52
54
  for (const entry of files) {
53
- if (entry.file.endsWith('/') && entry.status.includes('?')) {
54
- const individualFiles = await getUntrackedFilesInDir(engineDir, entry.file);
55
+ if (isCollapsedUntracked(entry)) {
56
+ const individualFiles = untrackedByDir.get(entry.file) ?? [];
55
57
  const cap = getMaxUntrackedFilesPerDir();
56
58
  if (individualFiles.length > cap) {
57
59
  // Recorded once here, reported once by renderTruncationBanner. The
@@ -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 {