@hominis/fireforge 0.47.1 → 0.49.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 (167) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli.js +16 -28
  5. package/dist/src/commands/build.js +30 -4
  6. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  7. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  8. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  9. package/dist/src/commands/doctor-macos-sdk.js +158 -0
  10. package/dist/src/commands/doctor-source-pin.js +9 -2
  11. package/dist/src/commands/doctor.js +4 -0
  12. package/dist/src/commands/download.js +64 -3
  13. package/dist/src/commands/furnace/apply.js +10 -4
  14. package/dist/src/commands/furnace/create-templates.js +0 -3
  15. package/dist/src/commands/import.js +5 -2
  16. package/dist/src/commands/lint-per-patch.js +44 -6
  17. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  18. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  19. package/dist/src/commands/lint.js +14 -0
  20. package/dist/src/commands/manifest.d.ts +1 -2
  21. package/dist/src/commands/patch/delete.js +18 -4
  22. package/dist/src/commands/re-export-lint.d.ts +17 -2
  23. package/dist/src/commands/re-export-lint.js +33 -4
  24. package/dist/src/commands/re-export-options.js +7 -0
  25. package/dist/src/commands/re-export-register.js +1 -0
  26. package/dist/src/commands/re-export-removed.d.ts +4 -0
  27. package/dist/src/commands/re-export-removed.js +49 -0
  28. package/dist/src/commands/re-export-single.js +4 -1
  29. package/dist/src/commands/re-export.js +8 -2
  30. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  31. package/dist/src/commands/rebase/confirm.js +6 -3
  32. package/dist/src/commands/rebase/index.js +77 -13
  33. package/dist/src/commands/run.js +43 -7
  34. package/dist/src/commands/status.js +5 -3
  35. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  36. package/dist/src/commands/test-browser-preflight.js +181 -0
  37. package/dist/src/commands/test-build-phase.js +30 -7
  38. package/dist/src/commands/test-checkjs.d.ts +3 -0
  39. package/dist/src/commands/test-checkjs.js +27 -0
  40. package/dist/src/commands/test-diagnose.js +3 -0
  41. package/dist/src/commands/test-harness-env.d.ts +5 -6
  42. package/dist/src/commands/test-harness-env.js +27 -7
  43. package/dist/src/commands/test-modes.d.ts +2 -4
  44. package/dist/src/commands/test-register.js +11 -5
  45. package/dist/src/commands/test-run.d.ts +2 -0
  46. package/dist/src/commands/test-run.js +21 -1
  47. package/dist/src/commands/test-verdict.d.ts +2 -2
  48. package/dist/src/commands/test.js +83 -211
  49. package/dist/src/commands/typecheck.d.ts +5 -0
  50. package/dist/src/commands/typecheck.js +7 -1
  51. package/dist/src/core/build-baseline-types.d.ts +12 -14
  52. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  53. package/dist/src/core/build-baseline-validate.js +53 -0
  54. package/dist/src/core/build-baseline.d.ts +4 -3
  55. package/dist/src/core/build-baseline.js +23 -11
  56. package/dist/src/core/build-prepare.d.ts +2 -0
  57. package/dist/src/core/build-prepare.js +67 -35
  58. package/dist/src/core/component-source-guard.d.ts +20 -0
  59. package/dist/src/core/component-source-guard.js +68 -0
  60. package/dist/src/core/config-paths.d.ts +1 -1
  61. package/dist/src/core/config-paths.js +1 -0
  62. package/dist/src/core/config-validate.js +7 -0
  63. package/dist/src/core/coverage-extend.d.ts +5 -34
  64. package/dist/src/core/coverage-extend.js +26 -37
  65. package/dist/src/core/discard-baseline.js +2 -1
  66. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  67. package/dist/src/core/engine-write-boundary.js +41 -0
  68. package/dist/src/core/forward-registrations.d.ts +33 -0
  69. package/dist/src/core/forward-registrations.js +16 -0
  70. package/dist/src/core/furnace-apply-helpers.js +8 -2
  71. package/dist/src/core/furnace-apply.js +7 -0
  72. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  73. package/dist/src/core/furnace-validate.js +0 -43
  74. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  75. package/dist/src/core/gecko-dom-types.js +17 -0
  76. package/dist/src/core/git-file-ops.d.ts +21 -0
  77. package/dist/src/core/git-file-ops.js +75 -0
  78. package/dist/src/core/git-status.d.ts +14 -0
  79. package/dist/src/core/git-status.js +41 -2
  80. package/dist/src/core/lit-template-regions.d.ts +4 -0
  81. package/dist/src/core/lit-template-regions.js +50 -0
  82. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  83. package/dist/src/core/mach-build-artifacts.js +13 -7
  84. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  85. package/dist/src/core/mach-mozconfig.js +2 -0
  86. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  87. package/dist/src/core/mach-resource-shim.js +2 -0
  88. package/dist/src/core/marionette-port.d.ts +2 -0
  89. package/dist/src/core/marionette-port.js +38 -53
  90. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  91. package/dist/src/core/mochitest-server-port.js +20 -17
  92. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  93. package/dist/src/core/objdir-maintenance.js +151 -0
  94. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  95. package/dist/src/core/patch-apply-fuzz.js +27 -6
  96. package/dist/src/core/patch-apply.d.ts +13 -0
  97. package/dist/src/core/patch-apply.js +27 -2
  98. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  99. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  100. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  101. package/dist/src/core/patch-lint-checkjs.js +10 -2
  102. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  103. package/dist/src/core/patch-lint-cross.js +13 -12
  104. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  105. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  106. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  107. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  108. package/dist/src/core/patch-lint.d.ts +11 -0
  109. package/dist/src/core/patch-lint.js +20 -2
  110. package/dist/src/core/patch-transform.d.ts +14 -13
  111. package/dist/src/core/patch-transform.js +29 -17
  112. package/dist/src/core/preflight-wait.d.ts +2 -0
  113. package/dist/src/core/preflight-wait.js +24 -0
  114. package/dist/src/core/process-owner.d.ts +9 -0
  115. package/dist/src/core/process-owner.js +43 -0
  116. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  117. package/dist/src/core/rebase-dry-run.js +105 -0
  118. package/dist/src/core/run-profile.d.ts +50 -0
  119. package/dist/src/core/run-profile.js +131 -0
  120. package/dist/src/core/status-binary.d.ts +1 -1
  121. package/dist/src/core/status-binary.js +3 -1
  122. package/dist/src/core/status-classify.js +29 -2
  123. package/dist/src/core/test-harness-crash.d.ts +1 -1
  124. package/dist/src/core/test-harness-crash.js +5 -5
  125. package/dist/src/core/test-host-state.d.ts +14 -0
  126. package/dist/src/core/test-host-state.js +80 -0
  127. package/dist/src/core/test-profile-files.d.ts +17 -0
  128. package/dist/src/core/test-profile-files.js +75 -0
  129. package/dist/src/core/test-stale-check.d.ts +6 -7
  130. package/dist/src/core/test-stale-check.js +9 -32
  131. package/dist/src/core/token-category.d.ts +7 -26
  132. package/dist/src/core/token-category.js +68 -121
  133. package/dist/src/core/token-inventory.js +12 -7
  134. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  135. package/dist/src/core/toolchain-preflight.js +1 -1
  136. package/dist/src/core/typecheck.d.ts +11 -1
  137. package/dist/src/core/typecheck.js +86 -12
  138. package/dist/src/errors/rebase.d.ts +9 -0
  139. package/dist/src/errors/rebase.js +20 -0
  140. package/dist/src/index.d.ts +3 -1
  141. package/dist/src/index.js +2 -1
  142. package/dist/src/types/commands/options.d.ts +29 -3
  143. package/dist/src/types/config.d.ts +10 -0
  144. package/dist/src/utils/assert.js +2 -7
  145. package/dist/src/utils/child-env.d.ts +0 -3
  146. package/dist/src/utils/child-env.js +0 -3
  147. package/dist/src/utils/concurrency.d.ts +2 -2
  148. package/dist/src/utils/concurrency.js +15 -4
  149. package/dist/src/utils/fs.js +11 -1
  150. package/dist/src/utils/options.d.ts +16 -1
  151. package/dist/src/utils/options.js +5 -3
  152. package/dist/src/utils/paths.js +3 -2
  153. package/dist/src/utils/process-group.d.ts +1 -2
  154. package/dist/src/utils/process-group.js +1 -2
  155. package/dist/src/utils/process-input.d.ts +27 -0
  156. package/dist/src/utils/process-input.js +46 -0
  157. package/dist/src/utils/process.d.ts +5 -2
  158. package/dist/src/utils/process.js +21 -8
  159. package/dist/src/utils/validation.d.ts +11 -0
  160. package/dist/src/utils/validation.js +43 -0
  161. package/docs/configuration.md +82 -0
  162. package/docs/exit-codes.md +1 -1
  163. package/docs/lifecycle-invariants.md +13 -8
  164. package/docs/machine-output.md +36 -19
  165. package/docs/patch-workflow.md +50 -1
  166. package/docs/testing.md +77 -12
  167. package/package.json +10 -5
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { tokenizer } from 'acorn';
3
+ /** Collects Lit html quasis, masking expressions while preserving nested templates. */
4
+ export function collectLitTemplateRegions(content) {
5
+ const regions = [];
6
+ const stack = [];
7
+ let previous = '';
8
+ let previousValue;
9
+ try {
10
+ for (const token of tokenizer(content, { ecmaVersion: 'latest', sourceType: 'module' })) {
11
+ const label = token.type.label;
12
+ if (label === '`') {
13
+ if (previous === 'template' || previous === 'invalidTemplate') {
14
+ const closed = stack.pop();
15
+ if (closed?.html)
16
+ regions.push(closed.text);
17
+ }
18
+ else {
19
+ stack.push({ html: previous === 'name' && previousValue === 'html', text: '' });
20
+ }
21
+ }
22
+ else if (label === 'template') {
23
+ const current = stack.at(-1);
24
+ if (current)
25
+ current.text += content.slice(token.start, token.end);
26
+ }
27
+ else if (label === '${') {
28
+ const current = stack.at(-1);
29
+ if (current)
30
+ current.text += '${expression}';
31
+ }
32
+ previous = label;
33
+ previousValue = content.slice(token.start, token.end);
34
+ }
35
+ }
36
+ catch {
37
+ // Keep completed regions from a file being edited; never re-pair backticks.
38
+ }
39
+ return regions;
40
+ }
41
+ /** Reads actual attribute names and values, skipping quoted values as indivisible tokens. */
42
+ export function parseTemplateAttributes(source) {
43
+ const attributes = new Map();
44
+ const pattern = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
45
+ for (const match of source.matchAll(pattern)) {
46
+ if (match[1])
47
+ attributes.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4]);
48
+ }
49
+ return attributes;
50
+ }
@@ -92,6 +92,8 @@ export interface MozinfoRewriteResult {
92
92
  reason?: string;
93
93
  /** New `topsrcdir` value written to disk (populated on success). */
94
94
  newTopsrcdir?: string;
95
+ /** Original source prefix checked after configure. */
96
+ oldTopsrcdir?: string;
95
97
  /** New `topobjdir` value written to disk (populated on success). */
96
98
  newTopobjdir?: string;
97
99
  /** New `mozconfig` value written to disk (populated on success when it lived inside topsrcdir). */
@@ -100,12 +102,10 @@ export interface MozinfoRewriteResult {
100
102
  /**
101
103
  * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
102
104
  *
103
- * Firefox build artefacts bake the topsrcdir into many generated files
104
- * (Makefiles, config.status, backend.mk, .deps dependency files, anything
105
- * produced by `mach configure`). A fresh `mach configure` rebuilds those
106
- * from the top, so the rewriter only needs to patch the one file `mach`
107
- * reads to learn where its checkout actually lives. Once mozinfo.json
108
- * agrees with the on-disk layout, `mach configure` regenerates the rest.
105
+ * Copied objdirs retain source symlinks, dependency metadata and tracking
106
+ * inventories as well as mozinfo.json. Repair them before configure so
107
+ * config.track cannot write nulls into the primary checkout. Configure then
108
+ * regenerates the root config files; callers verify old-source links too.
109
109
  *
110
110
  * Safety rules. The rewrite is refused when any of them are violated:
111
111
  * - `topsrcdir` and `topobjdir` must both be present and non-empty.
@@ -9,6 +9,7 @@ import { verbose } from '../utils/logger.js';
9
9
  import { getPlatform } from '../utils/platform.js';
10
10
  import { isObject, isString } from '../utils/validation.js';
11
11
  import { extractMozObjdirName } from './mach-mozconfig.js';
12
+ import { findStaleObjdirLink, relocateObjdirProducts } from './objdir-maintenance.js';
12
13
  function validateBuildMozinfo(data) {
13
14
  if (!isObject(data)) {
14
15
  throw new Error('mozinfo metadata must be an object');
@@ -239,12 +240,10 @@ export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName)
239
240
  /**
240
241
  * Safe-relocation rewriter for mozinfo.json under the active obj-* tree.
241
242
  *
242
- * Firefox build artefacts bake the topsrcdir into many generated files
243
- * (Makefiles, config.status, backend.mk, .deps dependency files, anything
244
- * produced by `mach configure`). A fresh `mach configure` rebuilds those
245
- * from the top, so the rewriter only needs to patch the one file `mach`
246
- * reads to learn where its checkout actually lives. Once mozinfo.json
247
- * agrees with the on-disk layout, `mach configure` regenerates the rest.
243
+ * Copied objdirs retain source symlinks, dependency metadata and tracking
244
+ * inventories as well as mozinfo.json. Repair them before configure so
245
+ * config.track cannot write nulls into the primary checkout. Configure then
246
+ * regenerates the root config files; callers verify old-source links too.
248
247
  *
249
248
  * Safety rules. The rewrite is refused when any of them are violated:
250
249
  * - `topsrcdir` and `topobjdir` must both be present and non-empty.
@@ -328,10 +327,17 @@ export async function attemptMozinfoRewrite(engineDir, objDir) {
328
327
  // its mozconfig will still fail configure. The operator can re-point
329
328
  // with `MOZCONFIG=…` or run a full clean rebuild.
330
329
  }
330
+ try {
331
+ await relocateObjdirProducts(engineDir, objDir, oldSrcResolved);
332
+ }
333
+ catch (error) {
334
+ return { rewritten: false, reason: `objdir relocation refused: ${toError(error).message}` };
335
+ }
331
336
  await writeJson(mozinfoPath, patched);
332
337
  return {
333
338
  rewritten: true,
334
339
  newTopsrcdir: newSrc,
340
+ oldTopsrcdir: oldSrcResolved,
335
341
  newTopobjdir: newObj,
336
342
  ...(newMozconfig ? { newMozconfig } : {}),
337
343
  };
@@ -426,5 +432,5 @@ export async function findObjdirRelocationViolation(args) {
426
432
  return `${objDir}/${name} still contains the primary engine path ${forbidden}`;
427
433
  }
428
434
  }
429
- return undefined;
435
+ return await findStaleObjdirLink(engineDir, objDir, forbidden);
430
436
  }
@@ -52,5 +52,6 @@ export declare function assertBrandingMozconfigAgreement(engineDir: string, mozc
52
52
  * @param configsDir - Path to the configs directory
53
53
  * @param engineDir - Path to the engine directory
54
54
  * @param config - FireForge configuration
55
+ * @returns Whether engine/mozconfig was rewritten (its content changed)
55
56
  */
56
- export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<void>;
57
+ export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<boolean>;
@@ -114,6 +114,7 @@ export async function assertBrandingMozconfigAgreement(engineDir, mozconfigPath,
114
114
  * @param configsDir - Path to the configs directory
115
115
  * @param engineDir - Path to the engine directory
116
116
  * @param config - FireForge configuration
117
+ * @returns Whether engine/mozconfig was rewritten (its content changed)
117
118
  */
118
119
  export async function generateMozconfig(configsDir, engineDir, config) {
119
120
  const platform = getPlatform();
@@ -161,4 +162,5 @@ export async function generateMozconfig(configsDir, engineDir, config) {
161
162
  // write, before anything consumes mozconfig) keeps `generateMozconfig`
162
163
  // the single source of truth for both the render and the sanity-check.
163
164
  await assertBrandingMozconfigAgreement(engineDir, outputPath, config);
165
+ return wrote;
164
166
  }
@@ -71,7 +71,7 @@
71
71
  * regression test (mach-resource-shim.python.test.ts), which asserts the
72
72
  * degraded readings' runtime behavior rather than string-matching the source.
73
73
  */
74
- export declare const GUARD_PYTHON_SOURCE = "# Generated by FireForge - do not edit.\n# Degrades the broken host resource monitor family (psutil vs Darwin) from\n# fatal startup errors into non-fatal warnings, so mach builds and tests\n# proceed. Installed into the mach virtualenv site-packages via a .pth file\n# (survives mach's venv re-exec, which drops PYTHONPATH).\nimport builtins\nimport collections\nimport inspect\nimport sys\nimport warnings\n\n# Any psutil degradation observed in this process. Once set, monitors are\n# kept inert (start never spawns the collector child) \u2014 on a flapping host a\n# healthy-looking moment must not be trusted to start a sample stream that a\n# later degraded reading could malform.\n_fireforge_host_degraded = [False]\n\n\ndef _fireforge_degraded_notice(exc):\n _fireforge_host_degraded[0] = True\n warnings.warn(\n \"FireForge: host resource monitor degraded (%s); continuing without resource monitoring.\" % exc\n )\n\n\nclass _DegradedReading(object):\n # Last-resort duck type of psutil's namedtuple results (svmem field\n # order): mozsystemmonitor subscripts, iterates, and unpacks readings,\n # so the degraded fallback must survive r[0], list(r), len(r), r._fields\n # and r._asdict(), not just attribute access. The parent also reconstructs\n # readings via type(reading)(*values), so the constructor must tolerate\n # the full positional field list. Normal\n # degraded paths use the per-function namedtuple fallbacks below; this\n # class only backstops a fallback that itself raised.\n _fields = (\"total\", \"available\", \"percent\", \"used\", \"free\", \"active\", \"inactive\", \"wired\")\n total = available = used = free = active = inactive = wired = 0\n percent = 0.0\n\n def __init__(self, *_args, **_kwargs):\n pass\n\n def __getattr__(self, _name):\n return 0\n\n def _values(self):\n return tuple(getattr(self, _field) for _field in self._fields)\n\n def __getitem__(self, index):\n return self._values()[index]\n\n def __iter__(self):\n return iter(self._values())\n\n def __len__(self):\n return len(self._fields)\n\n def _asdict(self):\n return dict(zip(self._fields, self._values()))\n\n\n# Per-function fallback result classes with the exact macOS field\n# orders/arities. Module-level on purpose: readings cross the\n# mozsystemmonitor collector pipe via pickle (by reference), and the parent\n# rebuilds each sample with type(reading)(*values) \u2014 so a swap fallback must\n# be 6-field sswap-shaped in every position. An svmem-shaped fallback there\n# makes the parent reject every sample, fill the pipe, block the collector\n# child in send(), and wedge mach's atexit join forever.\n_FallbackVmem = collections.namedtuple(\n \"_FallbackVmem\",\n (\"total\", \"available\", \"percent\", \"used\", \"free\", \"active\", \"inactive\", \"wired\"),\n)\n_FallbackSwap = collections.namedtuple(\n \"_FallbackSwap\", (\"total\", \"used\", \"free\", \"percent\", \"sin\", \"sout\")\n)\n_FallbackCpuTimes = collections.namedtuple(\"_FallbackCpuTimes\", (\"user\", \"nice\", \"system\", \"idle\"))\n_FallbackDiskIO = collections.namedtuple(\n \"_FallbackDiskIO\",\n (\"read_count\", \"write_count\", \"read_bytes\", \"write_bytes\", \"read_time\", \"write_time\"),\n)\n\n_FALLBACK_CLASSES = {\n \"virtual_memory\": _FallbackVmem,\n \"swap_memory\": _FallbackSwap,\n \"cpu_times\": _FallbackCpuTimes,\n \"disk_io_counters\": _FallbackDiskIO,\n}\n\n# psutil result namedtuple per wrapped function, resolvable via getattr from\n# the platform module / _common without invoking the failing syscall.\n_PSUTIL_RESULT_CLASSES = {\n \"virtual_memory\": \"svmem\",\n \"swap_memory\": \"sswap\",\n \"cpu_times\": \"scputimes\",\n \"disk_io_counters\": \"sdiskio\",\n}\n\n\ndef _zeroed(cls):\n return cls(*([0] * len(cls._fields)))\n\n\ndef _percpu_requested(args, kwargs, positional_index):\n if \"percpu\" in kwargs:\n return bool(kwargs[\"percpu\"])\n return len(args) > positional_index and bool(args[positional_index])\n\n\ndef _degraded_result_factory(psutil, name):\n # cpu_percent returns a plain float (or a per-CPU list) callers do\n # arithmetic on.\n if name == \"cpu_percent\":\n def _cpu_percent_fallback(*args, **kwargs):\n # cpu_percent(interval=None, percpu=False)\n if _percpu_requested(args, kwargs, 1):\n return []\n return 0.0\n\n return _cpu_percent_fallback\n # The guard-owned class is always shape-correct for this function;\n # psutil's own result namedtuple is still preferred when resolvable so\n # type() captures stay identical to real readings on a flapping host.\n _cls = _FALLBACK_CLASSES.get(name, _DegradedReading)\n _cls_name = _PSUTIL_RESULT_CLASSES.get(name)\n if _cls_name is not None:\n for _mod in (getattr(psutil, \"_psplatform\", None), getattr(psutil, \"_common\", None)):\n _real = getattr(_mod, _cls_name, None) if _mod is not None else None\n if _real is not None and getattr(_real, \"_fields\", None):\n _cls = _real\n break\n if name == \"cpu_times\":\n def _cpu_times_fallback(*args, **kwargs):\n # cpu_times(percpu=False); the collector child samples per-CPU.\n if _percpu_requested(args, kwargs, 0):\n return []\n return _zeroed(_cls)\n\n return _cpu_times_fallback\n\n def _reading_fallback(*_args, **_kwargs):\n return _zeroed(_cls)\n\n return _reading_fallback\n\n\ndef _guard_psutil():\n try:\n import psutil\n except Exception:\n return\n\n def _wrap(orig, fallback):\n def wrapper(*args, **kwargs):\n try:\n return orig(*args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n try:\n return fallback(*args, **kwargs)\n except Exception: # noqa: BLE001\n return _DegradedReading()\n\n return wrapper\n\n for _name in (\"virtual_memory\", \"swap_memory\", \"cpu_percent\", \"cpu_times\", \"disk_io_counters\"):\n _orig = getattr(psutil, _name, None)\n if _orig is not None and not getattr(_orig, \"_fireforge_guarded\", False):\n try:\n _fallback = _degraded_result_factory(psutil, _name)\n except Exception: # noqa: BLE001\n _fallback = _DegradedReading\n _wrapped = _wrap(_orig, _fallback)\n _wrapped._fireforge_guarded = True\n setattr(psutil, _name, _wrapped)\n\n\n_MONITOR_METHOD_NAMES = (\n \"start\",\n \"stop\",\n \"record_event\",\n \"record_marker\",\n \"begin_phase\",\n \"finish_phase\",\n \"aggregate_cpu_percent\",\n \"aggregate_cpu_times\",\n \"aggregate_io\",\n \"min_memory_available\",\n \"as_dict\",\n)\n\n\ndef _fireforge_stop_collector(monitor):\n # Best-effort: terminate a live collector child and drain the parent end\n # of the pipe, so a child blocked in send() on a full pipe can never\n # keep mach's atexit join (os.waitpid) waiting forever. Attribute names\n # follow upstream resourcemonitor.py (self._process / self._pipe); a\n # miss on a refactored upstream is harmless \u2014 the shape-correct\n # fallbacks above still keep the sample stream well-formed.\n try:\n proc = getattr(monitor, \"_process\", None)\n if proc is not None and getattr(proc, \"is_alive\", lambda: False)():\n proc.terminate()\n proc.join(1)\n except Exception: # noqa: BLE001\n pass\n try:\n pipe = getattr(monitor, \"_pipe\", None)\n if pipe is not None:\n while pipe.poll(0):\n pipe.recv()\n except Exception: # noqa: BLE001\n pass\n\n\ndef _monitor_per_cpu_requested(args, kwargs):\n # aggregate_cpu_percent/aggregate_cpu_times(start=None, end=None,\n # phase=None, per_cpu=True) \u2014 self already stripped from args.\n if \"per_cpu\" in kwargs:\n return bool(kwargs[\"per_cpu\"])\n if len(args) > 3:\n return bool(args[3])\n return True\n\n\ndef _monitor_degraded_result(name, args, kwargs):\n # Degraded aggregate methods return zeroed shapes, not None, where\n # callers subscript the result: mozbuild's log_resource_usage does\n # usage[\"io\"].read_bytes after a successful compile, so None there fails\n # the build with complete artifacts already on disk.\n if name == \"aggregate_io\":\n return _zeroed(_FallbackDiskIO)\n if name == \"aggregate_cpu_percent\":\n return [] if _monitor_per_cpu_requested(args, kwargs) else 0.0\n if name == \"aggregate_cpu_times\":\n return [] if _monitor_per_cpu_requested(args, kwargs) else _zeroed(_FallbackCpuTimes)\n return None\n\n\ndef _patch_monitor_class(cls):\n if cls is None or getattr(cls, \"_fireforge_guarded\", False):\n return\n orig_init = cls.__init__\n\n def guarded_init(self, *args, **kwargs):\n # Pre-populate the attributes a partially-constructed instance is\n # later polled for, so a failing __init__ can never surface\n # AttributeError: poll_interval.\n self.poll_interval = kwargs.get(\"poll_interval\") or 1.0\n self._fireforge_degraded = False\n try:\n orig_init(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n self._fireforge_degraded = True\n _fireforge_degraded_notice(exc)\n if _fireforge_host_degraded[0] and not self._fireforge_degraded:\n # Host already degraded once: keep the monitor inert so the\n # collector child never spawns on a flapping host.\n self._fireforge_degraded = True\n if self._fireforge_degraded:\n _fireforge_stop_collector(self)\n\n cls.__init__ = guarded_init\n\n def _wrap_method(name, orig):\n def wrapper(self, *args, **kwargs):\n if (\n name == \"start\"\n and _fireforge_host_degraded[0]\n and not getattr(self, \"_fireforge_degraded\", False)\n ):\n self._fireforge_degraded = True\n _fireforge_stop_collector(self)\n if getattr(self, \"_fireforge_degraded\", False):\n if name == \"stop\":\n _fireforge_stop_collector(self)\n return _monitor_degraded_result(name, args, kwargs)\n try:\n return orig(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n self._fireforge_degraded = True\n _fireforge_degraded_notice(exc)\n # A raising stop (or any degraded transition) must still\n # take the collector child down, or the parent's atexit\n # join hangs on a child blocked writing to a full pipe.\n _fireforge_stop_collector(self)\n return _monitor_degraded_result(name, args, kwargs)\n\n return wrapper\n\n def _wrap_unbound(name, func):\n # Upstream declares record_event/record_marker as @staticmethods (and\n # may declare others as @classmethods). Those take no receiver, so\n # they cannot use the per-instance degraded flag and must not have one\n # threaded in \u2014 see the descriptor dispatch below.\n def wrapper(*args, **kwargs):\n if _fireforge_host_degraded[0]:\n return _monitor_degraded_result(name, args, kwargs)\n try:\n return func(*args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n return _monitor_degraded_result(name, args, kwargs)\n\n return wrapper\n\n for _name in _MONITOR_METHOD_NAMES:\n # getattr_static returns the raw class attribute, so a staticmethod\n # is still a staticmethod object here. Plain getattr would hand back\n # the underlying function, and re-setting THAT on the class rebinds\n # it as an instance method: the receiver is then passed through as an\n # extra leading positional and every call raises\n # \"takes N positional arguments but N+1 were given\". Re-apply the\n # descriptor the attribute actually had.\n try:\n _raw = inspect.getattr_static(cls, _name)\n except AttributeError:\n continue\n if isinstance(_raw, staticmethod):\n setattr(cls, _name, staticmethod(_wrap_unbound(_name, _raw.__func__)))\n continue\n if isinstance(_raw, classmethod):\n setattr(cls, _name, classmethod(_wrap_unbound(_name, _raw.__func__)))\n continue\n _orig = getattr(cls, _name, None)\n if _orig is not None:\n setattr(cls, _name, _wrap_method(_name, _orig))\n cls._fireforge_guarded = True\n\n\ndef _patch_build_monitor_class(cls):\n # mozbuild's BuildMonitor is not a SystemResourceMonitor; only\n # log_resource_usage needs guarding \u2014 end-of-build resource reporting\n # must never fail a build whose artifacts are complete. No __init__\n # replacement, no degraded-instance gating.\n if cls is None or getattr(cls, \"_fireforge_guarded\", False):\n return\n _orig = getattr(cls, \"log_resource_usage\", None)\n if _orig is not None:\n def _guarded_log_resource_usage(self, *args, **kwargs):\n try:\n return _orig(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n return None\n\n cls.log_resource_usage = _guarded_log_resource_usage\n cls._fireforge_guarded = True\n\n\n_MONITOR_CLASS_PATCHES = (\n (\"mozsystemmonitor.resourcemonitor\", \"SystemResourceMonitor\", _patch_monitor_class),\n (\"mozbuild.resources\", \"SystemResourceMonitor\", _patch_monitor_class),\n (\"mozbuild.controller.building\", \"BuildMonitor\", _patch_build_monitor_class),\n)\n\n\ndef _patch_loaded_monitor_modules():\n for _mod_name, _cls_name, _patcher in _MONITOR_CLASS_PATCHES:\n _mod = sys.modules.get(_mod_name)\n if _mod is not None:\n try:\n _patcher(getattr(_mod, _cls_name, None))\n except Exception: # noqa: BLE001\n pass\n\n\ndef _install_import_hook():\n _orig_import = builtins.__import__\n if getattr(_orig_import, \"_fireforge_guarded\", False):\n return\n\n def _guarding_import(name, *args, **kwargs):\n _module = _orig_import(name, *args, **kwargs)\n try:\n _patch_loaded_monitor_modules()\n except Exception: # noqa: BLE001\n pass\n return _module\n\n _guarding_import._fireforge_guarded = True\n builtins.__import__ = _guarding_import\n\n\ntry:\n _guard_psutil()\n _patch_loaded_monitor_modules()\n _install_import_hook()\nexcept Exception: # noqa: BLE001\n pass\n";
74
+ export declare const GUARD_PYTHON_SOURCE = "# Generated by FireForge - do not edit.\n# Degrades the broken host resource monitor family (psutil vs Darwin) from\n# fatal startup errors into non-fatal warnings, so mach builds and tests\n# proceed. Installed into the mach virtualenv site-packages via a .pth file\n# (survives mach's venv re-exec, which drops PYTHONPATH).\nimport builtins\nimport collections\nimport inspect\nimport sys\nimport warnings\n\nimport os\nimport shutil\n_fireforge_profile_root = os.environ.get(\"FIREFORGE_PROFILE_MERGE_ROOT\")\nif _fireforge_profile_root and not getattr(shutil.copytree, \"_fireforge_profile_merge\", False):\n _fireforge_copytree = shutil.copytree\n def _fireforge_merge_copytree(src, dst, *args, **kwargs):\n if os.path.realpath(src).startswith(os.path.realpath(_fireforge_profile_root) + os.sep):\n # Python's recursive calls may supply dirs_exist_ok positionally.\n if len(args) >= 5:\n args = args[:4] + (True,) + args[5:]\n else:\n kwargs[\"dirs_exist_ok\"] = True\n return _fireforge_copytree(src, dst, *args, **kwargs)\n _fireforge_merge_copytree._fireforge_profile_merge = True\n shutil.copytree = _fireforge_merge_copytree\n\n\n# Any psutil degradation observed in this process. Once set, monitors are\n# kept inert (start never spawns the collector child) \u2014 on a flapping host a\n# healthy-looking moment must not be trusted to start a sample stream that a\n# later degraded reading could malform.\n_fireforge_host_degraded = [False]\n\n\ndef _fireforge_degraded_notice(exc):\n _fireforge_host_degraded[0] = True\n warnings.warn(\n \"FireForge: host resource monitor degraded (%s); continuing without resource monitoring.\" % exc\n )\n\n\nclass _DegradedReading(object):\n # Last-resort duck type of psutil's namedtuple results (svmem field\n # order): mozsystemmonitor subscripts, iterates, and unpacks readings,\n # so the degraded fallback must survive r[0], list(r), len(r), r._fields\n # and r._asdict(), not just attribute access. The parent also reconstructs\n # readings via type(reading)(*values), so the constructor must tolerate\n # the full positional field list. Normal\n # degraded paths use the per-function namedtuple fallbacks below; this\n # class only backstops a fallback that itself raised.\n _fields = (\"total\", \"available\", \"percent\", \"used\", \"free\", \"active\", \"inactive\", \"wired\")\n total = available = used = free = active = inactive = wired = 0\n percent = 0.0\n\n def __init__(self, *_args, **_kwargs):\n pass\n\n def __getattr__(self, _name):\n return 0\n\n def _values(self):\n return tuple(getattr(self, _field) for _field in self._fields)\n\n def __getitem__(self, index):\n return self._values()[index]\n\n def __iter__(self):\n return iter(self._values())\n\n def __len__(self):\n return len(self._fields)\n\n def _asdict(self):\n return dict(zip(self._fields, self._values()))\n\n\n# Per-function fallback result classes with the exact macOS field\n# orders/arities. Module-level on purpose: readings cross the\n# mozsystemmonitor collector pipe via pickle (by reference), and the parent\n# rebuilds each sample with type(reading)(*values) \u2014 so a swap fallback must\n# be 6-field sswap-shaped in every position. An svmem-shaped fallback there\n# makes the parent reject every sample, fill the pipe, block the collector\n# child in send(), and wedge mach's atexit join forever.\n_FallbackVmem = collections.namedtuple(\n \"_FallbackVmem\",\n (\"total\", \"available\", \"percent\", \"used\", \"free\", \"active\", \"inactive\", \"wired\"),\n)\n_FallbackSwap = collections.namedtuple(\n \"_FallbackSwap\", (\"total\", \"used\", \"free\", \"percent\", \"sin\", \"sout\")\n)\n_FallbackCpuTimes = collections.namedtuple(\"_FallbackCpuTimes\", (\"user\", \"nice\", \"system\", \"idle\"))\n_FallbackDiskIO = collections.namedtuple(\n \"_FallbackDiskIO\",\n (\"read_count\", \"write_count\", \"read_bytes\", \"write_bytes\", \"read_time\", \"write_time\"),\n)\n\n_FALLBACK_CLASSES = {\n \"virtual_memory\": _FallbackVmem,\n \"swap_memory\": _FallbackSwap,\n \"cpu_times\": _FallbackCpuTimes,\n \"disk_io_counters\": _FallbackDiskIO,\n}\n\n# psutil result namedtuple per wrapped function, resolvable via getattr from\n# the platform module / _common without invoking the failing syscall.\n_PSUTIL_RESULT_CLASSES = {\n \"virtual_memory\": \"svmem\",\n \"swap_memory\": \"sswap\",\n \"cpu_times\": \"scputimes\",\n \"disk_io_counters\": \"sdiskio\",\n}\n\n\ndef _zeroed(cls):\n return cls(*([0] * len(cls._fields)))\n\n\ndef _percpu_requested(args, kwargs, positional_index):\n if \"percpu\" in kwargs:\n return bool(kwargs[\"percpu\"])\n return len(args) > positional_index and bool(args[positional_index])\n\n\ndef _degraded_result_factory(psutil, name):\n # cpu_percent returns a plain float (or a per-CPU list) callers do\n # arithmetic on.\n if name == \"cpu_percent\":\n def _cpu_percent_fallback(*args, **kwargs):\n # cpu_percent(interval=None, percpu=False)\n if _percpu_requested(args, kwargs, 1):\n return []\n return 0.0\n\n return _cpu_percent_fallback\n # The guard-owned class is always shape-correct for this function;\n # psutil's own result namedtuple is still preferred when resolvable so\n # type() captures stay identical to real readings on a flapping host.\n _cls = _FALLBACK_CLASSES.get(name, _DegradedReading)\n _cls_name = _PSUTIL_RESULT_CLASSES.get(name)\n if _cls_name is not None:\n for _mod in (getattr(psutil, \"_psplatform\", None), getattr(psutil, \"_common\", None)):\n _real = getattr(_mod, _cls_name, None) if _mod is not None else None\n if _real is not None and getattr(_real, \"_fields\", None):\n _cls = _real\n break\n if name == \"cpu_times\":\n def _cpu_times_fallback(*args, **kwargs):\n # cpu_times(percpu=False); the collector child samples per-CPU.\n if _percpu_requested(args, kwargs, 0):\n return []\n return _zeroed(_cls)\n\n return _cpu_times_fallback\n\n def _reading_fallback(*_args, **_kwargs):\n return _zeroed(_cls)\n\n return _reading_fallback\n\n\ndef _guard_psutil():\n try:\n import psutil\n except Exception:\n return\n\n def _wrap(orig, fallback):\n def wrapper(*args, **kwargs):\n try:\n return orig(*args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n try:\n return fallback(*args, **kwargs)\n except Exception: # noqa: BLE001\n return _DegradedReading()\n\n return wrapper\n\n for _name in (\"virtual_memory\", \"swap_memory\", \"cpu_percent\", \"cpu_times\", \"disk_io_counters\"):\n _orig = getattr(psutil, _name, None)\n if _orig is not None and not getattr(_orig, \"_fireforge_guarded\", False):\n try:\n _fallback = _degraded_result_factory(psutil, _name)\n except Exception: # noqa: BLE001\n _fallback = _DegradedReading\n _wrapped = _wrap(_orig, _fallback)\n _wrapped._fireforge_guarded = True\n setattr(psutil, _name, _wrapped)\n\n\n_MONITOR_METHOD_NAMES = (\n \"start\",\n \"stop\",\n \"record_event\",\n \"record_marker\",\n \"begin_phase\",\n \"finish_phase\",\n \"aggregate_cpu_percent\",\n \"aggregate_cpu_times\",\n \"aggregate_io\",\n \"min_memory_available\",\n \"as_dict\",\n)\n\n\ndef _fireforge_stop_collector(monitor):\n # Best-effort: terminate a live collector child and drain the parent end\n # of the pipe, so a child blocked in send() on a full pipe can never\n # keep mach's atexit join (os.waitpid) waiting forever. Attribute names\n # follow upstream resourcemonitor.py (self._process / self._pipe); a\n # miss on a refactored upstream is harmless \u2014 the shape-correct\n # fallbacks above still keep the sample stream well-formed.\n try:\n proc = getattr(monitor, \"_process\", None)\n if proc is not None and getattr(proc, \"is_alive\", lambda: False)():\n proc.terminate()\n proc.join(1)\n except Exception: # noqa: BLE001\n pass\n try:\n pipe = getattr(monitor, \"_pipe\", None)\n if pipe is not None:\n while pipe.poll(0):\n pipe.recv()\n except Exception: # noqa: BLE001\n pass\n\n\ndef _monitor_per_cpu_requested(args, kwargs):\n # aggregate_cpu_percent/aggregate_cpu_times(start=None, end=None,\n # phase=None, per_cpu=True) \u2014 self already stripped from args.\n if \"per_cpu\" in kwargs:\n return bool(kwargs[\"per_cpu\"])\n if len(args) > 3:\n return bool(args[3])\n return True\n\n\ndef _monitor_degraded_result(name, args, kwargs):\n # Degraded aggregate methods return zeroed shapes, not None, where\n # callers subscript the result: mozbuild's log_resource_usage does\n # usage[\"io\"].read_bytes after a successful compile, so None there fails\n # the build with complete artifacts already on disk.\n if name == \"aggregate_io\":\n return _zeroed(_FallbackDiskIO)\n if name == \"aggregate_cpu_percent\":\n return [] if _monitor_per_cpu_requested(args, kwargs) else 0.0\n if name == \"aggregate_cpu_times\":\n return [] if _monitor_per_cpu_requested(args, kwargs) else _zeroed(_FallbackCpuTimes)\n return None\n\n\ndef _patch_monitor_class(cls):\n if cls is None or getattr(cls, \"_fireforge_guarded\", False):\n return\n orig_init = cls.__init__\n\n def guarded_init(self, *args, **kwargs):\n # Pre-populate the attributes a partially-constructed instance is\n # later polled for, so a failing __init__ can never surface\n # AttributeError: poll_interval.\n self.poll_interval = kwargs.get(\"poll_interval\") or 1.0\n self._fireforge_degraded = False\n try:\n orig_init(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n self._fireforge_degraded = True\n _fireforge_degraded_notice(exc)\n if _fireforge_host_degraded[0] and not self._fireforge_degraded:\n # Host already degraded once: keep the monitor inert so the\n # collector child never spawns on a flapping host.\n self._fireforge_degraded = True\n if self._fireforge_degraded:\n _fireforge_stop_collector(self)\n\n cls.__init__ = guarded_init\n\n def _wrap_method(name, orig):\n def wrapper(self, *args, **kwargs):\n if (\n name == \"start\"\n and _fireforge_host_degraded[0]\n and not getattr(self, \"_fireforge_degraded\", False)\n ):\n self._fireforge_degraded = True\n _fireforge_stop_collector(self)\n if getattr(self, \"_fireforge_degraded\", False):\n if name == \"stop\":\n _fireforge_stop_collector(self)\n return _monitor_degraded_result(name, args, kwargs)\n try:\n return orig(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n self._fireforge_degraded = True\n _fireforge_degraded_notice(exc)\n # A raising stop (or any degraded transition) must still\n # take the collector child down, or the parent's atexit\n # join hangs on a child blocked writing to a full pipe.\n _fireforge_stop_collector(self)\n return _monitor_degraded_result(name, args, kwargs)\n\n return wrapper\n\n def _wrap_unbound(name, func):\n # Upstream declares record_event/record_marker as @staticmethods (and\n # may declare others as @classmethods). Those take no receiver, so\n # they cannot use the per-instance degraded flag and must not have one\n # threaded in \u2014 see the descriptor dispatch below.\n def wrapper(*args, **kwargs):\n if _fireforge_host_degraded[0]:\n return _monitor_degraded_result(name, args, kwargs)\n try:\n return func(*args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n return _monitor_degraded_result(name, args, kwargs)\n\n return wrapper\n\n for _name in _MONITOR_METHOD_NAMES:\n # getattr_static returns the raw class attribute, so a staticmethod\n # is still a staticmethod object here. Plain getattr would hand back\n # the underlying function, and re-setting THAT on the class rebinds\n # it as an instance method: the receiver is then passed through as an\n # extra leading positional and every call raises\n # \"takes N positional arguments but N+1 were given\". Re-apply the\n # descriptor the attribute actually had.\n try:\n _raw = inspect.getattr_static(cls, _name)\n except AttributeError:\n continue\n if isinstance(_raw, staticmethod):\n setattr(cls, _name, staticmethod(_wrap_unbound(_name, _raw.__func__)))\n continue\n if isinstance(_raw, classmethod):\n setattr(cls, _name, classmethod(_wrap_unbound(_name, _raw.__func__)))\n continue\n _orig = getattr(cls, _name, None)\n if _orig is not None:\n setattr(cls, _name, _wrap_method(_name, _orig))\n cls._fireforge_guarded = True\n\n\ndef _patch_build_monitor_class(cls):\n # mozbuild's BuildMonitor is not a SystemResourceMonitor; only\n # log_resource_usage needs guarding \u2014 end-of-build resource reporting\n # must never fail a build whose artifacts are complete. No __init__\n # replacement, no degraded-instance gating.\n if cls is None or getattr(cls, \"_fireforge_guarded\", False):\n return\n _orig = getattr(cls, \"log_resource_usage\", None)\n if _orig is not None:\n def _guarded_log_resource_usage(self, *args, **kwargs):\n try:\n return _orig(self, *args, **kwargs)\n except Exception as exc: # noqa: BLE001\n _fireforge_degraded_notice(exc)\n return None\n\n cls.log_resource_usage = _guarded_log_resource_usage\n cls._fireforge_guarded = True\n\n\n_MONITOR_CLASS_PATCHES = (\n (\"mozsystemmonitor.resourcemonitor\", \"SystemResourceMonitor\", _patch_monitor_class),\n (\"mozbuild.resources\", \"SystemResourceMonitor\", _patch_monitor_class),\n (\"mozbuild.controller.building\", \"BuildMonitor\", _patch_build_monitor_class),\n)\n\n\ndef _patch_loaded_monitor_modules():\n for _mod_name, _cls_name, _patcher in _MONITOR_CLASS_PATCHES:\n _mod = sys.modules.get(_mod_name)\n if _mod is not None:\n try:\n _patcher(getattr(_mod, _cls_name, None))\n except Exception: # noqa: BLE001\n pass\n\n\ndef _install_import_hook():\n _orig_import = builtins.__import__\n if getattr(_orig_import, \"_fireforge_guarded\", False):\n return\n\n def _guarding_import(name, *args, **kwargs):\n _module = _orig_import(name, *args, **kwargs)\n try:\n _patch_loaded_monitor_modules()\n except Exception: # noqa: BLE001\n pass\n return _module\n\n _guarding_import._fireforge_guarded = True\n builtins.__import__ = _guarding_import\n\n\ntry:\n _guard_psutil()\n _patch_loaded_monitor_modules()\n _install_import_hook()\nexcept Exception: # noqa: BLE001\n pass\n";
75
75
  /** Result of installing the mach resource guard before a dispatch. */
76
76
  export interface MachResourceGuardInstallation {
77
77
  /**
@@ -70,6 +70,7 @@ import { BuildError } from '../errors/build.js';
70
70
  import { toError } from '../utils/errors.js';
71
71
  import { pathExists, writeText } from '../utils/fs.js';
72
72
  import { verbose } from '../utils/logger.js';
73
+ import { PROFILE_MERGE_PYTHON } from './test-profile-files.js';
73
74
  /**
74
75
  * Prefix of the per-user directory (under the OS temp dir) holding the
75
76
  * PYTHONPATH fallback. The uid (or, on Windows, the username) is appended:
@@ -99,6 +100,7 @@ import collections
99
100
  import inspect
100
101
  import sys
101
102
  import warnings
103
+ ${PROFILE_MERGE_PYTHON}
102
104
 
103
105
  # Any psutil degradation observed in this process. Once set, monitors are
104
106
  # kept inert (start never spawns the collector child) — on a flapping host a
@@ -38,6 +38,8 @@ export interface RunningBundleProcess {
38
38
  * exactly the wrong attribution for a peer session's long-lived browser.
39
39
  */
40
40
  elapsedSeconds: number;
41
+ parentPid?: number;
42
+ owner?: string;
41
43
  }
42
44
  /**
43
45
  * Pure parser kept exported so platform output handling is
@@ -30,6 +30,8 @@ import { toError } from '../utils/errors.js';
30
30
  import { getPlatform } from '../utils/platform.js';
31
31
  import { exec } from '../utils/process.js';
32
32
  import { formatPsDuration, parsePsDuration } from '../utils/ps-duration.js';
33
+ import { waitForPreflight } from './preflight-wait.js';
34
+ import { isOrphanedHarness, isStillOrphanedProcess, readProcessOwner } from './process-owner.js';
33
35
  /** Default Marionette control port set by `-marionette`. */
34
36
  export const DEFAULT_MARIONETTE_PORT = 2828;
35
37
  /** Basenames of browser binaries that ship a Marionette listener. */
@@ -98,7 +100,9 @@ export function parseProcessList(stdout, launchableBinary) {
98
100
  const commandLine = Number.isNaN(elapsedSeconds)
99
101
  ? `${maybeElapsed} ${match[3] ?? ''}`.trim()
100
102
  : (match[3] ?? '');
101
- if (!commandLine.includes(launchableBinary))
103
+ if (!(commandLine === launchableBinary ||
104
+ commandLine.startsWith(launchableBinary + ' ') ||
105
+ commandLine.startsWith('"' + launchableBinary + '" ')))
102
106
  continue;
103
107
  matches.push({ pid, commandLine, elapsedSeconds });
104
108
  }
@@ -118,40 +122,24 @@ export async function ensureLaunchableBrowserNotRunning(launchableBinary, option
118
122
  const holder = parent ?? processes[0];
119
123
  if (!holder)
120
124
  return;
121
- if (options.killStaleBrowser) {
125
+ const ownership = await readProcessOwner(holder.pid);
126
+ if (ownership)
127
+ Object.assign(holder, ownership);
128
+ const orphan = isOrphanedHarness(holder.commandLine, holder.parentPid);
129
+ if (options.killStaleBrowser && orphan) {
130
+ if (!(await isStillOrphanedProcess(holder.pid, holder.commandLine))) {
131
+ throw new PreflightRefusalError(`Browser PID ${holder.pid} changed identity or ownership; re-run the preflight before requesting cleanup.`, 'browser-busy');
132
+ }
122
133
  try {
123
- if (process.platform === 'win32') {
124
- await exec('powershell.exe', [
125
- '-NoProfile',
126
- '-NonInteractive',
127
- '-Command',
128
- `Stop-Process -Id ${holder.pid} -Force`,
129
- ]);
130
- }
131
- else {
132
- process.kill(holder.pid, 'SIGTERM');
133
- }
134
+ process.kill(holder.pid, 'SIGTERM');
135
+ await waitForPreflight(() => ensureLaunchableBrowserNotRunning(launchableBinary), 2);
134
136
  return;
135
137
  }
136
138
  catch (error) {
137
139
  throw new PreflightRefusalError(`A browser from this objdir is still running (PID ${holder.pid}), but FireForge could not terminate it: ${toError(error).message}`, 'stale-browser-kill-failed');
138
140
  }
139
141
  }
140
- throw new PreflightRefusalError(describeRunningBundleRefusal(holder), 'stale-browser');
141
- }
142
- /**
143
- * True when the process was launched by a test harness, i.e. its command
144
- * line carries `-marionette` (the control channel every `fireforge test`
145
- * browser is started with) or a harness `-profile`.
146
- *
147
- * This is the distinction the refusal turns on. The objdir-bundle probe
148
- * matches any process running this project's binary, which on a shared
149
- * checkout includes a peer session's live browser and a developer's own
150
- * interactive one. Only a marionette-driven browser is safely
151
- * attributable to an interrupted run.
152
- */
153
- function isHarnessDrivenBrowser(commandLine) {
154
- return /\s-marionette(?:\s|$)/.test(commandLine) || /\s-profile(?:\s|=)/.test(commandLine);
142
+ throw new PreflightRefusalError(describeRunningBundleRefusal(holder), orphan ? 'stale-browser' : 'browser-busy');
155
143
  }
156
144
  /**
157
145
  * Builds the refusal for a running objdir browser.
@@ -177,9 +165,10 @@ export function describeRunningBundleRefusal(holder) {
177
165
  : `kill ${holder.pid} # or "kill -9 ${holder.pid}" if it doesn't exit`;
178
166
  const elapsed = formatPsDuration(holder.elapsedSeconds);
179
167
  const age = elapsed !== undefined ? ` running for ${elapsed}` : '';
180
- const harnessDriven = isHarnessDrivenBrowser(holder.commandLine);
168
+ const harnessDriven = isOrphanedHarness(holder.commandLine, holder.parentPid);
181
169
  const evidence = `A browser from this project's objdir is already running (PID ${holder.pid}${age}).\n` +
182
- ` command: ${holder.commandLine}\n`;
170
+ ` command: ${holder.commandLine}\n` +
171
+ ` owner: parent ${holder.parentPid ?? 'unknown'} (${holder.owner ?? 'unavailable'})\n`;
183
172
  if (harnessDriven) {
184
173
  return (evidence +
185
174
  ' This one carries harness arguments (-marionette/-profile), so it is very likely an orphan ' +
@@ -187,11 +176,8 @@ export function describeRunningBundleRefusal(holder) {
187
176
  ` Stop it with "${killHint}", or retry with "--kill-stale-marionette".`);
188
177
  }
189
178
  return (evidence +
190
- ' This one carries NO harness arguments (-marionette/-profile), so it is not a test browser — ' +
191
- "it is more likely a developer's own window, or another session sharing this checkout.\n" +
192
- ' "--kill-stale-marionette" is deliberately NOT suggested here: FireForge cannot tell an orphan ' +
193
- "from someone else's live browser. Confirm the owner (start time above, and the command line), " +
194
- `then stop it yourself with "${killHint}" if it is in fact yours.`);
179
+ ' This browser is busy; its ownership does not prove a harness orphan. ' +
180
+ 'FireForge will not terminate it. Use --wait-browser [seconds] to queue behind the owner.');
195
181
  }
196
182
  /**
197
183
  * Returns `true` when the holder's command basename or command-line
@@ -347,14 +333,14 @@ export async function assertMarionettePortAvailable(port = DEFAULT_MARIONETTE_PO
347
333
  return;
348
334
  const holder = probe.holder;
349
335
  if (isBrowserMarionettePortHolder(holder, options.binaryName)) {
350
- const killHint = process.platform === 'win32'
351
- ? `Stop-Process -Id ${holder.pid} -Force`
352
- : `kill ${holder.pid} # or "kill -9 ${holder.pid}" if it doesn't exit`;
336
+ const owner = await readProcessOwner(holder.pid);
337
+ const orphan = isOrphanedHarness(holder.commandLine, owner?.parentPid);
353
338
  throw new PreflightRefusalError(`Marionette port ${port} is already in use by ${holder.command} (PID ${holder.pid}). ` +
354
- `This is usually a browser left running by a previously interrupted "fireforge test" run. ` +
355
- `Kill it with "${killHint}", then retry. ` +
356
- `(If you expected ${holder.command} to be running on ${port}, stop it manually or pass ` +
357
- `"--marionette-port <port>" to launch mach test on a different port.)`, 'marionette-port-busy');
339
+ `Owner: parent ${owner?.parentPid ?? 'unknown'} (${owner?.owner ?? 'unavailable'}). ` +
340
+ (orphan
341
+ ? 'The harness parent exited; retry with --kill-stale-marionette to reap this orphan. '
342
+ : 'A live peer may own it; FireForge will not suggest terminating it. ') +
343
+ 'Use --wait-port [seconds] to queue, or --marionette-port <port> to use a different port.', 'marionette-port-busy');
358
344
  }
359
345
  // Non-browser holder: mach test will still fail to bind, but the
360
346
  // cause is not a stale FireForge-launched browser. Flag it
@@ -377,23 +363,22 @@ export async function ensureMarionettePortAvailable(port = DEFAULT_MARIONETTE_PO
377
363
  await assertMarionettePortAvailable(port, options);
378
364
  return;
379
365
  }
366
+ const owner = await readProcessOwner(holder.pid);
367
+ if (!isOrphanedHarness(holder.commandLine, owner?.parentPid)) {
368
+ await assertMarionettePortAvailable(port, options);
369
+ return;
370
+ }
371
+ if (!(await isStillOrphanedProcess(holder.pid, holder.commandLine))) {
372
+ throw new PreflightRefusalError(`Marionette listener PID ${holder.pid} changed identity or ownership; re-run the preflight before requesting cleanup.`, 'marionette-port-busy');
373
+ }
380
374
  try {
381
- if (process.platform === 'win32') {
382
- await exec('powershell.exe', [
383
- '-NoProfile',
384
- '-NonInteractive',
385
- '-Command',
386
- `Stop-Process -Id ${holder.pid} -Force`,
387
- ]);
388
- }
389
- else {
390
- process.kill(holder.pid, 'SIGTERM');
391
- }
375
+ process.kill(holder.pid, 'SIGTERM');
392
376
  }
393
377
  catch (error) {
394
378
  throw new PreflightRefusalError(`Marionette port ${port} is held by stale browser ${holder.command} (PID ${holder.pid}), ` +
395
379
  `but FireForge could not terminate it: ${toError(error).message}`, 'stale-browser-kill-failed');
396
380
  }
381
+ await waitForPreflight(() => assertMarionettePortAvailable(port, options), 2);
397
382
  }
398
383
  /**
399
384
  * Extracts a `--marionette-port=N` (or `--marionette-port N`) argument from
@@ -78,4 +78,4 @@ export declare function ensureMochitestServerPortAvailable(port: number | undefi
78
78
  * @param holder - The listener found on it
79
79
  * @param holderClass - Whose harness `server.js` it is, if it is one at all
80
80
  */
81
- export declare function describeMochitestServerRefusal(port: number, holder: MarionettePortHolder, holderClass: MochitestServerHolderClass): string;
81
+ export declare function describeMochitestServerRefusal(port: number, holder: MarionettePortHolder, holderClass: MochitestServerHolderClass, parentPid?: number): string;
@@ -22,10 +22,11 @@
22
22
  * the missing half, and it reuses {@link probeMarionettePort}, which is
23
23
  * port-generic, rather than growing a second probe.
24
24
  */
25
- import { GeneralError } from '../errors/base.js';
25
+ import { PreflightRefusalError } from '../errors/base.js';
26
26
  import { isExplicitAbsolutePath, isPathInsideRoot } from '../utils/paths.js';
27
- import { exec } from '../utils/process.js';
28
27
  import { probeMarionettePort } from './marionette-port.js';
28
+ import { waitForPreflight } from './preflight-wait.js';
29
+ import { isStillOrphanedProcess, readProcessOwner } from './process-owner.js';
29
30
  /** Default port the mochitest harness binds its httpd to. */
30
31
  export const DEFAULT_MOCHITEST_SERVER_PORT = 8888;
31
32
  /**
@@ -109,19 +110,17 @@ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITES
109
110
  return;
110
111
  const holder = probe.holder;
111
112
  const holderClass = classifyMochitestServerHolder(holder, options.engineDir);
112
- if (holderClass === 'this-checkout' && options.killStaleServer === true) {
113
+ const owner = await readProcessOwner(holder.pid);
114
+ if (holderClass === 'this-checkout' &&
115
+ owner?.parentPid === 1 &&
116
+ options.killStaleServer === true &&
117
+ (await isStillOrphanedProcess(holder.pid, holder.commandLine))) {
113
118
  try {
114
- if (process.platform === 'win32') {
115
- await exec('powershell.exe', [
116
- '-NoProfile',
117
- '-NonInteractive',
118
- '-Command',
119
- `Stop-Process -Id ${holder.pid} -Force`,
120
- ]);
121
- }
122
- else {
123
- process.kill(holder.pid, 'SIGTERM');
124
- }
119
+ process.kill(holder.pid, 'SIGTERM');
120
+ await waitForPreflight(async () => {
121
+ if ((await probeMarionettePort(port)).inUse)
122
+ throw new PreflightRefusalError(`Mochitest port ${port} remains busy after orphan cleanup; wait for the holder to exit.`, 'mochitest-port-busy');
123
+ }, 2);
125
124
  return;
126
125
  }
127
126
  catch {
@@ -130,7 +129,8 @@ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITES
130
129
  // pretending the port is now free.
131
130
  }
132
131
  }
133
- throw new GeneralError(describeMochitestServerRefusal(port, holder, holderClass));
132
+ throw new PreflightRefusalError(describeMochitestServerRefusal(port, holder, holderClass, owner?.parentPid) +
133
+ `\n Parent: ${owner?.parentPid ?? 'unknown'} (${owner?.owner ?? 'unavailable'}). Use --wait-port [seconds] to queue.`, 'mochitest-port-busy');
134
134
  }
135
135
  /**
136
136
  * Builds the operator-facing refusal. Exported for direct unit testing.
@@ -139,7 +139,7 @@ export async function ensureMochitestServerPortAvailable(port = DEFAULT_MOCHITES
139
139
  * @param holder - The listener found on it
140
140
  * @param holderClass - Whose harness `server.js` it is, if it is one at all
141
141
  */
142
- export function describeMochitestServerRefusal(port, holder, holderClass) {
142
+ export function describeMochitestServerRefusal(port, holder, holderClass, parentPid) {
143
143
  const killHint = process.platform === 'win32'
144
144
  ? `Stop-Process -Id ${holder.pid} -Force`
145
145
  : `kill -9 ${holder.pid}`;
@@ -150,9 +150,12 @@ export function describeMochitestServerRefusal(port, holder, holderClass) {
150
150
  'manifest — the run would stall before TEST_START and die on the no-output timeout with ' +
151
151
  '"Ran 0 checks".\n';
152
152
  if (holderClass === 'this-checkout') {
153
+ if (parentPid !== 1)
154
+ return (evidence +
155
+ ' This checkout has a live or unattributed harness server. Wait for its owner to finish; --wait-port [seconds] queues behind it.');
153
156
  return (evidence +
154
157
  " This is the mochitest harness's own server.js from THIS checkout's objdir, so it is " +
155
- 'debris from an interrupted run. Retry with "--kill-stale-marionette" to have FireForge ' +
158
+ 'possibly debris from an interrupted run. For a dead parent, retry with "--kill-stale-marionette" to have FireForge ' +
156
159
  `stop it, or stop it yourself with "${killHint}" (a wedged httpd can ignore SIGTERM).`);
157
160
  }
158
161
  if (holderClass === 'foreign-checkout') {
@@ -0,0 +1,10 @@
1
+ /** Refuses an objdir that writes through a directory alias into a different checkout. */
2
+ export declare function assertLocalObjdir(engineDir: string, objDir: string): Promise<void>;
3
+ /** Repairs copied links and dependency metadata BEFORE configure can consume config.track. */
4
+ export declare function relocateObjdirProducts(engineDir: string, objDir: string, oldRoot: string): Promise<void>;
5
+ /** Finds a link still consulting the previous source tree after relocation/configure. */
6
+ export declare function findStaleObjdirLink(engineDir: string, objDir: string, oldRoot: string): Promise<string | undefined>;
7
+ /** Prunes only dangling _tests symlinks; never descends through a symlink. */
8
+ export declare function pruneDanglingTestLinks(engineDir: string, objDir: string): Promise<number>;
9
+ /** Names a partial-config group whose entire persisted value set was nulled. */
10
+ export declare function assertHealthyPartialConfig(engineDir: string, objDir: string): Promise<void>;