Merge pull request #1282 from bilawalsidhu/codex/fix-hidden-tab-polling

fix(layers): pause periodic network updates in hidden tabs
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-24 23:59:30 +01:00
commit 2dca762e4a
16 files changed
+391 -53

No files matched your search

+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();
+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,