[api] Don't spam tracebacks when decoding a crash without a local build (#17597)

This commit is contained in:
J. Nick Koston
2026-07-16 08:08:28 -04:00
committed by GitHub
parent 14e71e190c
commit 5b4ae22f58
5 changed files with 135 additions and 24 deletions
+30 -6
View File
@@ -12,11 +12,9 @@ from esphome.core import EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
on_log runs inside an asyncio protocol callback; if EsphomeError
escapes, the loop reports "Fatal error: protocol.data_received()
call failed.", tears the connection down, and ReconnectLogic loops
forever as the device replays the same crash trace on every
reconnect.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
@@ -43,6 +41,32 @@ def test_decoder_swallows_platform_handler_error() -> None:
assert processor.backtrace_state is False
def test_decoder_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
@@ -61,7 +85,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to PlatformIO; a crash dump can contain many
_decode_pc shells out to the toolchain; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
+73 -1
View File
@@ -5,12 +5,13 @@
import json
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
import pytest
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
from esphome.core import CORE, EsphomeError
from esphome.espidf import toolchain
@@ -237,6 +238,77 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_cmake_output_without_build_dir(setup_core: Path) -> None:
"""A build dir that was never created raises EsphomeError.
Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which
the log stack-trace decoder doesn't recognise as a decode failure.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
assert not build_dir.exists()
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None:
"""A build dir that exists but was never configured raises EsphomeError."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_with_configured_build(setup_core: Path) -> None:
"""A configured build still runs cmake and caches the output.
The missing-build guard must not get in the way of a real build.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
(build_dir / "CMakeCache.txt").write_text("")
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n"
)
with (
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run,
):
assert toolchain._get_cmake_output(build_dir) == completed.stdout
# Second call is served from the cache rather than re-running cmake.
assert toolchain._get_cmake_output(build_dir) == completed.stdout
mock_run.assert_called_once()
assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line")
def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
setup_core: Path,
) -> None:
"""The build check runs before the env is resolved.
Resolving the env calls check_esp_idf_install(), which can download and
extract the whole framework. A doomed call must never start that.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
with (
patch.object(toolchain, "_get_idf_env") as mock_env,
patch.object(toolchain.subprocess, "run") as mock_run,
pytest.raises(EsphomeError),
):
toolchain._get_cmake_output(build_dir)
mock_env.assert_not_called()
mock_run.assert_not_called()
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION