Merge branch 'main' into upstream/wind-stale

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-24 21:26:18 -07:00
commit 4ba0a1b57f
18 files changed
+432 -65

No files matched your search

@@ -0,0 +1,4 @@
- The terrain-heights bounds tests no longer read the checkout's
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
`npm test` no longer fails after the dev server has cached terrain near
Austin (KnottyDyes, #1244).
+7
View File
@@ -0,0 +1,7 @@
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
+3 -7
View File
@@ -9,13 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery. search keeps a successful geocode, with no search or view-based region recovery.
Overpass reserves each normalized query before its disk-cache lookup. The Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
initial disk lookup, upstream quota admission, fetching and memory publication
are coalesced under that reservation. A caller that joined a reservation
rejected by another client's quota retries admission with its own quota.
Stale fallback runs per response after the reservation is released, and disk
writes finish asynchronously. Settled and rejected reservations are removed
only while they still own their map entry.
Terrain height requests accept at most 64 points, matching the browser service's Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP existing batch size and one bounded upstream chunk. Larger requests return HTTP
@@ -4221,6 +4215,8 @@ animation. Polls, surface completions and trail fades request individual renders
Disable clears vehicles, detection references, requests, timers and holds; Disable clears vehicles, detection references, requests, timers and holds;
destruction also releases collections and registrations. destruction also releases collections and registrations.
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
**Acceptance harness.** Run against an already running development server: **Acceptance harness.** Run against an already running development server:
```sh ```sh
+1
View File
@@ -6,6 +6,7 @@
"scripts/qa-form-fields.mjs", "scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json", "scripts/package-boundaries.json",
"scripts/format.mjs", "scripts/format.mjs",
"scripts/qa-layer-visibility.mjs",
"scripts/next-layer-state-token.mjs", "scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs", "scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs", "scripts/qa-layer-token-twochar.mjs",
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Real-browser proof for manager-owned periodic work and document visibility.
* Uses the app's development-only layer registration seam; no provider keys.
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
* Hiding uses a real background tab, never a patched document.hidden getter.
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = new URL(
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
process.env.QA_BASE_URL ||
'http://localhost:4173',
);
url.searchParams.set('welcome', '0');
const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (name, actual, expected) => {
assert.equal(actual, expected, name);
console.log(`PASS ${name}: ${actual}`);
};
try {
const page = await browser.newPage();
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
window.__qaVisibilityUpdates = 0;
window.__gevQaRegisterLayer(manager, {
id: 'qa-document-visibility',
name: 'Visibility QA',
updateInterval: 750,
init() {},
enable() {},
disable() {},
destroy() {},
async update() {
window.__qaVisibilityUpdates++;
return true;
},
getStats() {
return { count: window.__qaVisibilityUpdates };
},
});
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
});
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
check('periodic updates run while visible', (await count()) >= 3, true);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
const cover = await browser.newPage();
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
const hiddenCount = await count();
await pause(2200);
check('hidden tab makes no periodic updates', await count(), hiddenCount);
check(
'explicit refresh remains available while hidden',
await page.evaluate(() =>
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
),
true,
);
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.waitForFunction(
() =>
document.visibilityState === 'visible' &&
window.__qaVisibilityUpdates > 0,
);
await pause(200);
check(
'resume performs one overdue refresh without a catch-up burst',
await count(),
hiddenCount + 2,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'qa-document-visibility',
false,
{ origin: 'qa' },
),
);
const stopped = await count();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await pause(1100);
check(
'disabled layer does not resume on visibility change',
await count(),
stopped,
);
check(
'QA layer destroys cleanly',
await page.evaluate(() =>
window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
'qa-document-visibility',
),
),
true,
);
console.log('RESULT: 7 passed');
} finally {
await browser.close();
}
+5
View File
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
return { return {
pageVisibility: document.visibilityState,
total: state._vehicles.size, total: state._vehicles.size,
visible: state._visible.size, visible: state._visible.size,
moving: state._moving.size, moving: state._moving.size,
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
}; };
}); });
console.log(` ${label}: ${JSON.stringify(result)}`); console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.pageVisibility !== 'visible')
throw new Error(
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
);
if (result.renderLoop === false || result.renderErrors.length) if (result.renderLoop === false || result.renderErrors.length)
throw new Error( throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+5
View File
@@ -1,6 +1,7 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = { export const BOSTON = {
id: 'boston', id: 'boston',
feedId: 'mbta',
label: 'Boston (MBTA)', label: 'Boston (MBTA)',
lat: 42.3554, lat: 42.3554,
lon: -71.0605, lon: -71.0605,
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
BOSTON, BOSTON,
{ {
id: 'austin', id: 'austin',
feedId: 'capmetro-austin',
label: 'Austin (CapMetro)', label: 'Austin (CapMetro)',
lat: 30.2672, lat: 30.2672,
lon: -97.7431, lon: -97.7431,
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
}, },
{ {
id: 'minneapolis', id: 'minneapolis',
feedId: 'metrotransit-msp',
label: 'Minneapolis (Metro Transit)', label: 'Minneapolis (Metro Transit)',
lat: 44.9778, lat: 44.9778,
lon: -93.265, lon: -93.265,
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
}, },
{ {
id: 'helsinki', id: 'helsinki',
feedId: 'hsl-helsinki',
label: 'Helsinki (HSL)', label: 'Helsinki (HSL)',
lat: 60.1699, lat: 60.1699,
lon: 24.9384, lon: 24.9384,
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
}, },
{ {
id: 'oslo', id: 'oslo',
feedId: 'entur-norway',
label: 'Oslo (Entur)', label: 'Oslo (Entur)',
lat: 59.9139, lat: 59.9139,
lon: 10.7522, lon: 10.7522,
+2 -2
View File
@@ -1,6 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */ /** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -307,7 +307,7 @@ try {
result.pixelPass = pixels.pass; result.pixelPass = pixels.pass;
result.pixelDetails = pixels; result.pixelDetails = pixels;
result.errors = errors; result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` }); await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass = const pass =
+7 -6
View File
@@ -1,3 +1,4 @@
import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed. // Not registered or active: camera discovery and live polls never own this feed.
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
); );
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-fleet-${count}.jpg`, path: `${shots}/${tag}-fleet-${count}.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 85, quality: 85,
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
JSON.stringify({ pixels, contrast }), JSON.stringify({ pixels, contrast }),
{ unexercised: pixels.length < 6 }, { unexercised: pixels.length < 6 },
); );
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 90, quality: 90,
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
target.key, target.key,
); );
await wait(500); await wait(500);
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 90, quality: 90,
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
); );
} }
await wait(300); await wait(300);
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 90, quality: 90,
@@ -579,7 +580,7 @@ export async function runBostonLive({
JSON.stringify(selection), JSON.stringify(selection),
{ unexercised: !target }, { unexercised: !target },
); );
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-boston-live-${view.name}.jpg`, path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 90, quality: 90,
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
stable: pixels.stable[i], stable: pixels.stable[i],
eligible: pixels.eligible[i], eligible: pixels.eligible[i],
})); }));
await page.screenshot({ await captureQaScreenshot(page, {
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg', type: 'jpeg',
quality: 95, quality: 95,
+67 -20
View File
@@ -36,10 +36,11 @@ import {
*/ */
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js'; import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js';
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
import { import {
reduceAnchor, reduceAnchor,
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(), executablePath: chromeExecutablePath(),
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--window-size=1920,1080', '--window-size=1920,1080',
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
// This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
boundPageEvaluations(page); boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 }); await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = []; const pageErrors = [];
@@ -286,11 +292,13 @@ page.on('request', (request) => {
}); });
const shot = (name, caption) => const shot = (name, caption) =>
page captureQaScreenshot(page, {
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) path: `${SHOTS}/${name}`,
.then(() => { type: 'jpeg',
console.log(` shot ${name} — ${caption}`); quality: 82,
}); }).then(() => {
console.log(` shot ${name} — ${caption}`);
});
const view = (city) => const view = (city) =>
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
@@ -389,22 +397,42 @@ try {
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
}); });
}, city); }, city);
await page.waitForFunction( const arrived = await page
() => .waitForFunction(
(window.__godsEyeView.dataManager.layers (feedId) =>
.get('transit') [
.module.getStats().count || 0) > 0, ...window.__godsEyeView.dataManager.layers
{ timeout: 90_000 }, .get('transit')
); .module._transitStateForTest()
._vehicles.keys(),
].some((key) => key.startsWith(`${feedId}:`)),
{ timeout: 90_000 },
city.feedId,
)
.then(
() => true,
() => false,
);
if (!arrived) {
check(
`${city.label}: live feed arrived`,
false,
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
{ unexercised: true },
);
continue;
}
await wait(9_000); await wait(9_000);
const fleet = await page.evaluate(() => { const fleet = await page.evaluate((feedId) => {
const layer = const layer =
window.__godsEyeView.dataManager.layers.get('transit').module; window.__godsEyeView.dataManager.layers.get('transit').module;
return { return {
count: layer._transitStateForTest()._vehicles.size, count: [...layer._transitStateForTest()._vehicles.keys()].filter(
(key) => key.startsWith(`${feedId}:`),
).length,
stats: layer.getStats(), stats: layer.getStats(),
}; };
}); }, city.feedId);
check( check(
`${city.label}: vehicles are rendered`, `${city.label}: vehicles are rendered`,
fleet.count >= 20, fleet.count >= 20,
@@ -426,6 +454,7 @@ try {
.module._transitStateForTest(); .module._transitStateForTest();
let best = null; let best = null;
for (const entry of state._vehicles.values()) { for (const entry of state._vehicles.values()) {
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
if (!best || d < best.d) if (!best || d < best.d)
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
@@ -1607,6 +1636,7 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
]; ];
const frames = []; const frames = [];
const observedPolls = new Set();
const publicationsBefore = state._cardPublications || 0; const publicationsBefore = state._cardPublications || 0;
let headSamples = 0, let headSamples = 0,
headErrorPx = 0, headErrorPx = 0,
@@ -1623,6 +1653,13 @@ try {
const proj = scene.camera.frustum.projectionMatrix; const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position; const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (
entry.marker.show !== false &&
rect &&
Number.isFinite(entry.pollSeq)
) {
observedPolls.add(entry.pollSeq);
}
frames.push({ frames.push({
t: performance.now() - t0, t: performance.now() - t0,
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
@@ -1668,7 +1705,15 @@ try {
headSamples++; headSamples++;
} }
} }
if (performance.now() - t0 >= durationMs) finish(); const elapsed = performance.now() - t0;
// Keep at least 20 seconds of motion and require an observed poll
// transition. A fixed 20s wall-clock window can miss a 15s poll
// when an earlier refresh is still settling.
if (
(elapsed >= 20_000 && observedPolls.size >= 2) ||
elapsed >= durationMs
)
finish();
}; };
let remove; let remove;
const finish = () => { const finish = () => {
@@ -1676,6 +1721,7 @@ try {
remove?.(); remove?.();
resolve({ resolve({
selected: true, selected: true,
elapsedMs: performance.now() - t0,
frames, frames,
textPublishes: textPublishes:
(state._cardPublications || 0) - publicationsBefore, (state._cardPublications || 0) - publicationsBefore,
@@ -1690,7 +1736,7 @@ try {
}); });
}, },
target.key, target.key,
20_000, 3 * TRANSIT_POLL_MS,
); );
} }
check( check(
@@ -1711,8 +1757,9 @@ try {
); );
check( check(
'card text publication stays throttled while the anchor runs at frame rate', 'card text publication stays throttled while the anchor runs at frame rate',
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, anchor.textPublishes > 0 &&
`${anchor.textPublishes} text publications in 20 s`, anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
); );
} else { } else {
check( check(
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager // Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, { const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration, allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability, getSourceAvailability: catalog?.getSourceAvailability,
}); });
defer(async () => { defer(async () => {
+60 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/** /**
* LayerLifecycle — Manages registration, toggling, and update loops * LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe. * for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/ */
export class LayerLifecycle { export class LayerLifecycle {
constructor( constructor(
viewer, viewer,
{ allowQaRegistration = false, getSourceAvailability } = {}, {
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) { ) {
this._catalogSourceAvailability = getSourceAvailability; this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer; this.viewer = viewer;
this._activityListeners = new Set(); this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
} }
} }
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) { _armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval); const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval); const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval ? updateInterval
: 0; : 0;
if (refreshInterval > 0) { if (refreshInterval > 0) {
entry.intervalId = setInterval(() => { const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry); void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval); };
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) { } else if (updateInterval === 0) {
entry.intervalId = setInterval(() => { entry.intervalId = setInterval(() => {
if (!entry.enabled) return; if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed; entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle(); settleLifecycle();
if (!entry.enabled && entry.intervalId) { if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
this._publishActivity({ type: 'status' }); this._publishActivity({ type: 'status' });
if (!compensated) { if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
} }
if (signal?.aborted) return finishCancelledDisable('disable'); if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
entry.enabled = false; entry.enabled = false;
entry.lifecycleUncertain = false; entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null; let abortCleanup = null;
const cancelEnable = () => { const cancelEnable = () => {
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
// Disable immediately so modules with their own AbortController (Radio) // Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second // cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry. // a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable); signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
await abortCleanup; await abortCleanup;
let cleanupConfirmed = false; let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
}; };
const finishFailedEnable = async (phase, error) => { const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
let cleanupConfirmed = false; let cleanupConfirmed = false;
try { try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never // Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling. // orphan a running timer and end up double-polling.
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
// Manager-owned periodic refresh work has one normalized loading/error // Manager-owned periodic refresh work has one normalized loading/error
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
return false; return false;
} }
if (entry.intervalId) { if (entry.intervalId) {
clearInterval(entry.intervalId); this._stopUpdateLoop(entry);
entry.intervalId = null;
} }
entry.enabled = false; entry.enabled = false;
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
/** Compatibility facade for callers that construct a manager with its panel. */ /** Compatibility facade for callers that construct a manager with its panel. */
export class DataLayerManager extends LayerLifecycle { export class DataLayerManager extends LayerLifecycle {
constructor(viewer, options) { constructor(viewer, options) {
super(viewer, options); super(viewer, { visibilityTarget: globalThis.document, ...options });
this._presentation = new LayerPresentation(this); this._presentation = new LayerPresentation(this);
} }
buildTogglePanel(container) { buildTogglePanel(container) {
+89
View File
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
lifecycle.refreshLayerStats(); lifecycle.refreshLayerStats();
assert.deepEqual(changes, ['status']); assert.deepEqual(changes, ['status']);
}); });
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('hidden-poll');
manager.register(module);
await manager.setEnabled('hidden-poll', true);
assert.equal(calls.update, 1);
doc.hidden = true;
t.mock.timers.tick(10000);
assert.equal(calls.update, 1);
assert.equal(await manager.refreshLayer('hidden-poll'), true);
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
doc.hidden = false;
for (const fn of listeners) fn();
for (const fn of listeners) fn();
assert.equal(calls.update, 3);
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(10000);
assert.equal(calls.update, 3);
});
async function resumeBetweenTicks(t, { failResume = false } = {}) {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('resume-poll');
const updates = [];
module.update = async () => {
calls.update++;
updates.push(Date.now());
await Promise.resolve();
if (failResume && updates.length === 2) throw new Error('resume failed');
};
manager.register(module);
await manager.setEnabled('resume-poll', true);
assert.deepEqual(updates, [1000]);
doc.hidden = true;
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
t.mock.timers.tick(500); // 2500: return between ticks
doc.hidden = false;
for (const fn of listeners) fn();
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
if (failResume) assert.equal(managerRefreshError, 'resume failed');
else assert.equal(managerRefreshError, null);
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
assert.deepEqual(updates, [1000, 2500]);
t.mock.timers.tick(1); // 3500: one interval after the resume poll
assert.deepEqual(updates, [1000, 2500, 3500]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(5000);
assert.equal(updates.length, 4);
}
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
await resumeBetweenTicks(t);
});
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
await resumeBetweenTicks(t, { failResume: true });
});
+1
View File
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
else if (hidden) { else if (hidden) {
hidden = false; hidden = false;
parts.rendering.resumePlayback(); parts.rendering.resumePlayback();
parts.viewport.runProximityCheck();
} }
} }
function bindVisibility() { function bindVisibility() {
+5
View File
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
} }
function runProximityCheck() { function runProximityCheck() {
if (
globalThis.document?.hidden ||
globalThis.document?.visibilityState === 'hidden'
)
return;
if (!state._enabled || !state._viewer) return; if (!state._enabled || !state._viewer) return;
refreshViewBounds(); refreshViewBounds();
const altitude = getCameraAltitude(); const altitude = getCameraAltitude();
+37 -12
View File
@@ -99,6 +99,18 @@ function stubTerrainUpstream(t) {
return asked; return asked;
} }
/**
* A cache file of the test's own, removed when the test ends. Without one the
* proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a
* point the app has already cached there would answer a request the test
* expects to reach the stubbed upstream (#1242).
*/
async function isolatedCachePath(t) {
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
t.after(() => rm(dir, { recursive: true, force: true }));
return path.join(dir, 'terrain-heights.json');
}
test('a coordinate off the globe is not a cache key', () => { test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these. // Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [ for (const raw of [
@@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
upstreamCalls += 1; upstreamCalls += 1;
return new Response('[]', { status: 200 }); return new Response('[]', { status: 200 });
}); });
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' }); const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400); assert.equal(refused.statusCode, 400);
@@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
test('one out-of-range point rejects the batch it is hidden in', async (t) => { test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 })); stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const mixed = await request(route, { const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27', url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
}); });
@@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => {
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t); const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests. // A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( const route = install(
'/api/terrain/heights', terrainHeightsProxy({
); cachePath: await isolatedCachePath(t),
cacheMaxEntries: 3,
}),
).get('/api/terrain/heights');
const point = (i) => `-97.7${i}000,30.26000`; const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1) for (let i = 1; i <= 4; i += 1)
@@ -168,9 +187,7 @@ test('the point cache stops growing, and an evicted point is simply refetched',
test('an over-large cache file is not read back into memory', async (t) => { test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t); const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000'; const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); const cachePath = await isolatedCachePath(t);
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
// A cache file holding the answer, but larger than the ceiling allows. // A cache file holding the answer, but larger than the ceiling allows.
const padded = { const padded = {
@@ -201,7 +218,9 @@ test('an over-large cache file is not read back into memory', async (t) => {
test('the route refuses a flood of requests from one client', async (t) => { test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t); stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
let refusedAt = 0; let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) { for (let i = 1; i <= 95; i += 1) {
@@ -218,7 +237,9 @@ test('the route refuses a flood of requests from one client', async (t) => {
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
const asked = stubTerrainUpstream(t); const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'localhost:5173'; const host = 'localhost:5173';
const refused = [ const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page { host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
@@ -253,7 +274,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
test('the app served behind a reverse proxy still gets terrain', async (t) => { test('the app served behind a reverse proxy still gets terrain', async (t) => {
const asked = stubTerrainUpstream(t); const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'globe.example.lan'; const host = 'globe.example.lan';
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The // What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
// cost-bearing routes refuse these; terrain is keyless and needed to render, // cost-bearing routes refuse these; terrain is keyless and needed to render,
@@ -347,7 +370,9 @@ test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () => stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
); );
const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1) for (let i = 0; i < 90; i += 1)
await request(route, { await request(route, {
+13
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { getTransitFeed } from '../data/transitFeeds.js';
import { import {
chooseLiveCities, chooseLiveCities,
selectStyle, selectStyle,
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
const cities = chooseLiveCities(now); const cities = chooseLiveCities(now);
assert.ok(cities.length >= 1); assert.ok(cities.length >= 1);
for (const city of cities) { for (const city of cities) {
const feed = getTransitFeed(city.feedId);
assert.ok(feed, `${city.id} has a registered feed`);
const distanceKm =
111.32 *
Math.hypot(
feed.center.lat - city.lat,
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
);
assert.ok(
distanceKm < feed.loadRadiusKm,
`${city.id} is inside its feed's admission region`,
);
const local = Number( const local = Number(
new Intl.DateTimeFormat('en', { new Intl.DateTimeFormat('en', {
timeZone: city.zone, timeZone: city.zone,