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:
MarekWo
2025-12-23 22:02:57 +01:00
parent a494f6b47d
commit c19a5bac88
3 changed files with 91 additions and 10 deletions
+2 -2
View File
@@ -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
View File
@@ -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,
+33 -3
View File
@@ -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'])