Fix waterfall crash on zoom by reusing WebSocket and adding USB release retry

Zooming caused "I/Q capture process exited immediately" because the client
closed the WebSocket and opened a new one, racing with the old rtl_sdr
process releasing the USB device. Now zoom/retune sends a start command on
the existing WebSocket, and the server adds a USB release delay plus retry
loop when restarting capture within the same connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-08 14:00:40 +00:00
parent 777b83f6e0
commit cca04918a9
2 changed files with 77 additions and 19 deletions

View File

@@ -132,6 +132,7 @@ def init_waterfall_websocket(app: Flask):
if cmd == 'start':
# Stop any existing capture
was_restarting = iq_process is not None
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
@@ -149,6 +150,9 @@ def init_waterfall_websocket(app: Flask):
send_queue.get_nowait()
except queue.Empty:
break
# Allow USB device to be released by the kernel
if was_restarting:
time.sleep(0.5)
# Parse config
center_freq = float(data.get('center_freq', 100.0))
@@ -212,25 +216,39 @@ def init_waterfall_websocket(app: Flask):
}))
continue
# Spawn I/Q capture process
# Spawn I/Q capture process (retry to handle USB release lag)
max_attempts = 3 if was_restarting else 1
try:
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
for attempt in range(max_attempts):
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
# Brief check that process started
time.sleep(0.2)
if iq_process.poll() is not None:
raise RuntimeError("I/Q capture process exited immediately")
# Brief check that process started
time.sleep(0.3)
if iq_process.poll() is not None:
unregister_process(iq_process)
iq_process = None
if attempt < max_attempts - 1:
logger.info(
f"I/Q process exited immediately, "
f"retrying ({attempt + 1}/{max_attempts})..."
)
time.sleep(0.5)
continue
raise RuntimeError(
"I/Q capture process exited immediately"
)
break # Process started successfully
except Exception as e:
logger.error(f"Failed to start I/Q capture: {e}")
if iq_process:

View File

@@ -3250,6 +3250,26 @@ async function syncWaterfallToFrequency(freq, options = {}) {
if (isDirectListening || waterfallMode === 'audio') return { started: false };
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
// Reuse existing WebSocket to avoid USB device release race
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
waterfallWebSocket.send(JSON.stringify({
cmd: 'start',
center_freq: (sf + ef) / 2,
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
}));
return { started: true };
}
await stopWaterfall();
return await startWaterfall({ silent: silent });
}
@@ -3275,8 +3295,28 @@ async function zoomWaterfall(direction) {
setWaterfallRange(center, newSpan);
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
await stopWaterfall();
await startWaterfall({ silent: true });
// Reuse existing WebSocket to avoid USB device release race
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
waterfallWebSocket.send(JSON.stringify({
cmd: 'start',
center_freq: (sf + ef) / 2,
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
}));
} else {
await stopWaterfall();
await startWaterfall({ silent: true });
}
}
}