diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index f5965c0a27..ba6daed1ec 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -322,6 +322,8 @@ def prepare_pch() -> None: """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(): + # Self-cleaning escape hatch: drop any previously built .gch + pch.discard_pch(CORE.relative_build_path("build")) return sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") try: diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index aa3a65cd3b..6060b62a61 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -156,9 +156,13 @@ _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.""" +def pch_compile_command( + build_dir: Path, header: Path, gch: Path +) -> tuple[list[str], Path] | None: + """The exact src C++ flags from compile_commands.json retargeted at the + header, with the directory they resolve against (relative -I paths must + be expanded and executed from the same root); None (logged) when no + configured C++ TU is available yet.""" from esphome.core import CORE try: @@ -187,9 +191,8 @@ def pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] | 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)) - ) + cmd_dir = Path(entry.get("directory", build_dir)) + tokens = expand_response_files(split_command(entry.get("command", "")), cmd_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]): @@ -215,7 +218,7 @@ def pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] | args.extend(("-include", inc)) continue args.append(tok) - return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)] + return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir def discard_pch(build_dir: Path) -> None: @@ -247,14 +250,18 @@ def prepare_pch( """ from esphome.core import CORE + _LOGGER.info( + "Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)" + ) 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: + cmd_and_dir = pch_compile_command(build_dir, header, gch) + if cmd_and_dir is None: # Freshness cannot be validated; a leftover .gch must not be consumed discard_pch(build_dir) return + cmd, cmd_dir = cmd_and_dir # 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 @@ -291,7 +298,7 @@ def prepare_pch( return try: result = subprocess.run( - cmd, cwd=build_dir, capture_output=True, text=True, check=False, timeout=300 + cmd, cwd=cmd_dir, capture_output=True, text=True, check=False, timeout=300 ) error = None if result.returncode != 0: diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ad520b9190..d8d3725804 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -492,6 +492,12 @@ def test_get_component_cmakelists_no_compile_features() -> None: assert "target_compile_features" not in content +@pytest.fixture(autouse=True) +def _pch_default_on(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the knob so a developer's ESPHOME_PCH_ENABLE=0 cannot fail these.""" + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + + 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_helpers.pch import PCH_DEFAULT_HEADERS @@ -634,7 +640,8 @@ 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) == [ + cmd, cmd_dir = pch_compile_command(build, header, gch) + assert cmd == [ "g++", "-DX=1", "-x", @@ -644,6 +651,8 @@ def test_pch_compile_command_variants(tmp_path: Path) -> None: "-o", str(gch), ] + # The compile must run where the flags were resolved + assert cmd_dir == build def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None: @@ -947,6 +956,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]) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 7e48593ad3..67e0aa8c29 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1582,6 +1582,9 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: assert env["CCACHE_NOHASHDIR"] == "true" assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + # The pch cannot cache under ccache without these + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: @@ -1991,12 +1994,3 @@ 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"