aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/background.py70
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)