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
This commit is contained in:
maziggy
2026-06-06 10:41:28 +02:00
parent d82f4e032f
commit f243e4e598
14 changed files with 253 additions and 32 deletions
+5 -3
View File
@@ -12,6 +12,7 @@ from pydantic import BaseModel
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.user import User
from backend.app.services.discovery import (
discovery_service,
@@ -154,9 +155,10 @@ async def start_subnet_scan(
request: Subnet to scan in CIDR notation (e.g., "192.168.1.0/24")
"""
# Start scan in background
import asyncio
asyncio.create_task(subnet_scanner.scan_subnet(request.subnet, request.timeout))
spawn_background_task(
subnet_scanner.scan_subnet(request.subnet, request.timeout),
name=f"subnet-scan-{request.subnet}",
)
# Return immediate status
scanned, total = subnet_scanner.progress
+2 -2
View File
@@ -1,6 +1,5 @@
"""API routes for File Manager (Library) functionality."""
import asyncio
import base64
import binascii
import contextlib
@@ -30,6 +29,7 @@ from backend.app.core.auth import (
from backend.app.core.config import settings as app_settings
from backend.app.core.database import async_session, get_db
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.print_queue import PrintQueueItem
@@ -1611,7 +1611,7 @@ async def scan_external_folder(
# folder_cache.values() covers the root + every pre-existing subfolder
# + every subfolder created during this scan. all_folder_ids on its own
# would miss the newly-created ones (it's snapshotted before the walk).
asyncio.create_task(
spawn_background_task(
_backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
name=f"stl-backfill-folder-{folder_id}",
)
+2 -2
View File
@@ -16,6 +16,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
from backend.app.models.print_batch import PrintBatch
@@ -961,7 +962,6 @@ async def stop_queue_item(
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
):
"""Stop an actively printing queue item."""
import asyncio
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.printer_manager import printer_manager
@@ -1031,7 +1031,7 @@ async def stop_queue_item(
logger.info("Auto-off: Powering off printer %s", printer_id)
await tasmota_service.turn_off(plug)
asyncio.create_task(cooldown_and_poweroff())
spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}")
return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
+5 -1
View File
@@ -12,6 +12,7 @@ from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, Require
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.ams_label import AmsLabel
from backend.app.models.printer import Printer
from backend.app.models.slot_preset import SlotPresetMapping
@@ -3120,7 +3121,10 @@ async def refresh_ams_slot(
raise HTTPException(400, message)
# Apply PA profile after delay (RFID re-read takes a few seconds)
asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
spawn_background_task(
_apply_pa_after_refresh(printer_id, ams_id, slot_id),
name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}",
)
return {"success": True, "message": message}
+5 -2
View File
@@ -12,6 +12,7 @@ from backend.app.api.routes.settings import get_setting
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.printer import Printer
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
@@ -249,14 +250,16 @@ async def start_tasmota_scan(
Auto-detects local network if no IP range provided.
"""
import asyncio
# Auto-detect network
from_ip, to_ip = get_local_network_range()
timeout = request.timeout if request else 1.0
# Start scan in background
asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
spawn_background_task(
tasmota_scanner.scan_range(from_ip, to_ip, timeout),
name="tasmota-scan",
)
# Return immediate status
scanned, total = tasmota_scanner.progress