From d38542e48be4dd39fbf5fa89293dd544e45f5d8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Aug 2026 00:40:37 -0500 Subject: [PATCH] Escalate all strict consumers, keep cached-probe rejections unlatched, drop the dead probe knob --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/espidf.py | 3 ++ esphome/build_helpers/pch.py | 35 +++++++++++-------- esphome/platformio/pch.py.script | 8 ++++- tests/unit_tests/build_gen/test_espidf.py | 8 +++++ .../unit_tests/test_platformio_pch_script.py | 4 ++- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index aabdaaaf4f..c967798a4e 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1303,7 +1303,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if pch_strict(): # Consumers wait on the probe stamp, so an unloadable .gch # reds the build here instead of warning ~100 times - probe = " ".join(pch_probe_args(PCH_HEADER_NAME, fatal=True)) + probe = " ".join(pch_probe_args(PCH_HEADER_NAME)) lines.append("rule pchprobe") # $out only expands in rule text, hence the inline stamp lines.append( diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 132ec29df7..dc9f3830ed 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -298,12 +298,15 @@ def _pch_cmake() -> str: """ if not pch_enabled(): return "" + # Strict inverts: a per-process consumer rejection reds the build + escalation = "-Werror=invalid-pch" if pch.pch_strict() else "-Wno-error=invalid-pch" return f""" # ESPHome precompiled header (see esphome/build_helpers/pch.py). # OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop # out of TU depfiles, and prepare_pch() touches the header on rebuild. target_compile_options(${{COMPONENT_LIB}} PRIVATE "$<$:-Winvalid-pch>" + "$<$:{escalation}>" "$<$:-include>" "$<$:{PCH_HEADER_NAME}>" ) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 5a57baaecc..e1814690df 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -120,15 +120,15 @@ def pch_disabled_degraded() -> None: pch_degraded("pch disabled by ESPHOME_PCH_ENABLE") -def pch_probe_args(header: str, fatal: bool = False) -> list[str]: +def pch_probe_args(header: str) -> list[str]: """Flags that load-check a built .gch via a syntax-only compile. - ``fatal`` escalates a rejected pch to an error for consumers that - cannot inspect stderr (the ninja probe edge). + Rejection must be a nonzero exit (never just a wording match), so the + invalid-pch class is always escalated. """ return [ "-Winvalid-pch", - *(["-Werror=invalid-pch"] if fatal else []), + "-Werror=invalid-pch", "-include", header, "-fsyntax-only", @@ -452,30 +452,37 @@ def prepare_pch( os.utime(header) pch_degraded(f"{reason}: {error[:200]}") - def _probe() -> None: + def _probe(latch: bool = True) -> None: """Load-check the built .gch: some toolchains build one they then refuse to load (per-process ASLR). Dep flags are already stripped from cmd, so no -MF is needed; cmd ends with the fixed - "-x c++-header -c -o" tail.""" + "-x c++-header -c -o" tail. A cached-header rejection may not + reproduce (per-process), so that caller passes latch=False.""" # The fixed tail pch_compile_command appends; the slice below # depends on it assert cmd[-6:-4] == ["-x", "c++-header"], cmd[-6:] - # fatal: rejection must be a nonzero exit, not a wording match - probe = _run([*cmd[:-6], *pch_probe_args(str(header), fatal=True)], "probe") + probe = _run([*cmd[:-6], *pch_probe_args(str(header))], "probe") if probe is None: return if probe.returncode != 0 or ".gch" in probe.stderr: - _fail( - probe.stderr.strip() or f"exit code {probe.returncode}", - "toolchain cannot load the pch", - ) + error = probe.stderr.strip() or f"exit code {probe.returncode}" + if latch: + _fail(error, "toolchain cannot load the pch") + else: + _LOGGER.warning( + "Precompiled header failed; compiling without it: %s", + error[:400], + ) + discard_pch(build_dir) + pch_degraded(f"toolchain cannot load the pch: {error[:200]}") if gch.is_file() and _read_stamp(sum_path) == checksum: _log_pch_in_use() if pch_strict(): # Rejection is per-process, so a cached .gch must re-prove - # loadability for the strict gate (CI-only cost) - _probe() + # loadability for the strict gate (CI-only cost); no latch, + # since the rejection may not reproduce either + _probe(latch=False) return if _read_stamp(failed_marker) == checksum: _LOGGER.info( diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 12760615b7..d9eb389ae9 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -383,7 +383,13 @@ def _setup_pch() -> bool | None: # -Wno-error: the per-process probe can pass while a later cc1plus # rejects the .gch; that must stay a warning under user -Werror. projenv.Prepend( # noqa: F821 - CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name] + CXXFLAGS=[ + "-Winvalid-pch", + # Strict inverts: a per-process consumer rejection reds the build + "-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch", + "-include", + header.name, + ] ) projenv["ENV"].update(ccache_updates) # noqa: F821 print("ESPHome: Compiling with precompiled header") diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index d577034597..e06ed6c428 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -604,6 +604,11 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None content = get_component_cmakelists() assert '"$<$:-include>"' in content + assert "-Wno-error=invalid-pch" in content + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + strict_content = get_component_cmakelists() + assert "-Werror=invalid-pch" in strict_content + monkeypatch.delenv("ESPHOME_PCH_STRICT") assert '"$<$:esphome_pch.h>"' in content monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") assert "-include" not in get_component_cmakelists() @@ -1296,3 +1301,6 @@ def test_prepare_pch_strict_reprobes_cached_gch( ): prepare_pch() assert not gch.exists() + # Per-process rejection may not reproduce: the cached path must not + # latch the pch off for later non-strict builds + assert not (dev / "build" / "esphome_pch.h.gch.failed").exists() diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index afd64b32d6..4287ddd511 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -542,4 +542,6 @@ def test_pch_script_strict_projenv_skip_gated_on_nobuild( 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 + # Strict escalates the consumer edges too + assert "-Werror=invalid-pch" in scons_env.prepended + assert "-Wno-error=invalid-pch" not in scons_env.prepended