tasks.py 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. """Background-task helper that keeps a strong reference to fire-and-forget tasks.
  2. asyncio holds only a weak reference to tasks returned by ``create_task`` --
  3. when the caller discards the return value (the "fire and forget" pattern),
  4. the task can be garbage-collected mid-execution and the event loop logs
  5. ``Task was destroyed but it is pending!`` with no traceback. A support
  6. bundle review under #1648 surfaced 94 such warnings in 8 days of v0.2.4.5.
  7. ``spawn_background_task`` is the one place in the codebase that calls
  8. ``asyncio.create_task``. It stores the task in a module-level set, removes
  9. it when the task completes, and surfaces any uncaught exception through
  10. the logger so a silently-swallowed error becomes a visible WARNING with
  11. the originating traceback instead of an opaque GC warning.
  12. Use this for any work that should run in the background without being
  13. awaited inline. For tasks that the service owns and needs to cancel on
  14. shutdown, store the returned ``asyncio.Task`` on the service instance
  15. instead (the helper still adds the strong reference, so storing it twice
  16. is redundant but harmless).
  17. """
  18. from __future__ import annotations
  19. import asyncio
  20. import logging
  21. from collections.abc import Coroutine
  22. from typing import Any
  23. logger = logging.getLogger(__name__)
  24. # Strong-reference holder. Tasks live here from creation through completion.
  25. # Module-level so the set survives across spawn calls; the done-callback
  26. # removes each task as it finishes so the set doesn't grow without bound
  27. # (the event loop's GC can't reap an entry the callback still holds, but
  28. # the discard breaks the cycle immediately).
  29. _background_tasks: set[asyncio.Task[Any]] = set()
  30. def spawn_background_task(
  31. coro: Coroutine[Any, Any, Any],
  32. *,
  33. name: str | None = None,
  34. ) -> asyncio.Task[Any]:
  35. """Schedule ``coro`` on the running loop without losing the task reference.
  36. Args:
  37. coro: The coroutine to run. Must not already be a Task.
  38. name: Optional task name surfaced in /tracebacks and the
  39. done-callback log line so a leaked task is traceable to its
  40. spawn site.
  41. Returns:
  42. The created ``asyncio.Task``. Most callers ignore it -- the helper
  43. keeps its own strong reference. Callers that need to ``await`` or
  44. cancel later can store it on a service instance.
  45. """
  46. task = asyncio.create_task(coro, name=name)
  47. _background_tasks.add(task)
  48. task.add_done_callback(_on_task_done)
  49. return task
  50. def _on_task_done(task: asyncio.Task[Any]) -> None:
  51. """Discard the strong reference and surface any uncaught exception.
  52. Without this, an exception raised inside a fire-and-forget task is
  53. silently retrieved by ``Task.__del__`` and never reaches the logger.
  54. Surface it here as a WARNING with the task name so support bundles
  55. capture the originating error instead of an opaque GC notice.
  56. """
  57. _background_tasks.discard(task)
  58. if task.cancelled():
  59. return
  60. exc = task.exception()
  61. if exc is not None:
  62. logger.warning(
  63. "Background task %r raised an uncaught exception",
  64. task.get_name(),
  65. exc_info=exc,
  66. )
  67. def active_task_count() -> int:
  68. """Number of background tasks currently in flight. Used by tests."""
  69. return len(_background_tasks)