diff options
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/background.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/background.py | 70 |
1 files changed, 70 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/background.py b/packages/meshbay-common/src/meshbay_common/background.py new file mode 100644 index 0000000..1f25dfd --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/background.py @@ -0,0 +1,70 @@ +""" +Background work that outlives the call which started it. + +**asyncio keeps only a weak reference to a task.** A coroutine started with +`asyncio.ensure_future(...)` whose result is thrown away can therefore be +collected while it is still running: the loop logs "Task was destroyed but it is +pending!" and the work simply does not happen. There is no error at the caller, +nothing fails, and what was lost is whatever that coroutine was in the middle of +— a notification never written, a file never indexed, a reload half applied. + +`spawn()` is the one door for that shape. The reference in `_running` is what +keeps the task alive; the done callback is what stops the set growing. + +Two things it deliberately does **not** replace: + +- A task whose handle the caller keeps — the daemon's service tasks, the + indexer's reconcile loop — is already held, and being held is the whole point. +- A task scoped to something that can end, where the owner must be able to + **cancel** it: a peer session's spawns die with the session + (`WebRTCPeerSession._spawn`), and an enrichment run is tracked by its own + module. Those keep their own sets, because a module-level one can only ever + grow and forget, never shut down. + +It also logs what a discarded task swallowed. An exception in a task nobody +awaits is reported by asyncio at collection time, out of context and often not +at all; here it is logged where it happened, naming the work. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Coroutine + +log = logging.getLogger(__name__) + +# Strong references to everything in flight. Module-level on purpose: these are +# tasks with no owner to hold them, which is exactly why they need this. +_running: set[asyncio.Task] = set() + + +def spawn(coro: Coroutine[Any, Any, Any], *, what: str = "") -> asyncio.Task: + """Run a coroutine in the background and hold on to it until it finishes. + + The work names itself in the log: `what` defaults to the coroutine's own + qualified name, so no call site has to carry a label for the sake of a line + nobody reads until something breaks. Pass one only where the coroutine's + name is not the useful answer. + """ + if not what: + what = getattr(coro, "__qualname__", "") or repr(coro) + task = asyncio.ensure_future(coro) + _running.add(task) + task.add_done_callback(lambda t: _finished(t, what)) + return task + + +def _finished(task: asyncio.Task, what: str) -> None: + _running.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + log.error("Background task %s failed: %s", what or "(unnamed)", exc, + exc_info=exc) + + +def pending() -> int: + """How many background tasks are in flight. For diagnostics and tests.""" + return len(_running) |