diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index fdc9f4dfd..503fe7806 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -541,10 +541,19 @@ class Job(CoreSysAttributes): ) def _clear_detached_task(self, task: asyncio.Task[Any]) -> None: - """Drop the reference to a finished detached task. + """Drop the reference to a finished detached task and consume its error. - Guarded by identity so an older task cannot clear a newer one. + A caller that does not await the task must not trigger asyncio's + "Task exception was never retrieved" report, so retrieve the error + here. Callers that do await the task still receive it. A HassioError + is only logged if it was raised with a logger, so log one line naming + the job to keep the failure observable. A JobException was already + logged with its traceback by the wrapper. Guarded by identity so an + older task cannot clear a newer one. """ + if not task.cancelled() and (err := task.exception()) is not None: + if not isinstance(err, JobException): + _LOGGER.warning("Detached job %s failed: %s", self.name, err) if self._detached_task is task: self._detached_task = None diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 3785e562a..1790a624e 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, timedelta +import gc from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch from uuid import uuid4 @@ -1835,3 +1836,36 @@ async def test_detach_drops_finished_task_reference(coresys: CoreSys): await asyncio.sleep(0) # pylint: disable-next=protected-access assert TestClass.job._detached_task is None + + +async def test_detach_unawaited_error_is_retrieved( + coresys: CoreSys, caplog: pytest.LogCaptureFixture +): + """Test a failed detached task nobody awaits is logged once, not reported by asyncio.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job(name="test_detach_unawaited_error_is_retrieved_execute", detach=True) + async def execute(self) -> None: + """Execute the class method.""" + raise HassioError("boom") + + test = TestClass(coresys) + with patch("asyncio.base_events.logger") as asyncio_logger: + task = await test.execute() + assert task is not None + await asyncio.sleep(0) + assert task.done() + del task + gc.collect() + + asyncio_logger.error.assert_not_called() + assert ( + "Detached job test_detach_unawaited_error_is_retrieved_execute failed: boom" + in caplog.text + )