@hominis/fireforge 0.37.0 → 0.38.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 (58) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +5 -5
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/download.js +5 -2
  6. package/dist/src/commands/export-flow.d.ts +5 -1
  7. package/dist/src/commands/export-flow.js +10 -7
  8. package/dist/src/commands/export-placement-gate.js +3 -5
  9. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  10. package/dist/src/commands/export-placement-policy.js +32 -29
  11. package/dist/src/commands/export.js +5 -5
  12. package/dist/src/commands/furnace/index.js +23 -18
  13. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  14. package/dist/src/commands/patch/move-files-create.js +116 -0
  15. package/dist/src/commands/patch/move-files.d.ts +3 -1
  16. package/dist/src/commands/patch/move-files.js +49 -4
  17. package/dist/src/commands/patch/split.d.ts +11 -1
  18. package/dist/src/commands/patch/split.js +6 -5
  19. package/dist/src/commands/re-export-scan.d.ts +17 -0
  20. package/dist/src/commands/re-export-scan.js +51 -0
  21. package/dist/src/commands/re-export.js +17 -6
  22. package/dist/src/commands/source.js +28 -3
  23. package/dist/src/commands/test-register.js +6 -5
  24. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  25. package/dist/src/commands/test-stale-gate.js +81 -0
  26. package/dist/src/commands/test.js +15 -50
  27. package/dist/src/core/build-audit.d.ts +12 -0
  28. package/dist/src/core/build-audit.js +14 -0
  29. package/dist/src/core/build-baseline-types.d.ts +34 -0
  30. package/dist/src/core/build-baseline.d.ts +6 -1
  31. package/dist/src/core/build-baseline.js +47 -7
  32. package/dist/src/core/config-paths.d.ts +1 -1
  33. package/dist/src/core/config-paths.js +1 -0
  34. package/dist/src/core/config-validate.js +6 -1
  35. package/dist/src/core/engine-session-lock.d.ts +10 -1
  36. package/dist/src/core/engine-session-lock.js +28 -2
  37. package/dist/src/core/file-lock.d.ts +33 -0
  38. package/dist/src/core/file-lock.js +32 -6
  39. package/dist/src/core/firefox-archive.d.ts +2 -1
  40. package/dist/src/core/firefox-archive.js +32 -8
  41. package/dist/src/core/firefox.d.ts +8 -3
  42. package/dist/src/core/firefox.js +11 -6
  43. package/dist/src/core/furnace-validate-helpers.js +28 -1
  44. package/dist/src/core/license-headers.d.ts +12 -0
  45. package/dist/src/core/license-headers.js +76 -1
  46. package/dist/src/core/patch-lint.d.ts +3 -0
  47. package/dist/src/core/patch-lint.js +25 -7
  48. package/dist/src/core/status-classify.d.ts +11 -2
  49. package/dist/src/core/status-classify.js +49 -25
  50. package/dist/src/core/test-stale-check.d.ts +41 -1
  51. package/dist/src/core/test-stale-check.js +77 -3
  52. package/dist/src/types/commands/options.d.ts +57 -3
  53. package/dist/src/types/config.d.ts +7 -0
  54. package/dist/src/utils/options.d.ts +20 -0
  55. package/dist/src/utils/options.js +39 -0
  56. package/dist/src/utils/validation.d.ts +5 -0
  57. package/dist/src/utils/validation.js +7 -0
  58. package/package.json +3 -3
@@ -10,17 +10,17 @@ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionette
10
10
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
11
11
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
12
12
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
13
- import { checkStaleBuildForTest, findUncoveredRequestPaths, formatStaleBuildWarning, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
14
13
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
15
14
  import { GeneralError } from '../errors/base.js';
16
15
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
17
16
  import { toError } from '../utils/errors.js';
18
17
  import { pathExists } from '../utils/fs.js';
19
- import { info, intro, outro, spinner, success, verbose, warn } from '../utils/logger.js';
18
+ import { info, intro, outro, spinner, success, verbose } from '../utils/logger.js';
20
19
  import { stripEnginePrefix } from '../utils/paths.js';
21
20
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
22
21
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
23
22
  import { DEFAULT_HARNESS_RETRIES, runShardedTests, runTestsWithRetries, } from './test-run.js';
23
+ import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
24
24
  async function assertTestPathsExist(engineDir, testPaths) {
25
25
  const missingPaths = [];
26
26
  for (const testPath of testPaths) {
@@ -135,9 +135,12 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
135
135
  // same files — in any invocation shape — is not refused. A failed
136
136
  // write never fails the run. The coverage claim is scoped to the
137
137
  // requested test paths, since a file-scoped `test --build` only
138
- // guarantees packaging for those manifests.
138
+ // guarantees packaging for those manifests. The previous baseline is
139
+ // passed through so a scoped write carries the static-components
140
+ // anchor forward (a `mach build faster` does not rebake
141
+ // components.conf into the compiled table).
139
142
  try {
140
- await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage);
143
+ await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, testPackagingCoverage, previousBaseline);
141
144
  }
142
145
  catch (baselineError) {
143
146
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -150,51 +153,6 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
150
153
  : 'Pre-test build failed', 'mach build faster');
151
154
  });
152
155
  }
153
- /**
154
- * Stale-build preflight — when `--build` was NOT requested, detect
155
- * packageable engine edits since the last successful build and fail
156
- * UP-FRONT unless the operator explicitly accepts the stale package risk.
157
- *
158
- * Packaging COVERAGE is checked first, on EVERY non-`--build` run and
159
- * regardless of staleness or `--allow-stale-build`: a runtime packaged by
160
- * a file-scoped `test --build` can lack support fixtures for OTHER
161
- * manifests even when nothing changed since — dispatching such a run
162
- * hangs on missing fixtures rather than failing, so the flag (which only
163
- * accepts stale CONTENT) must not be the trigger. Field incident: a
164
- * three-file scoped rebuild left `file_tiles_audio.html` unpackaged and a
165
- * later run over different files timed out twice at 45s waiting on
166
- * `DOMAudioPlaybackStarted`.
167
- *
168
- * Exception: a path-less `test --doctor` stops at the Marionette health
169
- * check (`runDoctorPreflight` returns 'stop' when no test paths were
170
- * given) and never dispatches a test, so it needs no packaging coverage —
171
- * treating it as a full-suite request would refuse a probe that touches
172
- * no fixtures. The stale-content refusal still applies to it unchanged.
173
- */
174
- async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
175
- const stale = await checkStaleBuildForTest(projectRoot, engineDir);
176
- const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
177
- if (!dispatchesNoTests) {
178
- const recordedCoverage = stale.baseline?.testPackagingCoverage;
179
- const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
180
- if (uncovered.length > 0) {
181
- throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
182
- }
183
- }
184
- const staleMessage = stale.stale
185
- ? `${formatStaleBuildWarning(stale)}\n\n` +
186
- 'Run `fireforge test --build` to refresh the packaged runtime first, or pass ' +
187
- '`--allow-stale-build` if you intentionally rebuilt out-of-band and accept the risk.'
188
- : undefined;
189
- if (staleMessage !== undefined) {
190
- if (options.allowStaleBuild === true) {
191
- warn(staleMessage);
192
- }
193
- else {
194
- throw new GeneralError(staleMessage);
195
- }
196
- }
197
- }
198
156
  function logTestSelection(scopes) {
199
157
  if (scopes.length > 0) {
200
158
  const labels = scopes.map((scope) => scope.isDirectory && scope.testFileCount > 0
@@ -366,8 +324,15 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
366
324
  // Run incremental build if requested
367
325
  if (options.build) {
368
326
  // A path-less `test --build` runs (and packages for) the full suite;
369
- // a scoped invocation only vouches for the requested paths.
327
+ // a scoped invocation only vouches for the requested paths. A SCOPED
328
+ // rebuild also cannot regenerate the compiled StaticComponents table,
329
+ // so it runs the components.conf gate up-front (the path-less shape
330
+ // refreshes the anchor itself and skips it).
370
331
  const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
332
+ if (normalizedPaths.length > 0) {
333
+ const previousBaseline = await readBuildBaseline(projectRoot);
334
+ await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
335
+ }
371
336
  await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, coverage);
372
337
  info('');
373
338
  }
@@ -36,6 +36,18 @@ export interface AuditSummary {
36
36
  * @returns True when the path implies packaging.
37
37
  */
38
38
  export declare function isPackageablePath(sourcePath: string): boolean;
39
+ /**
40
+ * Decides whether a source path is an XPCOM static-component manifest —
41
+ * i.e. a file whose registrations are baked into the compiled
42
+ * StaticComponents table at FULL-build time rather than packaged into
43
+ * `dist/`. This basename check is the single extension point for that
44
+ * classification; parsing `moz.build` `XPCOM_MANIFESTS` entries to follow
45
+ * renamed manifests is out of scope.
46
+ *
47
+ * @param sourcePath Engine-relative POSIX path.
48
+ * @returns True when the path is a `components.conf` manifest.
49
+ */
50
+ export declare function isXpcomManifestPath(sourcePath: string): boolean;
39
51
  /**
40
52
  * Runs the post-build audit. Emits per-file warnings for missing or
41
53
  * stale artifacts and a summary info line at the end. Always returns
@@ -111,6 +111,20 @@ export function isPackageablePath(sourcePath) {
111
111
  }
112
112
  return false;
113
113
  }
114
+ /**
115
+ * Decides whether a source path is an XPCOM static-component manifest —
116
+ * i.e. a file whose registrations are baked into the compiled
117
+ * StaticComponents table at FULL-build time rather than packaged into
118
+ * `dist/`. This basename check is the single extension point for that
119
+ * classification; parsing `moz.build` `XPCOM_MANIFESTS` entries to follow
120
+ * renamed manifests is out of scope.
121
+ *
122
+ * @param sourcePath Engine-relative POSIX path.
123
+ * @returns True when the path is a `components.conf` manifest.
124
+ */
125
+ export function isXpcomManifestPath(sourcePath) {
126
+ return basename(sourcePath.replace(/\\/g, '/')) === 'components.conf';
127
+ }
114
128
  /*
115
129
  * Finds the unique obj-star directory with a dist subtree, or undefined
116
130
  * when zero or multiple match. The ambiguous case is already rejected
@@ -55,8 +55,42 @@ export interface BuildBaseline {
55
55
  * The record is project-scoped, which is also per-obj-dir: multi-objdir
56
56
  * checkouts are refused up-front (`AmbiguousBuildArtifactsError`), so at
57
57
  * most one obj dir exists per project.
58
+ *
59
+ * Union/"shared coverage" across successive scoped builds was evaluated
60
+ * and rejected as unsound — every baseline write refreshes
61
+ * `packageableFingerprints` for ALL dirty packageable paths, so a union
62
+ * would whitewash an earlier scope's edited fixtures while
63
+ * `obj-*`/`_tests/` still holds its stale staging; coverage therefore
64
+ * REPLACES.
58
65
  */
59
66
  testPackagingCoverage?: TestPackagingCoverage;
67
+ /**
68
+ * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
69
+ * the last FULL-coverage build plus content fingerprints of the
70
+ * `components.conf` manifests that were dirty at that moment. Written
71
+ * fresh only on FULL-coverage baseline writes (`fireforge build`,
72
+ * `build --ui`, path-less `test --build`); a scoped `test --build`
73
+ * carries the previous record forward verbatim, because `mach build
74
+ * faster` does not rebake `components.conf` registrations into the
75
+ * compiled table. Missing on pre-0.38.0 baselines — the
76
+ * static-components stale check degrades to "fresh" in that case.
77
+ */
78
+ staticComponentsBaseline?: StaticComponentsBaseline;
79
+ }
80
+ /**
81
+ * State of the engine at the last FULL build, as far as compiled-in XPCOM
82
+ * component registration is concerned. See
83
+ * {@link BuildBaseline.staticComponentsBaseline}.
84
+ */
85
+ export interface StaticComponentsBaseline {
86
+ /** Engine HEAD SHA at the time of the last full build. */
87
+ engineHeadSha: string;
88
+ /**
89
+ * Hex-encoded SHA-256 per `components.conf` path that was dirty
90
+ * (modified-against-HEAD or untracked) when the full build ran. Keys are
91
+ * engine-relative POSIX paths.
92
+ */
93
+ fingerprints: Record<string, string>;
60
94
  }
61
95
  /** Coverage claim of the packaged test runtime. See {@link BuildBaseline.testPackagingCoverage}. */
62
96
  export type TestPackagingCoverage = 'full' | string[];
@@ -42,5 +42,10 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
42
42
  * @param testPackagingCoverage - Coverage claim of the packaged test
43
43
  * runtime this build produced (`'full'`, or the scoped request paths of
44
44
  * a `test --build` invocation). Omitted → field left off the marker.
45
+ * @param previousBaseline - The baseline this write replaces, when the
46
+ * caller has it. A scoped (non-`'full'`) write carries its
47
+ * `staticComponentsBaseline` forward verbatim — `mach build faster`
48
+ * does not rebake `components.conf` registrations, so the last FULL
49
+ * build stays the honest anchor for the compiled StaticComponents table.
45
50
  */
46
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage): Promise<void>;
51
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline): Promise<void>;
@@ -22,7 +22,7 @@ import { join } from 'node:path';
22
22
  import { toError } from '../utils/errors.js';
23
23
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
24
24
  import { verbose } from '../utils/logger.js';
25
- import { isPackageablePath } from './build-audit.js';
25
+ import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
26
26
  import { FIREFORGE_DIR } from './config-paths.js';
27
27
  import { getHead, hasChanges, isMissingHeadError } from './git.js';
28
28
  import { git } from './git-base.js';
@@ -68,8 +68,13 @@ export async function readBuildBaseline(projectRoot) {
68
68
  * @param testPackagingCoverage - Coverage claim of the packaged test
69
69
  * runtime this build produced (`'full'`, or the scoped request paths of
70
70
  * a `test --build` invocation). Omitted → field left off the marker.
71
+ * @param previousBaseline - The baseline this write replaces, when the
72
+ * caller has it. A scoped (non-`'full'`) write carries its
73
+ * `staticComponentsBaseline` forward verbatim — `mach build faster`
74
+ * does not rebake `components.conf` registrations, so the last FULL
75
+ * build stays the honest anchor for the compiled StaticComponents table.
71
76
  */
72
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage) {
77
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline) {
73
78
  let engineHeadSha = '';
74
79
  try {
75
80
  engineHeadSha = await getHead(engineDir);
@@ -84,12 +89,20 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
84
89
  }
85
90
  }
86
91
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
92
+ // A full-coverage write (and the legacy no-claim `fireforge build` shape)
93
+ // just recompiled the StaticComponents table, so it anchors the table to
94
+ // the current engine state. A scoped write did not — carry the previous
95
+ // anchor forward verbatim.
96
+ const staticComponentsBaseline = testPackagingCoverage === undefined || testPackagingCoverage === 'full'
97
+ ? await collectStaticComponentsBaseline(engineDir, engineHeadSha)
98
+ : previousBaseline?.staticComponentsBaseline;
87
99
  const baseline = {
88
100
  engineHeadSha,
89
101
  builtAt: new Date().toISOString(),
90
102
  binaryName,
91
103
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
92
104
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
105
+ ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
93
106
  };
94
107
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
95
108
  }
@@ -107,6 +120,33 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
107
120
  * back to the pre-0.16.0 "path-only" behavior on the next test run.
108
121
  */
109
122
  async function collectPackageableFingerprints(engineDir) {
123
+ return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
124
+ }
125
+ /**
126
+ * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
127
+ * this (full) build plus content fingerprints of the `components.conf`
128
+ * manifests dirty right now — the same dirty-path probe family as
129
+ * {@link collectPackageableFingerprints}, filtered to XPCOM manifests.
130
+ * Returns `undefined` on probe failure so a broken probe omits the field
131
+ * (the static-components stale check then degrades to "fresh") rather than
132
+ * anchoring to a garbage record.
133
+ */
134
+ async function collectStaticComponentsBaseline(engineDir, engineHeadSha) {
135
+ const fingerprints = await collectDirtyFingerprints(engineDir, isXpcomManifestPath, 'static-components fingerprint');
136
+ if (fingerprints === undefined) {
137
+ return undefined;
138
+ }
139
+ return { engineHeadSha, fingerprints };
140
+ }
141
+ /**
142
+ * Shared dirty-path fingerprint probe backing
143
+ * {@link collectPackageableFingerprints} and
144
+ * {@link collectStaticComponentsBaseline}: enumerates engine workdir
145
+ * modifications (tracked and untracked), keeps the paths `includePath`
146
+ * accepts, and hashes each. Returns `undefined` on any git failure so a
147
+ * broken probe never corrupts the on-disk baseline with `{}`.
148
+ */
149
+ async function collectDirtyFingerprints(engineDir, includePath, contextLabel) {
110
150
  try {
111
151
  const dirtyPaths = new Set();
112
152
  if (await hasChanges(engineDir)) {
@@ -120,12 +160,12 @@ async function collectPackageableFingerprints(engineDir) {
120
160
  dirtyPaths.add(untracked);
121
161
  }
122
162
  }
123
- const packageable = [...dirtyPaths].filter(isPackageablePath);
124
- if (packageable.length === 0) {
163
+ const included = [...dirtyPaths].filter(includePath);
164
+ if (included.length === 0) {
125
165
  return {};
126
166
  }
127
167
  const fingerprints = {};
128
- for (const relPath of packageable) {
168
+ for (const relPath of included) {
129
169
  try {
130
170
  const buffer = await readFile(join(engineDir, relPath));
131
171
  fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
@@ -134,13 +174,13 @@ async function collectPackageableFingerprints(engineDir) {
134
174
  // A file that disappeared between status probe and hash is
135
175
  // expected in concurrent scenarios; skip it without failing the
136
176
  // whole baseline write.
137
- verbose(`Build baseline: skipping fingerprint for ${relPath} — ${toError(fileError).message}`);
177
+ verbose(`Build baseline: skipping ${contextLabel} for ${relPath} — ${toError(fileError).message}`);
138
178
  }
139
179
  }
140
180
  return fingerprints;
141
181
  }
142
182
  catch (error) {
143
- verbose(`Build baseline: packageable fingerprint probe failed — ${toError(error).message}`);
183
+ verbose(`Build baseline: ${contextLabel} probe failed — ${toError(error).message}`);
144
184
  return undefined;
145
185
  }
146
186
  }
@@ -11,7 +11,7 @@ export declare const STATE_FILENAME = "state.json";
11
11
  /** Supported top-level fireforge.json keys backed by the current schema. */
12
12
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "test", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
13
13
  /** Supported config paths that can be read or set without --force. */
14
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
14
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
15
15
  /**
16
16
  * Gets all project paths based on a root directory.
17
17
  * @param root - Root directory of the project
@@ -45,6 +45,7 @@ export const SUPPORTED_CONFIG_PATHS = [
45
45
  'firefox.version',
46
46
  'firefox.product',
47
47
  'firefox.sha256',
48
+ 'firefox.candidate',
48
49
  'build',
49
50
  'build.jobs',
50
51
  'test',
@@ -6,7 +6,7 @@ import { ConfigError } from '../errors/config.js';
6
6
  import { verbose } from '../utils/logger.js';
7
7
  import { parseObject } from '../utils/parse.js';
8
8
  import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
- import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
9
+ import { isValidAppId, isValidFirefoxCandidate, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
11
  import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
12
12
  import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
@@ -81,10 +81,15 @@ function parseFirefoxBlock(rec) {
81
81
  if (firefoxSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(firefoxSha256)) {
82
82
  throw new ConfigError('Config field "firefox.sha256" must be a 64-character SHA-256 hex digest');
83
83
  }
84
+ const firefoxCandidate = optionalConfigString(firefoxRec, 'candidate', 'firefox.candidate');
85
+ if (firefoxCandidate !== undefined && !isValidFirefoxCandidate(firefoxCandidate)) {
86
+ throw new ConfigError('Config field "firefox.candidate" must look like "buildN" (e.g. "build2")');
87
+ }
84
88
  return {
85
89
  version: firefoxVersion,
86
90
  product: firefoxProduct,
87
91
  ...(firefoxSha256 !== undefined ? { sha256: firefoxSha256.toLowerCase() } : {}),
92
+ ...(firefoxCandidate !== undefined ? { candidate: firefoxCandidate } : {}),
88
93
  };
89
94
  }
90
95
  /** Parses the optional `build` block (currently just `build.jobs`). */
@@ -1,7 +1,16 @@
1
+ export interface EngineSessionLockOptions {
2
+ /**
3
+ * Wait up to this many seconds for the engine session lock instead of the
4
+ * legacy ~1 s fail-fast. Enables exponential poll backoff (100 ms → 2 s)
5
+ * and a holder-identified progress line roughly every 5 s. `undefined`
6
+ * preserves the historical fail-fast behavior exactly.
7
+ */
8
+ waitLockSeconds?: number | undefined;
9
+ }
1
10
  /**
2
11
  *
3
12
  */
4
- export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>): Promise<T>;
13
+ export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
5
14
  /**
6
15
  *
7
16
  */
@@ -1,19 +1,45 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { GeneralError } from '../errors/base.js';
4
+ import { info } from '../utils/logger.js';
4
5
  import { withFileLock } from './file-lock.js';
5
6
  import { git } from './git-base.js';
6
7
  const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
8
+ /**
9
+ * Formats the periodic operator-facing waiting line printed while `--wait-lock`
10
+ * polls a contended engine session lock. The holder identification comes from
11
+ * the lock's owner-metadata lines (`command=…`, `started=…`); an unreadable
12
+ * owner file degrades to the anonymous form.
13
+ */
14
+ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
15
+ const progress = `${String(Math.round(waitedMs / 1000))}s of up to ${String(Math.round(timeoutMs / 1000))}s`;
16
+ if (holder === undefined) {
17
+ return `Waiting for the FireForge engine lock — ${progress}.`;
18
+ }
19
+ const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
20
+ return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
21
+ }
7
22
  /**
8
23
  *
9
24
  */
10
- export async function withEngineSessionLock(projectRoot, command, operation) {
25
+ export async function withEngineSessionLock(projectRoot, command, operation, options = {}) {
11
26
  if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
12
27
  (process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
13
28
  return operation();
14
29
  }
30
+ const { waitLockSeconds } = options;
15
31
  return withFileLock(join(projectRoot, ENGINE_SESSION_LOCK_PATH), operation, {
16
- timeoutMs: 1000,
32
+ timeoutMs: waitLockSeconds !== undefined ? waitLockSeconds * 1000 : 1000,
33
+ ...(waitLockSeconds !== undefined
34
+ ? {
35
+ pollMs: 100,
36
+ pollMaxMs: 2000,
37
+ waitProgressMs: 5000,
38
+ onWaitProgress: ({ waitedMs, timeoutMs, holder }) => {
39
+ info(formatWaitProgressLine(waitedMs, timeoutMs, holder));
40
+ },
41
+ }
42
+ : {}),
17
43
  ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
18
44
  onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
19
45
  `Wait for it to finish, then retry \`${command}\`.`,
@@ -1,6 +1,23 @@
1
+ /**
2
+ * Snapshot of the current lock holder handed to {@link FileLockOptions.onWaitProgress}.
3
+ * `metadata` carries the pid file's human-diagnostic lines (line 3 onward,
4
+ * e.g. `command=build`, `started=…`) written via
5
+ * {@link FileLockOptions.ownerMetadata}.
6
+ */
7
+ export interface LockHolder {
8
+ pid: number;
9
+ alive: boolean;
10
+ metadata: string[];
11
+ }
1
12
  export interface FileLockOptions {
2
13
  timeoutMs?: number;
3
14
  pollMs?: number;
15
+ /**
16
+ * When set, the poll interval doubles after every contended poll (starting
17
+ * from `pollMs`) up to this cap. When absent, polling stays at the fixed
18
+ * `pollMs` interval — zero behavior change for existing callers.
19
+ */
20
+ pollMaxMs?: number;
4
21
  staleMs?: number;
5
22
  /**
6
23
  * Interval between stale-lock probes while waiting (defaults to
@@ -8,6 +25,22 @@ export interface FileLockOptions {
8
25
  * the mid-wait reaping path without multi-second sleeps.
9
26
  */
10
27
  staleReprobeMs?: number;
28
+ /**
29
+ * Interval between {@link onWaitProgress} callbacks while the lock is
30
+ * contended. Progress reporting is off unless both this and
31
+ * `onWaitProgress` are set.
32
+ */
33
+ waitProgressMs?: number;
34
+ /**
35
+ * Invoked roughly every `waitProgressMs` while waiting for a contended
36
+ * lock. `holder` is `undefined` when the owner PID file is missing or
37
+ * unreadable.
38
+ */
39
+ onWaitProgress?: (progress: {
40
+ waitedMs: number;
41
+ timeoutMs: number;
42
+ holder: LockHolder | undefined;
43
+ }) => void;
11
44
  onTimeoutMessage?: string;
12
45
  onStaleLockMessage?: (ageMs: number) => string | undefined;
13
46
  /** Extra owner-file lines for human diagnostics; ignored by lock mechanics. */
@@ -50,7 +50,8 @@ function isProcessAlive(pid) {
50
50
  }
51
51
  }
52
52
  /**
53
- * Reads the owner PID (line 1) and acquisition token (line 2) from a lock
53
+ * Reads the owner PID (line 1), acquisition token (line 2), and diagnostic
54
+ * metadata (lines 3+, see {@link FileLockOptions.ownerMetadata}) from a lock
54
55
  * directory's PID file. Returns `{ present: false }` when the PID file is
55
56
  * missing or the PID does not parse as a finite integer (caller falls back
56
57
  * to the age-only staleness heuristic).
@@ -62,14 +63,15 @@ function isProcessAlive(pid) {
62
63
  async function readLockOwner(lockPath) {
63
64
  try {
64
65
  const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
65
- const [pidLine, tokenLine] = pidContent.split('\n');
66
+ const [pidLine, tokenLine, ...metadataLines] = pidContent.split('\n');
66
67
  const pid = parseInt((pidLine ?? '').trim(), 10);
67
68
  if (Number.isFinite(pid)) {
69
+ const metadata = metadataLines.map((line) => line.trim()).filter((line) => line.length > 0);
68
70
  const token = tokenLine?.trim();
69
71
  if (token !== undefined && token.length > 0) {
70
- return { present: true, pid, token };
72
+ return { present: true, pid, token, metadata };
71
73
  }
72
- return { present: true, pid };
74
+ return { present: true, pid, metadata };
73
75
  }
74
76
  }
75
77
  catch {
@@ -77,6 +79,18 @@ async function readLockOwner(lockPath) {
77
79
  }
78
80
  return { present: false };
79
81
  }
82
+ /**
83
+ * Reads the contended lock's owner and reports wait progress to the caller's
84
+ * `onWaitProgress` callback. Failure to read the owner degrades to
85
+ * `holder: undefined` — progress reporting must never break the wait loop.
86
+ */
87
+ async function reportWaitProgress(lockPath, waitedMs, timeoutMs, onWaitProgress) {
88
+ const owner = await readLockOwner(lockPath);
89
+ const holder = owner.present
90
+ ? { pid: owner.pid, alive: isProcessAlive(owner.pid), metadata: owner.metadata }
91
+ : undefined;
92
+ onWaitProgress({ waitedMs, timeoutMs, holder });
93
+ }
80
94
  /**
81
95
  * Reaps a lock directory believed to be stale, safely against concurrent
82
96
  * reapers and a concurrent legitimate re-acquisition.
@@ -216,8 +230,11 @@ export async function withFileLock(lockPath, operation, options = {}) {
216
230
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
217
231
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
218
232
  const staleReprobeMs = options.staleReprobeMs ?? STALE_REPROBE_INTERVAL_MS;
219
- const deadline = Date.now() + timeoutMs;
233
+ const startedAt = Date.now();
234
+ const deadline = startedAt + timeoutMs;
220
235
  let lastStaleProbeAt;
236
+ let currentPollMs = pollMs;
237
+ let lastProgressAt = startedAt;
221
238
  await ensureDir(dirname(lockPath));
222
239
  for (;;) {
223
240
  try {
@@ -246,7 +263,16 @@ export async function withFileLock(lockPath, operation, options = {}) {
246
263
  : ' If no other FireForge process is running, remove the lock directory and retry.';
247
264
  throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
248
265
  }
249
- await sleep(pollMs);
266
+ if (options.onWaitProgress !== undefined &&
267
+ options.waitProgressMs !== undefined &&
268
+ Date.now() - lastProgressAt >= options.waitProgressMs) {
269
+ lastProgressAt = Date.now();
270
+ await reportWaitProgress(lockPath, Date.now() - startedAt, timeoutMs, options.onWaitProgress);
271
+ }
272
+ await sleep(currentPollMs);
273
+ if (options.pollMaxMs !== undefined) {
274
+ currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
275
+ }
250
276
  }
251
277
  }
252
278
  // Stamp ownership (PID + per-acquisition token) into the lock directory so
@@ -39,6 +39,7 @@ export declare function validateArchiveMetadata(data: unknown): ArchiveMetadata;
39
39
  * Resolves archive identity for URL generation and cache storage.
40
40
  * @param version - Requested Firefox version
41
41
  * @param product - Firefox product type
42
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
42
43
  * @returns Resolved archive descriptor
43
44
  */
44
- export declare function resolveArchive(version: string, product?: FirefoxProduct): ResolvedArchive;
45
+ export declare function resolveArchive(version: string, product?: FirefoxProduct, candidate?: string): ResolvedArchive;
@@ -4,15 +4,26 @@
4
4
  */
5
5
  import { ConfigError } from '../errors/config.js';
6
6
  import { parseObject } from '../utils/parse.js';
7
- import { isValidFirefoxProduct } from '../utils/validation.js';
7
+ import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
8
8
  /**
9
9
  * Base URLs for Firefox source archives on archive.mozilla.org.
10
10
  */
11
- const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
12
- const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition/releases';
11
+ const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox';
12
+ const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition';
13
13
  function getArchiveBaseUrl(product) {
14
14
  return product === 'firefox-devedition' ? DEVEDITION_ARCHIVE_BASE_URL : FIREFOX_ARCHIVE_BASE_URL;
15
15
  }
16
+ /**
17
+ * Directory holding the release's artifacts: `releases/<version>/` for
18
+ * final releases, `candidates/<version>-candidates/<buildN>/` when a
19
+ * release-candidate build is requested for pre-release verification.
20
+ */
21
+ function getArchiveDirUrl(product, archiveVersion, candidate) {
22
+ const base = getArchiveBaseUrl(product);
23
+ return candidate === undefined
24
+ ? `${base}/releases/${archiveVersion}`
25
+ : `${base}/candidates/${archiveVersion}-candidates/${candidate}`;
26
+ }
16
27
  /**
17
28
  * Validates raw JSON data as ArchiveMetadata.
18
29
  * @param data - Unknown data to validate
@@ -41,13 +52,20 @@ export function validateArchiveMetadata(data) {
41
52
  * Resolves archive identity for URL generation and cache storage.
42
53
  * @param version - Requested Firefox version
43
54
  * @param product - Firefox product type
55
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
44
56
  * @returns Resolved archive descriptor
45
57
  */
46
- export function resolveArchive(version, product = 'firefox') {
58
+ export function resolveArchive(version, product = 'firefox', candidate) {
47
59
  // Reject versions containing path traversal characters
48
60
  if (version.includes('/') || version.includes('..') || version.includes('\\')) {
49
61
  throw new ConfigError(`Invalid Firefox version "${version}": contains disallowed characters`, 'firefox.version');
50
62
  }
63
+ // Defense-in-depth: config validation enforces this shape already, but a
64
+ // caller-supplied candidate becomes both a URL path segment and part of
65
+ // the cache filename, so re-validate here like the version check above.
66
+ if (candidate !== undefined && !isValidFirefoxCandidate(candidate)) {
67
+ throw new ConfigError(`Invalid Firefox candidate "${candidate}": must look like "buildN" (e.g. "build2")`, 'firefox.candidate');
68
+ }
51
69
  // ESR status is determined solely by the product field. Config validation
52
70
  // ensures product and version are consistent, so we never need to infer
53
71
  // ESR from the version string independently.
@@ -55,18 +73,24 @@ export function resolveArchive(version, product = 'firefox') {
55
73
  const isEsr = product === 'firefox-esr';
56
74
  const archiveVersion = isEsr ? `${cleanVersion}esr` : cleanVersion;
57
75
  const safeProduct = product.replace(/[^a-z0-9-]/gi, '-');
76
+ const dirUrl = getArchiveDirUrl(product, archiveVersion, candidate);
77
+ // Candidate artifacts get a `-<candidate>` filename suffix so a build2
78
+ // archive can never collide with the final release artifact (same
79
+ // archiveVersion, potentially different bytes) in .fireforge/cache.
80
+ const candidateSuffix = candidate === undefined ? '' : `-${candidate}`;
81
+ const filename = `firefox-${safeProduct}-${archiveVersion}${candidateSuffix}.source.tar.xz`;
58
82
  return {
59
83
  requestedVersion: version,
60
84
  product,
61
85
  archiveVersion,
62
- url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
63
- filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
64
- metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
86
+ url: `${dirUrl}/source/firefox-${archiveVersion}.source.tar.xz`,
87
+ filename,
88
+ metadataFilename: `${filename}.json`,
65
89
  // Mozilla publishes one SHA256SUMS per release directory listing every
66
90
  // artifact by its release-relative path. Downloads verify against it by
67
91
  // default (fail closed on mismatch), so a compromised CDN response
68
92
  // cannot silently become the trusted git baseline.
69
- checksumsUrl: `${getArchiveBaseUrl(product)}/${archiveVersion}/SHA256SUMS`,
93
+ checksumsUrl: `${dirUrl}/SHA256SUMS`,
70
94
  pathInChecksums: `source/firefox-${archiveVersion}.source.tar.xz`,
71
95
  };
72
96
  }