Add the OBJECT_DEPENDS edge so a rebuilt gch recompiles its consumers

This commit is contained in:
J. Nick Koston
2026-08-25 14:38:04 -05:00
parent 078e918bdb
commit aa6e3f74fd
3 changed files with 99 additions and 7 deletions
+21 -5
View File
@@ -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
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{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(
+4 -2
View File
@@ -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.
+74
View File
@@ -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