Parcourir la source

Use SingletonSlot for thread-safe chattolib client

- Add AsyncSingletonSlot class for thread-safe async client creation
- Update _get_chatto_client() to use AsyncSingletonSlot with double-checked locking
- Maintain self._chatto_client cache for backward compatibility
- Clear client cache on disconnect and relogin

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Liv Klumpp il y a 1 semaine
Parent
commit
b7840b7074
1 fichiers modifiés avec 62 ajouts et 16 suppressions
  1. 62 16
      adapter.py

+ 62 - 16
adapter.py

@@ -35,6 +35,7 @@ import hashlib
 import logging
 import mimetypes
 import os
+import threading
 from collections import OrderedDict
 from datetime import datetime, timezone
 from typing import Any, Dict, List, Optional
@@ -54,6 +55,35 @@ from gateway.config import Platform
 # Chattolib imports (lazy loaded)
 from tools.lazy_deps import lazy_import
 
+# Thread-safe singleton helper
+from plugins.plugin_utils import SingletonSlot
+
+
+class AsyncSingletonSlot:
+    """Thread-safe async singleton helper (extends SingletonSlot pattern for async factories)."""
+    def __init__(self):
+        self._instance = None
+        self._lock = threading.Lock()
+        self._future = None
+
+    async def get(self, factory):
+        """Get or create instance using async factory. Thread-safe with double-checked locking."""
+        if self._instance is not None:
+            return self._instance
+
+        with self._lock:
+            if self._instance is None:
+                if self._future is None:
+                    self._future = asyncio.ensure_future(factory())
+                return await self._future
+            return self._instance
+
+    def reset(self):
+        """Reset the singleton instance."""
+        with self._lock:
+            self._instance = None
+            self._future = None
+
 ChattoClient = lazy_import("chattolib", "ChattoClient")
 ChattoError = lazy_import("chattolib", "ChattoError")
 ChattoAuthError = lazy_import("chattolib", "ChattoAuthError")
@@ -169,8 +199,9 @@ class ChattoAdapter(BasePlatformAdapter):
                 str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
             )
 
-        # --- Chattolib client ---
-        self._chatto_client: Optional[ChattoClient] = None
+        # --- Chattolib client (thread-safe lazy singleton) ---
+        self._client_slot: AsyncSingletonSlot = AsyncSingletonSlot()
+        self._chatto_client: Optional[ChattoClient] = None  # Cached client instance
 
         # --- Runtime state ---
         self._token: Optional[str] = None
@@ -205,7 +236,8 @@ class ChattoAdapter(BasePlatformAdapter):
     # ------------------------------------------------------------------ #
 
     async def _get_chatto_client(self) -> Optional[ChattoClient]:
-        """Get or create a ChattoClient instance."""
+        """Get or create a ChattoClient instance using thread-safe async singleton."""
+        # Fast path: return cached client if available
         if self._chatto_client is not None:
             return self._chatto_client
         
@@ -214,13 +246,16 @@ class ChattoAdapter(BasePlatformAdapter):
             return None
         
         try:
-            self._chatto_client = await ChattoClient.login(
-                self._login,
-                self._password,
-                base_url=self._base_url,
+            client = await self._client_slot.get(
+                lambda: ChattoClient.login(
+                    self._login,
+                    self._password,
+                    base_url=self._base_url,
+                )
             )
+            self._chatto_client = client  # Cache for direct access
             logger.info("Chatto: logged in as %s via chattolib", self._login)
-            return self._chatto_client
+            return client
         except ChattoAuthError as e:
             logger.error("Chatto: authentication failed: %s", e)
             self._set_fatal_error("auth_failed", str(e), retryable=True)
@@ -230,6 +265,10 @@ class ChattoAdapter(BasePlatformAdapter):
             self._set_fatal_error("client_error", str(e), retryable=True)
             return None
 
+    async def _client(self) -> ChattoClient:
+        """Helper to get the chattolib client. Thread-safe via AsyncSingletonSlot."""
+        return await self._get_chatto_client()
+
     async def _ensure_token(self) -> bool:
         """Login via chattolib."""
         if self._token:
@@ -245,7 +284,8 @@ class ChattoAdapter(BasePlatformAdapter):
     async def _relogin(self) -> bool:
         """Force re-login (token expired)."""
         self._token = None
-        self._chatto_client = None  # Also clear chattolib client
+        self._chatto_client = None  # Clear cached client
+        self._client_slot.reset()  # Clear chattolib client singleton
         return await self._ensure_token()
 
 
@@ -258,7 +298,7 @@ class ChattoAdapter(BasePlatformAdapter):
         if not await self._ensure_token():
             return False
 
-        client = self._chatto_client
+        client = await self._get_chatto_client()
         
         # Get our own user info
         try:
@@ -391,6 +431,8 @@ class ChattoAdapter(BasePlatformAdapter):
                 pass
             self._ws_task = None
         self._token = None
+        self._chatto_client = None
+        self._client_slot.reset()
 
     # ------------------------------------------------------------------ #
     # Liveness probe
@@ -438,7 +480,8 @@ class ChattoAdapter(BasePlatformAdapter):
             if not self._running:
                 return
             try:
-                await self._chatto_client.get_viewer()
+                client = await self._get_chatto_client()
+                await client.get_viewer()
                 failures = 0
                 # Refresh presence to keep showing as online
                 try:
@@ -479,7 +522,8 @@ class ChattoAdapter(BasePlatformAdapter):
     async def _join_room(self, room_id: str) -> None:
         """Join a room if not already a member."""
         try:
-            await self._chatto_client.join_room(room_id=room_id)
+            client = await self._get_chatto_client()
+            await client.join_room(room_id=room_id)
             logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
         except ChattoError as e:
             if "permission_denied" in str(e).lower() or "403" in str(e):
@@ -493,9 +537,10 @@ class ChattoAdapter(BasePlatformAdapter):
             room_service_pb2 = lazy_import("chattolib._pb.chatto.api.v1", "room_service_pb2")
             pb_to_dict = lazy_import("chattolib._transport", "pb_to_dict")
             
-            resp = await self._chatto_client.services.rooms.get_room_events(
+            client = await self._get_chatto_client()
+            resp = await client.services.rooms.get_room_events(
                 room_service_pb2.GetRoomEventsRequest(room_id=room_id),
-                headers=self._chatto_client._headers(),
+                headers=client._headers(),
             )
             data = pb_to_dict(resp)
             events = data.get("page", {}).get("events", [])
@@ -556,7 +601,7 @@ class ChattoAdapter(BasePlatformAdapter):
         This replaces the manual WebSocket loop with chattolib's high-level
         stream_events() which provides pre-decoded RealtimeEvent objects.
         """
-        client = self._chatto_client
+        client = await self._get_chatto_client()
         
         backoff = _WS_RECONNECT_INITIAL_BACKOFF
         
@@ -1331,7 +1376,8 @@ class ChattoAdapter(BasePlatformAdapter):
         """Add a reaction to a message via MessageService/AddReaction."""
         shortcode = self._emoji_to_shortcode(emoji)
         try:
-            result = await self._chatto_client.add_reaction(
+            client = await self._client()
+            result = await client.add_reaction(
                 room_id=str(chat_id),
                 message_event_id=str(message_id),
                 emoji=shortcode,