bambuddy/backend/app/core/tasks.py
maziggy f243e4e598 fix(asyncio): track strong refs on orphan create_task sites
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
2026-06-06 10:41:28 +02:00

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)