Files
esphome/tests/integration/test_scheduler_blocking_warning.py
J. Nick Koston 3a4f67def8 [core] Fix delay on failed component being dropped; DRY the is_failed check
The is_failed() skip exists in two execution paths: the heap loop in call()
and should_skip_item_() (defer queue / delay:0). The previous commit only
exempted SELF_POINTER items from the heap-path check, so on multi-threaded
platforms a delay:0 continuation whose host component had failed would still
be silently dropped.

Extract a single is_item_failed_() helper (with the SELF_POINTER exemption)
and use it from both paths so they cannot drift again.

Add an integration test that schedules a delay from a component that marks
itself failed and asserts the continuation still fires (verified to fail
without the exemption).
2026-06-02 13:54:17 -05:00

92 lines
3.6 KiB
Python

"""Integration test for blocking-warning source attribution.
A blocking operation that runs inside a deferred scheduler continuation (e.g. after
a ``delay`` in a script/automation) used to be reported as
``<null> took a long time for an operation (NN ms), max is 30 ms`` because the
continuation carries no component. The warning should instead name the component that
was current when the delay was scheduled and report the real threshold (50 ms).
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Matches: "<source> took a long time for an operation (NN ms), max is NN ms"
WARN_PATTERN = re.compile(
r"took a long time for an operation \((\d+) ms\), max is (\d+) ms"
)
@pytest.mark.asyncio
async def test_scheduler_blocking_warning(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Deferred blocking work is attributed to a real component, not "<null>"."""
loop = asyncio.get_running_loop()
warning_future: asyncio.Future[str] = loop.create_future()
def check_output(line: str) -> None:
if WARN_PATTERN.search(line) and not warning_future.done():
warning_future.set_result(line)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
# The interval fires, defers via delay, then busy-blocks > 50 ms in the
# continuation, which should trip the blocking warning.
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
# The deferred block must be attributed to a real component, not "<null>".
assert "<null>" not in warning_line, (
f"Warning should name a component, got: {warning_line}"
)
# The delay was scheduled from a known component (the interval), so the warning
# must name it rather than falling back to the generic scheduled-task label.
assert "a scheduled task" not in warning_line, (
f"Warning should name the interval component, got: {warning_line}"
)
# The reported threshold must be the real default (50 ms), not the stale "30 ms".
match = WARN_PATTERN.search(warning_line)
assert match is not None
assert match.group(2) == "50", f"Expected 'max is 50 ms', got: {warning_line}"
@pytest.mark.asyncio
async def test_scheduler_delay_runs_on_failed_component(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A delay must still fire even when its host component is marked failed.
DelayAction records the current component on its scheduler item purely for log
attribution. That component must not gate execution: the scheduler skips items
belonging to failed components, but SELF_POINTER items (delays) are exempt. This
guards the is_item_failed_() exception on both the heap and defer-queue paths.
"""
loop = asyncio.get_running_loop()
fired: asyncio.Future[bool] = loop.create_future()
def check_output(line: str) -> None:
if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done():
fired.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
assert await client.device_info() is not None
# If the failed host component wrongly dropped the delay, this times out.
await asyncio.wait_for(fired, timeout=10.0)