From d782cd828963eaf05df377d7f7b3fefa2b1b0c4a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 21 Jun 2026 23:26:44 -0400 Subject: [PATCH] Fix singleton leaving a dangling Event when wrapped coroutine raises (#174400) Co-authored-by: Claude Opus 4.8 --- homeassistant/helpers/singleton.py | 25 ++++++++----- tests/helpers/test_singleton.py | 56 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 64db32e15e77..da1fe6baad1a 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -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 diff --git a/tests/helpers/test_singleton.py b/tests/helpers/test_singleton.py index 4722c58dc9f6..12d4b095cf81 100644 --- a/tests/helpers/test_singleton.py +++ b/tests/helpers/test_singleton.py @@ -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