@hominis/fireforge 0.52.0 → 0.54.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 (248) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/bootstrap.js +2 -3
  4. package/dist/src/commands/build-prelock.d.ts +3 -3
  5. package/dist/src/commands/build-prelock.js +3 -3
  6. package/dist/src/commands/build.js +4 -0
  7. package/dist/src/commands/declarations-report.d.ts +9 -3
  8. package/dist/src/commands/declarations-report.js +18 -11
  9. package/dist/src/commands/discard.js +3 -3
  10. package/dist/src/commands/doctor-furnace-config-sync.js +4 -4
  11. package/dist/src/commands/doctor-furnace.js +5 -5
  12. package/dist/src/commands/export-adopt-check.d.ts +5 -6
  13. package/dist/src/commands/export-adopt-check.js +5 -6
  14. package/dist/src/commands/export-flow.js +6 -9
  15. package/dist/src/commands/export-staged-registration.d.ts +4 -4
  16. package/dist/src/commands/export-staged-registration.js +4 -4
  17. package/dist/src/commands/forward-import-remedy.d.ts +2 -3
  18. package/dist/src/commands/forward-import-remedy.js +2 -3
  19. package/dist/src/commands/furnace/create-dry-run.d.ts +2 -2
  20. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +2 -2
  21. package/dist/src/commands/furnace/deploy-fragment-includers.js +2 -2
  22. package/dist/src/commands/furnace/index.js +2 -2
  23. package/dist/src/commands/lint.js +4 -4
  24. package/dist/src/commands/patch/compact.js +1 -1
  25. package/dist/src/commands/patch/lint-ignore-from.d.ts +3 -4
  26. package/dist/src/commands/patch/lint-ignore-from.js +3 -4
  27. package/dist/src/commands/patch/move-files-adopt.d.ts +33 -0
  28. package/dist/src/commands/patch/move-files-adopt.js +65 -0
  29. package/dist/src/commands/patch/move-files-create.js +41 -20
  30. package/dist/src/commands/patch/move-files-into.d.ts +18 -0
  31. package/dist/src/commands/patch/move-files-into.js +125 -60
  32. package/dist/src/commands/patch/move-files-retire.d.ts +39 -0
  33. package/dist/src/commands/patch/move-files-retire.js +91 -0
  34. package/dist/src/commands/patch/move-files.d.ts +3 -0
  35. package/dist/src/commands/patch/move-files.js +42 -12
  36. package/dist/src/commands/patch/move-size-tier.d.ts +27 -0
  37. package/dist/src/commands/patch/move-size-tier.js +66 -0
  38. package/dist/src/commands/patch/rename.js +1 -1
  39. package/dist/src/commands/patch/reorder-projection.d.ts +17 -0
  40. package/dist/src/commands/patch/reorder-projection.js +49 -0
  41. package/dist/src/commands/patch/reorder-shift.d.ts +30 -0
  42. package/dist/src/commands/patch/reorder-shift.js +307 -0
  43. package/dist/src/commands/patch/reorder.d.ts +9 -15
  44. package/dist/src/commands/patch/reorder.js +52 -50
  45. package/dist/src/commands/patch/split-plan.d.ts +31 -3
  46. package/dist/src/commands/patch/split-plan.js +36 -12
  47. package/dist/src/commands/patch/split-staged.d.ts +2 -2
  48. package/dist/src/commands/patch/split-staged.js +2 -2
  49. package/dist/src/commands/patch/split.js +18 -5
  50. package/dist/src/commands/patch/staged-dependency-entries.d.ts +9 -1
  51. package/dist/src/commands/patch/staged-dependency-entries.js +32 -4
  52. package/dist/src/commands/patch/staged-dependency-from.js +15 -9
  53. package/dist/src/commands/patch/staged-dependency-remove.d.ts +26 -4
  54. package/dist/src/commands/patch/staged-dependency-remove.js +60 -23
  55. package/dist/src/commands/patch/staged-dependency.js +35 -24
  56. package/dist/src/commands/patch/tier.js +2 -2
  57. package/dist/src/commands/patch-rename-map.d.ts +14 -3
  58. package/dist/src/commands/patch-rename-map.js +21 -3
  59. package/dist/src/commands/re-export-adjacent.d.ts +4 -5
  60. package/dist/src/commands/re-export-lint.d.ts +4 -3
  61. package/dist/src/commands/re-export-lint.js +4 -3
  62. package/dist/src/commands/re-export-options.js +8 -2
  63. package/dist/src/commands/re-export-preflight.js +1 -2
  64. package/dist/src/commands/re-export-purity-scope.d.ts +1 -1
  65. package/dist/src/commands/re-export-purity-scope.js +13 -7
  66. package/dist/src/commands/re-export-retired-declarations.d.ts +22 -0
  67. package/dist/src/commands/re-export-retired-declarations.js +113 -0
  68. package/dist/src/commands/re-export-single.js +2 -2
  69. package/dist/src/commands/re-export-stale-registrations.d.ts +27 -6
  70. package/dist/src/commands/re-export-stale-registrations.js +65 -21
  71. package/dist/src/commands/re-export.js +4 -0
  72. package/dist/src/commands/rebase/continue.js +3 -3
  73. package/dist/src/commands/register.js +5 -4
  74. package/dist/src/commands/run.js +11 -4
  75. package/dist/src/commands/status.js +1 -1
  76. package/dist/src/commands/test-build-furnace.d.ts +1 -2
  77. package/dist/src/commands/test-build-furnace.js +1 -2
  78. package/dist/src/commands/test-build-phase.d.ts +8 -0
  79. package/dist/src/commands/test-build-phase.js +42 -7
  80. package/dist/src/commands/test-control-restore.d.ts +40 -0
  81. package/dist/src/commands/test-control-restore.js +86 -0
  82. package/dist/src/commands/test-dispatch.js +68 -7
  83. package/dist/src/commands/test-multi-verdict.js +1 -0
  84. package/dist/src/commands/test-orchestrate.d.ts +6 -0
  85. package/dist/src/commands/test-orchestrate.js +32 -1
  86. package/dist/src/commands/test-perf-scope.d.ts +1 -1
  87. package/dist/src/commands/test-perf-scope.js +1 -1
  88. package/dist/src/commands/test-prelock.d.ts +18 -8
  89. package/dist/src/commands/test-prelock.js +42 -14
  90. package/dist/src/commands/test-register.js +4 -3
  91. package/dist/src/commands/test-results.d.ts +10 -1
  92. package/dist/src/commands/test-results.js +20 -3
  93. package/dist/src/commands/test-run.d.ts +6 -0
  94. package/dist/src/commands/test-run.js +23 -4
  95. package/dist/src/commands/test-shards.d.ts +7 -3
  96. package/dist/src/commands/test-shards.js +24 -7
  97. package/dist/src/commands/test-stale-gate.d.ts +6 -1
  98. package/dist/src/commands/test-stale-gate.js +40 -2
  99. package/dist/src/commands/test-verdict.d.ts +5 -3
  100. package/dist/src/commands/test.js +14 -2
  101. package/dist/src/commands/token.js +2 -2
  102. package/dist/src/commands/verify.js +1 -1
  103. package/dist/src/commands/watch.js +1 -1
  104. package/dist/src/commands/wire.js +1 -1
  105. package/dist/src/core/build-audit-resolve.d.ts +4 -5
  106. package/dist/src/core/build-audit-resolve.js +4 -5
  107. package/dist/src/core/build-audit-skip.d.ts +2 -2
  108. package/dist/src/core/build-audit.d.ts +3 -4
  109. package/dist/src/core/build-baseline-types.d.ts +24 -3
  110. package/dist/src/core/build-baseline-validate.d.ts +30 -1
  111. package/dist/src/core/build-baseline-validate.js +127 -35
  112. package/dist/src/core/build-baseline.d.ts +8 -2
  113. package/dist/src/core/build-baseline.js +25 -16
  114. package/dist/src/core/build-jar-escalation.d.ts +4 -4
  115. package/dist/src/core/build-jar-escalation.js +4 -4
  116. package/dist/src/core/build-prepare.js +4 -4
  117. package/dist/src/core/config-validate.js +4 -4
  118. package/dist/src/core/coverage-extend.d.ts +5 -3
  119. package/dist/src/core/coverage-extend.js +8 -3
  120. package/dist/src/core/coverage-records.d.ts +4 -5
  121. package/dist/src/core/coverage-records.js +4 -5
  122. package/dist/src/core/destructive.d.ts +2 -2
  123. package/dist/src/core/destructive.js +2 -2
  124. package/dist/src/core/discard-baseline.d.ts +2 -2
  125. package/dist/src/core/discard-baseline.js +3 -3
  126. package/dist/src/core/engine-session-lock.d.ts +1 -1
  127. package/dist/src/core/engine-session-lock.js +1 -1
  128. package/dist/src/core/file-lock-owner.d.ts +5 -5
  129. package/dist/src/core/file-lock-owner.js +5 -5
  130. package/dist/src/core/file-lock-queue.js +6 -8
  131. package/dist/src/core/file-lock-shared.d.ts +4 -4
  132. package/dist/src/core/file-lock-shared.js +4 -4
  133. package/dist/src/core/file-lock.js +7 -10
  134. package/dist/src/core/firefox.js +2 -2
  135. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +1 -1
  136. package/dist/src/core/furnace-apply-overwrite-warn.js +1 -1
  137. package/dist/src/core/furnace-apply.js +3 -3
  138. package/dist/src/core/furnace-operation.js +5 -5
  139. package/dist/src/core/furnace-registration-jar-inplace.js +5 -6
  140. package/dist/src/core/furnace-registration-rename.d.ts +5 -5
  141. package/dist/src/core/furnace-registration-rename.js +5 -5
  142. package/dist/src/core/furnace-registration-validate.d.ts +2 -1
  143. package/dist/src/core/furnace-registration-validate.js +2 -1
  144. package/dist/src/core/furnace-registration.js +2 -2
  145. package/dist/src/core/git-base.js +4 -3
  146. package/dist/src/core/git-diff.js +1 -1
  147. package/dist/src/core/git.js +1 -1
  148. package/dist/src/core/harness-orphans.d.ts +6 -6
  149. package/dist/src/core/harness-orphans.js +6 -6
  150. package/dist/src/core/harness-port-reservation.d.ts +60 -0
  151. package/dist/src/core/harness-port-reservation.js +122 -0
  152. package/dist/src/core/history-log-file.d.ts +9 -9
  153. package/dist/src/core/history-log-file.js +15 -15
  154. package/dist/src/core/history-log.d.ts +3 -3
  155. package/dist/src/core/history-log.js +3 -3
  156. package/dist/src/core/launchable-build.d.ts +39 -0
  157. package/dist/src/core/launchable-build.js +85 -0
  158. package/dist/src/core/marionette-port.js +1 -3
  159. package/dist/src/core/moz-manifest-rules.js +3 -2
  160. package/dist/src/core/negative-control-compiled.d.ts +13 -3
  161. package/dist/src/core/negative-control-compiled.js +27 -5
  162. package/dist/src/core/negative-control-diff.js +31 -6
  163. package/dist/src/core/parser-fallback.d.ts +5 -5
  164. package/dist/src/core/parser-fallback.js +5 -5
  165. package/dist/src/core/patch-identifier-suggest.d.ts +6 -0
  166. package/dist/src/core/patch-identifier-suggest.js +11 -4
  167. package/dist/src/core/patch-lint-checkjs.d.ts +1 -1
  168. package/dist/src/core/patch-lint-checkjs.js +1 -1
  169. package/dist/src/core/patch-lint-css.js +2 -2
  170. package/dist/src/core/patch-lint-file-size.d.ts +5 -7
  171. package/dist/src/core/patch-lint-file-size.js +5 -7
  172. package/dist/src/core/patch-lint-ignore.d.ts +3 -3
  173. package/dist/src/core/patch-lint-ignore.js +3 -3
  174. package/dist/src/core/patch-lint-prettier.d.ts +4 -6
  175. package/dist/src/core/patch-lint-prettier.js +4 -6
  176. package/dist/src/core/patch-lint-projection.d.ts +1 -1
  177. package/dist/src/core/patch-lint-projection.js +1 -1
  178. package/dist/src/core/patch-lint-queue-types.d.ts +2 -2
  179. package/dist/src/core/patch-lint-queue-types.js +2 -2
  180. package/dist/src/core/patch-manifest-consistency.d.ts +2 -2
  181. package/dist/src/core/patch-manifest-consistency.js +2 -2
  182. package/dist/src/core/patch-manifest-io.d.ts +5 -1
  183. package/dist/src/core/patch-manifest-io.js +12 -10
  184. package/dist/src/core/patch-manifest-key-order.d.ts +3 -4
  185. package/dist/src/core/patch-manifest-key-order.js +3 -4
  186. package/dist/src/core/patch-parse.d.ts +3 -3
  187. package/dist/src/core/patch-parse.js +3 -3
  188. package/dist/src/core/patch-policy.d.ts +1 -1
  189. package/dist/src/core/patch-policy.js +1 -1
  190. package/dist/src/core/patch-source-metadata.d.ts +1 -1
  191. package/dist/src/core/patch-source-metadata.js +1 -1
  192. package/dist/src/core/patch-subset.d.ts +5 -3
  193. package/dist/src/core/patch-subset.js +8 -7
  194. package/dist/src/core/patch-transform.d.ts +2 -2
  195. package/dist/src/core/patch-transform.js +2 -2
  196. package/dist/src/core/rebase-session.js +4 -6
  197. package/dist/src/core/register-xpcshell-test.d.ts +28 -7
  198. package/dist/src/core/register-xpcshell-test.js +76 -27
  199. package/dist/src/core/run-log.d.ts +8 -8
  200. package/dist/src/core/run-log.js +8 -8
  201. package/dist/src/core/status-classify.d.ts +1 -2
  202. package/dist/src/core/status-classify.js +1 -2
  203. package/dist/src/core/test-harness-crash.d.ts +5 -3
  204. package/dist/src/core/test-harness-crash.js +13 -14
  205. package/dist/src/core/test-harness-infra.d.ts +32 -0
  206. package/dist/src/core/test-harness-infra.js +69 -0
  207. package/dist/src/core/test-harness-signature.d.ts +6 -0
  208. package/dist/src/core/test-stale-check.d.ts +38 -0
  209. package/dist/src/core/test-stale-check.js +82 -1
  210. package/dist/src/core/test-stall-triage.js +5 -7
  211. package/dist/src/core/token-category.js +2 -2
  212. package/dist/src/core/token-inventory.js +3 -3
  213. package/dist/src/core/token-manager.d.ts +2 -2
  214. package/dist/src/core/token-manager.js +5 -7
  215. package/dist/src/core/token-variant.d.ts +6 -6
  216. package/dist/src/core/token-variant.js +7 -7
  217. package/dist/src/core/typecheck-shim.js +1 -1
  218. package/dist/src/core/wire-destroy.d.ts +1 -1
  219. package/dist/src/core/wire-destroy.js +1 -1
  220. package/dist/src/core/wire-dom-fragment.js +1 -1
  221. package/dist/src/core/wire-init.d.ts +1 -1
  222. package/dist/src/core/wire-init.js +1 -1
  223. package/dist/src/core/wire-subscript.d.ts +1 -1
  224. package/dist/src/core/wire-subscript.js +1 -1
  225. package/dist/src/core/wire-utils.d.ts +2 -2
  226. package/dist/src/core/wire-utils.js +3 -3
  227. package/dist/src/errors/base.d.ts +1 -1
  228. package/dist/src/errors/base.js +1 -1
  229. package/dist/src/errors/rebase.d.ts +1 -1
  230. package/dist/src/errors/rebase.js +1 -1
  231. package/dist/src/types/commands/options.d.ts +38 -3
  232. package/dist/src/types/config.d.ts +5 -7
  233. package/dist/src/utils/options.js +2 -2
  234. package/dist/src/utils/process.js +2 -2
  235. package/dist/src/utils/regex.d.ts +2 -2
  236. package/dist/src/utils/regex.js +3 -3
  237. package/dist/src/utils/sleep.d.ts +5 -6
  238. package/dist/src/utils/sleep.js +5 -6
  239. package/dist/src/utils/validation.js +2 -2
  240. package/docs/configuration.md +28 -23
  241. package/docs/exit-codes.md +8 -11
  242. package/docs/furnace.md +3 -3
  243. package/docs/lifecycle-invariants.md +36 -45
  244. package/docs/machine-output.md +41 -24
  245. package/docs/patch-workflow.md +140 -29
  246. package/docs/run-logs.md +8 -9
  247. package/docs/testing.md +114 -48
  248. package/package.json +1 -1
@@ -3,23 +3,40 @@
3
3
  * Re-export prunes a staged registration the queue can no longer satisfy.
4
4
  *
5
5
  * A merge that deletes a file whose jar.mn line lives in another patch
6
- * left the staged registration on that patch: re-export kept it, lint then
7
- * warned `staged-dependency-unused`, and the stale declaration also kept
8
- * the holder measuring above a just-lowered size ceiling. When no patch in
6
+ * leaves the staged registration on that patch: lint warns
7
+ * `staged-dependency-unused`, and the stale declaration keeps the holder
8
+ * measuring above a just-lowered size ceiling. When no patch in
9
9
  * the queue owns a registration's `creates` path any more, the declaration
10
10
  * cannot be met, so the holder's own re-export drops it and says so.
11
+ *
12
+ * A registration whose `creates` file exists in `engine/`, or is an
13
+ * `--expect-new` path of the run, is kept: a manifest owner's re-export can
14
+ * run between declaring the registration and the scan that adopts the
15
+ * file, and pruning it there would break the scan that needs it next.
11
16
  */
17
+ import { join } from 'node:path';
12
18
  import { appendHistoryBestEffort } from '../core/history-log.js';
19
+ import { pathExists } from '../utils/fs.js';
13
20
  import { info } from '../utils/logger.js';
14
21
  /**
15
- * Finds `patch`'s registrations whose `creates` path no patch in the queue
16
- * owns, judging `patch` by the file list this re-export will write.
17
- * Returns undefined when nothing is stale.
22
+ * The unowned `creates` paths of `patch`'s registrations that are pending
23
+ * adoption: the file exists in `engine/`, or is an `--expect-new` path of
24
+ * this run. Such a registration is waiting for its subject patch's scan.
18
25
  */
19
- export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected) {
20
- const registrations = patch.stagedDependencies?.registrations ?? [];
21
- if (registrations.length === 0)
22
- return undefined;
26
+ export async function findPendingRegistrationCreates(engineDir, manifest, patch, currentFilesAffected, expectedNew) {
27
+ const owned = ownedPaths(manifest, patch, currentFilesAffected);
28
+ const pending = new Set();
29
+ for (const registration of patch.stagedDependencies?.registrations ?? []) {
30
+ const creates = registration.creates;
31
+ if (owned.has(creates) || pending.has(creates))
32
+ continue;
33
+ if (expectedNew.has(creates) || (await pathExists(join(engineDir, creates)))) {
34
+ pending.add(creates);
35
+ }
36
+ }
37
+ return pending;
38
+ }
39
+ function ownedPaths(manifest, patch, currentFilesAffected) {
23
40
  const owned = new Set(currentFilesAffected);
24
41
  for (const entry of manifest.patches) {
25
42
  if (entry.filename === patch.filename)
@@ -27,10 +44,26 @@ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected)
27
44
  for (const file of entry.filesAffected)
28
45
  owned.add(file);
29
46
  }
30
- const dropped = registrations.filter((registration) => !owned.has(registration.creates));
31
- if (dropped.length === 0)
47
+ return owned;
48
+ }
49
+ /**
50
+ * Finds `patch`'s registrations whose `creates` path no patch in the queue
51
+ * owns, judging `patch` by the file list this re-export will write. A
52
+ * registration whose `creates` path is in `pending` (see
53
+ * {@link findPendingRegistrationCreates}) is kept and reported instead.
54
+ * Returns undefined when nothing is stale or pending.
55
+ */
56
+ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected, pendingCreates = new Set()) {
57
+ const registrations = patch.stagedDependencies?.registrations ?? [];
58
+ if (registrations.length === 0)
32
59
  return undefined;
33
- const kept = registrations.filter((registration) => owned.has(registration.creates));
60
+ const owned = ownedPaths(manifest, patch, currentFilesAffected);
61
+ const unowned = registrations.filter((registration) => !owned.has(registration.creates));
62
+ const pending = unowned.filter((registration) => pendingCreates.has(registration.creates));
63
+ const dropped = unowned.filter((registration) => !pendingCreates.has(registration.creates));
64
+ if (dropped.length === 0 && pending.length === 0)
65
+ return undefined;
66
+ const kept = registrations.filter((registration) => !dropped.includes(registration));
34
67
  const forwardImports = patch.stagedDependencies?.forwardImports ?? [];
35
68
  const staged = {
36
69
  ...(forwardImports.length > 0 ? { forwardImports } : {}),
@@ -38,25 +71,36 @@ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected)
38
71
  };
39
72
  return {
40
73
  dropped,
74
+ pending,
41
75
  stagedDependencies: Object.keys(staged).length > 0 ? staged : undefined,
42
76
  };
43
77
  }
44
- /** Names each dropped registration, in both a dry run and a write. */
78
+ /** Names each dropped and each kept-pending registration, in both a dry run and a write. */
45
79
  export function reportStaleRegistrationDrop(patchFilename, plan, isDryRun) {
46
80
  const verb = isDryRun ? 'would drop' : 'drops';
47
81
  for (const registration of plan.dropped) {
48
82
  info(`${patchFilename}: ${verb} staged registration ${registration.file} "${registration.line.trim()}": ` +
49
83
  `no patch in the queue creates ${registration.creates} any more.`);
50
84
  }
85
+ for (const registration of plan.pending) {
86
+ info(`${patchFilename}: staged registration ${registration.file} "${registration.line.trim()}" ` +
87
+ `for ${registration.creates} kept: file exists but no patch owns it yet.`);
88
+ }
51
89
  }
52
- /** Records the dropped declarations as a delta, like `staged-dependency --remove`. */
53
- export async function recordStaleRegistrationDrop(patchesDir, patchFilename, plan) {
90
+ /**
91
+ * Records declarations a re-export pruned from one patch as a delta, like
92
+ * `staged-dependency --remove`.
93
+ */
94
+ export async function recordStagedDeclarationPrune(patchesDir, patchFilename, removed, what) {
95
+ if (removed.length === 0)
96
+ return;
54
97
  await appendHistoryBestEffort(patchesDir, {
55
98
  operation: 're-export-staged-prune',
56
- args: {
57
- filename: patchFilename,
58
- removed: plan.dropped.map((registration) => ({ kind: 'registration', ...registration })),
59
- },
99
+ args: { filename: patchFilename, removed: [...removed] },
60
100
  result: 'ok',
61
- }, `re-export pruned stale staged registrations on ${patchFilename}`);
101
+ }, `re-export pruned ${what} on ${patchFilename}`);
102
+ }
103
+ /** Records the dropped registrations as a delta, like `staged-dependency --remove`. */
104
+ export async function recordStaleRegistrationDrop(patchesDir, patchFilename, plan) {
105
+ await recordStagedDeclarationPrune(patchesDir, patchFilename, plan.dropped.map((registration) => ({ kind: 'registration', ...registration })), 'stale staged registrations');
62
106
  }
@@ -17,6 +17,7 @@ import { reExportFilesInPlace } from './re-export-files.js';
17
17
  import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
18
18
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
19
19
  import { validateExpectedRemovals } from './re-export-removed.js';
20
+ import { dropRetiredPathDeclarations } from './re-export-retired-declarations.js';
20
21
  import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
21
22
  import { assignScanFileOwners, scanOptionsForPatch } from './re-export-scan-owner.js';
22
23
  import { reExportSinglePatchWithIndexLockRetry, } from './re-export-single.js';
@@ -190,6 +191,9 @@ export async function reExportCommand(projectRoot, patches, options) {
190
191
  progress.error('Re-export failed');
191
192
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
192
193
  }
194
+ // Declarations on a path this run retired can never be met; drop them
195
+ // now rather than leave them for the next per-patch lint to report.
196
+ await dropRetiredPathDeclarations({ paths, manifest, options, selectedPatches, isDryRun });
193
197
  // `--stamp` only fires on a run where every selected patch refreshed
194
198
  // cleanly. A partial success would leave some patches with a stale body
195
199
  // but a new version, the opposite of the "what I tested, what the
@@ -21,9 +21,9 @@ import { runPatchLoop } from './patch-loop.js';
21
21
  */
22
22
  export async function handleContinue(projectRoot, maxFuzz, waitLockSeconds) {
23
23
  intro('FireForge Rebase — Continue');
24
- // A present-but-unreadable session is not "no session in progress". That
25
- // conflation is what made a corrupt file unrecoverable. Name the file and
26
- // point at --abort, which can now clear it.
24
+ // A present-but-unreadable session is not "no session in progress":
25
+ // treating it as one leaves a corrupt file unrecoverable. Name the file
26
+ // and point at --abort, which can clear it.
27
27
  const read = await readRebaseSession(projectRoot);
28
28
  if (!read.present)
29
29
  throw new NoRebaseSessionError();
@@ -32,9 +32,9 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
32
32
  }
33
33
  // Accept either repo-root-relative (`engine/browser/...`) or
34
34
  // engine-relative (`browser/...`) inputs. Operators frequently paste
35
- // the former from the output of tab completion or `git status`, and
36
- // the mismatch used to produce a "File not found" error that named
37
- // the original path with no hint that dropping `engine/` would fix it.
35
+ // the former from the output of tab completion or `git status`, so
36
+ // both shapes resolve to the same path instead of a "File not found"
37
+ // error.
38
38
  const engineRelativePath = stripEnginePrefix(filePath);
39
39
  // Verify the file exists in engine/ (skip for dry-run)
40
40
  if (!options.dryRun) {
@@ -97,7 +97,8 @@ export function registerRegister(program, { getProjectRoot, withErrorHandling })
97
97
  .command('register <path>')
98
98
  .description('Register a file in the appropriate build manifest')
99
99
  .option('--dry-run', 'Show what would be changed without writing')
100
- .option('--after <entry>', 'Place entry after line containing this substring (instead of alphabetical)')
100
+ .option('--after <entry>', 'Place entry after line containing this substring (instead of alphabetical); for an ' +
101
+ 'xpcshell test, after the whole section of the xpcshell.toml entry it names')
101
102
  .option('--create-manifest', 'Scaffold a missing manifest (directory moz.build with EXTRA_JS_MODULES / XPCSHELL_TESTS_MANIFESTS, or xpcshell.toml) and wire the parent DIRS chain instead of failing with "Manifest not found"')
102
103
  .action(withErrorHandling(async (path, options) => {
103
104
  await registerCommand(getProjectRoot(), path, pickDefined(options));
@@ -130,6 +130,7 @@ async function runInteractive(engineDir, options, profile) {
130
130
  const exitCode = await run(engineDir, [
131
131
  ...(options.headless ? ['--headless'] : []),
132
132
  ...profile.args,
133
+ ...(options.args ?? []),
133
134
  ]);
134
135
  // Exit-code whitelist:
135
136
  // 0: clean shutdown
@@ -234,7 +235,12 @@ async function runSmokeExit(engineDir, options, profile) {
234
235
  const startedAt = Date.now();
235
236
  let result;
236
237
  try {
237
- const machArgs = ['run', ...(options.headless ? ['--headless'] : []), ...profile.args];
238
+ const machArgs = [
239
+ 'run',
240
+ ...(options.headless ? ['--headless'] : []),
241
+ ...profile.args,
242
+ ...(options.args ?? []),
243
+ ];
238
244
  result = await runMachSmoke(machArgs, engineDir, {
239
245
  smokeTimeoutMs,
240
246
  onStdoutLine: (line) => {
@@ -394,6 +400,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
394
400
  program
395
401
  .command('run')
396
402
  .description('Launch the built browser')
403
+ .argument('[args...]', 'Arguments passed to the browser after the profile ones, given after "--" (e.g. -- -marionette -remote-allow-system-access)')
397
404
  .option('--smoke-exit <seconds>', 'Smoke-run mode (POSIX only): launch, capture console, SIGTERM the process group after <seconds>. Exit 0 on a clean window, 12 on unallowed errors, 13 on launch failure.', commanderArgParser((value) => {
398
405
  const parsed = Number.parseInt(value, 10);
399
406
  if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value.trim()) {
@@ -410,8 +417,8 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
410
417
  .option('--temp-profile', 'Launch with a fresh temporary profile that is removed when the run ends. The default for --smoke-exit.')
411
418
  .action(withErrorHandling(
412
419
  // `args` is the variadic positional, not a Commander flag, so it
413
- // never appears in this object.
414
- async (options) => {
415
- await runCommand(getProjectRoot(), pickDefined(options));
420
+ // arrives apart from the options object.
421
+ async (args, options) => {
422
+ await runCommand(getProjectRoot(), pickDefined({ ...options, ...(args.length > 0 ? { args } : {}) }));
416
423
  }));
417
424
  }
@@ -409,7 +409,7 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
409
409
  .option('--fail-on <classifications>', 'Comma-separated classification list that fails --check, replacing the default set (implies --check). Valid: patch-backed, patch-owned-drift, unmanaged, branding, furnace, conflict, binary-unsupported, ownership-conflict (a path claimed by more than one patch)')
410
410
  .option('--allow <classifications>', 'Comma-separated classification list that never fails --check, removed from the fail set (implies --check; same values as --fail-on)')
411
411
  .option('--fail-on-unknown', 'With --allow: also fail on every present classification that neither --fail-on nor --allow names, so a classification added in a later FireForge fails closed (implies --check)')
412
- .option('--patches <names...>', 'Judge only the named patches: keeps the files each owns or claims and the ownership rows naming it (filename, stem, order number, manifest name, or slug; repeat or comma-separate). Unmanaged files belong to no patch and drop out. Default view, --json and --ownership only')
412
+ .option('--patches <names...>', 'Judge only the named patches: keeps the files each owns or claims and the ownership rows naming it (filename, stem, order number, manifest name, or slug; repeat, or separate with commas or whitespace). Unmanaged files belong to no patch and drop out. Default view, --json and --ownership only')
413
413
  .action(withErrorHandling(async (options) => {
414
414
  await statusCommand(getProjectRoot(), options);
415
415
  }));
@@ -3,8 +3,7 @@
3
3
  * runs `furnace apply` over the whole `components/` tree, so a
4
4
  * `fireforge test --build` meant to capture the committed behaviour could
5
5
  * silently ship component edits nobody had deployed yet (another session's
6
- * included), and nothing in the test output said so. The applied names now
7
- * ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
6
+ * included). The applied names therefore ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
8
7
  * stands, naming the undeployed edits it left out.
9
8
  */
10
9
  import type { BuildPreparation } from '../core/build-prepare.js';
@@ -4,8 +4,7 @@
4
4
  * runs `furnace apply` over the whole `components/` tree, so a
5
5
  * `fireforge test --build` meant to capture the committed behaviour could
6
6
  * silently ship component edits nobody had deployed yet (another session's
7
- * included), and nothing in the test output said so. The applied names now
8
- * ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
7
+ * included). The applied names therefore ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
9
8
  * stands, naming the undeployed edits it left out.
10
9
  */
11
10
  import { listStaleFurnaceComponents } from '../core/furnace-staleness.js';
@@ -3,6 +3,7 @@
3
3
  * the per-file line budget): the incremental protected mach build with
4
4
  * its baseline write, and the `--build-only` union-build shape.
5
5
  */
6
+ import type { ControlBuildStamp } from '../core/build-baseline-types.js';
6
7
  import type { TestOptions } from '../types/commands/index.js';
7
8
  import type { FireForgeConfig, ProjectPaths } from '../types/config.js';
8
9
  import type { HarnessClassification } from './test-modes.js';
@@ -18,4 +19,11 @@ export declare function runTestBuildPhase(projectRoot: string, paths: ProjectPat
18
19
  normalizedPaths: string[];
19
20
  /** Forces a full pre-test build (a compiled negative control). */
20
21
  fullBuildReason?: string;
22
+ /** Recorded on the pre-test build's baseline (a compiled negative control). */
23
+ controlBuild?: ControlBuildStamp;
24
+ /**
25
+ * Engine-relative launchable binary: anchors the stale-compiled check,
26
+ * which refuses a build-less run and escalates a `--build` one.
27
+ */
28
+ launchablePath?: string | undefined;
21
29
  }): Promise<boolean>;
@@ -7,10 +7,12 @@
7
7
  import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
8
8
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
9
9
  import { applyMozconfigAnchor, formatCoverageRetentionNotice, formatExtendCoverageRefusal, planCoverageRetention, recordScopedCoverage, } from '../core/coverage-extend.js';
10
+ import { normalizeRequestPath } from '../core/coverage-records.js';
10
11
  import { hasBuildArtifacts } from '../core/mach.js';
11
12
  import { runProtectedMachBuild, withBuildLock } from '../core/mach.js';
12
13
  import { assertHealthyPartialConfig, pruneDanglingTestLinks } from '../core/objdir-maintenance.js';
13
14
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
15
+ import { findCompiledSourcesNewerThanBinary, formatStaleCompiledBuildNotice, } from '../core/test-stale-check.js';
14
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
17
  import { BuildError } from '../errors/build.js';
16
18
  import { toError } from '../utils/errors.js';
@@ -27,7 +29,7 @@ import { emitPassVerdict } from './test-verdict.js';
27
29
  * @param options - See {@link RunPreTestBuildOptions}
28
30
  */
29
31
  async function runPreTestBuild(options) {
30
- const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, fullBuildReason, } = options;
32
+ const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, fullBuildReason, controlBuild, } = options;
31
33
  await withBuildLock(projectRoot, async () => {
32
34
  // Pass the previous baseline exactly like `fireforge build` does, so
33
35
  // auto-configure runs under the same conditions on both paths. The
@@ -121,6 +123,7 @@ async function runPreTestBuild(options) {
121
123
  ? 'carry-forward'
122
124
  : 'auto',
123
125
  buildKind,
126
+ controlBuild,
124
127
  });
125
128
  }
126
129
  catch (baselineError) {
@@ -200,7 +203,7 @@ function describeBuildInvocation(extending, coverage) {
200
203
  * next steps, and emits the run's single `PASS` verdict.
201
204
  */
202
205
  export async function runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, request) {
203
- const { classification, normalizedPaths, fullBuildReason } = request;
206
+ const { classification, normalizedPaths, controlBuild, launchablePath } = request;
204
207
  const extending = options.extendCoverage === true;
205
208
  if (extending) {
206
209
  assertExtendCoverageUsage(options, normalizedPaths);
@@ -213,19 +216,28 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
213
216
  // rebuild also cannot regenerate the compiled StaticComponents table,
214
217
  // so it runs the components.conf gate up-front (the path-less shape
215
218
  // refreshes the anchor itself and skips it).
216
- const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
217
- if (normalizedPaths.length > 0) {
219
+ // Coverage paths are recorded normalized (no trailing slash, no `./`):
220
+ // the baseline validator refuses the raw `dir/` CLI shape, which would
221
+ // block every later plain `fireforge test`. A request
222
+ // for the engine root (`.`) packages everything, so it records `'full'`.
223
+ const coveragePaths = normalizedPaths.map(normalizeRequestPath);
224
+ const coverage = coveragePaths.length === 0 || coveragePaths.includes('')
225
+ ? 'full'
226
+ : [...new Set(coveragePaths)];
227
+ if (coverage !== 'full') {
218
228
  const previousBaseline = await readBuildBaseline(projectRoot);
219
229
  await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
220
230
  if (extending) {
221
231
  // Staging-input half of the plan, before the build lock: a refusal
222
232
  // here costs neither build time nor the lock.
223
- const plan = await planCoverageRetention(paths.engine, previousBaseline, normalizedPaths);
233
+ const plan = await planCoverageRetention(paths.engine, previousBaseline, coverage);
224
234
  if (plan.dropped.length > 0) {
225
235
  throw new GeneralError(formatExtendCoverageRefusal(plan.dropped));
226
236
  }
227
237
  }
228
238
  }
239
+ const fullBuildReason = request.fullBuildReason ??
240
+ (await describeCompiledEscalation(projectRoot, paths.engine, launchablePath));
229
241
  await runPreTestBuild({
230
242
  projectRoot,
231
243
  paths,
@@ -233,8 +245,9 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
233
245
  harnessRetries,
234
246
  testPackagingCoverage: coverage,
235
247
  refuseUnexportedDrift: options.refuseUnexportedDrift === true,
236
- extend: extending ? { requestedPaths: normalizedPaths } : undefined,
248
+ extend: extending && coverage !== 'full' ? { requestedPaths: coverage } : undefined,
237
249
  fullBuildReason,
250
+ controlBuild,
238
251
  furnace: await planPreTestFurnace(projectRoot, paths, projectConfig, options, normalizedPaths),
239
252
  });
240
253
  info('');
@@ -249,7 +262,29 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
249
262
  }
250
263
  }
251
264
  else {
252
- await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths);
265
+ await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths, {
266
+ launchablePath,
267
+ });
253
268
  }
254
269
  return false;
255
270
  }
271
+ /**
272
+ * Why a `--build` run's pre-test build must be a full `mach build` although
273
+ * nothing asked for one: compiled sources changed after the binary was
274
+ * linked, or the binary still carries a negative control a killed run (or a
275
+ * failed restore build) left compiled in. `mach build faster` compiles
276
+ * nothing, so either would otherwise survive the build.
277
+ */
278
+ async function describeCompiledEscalation(projectRoot, engineDir, launchablePath) {
279
+ const baseline = await readBuildBaseline(projectRoot);
280
+ const stale = await findCompiledSourcesNewerThanBinary(engineDir, baseline, launchablePath);
281
+ if (stale !== undefined && stale.paths.length > 0)
282
+ return formatStaleCompiledBuildNotice(stale);
283
+ const stamp = baseline?.controlBuild;
284
+ if (stamp !== undefined) {
285
+ return (`The binary was built with the negative control ${stamp.diffFile} applied and not ` +
286
+ 'rebuilt since; escalating this pre-test build to a full mach build so it recompiles ' +
287
+ 'the restored source.');
288
+ }
289
+ return undefined;
290
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The restore build after a compiled negative control.
3
+ *
4
+ * A control on compiled source is applied before a full pre-test build, so
5
+ * the binary under test carries it. Restoring the source byte-exact after
6
+ * the run does not unbuild it: the objdir binary keeps the mutation until a
7
+ * build recompiles the original. Every later `fireforge test` would then
8
+ * test the control. The orchestrator therefore runs one more full
9
+ * `mach build` right after a successful restore, still under the same
10
+ * engine-session hold, and its baseline drops the `controlBuild` stamp the
11
+ * control's build recorded. When that build fails, or the run never gets
12
+ * here (killed), the stamp stays and the next `fireforge test` refuses with
13
+ * `note=control-built` until `fireforge build` replaces it.
14
+ */
15
+ import type { ControlBuildStamp } from '../core/build-baseline-types.js';
16
+ /** Outcome of {@link runControlRestoreBuild}, stamped as `control-restore-build=`. */
17
+ export type ControlRestoreBuildOutcome = 'done' | 'failed';
18
+ /**
19
+ * Whether the recorded baseline is the one this run's control build wrote:
20
+ * only then did a build compile the control in (a failed control build
21
+ * writes no baseline, and its restored source's fresh mtime trips the
22
+ * stale-compiled check instead).
23
+ *
24
+ * @param projectRoot - Project root
25
+ * @param stamp - The stamp this run's control build recorded
26
+ */
27
+ export declare function controlBuildRecorded(projectRoot: string, stamp: ControlBuildStamp): Promise<boolean>;
28
+ /**
29
+ * Rebuilds the binary after a compiled control was restored: a full
30
+ * protected `mach build` under the build lock, then a baseline without the
31
+ * stamp. The previous baseline's packaging-coverage claim is carried
32
+ * forward unchanged, since recompiling the restored source stages no tests.
33
+ * Never throws: a failure leaves the stamp in place, which is the refusal
34
+ * the next run needs.
35
+ *
36
+ * @param projectRoot - Project root
37
+ * @param stamp - The stamp this run's control build recorded
38
+ * @param harnessRetries - Recognized-crash retry budget for the build
39
+ */
40
+ export declare function runControlRestoreBuild(projectRoot: string, stamp: ControlBuildStamp, harnessRetries: number | undefined): Promise<ControlRestoreBuildOutcome>;
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The restore build after a compiled negative control.
4
+ *
5
+ * A control on compiled source is applied before a full pre-test build, so
6
+ * the binary under test carries it. Restoring the source byte-exact after
7
+ * the run does not unbuild it: the objdir binary keeps the mutation until a
8
+ * build recompiles the original. Every later `fireforge test` would then
9
+ * test the control. The orchestrator therefore runs one more full
10
+ * `mach build` right after a successful restore, still under the same
11
+ * engine-session hold, and its baseline drops the `controlBuild` stamp the
12
+ * control's build recorded. When that build fails, or the run never gets
13
+ * here (killed), the stamp stays and the next `fireforge test` refuses with
14
+ * `note=control-built` until `fireforge build` replaces it.
15
+ */
16
+ import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
17
+ import { getProjectPaths, loadConfig } from '../core/config.js';
18
+ import { runProtectedMachBuild, withBuildLock } from '../core/mach.js';
19
+ import { toError } from '../utils/errors.js';
20
+ import { info, notice, verbose, warn } from '../utils/logger.js';
21
+ /**
22
+ * Whether the recorded baseline is the one this run's control build wrote:
23
+ * only then did a build compile the control in (a failed control build
24
+ * writes no baseline, and its restored source's fresh mtime trips the
25
+ * stale-compiled check instead).
26
+ *
27
+ * @param projectRoot - Project root
28
+ * @param stamp - The stamp this run's control build recorded
29
+ */
30
+ export async function controlBuildRecorded(projectRoot, stamp) {
31
+ const baseline = await readBuildBaseline(projectRoot);
32
+ return (baseline?.controlBuild?.appliedAt === stamp.appliedAt &&
33
+ baseline.controlBuild.diffFile === stamp.diffFile);
34
+ }
35
+ /**
36
+ * Rebuilds the binary after a compiled control was restored: a full
37
+ * protected `mach build` under the build lock, then a baseline without the
38
+ * stamp. The previous baseline's packaging-coverage claim is carried
39
+ * forward unchanged, since recompiling the restored source stages no tests.
40
+ * Never throws: a failure leaves the stamp in place, which is the refusal
41
+ * the next run needs.
42
+ *
43
+ * @param projectRoot - Project root
44
+ * @param stamp - The stamp this run's control build recorded
45
+ * @param harnessRetries - Recognized-crash retry budget for the build
46
+ */
47
+ export async function runControlRestoreBuild(projectRoot, stamp, harnessRetries) {
48
+ const paths = getProjectPaths(projectRoot);
49
+ notice(`Rebuilding after the compiled negative control (${stamp.targets.map((target) => `engine/${target}`).join(', ')}): ` +
50
+ 'the binary still carries the mutation until a full mach build recompiles the restored source.');
51
+ try {
52
+ const config = await loadConfig(projectRoot);
53
+ return await withBuildLock(projectRoot, async () => {
54
+ const previousBaseline = await readBuildBaseline(projectRoot);
55
+ const result = await runProtectedMachBuild('full', paths.engine, {
56
+ ...(harnessRetries === undefined ? {} : { retries: harnessRetries }),
57
+ });
58
+ if (result.exitCode !== 0) {
59
+ warn('The restore build after the negative control failed, so the binary still carries ' +
60
+ 'the control. The next "fireforge test" refuses (note=control-built) until ' +
61
+ '"fireforge build" succeeds.');
62
+ return 'failed';
63
+ }
64
+ await writeBuildBaseline({
65
+ projectRoot,
66
+ engineDir: paths.engine,
67
+ binaryName: config.binaryName,
68
+ testPackagingCoverage: previousBaseline?.testPackagingCoverage,
69
+ testCoverageRecords: previousBaseline?.testCoverageRecords,
70
+ fullCoverageAnchor: previousBaseline?.fullCoverageAnchor,
71
+ previousBaseline,
72
+ recordedBy: 'fireforge test --negative-control (restore build)',
73
+ staticComponentsHandling: 'refresh',
74
+ buildKind: 'full',
75
+ });
76
+ info('Restore build complete: the binary no longer carries the negative control.');
77
+ return 'done';
78
+ });
79
+ }
80
+ catch (error) {
81
+ verbose(`Restore build after the negative control failed: ${toError(error).message}`);
82
+ warn(`The restore build after the negative control could not run (${toError(error).message}). ` +
83
+ 'The next "fireforge test" refuses (note=control-built) until "fireforge build" succeeds.');
84
+ return 'failed';
85
+ }
86
+ }
@@ -6,11 +6,14 @@
6
6
  * `--jobs` parallel harness instances.
7
7
  */
8
8
  import { createServer } from 'node:net';
9
+ import { join } from 'node:path';
9
10
  import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
10
- import { autoPortsRequested } from '../core/harness-port-fallback.js';
11
+ import { autoPortsRequested, detectHarnessPortFallback } from '../core/harness-port-fallback.js';
12
+ import { reserveHarnessServerPorts, } from '../core/harness-port-reservation.js';
11
13
  import { extractForwardedMarionettePort } from '../core/marionette-port.js';
12
14
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
15
  import { BuildError } from '../errors/build.js';
16
+ import { ensureDir } from '../utils/fs.js';
14
17
  import { notice } from '../utils/logger.js';
15
18
  import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
16
19
  import { shuffleTestGroups } from './test-harness-env.js';
@@ -95,15 +98,40 @@ async function allocateLoopbackPort() {
95
98
  function testRunManifestArg(index) {
96
99
  return `--testrun-manifest-file=fireforge-tests-${process.pid}-job${index + 1}.json`;
97
100
  }
101
+ /**
102
+ * The `MOZ_UPLOAD_DIR` the caller gave this run (its own env first, then
103
+ * the process environment), or undefined when there is none.
104
+ */
105
+ function uploadDirBase(base) {
106
+ const dir = base.env?.['MOZ_UPLOAD_DIR'] ?? process.env['MOZ_UPLOAD_DIR'];
107
+ return dir === undefined || dir === '' ? undefined : dir;
108
+ }
109
+ /**
110
+ * `--jobs` instance `index`'s own upload directory, `<base>/job-<n>`. The
111
+ * harness writes per-run artifacts there under fixed names (the
112
+ * `modifiedPrefs.json` pref census among them), so instances sharing the
113
+ * caller's directory replace each other's files.
114
+ */
115
+ function instanceUploadDir(uploadBase, index) {
116
+ return join(uploadBase, `job-${index + 1}`);
117
+ }
98
118
  /**
99
119
  * The context for `--jobs` instance `index` (0-based): its own output
100
- * prefix and test-list manifest, and from the second instance on its own
101
- * Marionette port, so browsers started at the same moment never race for
102
- * 2828. The mochitest server ports are left to the engine harness's
103
- * busy-port fallback, which `--jobs` requires (auto ports).
120
+ * prefix, upload directory (when the caller set `MOZ_UPLOAD_DIR`) and
121
+ * test-list manifest, and from the second instance on its own Marionette
122
+ * port, so browsers started at the same moment never race for 2828. The
123
+ * mochitest server ports are moved by the engine harness's busy-port
124
+ * fallback, which `--jobs` requires (auto ports), while
125
+ * {@link reserveHarnessServerPorts} holds the defaults.
104
126
  */
105
127
  async function instanceContext(base, index, xpcshellOnly) {
106
- const ctx = { ...base, outputPrefix: `[job ${index + 1}] ` };
128
+ let ctx = { ...base, outputPrefix: `[job ${index + 1}] ` };
129
+ const uploadBase = uploadDirBase(base);
130
+ if (uploadBase !== undefined) {
131
+ const uploadDir = instanceUploadDir(uploadBase, index);
132
+ await ensureDir(uploadDir);
133
+ ctx = { ...ctx, env: { ...base.env, MOZ_UPLOAD_DIR: uploadDir } };
134
+ }
107
135
  if (xpcshellOnly)
108
136
  return ctx;
109
137
  const own = [...base.baseExtraArgs, testRunManifestArg(index)];
@@ -115,6 +143,37 @@ async function instanceContext(base, index, xpcshellOnly) {
115
143
  baseExtraArgs: [...own, `--setpref=marionette.port=${port}`, `--marionette=127.0.0.1:${port}`],
116
144
  };
117
145
  }
146
+ /**
147
+ * Stamps a `--jobs` run's verdict attributes: `jobs=<n>`, and
148
+ * `upload-dirs=<dir>,...` naming each instance's upload directory, so a
149
+ * caller can merge per-instance artifacts.
150
+ */
151
+ function stampParallelAttributes(runCtx, jobs, shards) {
152
+ setVerdictRunAttribute('jobs', String(jobs));
153
+ const uploadBase = uploadDirBase(runCtx);
154
+ if (uploadBase === undefined)
155
+ return;
156
+ const instances = Math.max(1, Math.min(jobs, shards));
157
+ setVerdictRunAttribute('upload-dirs', Array.from({ length: instances }, (_, index) => instanceUploadDir(uploadBase, index)).join(','));
158
+ }
159
+ /**
160
+ * Holds the mochitest server ports for a browser run on more than one
161
+ * instance (see `harness-port-reservation.ts`). Only on an engine whose
162
+ * harness falls back from a busy server port: without that fallback a
163
+ * held port would fail every instance instead of moving it.
164
+ */
165
+ async function reserveParallelServerPorts(engineDir, instances, xpcshellOnly) {
166
+ if (instances < 2 || xpcshellOnly)
167
+ return undefined;
168
+ if (!(await detectHarnessPortFallback(engineDir)).serverPorts)
169
+ return undefined;
170
+ const reservation = await reserveHarnessServerPorts();
171
+ if (reservation.held.length > 0) {
172
+ notice(`Holding mochitest server port(s) ${reservation.held.join(', ')} for the parallel run, so ` +
173
+ "each instance's harness moves to server ports of its own.");
174
+ }
175
+ return reservation;
176
+ }
118
177
  /** The shards to run, or undefined for one combined invocation. */
119
178
  function planShards(inputs) {
120
179
  const { dispatchGroups, options, canaryPath, shuffleSeed } = inputs;
@@ -152,9 +211,10 @@ export async function dispatchTests(inputs) {
152
211
  notice(describeSharding(shards.length, options, jobs));
153
212
  const base = jobs > 1 ? await instanceContext(runCtx, 0, inputs.xpcshellOnly) : runCtx;
154
213
  if (jobs > 1)
155
- setVerdictRunAttribute('jobs', String(jobs));
214
+ stampParallelAttributes(runCtx, jobs, shards.length);
156
215
  const generationBefore = await snapshotEngineGeneration(engineDir);
157
216
  let summary;
217
+ const reservation = await reserveParallelServerPorts(engineDir, Math.min(jobs, shards.length), inputs.xpcshellOnly);
158
218
  try {
159
219
  summary = await runShardedTests(base, shards, (outcome, label) => diagnoseShardOutcome(outcome, label, projectConfig.binaryName, inputs.postRebuildContext), {
160
220
  jobs,
@@ -163,6 +223,7 @@ export async function dispatchTests(inputs) {
163
223
  });
164
224
  }
165
225
  finally {
226
+ await reservation?.release();
166
227
  // Runs before the aggregate verdict below: a mutated engine/ emits
167
228
  // `FAIL reason=inconclusive` and throws, so an invalidated run can
168
229
  // never print `PASS shards=N/N` first. (A throw here masks an