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:
@@ -0,0 +1,85 @@
|
||||
"""Signal-level regression tests for Rich CLI turn cancellation."""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist.cli import interactive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_turns_are_serialized() -> None:
|
||||
"""A channel turn cannot start while a foreground turn owns the session."""
|
||||
turn_lock = asyncio.Lock()
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
order: list[str] = []
|
||||
|
||||
async def first_turn() -> None:
|
||||
order.append("first-started")
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
order.append("first-finished")
|
||||
|
||||
async def second_turn() -> None:
|
||||
order.append("second-started")
|
||||
second_started.set()
|
||||
|
||||
first = asyncio.create_task(interactive._run_serialized_turn(turn_lock, first_turn))
|
||||
await first_started.wait()
|
||||
second = asyncio.create_task(
|
||||
interactive._run_serialized_turn(turn_lock, second_turn)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not second_started.is_set()
|
||||
release_first.set()
|
||||
await asyncio.gather(first, second)
|
||||
assert order == ["first-started", "first-finished", "second-started"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
threading.current_thread() is not threading.main_thread(),
|
||||
reason="process signal handlers require the main thread",
|
||||
)
|
||||
def test_ctrl_c_can_cancel_two_separate_rich_cli_turns(monkeypatch):
|
||||
"""A recovered turn must not consume asyncio.run's force-quit budget."""
|
||||
started = asyncio.Event()
|
||||
calls = 0
|
||||
|
||||
async def fake_run_streaming_async(**kwargs):
|
||||
nonlocal calls
|
||||
assert kwargs["recover_on_cancel"] is True
|
||||
calls += 1
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
current = asyncio.current_task()
|
||||
assert current is not None
|
||||
current.uncancel()
|
||||
return "[Stopped.]"
|
||||
|
||||
monkeypatch.setattr(interactive, "run_streaming_async", fake_run_streaming_async)
|
||||
original_sigint = signal.getsignal(signal.SIGINT)
|
||||
|
||||
async def cancel_started_turn() -> None:
|
||||
await started.wait()
|
||||
signal.raise_signal(signal.SIGINT)
|
||||
|
||||
async def scenario() -> None:
|
||||
runner_sigint = signal.getsignal(signal.SIGINT)
|
||||
for _ in range(2):
|
||||
started.clear()
|
||||
sender = asyncio.create_task(cancel_started_turn())
|
||||
assert await interactive._run_rich_cli_streaming_turn() == "[Stopped.]"
|
||||
await sender
|
||||
assert signal.getsignal(signal.SIGINT) is runner_sigint
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert calls == 2
|
||||
assert signal.getsignal(signal.SIGINT) is original_sigint
|
||||
Reference in New Issue
Block a user