From cd78c392e9c2df001d4bdd00a8ae8c2a8223ba4d Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:26:18 -0700 Subject: [PATCH] Bound /api/coverage parameters and run the ITM model off the event loop (#172) * Update CONTRIBUTING.md * Update CONTRIBUTING.md * Bound /api/coverage parameters and run the ITM model off the event loop The coverage endpoint took radius_km, step_km and the other model inputs straight from the query string with only a float() conversion. The ITM loop advances by step_km until it passes radius_km, so step_km=0 (or a negative value) never ends and radius_km=inf never ends either; a very small step with a large radius means millions of model calls. All of this ran synchronously inside the request handler, so one such request stalls every other request the server is handling. - Reject non-finite values and anything outside a sane range for each parameter (400 with a message saying the allowed range). - Cap radius_km / step_km at 4000 steps. - Run compute_perimeter / compute_coverage in a worker thread so the event loop keeps serving other requests while the model runs. - Make the loops themselves refuse a non-positive step or non-finite radius, so a future caller cannot reintroduce the hang, and shrink the result cache (64 entries) since bounded inputs make large results possible. Co-Authored-By: Claude Mythos 5.1 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/radio/coverage.py | 13 +++++++++---- meshview/web_api/api.py | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) 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),