87 lines
2.8 KiB
Python
87 lines
2.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Background asyncio event loop running in a daemon thread.
|
|
|
|
Sync code (chatwoot_ws_agent) submits coroutines via ``gateway_loop.run(coro)``
|
|
and blocks on the result. All blocking I/O for the 3rd-party platforms happens
|
|
on this loop, so the WS Agent's main thread never stalls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
from concurrent.futures import TimeoutError as FutTimeout
|
|
from typing import Any, Coroutine
|
|
|
|
log = logging.getLogger("chathub.gateway.loop")
|
|
|
|
|
|
class BackgroundLoop:
|
|
"""One asyncio loop in a daemon thread, exposed as a sync facade.
|
|
|
|
Lifecycle:
|
|
loop = BackgroundLoop()
|
|
loop.start() # call once at process start
|
|
loop.run(coro, 5) # block on a coroutine
|
|
loop.stop() # at shutdown
|
|
"""
|
|
|
|
def __init__(self, name: str = "gateway-loop") -> None:
|
|
self.name = name
|
|
self.loop: asyncio.AbstractEventLoop | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._ready = threading.Event()
|
|
self._closed = False
|
|
|
|
def start(self) -> None:
|
|
if self._thread is not None:
|
|
return
|
|
self._thread = threading.Thread(
|
|
target=self._runner, daemon=True, name=self.name
|
|
)
|
|
self._thread.start()
|
|
if not self._ready.wait(timeout=5.0):
|
|
raise RuntimeError(f"{self.name} failed to start in 5s")
|
|
log.info("Background loop %s started", self.name)
|
|
|
|
def _runner(self) -> None:
|
|
self.loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(self.loop)
|
|
self._ready.set()
|
|
try:
|
|
self.loop.run_forever()
|
|
finally:
|
|
self.loop.close()
|
|
|
|
def run(self, coro: Coroutine, timeout: float = 30.0) -> Any:
|
|
"""Submit a coroutine from sync code, block on result.
|
|
|
|
Raises:
|
|
RuntimeError: loop not started
|
|
TimeoutError: coroutine exceeded ``timeout`` seconds
|
|
Exception: whatever the coroutine raised
|
|
"""
|
|
if self._closed:
|
|
raise RuntimeError("loop is closed")
|
|
if not self.loop or not self.loop.is_running():
|
|
raise RuntimeError("loop not started; call .start() first")
|
|
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
try:
|
|
return future.result(timeout=timeout)
|
|
except FutTimeout:
|
|
future.cancel()
|
|
raise TimeoutError(f"coroutine timed out after {timeout}s") from None
|
|
|
|
def stop(self) -> None:
|
|
if self._closed or not self.loop:
|
|
return
|
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
self._thread.join(timeout=5)
|
|
self._closed = True
|
|
log.info("Background loop %s stopped", self.name)
|
|
|
|
|
|
# Singleton used by router.py and the WS Agent hook.
|
|
gateway_loop = BackgroundLoop()
|