evalstone/evalscope/tests/agent/external/test_bridge_lifecycle.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

364 lines
12 KiB
Python

import asyncio
import gc
from types import SimpleNamespace
from typing import Any, Iterator
from unittest.mock import AsyncMock
import aiohttp
import pytest
from evalscope.agent.external.bridge import ModelProxyServer
from evalscope.api.model import GenerateConfig
from evalscope.utils.asyncio_runtime import AsyncioLoopRunner
@pytest.fixture(autouse=True)
def _clear_bridge_runner() -> Iterator[None]:
yield
AsyncioLoopRunner.shutdown_for_thread()
def test_registry_uses_owner_loop_and_explicit_shutdown_clears_instance() -> None:
async def run() -> None:
owner_loop = asyncio.get_running_loop()
proxy = await ModelProxyServer.get_or_start()
assert ModelProxyServer._instances == {owner_loop: proxy}
assert proxy._owner_loop is owner_loop
await proxy.shutdown()
assert ModelProxyServer._instances == {}
asyncio.run(run())
def test_runner_shutdown_clears_bridge_instance() -> None:
async def start() -> asyncio.AbstractEventLoop:
owner_loop = asyncio.get_running_loop()
proxy = await ModelProxyServer.get_or_start()
assert ModelProxyServer._instances == {owner_loop: proxy}
return owner_loop
owner_loop = AsyncioLoopRunner.run(start())
AsyncioLoopRunner.shutdown_for_thread()
assert owner_loop.is_closed()
assert ModelProxyServer._instances == {}
def test_shutdown_rejects_non_owner_loop() -> None:
async def start() -> ModelProxyServer:
return await ModelProxyServer.get_or_start()
proxy = AsyncioLoopRunner.run(start())
with pytest.raises(RuntimeError, match='owning event loop'):
asyncio.run(proxy.shutdown())
AsyncioLoopRunner.shutdown_for_thread()
assert ModelProxyServer._instances == {}
def test_cancelled_start_waiter_does_not_orphan_shared_instance(monkeypatch: pytest.MonkeyPatch) -> None:
start_entered = asyncio.Event()
release_start = asyncio.Event()
async def slow_start(self: ModelProxyServer) -> None:
start_entered.set()
await release_start.wait()
self._phase = type(self._phase).RUNNING
async def run() -> None:
owner_loop = asyncio.get_running_loop()
first = asyncio.create_task(ModelProxyServer.get_or_start())
second = asyncio.create_task(ModelProxyServer.get_or_start())
await start_entered.wait()
first.cancel()
with pytest.raises(asyncio.CancelledError):
await first
release_start.set()
proxy = await second
assert ModelProxyServer._instances == {owner_loop: proxy}
assert await ModelProxyServer.get_or_start() is proxy
await proxy.shutdown()
monkeypatch.setattr(ModelProxyServer, '_start_server', slow_start)
asyncio.run(run())
def test_start_failure_cleans_runner_and_registry(monkeypatch: pytest.MonkeyPatch) -> None:
runner = SimpleNamespace(setup=AsyncMock(), cleanup=AsyncMock())
class FailingSite:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
async def start(self) -> None:
raise OSError('bind failed')
async def run() -> None:
with pytest.raises(OSError, match='bind failed'):
await ModelProxyServer.get_or_start()
assert ModelProxyServer._instances == {}
monkeypatch.setattr('evalscope.agent.external.bridge.server.web.AppRunner', lambda *args, **kwargs: runner)
monkeypatch.setattr('evalscope.agent.external.bridge.server.web.TCPSite', FailingSite)
asyncio.run(run())
runner.cleanup.assert_awaited_once()
def test_get_or_start_waits_for_closing_instance_before_replacing_it() -> None:
async def run() -> None:
owner_loop = asyncio.get_running_loop()
first = await ModelProxyServer.get_or_start()
assert first._runner is not None
cleanup_entered = asyncio.Event()
release_cleanup = asyncio.Event()
original_cleanup = first._runner.cleanup
cleanup_calls = 0
async def blocking_cleanup() -> None:
nonlocal cleanup_calls
cleanup_calls += 1
cleanup_entered.set()
await release_cleanup.wait()
await original_cleanup()
first._runner = SimpleNamespace(cleanup=blocking_cleanup)
shutdown_task = asyncio.create_task(first.shutdown())
await cleanup_entered.wait()
second_shutdown_task = asyncio.create_task(first.shutdown())
replacement_task = asyncio.create_task(ModelProxyServer.get_or_start())
await asyncio.sleep(0)
assert not replacement_task.done()
assert not second_shutdown_task.done()
assert ModelProxyServer._instances == {owner_loop: first}
release_cleanup.set()
await asyncio.gather(shutdown_task, second_shutdown_task)
assert cleanup_calls == 1
replacement = await replacement_task
assert replacement is not first
assert ModelProxyServer._instances == {owner_loop: replacement}
await replacement.shutdown()
asyncio.run(run())
@pytest.mark.parametrize('protocol', ['openai', 'anthropic', 'gemini'])
def test_stream_disconnect_waits_for_generation_task(
monkeypatch: pytest.MonkeyPatch,
protocol: str,
) -> None:
generation_started = asyncio.Event()
generation_finished = asyncio.Event()
class BlockingModel:
async def generate_async(self, **kwargs: Any) -> None:
generation_started.set()
try:
await asyncio.Event().wait()
finally:
generation_finished.set()
class FakeResponse:
async def write(self, chunk: bytes) -> None:
await asyncio.sleep(0)
async def write_eof(self) -> None:
return None
async def prepare_response(request: Any) -> FakeResponse:
return FakeResponse()
async def run() -> None:
owner_loop = asyncio.get_running_loop()
proxy = ModelProxyServer('127.0.0.1', None, owner_loop)
session = SimpleNamespace(
model=BlockingModel(),
framework='test',
trial_id='test-trial',
recorder=None,
)
body = {'model': 'test-model'}
config = GenerateConfig()
if protocol == 'openai':
response_coro = proxy._respond_streaming_openai(
None, session, body, [], [], None, config, include_usage=False
)
elif protocol == 'gemini':
response_coro = proxy._respond_streaming_gemini(None, session, body, [], [], None, config)
else:
response_coro = proxy._respond_streaming(None, session, body, [], [], config)
handler_task = asyncio.create_task(response_coro)
await generation_started.wait()
handler_task.cancel()
with pytest.raises(asyncio.CancelledError):
await handler_task
assert handler_task.done()
assert generation_finished.is_set()
current_task = asyncio.current_task()
assert all(task is current_task or task.done() for task in asyncio.all_tasks())
monkeypatch.setattr(ModelProxyServer, '_prepare_sse_response', staticmethod(prepare_response))
asyncio.run(run())
@pytest.mark.parametrize('protocol', ['openai', 'anthropic', 'gemini'])
def test_stream_disconnect_observes_already_failed_generation_task(
monkeypatch: pytest.MonkeyPatch,
protocol: str,
) -> None:
unhandled_contexts: list[dict[str, Any]] = []
class FailingModel:
async def generate_async(self, **kwargs: Any) -> None:
raise RuntimeError('generation failed')
class DisconnectedResponse:
async def write(self, chunk: bytes) -> None:
await asyncio.sleep(0)
raise ConnectionResetError
async def write_eof(self) -> None:
return None
async def prepare_response(request: Any) -> DisconnectedResponse:
return DisconnectedResponse()
async def run() -> None:
loop = asyncio.get_running_loop()
loop.set_exception_handler(lambda _loop, context: unhandled_contexts.append(context))
proxy = ModelProxyServer('127.0.0.1', None, loop)
session = SimpleNamespace(
model=FailingModel(),
framework='test',
trial_id='test-trial',
recorder=None,
)
body = {'model': 'test-model'}
config = GenerateConfig()
if protocol == 'openai':
await proxy._respond_streaming_openai(
None,
session,
body,
[],
[],
None,
config,
include_usage=False,
)
elif protocol == 'gemini':
await proxy._respond_streaming_gemini(None, session, body, [], [], None, config)
else:
await proxy._respond_streaming(None, session, body, [], [], config)
gc.collect()
await asyncio.sleep(0)
monkeypatch.setattr(ModelProxyServer, '_prepare_sse_response', staticmethod(prepare_response))
asyncio.run(run())
assert unhandled_contexts == []
def test_streaming_upstream_failure_returns_complete_error_response() -> None:
class FailingModel:
name = 'failing-model'
async def generate_async(self, **kwargs: Any) -> None:
raise RuntimeError('generation failed')
async def run() -> None:
proxy = await ModelProxyServer.get_or_start()
try:
async with proxy.trial_session(model=FailingModel(), framework='test') as session:
async with aiohttp.ClientSession() as client:
async with client.post(
f'{proxy.base_url}/openai/v1/chat/completions',
headers={'Authorization': f'Bearer {session.token}'},
json={
'model': 'test-model',
'messages': [{
'role': 'user',
'content': 'test'
}],
'stream': True,
},
) as response:
body = await response.text()
assert response.status == 200
assert 'generation failed' in body
assert 'data: [DONE]' in body
finally:
await proxy.shutdown()
asyncio.run(run())
def test_stream_write_disconnect_waits_for_generation_task(monkeypatch: pytest.MonkeyPatch) -> None:
generation_finished = asyncio.Event()
class BlockingModel:
async def generate_async(self, **kwargs: Any) -> None:
try:
await asyncio.Event().wait()
finally:
generation_finished.set()
class DisconnectedResponse:
async def write(self, chunk: bytes) -> None:
await asyncio.sleep(0)
raise ConnectionResetError
async def write_eof(self) -> None:
return None
async def prepare_response(request: Any) -> DisconnectedResponse:
return DisconnectedResponse()
async def run() -> None:
proxy = ModelProxyServer('127.0.0.1', None, asyncio.get_running_loop())
session = SimpleNamespace(
model=BlockingModel(),
framework='test',
trial_id='test-trial',
recorder=None,
)
await proxy._respond_streaming_openai(
None,
session,
{'model': 'test-model'},
[],
[],
None,
GenerateConfig(),
include_usage=False,
)
assert generation_finished.is_set()
current_task = asyncio.current_task()
assert all(task is current_task or task.done() for task in asyncio.all_tasks())
monkeypatch.setattr(ModelProxyServer, '_prepare_sse_response', staticmethod(prepare_response))
asyncio.run(run())