From 5c2b05d0eb5fe08cb1cbb4b7d133e30308c49835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 09:00:51 -1000 Subject: [PATCH] [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) --- script/cpp_unit_test.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c5179a1b1e9..c2b34e1b409 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -3,7 +3,9 @@ import argparse from functools import partial import os from pathlib import Path +import subprocess import sys +import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, @@ -12,6 +14,9 @@ from build_helpers import ( ) from helpers import get_all_components, root_path +from esphome.core import EsphomeError +from esphome.host.toolchain import find_compilers + # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -20,11 +25,55 @@ BUILD_FLAGS = [ "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", "-fno-omit-frame-pointer", ] +SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] + + +def sanitizer_flags() -> list[str]: + """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime + does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which + would fail every test for a toolchain problem, so each flag is tried on an empty program + first. A compiler that cannot build the empty program is an error, not a dropped flag. + CI keeps every flag. + """ + if sys.platform != "darwin": + return SANITIZER_FLAGS + try: + cxx = find_compilers().cxx + except EsphomeError as err: + sys.exit(str(err)) + flags: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + program = Path(tmp) / "probe" + for flag in SANITIZER_FLAGS: + compiled = subprocess.run( + [*cxx, flag, "-x", "c++", "-", "-o", program], + check=False, + input=b"int main() { return 0; }", + capture_output=True, + ) + if compiled.returncode != 0: + sys.exit( + f"{cxx[0]} cannot build an empty program with {flag}:\n" + f"{compiled.stderr.decode(errors='replace').strip()}" + ) + ran = subprocess.run([program], check=False, capture_output=True) + if ran.returncode == 0: + flags.append(flag) + continue + lines = [ + line + for line in ran.stderr.decode(errors="replace").splitlines() + if line.strip() + ] + print( + f"{flag} aborts at start with {cxx[0]}: " + f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" + " building the tests without it; set CXX to a compiler whose runtime works to keep it", + file=sys.stderr, + ) + return flags def run_tests(selected_components: list[str]) -> int: @@ -39,7 +88,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - build_flags=BUILD_FLAGS, + build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", )