mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Fix singleton leaving a dangling Event when wrapped coroutine raises (#174400)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2f633193e9
commit
d782cd8289
@@ -59,19 +59,28 @@ def singleton[_S, _T, _U](
|
||||
@functools.wraps(func)
|
||||
async def async_wrapped(hass: HomeAssistant) -> _T:
|
||||
if data_key not in hass.data:
|
||||
evt = hass.data[data_key] = asyncio.Event()
|
||||
result = await func(hass)
|
||||
future: asyncio.Future[_T] = asyncio.get_running_loop().create_future()
|
||||
hass.data[data_key] = future
|
||||
try:
|
||||
result = await func(hass)
|
||||
except BaseException as err:
|
||||
# Clear the key so a future call retries, and propagate the
|
||||
# failure to any waiters instead of leaving them hung.
|
||||
del hass.data[data_key]
|
||||
future.set_exception(err)
|
||||
# Retrieve so an unawaited future does not log the exception.
|
||||
future.exception()
|
||||
raise
|
||||
hass.data[data_key] = result
|
||||
evt.set()
|
||||
future.set_result(result)
|
||||
return cast(_T, result)
|
||||
|
||||
obj_or_evt = hass.data[data_key]
|
||||
obj_or_future = hass.data[data_key]
|
||||
|
||||
if isinstance(obj_or_evt, asyncio.Event):
|
||||
await obj_or_evt.wait()
|
||||
return cast(_T, hass.data[data_key])
|
||||
if isinstance(obj_or_future, asyncio.Future):
|
||||
return cast(_T, await obj_or_future)
|
||||
|
||||
return cast(_T, obj_or_evt)
|
||||
return cast(_T, obj_or_future)
|
||||
|
||||
return async_wrapped
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test singleton helper."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -45,3 +46,58 @@ def test_singleton(mock_hass: HomeAssistant, result: Any) -> None:
|
||||
assert result1 is result2
|
||||
assert "test_key" in mock_hass.data
|
||||
assert mock_hass.data["test_key"] is result1
|
||||
|
||||
|
||||
async def test_singleton_async_raises(mock_hass: HomeAssistant) -> None:
|
||||
"""Test the key is not poisoned when the wrapped coroutine raises."""
|
||||
calls = 0
|
||||
|
||||
@singleton.singleton("test_key")
|
||||
async def something(hass: HomeAssistant) -> Any:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise ValueError("boom")
|
||||
return "result"
|
||||
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
await something(mock_hass)
|
||||
|
||||
# The failure cleared the key, so a later call retries cleanly.
|
||||
assert "test_key" not in mock_hass.data
|
||||
|
||||
assert await something(mock_hass) == "result"
|
||||
assert mock_hass.data["test_key"] == "result"
|
||||
assert calls == 2
|
||||
|
||||
|
||||
async def test_singleton_async_concurrent_raises(mock_hass: HomeAssistant) -> None:
|
||||
"""Test a concurrent caller wakes up when the in-flight call raises."""
|
||||
release = asyncio.Event()
|
||||
calls = 0
|
||||
|
||||
@singleton.singleton("test_key")
|
||||
async def something(hass: HomeAssistant) -> Any:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await release.wait()
|
||||
raise ValueError("boom")
|
||||
|
||||
# First caller installs the future and parks on release.wait().
|
||||
task1 = asyncio.create_task(something(mock_hass))
|
||||
await asyncio.sleep(0)
|
||||
# Second caller finds the in-flight future and waits on it.
|
||||
task2 = asyncio.create_task(something(mock_hass))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
release.set()
|
||||
|
||||
async with asyncio.timeout(1):
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
await task1
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
await task2
|
||||
|
||||
# Only the first caller ran the wrapped function; the waiter observed its error.
|
||||
assert calls == 1
|
||||
assert "test_key" not in mock_hass.data
|
||||
|
||||
Reference in New Issue
Block a user