mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
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:
@@ -364,6 +364,7 @@ def parse_sbs_stream(service_addr):
|
||||
_sbs_error_logged = False
|
||||
|
||||
while adsb_using_service:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
@@ -586,7 +587,6 @@ def parse_sbs_stream(service_addr):
|
||||
continue
|
||||
|
||||
flush_pending_updates(force=True)
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
@@ -594,6 +594,12 @@ def parse_sbs_stream(service_addr):
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
_sbs_error_logged = True
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
adsb_connected = False
|
||||
logger.info("SBS stream parser stopped")
|
||||
|
||||
@@ -80,6 +80,7 @@ def parse_ais_stream(port: int):
|
||||
_ais_error_logged = True
|
||||
|
||||
while ais_running:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||
@@ -152,7 +153,6 @@ def parse_ais_stream(port: int):
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
ais_connected = False
|
||||
except OSError as e:
|
||||
ais_connected = False
|
||||
@@ -160,6 +160,12 @@ def parse_ais_stream(port: int):
|
||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||
_ais_error_logged = True
|
||||
time.sleep(AIS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ais_connected = False
|
||||
logger.info("AIS stream parser stopped")
|
||||
|
||||
@@ -28,6 +28,17 @@ from utils.validation import validate_latitude, validate_longitude, validate_hou
|
||||
|
||||
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)
|
||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
@@ -178,7 +189,7 @@ def satellite_dashboard():
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
@@ -219,7 +230,7 @@ def predict_passes():
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
@@ -332,7 +343,7 @@ def predict_passes():
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
|
||||
@@ -361,7 +372,7 @@ def get_satellite_position():
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
@@ -4158,6 +4158,10 @@
|
||||
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
||||
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.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]) {
|
||||
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||||
|
||||
@@ -254,7 +254,9 @@
|
||||
|
||||
acarsMainEventSource.onerror = function() {
|
||||
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();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -164,7 +164,9 @@
|
||||
|
||||
aisEventSource.onerror = function() {
|
||||
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();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -220,7 +220,9 @@
|
||||
|
||||
radiosondeEventSource.onerror = function() {
|
||||
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();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -169,32 +169,32 @@
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopVdl2Mode() {
|
||||
fetch('/vdl2/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'vdl2_mode' })
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
||||
throw new Error(data.message || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(() => {
|
||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||
if (vdl2MainEventSource) {
|
||||
vdl2MainEventSource.close();
|
||||
vdl2MainEventSource = null;
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
||||
}
|
||||
function stopVdl2Mode() {
|
||||
fetch('/vdl2/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'vdl2_mode' })
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
||||
throw new Error(data.message || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(() => {
|
||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||
if (vdl2MainEventSource) {
|
||||
vdl2MainEventSource.close();
|
||||
vdl2MainEventSource = null;
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
||||
}
|
||||
|
||||
function startVdl2MainSSE() {
|
||||
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
||||
@@ -212,7 +212,9 @@
|
||||
|
||||
vdl2MainEventSource.onerror = function() {
|
||||
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();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -34,6 +34,16 @@ def unregister_process(process: subprocess.Popen) -> None:
|
||||
_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:
|
||||
"""Clean up all registered processes on exit."""
|
||||
logger.info("Cleaning up all spawned processes...")
|
||||
@@ -47,6 +57,7 @@ def cleanup_all_processes() -> None:
|
||||
process.kill()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up process: {e}")
|
||||
close_process_pipes(process)
|
||||
_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:
|
||||
# Already dead
|
||||
close_process_pipes(process)
|
||||
unregister_process(process)
|
||||
return False
|
||||
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=timeout)
|
||||
close_process_pipes(process)
|
||||
unregister_process(process)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -80,10 +93,12 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
close_process_pipes(process)
|
||||
unregister_process(process)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Error terminating process: {e}")
|
||||
close_process_pipes(process)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ from utils.weather_sat import WEATHER_SATELLITES
|
||||
|
||||
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:
|
||||
"""Return an ISO8601 UTC timestamp with a single timezone designator."""
|
||||
@@ -48,7 +59,7 @@ def predict_passes(
|
||||
ImportError: If skyfield is not installed.
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -61,7 +72,7 @@ def predict_passes(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
||||
|
||||
Reference in New Issue
Block a user