From 4a3b095172ffd9ad346d2dc3d2a6ddabd79072cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Aug 2026 22:47:42 -0500 Subject: [PATCH] Probe the built .gch on the native paths so strict CI reds on an unloadable pch --- esphome/build_gen/arduino8266.py | 23 ++++++++++- esphome/build_helpers/pch.py | 36 ++++++++++++++++ .../unit_tests/build_gen/test_arduino8266.py | 18 ++++++++ tests/unit_tests/build_gen/test_espidf.py | 41 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 311adb29f2..cbe7fec81f 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -42,6 +42,7 @@ from esphome.build_helpers.pch import ( pch_degraded, pch_enabled, pch_header_text, + pch_strict, ) from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import ( @@ -1290,7 +1291,27 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: f"-Winvalid-pch -Wno-error=invalid-pch -include {PCH_HEADER_NAME}" ] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") - src_cxx_override = ("$srccxxflags", gch) + pch_dep = gch + if pch_strict(): + # Consumers wait on the probe stamp, so an unloadable .gch + # reds the build here instead of warning ~100 times + lines.append("rule pchprobe") + lines.append( + " command = $cxx $cxxflags $flags -Winvalid-pch" + " -Werror=invalid-pch" + f" -include {PCH_HEADER_NAME} -fsyntax-only -x c++" + f" {_q(Path(os.devnull))} && $stamp" + ) + lines.append(" description = PCHPROBE $out") + lines.append(f"build esphome_pch.probe: pchprobe {gch}") + if src_other: + lines.append(f" flags = {' '.join(src_other)}") + stamp = ( + "cmd /c copy /y nul $out >nul" if os.name == "nt" else "touch $out" + ) + lines.append(f" stamp = {stamp}") + pch_dep = f"{gch} esphome_pch.probe" + src_cxx_override = ("$srccxxflags", pch_dep) mark_pch_emitted() src_objs = _ninja_compile_edges( lines, diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 59dc8165f6..233bd43cb9 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -435,6 +435,42 @@ def prepare_pch( os.utime(header) pch_degraded(f"compile failed: {error[:200]}") return + # Load probe: some toolchains build a .gch they then refuse to load + # (per-process ASLR); dep flags are already stripped from cmd, so no + # -MF is needed + probe_cmd = [ + *cmd[: cmd.index("-x")], + "-Winvalid-pch", + "-include", + str(header), + "-fsyntax-only", + "-x", + "c++", + os.devnull, + ] + try: + probe = subprocess.run( + probe_cmd, + cwd=cmd_dir, + env={**os.environ, "LC_ALL": "C"}, + capture_output=True, + text=True, + check=False, + timeout=300, + ) + except (OSError, subprocess.SubprocessError) as err: + _LOGGER.warning("Precompiled header probe did not run: %s", err) + discard_pch(build_dir) + pch_degraded(f"probe did not run: {err}") + return + if probe.returncode != 0 or ".gch" in probe.stderr: + error = f"toolchain cannot load the pch: {probe.stderr.strip()[:400]}" + _LOGGER.warning("Precompiled header failed; compiling without it: %s", error) + discard_pch(build_dir) + failed_marker.write_text(checksum + "\n", encoding="utf-8") + os.utime(header) + pch_degraded(error) + return failed_marker.unlink(missing_ok=True) sum_path.write_text(checksum + "\n", encoding="utf-8") # Consumers depend on the header (depfiles cannot see through a .gch); diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 7d3928c4d8..e220ddd89d 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1856,3 +1856,21 @@ def test_write_project_pch_strict_raises_on_skip( ) with pytest.raises(EsphomeError, match="precedes the pch"): _write_ninja(paths, ccache="/usr/bin/ccache") + + +def test_write_project_pch_strict_emits_probe_edge( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Strict mode gates C++ src edges on a hard-failing load probe.""" + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "pchprobe" not in content + + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "build esphome_pch.probe: pchprobe esphome_pch.h.gch" in content + assert "-Werror=invalid-pch" in content + for line in content.splitlines(): + if line.startswith("build obj/src/main.cpp.o:"): + assert line.endswith("| esphome_pch.h.gch esphome_pch.probe") diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index c0286444b1..450a2b673c 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -550,6 +550,9 @@ def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None: gch = dev / "build" / "esphome_pch.h.gch" def fake_compile(cmd, **kwargs): + if "-fsyntax-only" in cmd: + # The load probe follows a successful .gch build + return subprocess.CompletedProcess(cmd, 0, "", "") # The compile must target the header, not the stub TU assert cmd[-5:-3] == ["c++-header", "-c"] gch.write_bytes(b"gch") @@ -1097,3 +1100,41 @@ def test_prepare_pch_strict_raises_on_missing_db( pytest.raises(EsphomeError, match="no usable compile command"), ): prepare_pch() + + +def test_prepare_pch_probe_rejection_latches_and_degrades( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A toolchain that cannot load its own .gch discards it, latches the + marker, and fails strict mode.""" + from esphome.build_gen.espidf import prepare_pch + from esphome.core import EsphomeError + + dev = _make_pch_device(tmp_path, "dev_p") + CORE.build_path = dev + gch = dev / "build" / "esphome_pch.h.gch" + + def rejecting(cmd, **kwargs): + if "-fsyntax-only" in cmd: + return subprocess.CompletedProcess( + cmd, 0, "", "warning: esphome_pch.h.gch: had text segment " + ) + gch.write_bytes(b"gch") + return subprocess.CompletedProcess(cmd, 0, "", "") + + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting), + ): + prepare_pch() + assert not gch.exists() + assert (dev / "build" / "esphome_pch.h.gch.failed").exists() + + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + (dev / "build" / "esphome_pch.h.gch.failed").unlink() + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting), + pytest.raises(EsphomeError, match="cannot load the pch"), + ): + prepare_pch()