@hominis/fireforge 0.34.0 → 0.34.2

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 CHANGED
@@ -5,6 +5,8 @@
5
5
  - Reworked the macOS resource-monitor (psutil) crash mitigation to an **in-process guard FireForge owns on its mach dispatches**: a `fireforge_mach_guard.pth` + module pair is installed directly into every discovered mach virtualenv site-packages (objdir `_virtualenvs` and the `~/.mozbuild` state dir) before each dispatch, because mach's venv re-exec drops `PYTHONPATH` and the 0.33.0 `sitecustomize.py` route never loaded (the env-var shim is retained only for the pre-venv bootstrap phase). The guard now covers the whole crash family: `psutil.virtual_memory`/`swap_memory`/`cpu_percent`/`cpu_times`/`disk_io_counters` are wrapped module-wide (which also covers the direct `psutil.virtual_memory()` call in `mozbuild.base._run_make`), and `SystemResourceMonitor` **construction** is guarded via an import hook that pre-populates `poll_interval` and degrades a partially-constructed monitor to a no-op — so the `AttributeError: ... poll_interval` variant can no longer escape a polling-only patch.
6
6
  - Routed every mach build dispatch — plain `fireforge build`, `build --ui` (`mach build faster`), and the pre-test build step of `fireforge test --build` — through one protected path (`runProtectedMachBuild`) with a **uniform recognized-crash retry budget** (default 2 retries, matching `--harness-retries`; the pre-test step forwards the operator's `--harness-retries` value). Each retry spawns a fresh mach process AND re-installs the guard (re-discovering venvs), so a venv materialized by a crashed first attempt is guarded on the next one instead of every retry dying on the same wedged state. Non-crash build failures are never retried; exhausted budgets surface the crash shape and evidence line above the regular diagnostics. The suite-specific test dispatches (`mach test` / `xpcshell-test` / `mochitest`) install the same guard.
7
7
  - Investigated the field incident where a failed pre-test build was followed by a ~64-minute full rebuild: FireForge's protected path performs **no configure/clobber beyond what `mach build faster` itself does** (retries never re-run `prepareBuildEnvironment`/`mach configure`; there is no fallback-to-full-build in FireForge). The pre-test build now also passes the previous build baseline into `prepareBuildEnvironment` exactly like `fireforge build` does, so auto-configure runs under identical conditions on both paths instead of diverging.
8
+ - Fixed the degraded-psutil fallback crashing mozsystemmonitor on the fallback itself (downstream report: `TypeError: '_DegradedReading' object is not subscriptable` from `_build_meta`'s `psutil.virtual_memory()[0]`, aborting `mach mochitest` at startup with zero `TEST-START` lines, and `_collect failed: '_DegradedReading' object is not iterable`). The guard's degraded reading is now a **real duck type of psutil's namedtuple results**: each wrapped function degrades to a zeroed instance of psutil's own result namedtuple (`svmem`/`sswap`/`scputimes`/`sdiskio`, resolved via `getattr` without touching the failing syscall), falling back to an extended `_DegradedReading` that supports subscripting, iteration, `len()`, `_fields` (svmem field order), and `_asdict()`. `cpu_percent` degrades to a plain `0.0` float (callers do arithmetic on it), and `disk_io_counters` deliberately degrades to a zeroed struct rather than `None`. Runtime-verified by new python3-executed regression tests covering a mozsystemmonitor-style `_build_meta`/`_collect` sequence against a degraded host.
9
+ - Taught the harness-crash classifier the two `_DegradedReading` crash signatures (`object is not subscriptable` / `object is not iterable`), so a startup abort with zero `TEST-START` lines from this family classifies as crash-not-failure and retries; the `_collect failed: '_DegradedReading' ...` evidence line is exempted from the noise-stripping that otherwise drops `_collect failed` degradation chatter, while a completed green summary still vetoes the classification.
8
10
  - Fixed the sharded/retry crash classifier marking fully green runs as `CRASH (N attempts)`: a completed green embedded summary (a `TEST_START`/`TEST-START` execution signal, `Unexpected results: 0`, and `SUITE_END`) now **vetoes** signature-based crash classification for that attempt, and resource-monitor degradation lines (`UserWarning: psutil failed to run: ...`, `_collect failed: ...`, FireForge's own degradation notice) plus mach's caught telemetry tracebacks are stripped from crash evidence entirely. Exit codes follow the corrected verdict: a run whose every shard is green exits 0, and a non-sharded (`--no-shard`) run whose embedded summary completed green exits 0 even when mach's own exit code went non-zero on harness noise (a note explains the override) — parsing embedded summaries by hand is no longer necessary. Runs with a non-zero unexpected count or real `TEST-UNEXPECTED-*` lines still fail, and the post-green shutdown re-entry shape still classifies as a crash.
9
11
  - Fixed `fireforge test <directory>` dispatching xpcshell-only directories to the mochitest runner ("could not find any mochitests under the following test path(s)"): the manifest walk now starts at the directory itself, so a directory whose own manifest is an `xpcshell.toml` dispatches to `mach xpcshell-test` just like an explicit `test_*.js` path.
10
12
  - Added `fireforge register --create-manifest`: registering a module under a directory with no `moz.build` now scaffolds the directory manifest (MPL-header + `EXTRA_JS_MODULES.<namespace>` list) and wires the parent `DIRS` chain up to the nearest existing moz.build (creating intermediates); without the flag the "Manifest not found" error names it. Also added an **xpcshell test-file pattern** (`**/test_*.js` outside `browser/base/content/test/`) that inserts the `["test_*.js"]` section into the directory's `xpcshell.toml` in mozbuild sort order — or creates the manifest and wires `XPCSHELL_TESTS_MANIFESTS` with `--create-manifest` — and extended the browser-chrome manifest rule to fork-owned `browser.toml` manifests at **arbitrary depth** under `browser/base/content/test/` (previously one level only).
@@ -13,6 +15,10 @@
13
15
  - Added `furnace scan --track`, which persists every discovered untracked component into the `stock` section of furnace.json non-interactively (same locked, rollback-journaled write path as the interactive confirm flow). Scan's help and its non-interactive output now state that scan is report-only by default and where the inventory is consumed, ending the report-persist-nothing-explain-nothing behavior.
14
16
  - Added `furnace chrome-doc create --browser-window`, a browser.xhtml-like scaffold for the document that ships as the fork's main browser window: `<html id="main-window">` root with the `windowtype="navigator:browser"`/`chromehidden`/geometry-`persist` attributes platform C++ reads before scripts run, while keeping the generic scaffold's bootstrap wiring, sentinel, and (already-correct) jar.mn registrations. When the target document matches a configured `tokenHostDocuments` entry and the flag was not passed, create warns that the browser-window variant is probably intended.
15
17
  - Added `furnace create --test-dir <dir>` to redirect the `--with-tests` scaffold (browser-chrome and xpcshell styles) to any engine-relative directory under `browser/base/content/test/` — nested manifests register correctly — and made all test scaffolds collision-safe: existing `browser.toml`/`xpcshell.toml`/`chrome.toml` manifests are appended to (with a shared-manifest notice) instead of scaffolded over, and existing `head.js`/test implementation files are never overwritten.
18
+ - Fixed the degraded-psutil guard wedging `mach build` at exit on flapping hosts (0.34.1 post-upgrade field report: psutil's vm/swap syscalls flap between working and degraded, the fallback swap reading arrived svmem-shaped (8 fields) where mozsystemmonitor's parent reconstructs a 6-field `sswap` via `self._swap_type(*swap_mem)`, so every collector sample was rejected — `resourcemonitor.py:766: UserWarning: failed to read the received data` — upstream's handler breaks its drain loop, the pipe fills, the collector child blocks in `send()` and never reads the terminate sentinel, and mach hangs forever in its atexit `waitpid`). The guard's fallback readings are now **per-function, arity-correct, module-level namedtuples** (svmem 8 / sswap 6 / scputimes 4 / sdiskio 6; psutil's own result classes are still preferred when resolvable) that pickle by reference across the collector pipe and survive `type(reading)(*values)` reconstruction in **either** flapping direction (real type + degraded reading, or degraded type + real reading); `cpu_percent`/`cpu_times` fallbacks honour `percpu=True` (return `[]`) for the collector child's per-CPU sampling, and the last-resort `_DegradedReading` now tolerates positional-field construction. The report's "factory resolution bypassed in the collector child" observation is consistent with the two-copy load (`sitecustomize` via PYTHONPATH plus the in-venv `.pth`) or degraded-import-time `psutil._psplatform` resolution — the fix no longer depends on psutil internals resolving, so it is robust to both.
19
+ - Added **collector suppression on degradation** to the guard: any observed psutil degradation sets a process-wide flag, after which monitors are kept inert — `start` never spawns the mozsystemmonitor collector child — and a degraded transition (any wrapped monitor method raising) or a raising `stop` best-effort terminates a live collector child (`_process.terminate()` + bounded join) and drains the parent end of the pipe, so a malformed sample stream can never fail the command or wedge shutdown even if a future shape mismatch slips through. (The upstream enabler — mozsystemmonitor warning then `break`ing out of its drain loop without draining, leaving the pipe to fill — is worth reporting to Mozilla but is not FireForge-fixable.)
20
+ - Fixed the same degradation **failing `mach build` after a fully successful compile** ("Error running mach" with complete, valid `dist/` artifacts): degraded aggregate methods now return zeroed shapes instead of `None` where callers subscript the result (`aggregate_io` → zeroed io namedtuple, so mozbuild's `log_resource_usage` no longer dies on `usage["io"].read_bytes` at `mozbuild/controller/building.py:526`; `aggregate_cpu_percent`/`aggregate_cpu_times` return `[]`/`0.0`/zeroed shapes per their `per_cpu` mode), and the guard additionally wraps `mozbuild.controller.building.BuildMonitor.log_resource_usage` to warn-and-continue on any exception. Runtime-verified by new python3-executed regression tests covering per-function fallback arity, pickle/`type(*values)` round-trips in both flapping directions, collector suppression (stub monitor with upstream-named `_process`/`_pipe`), and the BuildMonitor wrap.
21
+ - Taught the harness-crash classifier the post-success `AttributeError: 'NoneType' object has no attribute 'read_bytes'` traceback (protected builds retry it as crash-not-failure — with the fixed guard the incremental retry is cheap and green; real test failures still take precedence), and exempted the upstream drain-loop `failed to read the received data` warning from crash evidence so runs that complete despite the rejected-sample stream never classify as crashes.
16
22
 
17
23
  ## 0.33.0
18
24
 
@@ -27,14 +27,66 @@
27
27
  * `cpu_times` / `disk_io_counters` module-wide, so every call site —
28
28
  * including the direct `psutil.virtual_memory()` in
29
29
  * `mozbuild.base._run_make` — degrades to a `UserWarning` and a zeroed
30
- * reading instead of aborting;
30
+ * reading instead of aborting. Downstream report (0.34.0 cycle):
31
+ * mozsystemmonitor subscripts (`_build_meta` does
32
+ * `psutil.virtual_memory()[0]`) and iterates/unpacks the reading, so the
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;
31
50
  * - guards `SystemResourceMonitor` CONSTRUCTION via an import hook:
32
51
  * `poll_interval` is pre-populated before the real `__init__` runs, a
33
52
  * failing `__init__` marks the instance degraded instead of raising,
34
53
  * and every monitor method no-ops on a degraded instance — so a
35
54
  * partially-constructed monitor can never surface the
36
- * `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.
79
+ */
80
+ /**
81
+ * Python guard body, importable both as the in-venv `fireforge_mach_guard`
82
+ * module (loaded by the sibling `.pth` at interpreter startup) and as the
83
+ * PYTHONPATH `sitecustomize` fallback. Defensive throughout: a missing or
84
+ * broken psutil/mozsystemmonitor leaves mach untouched, and every wrapper
85
+ * only intercepts the failure path. Exported for the Python-executing
86
+ * regression test (mach-resource-shim.python.test.ts), which asserts the
87
+ * degraded readings' runtime behavior rather than string-matching the source.
37
88
  */
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";
38
90
  /** Result of installing the mach resource guard before a dispatch. */
39
91
  export interface MachResourceGuardInstallation {
40
92
  /**
@@ -28,13 +28,55 @@
28
28
  * `cpu_times` / `disk_io_counters` module-wide, so every call site —
29
29
  * including the direct `psutil.virtual_memory()` in
30
30
  * `mozbuild.base._run_make` — degrades to a `UserWarning` and a zeroed
31
- * reading instead of aborting;
31
+ * reading instead of aborting. Downstream report (0.34.0 cycle):
32
+ * mozsystemmonitor subscripts (`_build_meta` does
33
+ * `psutil.virtual_memory()[0]`) and iterates/unpacks the reading, so the
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;
32
51
  * - guards `SystemResourceMonitor` CONSTRUCTION via an import hook:
33
52
  * `poll_interval` is pre-populated before the real `__init__` runs, a
34
53
  * failing `__init__` marks the instance degraded instead of raising,
35
54
  * and every monitor method no-ops on a degraded instance — so a
36
55
  * partially-constructed monitor can never surface the
37
- * `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.
38
80
  */
39
81
  import { readdir } from 'node:fs/promises';
40
82
  import { homedir, tmpdir } from 'node:os';
@@ -51,31 +93,153 @@ const GUARD_MODULE_NAME = 'fireforge_mach_guard';
51
93
  * module (loaded by the sibling `.pth` at interpreter startup) and as the
52
94
  * PYTHONPATH `sitecustomize` fallback. Defensive throughout: a missing or
53
95
  * broken psutil/mozsystemmonitor leaves mach untouched, and every wrapper
54
- * only intercepts the failure path.
96
+ * only intercepts the failure path. Exported for the Python-executing
97
+ * regression test (mach-resource-shim.python.test.ts), which asserts the
98
+ * degraded readings' runtime behavior rather than string-matching the source.
55
99
  */
56
- const GUARD_PYTHON_SOURCE = `# Generated by FireForge - do not edit.
100
+ export const GUARD_PYTHON_SOURCE = `# Generated by FireForge - do not edit.
57
101
  # Degrades the broken host resource monitor family (psutil vs Darwin) from
58
102
  # fatal startup errors into non-fatal warnings, so mach builds and tests
59
103
  # proceed. Installed into the mach virtualenv site-packages via a .pth file
60
104
  # (survives mach's venv re-exec, which drops PYTHONPATH).
61
105
  import builtins
106
+ import collections
62
107
  import sys
63
108
  import warnings
64
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
+
65
116
 
66
117
  def _fireforge_degraded_notice(exc):
118
+ _fireforge_host_degraded[0] = True
67
119
  warnings.warn(
68
120
  "FireForge: host resource monitor degraded (%s); continuing without resource monitoring." % exc
69
121
  )
70
122
 
71
123
 
72
124
  class _DegradedReading(object):
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.
133
+ _fields = ("total", "available", "percent", "used", "free", "active", "inactive", "wired")
73
134
  total = available = used = free = active = inactive = wired = 0
74
135
  percent = 0.0
75
136
 
137
+ def __init__(self, *_args, **_kwargs):
138
+ pass
139
+
76
140
  def __getattr__(self, _name):
77
141
  return 0
78
142
 
143
+ def _values(self):
144
+ return tuple(getattr(self, _field) for _field in self._fields)
145
+
146
+ def __getitem__(self, index):
147
+ return self._values()[index]
148
+
149
+ def __iter__(self):
150
+ return iter(self._values())
151
+
152
+ def __len__(self):
153
+ return len(self._fields)
154
+
155
+ def _asdict(self):
156
+ return dict(zip(self._fields, self._values()))
157
+
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
+
187
+ # psutil result namedtuple per wrapped function, resolvable via getattr from
188
+ # the platform module / _common without invoking the failing syscall.
189
+ _PSUTIL_RESULT_CLASSES = {
190
+ "virtual_memory": "svmem",
191
+ "swap_memory": "sswap",
192
+ "cpu_times": "scputimes",
193
+ "disk_io_counters": "sdiskio",
194
+ }
195
+
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
+
207
+ def _degraded_result_factory(psutil, name):
208
+ # cpu_percent returns a plain float (or a per-CPU list) callers do
209
+ # arithmetic on.
210
+ if name == "cpu_percent":
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)
222
+ _cls_name = _PSUTIL_RESULT_CLASSES.get(name)
223
+ if _cls_name is not None:
224
+ for _mod in (getattr(psutil, "_psplatform", None), getattr(psutil, "_common", None)):
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
242
+
79
243
 
80
244
  def _guard_psutil():
81
245
  try:
@@ -83,20 +247,27 @@ def _guard_psutil():
83
247
  except Exception:
84
248
  return
85
249
 
86
- def _wrap(orig):
250
+ def _wrap(orig, fallback):
87
251
  def wrapper(*args, **kwargs):
88
252
  try:
89
253
  return orig(*args, **kwargs)
90
254
  except Exception as exc: # noqa: BLE001
91
255
  _fireforge_degraded_notice(exc)
92
- return _DegradedReading()
256
+ try:
257
+ return fallback(*args, **kwargs)
258
+ except Exception: # noqa: BLE001
259
+ return _DegradedReading()
93
260
 
94
261
  return wrapper
95
262
 
96
263
  for _name in ("virtual_memory", "swap_memory", "cpu_percent", "cpu_times", "disk_io_counters"):
97
264
  _orig = getattr(psutil, _name, None)
98
265
  if _orig is not None and not getattr(_orig, "_fireforge_guarded", False):
99
- _wrapped = _wrap(_orig)
266
+ try:
267
+ _fallback = _degraded_result_factory(psutil, _name)
268
+ except Exception: # noqa: BLE001
269
+ _fallback = _DegradedReading
270
+ _wrapped = _wrap(_orig, _fallback)
100
271
  _wrapped._fireforge_guarded = True
101
272
  setattr(psutil, _name, _wrapped)
102
273
 
@@ -116,6 +287,54 @@ _MONITOR_METHOD_NAMES = (
116
287
  )
117
288
 
118
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
+
119
338
  def _patch_monitor_class(cls):
120
339
  if cls is None or getattr(cls, "_fireforge_guarded", False):
121
340
  return
@@ -132,38 +351,81 @@ def _patch_monitor_class(cls):
132
351
  except Exception as exc: # noqa: BLE001
133
352
  self._fireforge_degraded = True
134
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)
135
360
 
136
361
  cls.__init__ = guarded_init
137
362
 
138
- def _wrap_method(orig):
363
+ def _wrap_method(name, orig):
139
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)
140
372
  if getattr(self, "_fireforge_degraded", False):
141
- return None
373
+ if name == "stop":
374
+ _fireforge_stop_collector(self)
375
+ return _monitor_degraded_result(name, args, kwargs)
142
376
  try:
143
377
  return orig(self, *args, **kwargs)
144
378
  except Exception as exc: # noqa: BLE001
145
379
  self._fireforge_degraded = True
146
380
  _fireforge_degraded_notice(exc)
147
- 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)
148
386
 
149
387
  return wrapper
150
388
 
151
389
  for _name in _MONITOR_METHOD_NAMES:
152
390
  _orig = getattr(cls, _name, None)
153
391
  if _orig is not None:
154
- setattr(cls, _name, _wrap_method(_orig))
392
+ setattr(cls, _name, _wrap_method(_name, _orig))
155
393
  cls._fireforge_guarded = True
156
394
 
157
395
 
158
- _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
+ )
159
421
 
160
422
 
161
423
  def _patch_loaded_monitor_modules():
162
- for _mod_name in _MONITOR_MODULES:
424
+ for _mod_name, _cls_name, _patcher in _MONITOR_CLASS_PATCHES:
163
425
  _mod = sys.modules.get(_mod_name)
164
426
  if _mod is not None:
165
427
  try:
166
- _patch_monitor_class(getattr(_mod, "SystemResourceMonitor", None))
428
+ _patcher(getattr(_mod, _cls_name, None))
167
429
  except Exception: # noqa: BLE001
168
430
  pass
169
431
 
@@ -56,6 +56,17 @@ const NO_OUTPUT_TIMEOUT_PATTERN = /timed out after \d+ seconds with no output/i;
56
56
  * on macOS. Each is matched per-line so the evidence line in the report is
57
57
  * the concrete failure, not the whole traceback.
58
58
  */
59
+ // Downstream report (0.34.0 cycle): a pre-fix _DegradedReading fallback that
60
+ // wasn't a real namedtuple duck type crashed mozsystemmonitor on the fallback
61
+ // itself (`_build_meta` subscripts the reading; `_collect` unpacks it); a
62
+ // startup abort with zero TEST-START lines from this family is a crash, not
63
+ // a test failure. The `_collect failed` variant is caught-and-logged, so it
64
+ // carries no Traceback header — it gets its own zero-TEST-START check in
65
+ // `detectHarnessCrashSignature` besides joining the traceback cluster.
66
+ const DEGRADED_READING_CRASH_SIGNALS = [
67
+ /'_DegradedReading' object is not subscriptable/,
68
+ /'_DegradedReading' object is not iterable/,
69
+ ];
59
70
  const STARTUP_TRACEBACK_SIGNALS = [
60
71
  /AttributeError:.*SystemResourceMonitor/,
61
72
  /'SystemResourceMonitor' object has no attribute/,
@@ -64,6 +75,14 @@ const STARTUP_TRACEBACK_SIGNALS = [
64
75
  /HOST_VM_INFO64/,
65
76
  /\(ipc\/mig\) array not large enough/,
66
77
  /psutil\.[A-Za-z]*Error/,
78
+ // Field report (0.34.1): on a degraded host every collector sample is
79
+ // rejected, aggregation has nothing, and mozbuild's log_resource_usage
80
+ // dies on usage["io"].read_bytes AFTER a fully successful compile
81
+ // ("Error running mach" with complete artifacts). Environmental, not a
82
+ // build regression — the protected build retries it (incremental retry
83
+ // is cheap and the guard keeps the retry green).
84
+ /AttributeError: 'NoneType' object has no attribute 'read_bytes'/,
85
+ ...DEGRADED_READING_CRASH_SIGNALS,
67
86
  ];
68
87
  function findLine(output, patterns) {
69
88
  for (const line of output.split(/\r?\n/)) {
@@ -83,9 +102,18 @@ function findLine(output, patterns) {
83
102
  const NOISE_LINE_PATTERNS = [
84
103
  /\bUserWarning\b/,
85
104
  /psutil failed to run/i,
86
- /_collect failed/i,
105
+ // `_collect failed` chatter is noise on green runs, EXCEPT the
106
+ // `_DegradedReading` variant: that is the crash evidence for the
107
+ // "object is not iterable" startup-abort signature above and must
108
+ // survive stripNonSignalNoise.
109
+ /_collect failed(?!.*_DegradedReading)/i,
87
110
  /FireForge: host resource monitor degraded/i,
88
111
  /warnings\.warn\(/,
112
+ // mozsystemmonitor's parent-side drain loop rejecting a malformed sample
113
+ // (field report 0.34.1); mach sometimes reprints the warning text without
114
+ // the `UserWarning:` token, and a run that completes despite the stream
115
+ // must never classify as crash on it.
116
+ /failed to read the received data/i,
89
117
  ];
90
118
  /** Matches the caught/telemetry context that marks a traceback as benign. */
91
119
  const BENIGN_TRACEBACK_CONTEXT = /telemetry|glean/i;
@@ -197,6 +225,18 @@ export function detectHarnessCrashSignature(output) {
197
225
  // the no-output timeout. A trailing "Passed: 0" summary is part of this
198
226
  // shape and must not be read as a result.
199
227
  if (!hasTestStart) {
228
+ // Startup abort on the degraded-reading fallback family: the `_collect
229
+ // failed: '_DegradedReading' ...` variant is caught-and-logged (no
230
+ // Traceback header), so the traceback cluster above never sees it.
231
+ if (realFailures.length === 0) {
232
+ const degradedLine = findLine(evidence, DEGRADED_READING_CRASH_SIGNALS);
233
+ if (degradedLine) {
234
+ return {
235
+ reason: 'degraded resource reading crashed the harness fallback (_DegradedReading)',
236
+ line: degradedLine,
237
+ };
238
+ }
239
+ }
200
240
  const timeoutLine = findLine(evidence, [NO_OUTPUT_TIMEOUT_PATTERN]);
201
241
  if (timeoutLine) {
202
242
  return { reason: 'no-output timeout before any test started', line: timeoutLine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",