mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 20:16:01 +00:00
Compare commits
199
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1c91a5f7 | ||
|
|
e2229f1a5e | ||
|
|
f99b95e9a2 | ||
|
|
2b7f5f0ea9 | ||
|
|
b81d250737 | ||
|
|
85b03d9dc1 | ||
|
|
8639526e5f | ||
|
|
95fd6c42f3 | ||
|
|
cdd89dbfed | ||
|
|
59367c9c44 | ||
|
|
2a41f7e2a8 | ||
|
|
9ef69d1576 | ||
|
|
150f0a3b6c | ||
|
|
cf8872344b | ||
|
|
6dc37ffe86 | ||
|
|
f528758c8a | ||
|
|
4af285a5a0 | ||
|
|
e43d2f71f4 | ||
|
|
741e436d2c | ||
|
|
34c54e4dce | ||
|
|
168d96d9f4 | ||
|
|
e6dfdc8009 | ||
|
|
4b43c026f0 | ||
|
|
0f6309bc92 | ||
|
|
94fc9ec63a | ||
|
|
340ac06699 | ||
|
|
455b14e10f | ||
|
|
9e995377d5 | ||
|
|
e51f711883 | ||
|
|
ece74ee0d1 | ||
|
|
7a947cd4c9 | ||
|
|
40f548a529 | ||
|
|
addcb472af | ||
|
|
4ecbde8bbc | ||
|
|
9794eb9fc2 | ||
|
|
fc41843cd5 | ||
|
|
b5c78a4711 | ||
|
|
a114147e03 | ||
|
|
80b592fa16 | ||
|
|
665f1d46ca | ||
|
|
5bc3102c82 | ||
|
|
a5e4882196 | ||
|
|
4f9a02f85a | ||
|
|
8f1e581bf6 | ||
|
|
c4c6fe198e | ||
|
|
7850fba837 | ||
|
|
c2eb877a17 | ||
|
|
72c670f5f1 | ||
|
|
41d68d2978 | ||
|
|
59556a730d | ||
|
|
7d4e28b5e2 | ||
|
|
87cbe42cae | ||
|
|
8180134d30 | ||
|
|
8a5d53899c | ||
|
|
92b71f8ce4 | ||
|
|
4605ac08a5 | ||
|
|
3e5688d6cd | ||
|
|
9580fd14da | ||
|
|
d38542e48b | ||
|
|
364c530832 | ||
|
|
c89c2d975d | ||
|
|
a0c3a010e4 | ||
|
|
fd871b8898 | ||
|
|
78eff4f485 | ||
|
|
833af674bf | ||
|
|
15d4f187c7 | ||
|
|
21643396c4 | ||
|
|
34daa6138a | ||
|
|
0266b7f0fb | ||
|
|
5d690104b5 | ||
|
|
4a3b095172 | ||
|
|
54bff55642 | ||
|
|
264ccc0989 | ||
|
|
7449623022 | ||
|
|
2eab26052a | ||
|
|
1e13390b6d | ||
|
|
c916e93988 | ||
|
|
3e3655036b | ||
|
|
58d6aec7fd | ||
|
|
16e4e05d92 | ||
|
|
153bfbc47c | ||
|
|
c5d14b9423 | ||
|
|
694741cd6b | ||
|
|
c2d1b1c583 | ||
|
|
198e4abc5d | ||
|
|
77fc8af9f0 | ||
|
|
b8675a4b56 | ||
|
|
f4199a7510 | ||
|
|
c7f0e17409 | ||
|
|
c6d056b240 | ||
|
|
ca81777eba | ||
|
|
f665ecf977 | ||
|
|
5e95306bcc | ||
|
|
c025b875d1 | ||
|
|
6a5a092f03 | ||
|
|
639e1c2fdf | ||
|
|
6722ec22ab | ||
|
|
e2446cad8f | ||
|
|
404cf7b728 | ||
|
|
2a7245ac79 | ||
|
|
1ca8b7066c | ||
|
|
0fea10f263 | ||
|
|
187199e9b3 | ||
|
|
a892f47f89 | ||
|
|
9fa031f134 | ||
|
|
bffbc6dce5 | ||
|
|
ba9131324a | ||
|
|
5ab40084c9 | ||
|
|
6223ecda32 | ||
|
|
c1c78ebf18 | ||
|
|
dd05cdd2f8 | ||
|
|
80206fd232 | ||
|
|
e18bcc74f3 | ||
|
|
5b53eb9f6c | ||
|
|
0768a1c8cf | ||
|
|
5cd9e77f3d | ||
|
|
5a05184ee3 | ||
|
|
53a3501bdc | ||
|
|
bcdefa873b | ||
|
|
9304661da3 | ||
|
|
fe95bd2d0c | ||
|
|
85d49bd940 | ||
|
|
36062184a1 | ||
|
|
5f81a04086 | ||
|
|
ac176636db | ||
|
|
68ba1b9a7f | ||
|
|
63e6f9bf70 | ||
|
|
f4a5671445 | ||
|
|
2beabb2ca3 | ||
|
|
c6f21e2447 | ||
|
|
9be7585000 | ||
|
|
80c9ccd657 | ||
|
|
c405d94f45 | ||
|
|
0194e2349c | ||
|
|
cf2ddde04c | ||
|
|
565ffad6f4 | ||
|
|
2145f50bb5 | ||
|
|
62f8451831 | ||
|
|
120ac5e516 | ||
|
|
259824d13d | ||
|
|
daf273b650 | ||
|
|
90c804886d | ||
|
|
e6e738775b | ||
|
|
c15bafacfb | ||
|
|
7bd2dd944c | ||
|
|
5d745f0605 | ||
|
|
5672302b01 | ||
|
|
e25ffaf017 | ||
|
|
80b62ceacc | ||
|
|
256457384c | ||
|
|
d5609f8dc6 | ||
|
|
d08bb3992a | ||
|
|
8197fcd5a1 | ||
|
|
85520f520e | ||
|
|
2dd850b990 | ||
|
|
b11b60dd86 | ||
|
|
efeb608725 | ||
|
|
600dab7185 | ||
|
|
609d9dfab3 | ||
|
|
a28086547f | ||
|
|
0cdd654cc6 | ||
|
|
a400753d96 | ||
|
|
0f5050a617 | ||
|
|
4819f986bc | ||
|
|
904083d531 | ||
|
|
d8255cbece | ||
|
|
6175ff24fe | ||
|
|
10a81076b2 | ||
|
|
9a2644e6e6 | ||
|
|
ceac527259 | ||
|
|
0908d1d3c6 | ||
|
|
408dcd353a | ||
|
|
ca9500b5a8 | ||
|
|
cce6f71076 | ||
|
|
36790f78ab | ||
|
|
f215e3f45b | ||
|
|
fcaf17dafa | ||
|
|
be60eca212 | ||
|
|
b24bfa7552 | ||
|
|
6431f6da35 | ||
|
|
4c26f23e76 | ||
|
|
f438e5a19a | ||
|
|
92233e0ff2 | ||
|
|
8421fe0170 | ||
|
|
91eb7f2f7c | ||
|
|
009e28998d | ||
|
|
e52af0dfa8 | ||
|
|
250c5bf606 | ||
|
|
f5fd82bc42 | ||
|
|
958ea20cc4 | ||
|
|
243b8a871c | ||
|
|
352129e77e | ||
|
|
e0a7347f41 | ||
|
|
0007dfab70 | ||
|
|
52176c4d59 | ||
|
|
e0662b1551 | ||
|
|
61009e1162 | ||
|
|
d5c044c7b3 | ||
|
|
94b02b46f4 |
@@ -197,6 +197,7 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -207,6 +208,17 @@ jobs:
|
||||
- ln882x-arduino
|
||||
- nrf52
|
||||
- host
|
||||
# Strict by default so a new matrix id cannot silently join in the
|
||||
# degrade-quietly mode the knob exists to catch; the knob is inert
|
||||
# where no pch code runs (esp32-*-platformio, nrf52).
|
||||
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
|
||||
include:
|
||||
- id: bk72xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: rtl87xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: ln882x-arduino
|
||||
pch_strict: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
@@ -218,6 +230,7 @@ jobs:
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -1298,8 +1298,10 @@ jobs:
|
||||
echo ""
|
||||
|
||||
# ESP8266 Arduino built directly (no PlatformIO); compile validates
|
||||
# config first, so a separate config pass is redundant.
|
||||
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
# config first, so a separate config pass is redundant. Strict pch:
|
||||
# exercises the native ninja pch (and its probe edge) against real
|
||||
# component configs; the docker matrix smoke-tests both toolchains.
|
||||
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
@@ -39,8 +39,13 @@ from esphome.build_helpers.pch import (
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_checksum,
|
||||
pch_consumer_escalation,
|
||||
pch_degraded,
|
||||
pch_disabled_degraded,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
pch_probe_args,
|
||||
pch_strict,
|
||||
)
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import (
|
||||
@@ -1231,6 +1236,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
"A -include in build_flags prevents the precompiled header from "
|
||||
"loading; compiling without it"
|
||||
)
|
||||
pch_degraded("a user -include precedes the pch")
|
||||
elif pch_enabled():
|
||||
# C++ src edges swap the force-includes for one precompiled prefix
|
||||
# header (same content plus defines.h); C/assembly keep srcflags
|
||||
@@ -1264,6 +1270,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
_LOGGER.warning(
|
||||
"Could not establish the pch identity; compiling without it: %s", err
|
||||
)
|
||||
pch_degraded(f"identity unknown: {err}")
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Compiling with a precompiled header "
|
||||
@@ -1283,13 +1290,37 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
if src_other:
|
||||
lines.append(f" flags = {' '.join(src_other)}")
|
||||
# Relative -include: absolute would break cross-device ccache.
|
||||
# -Wno-error keeps a rejected .gch a warning under user -Werror
|
||||
# -Wno-error keeps a rejected .gch a warning under user -Werror;
|
||||
# strict inverts it so any consumer rejection reds the build
|
||||
# (rejection is per-process, so the probe alone cannot prove
|
||||
# the consumers)
|
||||
escalation = pch_consumer_escalation()
|
||||
cxx_parts = src_other + [
|
||||
f"-Winvalid-pch -Wno-error=invalid-pch -include {PCH_HEADER_NAME}"
|
||||
f"-Winvalid-pch {escalation} -include {PCH_HEADER_NAME}"
|
||||
]
|
||||
lines.append(f"srccxxflags = {' '.join(cxx_parts)}")
|
||||
src_cxx_override = ("$srccxxflags", gch)
|
||||
pch_dep = gch
|
||||
if pch_strict():
|
||||
# Consumers wait on the probe stamp, so an unloadable .gch
|
||||
# reds the build here instead of warning ~100 times
|
||||
probe = " ".join(pch_probe_args(PCH_HEADER_NAME, source=os.devnull))
|
||||
lines.append("rule pchprobe")
|
||||
# $out only expands in rule text, hence the inline stamp
|
||||
lines.append(
|
||||
f" command = $cxx $cxxflags $flags {probe}"
|
||||
" && $python $buildtool touch $out"
|
||||
)
|
||||
lines.append(" description = PCHPROBE $out")
|
||||
# Runs when the .gch is (re)built; strict consumer -Werror
|
||||
# covers a cached .gch this process cannot load
|
||||
lines.append(f"build esphome_pch.probe: pchprobe {gch}")
|
||||
if src_other:
|
||||
lines.append(f" flags = {' '.join(src_other)}")
|
||||
pch_dep = f"{gch} esphome_pch.probe"
|
||||
src_cxx_override = ("$srccxxflags", pch_dep)
|
||||
mark_pch_emitted()
|
||||
else:
|
||||
pch_disabled_degraded()
|
||||
src_objs = _ninja_compile_edges(
|
||||
lines,
|
||||
_collect_sources(src_dir),
|
||||
|
||||
@@ -6,6 +6,7 @@ started esphome and must not depend on the package being importable.
|
||||
Subcommands:
|
||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||
copy <src> <dst> copy a file
|
||||
touch <path> create/update a stamp file
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
@@ -83,9 +84,18 @@ def _run_copy(src: str, dst: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_touch(path: str) -> int:
|
||||
try:
|
||||
Path(path).touch()
|
||||
except OSError as err:
|
||||
print(f"touch: {path} failed: {err}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# mode -> (handler, expected operand count); surplus argv means a
|
||||
# mis-specified ninja rule and must error, not silently drop operands
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -298,12 +298,17 @@ def _pch_cmake() -> str:
|
||||
"""
|
||||
if not pch_enabled():
|
||||
return ""
|
||||
# Strict inverts: a per-process consumer rejection reds the build.
|
||||
# Baked at generation: a knob flip takes effect when the CMakeLists is
|
||||
# rewritten (every esphome compile); a hand-run idf.py keeps the old one
|
||||
escalation = pch.pch_consumer_escalation()
|
||||
return f"""
|
||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
||||
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
|
||||
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
|
||||
target_compile_options(${{COMPONENT_LIB}} PRIVATE
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
||||
)
|
||||
@@ -323,6 +328,7 @@ def prepare_pch() -> None:
|
||||
if not pch_enabled():
|
||||
# Self-cleaning escape hatch: drop any previously built .gch
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
||||
try:
|
||||
@@ -334,6 +340,7 @@ def prepare_pch() -> None:
|
||||
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
|
||||
)
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_degraded(f"sdkconfig unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
CORE.relative_build_path("build"),
|
||||
|
||||
@@ -21,12 +21,13 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies.
|
||||
as unset so the caller's default policy applies — or raises when
|
||||
``strict`` (a typo must not silently disable a CI gate).
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
@@ -39,6 +40,10 @@ def parse_enable_env(name: str) -> bool | None:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
if strict:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
+175
-49
@@ -8,6 +8,7 @@ it too; Arduino.h visibility there is intended (esphome#8693).
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
@@ -102,6 +103,55 @@ def pch_enabled() -> bool:
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
||||
|
||||
|
||||
def pch_strict() -> bool:
|
||||
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
|
||||
|
||||
A set-but-unrecognized value raises: a typo must not silently turn
|
||||
the gate into a no-op that proves nothing.
|
||||
"""
|
||||
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
|
||||
|
||||
|
||||
def pch_degraded(reason: str) -> None:
|
||||
"""Every degrade path funnels through here; strict mode raises."""
|
||||
if pch_strict():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
|
||||
|
||||
|
||||
def pch_disabled_degraded() -> None:
|
||||
"""Strict CI must not read "no pch at all" as success."""
|
||||
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
|
||||
|
||||
|
||||
def pch_probe_tail(source: str = "-") -> list[str]:
|
||||
"""The syntax-only compile shared by the probe and its baseline."""
|
||||
return ["-fsyntax-only", "-x", "c++", source]
|
||||
|
||||
|
||||
def pch_probe_args(header: str, source: str = "-") -> list[str]:
|
||||
"""Flags that load-check a built .gch via a syntax-only compile.
|
||||
|
||||
Rejection must be a nonzero exit (never just a wording match), so the
|
||||
invalid-pch class is always escalated. ``source`` defaults to stdin
|
||||
(host independent); the ninja probe edge passes a real file.
|
||||
"""
|
||||
return [
|
||||
"-Winvalid-pch",
|
||||
"-Werror=invalid-pch",
|
||||
"-include",
|
||||
header,
|
||||
*pch_probe_tail(source),
|
||||
]
|
||||
|
||||
|
||||
def pch_consumer_escalation() -> str:
|
||||
"""Consumer-side invalid-pch flag: strict reds the build on rejection
|
||||
(per-process, so the probe alone cannot prove the consumers)."""
|
||||
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
|
||||
|
||||
|
||||
def ccache_pch_env() -> dict[str, str]:
|
||||
"""Settings ccache needs to cache compiles that consume the .gch;
|
||||
empty unless this build actually emitted one. User-set values win.
|
||||
@@ -135,7 +185,10 @@ def ccache_pch_env() -> dict[str, str]:
|
||||
def pch_extra_scripts() -> list[str]:
|
||||
"""The extra_scripts entries a PlatformIO platform registers for the
|
||||
pch; empty when disabled (the script itself has no enable check)."""
|
||||
return ["post:pch.py"] if pch_enabled() else []
|
||||
if not pch_enabled():
|
||||
pch_disabled_degraded()
|
||||
return []
|
||||
return ["post:pch.py"]
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
@@ -303,15 +356,30 @@ def discard_pch(build_dir: Path) -> None:
|
||||
|
||||
Bumps the header only when a .gch was actually removed: TUs compiled
|
||||
against it have incomplete depfiles, while a repeat failure with no
|
||||
.gch must not force a full rebuild every build.
|
||||
.gch must not force a full rebuild every build. A .gch that survives
|
||||
an unlink failure would be consumed silently (wrong output, not a
|
||||
slow build), so that raises.
|
||||
"""
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
had_gch = gch.is_file()
|
||||
gch.unlink(missing_ok=True)
|
||||
Path(f"{gch}.sum").unlink(missing_ok=True)
|
||||
errors = []
|
||||
for sidecar in (gch, Path(f"{gch}.sum")):
|
||||
try:
|
||||
sidecar.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
if sidecar.is_file():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(
|
||||
f"Could not discard the stale precompiled header: {err}"
|
||||
) from err
|
||||
errors.append(err)
|
||||
for err in errors:
|
||||
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
|
||||
if had_gch and header.is_file():
|
||||
os.utime(header)
|
||||
with suppress(OSError):
|
||||
os.utime(header)
|
||||
|
||||
|
||||
def prepare_pch(
|
||||
@@ -334,6 +402,7 @@ def prepare_pch(
|
||||
if cmd_and_dir is None:
|
||||
# Freshness cannot be validated; a leftover .gch must not be consumed
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("no usable compile command")
|
||||
return
|
||||
cmd, cmd_dir = cmd_and_dir
|
||||
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
|
||||
@@ -360,61 +429,118 @@ def prepare_pch(
|
||||
"Could not establish the pch identity; compiling without it: %s", err
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
return
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
pch_degraded(f"identity unknown: {err}")
|
||||
return
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
|
||||
def _run(
|
||||
run_cmd: list[str], what: str, stdin: str | None = None
|
||||
) -> subprocess.CompletedProcess | None:
|
||||
"""Spawn one pch tool step; environmental failures discard and
|
||||
degrade (None): spawn/IO/timeout errors and signal kills never
|
||||
latch the marker."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
run_cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} did not run: {err}")
|
||||
return None
|
||||
if proc.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header %s was killed (signal %d); retrying next build",
|
||||
what,
|
||||
-proc.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} killed by signal {-proc.returncode}")
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _fail(error: str, reason: str, latch: bool) -> None:
|
||||
"""Discard and degrade; deterministic failures latch when asked."""
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# Latching paths keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
pch_degraded(f"{reason}: {error[:200]}")
|
||||
|
||||
def _probe(latch: bool = True) -> None:
|
||||
"""Load-check the built .gch: some toolchains build one they then
|
||||
refuse to load (per-process ASLR). Dep flags are already stripped
|
||||
from cmd, so no -MF is needed; cmd ends with the fixed
|
||||
"-x c++-header -c -o" tail. A cached-header rejection may not
|
||||
reproduce (per-process), so that caller passes latch=False."""
|
||||
if cmd[-6:-4] != ["-x", "c++-header"]:
|
||||
# The slice below depends on pch_compile_command's fixed tail
|
||||
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("unexpected pch command shape")
|
||||
return
|
||||
base = cmd[:-6]
|
||||
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
|
||||
if probe is None:
|
||||
return
|
||||
if probe.returncode != 0:
|
||||
# Disambiguate: only blame the pch when the same compile passes
|
||||
# without it; a failing baseline is its own (latchable) problem
|
||||
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
|
||||
if baseline is None:
|
||||
return
|
||||
if baseline.returncode == 0:
|
||||
error = probe.stderr.strip() or f"exit code {probe.returncode}"
|
||||
_fail(error, "toolchain cannot load the pch", latch=latch)
|
||||
else:
|
||||
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
|
||||
_fail(error, "probe cannot run at all", latch=latch)
|
||||
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
if pch_strict():
|
||||
# Rejection is per-process, so a cached .gch must re-prove
|
||||
# loadability for the strict gate (CI-only cost); no latch,
|
||||
# since the rejection may not reproduce either
|
||||
_probe(latch=False)
|
||||
return
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
_LOGGER.info(
|
||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
||||
failed_marker,
|
||||
)
|
||||
pch_degraded("earlier failure latched")
|
||||
return
|
||||
_log_pch_in_use()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
error = None
|
||||
if result.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header compile was killed (signal %d); retrying "
|
||||
"next build",
|
||||
-result.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
return
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# Transient (timeout, spawn/IO): warn and retry next build, no marker
|
||||
_LOGGER.warning("Precompiled header compile did not run: %s", err)
|
||||
discard_pch(build_dir)
|
||||
result = _run(cmd, "compile")
|
||||
if result is None:
|
||||
return
|
||||
error = None
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
if error is not None:
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# This path latches, so keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch compile output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Resource exhaustion clears on its own; retry next build
|
||||
return
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
_fail(error, "compile failed", latch=True)
|
||||
return
|
||||
_probe()
|
||||
if not gch.is_file():
|
||||
# The probe discarded a rejected or unrunnable .gch
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from esphome.build_helpers.pch import pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -10,7 +11,7 @@ from esphome.const import (
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import KEY_HOST
|
||||
@@ -18,6 +19,9 @@ from .const import KEY_HOST
|
||||
# force import gpio to register pin schema
|
||||
from .gpio import host_pin_to_code # noqa: F401
|
||||
|
||||
# Guarded wrapper: build_src_flags reaches C/assembly edges too
|
||||
HOST_PCH_PREFIX = "esphome/core/pch_prefix.h"
|
||||
|
||||
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
|
||||
AUTO_LOAD = ["network", "preferences"]
|
||||
IS_TARGET_PLATFORM = True
|
||||
@@ -55,9 +59,18 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
if pch_enabled():
|
||||
# Curated prefix for the pch (the script folds it plus defines.h):
|
||||
# host has no framework force-includes, and the per-TU cost is the
|
||||
# STL closure behind the core headers. Measured -43% compile CPU.
|
||||
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
|
||||
# .gch fails to build or load, the force-include stays and every TU
|
||||
# parses the closure as text: correct, but slower than no pch.
|
||||
cg.add_platformio_option("build_src_flags", f"-include {HOST_PCH_PREFIX}")
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -26,7 +27,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa: F401
|
||||
@@ -513,7 +514,7 @@ async def component_to_code(config):
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||
# On other families, patch_linker.py routes .sram.text into the right
|
||||
# RAM-executable output section and prints a post-link placement summary.
|
||||
@@ -619,3 +620,4 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("patch_linker.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -6,6 +6,7 @@ from string import ascii_letters, digits
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -33,7 +34,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -340,7 +341,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||
cg.add_define(ThreadModel.SINGLE)
|
||||
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||
cg.add_platformio_option(
|
||||
"extra_scripts",
|
||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
||||
)
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
@@ -644,6 +648,7 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("inject_lwip_include.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
_generate_lwipopts_h()
|
||||
if generate_pio_files():
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
// Curated precompiled-header prefix for backends that force-include it via
|
||||
// build_src_flags (the pch script folds it into the .gch). Guarded because
|
||||
// build_src_flags also reaches C and assembly src edges.
|
||||
#ifdef __cplusplus
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#endif
|
||||
@@ -1,6 +1,5 @@
|
||||
"""ESP-IDF direct build API for ESPHome."""
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
@@ -536,9 +535,14 @@ def run_compile(config, verbose: bool) -> int:
|
||||
try:
|
||||
prepare_pch()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Discard so a stale .gch can never be consumed
|
||||
with suppress(OSError):
|
||||
discard_pch()
|
||||
from esphome.build_helpers.pch import pch_strict
|
||||
|
||||
# Strict first: its own knob error must not mask the real failure
|
||||
strict = pch_strict()
|
||||
# Raises itself if a stale .gch survives (silently wrong output)
|
||||
discard_pch()
|
||||
if strict:
|
||||
raise
|
||||
_LOGGER.warning(
|
||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
||||
)
|
||||
|
||||
@@ -20,11 +20,22 @@ except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
|
||||
# Precompile the src force-includes plus defines.h and force-include the
|
||||
# result into C++ src compiles only; their preprocessed output is unchanged.
|
||||
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
|
||||
# values, and stamp flow in sync with build_helpers/pch.py.
|
||||
# values, probe flow, stamp flow, and env-knob spellings in sync with
|
||||
# build_helpers/pch.py.
|
||||
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
# Keep in sync with helpers.TRUTHY_ENV_STRINGS / FALSY_ENV_STRINGS
|
||||
_TRUTHY = ("1", "true", "yes", "on", "enable")
|
||||
_FALSY = ("", "0", "false", "no", "off", "disable")
|
||||
_STRICT_RAW = os.environ.get("ESPHOME_PCH_STRICT")
|
||||
_STRICT_VALUE = (_STRICT_RAW or "").strip().lower()
|
||||
_STRICT = _STRICT_VALUE in _TRUTHY
|
||||
if _STRICT_RAW is not None and _STRICT_VALUE not in _TRUTHY + _FALSY:
|
||||
# A typo must not silently turn the gate into a no-op
|
||||
raise RuntimeError(f"Unrecognized ESPHOME_PCH_STRICT={_STRICT_RAW!r}; use 1 or 0")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
@@ -117,26 +128,12 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
|
||||
raise OSError(f"compiler killed by signal {-result.returncode}")
|
||||
if result.returncode != 0:
|
||||
return result.stderr
|
||||
# -MF is only legal alongside a dependency flag; pass it solely to
|
||||
# redirect a depfile that -MD/-MMD in the flags would otherwise write
|
||||
dep_redirect = (
|
||||
["-MF", os.devnull]
|
||||
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
|
||||
else []
|
||||
)
|
||||
return _probe_gch(cxx, flags, header, proj_dir)
|
||||
|
||||
|
||||
def _probe_run(cxx, flags, extra, proj_dir: Path):
|
||||
probe = subprocess.run( # noqa: PLW1510
|
||||
[
|
||||
cxx,
|
||||
*flags,
|
||||
*dep_redirect,
|
||||
"-Winvalid-pch",
|
||||
"-include",
|
||||
str(header),
|
||||
"-fsyntax-only",
|
||||
"-x",
|
||||
"c++",
|
||||
"-",
|
||||
],
|
||||
[cxx, *flags, *extra, "-fsyntax-only", "-x", "c++", "-"],
|
||||
cwd=proj_dir,
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input="",
|
||||
@@ -145,9 +142,34 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
|
||||
)
|
||||
if probe.returncode < 0:
|
||||
raise OSError(f"probe killed by signal {-probe.returncode}")
|
||||
if probe.returncode != 0 or ".gch" in probe.stderr:
|
||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
||||
return None
|
||||
return probe
|
||||
|
||||
|
||||
def _probe_gch(cxx, flags, header: Path, proj_dir: Path):
|
||||
"""Load-check an existing .gch; error string or None. Rejection must
|
||||
be a nonzero exit (keep in sync with pch_probe_args); a baseline run
|
||||
without the pch keeps environmental failures from being blamed on it."""
|
||||
# -MF is only legal alongside a dependency flag; pass it solely to
|
||||
# redirect a depfile that -MD/-MMD in the flags would otherwise write
|
||||
dep_redirect = (
|
||||
["-MF", os.devnull]
|
||||
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
|
||||
else []
|
||||
)
|
||||
probe = _probe_run(
|
||||
cxx,
|
||||
flags,
|
||||
[*dep_redirect, "-Winvalid-pch", "-Werror=invalid-pch", "-include", str(header)],
|
||||
proj_dir,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return None
|
||||
baseline = _probe_run(cxx, flags, dep_redirect, proj_dir)
|
||||
if baseline.returncode != 0:
|
||||
# Deterministic and latchable; the transient filter at the caller
|
||||
# keeps resource exhaustion from latching
|
||||
return f"probe cannot run at all: {baseline.stderr.strip()[:200]}"
|
||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
@@ -158,11 +180,18 @@ def _read_stamp(path: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _setup_pch() -> None:
|
||||
def _setup_pch() -> bool | None:
|
||||
if projenv is None:
|
||||
# Expected under -t nobuild; anything else must leave a trail
|
||||
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
|
||||
return
|
||||
try:
|
||||
from SCons.Script import COMMAND_LINE_TARGETS
|
||||
except ImportError:
|
||||
# No SCons is an anomaly under PlatformIO: the unknown state
|
||||
# must not read as success (strict decides fatality at the gate)
|
||||
return False
|
||||
# Expected under -t nobuild (nothing compiles); a missing
|
||||
# projenv on a real compile must not pass strict
|
||||
return "nobuild" in [str(t) for t in COMMAND_LINE_TARGETS]
|
||||
# Project root: SCons compiles run here, so the relative -include
|
||||
# resolves; an absolute path would break cross-device ccache sharing.
|
||||
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
||||
@@ -286,12 +315,22 @@ def _setup_pch() -> None:
|
||||
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 (_read_stamp(sum_path) != checksum)
|
||||
):
|
||||
fresh = (
|
||||
header.is_file()
|
||||
and gch.is_file()
|
||||
and sum_path.is_file()
|
||||
and (_read_stamp(sum_path) == checksum)
|
||||
)
|
||||
if fresh and _STRICT:
|
||||
# Rejection is per-process: strict re-proves a cached .gch loads
|
||||
# (mirrors the pch_strict() re-probe in build_helpers/pch.py)
|
||||
error = _probe_gch(cxx, flags, header, proj_dir)
|
||||
if error is not None:
|
||||
print(f"ESPHome: {error}")
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
return
|
||||
if not fresh:
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
print(
|
||||
@@ -351,14 +390,27 @@ def _setup_pch() -> None:
|
||||
# -Wno-error: the per-process probe can pass while a later cc1plus
|
||||
# rejects the .gch; that must stay a warning under user -Werror.
|
||||
projenv.Prepend( # noqa: F821
|
||||
CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name]
|
||||
CXXFLAGS=[
|
||||
"-Winvalid-pch",
|
||||
# Strict inverts: a per-process consumer rejection reds the build
|
||||
"-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch",
|
||||
"-include",
|
||||
header.name,
|
||||
]
|
||||
)
|
||||
projenv["ENV"].update(ccache_updates) # noqa: F821
|
||||
print("ESPHome: Compiling with precompiled header")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
try:
|
||||
_setup_pch()
|
||||
_used = _setup_pch()
|
||||
except Exception: # noqa: BLE001 -- a speedup must never break the build
|
||||
if _STRICT:
|
||||
raise
|
||||
print("ESPHome: pch internal error; compiling without it")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if _STRICT and not _used:
|
||||
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
|
||||
|
||||
@@ -247,6 +247,7 @@ def copy_src_tree():
|
||||
Path(
|
||||
"esphome/core/ring_buffer.h"
|
||||
), # moved to components/ring_buffer/, removed in 2026.11.0
|
||||
Path("esphome/core/pch_prefix.h"), # build machinery, not user API
|
||||
}
|
||||
include_l = []
|
||||
for target, _ in source_files_l:
|
||||
|
||||
@@ -1842,3 +1842,53 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
|
||||
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
|
||||
)
|
||||
assert sums[0] == sums[1]
|
||||
|
||||
|
||||
def test_write_project_pch_strict_raises_on_skip(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
paths = _make_framework(tmp_path)
|
||||
_set_flags(
|
||||
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h"
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="precedes the pch"):
|
||||
_write_ninja(paths, ccache="/usr/bin/ccache")
|
||||
|
||||
|
||||
def test_write_project_pch_strict_emits_probe_edge(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strict mode gates C++ src edges on a hard-failing load probe."""
|
||||
paths = _make_framework(tmp_path)
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
|
||||
content = _write_ninja(paths, ccache="/usr/bin/ccache")
|
||||
assert "pchprobe" not in content
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
content = _write_ninja(paths, ccache="/usr/bin/ccache")
|
||||
assert "build esphome_pch.probe: pchprobe esphome_pch.h.gch" in content
|
||||
assert "-Werror=invalid-pch" in content
|
||||
# $out only expands in rule text; an edge-level binding would emit a
|
||||
# bare stamp command and fail every strict build
|
||||
assert "&& $python $buildtool touch $out" in content
|
||||
assert "$stamp" not in content
|
||||
# Strict consumers escalate: a per-TU rejection must red the build
|
||||
assert "srccxxflags = -Winvalid-pch -Werror=invalid-pch" in content
|
||||
assert "-Wno-error=invalid-pch" not in content
|
||||
|
||||
# With extra src flags the probe edge carries them like the .gch edge
|
||||
CORE.platformio_options["build_src_flags"] = (
|
||||
"-include esphome/components/esp8266/throw_stubs.h -DSRC_EXTRA"
|
||||
)
|
||||
content = _write_ninja(paths, ccache="/usr/bin/ccache")
|
||||
assert "pchprobe esphome_pch.h.gch\n flags = " in content
|
||||
edges = [
|
||||
line
|
||||
for line in content.splitlines()
|
||||
if line.startswith("build obj/src/") and ".cpp.o:" in line
|
||||
]
|
||||
assert edges
|
||||
assert all(line.endswith("| esphome_pch.h.gch esphome_pch.probe") for line in edges)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -244,3 +245,20 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
|
||||
|
||||
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
|
||||
stamp = tmp_path / "esphome_pch.probe"
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.is_file()
|
||||
os.utime(stamp, (1, 1))
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.stat().st_mtime > 1
|
||||
|
||||
|
||||
def test_touch_reports_failure(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
missing_dir = tmp_path / "gone" / "stamp"
|
||||
assert build_tool._run_touch(str(missing_dir)) == 1
|
||||
assert "touch:" in capsys.readouterr().err
|
||||
|
||||
@@ -550,6 +550,9 @@ def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
# The load probe follows a successful .gch build
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
# The compile must target the header, not the stub TU
|
||||
assert cmd[-5:-3] == ["c++-header", "-c"]
|
||||
gch.write_bytes(b"gch")
|
||||
@@ -601,6 +604,11 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
|
||||
assert "-Wno-error=invalid-pch" in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
strict_content = get_component_cmakelists()
|
||||
assert "-Werror=invalid-pch" in strict_content
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert "-include" not in get_component_cmakelists()
|
||||
@@ -891,6 +899,125 @@ def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_probe_spawn_failure_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A probe that cannot run discards the pch; strict raises."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pf")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def probe_dies(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
raise OSError("probe spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
pytest.raises(EsphomeError, match="probe did not run"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stderr", "code"),
|
||||
[("", -9), ("fatal: No space left on device", 1)],
|
||||
ids=("signal-kill", "enospc"),
|
||||
)
|
||||
def test_prepare_pch_probe_environmental_failures_do_not_latch(
|
||||
tmp_path: Path, stderr: str, code: int
|
||||
) -> None:
|
||||
"""A signal-killed or ENOSPC probe retries next build, no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pe")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def env_probe(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, code, "", stderr)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=env_probe),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_signal_kill_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ks")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.subprocess.run",
|
||||
side_effect=lambda cmd, **kw: subprocess.CompletedProcess(cmd, -9, "", ""),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="killed by signal"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_when_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strict must not read a disabled pch as success."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ds")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="disabled"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_sdkconfig_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ss")
|
||||
(dev / "sdkconfig.test").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="sdkconfig unreadable"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None:
|
||||
"""Stale checksum but no configured TU yet: no compile, no sidecars."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
@@ -1103,3 +1230,182 @@ def test_prepare_pch_transient_compiler_failure_does_not_latch(
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_on_missing_db(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_st")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
pytest.raises(EsphomeError, match="no usable compile command"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_rejection_latches_and_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A toolchain that cannot load its own .gch discards it, latches the
|
||||
marker, and fails strict mode."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_p")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def rejecting(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" not in cmd:
|
||||
# Baseline without the pch passes: the pch is to blame
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
(dev / "build" / "esphome_pch.h.gch.failed").unlink()
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_reprobes_cached_gch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rejection is per-process: strict must re-prove a cached .gch loads."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_rc")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
if "-fsyntax-only" not in cmd:
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
):
|
||||
prepare_pch()
|
||||
assert gch.exists()
|
||||
|
||||
def reject(cmd, **kwargs):
|
||||
assert "-fsyntax-only" in cmd, "cached path must not recompile"
|
||||
if "-include" not in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=reject),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
# Per-process rejection may not reproduce: the cached path must not
|
||||
# latch the pch off for later non-strict builds
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_unexpected_command_shape_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A tail the probe slice cannot trust discards and degrades."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_sh")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="command shape"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
# Non-strict: same shape problem degrades without raising
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_baseline_spawn_failure_is_transient(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A baseline that cannot spawn is environmental: no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_bs")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def flaky(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "boom")
|
||||
raise OSError("baseline spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=flaky),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
@@ -216,3 +216,121 @@ def test_ccache_pch_env_warns_on_falsy_extsum(
|
||||
env = pch.ccache_pch_env()
|
||||
assert "CCACHE_PCH_EXTSUM" not in env
|
||||
assert "disables pch caching" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[(None, False), ("0", False), ("1", True), ("true", True)],
|
||||
)
|
||||
def test_pch_strict(
|
||||
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
if value is None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
|
||||
assert pch.pch_strict() is expected
|
||||
|
||||
|
||||
def test_pch_degraded_raises_only_in_strict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
pch.pch_degraded("reason")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="reason"):
|
||||
pch.pch_degraded("reason")
|
||||
|
||||
|
||||
def test_pch_extra_scripts_strict_raises_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="disabled"):
|
||||
pch.pch_extra_scripts()
|
||||
|
||||
|
||||
def test_pch_strict_rejects_unrecognized_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A typo must not silently disable the gate."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "yolo")
|
||||
with pytest.raises(EsphomeError, match="Unrecognized ESPHOME_PCH_STRICT"):
|
||||
pch.pch_strict()
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_gch_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A .gch an unlink failure leaves behind would be consumed silently."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
gch = tmp_path / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".gch"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_sum_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The .sum is ccache's pch identity; one that survives is as unsafe
|
||||
as a surviving .gch."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
(tmp_path / "esphome_pch.h.gch.sum").write_text("x")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_warns_when_file_vanished_concurrently(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unlink error on a file that is nonetheless gone only warns."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def racing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
# Racer removed it, then our unlink errored
|
||||
raise OSError("stale handle")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", racing_unlink)
|
||||
pch.discard_pch(tmp_path)
|
||||
assert "Could not discard the pch sidecars" in caplog.text
|
||||
|
||||
@@ -669,6 +669,52 @@ def test_get_core_framework_version_from_core_data():
|
||||
assert toolchain._get_core_framework_version() == "5.5.4"
|
||||
|
||||
|
||||
def test_run_compile_aborts_when_stale_pch_survives_discard(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An undiscardable stale .gch means silently wrong output: abort."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
_setup_build(setup_core)
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch("esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.discard_pch",
|
||||
side_effect=EsphomeError("Could not discard the stale precompiled header"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Could not discard"),
|
||||
):
|
||||
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
|
||||
|
||||
def test_run_compile_strict_reraises_pch_failure(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ESPHOME_PCH_STRICT must reach through the real compile flow."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
_setup_build(setup_core)
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.prepare_pch",
|
||||
side_effect=EsphomeError("ESPHOME_PCH_STRICT: no usable compile command"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="no usable compile command"),
|
||||
):
|
||||
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
|
||||
|
||||
def test_run_compile_invokes_prepare_pch_and_survives_failure(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""The host pch prefix must keep resolving; a rename would silently
|
||||
collapse the precompiled set to defines.h with strict CI still green."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.components.host import HOST_PCH_PREFIX
|
||||
|
||||
REPO = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def test_host_pch_prefix_resolves() -> None:
|
||||
prefix = REPO / HOST_PCH_PREFIX
|
||||
assert prefix.is_file()
|
||||
body = prefix.read_text()
|
||||
includes = re.findall(r'#include "([^"]+)"', body)
|
||||
assert includes, "prefix wrapper folds nothing"
|
||||
for name in includes:
|
||||
assert (REPO / name).is_file(), f"{name} does not resolve"
|
||||
# The C guard is what keeps build_src_flags safe on C/assembly edges
|
||||
assert "#ifdef __cplusplus" in body
|
||||
@@ -100,12 +100,14 @@ def _fake_cxx(
|
||||
body += f"echo {fail_msg or 'boom'} >&2\nexit 1\n"
|
||||
else:
|
||||
# Only the c++-header compile has a -o; the load probe has none
|
||||
body += 'out=""; prev=""; mf=0; dep=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
|
||||
body += 'out=""; prev=""; mf=0; dep=0; inc=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; [ "$a" = "-include" ] && inc=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
|
||||
# Real cc1plus rejects -MF without a dependency flag
|
||||
body += 'if [ "$mf" = 1 ] && [ "$dep" = 0 ]; then echo "cc1plus: error: to generate dependencies you must specify either \x27-M\x27 or \x27-MM\x27" >&2; exit 1; fi\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'
|
||||
# -Werror=invalid-pch makes rejection a nonzero exit; the
|
||||
# baseline (no -include) still passes
|
||||
body += 'case " $* " in *c++-header*) ;; *) if [ "$inc" = 1 ]; then echo "error: esphome_pch.h.gch: had text segment at different address" >&2; exit 1; fi;; 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)
|
||||
@@ -293,13 +295,17 @@ def test_pch_script_spawn_failure_is_transient(
|
||||
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."""
|
||||
def test_pch_script_probe_baseline_failure_latches_with_honest_label(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Probe AND baseline failing is a deterministic environment problem:
|
||||
latch, but blame the environment rather than the pch."""
|
||||
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 == []
|
||||
assert "probe cannot run at all" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
|
||||
@@ -458,3 +464,118 @@ def test_pch_script_unreadable_local_header_skips_pch(
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
assert "skipping precompiled header" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_rejects_unrecognized_strict_value(tmp_path: Path) -> None:
|
||||
"""A typo'd knob must fail the build, not silently disable the gate."""
|
||||
with pytest.raises(RuntimeError, match="Unrecognized ESPHOME_PCH_STRICT"):
|
||||
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "yolo"})
|
||||
|
||||
|
||||
def test_pch_script_strict_tables_match_helpers(tmp_path: Path) -> None:
|
||||
"""The script's mirrored spelling tables must not drift."""
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
# Hermetic: the constants are module-level, but the exec still runs
|
||||
# _setup_pch, which must not touch the host toolchain
|
||||
cxx = _fake_cxx(tmp_path)
|
||||
env = _FakeSConsEnv(proj, proj / "src", str(cxx), ["-DX=1"])
|
||||
namespace = {"Import": lambda *_names: None, "env": env, "projenv": env}
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102
|
||||
assert set(namespace["_TRUTHY"]) == set(TRUTHY_ENV_STRINGS)
|
||||
# parse_enable_env handles the empty string separately
|
||||
assert set(namespace["_FALSY"]) - {""} == set(FALSY_ENV_STRINGS)
|
||||
|
||||
|
||||
def test_pch_script_strict_reprobes_cached_gch(tmp_path: Path) -> None:
|
||||
"""Rejection is per-process: strict re-proves a cached .gch loads."""
|
||||
_run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
# Second run: cache fresh, but the toolchain now rejects loads
|
||||
with pytest.raises(RuntimeError, match="not used"):
|
||||
_run_script(tmp_path, reject_pch=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
|
||||
|
||||
def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None:
|
||||
"""ESPHOME_PCH_STRICT fails the build instead of degrading."""
|
||||
with pytest.raises(RuntimeError, match="ESPHOME_PCH_STRICT"):
|
||||
_run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
|
||||
|
||||
def test_pch_script_strict_fails_without_scons(tmp_path: Path) -> None:
|
||||
"""No SCons under PlatformIO is an anomaly; strict must not pass."""
|
||||
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'")
|
||||
|
||||
import sys
|
||||
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
with (
|
||||
# None forces ImportError even where SCons is installed
|
||||
patch.dict(sys.modules, {"SCons.Script": None}),
|
||||
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
|
||||
pytest.raises(RuntimeError, match="not used"),
|
||||
):
|
||||
exec( # noqa: S102
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
|
||||
|
||||
def test_pch_script_strict_reraises_internal_errors(tmp_path: Path) -> None:
|
||||
"""The catch-all must not swallow programming errors in strict mode."""
|
||||
with pytest.raises(TypeError):
|
||||
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}, platform_cls=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("targets", "passes"), [(["nobuild"], True), ([], False)])
|
||||
def test_pch_script_strict_projenv_skip_gated_on_nobuild(
|
||||
tmp_path: Path, targets: list[str], passes: bool
|
||||
) -> None:
|
||||
"""-t nobuild compiles nothing, so the skip passes strict; a missing
|
||||
projenv on a real compile must not."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
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'")
|
||||
|
||||
scons = types.ModuleType("SCons")
|
||||
scons_script = types.ModuleType("SCons.Script")
|
||||
scons_script.COMMAND_LINE_TARGETS = targets
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
with (
|
||||
patch.dict(sys.modules, {"SCons": scons, "SCons.Script": scons_script}),
|
||||
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
|
||||
):
|
||||
run = lambda: exec( # noqa: S102, E731
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
if passes:
|
||||
run()
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="not used"):
|
||||
run()
|
||||
assert not (proj / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None:
|
||||
scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
# Strict escalates the consumer edges too
|
||||
assert "-Werror=invalid-pch" in scons_env.prepended
|
||||
assert "-Wno-error=invalid-pch" not in scons_env.prepended
|
||||
|
||||
Reference in New Issue
Block a user