Skip to content

security(api): bound public analytics response caches - #430

Merged
dborup merged 3 commits into
masterfrom
codex/issue-370-analytics-cache-bounds
Oct 13, 2026
Merged

dborup merged 3 commits into
masterfrom
codex/issue-370-analytics-cache-bounds

Conversation

@dborup-agent

Copy link
Copy Markdown
Collaborator

Relates to #370

Summary

Public analytics requests could create unbounded in-memory cache entries under caller-controlled keys. The map[string]*cachedResult pool in cmd/server/store.go (and the maps that share it) only had TTL expiry — expiry does not cap how many distinct keys are retained inside one TTL window, and absolute ?from=&to= ranges are not a closed alias set like ?window=, so a client could mint an unlimited number of distinct keys. Region/area filters also reached expensive analytics work with no validation.

This PR gives every affected cache a fixed entry ceiling (expired entries evicted first, then a deterministic single victim; a refresh of an existing key never evicts another live entry) and rejects overlong filters and unknown areas before any cache/index/graph work. Response schemas and valid-filter results are unchanged. No new map[string]interface{}; cmd/server stays read-only (no DB writes); scripts/check-xss-sinks.sh --diff origin/master is clean; fork-guards unchanged (9 in deploy.yml, 1 in release-fast-path.yml).

Reuses the existing eviction shape from channelListCache.put, evictReachLocked (#358) and setNodeRegionEntry rather than adding a parallel mechanism. Recheck note: written against current master (4a5ec5e2), after #373/#394/#402 (XSS), #385 (request limits) and #358 (Reach) merged.

Inventory — every cmd/server cache keyed by filter / time-window input

Cache File Key (user input) Before After
rfCache (+ relay-airtime share) store.go region|area|window TTL only ceiling 256
topoCache store.go region|area|window TTL only ceiling 256
hashCache store.go region|area TTL only ceiling 256
collisionCache store.go region|area TTL only ceiling 256
chanCache store.go region|area|window TTL only ceiling 256
distCache store.go region|area TTL only ceiling 256
subpathCache store.go region|minLen|maxLen|limit|window TTL only ceiling 256 + maxLen clamp
wardrivingStatsCache routes.go window|channel (free-text channel) TTL only ceiling 64 + channel length check
inspectCache path_inspect.go prefixes|observer|since|until` expired-sweep only ceiling 256
regionObsCache store.go region 30s wholesale reset only ceiling 256

Already bounded by construction (documented, no change): scopeStatsCache / hopDepthCache key on a 3-value window whitelist; areaNodeCache only ever stores configured-area keys; hashSizeInfoCache / relayStatsCache / repeaterRelayCache / repeaterUsefulCache key on internal node pubkeys, not HTTP params. The persistent neighbor graph is a single atomic snapshot, not per-filter.

Mechanism

  • cmd/server/analytics_cache_bounds.go (new): putCachedResultLocked / evictCachedResultLocked for the shared cachedResult pool; evictStatsCacheLocked (generic, for the routes.go per-window stats cache); evictInspectCacheLocked; evictRegionObsLocked; analyticsFilterError + the rejectAnalyticsFilters handler guard.
  • Eviction rule everywhere: no-op on refresh or below the ceiling → drop expired → if still full, evict the single entry nearest expiry, ties broken by the smallest key (deterministic regardless of Go map iteration order). regionObsCache has no per-entry clock, so its deterministic victim is the smallest key.
  • Validation: region/area/channel longer than 128 bytes → 400; a non-empty area not in cfg.Areas → 400 — both before any store work. ?maxLen= on the subpath endpoints is clamped to 32 (second key-cardinality vector; the ceiling still backstops it). Empty region/area (global) and legitimate configured selections are untouched — the UI only ever sends configured areas and short channels.

Perf note (AGENTS rule 0)

go test -bench on the shared helper (darwin/arm64, go1.26), ns/op:

Path ns/op note
putCachedResultLocked, cache pinned full, cold miss ~3,740 worst case: full O(ceiling=256) victim scan per new key
putCachedResultLocked, refresh (existing key) ~49 guard returns before any scan
plain map[key]=… (pre-PR, growing) ~241 baseline

So the eviction adds a bounded ~3.5 µs to a cold-miss insertion only when the cache is pinned at the ceiling — negligible against the millisecond+ analytics compute each miss already pays, and nothing extra on cache hits or refreshes. No unbounded per-request work is added to insertion.

Tests (red before → green after) + mutants

New cmd/server/analytics_cache_bounds_test.go:

Acceptance Test(s) Mutant(s) shown red
cachedResult pool bounded + refresh never evicts TestPutCachedResultLocked_BoundsEntries, _RefreshDoesNotEvict, TestGetAnalyticsRF_CacheBounded (wiring, red before wiring: 768→256), TestGetAnalyticsRF_ConcurrentBounded (-race) >=→<= ceiling off-by-one; drop refresh guard
expired-first + deterministic victim _ExpiredFirst, _DeterministicVictim, _TieBreakSmallestKey tie-break k < victim→k > victim
invalid filters stop before work; valid pass TestAnalyticsFilterError, _NilAreasRejectsArea, TestHandleAnalyticsRF_FilterRejection, TestHandleAnalyticsSubpathsBulk_RegionRejection, TestHandleWardrivingStats_RejectsOverlongChannel length >→>=; drop unknown-area check
wardriving / inspect / regionObs bounded TestEvictStatsCacheLocked_Bounds, TestEvictInspectCacheLocked_Bounds, TestEvictRegionObsLocked_Bounds stats ceiling off-by-one; inspect expired-sweep inverted; regionObs victim direction

8 mutants total, each shown red then reverted.

Verification run

  • go test ./... (cmd/server) — pass (full package, ~43s); new tests also pass under -race.
  • sh test-all.sh — 238/238 files pass. node test-frontend-helpers.js — 713/713.
  • scripts/check-xss-sinks.sh --diff origin/master — clean (no public/** changes).
  • Affected analytics endpoints driven live against a locally-built server on the migrated e2e-fixture.db: valid (region/from-to/global) → 200, overlong region/area/channel → 400, unknown area → 400, across rf/topology/channels/distance/hash-sizes/hash-collisions/subpaths-bulk/wardriving.
  • Browser E2E test-issue-205-analytics-subtab-deeplinks, test-issue-236-analytics-sorting, test-issue-1657-analytics-channels-group-sprites — pass against that server.

Left for the owner

  • Ceiling values (256 for the analytics/response caches, 64 for wardriving) and the 128-byte filter / 32-hop maxLen limits are chosen to sit far above any legitimate UI selection. If a deployment configures many regions/areas and legitimately exceeds these, they are the knobs to revisit — no deployment or config change is made here.
  • Unknown-area requests now return 400 instead of silently serving an empty/global result. The UI only offers configured areas, so this is not expected to affect clients, but it is a visible contract change for any direct API caller that relied on the old silent fallback.

Give each public analytics response cache keyed by caller-controlled
filter/time-window input a fixed entry ceiling (expired-first, then a
deterministic single victim; a refresh never evicts another live key),
and reject overlong region/area/channel filters and unknown areas before
any cache/index/graph work. Reuses the channelListCache/evictReachLocked
eviction shape. Response schemas and valid-filter results unchanged.

Relates to #370

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent1 PR#430 — head 2e42d4b

Dom: REQUEST CHANGES

Independent, read-only review. The author was interrupted and posted no report, so the PR description is the only basis and every claim below was re-checked here.

Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code.

The mechanism is sound. Valid requests are unchanged, and the bound holds live: 700 distinct rf?from= windows take the analytics cache from 10 to 710 entries on master but cap it at 263 (256 rf + 7 others) on this PR, with no latency change [T]. The blockers are test coverage against the issue's acceptance text, one handler the issue names that is neither fixed nor deferred, and the map[string]interface{} rule.

Findings

# Sev Finding Evidence
1 Blocking Wiring is tested for rfCache only. The issue requires tests that "exercise each affected cache family with more distinct keys than the ceiling". Five of the ten write sites can be reverted to the old unbounded write with the full cmd/server suite still green: topoCache and chanCache back to a plain m[k]=…, and the removal of the evictStatsCacheLocked (wardriving), evictInspectCacheLocked (path-inspect) and evictRegionObsLocked calls. Helper unit tests do not cover the call sites. Suggest one table-driven wiring test that drives each Get… wrapper (rf/topo/chan/dist/hash/collision/subpath plus relay-airtime-share) past the ceiling, plus handler-level tests for wardriving, inspect and regionObs. [T] mutants A, B, C, D, I survived
2 Blocking Handler rejection is tested on 3 of 9 guarded handlers, and none of the tests proves "before expensive work". Removing the guard from handleAnalyticsTopology survives the suite. TestHandleAnalyticsRF_FilterRejection runs with store == nil, so it can only show the status code; it cannot show that compute was skipped. The issue asks for proof that invalid filters "stop before expensive work". A call counter or a store whose compute path fails the test would show that. [T] mutant G survived; [K] test setup
3 Blocking /api/analytics/neighbor-graph?region= is not guarded and not deferred. The issue names the neighbor graph and asks for rejection "before cache/index/graph work". A 129-byte region still returns 200 and runs computeNeighborGraphResponse, a full graph build plus region observer resolution. The PR inventory only covers the cache side ("single atomic snapshot"). Either add rejectAnalyticsFilters there with a test, or move it to "Left for the owner" with a reason. [T] live 200; [K] neighbor_api.go:245
4 Blocking (rule) map[string]interface{} count goes up by one: 729 → 730 non-test occurrences in cmd/server, from the new putCachedResultLocked(…, result map[string]interface{}, …) parameter. The PR body says "No new map[string]interface{}". AGENTS.md requires this count to decrease monotonically. A trivial fix is to take the existing *cachedResult (e.g. putCachedResultLocked(m, key, &cachedResult{data: result, expiresAt: …})), or to call evictCachedResultLocked and keep the original literal at each site. [T] git grep -c on base vs merged
5 Medium The ?maxLen= clamp to 32 is untested and silently changes behavior. Removing the clamp survives the suite. The firmware allows longer paths (MAX_PATH_SIZE 64 in firmware/src/MeshCore.h, so >32 hops is possible with 1-byte hashes), and a direct API call with maxLen>32 now gets different results. The UI only uses groups up to 5-8, so it is unaffected. The subpathCache ceiling already bounds cardinality, so the clamp looks unnecessary. Either drop it (YAGNI) or test it and list it as a behavior change under "Left for the owner". Right now it appears there only as a "knob". [T] mutant E survived; [K] firmware, public/analytics.js:2114
6 Low The deterministic victim in evictStatsCacheLocked is untested, although TestEvictStatsCacheLocked_Bounds's doc comment says it covers "the deterministic smallest-key victim on a tie". Flipping the tie-break (k < victim → k > victim) survives. The test should assert which key goes, or the doc comment should stop claiming it. [T] mutant F survived
7 Nit The wantSubstring field in TestAnalyticsFilterError is set but never asserted, so the per-case message is not checked. [K]
8 Nit / owner The 128-byte region cap is about 32 three-letter codes. region-filter.js sends the comma-joined selection and only collapses to "all" when every code is selected. On a deployment with roughly 33+ IATA codes, a user who selects most of them would get a 400 on every analytics tab, and the issue says not to reject legitimate region selections. The PR's "Left for the owner" mentions many regions in general terms. Please state the concrete limit and the UI failure mode there, or validate the number of codes instead of the byte length. [K] public/region-filter.js:58-80; [A] not reproduced on real data

Answers to the review points

Issue items, one by one

  • Inventory of caches keyed by user input.
    • Complete for per-filter caches [K]. I checked every map[...] cache field in cmd/server:
      • rf, topo, hash, collision, chan, dist and subpath are bounded. relay-airtime-share writes into rfCache under a relay-airtime-share|<window> key, so it shares the 256 ceiling.
      • wardrivingStats, inspect and regionObs are bounded.
      • scopeStats and hopDepth accept only a 3-value window whitelist (1h/24h/7d, else 400).
      • areaNodeCache is reached only after a cfg.Areas[areaKey] hit.
      • channelListCache, reach and nodeAdvertRouteCache were already capped.
      • relayTokenResolver.cache lives only for one request.
    • The neighbor graph has no per-filter cache, but see finding 3 for its validation gap.
  • Fixed ceiling, expired entries first, deterministic victim, refresh never evicts.
    • All four helpers do this [K]. A refresh returns before any eviction in evictCachedResultLocked, evictStatsCacheLocked and evictRegionObsLocked. evictInspectCacheLocked drops only expired entries on refresh, which is the pre-PR behavior, and never drops a live one.
    • Expiry-first and the nearest-expiry/smallest-key victim are unit-tested for cachedResult and inspect [T]. The stats victim is not (finding 6).
    • regionObsCache has no per-entry clock (the whole map resets every 30s), so smallest-key is a reasonable deterministic choice [K].
    • The regionObs eviction runs under regionObsMu, before the DB call [K].
  • Reject overlong filters and unknown areas before work, without rejecting legitimate selections.
    • Done for rf, topology, channels, distance, hash-sizes, hash-collisions, subpaths, subpaths-bulk and wardriving [T, live 400s]. Missing for the neighbor graph (finding 3).
    • Area matching is exact, the same as resolveAreaNodes (cfg.Areas[areaKey]), so no configured area is rejected [K].
    • A stale area kept in localStorage is cleared by area-filter.js once /api/config/areas loads [K].
    • Region byte cap: see finding 8.
  • Schemas and valid results unchanged.
    • I compared 19 valid queries between a master server and a PR server on the same prepared fixture: rf (global, region, window, from), topology, channels, distance, hash-sizes, hash-collisions, subpaths (including maxLen=40), subpaths-bulk, wardriving, relay-airtime-share, neighbor-graph and hop-depth.
    • Status codes match for all 19. Bodies match apart from two differences that are not caused by this PR: rawHops order, which is nondeterministic in the compute, and time-decay score drift in neighbor-graph edges, which differs by about 1e-5 because the two servers computed at different times. Neighbor-graph nodes and edges and hop-depth are identical as sets [T].
  • Acceptance: tests for each cache family past the ceiling. Not met (finding 1).
  • Acceptance: handler tests stop invalid filters before work. Partly met (finding 2).
  • Acceptance: race tests, full suite, no unbounded per-request insertion work. Met:
    • focused -race passes;
    • the full suite passes, and so does the full-package -race run with CI's -timeout 20m (see the tests below);
    • insertion cost is O(ceiling) and only applies to a new key when the cache is full [T].

Red before / green after [T]: with only the new helper and test files dropped onto origin/master (no call-site changes), these five tests fail:

  • TestHandleAnalyticsRF_FilterRejection
  • TestHandleAnalyticsSubpathsBulk_RegionRejection
  • TestHandleWardrivingStats_RejectsOverlongChannel
  • TestGetAnalyticsRF_CacheBounded
  • TestGetAnalyticsRF_ConcurrentBounded

All pass on the PR tree. The helper-only unit tests have no meaningful red-before state, because the helpers are new.

Scope. The PR touches only cmd/server. There are no public/**, config or ingestor changes. The two contract changes, unknown area → 400 and the maxLen clamp, are new behavior. The first is in "Left for the owner"; the second is listed only as a limit (finding 5).

Invariants [T]:

  • cmd/server gains no write paths and makes no DB writes.
  • bash scripts/check-xss-sinks.sh --diff origin/master is clean (no public/** changes). Note that the script needs bash; under sh (dash) it fails with a syntax error.
  • No hardcoded colors.
  • The fork guards are unchanged: 9 in deploy.yml and 1 in release-fast-path.yml.
  • There are no closing keywords in the body or the commit, and closingIssuesReferences is empty.
  • It is a single commit, authored and committed by dborup <kontakt@meshview.dk>.
  • The map[string]interface{} rule is violated (finding 4).

CI [T]: all jobs on run 38390692820 for this head passed. "Go Build & Test", "Playwright E2E Tests", "Build & Publish Docker Image" and "Validate Docker Image" are green. Deploy, publish and release were skipped, as expected for a PR. Neither of the known flaky tests (#256, #267) was involved.

Perf [T]: go test -bench (i7-1260P, 3 runs, ns/op):

Benchmark ns/op
putCachedResultLocked, full-cache miss ~4,950–5,220
putCachedResultLocked, refresh ~84
plain map assign (baseline) ~265

These are consistent with the PR's darwin numbers. Live, 700 distinct-window rf misses had p50 0.51 ms and p95 0.73 ms on the PR, against 0.53 ms and 0.78 ms on master.

Tests and mutants

All runs are on the merged tree git merge-tree --write-tree origin/master 2e42d4b… → 4cd48a81 (base origin/master 779f2b93), extracted with git archive into scratch [T].

  • cmd/server go test ./...: ok (37s).
  • Focused -race (all new tests plus the path-inspect, regionObs, wardriving and subpath tests): ok.
  • Full-package -race with CI's -timeout 20m: ok (702s, 0 DATA RACE warnings). A first run without -timeout hit Go's default 600s limit with no race reported.
  • cmd/ingestor: not touched by the PR.
  • sh test-all.sh: 242/242.
  • node test-frontend-helpers.js: 713/713.
  • E2E against a local Go server on e2e-fixture.db, prepared as in CI (freshen, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed SQL, corescope-migrate, seeds 2073, 199 and 245). The server was stopped via its port's pid.
    • test-issue-205-analytics-subtab-deeplinks-e2e.js: 15/15
    • test-issue-236-analytics-sorting-e2e.js: pass
    • test-issue-1657-analytics-channels-group-sprites-e2e.js: 5/5
    • test-analytics-fluid-charts.js: 9/9
    • test-e2e-playwright.js stops at "Version info lives on Perf dashboard" (the #navStats wait times out). It fails identically against a master server in the same environment, so it is environmental and not caused by this PR. CI's Playwright job is green.
  • Live endpoint matrix (overlong/unknown → 400, valid → 200, neighbor-graph → 200), the master-vs-PR body comparison, and the live cache-size measurement above.

Mutants (full cmd/server package per mutant):

Mutant Result
A: topoCache write back to a plain m[k]=… survived
B: drop the evictStatsCacheLocked call (wardriving) survived
C: drop the evictInspectCacheLocked call survived
D: drop the evictRegionObsLocked call survived
E: drop the maxLen clamp survived
F: stats tie-break < → > survived
G: drop the guard in handleAnalyticsTopology survived
H: skip the expired sweep in evictCachedResultLocked killed (_ExpiredFirst)
I: chanCache write back to a plain m[k]=… survived
J: drop the unknown-area check killed (3 tests)

On its first run, mutant A was reported "killed" by TestHandleNodePaths_PrefixCollision_1352 and _FallbackPreconfirmed_1352. On a rerun it survived, and both tests pass 5/5 in isolation on the unmutated tree. That looks like a load-related flake in those tests, unrelated to this PR [T/A].

Not verified

  • Browser validation of the analytics UI with a multi-region selection longer than 128 bytes (finding 8). The fixture has too few regions to reproduce it.
  • Memory and heap impact on a real deployment. I measured only entry counts and latency on the fixture.
  • No staging or prod access, by design.
  • The darwin benchmark numbers in the PR body. I re-measured on linux/amd64 instead.

dborup and others added 2 commits October 13, 2026 15:33
…he put (#430 review)

Round 2 of the #370 analytics cache bounds, addressing review findings 1-7:

- F1: table-driven wiring test drives all ten cachedResult write sites
  (rf, relay-airtime-share, topology, channels, distance, hash-sizes,
  hash-collisions, subpaths, subpaths-window, subpaths-bulk) plus the
  wardriving, path-inspect and regionObs sites past their ceilings, and
  asserts a refill at the ceiling evicts no other live key.
- F2: handler test for all nine guarded handlers (plus neighbor-graph)
  snapshots every side effect of the expensive path and asserts a
  rejected request leaves it unchanged; a positive control proves the
  probe sees each handler's work.
- F3: /api/analytics/neighbor-graph rejects an overlong region before
  the graph compute.
- F4: putCachedResultLocked takes *cachedResult, so cmd/server non-test
  map[string]interface{} stays at the master count (730 -> 729).
- F5: maxLen ceiling raised from 32 to 63, the firmware's maximum hop
  count (path_length bits 0-5), so results are unchanged; tested.
- F6: test pins the stats-cache smallest-key tie-break.
- F7: TestAnalyticsFilterError now asserts wantSubstring.

Relates to #370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#430 runde-2 — head 05d598d

Review feedback addressed (commit 05d598d3)

Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code.

origin/master (779f2b93) was merged in with a merge commit (fdf2fa4d), then one fix commit on top. There was no rebase, amend or force-push. Both commits are authored and committed by dborup <kontakt@meshview.dk> [T].

  1. F1 (blocking): wiring is now tested for every write site. I added analytics_cache_wiring_test.go.

    • TestAnalyticsCacheWiring_EachFamilyBounded is table-driven. It drives the real Get… wrapper for all ten cachedResult write sites with ceiling + 32 distinct keys: rf, relay-airtime-share, topology, channels, distance, hash-sizes, hash-collisions, subpaths, subpaths-window and subpaths-bulk. It asserts the cache never exceeds the ceiling and ends exactly at it. It then expires the newest key, requests it again at a full cache, and asserts no other live key was evicted and the key was stored again [T].
    • Three handler- or store-level tests cover the remaining sites the same way:
      • TestWardrivingStatsCacheWiring_Bounded sends real /api/analytics/wardriving requests with distinct channels.
      • TestPathInspectCacheWiring_Bounded sends real handlePathInspect POSTs with distinct observer contexts.
      • TestRegionObsCacheWiring_Bounded calls the real resolveRegionObservers against the seeded DB.
    • All 13 per-site mutants are killed, plus a refresh mutant (see the table below). The reviewer's mutants A, B, C, D and I are among them [T].
  2. F2 (blocking): rejection is proven to happen before the expensive work. TestAnalyticsHandlers_RejectBeforeExpensiveWork covers all nine guarded handlers plus neighbor-graph, through the real router on the seeded test server.

    • Each rejected request (overlong region, overlong area, unknown area, or overlong channel for wardriving) must return 400 and leave the work probe unchanged.
    • The probe records:
      • analytics cache hits and misses, which every Get… wrapper increments;
      • the regionObsCache size;
      • whether the lazy distance-index build was triggered;
      • the wardrivingStatsCache size;
      • a call counter on computeNeighborGraphResponseFn.
    • Each handler also has a positive control: a legitimate request must move the probe. This proves the probe can see that handler's work [T].
    • The reviewer's mutant G (guard removed from handleAnalyticsTopology) is now red. So are the guard-removal mutants for the other eight handlers [T].
  3. F3 (blocking): /api/analytics/neighbor-graph?region= is guarded. handleNeighborGraph now calls rejectAnalyticsFilters(w, region, "") before either cache shortcut and before computeNeighborGraphResponseDispatch.

    • The new test was red before the fix ("want 400, got 200") and is green after [T].
    • Live on a local server: a 129-byte region returns 400; region=SJC and the default shape return 200 [T].
  4. F4 (blocking, rule): map[string]interface{} count is back to the master value. putCachedResultLocked(m, key, entry *cachedResult) now takes the existing named type, and the call sites build &cachedResult{data: …, expiresAt: …}. Counts from git grep -c 'map\[string\]interface{}' over cmd/server/*.go, excluding *_test.go [T]:

    Tree Count
    origin/master 779f2b93 729
    Old head 2e42d4b9 730
    New head 05d598d3 729
  5. F5: the ?maxLen= clamp is now set from the firmware. I agree that 32 silently changed results. Per the firmware, path_length bits 0-5 hold the hop count (docs/packet_format.md; Packet::getPathHashCount() = path_len & 63 in src/Packet.h), so no path has more than 63 hops [K].

    • subpathMaxLenCeiling is therefore 63. maxLen only appears in hopLen > maxLen and l <= maxLen && l <= len(named), so a clamp at 63 cannot change any result. It only collapses maxLen ≥ 63 onto one cache key [K].
    • TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling injects a 63-hop subpath into spIndex. It asserts that maxLen=63, 64 and 1000 all return that subpath, and that the three requests share one subpathCache key.
    • With the old clamp (32) the test is red (0 subpaths), and with the clamp removed it is red too (3 keys) [T].
    • I kept the clamp rather than dropping it, because without it maxLen remains a free cache-key dimension. It is behavior-neutral now.
  6. F6: the stats-cache tie-break is now tested. TestEvictStatsCacheLocked_TieBreakSmallestKey stores four entries at the same instant and asserts the smallest key is the victim. The reviewer's mutant F (k < victim → k > victim) is now red, so the doc comment's claim now holds [T].

  7. F7: wantSubstring is asserted. TestAnalyticsFilterError now checks that the message contains wantSubstring. A message mutant ("region filter too long" → "filter too long") is red [T].

  8. F8 (nit/owner): not changed, per this round's scope (findings 1–5 plus review nits). It is listed under "Left for the owner" below.

Tests

All runs are on head 05d598d3 (master merged in) [T]:

  • cmd/server go test -count=1 ./...: ok (45s).

  • Focused -race, covering all new and old security(api): bound public analytics response caches #370 tests plus the path-inspect and neighbor-graph tests: ok, with no DATA RACE.

  • cmd/ingestor go test ./...: ok (untouched; run because of the master merge).

  • sh test-all.sh: 242/242.

  • node test-frontend-helpers.js: 713/713.

  • E2E against a local Go server on port 13917, using e2e-fixture.db prepared as in CI (freshen-fixture, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed SQL extracted from deploy.yml, corescope-migrate, seeds 2073, 199 and 245):

    Test Result
    test-issue-205-analytics-subtab-deeplinks-e2e.js 15/15
    test-issue-236-analytics-sorting-e2e.js PASS
    test-issue-1657-analytics-channels-group-sprites-e2e.js 5/5
    test-analytics-fluid-charts.js 9/9 (needs CHROMIUM_PATH on macOS; the first run failed only on the hardcoded /usr/bin/chromium)
    test-issue-1758-ng-filter-rerenders-e2e.js PASS
    test-show-neighbors.js 4/4

    The server was stopped via the pid of its port.

  • Live endpoint check on that server: overlong rf region → 400, unknown topology area → 400, overlong neighbor-graph region → 400. Neighbor-graph with region=SJC and the default shape, subpaths?maxLen=1000, and rf with region=SJC all → 200.

Mutants

Each mutant was run against the full cmd/server package (go test -count=1 ./...) in a scratch copy. All 28 were killed; none survived [T].

Mutant Killed by
F1-rf: plain m[k]= …EachFamilyBounded/rf, TestGetAnalyticsRF_CacheBounded, …_ConcurrentBounded
F1-relay …EachFamilyBounded/relay-airtime-share
F1-topo (review A) …EachFamilyBounded/topology
F1-chan (review I) …EachFamilyBounded/channels
F1-dist …EachFamilyBounded/distance
F1-hash …EachFamilyBounded/hash-sizes
F1-collision …EachFamilyBounded/hash-collisions
F1-subpath-window …EachFamilyBounded/subpaths-window
F1-subpath …EachFamilyBounded/subpaths
F1-subpath-bulk …EachFamilyBounded/subpaths-bulk
F1-wardriving: drop evictStatsCacheLocked (review B) TestWardrivingStatsCacheWiring_Bounded
F1-inspect: drop evictInspectCacheLocked (review C) TestPathInspectCacheWiring_Bounded
F1-regionobs: drop evictRegionObsLocked (review D) TestRegionObsCacheWiring_Bounded
F1-refresh: evict the smallest key whenever full, even on refresh …EachFamilyBounded/* (all families)
F2-rf … F2-subpaths-bulk (8 guard removals, including review G = topology) TestAnalyticsHandlers_RejectBeforeExpensiveWork/<handler>
F2-wardriving: drop the channel length guard …RejectBeforeExpensiveWork/wardriving, TestHandleWardrivingStats_RejectsOverlongChannel
F3-neighbor-graph: drop the new guard …RejectBeforeExpensiveWork/neighbor-graph
F5-clamp32 (old value) TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling
F5-noclamp (review E) TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling
F6-tiebreak < → > (review F) TestEvictStatsCacheLocked_TieBreakSmallestKey
F7-message TestAnalyticsFilterError/overlong_region

Red before / green after [T]:

  • F3 and F5 were red against the unfixed tree: "want 400, got 200" and "want the 63-hop subpath, got 0".
  • The F1 and F2 wiring already existed, so their red state is shown by the mutants above.
  • F4 is a signature change, verified by the count table above.

CI

Run 38411135474 for head 05d598d3 [T]:

Job Result
Go Build & Test success
Playwright E2E Tests success
Validate Docker Image success
Build & Publish Docker Image success
Release Artifacts, Deploy Staging, Publish Badges & Summary skipped (expected for a PR)

The known flaky tests (#271, #301) did not appear, and no job was re-run.

Left for the owner

  • F8, region byte cap. The 128-byte region limit is about 32 three-letter IATA codes, joined with commas. public/region-filter.js collapses the selection to "all" only when every code is selected. On a deployment with roughly 33 or more codes, selecting most but not all of them sends a region value over 128 bytes, and every analytics tab then gets a 400 [K/A]. I did not reproduce this on real data, because the fixture has too few regions. The options are to raise the cap or to validate the number of codes instead of the byte length. This round changes neither.
  • subpaths-bulk group maxLen (2-200:5) is not clamped. It is part of the subpathCache key, but the 256-entry ceiling bounds it, and it has the same result-neutral semantics as ?maxLen=. I left it unchanged to keep this round within scope [K].
  • Unchanged from round 1: an unknown area now returns 400 instead of an empty or global result. This is a contract change.

Not verified

  • Browser behavior with a real multi-region selection over 128 bytes (F8).
  • Heap impact on a real deployment.
  • No staging or prod access, by design.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent1 PR#430 — head 05d598d

Dom: APPROVE med nits

Independent, read-only re-review after round 2. It checks the "Rapport — CS-MacBook PR#430 runde-2" against my round-1 review (findings 1–8 on 2e42d4b9).

Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code.

All four round-1 blockers are resolved:

  • Every cache family now has a wiring test past the ceiling.
  • All nine guarded handlers plus neighbor-graph are proven to reject before work.
  • Neighbor-graph is guarded.
  • The map[string]interface{} count is back to the master value.

Every mutant I ran is now killed, including the seven that survived in round 1 (A, B, C, D, E, F, G, I). Only nits are left. None of them blocks a merge.

Findings

# Sev Finding Evidence
R1 Nit The refresh check in TestAnalyticsCacheWiring_EachFamilyBounded cannot see the refresh guard. It refreshes an expired key at a full cache. Without the exists guard in evictCachedResultLocked, the expired sweep removes that key first, so the cache drops below the ceiling and no live key is evicted. The test would still pass. The live-key refresh guard is covered only by the unit test TestPutCachedResultLocked_RefreshDoesNotEvict, which does kill that mutant (L below). The wrappers only write on a miss, so in production a live-key refresh only happens when two requests miss the same key at once. Coverage is adequate. The wiring test's comment ("no other live key may go") just claims slightly more than it can show. [A/K]; [T] mutant L killed by the unit test only
R2 Nit (pre-existing, not this PR) TestHandleAnalyticsSubpathDetailWithStore and TestHandleNodePaths_* flake under CPU load with 503 index loading. They send a request without waiting for the async index build. Under equal synthetic CPU load the subpath-detail test failed 11/30 and 11/30 on origin/master, and 14/30 and 15/30 on the merged tree. On an idle machine it failed 0/30 twice on both. The PR does not touch index_ready_1008.go, the subpath-detail handler or Load(). This is a candidate for a separate flaky-test issue, not a change in this PR. [T] alternating runs, same load
R3 Nit (report) The report says the known flaky tests "(#271, #301) did not appear". The known flakies tracked for this repo are #256 (Hash Stats-sort) and #267 (backfill write-hold). Neither appears in CI run 38411135474 either, so the conclusion holds and only the reference is off. [T] CI jobs
R4 Owner (unchanged) The F8 region byte cap (128 bytes ≈ 32 IATA codes) and the unknown-area → 400 contract change are correctly listed under "Left for the owner". This is still not reproduced on real data. [K]
R5 Correction to my round 1 In round-1 finding 5 I said the old clamp at 32 "silently changes behavior". That is only partly true. The unwindowed spIndex indexes subpaths of at most 8 hops (addTxToSubpathIndexFull, min(8, len(hops))), so maxLen > 8 has never changed that path. Only computeAnalyticsSubpathsWindowed (l <= maxLen && l <= len(named)) could see longer subpaths. The new ceiling of 63 is result-neutral on both paths, so the conclusion stands. [K] store.go

Answers to the review points

  • All ten cache families are tested over the ceiling. Yes [T/K].
    • TestAnalyticsCacheWiring_EachFamilyBounded drives the real Get… wrappers for rf, relay-airtime-share, topology, channels, distance, hash-sizes, hash-collisions, subpaths, subpaths-window and subpaths-bulk with ceiling + 32 keys.
    • Handler- and store-level tests cover wardriving, path-inspect and regionObs.
    • Red before: I put only analytics_cache_bounds.go and the two test files onto origin/master. All ten family subtests and all three handler/store wiring tests were red. On the merged tree they are green.
    • Mutants that turn single write sites back into a plain m[k]=… are killed for topo (A), chan (I) and bulk (P).
  • The nine handlers reject before the expensive work. Yes [T].
    • TestAnalyticsHandlers_RejectBeforeExpensiveWork probes cache hits and misses, the regionObs size, the lazy distance build, the wardriving cache and the neighbor-graph compute counter. Each handler has a positive control that proves the probe sees its work.
    • My mutant O moves the work in front of the guard in handleAnalyticsDistance, which is the ordering bug the issue cares about. It is killed by …/distance.
    • Guard removal for topology (G) and neighbor-graph (K) is killed. The test is red on master for all ten handlers.
  • Neighbor-graph is protected. Yes [K/T].
    • rejectAnalyticsFilters(w, region, "") sits right after the param parse, before both atomic-snapshot shortcuts and before computeNeighborGraphResponseDispatch.
    • Live on a local server: a 129-byte region returns 400. region=SJC and the default shape return 200.
  • The map[string]interface{} count has not risen. It is 729 non-test occurrences in cmd/server on origin/master, on head and on both merged trees, and the diff adds or removes no occurrence [T]. putCachedResultLocked now takes *cachedResult.
  • ?maxLen is handled.
    • The ceiling of 63 matches the firmware: Packet::getPathHashCount() = path_len & 63 (src/Packet.h:80) and docs/packet_format.md ("Bits 0-5 … 0-63"). The server decoder does the same (pathByte & 0x3F).
    • maxLen only appears as an upper bound in both compute paths, so the clamp is result-neutral and only collapses cache keys [K].
    • Dropping the clamp (E) is killed by TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling. Live, subpaths?maxLen=1000 returns 200 [T].
  • Round-1 findings 6 and 7 (stats tie-break, wantSubstring): both fixed. Mutant F is killed by TestEvictStatsCacheLocked_TieBreakSmallestKey, and mutant N is killed by TestAnalyticsFilterError/region_at_limit [T].
  • No behavior change beyond the purpose. Round 2 touches only cmd/server: neighbor_api.go (+3 lines), the typed putCachedResultLocked call sites, the clamp constant and tests [K].
    • There are no public/**, config, ingestor or workflow changes against master.
    • Valid requests return 200 on the live matrix [T].
  • Invariants:
    • cmd/server stays read-only: no new write paths or DB writes [K].
    • bash scripts/check-xss-sinks.sh --diff origin/master is clean on a synthetic merge commit in a scratch clone ("no public changes") [T].
    • No hardcoded colors (no CSS or JS changes) [K].
    • The fork guards are 9 in deploy.yml and 1 in release-fast-path.yml on master, head and the merged tree [T].
    • There are no closing keywords in the commits or the body, and closingIssuesReferences is [] [T].
    • All three commits are authored and committed by dborup <kontakt@meshview.dk> [T].
  • CI [T]: run 38411135474 (attempt 1, head 05d598d3):

Tests and mutants

origin/master moved from d34b228a to 8665b0c7 (#435, nodes and frontend only) during the review, so I ran the suites on both merged trees. Both were built with git merge-tree --write-tree origin/master 05d598d3… and extracted with git archive into scratch. Both merges are clean [T].

  • Merged tree 788485a3 (base d34b228a):

    • cmd/server go test -count=1 ./...: ok (39s).
    • cmd/ingestor go test ./...: ok (238s).
    • Focused -race (cache, wiring, rejection, path-inspect, regionObs, wardriving, neighbor-graph and subpath tests): ok, with 0 DATA RACE warnings.
    • sh test-all.sh: 242/242.
    • node test-frontend-helpers.js: 713/713.
  • Merged tree f0c1b23e (base 8665b0c7):

    • cmd/server go test -count=1 ./...: ok (41s).
    • sh test-all.sh: 242/242.
    • node test-frontend-helpers.js: 713/713.
    • The interface-map count is still 729.
  • E2E against a local Go server on the merged tree. e2e-fixture.db was prepared as in CI: freshen-fixture.sh, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed SQL extracted from deploy.yml, corescope-migrate, then seeds 2073, 199 and 245. The server was stopped via the pid on its port.

    Test Result
    test-issue-205-analytics-subtab-deeplinks-e2e.js PASS
    test-issue-236-analytics-sorting-e2e.js PASS
    test-issue-1657-analytics-channels-group-sprites-e2e.js 5/5
    test-analytics-fluid-charts.js 9/9
    test-issue-1758-ng-filter-rerenders-e2e.js PASS
    test-show-neighbors.js 4/4
    test-e2e-playwright.js stops at "Version info lives on Perf dashboard" (waitForFunction timeout) after 6 passes. It fails identically against a origin/master server on the same fixture, so it is environmental. CI's Playwright job is green.
  • Live endpoint matrix [T]:

    • 400 for an overlong rf region, unknown topology area, overlong neighbor-graph region, overlong wardriving channel and overlong subpaths-bulk region.
    • 200 for rf region=SJC, neighbor-graph region=SJC and its default shape, subpaths?maxLen=1000, hash-sizes and channels.
  • Mutants: 14, each against the full cmd/server package in a scratch copy of the merged tree. All were killed and none survived [T].

    Mutant Killed by
    A topoCache back to plain m[k]= (survived in round 1) …EachFamilyBounded/topology
    B drop evictStatsCacheLocked (wardriving; survived in round 1) TestWardrivingStatsCacheWiring_Bounded
    C drop evictInspectCacheLocked (survived in round 1) TestPathInspectCacheWiring_Bounded
    D drop evictRegionObsLocked (survived in round 1) TestRegionObsCacheWiring_Bounded
    E drop the maxLen clamp (survived in round 1) TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling
    F stats tie-break < → > (survived in round 1) TestEvictStatsCacheLocked_TieBreakSmallestKey
    G drop the topology guard (survived in round 1) …RejectBeforeExpensiveWork/topology
    I chanCache back to plain m[k]= (survived in round 1) …EachFamilyBounded/channels
    J drop the unknown-area check TestAnalyticsFilterError/unknown_area, …RejectBeforeExpensiveWork/{channels,distance,hash-*,…}
    K drop the neighbor-graph guard (new) …RejectBeforeExpensiveWork/neighbor-graph
    L drop the exists refresh guard in evictCachedResultLocked (new) TestPutCachedResultLocked_RefreshDoesNotEvict (unit test only, see R1)
    N length check > → >= (new) TestAnalyticsFilterError/region_at_limit
    O distance compute moved before the guard (new) …RejectBeforeExpensiveWork/distance
    P subpaths-bulk back to plain m[k]= (new) …EachFamilyBounded/subpaths-bulk

    Under mutant load, A and G also tripped TestHandleNodePaths_HopName_CanonicalPathShowsTarget_1144 and TestHandleAnalyticsSubpathDetailWithStore with 503 index loading. That is the pre-existing load flake in R2. It is not counted as evidence for either mutant.

Not verified

  • A browser check of the analytics UI with a real multi-region selection over 128 bytes (R4 / F8). The fixture has too few regions.
  • Heap impact on a real deployment, and the darwin benchmark numbers. I did not re-run the benchmarks this round; the round-2 change to the helper signature does not touch the eviction loop.
  • E2E on the second merged tree (f0c1b23e). The new master commits touch only public/nodes.js and style.css plus node tests, not analytics.
  • No staging or prod access, by design.

git ls-remote reported head 05d598d3 both before and after the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants