diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 42be51cdd9..757340074b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -201,6 +201,17 @@ jobs: - ln882x-arduino - nrf52 - host + # Fail the job if the precompiled header silently degrades on the + # platforms where it must work (libretiny needs a toolchain bump) + include: + - id: esp8266-arduino + pch_strict: "1" + - id: esp32-idf-esp-idf + pch_strict: "1" + - id: esp32-arduino-esp-idf + pch_strict: "1" + - id: rp2040-arduino + pch_strict: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact @@ -212,6 +223,7 @@ jobs: - name: Compile ${{ matrix.id }} run: | docker run --rm \ + -e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '0' }}" \ -v "${{ github.workspace }}/docker/test_configs:/config" \ "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ compile "${{ matrix.id }}.yaml" diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index d93c1b7943..311adb29f2 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -39,6 +39,7 @@ from esphome.build_helpers.pch import ( PCH_HEADER_NAME, mark_pch_emitted, pch_checksum, + pch_degraded, pch_enabled, pch_header_text, ) @@ -1230,6 +1231,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: "A -include in build_flags prevents the precompiled header from " "loading; compiling without it" ) + pch_degraded("a user -include precedes the pch") elif pch_enabled(): # C++ src edges swap the force-includes for one precompiled prefix # header (same content plus defines.h); C/assembly keep srcflags @@ -1263,6 +1265,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: _LOGGER.warning( "Could not establish the pch identity; compiling without it: %s", err ) + pch_degraded(f"identity unknown: {err}") else: _LOGGER.info( "Compiling with a precompiled header " diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index d34d3b0029..96b663dd93 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -102,6 +102,19 @@ def pch_enabled() -> bool: return parse_enable_env("ESPHOME_PCH_ENABLE") is not False +def pch_strict() -> bool: + """CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.""" + return parse_enable_env("ESPHOME_PCH_STRICT") is True + + +def pch_degraded(reason: str) -> None: + """Every degrade path funnels through here; strict mode raises.""" + if pch_strict(): + from esphome.core import EsphomeError + + raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}") + + def ccache_pch_env() -> dict[str, str]: """Settings ccache needs to cache compiles that consume the .gch; empty unless this build actually emitted one. User-set values win. @@ -333,6 +346,7 @@ def prepare_pch( if cmd_and_dir is None: # Freshness cannot be validated; a leftover .gch must not be consumed discard_pch(build_dir) + pch_degraded("no usable compile command") return cmd, cmd_dir = cmd_and_dir # Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw @@ -359,6 +373,7 @@ def prepare_pch( "Could not establish the pch identity; compiling without it: %s", err ) discard_pch(build_dir) + pch_degraded(f"identity unknown: {err}") return if gch.is_file() and _read_stamp(sum_path) == checksum: _log_pch_in_use() @@ -369,6 +384,7 @@ def prepare_pch( "Precompiled header disabled after an earlier failure; delete %s to retry", failed_marker, ) + pch_degraded("earlier failure latched") return _log_pch_in_use() try: @@ -400,6 +416,7 @@ def prepare_pch( # Transient (timeout, spawn/IO): warn and retry next build, no marker _LOGGER.warning("Precompiled header compile did not run: %s", err) discard_pch(build_dir) + pch_degraded(f"compile did not run: {err}") return if error is not None: _LOGGER.warning( @@ -410,10 +427,12 @@ def prepare_pch( discard_pch(build_dir) if any(m in error for m in _TRANSIENT_ERRORS): # Resource exhaustion clears on its own; retry next build + pch_degraded(f"transient compile failure: {error[:200]}") return # Skip retries until a header/flag/backend-identity/command change failed_marker.write_text(checksum + "\n", encoding="utf-8") os.utime(header) + pch_degraded(f"compile failed: {error[:200]}") return failed_marker.unlink(missing_ok=True) sum_path.write_text(checksum + "\n", encoding="utf-8") diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 64f1729050..505ba1c74a 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -158,7 +158,7 @@ def _read_stamp(path: Path) -> str: return "" -def _setup_pch() -> None: +def _setup_pch() -> bool | None: if projenv is None: # Expected under -t nobuild; anything else must leave a trail print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch") @@ -355,10 +355,20 @@ def _setup_pch() -> None: ) projenv["ENV"].update(ccache_updates) # noqa: F821 print("ESPHome: Compiling with precompiled header") + return True +_strict = os.environ.get("ESPHOME_PCH_STRICT", "").strip().lower() in ( + "1", + "true", + "yes", + "on", +) try: - _setup_pch() + if not _setup_pch() and _strict: + raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used") except Exception: # noqa: BLE001 -- a speedup must never break the build + if _strict: + raise print("ESPHome: pch internal error; compiling without it") traceback.print_exc() diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index dd0d9f5d40..7d3928c4d8 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1842,3 +1842,17 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() ) assert sums[0] == sums[1] + + +def test_write_project_pch_strict_raises_on_skip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from esphome.core import EsphomeError + + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h" + ) + with pytest.raises(EsphomeError, match="precedes the pch"): + _write_ninja(paths, ccache="/usr/bin/ccache") diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index dca97c5fde..c0286444b1 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -1078,3 +1078,22 @@ def test_prepare_pch_transient_compiler_failure_does_not_latch( prepare_pch() assert not (dev / "build" / "esphome_pch.h.gch.failed").exists() assert len(calls) == 2 + + +def test_prepare_pch_strict_raises_on_missing_db( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ESPHOME_PCH_STRICT turns the silent skip into a failure.""" + from esphome.build_gen.espidf import prepare_pch + from esphome.core import EsphomeError + + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + dev = _make_pch_device(tmp_path, "dev_st") + (dev / "build" / "compile_commands.json").unlink() + CORE.build_path = dev + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError), + pytest.raises(EsphomeError, match="no usable compile command"), + ): + prepare_pch() diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 010c93bb66..1d8423eb1c 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -216,3 +216,29 @@ def test_ccache_pch_env_warns_on_falsy_extsum( env = pch.ccache_pch_env() assert "CCACHE_PCH_EXTSUM" not in env assert "disables pch caching" in caplog.text + + +@pytest.mark.parametrize( + ("value", "expected"), + [(None, False), ("0", False), ("1", True), ("true", True)], +) +def test_pch_strict( + value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + if value is None: + monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False) + else: + monkeypatch.setenv("ESPHOME_PCH_STRICT", value) + assert pch.pch_strict() is expected + + +def test_pch_degraded_raises_only_in_strict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from esphome.core import EsphomeError + + monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False) + pch.pch_degraded("reason") + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + with pytest.raises(EsphomeError, match="reason"): + pch.pch_degraded("reason") diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index ab6408f0ed..60c07bdb9d 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -458,3 +458,14 @@ def test_pch_script_unreadable_local_header_skips_pch( assert not (proj / "esphome_pch.h.gch.sum").exists() assert scons_env.prepended == [] assert "skipping precompiled header" in capsys.readouterr().out + + +def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None: + """ESPHOME_PCH_STRICT fails the build instead of degrading.""" + with pytest.raises(Exception, match="ESPHOME_PCH_STRICT|boom"): + _run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"}) + + +def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None: + scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}) + assert scons_env.prepended