refactor(runtime): centralize async bridges under an owned runtime (#376)
* feat(runtime): add application-scoped async runtime * refactor(cli): use owned runtime for session stats * refactor(onboard): use the owned async runtime * docs(runtime): record async bridge ownership * refactor(middleware): keep sync fallback synchronous * refactor(mcp): load tools on an owned runtime * refactor(cli): share owned runtime across entry points * refactor(channels): make inbound sync bridge explicit * refactor(stream): run Rich streaming on owned runtime * chore(runtime): remove nest-asyncio dependency * refactor(asyncio): require active loops in async code * docs(runtime): document final event loop ownership * fix(stream): cancel stalled owned streams * fix(cli): recover cleanly from stream cancellation * fix(runtime): drain executor work before shutdown * fix(runtime): terminate cancelled shell process trees * fix(models): let fallback bypass selector failures * fix(cli): reset interrupt handling between turns * docs: rm implementation spec * fix(serve): cancel active turns during shutdown * fix(runtime): protect settlement from waiter cancellation * fix(backends): reject empty shell commands * fix(runtime): terminate descendants after shell exit * fix(mcp): keep standalone discovery off channel loop * fix(cli): own and settle interactive prompt cancellation * fix(serve): keep channel sends off runtime loop * fix(stream): scope cancel context to iterator steps * refactor(serve): require the owned async runtime * fix(channels): keep interactive sends off runtime loop * fix(selector): surface fallback without log spam * test(runtime): normalize Windows shell marker * fix(cli): serialize interactive session turns * fix(shell): bound output drain after termination * fix(ui): do not retry owned runtime failures * fix(shell): allow signal-safe registry reentry * fix(shell): avoid terminating reused process ids * fix(channels): preserve streaming send order * fix(cli): report runtime shutdown timeouts cleanly * fix(mcp): guide async callers to async loader * docs(runtime): clarify reserved async bridge APIs * fix(runtime): bound code interpreter cleanup * test(shell): use active Python for drain regression --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
"""Tests for UI backend runtime selection."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist.cli.tui_runtime import (
|
||||
StreamCancellationTimeout,
|
||||
normalize_ui_backend,
|
||||
resolve_ui_backend,
|
||||
run_streaming,
|
||||
run_streaming_async,
|
||||
)
|
||||
from EvoScientist.runtime import AsyncRuntimeError
|
||||
from tests.fakes import FakeGraphGateway
|
||||
|
||||
|
||||
@@ -77,3 +85,151 @@ def test_run_streaming_falls_back_to_cli_on_runtime_error(monkeypatch):
|
||||
gateway=FakeGraphGateway(),
|
||||
)
|
||||
assert result == "fallback-ok"
|
||||
|
||||
|
||||
def test_run_streaming_does_not_retry_on_owned_runtime_error(monkeypatch):
|
||||
attempts = 0
|
||||
|
||||
class _RuntimeFailureBackend:
|
||||
def run_streaming(self, **kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise AsyncRuntimeError("owned runtime failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.cli.tui_runtime.get_backend",
|
||||
lambda *a, **k: _RuntimeFailureBackend(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.cli.tui_runtime.RichStreamingBackend",
|
||||
lambda: _RuntimeFailureBackend(),
|
||||
)
|
||||
|
||||
with pytest.raises(AsyncRuntimeError, match="owned runtime failed"):
|
||||
run_streaming(
|
||||
ui_backend="tui",
|
||||
agent=object(),
|
||||
message="hello",
|
||||
thread_id="t1",
|
||||
show_thinking=False,
|
||||
interactive=True,
|
||||
gateway=FakeGraphGateway(),
|
||||
)
|
||||
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
async def test_async_streaming_cancellation_stops_and_joins_worker(monkeypatch):
|
||||
from EvoScientist.stream.display import (
|
||||
discard_stream_cancel,
|
||||
is_stream_cancel_requested,
|
||||
)
|
||||
|
||||
scope = "test:async-renderer-cancel"
|
||||
started = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
def fake_run_streaming(**kwargs):
|
||||
assert kwargs["cancel_scope"] == scope
|
||||
started.set()
|
||||
while not is_stream_cancel_requested(scope):
|
||||
time.sleep(0.001)
|
||||
finished.set()
|
||||
return "stopped"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
|
||||
)
|
||||
|
||||
task = asyncio.create_task(run_streaming_async(cancel_scope=scope))
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert finished.is_set()
|
||||
discard_stream_cancel(scope)
|
||||
|
||||
|
||||
async def test_async_streaming_can_recover_foreground_task_after_cancel(monkeypatch):
|
||||
from EvoScientist.stream.display import (
|
||||
discard_stream_cancel,
|
||||
is_stream_cancel_requested,
|
||||
)
|
||||
|
||||
scope = "test:async-renderer-recover"
|
||||
started = threading.Event()
|
||||
|
||||
def fake_run_streaming(**kwargs):
|
||||
started.set()
|
||||
while not is_stream_cancel_requested(scope):
|
||||
time.sleep(0.001)
|
||||
return "[Stopped.]"
|
||||
|
||||
cleanup_called = False
|
||||
|
||||
async def fake_cleanup() -> None:
|
||||
nonlocal cleanup_called
|
||||
cleanup_called = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
|
||||
fake_cleanup,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
|
||||
assert await task == "[Stopped.]"
|
||||
assert cleanup_called
|
||||
assert not task.cancelled()
|
||||
discard_stream_cancel(scope)
|
||||
|
||||
|
||||
async def test_noncooperative_worker_reports_settlement_timeout(monkeypatch):
|
||||
"""Cancellation timeout is an ordinary lifecycle error, not BaseException."""
|
||||
from EvoScientist.cli import tui_runtime
|
||||
from EvoScientist.stream.display import discard_stream_cancel
|
||||
|
||||
scope = "test:async-renderer-timeout"
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
def fake_run_streaming(**_kwargs):
|
||||
started.set()
|
||||
release.wait()
|
||||
finished.set()
|
||||
return "late"
|
||||
|
||||
async def fake_cleanup() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(tui_runtime, "run_streaming", fake_run_streaming)
|
||||
monkeypatch.setattr(tui_runtime, "STREAM_CANCEL_SETTLE_TIMEOUT", 0.05)
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
|
||||
fake_cleanup,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(StreamCancellationTimeout, match="did not stop"):
|
||||
await task
|
||||
|
||||
assert not finished.is_set()
|
||||
release.set()
|
||||
assert await asyncio.to_thread(finished.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
discard_stream_cancel(scope)
|
||||
|
||||
Reference in New Issue
Block a user