From 7c02f8ff07904b9630bda3335b034bc0bcd3d7eb Mon Sep 17 00:00:00 2001 From: jehanazad Date: Wed, 26 Aug 2026 21:50:56 +0000 Subject: [PATCH 1/3] Give the sim-ingest writer test real transponder hexes Since b9fd39b sim_push_adsb_positions rejects any hex that fails is_transponder_hex(), so the readable "sim{name}" fixtures are dropped before they reach state.adsb_aircraft and the test dies on the KeyError instead of testing the writer. The tcp/fp writers don't filter, so only this class needs real-looking 24-bit addresses. Main has been red on exactly these three cases since the #253 merge. Co-Authored-By: Claude Opus 5 --- backend/tests/test_adsb_seed_backend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_adsb_seed_backend.py b/backend/tests/test_adsb_seed_backend.py index 724bc153..d0c5dd39 100644 --- a/backend/tests/test_adsb_seed_backend.py +++ b/backend/tests/test_adsb_seed_backend.py @@ -241,6 +241,10 @@ def _assert_derived(hexn: str, raw: dict) -> None: ("ground", {"alt_baro": "ground", "gs": None, "track": "unknown"}), ] +# The sim push endpoint rejects non-transponder hexes (id_utils.is_transponder_hex), +# so its writer fixtures must look like real 24-bit addresses, not readable labels. +_SIM_CASE_HEX = {name: f"a1b2c{i}" for i, (name, _kin) in enumerate(_WRITE_CASES)} + class TestDerivedFieldsAtWriteTime: """Every path that writes state.adsb_aircraft stores the SI-unit fields @@ -284,7 +288,7 @@ async def test_sim_ingest_writer(self, name, kin): # what is under test, not the gate in front of it. from routes.sim_ingest import sim_push_adsb_positions - hexn = f"sim{name}" + hexn = _SIM_CASE_HEX[name] entry = {"hex": hexn, "lat": 33.9, "lon": -84.6, **kin} await sim_push_adsb_positions(body={"ts_ms": 4242, "aircraft": [entry]}, _key=None) From 5f0b2457671330de1bec91a15097027587362c11 Mon Sep 17 00:00:00 2001 From: jehanazad Date: Wed, 26 Aug 2026 21:39:03 +0000 Subject: [PATCH 2/3] Apply ruff format to the warm-up test The combined `with` fits on one line at the repo's 120-char limit. Caught by CI's pre-commit gate, which I should have run before pushing rather than `ruff check` alone. Co-Authored-By: Claude Opus 5 --- backend/tests/test_region_lookup_warm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tests/test_region_lookup_warm.py b/backend/tests/test_region_lookup_warm.py index 9aa8e207..17a42e08 100644 --- a/backend/tests/test_region_lookup_warm.py +++ b/backend/tests/test_region_lookup_warm.py @@ -59,9 +59,7 @@ def test_a_warm_cache_is_never_re_parsed(self): _clear_cache() region_lookup.warm_borders() - with unittest.mock.patch.object( - region_lookup, "shape", side_effect=AssertionError("re-parsed after warm-up") - ): + with unittest.mock.patch.object(region_lookup, "shape", side_effect=AssertionError("re-parsed after warm-up")): assert region_lookup.classify_region(42.38708028093612, -71.24905416622781) == "us" assert region_lookup.classify_region(48.8566, 2.3522) is None From 47273a6f15f17538db24ed3e40a043b4cc1c9a5d Mon Sep 17 00:00:00 2001 From: jehanazad Date: Wed, 26 Aug 2026 21:51:22 +0000 Subject: [PATCH 3/3] Harden the border-polygon warm-up: atomic publish, guarded startup Two follow-ups from auditing #254: _load_borders() filled _geoms feature-by-feature behind an unlocked fast-path check, so a caller running concurrently with the parse could observe {"us"} only, skip the load, and silently classify a Canadian point as unsupported. Unreachable while every caller sat on the event loop, but the warm-up moved the parse onto a threadpool thread, which removed that structural guarantee. Build the dict locally and publish it in one update instead. The lifespan called warm_borders() unguarded, turning a missing or corrupt geojson into a whole-API boot failure where it used to be a 500 on /api/towers alone - the exact blast radius prime_pipeline_at_startup documents refusing, ten lines up. Swallow and log; classify_region still loads on demand. Co-Authored-By: Claude Opus 5 --- backend/main.py | 10 +++++++++- backend/services/region_lookup.py | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 623bf475..ce0add12 100644 --- a/backend/main.py +++ b/backend/main.py @@ -156,7 +156,15 @@ async def lifespan(app: FastAPI): from services.region_lookup import warm_borders - await run_in_threadpool(warm_borders) + try: + await run_in_threadpool(warm_borders) + except Exception: + # Same policy as prime_pipeline_at_startup above: a missing or corrupt + # borders file costs /api/towers its region detection until the next + # restart (classify_region still loads on demand and surfaces the real + # error there); raising would abort the lifespan and take the whole + # API down with it. + logging.exception("Warming the border polygons failed; region lookup will load on demand") # Restore persisted state before accepting connections restored = restore_snapshot() diff --git a/backend/services/region_lookup.py b/backend/services/region_lookup.py index ed43c124..9a188a8b 100644 --- a/backend/services/region_lookup.py +++ b/backend/services/region_lookup.py @@ -42,11 +42,18 @@ def _load_borders() -> None: return with open(_BORDERS_PATH) as f: data = json.load(f) + loaded: dict[str, BaseGeometry] = {} for feature in data["features"]: admin = feature["properties"].get("ADMIN") source = _ADMIN_TO_SOURCE.get(admin) if source is not None: - _geoms[source] = shape(feature["geometry"]) + loaded[source] = shape(feature["geometry"]) + # Published complete or not at all: the fast path above reads _geoms + # without the lock, so filling it feature-by-feature would let a + # concurrent caller see {"us"} mid-parse, skip the load, and silently + # misclassify a Canadian point as unsupported. dict.update runs under + # one GIL hold, so readers see the border set whole or empty. + _geoms.update(loaded) def warm_borders() -> None: