From 5c04b121648873ccd37da6236685f4b2d59444a4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:43:49 +0400 Subject: [PATCH 1/9] Roll back a Space Missions entry whose mission index fails to load The launches layer swallowed every fetch error in its update, so the manager treated a 429 or timeout on /api/launches as a successful start. Context then committed the isolating mode with no missions: the other layers stayed off, unrelated enables were refused, the camera stayed at the full-globe view and the roster kept reading "loading". Until the first index loads, the update now reports the failure. The manager fails the enable closed and both entry routes roll back to the pre-entry layers. The camera pose captured at entry is restored when the mode is left before any index loaded, and the roster shows a failed state instead of loading. A valid empty index still enters, and a refresh failure after a loaded index keeps its records. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/launches/failedEntry.test.mjs | 348 +++++++++++++++++++++++ src/layers/launches/ingestion.js | 19 +- src/layers/launches/lifecycle.js | 34 +++ src/layers/launches/panel.js | 13 +- src/layers/launches/state.js | 2 + 18 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 src/layers/launches/failedEntry.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..afa8714ec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5169,7 +5169,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..f316bc72e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -474,6 +474,7 @@ "src/layers/satellites/stale.test.mjs", "src/layers/launches/source.test.mjs", "src/layers/launches/ingestion.test.mjs", + "src/layers/launches/failedEntry.test.mjs", "src/layers/installations/lifecycle.js", "src/layers/installations/viewport.js", "src/layers/installations/controls.js", diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..a96fccc7d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -301,6 +301,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", + "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..72796858c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -760,6 +760,7 @@ "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "Internet radio and local SDR": "Internetradio und lokales SDR", "STATION TAG": "KATEGORIE", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..7341841d5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -192,6 +192,7 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE", "RADIO": "RADIO", "ENABLE": "ACTIVAR", "Previous filtered radio station": "Emisora de radio filtrada anterior", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..d27bdd2eb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2799,6 +2799,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", "STATION TAG": "TAG DE STATION", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..431360c8e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2799,6 +2799,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", "STATION TAG": "TAG STAZIONE", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..0bd3c47ef 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2657,6 +2657,7 @@ "AVAILABLE MISSIONS": "利用可能なミッション", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", + "30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "Internet radio and local SDR": "インターネットラジオとローカルSDR", "STATION TAG": "局タグ", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..31ba6ba2e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2801,6 +2801,7 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", "STATION TAG": "TAG DA ESTAÇÃO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..c46f3375c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -722,6 +722,7 @@ "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН", "Radio playback": "Воспроизведение радио", "Radio volume": "Громкость радио", "STATION TAG": "ТЕГ СТАНЦИИ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..794c65023 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2869,6 +2869,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", + "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "STATION TAG": "ТЕГ СТАНЦІЇ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..41c4718a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2556,6 +2556,7 @@ "AVAILABLE MISSIONS": "可用任务", "SELECT A MISSION TO INSPECT": "选择一项任务以查看", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "Internet radio and local SDR": "网络电台与本地 SDR", "STATION TAG": "电台标签", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..31e16701e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2544,6 +2544,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", "STATION TAG": "電臺標籤", diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs new file mode 100644 index 000000000..f8b760dde --- /dev/null +++ b/src/layers/launches/failedEntry.test.mjs @@ -0,0 +1,348 @@ +// A Space Missions entry whose mission index never loads must not commit the +// mode. The real manager, Context coordinator and launches ingestion run +// against a local /api/launches that answers 429, so the test follows the +// same failure the browser sees: isolation clears the other layers, the first +// update fails, and the entry rolls back to the layers and view it replaced. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import * as Cesium from 'cesium'; +import { DataLayerManager } from '../../data/manager.js'; +import { ContextControls } from '../../ui/contextControls.js'; +import { createLaunchSource } from './source.js'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createPanel } from './panel.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; + +const noop = () => {}; + +const launchRecord = { + id: 'fixture-launch', + name: 'Fixture mission', + net: new Date(Date.now() - 3600000).toISOString(), + status: { name: 'Launch Successful' }, + pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 }, + rocket: { payloads: [] }, +}; + +function plainLayer(id, name, params = {}) { + let current = { ...params }; + return { + id, + name, + icon: '', + source: 'test', + updateInterval: -1, + init: noop, + enable: noop, + disable: noop, + update: noop, + getParams: () => ({ ...current }), + setParams(next) { + current = { ...current, ...next }; + return true; + }, + }; +} + +function fakeCamera() { + const camera = { + position: new Cesium.Cartesian3(1, 2, 3), + heading: 0.1, + pitch: -0.5, + roll: 0, + flights: 0, + cancelled: 0, + get positionWC() { + return camera.position; + }, + flyTo({ destination }) { + camera.flights += 1; + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = 0; + camera.pitch = -Math.PI / 2; + }, + cancelFlight() { + camera.cancelled += 1; + }, + setView({ destination, orientation }) { + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = orientation.heading; + camera.pitch = orientation.pitch; + camera.roll = orientation.roll; + }, + }; + return camera; +} + +async function fixture(t) { + const upstream = { status: 429, results: [launchRecord] }; + const server = http.createServer((req, res) => { + if (req.url === '/api/celestrak/active') { + res.end(''); + return; + } + if (upstream.status !== 200) { + res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ results: upstream.results })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const source = createLaunchSource({ + fetchImpl: (url, options) => fetch(new URL(url, origin), options), + }); + + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + }; + const state = createState({ services }); + const camera = fakeCamera(); + const canvas = Object.assign(new EventTarget(), { + disableRootEvents: true, + onwheel: null, + style: {}, + clientWidth: 1200, + clientHeight: 800, + }); + const viewer = { + camera, + dataSources: new Cesium.DataSourceCollection(), + scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, + }; + // Scene presentation is inert; source, ingestion, lifecycle, panel views + // and the satellites dependency run unchanged. + const parts = { + overlays: { + shortMissionLabel: (name) => name, + createReplayVehicleOverlay: noop, + clearMissionOverlaySources: noop, + hideReplayVehicleOverlay: noop, + destroyReplayVehicleOverlay: noop, + }, + replay: { stopMissionReplay: noop, presentReplay: () => null }, + selection: { + focusFullGlobe: (target) => + target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }), + setSelectedMission: noop, + focusMission: noop, + stopMissionZoomAnchor: noop, + entityLaunchId: () => null, + }, + orbitRendering: { + removeMissionOrbitPrimitives: noop, + syncMissionOrbitPrimitiveVisibility: noop, + }, + launchPad: { + initLaunchPadZonePrimitive: noop, + hideLaunchPadZone: noop, + destroyLaunchPadZonePrimitive: noop, + }, + rendering: { + updateMissionFrame: noop, + addLaunchEntity(record) { + state._dataSource.entities.add({ + id: record.id, + position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat), + }); + }, + }, + }; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.model = createModel(context); + parts.panel = createPanel(context); + parts.ingestion = createIngestion(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + const missions = Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + const rosters = []; + parts.panel.subscribePanel((view) => rosters.push(view.roster)); + + const manager = new DataLayerManager(viewer); + manager.register(plainLayer('earthquakes', 'Earthquakes (24h)')); + manager.register(plainLayer('satellites', 'Satellites')); + manager.register(plainLayer('wind', 'Wind')); + manager.register(missions); + missions.attachDataManager(manager); + + const toasts = []; + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: noop, + showToast: (message) => toasts.push(message), + setClearBusy: noop, + syncDetection: noop, + scheduleLayout: noop, + refreshRadio: noop, + setPanelCollapsed: noop, + }, + }); + controls.connect(manager); + + t.after(async () => { + controls.stop(); + controls.disconnect(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind']) + await manager.setEnabled(id, false); + await missions.destroy(viewer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + + const enabledIds = () => [...manager.getEnabledLayerIds()].sort(); + const view = () => ({ + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + heading: camera.heading, + pitch: camera.pitch, + }); + const settle = async () => { + await controls._waitForContextLayerSettlement(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes']) + await manager.waitForLayerSettled(id); + await controls._waitForContextLayerSettlement(); + }; + return { + upstream, + manager, + controls, + missions, + state, + camera, + rosters, + toasts, + enabledIds, + view, + settle, + }; +} + +test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => { + const f = await fixture(t); + assert.equal( + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }), + true, + ); + const before = f.view(); + + const entered = await f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await f.settle(); + + assert.equal(entered, false, 'a failed mission index fails the enable'); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.equal(f.controls.getContextModeState().active, false); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); + assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); + + // Nothing is left isolating the workspace. + assert.equal( + await f.manager.setEnabled('wind', true, { origin: 'user' }), + true, + 'an unrelated layer is not blocked after the failed entry', + ); +}); + +test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + + const result = await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }); + await f.settle(); + + assert.equal(result.ok, false); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.deepEqual(f.view(), before); +}); + +test('the roster reports a failed mission index instead of loading forever', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + + const roster = f.rosters.at(-1); + assert.ok(roster, 'a failed load publishes a roster view'); + assert.deepEqual(roster.entries, []); + assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE'); + + // A new attempt starts from the loading state, then shows the index. + f.upstream.status = 200; + const loadingFrom = f.rosters.length; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.rosters.slice(loadingFrom).includes(null), true); + assert.equal(f.rosters.at(-1).entries.length, 1); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); +}); + +test('a valid empty mission index is a successful entry', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + f.upstream.results = []; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal( + f.rosters.at(-1).empty, + 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + ); + assert.ok(f.camera.flights > 0, 'a successful entry frames the globe'); +}); + +test('a refresh failure after a loaded index keeps the mode and its records', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const framed = f.view(); + + f.upstream.status = 429; + await f.missions.update(); + await f.settle(); + + assert.equal(f.manager.isEnabled('rocket-launches'), true); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal(f.missions.getStats().count, 1); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.equal(f.rosters.at(-1).entries.length, 1); + + // Leaving a mode that loaded keeps today's camera behaviour. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.deepEqual(f.view(), framed); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index bd1529c0b..171c95a63 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -50,8 +50,9 @@ export function createIngestion({ layerState._postTleRetryCount++; layerState._retryTimer = setTimeout(() => { layerState._retryTimer = null; + // A failed retry is already recorded in the layer stats. if (layerState._enabled && token === layerState._lifecycleToken) - requestMissionUpdate(); + requestMissionUpdate().catch(() => {}); }, POST_TLE_RETRY_DELAY_MS); } @@ -201,6 +202,10 @@ export function createIngestion({ if (layerState._renderedTleText === layerState._activeTleText) clearPostTleRetry(); layerState._count = launches.length; + // The roster reads these to tell a loaded empty index from loading. + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + layerState._stale = stale === true; parts.panel.renderMissionRoster(); if ( !layerState._selectedLaunchId || @@ -219,13 +224,19 @@ export function createIngestion({ } layerState._focusAfterActiveLookup = false; parts.panel.renderMissionPanel(); - layerState._lastUpdate = Date.now(); - layerState._lastError = null; - layerState._stale = stale === true; + layerState._entryCameraView = null; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; console.warn('[Data:RocketLaunches] Fetch error:', error); + // Without one usable index there is no mode to enter: report the + // failure so the manager fails the enable closed and Context rolls + // back. A refresh after a loaded index keeps its records instead. + if (layerState._lastUpdate === null) { + parts.panel.renderMissionRoster(); + throw error; + } + layerState._entryCameraView = null; } } } diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index ca36a87ec..cf0332823 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -111,6 +111,28 @@ export function createLaunchHover({ }; } +/** The camera pose to give back, in world coordinates. */ +function captureCameraView(viewer) { + const camera = viewer?.camera; + if (!camera?.positionWC) return null; + return { + destination: Cesium.Cartesian3.clone(camera.positionWC), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + }; +} + +/** Stop the full-globe flight and return to a captured pose. */ +function restoreCameraView(viewer, view) { + const camera = viewer?.camera; + if (!camera) return; + camera.cancelFlight?.(); + camera.setView(view); +} + export function createLifecycle({ state: layerState, services, @@ -200,6 +222,14 @@ export function createLifecycle({ layerState._updateDirty = false; if (layerState._dataSource) layerState._dataSource.show = true; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + if (layerState._lastUpdate === null) { + // A new attempt at an index that never loaded starts from loading. + layerState._lastError = null; + parts.panel.renderMissionRoster(); + } + // Kept until the mission index loads, so an entry that never does + // gives the view back. + layerState._entryCameraView = captureCameraView(layerState._viewer); parts.selection.focusFullGlobe(layerState._viewer); if (layerState._selectedLaunchId) parts.selection.setSelectedMission( @@ -214,6 +244,9 @@ export function createLifecycle({ hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; + const entryView = layerState._entryCameraView; + layerState._entryCameraView = null; + if (entryView) restoreCameraView(layerState._viewer, entryView); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; @@ -239,6 +272,7 @@ export function createLifecycle({ layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; + layerState._entryCameraView = null; layerState._lifecycleToken++; layerState._updateDirty = false; await parts.ingestion.restoreSatelliteDependency(); diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 07f1c8497..d45d4364c 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -263,8 +263,19 @@ export function createPanel({ state: layerState, services, parts, source }) { publishPanel(); } - /** The roster entries as the list shows them. */ + /** + * The roster entries as the list shows them; null while the first index + * is still loading, and an empty failed roster once that load failed. + */ function presentRoster() { + if (layerState._lastUpdate === null && !layerState._launches.length) { + if (!layerState._lastError) return null; + return { + count: '—', + entries: [], + empty: t('30-DAY MISSION INDEX UNAVAILABLE'), + }; + } const entries = parts.policyHelpers .missionRosterEntries(layerState._launches) .map(({ launch, index }) => { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..c4c032b10 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._entryCameraView = null; + state._stale = false; state._orbitMatches = 0; From c64ed40c6238d4fbb411d53cee65f26b0d667934 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:54:36 +0400 Subject: [PATCH 2/9] Add the changelog entry for #1578 --- changelog.d/1578-space-missions-failed-entry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1578-space-missions-failed-entry.md diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md new file mode 100644 index 000000000..9ad1630db --- /dev/null +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -0,0 +1,5 @@ +- Space Missions no longer locks the workspace when its mission index fails to + load. A 429, timeout or bad payload on `/api/launches` now rolls the entry + back to the previous layers and camera view instead of leaving an empty, + isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` + rather than loading forever (German, @gekh, #1578). From 655d93cc925cb6e0a08c20f4e2e4cfb05712e0c3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:02 +0400 Subject: [PATCH 3/9] Keep a newer camera view when a failed Space Missions entry rolls back --- src/layers/launches/controls.js | 2 + src/layers/launches/failedEntry.test.mjs | 107 ++++++++++++++++++++++- src/layers/launches/lifecycle.js | 47 ++++++++-- src/layers/launches/selection.js | 8 +- src/layers/launches/state.js | 2 + 5 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index b0dc035cb..95a212a73 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,6 +12,8 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { + // A failed entry no longer gives back the view it replaced. + layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index f8b760dde..b82663597 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -57,17 +57,29 @@ function fakeCamera() { roll: 0, flights: 0, cancelled: 0, + flight: null, get positionWC() { return camera.position; }, - flyTo({ destination }) { + // Like Cesium, a new flight cancels the one in progress. + flyTo({ destination, complete, cancel }) { + camera.flight?.cancel?.(); + camera.flight = { complete, cancel }; camera.flights += 1; camera.position = Cesium.Cartesian3.clone(destination); camera.heading = 0; camera.pitch = -Math.PI / 2; }, + land() { + const flight = camera.flight; + camera.flight = null; + flight?.complete?.(); + }, cancelFlight() { camera.cancelled += 1; + const flight = camera.flight; + camera.flight = null; + flight?.cancel?.(); }, setView({ destination, orientation }) { camera.position = Cesium.Cartesian3.clone(destination); @@ -81,11 +93,12 @@ function fakeCamera() { async function fixture(t) { const upstream = { status: 429, results: [launchRecord] }; - const server = http.createServer((req, res) => { + const server = http.createServer(async (req, res) => { if (req.url === '/api/celestrak/active') { res.end(''); return; } + if (upstream.hold) await upstream.hold; if (upstream.status !== 200) { res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); @@ -134,8 +147,13 @@ async function fixture(t) { }, replay: { stopMissionReplay: noop, presentReplay: () => null }, selection: { - focusFullGlobe: (target) => - target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }), + focusFullGlobe(target, flight) { + target.camera.flyTo({ + destination: new Cesium.Cartesian3(9, 9, 9), + ...flight, + }); + return true; + }, setSelectedMission: noop, focusMission: noop, stopMissionZoomAnchor: noop, @@ -284,6 +302,87 @@ test('a Context-panel Space Missions entry that gets HTTP 429 restores the previ assert.deepEqual(f.view(), before); }); +// Holds the /api/launches reply until the returned release is called. +function holdReply(f) { + let release; + f.upstream.hold = new Promise((resolve) => { + release = resolve; + }); + return () => { + f.upstream.hold = null; + release(); + }; +} + +async function untilFlown(f) { + for (let i = 0; i < 200 && f.camera.flights === 0; i++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight'); +} + +test('a landed entry flight still gives the view back after a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), before); +}); + +test('a camera move after the entry flight survives a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + // The user drags the globe while the mission index is still loading. + f.camera.setView({ + destination: new Cesium.Cartesian3(7, 7, 7), + orientation: { heading: 0.3, pitch: -0.2, roll: 0 }, + }); + const moved = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), moved); +}); + +test('a navigation during a failed first load is not cancelled by the rollback', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + // Search or Home: navigation releases mission camera ownership, then flies. + f.missions.releaseCameraOwnership(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(5, 5, 5) }); + const cancelled = f.camera.cancelled; + const navigated = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.equal(f.camera.cancelled, cancelled); + assert.deepEqual(f.view(), navigated); +}); + test('the roster reports a failed mission index instead of loading forever', async (t) => { const f = await fixture(t); await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index cf0332823..6ef0ce4df 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -125,11 +125,29 @@ function captureCameraView(viewer) { }; } -/** Stop the full-globe flight and return to a captured pose. */ -function restoreCameraView(viewer, view) { +function sameCameraView(camera, view) { + return ( + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + view.destination, + 0, + 1, + ) && + ['heading', 'pitch', 'roll'].every((key) => + Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6), + ) + ); +} + +/** + * Return to the pose before entry while the entry's full-globe flight still + * holds the camera: in flight, or landed with nothing moved since. + */ +function restoreCameraView(viewer, view, landedView) { const camera = viewer?.camera; if (!camera) return; - camera.cancelFlight?.(); + if (!landedView) camera.cancelFlight?.(); + else if (!sameCameraView(camera, landedView)) return; camera.setView(view); } @@ -228,9 +246,21 @@ export function createLifecycle({ parts.panel.renderMissionRoster(); } // Kept until the mission index loads, so an entry that never does - // gives the view back. + // gives the view back. A newer flight takes the camera and drops it. + const token = layerState._lifecycleToken; layerState._entryCameraView = captureCameraView(layerState._viewer); - parts.selection.focusFullGlobe(layerState._viewer); + layerState._entryLandedView = null; + const flying = parts.selection.focusFullGlobe(layerState._viewer, { + complete: () => { + if (token === layerState._lifecycleToken) + layerState._entryLandedView = captureCameraView(layerState._viewer); + }, + cancel: () => { + if (token === layerState._lifecycleToken) + layerState._entryCameraView = null; + }, + }); + if (!flying) layerState._entryCameraView = null; if (layerState._selectedLaunchId) parts.selection.setSelectedMission( layerState._selectedLaunchId, @@ -246,7 +276,12 @@ export function createLifecycle({ layerState._enabled = false; const entryView = layerState._entryCameraView; layerState._entryCameraView = null; - if (entryView) restoreCameraView(layerState._viewer, entryView); + if (entryView) + restoreCameraView( + layerState._viewer, + entryView, + layerState._entryLandedView, + ); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 813b7875f..0f7955551 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -9,7 +9,8 @@ export function createSelection({ }) { const { getKeyholeGeometry } = services.geometry; - function focusFullGlobe(viewer, duration = 2.4) { + /** @returns {boolean} Whether the flight started. */ + function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) { const canvas = viewer?.scene?.canvas; const height = canvas?.clientHeight || canvas?.height; const width = canvas?.clientWidth || canvas?.width; @@ -23,7 +24,7 @@ export function createSelection({ fovy <= 0 || fovy >= Math.PI ) - return; + return false; const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry(width, height).radius; const targetScreenRadius = keyholeRadius * 0.61; @@ -45,7 +46,10 @@ export function createSelection({ }, duration, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete, + cancel, }); + return true; } function entityLaunchId(entity) { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index c4c032b10..9ef571428 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -21,6 +21,8 @@ export function createState({ services }) { state._entryCameraView = null; + state._entryLandedView = null; + state._stale = false; state._orbitMatches = 0; From 0de347c328c5489648040039bd001dd9d0682665 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:36 +0400 Subject: [PATCH 4/9] Leave other camera flights running when Space Missions stops without a replay --- src/layers/launches/failedEntry.test.mjs | 18 ++++++++++++++++-- src/layers/launches/replay.js | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index b82663597..9284e4fd2 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -17,6 +17,7 @@ import { createIngestion } from './ingestion.js'; import { createPanel } from './panel.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createReplay } from './replay.js'; const noop = () => {}; @@ -81,6 +82,7 @@ function fakeCamera() { camera.flight = null; flight?.cancel?.(); }, + lookAtTransform: noop, setView({ destination, orientation }) { camera.position = Cesium.Cartesian3.clone(destination); camera.heading = orientation.heading; @@ -144,8 +146,8 @@ async function fixture(t) { clearMissionOverlaySources: noop, hideReplayVehicleOverlay: noop, destroyReplayVehicleOverlay: noop, + syncMissionOverlayEntries: noop, }, - replay: { stopMissionReplay: noop, presentReplay: () => null }, selection: { focusFullGlobe(target, flight) { target.camera.flyTo({ @@ -182,6 +184,7 @@ async function fixture(t) { parts.policyHelpers = createPolicyHelpers(context); parts.model = createModel(context); parts.panel = createPanel(context); + parts.replay = createReplay(context); parts.ingestion = createIngestion(context); parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); @@ -372,13 +375,24 @@ test('a navigation during a failed first load is not cancelled by the rollback', await untilFlown(f); // Search or Home: navigation releases mission camera ownership, then flies. f.missions.releaseCameraOwnership(); - f.camera.flyTo({ destination: new Cesium.Cartesian3(5, 5, 5) }); + let navigationCancelled = false; + f.camera.flyTo({ + destination: new Cesium.Cartesian3(5, 5, 5), + cancel: () => { + navigationCancelled = true; + }, + }); const cancelled = f.camera.cancelled; const navigated = f.view(); release(); assert.equal(await entering, false); await f.settle(); + assert.equal( + navigationCancelled, + false, + 'the rollback does not cancel the Search/Home flight', + ); assert.equal(f.camera.cancelled, cancelled); assert.deepEqual(f.view(), navigated); }); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 86b6a08f0..e74c636f9 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,9 +314,11 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - layerState._viewer?.camera?.cancelFlight(); - if (layerState._viewer?.camera) + // Without a replay the camera is not ours: leave other flights running. + if (stoppedLaunchId && layerState._viewer?.camera) { + layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } layerState._replayCameraLaunchId = null; layerState._replayPaused = false; layerState._replayPausedAtMs = null; From 8917eebe1254aa0f7413f3d31370fda01103d106 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 5/9] Tighten failed-entry comments and docs --- docs/CURRENT-STATE.md | 2 +- src/layers/launches/controls.js | 2 +- src/layers/launches/failedEntry.test.mjs | 13 ++++--------- src/layers/launches/ingestion.js | 5 ++--- src/layers/launches/lifecycle.js | 5 +---- src/layers/launches/panel.js | 5 +---- src/layers/launches/replay.js | 2 +- 7 files changed, 11 insertions(+), 23 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57c1be594..456c8d8dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5329,7 +5329,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 95a212a73..019154269 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,7 +12,7 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { - // A failed entry no longer gives back the view it replaced. + // Navigation takes the camera; a failed entry must not restore the old view. layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 9284e4fd2..7878a8e2d 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -1,8 +1,5 @@ -// A Space Missions entry whose mission index never loads must not commit the -// mode. The real manager, Context coordinator and launches ingestion run -// against a local /api/launches that answers 429, so the test follows the -// same failure the browser sees: isolation clears the other layers, the first -// update fails, and the entry rolls back to the layers and view it replaced. +// The real manager, Context coordinator and launches ingestion run against a +// local /api/launches, so a failed first load takes the browser's path. import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; @@ -137,8 +134,7 @@ async function fixture(t) { dataSources: new Cesium.DataSourceCollection(), scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, }; - // Scene presentation is inert; source, ingestion, lifecycle, panel views - // and the satellites dependency run unchanged. + // Only scene presentation is stubbed. const parts = { overlays: { shortMissionLabel: (name) => name, @@ -281,7 +277,6 @@ test('a direct Space Missions entry that gets HTTP 429 rolls back instead of iso assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); - // Nothing is left isolating the workspace. assert.equal( await f.manager.setEnabled('wind', true, { origin: 'user' }), true, @@ -454,7 +449,7 @@ test('a refresh failure after a loaded index keeps the mode and its records', as assert.equal(f.missions.getStats().error, 'HTTP 429'); assert.equal(f.rosters.at(-1).entries.length, 1); - // Leaving a mode that loaded keeps today's camera behaviour. + // Leaving a loaded mode does not restore the pre-entry view. await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); await f.settle(); assert.deepEqual(f.view(), framed); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 171c95a63..9994acb86 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -229,9 +229,8 @@ export function createIngestion({ if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; console.warn('[Data:RocketLaunches] Fetch error:', error); - // Without one usable index there is no mode to enter: report the - // failure so the manager fails the enable closed and Context rolls - // back. A refresh after a loaded index keeps its records instead. + // No index yet: fail the enable so Context rolls back. + // After a loaded index, a failed refresh keeps its records. if (layerState._lastUpdate === null) { parts.panel.renderMissionRoster(); throw error; diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6ef0ce4df..6d87b47d8 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -139,10 +139,7 @@ function sameCameraView(camera, view) { ); } -/** - * Return to the pose before entry while the entry's full-globe flight still - * holds the camera: in flight, or landed with nothing moved since. - */ +/** Restore the pre-entry view if the entry flight still holds the camera. */ function restoreCameraView(viewer, view, landedView) { const camera = viewer?.camera; if (!camera) return; diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index d45d4364c..b93fcb2e5 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -263,10 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) { publishPanel(); } - /** - * The roster entries as the list shows them; null while the first index - * is still loading, and an empty failed roster once that load failed. - */ + /** The roster as listed; null until the first index loads or fails. */ function presentRoster() { if (layerState._lastUpdate === null && !layerState._launches.length) { if (!layerState._lastError) return null; diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index e74c636f9..bf6ad84de 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,7 +314,7 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - // Without a replay the camera is not ours: leave other flights running. + // Cancel only a replay's flight; any other flight runs on. if (stoppedLaunchId && layerState._viewer?.camera) { layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); From 096b891099e15cefd9c08ea982fcb8e323fff80f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 6/9] Simplify failed-entry camera handling and tests --- .../1578-space-missions-failed-entry.md | 6 +- docs/CURRENT-STATE.md | 2 +- src/layers/launches/controls.js | 2 - src/layers/launches/failedEntry.test.mjs | 107 +++++++----------- src/layers/launches/ingestion.js | 1 - src/layers/launches/lifecycle.js | 9 +- 6 files changed, 49 insertions(+), 78 deletions(-) diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md index 9ad1630db..cb910d30e 100644 --- a/changelog.d/1578-space-missions-failed-entry.md +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -1,5 +1,3 @@ - Space Missions no longer locks the workspace when its mission index fails to - load. A 429, timeout or bad payload on `/api/launches` now rolls the entry - back to the previous layers and camera view instead of leaving an empty, - isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` - rather than loading forever (German, @gekh, #1578). + load: the entry rolls back to the previous layers and view, and the roster + reports the index as unavailable (German, @gekh, #1578). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 456c8d8dc..98b61b3b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5329,7 +5329,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 019154269..b0dc035cb 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,8 +12,6 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { - // Navigation takes the camera; a failed entry must not restore the old view. - layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 7878a8e2d..0b5a20e04 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -1,8 +1,7 @@ // The real manager, Context coordinator and launches ingestion run against a -// local /api/launches, so a failed first load takes the browser's path. +// stubbed /api/launches, so a failed first load takes the browser's path. import test from 'node:test'; import assert from 'node:assert/strict'; -import http from 'node:http'; import * as Cesium from 'cesium'; import { DataLayerManager } from '../../data/manager.js'; import { ContextControls } from '../../ui/contextControls.js'; @@ -92,24 +91,15 @@ function fakeCamera() { async function fixture(t) { const upstream = { status: 429, results: [launchRecord] }; - const server = http.createServer(async (req, res) => { - if (req.url === '/api/celestrak/active') { - res.end(''); - return; - } - if (upstream.hold) await upstream.hold; - if (upstream.status !== 200) { - res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); - return; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ results: upstream.results })); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const origin = `http://127.0.0.1:${server.address().port}`; const source = createLaunchSource({ - fetchImpl: (url, options) => fetch(new URL(url, origin), options), + async fetchImpl(url) { + if (url === '/api/celestrak/active') return new Response(''); + if (upstream.hold) await upstream.hold; + return Response.json( + upstream.status === 200 ? { results: upstream.results } : {}, + { status: upstream.status }, + ); + }, }); const services = { @@ -200,14 +190,13 @@ async function fixture(t) { manager.register(missions); missions.attachDataManager(manager); - const toasts = []; const controls = new ContextControls({ elements: {}, installations: {}, actions: { getCockpit: () => null, claimVisualAuthority: noop, - showToast: (message) => toasts.push(message), + showToast: noop, setClearBusy: noop, syncDetection: noop, scheduleLayout: noop, @@ -223,8 +212,6 @@ async function fixture(t) { for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind']) await manager.setEnabled(id, false); await missions.destroy(viewer); - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); }); const enabledIds = () => [...manager.getEnabledLayerIds()].sort(); @@ -249,56 +236,46 @@ async function fixture(t) { state, camera, rosters, - toasts, enabledIds, view, settle, }; } -test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => { - const f = await fixture(t); - assert.equal( - await f.manager.setEnabled('earthquakes', true, { origin: 'user' }), - true, - ); - const before = f.view(); +const entries = { + direct: (f) => + f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + 'Context-panel': async (f) => + ( + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }) + ).ok, +}; - const entered = await f.manager.setEnabled('rocket-launches', true, { - origin: 'user', +for (const [path, enter] of Object.entries(entries)) + test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + + const entered = await enter(f); + await f.settle(); + + assert.equal(entered, false, 'a failed mission index fails the enable'); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.equal(f.controls.getContextModeState().active, false); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); + assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); + + assert.equal( + await f.manager.setEnabled('wind', true, { origin: 'user' }), + true, + 'an unrelated layer is not blocked after the failed entry', + ); }); - await f.settle(); - - assert.equal(entered, false, 'a failed mission index fails the enable'); - assert.deepEqual(f.enabledIds(), ['earthquakes']); - assert.equal(f.controls.getContextModeState().mode, null); - assert.equal(f.controls.getContextModeState().active, false); - assert.equal(f.missions.getStats().error, 'HTTP 429'); - assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); - assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); - - assert.equal( - await f.manager.setEnabled('wind', true, { origin: 'user' }), - true, - 'an unrelated layer is not blocked after the failed entry', - ); -}); - -test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => { - const f = await fixture(t); - await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); - const before = f.view(); - - const result = await f.controls.setContextMode('space-missions', { - claimVisualAuthority: false, - }); - await f.settle(); - - assert.equal(result.ok, false); - assert.deepEqual(f.enabledIds(), ['earthquakes']); - assert.equal(f.controls.getContextModeState().mode, null); - assert.deepEqual(f.view(), before); -}); // Holds the /api/launches reply until the returned release is called. function holdReply(f) { diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 9994acb86..e0366a1e7 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -235,7 +235,6 @@ export function createIngestion({ parts.panel.renderMissionRoster(); throw error; } - layerState._entryCameraView = null; } } } diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6d87b47d8..a8de16d06 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -241,12 +241,12 @@ export function createLifecycle({ // A new attempt at an index that never loaded starts from loading. layerState._lastError = null; parts.panel.renderMissionRoster(); + // Kept until the mission index loads, so an entry that never does + // gives the view back. A newer flight takes the camera and drops it. + layerState._entryCameraView = captureCameraView(layerState._viewer); + layerState._entryLandedView = null; } - // Kept until the mission index loads, so an entry that never does - // gives the view back. A newer flight takes the camera and drops it. const token = layerState._lifecycleToken; - layerState._entryCameraView = captureCameraView(layerState._viewer); - layerState._entryLandedView = null; const flying = parts.selection.focusFullGlobe(layerState._viewer, { complete: () => { if (token === layerState._lifecycleToken) @@ -304,7 +304,6 @@ export function createLifecycle({ layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; - layerState._entryCameraView = null; layerState._lifecycleToken++; layerState._updateDirty = false; await parts.ingestion.restoreSatelliteDependency(); From 13219828e379494ff56d842d031aeae608fb424d Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 19:20:11 +0100 Subject: [PATCH 7/9] fix(launches): cancel mission flights on exit and name a failed index load Review fixes on top of #1578: - Exiting Space Missions cancels the layer's own camera flights again (entry, roster preview, mission focus); only a flight navigation started after releaseCameraOwnership() runs on. - A failed first index load says so: "Space Missions unavailable: the mission index could not load. Try again later." on both the Context and Data Layers routes, instead of a generic transition toast. Translated. - Regression tests for both; docs and changelog describe the real feedback. Co-Authored-By: Claude Opus 5.5 --- .../1578-space-missions-failed-entry.md | 7 ++-- docs/CURRENT-STATE.md | 2 +- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/launches/controls.js | 1 + src/layers/launches/failedEntry.test.mjs | 39 ++++++++++++++++++- src/layers/launches/policyHelpers.js | 1 + src/layers/launches/replay.js | 8 +++- src/layers/launches/selection.js | 3 ++ src/layers/launches/state.js | 3 ++ src/ui/contextActions.js | 17 +++++++- src/ui/contextBindings.js | 9 +++-- src/ui/contextLayerChanges.js | 13 +++++-- 22 files changed, 98 insertions(+), 16 deletions(-) diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md index 9ad1630db..5d8d67735 100644 --- a/changelog.d/1578-space-missions-failed-entry.md +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -1,5 +1,4 @@ - Space Missions no longer locks the workspace when its mission index fails to - load. A 429, timeout or bad payload on `/api/launches` now rolls the entry - back to the previous layers and camera view instead of leaving an empty, - isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` - rather than loading forever (German, @gekh, #1578). + load (429, timeout or bad payload): entry rolls back to the previous layers + and camera view and a toast says the index could not load (German, @gekh, + #1578). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c261c6205..76deeb9a0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5336,7 +5336,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 10d7ab268..1cd3834d4 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -301,6 +301,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", + "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.", "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ca4df37f7..d7e73583e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -760,6 +760,7 @@ "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.", "30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "Internet radio and local SDR": "Internetradio und lokales SDR", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5f43c2a35..a9ce5da9f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -192,6 +192,7 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.", "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE", "RADIO": "RADIO", "ENABLE": "ACTIVAR", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a610754ed..2d64b3e2a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2805,6 +2805,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4ab1283fc..d349fe1e9 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2805,6 +2805,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.", "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b278db058..5d0585cf7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2663,6 +2663,7 @@ "AVAILABLE MISSIONS": "利用可能なミッション", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", + "Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。", "30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "Internet radio and local SDR": "インターネットラジオとローカルSDR", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bd0ca7299..6ddf3238e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2807,6 +2807,7 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.", "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e5374f3c0..861e4245d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -722,6 +722,7 @@ "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.", "30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН", "Radio playback": "Воспроизведение радио", "Radio volume": "Громкость радио", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index b94a91dd5..67efb91bb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2875,6 +2875,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.", "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0875775b2..8832de1fe 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2562,6 +2562,7 @@ "AVAILABLE MISSIONS": "可用任务", "SELECT A MISSION TO INSPECT": "选择一项任务以查看", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "Internet radio and local SDR": "网络电台与本地 SDR", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c6bff6cb6..dce0b2e75 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2550,6 +2550,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 019154269..d8b4c6d6a 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -17,6 +17,7 @@ export function createControls({ state: layerState, services, parts, source }) { parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); + layerState._cameraReleased = true; }, /** diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 7878a8e2d..52adcb792 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -134,7 +134,7 @@ async function fixture(t) { dataSources: new Cesium.DataSourceCollection(), scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, }; - // Only scene presentation is stubbed. + // Scene presentation is stubbed; ingestion, lifecycle and panel are real. const parts = { overlays: { shortMissionLabel: (name) => name, @@ -454,3 +454,40 @@ test('a refresh failure after a loaded index keeps the mode and its records', as await f.settle(); assert.deepEqual(f.view(), framed); }); +// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). +test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress'); + + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.equal( + f.camera.flight, + null, + 'leaving Space Missions stops the flight it started (main cancels it)', + ); +}); +// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). +// Encodes the "surface the reason where the user acted" fix: the roster that +// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the +// lasting feedback (the toast) has to name the cause. +test('a failed first load tells the user the mission index is unavailable', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, null, 'rolled back'); + assert.ok( + f.toasts.some((message) => /mission index/i.test(message)), + `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, + ); +}); diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index d4539289a..b0ec18373 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -229,6 +229,7 @@ export function createPolicyHelpers({ layerState._viewer.camera.positionCartographic?.height, ); const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(position, 0), { diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index bf6ad84de..d2fe51bb4 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,8 +314,12 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - // Cancel only a replay's flight; any other flight runs on. - if (stoppedLaunchId && layerState._viewer?.camera) { + // Cancel the layer's own flights, never one navigation started after + // taking the camera. + if ( + (stoppedLaunchId || !layerState._cameraReleased) && + layerState._viewer?.camera + ) { layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 0f7955551..6d7ae40cb 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -33,6 +33,7 @@ export function createSelection({ ); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + layerState._cameraReleased = false; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromRadians( cartographic.longitude, @@ -191,6 +192,7 @@ export function createSelection({ Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3(), ); + layerState._cameraReleased = false; layerState._viewer.camera.flyTo({ destination, orientation: { direction, up }, @@ -213,6 +215,7 @@ export function createSelection({ layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( `rocket-launch:${launch.id}`, ); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(launchPosition, 0), { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 9ef571428..beb8d1bf7 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -23,6 +23,9 @@ export function createState({ services }) { state._entryLandedView = null; + /** Navigation took the camera; set until the layer flies again. */ + state._cameraReleased = false; + state._stale = false; state._orbitMatches = 0; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index a621137ad..702130073 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,6 +4,18 @@ import { contextModeWord, } from '../contextModePolicy.js'; +/** Why a Space Missions entry failed, when its mission index never loaded. */ +export function spaceMissionsIndexFailure(dataManager) { + const stats = dataManager?.layers + ?.get('rocket-launches') + ?.module?.getStats?.(); + return stats?.error && stats.lastUpdate == null + ? t( + 'Space Missions unavailable: the mission index could not load. Try again later.', + ) + : null; +} + export async function _runUserFacingContextAction( operation, message = t('Context could not restore every layer; try again'), @@ -19,7 +31,10 @@ export async function _runUserFacingContextAction( onFailure: (error) => { if (this.destroyed) return; console.warn('[Context] user-facing transition failed', error); - this.showToast(localizedMessage || message); + this.showToast( + localizedMessage || + (typeof message === 'function' ? message() : message), + ); }, }); } finally { diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index cc09c49bf..c5e4fc52e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -1,4 +1,5 @@ import { t } from '../i18n/index.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; export function _initGlobalContextPanel() { @@ -62,9 +63,11 @@ export function _initGlobalContextPanel() { void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), - t( - 'Space Missions could not complete the requested transition; try again', - ), + () => + (nextMode && spaceMissionsIndexFailure(this._dataManager)) || + t( + 'Space Missions could not complete the requested transition; try again', + ), ).then((succeeded) => { if ( !this.destroyed && diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index d4ddd6cd5..295242239 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -9,6 +9,7 @@ import { spaceMissionEntryCancellationDisposition, } from '../contextModePolicy.js'; import { localizeLayerName } from './layerNames.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; /** * A blocked-visibility reason in the UI language. Reasons stay English (voice @@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) { return; } if (change?.type === 'visibility-failed') { - const failureMessage = t('{layerId} could not {action} cleanly', { - layerId: change.layerId, - action: change.enabled ? t('start') : t('stop'), - }); + const failureMessage = + (change.layerId === 'rocket-launches' && + change.enabled && + spaceMissionsIndexFailure(this._dataManager)) || + t('{layerId} could not {action} cleanly', { + layerId: change.layerId, + action: change.enabled ? t('start') : t('stop'), + }); // A failed direct Context-shell START has already had its siblings // cleared by the visibility guard. Wait outside the synchronous manager // notification for this queue to settle, then reconcile the complete From efa4374644a8558b900aa927ea57d6beae6dbb52 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 19:28:56 +0100 Subject: [PATCH 8/9] fix(launches): hand the camera over before stopping, and only blame a fresh index error - releaseCameraOwnership marks the camera released before stopping, so a flight navigation asked to preserve survives; exit also releases it, so an inactive layer never cancels another owner's flight. - The failure toast names the mission index only when its error is from the current Context attempt (stats.errorAt), not an earlier one. Co-Authored-By: Claude Opus 5.5 --- src/layers/launches/controls.js | 3 ++- src/layers/launches/failedEntry.test.mjs | 33 ++++++++++++++++++++---- src/layers/launches/ingestion.js | 1 + src/layers/launches/lifecycle.js | 2 ++ src/layers/launches/state.js | 2 ++ src/ui/contextActions.js | 7 ++--- src/ui/contextBindings.js | 3 ++- 7 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d8b4c6d6a..9adc88922 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -14,10 +14,10 @@ export function createControls({ state: layerState, services, parts, source }) { releaseCameraOwnership() { // Navigation takes the camera; a failed entry must not restore the old view. layerState._entryCameraView = null; + layerState._cameraReleased = true; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); - layerState._cameraReleased = true; }, /** @@ -60,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + errorAt: layerState._lastErrorAt, stale: layerState._stale, }; }, diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 52adcb792..4330cf0d8 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -6,6 +6,7 @@ import http from 'node:http'; import * as Cesium from 'cesium'; import { DataLayerManager } from '../../data/manager.js'; import { ContextControls } from '../../ui/contextControls.js'; +import { spaceMissionsIndexFailure } from '../../ui/contextActions.js'; import { createLaunchSource } from './source.js'; import { createState } from './state.js'; import { createModel } from './model.js'; @@ -454,7 +455,6 @@ test('a refresh failure after a loaded index keeps the mode and its records', as await f.settle(); assert.deepEqual(f.view(), framed); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { const f = await fixture(t); f.upstream.status = 200; @@ -473,10 +473,7 @@ test('exiting during a successful entry flight cancels the mission-owned flight' 'leaving Space Missions stops the flight it started (main cancels it)', ); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). -// Encodes the "surface the reason where the user acted" fix: the roster that -// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the -// lasting feedback (the toast) has to name the cause. +// The rollback hides the roster, so the toast has to name the cause. test('a failed first load tells the user the mission index is unavailable', async (t) => { const f = await fixture(t); await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); @@ -491,3 +488,29 @@ test('a failed first load tells the user the mission index is unavailable', asyn `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, ); }); + +test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const flight = f.camera.flight; + assert.ok(flight, 'the entry flight is in progress'); + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, flight, 'navigation decides about the flight'); + // After exit an inactive layer never cancels another owner's flight. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) }); + const other = f.camera.flight; + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, other); +}); + +test("an earlier attempt's index error is not blamed for a later failure", async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + assert.match(spaceMissionsIndexFailure(f.manager), /mission index/); + assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 9994acb86..be6190815 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -228,6 +228,7 @@ export function createIngestion({ } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; + layerState._lastErrorAt = Date.now(); console.warn('[Data:RocketLaunches] Fetch error:', error); // No index yet: fail the enable so Context rolls back. // After a loaded index, a failed refresh keeps its records. diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6d87b47d8..13150e805 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -285,6 +285,7 @@ export function createLifecycle({ parts.ingestion.clearPostTleRetry(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); if (layerState._dataSource) layerState._dataSource.show = false; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); @@ -310,6 +311,7 @@ export function createLifecycle({ await parts.ingestion.restoreSatelliteDependency(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); parts.ingestion.clearPostTleRetry(); if (layerState._clickHandler) layerState._clickHandler.destroy(); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index beb8d1bf7..fd2ae7176 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._lastErrorAt = null; + state._entryCameraView = null; state._entryLandedView = null; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index 702130073..eeb22fefe 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,12 +4,13 @@ import { contextModeWord, } from '../contextModePolicy.js'; -/** Why a Space Missions entry failed, when its mission index never loaded. */ -export function spaceMissionsIndexFailure(dataManager) { +/** Why a Space Missions entry failed: its mission index never loaded, and + * the error is no older than `since` (an earlier attempt's is not the cause). */ +export function spaceMissionsIndexFailure(dataManager, since = 0) { const stats = dataManager?.layers ?.get('rocket-launches') ?.module?.getStats?.(); - return stats?.error && stats.lastUpdate == null + return stats?.error && stats.lastUpdate == null && stats.errorAt >= since ? t( 'Space Missions unavailable: the mission index could not load. Try again later.', ) diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index c5e4fc52e..8c6b8760e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -60,11 +60,12 @@ export function _initGlobalContextPanel() { const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this._claimContextVisualAuthority(); + const startedAt = Date.now(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), () => - (nextMode && spaceMissionsIndexFailure(this._dataManager)) || + (nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) || t( 'Space Missions could not complete the requested transition; try again', ), From 8da8c05eaf5b065fdb547480f18a5342e00d4f42 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 20:10:34 +0100 Subject: [PATCH 9/9] test(launches): keep the fixture's toast recorder for the failure-message test Co-Authored-By: Claude Opus 5.5 --- src/layers/launches/failedEntry.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index d43a5c6e9..a2bccad7d 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -191,13 +191,14 @@ async function fixture(t) { manager.register(missions); missions.attachDataManager(manager); + const toasts = []; const controls = new ContextControls({ elements: {}, installations: {}, actions: { getCockpit: () => null, claimVisualAuthority: noop, - showToast: noop, + showToast: (message) => toasts.push(message), setClearBusy: noop, syncDetection: noop, scheduleLayout: noop, @@ -234,6 +235,7 @@ async function fixture(t) { manager, controls, missions, + toasts, state, camera, rosters,