mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
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:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,6 +183,7 @@ 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")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
||||
capture_output=True,
|
||||
@@ -176,6 +191,11 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> Non
|
||||
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()) == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user