chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""Bug C2 invariant test — AC-C2-2.
|
||||
|
||||
Verifies that two concurrent asyncio Tasks calling TurnRunner.run() for the
|
||||
same session are properly serialized (max in-flight == 1) rather than running
|
||||
concurrently due to the broken lock.locked() owner-blind check.
|
||||
|
||||
The fix replaces lock.locked() with a ContextVar-based owner check so Task B
|
||||
cannot skip lock acquisition just because Task A holds it.
|
||||
"""
|
||||
|
||||
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 TurnRunner
|
||||
from opensquilla.engine.types import DoneEvent
|
||||
|
||||
|
||||
def _make_runner_with_lock(
|
||||
shared_lock: asyncio.Lock,
|
||||
) -> TurnRunner:
|
||||
"""Build a minimal TurnRunner whose session lock is the provided shared_lock."""
|
||||
provider = MagicMock()
|
||||
provider.provider_name = "stub"
|
||||
|
||||
async def _chat(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
|
||||
# Yield a small delay so concurrent tasks have time to overlap if
|
||||
# serialization is broken.
|
||||
await asyncio.sleep(0)
|
||||
yield DoneEvent(stop_reason="end_turn", usage={})
|
||||
|
||||
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=lambda key: shared_lock,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_concurrent_runs_serialize() -> None:
|
||||
"""Two concurrent TurnRunner.run() calls for the same session must serialize.
|
||||
|
||||
AC-C2-2: max tasks in-flight simultaneously must be 1, not 2.
|
||||
|
||||
If the bug is present (lock.locked() used instead of ContextVar owner check),
|
||||
Task B sees locked()=True while Task A holds the lock and skips acquire,
|
||||
causing both to run concurrently (max_in_flight == 2).
|
||||
|
||||
With the fix, Task B correctly waits for Task A to release the lock.
|
||||
"""
|
||||
session_key = "agent:main:concurrent-run-test"
|
||||
shared_lock = asyncio.Lock()
|
||||
runner = _make_runner_with_lock(shared_lock)
|
||||
|
||||
from opensquilla.tools.types import ToolContext
|
||||
|
||||
tool_ctx = ToolContext(session_key=session_key)
|
||||
|
||||
in_flight = 0
|
||||
max_in_flight = 0
|
||||
|
||||
# Patch _run_turn to track concurrency
|
||||
original_run_turn = runner._run_turn
|
||||
|
||||
async def _instrumented_run_turn(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
|
||||
nonlocal in_flight, max_in_flight
|
||||
in_flight += 1
|
||||
if in_flight > max_in_flight:
|
||||
max_in_flight = in_flight
|
||||
try:
|
||||
async for event in original_run_turn(*args, **kwargs):
|
||||
yield event
|
||||
finally:
|
||||
in_flight -= 1
|
||||
|
||||
runner._run_turn = _instrumented_run_turn # type: ignore[method-assign]
|
||||
|
||||
async def _run_one() -> None:
|
||||
async for _ in runner.run(
|
||||
message="hello",
|
||||
session_key=session_key,
|
||||
tool_context=tool_ctx,
|
||||
):
|
||||
pass
|
||||
|
||||
# Launch two concurrent tasks for the same session
|
||||
await asyncio.gather(_run_one(), _run_one())
|
||||
|
||||
assert max_in_flight == 1, (
|
||||
f"Expected max 1 concurrent _run_turn execution per session, "
|
||||
f"got {max_in_flight}. "
|
||||
"Two concurrent TurnRunner.run() calls are not serialized — "
|
||||
"the lock.locked() owner-blind check is still present."
|
||||
)
|
||||
Reference in New Issue
Block a user