from __future__ import annotations import asyncio import logging import shutil from collections.abc import Callable, Awaitable from yuxi.channels.models import HealthStatus from yuxi.channels.adapters.signal.client import RpcClient logger = logging.getLogger(__name__) DAEMON_STARTUP_RETRIES = 30 DAEMON_STARTUP_RETRY_DELAY = 1.0 DAEMON_STOP_TIMEOUT = 10.0 class SignalDaemonManager: def __init__( self, cli_path: str, account: str, http_listen: str = "127.0.0.1:8080", home_dir: str | None = None, java_opts: str | None = None, receive_mode: str | None = None, send_read_receipts: bool | None = None, daemon_startup_retries: int = DAEMON_STARTUP_RETRIES, auto_start: bool = True, poll_interval_ms: int = 150, log_after_ms: int = 10000, log_interval_ms: int = 10000, ): self._cli_path = cli_path self._account = account self._http_listen = http_listen self._home_dir = home_dir self._java_opts = java_opts self._receive_mode = receive_mode self._send_read_receipts = send_read_receipts self._daemon_startup_retries = daemon_startup_retries self._auto_start = auto_start self._poll_interval_ms = poll_interval_ms self._log_after_ms = log_after_ms self._log_interval_ms = log_interval_ms self._process: asyncio.subprocess.Process | None = None self._monitor_task: asyncio.Task | None = None self._on_crash: asyncio.Event | None = None self._crash_handler: Callable[[int, str], Awaitable[None]] | None = None @property def listen_addr(self) -> str: return self._http_listen @property def is_running(self) -> bool: return self._process is not None and self._process.returncode is None def on_crash(self, handler: Callable[[int, str], Awaitable[None]]) -> None: self._crash_handler = handler async def start(self) -> None: if not self._auto_start: logger.info("signal-cli daemon auto_start is disabled, skipping") return if not shutil.which(self._cli_path) and self._cli_path != "signal-cli": raise FileNotFoundError(f"signal-cli not found at: {self._cli_path}") cmd = [self._cli_path, "-a", self._account, "daemon", "--http-listen", self._http_listen] if self._receive_mode: cmd.extend(["--receive-mode", self._receive_mode]) if self._send_read_receipts is not None: if self._send_read_receipts: cmd.append("--send-read-receipts") else: cmd.append("--no-send-read-receipts") if self._home_dir: cmd.extend(["--config", self._home_dir]) env = None if self._java_opts: env = {**dict(__import__("os").environ), "JAVA_OPTS": self._java_opts} logger.info(f"Starting signal-cli daemon: {' '.join(cmd)}") self._process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, ) self._on_crash = asyncio.Event() self._monitor_task = asyncio.create_task(self._monitor_process()) rpc_client = RpcClient(f"http://{self._http_listen}") await rpc_client.connect() try: poll_ms = max(100, self._poll_interval_ms) total_elapsed_ms = 0 timeout_ms = self._daemon_startup_retries * 1000 logged = False while total_elapsed_ms < timeout_ms: try: await rpc_client.call("version") logger.info("signal-cli daemon is ready") return except Exception: await asyncio.sleep(poll_ms / 1000.0) total_elapsed_ms += poll_ms if not logged and total_elapsed_ms >= self._log_after_ms: logged = True logger.info( f"signal-cli daemon not ready after {total_elapsed_ms}ms, " f"will log every {self._log_interval_ms}ms" ) elif logged and total_elapsed_ms % self._log_interval_ms < poll_ms: logger.info(f"signal-cli daemon still not ready (elapsed={total_elapsed_ms}ms)") raise TimeoutError( f"signal-cli daemon did not become ready within {timeout_ms}ms (poll_interval={poll_ms}ms)" ) finally: await rpc_client.disconnect() async def stop(self) -> None: if self._monitor_task: self._monitor_task.cancel() try: await self._monitor_task except asyncio.CancelledError: pass self._monitor_task = None if self._process: logger.info("Stopping signal-cli daemon") self._process.terminate() try: await asyncio.wait_for(self._process.wait(), timeout=DAEMON_STOP_TIMEOUT) except TimeoutError: logger.warning("signal-cli daemon did not stop gracefully, sending SIGKILL") self._process.kill() await self._process.wait() self._process = None if self._on_crash: self._on_crash.clear() async def restart(self) -> None: logger.info("Restarting signal-cli daemon") await self.stop() await self.start() async def health_check(self) -> HealthStatus: if not self.is_running: return HealthStatus( status="unhealthy", last_error="signal-cli daemon process is not running", ) try: rpc_client = RpcClient(f"http://{self._http_listen}") await rpc_client.connect() try: await rpc_client.call("version") return HealthStatus(status="healthy") finally: await rpc_client.disconnect() except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) async def _monitor_process(self) -> None: while self._process and self._process.returncode is None: try: await asyncio.wait_for(self._process.wait(), timeout=5.0) break except TimeoutError: continue if self._process: exit_code = self._process.returncode stderr_data = await self._process.stderr.read() if self._process.stderr else b"" msg = stderr_data.decode("utf-8", errors="replace")[:500] self._classify_and_log_stderr(msg) logger.error(f"signal-cli daemon exited with code {exit_code}: {msg}") if self._on_crash: self._on_crash.set() if self._crash_handler: try: await self._crash_handler(exit_code, msg) except Exception: logger.exception("Crash handler failed") @staticmethod def _classify_and_log_stderr(stderr_text: str) -> None: if not stderr_text: return lower = stderr_text.lower() if any(kw in lower for kw in ("error", "exception", "fatal", "failed")): logger.error(f"[signal-cli stderr] {stderr_text[:500]}") elif any(kw in lower for kw in ("warn", "warning")): logger.warning(f"[signal-cli stderr] {stderr_text[:500]}") elif any(kw in lower for kw in ("debug", "trace")): logger.debug(f"[signal-cli stderr] {stderr_text[:500]}") else: logger.info(f"[signal-cli stderr] {stderr_text[:500]}")