diff --git a/meshview/radio/coverage.py b/meshview/radio/coverage.py index d14ed8c..524a3ac 100644 --- a/meshview/radio/coverage.py +++ b/meshview/radio/coverage.py @@ -42,7 +42,12 @@ def destination_point( return math.degrees(lat2), math.degrees(lon2) -@lru_cache(maxsize=512) +def _loop_is_safe(radius_km: float, step_km: float) -> bool: + """A non-positive step or a non-finite radius would make the distance loop endless.""" + return math.isfinite(radius_km) and math.isfinite(step_km) and step_km > 0 + + +@lru_cache(maxsize=64) def compute_coverage( lat: float, lon: float, @@ -54,7 +59,7 @@ def compute_coverage( step_km: float, reliability: float, ) -> list[tuple[float, float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] points = [] @@ -90,7 +95,7 @@ def compute_coverage( return points -@lru_cache(maxsize=512) +@lru_cache(maxsize=64) def compute_perimeter( lat: float, lon: float, @@ -103,7 +108,7 @@ def compute_perimeter( reliability: float, threshold_dbm: float, ) -> list[tuple[float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] perimeter = [] diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index f811ce4..29bbd09 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -1,5 +1,6 @@ """API endpoints for MeshView.""" +import asyncio import datetime import json import logging @@ -154,6 +155,20 @@ def _bearing_deg(lat1, lon1, lat2, lon2): OBSERVED_MAX_DISTANCE_KM = 50.0 +# Bounds for /api/coverage query parameters. The ITM loop runs once per (distance step x +# bearing), so the request size must be capped or a single call can hold the CPU for minutes. +COVERAGE_LIMITS = { + "freq_mhz": (1.0, 10_000.0), + "tx_dbm": (-50.0, 60.0), + "tx_height_m": (0.0, 1_000.0), + "rx_height_m": (0.0, 1_000.0), + "radius_km": (1.0, 200.0), + "step_km": (0.05, 50.0), + "reliability": (0.01, 0.99), + "threshold_dbm": (-200.0, 0.0), +} +COVERAGE_MAX_STEPS = 4_000 # radius_km / step_km + def init_api_module(packet_class, seq_regex, lang_dir): """Initialize API module with dependencies from main web module.""" @@ -1227,12 +1242,19 @@ async def api_coverage(request): if value is None: return default try: - return float(value) + parsed = float(value) except ValueError as exc: raise web.HTTPBadRequest( text=json.dumps({"error": f"{name} must be a number"}), content_type="application/json", ) from exc + low, high = COVERAGE_LIMITS[name] + if not math.isfinite(parsed) or not (low <= parsed <= high): + raise web.HTTPBadRequest( + text=json.dumps({"error": f"{name} must be between {low} and {high}"}), + content_type="application/json", + ) + return parsed try: freq_mhz = parse_float("freq_mhz", 907.0) @@ -1246,6 +1268,11 @@ async def api_coverage(request): except web.HTTPBadRequest as exc: raise exc + if radius_km / step_km > COVERAGE_MAX_STEPS: + return web.json_response( + {"error": f"radius_km / step_km must not exceed {COVERAGE_MAX_STEPS}"}, status=400 + ) + node = await store.get_node(node_id) if not node or not node.last_lat or not node.last_long: return web.json_response({"error": "Node not found or missing location"}, status=404) @@ -1254,8 +1281,11 @@ async def api_coverage(request): lon = node.last_long * 1e-7 mode = request.query.get("mode", "perimeter") + # The ITM model is pure CPU work; keep it off the event loop so other requests + # are still served while it runs. if mode == "perimeter": - perimeter = compute_perimeter( + perimeter = await asyncio.to_thread( + compute_perimeter, lat=round(lat, 7), lon=round(lon, 7), freq_mhz=round(freq_mhz, 3), @@ -1271,7 +1301,8 @@ async def api_coverage(request): {"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter} ) - points = compute_coverage( + points = await asyncio.to_thread( + compute_coverage, lat=round(lat, 7), lon=round(lon, 7), freq_mhz=round(freq_mhz, 3),