소스 검색

Fix asyncio event loop mismatch in pattern_manager

- Initialize pause_event and pattern_lock lazily via getter functions
- Asyncio primitives must be created in the running event loop context
- Use Optional[] syntax for Python 3.9 compatibility
- Fixes RuntimeError: Task got Future attached to a different loop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 주 전
부모
커밋
60a078f13a
3개의 변경된 파일58개의 추가작업 그리고 39개의 파일을 삭제
  1. 1 1
      frontend/src/components/ui/select.tsx
  2. 23 19
      frontend/src/pages/SettingsPage.tsx
  3. 34 19
      modules/core/pattern_manager.py

+ 1 - 1
frontend/src/components/ui/select.tsx

@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
     <SelectPrimitive.Content
       ref={ref}
       ref={ref}
       className={cn(
       className={cn(
-        "relative z-[100] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        "relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
         position === "popper" &&
         position === "popper" &&
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
         className
         className

+ 23 - 19
frontend/src/pages/SettingsPage.tsx

@@ -177,6 +177,7 @@ export function SettingsPage() {
     try {
     try {
       const response = await fetch('/serial_status')
       const response = await fetch('/serial_status')
       const data = await response.json()
       const data = await response.json()
+      console.log('Serial status response:', data)
       setPorts(data.available_ports || [])
       setPorts(data.available_ports || [])
       setIsConnected(data.connected || false)
       setIsConnected(data.connected || false)
       setConnectionStatus(data.connected ? 'Connected' : 'Disconnected')
       setConnectionStatus(data.connected ? 'Connected' : 'Disconnected')
@@ -188,6 +189,11 @@ export function SettingsPage() {
     }
     }
   }
   }
 
 
+  // Always fetch ports on mount since connection is the default section
+  useEffect(() => {
+    fetchPorts()
+  }, [])
+
   const fetchSettings = async () => {
   const fetchSettings = async () => {
     try {
     try {
       const response = await fetch('/api/settings')
       const response = await fetch('/api/settings')
@@ -507,26 +513,24 @@ export function SettingsPage() {
             <div className="space-y-3">
             <div className="space-y-3">
               <Label>Available Serial Ports</Label>
               <Label>Available Serial Ports</Label>
               <div className="flex gap-3">
               <div className="flex gap-3">
-                <div className="relative flex-1" style={{ zIndex: 50 }}>
-                  <Select value={selectedPort} onValueChange={setSelectedPort}>
-                    <SelectTrigger>
-                      <SelectValue placeholder="Select a port..." />
-                    </SelectTrigger>
-                    <SelectContent position="popper" sideOffset={4}>
-                      {ports.length === 0 ? (
-                        <SelectItem value="_none" disabled>
-                          No ports available
+                <Select value={selectedPort} onValueChange={setSelectedPort}>
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Select a port..." />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {ports.length === 0 ? (
+                      <div className="py-6 text-center text-sm text-muted-foreground">
+                        No serial ports found
+                      </div>
+                    ) : (
+                      ports.map((port) => (
+                        <SelectItem key={port.port} value={port.port}>
+                          {port.port} - {port.description}
                         </SelectItem>
                         </SelectItem>
-                      ) : (
-                        ports.map((port) => (
-                          <SelectItem key={port.port} value={port.port}>
-                            {port.port} - {port.description}
-                          </SelectItem>
-                        ))
-                      )}
-                    </SelectContent>
-                  </Select>
-                </div>
+                      ))
+                    )}
+                  </SelectContent>
+                </Select>
                 <Button
                 <Button
                   onClick={handleConnect}
                   onClick={handleConnect}
                   disabled={isLoading === 'connect' || !selectedPort || isConnected}
                   disabled={isLoading === 'connect' || !selectedPort || isConnected}

+ 34 - 19
modules/core/pattern_manager.py

@@ -64,15 +64,26 @@ def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_ti
     except Exception as e:
     except Exception as e:
         logger.error(f"Failed to log execution time: {e}")
         logger.error(f"Failed to log execution time: {e}")
 
 
-# Create an asyncio Event for pause/resume
-pause_event = asyncio.Event()
-pause_event.set()  # Initially not paused
+# Asyncio primitives - initialized lazily to avoid event loop issues
+# These must be created in the context of the running event loop
+pause_event: Optional[asyncio.Event] = None
+pattern_lock: Optional[asyncio.Lock] = None
+progress_update_task = None
 
 
-# Create an asyncio Lock for pattern execution
-pattern_lock = asyncio.Lock()
+def get_pause_event() -> asyncio.Event:
+    """Get or create the pause event in the current event loop."""
+    global pause_event
+    if pause_event is None:
+        pause_event = asyncio.Event()
+        pause_event.set()  # Initially not paused
+    return pause_event
 
 
-# Progress update task
-progress_update_task = None
+def get_pattern_lock() -> asyncio.Lock:
+    """Get or create the pattern lock in the current event loop."""
+    global pattern_lock
+    if pattern_lock is None:
+        pattern_lock = asyncio.Lock()
+    return pattern_lock
 
 
 # Cache timezone at module level - read once per session (cleared when user changes timezone)
 # Cache timezone at module level - read once per session (cleared when user changes timezone)
 _cached_timezone = None
 _cached_timezone = None
@@ -430,12 +441,13 @@ async def cleanup_pattern_manager():
 
 
         # Clean up pattern lock - wait for it to be released naturally, don't force release
         # Clean up pattern lock - wait for it to be released naturally, don't force release
         # Force releasing an asyncio.Lock can corrupt internal state if held by another coroutine
         # Force releasing an asyncio.Lock can corrupt internal state if held by another coroutine
-        if pattern_lock and pattern_lock.locked():
+        current_lock = pattern_lock
+        if current_lock and current_lock.locked():
             logger.info("Pattern lock is held, waiting for release (max 5s)...")
             logger.info("Pattern lock is held, waiting for release (max 5s)...")
             try:
             try:
                 # Wait with timeout for the lock to become available
                 # Wait with timeout for the lock to become available
                 async with asyncio.timeout(5.0):
                 async with asyncio.timeout(5.0):
-                    async with pattern_lock:
+                    async with current_lock:
                         pass  # Lock acquired means previous holder released it
                         pass  # Lock acquired means previous holder released it
                 logger.info("Pattern lock released normally")
                 logger.info("Pattern lock released normally")
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
@@ -444,9 +456,10 @@ async def cleanup_pattern_manager():
                 logger.error(f"Error waiting for pattern lock: {e}")
                 logger.error(f"Error waiting for pattern lock: {e}")
 
 
         # Clean up pause event - wake up any waiting tasks, then create fresh event
         # Clean up pause event - wake up any waiting tasks, then create fresh event
-        if pause_event:
+        current_event = pause_event
+        if current_event:
             try:
             try:
-                pause_event.set()  # Wake up any waiting tasks
+                current_event.set()  # Wake up any waiting tasks
             except Exception as e:
             except Exception as e:
                 logger.error(f"Error setting pause event: {e}")
                 logger.error(f"Error setting pause event: {e}")
 
 
@@ -688,11 +701,12 @@ def is_clear_pattern(file_path):
 
 
 async def run_theta_rho_file(file_path, is_playlist=False):
 async def run_theta_rho_file(file_path, is_playlist=False):
     """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
     """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
-    if pattern_lock.locked():
+    lock = get_pattern_lock()
+    if lock.locked():
         logger.warning("Another pattern is already running. Cannot start a new one.")
         logger.warning("Another pattern is already running. Cannot start a new one.")
         return
         return
 
 
-    async with pattern_lock:  # This ensures only one pattern can run at a time
+    async with lock:  # This ensures only one pattern can run at a time
         # Start progress update task only if not part of a playlist
         # Start progress update task only if not part of a playlist
         global progress_update_task
         global progress_update_task
         if not is_playlist and not progress_update_task:
         if not is_playlist and not progress_update_task:
@@ -798,7 +812,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         if state.pause_requested:
                         if state.pause_requested:
                             # For manual pause, wait directly on the event for immediate response
                             # For manual pause, wait directly on the event for immediate response
                             # The while loop re-checks state after wake to handle rapid pause/resume
                             # The while loop re-checks state after wake to handle rapid pause/resume
-                            await pause_event.wait()
+                            await get_pause_event().wait()
                         else:
                         else:
                             # For scheduled pause only, check periodically
                             # For scheduled pause only, check periodically
                             await asyncio.sleep(1)
                             await asyncio.sleep(1)
@@ -1102,10 +1116,11 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         # Wait for the pattern lock to be released before continuing
         # Wait for the pattern lock to be released before continuing
         # This ensures that when stop_actions completes, the pattern has fully stopped
         # This ensures that when stop_actions completes, the pattern has fully stopped
         # Skip this if called from within pattern execution to avoid deadlock
         # Skip this if called from within pattern execution to avoid deadlock
-        if wait_for_lock and pattern_lock.locked():
+        lock = get_pattern_lock()
+        if wait_for_lock and lock.locked():
             logger.info("Waiting for pattern to fully stop...")
             logger.info("Waiting for pattern to fully stop...")
             # Acquire and immediately release the lock to ensure the pattern has exited
             # Acquire and immediately release the lock to ensure the pattern has exited
-            async with pattern_lock:
+            async with lock:
                 logger.info("Pattern lock acquired - pattern has fully stopped")
                 logger.info("Pattern lock acquired - pattern has fully stopped")
 
 
         # Call async function directly since we're in async context
         # Call async function directly since we're in async context
@@ -1155,14 +1170,14 @@ def pause_execution():
     """Pause pattern execution using asyncio Event."""
     """Pause pattern execution using asyncio Event."""
     logger.info("Pausing pattern execution")
     logger.info("Pausing pattern execution")
     state.pause_requested = True
     state.pause_requested = True
-    pause_event.clear()  # Clear the event to pause execution
+    get_pause_event().clear()  # Clear the event to pause execution
     return True
     return True
 
 
 def resume_execution():
 def resume_execution():
     """Resume pattern execution using asyncio Event."""
     """Resume pattern execution using asyncio Event."""
     logger.info("Resuming pattern execution")
     logger.info("Resuming pattern execution")
     state.pause_requested = False
     state.pause_requested = False
-    pause_event.set()  # Set the event to resume execution
+    get_pause_event().set()  # Set the event to resume execution
     return True
     return True
     
     
 async def reset_theta():
 async def reset_theta():
@@ -1228,7 +1243,7 @@ async def broadcast_progress():
         # Check if we should stop broadcasting
         # Check if we should stop broadcasting
         if not state.current_playlist:
         if not state.current_playlist:
             # If no playlist, only stop if no pattern is being executed
             # If no playlist, only stop if no pattern is being executed
-            if not pattern_lock.locked():
+            if not get_pattern_lock().locked():
                 logger.info("No playlist or pattern running, stopping broadcast")
                 logger.info("No playlist or pattern running, stopping broadcast")
                 break
                 break