Fail fast on empty compile databases and unsupported analyze-memory toolchains; degrade a missing size tool

ninja -t compdb exits 0 with an empty list when the rule names drift, so
the compile database is now parsed and an empty result fails the build
and drops the stale file. analyze-memory refuses an unsupported
toolchain before paying for the compile. A missing size binary warns
instead of discarding an already-linked build, _parse_app_size returns
explicitly from its not-found branch, and the CI cache action asserts
the caller's install prefix matches the cached path. The esp8266
run_compile dispatch hook and copy_files early-return gain direct
tests.
This commit is contained in:
J. Nick Koston
2026-08-22 12:06:38 -05:00
parent 583f47034f
commit b002a37987
5 changed files with 104 additions and 19 deletions
@@ -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
+9 -8
View File
@@ -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())
+28 -8
View File
@@ -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
@@ -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()) == []
+34 -2
View File
@@ -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: