diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index ee5bfb1871..1f2fbb651f 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -36,6 +36,7 @@ from esphome.build_helpers.ninja import ( from esphome.build_helpers.pch import ( PCH_CORE_HEADER, PCH_HEADER_NAME, + mark_pch_emitted, pch_checksum, pch_enabled, pch_header_text, @@ -1284,6 +1285,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: ] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") src_cxx_override = ("$srccxxflags", gch) + mark_pch_emitted() src_objs = _ninja_compile_edges( lines, _collect_sources(src_dir), diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index e26b3a9408..6c52f41810 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -8,6 +8,7 @@ from esphome.build_helpers import pch from esphome.build_helpers.pch import ( PCH_DEFAULT_HEADERS, PCH_HEADER_NAME, + mark_pch_emitted, pch_enabled, pch_header_text, ) @@ -371,6 +372,8 @@ def write_project( CORE.relative_build_path("build", PCH_HEADER_NAME), pch_header_text(PCH_DEFAULT_HEADERS), ) + # Consumers carry the -include; gate the ccache relaxation on it + mark_pch_emitted() # Snapshot the exclusion set so has_outdated_files() can trigger a # discovery reconfigure when it changes. Excluded components never diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index bf57a157e3..eb0e0a831c 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -8,6 +8,7 @@ it too; Arduino.h visibility there is intended (esphome#8693). from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass import hashlib import json import logging @@ -25,6 +26,27 @@ from esphome.build_helpers.idedata import ( split_command, ) +_DOMAIN = "pch" + + +@dataclass +class _PCHData: + emitted: bool = False + + +def _pch_data() -> _PCHData: + from esphome.core import CORE + + if _DOMAIN not in CORE.data: + CORE.data[_DOMAIN] = _PCHData() + return CORE.data[_DOMAIN] + + +def mark_pch_emitted() -> None: + """Record that this build's consumers reference the pch.""" + _pch_data().emitted = True + + _LOGGER = logging.getLogger(__name__) # The header and its .gch/.sum sidecars live in the build directory. @@ -81,9 +103,10 @@ def pch_enabled() -> bool: def ccache_pch_env() -> dict[str, str]: """Settings ccache needs to cache compiles that consume the .gch; - empty when the pch is disabled. User-set values win. Native backends - export these process-wide; only time_macros affects non-pch TUs.""" - if not pch_enabled(): + empty unless this build actually emitted one. User-set values win. + Native backends export these process-wide; only time_macros affects + non-pch TUs.""" + if not (pch_enabled() and _pch_data().emitted): return {} env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ} user_sloppiness = os.environ.get("CCACHE_SLOPPINESS") diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 4009e91f01..dd0d9f5d40 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -386,6 +386,10 @@ def test_write_project_pch(tmp_path: Path) -> None: '#include "esphome/core/defines.h"', ] assert (build_dir / "esphome_pch.h.gch.sum").read_text().strip() + # Emission recorded so the framework env exports the ccache settings + from esphome.build_helpers.pch import ccache_pch_env + + assert "CCACHE_PCH_EXTSUM" in ccache_pch_env() def test_write_project_pch_sum_only_with_ccache(tmp_path: Path) -> None: @@ -456,6 +460,10 @@ def test_write_project_pch_skipped_when_user_force_include_precedes( assert "esphome_pch" not in content assert "srccxxflags" not in content assert "prevents the precompiled header" in caplog.text + # No pch emitted: the ccache relaxation must stay off + from esphome.build_helpers.pch import ccache_pch_env + + assert ccache_pch_env() == {} def test_write_project_pch_disabled( diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index e8ea0c18bb..bb6ee81a7f 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -33,7 +33,14 @@ def test_pch_enabled(value: str | None, expected: bool) -> None: assert pch.pch_enabled() is expected +def test_ccache_pch_env_empty_until_emitted() -> None: + """No sloppiness relaxation for a build that skipped the pch.""" + with patch.dict(os.environ, {}, clear=True): + assert pch.ccache_pch_env() == {} + + def test_ccache_pch_env_enabled() -> None: + pch.mark_pch_emitted() with patch.dict(os.environ, {}, clear=True): env = pch.ccache_pch_env() assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" @@ -48,6 +55,7 @@ def test_ccache_pch_env_disabled() -> None: def test_ccache_pch_env_token_check_is_membership_not_substring( caplog: pytest.LogCaptureFixture, ) -> None: + pch.mark_pch_emitted() """A token merely containing ours must not suppress the union.""" with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "pch_defines_extra"}, clear=True): env = pch.ccache_pch_env() @@ -57,6 +65,7 @@ def test_ccache_pch_env_token_check_is_membership_not_substring( def test_ccache_pch_env_unions_user_sloppiness( caplog: pytest.LogCaptureFixture, ) -> None: + pch.mark_pch_emitted() """Without pch_defines/time_macros ccache declines every pch-consuming compile, so missing tokens are unioned onto the user's value.""" with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True): diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 7f41687909..a17dec4397 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.arduino8266 import framework, toolchain +from esphome.build_helpers.pch import mark_pch_emitted import esphome.config_validation as cv from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, @@ -632,6 +633,7 @@ def test_get_idedata_accepts_preresolved_ccache() -> None: def test_ccache_env_includes_pch_settings() -> None: + mark_pch_emitted() """The native build exports the ccache settings the pch needs.""" with patch.dict(os.environ, {}, clear=True): env = framework.ccache_env("/usr/bin/ccache") @@ -647,6 +649,7 @@ def test_ccache_env_pch_disabled() -> None: def test_ccache_env_respects_user_sloppiness() -> None: + mark_pch_emitted() with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True): env = framework.ccache_env("/usr/bin/ccache") # The user's tokens survive; the ones the pch needs are unioned on diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index cad66a948b..a04b844d3c 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.build_helpers.pch import _PCHData from esphome.espidf.framework import ( ESPHOME_STAMP_FILE, STAMP_SCHEMA_VERSION, @@ -1565,10 +1566,14 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), - # ccache_defaults_env (build_helpers.ccache) reads CORE at call time + # ccache_defaults_env and the pch emission flag read CORE at call time patch( "esphome.core.CORE", - SimpleNamespace(build_path=build_path), + SimpleNamespace( + build_path=build_path, + # Pre-marked: these env tests model a pch-emitting build + data={"pch": _PCHData(emitted=True)}, + ), ), )