mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40: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
|
_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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -169,32 +169,32 @@
|
|||||||
.catch(err => alert('Error: ' + err.message));
|
.catch(err => alert('Error: ' + err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopVdl2Mode() {
|
function stopVdl2Mode() {
|
||||||
fetch('/vdl2/stop', {
|
fetch('/vdl2/stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source: 'vdl2_mode' })
|
body: JSON.stringify({ source: 'vdl2_mode' })
|
||||||
})
|
})
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
const data = text ? JSON.parse(text) : {};
|
const data = text ? JSON.parse(text) : {};
|
||||||
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
||||||
throw new Error(data.message || `HTTP ${r.status}`);
|
throw new Error(data.message || `HTTP ${r.status}`);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||||
if (vdl2MainEventSource) {
|
if (vdl2MainEventSource) {
|
||||||
vdl2MainEventSource.close();
|
vdl2MainEventSource.close();
|
||||||
vdl2MainEventSource = null;
|
vdl2MainEventSource = null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startVdl2MainSSE() {
|
function startVdl2MainSSE() {
|
||||||
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user