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 97faf0f504..1f59241281 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -200,13 +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) - 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 == "-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 f21e28cd13..9ad2bbd2b9 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"