mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-26 14:45:33 -04:00
Two background tasks scheduled on every chat completion in routes/chat_helpers.py — the memory/skill extraction dispatch and the session auto-namer — are created via bare asyncio.create_task(...). asyncio only holds a weak reference to the outer task, so the GC can collect it mid-execution and the work silently never runs. Add a module-private _BG_TASKS set and a _spawn_bg() helper that mirrors WebhookManager._spawn_tracked (the pattern #3964 / #4336 established for the webhook emitters two lines apart in the same function). Route both call sites through it so the lifecycle owner is explicit. Adds an AST-level guard test that fails on any bare asyncio.create_task(...) statement in routes/chat_helpers.py to prevent a regression — same shape as test_webhook_emitters_use_manager.py from #4336. The same bare pattern exists in routes/email_routes.py and routes/cookbook_routes.py; left out of this PR per CONTRIBUTING.md's "one fix per PR" and tracked in #4443's "Additional Information" for a follow-up.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Guard: chat hot-path background tasks must go through _spawn_bg.
|
||||
|
||||
asyncio only holds a weak reference to a bare create_task() result, so the
|
||||
GC can collect the outer task before its body runs and the background work
|
||||
(memory/skill extraction, session auto-naming) silently never happens.
|
||||
routes/chat_helpers.py owns these schedules via _spawn_bg(), which adds the
|
||||
task to _BG_TASKS and discards it via a done-callback. This guard catches a
|
||||
regression where a copy-paste re-introduces a bare asyncio.create_task.
|
||||
|
||||
This is the routes/chat_helpers.py-scoped sibling of the webhook-emitter
|
||||
guard added in #4336 (tests/test_webhook_emitters_use_manager.py).
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
CHAT_HELPERS = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "chat_helpers.py"
|
||||
)
|
||||
|
||||
|
||||
def _untracked_create_task_calls(tree: ast.AST) -> list[tuple[int, str]]:
|
||||
"""(lineno, snippet) for any bare asyncio.create_task(...).
|
||||
|
||||
A call is "bare" when its return value is dropped — i.e. it is the direct
|
||||
expression of an ast.Expr statement. Captured forms (`x = asyncio.create_task(...)`,
|
||||
`[asyncio.create_task(...), ...]`, `await asyncio.create_task(...)`) are fine
|
||||
because something else holds the reference.
|
||||
|
||||
The helper itself (_spawn_bg) is exempt: it calls asyncio.create_task once
|
||||
and registers the task in _BG_TASKS before returning.
|
||||
"""
|
||||
hits: list[tuple[int, str]] = []
|
||||
|
||||
def _is_create_task(call: ast.Call) -> bool:
|
||||
f = call.func
|
||||
return (
|
||||
isinstance(f, ast.Attribute)
|
||||
and f.attr == "create_task"
|
||||
and isinstance(f.value, ast.Name)
|
||||
and f.value.id == "asyncio"
|
||||
)
|
||||
|
||||
spawn_helper_lines: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_spawn_bg":
|
||||
for n in ast.walk(node):
|
||||
if hasattr(n, "lineno"):
|
||||
spawn_helper_lines.add(n.lineno)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
if not isinstance(node.value, ast.Call):
|
||||
continue
|
||||
if not _is_create_task(node.value):
|
||||
continue
|
||||
if node.lineno in spawn_helper_lines:
|
||||
continue
|
||||
hits.append((node.lineno, ast.unparse(node.value)))
|
||||
return hits
|
||||
|
||||
|
||||
def test_no_untracked_create_task_in_chat_helpers():
|
||||
tree = ast.parse(CHAT_HELPERS.read_text(), filename=str(CHAT_HELPERS))
|
||||
offenders = _untracked_create_task_calls(tree)
|
||||
assert not offenders, (
|
||||
"Background tasks scheduled from routes/chat_helpers.py must go through "
|
||||
"_spawn_bg(coro) so the task is registered in _BG_TASKS and survives until "
|
||||
"it finishes. Found bare asyncio.create_task(...) call(s):\n "
|
||||
+ "\n ".join(f"chat_helpers.py:{ln}: {snip}" for ln, snip in offenders)
|
||||
)
|
||||
Reference in New Issue
Block a user