Consume the exception of a finished detached job task (#7217)

* Consume the exception of a finished detached job task

With detach, errors raised by the job body surface on the returned task
rather than to the caller. A caller that starts a detached job without
awaiting the task, such as a scheduled reload starting the Supervisor
auto update, would then leave an unretrieved exception that asyncio
reports with a full traceback when the task is garbage collected. The
wrapper has already captured the error on the job and logged it, so
that report only adds noise.

Retrieve the exception in the done callback the decorator already
registers on every detached task. This only marks it as retrieved:
callers that await the task still receive the exception.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Log a consumed detached job failure

A HassioError is only logged when it is raised with a logger, so
consuming the exception of an unawaited detached task could leave a
failure without any trace once the job record is cleaned up. Log one
warning line naming the job. A JobException is skipped because the
wrapper already logged it with its traceback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Stefan AgnerandClaude Fable 5.1 authored and GitHub committed 2026-09-14 15:30:17 +02:00
1 parent fb84e69f29
commit 24abbd2eba
2 files changed
+45 -2

No files matched your search

+11 -2
View File
@@ -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
+34
View File
@@ -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
)