Strict re-probes a cached .gch, probe diagnostics get the exit-code fallback, nobuild detection narrows to ImportError

This commit is contained in:
J. Nick Koston
2026-08-26 23:43:09 -05:00
parent 833af674bf
commit 78eff4f485
4 changed files with 98 additions and 28 deletions
+32 -19
View File
@@ -401,18 +401,7 @@ def prepare_pch(
discard_pch(build_dir)
pch_degraded(f"identity unknown: {err}")
return
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
return
failed_marker = Path(f"{gch}.failed")
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()
def _run(run_cmd: list[str], what: str) -> subprocess.CompletedProcess | None:
"""Spawn one pch tool step; environmental failures discard and
@@ -463,6 +452,35 @@ def prepare_pch(
os.utime(header)
pch_degraded(f"{reason}: {error[:200]}")
def _probe() -> 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."""
probe = _run([*cmd[:-6], *pch_probe_args(str(header))], "probe")
if probe is None:
return
if probe.returncode != 0 or ".gch" in probe.stderr:
_fail(
probe.stderr.strip() or f"exit code {probe.returncode}",
"toolchain cannot load the pch",
)
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)
_probe()
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()
result = _run(cmd, "compile")
if result is None:
return
@@ -474,14 +492,9 @@ def prepare_pch(
if error is not None:
_fail(error, "compile failed")
return
# Load probe: some toolchains build a .gch 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.
probe = _run([*cmd[:-6], *pch_probe_args(str(header))], "probe")
if probe is None:
return
if probe.returncode != 0 or ".gch" in probe.stderr:
_fail(probe.stderr.strip(), "toolchain cannot load the pch")
_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")
+5 -5
View File
@@ -164,12 +164,12 @@ def _setup_pch() -> bool | None:
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
try:
from SCons.Script import COMMAND_LINE_TARGETS
# 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]
except Exception: # noqa: BLE001 -- no SCons: cannot tell, stay lenient
except ImportError:
# No SCons: cannot tell, stay lenient
return True
# 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
+39
View File
@@ -1257,3 +1257,42 @@ def test_prepare_pch_probe_rejection_latches_and_degrades(
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"
return subprocess.CompletedProcess(
cmd, 0, "", "warning: 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()
+22 -4
View File
@@ -472,8 +472,15 @@ def test_pch_script_strict_reraises_internal_errors(tmp_path: Path) -> None:
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}, platform_cls=None)
def test_pch_script_strict_allows_nobuild_skip(tmp_path: Path) -> None:
"""-t nobuild compiles nothing; the skip is expected even in strict."""
@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)
@@ -481,12 +488,23 @@ def test_pch_script_strict_allows_nobuild_skip(tmp_path: Path) -> 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(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True):
exec( # noqa: S102
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()