[core] Leave the precompiled header off on Windows for a GCC with bug 14940 (#19875)

This commit is contained in:
J. Nick Koston
2026-09-29 18:51:12 +02:00
committed by GitHub
parent a7e0afeb46
commit b22f2e6b4c
17 changed files with 410 additions and 35 deletions
@@ -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
+48
View File
@@ -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"),
[
@@ -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()
+22 -9
View File
@@ -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, {})
+12 -3
View File
@@ -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")
@@ -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
+53 -2
View File
@@ -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:
@@ -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"),
[