mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-03 23:42:31 +02:00
fix: Add request locking and caching to prevent USB conflicts
Root cause: Multiple concurrent meshcli calls were fighting for USB access, causing "Protocol error" and 504 Gateway Timeouts. Changes to meshcore-bridge: - Add threading.Lock to serialize meshcli subprocess calls - Prevent concurrent USB access that causes OSError [Errno 71] - Reduce DEFAULT_TIMEOUT from 30s to 10s - Add detailed logging for lock acquisition and release Changes to main API: - Implement 30s cache for get_channels() to reduce USB calls - Cache invalidation after channel create/join/delete operations - Use cached channels in /api/channels and /api/messages/updates - Reduce HTTP timeout from 30s to 12s (10s bridge + 2s buffer) Impact: - Eliminates race conditions when page loads (multiple API calls) - Prevents USB port conflicts and protocol errors - Faster response times due to caching - No need for manual USB resets after container restarts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -10,8 +10,8 @@ from app.config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Command timeout in seconds
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# Command timeout in seconds (reduced to prevent long waits)
|
||||
DEFAULT_TIMEOUT = 12 # Reduced from 30s - bridge has 10s + 2s buffer
|
||||
RECV_TIMEOUT = 60 # recv can take longer
|
||||
|
||||
|
||||
|
||||
+56
-5
@@ -6,6 +6,7 @@ import logging
|
||||
import json
|
||||
import re
|
||||
import base64
|
||||
import time
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
@@ -17,6 +18,53 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
# Simple cache for get_channels() to reduce USB/meshcli calls
|
||||
# Channels don't change frequently, so caching for 30s is safe
|
||||
_channels_cache = None
|
||||
_channels_cache_timestamp = 0
|
||||
CHANNELS_CACHE_TTL = 30 # seconds
|
||||
|
||||
|
||||
def get_channels_cached(force_refresh=False):
|
||||
"""
|
||||
Get channels with caching to reduce USB/meshcli calls.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass cache and fetch fresh data
|
||||
|
||||
Returns:
|
||||
Tuple of (success, channels_list)
|
||||
"""
|
||||
global _channels_cache, _channels_cache_timestamp
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Return cached data if valid and not forcing refresh
|
||||
if (not force_refresh and
|
||||
_channels_cache is not None and
|
||||
(current_time - _channels_cache_timestamp) < CHANNELS_CACHE_TTL):
|
||||
logger.debug(f"Returning cached channels (age: {current_time - _channels_cache_timestamp:.1f}s)")
|
||||
return True, _channels_cache
|
||||
|
||||
# Fetch fresh data
|
||||
logger.debug("Fetching fresh channels from meshcli")
|
||||
success, channels = cli.get_channels()
|
||||
|
||||
if success:
|
||||
_channels_cache = channels
|
||||
_channels_cache_timestamp = current_time
|
||||
logger.debug(f"Channels cached ({len(channels)} channels)")
|
||||
|
||||
return success, channels
|
||||
|
||||
|
||||
def invalidate_channels_cache():
|
||||
"""Invalidate channels cache (call after add/remove channel)"""
|
||||
global _channels_cache, _channels_cache_timestamp
|
||||
_channels_cache = None
|
||||
_channels_cache_timestamp = 0
|
||||
logger.debug("Channels cache invalidated")
|
||||
|
||||
|
||||
@api_bp.route('/messages', methods=['GET'])
|
||||
def get_messages():
|
||||
@@ -352,13 +400,13 @@ def trigger_archive():
|
||||
@api_bp.route('/channels', methods=['GET'])
|
||||
def get_channels():
|
||||
"""
|
||||
Get list of configured channels.
|
||||
Get list of configured channels (cached for 30s).
|
||||
|
||||
Returns:
|
||||
JSON with channels list
|
||||
"""
|
||||
try:
|
||||
success, channels = cli.get_channels()
|
||||
success, channels = get_channels_cached()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
@@ -417,6 +465,7 @@ def create_channel():
|
||||
success, message, key = cli.add_channel(name)
|
||||
|
||||
if success:
|
||||
invalidate_channels_cache() # Clear cache to force refresh
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message,
|
||||
@@ -469,7 +518,7 @@ def join_channel():
|
||||
index = int(data['index'])
|
||||
else:
|
||||
# Find first free slot (1-7, skip 0 which is Public)
|
||||
success_ch, channels = cli.get_channels()
|
||||
success_ch, channels = get_channels_cached()
|
||||
if not success_ch:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
@@ -492,6 +541,7 @@ def join_channel():
|
||||
success, message = cli.set_channel(index, name, key)
|
||||
|
||||
if success:
|
||||
invalidate_channels_cache() # Clear cache to force refresh
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Joined channel "{name}" at slot {index}',
|
||||
@@ -530,6 +580,7 @@ def delete_channel(index):
|
||||
success, message = cli.remove_channel(index)
|
||||
|
||||
if success:
|
||||
invalidate_channels_cache() # Clear cache to force refresh
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Channel {index} removed'
|
||||
@@ -682,8 +733,8 @@ def get_messages_updates():
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
last_seen = {}
|
||||
|
||||
# Get list of channels
|
||||
success_ch, channels = cli.get_channels()
|
||||
# Get list of channels (cached)
|
||||
success_ch, channels = get_channels_cached()
|
||||
if not success_ch:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
|
||||
@@ -8,6 +8,8 @@ The main mc-webui container communicates with this bridge via HTTP.
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -20,12 +22,20 @@ app = Flask(__name__)
|
||||
|
||||
# Configuration
|
||||
MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0')
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_TIMEOUT = 10 # Reduced from 30s to 10s
|
||||
RECV_TIMEOUT = 60
|
||||
|
||||
# Thread lock to prevent concurrent meshcli calls
|
||||
# Only one meshcli command can execute at a time to avoid USB conflicts
|
||||
meshcli_lock = threading.Lock()
|
||||
lock_wait_timeout = 15 # Max time to wait for lock
|
||||
|
||||
def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT):
|
||||
"""
|
||||
Execute meshcli command via subprocess.
|
||||
Execute meshcli command via subprocess with locking.
|
||||
|
||||
Uses a thread lock to ensure only one meshcli command runs at a time,
|
||||
preventing USB port conflicts and protocol errors.
|
||||
|
||||
Args:
|
||||
args: List of command arguments
|
||||
@@ -36,9 +46,23 @@ def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT):
|
||||
"""
|
||||
full_command = ['meshcli', '-s', MC_SERIAL_PORT] + args
|
||||
|
||||
logger.info(f"Executing: {' '.join(full_command)}")
|
||||
logger.info(f"Waiting for lock to execute: {' '.join(full_command)}")
|
||||
|
||||
# Try to acquire lock with timeout
|
||||
lock_acquired = meshcli_lock.acquire(timeout=lock_wait_timeout)
|
||||
|
||||
if not lock_acquired:
|
||||
logger.error(f"Failed to acquire lock after {lock_wait_timeout}s - another command is running")
|
||||
return {
|
||||
'success': False,
|
||||
'stdout': '',
|
||||
'stderr': f'Another meshcli command is already running (timeout after {lock_wait_timeout}s)',
|
||||
'returncode': -1
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Lock acquired, executing: {' '.join(full_command)}")
|
||||
|
||||
result = subprocess.run(
|
||||
full_command,
|
||||
capture_output=True,
|
||||
@@ -50,6 +74,8 @@ def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT):
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Command failed with code {result.returncode}: {result.stderr}")
|
||||
else:
|
||||
logger.info(f"Command completed successfully")
|
||||
|
||||
return {
|
||||
'success': success,
|
||||
@@ -74,6 +100,10 @@ def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT):
|
||||
'stderr': str(e),
|
||||
'returncode': -1
|
||||
}
|
||||
finally:
|
||||
# Always release lock
|
||||
meshcli_lock.release()
|
||||
logger.info("Lock released")
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
|
||||
Reference in New Issue
Block a user