Files
2026-07-13 13:12:33 +08:00

188 lines
6.9 KiB
Python

"""Single-lock invariant test.
Verifies that the merged single-lock design does NOT deadlock when
TaskRuntime._execute() holds the shared session lock and then calls
TurnRunner.run() (which formerly would try to re-acquire the same lock).
Design:
- A shared asyncio.Lock plays the role of TaskRuntime._session_locks[key].
- TurnRunner is given a session_lock_provider that returns this shared lock.
- A fake turn handler simulates the full call chain:
outer acquire lock → set _SESSION_LOCK_OWNER → call turn_runner.run()
→ run() detects same-task ownership → skips re-acquire → done.
The test uses asyncio.wait_for with a 3-second timeout. If the design
introduced a deadlock (e.g. run() tried to acquire the already-held lock),
the coroutine would hang and TimeoutError would be raised, failing the test.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from opensquilla.engine.runtime import _SESSION_LOCK_OWNER, TurnRunner
from opensquilla.engine.types import DoneEvent
def _make_minimal_turn_runner(
session_lock_provider: Any,
) -> TurnRunner:
"""Build a TurnRunner with stub dependencies sufficient for run()."""
provider = MagicMock()
provider.provider_name = "stub"
# chat() is an async generator that immediately yields done
async def _chat(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
yield DoneEvent()
provider.chat = _chat
selector = MagicMock()
selector.resolve.return_value = provider
selector.clone.return_value = selector
selector.current_config = MagicMock(model="stub-model")
session_manager = MagicMock()
session_manager.get = AsyncMock(return_value=None)
session_manager.append_message = AsyncMock(return_value=None)
session_manager.update = AsyncMock(return_value=None)
session_manager.get_compaction_summary = AsyncMock(return_value=None)
return TurnRunner(
provider_selector=selector,
session_manager=session_manager,
session_lock_provider=session_lock_provider,
)
@pytest.mark.asyncio
async def test_no_self_deadlock() -> None:
"""Simulates OUTER lock held → TurnRunner.run() → no re-acquire deadlock.
asyncio.wait_for 3s timeout must NOT fire (no deadlock).
"""
session_key = "agent:main:test-deadlock-check"
shared_lock = asyncio.Lock()
def lock_provider(key: str) -> asyncio.Lock:
# Always return the same shared lock (mirrors TaskRuntime._session_locks)
return shared_lock
runner = _make_minimal_turn_runner(lock_provider)
from opensquilla.tools.types import ToolContext
tool_ctx = ToolContext(session_key=session_key)
async def _simulate_execute() -> None:
"""Mimic TaskRuntime._execute lock ownership before calling run()."""
async with shared_lock:
# Set _SESSION_LOCK_OWNER so TurnRunner.run() detects same-task
# ownership and skips re-acquisition (mirrors _execute behaviour).
current_task = asyncio.current_task()
prev_map = _SESSION_LOCK_OWNER.get(None)
new_map: dict[int, Any] = dict(prev_map or {})
if current_task is not None:
new_map[id(shared_lock)] = current_task
token = _SESSION_LOCK_OWNER.set(new_map)
try:
events = []
async for event in runner.run(
message="hello",
session_key=session_key,
tool_context=tool_ctx,
):
events.append(event)
finally:
_SESSION_LOCK_OWNER.reset(token)
# 3-second timeout — any deadlock will cause TimeoutError here
await asyncio.wait_for(_simulate_execute(), timeout=3.0)
@pytest.mark.asyncio
async def test_no_self_deadlock_when_run_consumed_by_child_task() -> None:
"""Heartbeat stream consumes TurnRunner.run() from a child task.
ContextVar ownership must be call-chain based rather than current-task
based, otherwise the child task tries to re-acquire the already-held lock.
"""
session_key = "agent:main:test-heartbeat-child-task-deadlock-check"
shared_lock = asyncio.Lock()
runner = _make_minimal_turn_runner(lambda key: shared_lock)
async def _fake_run_turn(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
yield DoneEvent()
runner._run_turn = _fake_run_turn # type: ignore[method-assign]
from opensquilla.tools.types import ToolContext
tool_ctx = ToolContext(session_key=session_key)
async def _consume_run() -> list[Any]:
events = []
async for event in runner.run(
message="hello",
session_key=session_key,
tool_context=tool_ctx,
):
events.append(event)
return events
async def _simulate_execute_with_child_task() -> list[Any]:
async with shared_lock:
current_task = asyncio.current_task()
prev_map = _SESSION_LOCK_OWNER.get(None)
new_map: dict[int, Any] = dict(prev_map or {})
if current_task is not None:
new_map[id(shared_lock)] = current_task
token = _SESSION_LOCK_OWNER.set(new_map)
try:
return await asyncio.create_task(_consume_run())
finally:
_SESSION_LOCK_OWNER.reset(token)
events = await asyncio.wait_for(_simulate_execute_with_child_task(), timeout=3.0)
assert any(isinstance(event, DoneEvent) for event in events)
@pytest.mark.asyncio
async def test_lock_provider_used_not_internal_dict() -> None:
"""When session_lock_provider is set, _get_session_lock returns the provider's lock.
Verifies that the provided session-lock path takes precedence over internal locks.
"""
session_key = "agent:main:provider-path-check"
external_lock = asyncio.Lock()
runner = _make_minimal_turn_runner(lambda key: external_lock)
returned = runner._get_session_lock(session_key)
assert returned is external_lock, (
"_get_session_lock must return the external provider's lock, "
f"got {returned!r} instead"
)
# TurnRunner has no internal _session_locks dict.
assert not hasattr(runner, "_session_locks"), (
"TurnRunner must not own an internal _session_locks dict"
)
def test_no_provider_uses_fallback_no_session_locks_field() -> None:
"""TurnRunner without session_lock_provider creates a closure-based fallback.
TurnRunner must NOT have a _session_locks attribute (the dict lives
in the provider closure, not as a named field on the object).
"""
external_lock = asyncio.Lock()
runner = _make_minimal_turn_runner(lambda key: external_lock)
assert not hasattr(runner, "_session_locks"), (
"TurnRunner must not own a _session_locks attribute — "
"lock dict lives in the provider closure"
)