From a8f2912b905ffae63d090937ca660202e7756105 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 13:58:42 +0000 Subject: [PATCH] Fix waterfall-to-listen SDR busy race condition Wait for server-side WebSocket stop confirmation before closing the connection, ensuring the IQ process is fully terminated and the USB device released. Add retry logic with back-off in the audio start endpoint as defense-in-depth for any remaining timing gaps. Co-Authored-By: Claude Opus 4.6 --- routes/listening_post.py | 37 ++++++++++++++++++++++--------- static/js/modes/listening-post.js | 28 ++++++++++++++++++----- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 8c48402..cb43aec 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1310,20 +1310,35 @@ def start_audio() -> Response: _stop_waterfall_internal() time.sleep(0.2) - # Release waterfall device claim if the WebSocket waterfall is still - # holding it. The JS client sends a stop command and closes the - # WebSocket before requesting audio, but the backend handler may not - # have finished its cleanup yet. - device_status = app_module.get_sdr_device_status() - if device_status.get(device) == 'waterfall': - app_module.release_sdr_device(device) - time.sleep(0.3) - - # Claim device for listening audio + # Claim device for listening audio. The WebSocket waterfall handler + # may still be tearing down its IQ capture process (thread join + + # safe_terminate can take several seconds), so we retry with back-off + # to give the USB device time to be fully released. if listening_active_device is None or listening_active_device != device: if listening_active_device is not None: app_module.release_sdr_device(listening_active_device) - error = app_module.claim_sdr_device(device, 'listening') + listening_active_device = None + + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + # Force-release a stale waterfall registry entry on each + # attempt — the WebSocket handler may not have finished + # cleanup yet. + device_status = app_module.get_sdr_device_status() + if device_status.get(device) == 'waterfall': + app_module.release_sdr_device(device) + + error = app_module.claim_sdr_device(device, 'listening') + if not error: + break + if attempt < max_claim_attempts - 1: + logger.debug( + f"Device claim attempt {attempt + 1}/{max_claim_attempts} " + f"failed, retrying in 0.5s: {error}" + ) + time.sleep(0.5) + if error: return jsonify({ 'status': 'error', diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 4273b23..98c7339 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -3943,11 +3943,31 @@ async function stopWaterfall() { // WebSocket path if (waterfallUseWebSocket && waterfallWebSocket) { + const ws = waterfallWebSocket; try { - if (waterfallWebSocket.readyState === WebSocket.OPEN) { - waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' })); + if (ws.readyState === WebSocket.OPEN) { + // Wait for server to confirm stop (it terminates the IQ + // process and releases the USB device before responding). + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 4000); + const prevHandler = ws.onmessage; + ws.onmessage = (event) => { + if (typeof event.data === 'string') { + try { + const msg = JSON.parse(event.data); + if (msg.status === 'stopped') { + clearTimeout(timeout); + resolve(); + return; + } + } catch (_) {} + } + if (prevHandler) prevHandler(event); + }; + ws.send(JSON.stringify({ cmd: 'stop' })); + }); } - waterfallWebSocket.close(); + ws.close(); } catch (e) { console.error('[WATERFALL] WebSocket stop error:', e); } @@ -3958,8 +3978,6 @@ async function stopWaterfall() { if (typeof releaseDevice === 'function') { releaseDevice('waterfall'); } - // Allow backend WebSocket handler to finish cleanup and release SDR - await new Promise(resolve => setTimeout(resolve, 300)); return; }