@hominis/fireforge 0.34.1 → 0.35.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 (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +17 -4
  3. package/dist/bin/fireforge.d.ts +4 -0
  4. package/dist/bin/fireforge.js +4 -0
  5. package/dist/src/commands/build.js +13 -0
  6. package/dist/src/commands/discard.js +6 -2
  7. package/dist/src/commands/download.js +23 -2
  8. package/dist/src/commands/run.js +14 -2
  9. package/dist/src/commands/test.js +46 -6
  10. package/dist/src/core/build-prepare.js +19 -5
  11. package/dist/src/core/firefox-cache.js +7 -4
  12. package/dist/src/core/firefox-extract.d.ts +21 -1
  13. package/dist/src/core/firefox-extract.js +117 -7
  14. package/dist/src/core/git.js +2 -2
  15. package/dist/src/core/license-headers.d.ts +16 -0
  16. package/dist/src/core/license-headers.js +19 -0
  17. package/dist/src/core/mach-error-hints.js +27 -0
  18. package/dist/src/core/mach-resource-shim.d.ts +42 -7
  19. package/dist/src/core/mach-resource-shim.js +227 -25
  20. package/dist/src/core/patch-apply.js +51 -21
  21. package/dist/src/core/patch-lint.js +12 -1
  22. package/dist/src/core/patch-manifest-io.js +7 -7
  23. package/dist/src/core/patch-manifest-query.d.ts +6 -0
  24. package/dist/src/core/patch-manifest-query.js +9 -2
  25. package/dist/src/core/test-harness-crash.js +12 -0
  26. package/dist/src/core/test-path-scope.d.ts +54 -0
  27. package/dist/src/core/test-path-scope.js +133 -0
  28. package/dist/src/core/toolchain-preflight.d.ts +64 -0
  29. package/dist/src/core/toolchain-preflight.js +195 -0
  30. package/dist/src/core/typecheck-shim.d.ts +4 -1
  31. package/dist/src/core/typecheck-shim.js +48 -17
  32. package/dist/src/errors/download.js +2 -0
  33. package/dist/src/types/commands/options.d.ts +7 -0
  34. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
5
5
  import { elapsedSince } from '../utils/elapsed.js';
6
6
  import { toError } from '../utils/errors.js';
7
- import { pathExists, removeFile } from '../utils/fs.js';
7
+ import { pathExists, pathExistsStrict, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
10
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
@@ -18,7 +18,7 @@ import { getWorkingTreeStatus } from './git-status.js';
18
18
  */
19
19
  export async function isGitRepository(dir) {
20
20
  const gitDir = join(dir, '.git');
21
- return pathExists(gitDir);
21
+ return pathExistsStrict(gitDir);
22
22
  }
23
23
  /**
24
24
  * Ensures the repository has an "origin" remote.
@@ -38,6 +38,22 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
38
38
  * @param style - Comment syntax of the file
39
39
  */
40
40
  export declare function hasAnyLicenseHeader(content: string, style: CommentStyle): boolean;
41
+ /**
42
+ * Returns true if `content` starts with the verbatim upstream Mozilla
43
+ * MPL-2.0 block header (`/* This Source Code Form … *\/`), optionally
44
+ * preceded by editor-directive comments — the exact shape a JS file
45
+ * copied from the Firefox source tree carries.
46
+ *
47
+ * Deliberately independent of the project license: a new JS file that
48
+ * legitimately keeps its upstream MPL block header (copied-from-upstream
49
+ * provenance) is valid in an EUPL/GPL/0BSD project too. Before 0.35.0
50
+ * this acceptance only existed behind a `license === 'MPL-2.0'` gate in
51
+ * patch-lint, making it dead code for every other project license and
52
+ * forcing repo-side audit workarounds.
53
+ *
54
+ * @param content - File content to check
55
+ */
56
+ export declare function hasUpstreamMplBlockHeader(content: string): boolean;
41
57
  /**
42
58
  * Returns true if `content` starts with any known license header in any
43
59
  * comment style (js, css, hash).
@@ -119,6 +119,25 @@ export function hasAnyLicenseHeader(content, style) {
119
119
  }
120
120
  return false;
121
121
  }
122
+ /**
123
+ * Returns true if `content` starts with the verbatim upstream Mozilla
124
+ * MPL-2.0 block header (`/* This Source Code Form … *\/`), optionally
125
+ * preceded by editor-directive comments — the exact shape a JS file
126
+ * copied from the Firefox source tree carries.
127
+ *
128
+ * Deliberately independent of the project license: a new JS file that
129
+ * legitimately keeps its upstream MPL block header (copied-from-upstream
130
+ * provenance) is valid in an EUPL/GPL/0BSD project too. Before 0.35.0
131
+ * this acceptance only existed behind a `license === 'MPL-2.0'` gate in
132
+ * patch-lint, making it dead code for every other project license and
133
+ * forcing repo-side audit workarounds.
134
+ *
135
+ * @param content - File content to check
136
+ */
137
+ export function hasUpstreamMplBlockHeader(content) {
138
+ const blockHeader = getLicenseHeader('MPL-2.0', 'css');
139
+ return (content.startsWith(blockHeader) || stripLeadingEditorDirectives(content).startsWith(blockHeader));
140
+ }
122
141
  /**
123
142
  * Returns true if `content` starts with any known license header in any
124
143
  * comment style (js, css, hash).
@@ -56,6 +56,33 @@ export const MACH_ERROR_HINTS = [
56
56
  'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
57
57
  '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
58
58
  },
59
+ {
60
+ // Firefox declares per-release toolchain minimums in-tree
61
+ // (`build/moz.configure/bindgen.configure` for cbindgen; mozboot's
62
+ // MINIMUM_RUST_VERSION for rustc/cargo), and a source hop can move
63
+ // them. The 152.0b7 → 153.0b8 source-refresh drill hit exactly this:
64
+ // the first post-hop build died ~8s into `mach configure` with
65
+ // "ERROR: cbindgen version 0.29.1 is too old. At least version
66
+ // 0.29.4 is required." — and mach's own remediation text names
67
+ // "./mach bootstrap", the wrong entry point for a FireForge-managed
68
+ // repo. Pattern matches configure's cbindgen die() shape.
69
+ pattern: /\bversion [\d.]+ is too old\.?\s+At least version [\d.]+ is required/i,
70
+ hint: 'A toolchain component is older than the minimum this Firefox source declares — typical ' +
71
+ 'after "fireforge download --force" moved the engine to a new Firefox major version. ' +
72
+ 'Run "fireforge bootstrap" (not mach\'s suggested "./mach bootstrap") to update the ' +
73
+ 'toolchain, then retry the build.',
74
+ },
75
+ {
76
+ // Same family, rust.configure's die() shapes: "Rust compiler {v} is
77
+ // too old." / "Cargo package manager {v} is too old." — the minimum
78
+ // is only named further down that message, so the pattern keys on
79
+ // the first line.
80
+ pattern: /\b(?:Rust compiler|Cargo package manager) [\d.]+(?:[\w.-]*)? is too old/i,
81
+ hint: "The Rust toolchain is older than the minimum this Firefox source declares (mozboot's " +
82
+ 'MINIMUM_RUST_VERSION) — typical after "fireforge download --force" moved the engine to a ' +
83
+ 'new Firefox major version. Run "fireforge bootstrap" to update the toolchain, then retry ' +
84
+ 'the build.',
85
+ },
59
86
  {
60
87
  // When `mach build` fails mid-compile, mach's own shutdown pipeline still
61
88
  // runs its trailing "Config object not found by mach. / Configure
@@ -30,17 +30,52 @@
30
30
  * reading instead of aborting. Downstream report (0.34.0 cycle):
31
31
  * mozsystemmonitor subscripts (`_build_meta` does
32
32
  * `psutil.virtual_memory()[0]`) and iterates/unpacks the reading, so the
33
- * zeroed fallback is a full namedtuple duck type a zeroed instance of
34
- * psutil's own result namedtuple where resolvable without touching the
35
- * failing syscall, else `_DegradedReading` (subscriptable, iterable, with
36
- * `len()`, `_fields`, `_asdict()`); `cpu_percent` degrades to `0.0`
37
- * because callers do arithmetic on it;
33
+ * zeroed fallback is a full namedtuple duck type; `cpu_percent` degrades
34
+ * to `0.0` because callers do arithmetic on it. Field report (0.34.1,
35
+ * flapping host): the fallback must ALSO be per-function arity-correct
36
+ * and reconstructible mozsystemmonitor's parent captures reading types
37
+ * at `__init__` (`self._swap_type = type(psutil.swap_memory())`) and
38
+ * rebuilds each collector sample via `self._swap_type(*swap_mem)`, and an
39
+ * svmem-shaped (8-field) fallback in the swap (6-field `sswap`) position
40
+ * made the parent reject every sample ("failed to read the received
41
+ * data") and BREAK its drain loop, so the pipe filled, the collector
42
+ * child blocked in `send()` and never read the terminate sentinel, and
43
+ * mach hung forever in its atexit `waitpid`. Each wrapped function now
44
+ * degrades to a zeroed instance of psutil's own result namedtuple where
45
+ * resolvable, else a module-level guard-owned namedtuple with the exact
46
+ * macOS field order/arity for THAT function (module-level so readings
47
+ * pickle by reference across the collector pipe); `_DegradedReading`
48
+ * remains only as a documented last resort and now tolerates
49
+ * `type(reading)(*values)` reconstruction;
38
50
  * - guards `SystemResourceMonitor` CONSTRUCTION via an import hook:
39
51
  * `poll_interval` is pre-populated before the real `__init__` runs, a
40
52
  * failing `__init__` marks the instance degraded instead of raising,
41
53
  * and every monitor method no-ops on a degraded instance — so a
42
54
  * partially-constructed monitor can never surface the
43
- * `AttributeError: poll_interval` variant.
55
+ * `AttributeError: poll_interval` variant. Degraded aggregate methods
56
+ * return zeroed shapes (not `None`) where callers subscript the result
57
+ * (`aggregate_io` → zeroed io reading, so mozbuild's
58
+ * `log_resource_usage` no longer dies on `usage["io"].read_bytes` after
59
+ * a fully successful compile);
60
+ * - suppresses the mozsystemmonitor collector child on degradation: once
61
+ * any psutil degradation is observed in the process, monitors are kept
62
+ * inert (`start` never spawns the collector), and a degraded transition
63
+ * or raising `stop` best-effort terminates a live collector child and
64
+ * drains the pipe — so a malformed sample stream can never wedge mach's
65
+ * shutdown even if a future shape mismatch slips through;
66
+ * - additionally wraps `mozbuild.controller.building.BuildMonitor
67
+ * .log_resource_usage` to warn-and-continue on any exception, so
68
+ * end-of-build resource reporting can never fail a build whose
69
+ * artifacts are complete.
70
+ *
71
+ * Why the 0.34.1 report saw `_DegradedReading` in the collector child even
72
+ * though the psutil result classes resolve when probed directly: two guard
73
+ * copies can load (PYTHONPATH `sitecustomize` in the bootstrap phase plus
74
+ * the in-venv `.pth`), each with its own factory/classes, and flapping at
75
+ * guard-import time can perturb `psutil._psplatform` attribute resolution
76
+ * in the spawn child specifically. The fix is robust to either mechanism:
77
+ * the guard-owned fallback classes never depend on psutil internals
78
+ * resolving and are arity-correct in every position.
44
79
  */
45
80
  /**
46
81
  * Python guard body, importable both as the in-venv `fireforge_mach_guard`
@@ -51,7 +86,7 @@
51
86
  * regression test (mach-resource-shim.python.test.ts), which asserts the
52
87
  * degraded readings' runtime behavior rather than string-matching the source.
53
88
  */
54
- 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 sys\nimport warnings\n\n\ndef _fireforge_degraded_notice(exc):\n warnings.warn(\n \"FireForge: host resource monitor degraded (%s); continuing without resource monitoring.\" % exc\n )\n\n\nclass _DegradedReading(object):\n # Duck type of psutil's namedtuple results (svmem field order):\n # mozsystemmonitor subscripts, iterates, and unpacks readings, so the\n # degraded fallback must survive r[0], list(r), len(r), r._fields and\n # r._asdict(), not just attribute access.\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 __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# 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 _degraded_result_factory(psutil, name):\n # cpu_percent returns a plain float callers do arithmetic on.\n if name == \"cpu_percent\":\n return lambda: 0.0\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 _cls = getattr(_mod, _cls_name, None) if _mod is not None else None\n _fields = getattr(_cls, \"_fields\", None)\n if _cls is not None and _fields:\n return lambda _cls=_cls, _n=len(_fields): _cls(*([0] * _n))\n return _DegradedReading\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()\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 _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\n cls.__init__ = guarded_init\n\n def _wrap_method(orig):\n def wrapper(self, *args, **kwargs):\n if getattr(self, \"_fireforge_degraded\", False):\n return None\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 return None\n\n return wrapper\n\n for _name in _MONITOR_METHOD_NAMES:\n _orig = getattr(cls, _name, None)\n if _orig is not None:\n setattr(cls, _name, _wrap_method(_orig))\n cls._fireforge_guarded = True\n\n\n_MONITOR_MODULES = (\"mozsystemmonitor.resourcemonitor\", \"mozbuild.resources\")\n\n\ndef _patch_loaded_monitor_modules():\n for _mod_name in _MONITOR_MODULES:\n _mod = sys.modules.get(_mod_name)\n if _mod is not None:\n try:\n _patch_monitor_class(getattr(_mod, \"SystemResourceMonitor\", 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";
89
+ 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 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. Field report (0.34.1): the\n # parent also reconstructs readings via type(reading)(*values), so the\n # constructor must tolerate 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 (field report 0.34.1: an\n# svmem-shaped fallback in the swap position made the parent reject every\n# sample, fill the pipe, block the collector child in send(), and wedge\n# 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 (field report\n # 0.34.1: 'NoneType' object has no attribute 'read_bytes' failed the\n # build with complete artifacts).\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 for _name in _MONITOR_METHOD_NAMES:\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";
55
90
  /** Result of installing the mach resource guard before a dispatch. */
56
91
  export interface MachResourceGuardInstallation {
57
92
  /**
@@ -31,17 +31,52 @@
31
31
  * reading instead of aborting. Downstream report (0.34.0 cycle):
32
32
  * mozsystemmonitor subscripts (`_build_meta` does
33
33
  * `psutil.virtual_memory()[0]`) and iterates/unpacks the reading, so the
34
- * zeroed fallback is a full namedtuple duck type a zeroed instance of
35
- * psutil's own result namedtuple where resolvable without touching the
36
- * failing syscall, else `_DegradedReading` (subscriptable, iterable, with
37
- * `len()`, `_fields`, `_asdict()`); `cpu_percent` degrades to `0.0`
38
- * because callers do arithmetic on it;
34
+ * zeroed fallback is a full namedtuple duck type; `cpu_percent` degrades
35
+ * to `0.0` because callers do arithmetic on it. Field report (0.34.1,
36
+ * flapping host): the fallback must ALSO be per-function arity-correct
37
+ * and reconstructible mozsystemmonitor's parent captures reading types
38
+ * at `__init__` (`self._swap_type = type(psutil.swap_memory())`) and
39
+ * rebuilds each collector sample via `self._swap_type(*swap_mem)`, and an
40
+ * svmem-shaped (8-field) fallback in the swap (6-field `sswap`) position
41
+ * made the parent reject every sample ("failed to read the received
42
+ * data") and BREAK its drain loop, so the pipe filled, the collector
43
+ * child blocked in `send()` and never read the terminate sentinel, and
44
+ * mach hung forever in its atexit `waitpid`. Each wrapped function now
45
+ * degrades to a zeroed instance of psutil's own result namedtuple where
46
+ * resolvable, else a module-level guard-owned namedtuple with the exact
47
+ * macOS field order/arity for THAT function (module-level so readings
48
+ * pickle by reference across the collector pipe); `_DegradedReading`
49
+ * remains only as a documented last resort and now tolerates
50
+ * `type(reading)(*values)` reconstruction;
39
51
  * - guards `SystemResourceMonitor` CONSTRUCTION via an import hook:
40
52
  * `poll_interval` is pre-populated before the real `__init__` runs, a
41
53
  * failing `__init__` marks the instance degraded instead of raising,
42
54
  * and every monitor method no-ops on a degraded instance — so a
43
55
  * partially-constructed monitor can never surface the
44
- * `AttributeError: poll_interval` variant.
56
+ * `AttributeError: poll_interval` variant. Degraded aggregate methods
57
+ * return zeroed shapes (not `None`) where callers subscript the result
58
+ * (`aggregate_io` → zeroed io reading, so mozbuild's
59
+ * `log_resource_usage` no longer dies on `usage["io"].read_bytes` after
60
+ * a fully successful compile);
61
+ * - suppresses the mozsystemmonitor collector child on degradation: once
62
+ * any psutil degradation is observed in the process, monitors are kept
63
+ * inert (`start` never spawns the collector), and a degraded transition
64
+ * or raising `stop` best-effort terminates a live collector child and
65
+ * drains the pipe — so a malformed sample stream can never wedge mach's
66
+ * shutdown even if a future shape mismatch slips through;
67
+ * - additionally wraps `mozbuild.controller.building.BuildMonitor
68
+ * .log_resource_usage` to warn-and-continue on any exception, so
69
+ * end-of-build resource reporting can never fail a build whose
70
+ * artifacts are complete.
71
+ *
72
+ * Why the 0.34.1 report saw `_DegradedReading` in the collector child even
73
+ * though the psutil result classes resolve when probed directly: two guard
74
+ * copies can load (PYTHONPATH `sitecustomize` in the bootstrap phase plus
75
+ * the in-venv `.pth`), each with its own factory/classes, and flapping at
76
+ * guard-import time can perturb `psutil._psplatform` attribute resolution
77
+ * in the spawn child specifically. The fix is robust to either mechanism:
78
+ * the guard-owned fallback classes never depend on psutil internals
79
+ * resolving and are arity-correct in every position.
45
80
  */
46
81
  import { readdir } from 'node:fs/promises';
47
82
  import { homedir, tmpdir } from 'node:os';
@@ -68,25 +103,40 @@ export const GUARD_PYTHON_SOURCE = `# Generated by FireForge - do not edit.
68
103
  # proceed. Installed into the mach virtualenv site-packages via a .pth file
69
104
  # (survives mach's venv re-exec, which drops PYTHONPATH).
70
105
  import builtins
106
+ import collections
71
107
  import sys
72
108
  import warnings
73
109
 
110
+ # Any psutil degradation observed in this process. Once set, monitors are
111
+ # kept inert (start never spawns the collector child) — on a flapping host a
112
+ # healthy-looking moment must not be trusted to start a sample stream that a
113
+ # later degraded reading could malform.
114
+ _fireforge_host_degraded = [False]
115
+
74
116
 
75
117
  def _fireforge_degraded_notice(exc):
118
+ _fireforge_host_degraded[0] = True
76
119
  warnings.warn(
77
120
  "FireForge: host resource monitor degraded (%s); continuing without resource monitoring." % exc
78
121
  )
79
122
 
80
123
 
81
124
  class _DegradedReading(object):
82
- # Duck type of psutil's namedtuple results (svmem field order):
83
- # mozsystemmonitor subscripts, iterates, and unpacks readings, so the
84
- # degraded fallback must survive r[0], list(r), len(r), r._fields and
85
- # r._asdict(), not just attribute access.
125
+ # Last-resort duck type of psutil's namedtuple results (svmem field
126
+ # order): mozsystemmonitor subscripts, iterates, and unpacks readings,
127
+ # so the degraded fallback must survive r[0], list(r), len(r), r._fields
128
+ # and r._asdict(), not just attribute access. Field report (0.34.1): the
129
+ # parent also reconstructs readings via type(reading)(*values), so the
130
+ # constructor must tolerate the full positional field list. Normal
131
+ # degraded paths use the per-function namedtuple fallbacks below; this
132
+ # class only backstops a fallback that itself raised.
86
133
  _fields = ("total", "available", "percent", "used", "free", "active", "inactive", "wired")
87
134
  total = available = used = free = active = inactive = wired = 0
88
135
  percent = 0.0
89
136
 
137
+ def __init__(self, *_args, **_kwargs):
138
+ pass
139
+
90
140
  def __getattr__(self, _name):
91
141
  return 0
92
142
 
@@ -106,6 +156,34 @@ class _DegradedReading(object):
106
156
  return dict(zip(self._fields, self._values()))
107
157
 
108
158
 
159
+ # Per-function fallback result classes with the exact macOS field
160
+ # orders/arities. Module-level on purpose: readings cross the
161
+ # mozsystemmonitor collector pipe via pickle (by reference), and the parent
162
+ # rebuilds each sample with type(reading)(*values) — so a swap fallback must
163
+ # be 6-field sswap-shaped in every position (field report 0.34.1: an
164
+ # svmem-shaped fallback in the swap position made the parent reject every
165
+ # sample, fill the pipe, block the collector child in send(), and wedge
166
+ # mach's atexit join forever).
167
+ _FallbackVmem = collections.namedtuple(
168
+ "_FallbackVmem",
169
+ ("total", "available", "percent", "used", "free", "active", "inactive", "wired"),
170
+ )
171
+ _FallbackSwap = collections.namedtuple(
172
+ "_FallbackSwap", ("total", "used", "free", "percent", "sin", "sout")
173
+ )
174
+ _FallbackCpuTimes = collections.namedtuple("_FallbackCpuTimes", ("user", "nice", "system", "idle"))
175
+ _FallbackDiskIO = collections.namedtuple(
176
+ "_FallbackDiskIO",
177
+ ("read_count", "write_count", "read_bytes", "write_bytes", "read_time", "write_time"),
178
+ )
179
+
180
+ _FALLBACK_CLASSES = {
181
+ "virtual_memory": _FallbackVmem,
182
+ "swap_memory": _FallbackSwap,
183
+ "cpu_times": _FallbackCpuTimes,
184
+ "disk_io_counters": _FallbackDiskIO,
185
+ }
186
+
109
187
  # psutil result namedtuple per wrapped function, resolvable via getattr from
110
188
  # the platform module / _common without invoking the failing syscall.
111
189
  _PSUTIL_RESULT_CLASSES = {
@@ -116,18 +194,51 @@ _PSUTIL_RESULT_CLASSES = {
116
194
  }
117
195
 
118
196
 
197
+ def _zeroed(cls):
198
+ return cls(*([0] * len(cls._fields)))
199
+
200
+
201
+ def _percpu_requested(args, kwargs, positional_index):
202
+ if "percpu" in kwargs:
203
+ return bool(kwargs["percpu"])
204
+ return len(args) > positional_index and bool(args[positional_index])
205
+
206
+
119
207
  def _degraded_result_factory(psutil, name):
120
- # cpu_percent returns a plain float callers do arithmetic on.
208
+ # cpu_percent returns a plain float (or a per-CPU list) callers do
209
+ # arithmetic on.
121
210
  if name == "cpu_percent":
122
- return lambda: 0.0
211
+ def _cpu_percent_fallback(*args, **kwargs):
212
+ # cpu_percent(interval=None, percpu=False)
213
+ if _percpu_requested(args, kwargs, 1):
214
+ return []
215
+ return 0.0
216
+
217
+ return _cpu_percent_fallback
218
+ # The guard-owned class is always shape-correct for this function;
219
+ # psutil's own result namedtuple is still preferred when resolvable so
220
+ # type() captures stay identical to real readings on a flapping host.
221
+ _cls = _FALLBACK_CLASSES.get(name, _DegradedReading)
123
222
  _cls_name = _PSUTIL_RESULT_CLASSES.get(name)
124
223
  if _cls_name is not None:
125
224
  for _mod in (getattr(psutil, "_psplatform", None), getattr(psutil, "_common", None)):
126
- _cls = getattr(_mod, _cls_name, None) if _mod is not None else None
127
- _fields = getattr(_cls, "_fields", None)
128
- if _cls is not None and _fields:
129
- return lambda _cls=_cls, _n=len(_fields): _cls(*([0] * _n))
130
- return _DegradedReading
225
+ _real = getattr(_mod, _cls_name, None) if _mod is not None else None
226
+ if _real is not None and getattr(_real, "_fields", None):
227
+ _cls = _real
228
+ break
229
+ if name == "cpu_times":
230
+ def _cpu_times_fallback(*args, **kwargs):
231
+ # cpu_times(percpu=False); the collector child samples per-CPU.
232
+ if _percpu_requested(args, kwargs, 0):
233
+ return []
234
+ return _zeroed(_cls)
235
+
236
+ return _cpu_times_fallback
237
+
238
+ def _reading_fallback(*_args, **_kwargs):
239
+ return _zeroed(_cls)
240
+
241
+ return _reading_fallback
131
242
 
132
243
 
133
244
  def _guard_psutil():
@@ -143,7 +254,7 @@ def _guard_psutil():
143
254
  except Exception as exc: # noqa: BLE001
144
255
  _fireforge_degraded_notice(exc)
145
256
  try:
146
- return fallback()
257
+ return fallback(*args, **kwargs)
147
258
  except Exception: # noqa: BLE001
148
259
  return _DegradedReading()
149
260
 
@@ -176,6 +287,54 @@ _MONITOR_METHOD_NAMES = (
176
287
  )
177
288
 
178
289
 
290
+ def _fireforge_stop_collector(monitor):
291
+ # Best-effort: terminate a live collector child and drain the parent end
292
+ # of the pipe, so a child blocked in send() on a full pipe can never
293
+ # keep mach's atexit join (os.waitpid) waiting forever. Attribute names
294
+ # follow upstream resourcemonitor.py (self._process / self._pipe); a
295
+ # miss on a refactored upstream is harmless — the shape-correct
296
+ # fallbacks above still keep the sample stream well-formed.
297
+ try:
298
+ proc = getattr(monitor, "_process", None)
299
+ if proc is not None and getattr(proc, "is_alive", lambda: False)():
300
+ proc.terminate()
301
+ proc.join(1)
302
+ except Exception: # noqa: BLE001
303
+ pass
304
+ try:
305
+ pipe = getattr(monitor, "_pipe", None)
306
+ if pipe is not None:
307
+ while pipe.poll(0):
308
+ pipe.recv()
309
+ except Exception: # noqa: BLE001
310
+ pass
311
+
312
+
313
+ def _monitor_per_cpu_requested(args, kwargs):
314
+ # aggregate_cpu_percent/aggregate_cpu_times(start=None, end=None,
315
+ # phase=None, per_cpu=True) — self already stripped from args.
316
+ if "per_cpu" in kwargs:
317
+ return bool(kwargs["per_cpu"])
318
+ if len(args) > 3:
319
+ return bool(args[3])
320
+ return True
321
+
322
+
323
+ def _monitor_degraded_result(name, args, kwargs):
324
+ # Degraded aggregate methods return zeroed shapes, not None, where
325
+ # callers subscript the result: mozbuild's log_resource_usage does
326
+ # usage["io"].read_bytes after a successful compile (field report
327
+ # 0.34.1: 'NoneType' object has no attribute 'read_bytes' failed the
328
+ # build with complete artifacts).
329
+ if name == "aggregate_io":
330
+ return _zeroed(_FallbackDiskIO)
331
+ if name == "aggregate_cpu_percent":
332
+ return [] if _monitor_per_cpu_requested(args, kwargs) else 0.0
333
+ if name == "aggregate_cpu_times":
334
+ return [] if _monitor_per_cpu_requested(args, kwargs) else _zeroed(_FallbackCpuTimes)
335
+ return None
336
+
337
+
179
338
  def _patch_monitor_class(cls):
180
339
  if cls is None or getattr(cls, "_fireforge_guarded", False):
181
340
  return
@@ -192,38 +351,81 @@ def _patch_monitor_class(cls):
192
351
  except Exception as exc: # noqa: BLE001
193
352
  self._fireforge_degraded = True
194
353
  _fireforge_degraded_notice(exc)
354
+ if _fireforge_host_degraded[0] and not self._fireforge_degraded:
355
+ # Host already degraded once: keep the monitor inert so the
356
+ # collector child never spawns on a flapping host.
357
+ self._fireforge_degraded = True
358
+ if self._fireforge_degraded:
359
+ _fireforge_stop_collector(self)
195
360
 
196
361
  cls.__init__ = guarded_init
197
362
 
198
- def _wrap_method(orig):
363
+ def _wrap_method(name, orig):
199
364
  def wrapper(self, *args, **kwargs):
365
+ if (
366
+ name == "start"
367
+ and _fireforge_host_degraded[0]
368
+ and not getattr(self, "_fireforge_degraded", False)
369
+ ):
370
+ self._fireforge_degraded = True
371
+ _fireforge_stop_collector(self)
200
372
  if getattr(self, "_fireforge_degraded", False):
201
- return None
373
+ if name == "stop":
374
+ _fireforge_stop_collector(self)
375
+ return _monitor_degraded_result(name, args, kwargs)
202
376
  try:
203
377
  return orig(self, *args, **kwargs)
204
378
  except Exception as exc: # noqa: BLE001
205
379
  self._fireforge_degraded = True
206
380
  _fireforge_degraded_notice(exc)
207
- return None
381
+ # A raising stop (or any degraded transition) must still
382
+ # take the collector child down, or the parent's atexit
383
+ # join hangs on a child blocked writing to a full pipe.
384
+ _fireforge_stop_collector(self)
385
+ return _monitor_degraded_result(name, args, kwargs)
208
386
 
209
387
  return wrapper
210
388
 
211
389
  for _name in _MONITOR_METHOD_NAMES:
212
390
  _orig = getattr(cls, _name, None)
213
391
  if _orig is not None:
214
- setattr(cls, _name, _wrap_method(_orig))
392
+ setattr(cls, _name, _wrap_method(_name, _orig))
215
393
  cls._fireforge_guarded = True
216
394
 
217
395
 
218
- _MONITOR_MODULES = ("mozsystemmonitor.resourcemonitor", "mozbuild.resources")
396
+ def _patch_build_monitor_class(cls):
397
+ # mozbuild's BuildMonitor is not a SystemResourceMonitor; only
398
+ # log_resource_usage needs guarding — end-of-build resource reporting
399
+ # must never fail a build whose artifacts are complete. No __init__
400
+ # replacement, no degraded-instance gating.
401
+ if cls is None or getattr(cls, "_fireforge_guarded", False):
402
+ return
403
+ _orig = getattr(cls, "log_resource_usage", None)
404
+ if _orig is not None:
405
+ def _guarded_log_resource_usage(self, *args, **kwargs):
406
+ try:
407
+ return _orig(self, *args, **kwargs)
408
+ except Exception as exc: # noqa: BLE001
409
+ _fireforge_degraded_notice(exc)
410
+ return None
411
+
412
+ cls.log_resource_usage = _guarded_log_resource_usage
413
+ cls._fireforge_guarded = True
414
+
415
+
416
+ _MONITOR_CLASS_PATCHES = (
417
+ ("mozsystemmonitor.resourcemonitor", "SystemResourceMonitor", _patch_monitor_class),
418
+ ("mozbuild.resources", "SystemResourceMonitor", _patch_monitor_class),
419
+ ("mozbuild.controller.building", "BuildMonitor", _patch_build_monitor_class),
420
+ )
219
421
 
220
422
 
221
423
  def _patch_loaded_monitor_modules():
222
- for _mod_name in _MONITOR_MODULES:
424
+ for _mod_name, _cls_name, _patcher in _MONITOR_CLASS_PATCHES:
223
425
  _mod = sys.modules.get(_mod_name)
224
426
  if _mod is not None:
225
427
  try:
226
- _patch_monitor_class(getattr(_mod, "SystemResourceMonitor", None))
428
+ _patcher(getattr(_mod, _cls_name, None))
227
429
  except Exception: # noqa: BLE001
228
430
  pass
229
431