test_tasks.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """Unit tests for the spawn_background_task helper (#1648 follow-up).
  2. asyncio holds only weak references to tasks, so a fire-and-forget
  3. create_task whose return value is discarded can be GC'd mid-flight and
  4. log ``Task was destroyed but it is pending!`` with no traceback.
  5. ``spawn_background_task`` is the central helper that fixes this: it
  6. stores a strong reference until completion, surfaces uncaught exceptions
  7. through the logger, and auto-removes finished tasks.
  8. """
  9. import asyncio
  10. import logging
  11. import pytest
  12. from backend.app.core.tasks import active_task_count, spawn_background_task
  13. @pytest.mark.asyncio
  14. async def test_holds_strong_ref_until_completion():
  15. """Discarding the returned task must not let asyncio reap it mid-flight.
  16. Pre-fix, ``asyncio.create_task(coro)`` with no caller-side reference
  17. would let GC swallow short tasks before they finished."""
  18. finished = asyncio.Event()
  19. async def work() -> None:
  20. await asyncio.sleep(0)
  21. finished.set()
  22. # Note: NOT storing the returned task -- this is exactly the
  23. # pattern the helper exists to support.
  24. spawn_background_task(work())
  25. await asyncio.wait_for(finished.wait(), timeout=1.0)
  26. @pytest.mark.asyncio
  27. async def test_removes_from_strong_ref_set_after_completion():
  28. """The strong-ref set must shrink as tasks complete; otherwise a
  29. long-running process accumulates one entry per spawned task and the
  30. helper itself becomes a leak."""
  31. before = active_task_count()
  32. async def work() -> None:
  33. await asyncio.sleep(0)
  34. spawn_background_task(work())
  35. spawn_background_task(work())
  36. # Yield enough times for both tasks + their done-callbacks to run.
  37. for _ in range(5):
  38. await asyncio.sleep(0)
  39. assert active_task_count() == before
  40. @pytest.mark.asyncio
  41. async def test_uncaught_exception_logged_as_warning(caplog):
  42. """A fire-and-forget task that raises must surface the exception via
  43. the logger with the traceback attached -- otherwise the error vanishes
  44. silently and only an opaque ``Task was destroyed`` notice reaches the
  45. support bundle."""
  46. async def boom() -> None:
  47. raise RuntimeError("synthetic failure for test")
  48. with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
  49. spawn_background_task(boom(), name="boom-task")
  50. for _ in range(5):
  51. await asyncio.sleep(0)
  52. # One WARNING with the task name and the exception info.
  53. boom_records = [r for r in caplog.records if "boom-task" in r.message]
  54. assert len(boom_records) == 1
  55. assert boom_records[0].levelno == logging.WARNING
  56. assert boom_records[0].exc_info is not None
  57. assert isinstance(boom_records[0].exc_info[1], RuntimeError)
  58. @pytest.mark.asyncio
  59. async def test_cancelled_task_does_not_log_exception(caplog):
  60. """Explicit cancellation isn't an error -- a service shutting down
  61. its background loops should not be reported as 'uncaught exception'."""
  62. async def long_running() -> None:
  63. await asyncio.sleep(10.0)
  64. with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
  65. task = spawn_background_task(long_running(), name="cancel-me")
  66. await asyncio.sleep(0) # Let it start.
  67. task.cancel()
  68. try:
  69. await task
  70. except asyncio.CancelledError:
  71. pass
  72. assert not any("cancel-me" in r.message for r in caplog.records)
  73. @pytest.mark.asyncio
  74. async def test_task_name_propagates():
  75. """Named tasks make the leak source visible in tracebacks and the
  76. done-callback log line. Pin that ``name`` reaches the underlying
  77. Task so support bundles surface the spawn site."""
  78. task = spawn_background_task(asyncio.sleep(0), name="named-spawn-test")
  79. assert task.get_name() == "named-spawn-test"
  80. await task