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 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 07:26:18 -07:00
1 parent 96b49bb893
commit cd78c392e9
2 files changed
+43 -7

No files matched your search

+9 -4
View File
@@ -42,7 +42,12 @@ def destination_point(
return math.degrees(lat2), math.degrees(lon2) 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( def compute_coverage(
lat: float, lat: float,
lon: float, lon: float,
@@ -54,7 +59,7 @@ def compute_coverage(
step_km: float, step_km: float,
reliability: float, reliability: float,
) -> list[tuple[float, float, float]]: ) -> list[tuple[float, float, float]]:
if not ITM_AVAILABLE: if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km):
return [] return []
points = [] points = []
@@ -90,7 +95,7 @@ def compute_coverage(
return points return points
@lru_cache(maxsize=512) @lru_cache(maxsize=64)
def compute_perimeter( def compute_perimeter(
lat: float, lat: float,
lon: float, lon: float,
@@ -103,7 +108,7 @@ def compute_perimeter(
reliability: float, reliability: float,
threshold_dbm: float, threshold_dbm: float,
) -> list[tuple[float, float]]: ) -> list[tuple[float, float]]:
if not ITM_AVAILABLE: if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km):
return [] return []
perimeter = [] perimeter = []
+34 -3
View File
@@ -1,5 +1,6 @@
"""API endpoints for MeshView.""" """API endpoints for MeshView."""
import asyncio
import datetime import datetime
import json import json
import logging import logging
@@ -154,6 +155,20 @@ def _bearing_deg(lat1, lon1, lat2, lon2):
OBSERVED_MAX_DISTANCE_KM = 50.0 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): def init_api_module(packet_class, seq_regex, lang_dir):
"""Initialize API module with dependencies from main web module.""" """Initialize API module with dependencies from main web module."""
@@ -1227,12 +1242,19 @@ async def api_coverage(request):
if value is None: if value is None:
return default return default
try: try:
return float(value) parsed = float(value)
except ValueError as exc: except ValueError as exc:
raise web.HTTPBadRequest( raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be a number"}), text=json.dumps({"error": f"{name} must be a number"}),
content_type="application/json", content_type="application/json",
) from exc ) 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: try:
freq_mhz = parse_float("freq_mhz", 907.0) freq_mhz = parse_float("freq_mhz", 907.0)
@@ -1246,6 +1268,11 @@ async def api_coverage(request):
except web.HTTPBadRequest as exc: except web.HTTPBadRequest as exc:
raise 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) node = await store.get_node(node_id)
if not node or not node.last_lat or not node.last_long: 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) 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 lon = node.last_long * 1e-7
mode = request.query.get("mode", "perimeter") 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": if mode == "perimeter":
perimeter = compute_perimeter( perimeter = await asyncio.to_thread(
compute_perimeter,
lat=round(lat, 7), lat=round(lat, 7),
lon=round(lon, 7), lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3), freq_mhz=round(freq_mhz, 3),
@@ -1271,7 +1301,8 @@ async def api_coverage(request):
{"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter} {"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter}
) )
points = compute_coverage( points = await asyncio.to_thread(
compute_coverage,
lat=round(lat, 7), lat=round(lat, 7),
lon=round(lon, 7), lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3), freq_mhz=round(freq_mhz, 3),