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:
Paulus Schoutsen
2026-06-21 23:26:44 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2f633193e9
commit d782cd8289
2 changed files with 73 additions and 8 deletions
+17 -8
View File
@@ -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
+56
View File
@@ -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