mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 04:18:20 +00:00
## Summary Fixes #1659 — analytics cards no longer show the post-restart slice when "All data" is selected. ## Root cause After server restart, `s.recompRF` / `s.recompTopology` / `s.recompChannels` cache the FIRST computation, which is the small in-RAM observations slice (background chunk-loader has not yet backfilled history). The recomputer serves that slice through `GetAnalyticsRFWithWindow`'s default shortcut for an entire recompute interval, while the client pins it via `CLIENT_TTL.analyticsRF`. UX: cards show a tiny window even when the user selects "All data". ## Fix shape (option B from the issue body) Server-side per-recomputer warm-up gate: - `cmd/server/analytics_warmup_1659.go` adds a per-recomputer `firstPassDoneNs` atomic timestamp, set ONLY by the first successful `runOnce()` (CAS-guarded for idempotency). `IsWarmingUp_1659()` / `FirstPassDoneAt_1659()` are lock-free reads. - `cmd/server/analytics_recomputer.go` `runOnce()` calls `markFirstPassDone_1659()` after every successful compute. - `cmd/server/routes.go` handlers for RF / Topology / Channels: when the request is the default shape (`region=="" && area=="" && window.IsZero()`) AND the matching recomputer is still warming up, return `503` + `Retry-After: 5` + `{"error":"analytics warming up","retry_after_s":5}`. Windowed / region-filtered requests bypass the gate (they already bypass the recomputer cache, so they are unaffected by the warm-up bug). Client-side: - `public/app.js` `api()` helper retries any 503 response, honoring `Retry-After`, with exponential backoff capped at 30s, max 6 attempts (~63s total). - Small "Computing analytics…" banner appears while any warm-up retry is in flight, dismissed once the request resolves. Pages can override via `window.onWarmup_1659`. ## Tests RED commit `8b2b2d7` ships failing-on-assertion tests + a stub. GREEN commit `2716c23` lands the fix and flips them green. - `cmd/server/analytics_warmup_1659_test.go` — 3 cases: 503 during warmup, 200 after first pass, windowed request bypasses gate. - `test-1659-analytics-warmup.js` — 3 cases: Retry-After honored, retry cap bounded, non-503 errors not retried. Wired into `.github/workflows/deploy.yml`. ## Preflight overrides - cross-stack: justified — server-side 503 contract MUST be paired with client-side retry-and-banner handling; splitting across two PRs would land a half-working fix. Fixes #1659. --------- Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw <openclaw@local>
218 lines
9.0 KiB
JavaScript
218 lines
9.0 KiB
JavaScript
/* test-1659-analytics-warmup.js
|
|
*
|
|
* Issue #1659: client-side warmup-retry for analytics endpoints.
|
|
*
|
|
* Asserts that public/app.js's api() helper:
|
|
* (a) retries on a 503 response and honors the Retry-After header
|
|
* value (in seconds) for the wait between attempts
|
|
* (b) eventually returns the JSON body once the server flips to 200
|
|
* (c) caps retries so a permanently broken endpoint does not loop
|
|
* forever
|
|
*
|
|
* The test loads app.js into a vm context with a fake fetch and a fake
|
|
* setTimeout that resolves immediately (to keep wall-time minimal).
|
|
*/
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
// Some tests intentionally cause api() to throw; the inflight tracker
|
|
// inside app.js may surface that as an unhandled rejection a tick later
|
|
// even after the test has caught it. Swallow them — they're expected
|
|
// for the "cap retries" test path.
|
|
process.on('unhandledRejection', (e) => {
|
|
if (e && /API \d+/.test(e.message || '')) return;
|
|
throw e;
|
|
});
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
return Promise.resolve()
|
|
.then(fn)
|
|
.then(() => { passed++; console.log(' ✅ ' + name); })
|
|
.catch(e => { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); });
|
|
}
|
|
|
|
function makeCtx(fetchImpl) {
|
|
const ctx = {
|
|
console, Date, Math, Promise, Error, isFinite, parseInt, JSON,
|
|
performance: { now: () => 0 },
|
|
window: {},
|
|
document: { readyState: 'complete', body: { appendChild: () => {} }, createElement: () => ({ style: {} }) },
|
|
fetch: fetchImpl,
|
|
setTimeout: (fn) => { fn(); return 0; }, // resolve immediately — backoff sleep is irrelevant in unit tests
|
|
setInterval: () => 0,
|
|
clearInterval: () => {},
|
|
Map, Set,
|
|
};
|
|
vm.createContext(ctx);
|
|
// Provide the no-op fetch('/api/config/cache') hit that app.js makes
|
|
// on module load by returning a thenable that ignores.
|
|
const src = fs.readFileSync('public/app.js', 'utf8');
|
|
// Strip everything after the api()-related helpers we need; we only
|
|
// want the top of the file (cache + api + _warmupNotify) and the
|
|
// fetchAllNodes helper isn't required for these tests. Run the full
|
|
// file under the sandbox; references to globals are guarded.
|
|
try { vm.runInContext(src, ctx, { lineOffset: 0 }); } catch (e) {
|
|
// Some downstream code expects window/document/localStorage; we
|
|
// only need api() to be defined for these tests. Ignore.
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
(async () => {
|
|
console.log('\n=== #1659: api() retries on 503 with Retry-After ===');
|
|
|
|
await test('honors Retry-After then returns body on success', async () => {
|
|
let calls = 0;
|
|
const ctx = makeCtx(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
if (calls < 3) {
|
|
return {
|
|
ok: false, status: 503,
|
|
headers: { get: (k) => k.toLowerCase() === 'retry-after' ? '5' : null },
|
|
json: async () => ({ error: 'analytics warming up', retry_after_s: 5 }),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ totalPackets: 99 }) };
|
|
});
|
|
const data = await ctx.api('/analytics/rf');
|
|
assert.strictEqual(calls, 3, 'expected 3 analytics fetches (two 503s then 200)');
|
|
assert.deepStrictEqual(data, { totalPackets: 99 });
|
|
});
|
|
|
|
await test('caps retry attempts (no infinite loop on permanent 503)', async () => {
|
|
let calls = 0;
|
|
const ctx = makeCtx(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
return {
|
|
ok: false, status: 503,
|
|
headers: { get: () => '1' },
|
|
json: async () => ({}),
|
|
};
|
|
});
|
|
let thrown = null;
|
|
try { await ctx.api('/analytics/rf'); } catch (e) { thrown = e; }
|
|
assert.ok(thrown, 'expected api() to throw after retries exhausted');
|
|
assert.ok(/API 503/.test(thrown.message), 'expected 503 error message, got: ' + thrown.message);
|
|
assert.ok(calls <= 12, 'retry cap should keep call count bounded, got ' + calls);
|
|
assert.ok(calls >= 2, 'should have retried at least once, got ' + calls);
|
|
});
|
|
|
|
await test('non-503 errors do not trigger retry loop', async () => {
|
|
let calls = 0;
|
|
const ctx = makeCtx(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
return { ok: false, status: 500, headers: { get: () => null }, json: async () => ({}) };
|
|
});
|
|
let thrown = null;
|
|
try { await ctx.api('/analytics/rf'); } catch (e) { thrown = e; }
|
|
assert.ok(thrown, 'expected throw on 500');
|
|
assert.strictEqual(calls, 1, 'should not retry on non-503 errors');
|
|
});
|
|
|
|
// === PR #1688 r1 — banner counter leak fixes (adv #1 + munger #4) ===
|
|
// The banner state must end HIDDEN after BOTH a retry-then-success
|
|
// path and a retry-exhausted-throw path. Previously the in-flight
|
|
// counter leaked, leaving the banner stuck visible.
|
|
//
|
|
// We can't read the internal `let _warmupInflight_1659` from outside
|
|
// the vm (let is not attached to globalThis). Instead we hook
|
|
// window.onWarmup_1659 which fires whenever banner state changes,
|
|
// and assert the LAST state is `false` (hidden) — equivalent to
|
|
// counter == 0.
|
|
|
|
function withBannerHook(fetchImpl) {
|
|
const ctx = makeCtx(fetchImpl);
|
|
const events = [];
|
|
ctx.window.onWarmup_1659 = (visible) => { events.push(visible); };
|
|
return { ctx, events };
|
|
}
|
|
|
|
await test('banner hidden after retry-then-success (no leak)', async () => {
|
|
let calls = 0;
|
|
const { ctx, events } = withBannerHook(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
if (calls < 4) {
|
|
return { ok: false, status: 503, headers: { get: () => '1' }, json: async () => ({}) };
|
|
}
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ ok: 1 }) };
|
|
});
|
|
await ctx.api('/analytics/rf');
|
|
assert.ok(events.length > 0, 'expected at least one banner state change');
|
|
assert.strictEqual(events[events.length - 1], false,
|
|
'banner must end hidden after success (events=' + JSON.stringify(events) + ')');
|
|
});
|
|
|
|
await test('banner hidden after retry-exhausted throw (no leak)', async () => {
|
|
const { ctx, events } = withBannerHook(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
return { ok: false, status: 503, headers: { get: () => '1' }, json: async () => ({}) };
|
|
});
|
|
let thrown = null;
|
|
try { await ctx.api('/analytics/rf'); } catch (e) { thrown = e; }
|
|
assert.ok(thrown, 'expected throw after retries exhausted');
|
|
assert.ok(events.length > 0, 'expected at least one banner state change');
|
|
assert.strictEqual(events[events.length - 1], false,
|
|
'banner must end hidden after retry-exhausted throw (events=' + JSON.stringify(events) + ')');
|
|
});
|
|
|
|
await test('banner hidden after three parallel analytics endpoints', async () => {
|
|
let calls = 0;
|
|
const { ctx, events } = withBannerHook(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
// Each endpoint takes one 503 then succeeds.
|
|
if (calls % 2 === 1) {
|
|
return { ok: false, status: 503, headers: { get: () => '1' }, json: async () => ({}) };
|
|
}
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
});
|
|
await Promise.all([
|
|
ctx.api('/analytics/rf'),
|
|
ctx.api('/analytics/topology'),
|
|
ctx.api('/analytics/channels'),
|
|
]);
|
|
assert.strictEqual(events[events.length - 1], false,
|
|
'banner must end hidden after three parallel calls (events=' + JSON.stringify(events) + ')');
|
|
});
|
|
|
|
await test('banner visible DURING retries (not just at the end)', async () => {
|
|
let calls = 0;
|
|
const { ctx, events } = withBannerHook(async (url) => {
|
|
if (!/analytics/.test(url || '')) {
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
}
|
|
calls++;
|
|
if (calls < 3) {
|
|
return { ok: false, status: 503, headers: { get: () => '1' }, json: async () => ({}) };
|
|
}
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
|
|
});
|
|
await ctx.api('/analytics/rf');
|
|
assert.ok(events.includes(true),
|
|
'banner must have been visible during retries (events=' + JSON.stringify(events) + ')');
|
|
assert.strictEqual(events[events.length - 1], false, 'banner must end hidden');
|
|
});
|
|
|
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
|
if (failed > 0) process.exit(1);
|
|
})();
|