From c19a5bac88cae854506e492d8e18ab418b661b6c Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 23 Dec 2025 22:02:57 +0100 Subject: [PATCH] fix: Add request locking and caching to prevent USB conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/meshcore/cli.py | 4 +-- app/routes/api.py | 61 +++++++++++++++++++++++++++++++++++---- meshcore-bridge/bridge.py | 36 +++++++++++++++++++++-- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 6cddd10..6839b3e 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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 diff --git a/app/routes/api.py b/app/routes/api.py index 9f767ee..502a837 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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, diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py index 60fa5de..eff8650 100644 --- a/meshcore-bridge/bridge.py +++ b/meshcore-bridge/bridge.py @@ -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'])