mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
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:
2 files changed
+43
-7
No files matched your search
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user