From 9636717b322f9fb409b37f69066ef55ac79a2a3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 19:08:21 -0500 Subject: [PATCH] Warn on unresolvable force-includes and pch_defines-less sloppiness, vary the unreadable marker --- esphome/build_helpers/idedata.py | 18 ++++++++++++---- esphome/build_helpers/pch.py | 18 +++++++++++++--- esphome/platformio/pch.py.script | 8 ++++++- .../unit_tests/build_helpers/test_idedata.py | 21 +++++++++++++++++++ tests/unit_tests/build_helpers/test_pch.py | 17 +++++++++++++-- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index a2eaea5b7a..033d2bea0d 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -195,6 +195,7 @@ def parse_entry( defines: list[str] = [] includes: list[str] = [] cxx_flags: list[str] = [] + unresolved_force_includes: list[str] = [] it = iter(tokens[1:]) for tok in it: @@ -207,11 +208,11 @@ def parse_entry( raw = next(it, "") if not raw: _LOGGER.warning("Dropping -include with no argument") + elif Path(resolved := _include(raw)).is_file(): + cxx_flags.extend(("-include", resolved)) else: - resolved = _include(raw) - cxx_flags.extend( - ("-include", resolved if Path(resolved).is_file() else raw) - ) + unresolved_force_includes.append(raw) + cxx_flags.extend(("-include", 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. @@ -230,6 +231,15 @@ def parse_entry( pass # input/output files else: cxx_flags.append(tok) + for raw in unresolved_force_includes: + # A deleted build artifact (clean_build removes esphome_pch.h) would + # otherwise surface only as an opaque downstream tooling error + if not any((Path(inc) / raw).is_file() for inc in includes): + _LOGGER.warning( + "-include %s found neither next to the compile nor on the " + "include path; cached idedata may not resolve it", + raw, + ) return cxx_path, defines, includes, cxx_flags diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 4c8d0936b9..3dc32a71d4 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -57,6 +57,14 @@ def ccache_pch_env() -> dict[str, str]: export these process-wide; only time_macros affects non-pch TUs.""" if not pch_enabled(): return {} + user_sloppiness = os.environ.get("CCACHE_SLOPPINESS") + if user_sloppiness is not None and "pch_defines" not in user_sloppiness: + # EXTSUM without pch_defines makes ccache silently decline every + # pch-consuming compile + _LOGGER.warning( + "CCACHE_SLOPPINESS lacks pch_defines; ccache will not cache " + "compiles that use the precompiled header" + ) return {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ} @@ -96,10 +104,14 @@ def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]: try: data = (src_dir / rel).read_bytes() except OSError as err: - # Hash a marker so an unreadable header invalidates instead of - # silently vanishing from the digest + # mtime/size keep a changed-but-unreadable header shifting the + # digest without device paths in it _LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err) - data = b"" + try: + st = (src_dir / rel).stat() + data = f"".encode() + except OSError: + data = b"" seen[rel] = data parent = posixpath.dirname(rel) stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data)) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index eb2a3362f4..479c19588a 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -46,7 +46,11 @@ def _include_closure(src_dir: Path, roots: list) -> dict: data = (src_dir / rel).read_bytes() except OSError as err: print(f"ESPHome: could not read {rel} for the pch checksum: {err}") - data = b"" + try: + st = (src_dir / rel).stat() + data = f"".encode() + except OSError: + data = b"" seen[rel] = data parent = posixpath.dirname(rel) stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data)) @@ -262,6 +266,8 @@ 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. + # The relative name also reaches "pio run -t idedata" output; external + # consumers replaying cxx_flags must run from the project dir. projenv.Prepend(CXXFLAGS=["-Winvalid-pch", "-include", header.name]) # noqa: F821 print("ESPHome: Compiling with precompiled header") diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index dbbf647060..b0dc6de2ce 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -117,6 +117,27 @@ def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None: assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h" +def test_parse_entry_warns_on_vanished_force_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A build-dir force-include deleted by clean_build must leave a trail; + a name resolvable via the -I chain must not warn.""" + inc = tmp_path / "inc" + inc.mkdir() + (inc / "Arduino.h").write_text("") + entry = _entry( + str(tmp_path), + f"{tmp_path}/src/esphome/x.cpp", + f"g++ -I{inc} -include Arduino.h -include esphome_pch.h -c x.cpp", + ) + + _, _, _, cxx_flags = idedata.parse_entry(entry) + + assert "Arduino.h" in cxx_flags + assert "esphome_pch.h" in caplog.text + assert "Arduino.h" not in caplog.text + + def test_parse_entry_drops_trailing_force_include( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index e2e21dcbb5..693ce80669 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -45,11 +45,22 @@ def test_ccache_pch_env_disabled() -> None: assert pch.ccache_pch_env() == {} -def test_ccache_pch_env_respects_user_values() -> None: +def test_ccache_pch_env_respects_user_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """A user CCACHE_SLOPPINESS wins, but one without pch_defines silently + stops ccache from caching pch consumers, so it must warn.""" with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True): env = pch.ccache_pch_env() assert "CCACHE_SLOPPINESS" not in env assert env["CCACHE_PCH_EXTSUM"] == "true" + assert "lacks pch_defines" in caplog.text + caplog.clear() + with patch.dict( + os.environ, {"CCACHE_SLOPPINESS": "pch_defines,locale"}, clear=True + ): + pch.ccache_pch_env() + assert "lacks pch_defines" not in caplog.text def test_pch_header_text_preserves_order() -> None: @@ -118,7 +129,9 @@ def test_include_closure_marks_unreadable( closure = pch._include_closure(tmp_path, ["a.h"]) finally: locked.chmod(0o644) - assert closure["locked.h"] == b"" + # stat still works, so the marker varies with mtime/size and a later + # edit to the unreadable file still shifts the digest + assert closure["locked.h"].startswith(b"