fix: Add thread-safe event emission and fix background thread errors

This commit is contained in:
devmatrix 2026-02-17 00:01:12 +00:00
parent 933434fb03
commit b6d33127e4
3 changed files with 62 additions and 8 deletions

View File

@ -104,6 +104,9 @@ class EUUtilityApp:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
# Start event bus first (captures main event loop)
self.event_bus.start()
# Discover and load plugins # Discover and load plugins
self.plugin_manager.discover_all() self.plugin_manager.discover_all()
self.plugin_manager.load_all(auto_activate=True) self.plugin_manager.load_all(auto_activate=True)
@ -119,10 +122,12 @@ class EUUtilityApp:
def shutdown(self): def shutdown(self):
"""Shutdown the application gracefully.""" """Shutdown the application gracefully."""
if self.plugin_manager:
self.plugin_manager.shutdown()
if self.game_client: if self.game_client:
self.game_client.stop() self.game_client.stop()
if self.plugin_manager:
self.plugin_manager.shutdown()
if self.event_bus:
self.event_bus.stop()
def _root_reducer(self, state, action): def _root_reducer(self, state, action):
"""Root state reducer.""" """Root state reducer."""

View File

@ -37,6 +37,8 @@ from abc import ABC, abstractmethod
from collections import defaultdict, deque from collections import defaultdict, deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from queue import Queue
from threading import Lock
from typing import ( from typing import (
Any, Callable, Coroutine, Dict, List, Optional, Set, Type, TypeVar, Any, Callable, Coroutine, Dict, List, Optional, Set, Type, TypeVar,
Union, Generic, Protocol, runtime_checkable Union, Generic, Protocol, runtime_checkable
@ -238,6 +240,11 @@ class EventBus:
self._event_count = 0 self._event_count = 0
self._dropped_count = 0 self._dropped_count = 0
# Thread-safe queue for cross-thread event emission
self._thread_queue: Queue = Queue()
self._thread_lock = Lock()
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
# ========== Subscription Methods ========== # ========== Subscription Methods ==========
def on( def on(
@ -321,6 +328,24 @@ class EventBus:
return sub_id return sub_id
def start(self) -> None:
"""Start the event bus and capture the main event loop.
This should be called from the main thread's async context.
"""
try:
self._main_loop = asyncio.get_running_loop()
self._running = True
self._logger.info("Event bus started")
except RuntimeError:
self._logger.warning("Event bus started without event loop")
def stop(self) -> None:
"""Stop the event bus."""
self._running = False
self._main_loop = None
self._logger.info("Event bus stopped")
def unsubscribe(self, subscription_id: str) -> bool: def unsubscribe(self, subscription_id: str) -> bool:
"""Unsubscribe from events. """Unsubscribe from events.
@ -413,17 +438,37 @@ class EventBus:
self._queue.append(event) self._queue.append(event)
self._event_count += 1 self._event_count += 1
# Process immediately in async context (thread-safe) # Try to process in async context
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
if loop.is_running(): # We're in an async context - safe to create task
asyncio.create_task(self._process_event(event)) asyncio.create_task(self._process_event(event))
except RuntimeError: except RuntimeError:
# No event loop running in this thread - queue for later processing # No event loop in this thread - queue for main thread
pass with self._thread_lock:
self._thread_queue.put(event)
# Try to notify main loop if available
if self._main_loop and self._main_loop.is_running():
try:
self._main_loop.call_soon_threadsafe(
lambda: asyncio.create_task(self._process_thread_queue())
)
except:
pass
return event return event
async def _process_thread_queue(self) -> None:
"""Process events queued from other threads."""
while not self._thread_queue.empty():
try:
with self._thread_lock:
if not self._thread_queue.empty():
event = self._thread_queue.get_nowait()
await self._process_event(event)
except:
break
async def emit_async( async def emit_async(
self, self,
event_type: str, event_type: str,

View File

@ -188,10 +188,14 @@ class GameClient:
except Exception as e: except Exception as e:
self._logger.error(f"Error in callback: {e}") self._logger.error(f"Error in callback: {e}")
# Emit to event bus # Emit to event bus (thread-safe)
if self.event_bus: if self.event_bus:
try: try:
self.event_bus.emit(f"game.{event_type}", data, source="game_client") self.event_bus.emit(f"game.{event_type}", data, source="game_client")
except RuntimeError as e:
# No event loop in this thread - event is queued for main thread
# This is expected when emitting from background threads
pass
except Exception as e: except Exception as e:
self._logger.error(f"Error emitting to event bus: {e}") self._logger.error(f"Error emitting to event bus: {e}")