@hominis/fireforge 0.36.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +6 -6
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  6. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  7. package/dist/src/commands/doctor.js +2 -0
  8. package/dist/src/commands/download.js +5 -2
  9. package/dist/src/commands/export-flow.d.ts +5 -1
  10. package/dist/src/commands/export-flow.js +10 -7
  11. package/dist/src/commands/export-placement-gate.js +3 -5
  12. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  13. package/dist/src/commands/export-placement-policy.js +32 -29
  14. package/dist/src/commands/export.js +12 -5
  15. package/dist/src/commands/furnace/index.js +23 -18
  16. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  17. package/dist/src/commands/patch/move-files-create.js +116 -0
  18. package/dist/src/commands/patch/move-files.d.ts +3 -1
  19. package/dist/src/commands/patch/move-files.js +49 -4
  20. package/dist/src/commands/patch/split.d.ts +11 -1
  21. package/dist/src/commands/patch/split.js +6 -5
  22. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  23. package/dist/src/commands/patch/staged-dependency.js +113 -51
  24. package/dist/src/commands/re-export-files.js +4 -0
  25. package/dist/src/commands/re-export-scan.d.ts +17 -0
  26. package/dist/src/commands/re-export-scan.js +51 -0
  27. package/dist/src/commands/re-export.js +25 -6
  28. package/dist/src/commands/source.js +28 -3
  29. package/dist/src/commands/test-diagnose.js +20 -1
  30. package/dist/src/commands/test-register.js +6 -5
  31. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  32. package/dist/src/commands/test-stale-gate.js +81 -0
  33. package/dist/src/commands/test.js +40 -23
  34. package/dist/src/core/build-audit.d.ts +12 -0
  35. package/dist/src/core/build-audit.js +14 -0
  36. package/dist/src/core/build-baseline-types.d.ts +58 -0
  37. package/dist/src/core/build-baseline.d.ts +10 -2
  38. package/dist/src/core/build-baseline.js +51 -7
  39. package/dist/src/core/config-paths.d.ts +1 -1
  40. package/dist/src/core/config-paths.js +1 -0
  41. package/dist/src/core/config-validate.js +6 -1
  42. package/dist/src/core/engine-session-lock.d.ts +10 -1
  43. package/dist/src/core/engine-session-lock.js +28 -2
  44. package/dist/src/core/file-lock.d.ts +33 -0
  45. package/dist/src/core/file-lock.js +32 -6
  46. package/dist/src/core/firefox-archive.d.ts +2 -1
  47. package/dist/src/core/firefox-archive.js +32 -8
  48. package/dist/src/core/firefox.d.ts +8 -3
  49. package/dist/src/core/firefox.js +11 -6
  50. package/dist/src/core/furnace-config-custom.js +10 -0
  51. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  52. package/dist/src/core/furnace-stale-export.js +123 -0
  53. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  54. package/dist/src/core/furnace-validate-helpers.js +28 -1
  55. package/dist/src/core/furnace-validate-structure.js +3 -2
  56. package/dist/src/core/license-headers.d.ts +12 -0
  57. package/dist/src/core/license-headers.js +76 -1
  58. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  59. package/dist/src/core/mach-known-noise-filter.js +193 -0
  60. package/dist/src/core/mach.d.ts +8 -0
  61. package/dist/src/core/mach.js +36 -3
  62. package/dist/src/core/patch-lint-cross.js +80 -2
  63. package/dist/src/core/patch-lint.d.ts +3 -0
  64. package/dist/src/core/patch-lint.js +25 -7
  65. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  66. package/dist/src/core/patch-manifest-io.js +27 -16
  67. package/dist/src/core/patch-manifest-validate.js +24 -0
  68. package/dist/src/core/status-classify.d.ts +11 -2
  69. package/dist/src/core/status-classify.js +49 -25
  70. package/dist/src/core/test-harness-crash.d.ts +16 -0
  71. package/dist/src/core/test-harness-crash.js +55 -0
  72. package/dist/src/core/test-stale-check.d.ts +69 -1
  73. package/dist/src/core/test-stale-check.js +134 -1
  74. package/dist/src/types/commands/index.d.ts +1 -1
  75. package/dist/src/types/commands/options.d.ts +84 -7
  76. package/dist/src/types/commands/patches.d.ts +29 -0
  77. package/dist/src/types/config.d.ts +7 -0
  78. package/dist/src/types/furnace.d.ts +13 -0
  79. package/dist/src/utils/options.d.ts +20 -0
  80. package/dist/src/utils/options.js +39 -0
  81. package/dist/src/utils/process-group.d.ts +33 -0
  82. package/dist/src/utils/process-group.js +161 -0
  83. package/dist/src/utils/process.d.ts +15 -0
  84. package/dist/src/utils/process.js +88 -44
  85. package/dist/src/utils/validation.d.ts +5 -0
  86. package/dist/src/utils/validation.js +7 -0
  87. package/package.json +3 -3
@@ -35,4 +35,62 @@ export interface BuildBaseline {
35
35
  * was recorded.
36
36
  */
37
37
  packageableFingerprints?: Record<string, string>;
38
+ /**
39
+ * What the packaged test runtime produced by the recorded build covers.
40
+ *
41
+ * - `'full'`: the build packaged the full test set — written by
42
+ * `fireforge build` / `build --ui` and by a path-less
43
+ * `fireforge test --build` (full-suite run).
44
+ * - `string[]`: engine-relative POSIX request paths of the file/directory-
45
+ * scoped `fireforge test --build` invocation that produced the runtime.
46
+ * A directory entry covers everything beneath it. Support fixtures for
47
+ * manifests outside this list may be missing from `obj-*`/`_tests/`, so
48
+ * an `--allow-stale-build` run over uncovered paths is refused rather
49
+ * than dispatched into a hang.
50
+ *
51
+ * Missing on baselines written before 0.37.0 — only `fireforge build`
52
+ * wrote baselines then, so "full" is the honest historical value and the
53
+ * stale-check treats an absent field as full coverage.
54
+ *
55
+ * The record is project-scoped, which is also per-obj-dir: multi-objdir
56
+ * checkouts are refused up-front (`AmbiguousBuildArtifactsError`), so at
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.
65
+ */
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>;
38
94
  }
95
+ /** Coverage claim of the packaged test runtime. See {@link BuildBaseline.testPackagingCoverage}. */
96
+ export type TestPackagingCoverage = 'full' | string[];
@@ -15,7 +15,7 @@
15
15
  * on successful build completion; a failed build does not update it, so a
16
16
  * subsequent run still audits against the last known-good tree.
17
17
  */
18
- import type { BuildBaseline } from './build-baseline-types.js';
18
+ import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
19
19
  /** Name of the last-build marker file under `.fireforge/`. */
20
20
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
21
21
  /**
@@ -39,5 +39,13 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
39
39
  * @param projectRoot - Root directory of the project
40
40
  * @param engineDir - Path to the engine directory
41
41
  * @param binaryName - Current `binaryName` from fireforge.json
42
+ * @param testPackagingCoverage - Coverage claim of the packaged test
43
+ * runtime this build produced (`'full'`, or the scoped request paths of
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.
42
50
  */
43
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string): 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';
@@ -65,8 +65,16 @@ export async function readBuildBaseline(projectRoot) {
65
65
  * @param projectRoot - Root directory of the project
66
66
  * @param engineDir - Path to the engine directory
67
67
  * @param binaryName - Current `binaryName` from fireforge.json
68
+ * @param testPackagingCoverage - Coverage claim of the packaged test
69
+ * runtime this build produced (`'full'`, or the scoped request paths of
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.
68
76
  */
69
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
77
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline) {
70
78
  let engineHeadSha = '';
71
79
  try {
72
80
  engineHeadSha = await getHead(engineDir);
@@ -81,11 +89,20 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
81
89
  }
82
90
  }
83
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;
84
99
  const baseline = {
85
100
  engineHeadSha,
86
101
  builtAt: new Date().toISOString(),
87
102
  binaryName,
88
103
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
104
+ ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
105
+ ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
89
106
  };
90
107
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
91
108
  }
@@ -103,6 +120,33 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
103
120
  * back to the pre-0.16.0 "path-only" behavior on the next test run.
104
121
  */
105
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) {
106
150
  try {
107
151
  const dirtyPaths = new Set();
108
152
  if (await hasChanges(engineDir)) {
@@ -116,12 +160,12 @@ async function collectPackageableFingerprints(engineDir) {
116
160
  dirtyPaths.add(untracked);
117
161
  }
118
162
  }
119
- const packageable = [...dirtyPaths].filter(isPackageablePath);
120
- if (packageable.length === 0) {
163
+ const included = [...dirtyPaths].filter(includePath);
164
+ if (included.length === 0) {
121
165
  return {};
122
166
  }
123
167
  const fingerprints = {};
124
- for (const relPath of packageable) {
168
+ for (const relPath of included) {
125
169
  try {
126
170
  const buffer = await readFile(join(engineDir, relPath));
127
171
  fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
@@ -130,13 +174,13 @@ async function collectPackageableFingerprints(engineDir) {
130
174
  // A file that disappeared between status probe and hash is
131
175
  // expected in concurrent scenarios; skip it without failing the
132
176
  // whole baseline write.
133
- verbose(`Build baseline: skipping fingerprint for ${relPath} — ${toError(fileError).message}`);
177
+ verbose(`Build baseline: skipping ${contextLabel} for ${relPath} — ${toError(fileError).message}`);
134
178
  }
135
179
  }
136
180
  return fingerprints;
137
181
  }
138
182
  catch (error) {
139
- verbose(`Build baseline: packageable fingerprint probe failed — ${toError(error).message}`);
183
+ verbose(`Build baseline: ${contextLabel} probe failed — ${toError(error).message}`);
140
184
  return undefined;
141
185
  }
142
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
  }
@@ -13,16 +13,18 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
13
13
  * Gets the download URL for a Firefox source tarball.
14
14
  * @param version - Firefox version (e.g., "140.9.0esr")
15
15
  * @param product - Firefox product type
16
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
16
17
  * @returns Full URL to the source tarball
17
18
  */
18
- export declare function getDownloadUrl(version: string, product?: FirefoxProduct): string;
19
+ export declare function getDownloadUrl(version: string, product?: FirefoxProduct, candidate?: string): string;
19
20
  /**
20
21
  * Gets the filename for a Firefox source tarball.
21
22
  * @param version - Firefox version
22
23
  * @param product - Firefox product type
24
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
23
25
  * @returns Tarball filename
24
26
  */
25
- export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
27
+ export declare function getTarballFilename(version: string, product?: FirefoxProduct, candidate?: string): string;
26
28
  /**
27
29
  * Lifecycle phase reported by {@link downloadFirefoxSource}. The download
28
30
  * CLI command uses this to swap spinners between the bytes-on-the-wire
@@ -57,5 +59,8 @@ export declare function sweepOrphanedEngineWorkDirs(destDir: string): Promise<st
57
59
  * @param onPhase - Optional callback fired when the function transitions
58
60
  * between phases (`'download'` → `'extract'`). Fires exactly once per
59
61
  * phase even if the cached archive path skips the wire entirely.
62
+ * @param candidate - Optional release-candidate build directory (e.g.
63
+ * "build2") resolving the archive under `candidates/` instead of
64
+ * `releases/`.
60
65
  */
61
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
66
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback, candidate?: string): Promise<void>;
@@ -19,19 +19,21 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
19
19
  * Gets the download URL for a Firefox source tarball.
20
20
  * @param version - Firefox version (e.g., "140.9.0esr")
21
21
  * @param product - Firefox product type
22
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
22
23
  * @returns Full URL to the source tarball
23
24
  */
24
- export function getDownloadUrl(version, product = 'firefox') {
25
- return resolveArchive(version, product).url;
25
+ export function getDownloadUrl(version, product = 'firefox', candidate) {
26
+ return resolveArchive(version, product, candidate).url;
26
27
  }
27
28
  /**
28
29
  * Gets the filename for a Firefox source tarball.
29
30
  * @param version - Firefox version
30
31
  * @param product - Firefox product type
32
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
31
33
  * @returns Tarball filename
32
34
  */
33
- export function getTarballFilename(version, product = 'firefox') {
34
- return resolveArchive(version, product).filename;
35
+ export function getTarballFilename(version, product = 'firefox', candidate) {
36
+ return resolveArchive(version, product, candidate).filename;
35
37
  }
36
38
  /**
37
39
  * Classifies an extraction failure as archive corruption (invalidate the
@@ -100,9 +102,12 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
100
102
  * @param onPhase - Optional callback fired when the function transitions
101
103
  * between phases (`'download'` → `'extract'`). Fires exactly once per
102
104
  * phase even if the cached archive path skips the wire entirely.
105
+ * @param candidate - Optional release-candidate build directory (e.g.
106
+ * "build2") resolving the archive under `candidates/` instead of
107
+ * `releases/`.
103
108
  */
104
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
105
- const archive = resolveArchive(version, product);
109
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress, candidate) {
110
+ const archive = resolveArchive(version, product, candidate);
106
111
  const tarballPath = join(cacheDir, archive.filename);
107
112
  // Ensure cache directory exists
108
113
  await ensureDir(cacheDir);
@@ -35,6 +35,13 @@ export function parseCustomConfig(data, name) {
35
35
  if (!isBoolean(data['localized'])) {
36
36
  throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
37
37
  }
38
+ if (data['kind'] !== undefined && data['kind'] !== 'element' && data['kind'] !== 'library') {
39
+ throw new FurnaceError(`Furnace config: custom "${name}.kind" must be "element" or "library" when set`);
40
+ }
41
+ if (data['kind'] === 'library' && data['register']) {
42
+ throw new FurnaceError(`Furnace config: custom "${name}" has kind "library" but register: true — ` +
43
+ 'a library exports no custom element; set register: false');
44
+ }
38
45
  if (data['composes'] !== undefined) {
39
46
  parseStringArray(data['composes'], `${name}.composes`);
40
47
  }
@@ -58,6 +65,9 @@ export function parseCustomConfig(data, name) {
58
65
  ? { composes: parseStringArray(data['composes'], `${name}.composes`) }
59
66
  : {}),
60
67
  ...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
68
+ // Explicit `kind: "element"` is the default; normalize it away so the
69
+ // stored config carries the field only when it changes behavior.
70
+ ...(data['kind'] === 'library' ? { kind: 'library' } : {}),
61
71
  ...(sharedFtl !== undefined ? { sharedFtl } : {}),
62
72
  };
63
73
  }