fix: close leaked file descriptors on mode switch (#169)

SSE EventSource connections for AIS, ACARS, VDL2, and radiosonde were
not closed when switching modes, causing fd exhaustion after repeated
switches. Also fixes socket leaks on exception paths in AIS/ADS-B
stream parsers, closes subprocess pipes in safe_terminate/cleanup, and
caches skyfield timescale at module level to avoid per-request fd churn.

Closes #169

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-02 13:38:21 +00:00
parent ff9961b846
commit 8379f42ec3
10 changed files with 99 additions and 38 deletions

View File

@@ -364,6 +364,7 @@ def parse_sbs_stream(service_addr):
_sbs_error_logged = False _sbs_error_logged = False
while adsb_using_service: while adsb_using_service:
sock = None
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SBS_SOCKET_TIMEOUT) sock.settimeout(SBS_SOCKET_TIMEOUT)
@@ -586,7 +587,6 @@ def parse_sbs_stream(service_addr):
continue continue
flush_pending_updates(force=True) flush_pending_updates(force=True)
sock.close()
adsb_connected = False adsb_connected = False
except OSError as e: except OSError as e:
adsb_connected = False adsb_connected = False
@@ -594,6 +594,12 @@ def parse_sbs_stream(service_addr):
logger.warning(f"SBS connection error: {e}, reconnecting...") logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True _sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY) time.sleep(SBS_RECONNECT_DELAY)
finally:
if sock:
try:
sock.close()
except OSError:
pass
adsb_connected = False adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")

View File

@@ -80,6 +80,7 @@ def parse_ais_stream(port: int):
_ais_error_logged = True _ais_error_logged = True
while ais_running: while ais_running:
sock = None
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT) sock.settimeout(AIS_SOCKET_TIMEOUT)
@@ -152,7 +153,6 @@ def parse_ais_stream(port: int):
except socket.timeout: except socket.timeout:
continue continue
sock.close()
ais_connected = False ais_connected = False
except OSError as e: except OSError as e:
ais_connected = False ais_connected = False
@@ -160,6 +160,12 @@ def parse_ais_stream(port: int):
logger.warning(f"AIS connection error: {e}, reconnecting...") logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True _ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY) time.sleep(AIS_RECONNECT_DELAY)
finally:
if sock:
try:
sock.close()
except OSError:
pass
ais_connected = False ais_connected = False
logger.info("AIS stream parser stopped") logger.info("AIS stream parser stopped")

View File

@@ -28,6 +28,17 @@ from utils.validation import validate_latitude, validate_longitude, validate_hou
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite') satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
_cached_timescale = None
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
_cached_timescale = load.timescale()
return _cached_timescale
# Maximum response size for external requests (1MB) # Maximum response size for external requests (1MB)
MAX_RESPONSE_SIZE = 1024 * 1024 MAX_RESPONSE_SIZE = 1024 * 1024
@@ -178,7 +189,7 @@ def satellite_dashboard():
def predict_passes(): def predict_passes():
"""Calculate satellite passes using skyfield.""" """Calculate satellite passes using skyfield."""
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -219,7 +230,7 @@ def predict_passes():
} }
name_to_norad = {v: k for k, v in norad_to_name.items()} name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = load.timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
t0 = ts.now() t0 = ts.now()
@@ -332,7 +343,7 @@ def predict_passes():
def get_satellite_position(): def get_satellite_position():
"""Get real-time positions of satellites.""" """Get real-time positions of satellites."""
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503 return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
@@ -361,7 +372,7 @@ def get_satellite_position():
else: else:
satellites.append(sat) satellites.append(sat)
ts = load.timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
now = ts.now() now = ts.now()
now_dt = now.utc_datetime() now_dt = now.utc_datetime()

View File

@@ -4158,6 +4158,10 @@
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(), sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(), websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(), spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
}; };
if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) { if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); } try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }

View File

@@ -254,7 +254,9 @@
acarsMainEventSource.onerror = function() { acarsMainEventSource.onerror = function() {
setTimeout(() => { setTimeout(() => {
if (document.getElementById('stopAcarsBtn').style.display === 'block') { const panel = document.getElementById('acarsMode');
if (panel && panel.classList.contains('active') &&
document.getElementById('stopAcarsBtn').style.display === 'block') {
startAcarsMainSSE(); startAcarsMainSSE();
} }
}, 2000); }, 2000);

View File

@@ -164,7 +164,9 @@
aisEventSource.onerror = function() { aisEventSource.onerror = function() {
setTimeout(() => { setTimeout(() => {
if (document.getElementById('stopAisBtn').style.display === 'block') { const panel = document.getElementById('aisMode');
if (panel && panel.classList.contains('active') &&
document.getElementById('stopAisBtn').style.display === 'block') {
startAisSSE(); startAisSSE();
} }
}, 2000); }, 2000);

View File

@@ -220,7 +220,9 @@
radiosondeEventSource.onerror = function() { radiosondeEventSource.onerror = function() {
setTimeout(() => { setTimeout(() => {
if (document.getElementById('stopRadiosondeBtn').style.display === 'block') { const panel = document.getElementById('radiosondeMode');
if (panel && panel.classList.contains('active') &&
document.getElementById('stopRadiosondeBtn').style.display === 'block') {
startRadiosondeSSE(); startRadiosondeSSE();
} }
}, 2000); }, 2000);

View File

@@ -212,7 +212,9 @@
vdl2MainEventSource.onerror = function() { vdl2MainEventSource.onerror = function() {
setTimeout(() => { setTimeout(() => {
if (document.getElementById('stopVdl2Btn').style.display === 'block') { const panel = document.getElementById('vdl2Mode');
if (panel && panel.classList.contains('active') &&
document.getElementById('stopVdl2Btn').style.display === 'block') {
startVdl2MainSSE(); startVdl2MainSSE();
} }
}, 2000); }, 2000);

View File

@@ -34,6 +34,16 @@ def unregister_process(process: subprocess.Popen) -> None:
_spawned_processes.remove(process) _spawned_processes.remove(process)
def close_process_pipes(process: subprocess.Popen) -> None:
"""Close stdin/stdout/stderr pipes on a subprocess to free file descriptors."""
for pipe in (process.stdin, process.stdout, process.stderr):
if pipe:
try:
pipe.close()
except OSError:
pass
def cleanup_all_processes() -> None: def cleanup_all_processes() -> None:
"""Clean up all registered processes on exit.""" """Clean up all registered processes on exit."""
logger.info("Cleaning up all spawned processes...") logger.info("Cleaning up all spawned processes...")
@@ -47,6 +57,7 @@ def cleanup_all_processes() -> None:
process.kill() process.kill()
except Exception as e: except Exception as e:
logger.warning(f"Error cleaning up process: {e}") logger.warning(f"Error cleaning up process: {e}")
close_process_pipes(process)
_spawned_processes.clear() _spawned_processes.clear()
@@ -66,12 +77,14 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
if process.poll() is not None: if process.poll() is not None:
# Already dead # Already dead
close_process_pipes(process)
unregister_process(process) unregister_process(process)
return False return False
try: try:
process.terminate() process.terminate()
process.wait(timeout=timeout) process.wait(timeout=timeout)
close_process_pipes(process)
unregister_process(process) unregister_process(process)
return True return True
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -80,10 +93,12 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
process.wait(timeout=3) process.wait(timeout=3)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
close_process_pipes(process)
unregister_process(process) unregister_process(process)
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Error terminating process: {e}") logger.warning(f"Error terminating process: {e}")
close_process_pipes(process)
return False return False

View File

@@ -13,6 +13,17 @@ from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict') logger = get_logger('intercept.weather_sat_predict')
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
_cached_timescale = None
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
_cached_timescale = load.timescale()
return _cached_timescale
def _format_utc_iso(dt: datetime.datetime) -> str: def _format_utc_iso(dt: datetime.datetime) -> str:
"""Return an ISO8601 UTC timestamp with a single timezone designator.""" """Return an ISO8601 UTC timestamp with a single timezone designator."""
@@ -48,7 +59,7 @@ def predict_passes(
ImportError: If skyfield is not installed. ImportError: If skyfield is not installed.
""" """
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, load, wgs84 from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
@@ -61,7 +72,7 @@ def predict_passes(
except ImportError: except ImportError:
pass pass
ts = load.timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
t0 = ts.now() t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours)) t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))