From f4cb71ba4288a633a41e3f26c1eaf177dcdec0dd Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:09:14 -0300 Subject: [PATCH 1/2] fix(network): degrade instead of crashing on profiler timeout system_profiler routinely exceeds 5s on a cold call; the uncaught TimeoutExpired tore down mm, mm net and mm watch. Collectors now swallow timeout/missing-binary the same way security._run already does. --- CHANGELOG.md | 4 +++ macmanager/network.py | 34 +++++++++++------- tests/test_network.py | 81 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8c95e..bdd3219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **`mm` / `mm net` / `mm watch` no longer crash** when + `system_profiler SPAirPortDataType` exceeds its 5s timeout, or when + `ipconfig` is missing. Network collection now degrades to empty Wi-Fi + / no local IP — the same pattern `security._run` already used. - **Caching bug discovered by the new tests**: `cache.cached` and `cache.peek` promised to fall back to a plain function call when called with unhashable arguments (e.g. a `dict`), but only diff --git a/macmanager/network.py b/macmanager/network.py index 8dd922b..8fc3fe0 100644 --- a/macmanager/network.py +++ b/macmanager/network.py @@ -30,13 +30,27 @@ class NetworkInfo: security: Optional[str] +def _run(cmd: list[str], timeout: int = 5) -> str: + """Return stdout. Timeouts, missing binaries and OS errors become "". + + Callers degrade to "unknown" instead of crashing `mm` / `mm net` / + `mm watch`. Mirrors `macmanager.security._run`. + """ + try: + p = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + return p.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return "" + + def _local_ip(iface: str = "en0") -> Optional[str]: - out = subprocess.run( - ["ipconfig", "getifaddr", iface], - capture_output=True, - text=True, - check=False, - ).stdout.strip() + out = _run(["ipconfig", "getifaddr", iface], timeout=2).strip() return out or None @@ -57,13 +71,7 @@ def _wifi_info() -> dict: (no sudo).""" data: dict = {} - out = subprocess.run( - ["/usr/sbin/system_profiler", "SPAirPortDataType"], - capture_output=True, - text=True, - check=False, - timeout=5, - ).stdout + out = _run(["/usr/sbin/system_profiler", "SPAirPortDataType"], timeout=5) cur = re.search(r"Current Network Information:\s*\n\s*([^\n]+):", out) if cur: diff --git a/tests/test_network.py b/tests/test_network.py index 81077ff..84cf5aa 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,9 +2,14 @@ from __future__ import annotations +import subprocess +from collections.abc import Iterator +from types import SimpleNamespace + import pytest -from macmanager.network import _signal_quality +from macmanager.cache import clear_all +from macmanager.network import _local_ip, _run, _signal_quality, _wifi_info class TestSignalQuality: @@ -42,3 +47,77 @@ def test_positive_rssi_is_treated_as_excellent(self) -> None: # doesn't special-case positive values — they trivially satisfy # `>= -55`. assert _signal_quality(0) == ("Excellent", "green") + + +class TestRun: + """`_run` is the crash boundary for every network shell-out. A timeout + or a missing binary must become an empty string — never an exception + that tears down `mm` / `mm net` / `mm watch`.""" + + def test_returns_stdout_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.network.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="192.168.1.10\n"), + ) + assert _run(["ipconfig", "getifaddr", "en0"]) == "192.168.1.10\n" + + @pytest.mark.parametrize( + "exc", + [ + subprocess.TimeoutExpired(cmd="system_profiler", timeout=5), + FileNotFoundError("ipconfig"), + OSError("permission denied"), + ], + ) + def test_swallows_expected_failures( + self, monkeypatch: pytest.MonkeyPatch, exc: Exception + ) -> None: + def boom(*args: object, **kwargs: object) -> None: + raise exc + + monkeypatch.setattr("macmanager.network.subprocess.run", boom) + assert _run(["/usr/sbin/system_profiler", "SPAirPortDataType"]) == "" + + +class TestLocalIp: + def test_empty_run_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "") + assert _local_ip("en0") is None + + def test_strips_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: " 10.0.0.4\n") + assert _local_ip("en0") == "10.0.0.4" + + +class TestWifiInfoDegrades: + """`system_profiler` routinely exceeds 5s on a cold call. The collector + must return an empty dict so the panel renders '—' instead of a + traceback.""" + + @pytest.fixture(autouse=True) + def _clear_wifi_cache(self) -> Iterator[None]: + clear_all() + yield + clear_all() + + def test_empty_profiler_output_returns_empty_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "") + assert _wifi_info() == {} + + def test_parses_ssid_from_profiler_block(self, monkeypatch: pytest.MonkeyPatch) -> None: + sample = ( + "Current Network Information:\n" + " HomeNet:\n" + " Security: WPA3 Personal\n" + " Channel: 36 (5GHz, 80MHz)\n" + " Signal / Noise: -55 dBm / -92 dBm\n" + " Transmit Rate: 1200\n" + ) + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: sample) + info = _wifi_info() + assert info["ssid"] == "HomeNet" + assert info["security"] == "WPA3 Personal" + assert info["rssi"] == -55 + assert info["tx_rate"] == "1200" From 616b10c7e1d40687e55fec6822ef381f6608919f Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:19:46 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(network):=20traduzir=20coment=C3=A1rio?= =?UTF-8?q?s=20e=20changelog=20do=20PR=20para=20portugu=C3=AAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O texto visível no GitHub (diff, changelog e descrição do PR) fica em português para manter o padrão do repositório daqui pra frente. --- CHANGELOG.md | 8 ++++---- macmanager/network.py | 6 +++--- tests/test_network.py | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd3219..9c80b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,10 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed -- **`mm` / `mm net` / `mm watch` no longer crash** when - `system_profiler SPAirPortDataType` exceeds its 5s timeout, or when - `ipconfig` is missing. Network collection now degrades to empty Wi-Fi - / no local IP — the same pattern `security._run` already used. +- **`mm` / `mm net` / `mm watch` não crasham mais** quando + `system_profiler SPAirPortDataType` passa do timeout de 5s, ou quando + `ipconfig` não existe. A coleta de rede degrada para Wi-Fi vazio + / sem IP local — o mesmo padrão que `security._run` já usava. - **Caching bug discovered by the new tests**: `cache.cached` and `cache.peek` promised to fall back to a plain function call when called with unhashable arguments (e.g. a `dict`), but only diff --git a/macmanager/network.py b/macmanager/network.py index 8fc3fe0..bd74008 100644 --- a/macmanager/network.py +++ b/macmanager/network.py @@ -31,10 +31,10 @@ class NetworkInfo: def _run(cmd: list[str], timeout: int = 5) -> str: - """Return stdout. Timeouts, missing binaries and OS errors become "". + """Devolve o stdout. Timeout, binário ausente e erro de OS viram "". - Callers degrade to "unknown" instead of crashing `mm` / `mm net` / - `mm watch`. Mirrors `macmanager.security._run`. + Quem chama degrada para "desconhecido" em vez de derrubar `mm` / + `mm net` / `mm watch`. Espelha `macmanager.security._run`. """ try: p = subprocess.run( diff --git a/tests/test_network.py b/tests/test_network.py index 84cf5aa..392f71b 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -50,9 +50,9 @@ def test_positive_rssi_is_treated_as_excellent(self) -> None: class TestRun: - """`_run` is the crash boundary for every network shell-out. A timeout - or a missing binary must become an empty string — never an exception - that tears down `mm` / `mm net` / `mm watch`.""" + """`_run` é a fronteira de crash de todo shell-out de rede. Timeout + ou binário ausente tem que virar string vazia — nunca uma exceção + que derrube `mm` / `mm net` / `mm watch`.""" def test_returns_stdout_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( @@ -90,9 +90,9 @@ def test_strips_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None: class TestWifiInfoDegrades: - """`system_profiler` routinely exceeds 5s on a cold call. The collector - must return an empty dict so the panel renders '—' instead of a - traceback.""" + """`system_profiler` passa de 5s com frequência na primeira chamada. + O coletor tem que devolver dict vazio para o painel mostrar '—' + em vez de traceback.""" @pytest.fixture(autouse=True) def _clear_wifi_cache(self) -> Iterator[None]: