From 6c2a4d39996b04f7e0d38bfab13b7fd1279dbd96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 18:58:27 +0200 Subject: [PATCH] [host] Precompile the core headers on the native build (#19822) --- esphome/build_gen/host.py | 32 +++++++++- tests/unit_tests/build_gen/test_host.py | 81 +++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index c883d912eb..4f7c556a48 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -15,6 +15,7 @@ from collections.abc import Iterable import logging import os from pathlib import Path +import subprocess import sys from typing import TYPE_CHECKING @@ -28,8 +29,11 @@ from esphome.build_helpers.ninja_gen import ( compile_edges, compile_rule_lines, library_edges, + pch_edges, + pch_rule_lines, tool_lines, ) +from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_enabled from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags from esphome.helpers import mkdir_p, write_file_if_changed @@ -189,6 +193,15 @@ def _file_macro_maps(build_dir: Path) -> list[str]: ] +def _compiler_version(cxx: tuple[str, ...]) -> str: + """What the compiler says it is: its path can stay the same across an + update (the macOS shims in /usr/bin).""" + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + return result.stdout + + def write_project(compilers: HostCompilers, ccache: str | None) -> bool: """Write the ninja build for the current configuration. @@ -228,6 +241,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), *compile_rule_lines(), + *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", " rspfile = $out.rsp", @@ -251,7 +265,23 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: archives, direct_objs = library_edges(lines, libraries) - src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src") + src_cxx_override = pch_edges( + lines, + build_dir, + src_dir, + PCH_DEFAULT_HEADERS, + # The arguments of a CXX override come before the flags + [*compilers.cxx[1:], *cxxflags], + (), + (compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (), + ) + src_objs = compile_edges( + lines, + collect_sources(src_dir), + src_dir, + "src", + cxx_override=src_cxx_override, + ) if not src_objs: raise EsphomeError(f"No source files found under {src_dir}") diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index d7fa73e897..9e40698c91 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os from pathlib import Path +import sys from unittest.mock import patch import pytest @@ -37,8 +38,13 @@ def _make_src(tmp_path: Path, *names: str) -> Path: return src -def _render(ccache: str | None = None) -> tuple[bool, str]: - changed = build_gen.write_project(COMPILERS, ccache) +def _render( + ccache: str | None = None, + version: str = "g++ 1.0", + compilers: HostCompilers = COMPILERS, +) -> tuple[bool, str]: + with patch.object(build_gen, "_compiler_version", return_value=version): + changed = build_gen.write_project(compilers, ccache) ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" return changed, ninja.read_text() @@ -189,7 +195,7 @@ def test_write_project_requires_generated_sources(tmp_path: Path) -> None: build_gen.write_project(COMPILERS, None) _make_src(tmp_path, "esphome.h") with pytest.raises(EsphomeError, match="No source files found"): - build_gen.write_project(COMPILERS, None) + _render() def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: @@ -207,7 +213,7 @@ def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: ) assert "build obj/src/x.S.o: aspp " in ninja assert "build obj/src/y.s.o: asm " in ninja - assert "h.h" not in ninja + assert _e(src / "h.h") not in ninja # __FILE__ stays relative to the build path, as it was under PlatformIO, # whether the source arrives absolute or rewritten by ccache sep = os.sep @@ -232,6 +238,73 @@ def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: assert changed is False +def test_write_project_precompiles_the_core_headers(tmp_path: Path) -> None: + """C++ src edges load one precompiled prefix; C and assembly do not.""" + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S") + _, ninja = _render() + build_dir = tmp_path / ".pioenvs" / "dev" + source = build_dir / "esphome_pch_src.h" + assert source.read_text() == '#include "esphome/core/pch_prefix.h"\n' + assert "rule pch\n" in ninja + sum_path = build_dir / "esphome_pch.h.gch.sum" + assert ( + f"build esphome_pch.h.gch: pch {_e(source)} | esphome_pch.h.gch.sum\n" in ninja + ) + assert "srccxxflags = -Winvalid-pch " in ninja + assert ( + f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')} | esphome_pch.h.gch\n" + " flags = $srccxxflags\n" + ) in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}\n" + ) in ninja + assert f"build obj/src/x.S.o: aspp {_e(src / 'x.S')}\n" in ninja + # An updated compiler behind the same path rebuilds the header + first = sum_path.read_text() + _render(version="g++ 2.0") + assert sum_path.read_text() != first + + +def test_compiler_version_asks_the_compiler() -> None: + assert build_gen._compiler_version((sys.executable,)).startswith("Python ") + + +def test_write_project_pch_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src = _make_src(tmp_path, "main.cpp") + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + # The compiler is not asked for its version either + with patch.object(build_gen, "_compiler_version", side_effect=AssertionError): + build_gen.write_project(COMPILERS, None) + ninja = (tmp_path / ".pioenvs" / "dev" / "build.ninja").read_text() + assert "esphome_pch" not in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}\n" in ninja + + +def test_write_project_pch_skipped_for_a_user_force_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A -include in the build flags precedes the prefix, so the compiler + would never load the precompiled header.""" + _make_src(tmp_path, "main.cpp") + CORE.build_flags = {"-include pre.h"} + _, ninja = _render() + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """CXX="g++ -include pre.h" puts it ahead of every flag.""" + _make_src(tmp_path, "main.cpp") + override = HostCompilers(cc=COMPILERS.cc, cxx=("/usr/bin/g++", "-include", "pre.h")) + _, ninja = _render(compilers=override) + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render()