From b22f2e6b4c50e9cb5a78c41aa8d4c7ff56e98ce2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:51:12 +0200 Subject: [PATCH] [core] Leave the precompiled header off on Windows for a GCC with bug 14940 (#19875) --- esphome/build_gen/arduino8266.py | 8 +- esphome/build_gen/espidf.py | 26 ++++++- esphome/build_gen/host.py | 1 + esphome/build_helpers/ninja_gen.py | 12 +-- esphome/build_helpers/pch.py | 74 ++++++++++++++++++- esphome/components/nrf52/__init__.py | 14 +++- esphome/espidf/toolchain.py | 7 ++ esphome/platformio/pch.py.script | 32 +++++++- esphome/platformio/toolchain.py | 9 ++- .../unit_tests/build_gen/test_arduino8266.py | 13 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 ++++++++++++ tests/unit_tests/build_helpers/test_pch.py | 67 +++++++++++++++++ tests/unit_tests/components/nrf52/test_pch.py | 31 +++++--- tests/unit_tests/conftest.py | 15 +++- tests/unit_tests/test_espidf_toolchain.py | 8 ++ .../unit_tests/test_platformio_pch_script.py | 55 +++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 25 +++++++ 17 files changed, 410 insertions(+), 35 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index c66292a836..fd198b2e0c 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1053,12 +1053,9 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] ) + cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ - *tool_lines( - (toolchain_tool(paths.toolchain, "gcc"),), - (toolchain_tool(paths.toolchain, "g++"),), - ccache, - ), + *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), *compile_rule_lines(), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), @@ -1146,6 +1143,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: flag_sets.cxxflags, src_other, (str(paths.framework), str(paths.toolchain)), + cxx, ) src_objs = compile_edges( lines, diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 766c945467..9f48013a4e 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,6 +3,7 @@ import json import logging from pathlib import Path +import textwrap from esphome.build_helpers import pch from esphome.components.esp32 import ( @@ -289,6 +290,8 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC # Where CMake puts the .gch of the src component; ccache reads the checksum # next to it in place of the .gch _PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum" +# Where the Windows gate records its choice +_PCH_CHOICE_VAR = "ESPHOME_PCH" def _pch_cmake_block() -> str: @@ -300,11 +303,21 @@ def _pch_cmake_block() -> str: f' "$<$:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"' for header in pch.PCH_DEFAULT_HEADERS ) - return f""" -# ESPHome precompiled header -target_precompile_headers(${{COMPONENT_LIB}} PRIVATE + block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE {headers} -) +)""" + if not pch.pch_needs_gcc_check(): + return f"\n# ESPHome precompiled header\n{block}\n" + # Before the first configure only CMake knows the compiler version + return f""" +# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC})) + message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it") + set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE) +else() + set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE) +{textwrap.indent(block, " ")} +endif() """ @@ -315,8 +328,13 @@ def _read_if_exists(path: Path) -> str: def write_pch_checksum() -> None: """Write the checksum ccache uses in place of the .gch: the core headers, the framework version, the sdkconfig and the managed component versions.""" + from esphome.espidf.toolchain import get_cmake_cache_value + if not pch.pch_enabled(): return + # The gate's choice, cached by configure + if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON": + return pch.log_pch_in_use() checksum = pch.pch_checksum( CORE.relative_src_path(), diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index 4f7c556a48..f1e53e1ccb 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -274,6 +274,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: [*compilers.cxx[1:], *cxxflags], (), (compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (), + compilers.cxx, ) src_objs = compile_edges( lines, diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 40cc8e7400..8e72b36b3f 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -24,8 +24,8 @@ from esphome.build_helpers.pch import ( PCH_SUM_NAME, log_pch_in_use, pch_consumer_flags, - pch_enabled, pch_identity, + pch_usable, write_pch_headers, ) from esphome.framework_helpers import strip_win_long_path_prefix @@ -125,15 +125,17 @@ def pch_edges( cxxflags: Sequence[str], src_flags: Sequence[str], identity: Sequence[str], + cxx: Sequence[Path | str], ) -> tuple[str, str] | None: """Emit the precompiled header for the C++ src edges. ``headers`` are folded into one prefix header, ``src_flags`` are the - flags every src edge carries and ``identity`` names what else the - compile depends on, the compiler included. Returns the ``cxx_override`` - for ``compile_edges``, or None without a pch. + flags every src edge carries, ``identity`` names what else the compile + depends on, the compiler included, and ``cxx`` is what the host rule + asks. Returns the ``cxx_override`` for ``compile_edges``, or None + without a pch. """ - if not pch_enabled(): + if not pch_usable(cxx): return None if any( tok == "-include" or tok.startswith("--include") or is_joined_include(tok) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index ec0ff416e5..c2e04c9c65 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -2,13 +2,15 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence import hashlib import logging import os from pathlib import Path import posixpath import re +import subprocess +import sys from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env from esphome.const import PLATFORM_NRF52 @@ -63,6 +65,76 @@ def pch_enabled() -> bool: return parse_enable_env("ESPHOME_PCH_ENABLE") is not False +def pch_forced() -> bool: + """``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no.""" + return parse_enable_env("ESPHOME_PCH_ENABLE") is True + + +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed; +# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table +PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) +# The same rule for CMake, which alone knows the version before configure +PCH_WINDOWS_CMAKE_OLD_GCC = ( + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)" +) + + +def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool: + """Whether a GCC of this version loads a .gch on Windows.""" + if not version: + return False + fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT) + return tuple(version[:2]) >= fixed + + +# GCC ends the first --version line with its version; clang names itself +_VERSION_RE = re.compile(r"\d+(?:\.\d+)+") + + +def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None: + """The GCC version from ``--version``: () when it cannot be read, None + for a compiler that is not GCC.""" + try: + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + except OSError as err: + _LOGGER.debug("Cannot run %s: %s", cxx[0], err) + return () + banner = result.stdout.partition("\n")[0] + if "clang" in banner.lower(): + return None + found = _VERSION_RE.findall(banner) + return tuple(int(part) for part in found[-1].split(".")) if found else () + + +def pch_needs_gcc_check() -> bool: + """Windows host with the knob unset: the compiler version decides.""" + return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None + + +def pch_usable(cxx: Sequence[Path | str]) -> bool: + """The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule.""" + if not pch_enabled(): + return False + if not pch_needs_gcc_check(): + return True + version = gcc_version(cxx) + if version is None or gcc_relocates_pch_on_windows(version): + return True + _LOGGER.info( + "GCC %s cannot load a precompiled header on Windows (GCC bug 14940, " + "fixed in 14.4, 15.3 and 16); compiling without it " + "(set ESPHOME_PCH_ENABLE=1 to force)", + ".".join(map(str, version)) or "of unknown version", + ) + return False + + def pch_consumer_flags() -> list[str]: """Flags a C++ src compile loads the pch with. The -include stays relative: an absolute path would enter the ccache key.""" diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 488984ff70..8e91cff049 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -75,6 +75,7 @@ from .framework import ( get_build_env, get_build_paths, setup_platformio_python_env, + toolchain_tool, ) # force import gpio to register pin schema @@ -843,7 +844,11 @@ def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: write_file_if_changed(app_dir / _PCH_SUM_PATH, checksum + "\n") -def _generate_cmake_lists() -> bool: +def _pch_usable() -> bool: + return pch.pch_usable((toolchain_tool("g++"),)) + + +def _generate_cmake_lists(pch_on: bool) -> bool: """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -886,7 +891,7 @@ def _generate_cmake_lists() -> bool: ")", ] - if pch.pch_enabled(): + if pch_on: lines += _PCH_CMAKE_LINES if link_flags: @@ -945,7 +950,8 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - cmake_lists_changed = _generate_cmake_lists() + pch_on = _pch_usable() + cmake_lists_changed = _generate_cmake_lists(pch_on) board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) @@ -962,7 +968,7 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) - if pch.pch_enabled(): + if pch_on: pch.log_pch_in_use() # Zephyr turns ccache on by itself when it is installed env.update(pch.ccache_pch_env()) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index f8575234f8..3bb1f5b83d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -605,6 +605,13 @@ def get_elf_path() -> Path: return build_dir / "firmware.elf" +def get_cmake_cache_value(var_name: str) -> str | None: + """One entry of the configured build's CMake cache, or None when unset.""" + cmake_output = _get_cmake_output(CORE.relative_build_path("build")) + match = re.search(rf"^{var_name}:\w+=(.*)$", cmake_output, re.MULTILINE) + return match.group(1).strip() if match else None + + def get_objdump_path() -> Path: return _get_cmake_tool_path("CMAKE_OBJDUMP") diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 960d5bd7d9..36751074d9 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -60,6 +60,10 @@ rc = libc.posix_spawn(None, argv[0], None, attr, (ctypes.c_char_p * len(argv))(* libc.strerror.restype = ctypes.c_char_p sys.exit("ESPHome: cannot start " + real + ": " + libc.strerror(rc).decode()) """ +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed +_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) _CXX_SUFFIXES = (".cpp", ".cc", ".cxx") _HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc") _INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter") @@ -113,7 +117,11 @@ def _src_envs(src_dir: Path) -> list: def _driver_output(cxx: str, option: str) -> str: - result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + """What the driver prints for ``option``, or "" when it cannot run.""" + try: + result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + except OSError: + return "" return result.stdout.strip() @@ -141,6 +149,26 @@ def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): return [f"-B{_CC1_DIR}/"] +def _gcc_loads_pch_here(cxx: str) -> bool: + """Whether a .gch loads here; ESPHOME_PCH_ENABLE=1, as esphome + normalizes it, insists.""" + if sys.platform != "win32" or os.environ.get("ESPHOME_PCH_ENABLE") == "1": + return True + # GCC ends the first --version line with its version; clang names itself + banner = _driver_output(cxx, "--version").partition("\n")[0] + if "clang" in banner.lower(): + return True + found = re.findall(r"\d+(?:\.\d+)+", banner) + version = tuple(int(p) for p in found[-1].split(".")) if found else () + if version and version[:2] >= _WINDOWS_GCC_FIXED.get(version[0], _WINDOWS_GCC_FIXED_DEFAULT): + return True + print( + f"ESPHome: GCC {'.'.join(map(str, version)) or 'of unknown version'} cannot load a " + "precompiled header on Windows (GCC bug 14940); compiling without it" + ) + return False + + def _setup_pch() -> None: if projenv is None: if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821 @@ -157,6 +185,8 @@ def _setup_pch() -> None: src_envs = _src_envs(src_dir) cxx = src_envs[0].subst("$CXX") + if not _gcc_loads_pch_here(cxx): + return pio_platform = env.PioPlatform() # noqa: F821 extra = _old_gcc_flags(proj_dir, pio_platform.name, cxx) if extra is None: diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 6a906a42d0..0eec532c7c 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -336,10 +336,17 @@ def run_platformio_cli(*args, **kwargs) -> str | int: env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) if CORE.is_configured: - from esphome.build_helpers.pch import ccache_pch_env, pch_script_enabled + from esphome.build_helpers.pch import ( + ccache_pch_env, + pch_forced, + pch_script_enabled, + ) if pch_script_enabled(): env.update(ccache_pch_env()) + if pch_forced(): + # Normalized for the script, like ESPHOME_CCACHE_ENABLE + env["ESPHOME_PCH_ENABLE"] = "1" # The runner offers the out-of-flash tip but has no configured CORE, so # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same # variable; clear an inherited one so it cannot reach the wrong build. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 8bd304b42b..4a0355fd01 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -447,6 +447,19 @@ def test_write_project_pch_disabled( assert " flags = $srcflags" in content +def test_write_project_pch_asks_the_toolchain_compiler_on_windows( + windows_gcc_rule: None, tmp_path: Path +) -> None: + from esphome.build_helpers import pch + + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + with patch.object(pch, "gcc_version", return_value=(10, 3, 0)) as asked: + content = _write_ninja(paths) + assert asked.call_args.args[0] == (toolchain_tool(paths.toolchain, "g++"),) + assert "esphome_pch" not in content + + def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: paths = _make_framework(tmp_path) CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ec8e2f3a36..4a84284796 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -538,6 +538,54 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None assert "target_precompile_headers" not in get_component_cmakelists() +def test_component_cmakelists_pch_gate_on_windows( + windows_gcc_rule: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """The block carries the rule and records its choice; the knob drops + the gate.""" + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + 'if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ' + "(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n" + " message(STATUS " in content + ) + assert ( + ' set(ESPHOME_PCH OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)\nelse()\n' + in content + ) + assert ( + ' set(ESPHOME_PCH ON CACHE BOOL "ESPHome precompiled header in use" FORCE)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n' + in content + ) + assert "endif()" in content + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + content = get_component_cmakelists() + assert "if(CMAKE_CXX_COMPILER_VERSION" not in content + assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content + + +@pytest.mark.parametrize(("choice", "written"), [("OFF", False), ("ON", True)]) +def test_pch_checksum_follows_the_cmake_choice_on_windows( + windows_gcc_rule: None, tmp_path: Path, choice: str, written: bool +) -> None: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + _make_pch_project(tmp_path) + with ( + patch( + "esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice + ) as asked, + patch.object(CORE, "name", "test"), + ): + write_pch_checksum() + assert asked.call_args.args == ("ESPHOME_PCH",) + assert CORE.relative_build_path(_PCH_SUM_PATH).exists() is written + + @pytest.mark.parametrize( ("file", "content"), [ diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index a03b9c3a11..1182dc6f8c 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import os from pathlib import Path +import sys from unittest.mock import patch import pytest @@ -35,6 +37,71 @@ def test_pch_enabled(value: str | None, expected: bool) -> None: assert pch.pch_enabled() is expected +@pytest.mark.parametrize( + ("version", "expected"), + [ + ((), False), + ((10, 3), False), + ((12, 2, 1), False), + ((14, 2, 0), False), + ((14, 3), False), + ((14, 4), True), + ((14,), False), + ((15, 2, 0), False), + ((15, 3), True), + ((16, 0), True), + ((17, 1), True), + ], +) +def test_gcc_relocates_pch_on_windows(version: tuple[int, ...], expected: bool) -> None: + assert pch.gcc_relocates_pch_on_windows(version) is expected + + +@pytest.mark.parametrize( + ("banner", "expected"), + [ + ("xtensa-esp32-elf-g++ (crosstool-NG esp-14.2.0_20260121) 14.2.0", (14, 2, 0)), + ("arm-zephyr-eabi-g++ (Zephyr SDK 0.16.8) 12.2.0", (12, 2, 0)), + ("g++.exe (Rev3, Built by MSYS2 project) 14.2.0", (14, 2, 0)), + ("clang version 15.0.0", None), + ("Apple clang version 17.0.0 (clang-1700.0.13.3)", None), + ("something else", ()), + ], +) +def test_gcc_version_reads_the_banner(banner: str, expected: object) -> None: + cxx = (sys.executable, "-c", f"print({banner!r}); print('more')") + assert pch.gcc_version(cxx) == expected + + +def test_gcc_version_of_a_compiler_that_cannot_run() -> None: + assert pch.gcc_version(("/nonexistent/g++",)) == () + + +def test_pch_usable_asks_the_compiler_on_windows_only( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger=pch.__name__) + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "darwin") + with patch.object(pch, "gcc_version", side_effect=AssertionError("off Windows")): + assert pch.pch_usable(("g++",)) + monkeypatch.setattr(pch.sys, "platform", "win32") + with patch.object(pch, "gcc_version", return_value=(14, 2, 0)): + assert not pch.pch_usable(("g++",)) + assert "GCC 14.2.0 cannot load a precompiled header on Windows" in caplog.text + with patch.object(pch, "gcc_version", return_value=(14, 4, 0)): + assert pch.pch_usable(("g++",)) + # The bug is GCC's; another compiler is not held to its table + with patch.object(pch, "gcc_version", return_value=None): + assert pch.pch_usable(("clang++",)) + # The knob overrides the rule both ways + with patch.object(pch, "gcc_version", side_effect=AssertionError("forced")): + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + assert pch.pch_usable(("g++",)) + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert not pch.pch_usable(("g++",)) + + def test_ccache_pch_env_enabled() -> None: with patch.dict(os.environ, {}, clear=True): env = pch.ccache_pch_env() diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py index 0052df0772..5af55586c3 100644 --- a/tests/unit_tests/components/nrf52/test_pch.py +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -13,7 +13,7 @@ from esphome.core import CORE, EsphomeError SUM = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" -def _generate_cmake(tmp_path: Path) -> str: +def _generate_cmake(tmp_path: Path, pch_on: bool = True) -> str: CORE.config_path = tmp_path / "test.yaml" CORE.build_path = tmp_path / "build" CORE.name = "livingroom" @@ -25,7 +25,7 @@ def _generate_cmake(tmp_path: Path) -> str: patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]), patch.object(nrf52, "get_project_link_flags", return_value=[]), ): - nrf52._generate_cmake_lists() + nrf52._generate_cmake_lists(pch_on) return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() @@ -43,15 +43,27 @@ def test_cmake_lists_precompile_the_core_headers(tmp_path: Path) -> None: assert "if(NOT esphome_pch_headers)\n message(FATAL_ERROR" in text -def test_cmake_lists_pch_block_disabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") - text = _generate_cmake(tmp_path) +def test_cmake_lists_pch_block_disabled(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path, pch_on=False) assert "precompile" not in text assert "zephyr_interface" not in text +@pytest.mark.parametrize(("version", "on"), [((12, 2, 0), False), ((14, 4, 0), True)]) +def test_the_zephyr_compiler_decides_on_windows( + windows_gcc_rule: None, version: tuple[int, ...], on: bool +) -> None: + from esphome.build_helpers import pch + + # platformdirs would pick its Windows backend from the patched sys.platform + with ( + patch.object(nrf52, "toolchain_tool", lambda name: Path(f"/sdk/{name}.exe")), + patch.object(pch, "gcc_version", return_value=version) as asked, + ): + assert nrf52._pch_usable() is on + assert asked.call_args.args[0] == (Path("/sdk/g++.exe"),) + + def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: """Write the checksum for a build dir whose app image sits in ``app``.""" CORE.build_path = tmp_path @@ -114,9 +126,10 @@ def run_cmd(tmp_path: Path) -> Mock: def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: - # clear=True also drops ambient CCACHE_* and ESPHOME_PCH_* overrides + # clear=True also drops ambient CCACHE_* overrides; the header is on + # explicitly since Windows hosts start with it off with ( - patch.dict("os.environ", {}, clear=True), + patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True), pytest.raises(EsphomeError, match="nRF52 native build failed"), ): nrf52.run_compile(None, {}) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 0353c8afc8..ff5564e815 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -178,6 +178,15 @@ def held_lock() -> Callable[..., Callable[..., None]]: @pytest.fixture(autouse=True) def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None: - """The precompiled header knobs change what the generators emit; a value - exported in the developer's or CI's shell must not reach the tests.""" - monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False) + """The knob changes what the generators emit: no ambient value, and on, + so the Windows runner asks no compiler.""" + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + + +@pytest.fixture +def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows host, knob unset: the compiler version decides.""" + from esphome.build_helpers import pch + + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "win32") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2d65563663..3e1e88d66d 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -725,3 +725,11 @@ def test_get_core_framework_version_from_core_data(): CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} assert toolchain._get_core_framework_version() == "5.5.4" + + +def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> None: + _setup_build(setup_core) + output = "CMAKE_OBJDUMP:FILEPATH=/tools/objdump\nESPHOME_PCH:BOOL=OFF\n" + with patch.object(toolchain, "_get_cmake_output", return_value=output): + assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF" + assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index e7ea1a3420..af9ec4a837 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -81,7 +81,9 @@ def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: 'case "$1" in -print-prog-name=*) n=${1#*=};' ' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";' ' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;' - ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;; esac\n' + ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;;' + ' --version) echo "${FAKE_GCC_BANNER:-fake-g++ (test) ${FAKE_GCC_VERSION:-10.3.1}}";' + " exit 0;; esac\n" 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' ) if fail: @@ -239,7 +241,8 @@ def test_pch_script_gcc10_skipped_elsewhere( ("linux", "aarch64", _LibreTinyPlatform, "10.3.1"), # From GCC 12 the .gch loads at any address, so the rule retires itself ("darwin", "arm64", _LibreTinyPlatform, "12.2.0"), - ("win32", "AMD64", _LibreTinyPlatform, "14.2.0"), + # Windows needs the fix for GCC bug 14940 as well + ("win32", "AMD64", _LibreTinyPlatform, "14.4.0"), ], ) def test_pch_script_no_wrapper_where_the_gch_loads( @@ -272,6 +275,54 @@ def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR + assert namespace["_WINDOWS_GCC_FIXED"] == pch.PCH_WINDOWS_GCC_FIXED + assert namespace["_WINDOWS_GCC_FIXED_DEFAULT"] == pch.PCH_WINDOWS_GCC_FIXED_DEFAULT + + +@pytest.mark.parametrize( + ("version", "env_vars", "on"), + [ + ("14.2.0", {}, False), + ("14.4.0", {}, True), + ("14.2.0", {"ESPHOME_PCH_ENABLE": "1"}, True), + ], +) +def test_pch_script_asks_the_compiler_on_windows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + version: str, + env_vars: dict[str, str], + on: bool, +) -> None: + """The Python rule again, plus the normalized knob.""" + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_VERSION": version, **env_vars} + ) + assert (scons_env.prepended == pch.pch_consumer_flags()) is on + assert (tmp_path / "dev" / "esphome_pch.h.gch").is_file() is on + out = capsys.readouterr().out + assert ("cannot load a precompiled header on Windows" in out) is not on + + +def test_pch_script_spares_another_compiler_the_gcc_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_BANNER": "clang version 15.0.0"} + ) + assert scons_env.prepended == pch.pch_consumer_flags() + + +def test_pch_script_leaves_the_header_off_when_the_compiler_cannot_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script(tmp_path, cxx=tmp_path / "missing-g++") + assert scons_env.prepended == [] + assert "GCC of unknown version cannot load" in capsys.readouterr().out def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index d218f3201f..60a5d1d423 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -354,6 +354,31 @@ def test_run_platformio_cli_exports_the_pch_ccache_settings( assert env.get("CCACHE_PCH_EXTSUM") == expected +@pytest.mark.parametrize( + ("value", "expected"), [("yes", "1"), ("0", "0"), (None, None)] +) +def test_run_platformio_cli_normalizes_a_forced_pch_for_the_script( + setup_core: Path, + mock_run_external_process: Mock, + value: str | None, + expected: str | None, +) -> None: + """The script cannot import the knob parser, so it only reads a ``1``.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + env_vars = {} if value is None else {"ESPHOME_PCH_ENABLE": value} + with patch.dict(os.environ, env_vars, clear=True): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get("ESPHOME_PCH_ENABLE") == expected + + @pytest.mark.parametrize( ("platform", "framework", "expected"), [