diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml index a9fff84d6c..15105004ec 100644 --- a/.github/actions/cache-arduino8266/action.yml +++ b/.github/actions/cache-arduino8266/action.yml @@ -16,6 +16,12 @@ runs: id: version shell: bash run: | + # The caller's install prefix must match the cached path below, or + # the cache silently stores/restores an empty directory + [ "$ESPHOME_ARDUINO8266_PREFIX" = "$HOME/.esphome-arduino8266" ] || { + echo "ESPHOME_ARDUINO8266_PREFIX is '$ESPHOME_ARDUINO8266_PREFIX', expected '$HOME/.esphome-arduino8266'" >&2 + exit 1 + } . venv/bin/activate key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")') [ -n "$key" ] || exit 1 diff --git a/esphome/__main__.py b/esphome/__main__.py index f1a8934c00..2a6033d296 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1999,6 +1999,15 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.cli import MemoryAnalyzerCLI from esphome.analyze_memory.ram_strings import RamStringsAnalyzer + # Refuse an unsupported toolchain before paying for a full compile + native_toolchain = _native_toolchain_module() + if native_toolchain is None and not CORE.using_toolchain_platformio: + _LOGGER.error( + "analyze-memory is not supported with the '%s' toolchain", + CORE.toolchain.value if CORE.toolchain else "unresolved", + ) + return 1 + # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) if exit_code != 0: @@ -2010,14 +2019,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - native_toolchain = _native_toolchain_module() - - if native_toolchain is None and not CORE.using_toolchain_platformio: - _LOGGER.error( - "analyze-memory is not supported with the '%s' toolchain", - CORE.toolchain.value if CORE.toolchain else "unresolved", - ) - return 1 if native_toolchain is not None: objdump_path = str(native_toolchain.get_objdump_path()) readelf_path = str(native_toolchain.get_readelf_path()) diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index 6ae7b9c277..d1a5e6e461 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from pathlib import Path import subprocess @@ -131,6 +132,18 @@ def _write_compile_commands( # the memory analyzer) can't silently read outdated data. (build_dir / "compile_commands.json").unlink(missing_ok=True) raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") + try: + entries = json.loads(result.stdout) + except ValueError: + entries = None + if not entries: + # compdb exits 0 with [] for unknown rule names; a renamed compile + # rule must fail the build, not silently strand every consumer + (build_dir / "compile_commands.json").unlink(missing_ok=True) + raise EsphomeError( + "ninja produced an empty compile database; the generator's rule " + "names no longer match" + ) # write_file_if_changed keeps the mtime stable on no-op builds so the # idedata cache in get_idedata() stays valid. write_file_if_changed(build_dir / "compile_commands.json", result.stdout) @@ -152,7 +165,8 @@ def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | N app_size = segment_length(ld_text, "irom0_0_seg") if app_size is None: _LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path) - elif app_size == 0: + return None + if app_size == 0: _LOGGER.warning( "irom0_0_seg has zero length in %s; skipping Flash summary", ld_path ) @@ -169,13 +183,19 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> Non from esphome.build_helpers.size_summary import print_size_line size_tool = _toolchain_tool("size") - result = subprocess.run( - [str(size_tool), "-A", "-d", str(get_elf_path())], - capture_output=True, - text=True, - check=False, - close_fds=False, - ) + try: + result = subprocess.run( + [str(size_tool), "-A", "-d", str(get_elf_path())], + capture_output=True, + text=True, + check=False, + close_fds=False, + ) + except OSError as err: + # The summary is a bonus artifact like idedata; a truncated + # toolchain extraction must not discard an already-linked build + _LOGGER.warning("Could not summarize firmware size: %s", err) + return if result.returncode != 0: _LOGGER.warning("Could not summarize firmware size: %s", result.stderr) return diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index f7ef786ea1..6582180e19 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -24,7 +24,7 @@ from esphome.const import ( CONF_VERSION, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.types import ConfigType @@ -163,3 +163,29 @@ def test_resolve_toolchain_rejects_unsupported() -> None: CORE.toolchain = Toolchain.SDK_NRF with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"): _resolve_toolchain({}) + + +def test_run_compile_platformio_falls_through() -> None: + """Under toolchain: platformio the hook returns False without touching + the native backend; this is what keeps existing users on PlatformIO.""" + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.arduino8266.toolchain.run_compile") as mock_native: + assert esp8266.run_compile(SimpleNamespace(), {}) is False + mock_native.assert_not_called() + + +def test_run_compile_arduino_failure_raises() -> None: + """A non-zero native build fails by name instead of returning success.""" + CORE.verbose = False + with ( + patch("esphome.arduino8266.toolchain.run_compile", return_value=1), + pytest.raises(EsphomeError, match="native build failed"), + ): + esp8266.run_compile(SimpleNamespace(), {}) + + +def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None: + """The native build writes no PlatformIO extra scripts.""" + CORE.build_path = tmp_path + esp8266.copy_files() + assert list(tmp_path.iterdir()) == [] diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index bc267ca2a2..e7a15552cd 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -120,13 +120,33 @@ def test_run_compile_warns_when_idedata_fails( def test_write_compile_commands(tmp_path: Path) -> None: build_dir = tmp_path / "build" build_dir.mkdir() + entries = '[{"file": "a.cpp", "command": "cc"}]\n' with patch.object( toolchain.subprocess, "run", - return_value=MagicMock(returncode=0, stdout="[]\n"), + return_value=MagicMock(returncode=0, stdout=entries), ): toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) - assert (build_dir / "compile_commands.json").read_text() == "[]\n" + assert (build_dir / "compile_commands.json").read_text() == entries + + +@pytest.mark.parametrize("stdout", ["[]\n", "not json"]) +def test_write_compile_commands_empty_db_raises(tmp_path: Path, stdout: str) -> None: + """An empty compile database (compdb exits 0 with [] for unknown rule + names) must fail the build and drop any stale database.""" + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "compile_commands.json").write_text("[stale]") + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=stdout), + ), + pytest.raises(EsphomeError, match="empty compile database"), + ): + toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) + assert not (build_dir / "compile_commands.json").exists() def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None: @@ -183,6 +203,18 @@ def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out +def test_print_size_summary_missing_size_tool_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A missing size binary degrades to a warning; the firmware already + linked and must not be discarded.""" + with patch.object( + toolchain.subprocess, "run", side_effect=FileNotFoundError("no size") + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert "Could not summarize firmware size" in caplog.text + + def test_print_size_summary_no_app_size( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: