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