diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index e7363938bb..1bf05bd906 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -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") diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index dcd944d6d6..c875fa1c23 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -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 diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index dea020eadf..d577034597 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -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() diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 6b25c3c989..902dbbf5dc 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -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()