mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 19:13:18 +08:00
317 lines
9.5 KiB
Python
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
|