Merge branch 'esp8266-native-pch' into esp32-idf-pch

This commit is contained in:
J. Nick Koston
2026-08-25 16:20:06 -05:00
5 changed files with 96 additions and 21 deletions
+5
View File
@@ -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))
+4 -7
View File
@@ -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.
+5 -4
View File
@@ -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():
@@ -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(
+64 -10
View File
@@ -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"