diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index bc40825c83..9b78d4f659 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1217,7 +1217,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") src_cxx_override = None - if pch_enabled() and "-include" in cxxflags: + if pch_enabled() and any(tok.startswith("-include") for tok in cxxflags): # GCC only loads a .gch while no tokens precede it, and the cxx rule # expands $cxxflags before $flags: a user -include in build_flags # means every TU would silently skip the .gch @@ -1281,7 +1281,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # Relative -include (resolved from the ninja cwd, where the header # lives): an absolute path would put the per-device build path on # every compile command and defeat cross-device ccache sharing - cxx_parts = src_other + [f"-Winvalid-pch -include {PCH_HEADER_NAME}"] + # -Wno-error keeps a rejected .gch a warning under user -Werror + cxx_parts = src_other + [ + f"-Winvalid-pch -Wno-error=invalid-pch -include {PCH_HEADER_NAME}" + ] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") src_cxx_override = ("$srccxxflags", gch) src_objs = _ninja_compile_edges( diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index fe414a94ca..aff553a087 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -71,6 +71,9 @@ _CCACHE_PCH_ENV = { # Both include forms: an angle include resolving under src/ must enter the # digest too; ones that do not resolve simply end the walk +# Compiler failures that clear on their own must not latch the .failed marker +_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily") + _INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) @@ -379,6 +382,9 @@ def prepare_pch( # 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) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 1ae0ae697c..cf15e7622f 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -25,6 +25,9 @@ except Exception as err: # noqa: BLE001 -- not exported under -t nobuild # include-closure recipe, and the checksum/failed-marker stamp flow 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") + _INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) _CORE_HEADER = "esphome/core/defines.h" @@ -76,9 +79,12 @@ def _shell_arg(element) -> str: except ValueError as err: print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}") return arg - # Anything but exactly one token means the model above is wrong for - # this element; pass it through untouched rather than dropping flags - return tokens[0] if len(tokens) == 1 else arg + if len(tokens) != 1: + # The shell-quoting model is wrong for this element; say so rather + # than surfacing only as downstream pch warnings + print(f"ESPHome: passing flag {arg!r} through unlexed for the pch") + return arg + return tokens[0] def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path): @@ -280,6 +286,9 @@ def _setup_pch() -> None: print(error) gch.unlink(missing_ok=True) sum_path.unlink(missing_ok=True) + if any(m in error for m in _TRANSIENT_ERRORS): + # Resource exhaustion clears on its own; retry next build + return # Skip retries until a flag/header/platform change alters the checksum failed_marker.write_text(checksum + "\n", encoding="utf-8") return diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index d1e6726dd5..7c822195e4 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -416,6 +416,19 @@ def test_write_project_pch_identity_unknown_skips_pch( assert "Could not establish the pch identity" in caplog.text +def test_write_project_pch_skipped_for_joined_force_include_spelling( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """GCC also accepts -includefoo.h as one token; the guard must see it.""" + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-includefoo.h" + ) + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "esphome_pch" not in content + assert "prevents the precompiled header" in caplog.text + + def test_write_project_pch_skipped_when_user_force_include_precedes( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -1799,7 +1812,10 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: CORE.build_path = tmp_path / name _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") content = _write_ninja(paths, ccache="/usr/bin/ccache") - assert "srccxxflags = -Winvalid-pch -include esphome_pch.h" in content + assert ( + "srccxxflags = -Winvalid-pch -Wno-error=invalid-pch " + "-include esphome_pch.h" in content + ) sums.append( (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() ) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a01c35c18c..dca97c5fde 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -1052,3 +1052,29 @@ def test_prepare_pch_identity_unknown_discards(tmp_path: Path) -> None: prepare_pch() assert not stale.exists() assert not (dev / "build" / "esphome_pch.h.gch.sum").exists() + + +def test_prepare_pch_transient_compiler_failure_does_not_latch( + tmp_path: Path, +) -> None: + """ENOSPC-style failures clear on their own; no .failed marker.""" + from esphome.build_gen.espidf import prepare_pch + + dev = _make_pch_device(tmp_path, "dev_e") + CORE.build_path = dev + calls = [] + + def enospc(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess( + cmd, 1, "", "fatal error: No space left on device" + ) + + with ( + patch.object(CORE, "name", "test"), + patch("esphome.build_helpers.pch.subprocess.run", side_effect=enospc), + ): + prepare_pch() + prepare_pch() + assert not (dev / "build" / "esphome_pch.h.gch.failed").exists() + assert len(calls) == 2 diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 6687fe938d..1d302c4cbf 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -82,6 +82,7 @@ class _FakeSConsEnv(dict): def _fake_cxx( tmp_path: Path, fail: bool = False, + fail_msg: str | None = None, reject_pch: bool = False, probe_exit: int = 0, ) -> Path: @@ -96,7 +97,7 @@ def _fake_cxx( 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' ) if fail: - body += "echo boom >&2\nexit 1\n" + 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=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n' @@ -113,6 +114,7 @@ def _run_script( tmp_path: Path, flags: list[str] | None = None, fail: bool = False, + fail_msg: str | None = None, reject_pch: bool = False, probe_exit: int = 0, missing_cxx: bool = False, @@ -124,7 +126,13 @@ def _run_script( src = proj / "src" (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") - cxx = _fake_cxx(tmp_path, fail=fail, reject_pch=reject_pch, probe_exit=probe_exit) + cxx = _fake_cxx( + tmp_path, + fail=fail, + fail_msg=fail_msg, + reject_pch=reject_pch, + probe_exit=probe_exit, + ) if missing_cxx: cxx = tmp_path / "no-such-gxx" args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) @@ -217,6 +225,16 @@ def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None: assert sums[0] == sums[1] +def test_pch_script_transient_compiler_failure_does_not_latch( + tmp_path: Path, +) -> None: + """ENOSPC-style failures clear on their own; no .failed marker.""" + scons_env = _run_script(tmp_path, fail=True, fail_msg="No space left on device") + proj = tmp_path / "dev" + assert not (proj / "esphome_pch.h.gch.failed").exists() + assert scons_env.prepended == [] + + def test_pch_script_failure_marker_suppresses_retry( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: