From c72de504fd75e64368204c24825fa61b50af001f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 14:43:48 -0500 Subject: [PATCH] Hard install lock, ninja-first install order, enforced floor, backoff, and by-name host failures everywhere --- esphome/arduino8266/framework.py | 31 ++++++++++++++++--- .../unit_tests/test_arduino8266_framework.py | 27 ++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 8d828dc936..5c2d9f4821 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -24,6 +24,7 @@ import os from pathlib import Path import platform import shutil +import time from esphome.core import EsphomeError, Version from esphome.framework_helpers import ( @@ -103,9 +104,15 @@ def _pio_system() -> str: sysname = platform.system().lower() machine = platform.machine().lower() if sysname == "darwin": - return "darwin_arm64" if machine == "arm64" else "darwin_x86_64" + if machine == "arm64": + return "darwin_arm64" + if machine == "x86_64": + return "darwin_x86_64" if sysname == "windows": - return "windows_amd64" if machine in ("amd64", "arm64") else "windows_x86" + if machine in ("amd64", "arm64"): + return "windows_amd64" + if machine in ("x86", "i686", "i386"): + return "windows_x86" if sysname == "linux": if machine in ("arm64", "aarch64"): return "linux_aarch64" @@ -129,7 +136,7 @@ def _registry_download(package: str, version: str) -> tuple[str, str, int | None url = _REGISTRY_URL.format(package=package) last_err: Exception | None = None - for _ in range(3): + for attempt in range(3): try: resp = requests.get(url, timeout=30) resp.raise_for_status() @@ -137,6 +144,8 @@ def _registry_download(package: str, version: str) -> tuple[str, str, int | None break except requests.RequestException as err: last_err = err + # Back off so the retries are not one burst against a hiccup + time.sleep(2**attempt) else: # A clean, retried error like the other download paths in the tree raise EsphomeError( @@ -186,7 +195,10 @@ def _install_package( # process cannot wipe the directory another is extracting into (same # filelock pattern as platformio/toolchain.py and git.py). dest.parent.mkdir(parents=True, exist_ok=True) - with FileLock(f"{dest}.lock"): + # fallback_to_soft would silently degrade to an existence lock on a + # flock-less filesystem; a hard-killed run would then hang every later + # build forever (same hazard git.py documents). + with FileLock(f"{dest}.lock", fallback_to_soft=False): if marker.is_file(): # Another process finished the install while we waited return @@ -248,6 +260,15 @@ def _find_ninja() -> Path: def check_and_install(framework_version: Version) -> dict[str, Path]: """Ensure framework, toolchain, and ninja are installed; return their paths.""" + if framework_version < MIN_FRAMEWORK_VERSION: + # Config validation enforces this too; keep the module honest when + # called directly. + raise EsphomeError( + f"The native toolchain requires the Arduino core " + f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}" + ) + # Probe the cheap local dependency before ~110 MB of downloads + ninja_path = _find_ninja() package_version = framework_package_version(framework_version) framework_path = get_framework_path(package_version) _install_package( @@ -268,7 +289,7 @@ def check_and_install(framework_version: Version) -> dict[str, Path]: return { "framework_path": framework_path, "toolchain_path": toolchain_path, - "ninja_path": _find_ninja(), + "ninja_path": ninja_path, } diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 7602674c16..3ca9e70ee2 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -64,6 +64,9 @@ def test_pio_system(system: str, machine: str, expected: str) -> None: [ ("FreeBSD", "amd64"), ("Linux", "ppc64le"), + ("Darwin", "ppc"), + ("Darwin", ""), + ("Windows", "ia64"), ], ) def test_pio_system_unsupported_host_raises(system: str, machine: str) -> None: @@ -88,10 +91,13 @@ def test_registry_download_network_error_is_clean_and_retried() -> None: with ( patch("requests.get", side_effect=requests.ConnectionError("boom")) as mock_get, + patch.object(framework.time, "sleep") as mock_sleep, pytest.raises(EsphomeError, match="Could not query the package registry"), ): framework._registry_download("pkg", "1.0.0") assert mock_get.call_count == 3 + # Backed-off retries, not one burst + assert mock_sleep.call_count == 3 def test_registry_download_retries_transient_error() -> None: @@ -109,6 +115,7 @@ def test_registry_download_retries_transient_error() -> None: ) with ( patch("requests.get", side_effect=[requests.ConnectionError("boom"), resp]), + patch.object(framework.time, "sleep"), patch.object(framework, "_pio_system", return_value="linux_x86_64"), ): assert framework._registry_download("pkg", "1.0.0") == ( @@ -439,3 +446,23 @@ def test_ccache_env_requires_build_path() -> None: pytest.raises(ValueError, match="build_path"), ): framework.ccache_env() + + +def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: + """Calling the installer below the floor fails before any download.""" + with pytest.raises(EsphomeError, match=">= 3.1.1"): + framework.check_and_install(cv.Version(3, 0, 2)) + + +def test_install_package_uses_hard_lock(tmp_path: Path) -> None: + """The install lock must never degrade to a soft (existence) lock.""" + dest = tmp_path / "pkg" + with ( + patch("filelock.FileLock") as mock_lock, + patch.object(framework, "download_from_mirrors"), + patch.object(framework, "archive_extract_all") as mock_extract, + patch.object(framework, "_pio_system", return_value="linux_x86_64"), + ): + mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir(exist_ok=True) + framework._install_package("pkg", "1.0.0", dest, ["http://m"]) + assert mock_lock.call_args.kwargs["fallback_to_soft"] is False