diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f4f6d1190..8880455ff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1202,8 +1202,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: + # the docker smoke test covers the PlatformIO default, this job is + # the only CI exercise of the native ninja pch (and its probe edge). + 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 diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index d886d37b0e..ada1b00149 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -442,8 +442,9 @@ def prepare_pch( # 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_cmd = [ - *cmd[: cmd.index("-x")], + *cmd[:-6], "-Winvalid-pch", "-include", str(header), @@ -467,12 +468,22 @@ def prepare_pch( discard_pch(build_dir) pch_degraded(f"probe did not run: {err}") return + if probe.returncode < 0: + # Killed by a signal (OOM, ^C): environmental, do not latch + _LOGGER.warning( + "Precompiled header probe was killed (signal %d); retrying next build", + -probe.returncode, + ) + discard_pch(build_dir) + pch_degraded(f"probe killed by signal {-probe.returncode}") + return if probe.returncode != 0 or ".gch" in probe.stderr: error = f"toolchain cannot load the pch: {probe.stderr.strip()[:400]}" _LOGGER.warning("Precompiled header failed; compiling without it: %s", error) discard_pch(build_dir) - failed_marker.write_text(checksum + "\n", encoding="utf-8") - os.utime(header) + if not any(m in error for m in _TRANSIENT_ERRORS): + failed_marker.write_text(checksum + "\n", encoding="utf-8") + os.utime(header) pch_degraded(error) return failed_marker.unlink(missing_ok=True) diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2bdb18c651..ceada787a5 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1878,6 +1878,10 @@ def test_write_project_pch_strict_emits_probe_edge( ) content = _write_ninja(paths, ccache="/usr/bin/ccache") assert "pchprobe esphome_pch.h.gch\n flags = " in content - for line in content.splitlines(): - if line.startswith("build obj/src/main.cpp.o:"): - assert line.endswith("| esphome_pch.h.gch esphome_pch.probe") + 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) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 5dfbb561a0..c2d7d0906e 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -903,6 +903,32 @@ def test_prepare_pch_probe_spawn_failure_degrades( prepare_pch() +def test_prepare_pch_probe_environmental_failures_do_not_latch( + tmp_path: Path, +) -> None: + """A signal-killed or ENOSPC probe retries next build, no marker.""" + from esphome.build_gen.espidf import prepare_pch + + for stderr, code in (("", -9), ("fatal: No space left on device", 1)): + dev = _make_pch_device(tmp_path, f"dev_pe{code}") + CORE.build_path = dev + gch = dev / "build" / "esphome_pch.h.gch" + + def env_probe(cmd, _gch=gch, _stderr=stderr, _code=code, **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: