diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 3980e5867c..b839d35f0e 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -221,8 +221,11 @@ def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None: raise header = build_dir / PCH_HEADER_NAME if not header.exists(): - with suppress(OSError): + try: header.touch() + except OSError as err: + # The coming OBJECT_DEPENDS error would hide the real cause + _LOGGER.warning("Could not create the pch placeholder: %s", err) _LOGGER.warning( "Precompiled header setup failed; compiling without it", exc_info=True ) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 4cbd37a8d2..42d4946306 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import shutil +import stat import subprocess from esphome import pins @@ -838,9 +839,12 @@ def _app_build_dir(build_dir: Path) -> Path: non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays truthful mid-build, unlike an SDK-version check.""" sysbuild_app = build_dir / "zephyr" - if (sysbuild_app / "CMakeCache.txt").is_file(): - return sysbuild_app - return build_dir + try: + cache = (sysbuild_app / "CMakeCache.txt").stat() + except (FileNotFoundError, NotADirectoryError): + return build_dir + # Other stat errors propagate; is_file() would silently mislocate the pch + return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir def _prepare_pch(app_dir: Path) -> None: @@ -976,7 +980,12 @@ def run_compile(args, config: ConfigType) -> bool: stream_output=True, cwd=str(paths["framework_path"]), ): - raise EsphomeError("nRF52 Zephyr header generation failed") + # A pch-only prerequisite: degrade, let the real build report + _LOGGER.warning( + "Zephyr header generation failed; compiling without the pch" + ) + pch.discard_pch(app_dir) + pch.pch_degraded("zephyr_generated_headers failed") else: app_dir = _app_build_dir(build_dir) diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index c6e04bf1d0..02d0ae31cc 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -249,6 +249,7 @@ def test_pch_cmake_consumer_substitutes_target_and_sources( def test_pch_cmake_consumer_strict_escalates( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False) monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}") diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py index 77e92f8d95..643917ed08 100644 --- a/tests/unit_tests/components/nrf52/test_pch.py +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -98,6 +98,20 @@ def test_app_build_dir_top_level_layout(build_dir: Path) -> None: assert nrf52._app_build_dir(build_dir) == build_dir +def test_app_build_dir_ignores_cache_directory(build_dir: Path) -> None: + (build_dir / "zephyr" / "CMakeCache.txt").mkdir(parents=True) + assert nrf52._app_build_dir(build_dir) == build_dir + + +def test_app_build_dir_propagates_stat_errors(build_dir: Path) -> None: + # is_file() would swallow this and mislocate the pch + with ( + patch.object(Path, "stat", side_effect=PermissionError("denied")), + pytest.raises(PermissionError), + ): + nrf52._app_build_dir(build_dir) + + def test_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None: _write_autoconf(build_dir) with ( @@ -203,10 +217,24 @@ class TestRunCompilePhases: # The pch is prepared in the app domain dir, not the sysbuild root assert prepare.call_args.args[0] == build_dir / "zephyr" - def test_generated_headers_failure_raises(self, compile_ctx) -> None: + def test_generated_headers_failure_degrades( + self, compile_ctx, caplog: pytest.LogCaptureFixture + ) -> None: + run_cmd, prepare, _ = compile_ctx + # headers target fails, the real build still runs (and fails here) + run_cmd.side_effect = [True, False, False] + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + self._run() + assert "Zephyr header generation failed" in caplog.text + assert prepare.called + + def test_generated_headers_failure_strict_raises( + self, monkeypatch: pytest.MonkeyPatch, compile_ctx + ) -> None: + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") run_cmd, prepare, _ = compile_ctx run_cmd.side_effect = [True, False] - with pytest.raises(EsphomeError, match="header generation failed"): + with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"): self._run() assert not prepare.called