@hominis/fireforge 0.48.0 → 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 (101) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +16 -28
  4. package/dist/src/commands/build.js +30 -4
  5. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  6. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  7. package/dist/src/commands/doctor-macos-sdk.js +7 -5
  8. package/dist/src/commands/doctor-source-pin.js +9 -2
  9. package/dist/src/commands/doctor.js +2 -0
  10. package/dist/src/commands/furnace/apply.js +10 -4
  11. package/dist/src/commands/furnace/create-templates.js +0 -3
  12. package/dist/src/commands/lint.js +4 -0
  13. package/dist/src/commands/manifest.d.ts +1 -2
  14. package/dist/src/commands/patch/delete.js +18 -4
  15. package/dist/src/commands/re-export-options.js +7 -0
  16. package/dist/src/commands/re-export-register.js +1 -0
  17. package/dist/src/commands/re-export-removed.d.ts +4 -0
  18. package/dist/src/commands/re-export-removed.js +49 -0
  19. package/dist/src/commands/re-export-single.js +4 -1
  20. package/dist/src/commands/re-export.js +4 -0
  21. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  22. package/dist/src/commands/test-browser-preflight.js +181 -0
  23. package/dist/src/commands/test-build-phase.js +30 -7
  24. package/dist/src/commands/test-checkjs.d.ts +3 -0
  25. package/dist/src/commands/test-checkjs.js +27 -0
  26. package/dist/src/commands/test-diagnose.js +3 -0
  27. package/dist/src/commands/test-harness-env.d.ts +5 -6
  28. package/dist/src/commands/test-harness-env.js +27 -7
  29. package/dist/src/commands/test-modes.d.ts +2 -4
  30. package/dist/src/commands/test-register.js +11 -5
  31. package/dist/src/commands/test-run.d.ts +2 -0
  32. package/dist/src/commands/test-run.js +21 -1
  33. package/dist/src/commands/test-verdict.d.ts +2 -2
  34. package/dist/src/commands/test.js +83 -211
  35. package/dist/src/core/build-baseline-types.d.ts +12 -14
  36. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  37. package/dist/src/core/build-baseline-validate.js +53 -0
  38. package/dist/src/core/build-baseline.d.ts +4 -3
  39. package/dist/src/core/build-baseline.js +23 -11
  40. package/dist/src/core/component-source-guard.d.ts +20 -0
  41. package/dist/src/core/component-source-guard.js +68 -0
  42. package/dist/src/core/coverage-extend.d.ts +5 -34
  43. package/dist/src/core/coverage-extend.js +26 -37
  44. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  45. package/dist/src/core/engine-write-boundary.js +41 -0
  46. package/dist/src/core/furnace-apply-helpers.js +8 -2
  47. package/dist/src/core/furnace-apply.js +7 -0
  48. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  49. package/dist/src/core/furnace-validate.js +0 -43
  50. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  51. package/dist/src/core/gecko-dom-types.js +17 -0
  52. package/dist/src/core/lit-template-regions.d.ts +4 -0
  53. package/dist/src/core/lit-template-regions.js +50 -0
  54. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  55. package/dist/src/core/mach-build-artifacts.js +13 -7
  56. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  57. package/dist/src/core/mach-resource-shim.js +2 -0
  58. package/dist/src/core/marionette-port.d.ts +2 -0
  59. package/dist/src/core/marionette-port.js +38 -53
  60. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  61. package/dist/src/core/mochitest-server-port.js +20 -17
  62. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  63. package/dist/src/core/objdir-maintenance.js +151 -0
  64. package/dist/src/core/patch-lint-checkjs.js +2 -1
  65. package/dist/src/core/preflight-wait.d.ts +2 -0
  66. package/dist/src/core/preflight-wait.js +24 -0
  67. package/dist/src/core/process-owner.d.ts +9 -0
  68. package/dist/src/core/process-owner.js +43 -0
  69. package/dist/src/core/test-harness-crash.d.ts +1 -1
  70. package/dist/src/core/test-harness-crash.js +5 -5
  71. package/dist/src/core/test-host-state.d.ts +14 -0
  72. package/dist/src/core/test-host-state.js +80 -0
  73. package/dist/src/core/test-profile-files.d.ts +17 -0
  74. package/dist/src/core/test-profile-files.js +75 -0
  75. package/dist/src/core/test-stale-check.d.ts +6 -7
  76. package/dist/src/core/test-stale-check.js +9 -32
  77. package/dist/src/core/token-category.d.ts +7 -26
  78. package/dist/src/core/token-category.js +68 -121
  79. package/dist/src/core/token-inventory.js +12 -7
  80. package/dist/src/core/typecheck.js +6 -6
  81. package/dist/src/types/commands/options.d.ts +14 -3
  82. package/dist/src/utils/assert.js +2 -7
  83. package/dist/src/utils/child-env.d.ts +0 -3
  84. package/dist/src/utils/child-env.js +0 -3
  85. package/dist/src/utils/concurrency.d.ts +2 -2
  86. package/dist/src/utils/concurrency.js +15 -4
  87. package/dist/src/utils/fs.js +11 -1
  88. package/dist/src/utils/options.d.ts +16 -1
  89. package/dist/src/utils/options.js +5 -3
  90. package/dist/src/utils/paths.js +3 -2
  91. package/dist/src/utils/process-group.d.ts +1 -2
  92. package/dist/src/utils/process-group.js +1 -2
  93. package/dist/src/utils/process-input.js +0 -3
  94. package/dist/src/utils/process.d.ts +3 -2
  95. package/dist/src/utils/process.js +19 -7
  96. package/docs/configuration.md +12 -11
  97. package/docs/lifecycle-invariants.md +9 -5
  98. package/docs/machine-output.md +36 -19
  99. package/docs/patch-workflow.md +15 -0
  100. package/docs/testing.md +77 -12
  101. package/package.json +4 -3
@@ -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>;
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { lstat, readdir, readFile, readlink, realpath, rename, rm, symlink, } from 'node:fs/promises';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { GeneralError } from '../errors/base.js';
5
+ import { getNodeErrorCode } from '../utils/errors.js';
6
+ import { writeText } from '../utils/fs.js';
7
+ /** Visits objdir entries without following directory symlinks into another tree. */
8
+ async function walk(dir, visit) {
9
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
10
+ const path = join(dir, entry.name);
11
+ if (entry.isDirectory())
12
+ await walk(path, visit);
13
+ else
14
+ await visit(path, entry.isSymbolicLink());
15
+ }
16
+ }
17
+ /** Refuses an objdir that writes through a directory alias into a different checkout. */
18
+ export async function assertLocalObjdir(engineDir, objDir) {
19
+ if (!/^[^/\\:]+$/.test(objDir) || objDir === '.' || objDir === '..') {
20
+ throw new GeneralError(`Objdir ${objDir} must be a local directory name.`);
21
+ }
22
+ const expected = resolve(engineDir, objDir);
23
+ if ((await realpath(expected)) !== resolve(await realpath(engineDir), objDir) ||
24
+ !(await lstat(expected)).isDirectory()) {
25
+ throw new GeneralError(`Objdir ${expected} resolves outside its local directory. Refusing to configure or rewrite it.`);
26
+ }
27
+ }
28
+ function replacePrefix(value, oldRoot, newRoot) {
29
+ return value === oldRoot || value.startsWith(oldRoot + sep)
30
+ ? newRoot + value.slice(oldRoot.length)
31
+ : value;
32
+ }
33
+ function isDependencyMetadata(path) {
34
+ return (/\.(?:pp|d)$/.test(path) ||
35
+ /[/\\]faster[/\\]/.test(path) ||
36
+ /[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path) ||
37
+ /[/\\]\.fingerprint[/\\].*\.json$/.test(path));
38
+ }
39
+ /** Repairs copied links and dependency metadata BEFORE configure can consume config.track. */
40
+ export async function relocateObjdirProducts(engineDir, objDir, oldRoot) {
41
+ await assertLocalObjdir(engineDir, objDir);
42
+ const root = resolve(engineDir, objDir);
43
+ const newRoot = resolve(engineDir);
44
+ const canonicalRoot = await realpath(root);
45
+ // Configure writes these groups. Even a local objdir can contain a copied
46
+ // directory alias into a peer, so validate each parent before touching it.
47
+ for (const directory of ['config.statusd', 'config.statusd/substs', 'config.statusd/defines']) {
48
+ const path = join(root, directory);
49
+ try {
50
+ if (!(await lstat(path)).isDirectory() ||
51
+ (await realpath(path)) !== join(canonicalRoot, directory))
52
+ throw new GeneralError(`Partial-config directory ${path} is aliased. Refusing relocation before configure.`);
53
+ }
54
+ catch (error) {
55
+ if (getNodeErrorCode(error) !== 'ENOENT')
56
+ throw error;
57
+ }
58
+ }
59
+ await walk(root, async (path, link) => {
60
+ // Remove tracking symlinks too, without following their write targets.
61
+ if (/[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path)) {
62
+ await rm(path);
63
+ return;
64
+ }
65
+ if (link) {
66
+ const target = await readlink(path);
67
+ const absolute = resolve(dirname(path), target);
68
+ const relocated = replacePrefix(absolute, oldRoot, newRoot);
69
+ if (relocated !== absolute) {
70
+ const temp = `${path}.relocate-${process.pid}`;
71
+ await symlink(relocated, temp);
72
+ try {
73
+ await rename(temp, path);
74
+ }
75
+ finally {
76
+ await rm(temp, { force: true });
77
+ }
78
+ }
79
+ }
80
+ else if (isDependencyMetadata(path)) {
81
+ const content = await readFile(path, 'utf8');
82
+ const rewritten = content.replaceAll(oldRoot + sep, newRoot + sep);
83
+ if (content !== rewritten)
84
+ await writeText(path, rewritten);
85
+ }
86
+ });
87
+ }
88
+ /** Finds a link still consulting the previous source tree after relocation/configure. */
89
+ export async function findStaleObjdirLink(engineDir, objDir, oldRoot) {
90
+ let violation;
91
+ await walk(resolve(engineDir, objDir), async (path, link) => {
92
+ if (!link || violation)
93
+ return;
94
+ const target = resolve(dirname(path), await readlink(path));
95
+ const canonical = await realpath(path).catch(() => target);
96
+ if ([target, canonical].some((value) => value === oldRoot || value.startsWith(oldRoot + sep))) {
97
+ violation = `${path} still links to the previous engine ${oldRoot}`;
98
+ }
99
+ });
100
+ return violation;
101
+ }
102
+ /** Prunes only dangling _tests symlinks; never descends through a symlink. */
103
+ export async function pruneDanglingTestLinks(engineDir, objDir) {
104
+ let removed = 0;
105
+ const root = join(engineDir, objDir, '_tests');
106
+ try {
107
+ if (!(await lstat(root)).isDirectory())
108
+ return 0;
109
+ await assertLocalObjdir(engineDir, objDir);
110
+ await walk(root, async (path, link) => {
111
+ if (!link)
112
+ return;
113
+ try {
114
+ await realpath(path);
115
+ }
116
+ catch (error) {
117
+ if (getNodeErrorCode(error) !== 'ENOENT')
118
+ throw error;
119
+ await rm(path);
120
+ removed++;
121
+ }
122
+ });
123
+ }
124
+ catch (error) {
125
+ if (getNodeErrorCode(error) !== 'ENOENT')
126
+ throw error;
127
+ }
128
+ return removed;
129
+ }
130
+ /** Names a partial-config group whose entire persisted value set was nulled. */
131
+ export async function assertHealthyPartialConfig(engineDir, objDir) {
132
+ for (const group of ['substs', 'defines']) {
133
+ const dir = join(engineDir, objDir, 'config.statusd', group);
134
+ let entries;
135
+ try {
136
+ entries = await readdir(dir, { withFileTypes: true });
137
+ }
138
+ catch (error) {
139
+ if (getNodeErrorCode(error) === 'ENOENT')
140
+ continue;
141
+ throw error;
142
+ }
143
+ const files = entries.filter((entry) => entry.isFile() && entry.name !== 'config.track');
144
+ if (files.length === 0)
145
+ continue;
146
+ const values = await Promise.all(files.map((entry) => readFile(join(dir, entry.name), 'utf8')));
147
+ if (values.every((value) => value.trim() === 'null')) {
148
+ throw new GeneralError(`All ${files.length} partial-config ${group} values in ${dir} are null. Repair with "cd engine && python3 ./mach configure", then retry the full build.`);
149
+ }
150
+ }
151
+ }
@@ -30,6 +30,7 @@ import { toError } from '../utils/errors.js';
30
30
  import { pathExists } from '../utils/fs.js';
31
31
  import { verbose } from '../utils/logger.js';
32
32
  import { normalizePathSlashes } from '../utils/paths.js';
33
+ import { withGeckoDomTypes } from './gecko-dom-types.js';
33
34
  import { withSharedSourceFiles } from './patch-lint-checkjs-cache.js';
34
35
  import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
35
36
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
@@ -295,7 +296,7 @@ export async function runCheckJsGrouped(input) {
295
296
  // Custom compiler host: reads patch-owned files from disk, returns
296
297
  // the shim for the shim path, and returns empty content for
297
298
  // anything else to avoid reading the full Firefox tree.
298
- const defaultHost = ts.createCompilerHost(options);
299
+ const defaultHost = withGeckoDomTypes(ts, ts.createCompilerHost(options));
299
300
  // Files pulled in via a reviewed `paths` mapping: outside the owned set
300
301
  // but read from disk so the resolver's targets actually type-check.
301
302
  const pathsResolved = new Set();
@@ -0,0 +1,2 @@
1
+ /** Waits for a busy browser or port without changing or terminating its owner. */
2
+ export declare function waitForPreflight(action: () => Promise<void>, seconds?: number): Promise<void>;
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { PreflightRefusalError } from '../errors/base.js';
3
+ import { info } from '../utils/logger.js';
4
+ import { sleep } from '../utils/sleep.js';
5
+ /** Waits for a busy browser or port without changing or terminating its owner. */
6
+ export async function waitForPreflight(action, seconds) {
7
+ const deadline = Date.now() + (seconds ?? 0) * 1000;
8
+ let announced = false;
9
+ for (;;) {
10
+ try {
11
+ await action();
12
+ return;
13
+ }
14
+ catch (error) {
15
+ if (!(error instanceof PreflightRefusalError) || Date.now() >= deadline)
16
+ throw error;
17
+ if (!announced) {
18
+ info(`Waiting up to ${seconds} seconds: ${error.message}`);
19
+ announced = true;
20
+ }
21
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,9 @@
1
+ /** Parent evidence; an unreadable parent is never proof of an orphan. */
2
+ export declare function readProcessOwner(pid: number): Promise<{
3
+ parentPid: number;
4
+ owner: string;
5
+ } | undefined>;
6
+ /** Harness arguments alone never prove abandonment: require a dead parent too. */
7
+ export declare function isOrphanedHarness(commandLine: string, parentPid?: number): boolean;
8
+ /** Refreshes the exact command and orphan evidence immediately before signaling. */
9
+ export declare function isStillOrphanedProcess(pid: number, commandLine: string): Promise<boolean>;
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { exec } from '../utils/process.js';
3
+ /** Parent evidence; an unreadable parent is never proof of an orphan. */
4
+ export async function readProcessOwner(pid) {
5
+ if (process.platform === 'win32')
6
+ return undefined;
7
+ try {
8
+ const result = await exec('ps', ['-p', String(pid), '-o', 'ppid='], { timeout: 5000 });
9
+ const parentPid = Number(result.stdout.trim());
10
+ if (result.exitCode !== 0 || !Number.isSafeInteger(parentPid) || parentPid < 1)
11
+ return undefined;
12
+ if (parentPid === 1)
13
+ return { parentPid, owner: 'init/launchd (parent exited)' };
14
+ const parent = await exec('ps', ['-p', String(parentPid), '-o', 'args='], { timeout: 5000 });
15
+ if (parent.exitCode !== 0 || !parent.stdout.trim())
16
+ return undefined;
17
+ return { parentPid, owner: parent.stdout.trim() };
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /** Harness arguments alone never prove abandonment: require a dead parent too. */
24
+ export function isOrphanedHarness(commandLine, parentPid) {
25
+ return (parentPid === 1 &&
26
+ (/\s-marionette(?:\s|$)/.test(commandLine) ||
27
+ /\s-profile(?:\s|=)[^\n]*(?:fireforge-|mochitest|rust_mozprofile)/.test(commandLine)));
28
+ }
29
+ /** Refreshes the exact command and orphan evidence immediately before signaling. */
30
+ export async function isStillOrphanedProcess(pid, commandLine) {
31
+ // Windows ownership has no verified implementation; never infer it from
32
+ // command-line appearance or enable a destructive PowerShell fallback.
33
+ if (process.platform === 'win32')
34
+ return false;
35
+ try {
36
+ const result = await exec('ps', ['-p', String(pid), '-o', 'ppid=,args='], { timeout: 5000 });
37
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(result.stdout);
38
+ return result.exitCode === 0 && match?.[1] === '1' && match[2] === commandLine;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
@@ -25,7 +25,7 @@ export type { HarnessCrashSignature } from './test-harness-signature.js';
25
25
  import type { HarnessCrashSignature } from './test-harness-signature.js';
26
26
  export { headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint } from './test-stall-triage.js';
27
27
  /** How a completed harness run should be interpreted. */
28
- export type HarnessRunClassification = 'tests-ran-ok' | 'test-failures' | 'harness-crash' | 'no-tests';
28
+ export type HarnessRunClassification = 'tests-ran-ok' | 'test-failures' | 'harness-crash' | 'harness-arguments' | 'no-tests';
29
29
  /** Numeric counts parsed from the harness's embedded result summary. */
30
30
  export interface HarnessSummaryCounts {
31
31
  /** Total checks the suite ran (`Ran N checks`), when the summary prints one. */