diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 1d4ecda45c..b8f5e95db5 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,6 +3,7 @@ import hashlib import json import logging +import os from pathlib import Path import subprocess @@ -331,11 +332,16 @@ def _pch_cmake() -> str: if not pch_enabled(): return "" return f""" -# ESPHome precompiled header (see esphome/build_helpers/pch.py) +# ESPHome precompiled header (see esphome/build_helpers/pch.py). The +# OBJECT_DEPENDS edge is on the header, not the .gch: headers baked into +# 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 "$<$:-include>" "$<$:{PCH_HEADER_NAME}>" ) +set_source_files_properties(${{app_sources}} PROPERTIES + OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}") """ @@ -349,12 +355,13 @@ def _pch_compile_command(build_dir: Path, header: Path, gch: Path) -> list[str] except (OSError, json.JSONDecodeError) as err: _LOGGER.debug("No usable compile database, skipping pch: %s", err) return None - src_prefix = str(CORE.relative_src_path()) + # 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", "").startswith(src_prefix) + if e.get("file", "").replace("\\", "/").startswith(src_prefix) and e.get("file", "").endswith(_CXX_SOURCE_SUFFIXES) ), None, @@ -420,6 +427,9 @@ def prepare_pch() -> None: 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 @@ -433,12 +443,14 @@ def prepare_pch() -> None: return try: result = subprocess.run( - cmd, cwd=build_dir, capture_output=True, text=True, check=False + 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}" - except OSError as err: + elif not gch.is_file(): + error = "compiler produced no .gch" + except (OSError, subprocess.SubprocessError) as err: error = str(err) if error is not None: _LOGGER.warning( @@ -448,9 +460,13 @@ def prepare_pch() -> None: 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") + 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/idedata.py b/esphome/build_helpers/idedata.py index 97e04407fc..8a46203890 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -198,8 +198,10 @@ def parse_entry( it = iter(tokens[1:]) for tok in it: - if tok in ("-c", "-o"): - next(it, None) # drop the flag and its argument (input/output) + 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) 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/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 34e5b3bca1..22c3f7ef8d 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -500,6 +500,13 @@ def _make_pch_device(tmp_path: Path, name: str) -> Path: path = dev / "src" / header path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") + # A real quoted include chain and a per-device-named sdkconfig with + # identical content: the closure and sdkconfig inputs must be exercised + (dev / "src" / "esphome" / "core" / "defines.h").write_text( + '#include "esphome/core/macros.h"\n' + ) + (dev / "src" / "esphome" / "core" / "macros.h").write_text("#define M 1\n") + (dev / f"sdkconfig.{name}").write_text("CONFIG_X=y\n") build = dev / "build" build.mkdir(exist_ok=True) from esphome.build_helpers.pch import pch_header_text @@ -736,3 +743,70 @@ def test_write_project_writes_pch_header(tmp_path: Path) -> None: assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text( _PCH_HEADERS ) + + +def test_prepare_pch_stale_bailout_removes_gch(tmp_path: Path) -> None: + """A stale .gch must not survive when no compile command is available.""" + 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") + (dev / "build" / "esphome_pch.h.gch.sum").write_text("stale-sum\n") + (dev / "build" / "compile_commands.json").unlink() + with patch.object(CORE, "name", "test"): + prepare_pch() + assert not gch.exists() + assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() + + +def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None: + from esphome.build_gen.espidf import prepare_pch + + dev = _make_pch_device(tmp_path, "dev_z") + CORE.build_path = dev + + def no_output(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "", "") + + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_gen.espidf.subprocess.run", side_effect=no_output), + ): + prepare_pch() + assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() + assert (dev / "build" / "esphome_pch.h.gch.failed").exists() + + +def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None: + """The OBJECT_DEPENDS edge watches the header; a rebuilt .gch must bump + it so pch-consuming TUs recompile.""" + import os as _os + + from esphome.build_gen.espidf import prepare_pch + + dev = _make_pch_device(tmp_path, "dev_t") + CORE.build_path = dev + header = dev / "build" / "esphome_pch.h" + gch = dev / "build" / "esphome_pch.h.gch" + _os.utime(header, (0, 0)) + before = header.stat().st_mtime + + 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() + assert header.stat().st_mtime > before + + +def test_component_cmakelists_pch_object_depends() -> None: + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content