From dc45cd4d3f713b0f29b77a38c436848467be9424 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:27:53 -0500 Subject: [PATCH 01/15] Probe that the toolchain can load the gch before enabling the pch --- esphome/platformio/pch.py.script | 26 +++++++++++++++++++ .../unit_tests/test_platformio_pch_script.py | 11 ++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 03f6567285..bb7d35708b 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -167,6 +167,32 @@ def _setup_pch() -> None: error = result.stderr if result.returncode != 0 else None except OSError as err: error = str(err) + if error is None: + # Some toolchains build a .gch they cannot load back (GCC 10 on + # macOS arm64 rejects it per-process: "had text segment at + # different address"); probe once so consumers never pay for a + # pch that every compile would silently reject + probe = subprocess.run( # noqa: PLW1510 + [ + cxx, + *flags, + "-MF", + os.devnull, + "-Winvalid-pch", + "-include", + str(header), + "-fsyntax-only", + "-x", + "c++", + "-", + ], + cwd=proj_dir, + input="", + capture_output=True, + text=True, + ) + if ".gch" in probe.stderr: + error = f"toolchain cannot load the pch: {probe.stderr.strip()}" if error is not None: print("ESPHome: precompiled header failed; compiling without it") print(error) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 2ff162b565..ae3ad91099 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -56,7 +56,7 @@ class _FakeSConsEnv(dict): def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" - body = 'printf \'%s\\n\' "$@" >> "$0.argv"\n' + body = 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' if fail: body += "echo boom >&2\nexit 1\n" else: @@ -109,10 +109,11 @@ def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: spaced.mkdir() flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"] _run_script(tmp_path, flags=flags) - argv = (tmp_path / "fake-gxx.argv").read_text().splitlines() - assert '-DUSB_PRODUCT="Pico 2W"' in argv - assert str(spaced) in argv - assert "-include" not in argv + calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n") + gch_call = next(c for c in calls if "c++-header" in c).splitlines() + assert '-DUSB_PRODUCT="Pico 2W"' in gch_call + assert str(spaced) in gch_call + assert "-include" not in gch_call # The stripped -include header is folded into the prefix header instead pch = (tmp_path / "dev" / "esphome_pch.h").read_text() assert pch.splitlines()[0] == '#include "other.h"' From 18eb05177b41ab85bcaf9e0373ea50b58787f5cb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:30:53 +0000 Subject: [PATCH 02/15] apply automatic formatting fixes --- tests/unit_tests/test_platformio_pch_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index ae3ad91099..8edb6be397 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -56,7 +56,9 @@ class _FakeSConsEnv(dict): def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" - body = 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + body = ( + 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + ) if fail: body += "echo boom >&2\nexit 1\n" else: From f198964bc554a12a1bfeb3d6a3516943696b60bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:32:46 +0000 Subject: [PATCH 03/15] apply automatic formatting fixes --- tests/unit_tests/test_platformio_pch_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index ae3ad91099..8edb6be397 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -56,7 +56,9 @@ class _FakeSConsEnv(dict): def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" - body = 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + body = ( + 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + ) if fail: body += "echo boom >&2\nexit 1\n" else: From d08c524f19e35190c10477dee986977c2ef0fb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:36:41 -0500 Subject: [PATCH 04/15] Harden the pch script for Windows paths and make ignored pchs visible --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_helpers/ccache.py | 6 +++- esphome/build_helpers/pch.py | 4 ++- esphome/platformio/pch.py.script | 34 +++++++++++++------ esphome/writer.py | 11 ++++++ .../unit_tests/build_gen/test_arduino8266.py | 2 +- .../unit_tests/test_platformio_pch_script.py | 2 +- 7 files changed, 45 insertions(+), 16 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 1f20ec7b06..1311152bde 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1254,7 +1254,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # Relative -include (resolved from the ninja cwd, where the header # lives): an absolute path would put the per-device build path on # every compile command and defeat cross-device ccache sharing - cxx_parts = src_other + [f"-include {PCH_HEADER_NAME}"] + cxx_parts = src_other + [f"-Winvalid-pch -include {PCH_HEADER_NAME}"] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") src_cxx_flags = "$srccxxflags" src_cxx_implicit = gch diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 99d1bbc111..cee0774561 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -97,4 +97,8 @@ def effective_ccache_basedir() -> str: wins, else the resolved build path (matching ccache_defaults_env).""" from esphome.core import CORE - return os.environ.get("CCACHE_BASEDIR") or str(Path(CORE.build_path).resolve()) + raw = os.environ.get("CCACHE_BASEDIR") + if raw is not None: + # An explicitly empty value disables ccache's rewriting; mirror it + return raw + return str(Path(CORE.build_path).resolve()) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 10761cdded..0b691c06a4 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -2,7 +2,9 @@ Safe by construction when the prefix header mirrors what the TUs already include first (ESP8266); a backend may instead inject a curated set of -self-contained core headers (ESP-IDF). +self-contained core headers (ESP-IDF). User sources from ``esphome: +includes:`` also receive the prefix, so they now see defines.h (and +Arduino.h on Arduino platforms) even when they did not include it. """ from __future__ import annotations diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index bb7d35708b..6e0ba70ddc 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -50,11 +50,13 @@ def _include_closure(src_dir: Path, roots: list) -> dict: def _shell_arg(element) -> str: """One compiler argv from one SCons element, matching the real spawn: - SCons whole-quotes spaced elements, the shell unquotes the rest.""" + SCons whole-quotes spaced elements, the shell unquotes the rest. On + Windows there is no POSIX shell pass and shlex would eat path + backslashes.""" arg = str(element) - if " " in arg: + if " " in arg or os.name == "nt": return arg.replace('\\"', '"') - return shlex.split(arg)[0] if arg else arg + return shlex.split(arg)[0] if arg.strip() else arg def _setup_pch() -> None: @@ -104,10 +106,13 @@ def _setup_pch() -> None: for package in sorted(platform.packages): try: version = platform.get_package_version(package) - except Exception as err: # noqa: BLE001 -- absent optional package - # Folded into the digest so an unexpected lookup failure still - # invalidates instead of hashing like a fixed absence - version = f"error:{type(err).__name__}" + except KeyError: + version = None # absent optional package + except Exception as err: # noqa: BLE001 + # Without trustworthy package identity a stale .gch could be + # reused across upgrades; skip the pch instead + print(f"ESPHome: skipping precompiled header: {err}") + return digest.update(f"{package}={version}".encode()) digest.update(b"\0") closure = _include_closure(src_dir, [*include_headers, _CORE_HEADER]) @@ -133,9 +138,13 @@ def _setup_pch() -> None: and not inc_dir.is_relative_to(src_dir) ): continue - for local in sorted(inc_dir.rglob("*.h")): + for local in sorted(p for p in inc_dir.rglob("*") if p.is_file()): + try: + data = local.read_bytes() + except OSError: + data = b"" digest.update(str(local.relative_to(proj_dir)).encode()) - digest.update(local.read_bytes()) + digest.update(data) digest.update(b"\0") checksum = digest.hexdigest() @@ -215,8 +224,11 @@ def _setup_pch() -> None: # Prepended so it is processed before the build_src_flags -include # entries: GCC only uses a .gch while no other tokens have been seen. - projenv.Prepend(CXXFLAGS=["-include", header.name]) # noqa: F821 + projenv.Prepend(CXXFLAGS=["-Winvalid-pch", "-include", header.name]) # noqa: F821 print("ESPHome: Compiling with precompiled header") -_setup_pch() +try: + _setup_pch() +except Exception as err: # noqa: BLE001 -- a speedup must never break the build + print(f"ESPHome: precompiled header setup failed; compiling without it: {err}") diff --git a/esphome/writer.py b/esphome/writer.py index 435c4804f1..44ed1e9179 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -609,6 +609,17 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): if idf_path.is_dir(): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) + # The PlatformIO pch artifacts live at the project root so the + # relative -include resolves; a partial clean must drop them too + for name in ( + "esphome_pch.h", + "esphome_pch.h.gch", + "esphome_pch.h.gch.sum", + "esphome_pch.h.gch.failed", + ): + pch_path = CORE.relative_build_path(name) + if pch_path.is_file(): + pch_path.unlink() # The idedata caches are derived from the build but live under the data # dir, not the build path, so they must be removed separately in both diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index de6abba6dc..aeec4e916c 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1766,7 +1766,7 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: CORE.build_path = tmp_path / name _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") content = _write_ninja(paths, ccache="/usr/bin/ccache") - assert "srccxxflags = -include esphome_pch.h" in content + assert "srccxxflags = -Winvalid-pch -include esphome_pch.h" in content sums.append( (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() ) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 8edb6be397..7007d278c1 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -97,7 +97,7 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None assert (proj / "esphome_pch.h.gch").is_file() assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 # Relative include: an absolute path would poison ccache keys - assert scons_env.prepended == ["-include", "esphome_pch.h"] + assert scons_env.prepended == ["-Winvalid-pch", "-include", "esphome_pch.h"] # ccache settings land on the SCons ENV only, never os.environ assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true" From 1b325a66ce8d72176d14701d46a844aecbbd57ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:37:15 -0500 Subject: [PATCH 05/15] Warn when a consumer ignores the precompiled header --- esphome/build_gen/espidf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b8f5e95db5..f461e0353d 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -337,6 +337,7 @@ def _pch_cmake() -> str: # a .gch drop out of the TU depfiles, and prepare_pch() touches the # header whenever it rebuilds the .gch so consumers recompile. target_compile_options(${{COMPONENT_LIB}} PRIVATE + "$<$:-Winvalid-pch>" "$<$:-include>" "$<$:{PCH_HEADER_NAME}>" ) From c1dfecf9648edbfe3e88c620b52348ad35f1d6e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:43:18 -0500 Subject: [PATCH 06/15] Cover the partial-clean pch artifact removal --- tests/unit_tests/test_writer.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 3dcc4b12b8..3760496510 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -677,6 +677,32 @@ def test_clean_build_partial_exists( assert "dependencies.lock" not in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_partial_removes_pch_artifacts( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """The PlatformIO pch sidecars live at the project root and must go in + a partial clean, like the native backend's under .pioenvs.""" + names = ( + "esphome_pch.h", + "esphome_pch.h.gch", + "esphome_pch.h.gch.sum", + "esphome_pch.h.gch.failed", + ) + for name in names: + (tmp_path / name).write_text("x") + mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs" + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + clean_build() + + for name in names: + assert not (tmp_path / name).exists() + + @patch("esphome.writer.CORE") def test_clean_build_nothing_exists( mock_core: MagicMock, From 278abf2771fa25b18ad6a1d35f2dc7108c0e6efe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:53:54 -0500 Subject: [PATCH 07/15] Treat pch spawn errors as transient, keep user force-includes, fold command into checksum --- esphome/build_gen/espidf.py | 75 +++++++++++++++-------- esphome/build_helpers/idedata.py | 13 ++-- esphome/espidf/toolchain.py | 10 ++- tests/unit_tests/build_gen/test_espidf.py | 69 +++++++++++++++++++-- tests/unit_tests/test_espidf_framework.py | 9 +++ tests/unit_tests/test_espidf_toolchain.py | 19 ++++++ 6 files changed, 158 insertions(+), 37 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index f461e0353d..3ec2bea0dc 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -1,6 +1,5 @@ """ESP-IDF direct build generator for ESPHome.""" -import hashlib import json import logging import os @@ -41,7 +40,9 @@ _LOGGER = logging.getLogger(__name__) # rest. Deliberately hard-coded: frequency-derived sets measured no better # and kept selecting headers that cannot compile standalone (X-macro, # platform-variant). Every entry must be safe to include first in an -# empty TU. +# empty TU. Caveat: application.h/automation.h become ambiently visible, +# so a TU missing those #includes still builds here but not on other +# platforms; ESPHOME_PCH_ENABLE=0 restores the strict view. _PCH_HEADERS = ( PCH_CORE_HEADER, "esphome/core/component.h", @@ -58,7 +59,7 @@ _PCH_BUILD_HEADER = f"build/{PCH_HEADER_NAME}" # Compile-command tokens dropped when retargeting a TU's flags at the # prefix header: source/output/depfile flags with an argument, and the # argument-less depfile flags (the pch compile must not touch depfiles) -_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-include", "-o", "-c", "-MT", "-MF", "-MQ"}) +_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"}) _PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"}) _CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx") @@ -354,7 +355,8 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] (build_dir / "compile_commands.json").read_text(encoding="utf-8") ) except (OSError, json.JSONDecodeError) as err: - _LOGGER.debug("No usable compile database, skipping pch: %s", err) + # Configure already succeeded, so an unusable DB is a real anomaly + _LOGGER.warning("No usable compile database, skipping pch: %s", err) return None # Windows compile DBs use backslashes; normalize both sides src_prefix = str(CORE.relative_src_path()).replace("\\", "/") @@ -368,10 +370,10 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] None, ) if entry is None: - _LOGGER.debug("No src C++ entry in the compile database, skipping pch") + _LOGGER.warning("No src C++ entry in the compile database, skipping pch") return None tokens = expand_response_files( - split_command(entry["command"]), Path(entry.get("directory", build_dir)) + split_command(entry.get("command", "")), Path(entry.get("directory", build_dir)) ) # A DB recorded with ccache enabled prefixes the compiler with the # launcher; the .gch must be compiled directly @@ -385,6 +387,13 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] continue if tok in _PCH_STRIP_FLAGS: continue + if tok == "-include": + # Drop only the injected prefix; user force-includes must reach + # the .gch compile or GCC rejects it over the macro mismatch + inc = next(arg_it, "") + if not inc.endswith(PCH_HEADER_NAME): + args.extend(("-include", inc)) + continue args.append(tok) return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)] @@ -394,8 +403,9 @@ def prepare_pch() -> None: Runs right before ninja, after every reconfigure, so the flags in compile_commands.json and the sdkconfig are the settled ones. The .sum - doubles as the freshness stamp; a failed .gch compile falls back to - the plain header include. + doubles as the freshness stamp and folds in the compile command, so a + flag-only change rebuilds the .gch. A failed compile falls back to the + plain header include. """ if not pch_enabled(): return @@ -403,12 +413,27 @@ def prepare_pch() -> None: header = CORE.relative_build_path(_PCH_BUILD_HEADER) gch = Path(f"{header}.gch") sum_path = Path(f"{gch}.sum") + cmd = _pch_compile_command(build_dir, header, gch) + if cmd is None: + # Freshness cannot be validated; a leftover .gch must not be consumed + gch.unlink(missing_ok=True) + sum_path.unlink(missing_ok=True) + return + sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") try: - sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}").read_text( - encoding="utf-8" + sdkconfig = sdkconfig_path.read_text(encoding="utf-8") + except OSError as err: + # Folding the error in keeps distinct unreadable states from colliding + _LOGGER.warning( + "Could not read %s for the pch checksum: %s", sdkconfig_path, err ) - except OSError: - sdkconfig = "no-sdkconfig" + sdkconfig = f"unreadable:{err}" + # Build-path stripped so identical configs hash identically across devices + cmd_id = ( + " ".join(cmd) + .replace(str(Path(CORE.build_path).resolve()), "") + .replace(str(CORE.build_path), "") + ) checksum = pch_checksum( CORE.relative_src_path(), _PCH_HEADERS, @@ -418,6 +443,7 @@ def prepare_pch() -> None: sdkconfig, *get_project_compile_flags(), *get_project_cxx_compile_flags(), + cmd_id, ), ) if ( @@ -426,21 +452,15 @@ def prepare_pch() -> None: and sum_path.read_text(encoding="utf-8").strip() == checksum ): return - cmd = _pch_compile_command(build_dir, header, gch) - if cmd is None: - # The checksum is stale; a leftover .gch must not be consumed - gch.unlink(missing_ok=True) - sum_path.unlink(missing_ok=True) - return - # Keyed on the checksum and the compile command: a failure caused by - # the command alone must retry when the command changes - marker_key = f"{checksum} {hashlib.sha256(' '.join(cmd).encode()).hexdigest()}" failed_marker = Path(f"{gch}.failed") if ( failed_marker.is_file() - and failed_marker.read_text(encoding="utf-8").strip() == marker_key + and failed_marker.read_text(encoding="utf-8").strip() == checksum ): - _LOGGER.debug("Pch previously failed for these inputs; skipping") + _LOGGER.info( + "Precompiled header disabled after an earlier failure; delete %s to retry", + failed_marker, + ) return try: result = subprocess.run( @@ -452,7 +472,12 @@ def prepare_pch() -> None: elif not gch.is_file(): error = "compiler produced no .gch" except (OSError, subprocess.SubprocessError) as err: - error = str(err) + # Transient (timeout, spawn/IO): warn and retry next build, no marker + _LOGGER.warning("Precompiled header compile did not run: %s", err) + gch.unlink(missing_ok=True) + sum_path.unlink(missing_ok=True) + os.utime(header) + return if error is not None: _LOGGER.warning( "Precompiled header failed; compiling without it: %s", error[:400] @@ -460,7 +485,7 @@ def prepare_pch() -> None: gch.unlink(missing_ok=True) sum_path.unlink(missing_ok=True) # Skip retries until a header/flag/sdkconfig/command change - failed_marker.write_text(marker_key + "\n", encoding="utf-8") + failed_marker.write_text(checksum + "\n", encoding="utf-8") os.utime(header) return failed_marker.unlink(missing_ok=True) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 8a46203890..97faf0f504 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -198,10 +198,15 @@ def parse_entry( it = iter(tokens[1:]) for tok in it: - if tok in ("-c", "-o", "-include"): - # Drop the flag and its argument; the injected relative - # -include esphome_pch.h does not resolve outside the build dir - next(it, None) + if tok in ("-c", "-o"): + next(it, None) # drop the flag and its argument (input/output) + continue + if tok == "-include": + # Drop only the injected pch include, whose relative path does + # not resolve outside the build dir; keep other force-includes + inc = next(it, "") + if not inc.endswith("esphome_pch.h"): + cxx_flags.extend(("-include", inc)) elif tok.startswith("-D"): # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single # quoted arg with a space after -D) that some flags arrive as. diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index a5d145e3c1..d501f6f2af 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -528,10 +528,16 @@ def run_compile(config, verbose: bool) -> int: return result.returncode _patch_memory_segments() - # After every reconfigure so compile_commands and sdkconfig are settled + # After every reconfigure so compile_commands and sdkconfig are settled. + # An optional speedup must never abort the build from esphome.build_gen.espidf import prepare_pch - prepare_pch() + try: + prepare_pch() + except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught + _LOGGER.warning( + "Precompiled header setup failed; compiling without it: %s", err + ) # Build args = [] diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 22c3f7ef8d..f5a8fed9cd 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -667,20 +667,27 @@ def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> No assert (dev / "build" / "esphome_pch.h.gch.failed").exists() -def test_prepare_pch_spawn_oserror_degrades(tmp_path: Path) -> None: +def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None: + """Spawn/IO failures retry on the next build instead of latching.""" from esphome.build_gen.espidf import prepare_pch dev = _make_pch_device(tmp_path, "dev_o") CORE.build_path = dev + calls = [] + + def raising(cmd, **kwargs): + calls.append(cmd) + raise OSError("no such compiler") + with ( patch.object(CORE, "name", "test"), - patch( - "esphome.build_gen.espidf.subprocess.run", - side_effect=OSError("no such compiler"), - ), + patch("esphome.build_gen.espidf.subprocess.run", side_effect=raising), ): prepare_pch() - assert (dev / "build" / "esphome_pch.h.gch.failed").exists() + prepare_pch() + assert not (dev / "build" / "esphome_pch.h.gch.failed").exists() + assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() + assert len(calls) == 2 def test_prepare_pch_disabled_is_noop( @@ -810,3 +817,53 @@ def test_component_cmakelists_pch_object_depends() -> None: content = get_component_cmakelists() assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content + + +def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None: + """A flag-only change in the compile DB must rebuild the .gch.""" + from esphome.build_gen.espidf import prepare_pch + + dev = _make_pch_device(tmp_path, "dev_c") + CORE.build_path = dev + gch = dev / "build" / "esphome_pch.h.gch" + + def fake_compile(cmd, **kwargs): + gch.write_bytes(b"gch") + return subprocess.CompletedProcess(cmd, 0, "", "") + + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + ): + prepare_pch() + first = (dev / "build" / "esphome_pch.h.gch.sum").read_text() + db = dev / "build" / "compile_commands.json" + db.write_text(db.read_text().replace("-DX=1", "-DX=2")) + prepare_pch() + assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first + + +def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None: + from esphome.build_gen.espidf import _pch_compile_command + + dev = _make_pch_device(tmp_path, "dev_u") + CORE.build_path = dev + build = dev / "build" + src_file = str(dev / "src" / "esphome" / "a.cpp") + build.joinpath("compile_commands.json").write_text( + json.dumps( + [ + { + "directory": str(build), + "command": ( + "g++ -include user.h -include esphome_pch.h " + f"-o a.obj -c {src_file}" + ), + "file": src_file, + } + ] + ) + ) + cmd = _pch_compile_command(build, build / "esphome_pch.h", build / "x.gch") + assert "user.h" in cmd + assert "esphome_pch.h" not in " ".join(cmd[:-3]) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index a49c292e2a..7e48593ad3 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1991,3 +1991,12 @@ def test_ccache_env_opt_in_with_usable_binary( env = _ccache_env() assert env["IDF_CCACHE_ENABLE"] == "1" assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_ccache_env_exports_pch_settings(tmp_path: Path) -> None: + # The pch cannot cache under ccache without these + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2cd5d4e1cb..e7d5df896e 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -667,3 +667,22 @@ def test_get_core_framework_version_from_core_data(): CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} assert toolchain._get_core_framework_version() == "5.5.4" + + +def test_run_compile_invokes_prepare_pch_and_survives_failure( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The pch hook runs before the build and a failure never aborts it.""" + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + _setup_build(setup_core) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + patch( + "esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom") + ) as prepare, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + prepare.assert_called_once() From a4a8c7715141d0fae4dbc808ed8f9fdade30eb7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 16:19:29 -0500 Subject: [PATCH 08/15] Resolve idedata force-include paths, test probe rejection and env scoping, derive clean names --- esphome/build_gen/arduino8266.py | 5 ++ esphome/build_helpers/idedata.py | 4 + esphome/writer.py | 9 ++- .../unit_tests/build_helpers/test_idedata.py | 18 +++++ .../unit_tests/test_platformio_pch_script.py | 74 ++++++++++++++++--- 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 1311152bde..7870fd7d68 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1223,6 +1223,11 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # C++ src edges swap the force-includes for one precompiled prefix # header holding the same content plus defines.h; C and assembly # edges keep srcflags (a .gch is a C++ artifact) + # The opt-out hint matters when a toolchain rejects its own .gch: + # the build stays correct but every TU warns via -Winvalid-pch + _LOGGER.info( + "Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)" + ) pch_header = build_dir / PCH_HEADER_NAME pch_includes = (*src_includes, PCH_CORE_HEADER) write_file_if_changed(pch_header, pch_header_text(pch_includes)) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 4b57b5e983..9522535f60 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -200,6 +200,10 @@ def parse_entry( for tok in it: if tok in ("-c", "-o"): next(it, None) # drop the flag and its argument (input/output) + elif tok == "-include": + # Resolve like -I so cached idedata works from any cwd (the pch + # include is emitted relative to the build dir) + cxx_flags.extend(("-include", _include(next(it, "")))) elif tok.startswith("-D"): # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single # quoted arg with a space after -D) that some flags arrive as. diff --git a/esphome/writer.py b/esphome/writer.py index 44ed1e9179..d2b16ffcc0 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,6 +7,7 @@ import re import time from esphome import loader +from esphome.build_helpers.pch import PCH_HEADER_NAME from esphome.compiled_config import save_compiled_config from esphome.config import iter_component_configs, iter_components from esphome.const import ( @@ -612,10 +613,10 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): # The PlatformIO pch artifacts live at the project root so the # relative -include resolves; a partial clean must drop them too for name in ( - "esphome_pch.h", - "esphome_pch.h.gch", - "esphome_pch.h.gch.sum", - "esphome_pch.h.gch.failed", + PCH_HEADER_NAME, + f"{PCH_HEADER_NAME}.gch", + f"{PCH_HEADER_NAME}.gch.sum", + f"{PCH_HEADER_NAME}.gch.failed", ): pch_path = CORE.relative_build_path(name) if pch_path.is_file(): diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index fcf9c67086..0a192fd438 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -85,6 +85,24 @@ def test_parse_entry_resolves_relative_includes() -> None: assert all(Path(inc).is_absolute() for inc in includes) +def test_parse_entry_resolves_force_include_path() -> None: + """The pch -include is emitted relative to the build dir; idedata must + resolve it so cached flags work from any cwd.""" + directory = f"{ABS}build/proj" + entry = _entry( + directory, + f"{directory}/src/esphome/x.cpp", + "g++ -include esphome_pch.h -c x.cpp", + ) + + _, _, _, cxx_flags = idedata.parse_entry(entry) + + idx = cxx_flags.index("-include") + resolved = cxx_flags[idx + 1] + assert Path(resolved).is_absolute() + assert resolved.endswith("build/proj/esphome_pch.h") + + def test_parse_entry_skips_dependency_flags() -> None: """Dependency-generation flags (and their args) are dropped.""" entry = _entry( diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 7007d278c1..4e4e5ca8e5 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -27,10 +27,22 @@ class _FakePlatform: return "1.2.3" +class _BrokenPlatform(_FakePlatform): + def get_package_version(self, name: str) -> str: + raise RuntimeError("manifest parse error") + + class _FakeSConsEnv(dict): """Just enough of a SCons construction environment for pch.py.""" - def __init__(self, proj_dir: Path, src_dir: Path, cxx: str, flags: list[str]): + def __init__( + self, + proj_dir: Path, + src_dir: Path, + cxx: str, + flags: list[str], + platform_cls: type[_FakePlatform] = _FakePlatform, + ): super().__init__(ENV={}) self._subst = { "$PROJECT_DIR": str(proj_dir), @@ -38,6 +50,7 @@ class _FakeSConsEnv(dict): "$CXX": cxx, } self._flags = flags + self._platform_cls = platform_cls self.prepended: list[str] = [] def subst(self, expr: str) -> str: # noqa: N802 @@ -47,14 +60,18 @@ class _FakeSConsEnv(dict): return [self._flags] def PioPlatform(self) -> _FakePlatform: # noqa: N802 - return _FakePlatform() + return self._platform_cls() def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803 self.prepended = CXXFLAGS -def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: - """A compiler stand-in that records its argv and writes the -o target.""" +def _fake_cxx(tmp_path: Path, fail: bool = False, reject_pch: bool = False) -> Path: + """A compiler stand-in that records its argv and writes the -o target. + + With reject_pch it builds the .gch fine but, like GCC 10 on macOS arm64, + warns on any consuming compile that the .gch cannot be loaded. + """ cxx = tmp_path / "fake-gxx" body = ( 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' @@ -62,7 +79,12 @@ def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: if fail: body += "echo boom >&2\nexit 1\n" else: - body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\necho gch > "$out"\n' + # Only the c++-header compile has a -o; the load probe has none + body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n' + body += '[ -n "$out" ] && echo gch > "$out"\n' + if reject_pch: + body += 'case " $* " in *c++-header*) ;; *) echo "warning: esphome_pch.h.gch: had text segment at different address" >&2;; esac\n' + body += "exit 0\n" cxx.write_text("#!/bin/sh\n" + body) cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC) return cxx @@ -72,22 +94,28 @@ def _run_script( tmp_path: Path, flags: list[str] | None = None, fail: bool = False, + reject_pch: bool = False, env_vars: dict[str, str] | None = None, name: str = "dev", + platform_cls: type[_FakePlatform] = _FakePlatform, ) -> _FakeSConsEnv: proj = tmp_path / name src = proj / "src" (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") - cxx = _fake_cxx(tmp_path, fail=fail) - scons_env = _FakeSConsEnv(proj, src, str(cxx), flags or ["-DX=1"]) + cxx = _fake_cxx(tmp_path, fail=fail, reject_pch=reject_pch) + args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) + # Distinct objects: the script must scope ccache/flags to projenv only + global_env = _FakeSConsEnv(*args) + projenv = _FakeSConsEnv(*args) + projenv.global_env = global_env source = _SCRIPT.read_text() with patch.dict(os.environ, env_vars or {}, clear=True): exec( # noqa: S102 compile(source, "pch.py", "exec"), - {"Import": lambda *_names: None, "env": scons_env, "projenv": scons_env}, + {"Import": lambda *_names: None, "env": global_env, "projenv": projenv}, ) - return scons_env + return projenv def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None: @@ -98,9 +126,12 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 # Relative include: an absolute path would poison ccache keys assert scons_env.prepended == ["-Winvalid-pch", "-include", "esphome_pch.h"] - # ccache settings land on the SCons ENV only, never os.environ + # ccache settings land on projenv's ENV only: framework/library TUs + # compile under the global env and must keep strict hashing assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true" + assert scons_env.global_env["ENV"] == {} + assert scons_env.global_env.prepended == [] assert "CCACHE_SLOPPINESS" not in os.environ @@ -154,6 +185,29 @@ def test_pch_script_failure_marker_suppresses_retry( assert "delete esphome_pch.h.gch.failed to retry" in out +def test_pch_script_probe_rejection_falls_back( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A toolchain that cannot load its own .gch (GCC 10 on macOS arm64) + must not leave consumers paying for a pch every compile rejects.""" + scons_env = _run_script(tmp_path, reject_pch=True) + proj = tmp_path / "dev" + assert not (proj / "esphome_pch.h.gch").exists() + assert not (proj / "esphome_pch.h.gch.sum").exists() + assert (proj / "esphome_pch.h.gch.failed").is_file() + assert scons_env.prepended == [] + assert "toolchain cannot load the pch" in capsys.readouterr().out + + +def test_pch_script_package_version_error_skips_pch(tmp_path: Path) -> None: + """Without trustworthy package identity a stale .gch could survive an + upgrade, so the script must not build one at all.""" + scons_env = _run_script(tmp_path, platform_cls=_BrokenPlatform) + proj = tmp_path / "dev" + assert not (proj / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + + def test_pch_script_rebuilds_when_header_missing(tmp_path: Path) -> None: _run_script(tmp_path) proj = tmp_path / "dev" From 564f3e31c1c04b62726624f9c00de120bd38c703 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:44:54 -0500 Subject: [PATCH 09/15] Harden idedata -include anchoring, probe exit check, package identity, and failure diagnostics --- esphome/build_helpers/idedata.py | 14 +++- esphome/platformio/pch.py.script | 67 ++++++++++++------- .../unit_tests/build_helpers/test_idedata.py | 37 ++++++++-- .../unit_tests/test_platformio_pch_script.py | 65 ++++++++++++++++-- 4 files changed, 146 insertions(+), 37 deletions(-) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 9522535f60..29f70b835c 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -201,9 +201,17 @@ def parse_entry( if tok in ("-c", "-o"): next(it, None) # drop the flag and its argument (input/output) elif tok == "-include": - # Resolve like -I so cached idedata works from any cwd (the pch - # include is emitted relative to the build dir) - cxx_flags.extend(("-include", _include(next(it, "")))) + # -include searches the compile cwd first, then the -I chain, so + # only re-anchor paths that really live next to the compile (the + # pch); a name meant for the -I chain must stay untouched + raw = next(it, "") + if not raw: + _LOGGER.warning("Dropping -include with no argument") + else: + resolved = _include(raw) + cxx_flags.extend( + ("-include", resolved if Path(resolved).is_file() else raw) + ) elif tok.startswith("-D"): # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single # quoted arg with a space after -D) that some flags arrive as. diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 6e0ba70ddc..45bb9ddb05 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -5,6 +5,7 @@ import posixpath import re import shlex import subprocess +import traceback # pylint: disable=E0602 Import("env", "projenv") # noqa: F821 @@ -107,6 +108,11 @@ def _setup_pch() -> None: try: version = platform.get_package_version(package) except KeyError: + # Only trust KeyError as "absent" when the package really is not + # installed; an unresolved manifest must not hash as a constant + if platform.get_package(package) is not None: + print(f"ESPHome: skipping precompiled header: no version for {package}") + return version = None # absent optional package except Exception as err: # noqa: BLE001 # Without trustworthy package identity a stale .gch could be @@ -141,8 +147,15 @@ def _setup_pch() -> None: for local in sorted(p for p in inc_dir.rglob("*") if p.is_file()): try: data = local.read_bytes() - except OSError: - data = b"" + except OSError as err: + print(f"ESPHome: could not read {local} for the pch checksum: {err}") + try: + # mtime/size keep a changed-but-unreadable header shifting + # the digest without putting device paths in it + st = local.stat() + data = f"".encode() + except OSError: + data = b"" digest.update(str(local.relative_to(proj_dir)).encode()) digest.update(data) digest.update(b"\0") @@ -181,27 +194,30 @@ def _setup_pch() -> None: # macOS arm64 rejects it per-process: "had text segment at # different address"); probe once so consumers never pay for a # pch that every compile would silently reject - probe = subprocess.run( # noqa: PLW1510 - [ - cxx, - *flags, - "-MF", - os.devnull, - "-Winvalid-pch", - "-include", - str(header), - "-fsyntax-only", - "-x", - "c++", - "-", - ], - cwd=proj_dir, - input="", - capture_output=True, - text=True, - ) - if ".gch" in probe.stderr: - error = f"toolchain cannot load the pch: {probe.stderr.strip()}" + try: + probe = subprocess.run( # noqa: PLW1510 + [ + cxx, + *flags, + "-MF", + os.devnull, + "-Winvalid-pch", + "-include", + str(header), + "-fsyntax-only", + "-x", + "c++", + "-", + ], + cwd=proj_dir, + input="", + capture_output=True, + text=True, + ) + if probe.returncode != 0 or ".gch" in probe.stderr: + error = f"toolchain cannot load the pch: {probe.stderr.strip()}" + except OSError as err: + error = str(err) if error is not None: print("ESPHome: precompiled header failed; compiling without it") print(error) @@ -230,5 +246,6 @@ def _setup_pch() -> None: try: _setup_pch() -except Exception as err: # noqa: BLE001 -- a speedup must never break the build - print(f"ESPHome: precompiled header setup failed; compiling without it: {err}") +except Exception: # noqa: BLE001 -- a speedup must never break the build + print("ESPHome: precompiled header setup failed; compiling without it") + traceback.print_exc() diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index 0a192fd438..dbbf647060 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -85,13 +85,13 @@ def test_parse_entry_resolves_relative_includes() -> None: assert all(Path(inc).is_absolute() for inc in includes) -def test_parse_entry_resolves_force_include_path() -> None: +def test_parse_entry_resolves_force_include_path(tmp_path: Path) -> None: """The pch -include is emitted relative to the build dir; idedata must resolve it so cached flags work from any cwd.""" - directory = f"{ABS}build/proj" + (tmp_path / "esphome_pch.h").write_text("") entry = _entry( - directory, - f"{directory}/src/esphome/x.cpp", + str(tmp_path), + f"{tmp_path}/src/esphome/x.cpp", "g++ -include esphome_pch.h -c x.cpp", ) @@ -100,7 +100,34 @@ def test_parse_entry_resolves_force_include_path() -> None: idx = cxx_flags.index("-include") resolved = cxx_flags[idx + 1] assert Path(resolved).is_absolute() - assert resolved.endswith("build/proj/esphome_pch.h") + assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/") + + +def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None: + """-include names resolved via the -I chain (libretiny's Arduino.h) must + not be re-anchored to a nonexistent build-dir path.""" + entry = _entry( + str(tmp_path), + f"{tmp_path}/src/esphome/x.cpp", + "g++ -include Arduino.h -c x.cpp", + ) + + _, _, _, cxx_flags = idedata.parse_entry(entry) + + assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h" + + +def test_parse_entry_drops_trailing_force_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + entry = _entry( + str(tmp_path), f"{tmp_path}/src/esphome/x.cpp", "g++ -c x.cpp -include" + ) + + _, _, _, cxx_flags = idedata.parse_entry(entry) + + assert "-include" not in cxx_flags + assert "no argument" in caplog.text def test_parse_entry_skips_dependency_flags() -> None: diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 4e4e5ca8e5..0f879eef47 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -26,12 +26,25 @@ class _FakePlatform: raise KeyError(name) return "1.2.3" + def get_package(self, name: str) -> object | None: + return None + class _BrokenPlatform(_FakePlatform): def get_package_version(self, name: str) -> str: raise RuntimeError("manifest parse error") +class _UnresolvedPlatform(_FakePlatform): + """KeyError from a package that IS installed: unresolved identity.""" + + def get_package_version(self, name: str) -> str: + raise KeyError(name) + + def get_package(self, name: str) -> object: + return object() + + class _FakeSConsEnv(dict): """Just enough of a SCons construction environment for pch.py.""" @@ -66,11 +79,17 @@ class _FakeSConsEnv(dict): self.prepended = CXXFLAGS -def _fake_cxx(tmp_path: Path, fail: bool = False, reject_pch: bool = False) -> Path: +def _fake_cxx( + tmp_path: Path, + fail: bool = False, + reject_pch: bool = False, + probe_exit: int = 0, +) -> Path: """A compiler stand-in that records its argv and writes the -o target. With reject_pch it builds the .gch fine but, like GCC 10 on macOS arm64, - warns on any consuming compile that the .gch cannot be loaded. + warns on any consuming compile that the .gch cannot be loaded; probe_exit + sets the exit code of non-header compiles (the load probe). """ cxx = tmp_path / "fake-gxx" body = ( @@ -84,7 +103,7 @@ def _fake_cxx(tmp_path: Path, fail: bool = False, reject_pch: bool = False) -> P body += '[ -n "$out" ] && echo gch > "$out"\n' if reject_pch: body += 'case " $* " in *c++-header*) ;; *) echo "warning: esphome_pch.h.gch: had text segment at different address" >&2;; esac\n' - body += "exit 0\n" + body += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n' cxx.write_text("#!/bin/sh\n" + body) cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC) return cxx @@ -95,6 +114,7 @@ def _run_script( flags: list[str] | None = None, fail: bool = False, reject_pch: bool = False, + probe_exit: int = 0, env_vars: dict[str, str] | None = None, name: str = "dev", platform_cls: type[_FakePlatform] = _FakePlatform, @@ -103,7 +123,7 @@ def _run_script( src = proj / "src" (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") - cxx = _fake_cxx(tmp_path, fail=fail, reject_pch=reject_pch) + cxx = _fake_cxx(tmp_path, fail=fail, reject_pch=reject_pch, probe_exit=probe_exit) args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) # Distinct objects: the script must scope ccache/flags to projenv only global_env = _FakeSConsEnv(*args) @@ -199,6 +219,22 @@ def test_pch_script_probe_rejection_falls_back( assert "toolchain cannot load the pch" in capsys.readouterr().out +def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None: + """A probe failure whose stderr never mentions .gch must still count.""" + scons_env = _run_script(tmp_path, probe_exit=1) + proj = tmp_path / "dev" + assert not (proj / "esphome_pch.h.gch").exists() + assert (proj / "esphome_pch.h.gch.failed").is_file() + assert scons_env.prepended == [] + + +def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None: + """A KeyError for an installed package is unresolved identity, not absence.""" + scons_env = _run_script(tmp_path, platform_cls=_UnresolvedPlatform) + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + + def test_pch_script_package_version_error_skips_pch(tmp_path: Path) -> None: """Without trustworthy package identity a stale .gch could survive an upgrade, so the script must not build one at all.""" @@ -238,3 +274,24 @@ def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) _run_script(tmp_path, flags=flags) assert (proj / "esphome_pch.h.gch.sum").read_text() != first + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes") +def test_pch_script_unreadable_local_header_warns_and_varies( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unreadable generated header still shifts the digest via mtime/size.""" + proj = tmp_path / "dev" + override = proj / "lwip_override" + override.mkdir(parents=True) + secret = override / "lwipopts.h" + secret.write_text("#define TCP_MSS 1460\n") + secret.chmod(0) + flags = ["-DX=1", "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + assert "could not read" in capsys.readouterr().out + os.utime(secret, (1, 1)) + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() != first From 1059ecf50c54eed656e775cd936a82a9b9878b39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:48:01 -0500 Subject: [PATCH 10/15] Fold header order into pch checksum, gate rebuild-forcing touch, guard malformed compile DBs --- esphome/build_gen/espidf.py | 41 +++++++++--- esphome/espidf/toolchain.py | 11 ++- tests/unit_tests/build_gen/test_espidf.py | 81 +++++++++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 7 ++ 4 files changed, 129 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 3ec2bea0dc..92f227731b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -358,13 +358,17 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] # Configure already succeeded, so an unusable DB is a real anomaly _LOGGER.warning("No usable compile database, skipping pch: %s", err) return None + if not isinstance(entries, list): + _LOGGER.warning("Malformed compile database, skipping pch") + return None # Windows compile DBs use backslashes; normalize both sides src_prefix = str(CORE.relative_src_path()).replace("\\", "/") entry = next( ( e for e in entries - if e.get("file", "").replace("\\", "/").startswith(src_prefix) + if isinstance(e, dict) + and e.get("file", "").replace("\\", "/").startswith(src_prefix) and e.get("file", "").endswith(_CXX_SOURCE_SUFFIXES) ), None, @@ -379,6 +383,11 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] # launcher; the .gch must be compiled directly if tokens and is_launcher(tokens[0]): tokens = tokens[1:] + if not tokens: + # An "arguments"-style or empty entry must skip cleanly, not spawn + # a compiler-less argv that warns on every build + _LOGGER.warning("Compile database entry has no usable command, skipping pch") + return None args: list[str] = [] arg_it = iter(tokens) for tok in arg_it: @@ -398,6 +407,22 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)] +def discard_pch() -> None: + """Remove the pch sidecars so a stale .gch is never consumed. + + Bumps the header only when a .gch was actually removed: TUs compiled + against it have incomplete depfiles, while a repeat failure with no + .gch must not force a full rebuild every build. + """ + header = CORE.relative_build_path(_PCH_BUILD_HEADER) + gch = Path(f"{header}.gch") + had_gch = gch.is_file() + gch.unlink(missing_ok=True) + Path(f"{gch}.sum").unlink(missing_ok=True) + if had_gch and header.is_file(): + os.utime(header) + + def prepare_pch() -> None: """Compile the prefix header's .gch and write its ccache .sum. @@ -416,18 +441,18 @@ def prepare_pch() -> None: cmd = _pch_compile_command(build_dir, header, gch) if cmd is None: # Freshness cannot be validated; a leftover .gch must not be consumed - gch.unlink(missing_ok=True) - sum_path.unlink(missing_ok=True) + discard_pch() return sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") try: sdkconfig = sdkconfig_path.read_text(encoding="utf-8") except OSError as err: - # Folding the error in keeps distinct unreadable states from colliding + # Path-independent marker: str(err) embeds the per-device path and + # would defeat cross-device .sum sharing _LOGGER.warning( "Could not read %s for the pch checksum: %s", sdkconfig_path, err ) - sdkconfig = f"unreadable:{err}" + sdkconfig = f"unreadable:{type(err).__name__}:{err.errno}" # Build-path stripped so identical configs hash identically across devices cmd_id = ( " ".join(cmd) @@ -438,6 +463,8 @@ def prepare_pch() -> None: CORE.relative_src_path(), _PCH_HEADERS, ( + # The closure is sorted, so root order only enters via the text + pch_header_text(_PCH_HEADERS), str(idf_version()), CORE.cpp_standard or "", sdkconfig, @@ -474,9 +501,7 @@ def prepare_pch() -> None: except (OSError, subprocess.SubprocessError) as err: # Transient (timeout, spawn/IO): warn and retry next build, no marker _LOGGER.warning("Precompiled header compile did not run: %s", err) - gch.unlink(missing_ok=True) - sum_path.unlink(missing_ok=True) - os.utime(header) + discard_pch() return if error is not None: _LOGGER.warning( diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index d501f6f2af..0eccf5af79 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,5 +1,6 @@ """ESP-IDF direct build API for ESPHome.""" +from contextlib import suppress from dataclasses import dataclass, field import hashlib import json @@ -530,13 +531,17 @@ def run_compile(config, verbose: bool) -> int: # After every reconfigure so compile_commands and sdkconfig are settled. # An optional speedup must never abort the build - from esphome.build_gen.espidf import prepare_pch + from esphome.build_gen.espidf import discard_pch, prepare_pch try: prepare_pch() - except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Discard so an unexpected error can never leave a stale .gch that + # GCC would silently consume; exc_info keeps the failure diagnosable + with suppress(OSError): + discard_pch() _LOGGER.warning( - "Precompiled header setup failed; compiling without it: %s", err + "Precompiled header setup failed; compiling without it", exc_info=True ) # Build diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index f5a8fed9cd..9b7e78f4c1 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import os from pathlib import Path import subprocess from unittest.mock import patch @@ -645,6 +646,59 @@ def test_pch_compile_command_variants(tmp_path: Path) -> None: ] +def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None: + """Malformed DB shapes and command-less entries skip cleanly instead of + producing a compiler-less argv retried every build.""" + from esphome.build_gen.espidf import _pch_compile_command + + build = tmp_path / "build" + build.mkdir() + header = build / "esphome_pch.h" + gch = build / "esphome_pch.h.gch" + db = build / "compile_commands.json" + src_file = str(tmp_path / "src" / "esphome" / "a.cpp") + + db.write_text(json.dumps({"not": "a list"})) + assert _pch_compile_command(build, header, gch) is None + + db.write_text(json.dumps(["just a string"])) + assert _pch_compile_command(build, header, gch) is None + + # arguments-style entry (allowed by the spec, unused by CMake) + db.write_text( + json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}]) + ) + assert _pch_compile_command(build, header, gch) is None + + +def test_pch_header_list_order_is_in_checksum( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reordering _PCH_HEADERS keeps the include closure identical, but the + generated header text differs, so the .gch must rebuild.""" + import esphome.build_gen.espidf as espidf_mod + + dev = _make_pch_device(tmp_path, "dev_r") + CORE.build_path = dev + gch = dev / "build" / "esphome_pch.h.gch" + + def fake_compile(cmd, **kwargs): + gch.write_bytes(b"gch") + return subprocess.CompletedProcess(cmd, 0, "", "") + + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + ): + espidf_mod.prepare_pch() + first = (dev / "build" / "esphome_pch.h.gch.sum").read_text() + monkeypatch.setattr( + espidf_mod, "_PCH_HEADERS", tuple(reversed(espidf_mod._PCH_HEADERS)) + ) + espidf_mod.prepare_pch() + assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first + + def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> None: from esphome.build_gen.espidf import prepare_pch @@ -679,6 +733,8 @@ def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None: calls.append(cmd) raise OSError("no such compiler") + header = dev / "build" / "esphome_pch.h" + before = header.stat().st_mtime_ns with ( patch.object(CORE, "name", "test"), patch("esphome.build_gen.espidf.subprocess.run", side_effect=raising), @@ -688,6 +744,31 @@ def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None: assert not (dev / "build" / "esphome_pch.h.gch.failed").exists() assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() assert len(calls) == 2 + # No .gch was ever in play, so the header must not be re-touched into + # forcing a full rebuild on every failing build + assert header.stat().st_mtime_ns == before + + +def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> None: + """A stale .gch removed on a transient failure must dirty its consumers.""" + from esphome.build_gen.espidf import prepare_pch + + dev = _make_pch_device(tmp_path, "dev_s") + CORE.build_path = dev + gch = dev / "build" / "esphome_pch.h.gch" + gch.write_bytes(b"stale") + header = dev / "build" / "esphome_pch.h" + os.utime(header, (1, 1)) + with ( + patch.object(CORE, "name", "test"), + patch( + "esphome.build_gen.espidf.subprocess.run", + side_effect=OSError("no such compiler"), + ), + ): + prepare_pch() + assert not gch.exists() + assert header.stat().st_mtime_ns > 1_000_000_000 def test_prepare_pch_disabled_is_noop( diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index e7d5df896e..7458d44764 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -675,6 +675,12 @@ def test_run_compile_invokes_prepare_pch_and_survives_failure( """The pch hook runs before the build and a failure never aborts it.""" monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") _setup_build(setup_core) + # A stale .gch must be discarded on the failure path, never consumed + build = setup_core / "build" / "test" / "build" + build.mkdir(parents=True, exist_ok=True) + (build / "esphome_pch.h").write_text("") + stale_gch = build / "esphome_pch.h.gch" + stale_gch.write_bytes(b"stale") with ( patch.object(toolchain, "need_reconfigure", return_value=False), @@ -686,3 +692,4 @@ def test_run_compile_invokes_prepare_pch_and_survives_failure( ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 prepare.assert_called_once() + assert not stale_gch.exists() From b6c1552bcb17f9444fc089baae777f8c6f6369b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:56:23 -0500 Subject: [PATCH 11/15] Simplify: shared artifact names, one cxx override param, extracted gch compile, transient spawn semantics in the script --- esphome/build_gen/arduino8266.py | 27 +++---- esphome/build_helpers/ccache.py | 3 +- esphome/build_helpers/idedata.py | 10 +-- esphome/build_helpers/pch.py | 8 ++ esphome/platformio/pch.py.script | 80 ++++++++++--------- esphome/writer.py | 13 +-- .../unit_tests/test_platformio_pch_script.py | 19 ++++- 7 files changed, 91 insertions(+), 69 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 7870fd7d68..1517f29f79 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -882,13 +882,12 @@ def _ninja_compile_edges( root: Path, group: str, flags: str = "", - cxx_flags: str = "", - cxx_implicit: str = "", + cxx_override: tuple[str, str] | None = None, ) -> list[str]: """Emit compile edges for ``sources``; return the object paths. - ``cxx_flags``/``cxx_implicit`` override ``flags`` and add an implicit - dependency on C++ edges only (used for the precompiled header). + ``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges + only, replacing ``flags`` (used for the precompiled header). """ objects = [] for src in sources: @@ -896,10 +895,10 @@ def _ninja_compile_edges( obj = f"obj/{group}/{rel}.o" escaped_obj = _e(obj) kind = SOURCE_KIND_FOR_SUFFIX[src.suffix] - is_cxx = kind == "cxx" - implicit = f" | {cxx_implicit}" if is_cxx and cxx_implicit else "" + override = cxx_override if kind == "cxx" and cxx_override else None + implicit = f" | {override[1]}" if override else "" lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}") - edge_flags = cxx_flags if is_cxx and cxx_flags else flags + edge_flags = override[0] if override else flags if edge_flags: lines.append(f" flags = {edge_flags}") # Escaped once here: the returned paths only ever appear in build @@ -1217,8 +1216,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") - src_cxx_flags = "" - src_cxx_implicit = "" + src_cxx_override = None if pch_enabled(): # C++ src edges swap the force-includes for one precompiled prefix # header holding the same content plus defines.h; C and assembly @@ -1230,7 +1228,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: ) pch_header = build_dir / PCH_HEADER_NAME pch_includes = (*src_includes, PCH_CORE_HEADER) - write_file_if_changed(pch_header, pch_header_text(pch_includes)) + pch_text = pch_header_text(pch_includes) + write_file_if_changed(pch_header, pch_text) if ccache: # The .sum sidecar only exists for CCACHE_PCH_EXTSUM; ninja's # depfile handles staleness. Mirror CCACHE_BASEDIR: strip the @@ -1243,7 +1242,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: src_dir, pch_includes, ( - pch_header_text(pch_includes), + pch_text, str(paths.framework), str(paths.toolchain), flags_id, @@ -1261,16 +1260,14 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # every compile command and defeat cross-device ccache sharing cxx_parts = src_other + [f"-Winvalid-pch -include {PCH_HEADER_NAME}"] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") - src_cxx_flags = "$srccxxflags" - src_cxx_implicit = gch + src_cxx_override = ("$srccxxflags", gch) src_objs = _ninja_compile_edges( lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags", - cxx_flags=src_cxx_flags, - cxx_implicit=src_cxx_implicit, + cxx_override=src_cxx_override, ) ld_deps = [f"ld/{_COMMON_LD_NAME}"] diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index cee0774561..7daad458dd 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -87,7 +87,8 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: "CCACHE_DIR": str(cache_dir), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", - "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + # A user value wins via the filter below + "CCACHE_BASEDIR": effective_ccache_basedir(), } return {k: v for k, v in defaults.items() if k not in os.environ} diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 29f70b835c..76319bd681 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -59,11 +59,11 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: _LOGGER.warning("Idedata failure detail", exc_info=True) -# C++ translation-unit suffixes used to identify ESPHome source files. -_CXX_SUFFIXES = (".cpp", ".cc") +# C++ translation-unit suffixes, shared with the pch backends. +CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx") # Suffixes of input/output files that appear bare on the command line (and so # must not be mistaken for compiler flags). -_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") +_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s") # Path marker identifying an ESPHome source translation unit. _ESPHOME_SRC_MARKER = "/src/esphome/" @@ -72,7 +72,7 @@ def _is_esphome_src(file: str) -> bool: """Whether ``file`` is an ESPHome C++ translation unit; normalized to ``/`` first since Windows compile DBs use backslashes.""" return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( - _CXX_SUFFIXES + CXX_SOURCE_SUFFIXES ) @@ -147,7 +147,7 @@ def _pick_entry(entries: list[dict]) -> dict: if _is_esphome_src(entry["file"]): return entry for entry in entries: - if entry["file"].endswith(_CXX_SUFFIXES): + if entry["file"].endswith(CXX_SOURCE_SUFFIXES): return entry raise ValueError("no C++ translation unit found in compile_commands.json") diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 0b691c06a4..4c8d0936b9 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -24,6 +24,14 @@ _LOGGER = logging.getLogger(__name__) # The header and its .gch/.sum sidecars live in the build directory. PCH_HEADER_NAME = "esphome_pch.h" +# Every artifact the pch machinery can leave behind, for cleanup. +PCH_ARTIFACT_NAMES = ( + PCH_HEADER_NAME, + f"{PCH_HEADER_NAME}.gch", + f"{PCH_HEADER_NAME}.gch.sum", + f"{PCH_HEADER_NAME}.gch.failed", +) + # The core defines header every backend anchors its prefix on. PCH_CORE_HEADER = "esphome/core/defines.h" diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 45bb9ddb05..310a64c25c 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -60,6 +60,43 @@ def _shell_arg(element) -> str: return shlex.split(arg)[0] if arg.strip() else arg +def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path): + """Compile the .gch, then probe that the toolchain can load it back + (GCC 10 on macOS arm64 builds one it then rejects per-process: "had + text segment at different address"). Returns a deterministic error + string or None; OSError propagates for transient handling.""" + result = subprocess.run( # noqa: PLW1510 + [cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)], + cwd=proj_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return result.stderr + probe = subprocess.run( # noqa: PLW1510 + [ + cxx, + *flags, + "-MF", + os.devnull, + "-Winvalid-pch", + "-include", + str(header), + "-fsyntax-only", + "-x", + "c++", + "-", + ], + cwd=proj_dir, + input="", + capture_output=True, + text=True, + ) + if probe.returncode != 0 or ".gch" in probe.stderr: + return f"toolchain cannot load the pch: {probe.stderr.strip()}" + return None + + def _setup_pch() -> None: # Project root, not $BUILD_DIR: SCons compiles run with the project dir # as cwd, so "-include esphome_pch.h" resolves here as a relative path. @@ -180,44 +217,13 @@ def _setup_pch() -> None: return header.write_text(content, encoding="utf-8") try: - result = subprocess.run( # noqa: PLW1510 - [cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)], - cwd=proj_dir, - capture_output=True, - text=True, - ) - error = result.stderr if result.returncode != 0 else None + error = _compile_gch(cxx, flags, header, gch, proj_dir) except OSError as err: - error = str(err) - if error is None: - # Some toolchains build a .gch they cannot load back (GCC 10 on - # macOS arm64 rejects it per-process: "had text segment at - # different address"); probe once so consumers never pay for a - # pch that every compile would silently reject - try: - probe = subprocess.run( # noqa: PLW1510 - [ - cxx, - *flags, - "-MF", - os.devnull, - "-Winvalid-pch", - "-include", - str(header), - "-fsyntax-only", - "-x", - "c++", - "-", - ], - cwd=proj_dir, - input="", - capture_output=True, - text=True, - ) - if probe.returncode != 0 or ".gch" in probe.stderr: - error = f"toolchain cannot load the pch: {probe.stderr.strip()}" - except OSError as err: - error = str(err) + # Transient spawn/IO failure: no marker, retry next build + print(f"ESPHome: precompiled header compile did not run: {err}") + gch.unlink(missing_ok=True) + sum_path.unlink(missing_ok=True) + return if error is not None: print("ESPHome: precompiled header failed; compiling without it") print(error) diff --git a/esphome/writer.py b/esphome/writer.py index d2b16ffcc0..a6b89c4108 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,7 +7,7 @@ import re import time from esphome import loader -from esphome.build_helpers.pch import PCH_HEADER_NAME +from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES from esphome.compiled_config import save_compiled_config from esphome.config import iter_component_configs, iter_components from esphome.const import ( @@ -612,15 +612,8 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): rmtree(idf_path) # The PlatformIO pch artifacts live at the project root so the # relative -include resolves; a partial clean must drop them too - for name in ( - PCH_HEADER_NAME, - f"{PCH_HEADER_NAME}.gch", - f"{PCH_HEADER_NAME}.gch.sum", - f"{PCH_HEADER_NAME}.gch.failed", - ): - pch_path = CORE.relative_build_path(name) - if pch_path.is_file(): - pch_path.unlink() + for name in PCH_ARTIFACT_NAMES: + CORE.relative_build_path(name).unlink(missing_ok=True) # The idedata caches are derived from the build but live under the data # dir, not the build path, so they must be removed separately in both diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 0f879eef47..9747dddb39 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -115,6 +115,7 @@ def _run_script( fail: bool = False, reject_pch: bool = False, probe_exit: int = 0, + missing_cxx: bool = False, env_vars: dict[str, str] | None = None, name: str = "dev", platform_cls: type[_FakePlatform] = _FakePlatform, @@ -124,6 +125,8 @@ def _run_script( (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") cxx = _fake_cxx(tmp_path, fail=fail, reject_pch=reject_pch, probe_exit=probe_exit) + if missing_cxx: + cxx = tmp_path / "no-such-gxx" args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) # Distinct objects: the script must scope ccache/flags to projenv only global_env = _FakeSConsEnv(*args) @@ -219,6 +222,18 @@ def test_pch_script_probe_rejection_falls_back( assert "toolchain cannot load the pch" in capsys.readouterr().out +def test_pch_script_spawn_failure_is_transient( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A spawn failure must not latch a .failed marker (matches espidf).""" + scons_env = _run_script(tmp_path, missing_cxx=True) + proj = tmp_path / "dev" + assert not (proj / "esphome_pch.h.gch.failed").exists() + assert not (proj / "esphome_pch.h.gch.sum").exists() + assert scons_env.prepended == [] + assert "did not run" in capsys.readouterr().out + + def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None: """A probe failure whose stderr never mentions .gch must still count.""" scons_env = _run_script(tmp_path, probe_exit=1) @@ -276,7 +291,9 @@ def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: assert (proj / "esphome_pch.h.gch.sum").read_text() != first -@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes") +@pytest.mark.skipif( + getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes" +) def test_pch_script_unreadable_local_header_warns_and_varies( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 2a41892559a39c78c1d8fb70afb9165a355c1fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:58:05 -0500 Subject: [PATCH 12/15] Simplify: reuse discard_pch, effective_ccache_basedir, and idedata's C++ suffixes --- esphome/build_gen/espidf.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 92f227731b..639148b46b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,7 +6,9 @@ import os from pathlib import Path import subprocess +from esphome.build_helpers.ccache import effective_ccache_basedir from esphome.build_helpers.idedata import ( + CXX_SOURCE_SUFFIXES, expand_response_files, is_launcher, split_command, @@ -61,7 +63,6 @@ _PCH_BUILD_HEADER = f"build/{PCH_HEADER_NAME}" # argument-less depfile flags (the pch compile must not touch depfiles) _PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"}) _PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"}) -_CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx") # Replaces the IDF default C++ standard (-std=gnu++2b appended to # CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via @@ -369,7 +370,7 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] for e in entries if isinstance(e, dict) and e.get("file", "").replace("\\", "/").startswith(src_prefix) - and e.get("file", "").endswith(_CXX_SOURCE_SUFFIXES) + and e.get("file", "").endswith(CXX_SOURCE_SUFFIXES) ), None, ) @@ -453,10 +454,12 @@ def prepare_pch() -> None: "Could not read %s for the pch checksum: %s", sdkconfig_path, err ) sdkconfig = f"unreadable:{type(err).__name__}:{err.errno}" - # Build-path stripped so identical configs hash identically across devices + # Stripped like ccache's own rewriting (a user CCACHE_BASEDIR wins) so + # identical configs hash identically across devices; the raw build path + # covers unresolved spellings in the compile DB cmd_id = ( " ".join(cmd) - .replace(str(Path(CORE.build_path).resolve()), "") + .replace(effective_ccache_basedir(), "") .replace(str(CORE.build_path), "") ) checksum = pch_checksum( @@ -507,8 +510,7 @@ def prepare_pch() -> None: _LOGGER.warning( "Precompiled header failed; compiling without it: %s", error[:400] ) - gch.unlink(missing_ok=True) - sum_path.unlink(missing_ok=True) + discard_pch() # Skip retries until a header/flag/sdkconfig/command change failed_marker.write_text(checksum + "\n", encoding="utf-8") os.utime(header) From 7387211cf1c3b7f18990b21adec07e91c783d74d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 18:02:51 -0500 Subject: [PATCH 13/15] Move the generic pch build machinery into build_helpers/pch.py --- esphome/build_gen/espidf.py | 170 +------------------- esphome/build_helpers/pch.py | 180 +++++++++++++++++++++- tests/unit_tests/build_gen/test_espidf.py | 44 +++--- 3 files changed, 208 insertions(+), 186 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 639148b46b..cec6c4e273 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -2,21 +2,12 @@ import json import logging -import os from pathlib import Path -import subprocess -from esphome.build_helpers.ccache import effective_ccache_basedir -from esphome.build_helpers.idedata import ( - CXX_SOURCE_SUFFIXES, - expand_response_files, - is_launcher, - split_command, -) +from esphome.build_helpers import pch from esphome.build_helpers.pch import ( PCH_CORE_HEADER, PCH_HEADER_NAME, - pch_checksum, pch_enabled, pch_header_text, ) @@ -58,12 +49,6 @@ _PCH_HEADERS = ( # _pch_cmake() and prepare_pch() for the layout rationale _PCH_BUILD_HEADER = f"build/{PCH_HEADER_NAME}" -# Compile-command tokens dropped when retargeting a TU's flags at the -# prefix header: source/output/depfile flags with an argument, and the -# argument-less depfile flags (the pch compile must not touch depfiles) -_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"}) -_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"}) - # Replaces the IDF default C++ standard (-std=gnu++2b appended to # CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via # cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), @@ -348,102 +333,16 @@ set_source_files_properties(${{app_sources}} PROPERTIES """ -def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] | None: - """The exact src C++ flags from compile_commands.json, retargeted at - the header; None (logged) when no configured C++ TU is available yet.""" - try: - entries = json.loads( - (build_dir / "compile_commands.json").read_text(encoding="utf-8") - ) - except (OSError, json.JSONDecodeError) as err: - # Configure already succeeded, so an unusable DB is a real anomaly - _LOGGER.warning("No usable compile database, skipping pch: %s", err) - return None - if not isinstance(entries, list): - _LOGGER.warning("Malformed compile database, skipping pch") - return None - # Windows compile DBs use backslashes; normalize both sides - src_prefix = str(CORE.relative_src_path()).replace("\\", "/") - entry = next( - ( - e - for e in entries - if isinstance(e, dict) - and e.get("file", "").replace("\\", "/").startswith(src_prefix) - and e.get("file", "").endswith(CXX_SOURCE_SUFFIXES) - ), - None, - ) - if entry is None: - _LOGGER.warning("No src C++ entry in the compile database, skipping pch") - return None - tokens = expand_response_files( - split_command(entry.get("command", "")), Path(entry.get("directory", build_dir)) - ) - # A DB recorded with ccache enabled prefixes the compiler with the - # launcher; the .gch must be compiled directly - if tokens and is_launcher(tokens[0]): - tokens = tokens[1:] - if not tokens: - # An "arguments"-style or empty entry must skip cleanly, not spawn - # a compiler-less argv that warns on every build - _LOGGER.warning("Compile database entry has no usable command, skipping pch") - return None - args: list[str] = [] - arg_it = iter(tokens) - for tok in arg_it: - if tok in _PCH_STRIP_FLAGS_WITH_ARG: - next(arg_it, None) - continue - if tok in _PCH_STRIP_FLAGS: - continue - if tok == "-include": - # Drop only the injected prefix; user force-includes must reach - # the .gch compile or GCC rejects it over the macro mismatch - inc = next(arg_it, "") - if not inc.endswith(PCH_HEADER_NAME): - args.extend(("-include", inc)) - continue - args.append(tok) - return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)] - - def discard_pch() -> None: - """Remove the pch sidecars so a stale .gch is never consumed. - - Bumps the header only when a .gch was actually removed: TUs compiled - against it have incomplete depfiles, while a repeat failure with no - .gch must not force a full rebuild every build. - """ - header = CORE.relative_build_path(_PCH_BUILD_HEADER) - gch = Path(f"{header}.gch") - had_gch = gch.is_file() - gch.unlink(missing_ok=True) - Path(f"{gch}.sum").unlink(missing_ok=True) - if had_gch and header.is_file(): - os.utime(header) + """Drop the pch sidecars in the IDF build dir.""" + pch.discard_pch(CORE.relative_build_path("build")) def prepare_pch() -> None: - """Compile the prefix header's .gch and write its ccache .sum. - - Runs right before ninja, after every reconfigure, so the flags in - compile_commands.json and the sdkconfig are the settled ones. The .sum - doubles as the freshness stamp and folds in the compile command, so a - flag-only change rebuilds the .gch. A failed compile falls back to the - plain header include. - """ + """Build the .gch right before ninja, after every reconfigure, so the + compile_commands.json flags and the sdkconfig are the settled ones.""" if not pch_enabled(): return - build_dir = CORE.relative_build_path("build") - header = CORE.relative_build_path(_PCH_BUILD_HEADER) - gch = Path(f"{header}.gch") - sum_path = Path(f"{gch}.sum") - cmd = _pch_compile_command(build_dir, header, gch) - if cmd is None: - # Freshness cannot be validated; a leftover .gch must not be consumed - discard_pch() - return sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") try: sdkconfig = sdkconfig_path.read_text(encoding="utf-8") @@ -454,72 +353,17 @@ def prepare_pch() -> None: "Could not read %s for the pch checksum: %s", sdkconfig_path, err ) sdkconfig = f"unreadable:{type(err).__name__}:{err.errno}" - # Stripped like ccache's own rewriting (a user CCACHE_BASEDIR wins) so - # identical configs hash identically across devices; the raw build path - # covers unresolved spellings in the compile DB - cmd_id = ( - " ".join(cmd) - .replace(effective_ccache_basedir(), "") - .replace(str(CORE.build_path), "") - ) - checksum = pch_checksum( - CORE.relative_src_path(), + pch.prepare_pch( + CORE.relative_build_path("build"), _PCH_HEADERS, ( - # The closure is sorted, so root order only enters via the text - pch_header_text(_PCH_HEADERS), str(idf_version()), CORE.cpp_standard or "", sdkconfig, *get_project_compile_flags(), *get_project_cxx_compile_flags(), - cmd_id, ), ) - if ( - gch.is_file() - and sum_path.is_file() - and sum_path.read_text(encoding="utf-8").strip() == checksum - ): - return - failed_marker = Path(f"{gch}.failed") - if ( - failed_marker.is_file() - and failed_marker.read_text(encoding="utf-8").strip() == checksum - ): - _LOGGER.info( - "Precompiled header disabled after an earlier failure; delete %s to retry", - failed_marker, - ) - return - try: - result = subprocess.run( - cmd, cwd=build_dir, capture_output=True, text=True, check=False, timeout=300 - ) - error = None - if result.returncode != 0: - error = result.stderr.strip() or f"exit code {result.returncode}" - elif not gch.is_file(): - error = "compiler produced no .gch" - except (OSError, subprocess.SubprocessError) as err: - # Transient (timeout, spawn/IO): warn and retry next build, no marker - _LOGGER.warning("Precompiled header compile did not run: %s", err) - discard_pch() - return - if error is not None: - _LOGGER.warning( - "Precompiled header failed; compiling without it: %s", error[:400] - ) - discard_pch() - # Skip retries until a header/flag/sdkconfig/command change - failed_marker.write_text(checksum + "\n", encoding="utf-8") - os.utime(header) - return - failed_marker.unlink(missing_ok=True) - sum_path.write_text(checksum + "\n", encoding="utf-8") - # The OBJECT_DEPENDS edge watches the header; bump it so consumers of - # the previous .gch recompile - os.utime(header) def write_project( diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 4c8d0936b9..6fa09e7457 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -11,13 +11,21 @@ from __future__ import annotations from collections.abc import Iterable import hashlib +import json import logging import os from pathlib import Path import posixpath import re +import subprocess -from esphome.build_helpers.ccache import parse_enable_env +from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env +from esphome.build_helpers.idedata import ( + CXX_SOURCE_SUFFIXES, + expand_response_files, + is_launcher, + split_command, +) _LOGGER = logging.getLogger(__name__) @@ -122,3 +130,173 @@ def pch_checksum( digest.update(item.encode()) digest.update(b"\0") return digest.hexdigest() + + +# Compile-command tokens dropped when retargeting a TU's flags at the +# prefix header: source/output/depfile flags with an argument, and the +# argument-less depfile flags (the pch compile must not touch depfiles) +_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"}) +_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"}) + + +def pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] | None: + """The exact src C++ flags from compile_commands.json, retargeted at + the header; None (logged) when no configured C++ TU is available yet.""" + from esphome.core import CORE + + try: + entries = json.loads( + (build_dir / "compile_commands.json").read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError) as err: + # Configure already succeeded, so an unusable DB is a real anomaly + _LOGGER.warning("No usable compile database, skipping pch: %s", err) + return None + if not isinstance(entries, list): + _LOGGER.warning("Malformed compile database, skipping pch") + return None + # Windows compile DBs use backslashes; normalize both sides + src_prefix = str(CORE.relative_src_path()).replace("\\", "/") + entry = next( + ( + e + for e in entries + if isinstance(e, dict) + and e.get("file", "").replace("\\", "/").startswith(src_prefix) + and e.get("file", "").endswith(CXX_SOURCE_SUFFIXES) + ), + None, + ) + if entry is None: + _LOGGER.warning("No src C++ entry in the compile database, skipping pch") + return None + tokens = expand_response_files( + split_command(entry.get("command", "")), Path(entry.get("directory", build_dir)) + ) + # A DB recorded with ccache enabled prefixes the compiler with the + # launcher; the .gch must be compiled directly + if tokens and is_launcher(tokens[0]): + tokens = tokens[1:] + if not tokens: + # An "arguments"-style or empty entry must skip cleanly, not spawn + # a compiler-less argv that warns on every build + _LOGGER.warning("Compile database entry has no usable command, skipping pch") + return None + args: list[str] = [] + arg_it = iter(tokens) + for tok in arg_it: + if tok in _PCH_STRIP_FLAGS_WITH_ARG: + next(arg_it, None) + continue + if tok in _PCH_STRIP_FLAGS: + continue + if tok == "-include": + # Drop only the injected prefix; user force-includes must reach + # the .gch compile or GCC rejects it over the macro mismatch + inc = next(arg_it, "") + if not inc.endswith(PCH_HEADER_NAME): + args.extend(("-include", inc)) + continue + args.append(tok) + return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)] + + +def discard_pch(build_dir: Path) -> None: + """Remove the pch sidecars so a stale .gch is never consumed. + + Bumps the header only when a .gch was actually removed: TUs compiled + against it have incomplete depfiles, while a repeat failure with no + .gch must not force a full rebuild every build. + """ + header = build_dir / PCH_HEADER_NAME + gch = Path(f"{header}.gch") + had_gch = gch.is_file() + gch.unlink(missing_ok=True) + Path(f"{gch}.sum").unlink(missing_ok=True) + if had_gch and header.is_file(): + os.utime(header) + + +def prepare_pch( + build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str] +) -> None: + """Compile ``build_dir``'s .gch from compile_commands.json flags and + write its ccache .sum. + + The .sum doubles as the freshness stamp and folds in the compile + command, so a flag-only change rebuilds the .gch; ``extra`` carries + backend identity (framework version, sdkconfig, ...). A failed + compile falls back to the plain header include. + """ + from esphome.core import CORE + + header = build_dir / PCH_HEADER_NAME + gch = Path(f"{header}.gch") + sum_path = Path(f"{gch}.sum") + cmd = pch_compile_command(build_dir, header, gch) + if cmd is None: + # Freshness cannot be validated; a leftover .gch must not be consumed + discard_pch(build_dir) + return + # Stripped like ccache's own rewriting (a user CCACHE_BASEDIR wins) so + # identical configs hash identically across devices; the raw build path + # covers unresolved spellings in the compile DB + cmd_id = ( + " ".join(cmd) + .replace(effective_ccache_basedir(), "") + .replace(str(CORE.build_path), "") + ) + checksum = pch_checksum( + CORE.relative_src_path(), + include_headers, + ( + # The closure is sorted, so root order only enters via the text + pch_header_text(include_headers), + *extra, + cmd_id, + ), + ) + if ( + gch.is_file() + and sum_path.is_file() + and sum_path.read_text(encoding="utf-8").strip() == checksum + ): + return + failed_marker = Path(f"{gch}.failed") + if ( + failed_marker.is_file() + and failed_marker.read_text(encoding="utf-8").strip() == checksum + ): + _LOGGER.info( + "Precompiled header disabled after an earlier failure; delete %s to retry", + failed_marker, + ) + return + try: + result = subprocess.run( + cmd, cwd=build_dir, capture_output=True, text=True, check=False, timeout=300 + ) + error = None + if result.returncode != 0: + error = result.stderr.strip() or f"exit code {result.returncode}" + elif not gch.is_file(): + error = "compiler produced no .gch" + except (OSError, subprocess.SubprocessError) as err: + # 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) + return + if error is not None: + _LOGGER.warning( + "Precompiled header failed; compiling without it: %s", error[:400] + ) + discard_pch(build_dir) + # Skip retries until a header/flag/backend-identity/command change + failed_marker.write_text(checksum + "\n", encoding="utf-8") + os.utime(header) + 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); + # bump it so users of the previous .gch recompile + os.utime(header) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 9b7e78f4c1..a1dc30b958 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -549,7 +549,7 @@ def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None: with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile), ): prepare_pch() checksum = (dev / "build" / "esphome_pch.h.gch.sum").read_text().strip() @@ -557,7 +557,7 @@ def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None: # Unchanged inputs: the second call must not recompile with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=AssertionError), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError), ): prepare_pch() @@ -579,7 +579,7 @@ def test_pch_no_device_path_poison(tmp_path: Path) -> None: with ( patch.object(CORE, "name", name), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile), ): prepare_pch() content = get_component_cmakelists() @@ -600,13 +600,13 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None def test_pch_compile_command_variants(tmp_path: Path) -> None: """Missing DB, no matching entry, and launcher-prefixed commands.""" - from esphome.build_gen.espidf import _pch_compile_command + from esphome.build_helpers.pch import pch_compile_command build = tmp_path / "build" build.mkdir() header = build / "esphome_pch.h" gch = build / "esphome_pch.h.gch" - assert _pch_compile_command(build, header, gch) is None + assert pch_compile_command(build, header, gch) is None (build / "compile_commands.json").write_text( json.dumps( @@ -615,7 +615,7 @@ def test_pch_compile_command_variants(tmp_path: Path) -> None: ] ) ) - assert _pch_compile_command(build, header, gch) is None + assert pch_compile_command(build, header, gch) is None src_file = str(tmp_path / "src" / "esphome" / "a.cpp") (build / "compile_commands.json").write_text( @@ -634,7 +634,7 @@ def test_pch_compile_command_variants(tmp_path: Path) -> None: ) ) # Launcher stripped; -include/-o/-c and depfile flags removed - assert _pch_compile_command(build, header, gch) == [ + assert pch_compile_command(build, header, gch) == [ "g++", "-DX=1", "-x", @@ -649,7 +649,7 @@ def test_pch_compile_command_variants(tmp_path: Path) -> None: def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None: """Malformed DB shapes and command-less entries skip cleanly instead of producing a compiler-less argv retried every build.""" - from esphome.build_gen.espidf import _pch_compile_command + from esphome.build_helpers.pch import pch_compile_command build = tmp_path / "build" build.mkdir() @@ -659,16 +659,16 @@ def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None: src_file = str(tmp_path / "src" / "esphome" / "a.cpp") db.write_text(json.dumps({"not": "a list"})) - assert _pch_compile_command(build, header, gch) is None + assert pch_compile_command(build, header, gch) is None db.write_text(json.dumps(["just a string"])) - assert _pch_compile_command(build, header, gch) is None + assert pch_compile_command(build, header, gch) is None # arguments-style entry (allowed by the spec, unused by CMake) db.write_text( json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}]) ) - assert _pch_compile_command(build, header, gch) is None + assert pch_compile_command(build, header, gch) is None def test_pch_header_list_order_is_in_checksum( @@ -688,7 +688,7 @@ def test_pch_header_list_order_is_in_checksum( with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile), ): espidf_mod.prepare_pch() first = (dev / "build" / "esphome_pch.h.gch.sum").read_text() @@ -712,7 +712,7 @@ def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> No with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=failing_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=failing_compile), ): prepare_pch() prepare_pch() @@ -737,7 +737,7 @@ def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None: before = header.stat().st_mtime_ns with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=raising), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=raising), ): prepare_pch() prepare_pch() @@ -762,7 +762,7 @@ def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> No with ( patch.object(CORE, "name", "test"), patch( - "esphome.build_gen.espidf.subprocess.run", + "esphome.build_helpers.pch.subprocess.run", side_effect=OSError("no such compiler"), ), ): @@ -779,7 +779,7 @@ def test_prepare_pch_disabled_is_noop( monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") dev = _make_pch_device(tmp_path, "dev_d") CORE.build_path = dev - with patch("esphome.build_gen.espidf.subprocess.run", side_effect=AssertionError): + with patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError): prepare_pch() @@ -792,7 +792,7 @@ def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None: CORE.build_path = dev with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=AssertionError), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError), ): prepare_pch() assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() @@ -860,7 +860,7 @@ def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None: with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=no_output), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=no_output), ): prepare_pch() assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() @@ -887,7 +887,7 @@ def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None: with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile), ): prepare_pch() assert header.stat().st_mtime > before @@ -914,7 +914,7 @@ def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None: with ( patch.object(CORE, "name", "test"), - patch("esphome.build_gen.espidf.subprocess.run", side_effect=fake_compile), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile), ): prepare_pch() first = (dev / "build" / "esphome_pch.h.gch.sum").read_text() @@ -925,7 +925,7 @@ def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None: def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None: - from esphome.build_gen.espidf import _pch_compile_command + from esphome.build_helpers.pch import pch_compile_command dev = _make_pch_device(tmp_path, "dev_u") CORE.build_path = dev @@ -945,6 +945,6 @@ def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None: ] ) ) - cmd = _pch_compile_command(build, build / "esphome_pch.h", build / "x.gch") + cmd = pch_compile_command(build, build / "esphome_pch.h", build / "x.gch") assert "user.h" in cmd assert "esphome_pch.h" not in " ".join(cmd[:-3]) From 3e2dc30c06d4c7b75d6feed6d95de4f42050d32c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 18:05:23 -0500 Subject: [PATCH 14/15] Move the curated header list to build_helpers as PCH_DEFAULT_HEADERS --- esphome/build_gen/espidf.py | 27 ++++------------------- esphome/build_helpers/pch.py | 17 ++++++++++++++ tests/unit_tests/build_gen/test_espidf.py | 18 ++++++++------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cec6c4e273..f5965c0a27 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,7 +6,7 @@ from pathlib import Path from esphome.build_helpers import pch from esphome.build_helpers.pch import ( - PCH_CORE_HEADER, + PCH_DEFAULT_HEADERS, PCH_HEADER_NAME, pch_enabled, pch_header_text, @@ -29,26 +29,6 @@ from esphome.helpers import mkdir_p, write_file_if_changed _LOGGER = logging.getLogger(__name__) -# Prefix-header contents, defines.h first so USE_* macros exist for the -# rest. Deliberately hard-coded: frequency-derived sets measured no better -# and kept selecting headers that cannot compile standalone (X-macro, -# platform-variant). Every entry must be safe to include first in an -# empty TU. Caveat: application.h/automation.h become ambiently visible, -# so a TU missing those #includes still builds here but not on other -# platforms; ESPHOME_PCH_ENABLE=0 restores the strict view. -_PCH_HEADERS = ( - PCH_CORE_HEADER, - "esphome/core/component.h", - "esphome/core/helpers.h", - "esphome/core/log.h", - "esphome/core/application.h", - "esphome/core/automation.h", -) - -# Header and .gch/.sum sidecars, relative to the device dir; see -# _pch_cmake() and prepare_pch() for the layout rationale -_PCH_BUILD_HEADER = f"build/{PCH_HEADER_NAME}" - # Replaces the IDF default C++ standard (-std=gnu++2b appended to # CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via # cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), @@ -355,7 +335,7 @@ def prepare_pch() -> None: sdkconfig = f"unreadable:{type(err).__name__}:{err.errno}" pch.prepare_pch( CORE.relative_build_path("build"), - _PCH_HEADERS, + PCH_DEFAULT_HEADERS, ( str(idf_version()), CORE.cpp_standard or "", @@ -387,7 +367,8 @@ def write_project( if pch_enabled(): write_file_if_changed( - CORE.relative_build_path(_PCH_BUILD_HEADER), pch_header_text(_PCH_HEADERS) + CORE.relative_build_path("build", PCH_HEADER_NAME), + pch_header_text(PCH_DEFAULT_HEADERS), ) # Snapshot the exclusion set so has_outdated_files() can trigger a diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 6fa09e7457..aa3a65cd3b 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -43,6 +43,23 @@ PCH_ARTIFACT_NAMES = ( # The core defines header every backend anchors its prefix on. PCH_CORE_HEADER = "esphome/core/defines.h" +# Prefix-header contents for backends that inject a curated set (rather +# than mirroring the TUs' own force-includes), defines.h first so USE_* +# macros exist for the rest. Deliberately hard-coded: frequency-derived +# sets measured no better and kept selecting headers that cannot compile +# standalone (X-macro, platform-variant). Every entry must be safe to +# include first in an empty TU. Caveat: application.h/automation.h become +# ambiently visible, so a TU missing those #includes still builds on such +# backends; ESPHOME_PCH_ENABLE=0 restores the strict view. +PCH_DEFAULT_HEADERS = ( + PCH_CORE_HEADER, + "esphome/core/component.h", + "esphome/core/helpers.h", + "esphome/core/log.h", + "esphome/core/application.h", + "esphome/core/automation.h", +) + # ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the # .sum sidecar instead of the .gch bytes, which are not reproducible. # Keep in sync with the literals in platformio/pch.py.script. diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a1dc30b958..ad520b9190 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -494,10 +494,10 @@ def test_get_component_cmakelists_no_compile_features() -> None: def _make_pch_device(tmp_path: Path, name: str) -> Path: """A device dir with the pch source headers and a stub compile_commands.""" - from esphome.build_gen.espidf import _PCH_HEADERS + from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS dev = tmp_path / name - for header in _PCH_HEADERS: + for header in PCH_DEFAULT_HEADERS: path = dev / "src" / header path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") @@ -512,7 +512,7 @@ def _make_pch_device(tmp_path: Path, name: str) -> Path: build.mkdir(exist_ok=True) from esphome.build_helpers.pch import pch_header_text - (build / "esphome_pch.h").write_text(pch_header_text(_PCH_HEADERS)) + (build / "esphome_pch.h").write_text(pch_header_text(PCH_DEFAULT_HEADERS)) # Native separators: mixed f-string paths break the src-prefix match # on Windows src_file = str(dev / "src" / "a.cpp") @@ -674,7 +674,7 @@ def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None: def test_pch_header_list_order_is_in_checksum( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Reordering _PCH_HEADERS keeps the include closure identical, but the + """Reordering PCH_DEFAULT_HEADERS keeps the include closure identical, but the generated header text differs, so the .gch must rebuild.""" import esphome.build_gen.espidf as espidf_mod @@ -693,7 +693,9 @@ def test_pch_header_list_order_is_in_checksum( espidf_mod.prepare_pch() first = (dev / "build" / "esphome_pch.h.gch.sum").read_text() monkeypatch.setattr( - espidf_mod, "_PCH_HEADERS", tuple(reversed(espidf_mod._PCH_HEADERS)) + espidf_mod, + "PCH_DEFAULT_HEADERS", + tuple(reversed(espidf_mod.PCH_DEFAULT_HEADERS)), ) espidf_mod.prepare_pch() assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first @@ -818,8 +820,8 @@ def test_write_project_pch_disabled_writes_no_header( def test_write_project_writes_pch_header(tmp_path: Path) -> None: """The header write_project emits is what _pch_cmake() force-includes; this pairing is the one non-fail-safe path in the design.""" - from esphome.build_gen.espidf import _PCH_HEADERS, write_project - from esphome.build_helpers.pch import pch_header_text + from esphome.build_gen.espidf import write_project + from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_header_text _write_project_description(tmp_path, {}) CORE.build_path = tmp_path @@ -829,7 +831,7 @@ def test_write_project_writes_pch_header(tmp_path: Path) -> None: ): write_project() assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text( - _PCH_HEADERS + PCH_DEFAULT_HEADERS ) From 1b54d1b5b0ce7e051d00a2ae594fd8075e3ecccb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 18:13:15 -0500 Subject: [PATCH 15/15] Survive -t nobuild, skip library trees in the local include digest, drop the false ENV scoping claim --- esphome/build_helpers/idedata.py | 2 +- esphome/platformio/pch.py.script | 24 ++++++++-- .../unit_tests/test_platformio_pch_script.py | 46 +++++++++++++++++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 76319bd681..a2eaea5b7a 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -59,7 +59,7 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: _LOGGER.warning("Idedata failure detail", exc_info=True) -# C++ translation-unit suffixes, shared with the pch backends. +# C++ translation-unit suffixes. CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx") # Suffixes of input/output files that appear bare on the command line (and so # must not be mistaken for compiler flags). diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 310a64c25c..eb2a3362f4 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -8,7 +8,11 @@ import subprocess import traceback # pylint: disable=E0602 -Import("env", "projenv") # noqa: F821 +Import("env") # noqa: F821 +try: + Import("projenv") # noqa: F821 +except Exception: # noqa: BLE001 -- not exported under -t nobuild + projenv = None # Precompile the src force-includes plus defines.h (which pulls in # Arduino.h on Arduino platforms) and force-include the result into C++ src @@ -98,6 +102,8 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path): def _setup_pch() -> None: + if projenv is None: + return # Project root, not $BUILD_DIR: SCons compiles run with the project dir # as cwd, so "-include esphome_pch.h" resolves here as a relative path. # An absolute path would put the per-device build path on every compile @@ -179,9 +185,18 @@ def _setup_pch() -> None: inc_dir.is_dir() and inc_dir.is_relative_to(proj_dir) and not inc_dir.is_relative_to(src_dir) + # Library/build trees are versioned via the package digest above; + # walking them would read every library file on every build + and not inc_dir.is_relative_to(proj_dir / ".piolibdeps") + and not inc_dir.is_relative_to(proj_dir / ".pioenvs") ): continue - for local in sorted(p for p in inc_dir.rglob("*") if p.is_file()): + headers = ( + p + for p in inc_dir.rglob("*") + if p.is_file() and p.suffix in (".h", ".hpp", ".hh", ".inc") + ) + for local in sorted(headers): try: data = local.read_bytes() except OSError as err: @@ -235,8 +250,9 @@ def _setup_pch() -> None: failed_marker.unlink(missing_ok=True) sum_path.write_text(checksum + "\n", encoding="utf-8") - # Scoped to src compiles: framework/library TUs never consume the .gch - # and keep strict ccache hashing. User-set values win. + # projenv["ENV"] aliases os.environ under PlatformIO, so these reach + # framework/library TUs too; only time_macros affects non-pch TUs (the + # trade-off ccache_pch_env documents). User-set values win. for key, value in ( ("CCACHE_SLOPPINESS", "pch_defines,time_macros"), ("CCACHE_PCH_EXTSUM", "true"), diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 9747dddb39..77302a7196 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -128,7 +128,7 @@ def _run_script( if missing_cxx: cxx = tmp_path / "no-such-gxx" args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) - # Distinct objects: the script must scope ccache/flags to projenv only + # Distinct objects: the -include flags must land on projenv only global_env = _FakeSConsEnv(*args) projenv = _FakeSConsEnv(*args) projenv.global_env = global_env @@ -149,13 +149,11 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 # Relative include: an absolute path would poison ccache keys assert scons_env.prepended == ["-Winvalid-pch", "-include", "esphome_pch.h"] - # ccache settings land on projenv's ENV only: framework/library TUs - # compile under the global env and must keep strict hashing + # In production projenv["ENV"] aliases os.environ; only the -include + # flags are genuinely scoped to projenv (src compiles) assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true" - assert scons_env.global_env["ENV"] == {} assert scons_env.global_env.prepended == [] - assert "CCACHE_SLOPPINESS" not in os.environ def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: @@ -275,6 +273,44 @@ def test_copy_pch_script(tmp_path: Path) -> None: assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text() +def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None: + """-t nobuild never exports projenv; the script must not abort.""" + proj = tmp_path / "dev" + (proj / "src").mkdir(parents=True) + + def strict_import(*names: str) -> None: + if "projenv" in names: + raise RuntimeError("Import of non-existent variable 'projenv'") + + env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"]) + exec( # noqa: S102 + compile(_SCRIPT.read_text(), "pch.py", "exec"), + {"Import": strict_import, "env": env}, + ) + assert not (proj / "esphome_pch.h").exists() + + +def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None: + """.piolibdeps and non-header files must not enter the digest (or be + read at all); package versions already cover library identity.""" + proj = tmp_path / "dev" + libdeps = proj / ".piolibdeps" / "lib" / "src" + libdeps.mkdir(parents=True) + (libdeps / "lib.h").write_text("#define A 1\n") + override = proj / "lwip_override" + override.mkdir(parents=True) + (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") + (override / "notes.txt").write_text("v1\n") + flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + (libdeps / "lib.h").write_text("#define A 2\n") + (override / "notes.txt").write_text("v2\n") + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() == first + + def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: """Generated headers in project-local -I dirs (e.g. rp2's lwip_override) must invalidate the checksum when they change."""