Files
esphome/tests/unit_tests/test_async_thread.py
T

317 lines
9.5 KiB
Python

"""Tests for the async thread helpers."""
from __future__ import annotations
import asyncio
import threading
from typing import Any
from unittest.mock import patch
import pytest
from esphome.async_thread import AsyncDispatchTimeout, AsyncThreadRunner, run_async
def _cleanup_threads() -> set[threading.Thread]:
"""Return the currently live orphan-cleanup threads."""
return {t for t in threading.enumerate() if t.name == "async-orphan-cleanup"}
def _join_new_cleanup_threads(before: set[threading.Thread]) -> None:
"""Wait for cleanup threads spawned since ``before`` to finish."""
for thread in _cleanup_threads() - before:
thread.join(5)
assert not thread.is_alive()
def test_run_async_returns_result() -> None:
"""The coroutine's result is returned to the sync caller."""
async def coro() -> int:
await asyncio.sleep(0)
return 42
assert run_async(coro) == 42
def test_run_async_propagates_exception() -> None:
"""Exceptions raised by the coroutine surface in the caller."""
async def coro() -> None:
raise ValueError("boom")
with pytest.raises(ValueError, match="boom"):
run_async(coro)
def test_run_async_propagates_base_exception() -> None:
"""A BaseException from the coroutine surfaces instead of a None result."""
class Boom(BaseException):
pass
async def coro() -> None:
raise Boom
with pytest.raises(Boom):
run_async(coro)
def test_run_async_timeout() -> None:
"""A coroutine that does not finish in time raises TimeoutError."""
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.05)
# Unblock the abandoned runner so its cleanup thread exits promptly.
release.set()
_join_new_cleanup_threads(before)
def test_run_async_surfaces_loop_startup_failure() -> None:
"""A failure before the coroutine runs raises instead of hanging."""
def failing_run(main: Any) -> None:
# Close the never-awaited coroutine so the test does not leave a
# RuntimeWarning attributed to whatever module GC runs in later.
main.close()
raise OSError("no fds for the event loop")
with (
patch("esphome.async_thread.asyncio.run", side_effect=failing_run),
pytest.raises(OSError, match="no fds"),
):
run_async(lambda: asyncio.sleep(0), timeout=5)
def test_run_preserves_result_when_cleanup_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A loop-cleanup failure after success is logged, not raised."""
async def coro() -> str:
return "ok"
runner: AsyncThreadRunner[str] = AsyncThreadRunner(coro)
def fake_run(main: Any) -> None:
main.close()
# Emulate _runner delivering the result before cleanup raised. A
# None result must count as delivered too, hence the completed flag.
runner.result = "ok"
runner.completed = True
raise KeyboardInterrupt
with (
caplog.at_level("DEBUG", logger="esphome.async_thread"),
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
):
runner.run()
assert runner.event.is_set()
assert runner.exception is None
assert runner.result == "ok"
assert "teardown failed after outcome recorded" in caplog.text
def test_run_async_none_result_is_success() -> None:
"""A coroutine legitimately returning None is not treated as a failure."""
async def coro() -> None:
return None
assert run_async(coro) is None
def test_run_async_on_orphan_skips_none_result() -> None:
"""A late None result completes cleanly without invoking on_orphan."""
orphaned: list[Any] = []
finished = threading.Event()
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
finished.set()
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
release.set()
assert finished.wait(5)
_join_new_cleanup_threads(before)
assert not orphaned
def test_late_failure_without_on_orphan_is_logged(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An abandoned thread's real error leaves a visible trace."""
release = threading.Event()
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
raise ValueError("the real cause")
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
assert "Abandoned async operation failed" in caplog.text
assert "the real cause" in caplog.text
def test_run_async_on_orphan_failure_is_contained(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An on_orphan callback that raises is logged, not propagated."""
released = threading.Event()
release = threading.Event()
def on_orphan(result: str) -> None:
released.set()
raise OSError("close failed")
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "late result"
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=on_orphan)
release.set()
assert released.wait(5)
_join_new_cleanup_threads(before)
assert "Error releasing orphaned result" in caplog.text
def test_run_async_on_orphan_releases_late_result() -> None:
"""A result produced after the timeout is handed to on_orphan."""
orphaned: list[Any] = []
delivered = threading.Event()
release = threading.Event()
def on_orphan(result: str) -> None:
orphaned.append(result)
delivered.set()
async def coro() -> str:
# Block until the test has observed the timeout, so the result is
# guaranteed to arrive late no matter how slowly the runner is
# scheduled.
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "late result"
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=on_orphan)
release.set()
assert delivered.wait(5)
_join_new_cleanup_threads(before)
assert orphaned == ["late result"]
def test_run_async_on_orphan_skips_late_failure() -> None:
"""A late failure after the timeout is not handed to on_orphan."""
orphaned: list[Any] = []
failed = threading.Event()
release = threading.Event()
async def coro() -> str:
# Block until the test has observed the timeout, so the failure is
# guaranteed to arrive late.
await asyncio.get_running_loop().run_in_executor(None, release.wait)
failed.set()
raise ValueError("late failure")
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
release.set()
assert failed.wait(5)
_join_new_cleanup_threads(before)
assert not orphaned
def test_run_async_detects_missing_outcome() -> None:
"""A run that records neither result nor exception raises loudly."""
def fake_run(main: Any) -> None:
# Simulate a loop that silently dropped the coroutine.
main.close()
with (
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
pytest.raises(RuntimeError, match="without a result"),
):
run_async(lambda: asyncio.sleep(0), timeout=5)
def test_run_async_raises_distinguishable_timeout() -> None:
"""The dispatcher's own expiry is a distinct TimeoutError subclass."""
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
before = _cleanup_threads()
with pytest.raises(AsyncDispatchTimeout):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
def test_orphan_watcher_gives_up_on_a_hung_coroutine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The watcher exits after its bound instead of parking forever."""
from esphome import async_thread
monkeypatch.setattr(async_thread, "ORPHAN_WAIT_TIMEOUT", 0.01)
release = threading.Event()
orphaned: list[Any] = []
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "too late"
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
_join_new_cleanup_threads(before)
assert not orphaned
release.set()
def test_late_real_result_without_handler_is_logged(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A genuinely dropped late result leaves the discard trace."""
release = threading.Event()
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "dropped"
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
assert "Discarding late result" in caplog.text