Merge remote-tracking branch 'origin/esp8266-native-pch' into esp8266-native-pch

This commit is contained in:
J. Nick Koston
2026-08-25 18:19:26 -05:00
10 changed files with 413 additions and 64 deletions
+18 -16
View File
@@ -882,13 +882,12 @@ def _ninja_compile_edges(
root: Path,
group: str,
flags: str = "",
cxx_flags: str = "",
cxx_implicit: str = "",
cxx_override: tuple[str, str] | None = None,
) -> list[str]:
"""Emit compile edges for ``sources``; return the object paths.
``cxx_flags``/``cxx_implicit`` override ``flags`` and add an implicit
dependency on C++ edges only (used for the precompiled header).
``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges
only, replacing ``flags`` (used for the precompiled header).
"""
objects = []
for src in sources:
@@ -896,10 +895,10 @@ def _ninja_compile_edges(
obj = f"obj/{group}/{rel}.o"
escaped_obj = _e(obj)
kind = SOURCE_KIND_FOR_SUFFIX[src.suffix]
is_cxx = kind == "cxx"
implicit = f" | {cxx_implicit}" if is_cxx and cxx_implicit else ""
override = cxx_override if kind == "cxx" and cxx_override else None
implicit = f" | {override[1]}" if override else ""
lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}")
edge_flags = cxx_flags if is_cxx and cxx_flags else flags
edge_flags = override[0] if override else flags
if edge_flags:
lines.append(f" flags = {edge_flags}")
# Escaped once here: the returned paths only ever appear in build
@@ -1217,15 +1216,20 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
# One shared variable instead of repeating the flags line on every src
# edge (hundreds of edges in a real project)
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
src_cxx_flags = ""
src_cxx_implicit = ""
src_cxx_override = None
if pch_enabled():
# 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))
pch_text = pch_header_text(pch_includes)
write_file_if_changed(pch_header, pch_text)
if ccache:
# The .sum sidecar only exists for CCACHE_PCH_EXTSUM; ninja's
# depfile handles staleness. Mirror CCACHE_BASEDIR: strip the
@@ -1238,7 +1242,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
src_dir,
pch_includes,
(
pch_header_text(pch_includes),
pch_text,
str(paths.framework),
str(paths.toolchain),
flags_id,
@@ -1254,18 +1258,16 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
# Relative -include (resolved from the ninja cwd, where the header
# lives): an absolute path would put the per-device build path on
# every compile command and defeat cross-device ccache sharing
cxx_parts = src_other + [f"-include {PCH_HEADER_NAME}"]
cxx_parts = src_other + [f"-Winvalid-pch -include {PCH_HEADER_NAME}"]
lines.append(f"srccxxflags = {' '.join(cxx_parts)}")
src_cxx_flags = "$srccxxflags"
src_cxx_implicit = gch
src_cxx_override = ("$srccxxflags", gch)
src_objs = _ninja_compile_edges(
lines,
_collect_sources(src_dir),
src_dir,
"src",
flags="$srcflags",
cxx_flags=src_cxx_flags,
cxx_implicit=src_cxx_implicit,
cxx_override=src_cxx_override,
)
ld_deps = [f"ld/{_COMMON_LD_NAME}"]
+7 -2
View File
@@ -87,7 +87,8 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"CCACHE_DIR": str(cache_dir),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
# A user value wins via the filter below
"CCACHE_BASEDIR": effective_ccache_basedir(),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
@@ -97,4 +98,8 @@ def effective_ccache_basedir() -> str:
wins, else the resolved build path (matching ccache_defaults_env)."""
from esphome.core import CORE
return os.environ.get("CCACHE_BASEDIR") or str(Path(CORE.build_path).resolve())
raw = os.environ.get("CCACHE_BASEDIR")
if raw is not None:
# An explicitly empty value disables ccache's rewriting; mirror it
return raw
return str(Path(CORE.build_path).resolve())
+17 -5
View File
@@ -59,11 +59,11 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
_LOGGER.warning("Idedata failure detail", exc_info=True)
# C++ translation-unit suffixes used to identify ESPHome source files.
_CXX_SUFFIXES = (".cpp", ".cc")
# C++ translation-unit suffixes.
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
# Suffixes of input/output files that appear bare on the command line (and so
# must not be mistaken for compiler flags).
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
# Path marker identifying an ESPHome source translation unit.
_ESPHOME_SRC_MARKER = "/src/esphome/"
@@ -72,7 +72,7 @@ def _is_esphome_src(file: str) -> bool:
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
``/`` first since Windows compile DBs use backslashes."""
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
_CXX_SUFFIXES
CXX_SOURCE_SUFFIXES
)
@@ -147,7 +147,7 @@ def _pick_entry(entries: list[dict]) -> dict:
if _is_esphome_src(entry["file"]):
return entry
for entry in entries:
if entry["file"].endswith(_CXX_SUFFIXES):
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
return entry
raise ValueError("no C++ translation unit found in compile_commands.json")
@@ -200,6 +200,18 @@ def parse_entry(
for tok in it:
if tok in ("-c", "-o"):
next(it, None) # drop the flag and its argument (input/output)
elif tok == "-include":
# -include searches the compile cwd first, then the -I chain, so
# only re-anchor paths that really live next to the compile (the
# pch); a name meant for the -I chain must stay untouched
raw = next(it, "")
if not raw:
_LOGGER.warning("Dropping -include with no argument")
else:
resolved = _include(raw)
cxx_flags.extend(
("-include", resolved if Path(resolved).is_file() else raw)
)
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.
+11 -1
View File
@@ -2,7 +2,9 @@
Safe by construction when the prefix header mirrors what the TUs already
include first (ESP8266); a backend may instead inject a curated set of
self-contained core headers (ESP-IDF).
self-contained core headers (ESP-IDF). User sources from ``esphome:
includes:`` also receive the prefix, so they now see defines.h (and
Arduino.h on Arduino platforms) even when they did not include it.
"""
from __future__ import annotations
@@ -22,6 +24,14 @@ _LOGGER = logging.getLogger(__name__)
# The header and its .gch/.sum sidecars live in the build directory.
PCH_HEADER_NAME = "esphome_pch.h"
# Every artifact the pch machinery can leave behind, for cleanup.
PCH_ARTIFACT_NAMES = (
PCH_HEADER_NAME,
f"{PCH_HEADER_NAME}.gch",
f"{PCH_HEADER_NAME}.gch.sum",
f"{PCH_HEADER_NAME}.gch.failed",
)
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
+99 -22
View File
@@ -5,9 +5,14 @@ import posixpath
import re
import shlex
import subprocess
import traceback
# pylint: disable=E0602
Import("env", "projenv") # noqa: F821
Import("env") # noqa: F821
try:
Import("projenv") # noqa: F821
except Exception: # noqa: BLE001 -- not exported under -t nobuild
projenv = None
# Precompile the src force-includes plus defines.h (which pulls in
# Arduino.h on Arduino platforms) and force-include the result into C++ src
@@ -50,14 +55,55 @@ def _include_closure(src_dir: Path, roots: list) -> dict:
def _shell_arg(element) -> str:
"""One compiler argv from one SCons element, matching the real spawn:
SCons whole-quotes spaced elements, the shell unquotes the rest."""
SCons whole-quotes spaced elements, the shell unquotes the rest. On
Windows there is no POSIX shell pass and shlex would eat path
backslashes."""
arg = str(element)
if " " in arg:
if " " in arg or os.name == "nt":
return arg.replace('\\"', '"')
return shlex.split(arg)[0] if arg else arg
return shlex.split(arg)[0] if arg.strip() else arg
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
"""Compile the .gch, then probe that the toolchain can load it back
(GCC 10 on macOS arm64 builds one it then rejects per-process: "had
text segment at different address"). Returns a deterministic error
string or None; OSError propagates for transient handling."""
result = subprocess.run( # noqa: PLW1510
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
cwd=proj_dir,
capture_output=True,
text=True,
)
if result.returncode != 0:
return result.stderr
probe = subprocess.run( # noqa: PLW1510
[
cxx,
*flags,
"-MF",
os.devnull,
"-Winvalid-pch",
"-include",
str(header),
"-fsyntax-only",
"-x",
"c++",
"-",
],
cwd=proj_dir,
input="",
capture_output=True,
text=True,
)
if probe.returncode != 0 or ".gch" in probe.stderr:
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
return None
def _setup_pch() -> None:
if projenv is None:
return
# Project root, not $BUILD_DIR: SCons compiles run with the project dir
# as cwd, so "-include esphome_pch.h" resolves here as a relative path.
# An absolute path would put the per-device build path on every compile
@@ -104,10 +150,18 @@ def _setup_pch() -> None:
for package in sorted(platform.packages):
try:
version = platform.get_package_version(package)
except Exception as err: # noqa: BLE001 -- absent optional package
# Folded into the digest so an unexpected lookup failure still
# invalidates instead of hashing like a fixed absence
version = f"error:{type(err).__name__}"
except KeyError:
# Only trust KeyError as "absent" when the package really is not
# installed; an unresolved manifest must not hash as a constant
if platform.get_package(package) is not None:
print(f"ESPHome: skipping precompiled header: no version for {package}")
return
version = None # absent optional package
except Exception as err: # noqa: BLE001
# Without trustworthy package identity a stale .gch could be
# reused across upgrades; skip the pch instead
print(f"ESPHome: skipping precompiled header: {err}")
return
digest.update(f"{package}={version}".encode())
digest.update(b"\0")
closure = _include_closure(src_dir, [*include_headers, _CORE_HEADER])
@@ -131,11 +185,31 @@ def _setup_pch() -> None:
inc_dir.is_dir()
and inc_dir.is_relative_to(proj_dir)
and not inc_dir.is_relative_to(src_dir)
# Library/build trees are versioned via the package digest above;
# walking them would read every library file on every build
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
):
continue
for local in sorted(inc_dir.rglob("*.h")):
headers = (
p
for p in inc_dir.rglob("*")
if p.is_file() and p.suffix in (".h", ".hpp", ".hh", ".inc")
)
for local in sorted(headers):
try:
data = local.read_bytes()
except OSError as err:
print(f"ESPHome: could not read {local} for the pch checksum: {err}")
try:
# mtime/size keep a changed-but-unreadable header shifting
# the digest without putting device paths in it
st = local.stat()
data = f"<unreadable:{st.st_mtime_ns}:{st.st_size}>".encode()
except OSError:
data = b"<unreadable>"
digest.update(str(local.relative_to(proj_dir)).encode())
digest.update(local.read_bytes())
digest.update(data)
digest.update(b"\0")
checksum = digest.hexdigest()
@@ -158,15 +232,13 @@ def _setup_pch() -> None:
return
header.write_text(content, encoding="utf-8")
try:
result = subprocess.run( # noqa: PLW1510
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
cwd=proj_dir,
capture_output=True,
text=True,
)
error = result.stderr if result.returncode != 0 else None
error = _compile_gch(cxx, flags, header, gch, proj_dir)
except OSError as err:
error = str(err)
# Transient spawn/IO failure: no marker, retry next build
print(f"ESPHome: precompiled header compile did not run: {err}")
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
return
if error is not None:
print("ESPHome: precompiled header failed; compiling without it")
print(error)
@@ -178,8 +250,9 @@ def _setup_pch() -> None:
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Scoped to src compiles: framework/library TUs never consume the .gch
# and keep strict ccache hashing. User-set values win.
# projenv["ENV"] aliases os.environ under PlatformIO, so these reach
# framework/library TUs too; only time_macros affects non-pch TUs (the
# trade-off ccache_pch_env documents). User-set values win.
for key, value in (
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
("CCACHE_PCH_EXTSUM", "true"),
@@ -189,8 +262,12 @@ def _setup_pch() -> None:
# Prepended so it is processed before the build_src_flags -include
# entries: GCC only uses a .gch while no other tokens have been seen.
projenv.Prepend(CXXFLAGS=["-include", header.name]) # noqa: F821
projenv.Prepend(CXXFLAGS=["-Winvalid-pch", "-include", header.name]) # noqa: F821
print("ESPHome: Compiling with precompiled header")
_setup_pch()
try:
_setup_pch()
except Exception: # noqa: BLE001 -- a speedup must never break the build
print("ESPHome: precompiled header setup failed; compiling without it")
traceback.print_exc()
+5
View File
@@ -7,6 +7,7 @@ import re
import time
from esphome import loader
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
from esphome.compiled_config import save_compiled_config
from esphome.config import iter_component_configs, iter_components
from esphome.const import (
@@ -609,6 +610,10 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
if idf_path.is_dir():
_LOGGER.info("Deleting %s", idf_path)
rmtree(idf_path)
# The PlatformIO pch artifacts live at the project root so the
# relative -include resolves; a partial clean must drop them too
for name in PCH_ARTIFACT_NAMES:
CORE.relative_build_path(name).unlink(missing_ok=True)
# The idedata caches are derived from the build but live under the data
# dir, not the build path, so they must be removed separately in both
@@ -1766,7 +1766,7 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
CORE.build_path = tmp_path / name
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "srccxxflags = -include esphome_pch.h" in content
assert "srccxxflags = -Winvalid-pch -include esphome_pch.h" in content
sums.append(
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
)
@@ -85,6 +85,51 @@ 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(tmp_path: Path) -> None:
"""The pch -include is emitted relative to the build dir; idedata must
resolve it so cached flags work from any cwd."""
(tmp_path / "esphome_pch.h").write_text("")
entry = _entry(
str(tmp_path),
f"{tmp_path}/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 == str(tmp_path / "esphome_pch.h").replace("\\", "/")
def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None:
"""-include names resolved via the -I chain (libretiny's Arduino.h) must
not be re-anchored to a nonexistent build-dir path."""
entry = _entry(
str(tmp_path),
f"{tmp_path}/src/esphome/x.cpp",
"g++ -include Arduino.h -c x.cpp",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h"
def test_parse_entry_drops_trailing_force_include(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
entry = _entry(
str(tmp_path), f"{tmp_path}/src/esphome/x.cpp", "g++ -c x.cpp -include"
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert "-include" not in cxx_flags
assert "no argument" in caplog.text
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
+184 -17
View File
@@ -26,11 +26,36 @@ class _FakePlatform:
raise KeyError(name)
return "1.2.3"
def get_package(self, name: str) -> object | None:
return None
class _BrokenPlatform(_FakePlatform):
def get_package_version(self, name: str) -> str:
raise RuntimeError("manifest parse error")
class _UnresolvedPlatform(_FakePlatform):
"""KeyError from a package that IS installed: unresolved identity."""
def get_package_version(self, name: str) -> str:
raise KeyError(name)
def get_package(self, name: str) -> object:
return object()
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 +63,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,20 +73,37 @@ 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,
probe_exit: int = 0,
) -> 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; probe_exit
sets the exit code of non-header compiles (the load probe).
"""
cxx = tmp_path / "fake-gxx"
body = 'printf \'%s\\n\' "$@" >> "$0.argv"\n'
body = (
'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n'
)
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 += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n'
cxx.write_text("#!/bin/sh\n" + body)
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
return cxx
@@ -70,22 +113,32 @@ def _run_script(
tmp_path: Path,
flags: list[str] | None = None,
fail: bool = False,
reject_pch: bool = False,
probe_exit: int = 0,
missing_cxx: 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, probe_exit=probe_exit)
if missing_cxx:
cxx = tmp_path / "no-such-gxx"
args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls)
# Distinct objects: the -include flags must land on 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:
@@ -95,11 +148,12 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None
assert (proj / "esphome_pch.h.gch").is_file()
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 == ["-include", "esphome_pch.h"]
# ccache settings land on the SCons ENV only, never os.environ
assert scons_env.prepended == ["-Winvalid-pch", "-include", "esphome_pch.h"]
# In production projenv["ENV"] aliases os.environ; only the -include
# flags are genuinely scoped to projenv (src compiles)
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true"
assert "CCACHE_SLOPPINESS" not in os.environ
assert scons_env.global_env.prepended == []
def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
@@ -109,10 +163,11 @@ def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
spaced.mkdir()
flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"]
_run_script(tmp_path, flags=flags)
argv = (tmp_path / "fake-gxx.argv").read_text().splitlines()
assert '-DUSB_PRODUCT="Pico 2W"' in argv
assert str(spaced) in argv
assert "-include" not in argv
calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n")
gch_call = next(c for c in calls if "c++-header" in c).splitlines()
assert '-DUSB_PRODUCT="Pico 2W"' in gch_call
assert str(spaced) in gch_call
assert "-include" not in gch_call
# The stripped -include header is folded into the prefix header instead
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
assert pch.splitlines()[0] == '#include "other.h"'
@@ -151,6 +206,57 @@ 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_spawn_failure_is_transient(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A spawn failure must not latch a .failed marker (matches espidf)."""
scons_env = _run_script(tmp_path, missing_cxx=True)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch.failed").exists()
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "did not run" in capsys.readouterr().out
def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None:
"""A probe failure whose stderr never mentions .gch must still count."""
scons_env = _run_script(tmp_path, probe_exit=1)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch").exists()
assert (proj / "esphome_pch.h.gch.failed").is_file()
assert scons_env.prepended == []
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
"""A KeyError for an installed package is unresolved identity, not absence."""
scons_env = _run_script(tmp_path, platform_cls=_UnresolvedPlatform)
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert scons_env.prepended == []
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"
@@ -167,6 +273,44 @@ def test_copy_pch_script(tmp_path: Path) -> None:
assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text()
def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None:
"""-t nobuild never exports projenv; the script must not abort."""
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
def strict_import(*names: str) -> None:
if "projenv" in names:
raise RuntimeError("Import of non-existent variable 'projenv'")
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
exec( # noqa: S102
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
assert not (proj / "esphome_pch.h").exists()
def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None:
""".piolibdeps and non-header files must not enter the digest (or be
read at all); package versions already cover library identity."""
proj = tmp_path / "dev"
libdeps = proj / ".piolibdeps" / "lib" / "src"
libdeps.mkdir(parents=True)
(libdeps / "lib.h").write_text("#define A 1\n")
override = proj / "lwip_override"
override.mkdir(parents=True)
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
(override / "notes.txt").write_text("v1\n")
flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
(libdeps / "lib.h").write_text("#define A 2\n")
(override / "notes.txt").write_text("v2\n")
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() == first
def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
"""Generated headers in project-local -I dirs (e.g. rp2's lwip_override)
must invalidate the checksum when they change."""
@@ -181,3 +325,26 @@ def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
@pytest.mark.skipif(
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
)
def test_pch_script_unreadable_local_header_warns_and_varies(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""An unreadable generated header still shifts the digest via mtime/size."""
proj = tmp_path / "dev"
override = proj / "lwip_override"
override.mkdir(parents=True)
secret = override / "lwipopts.h"
secret.write_text("#define TCP_MSS 1460\n")
secret.chmod(0)
flags = ["-DX=1", "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
assert "could not read" in capsys.readouterr().out
os.utime(secret, (1, 1))
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
+26
View File
@@ -677,6 +677,32 @@ def test_clean_build_partial_exists(
assert "dependencies.lock" not in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_partial_removes_pch_artifacts(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""The PlatformIO pch sidecars live at the project root and must go in
a partial clean, like the native backend's under .pioenvs."""
names = (
"esphome_pch.h",
"esphome_pch.h.gch",
"esphome_pch.h.gch.sum",
"esphome_pch.h.gch.failed",
)
for name in names:
(tmp_path / name).write_text("x")
mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs"
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
clean_build()
for name in names:
assert not (tmp_path / name).exists()
@patch("esphome.writer.CORE")
def test_clean_build_nothing_exists(
mock_core: MagicMock,