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.
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
"""Cross-cutting heartbeat regressions for synchronous persistence locks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
from filelock import FileLock
|
|
|
|
from nanobot.agent.tools.context import RequestContext, request_context
|
|
from nanobot.agent.tools.cron import CronTool
|
|
from nanobot.config.loader import load_config, save_config
|
|
from nanobot.config.schema import Config
|
|
from nanobot.cron.service import CronService
|
|
from nanobot.session.manager import SessionManager
|
|
from nanobot.triggers.local_runner import run_local_trigger_queue
|
|
from nanobot.triggers.local_store import LocalTriggerStore
|
|
from nanobot.webui.settings_api import update_api_settings
|
|
from nanobot.webui.settings_services import WebUISettingsServices
|
|
|
|
|
|
async def _assert_10ms_heartbeat_runs_while_pending(task: asyncio.Task[object]) -> None:
|
|
for _ in range(3):
|
|
await asyncio.sleep(0.01)
|
|
assert not task.done()
|
|
|
|
|
|
async def test_session_lock_contention_does_not_block_event_loop(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
sessions_root = tmp_path / "session-data"
|
|
legacy_root = tmp_path / "legacy-sessions"
|
|
monkeypatch.setattr(
|
|
"nanobot.session.manager.get_legacy_sessions_dir",
|
|
lambda: legacy_root,
|
|
)
|
|
manager = SessionManager(workspace, sessions_root=sessions_root)
|
|
session = manager.get_or_create("websocket:lock-test")
|
|
session.add_message("user", "hello")
|
|
lock = manager._jsonl_store._session_files_lock
|
|
assert lock.timeout == 5
|
|
blocker = FileLock(lock.lock_file, timeout=1)
|
|
blocker.acquire()
|
|
task = asyncio.create_task(manager.save_async(session))
|
|
try:
|
|
await _assert_10ms_heartbeat_runs_while_pending(task)
|
|
finally:
|
|
blocker.release()
|
|
|
|
await asyncio.wait_for(task, timeout=1)
|
|
assert manager.read_session_file(session.key) is not None
|
|
|
|
|
|
async def test_cron_lock_contention_does_not_block_event_loop(tmp_path: Path) -> None:
|
|
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
tool = CronTool(service)
|
|
assert service._lock.timeout == 5
|
|
blocker = FileLock(service._lock.lock_file, timeout=1)
|
|
blocker.acquire()
|
|
with request_context(
|
|
RequestContext(
|
|
channel="websocket",
|
|
chat_id="lock-test",
|
|
session_key="websocket:lock-test",
|
|
)
|
|
):
|
|
task = asyncio.create_task(
|
|
tool.execute(action="add", message="wake up", every_seconds=60)
|
|
)
|
|
try:
|
|
await _assert_10ms_heartbeat_runs_while_pending(task)
|
|
finally:
|
|
blocker.release()
|
|
|
|
result = await asyncio.wait_for(task, timeout=1)
|
|
assert result.startswith("Created job")
|
|
|
|
|
|
async def test_local_trigger_lock_contention_does_not_block_event_loop(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = LocalTriggerStore(tmp_path)
|
|
assert store._lock.timeout == 5
|
|
blocker = FileLock(store._lock.lock_file, timeout=1)
|
|
blocker.acquire()
|
|
|
|
async def submit_turn(_message):
|
|
return None
|
|
|
|
task = asyncio.create_task(
|
|
run_local_trigger_queue(
|
|
store=store,
|
|
submit_turn=submit_turn,
|
|
is_channel_enabled=lambda _name: True,
|
|
poll_interval_s=0.01,
|
|
)
|
|
)
|
|
try:
|
|
await _assert_10ms_heartbeat_runs_while_pending(task)
|
|
finally:
|
|
blocker.release()
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, timeout=1)
|
|
|
|
|
|
async def test_settings_lock_contention_does_not_block_event_loop(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
save_config(Config(), config_path)
|
|
services = WebUISettingsServices.create(config_path)
|
|
assert services.config._file_lock.timeout == 5
|
|
blocker = FileLock(services.config._file_lock.lock_file, timeout=1)
|
|
blocker.acquire()
|
|
task = asyncio.create_task(
|
|
services.mutate_async(update_api_settings, {"port": ["19001"]})
|
|
)
|
|
try:
|
|
await _assert_10ms_heartbeat_runs_while_pending(task)
|
|
finally:
|
|
blocker.release()
|
|
|
|
await asyncio.wait_for(task, timeout=1)
|
|
assert load_config(config_path).api.port == 19001
|