fix(analytics): treat the distance index's 202 as a transient building state - #133
Merged
Merged
Conversation
/api/analytics/distance answers 202 {status:"building"} while its lazy
index builds. test-issue-120-distance-building.js loads the real
public/app.js (fake fetch) and public/analytics.js (stubbed api(), fake
clock) and checks:
- api(): a 202 is not cached, a 200 is, a valid Retry-After reaches the
caller without changing the JSON body;
- renderDistanceTab: a building state instead of an error, the retry
delay (header, body, 5s fallback, clamp 1..30s), 202 -> 200, destroy
and tab switch, re-entry, an older response arriving after a newer
one, at most one retry timer, and the error state for a failure.
On master 10 of 13 fail: the 202 is cached, the tab shows "Cannot read
properties of undefined (reading 'totalHops')", nothing retries, and a
late response overwrites a newer render or a left view. The three
controls pass. The only production change here is a test hook,
window._analyticsRenderDistanceTab, next to the other tab hooks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
…g state (#120) api() no longer caches a 202 Accepted body: the lazy distance index answers 202 {status:"building"} until it is built, and caching it kept the placeholder for the whole analytics TTL. A valid Retry-After on a 202 is passed to the caller as a non-enumerable retryAfterSeconds property, so the JSON body is unchanged. 200s cache as before and the 503 warm-up retry is untouched. renderDistanceTab shows "Building the distance index…" instead of reading data.summary from the placeholder, and retries after the Retry-After header, else the body's retry_after_seconds, else 5s, clamped to 1..30s. A generation counter, bumped by every render, by switching away from the tab and by destroy(), makes an older response or retry inert and keeps at most one retry timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Owner
Author
Independent review of
|
| Criterion | Result |
|---|---|
Never insert a 202 into _apiCache |
Met [F]: code, test, and mutant M1 is caught |
| Clear building state instead of a throw | Met [F]: the browser shows "Building the distance index… Retrying in Ns." (role="status"). Master shows Cannot read properties of undefined (reading 'totalHops') |
| Honour a valid Retry-After, bounded fallback otherwise | Met [F]: in the browser, RA=2 retries every ~2 s; no header and abc give 5 s; 999 is clamped to 30 s. Mutants M5 and M6 are caught |
| At most one retry chain per active Distance view | Met [F]: test, mutant M8 is caught, and the browser's retry interval is even |
| Navigation cancels timers and responses; an older generation cannot overwrite | Met [F] in code and in the browser. After a tab switch and after a route change to #/nodes, there are 0 distance requests over 12 s and nothing is written. Mutants M2, M3 and M4 are caught. The catch branch is not tested (finding 1) |
| A later 200 replaces the building state and is cached normally | Met [F]: the browser shows building, then "1,808 Total Hops Analyzed…"; test "a 200 is still cached" |
| 503 warm-up is intact | Met [F]: test-1659-analytics-warmup.js 7/0 on master and the head; the 503 loop is untouched in the diff |
| No console errors or unhandled rejections | Met [F]: 0 page errors and 0 unhandledrejection in every browser run |
Test-first and mutants
- [F]
test-issue-120-distance-building.js: commit A gives 3 passed, 10 failed. Master plus the test file gives 2/11, because the hook is missing. The head gives 13/0. - [F] Eight mutants, seven caught:
| Mutant | Result |
|---|---|
M1 api() caches 202 again |
1 fail |
M2 no generation check after await |
3 fail |
| M3 tab switch does not leave Distance | 1 fail |
M4 destroy() does not leave Distance |
1 fail |
| M5 header ignored (body only) | 2 fail |
| M6 no 30 s clamp | 1 fail |
| M8 a new render does not supersede the previous one | 2 fail |
M7 no generation guard in catch |
survives (finding 1) |
Suites run locally (master vs head, same result on both)
- [F]
test-1659-analytics-warmup.js7/0 ·test-app-api-inflight-cleanup-rejection.jsPASS ·test-analytics-distance-view-path.js8/0 ·test-analytics-table-ids-unique.js4/0 ·test-analytics-wardriving-tab.js18/0 ·test-analytics-foreign-traffic-tab.js12/0 ·test-analytics-areas-tab.js19/0 ·test-packet-filter.js92/0 ·test-aging.js19/0. - [F]
test-analytics-channels-integration.js23/1 andtest-frontend-helpers.js705/2. The failing test names are the same on master and the head, so the failures are pre-existing.
Browser
Run with Playwright/Chromium against a local Go server on a freshened and migrated test-fixtures/e2e-fixture.db.
- [F] Fresh server, real backend, 1440×900: the head gets
202 (Retry-After: 5), then200, and shows the data. Master gets only202and shows the error text, with no retry. - [F] Slow build, simulated with route interception (202 for 9 s, then the real backend):
RA=2: retries at about 2.5 / 4.5 / 6.6 / 8.6 s, data at 10.6 s.- No header: 5 s.
abc: 5 s.999: "Retrying in 30s".- 375×900 with
RA=2: building, then data.
- [F] Tab switch (Distance → RF) while building: the RF content stays put for 12 s, there are 0 new distance requests, and a new chain works on re-entry. Route change to
#/nodes: 0 requests and no Distance content.
Performance and security
- [F] Not a hot path. The change is one branch per
api()response and at most one timer. - [F] The building HTML is static text plus an integer. The error path still uses
esc(e.message). There are no new unbounded data structures and no Go, API or DB changes, somode=roand themap[string]interface{}rule are not affected. - The hard-coded
#ff6b6bin the error path already exists on master and is not touched.
Not verified
- I did not run
scripts/check-xss-sinks.sh --diff origin/masteror eslint myself [T]. - The full
test-e2e-playwright.js. Locally it stops (fail-fast) on "Version info lives on Perf dashboard", identically on master and the head, so it is my environment. I rely on CI's green Playwright job. - Staging, and a really long index build on a production-sized database. The long build was only simulated with route interception. The PR's "Not verified" list mentions staging. It says only 1440×900 was driven, which I have supplemented with 375 px.
This was referenced Sep 29, 2026
dborup
marked this pull request as ready for review
September 29, 2026 10:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Relates to #120
Summary
The Analytics → Distance tab no longer breaks while the lazy distance index is building.
/api/analytics/distanceanswers202 AcceptedwithRetry-After: 5and{"status":"building",…}until the index is ready (cmd/server/routes.go,handleAnalyticsDistance). This caused two problems:api()cached that 202 body for the analytics TTL, so the placeholder was served back for minutes.renderDistanceTab()readdata.summary.totalHopsfrom it and showed "Failed to load distance analytics: Cannot read properties of undefined (reading 'totalHops')".Now the tab shows a building state, retries on the server's interval, and renders the data when the 200 arrives. The server side is unchanged.
Upstream reference (read only):
Kpa-clawbot/CoreScope#2051.Plan and design
Autonomous run, so the plan is written here instead of waiting for approval (AGENTS.md rule 5).
810df7d9). The new test loads the realpublic/app.jsandpublic/analytics.jsin a vm, with a fake fetch, a stubbedapi()and a fake clock. The only production change in this commit is a test hook,window._analyticsRenderDistanceTab, next to the other tab hooks.ab35ccf9).public/app.js,api()_apiCache. A 200 caches as before, and the 503 warm-up retry loop is untouched.Retry-After(a positive integer of seconds), the value is attached to the returned object as a non-enumerableretryAfterSeconds. The JSON body as served is unchanged, and no other caller sees a new field.public/analytics.js,renderDistanceTab(){status:"building"}body withoutsummaryrenders "Building the distance index…" (role="status") with the retry delay. It no longer readsdata.summary.Retry-Afterheader is used first, then the body'sretry_after_seconds, then 5 s. The delay is clamped to 1–30 s._distanceGenis bumped by every render, by switching away from the tab (tab-bar handler) and bydestroy(). A response or retry from an older generation does nothing. Every bump also clears the timer, so at most one retry chain exists per active Distance view.Where the fork differs from upstream
retry_after_seconds. The issue asks forRetry-After, so the header is honoured and the body is only a fallback.Config and customizer (AGENTS.md rule 8). The 5 s fallback and the 1–30 s clamp are hard-coded. They only pace a one-off retry, so they are not customizer material.
Acceptance criteria
_apiCache_leaveDistanceTab()), and an older 202 arriving after a newer 200test-1659-analytics-warmup.js7/0, same as masterTests
node test-issue-120-distance-building.js810df7d9(master behaviour plus the test hook): 3 passed, 10 failed. The failures:Cannot read properties of undefined (reading 'totalHops');Mutation checks (each run against the fixed code, then restored)
awaitapi()caches 202 againRelated suites, same results on master
85bfee49and this branchtest-1659-analytics-warmup.jstest-app-api-inflight-cleanup-rejection.jstest-analytics-distance-view-path.jstest-analytics-table-ids-unique.jstest-analytics-wardriving-tab.jstest-analytics-foreign-traffic-tab.jstest-analytics-areas-tab.jstest-analytics-channels-integration.jstest-frontend-helpers.jstest-packet-filter.jstest-aging.jsThe failures in the channels-integration and frontend-helpers rows are pre-existing, and neither file is in the CI unit step.
Static checks
scripts/check-xss-sinks.sh --diff origin/master: exit 0.eslint@8 --quiet public/*.js: 0 errors.Browser (Playwright with Chromium, a fresh local server on the migrated and freshened
test-fixtures/e2e-fixture.db, 1440×900, opening#/analytics?tab=distance)202, 200202onlyLeaflet's CDN is blocked in this sandbox. That
L is not definederror is ignored in both runs.Performance. Not a hot path. There is one extra branch per
api()response and one timer at most while building.Not verified
Overlap with my other open PRs
.github/workflows/deploy.yml(unit step) andtest-all.sh.🤖 Generated with Claude Code
https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Generated by Claude Code