Files
EvoScientist-Multi/EvoScientist/cli/channel_sends.py
T
dinos 8b1451cdda 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>
2026-07-27 14:17:57 +01:00

113 lines
3.9 KiB
Python

"""Non-blocking bridge for streaming callbacks sent through a channel loop."""
from __future__ import annotations
import asyncio
import concurrent.futures
import logging
import threading
from collections.abc import Coroutine
from typing import Any
class PendingChannelSends:
"""Schedule channel I/O without blocking the caller's event loop.
Streaming callbacks run on the owned async runtime, while channel clients
belong to the channel bus loop. Submissions therefore only enqueue work;
the frontend settles the returned futures after streaming has unwound.
"""
def __init__(
self,
loop: asyncio.AbstractEventLoop | None,
logger: logging.Logger,
) -> None:
self._loop = loop
self._logger = logger
self._lock = threading.Lock()
self._pending: list[tuple[concurrent.futures.Future[Any], str, int]] = []
self._tail: concurrent.futures.Future[Any] | None = None
@staticmethod
def _close(coro: Coroutine[Any, Any, Any]) -> None:
coro.close()
async def _run_after(
self,
predecessor: concurrent.futures.Future[Any] | None,
coro: Coroutine[Any, Any, Any],
) -> Any:
if predecessor is not None:
try:
await asyncio.shield(asyncio.wrap_future(predecessor))
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling():
self._close(coro)
raise
except Exception:
pass
return await coro
def submit(
self,
coro: Coroutine[Any, Any, Any],
label: str,
timeout: int = 15,
) -> None:
"""Schedule one send and return immediately."""
if self._loop is None:
self._close(coro)
return
with self._lock:
ordered_coro = self._run_after(self._tail, coro)
try:
future = asyncio.run_coroutine_threadsafe(ordered_coro, self._loop)
except Exception as exc:
self._close(ordered_coro)
self._close(coro)
self._logger.debug("%s send failed: %s", label, exc)
return
self._tail = future
self._pending.append((future, label, timeout))
def _take_pending(
self,
) -> list[tuple[concurrent.futures.Future[Any], str, int]]:
with self._lock:
pending = self._pending
self._pending = []
return pending
def settle(self) -> None:
"""Wait for scheduled sends from a synchronous frontend thread."""
for future, label, timeout in self._take_pending():
try:
future.result(timeout=timeout)
except Exception as exc:
future.cancel()
self._logger.debug("%s send failed: %s", label, exc)
async def settle_async(self) -> None:
"""Wait for scheduled sends without blocking the frontend loop."""
pending = self._take_pending()
try:
for future, label, timeout in pending:
try:
await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
except TimeoutError as exc:
future.cancel()
self._logger.debug("%s send failed: %s", label, exc)
except asyncio.CancelledError as exc:
task = asyncio.current_task()
if task is not None and task.cancelling():
raise
self._logger.debug("%s send failed: %s", label, exc)
except Exception as exc:
self._logger.debug("%s send failed: %s", label, exc)
except asyncio.CancelledError:
for future, _label, _timeout in pending:
future.cancel()
raise