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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-09 13:58:42 +00:00
parent a2a7ac8fec
commit a8f2912b90
2 changed files with 49 additions and 16 deletions
+26 -11
View File
@@ -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',
+23 -5
View File
@@ -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;
}