mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
asyncio holds only a weak reference to tasks returned by ``create_task``. Fire-and-forget callers that discard the return value let the event loop GC the task before it finishes, logging ``Task was destroyed but it is pending!`` with no traceback. The #1648 support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5 -- the silently-vanished exceptions reach support bundles as opaque GC notices instead of actionable errors. New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None) is the one place in the codebase that calls asyncio.create_task. It stores the task in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts name= so a leak source is traceable through /tracebacks and the log line. Cancelled tasks don't log (a shutting-down service is not an error). Migrated the 16 truly-orphan create_task call sites to the helper: main.py (8): reconcile-stale, cooldown-poweroff, energy calc, smart-plug, maintenance-check, photo-then-notify, layer-timelapse, scan-timelapse, print-scheduler, notify-no-archive (the last one was hand-rolling the same pattern with task + no-op done_callback) printers.py:3123 apply-pa-after-refresh print_queue.py:1034 queue cooldown-poweroff firmware_update.py:261 firmware upload archive.py:1514 timelapse mp4 convert print_scheduler.py:2199 watchdog print-start library.py:1614 STL backfill smart_plugs.py:259 tasmota scan discovery.py:159 subnet scan smart_plug_manager.py x3 plug auto-off-pending background_dispatch.py x2 (lambda-wrapped inside loop.call_soon_threadsafe) upload progress Sites that already kept strong refs are unchanged: self._tasks.append(asyncio.create_task(...)) -- VP manager, tcp_proxy, mqtt_server self._x_task = asyncio.create_task(...) on service instances -- mqtt_bridge, obico_detection, github_backup, archive_purge, local_backup, library_trash, discovery service Locally assigned + awaited/gathered -- tcp_proxy bidirectional pumps, camera_fanout, slice_dispatch, slicer_api progress_task, manager._finish_release_task, main.py module-level cleanup loops
85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""Background-task helper that keeps a strong reference to fire-and-forget tasks.
|
|
|
|
asyncio holds only a weak reference to tasks returned by ``create_task`` --
|
|
when the caller discards the return value (the "fire and forget" pattern),
|
|
the task can be garbage-collected mid-execution and the event loop logs
|
|
``Task was destroyed but it is pending!`` with no traceback. A support
|
|
bundle review under #1648 surfaced 94 such warnings in 8 days of v0.2.4.5.
|
|
|
|
``spawn_background_task`` is the one place in the codebase that calls
|
|
``asyncio.create_task``. It stores the task in a module-level set, removes
|
|
it when the task completes, and surfaces any uncaught exception through
|
|
the logger so a silently-swallowed error becomes a visible WARNING with
|
|
the originating traceback instead of an opaque GC warning.
|
|
|
|
Use this for any work that should run in the background without being
|
|
awaited inline. For tasks that the service owns and needs to cancel on
|
|
shutdown, store the returned ``asyncio.Task`` on the service instance
|
|
instead (the helper still adds the strong reference, so storing it twice
|
|
is redundant but harmless).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Coroutine
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Strong-reference holder. Tasks live here from creation through completion.
|
|
# Module-level so the set survives across spawn calls; the done-callback
|
|
# removes each task as it finishes so the set doesn't grow without bound
|
|
# (the event loop's GC can't reap an entry the callback still holds, but
|
|
# the discard breaks the cycle immediately).
|
|
_background_tasks: set[asyncio.Task[Any]] = set()
|
|
|
|
|
|
def spawn_background_task(
|
|
coro: Coroutine[Any, Any, Any],
|
|
*,
|
|
name: str | None = None,
|
|
) -> asyncio.Task[Any]:
|
|
"""Schedule ``coro`` on the running loop without losing the task reference.
|
|
|
|
Args:
|
|
coro: The coroutine to run. Must not already be a Task.
|
|
name: Optional task name surfaced in /tracebacks and the
|
|
done-callback log line so a leaked task is traceable to its
|
|
spawn site.
|
|
|
|
Returns:
|
|
The created ``asyncio.Task``. Most callers ignore it -- the helper
|
|
keeps its own strong reference. Callers that need to ``await`` or
|
|
cancel later can store it on a service instance.
|
|
"""
|
|
task = asyncio.create_task(coro, name=name)
|
|
_background_tasks.add(task)
|
|
task.add_done_callback(_on_task_done)
|
|
return task
|
|
|
|
|
|
def _on_task_done(task: asyncio.Task[Any]) -> None:
|
|
"""Discard the strong reference and surface any uncaught exception.
|
|
|
|
Without this, an exception raised inside a fire-and-forget task is
|
|
silently retrieved by ``Task.__del__`` and never reaches the logger.
|
|
Surface it here as a WARNING with the task name so support bundles
|
|
capture the originating error instead of an opaque GC notice.
|
|
"""
|
|
_background_tasks.discard(task)
|
|
if task.cancelled():
|
|
return
|
|
exc = task.exception()
|
|
if exc is not None:
|
|
logger.warning(
|
|
"Background task %r raised an uncaught exception",
|
|
task.get_name(),
|
|
exc_info=exc,
|
|
)
|
|
|
|
|
|
def active_task_count() -> int:
|
|
"""Number of background tasks currently in flight. Used by tests."""
|
|
return len(_background_tasks)
|