mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
163 lines
4.9 KiB
Python
163 lines
4.9 KiB
Python
"""Cancellation settlement guarantees for native SessionManager mutations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from nanobot.session.manager import Session, SessionManager
|
|
|
|
|
|
async def _cancel_blocked_mutation(
|
|
task: asyncio.Task[Any],
|
|
*,
|
|
started: threading.Event,
|
|
release: threading.Event,
|
|
) -> None:
|
|
assert await asyncio.to_thread(started.wait, 1)
|
|
try:
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done(), "cancellation escaped before the mutation worker settled"
|
|
finally:
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, timeout=1)
|
|
|
|
|
|
async def test_save_async_cancellation_waits_for_durable_write_and_cache(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = SessionManager(tmp_path)
|
|
session = Session(key="test:cancel-save")
|
|
session.add_message("user", "persist exactly once")
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
finished = threading.Event()
|
|
save_calls = 0
|
|
original_save = manager._store.save
|
|
|
|
def blocked_save(target: Session, *, fsync: bool = False) -> None:
|
|
nonlocal save_calls
|
|
save_calls += 1
|
|
started.set()
|
|
assert release.wait(timeout=1)
|
|
original_save(target, fsync=fsync)
|
|
finished.set()
|
|
|
|
monkeypatch.setattr(manager._store, "save", blocked_save)
|
|
task = asyncio.create_task(manager.save_async(session))
|
|
|
|
await _cancel_blocked_mutation(task, started=started, release=release)
|
|
|
|
assert finished.is_set()
|
|
assert save_calls == 1
|
|
assert manager.get_cached(session.key) is session
|
|
durable = manager.read_session_file(session.key)
|
|
assert durable is not None
|
|
assert durable["messages"][0]["content"] == "persist exactly once"
|
|
|
|
await asyncio.sleep(0.05)
|
|
assert save_calls == 1
|
|
assert manager.read_session_file(session.key) == durable
|
|
|
|
|
|
async def test_update_metadata_async_cancellation_waits_for_file_and_cache_refresh(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = SessionManager(tmp_path)
|
|
session = manager.get_or_create("test:cancel-metadata")
|
|
session.metadata["title"] = "before"
|
|
manager.save(session)
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
finished = threading.Event()
|
|
update_calls = 0
|
|
original_update = manager._store.update_metadata
|
|
|
|
def blocked_update(
|
|
key: str,
|
|
updates: dict[str, Any],
|
|
*,
|
|
fsync: bool = False,
|
|
) -> bool:
|
|
nonlocal update_calls
|
|
update_calls += 1
|
|
started.set()
|
|
assert release.wait(timeout=1)
|
|
updated = original_update(key, updates, fsync=fsync)
|
|
finished.set()
|
|
return updated
|
|
|
|
monkeypatch.setattr(manager._store, "update_metadata", blocked_update)
|
|
task = asyncio.create_task(
|
|
manager.update_session_metadata_async(
|
|
session.key,
|
|
{"title": "after", "settled": True},
|
|
)
|
|
)
|
|
|
|
await _cancel_blocked_mutation(task, started=started, release=release)
|
|
|
|
assert finished.is_set()
|
|
assert update_calls == 1
|
|
assert session.metadata["title"] == "after"
|
|
assert session.metadata["settled"] is True
|
|
durable = manager.read_session_file(session.key)
|
|
assert durable is not None
|
|
assert durable["metadata"]["title"] == "after"
|
|
assert durable["metadata"]["settled"] is True
|
|
|
|
await asyncio.sleep(0.05)
|
|
assert update_calls == 1
|
|
assert manager.read_session_file(session.key) == durable
|
|
|
|
|
|
async def test_delete_async_cancellation_waits_for_file_cache_and_observer(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
manager = SessionManager(tmp_path)
|
|
session = manager.get_or_create("test:cancel-delete")
|
|
session.add_message("user", "delete me")
|
|
manager.save(session)
|
|
observed: list[str] = []
|
|
manager.set_delete_observer(observed.append)
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
finished = threading.Event()
|
|
delete_calls = 0
|
|
original_delete = manager._store.delete
|
|
|
|
def blocked_delete(key: str) -> bool:
|
|
nonlocal delete_calls
|
|
delete_calls += 1
|
|
started.set()
|
|
assert release.wait(timeout=1)
|
|
deleted = original_delete(key)
|
|
finished.set()
|
|
return deleted
|
|
|
|
monkeypatch.setattr(manager._store, "delete", blocked_delete)
|
|
task = asyncio.create_task(manager.delete_session_async(session.key))
|
|
|
|
await _cancel_blocked_mutation(task, started=started, release=release)
|
|
|
|
assert finished.is_set()
|
|
assert delete_calls == 1
|
|
assert manager.read_session_file(session.key) is None
|
|
assert manager.get_cached(session.key) is None
|
|
assert observed == [session.key]
|
|
|
|
await asyncio.sleep(0.05)
|
|
assert delete_calls == 1
|
|
assert observed == [session.key]
|
|
assert manager.read_session_file(session.key) is None
|