From 2a47bdb8e74e34ae633e5502a88461063f069851 Mon Sep 17 00:00:00 2001 From: Arthur Bogaart <526197+abogaart@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:28:23 +0200 Subject: [PATCH 1/5] fix: spawn Apple /bin/chmod by absolute path when hardening credentials The macOS credential hardener strips ACLs with `chmod -N`, spawning `chmod` by bare name. On machines where Nix home-manager or Homebrew coreutils sit ahead of /bin on PATH, that resolves to GNU chmod, which has no `-N` flag and exits non-zero. The hardener treated that as a failure and refused every save from the Provider Settings panel with "could not restrict the credential file to your account; nothing was saved". Spawn /bin/chmod by absolute path instead, matching how the Windows branch already resolves its native tools without consulting PATH, and add a regression test that pins the absolute path. --- server/standalone/key-setup-hardening.mjs | 6 +++++- src/keySetupHardening.test.mjs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/server/standalone/key-setup-hardening.mjs b/server/standalone/key-setup-hardening.mjs index 002ccabdc..e2d11182c 100644 --- a/server/standalone/key-setup-hardening.mjs +++ b/server/standalone/key-setup-hardening.mjs @@ -123,7 +123,11 @@ export function hardenCredentialFile( if (platform !== 'win32') { try { if (platform === 'darwin') { - const aclRemoval = spawn('chmod', ['-N', filepath], { + // `-N` (strip the ACL) is Apple-only. Spawn /bin/chmod by absolute + // path, never by name: a Nix/Homebrew coreutils profile puts GNU chmod + // first on PATH, and GNU chmod rejects `-N` — which read as a + // hardening failure and refused every save on such machines. + const aclRemoval = spawn('/bin/chmod', ['-N', filepath], { stdio: 'ignore', }); if (!commandCompletedSuccessfully(aclRemoval)) return false; diff --git a/src/keySetupHardening.test.mjs b/src/keySetupHardening.test.mjs index 2578be37d..c519576e3 100644 --- a/src/keySetupHardening.test.mjs +++ b/src/keySetupHardening.test.mjs @@ -55,6 +55,21 @@ test('macOS ACL removal failure stops before chmod and fails closed', () => { assert.deepEqual(fileSystem.calls, [], 'mode bits must not disguise an ACL-removal failure'); }); +test('macOS ACL removal spawns Apple /bin/chmod by absolute path, not via PATH', () => { + const spawned = []; + hardenCredentialFile(FILE, { + platform: 'darwin', + fileSystem: fileSystemWithMode(0o100600), + spawn(command, args) { + spawned.push([command, args]); + return { status: 0, signal: null }; + }, + }); + // A coreutils-first PATH (Nix home-manager, Homebrew) resolves bare `chmod` + // to GNU chmod, which has no `-N` and would fail every save. + assert.deepEqual(spawned, [['/bin/chmod', ['-N', FILE]]]); +}); + test('POSIX hardening verifies the resulting 0600 mode', () => { const goodFs = fileSystemWithMode(0o100600); assert.equal(hardenCredentialFile(FILE, { From b456eb8ff1a953dd9bf0f8d7825dff954d39bcdd Mon Sep 17 00:00:00 2001 From: Sean Armstrong Date: Tue, 22 Sep 2026 09:53:05 -0600 Subject: [PATCH 2/5] fix(render): keep Cesium's model atmosphere off Apple Metal iPad and iPhone show "An error occurred while rendering. Rendering has stopped." on load. Cesium's AtmosphereStageVS calls czm_computeGroundAtmosphereScattering with its `out` parameters bound directly to varyings: czm_computeGroundAtmosphereScattering( v_positionWC, lightDirection, v_atmosphereRayleighColor, v_atmosphereMieColor, v_atmosphereOpacity); ANGLE's Metal backend wraps each `out` argument in an ANGLE_out(...) helper taking `thread T &`, but vertex-output members live in `__metal_generic` and cannot bind to `thread`, so the program fails to LINK and Cesium tears down the render loop. This is the only shader in Cesium that binds varyings to `out` params -- SkyAtmosphere (czm_computeScattering) and the ground-atmosphere fragment path both route through locals, so they are unaffected and stay on. ModelSceneGraph.configurePipeline attaches the stage under a single condition, `fog.enabled && fog.renderable`, so clearing `fog.renderable` keeps the broken program from ever being built. `renderable` rather than `enabled` is deliberate: Fog.update returns early when `enabled` is false and forfeits the density that drives 3D Tiles screen-space-error scaling, and visualSettings saves/restores `fog.enabled` around IR boost, which would resurrect the broken shader mid-session. Detection is a WebGL2 link probe of the same out-param/varying pattern, so a future driver fix silently restores the effect with no code change, OR'd with iOS/iPadOS platform detection as a backstop for when no probe context can be created. iPadOS desktop mode is identified by the Macintosh UA token plus maxTouchPoints > 1, since real Macs report zero and navigator.platform is deprecated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HoRD3yHVgfTHeikTmsUoHf --- CHANGELOG.md | 12 ++ docs/CURRENT-STATE.md | 18 +++ scripts/format-scope.json | 1 + scripts/package-boundaries.json | 3 +- src/app/atmosphereCompat.js | 177 +++++++++++++++++++++++++ src/app/atmosphereCompat.test.mjs | 209 ++++++++++++++++++++++++++++++ src/app/viewer.js | 5 + 7 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/app/atmosphereCompat.js create mode 100644 src/app/atmosphereCompat.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcdebf70..8893a60e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,18 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). +- Render on iPad and iPhone instead of stopping with "An error occurred while + rendering." Cesium's per-vertex model atmosphere binds shader `out` + parameters directly to varyings, which Apple's Metal/ANGLE backend cannot + link, so the program failed and the render loop was torn down. The stage is + now kept out of the pipeline on affected devices by clearing + `scene.fog.renderable`, which leaves `fog.enabled` — and the fog density that + drives 3D Tiles refinement — untouched. Detection is a WebGL2 link probe of + the same pattern, so a future driver fix restores the effect with no code + change, with iOS/iPadOS detection as a backstop. Sky atmosphere and the + ground-atmosphere fragment path route through locals and are unaffected. + `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and + the `renderable`-not-`enabled` choice. - Remove the spurious scrollbars that appeared on both panel stacks at narrow widths (720px and below) as soon as a panel was expanded. The stacks scroll diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 67acfffce..6592915f9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -664,6 +664,24 @@ snapshots, lifecycle actions and row descriptors. Remount and teardown remove listeners and row subscriptions; obsolete completions do not repaint old rows. The clear control presents busy state while its existing action owns the transaction. + +## Model atmosphere on Apple Metal + +Cesium's per-vertex model atmosphere is kept out of the pipeline on devices +whose driver cannot link it. `AtmosphereStageVS` binds shader `out` parameters +directly to varyings, which ANGLE's Metal backend rejects at link time, tearing +down the render loop on iPadOS and iOS. `ModelSceneGraph.configurePipeline` +attaches that stage only when `fog.enabled && fog.renderable`, so the viewer +clears `scene.fog.renderable` and leaves `fog.enabled` — and the fog density +that drives 3D Tiles screen-space-error scaling — intact. Sky atmosphere and the +ground-atmosphere fragment path route through locals and are unaffected. + +Detection is a WebGL2 link probe of the same out-parameter/varying pattern, so a +future driver fix restores the effect with no code change; iOS/iPadOS platform +detection is the backstop for when no probe context can be created. Applied +during viewer construction, before any tile builds a draw command. + + ## Map Source control ownership Map Source controls own chip listeners, source-state subscriptions and selection diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 97bb0b8f2..01e429d0d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -31,6 +31,7 @@ "src/app/mapLifecycle.test.mjs", "src/app/startupCamera.test.mjs", "src/app/viewer.js", + "src/app/atmosphereCompat.test.mjs", "src/standalone/application.js", "src/standalone/controls.js", "src/standalone/data.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5273abbf8..097ac488e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -20,7 +20,7 @@ }, "viewer": { "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], + "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], "external": ["cesium"] }, "vite-build": { @@ -1287,6 +1287,7 @@ "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", + "src/app/atmosphereCompat.js", "src/bloom.js", "src/camera.js", "src/cameraVerbs.js", diff --git a/src/app/atmosphereCompat.js b/src/app/atmosphereCompat.js new file mode 100644 index 000000000..802f2b212 --- /dev/null +++ b/src/app/atmosphereCompat.js @@ -0,0 +1,177 @@ +/** + * Workaround for Apple's Metal/ANGLE shader translator rejecting Cesium's + * per-vertex model atmosphere. + * + * Cesium's `AtmosphereStageVS` calls a function whose `out` parameters are + * bound DIRECTLY to varyings: + * + * czm_computeGroundAtmosphereScattering( + * v_positionWC, lightDirection, + * v_atmosphereRayleighColor, v_atmosphereMieColor, v_atmosphereOpacity); + * + * ANGLE's Metal backend wraps each `out` argument in an `ANGLE_out(...)` + * helper that takes a `thread T &`. Vertex-output struct members live in + * `__metal_generic`, which cannot bind to `thread`, so the program fails to + * LINK (not compile) and Cesium tears the render loop down: + * + * RuntimeError: Program failed to link. ... MSL compilation error: + * reference to type 'thread float3' could not bind to an lvalue of type + * '__metal_generic float3' + * + * This is the only shader in Cesium that binds varyings to `out` params -- + * SkyAtmosphere and the ground-atmosphere fragment path both route through + * locals first, so they are unaffected and stay on. + * + * Cesium attaches that stage in `ModelSceneGraph.configurePipeline` under a + * single condition: + * + * const fogRenderable = frameState.fog.enabled && frameState.fog.renderable; + * if (fogRenderable) modelPipelineStages.push(AtmospherePipelineStage); + * + * so clearing `scene.fog.renderable` keeps the broken stage out of the + * pipeline entirely -- the program is never built, so it cannot fail to link. + * + * We clear `renderable` rather than `enabled` deliberately: + * - `Fog.update` still computes fog density when only `renderable` is off, + * preserving the distance-based screen-space-error scaling that governs + * 3D Tiles refinement. Clearing `enabled` returns early and forfeits it. + * - `visualSettings.js` saves and restores `scene.fog.enabled` around IR + * boost. Parking our fix on `enabled` would let that restore resurrect + * the broken shader mid-session; `renderable` is orthogonal to it. + * + * @module app/atmosphereCompat + */ + +/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */ +const PROBE_VERTEX_SHADER = `#version 300 es +precision highp float; +in vec3 a_position; +out vec3 v_probe; +void writeOut(out vec3 value) { value = vec3(1.0); } +void main() { + writeOut(v_probe); + gl_Position = vec4(a_position, 1.0); +}`; + +const PROBE_FRAGMENT_SHADER = `#version 300 es +precision highp float; +in vec3 v_probe; +out vec4 fragColor; +void main() { fragColor = vec4(v_probe, 1.0); }`; + +/** + * True when the user agent is iOS or iPadOS. + * + * iPadOS 13+ defaults to desktop-mode browsing and reports `MacIntel` with no + * iPad token, so it is identified by touch points instead -- real Macs report + * `maxTouchPoints` of 0, including Apple Silicon ones that also claim + * `MacIntel`. Brave/Chrome/Firefox on iOS are all WebKit underneath and share + * the same Metal backend, so matching the platform rather than the browser is + * what we want. + */ +export function isAppleMobilePlatform(navigatorLike = globalThis.navigator) { + if (!navigatorLike) return false; + const ua = String(navigatorLike.userAgent || ''); + if (/\b(iPad|iPhone|iPod)\b/.test(ua)) return true; + // Desktop-mode iPadOS presents as a Mac. What separates it from a real Mac + // is touch: Macs report 0 touch points, including Apple Silicon ones that + // also claim `MacIntel`. `navigator.platform` is deprecated and may be + // empty, so the UA's Macintosh token is accepted as an equal signal rather + // than relying on platform alone. + const platform = String(navigatorLike.platform || ''); + const macLike = + platform === 'MacIntel' || + platform === 'MacARM' || + /\bMacintosh\b/.test(ua); + const touchPoints = Number(navigatorLike.maxTouchPoints) || 0; + return macLike && touchPoints > 1; +} + +/** + * Link the probe program and report whether the driver rejects the pattern. + * + * Returns `false` when WebGL2 is unavailable so that a missing context is + * never mistaken for a broken driver -- Cesium has its own handling for that, + * and the caller still falls back to platform detection. + */ +export function probeOutParamVaryingLinkFailure(gl) { + if (!gl) return false; + const vs = gl.createShader(gl.VERTEX_SHADER); + const fs = gl.createShader(gl.FRAGMENT_SHADER); + const program = gl.createProgram(); + if (!vs || !fs || !program) return false; + try { + gl.shaderSource(vs, PROBE_VERTEX_SHADER); + gl.compileShader(vs); + gl.shaderSource(fs, PROBE_FRAGMENT_SHADER); + gl.compileShader(fs); + // A compile failure here is not the bug we are probing for: the driver + // rejects this pattern at LINK time, after both stages compile cleanly. + if ( + !gl.getShaderParameter(vs, gl.COMPILE_STATUS) || + !gl.getShaderParameter(fs, gl.COMPILE_STATUS) + ) + return false; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + return !gl.getProgramParameter(program, gl.LINK_STATUS); + } catch { + return false; + } finally { + gl.deleteShader(vs); + gl.deleteShader(fs); + gl.deleteProgram(program); + } +} + +/** Build a throwaway WebGL2 context for the probe, or null if unavailable. */ +function defaultProbeContext() { + try { + const canvas = + typeof OffscreenCanvas === 'function' + ? new OffscreenCanvas(1, 1) + : globalThis.document?.createElement('canvas'); + return canvas?.getContext('webgl2') ?? null; + } catch { + return null; + } +} + +/** + * Decide whether the per-vertex model atmosphere must be kept out of the + * pipeline on this device. + * + * The probe is authoritative when it fires, so a future driver fix silently + * restores the effect without a code change. Platform detection is the + * backstop for the case where no context can be created -- on Apple mobile a + * link failure is fatal and unrecoverable, so we would rather lose fog than + * gamble the whole render loop on the probe having run. + */ +export function shouldDisableModelAtmosphere({ + navigatorLike = globalThis.navigator, + createProbeContext = defaultProbeContext, +} = {}) { + const gl = createProbeContext(); + if (gl) { + if (probeOutParamVaryingLinkFailure(gl)) return true; + // Probe ran and the driver accepted the pattern. Apple mobile is still + // quarantined: the probe program is far simpler than Cesium's, and a + // false negative there costs the entire scene. + return isAppleMobilePlatform(navigatorLike); + } + return isAppleMobilePlatform(navigatorLike); +} + +/** + * Keep Cesium's broken model-atmosphere stage out of the pipeline. + * + * Returns true when the workaround was applied. Safe to call on any scene; + * scenes without a fog object are left untouched. + */ +export function applyModelAtmosphereWorkaround(scene, options = {}) { + if (!scene?.fog) return false; + if (!shouldDisableModelAtmosphere(options)) return false; + scene.fog.renderable = false; + return true; +} diff --git a/src/app/atmosphereCompat.test.mjs b/src/app/atmosphereCompat.test.mjs new file mode 100644 index 000000000..7f601512b --- /dev/null +++ b/src/app/atmosphereCompat.test.mjs @@ -0,0 +1,209 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isAppleMobilePlatform, + probeOutParamVaryingLinkFailure, + shouldDisableModelAtmosphere, + applyModelAtmosphereWorkaround, +} from './atmosphereCompat.js'; + +const IPAD_OS12 = { + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15', + platform: 'iPad', + maxTouchPoints: 5, +}; +// iPadOS 13+ desktop mode: no iPad token, reports as a Mac. +const IPAD_DESKTOP_MODE = { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + platform: 'MacIntel', + maxTouchPoints: 5, +}; +const MAC_DESKTOP = { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + platform: 'MacIntel', + maxTouchPoints: 0, +}; +const LINUX_DESKTOP = { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/141', + platform: 'Linux x86_64', + maxTouchPoints: 0, +}; + +/** Minimal GL double. `linkOk: false` mimics the Metal translator's refusal. */ +function fakeGl({ linkOk = true, compileOk = true, throwOn = null } = {}) { + const deleted = { shaders: 0, programs: 0 }; + return { + deleted, + VERTEX_SHADER: 1, + FRAGMENT_SHADER: 2, + COMPILE_STATUS: 3, + LINK_STATUS: 4, + createShader: () => ({}), + createProgram: () => ({}), + shaderSource() { + if (throwOn === 'shaderSource') throw new Error('context lost'); + }, + compileShader() {}, + attachShader() {}, + linkProgram() {}, + getShaderParameter: () => compileOk, + getProgramParameter: () => linkOk, + deleteShader: () => (deleted.shaders += 1), + deleteProgram: () => (deleted.programs += 1), + }; +} + +test('identifies iPadOS in both legacy and desktop-mode guises', () => { + assert.equal(isAppleMobilePlatform(IPAD_OS12), true); + assert.equal(isAppleMobilePlatform(IPAD_DESKTOP_MODE), true); +}); + +test('does not mistake a real Mac or a Linux desktop for Apple mobile', () => { + // Regression guard: the desktop-mode check keys on touch points, and a Mac + // reports the same MacIntel platform string with zero of them. + assert.equal(isAppleMobilePlatform(MAC_DESKTOP), false); + assert.equal(isAppleMobilePlatform(LINUX_DESKTOP), false); +}); + +test('treats a missing navigator as not-Apple rather than throwing', () => { + assert.equal(isAppleMobilePlatform(undefined), false); + assert.equal(isAppleMobilePlatform(null), false); +}); + +test('detects desktop-mode iPadOS without navigator.platform', () => { + // `navigator.platform` is deprecated and can be absent or stripped; the UA's + // Macintosh token plus touch points has to carry the detection on its own. + assert.equal( + isAppleMobilePlatform({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 5, + }), + true, + ); + // Same UA, no touch: a real Mac, which must stay untouched. + assert.equal( + isAppleMobilePlatform({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 0, + }), + false, + ); +}); + +test('probe reports failure only when the program fails to link', () => { + assert.equal( + probeOutParamVaryingLinkFailure(fakeGl({ linkOk: false })), + true, + ); + assert.equal( + probeOutParamVaryingLinkFailure(fakeGl({ linkOk: true })), + false, + ); +}); + +test('a compile failure is not reported as the link bug', () => { + // The Metal defect surfaces at link time; both stages compile cleanly. + // Treating a compile error as the bug would disable fog for the wrong reason. + assert.equal( + probeOutParamVaryingLinkFailure( + fakeGl({ compileOk: false, linkOk: false }), + ), + false, + ); +}); + +test('probe releases its GL objects on both paths', () => { + const ok = fakeGl({ linkOk: true }); + probeOutParamVaryingLinkFailure(ok); + assert.equal(ok.deleted.shaders, 2); + assert.equal(ok.deleted.programs, 1); + + const thrown = fakeGl({ throwOn: 'shaderSource' }); + probeOutParamVaryingLinkFailure(thrown); + assert.equal(thrown.deleted.shaders, 2, 'shaders freed even when GL throws'); + assert.equal(thrown.deleted.programs, 1); +}); + +test('probe treats a null context as healthy, not broken', () => { + assert.equal(probeOutParamVaryingLinkFailure(null), false); +}); + +test('a failing probe disables the stage on any platform', () => { + assert.equal( + shouldDisableModelAtmosphere({ + navigatorLike: LINUX_DESKTOP, + createProbeContext: () => fakeGl({ linkOk: false }), + }), + true, + ); +}); + +test('a passing probe still quarantines Apple mobile but frees desktops', () => { + const createProbeContext = () => fakeGl({ linkOk: true }); + assert.equal( + shouldDisableModelAtmosphere({ + navigatorLike: IPAD_DESKTOP_MODE, + createProbeContext, + }), + true, + ); + assert.equal( + shouldDisableModelAtmosphere({ + navigatorLike: LINUX_DESKTOP, + createProbeContext, + }), + false, + ); +}); + +test('falls back to platform detection when no context can be made', () => { + const createProbeContext = () => null; + assert.equal( + shouldDisableModelAtmosphere({ + navigatorLike: IPAD_OS12, + createProbeContext, + }), + true, + ); + assert.equal( + shouldDisableModelAtmosphere({ + navigatorLike: MAC_DESKTOP, + createProbeContext, + }), + false, + ); +}); + +test('applies to the scene by clearing renderable, never enabled', () => { + // `enabled` must survive: Fog.update short-circuits when it is false, which + // forfeits the density used for 3D Tiles screen-space-error scaling, and + // visualSettings' IR boost saves/restores that same flag. + const scene = { fog: { enabled: true, renderable: true } }; + const applied = applyModelAtmosphereWorkaround(scene, { + navigatorLike: IPAD_DESKTOP_MODE, + createProbeContext: () => null, + }); + assert.equal(applied, true); + assert.equal(scene.fog.renderable, false); + assert.equal(scene.fog.enabled, true); +}); + +test('leaves unaffected devices completely untouched', () => { + const scene = { fog: { enabled: true, renderable: true } }; + const applied = applyModelAtmosphereWorkaround(scene, { + navigatorLike: LINUX_DESKTOP, + createProbeContext: () => fakeGl({ linkOk: true }), + }); + assert.equal(applied, false); + assert.equal(scene.fog.renderable, true); +}); + +test('tolerates a scene with no fog object', () => { + assert.equal(applyModelAtmosphereWorkaround({}, {}), false); + assert.equal(applyModelAtmosphereWorkaround(null, {}), false); +}); diff --git a/src/app/viewer.js b/src/app/viewer.js index ec58f52dc..9b6e7f631 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -124,6 +125,10 @@ export function createApplicationViewer({ container, creditContainer }) { }); try { viewer.targetFrameRate = 60; + // Before any tile builds a draw command: Cesium's per-vertex model + // atmosphere fails to LINK on Apple's Metal backend and kills the + // render loop. See app/atmosphereCompat.js. + applyModelAtmosphereWorkaround(viewer.scene); viewer.scene.globe.show = false; viewer.scene.skyAtmosphere.show = true; viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; From ce671ce500a393be27e3cbb2a08799fbca9b6e28 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:52:41 -0500 Subject: [PATCH 3/5] fix(data): load bundled region packs in the browser; bound region lookups (#743) The Natural Earth and neighborhood packs were loaded with a JSON-attributed import(). The Vite dev server answers that request with a JavaScript module, which the browser rejects, so every analyst region scope failed and fell through to a geocode lookup that could take 12-28 s. - src/data/bundledJson.js fetches the pack from a new URL(..., import.meta.url) asset in the browser; under Node the caller's attributed import runs. - resolveRegionRingForQuery caps the geocode + admin-boundary rung at 3 s and returns { error: 'region-timeout' }; the lookup keeps running and fills the cache. The analyst query reports it as code 'region-timeout'. The Nepal locator opts out of the budget. - Unit tests for the loader, the timeout and the analyst error; the behavior harness checks the Gulf of Mexico scope and retries Texas on region-timeout. Co-authored-by: Bilawal Sidhu --- CHANGELOG.md | 7 +++ scripts/format-scope.json | 1 + scripts/package-boundaries.json | 4 ++ scripts/qa-voice-routing.mjs | 17 +++++- src/annotations/regionRing.test.mjs | 86 +++++++++++++++++++++++++++++ src/annotations/resolver.js | 30 +++++++++- src/browserModuleBoundary.test.mjs | 2 +- src/data/analystEngine.js | 10 +++- src/data/analystEngine.test.mjs | 13 +++++ src/data/bundledJson.js | 29 ++++++++++ src/data/bundledJson.test.mjs | 36 ++++++++++++ src/data/naturalEarthRegions.js | 41 +++++++------- src/data/neighborhoodPolygons.js | 12 ++-- src/standalone/application.js | 2 + src/voice/gevActions.js | 1 + 15 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 src/annotations/regionRing.test.mjs create mode 100644 src/data/bundledJson.js create mode 100644 src/data/bundledJson.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe29059d..79d393162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over + the Alps") work again in the dev server: the bundled Natural Earth and + neighborhood packs are fetched as JSON in the browser + (`src/data/bundledJson.js`). When a region is not in the bundled packs, the + geocode and admin-boundary fallback answers `region-timeout` after 3 s + instead of holding the reply. + - Transit and Directions rows repaint as soon as their data lands again: `refreshLayerStats()` now lives on the layer lifecycle, not only on the compatibility facade. `scripts/qa-radio.mjs` uses it instead of a private diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0d629d0f3..fb425c09b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -562,6 +562,7 @@ "src/data/telegeographySubmarineCables.test.mjs", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/layers/submarineCables/bundledSource.test.mjs", "src/maps/availability.js", "src/maps/catalog.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0184b4684..13b758c6e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1174,6 +1174,7 @@ "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", "src/data/local_data/natural_earth/regions.json", @@ -1408,6 +1409,7 @@ "src/data/regionalBrief.js", "src/data/regionalModel.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/rocketLaunches.js", "src/data/routePlausible.js", @@ -2059,6 +2061,7 @@ "src/data/pickRegistry.js", "src/data/radioCountry.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/routePlausible.js", "src/data/satelliteClass.js", @@ -2436,6 +2439,7 @@ "src/data/local_data/neighborhoods/san-francisco.json", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/scenePick.js", "src/keylessGeocoder.js", diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 4c5c9dedc..de5f44fec 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -625,12 +625,27 @@ async function runBehaviorLayer() { // (4b) analyst engine end-to-end: count flights over Texas (region ring // via NE-pack/admin machinery), then a follow-up over the same set. + // A cold boundary lookup can exceed the resolver's budget; it answers + // region-timeout and keeps loading, so asking again must succeed. + let texasTimeouts = 0; r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + while (r?.code === 'region-timeout' && texasTimeouts < 8) { + texasTimeouts += 1; + await settle(5000); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + } report(r?.ok === true && Number.isFinite(r?.count) && r.count > 0 && String(r?.coverage?.scope || '').includes('Texas'), - 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} err=${r?.error || ''}`); + 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`); r = await run('analyst_query', { followUp: true, filters: [{ field: 'onGround', op: 'eq', value: false }], sortBy: 'altitudeM', limit: 3 }); report(r?.ok === true && r?.coverage?.followUp === true, 'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`); + // A marine region resolves from the bundled Natural Earth pack in the page, + // without the slower geocode fallback. + const gulfStarted = Date.now(); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Gulf of Mexico' }, limit: 5 }); + const gulfMs = Date.now() - gulfStarted; + report(r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && gulfMs < 3000, + 'behavior: analyst resolves the Gulf of Mexico from the bundled pack', `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`); // (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band) r = await run('zoom_to_globe', {}); diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs new file mode 100644 index 000000000..b84bd861b --- /dev/null +++ b/src/annotations/regionRing.test.mjs @@ -0,0 +1,86 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; +import { createAnnotationResolver } from './resolver.js'; + +function featureSource(overrides = {}) { + const source = {}; + for (const method of FEATURE_SOURCE_METHODS) + source[method] = async () => null; + return Object.assign(source, overrides); +} + +const texasGeocode = { + async geocode() { + return { + place: { + lat: 31.0, + lng: -99.9, + label: 'Texas, USA', + name: 'Texas', + types: ['administrative_area_level_1', 'political'], + }, + }; + }, +}; + +test('region ring: Natural Earth names resolve without a geocoder', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + for (const name of ['Gulf of Mexico', 'the Alps']) { + const region = await resolveRegionRingForQuery(name); + assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`); + assert.equal(region.error, undefined); + } +}); + +test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { + let adminSignal; + let release; + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas(_point, { signal }) { + adminSignal = signal; + return new Promise((resolve) => { + release = resolve; + }); + }, + }), + }); + const started = Date.now(); + const region = await resolveRegionRingForQuery( + 'Texas', + undefined, + texasGeocode, + { budgetMs: 50 }, + ); + assert.deepEqual(region, { + name: 'Texas', + ring: null, + error: 'region-timeout', + }); + assert.ok( + Date.now() - started < 1000, + 'returns at the budget, not the lookup', + ); + // The lookup is left running so it can fill the boundary cache. + assert.equal(adminSignal?.aborted ?? false, false); + release(null); +}); + +test('region ring: an unbounded budget waits for the lookup', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas: () => + new Promise((resolve) => setTimeout(() => resolve(null), 30)), + }), + }); + const region = await resolveRegionRingForQuery( + 'Texas', + undefined, + texasGeocode, + { budgetMs: Infinity }, + ); + assert.equal(region, null); +}); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 3f757159e..214223ff1 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -25,6 +25,9 @@ import { import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; +/** Ms the analyst region lookup waits on geocode + admin boundary. */ +export const REGION_FALLBACK_BUDGET_MS = 3_000; + /** Own annotation lookup caches and ranking of supplied feature candidates. */ export function createAnnotationResolver({ boundarySource, @@ -1877,14 +1880,22 @@ export function createAnnotationResolver({ * resolve to a region-like boundary — the analyst engine reports that * honestly rather than silently scoping to nothing. * + * The geocode + admin-boundary rung is capped at `budgetMs`. Past it the + * call returns `{name, ring: null, error: 'region-timeout'}` so a voice + * answer is not held for tens of seconds; the lookup keeps running and + * fills the boundary cache, so asking again shortly is fast. + * * @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico" * @param {AbortSignal} [signal] - * @returns {Promise<{name:string, ring:Array<[number,number]>}|null>} + * @param {object} [placeSearch] + * @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup. + * @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>} */ async function resolveRegionRingForQuery( name, signal, placeSearch = unavailablePlaceSearch, + { budgetMs = REGION_FALLBACK_BUDGET_MS } = {}, ) { signal = lifetime ? signal @@ -1901,6 +1912,23 @@ export function createAnnotationResolver({ const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0]; if (ring?.length >= 3) return { name: ne.name, ring }; } + const lookup = resolveAdminRegionRing(q, signal, placeSearch); + if (!Number.isFinite(budgetMs)) return lookup; + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ name: q, ring: null, error: 'region-timeout' }), + budgetMs, + ); + }); + try { + return await Promise.race([lookup, timeout]); + } finally { + clearTimeout(timer); + } + } + + async function resolveAdminRegionRing(q, signal, placeSearch) { const geo = await geocodePlace(q, null, signal, placeSearch).catch( () => null, ); diff --git a/src/browserModuleBoundary.test.mjs b/src/browserModuleBoundary.test.mjs index d4c1c3bdb..ff883e46f 100644 --- a/src/browserModuleBoundary.test.mjs +++ b/src/browserModuleBoundary.test.mjs @@ -22,7 +22,7 @@ test('no browser-built module imports a Node core module', () => { // import survives the build and turns into a runtime failure the moment the // guard around it is wrong. src/data/naturalEarthRegions.js and // src/data/neighborhoodPolygons.js both carried one to read their bundled - // JSON packs under node:test; an import attribute serves both runtimes. + // JSON packs under node:test; src/data/bundledJson.js now serves both runtimes. const offenders = []; for (const file of browserModules()) { const source = readFileSync(file, 'utf8'); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index b77121c12..6016048d3 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -18,7 +18,7 @@ * * Providers (injected — keeps the engine pure and node-testable): * getRecords(layerKey) → Array (layer accessor snapshot) - * resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary) + * resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null> * getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived) * * @module data/analystEngine @@ -234,6 +234,14 @@ export function createAnalystEngine(providers) { const scope = spec.scope || { kind: 'view' }; if (scope.kind === 'region' && scope.name) { const region = await providers.resolveRegionRing(scope.name); + if (region?.error === 'region-timeout') { + return { + ok: false, + code: 'region-timeout', + error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`, + coverage: { layersQueried, scope: `region:${scope.name}:timeout` }, + }; + } if (!region?.ring) { return { ok: false, diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 72a0d0f5c..2b2e6dae4 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -187,6 +187,19 @@ test('analyst: unresolved region is an honest failure, not empty success', async assert.match(r.error, /Atlantis/); }); +test('analyst: a region lookup timeout is reported as region-timeout', async () => { + const eng = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }), + getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), + }); + const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } }); + assert.equal(r.ok, false); + assert.equal(r.code, 'region-timeout'); + assert.match(r.error, /Texas/); + assert.equal(r.coverage.scope, 'region:Texas:timeout'); +}); + test('analyst: route fields queryable from cached enrichment only', async () => { const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, diff --git a/src/data/bundledJson.js b/src/data/bundledJson.js new file mode 100644 index 000000000..61e98fc14 --- /dev/null +++ b/src/data/bundledJson.js @@ -0,0 +1,29 @@ +/** + * @file Load a JSON file shipped beside the source modules. + * + * The browser fetches the file from `url` — a `new URL('./….json', + * import.meta.url)` written in the calling module, which Vite serves as-is in + * dev and emits as an asset in the production build. Under Node (node:test) + * that URL is a `file:` URL, which `fetch` cannot read, so the caller's + * JSON-attributed import runs instead. + * + * The attributed import cannot serve the browser: the Vite dev server answers + * it with a JavaScript module, which the browser rejects for a JSON import. + * + * @module data/bundledJson + */ + +/** + * @param {URL} url - Resolved against the calling module's `import.meta.url`. + * @param {() => Promise<{default: any}>} importJson - The caller's literal + * `import('./….json', { with: { type: 'json' } })`, used only under Node. + * @returns {Promise} The parsed JSON. + */ +export async function loadBundledJson(url, importJson) { + if (url.protocol === 'file:') return (await importJson()).default; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url.pathname}`); + } + return response.json(); +} diff --git a/src/data/bundledJson.test.mjs b/src/data/bundledJson.test.mjs new file mode 100644 index 000000000..2a7dfe293 --- /dev/null +++ b/src/data/bundledJson.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadBundledJson } from './bundledJson.js'; + +const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url); +const importMarine = () => + import('./local_data/natural_earth/marine.json', { with: { type: 'json' } }); + +test('bundled JSON: a file URL uses the JSON import under Node', async (t) => { + const fetchMock = t.mock.method(globalThis, 'fetch'); + const pack = await loadBundledJson(MARINE, importMarine); + assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico')); + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('bundled JSON: a served URL is fetched as plain JSON', async (t) => { + const requests = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requests.push(String(url)); + return new Response('{"features":[]}', { + headers: { 'content-type': 'application/json' }, + }); + }); + const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json'); + const importJson = () => assert.fail('the browser path must not import JSON'); + assert.deepEqual(await loadBundledJson(url, importJson), { features: [] }); + assert.deepEqual(requests, [url.href]); +}); + +test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 })); + await assert.rejects( + loadBundledJson(new URL('http://localhost/missing.json'), importMarine), + /HTTP 404/, + ); +}); diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 891e7e9a1..5eba85231 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -11,11 +11,12 @@ * * PURE data module — no Cesium imports, node-testable. The packs are lazy- * loaded on first lookup and cached in module scope (bbox/area computed once - * at load). In the browser Vite bundles the JSON via dynamic import; under - * node the same files are read from disk. A failed load is retried on the - * next lookup rather than cached (see `createRetryableLoader`). + * at load) through `loadBundledJson`, which works in the browser and under + * node:test. A failed load is retried on the next lookup rather than cached + * (see `createRetryableLoader`). */ +import { loadBundledJson } from './bundledJson.js'; import { createRetryableLoader } from './retryableLoad.js'; const EARTH_RADIUS_KM = 6371; @@ -120,20 +121,22 @@ function suffixVariants(norm) { /** @type {Array|null} flat entry list for listRegions() */ let _entries = null; -async function loadPackFile(base) { - // Vite bundles these JSON files as modules; the import attribute is what Node - // needs to load the same files under node:test (same pattern as - // neighborhoodPolygons.js). One path, so no node: import reaches the browser. - const mod = - base === 'regions' - ? await import('./local_data/natural_earth/regions.json', { - with: { type: 'json' }, - }) - : await import('./local_data/natural_earth/marine.json', { - with: { type: 'json' }, - }); - return mod.default || mod; -} +const PACKS = { + regions: { + url: new URL('./local_data/natural_earth/regions.json', import.meta.url), + importJson: () => + import('./local_data/natural_earth/regions.json', { + with: { type: 'json' }, + }), + }, + marine: { + url: new URL('./local_data/natural_earth/marine.json', import.meta.url), + importJson: () => + import('./local_data/natural_earth/marine.json', { + with: { type: 'json' }, + }), + }, +}; function buildEntries(pack, kind) { const out = []; @@ -175,8 +178,8 @@ function buildEntries(pack, kind) { */ const loadIndex = createRetryableLoader(async () => { const [regions, marine] = await Promise.all([ - loadPackFile('regions'), - loadPackFile('marine'), + loadBundledJson(PACKS.regions.url, PACKS.regions.importJson), + loadBundledJson(PACKS.marine.url, PACKS.marine.importJson), ]); _entries = [ ...buildEntries(regions, 'natural'), diff --git a/src/data/neighborhoodPolygons.js b/src/data/neighborhoodPolygons.js index 2861f9318..2a3c8ca87 100644 --- a/src/data/neighborhoodPolygons.js +++ b/src/data/neighborhoodPolygons.js @@ -10,6 +10,7 @@ * touching this module — see `local_data/neighborhoods/SOURCE.md`. */ +import { loadBundledJson } from './bundledJson.js'; import { createRetryableLoader } from './retryableLoad.js'; // bbox = [west, south, east, north]; only load a city file when the point falls in its box. @@ -17,7 +18,11 @@ const CITY_FILES = [ { id: 'san-francisco', bbox: [-122.55, 37.7, -122.35, 37.84], - loader: () => + url: new URL( + './local_data/neighborhoods/san-francisco.json', + import.meta.url, + ), + importJson: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' }, }), @@ -107,10 +112,7 @@ function cityLoader(city) { let loader = _cityLoaders.get(city.id); if (!loader) { loader = createRetryableLoader(async () => { - // One path for both runtimes: Vite bundles the JSON as a module, and the - // import attribute is what Node needs to load the same file under node:test. - const mod = await city.loader(); - const fc = mod.default || mod; + const fc = await loadBundledJson(city.url, city.importJson); return Array.isArray(fc.features) ? fc.features : []; }); _cityLoaders.set(city.id, loader); diff --git a/src/standalone/application.js b/src/standalone/application.js index 3aaa9bde8..cbdf9cb7f 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -48,6 +48,8 @@ export function createStandaloneApplication({ 'Nepal', signal, placeSearch, + // The locator draws the border whenever it arrives. + { budgetMs: Infinity }, ), signal: context.signal, surface: scene.operations.surface, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index df025e867..cfdb1fb0b 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4323,6 +4323,7 @@ async function runAnalystQuery( return { ok: false, action: 'analyst_query', + ...(result.code ? { code: result.code } : {}), error: result.error, coverage: result.coverage, }; From 20a03aad8789cef7d39d469cea968d80254b6dda Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 24 Sep 2026 21:53:06 -0700 Subject: [PATCH 4/5] fix(render): release the atmosphere probe's WebGL context Lose the throwaway WebGL2 probe context right after the link probe instead of leaving it for GC; browsers (Safari especially) cap live contexts. Note that affected devices also lose globe-basemap fog, credit the changelog entry and move it to the top. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 27 ++++++++++++++------------- docs/CURRENT-STATE.md | 5 +++-- src/app/atmosphereCompat.js | 14 +++++++++++++- src/app/atmosphereCompat.test.mjs | 12 ++++++++++++ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a74edbbae..dceec6a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +- Render on iPad and iPhone instead of stopping with "An error occurred while + rendering." Cesium's per-vertex model atmosphere binds shader `out` + parameters directly to varyings, which Apple's Metal/ANGLE backend cannot + link, so the program failed and the render loop was torn down. The stage is + now kept out of the pipeline on affected devices by clearing + `scene.fog.renderable`, which leaves `fog.enabled` — and the fog density that + drives 3D Tiles refinement — untouched. Detection is a WebGL2 link probe of + the same pattern, so a future driver fix restores the effect with no code + change, with iOS/iPadOS detection as a backstop. Sky atmosphere and the + ground-atmosphere fragment path route through locals and are unaffected; + affected devices lose distance fog on 3D tiles and on the globe basemaps. + `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and + the `renderable`-not-`enabled` choice (KnottyDyes, #705). + - Region scopes in voice analyst queries ("in the Gulf of Mexico", "over the Alps") work again in the dev server: the bundled Natural Earth and neighborhood packs are fetched as JSON in the browser @@ -196,19 +210,6 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound retain geometry; incident-link checks abort on disable or selection change, and the row includes a containment legend. -- Render on iPad and iPhone instead of stopping with "An error occurred while - rendering." Cesium's per-vertex model atmosphere binds shader `out` - parameters directly to varyings, which Apple's Metal/ANGLE backend cannot - link, so the program failed and the render loop was torn down. The stage is - now kept out of the pipeline on affected devices by clearing - `scene.fog.renderable`, which leaves `fog.enabled` — and the fog density that - drives 3D Tiles refinement — untouched. Detection is a WebGL2 link probe of - the same pattern, so a future driver fix restores the effect with no code - change, with iOS/iPadOS detection as a backstop. Sky atmosphere and the - ground-atmosphere fragment path route through locals and are unaffected. - `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and - the `renderable`-not-`enabled` choice. - - Remove the spurious scrollbars that appeared on both panel stacks at narrow widths (720px and below) as soon as a panel was expanded. The stacks scroll vertically there, and each panel's decorative glow, absolutely positioned diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 92eccb9bd..348a25329 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -701,7 +701,6 @@ snapshots, lifecycle actions and row descriptors. Remount and teardown remove listeners and row subscriptions; obsolete completions do not repaint old rows. The clear control presents busy state while its existing action owns the transaction. - ## Model atmosphere on Apple Metal Cesium's per-vertex model atmosphere is kept out of the pipeline on devices @@ -712,13 +711,15 @@ attaches that stage only when `fog.enabled && fog.renderable`, so the viewer clears `scene.fog.renderable` and leaves `fog.enabled` — and the fog density that drives 3D Tiles screen-space-error scaling — intact. Sky atmosphere and the ground-atmosphere fragment path route through locals and are unaffected. +Affected devices lose distance fog on 3D tiles and on globe basemaps (Esri, +Bing, keyless), since the globe fog shader uses the same `renderable` flag. +The probe releases its throwaway WebGL2 context immediately. Detection is a WebGL2 link probe of the same out-parameter/varying pattern, so a future driver fix restores the effect with no code change; iOS/iPadOS platform detection is the backstop for when no probe context can be created. Applied during viewer construction, before any tile builds a draw command. - ## Map Source control ownership Map Source controls own chip listeners, source-state subscriptions and selection diff --git a/src/app/atmosphereCompat.js b/src/app/atmosphereCompat.js index 802f2b212..1bd23c8ea 100644 --- a/src/app/atmosphereCompat.js +++ b/src/app/atmosphereCompat.js @@ -154,7 +154,19 @@ export function shouldDisableModelAtmosphere({ } = {}) { const gl = createProbeContext(); if (gl) { - if (probeOutParamVaryingLinkFailure(gl)) return true; + let linkFails; + try { + linkFails = probeOutParamVaryingLinkFailure(gl); + } finally { + // Browsers cap live WebGL contexts (Safari especially); release the + // throwaway probe context now rather than waiting for GC. + try { + gl.getExtension?.('WEBGL_lose_context')?.loseContext(); + } catch { + // A context that cannot be released is left to GC. + } + } + if (linkFails) return true; // Probe ran and the driver accepted the pattern. Apple mobile is still // quarantined: the probe program is far simpler than Cesium's, and a // false negative there costs the entire scene. diff --git a/src/app/atmosphereCompat.test.mjs b/src/app/atmosphereCompat.test.mjs index 7f601512b..f1c1d43bf 100644 --- a/src/app/atmosphereCompat.test.mjs +++ b/src/app/atmosphereCompat.test.mjs @@ -207,3 +207,15 @@ test('tolerates a scene with no fog object', () => { assert.equal(applyModelAtmosphereWorkaround({}, {}), false); assert.equal(applyModelAtmosphereWorkaround(null, {}), false); }); + +test('releases the throwaway probe context after probing', () => { + let lost = 0; + const gl = fakeGl({ linkOk: true }); + gl.getExtension = (name) => + name === 'WEBGL_lose_context' ? { loseContext: () => (lost += 1) } : null; + shouldDisableModelAtmosphere({ + navigatorLike: LINUX_DESKTOP, + createProbeContext: () => gl, + }); + assert.equal(lost, 1, 'probe context must not linger until GC'); +}); From f631d5428baffd475696fed0364bbf99c0ba6e22 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 24 Sep 2026 22:01:56 -0700 Subject: [PATCH 5/5] docs: changelog entry for the macOS chmod fix (#694) Credit the fix in CHANGELOG and tidy the wording of its inline comment. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +++++ server/standalone/key-setup-hardening.mjs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dceec6a9c..419c03e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Saving a key from Provider Settings works again on Macs where Nix or + Homebrew coreutils sit ahead of `/bin` on `PATH`. The credential hardener + now spawns Apple's `/bin/chmod -N` by absolute path; GNU `chmod` has no `-N`, + so the ACL strip failed closed and every save was refused (Arthur Bogaart, #694). + - Render on iPad and iPhone instead of stopping with "An error occurred while rendering." Cesium's per-vertex model atmosphere binds shader `out` parameters directly to varyings, which Apple's Metal/ANGLE backend cannot diff --git a/server/standalone/key-setup-hardening.mjs b/server/standalone/key-setup-hardening.mjs index e2d11182c..78440948c 100644 --- a/server/standalone/key-setup-hardening.mjs +++ b/server/standalone/key-setup-hardening.mjs @@ -125,7 +125,7 @@ export function hardenCredentialFile( if (platform === 'darwin') { // `-N` (strip the ACL) is Apple-only. Spawn /bin/chmod by absolute // path, never by name: a Nix/Homebrew coreutils profile puts GNU chmod - // first on PATH, and GNU chmod rejects `-N` — which read as a + // first on PATH, and GNU chmod rejects `-N` — which was treated as a // hardening failure and refused every save on such machines. const aclRemoval = spawn('/bin/chmod', ['-N', filepath], { stdio: 'ignore',