Files
esphome/esphome/platformio/pch.py.script
T

274 lines
10 KiB
Plaintext

import hashlib
import os
from pathlib import Path
import posixpath
import re
import shlex
import subprocess
import traceback
# pylint: disable=E0602
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
# compiles only; those TUs already include this content first, so their
# preprocessed output is unchanged. Post script: the final src flags exist,
# nothing compiled yet. Registration is gated host-side (the platform's
# __init__.py, pch_enabled()). Keep the header tail, ccache values,
# include-closure recipe, and the checksum/failed-marker stamp flow in sync
# with build_helpers/pch.py.
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+"([^"]+)"', re.MULTILINE)
_CORE_HEADER = "esphome/core/defines.h"
def _include_closure(src_dir: Path, roots: list) -> dict:
"""Quoted-include closure: src-relative name -> contents (mirror of
build_helpers/pch.py)."""
seen = {}
stack = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and (src_dir / rel).is_file():
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
data = b"<unreadable>"
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data))
return seen
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. On
Windows there is no POSIX shell pass and shlex would eat path
backslashes."""
arg = str(element)
if " " in arg or os.name == "nt":
return arg.replace('\\"', '"')
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
# command and defeat cross-device ccache sharing.
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
header = proj_dir / "esphome_pch.h"
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cxx = projenv.subst("$CXX") # noqa: F821
# The header holds the -include entries itself, so the .gch compile must
# not see them; consumers keep theirs, which the .gch then satisfies.
flags = []
include_headers = []
flag_it = iter(
_shell_arg(element)
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
)
for tok in flag_it:
if tok == "-include":
include_headers.append(next(flag_it, ""))
else:
flags.append(tok)
if any(not name for name in include_headers):
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
return
content = "".join(
f'#include "{name}"\n' for name in (*include_headers, _CORE_HEADER)
)
digest = hashlib.sha256()
digest.update(content.encode())
digest.update(cxx.encode())
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
# configs produce identical .sum files and share cache entries
flags_id = " ".join(flags)
if basedir := os.environ.get("CCACHE_BASEDIR"):
flags_id = flags_id.replace(basedir, "")
digest.update(flags_id.encode())
# GCC never validates a .gch against its source headers, and PlatformIO
# package paths carry no version, so a package bump must invalidate here
platform = env.PioPlatform() # noqa: F821
for package in sorted(platform.packages):
try:
version = platform.get_package_version(package)
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])
for rel in sorted(closure):
digest.update(rel.encode())
digest.update(closure[rel])
digest.update(b"\0")
# Project-local include dirs outside src (e.g. rp2's lwip_override)
# hold generated headers the src closure cannot see; hash them so an
# ESPHome-side change invalidates an existing build dir
prev = ""
for tok in flags:
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
if prev == "-I":
inc = tok
prev = tok
if not inc:
continue
inc_dir = Path(inc)
if not (
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
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(data)
digest.update(b"\0")
checksum = digest.hexdigest()
# The ccache .sum sidecar doubles as the freshness stamp
if (
not header.is_file()
or not gch.is_file()
or not sum_path.is_file()
or (sum_path.read_text(encoding="utf-8").strip() != checksum)
):
failed_marker = Path(f"{gch}.failed")
if (
failed_marker.is_file()
and failed_marker.read_text(encoding="utf-8").strip() == checksum
):
print(
"ESPHome: skipping precompiled header (previous attempt "
f"failed); delete {failed_marker.name} to retry"
)
return
header.write_text(content, encoding="utf-8")
try:
error = _compile_gch(cxx, flags, header, gch, proj_dir)
except OSError as 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)
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
# Skip retries until a flag/header/platform change alters the checksum
failed_marker.write_text(checksum + "\n", encoding="utf-8")
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# 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"),
):
if key not in os.environ:
projenv["ENV"][key] = value # noqa: F821
# 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=["-Winvalid-pch", "-include", header.name]) # noqa: F821
print("ESPHome: Compiling with precompiled header")
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()