Files
roboco/tests/unit/services/test_optimal_close.py
T
Renn F e8f5d531c7 [F136] cancel startup indexing task on OptimalService.close()
close() cancelled only the periodic update task, then cleared the plugins.
The startup _indexing_task (background auto-index, slow Ollama / large repo)
could still be mid-flight at shutdown and write against closed/cleared
plugins. Cancel and await _indexing_task FIRST (its tail starts the periodic
task, so ordering also prevents a late periodic spawn), then the periodic
task, then clear plugins.
2026-06-29 00:38:13 +02:00

100 lines
3.1 KiB
Python

"""OptimalService.close() must cancel the startup indexing task, not just the
periodic update task.
The startup auto-indexing task (``_indexing_task``) runs in the background and
can still be mid-flight when ``close()`` runs (slow Ollama, a large repo). If
``close()`` only cancels the periodic update task and then clears the plugins,
the still-running indexing task writes against closed/cleared plugins —
errors on shutdown. ``close()`` must cancel and await ``_indexing_task`` too.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.models.optimal import IndexType
from roboco.services.optimal import OptimalService
def _svc() -> OptimalService:
svc = OptimalService.__new__(OptimalService)
svc._initialized = True
svc._plugins = {}
svc._indexing_task = None
svc._periodic_update_task = None
svc._file_mtimes = {}
svc._docs_root = None
return svc
@pytest.mark.asyncio
async def test_close_cancels_running_indexing_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A still-running startup indexing task is cancelled + awaited on close,
so it can't write against cleared plugins."""
svc = _svc()
plugin = MagicMock()
plugin.close = AsyncMock()
svc._plugins[IndexType.DOCUMENTATION] = plugin
# A real background task that would otherwise run for the whole test.
indexing_task = asyncio.create_task(asyncio.sleep(100))
svc._indexing_task = indexing_task
monkeypatch.setattr(
"roboco.services.optimal_brain.shared_embedder.close_shared_embedder",
AsyncMock(),
)
await svc.close()
assert indexing_task.cancelled(), "indexing task was not cancelled on close"
assert svc._plugins == {}, "plugins were not cleared on close"
assert svc._initialized is False
@pytest.mark.asyncio
async def test_close_cancels_indexing_task_before_clearing_plugins(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The indexing task is cancelled BEFORE the plugins are closed/cleared —
otherwise a mid-flight index write lands on a closed plugin."""
svc = _svc()
closed_order: list[str] = []
async def _slow_plugin_close() -> None:
closed_order.append("plugin")
await asyncio.sleep(0)
plugin = MagicMock()
plugin.close = _slow_plugin_close
svc._plugins[IndexType.CODE] = plugin
async def _indexing_work() -> None:
try:
await asyncio.sleep(100)
except asyncio.CancelledError:
closed_order.append("indexing_cancelled")
raise
svc._indexing_task = asyncio.create_task(_indexing_work())
# Let the indexing task start and reach its await point so a cancel
# actually runs its body's except-branch (a not-yet-started task is
# cancelled without ever entering the coroutine).
await asyncio.sleep(0)
monkeypatch.setattr(
"roboco.services.optimal_brain.shared_embedder.close_shared_embedder",
AsyncMock(),
)
await svc.close()
assert closed_order[0] == "indexing_cancelled", (
f"indexing task must be cancelled before plugins are closed, got {closed_order}"
)