| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- """Unit tests for the spawn_background_task helper (#1648 follow-up).
- asyncio holds only weak references to tasks, so a fire-and-forget
- create_task whose return value is discarded can be GC'd mid-flight and
- log ``Task was destroyed but it is pending!`` with no traceback.
- ``spawn_background_task`` is the central helper that fixes this: it
- stores a strong reference until completion, surfaces uncaught exceptions
- through the logger, and auto-removes finished tasks.
- """
- import asyncio
- import logging
- import pytest
- from backend.app.core.tasks import active_task_count, spawn_background_task
- @pytest.mark.asyncio
- async def test_holds_strong_ref_until_completion():
- """Discarding the returned task must not let asyncio reap it mid-flight.
- Pre-fix, ``asyncio.create_task(coro)`` with no caller-side reference
- would let GC swallow short tasks before they finished."""
- finished = asyncio.Event()
- async def work() -> None:
- await asyncio.sleep(0)
- finished.set()
- # Note: NOT storing the returned task -- this is exactly the
- # pattern the helper exists to support.
- spawn_background_task(work())
- await asyncio.wait_for(finished.wait(), timeout=1.0)
- @pytest.mark.asyncio
- async def test_removes_from_strong_ref_set_after_completion():
- """The strong-ref set must shrink as tasks complete; otherwise a
- long-running process accumulates one entry per spawned task and the
- helper itself becomes a leak."""
- before = active_task_count()
- async def work() -> None:
- await asyncio.sleep(0)
- spawn_background_task(work())
- spawn_background_task(work())
- # Yield enough times for both tasks + their done-callbacks to run.
- for _ in range(5):
- await asyncio.sleep(0)
- assert active_task_count() == before
- @pytest.mark.asyncio
- async def test_uncaught_exception_logged_as_warning(caplog):
- """A fire-and-forget task that raises must surface the exception via
- the logger with the traceback attached -- otherwise the error vanishes
- silently and only an opaque ``Task was destroyed`` notice reaches the
- support bundle."""
- async def boom() -> None:
- raise RuntimeError("synthetic failure for test")
- with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
- spawn_background_task(boom(), name="boom-task")
- for _ in range(5):
- await asyncio.sleep(0)
- # One WARNING with the task name and the exception info.
- boom_records = [r for r in caplog.records if "boom-task" in r.message]
- assert len(boom_records) == 1
- assert boom_records[0].levelno == logging.WARNING
- assert boom_records[0].exc_info is not None
- assert isinstance(boom_records[0].exc_info[1], RuntimeError)
- @pytest.mark.asyncio
- async def test_cancelled_task_does_not_log_exception(caplog):
- """Explicit cancellation isn't an error -- a service shutting down
- its background loops should not be reported as 'uncaught exception'."""
- async def long_running() -> None:
- await asyncio.sleep(10.0)
- with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
- task = spawn_background_task(long_running(), name="cancel-me")
- await asyncio.sleep(0) # Let it start.
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- assert not any("cancel-me" in r.message for r in caplog.records)
- @pytest.mark.asyncio
- async def test_task_name_propagates():
- """Named tasks make the leak source visible in tracebacks and the
- done-callback log line. Pin that ``name`` reaches the underlying
- Task so support bundles surface the spawn site."""
- task = spawn_background_task(asyncio.sleep(0), name="named-spawn-test")
- assert task.get_name() == "named-spawn-test"
- await task
|