From 88cbef1aba7425c2467706dd8cf6dc6c13eb8b2d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 22:03:13 +0300 Subject: [PATCH] perf(map): stage cesium provider startup --- apps/catalog/src/CesiumMapRenderer.tsx | 135 +++++++++++------- apps/catalog/src/mapProviderStartup.d.mts | 44 ++++++ apps/catalog/src/mapProviderStartup.mjs | 141 +++++++++++++++++++ package.json | 3 +- scripts/map-provider-startup.test.mjs | 163 ++++++++++++++++++++++ 5 files changed, 435 insertions(+), 51 deletions(-) create mode 100644 apps/catalog/src/mapProviderStartup.d.mts create mode 100644 apps/catalog/src/mapProviderStartup.mjs create mode 100644 scripts/map-provider-startup.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 1314f86..02afd40 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -37,6 +37,11 @@ import type { MapPresentationProfile, } from "./mapPresentationProfile.js"; import { fixedGridOrigin } from "./mapSectorGrid.mjs"; +import { + MAP_PROVIDER_STARTUP_POLICY, + runStagedMapProviders, + waitForGlobeViewportReady, +} from "./mapProviderStartup.mjs"; import type { CameraSpiralConfig, CameraSpiralState, @@ -941,14 +946,15 @@ export const CesiumMapRenderer = forwardRef void) | undefined; let removeRenderErrorListener: (() => void) | undefined; const removeProviderFailureListeners: Array<() => void> = []; + const providerStartupAbort = new AbortController(); let cancelled = false; const start = async () => { try { - const response = await fetch("/api/map/runtime-config"); + const response = await fetch("/api/map/runtime-config", { signal: providerStartupAbort.signal }); const config = response.ok ? await response.json() as RuntimeConfig : null; if (config?.gatewayHealthUrl) { - void fetch(config.gatewayHealthUrl) + void fetch(config.gatewayHealthUrl, { signal: providerStartupAbort.signal }) .then((healthResponse) => healthResponse.ok ? healthResponse.json() as Promise : null) .then((health) => { if (!cancelled) onGatewayHealth?.(health); }) .catch(() => { if (!cancelled) onGatewayHealth?.(null); }); @@ -997,9 +1003,11 @@ export const CesiumMapRenderer = forwardRef { + const loadEndpoint = async (assetId: string, signal = providerStartupAbort.signal) => { if (!config?.gatewayReady) throw new Error("map_gateway_not_ready"); - const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`); + const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`, { + signal, + }); if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`); return endpointResponse.json() as Promise; }; @@ -1102,57 +1110,83 @@ export const CesiumMapRenderer = forwardRef { - if (endpoint.externalType !== "BING" || !endpoint.options?.url || endpoint.credentialMode !== "gateway") throw new Error("cesium_live_imagery_endpoint_invalid"); - const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), { - // Cesium requires a key-shaped value to form its Bing URL. This - // public marker is stripped by Map Gateway before upstream use. - key: "nodedc-gateway", - mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle, - tileProtocol: "https", - }); - if (cancelled || !viewer || viewer.isDestroyed()) return; - removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery"))); - for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); - imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider); - applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); - reportProvider("imagery", "ready"); - }).catch((error) => reportProvider("imagery", "error", error)); - void loadEndpoint("1").then(async (terrainEndpoint) => { - if (!terrainEndpoint.url || terrainEndpoint.credentialMode !== "gateway") throw new Error("terrain_endpoint_invalid"); - const terrainResource = buildResource(terrainEndpoint.url); - const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true }); - if (cancelled || !viewer || viewer.isDestroyed()) return; - removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain"))); - terrain.world = world; - for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); - applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); - onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world)); - reportProvider("terrain", "ready"); - }).catch((error) => reportProvider("terrain", "error", error)); - void loadEndpoint("96188").then(async (buildingsEndpoint) => { - if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid"); - const buildingsResource = buildResource(buildingsEndpoint.url); - const buildings = await Cesium3DTileset.fromUrl(buildingsResource); - if (cancelled || !viewer || viewer.isDestroyed()) return; - removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings"))); - viewer.scene.primitives.add(buildings); - buildingsRef.current = buildings; - for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); - applyPresentation(viewer, imageryLayerRef.current, buildings, terrain, presentationRef.current); - reportProvider("buildings", "ready"); - }).catch((error) => reportProvider("buildings", "error", error)); - if (config.gaussianSplatsReady && config.gaussianAssetId) { - void loadEndpoint(config.gaussianAssetId).then(async (gaussianEndpoint) => { + // Give the visible base map first use of network and decoder + // capacity. Public Globe readiness events provide bounded gates; + // provider errors remain isolated and never block a later layer. + void runStagedMapProviders({ + signal: providerStartupAbort.signal, + isCancelled: () => cancelled || !viewer || viewer.isDestroyed(), + loadImagery: async ({ signal }) => { + const endpoint = await loadEndpoint("2", signal); + if (endpoint.externalType !== "BING" || !endpoint.options?.url || endpoint.credentialMode !== "gateway") throw new Error("cesium_live_imagery_endpoint_invalid"); + const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), { + // Cesium requires a key-shaped value to form its Bing URL. + // This public marker is stripped by Gateway before upstream. + key: "nodedc-gateway", + mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle, + tileProtocol: "https", + }); + if (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return; + removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery"))); + for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); + imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider); + applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); + reportProvider("imagery", "ready"); + }, + waitAfterImagery: async () => { + if (!viewer || viewer.isDestroyed() || !imageryLayerRef.current?.show) return; + await waitForGlobeViewportReady({ + globe: viewer.scene.globe, + scene: viewer.scene, + signal: providerStartupAbort.signal, + timeoutMs: MAP_PROVIDER_STARTUP_POLICY.imageryViewportTimeoutMs, + }); + }, + loadTerrain: async ({ signal }) => { + const terrainEndpoint = await loadEndpoint("1", signal); + if (!terrainEndpoint.url || terrainEndpoint.credentialMode !== "gateway") throw new Error("terrain_endpoint_invalid"); + const terrainResource = buildResource(terrainEndpoint.url); + const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true }); + if (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return; + removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain"))); + terrain.world = world; + for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); + applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); + onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world)); + reportProvider("terrain", "ready"); + }, + waitAfterTerrain: async () => { + if (!viewer || viewer.isDestroyed() || !presentationRef.current.terrainEnabled || !terrain.world) return; + await waitForGlobeViewportReady({ + globe: viewer.scene.globe, + scene: viewer.scene, + signal: providerStartupAbort.signal, + timeoutMs: MAP_PROVIDER_STARTUP_POLICY.terrainViewportTimeoutMs, + }); + }, + loadBuildings: async ({ signal }) => { + const buildingsEndpoint = await loadEndpoint("96188", signal); + if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid"); + const buildingsResource = buildResource(buildingsEndpoint.url); + const buildings = await Cesium3DTileset.fromUrl(buildingsResource); + if (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return; + removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings"))); + viewer.scene.primitives.add(buildings); + buildingsRef.current = buildings; + for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); + applyPresentation(viewer, imageryLayerRef.current, buildings, terrain, presentationRef.current); + reportProvider("buildings", "ready"); + }, + loadDeferred: config.gaussianSplatsReady && config.gaussianAssetId ? async () => { + const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId!); if (!gaussianEndpoint.url || gaussianEndpoint.credentialMode !== "gateway") throw new Error("gaussian_endpoint_invalid"); const gaussianResource = buildResource(gaussianEndpoint.url); const gaussian = await Cesium3DTileset.fromUrl(gaussianResource); if (cancelled || !viewer || viewer.isDestroyed()) return; viewer.scene.primitives.add(gaussian); - }).catch(() => undefined); - } + } : undefined, + onProviderError: (provider, error) => reportProvider(provider, "error", error), + }); } applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); @@ -1250,6 +1284,7 @@ export const CesiumMapRenderer = forwardRef { cancelled = true; + providerStartupAbort.abort(); stopSpiralAnimation("renderer_restarted"); onReadyChangeRef.current?.(false); resizeObserver?.disconnect(); diff --git a/apps/catalog/src/mapProviderStartup.d.mts b/apps/catalog/src/mapProviderStartup.d.mts new file mode 100644 index 0000000..55a5aa7 --- /dev/null +++ b/apps/catalog/src/mapProviderStartup.d.mts @@ -0,0 +1,44 @@ +export type MapProviderStartupPolicy = Readonly<{ + providerInitializationTimeoutMs: number; + imageryViewportTimeoutMs: number; + terrainViewportTimeoutMs: number; + minimumRenderFrames: number; +}>; + +export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled"; +export type MapProviderStartupResult = "complete" | "cancelled"; +export type MapProviderName = "imagery" | "terrain" | "buildings"; + +type CesiumEventLike void> = { + addEventListener(listener: TListener): () => void; +}; + +export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy; + +export function waitForGlobeViewportReady(input: { + globe: { + readonly tilesLoaded: boolean; + tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>; + }; + scene: { + postRender: CesiumEventLike<() => void>; + requestRender(): void; + }; + signal?: AbortSignal; + timeoutMs: number; + minimumRenderFrames?: number; +}): Promise; + +export function runStagedMapProviders(input: { + signal?: AbortSignal; + isCancelled?: () => boolean; + providerInitializationTimeoutMs?: number; + loadImagery: (context: { signal: AbortSignal }) => void | Promise; + waitAfterImagery?: () => void | Promise; + loadTerrain: (context: { signal: AbortSignal }) => void | Promise; + waitAfterTerrain?: () => void | Promise; + loadBuildings: (context: { signal: AbortSignal }) => void | Promise; + loadDeferred?: () => void | Promise; + onProviderError?: (provider: MapProviderName, error: unknown) => void; + onDeferredError?: (error: unknown) => void; +}): Promise; diff --git a/apps/catalog/src/mapProviderStartup.mjs b/apps/catalog/src/mapProviderStartup.mjs new file mode 100644 index 0000000..9ac9c06 --- /dev/null +++ b/apps/catalog/src/mapProviderStartup.mjs @@ -0,0 +1,141 @@ +export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({ + providerInitializationTimeoutMs: 8_000, + imageryViewportTimeoutMs: 6_000, + terrainViewportTimeoutMs: 5_000, + minimumRenderFrames: 2, +}); + +/** + * Wait until Cesium has rendered enough frames to discover the current + * viewport and the public Globe queue reports that its terrain and imagery + * are loaded. The deadline is deliberate: a slow or unavailable provider + * must never block the next independent layer. + */ +export function waitForGlobeViewportReady({ + globe, + scene, + signal, + timeoutMs, + minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames, +}) { + if (signal?.aborted) return Promise.resolve("cancelled"); + + const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames)); + const deadlineMs = Math.max(0, Math.trunc(timeoutMs)); + + return new Promise((resolve) => { + let settled = false; + let renderFrames = 0; + let removeProgressListener; + let removePostRenderListener; + let timeout; + + const cleanup = () => { + removeProgressListener?.(); + removePostRenderListener?.(); + if (timeout !== undefined) clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }; + const finish = (reason) => { + if (settled) return; + settled = true; + cleanup(); + resolve(reason); + }; + const inspect = () => { + if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded"); + }; + const onAbort = () => finish("cancelled"); + const onTileLoadProgress = () => { + inspect(); + if (!settled) scene.requestRender(); + }; + const onPostRender = () => { + renderFrames += 1; + inspect(); + if (!settled && renderFrames < frameTarget) scene.requestRender(); + }; + + removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress); + removePostRenderListener = scene.postRender.addEventListener(onPostRender); + signal?.addEventListener("abort", onAbort, { once: true }); + timeout = setTimeout(() => finish("timeout"), deadlineMs); + scene.requestRender(); + }); +} + +/** + * Preserve independent failure domains while giving the base map first use + * of network and decode capacity. Each provider owns its error; viewport + * gates are scheduling hints and therefore never become provider failures. + */ +export async function runStagedMapProviders({ + signal, + isCancelled, + providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs, + loadImagery, + waitAfterImagery, + loadTerrain, + waitAfterTerrain, + loadBuildings, + loadDeferred, + onProviderError, + onDeferredError, +}) { + const cancelled = () => Boolean(signal?.aborted || isCancelled?.()); + const loadProvider = async (provider, load) => { + if (cancelled()) return false; + const stageAbort = new AbortController(); + let timeout; + let rejectDeadline; + const abortStage = () => { + rejectDeadline?.(new Error(`${provider}_startup_cancelled`)); + stageAbort.abort(); + }; + signal?.addEventListener("abort", abortStage, { once: true }); + const deadline = new Promise((_, reject) => { + rejectDeadline = reject; + timeout = setTimeout(() => { + reject(new Error(`${provider}_startup_timeout`)); + stageAbort.abort(); + }, Math.max(0, Math.trunc(providerInitializationTimeoutMs))); + }); + try { + await Promise.race([load({ signal: stageAbort.signal }), deadline]); + return !cancelled() && !stageAbort.signal.aborted; + } catch (error) { + if (!cancelled()) onProviderError?.(provider, error); + return false; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + signal?.removeEventListener("abort", abortStage); + } + }; + const waitForViewport = async (wait) => { + if (!wait || cancelled()) return; + try { + await wait(); + } catch { + // A readiness gate controls ordering only. The provider's own error + // event remains the authority for availability and user diagnostics. + } + }; + + const imageryReady = await loadProvider("imagery", loadImagery); + if (imageryReady) await waitForViewport(waitAfterImagery); + + const terrainReady = await loadProvider("terrain", loadTerrain); + if (terrainReady) await waitForViewport(waitAfterTerrain); + + await loadProvider("buildings", loadBuildings); + + if (loadDeferred && !cancelled()) { + try { + await loadDeferred(); + } catch (error) { + if (!cancelled()) onDeferredError?.(error); + } + } + + return cancelled() ? "cancelled" : "complete"; +} diff --git a/package.json b/package.json index 984be2a..fa6b19c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -32,6 +32,7 @@ "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", + "test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs", "test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", "test:range-control": "node --test scripts/range-control-contract.test.mjs" diff --git a/scripts/map-provider-startup.test.mjs b/scripts/map-provider-startup.test.mjs new file mode 100644 index 0000000..e349d47 --- /dev/null +++ b/scripts/map-provider-startup.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + runStagedMapProviders, + waitForGlobeViewportReady, +} from "../apps/catalog/src/mapProviderStartup.mjs"; + +function eventHarness() { + const listeners = new Set(); + return { + event: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + raise(...args) { + for (const listener of [...listeners]) listener(...args); + }, + get size() { + return listeners.size; + }, + }; +} + +test("providers start in base-map order", async () => { + const calls = []; + const result = await runStagedMapProviders({ + loadImagery: () => calls.push("imagery"), + waitAfterImagery: () => calls.push("imagery-ready"), + loadTerrain: () => calls.push("terrain"), + waitAfterTerrain: () => calls.push("terrain-ready"), + loadBuildings: () => calls.push("buildings"), + loadDeferred: () => calls.push("deferred"), + }); + + assert.equal(result, "complete"); + assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]); +}); + +test("imagery failure is reported without blocking terrain or buildings", async () => { + const calls = []; + const errors = []; + await runStagedMapProviders({ + loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); }, + waitAfterImagery: () => calls.push("imagery-ready"), + loadTerrain: () => calls.push("terrain"), + waitAfterTerrain: () => calls.push("terrain-ready"), + loadBuildings: () => calls.push("buildings"), + onProviderError: (provider, error) => errors.push([provider, error.message]), + }); + + assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]); + assert.deepEqual(errors, [["imagery", "imagery failed"]]); +}); + +test("terrain failure is reported without blocking buildings", async () => { + const calls = []; + const errors = []; + await runStagedMapProviders({ + loadImagery: () => calls.push("imagery"), + waitAfterImagery: () => calls.push("imagery-ready"), + loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); }, + waitAfterTerrain: () => calls.push("terrain-ready"), + loadBuildings: () => calls.push("buildings"), + onProviderError: (provider, error) => errors.push([provider, error.message]), + }); + + assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]); + assert.deepEqual(errors, [["terrain", "terrain failed"]]); +}); + +test("cancellation prevents all later stages", async () => { + const controller = new AbortController(); + const calls = []; + const result = await runStagedMapProviders({ + signal: controller.signal, + loadImagery: () => { calls.push("imagery"); controller.abort(); }, + waitAfterImagery: () => calls.push("imagery-ready"), + loadTerrain: () => calls.push("terrain"), + loadBuildings: () => calls.push("buildings"), + }); + + assert.equal(result, "cancelled"); + assert.deepEqual(calls, ["imagery"]); +}); + +test("a stalled provider times out without inserting late or blocking later stages", async () => { + const calls = []; + const errors = []; + let releaseImagery; + const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; }); + await runStagedMapProviders({ + providerInitializationTimeoutMs: 1, + loadImagery: async ({ signal }) => { + calls.push("imagery-start"); + await stalledImagery; + if (!signal.aborted) calls.push("imagery-insert"); + }, + loadTerrain: () => calls.push("terrain"), + loadBuildings: () => calls.push("buildings"), + onProviderError: (provider, error) => errors.push([provider, error.message]), + }); + releaseImagery(); + await Promise.resolve(); + + assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]); + assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]); +}); + +test("viewport readiness waits for discovery frames and cleans listeners", async () => { + const progress = eventHarness(); + const postRender = eventHarness(); + const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event }; + const scene = { postRender: postRender.event, requestRender() {} }; + let settled = false; + const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 }) + .then((reason) => { settled = true; return reason; }); + + postRender.raise(); + await Promise.resolve(); + assert.equal(settled, false); + postRender.raise(); + assert.equal(await ready, "loaded"); + assert.equal(progress.size, 0); + assert.equal(postRender.size, 0); +}); + +test("viewport readiness times out and aborts without leaking listeners", async () => { + const timeoutProgress = eventHarness(); + const timeoutPostRender = eventHarness(); + assert.equal(await waitForGlobeViewportReady({ + globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event }, + scene: { postRender: timeoutPostRender.event, requestRender() {} }, + timeoutMs: 1, + }), "timeout"); + assert.equal(timeoutProgress.size, 0); + assert.equal(timeoutPostRender.size, 0); + + const abortProgress = eventHarness(); + const abortPostRender = eventHarness(); + const controller = new AbortController(); + const pending = waitForGlobeViewportReady({ + globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event }, + scene: { postRender: abortPostRender.event, requestRender() {} }, + signal: controller.signal, + timeoutMs: 100, + }); + controller.abort(); + assert.equal(await pending, "cancelled"); + assert.equal(abortProgress.size, 0); + assert.equal(abortPostRender.size, 0); +}); + +test("the live renderer uses the staged provider coordinator", async () => { + const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); + assert.match(renderer, /runStagedMapProviders\(\{/); + assert.match(renderer, /waitForGlobeViewportReady\(\{/); + assert.match(renderer, /providerStartupAbort\.abort\(\)/); + assert.doesNotMatch(renderer, /Do not serialize provider startup/); +});