From 052906f2cf8e541eb97a09800acad5083ab056e2 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:34 +0200 Subject: [PATCH 1/8] test(nav): reproduce the unreadable drawer header in the light theme (#150) Step (k) in test-nav-drawer-1064-e2e.js opens the drawer in the light and the dark theme and computes the contrast of the header title (and the close button) against the header background from computed styles. On master the light theme paints the header with --surface-2 (white) under the white --nav-text title: 1.00:1. Dark passes. Co-Authored-By: Claude Opus 5.5 --- test-nav-drawer-1064-e2e.js | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js index 9369d3b13..414985b9e 100644 --- a/test-nav-drawer-1064-e2e.js +++ b/test-nav-drawer-1064-e2e.js @@ -273,6 +273,70 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) { await wideCtx.close(); + // (k) #150: the header title (and close button) must be readable on the + // header background in both themes. The light theme used to paint the + // header with --surface-2 (white) under the white --nav-text title. + for (const theme of ['light', 'dark']) { + await step(`(k) ${theme} theme: header title ≥ 4.5:1 and close button ≥ 3:1 on the header background`, async () => { + const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme }); + const p = await ctx.newPage(); + p.setDefaultTimeout(10000); + await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (_) {} }, theme); + try { + await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open)); + await p.evaluate(() => window.__navDrawer.open()); + await p.waitForSelector('[data-nav-drawer] .nav-drawer-title', { state: 'visible' }); + const r = await p.evaluate(() => { + function rgba(s) { + const m = /rgba?\(([^)]+)\)/.exec(s); + if (!m) throw new Error('unparsed colour ' + s); + const v = m[1].split(/[\s,/]+/).filter(Boolean).map(Number); + return { r: v[0], g: v[1], b: v[2], a: v.length > 3 ? v[3] : 1 }; + } + // opaque colour under el: its own background composited over its + // ancestors' (stopping at the first opaque one) + function backdrop(el) { + const layers = []; + for (let n = el; n; n = n.parentElement) { + const c = rgba(getComputedStyle(n).backgroundColor); + if (c.a > 0) layers.push(c); + if (c.a >= 1) break; + } + let out = { r: 255, g: 255, b: 255 }; + for (let i = layers.length - 1; i >= 0; i--) { + const c = layers[i]; + out = { r: c.r * c.a + out.r * (1 - c.a), g: c.g * c.a + out.g * (1 - c.a), b: c.b * c.a + out.b * (1 - c.a) }; + } + return out; + } + function lum(c) { + const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); + } + function ratio(fg, bg) { + const a = lum(fg), b = lum(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + const header = document.querySelector('[data-nav-drawer] .nav-drawer-header'); + const title = header.querySelector('.nav-drawer-title'); + const close = header.querySelector('.nav-drawer-close'); + const bg = backdrop(header); + return { + theme: document.documentElement.getAttribute('data-theme'), + bg: getComputedStyle(header).backgroundColor, + title: getComputedStyle(title).color, + titleRatio: ratio(rgba(getComputedStyle(title).color), bg), + closeRatio: ratio(rgba(getComputedStyle(close).color), backdrop(close)), + }; + }); + assert(r.theme === theme, 'data-theme is ' + r.theme + ', want ' + theme); + assert(r.titleRatio >= 4.5, `title ${r.title} on header ${r.bg}: ${r.titleRatio.toFixed(2)}:1 < 4.5:1`); + assert(r.closeRatio >= 3, `close button on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 3:1`); + } finally { await ctx.close(); } + }); + } + // ── Narrow viewport (Option A): drawer disabled ── const narrowCtx = await browser.newContext({ viewport: { width: 360, height: 800 } }); const narrow = await narrowCtx.newPage(); From af6abc13d9cd0e8c58644dbc5d81f5f6aebedda0 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:34 +0200 Subject: [PATCH 2/8] fix(css): put the nav drawer header on the nav colours (#150) nav-drawer.css paints the drawer title and close button with --nav-text and --nav-text-muted, but style.css gave the header --surface-2, white in the light theme. The header now uses --nav-bg2, which the customizer already maps (navBg2) and every preset pairs with navText. Default themes: title 17.1:1 on --nav-bg2 in light and dark. Co-Authored-By: Claude Opus 5.5 --- public/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index 60b3a97d5..5b02ae965 100644 --- a/public/style.css +++ b/public/style.css @@ -5101,7 +5101,9 @@ td[data-filter-field] { cursor: context-menu; } justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--border); - background: var(--surface-2, var(--surface)); + /* nav-drawer.css paints the title and close button with --nav-text(-muted), + so the header sits on the nav colours in every theme (#150). */ + background: var(--nav-bg2); } .nav-drawer-title { font-weight: 700; From 71a8ba9e4de10e53dab42d4e2a637fc5b695f351 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:55 +0200 Subject: [PATCH 3/8] test(live): reproduce Heat unchecked after Matrix OFF during init; pin #135 (#150) New steps in test-issue-125-live-toggles-early-e2e.js: - Matrix and Heat saved ON, Matrix clicked OFF while init is held: Heat must be checked and enabled, its layer shown after init, and Matrix ON/OFF after init must bring Heat back as saved. Red on master: the checkbox stays unchecked while applyLiveControlEffects() shows the layer. - The same with Heat saved OFF: Heat stays unchecked with no layer. - #135 regression: leave Live while init awaits loadNodes(), come back, then one click on each of the nine toggles must write localStorage once. api() shares the in-flight request, so both inits continue when it is answered. Green on master (fixed by #135); against master with live.js from before #135 it fails on 8 of 9 toggles with 2 writes. Co-Authored-By: Claude Opus 5.5 --- test-issue-125-live-toggles-early-e2e.js | 106 ++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/test-issue-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js index 6e59d6236..efa164bf7 100644 --- a/test-issue-125-live-toggles-early-e2e.js +++ b/test-issue-125-live-toggles-early-e2e.js @@ -21,6 +21,14 @@ * shows its canvas. Finally three SPA round trips leave one listener per * toggle (one click writes once). * + * #150 follow-ups: with Matrix and Heat saved ON, Matrix switched OFF during + * init leaves the Heat checkbox, the heat layer and the saved setting in + * agreement (also for Matrix OFF after init, and for Heat saved OFF). And + * leaving Live while init is still awaiting loadNodes(), then coming back, + * leaves one listener per toggle on the new mount: before #135 the old + * init's continuation wired the new mount's toggles a second time (2 + * listeners on 8 of 9 toggles, one click wrote localStorage twice). + * * Usage: BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js */ 'use strict'; @@ -46,7 +54,9 @@ const TOGGLES = [ { id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false }, { id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false }, ]; -const KEYS = TOGGLES.map((t) => t.key); +// All nine wired toggles (#150): Multibyte (#88) has its own E2E above. +const ALL_TOGGLES = TOGGLES.concat([{ id: 'liveMultibyteToggle', key: 'live-multibyte-only', dflt: false }]); +const KEYS = ALL_TOGGLES.map((t) => t.key); // A context whose first page load starts from `seed` ({key: 'true'|'false'}; // other toggle keys removed), counting writes per key and recording errors. @@ -237,6 +247,100 @@ const loadLive = (page) => () => page.goto(BASE + '/#/live', { waitUntil: 'domco } finally { await ctx.close(); } }); + await step('#150: Matrix and Heat saved ON, Matrix clicked OFF during init: Heat checked, enabled and its layer shown', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.checked === true && st.t.liveHeatToggle.disabled === false, + 'Matrix OFF during init: Heat must be checked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + let st = await readState(page); + assert(st.heatLayer, 'heat layer missing after init although Heat is saved ON and Matrix is OFF'); + assert(st.t.liveHeatToggle.checked === true, 'Heat checkbox unchecked while the heat layer is shown'); + assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed (stored=' + st.t.liveHeatToggle.stored + ')'); + // after init: Matrix ON hides the layer; Matrix OFF brings Heat back as saved + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(!st.heatLayer && !st.t.liveHeatToggle.checked && st.t.liveHeatToggle.disabled, 'Matrix ON after init: Heat must be hidden, unchecked and disabled'); + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(st.t.liveHeatToggle.checked === true && !st.t.liveHeatToggle.disabled, 'Matrix OFF after init: Heat must be checked and enabled again (checked=' + st.t.liveHeatToggle.checked + ')'); + assert(st.heatLayer, 'Matrix OFF after init: Heat is checked but its layer is not shown'); + assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed by Matrix (stored=' + st.t.liveHeatToggle.stored + ')'); + } finally { await ctx.close(); } + }); + + await step('#150: Matrix ON and Heat OFF saved, Matrix clicked OFF during init: Heat stays unchecked with no layer', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'false' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.checked === false && st.t.liveHeatToggle.disabled === false, + 'Matrix OFF during init with Heat saved OFF: Heat must be unchecked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + const st = await readState(page); + assert(!st.heatLayer, 'heat layer shown although Heat is saved OFF'); + assert(st.t.liveHeatToggle.checked === false, 'Heat checkbox checked although Heat is saved OFF'); + } finally { await ctx.close(); } + }); + + await step('#150 (#135 regression): leaving Live while init awaits loadNodes() and coming back keeps one listener per toggle', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + // this scenario's own errors: the abandoned init's continuation is not + // what this step checks (listed, not asserted) + const ownErrors = []; + page.on('pageerror', (e) => ownErrors.push(e.message)); + try { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.waitForLoadState('networkidle'); + // hold the first Live loadNodes() request: init is then past its first + // await and stuck in its second one + let armed = true, release, hit; + const released = new Promise((r) => { release = r; }); + const intercepted = new Promise((r) => { hit = r; }); + await page.route(/\/api\/nodes\?limit=/, async (route) => { + if (!armed) return route.continue(); + armed = false; + hit(); + await released; + await route.continue().catch((e) => { if (!page.isClosed()) throw e; }); + }); + try { + await page.evaluate(() => { location.hash = '#/live'; }); + let timer; + await Promise.race([intercepted, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error( + 'Live init never requested /api/nodes within 15s; the hold point this test relies on has moved')), 15000); })]); + clearTimeout(timer); + assert(!(await page.evaluate(() => !!window._liveWSHandler())), 'Live finished init while held'); + await page.evaluate(() => { location.hash = '#/packets'; }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.evaluate(() => { location.hash = '#/live'; }); + await page.locator('#liveHeatToggle').waitFor({ state: 'attached' }); + } finally { + // api() shares the in-flight request, so the old and the new init + // both continue when it is answered (the old one first) + release(); + } + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + const doubled = []; + for (const t of ALL_TOGGLES) { + const before = (await readState(page)).writes[t.key] || 0; + await page.evaluate((id) => document.getElementById(id).click(), t.id); + const after = (await page.evaluate(() => Object.assign({}, window.__t125Writes)))[t.key] || 0; + if (after - before !== 1) doubled.push(t.id + '=' + (after - before)); + } + assert(doubled.length === 0, 'one click wrote localStorage more or less than once on ' + doubled.length + ' of ' + + ALL_TOGGLES.length + ' toggles: ' + doubled.join(', ') + (ownErrors.length ? ' (page errors: ' + ownErrors.slice(0, 3).join(' | ') + ')' : '')); + if (ownErrors.length) console.log(' note: page errors from the abandoned init: ' + ownErrors.slice(0, 3).join(' | ')); + } finally { await ctx.close(); } + }); + await step('three SPA round trips keep one listener per toggle', async () => { const { ctx, page } = await newLivePage(browser, errors, {}); try { From 41c587b44ed8d24fad286edaa58206ab488206a5 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:55 +0200 Subject: [PATCH 4/8] fix(live): keep the Heat checkbox in step with Heat when Matrix goes off (#150) syncHeatToggleToMatrix(false) only re-enabled the checkbox, so after Matrix was switched off during init it stayed unchecked while applyLiveControlEffects() showed the heat layer. It now also sets checked from heatEnabled. Switching Matrix off after init also rebuilds the heat layer when Heat is on, so the checked box is never shown without its layer. Co-Authored-By: Claude Opus 5.5 --- public/live.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/public/live.js b/public/live.js index b7d38aa89..382638050 100644 --- a/public/live.js +++ b/public/live.js @@ -1182,6 +1182,9 @@ localStorage.setItem('live-matrix-mode', matrixMode); applyMatrixTheme(matrixMode); syncHeatToggleToMatrix(matrixMode); + // Matrix ON hid the heat layer; OFF brings it back as Heat is set. + // During init applyLiveControlEffects() (re)builds it after loadNodes(). + if (!matrixMode && heatEnabled) showHeatMap(); } }, { id: 'liveMatrixRainToggle', restore: () => matrixRain, onChange: (v) => { matrixRain = v; @@ -1202,7 +1205,8 @@ } // Matrix mode owns the heat map: while it is on, the heat layer is hidden - // and its toggle unchecked and disabled. Safe before the map exists. + // and its toggle unchecked and disabled. When it is off, the toggle shows + // the Heat setting again (#150). Safe before the map exists. function syncHeatToggleToMatrix(on) { const ht = document.getElementById('liveHeatToggle'); if (on) { @@ -1210,6 +1214,7 @@ if (ht) { ht.checked = false; ht.disabled = true; } } else if (ht) { ht.disabled = false; // recover from stale state + ht.checked = heatEnabled; } } From c4910e36d4af5076ea71977274eef89108515d6a Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:55 +0200 Subject: [PATCH 5/8] test(rx-coverage): reproduce a stale days response winning (#150) Test 13 in test-issue-124-rx-coverage-viewport.js switches days 7 -> 30, answers the days=30 coverage and leaderboard first, then releases the slow days=7 responses: the days=30 data must stay. A failed older leaderboard request must not replace a newer one either. Red on master: the days=7 leaderboard replaces the days=30 one. Co-Authored-By: Claude Opus 5.5 --- test-issue-124-rx-coverage-viewport.js | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js index bf077c58f..147b26d73 100644 --- a/test-issue-124-rx-coverage-viewport.js +++ b/test-issue-124-rx-coverage-viewport.js @@ -281,6 +281,60 @@ function assertView(env, want, tag) { assert.strictEqual(newLayer.cleared + newLayer.added, 0, 'the old coverage response was drawn on the new layer (cleared ' + newLayer.cleared + ', added ' + newLayer.added + ')'); }); + await test('13. #150: switching days quickly, the older days response cannot overwrite the newer data', async () => { + const env = makeEnv({ storage: { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) } }); + const layers = []; + env.sandbox.L.layerGroup = () => { + const l = { cleared: 0, polys: [], addTo() { return l; }, clearLayers() { l.cleared++; l.polys = []; } }; + layers.push(l); + return l; + }; + env.sandbox.L.polygon = (ring) => { + const pg = { addTo(l) { l.polys.push(ring); return pg; }, bindTooltip() { return pg; } }; + return pg; + }; + // the days bar: record its click handler + const bar = env.sandbox.document.getElementById('rxDays'); + bar.addEventListener = (ev, fn) => { if (ev === 'click') bar.onclick = fn; }; + const pickDays = (d) => bar.onclick({ target: { closest: () => ({ dataset: { days: String(d) } }) } }); + const feature = (lat) => ({ features: [{ properties: {}, geometry: { coordinates: [[[10, lat], [11, lat], [10, lat + 1]]] } }] }); + const observers = (name) => ({ observers: [{ pubkey: name.toLowerCase(), name, score: 1, cells: 1, nodes: 1, receptions: 1 }] }); + + await mount(env, null); + env.runTimers(); // first coverage request (days=7) + assert(env.pending.some((p) => /rx-leaderboard\?days=7&/.test(p.url)), 'no days=7 leaderboard request'); + assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=7$/.test(p.url)), 'no days=7 coverage request'); + + pickDays(30); + assert(env.pending.some((p) => /rx-leaderboard\?days=30&/.test(p.url)), 'no days=30 leaderboard request'); + assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=30$/.test(p.url)), 'no days=30 coverage request'); + + // the newer (days=30) responses arrive first + const board = env.sandbox.document.getElementById('rxBoard'); + assert(env.respond(/rx-leaderboard\?days=30&/, observers('NEWER'))); + assert(env.respond(/rx-coverage\?bbox=.*&days=30$/, feature(60))); + await flush(); + const layer = layers[0]; + assert(/NEWER/.test(board.innerHTML), 'the days=30 leaderboard did not render'); + assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the days=30 coverage did not render: ' + JSON.stringify(layer.polys)); + + // then the slow days=7 responses are released + assert(env.respond(/rx-leaderboard\?days=7&/, observers('OLDER'))); + assert(env.respond(/rx-coverage\?bbox=.*&days=7$/, feature(50))); + await flush(); + assert(/NEWER/.test(board.innerHTML) && !/OLDER/.test(board.innerHTML), 'the older days=7 leaderboard replaced the days=30 one'); + assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the older days=7 coverage replaced the days=30 one: ' + JSON.stringify(layer.polys)); + + // a late failure of an older leaderboard request does not replace it either + pickDays(14); + pickDays(1); + assert(env.respond(/rx-leaderboard\?days=1&/, observers('NEWEST'))); + await flush(); + assert(env.failFetch(/rx-leaderboard\?days=14&/)); + await flush(); + assert(/NEWEST/.test(board.innerHTML), 'a failed older leaderboard request replaced the newest one: ' + board.innerHTML); + }); + await test('11. coverage filtering and leaderboard requests are unchanged', async () => { const env = makeEnv({ hash: '#/rx-coverage?days=14' }); await mount(env, { center: [55.68, 12.57], zoom: 9 }); From 864d113070979d1d4c6500d5bc184e430b225884 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:55 +0200 Subject: [PATCH 6/8] fix(rx-coverage): render only the latest coverage and leaderboard request (#150) drawCoverage() and loadBoard() each take a sequence number (coverageSeq, boardSeq) next to the mount generation from #124, and a response (or a leaderboard failure) renders only if no newer request of its kind has started. An older days response can no longer overwrite newer data; the same holds for coverage requests from quick pans or rx changes. Co-Authored-By: Claude Opus 5.5 --- public/rx-coverage.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/public/rx-coverage.js b/public/rx-coverage.js index 8db95f527..da1ba9df2 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -11,6 +11,11 @@ // coverage and leaderboard responses, the settle timer, move handlers) // captures it and does nothing once a newer mount or a destroy happened. var generation = 0; + // Request sequence per data stream (#150): a coverage or leaderboard + // response renders only if no newer request of its kind has started since, + // so a slow response for an earlier days (or rx, or viewport) cannot + // overwrite newer data. + var coverageSeq = 0, boardSeq = 0; // Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom, // this page's own saved view, /api/config/map, then this offline fallback @@ -22,6 +27,8 @@ var MIN_ZOOM = 1, MAX_ZOOM = 19; function isLive(gen) { return !destroyed && gen === generation; } + function isLatestCoverage(gen, seq) { return isLive(gen) && seq === coverageSeq; } + function isLatestBoard(gen, seq) { return isLive(gen) && seq === boardSeq; } // validView returns {lat, lon, zoom} when all three are present, numeric and // in range; anything invalid, partial or out of range gives null. @@ -124,12 +131,12 @@ function drawCoverage() { if (!map || destroyed) return; - var gen = generation; + var gen = generation, seq = ++coverageSeq; var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (!isLive(gen) || !covLayer) return; + if (!isLatestCoverage(gen, seq) || !covLayer) return; covLayer.clearLayers(); (fc.features || []).forEach(function (f) { var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); @@ -251,11 +258,11 @@ } function loadBoard() { - var gen = generation; + var gen = generation, seq = ++boardSeq; fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); }) - .then(function (d) { if (!isLive(gen)) return; boardCache = d.observers || []; renderBoard(); }) + .then(function (d) { if (!isLatestBoard(gen, seq)) return; boardCache = d.observers || []; renderBoard(); }) .catch(function (e) { - if (!isLive(gen)) return; + if (!isLatestBoard(gen, seq)) return; console.warn('rx-coverage: leaderboard fetch failed', e); var el = document.getElementById('rxBoard'); if (el) el.innerHTML = '
Could not load mobile observers.
'; From daa2793ff38e67f8b5fa306ee938502ecd6169ad Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:56 +0200 Subject: [PATCH 7/8] test(ingestor): reproduce the iataWarnIntervalSec overflow; pin oldest (#150) - TestIATAWarnIntervalClampsHugeValues: 1e10 s (also from JSON), MaxInt, 86401 s and a year must give a 24h interval. Red on master: 1e10 s overflows time.Duration to a negative interval. - TestIATADropThrottleOldestTracksStaggeredEntries and TestIATADropThrottleOldestFollowsAnEarlierDrop cover the `oldest` lower bound with staggered and out-of-order timestamps. They pass on master; they kill four mutants that survived the existing tests (oldest left at now after a sweep, set to now on every insert, and either half of the insert condition dropped). Co-Authored-By: Claude Opus 5.5 --- cmd/ingestor/iata_drop_warn_test.go | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/cmd/ingestor/iata_drop_warn_test.go b/cmd/ingestor/iata_drop_warn_test.go index 9ea129312..8d4b1617c 100644 --- a/cmd/ingestor/iata_drop_warn_test.go +++ b/cmd/ingestor/iata_drop_warn_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "math" "os" "path/filepath" "strings" @@ -32,6 +33,43 @@ func TestIATAWarnIntervalDefaultAndConfigured(t *testing.T) { } } +// A huge configured interval must not overflow time.Duration (int64 +// nanoseconds) into a negative interval, which would log every drop: the +// flood the throttle exists to prevent (#150). It is capped at 24 hours. +func TestIATAWarnIntervalClampsHugeValues(t *testing.T) { + var huge int64 = 1e10 // seconds; 1e19 ns overflows int64 + for _, sec := range []int{int(huge), math.MaxInt, 24*60*60 + 1, 365 * 24 * 60 * 60} { + if got := (&Config{IATAWarnIntervalSec: sec}).IATAWarnInterval(); got != 24*time.Hour { + t.Errorf("iataWarnIntervalSec=%d: interval %v, want the 24h cap", sec, got) + } + } + if got := (&Config{IATAWarnIntervalSec: 24 * 60 * 60}).IATAWarnInterval(); got != 24*time.Hour { + t.Errorf("exactly 24h: interval %v", got) + } + if got := (&Config{IATAWarnIntervalSec: 24*60*60 - 1}).IATAWarnInterval(); got != 24*time.Hour-time.Second { + t.Errorf("just under the cap: interval %v", got) + } + + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":10000000000}`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.IATAWarnInterval(); got != 24*time.Hour { + t.Fatalf("iataWarnIntervalSec=10000000000 from JSON: interval %v, want 24h", got) + } + // and the throttle built on it still suppresses a repeat + if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA, cfg.IATAWarnInterval()); !w { + t.Fatal("first drop not logged") + } + if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA.Add(time.Minute), cfg.IATAWarnInterval()); w { + t.Error("a repeat a minute later was logged again") + } +} + func TestIATAWarnIntervalFromJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":600}`), 0o600); err != nil { @@ -159,6 +197,85 @@ func TestIATADropThrottleSweepsAreAmortized(t *testing.T) { } } +// fillIATA tracks n new codes with the given prefix at ts; each must get its +// own slot. +func fillIATA(t *testing.T, th *iataDropThrottle, prefix string, n int, ts time.Time, iv time.Duration) { + t.Helper() + for i := 0; i < n; i++ { + if w, o := th.shouldWarn(fmt.Sprintf("%s%04d", prefix, i), ts, iv); !w || o { + t.Fatalf("%s%04d at %v: warn=%v overflow=%v, want an own slot", prefix, i, ts.Sub(t0IATA), w, o) + } + } +} + +// oldest is a lower bound on the oldest tracked time (#150). With entries +// of different ages it must follow the oldest one still tracked: too high +// and an expired slot is not reclaimed (a new region falls into the shared +// overflow throttle), too low and a full table of fresh entries is swept +// on every drop. +func TestIATADropThrottleOldestTracksStaggeredEntries(t *testing.T) { + var th iataDropThrottle + iv := time.Hour + half := iataWarnMaxTracked / 2 + fillIATA(t, &th, "A", half, t0IATA, iv) + fillIATA(t, &th, "B", iataWarnMaxTracked-half, t0IATA.Add(30*time.Minute), iv) + + // full of fresh entries: overflow, and no sweep (nothing can be expired) + if w, o := th.shouldWarn("EARLY", t0IATA.Add(40*time.Minute), iv); !w || !o { + t.Fatalf("full table at +40m: warn=%v overflow=%v, want the overflow warning", w, o) + } + if th.sweeps != 0 { + t.Fatalf("%d sweeps before any entry could expire", th.sweeps) + } + + // +1h: the A half has expired; a new region reclaims a slot + if w, o := th.shouldWarn("N1", t0IATA.Add(time.Hour), iv); !w || o { + t.Fatalf("+1h: warn=%v overflow=%v, want an own slot (A entries expired)", w, o) + } + if len(th.last) != iataWarnMaxTracked-half+1 || th.sweeps != 1 { + t.Fatalf("+1h: %d entries after %d sweeps, want %d after 1", len(th.last), th.sweeps, iataWarnMaxTracked-half+1) + } + // refill to the cap with entries at +1h + fillIATA(t, &th, "C", half-1, t0IATA.Add(time.Hour), iv) + + // +1h30m: the B half (+30m) has expired now and must be reclaimed, + // although every entry swept at +1h is gone and the newest are fresh + if w, o := th.shouldWarn("N2", t0IATA.Add(90*time.Minute), iv); !w || o { + t.Fatalf("+1h30m: warn=%v overflow=%v, want an own slot (B entries expired)", w, o) + } + if _, ok := th.last["B0000"]; ok { + t.Error("expired B entries were not reclaimed at +1h30m") + } + if len(th.last) != half+1 || th.sweeps != 2 { + t.Fatalf("+1h30m: %d entries after %d sweeps, want %d after 2", len(th.last), th.sweeps, half+1) + } +} + +// A drop can carry an earlier time than the current oldest entry (the +// caller takes the time before the lock). oldest must drop to it, or that +// entry outlives its interval in a full table. +func TestIATADropThrottleOldestFollowsAnEarlierDrop(t *testing.T) { + var th iataDropThrottle + iv := time.Hour + fillIATA(t, &th, "A", iataWarnMaxTracked, t0IATA, iv) + // +1h: everything expired; sweep, keep only LATE (taken at +1h) + if w, o := th.shouldWarn("LATE", t0IATA.Add(time.Hour), iv); !w || o { + t.Fatalf("+1h: warn=%v overflow=%v", w, o) + } + // a drop stamped +50m arrives after it + if w, o := th.shouldWarn("EARLIER", t0IATA.Add(50*time.Minute), iv); !w || o { + t.Fatalf("out-of-order drop: warn=%v overflow=%v", w, o) + } + fillIATA(t, &th, "F", iataWarnMaxTracked-2, t0IATA.Add(time.Hour), iv) + // +1h50m: EARLIER has expired and must free its slot + if w, o := th.shouldWarn("NEW", t0IATA.Add(110*time.Minute), iv); !w || o { + t.Fatalf("+1h50m: warn=%v overflow=%v, want EARLIER's expired slot", w, o) + } + if _, ok := th.last["EARLIER"]; ok { + t.Error("EARLIER outlived its interval in a full table") + } +} + func TestIATADropThrottleConcurrent(t *testing.T) { var th iataDropThrottle iv := time.Hour From 8fedaa1d0b88c5c86afa67d961563cfbd26b3bad Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 16:20:56 +0200 Subject: [PATCH 8/8] fix(ingestor): cap iataWarnIntervalSec at 24 hours (#150) time.Duration(c.IATAWarnIntervalSec) * time.Second overflowed for huge values (1e10 s became a negative interval), so every dropped message was logged. IATAWarnInterval() now caps the configured value at 86400 s. Documented in config.go and config.example.json. Co-Authored-By: Claude Opus 5.5 --- cmd/ingestor/config.go | 3 ++- cmd/ingestor/iata_drop_warn.go | 10 +++++++++- config.example.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index 3c4082416..493905dd6 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -73,7 +73,8 @@ type Config struct { // IATAWarnIntervalSec is how often a region dropped by // ObserverIATAWhitelist is re-logged while it keeps arriving (#110). - // 0 or less means the default, 6 hours. See iata_drop_warn.go. + // 0 or less means the default, 6 hours; larger than 86400 (24 hours) + // is capped at 86400. See iata_drop_warn.go. IATAWarnIntervalSec int `json:"iataWarnIntervalSec,omitempty"` // iataDropWarn is the bounded per-region throttle for that warning. diff --git a/cmd/ingestor/iata_drop_warn.go b/cmd/ingestor/iata_drop_warn.go index 6451da0d7..22cf41af7 100644 --- a/cmd/ingestor/iata_drop_warn.go +++ b/cmd/ingestor/iata_drop_warn.go @@ -33,6 +33,10 @@ const ( iataWarnMaxKeyLen = 32 // defaultIATAWarnIntervalSec is the default re-log interval (6h). defaultIATAWarnIntervalSec = 6 * 60 * 60 + // maxIATAWarnIntervalSec caps the configured interval (24h). A larger + // value buys nothing for a warning meant to stay visible, and a huge one + // overflows time.Duration into a negative interval that logs every drop. + maxIATAWarnIntervalSec = 24 * 60 * 60 ) // iataDropThrottle is the per-Config throttle state. The zero value is ready. @@ -47,11 +51,15 @@ type iataDropThrottle struct { sweeps int // full-table sweeps, for tests } -// IATAWarnInterval returns how often a dropped region is re-logged. +// IATAWarnInterval returns how often a dropped region is re-logged: +// iataWarnIntervalSec capped at 24 hours, or the default for 0 or less. func (c *Config) IATAWarnInterval() time.Duration { if c == nil || c.IATAWarnIntervalSec <= 0 { return defaultIATAWarnIntervalSec * time.Second } + if c.IATAWarnIntervalSec > maxIATAWarnIntervalSec { + return maxIATAWarnIntervalSec * time.Second + } return time.Duration(c.IATAWarnIntervalSec) * time.Second } diff --git a/config.example.json b/config.example.json index ce4bd1f92..a5446416c 100644 --- a/config.example.json +++ b/config.example.json @@ -8,7 +8,7 @@ "observerIATAWhitelist": [], "_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources. A dropped region is logged by the ingestor as one '[region-filter] dropping region \"XYZ\"' line, repeated at most every iataWarnIntervalSec while it keeps arriving.", "iataWarnIntervalSec": 21600, - "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.", + "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default; maximum 86400 (24h), larger values are capped. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.", "retention": { "nodeDays": 7, "observerDays": 14,