from __future__ import annotations import os from pathlib import Path import threading from typing import Any from services.shared.core import utc_now_iso class BridgeState: def __init__(self) -> None: self._lock = threading.Lock() self.ami_connected = False self.last_event_at: str | None = None self.last_error: str | None = None self.reconnect_requested = False def snapshot(self) -> dict[str, Any]: with self._lock: return { "ami_connected": self.ami_connected, "last_event_at": self.last_event_at, "last_error": self.last_error, "reconnect_requested": self.reconnect_requested, } def set_connected(self, value: bool) -> None: with self._lock: self.ami_connected = value if value: self.last_error = None def set_last_event(self) -> None: with self._lock: self.last_event_at = utc_now_iso() def set_error(self, message: str) -> None: with self._lock: self.last_error = message def request_reconnect(self) -> None: with self._lock: self.reconnect_requested = True def take_reconnect(self) -> bool: with self._lock: current = self.reconnect_requested self.reconnect_requested = False return current _BACKGROUND_LOCK = threading.Lock() _BACKGROUND_STOP_EVENT: threading.Event | None = None _BACKGROUND_THREADS: list[threading.Thread] = [] _BRIDGE_SINGLETON_DB_CONNECTION = None _BRIDGE_SINGLETON_FILE_HANDLE = None def bridge_singleton_lock_path(*, database_url: str, cc_data_dir: str) -> Path: if database_url.startswith("sqlite:///"): db_path = Path(database_url[len("sqlite:///") :]).resolve() db_path.parent.mkdir(parents=True, exist_ok=True) return db_path.parent / "asterisk_bridge_service.singleton.lock" data_dir = Path(cc_data_dir).resolve() data_dir.mkdir(parents=True, exist_ok=True) return data_dir / "asterisk_bridge_service.singleton.lock" def try_lock_file(handle) -> bool: try: handle.seek(0) handle.write(b"0") handle.flush() handle.seek(0) except OSError: return False if os.name == "nt": import msvcrt try: msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) return True except OSError: return False import fcntl try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) return True except OSError: return False def unlock_file(handle) -> None: handle.seek(0) if os.name == "nt": import msvcrt msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) return import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_UN) def try_acquire_bridge_singleton_guard( *, database_url: str, engine: Any, lock_key: int, cc_data_dir: str, ) -> bool: global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE if _BRIDGE_SINGLETON_DB_CONNECTION is not None or _BRIDGE_SINGLETON_FILE_HANDLE is not None: return True if database_url.startswith("postgresql"): connection = engine.raw_connection() try: cursor = connection.cursor() try: cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_key,)) acquired_row = cursor.fetchone() finally: cursor.close() connection.commit() except Exception: connection.close() raise if acquired_row and bool(acquired_row[0]): _BRIDGE_SINGLETON_DB_CONNECTION = connection return True connection.close() return False lock_path = bridge_singleton_lock_path(database_url=database_url, cc_data_dir=cc_data_dir) lock_path.touch(exist_ok=True) handle = lock_path.open("r+b") if try_lock_file(handle): _BRIDGE_SINGLETON_FILE_HANDLE = handle return True try: handle.close() except OSError: pass return False def release_bridge_singleton_guard(*, lock_key: int) -> None: global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE if _BRIDGE_SINGLETON_DB_CONNECTION is not None: connection = _BRIDGE_SINGLETON_DB_CONNECTION _BRIDGE_SINGLETON_DB_CONNECTION = None try: cursor = connection.cursor() try: cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_key,)) finally: cursor.close() connection.commit() finally: connection.close() if _BRIDGE_SINGLETON_FILE_HANDLE is not None: handle = _BRIDGE_SINGLETON_FILE_HANDLE _BRIDGE_SINGLETON_FILE_HANDLE = None try: unlock_file(handle) finally: handle.close() def bridge_singleton_db_connection(): return _BRIDGE_SINGLETON_DB_CONNECTION def bridge_singleton_file_handle(): return _BRIDGE_SINGLETON_FILE_HANDLE def background_threads_alive() -> list[threading.Thread]: return [thread for thread in _BACKGROUND_THREADS if thread.is_alive()] def background_stop_event() -> threading.Event: global _BACKGROUND_STOP_EVENT if _BACKGROUND_STOP_EVENT is None or _BACKGROUND_STOP_EVENT.is_set(): _BACKGROUND_STOP_EVENT = threading.Event() return _BACKGROUND_STOP_EVENT def background_shutdown_timeout_seconds(*, poll_interval: float, failed_retry_interval_seconds: float) -> float: return max(10.0, poll_interval, failed_retry_interval_seconds) + 1.0 def start_background_threads( *, ami_loop, failed_retry_loop, ensure_guard, extra_loops: list[tuple[str, Any]] | None = None, ) -> None: global _BACKGROUND_THREADS with _BACKGROUND_LOCK: alive = background_threads_alive() if alive: _BACKGROUND_THREADS = alive ensure_guard() return stop_event = background_stop_event() ensure_guard() threads = [ threading.Thread(target=ami_loop, args=(stop_event,), daemon=True, name="asterisk-ami-loop"), threading.Thread( target=failed_retry_loop, args=(stop_event,), daemon=True, name="asterisk-failed-retry-loop", ), ] for name, target in extra_loops or []: threads.append(threading.Thread(target=target, args=(stop_event,), daemon=True, name=name)) _BACKGROUND_THREADS = threads try: for thread in threads: thread.start() except Exception: stop_event.set() _BACKGROUND_THREADS = [] raise def stop_background_threads( *, state: BridgeState, release_guard, shutdown_timeout_seconds: float, ) -> None: global _BACKGROUND_STOP_EVENT, _BACKGROUND_THREADS with _BACKGROUND_LOCK: stop_event = _BACKGROUND_STOP_EVENT threads = list(_BACKGROUND_THREADS) if stop_event is not None: stop_event.set() state.request_reconnect() for thread in threads: if thread.is_alive(): thread.join(timeout=shutdown_timeout_seconds) with _BACKGROUND_LOCK: alive = background_threads_alive() if alive: _BACKGROUND_THREADS = alive return _BACKGROUND_THREADS = [] _BACKGROUND_STOP_EVENT = None state.set_connected(False) release_guard()