@hominis/fireforge 0.48.0 → 0.49.1
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.
- package/CHANGELOG.md +29 -0
- package/dist/build-info.json +4 -4
- package/dist/src/cli.js +16 -28
- package/dist/src/commands/build.js +30 -4
- package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
- package/dist/src/commands/doctor-engine-eslint.js +12 -0
- package/dist/src/commands/doctor-macos-sdk.js +7 -5
- package/dist/src/commands/doctor-source-pin.js +9 -2
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/furnace/apply.js +10 -4
- package/dist/src/commands/furnace/create-templates.js +0 -3
- package/dist/src/commands/lint.js +4 -0
- package/dist/src/commands/manifest.d.ts +1 -2
- package/dist/src/commands/patch/delete.js +18 -4
- package/dist/src/commands/re-export-options.js +7 -0
- package/dist/src/commands/re-export-register.js +1 -0
- package/dist/src/commands/re-export-removed.d.ts +4 -0
- package/dist/src/commands/re-export-removed.js +49 -0
- package/dist/src/commands/re-export-single.js +4 -1
- package/dist/src/commands/re-export.js +4 -0
- package/dist/src/commands/test-browser-preflight.d.ts +28 -0
- package/dist/src/commands/test-browser-preflight.js +181 -0
- package/dist/src/commands/test-build-phase.js +30 -7
- package/dist/src/commands/test-checkjs.d.ts +3 -0
- package/dist/src/commands/test-checkjs.js +27 -0
- package/dist/src/commands/test-diagnose.js +3 -0
- package/dist/src/commands/test-harness-env.d.ts +5 -6
- package/dist/src/commands/test-harness-env.js +27 -7
- package/dist/src/commands/test-modes.d.ts +2 -4
- package/dist/src/commands/test-register.js +11 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +21 -1
- package/dist/src/commands/test-verdict.d.ts +2 -2
- package/dist/src/commands/test.js +83 -211
- package/dist/src/core/build-baseline-types.d.ts +12 -14
- package/dist/src/core/build-baseline-validate.d.ts +3 -0
- package/dist/src/core/build-baseline-validate.js +53 -0
- package/dist/src/core/build-baseline.d.ts +4 -3
- package/dist/src/core/build-baseline.js +23 -11
- package/dist/src/core/component-source-guard.d.ts +20 -0
- package/dist/src/core/component-source-guard.js +68 -0
- package/dist/src/core/coverage-extend.d.ts +5 -34
- package/dist/src/core/coverage-extend.js +26 -37
- package/dist/src/core/engine-write-boundary.d.ts +5 -0
- package/dist/src/core/engine-write-boundary.js +41 -0
- package/dist/src/core/furnace-apply-helpers.js +8 -2
- package/dist/src/core/furnace-apply.js +7 -0
- package/dist/src/core/furnace-validate-accessibility.js +9 -12
- package/dist/src/core/furnace-validate.js +0 -43
- package/dist/src/core/gecko-dom-types.d.ts +2 -0
- package/dist/src/core/gecko-dom-types.js +17 -0
- package/dist/src/core/lit-template-regions.d.ts +4 -0
- package/dist/src/core/lit-template-regions.js +50 -0
- package/dist/src/core/mach-build-artifacts.d.ts +6 -6
- package/dist/src/core/mach-build-artifacts.js +13 -7
- package/dist/src/core/mach-resource-shim.d.ts +1 -1
- package/dist/src/core/mach-resource-shim.js +2 -0
- package/dist/src/core/marionette-port.d.ts +2 -0
- package/dist/src/core/marionette-port.js +38 -53
- package/dist/src/core/mochitest-server-port.d.ts +1 -1
- package/dist/src/core/mochitest-server-port.js +20 -17
- package/dist/src/core/objdir-maintenance.d.ts +10 -0
- package/dist/src/core/objdir-maintenance.js +167 -0
- package/dist/src/core/patch-lint-checkjs.js +2 -1
- package/dist/src/core/patch-parse.js +8 -6
- package/dist/src/core/preflight-wait.d.ts +2 -0
- package/dist/src/core/preflight-wait.js +24 -0
- package/dist/src/core/process-owner.d.ts +9 -0
- package/dist/src/core/process-owner.js +43 -0
- package/dist/src/core/test-harness-crash.d.ts +1 -1
- package/dist/src/core/test-harness-crash.js +5 -5
- package/dist/src/core/test-host-state.d.ts +14 -0
- package/dist/src/core/test-host-state.js +80 -0
- package/dist/src/core/test-profile-files.d.ts +17 -0
- package/dist/src/core/test-profile-files.js +75 -0
- package/dist/src/core/test-stale-check.d.ts +6 -7
- package/dist/src/core/test-stale-check.js +9 -32
- package/dist/src/core/token-category.d.ts +7 -26
- package/dist/src/core/token-category.js +91 -117
- package/dist/src/core/token-inventory.js +12 -7
- package/dist/src/core/typecheck.js +6 -6
- package/dist/src/types/commands/options.d.ts +14 -3
- package/dist/src/utils/assert.js +2 -7
- package/dist/src/utils/child-env.d.ts +0 -3
- package/dist/src/utils/child-env.js +0 -3
- package/dist/src/utils/concurrency.d.ts +2 -2
- package/dist/src/utils/concurrency.js +15 -4
- package/dist/src/utils/fs.js +11 -1
- package/dist/src/utils/options.d.ts +16 -1
- package/dist/src/utils/options.js +5 -3
- package/dist/src/utils/paths.js +3 -2
- package/dist/src/utils/process-group.d.ts +1 -2
- package/dist/src/utils/process-group.js +1 -2
- package/dist/src/utils/process-input.js +0 -3
- package/dist/src/utils/process.d.ts +3 -2
- package/dist/src/utils/process.js +19 -7
- package/docs/configuration.md +12 -11
- package/docs/lifecycle-invariants.md +9 -5
- package/docs/machine-output.md +36 -19
- package/docs/patch-workflow.md +15 -0
- package/docs/testing.md +77 -12
- package/package.json +6 -5
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
|
191
|
-
|
|
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
|
|
351
|
-
|
|
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
|
-
`
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
'-
|
|
118
|
-
|
|
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
|
|
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.
|
|
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,167 @@
|
|
|
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
|
+
/** Rewrites references under oldRoot, including the forward-slash spelling mozbuild writes on Windows. */
|
|
34
|
+
function rewriteRootReferences(content, oldRoot, newRoot) {
|
|
35
|
+
const rewritten = content.replaceAll(oldRoot + sep, newRoot + sep);
|
|
36
|
+
if (sep !== '\\')
|
|
37
|
+
return rewritten;
|
|
38
|
+
return rewritten
|
|
39
|
+
.replaceAll(oldRoot + '/', newRoot + '/')
|
|
40
|
+
.replaceAll(oldRoot.replaceAll('\\', '/') + '/', newRoot.replaceAll('\\', '/') + '/');
|
|
41
|
+
}
|
|
42
|
+
function isDependencyMetadata(path) {
|
|
43
|
+
return (/\.(?:pp|d)$/.test(path) ||
|
|
44
|
+
/[/\\]faster[/\\]/.test(path) ||
|
|
45
|
+
/[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path) ||
|
|
46
|
+
/[/\\]\.fingerprint[/\\].*\.json$/.test(path));
|
|
47
|
+
}
|
|
48
|
+
/** Repairs copied links and dependency metadata BEFORE configure can consume config.track. */
|
|
49
|
+
export async function relocateObjdirProducts(engineDir, objDir, oldRoot) {
|
|
50
|
+
await assertLocalObjdir(engineDir, objDir);
|
|
51
|
+
const root = resolve(engineDir, objDir);
|
|
52
|
+
const newRoot = resolve(engineDir);
|
|
53
|
+
const canonicalRoot = await realpath(root);
|
|
54
|
+
// Configure writes these groups. Even a local objdir can contain a copied
|
|
55
|
+
// directory alias into a peer, so validate each parent before touching it.
|
|
56
|
+
for (const directory of ['config.statusd', 'config.statusd/substs', 'config.statusd/defines']) {
|
|
57
|
+
const path = join(root, directory);
|
|
58
|
+
try {
|
|
59
|
+
if (!(await lstat(path)).isDirectory() ||
|
|
60
|
+
(await realpath(path)) !== join(canonicalRoot, directory))
|
|
61
|
+
throw new GeneralError(`Partial-config directory ${path} is aliased. Refusing relocation before configure.`);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (getNodeErrorCode(error) !== 'ENOENT')
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await walk(root, async (path, link) => {
|
|
69
|
+
// Remove tracking symlinks too, without following their write targets.
|
|
70
|
+
if (/[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path)) {
|
|
71
|
+
await rm(path);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (link) {
|
|
75
|
+
const target = await readlink(path);
|
|
76
|
+
const absolute = resolve(dirname(path), target);
|
|
77
|
+
const relocated = replacePrefix(absolute, oldRoot, newRoot);
|
|
78
|
+
if (relocated !== absolute) {
|
|
79
|
+
const temp = `${path}.relocate-${process.pid}`;
|
|
80
|
+
await symlink(relocated, temp);
|
|
81
|
+
try {
|
|
82
|
+
await rename(temp, path);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Windows refuses to rename over a directory link; replace it in two steps.
|
|
86
|
+
if (process.platform !== 'win32' || getNodeErrorCode(error) !== 'EPERM')
|
|
87
|
+
throw error;
|
|
88
|
+
await rm(path);
|
|
89
|
+
await rename(temp, path);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await rm(temp, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (isDependencyMetadata(path)) {
|
|
97
|
+
const content = await readFile(path, 'utf8');
|
|
98
|
+
const rewritten = rewriteRootReferences(content, oldRoot, newRoot);
|
|
99
|
+
if (content !== rewritten)
|
|
100
|
+
await writeText(path, rewritten);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Finds a link still consulting the previous source tree after relocation/configure. */
|
|
105
|
+
export async function findStaleObjdirLink(engineDir, objDir, oldRoot) {
|
|
106
|
+
let violation;
|
|
107
|
+
await walk(resolve(engineDir, objDir), async (path, link) => {
|
|
108
|
+
if (!link || violation)
|
|
109
|
+
return;
|
|
110
|
+
const target = resolve(dirname(path), await readlink(path));
|
|
111
|
+
const canonical = await realpath(path).catch(() => target);
|
|
112
|
+
if ([target, canonical].some((value) => value === oldRoot || value.startsWith(oldRoot + sep))) {
|
|
113
|
+
violation = `${path} still links to the previous engine ${oldRoot}`;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return violation;
|
|
117
|
+
}
|
|
118
|
+
/** Prunes only dangling _tests symlinks; never descends through a symlink. */
|
|
119
|
+
export async function pruneDanglingTestLinks(engineDir, objDir) {
|
|
120
|
+
let removed = 0;
|
|
121
|
+
const root = join(engineDir, objDir, '_tests');
|
|
122
|
+
try {
|
|
123
|
+
if (!(await lstat(root)).isDirectory())
|
|
124
|
+
return 0;
|
|
125
|
+
await assertLocalObjdir(engineDir, objDir);
|
|
126
|
+
await walk(root, async (path, link) => {
|
|
127
|
+
if (!link)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
await realpath(path);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (getNodeErrorCode(error) !== 'ENOENT')
|
|
134
|
+
throw error;
|
|
135
|
+
await rm(path);
|
|
136
|
+
removed++;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (getNodeErrorCode(error) !== 'ENOENT')
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return removed;
|
|
145
|
+
}
|
|
146
|
+
/** Names a partial-config group whose entire persisted value set was nulled. */
|
|
147
|
+
export async function assertHealthyPartialConfig(engineDir, objDir) {
|
|
148
|
+
for (const group of ['substs', 'defines']) {
|
|
149
|
+
const dir = join(engineDir, objDir, 'config.statusd', group);
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (getNodeErrorCode(error) === 'ENOENT')
|
|
156
|
+
continue;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const files = entries.filter((entry) => entry.isFile() && entry.name !== 'config.track');
|
|
160
|
+
if (files.length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
const values = await Promise.all(files.map((entry) => readFile(join(dir, entry.name), 'utf8')));
|
|
163
|
+
if (values.every((value) => value.trim() === 'null')) {
|
|
164
|
+
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.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -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();
|
|
@@ -142,12 +142,14 @@ export function parseDiffGitHeader(line) {
|
|
|
142
142
|
return { sourcePath: candidate, targetPath: candidate };
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
// Rename/copy (differing paths):
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
// Rename/copy (differing paths): split at the first ` b/` with a path on
|
|
146
|
+
// each side. indexOf keeps this linear where a lazy regex backtracks.
|
|
147
|
+
if (/[\n\r\u2028\u2029]/.test(rest))
|
|
148
|
+
return null;
|
|
149
|
+
const split = rest.indexOf(' b/', 1);
|
|
150
|
+
if (split === -1 || split + 3 >= rest.length)
|
|
151
|
+
return null;
|
|
152
|
+
return { sourcePath: rest.slice(0, split), targetPath: rest.slice(split + 3) };
|
|
151
153
|
}
|
|
152
154
|
/**
|
|
153
155
|
* Splits raw diff content into lines, tolerating CRLF-saved patch files.
|
|
@@ -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>;
|