fix: ADS-B device release leak and startup performance

Move adsb_active_device/sdr_type assignment to immediately after
claim_sdr_device so stop_adsb() can always release the device, even
during startup. Sync sdr_type_str after SDRType fallback to prevent
claim/release key mismatch. Clear active device on all error paths.

Replace blind 3s sleep for dump1090 readiness with port-polling loop
(100ms intervals, 3s max). Replace subprocess.run() in rtl_test probe
with Popen + select-based early termination on success/error detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-27 11:13:44 +00:00
parent 121ea56519
commit fdb3af34ff
2 changed files with 134 additions and 86 deletions
+21 -3
View File
@@ -758,6 +758,7 @@ def start_adsb():
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
@@ -796,6 +797,10 @@ def start_adsb():
'message': error 'message': error
}), 409 }), 409
# Track claimed device immediately so stop_adsb() can always release it
adsb_active_device = device
adsb_active_sdr_type = sdr_type_str
# Create device object and build command via abstraction layer # Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -822,11 +827,24 @@ def start_adsb():
) )
write_dump1090_pid(app_module.adsb_process.pid) write_dump1090_pid(app_module.adsb_process.pid)
time.sleep(DUMP1090_START_WAIT) # Poll for dump1090 readiness instead of blind sleep
dump1090_ready = False
poll_interval = 0.1
elapsed = 0.0
while elapsed < DUMP1090_START_WAIT:
if app_module.adsb_process.poll() is not None:
break # Process exited early — handle below
if check_dump1090_service():
dump1090_ready = True
break
time.sleep(poll_interval)
elapsed += poll_interval
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -872,8 +890,6 @@ def start_adsb():
}) })
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device is being used
adsb_active_sdr_type = sdr_type_str
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start() thread.start()
@@ -894,6 +910,8 @@ def start_adsb():
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
+113 -83
View File
@@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
import shutil import shutil
import subprocess import subprocess
import time import time
from typing import Optional from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run # Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode. # hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = [] _hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0 _hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0 _HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool: def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream.""" """Return True when probing HackRF would interfere with an active stream."""
try: try:
from utils.subghz import get_subghz_manager from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'} return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception: except Exception:
return False return False
def _check_tool(name: str) -> bool: def _check_tool(name: str) -> bool:
@@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '') current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run( result = subprocess.run(
['rtl_test', '-t'], ['rtl_test', '-t'],
capture_output=True, capture_output=True,
text=True, text=True,
encoding='utf-8', encoding='utf-8',
errors='replace', errors='replace',
timeout=5, timeout=5,
env=env env=env
) )
output = result.stderr + result.stdout output = result.stderr + result.stdout
# Parse device info from rtl_test output # Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
# Require a non-empty serial to avoid matching malformed lines like "SN:". # Require a non-empty serial to avoid matching malformed lines like "SN:".
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
@@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
line = line.strip() line = line.strip()
match = re.match(device_pattern, line) match = re.match(device_pattern, line)
if match: if match:
devices.append(SDRDevice( devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR, sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)), index=int(match.group(1)),
name=match.group(2).strip().rstrip(','), name=match.group(2).strip().rstrip(','),
serial=match.group(3), serial=match.group(3),
driver='rtlsdr', driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES capabilities=RTLSDRCommandBuilder.CAPABILITIES
)) ))
# Fallback: if we found devices but couldn't parse details # Fallback: if we found devices but couldn't parse details
if not devices: if not devices:
@@ -314,29 +314,29 @@ def _add_soapy_device(
)) ))
def detect_hackrf_devices() -> list[SDRDevice]: def detect_hackrf_devices() -> list[SDRDevice]:
""" """
Detect HackRF devices using native hackrf_info tool. Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available. Fallback for when SoapySDR is not available.
""" """
global _hackrf_cache, _hackrf_cache_ts global _hackrf_cache, _hackrf_cache_ts
now = time.time() now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls. # While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream. # Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked(): if _hackrf_probe_blocked():
return list(_hackrf_cache) return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS: if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache) return list(_hackrf_cache)
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'): if not _check_tool('hackrf_info'):
_hackrf_cache = devices _hackrf_cache = devices
_hackrf_cache_ts = now _hackrf_cache_ts = now
return devices return devices
try: try:
result = subprocess.run( result = subprocess.run(
@@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES capabilities=HackRFCommandBuilder.CAPABILITIES
)) ))
except Exception as e: except Exception as e:
logger.debug(f"HackRF detection error: {e}") logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices) _hackrf_cache = list(devices)
_hackrf_cache_ts = now _hackrf_cache_ts = now
return devices return devices
def probe_rtlsdr_device(device_index: int) -> str | None: def probe_rtlsdr_device(device_index: int) -> str | None:
@@ -413,16 +413,50 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
lib_paths + [current_ld] if current_ld else lib_paths lib_paths + [current_ld] if current_ld else lib_paths
) )
result = subprocess.run( # Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running
# its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen(
['rtl_test', '-d', str(device_index), '-t'], ['rtl_test', '-d', str(device_index), '-t'],
capture_output=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, text=True,
timeout=3,
env=env, env=env,
) )
output = result.stderr + result.stdout
if 'usb_claim_interface' in output or 'Failed to open' in output: import select
error_found = False
deadline = time.monotonic() + 3.0
try:
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
# Wait for stderr output with timeout
ready, _, _ = select.select(
[proc.stderr], [], [], min(remaining, 0.1)
)
if ready:
line = proc.stderr.readline()
if not line:
break # EOF — process closed stderr
if 'usb_claim_interface' in line or 'Failed to open' in line:
error_found = True
break
if 'Found' in line and 'device' in line.lower():
# Device opened successfully — no need to wait longer
break
if proc.poll() is not None:
break # Process exited
finally:
try:
proc.kill()
except OSError:
pass
proc.wait()
if error_found:
logger.warning( logger.warning(
f"RTL-SDR device {device_index} USB probe failed: " f"RTL-SDR device {device_index} USB probe failed: "
f"device busy or unavailable" f"device busy or unavailable"
@@ -434,10 +468,6 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
f'or try a different device.' f'or try a different device.'
) )
except subprocess.TimeoutExpired:
# rtl_test opened the device successfully and is running the
# test — that means the device *is* available.
pass
except Exception as e: except Exception as e:
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}") logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")