From 3c9fd6420e95099051cb792cf427da4d02d9926f Mon Sep 17 00:00:00 2001 From: Asier Arranz Date: Sat, 4 Jul 2026 02:02:39 +0200 Subject: [PATCH 1/2] fix(webxr): apply effective target frame rate before CloudXR session creation The XR store no longer requests the automatic 'high' rate; the configured rate is applied and awaited (bounded, frameratechange-aware) inside the sessionstart handler before CloudXR.createSession, and the effective browser rate is what gets advertised to CloudXR. The late fire-and-forget store.setFrameRate call is removed. Signed-off-by: Asier Arranz --- .../helpers/react/CloudXRComponent.tsx | 16 +- deps/cloudxr/webxr_client/src/App.tsx | 20 +- .../webxr_client/src/config/frameRate.test.ts | 154 +++++++++++++++ .../webxr_client/src/config/frameRate.ts | 184 ++++++++++++++++++ 4 files changed, 353 insertions(+), 21 deletions(-) create mode 100644 deps/cloudxr/webxr_client/src/config/frameRate.test.ts create mode 100644 deps/cloudxr/webxr_client/src/config/frameRate.ts diff --git a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx index 6b9eea500..61f355966 100644 --- a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx +++ b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx @@ -42,6 +42,7 @@ import { useThree, useFrame } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import { useRef, useEffect } from 'react'; import type { WebGLRenderer } from 'three'; +import { applyTargetFrameRate, FrameRateSession } from '../../src/config/frameRate'; /** * Props for the CloudXRComponent. @@ -150,13 +151,20 @@ export default function CloudXRComponent({ if (webXRManager) { const handleSessionStart = async () => { + const xrSession: XRSession | null = (webXRManager as any).getSession + ? (webXRManager as any).getSession() + : null; + + // CloudXR must advertise the rate actually used by the headset. Wait for WebXR to + // apply the configured rate before creating the CloudXR session to avoid a pacing race. + const effectiveDeviceFrameRate = xrSession + ? await applyTargetFrameRate(xrSession as XRSession & FrameRateSession, config.deviceFrameRate) + : config.deviceFrameRate; + // Explicitly request the desired reference space from the XRSession to avoid // inheriting a default 'local-floor' space that could stack with UI offsets. let referenceSpace: XRReferenceSpace | null = null; try { - const xrSession: XRSession | null = (webXRManager as any).getSession - ? (webXRManager as any).getSession() - : null; if (xrSession) { if (config.referenceSpaceType === 'auto') { const fallbacks: XRReferenceSpaceType[] = [ @@ -243,7 +251,7 @@ export default function CloudXRComponent({ codec: config.codec, gl: gl, referenceSpace: referenceSpace, - deviceFrameRate: config.deviceFrameRate, + deviceFrameRate: effectiveDeviceFrameRate, maxStreamingBitrateKbps: config.maxStreamingBitrateMbps * 1000, // Convert Mbps to Kbps enablePoseSmoothing: config.enablePoseSmoothing, posePredictionFactor: config.posePredictionFactor, diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index fdcc1cdb7..7562b2744 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -300,6 +300,9 @@ function App() { createXRStore({ emulate: false, // Disable IWER emulation from react in favor of custom iwer loading function foveation: xrFoveation, + // CloudXRComponent applies the configured rate before CloudXR negotiates the stream. + // Disabling the store's automatic "high" preference avoids racing that negotiation. + frameRate: false, frameBufferScaling: xrFrameBufferScaling, // Use local WebXR input profile assets only when bundled (optional build without assets) ...(process.env.WEBXR_ASSETS_VERSION && { @@ -378,23 +381,6 @@ function App() { } else { setErrorMessage('Unrecognized immersive mode'); } - store.setFrameRate((supportedFrameRates: ArrayLike): number | false => { - let frameRate = ui.getConfiguration().deviceFrameRate; - let found = false; - for (let i = 0; i < supportedFrameRates.length; ++i) { - if (supportedFrameRates[i] === frameRate) { - found = true; - break; - } - } - if (found) { - console.info('Changed frame rate to', frameRate); - return frameRate; - } else { - console.error('Failed to change frame rate to', frameRate); - return false; - } - }); }; ui.setupConnectButtonHandler(doConnect, (error: Error) => { diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.test.ts b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts new file mode 100644 index 000000000..33c3f8606 --- /dev/null +++ b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { applyTargetFrameRate, FrameRateLogger, FrameRateSession } from './frameRate'; + +const logger = (): jest.Mocked => ({ + info: jest.fn(), + warn: jest.fn(), +}); + +describe('applyTargetFrameRate', () => { + it('applies a supported rate and returns the effective rate', async () => { + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90, 120]), + updateTargetFrameRate: jest.fn(async rate => { + session.frameRate = rate; + }), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(72); + expect(session.updateTargetFrameRate).toHaveBeenCalledWith(72); + }); + + it('does not request an unsupported rate', async () => { + const updateTargetFrameRate = jest.fn(async () => {}); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([90, 120]), + updateTargetFrameRate, + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + expect(updateTargetFrameRate).not.toHaveBeenCalled(); + }); + + it('uses the current rate when the update API is unavailable', async () => { + const session: FrameRateSession = { frameRate: 90 }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + }); + + it('falls back to the current rate when the update is rejected', async () => { + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => { + throw new Error('headset rejected rate'); + }), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + }); + + it('does not resolve until the headset update has completed', async () => { + let releaseUpdate!: () => void; + const update = new Promise(resolve => { + releaseUpdate = resolve; + }); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(() => update), + }; + let completed = false; + const applying = applyTargetFrameRate(session, 72, logger()).then(value => { + completed = true; + return value; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + session.frameRate = 72; + releaseUpdate(); + await expect(applying).resolves.toBe(72); + }); + + it('does not block CloudXR when the update never settles', async () => { + const log = logger(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(() => new Promise(() => {})), + }; + + await expect( + applyTargetFrameRate(session, 72, log, { updateTimeoutMs: 20 }) + ).resolves.toBe(90); + expect(log.warn).toHaveBeenCalled(); + }); + + it('waits for frameratechange when the attribute updates late', async () => { + const listeners: Array<() => void> = []; + const removeEventListener = jest.fn(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => { + setTimeout(() => { + session.frameRate = 72; + listeners.forEach(listener => listener()); + }, 5); + }), + addEventListener: jest.fn((_type, listener) => { + listeners.push(listener); + }), + removeEventListener, + }; + + await expect( + applyTargetFrameRate(session, 72, logger(), { rateSettleGraceMs: 200 }) + ).resolves.toBe(72); + expect(removeEventListener).toHaveBeenCalledWith('frameratechange', expect.any(Function)); + }); + + it('advertises the accepted target even when the attribute lags past the grace', async () => { + const log = logger(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => {}), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + await expect( + applyTargetFrameRate(session, 72, log, { rateSettleGraceMs: 10 }) + ).resolves.toBe(72); + expect(log.warn).toHaveBeenCalled(); + }); + + it('trusts the accepted request when the browser does not expose frameRate', async () => { + const session: FrameRateSession = { + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => {}), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(72); + }); +}); diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.ts b/deps/cloudxr/webxr_client/src/config/frameRate.ts new file mode 100644 index 000000000..f0b5427b4 --- /dev/null +++ b/deps/cloudxr/webxr_client/src/config/frameRate.ts @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** The optional WebXR frame-rate members used by supported headsets. */ +export interface FrameRateSession { + frameRate?: number; + supportedFrameRates?: ArrayLike; + updateTargetFrameRate?: (rate: number) => Promise; + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; +} + +export interface FrameRateLogger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +export interface ApplyTargetFrameRateOptions { + /** Give up waiting for updateTargetFrameRate() after this long, without failing the session. */ + updateTimeoutMs?: number; + /** After the update resolves, wait at most this long for session.frameRate to report the new rate. */ + rateSettleGraceMs?: number; +} + +const DEFAULT_UPDATE_TIMEOUT_MS = 2000; +const DEFAULT_RATE_SETTLE_GRACE_MS = 500; + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function containsFrameRate(values: ArrayLike | undefined, target: number): boolean { + if (!values) return false; + for (let index = 0; index < values.length; index += 1) { + if (values[index] === target) return true; + } + return false; +} + +type UpdateOutcome = + | { kind: 'updated' } + | { kind: 'timeout' } + | { kind: 'rejected'; error: unknown }; + +function raceUpdateAgainstTimeout(update: Promise, timeoutMs: number): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + update.then( + () => { + clearTimeout(timer); + resolve({ kind: 'updated' }); + }, + (error: unknown) => { + clearTimeout(timer); + resolve({ kind: 'rejected', error }); + } + ); + }); +} + +/** + * The WebXR spec allows session.frameRate to keep its old value when + * updateTargetFrameRate() resolves; the attribute change is only observable via a + * later "frameratechange" event. Wait briefly for it so a stale value is not + * advertised to CloudXR. + */ +function waitForReportedFrameRate( + session: FrameRateSession, + targetFrameRate: number, + graceMs: number +): Promise { + if (!session.addEventListener) { + return Promise.resolve(); + } + return new Promise(resolve => { + let done = false; + let timer: ReturnType | undefined; + const finish = () => { + if (done) return; + done = true; + if (timer !== undefined) clearTimeout(timer); + session.removeEventListener?.('frameratechange', onChange); + resolve(); + }; + const onChange = () => { + if (session.frameRate === targetFrameRate) finish(); + }; + session.addEventListener('frameratechange', onChange); + timer = setTimeout(finish, graceMs); + if (session.frameRate === targetFrameRate) finish(); + }); +} + +/** + * Apply a headset frame rate and wait for the browser before CloudXR negotiates its stream. + * Returns the effective rate to advertise to CloudXR, falling back without aborting the session. + * A hung updateTargetFrameRate() must not block CloudXR session creation, so the wait is bounded. + */ +export async function applyTargetFrameRate( + session: FrameRateSession, + targetFrameRate: number, + logger: FrameRateLogger = console, + options: ApplyTargetFrameRateOptions = {} +): Promise { + const updateTimeoutMs = options.updateTimeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; + const rateSettleGraceMs = options.rateSettleGraceMs ?? DEFAULT_RATE_SETTLE_GRACE_MS; + const currentFrameRate = isFinitePositive(session.frameRate) ? session.frameRate : undefined; + + if (!isFinitePositive(targetFrameRate)) { + logger.warn('Ignoring invalid target WebXR frame rate:', targetFrameRate); + return currentFrameRate ?? targetFrameRate; + } + + if (!session.updateTargetFrameRate || !session.supportedFrameRates) { + logger.warn( + 'WebXR target frame-rate API is unavailable; using the current/default frame rate:', + currentFrameRate ?? targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + if (!containsFrameRate(session.supportedFrameRates, targetFrameRate)) { + logger.warn( + 'Requested WebXR frame rate is not supported by this device:', + targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + let update: Promise; + try { + update = session.updateTargetFrameRate(targetFrameRate); + } catch (error) { + logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, error); + return currentFrameRate ?? targetFrameRate; + } + + const outcome = await raceUpdateAgainstTimeout(update, updateTimeoutMs); + + if (outcome.kind === 'timeout') { + logger.warn( + `updateTargetFrameRate(${targetFrameRate}) did not settle within ${updateTimeoutMs} ms; ` + + 'continuing so CloudXR negotiation is not blocked. Advertising the known rate:', + currentFrameRate ?? targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + if (outcome.kind === 'rejected') { + logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, outcome.error); + return currentFrameRate ?? targetFrameRate; + } + + if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + await waitForReportedFrameRate(session, targetFrameRate, rateSettleGraceMs); + } + + // A resolved updateTargetFrameRate() means the device accepted the rate change. + // session.frameRate may lag behind the acceptance (it only settles with a later + // frameratechange event), so the accepted target is the honest rate to advertise. + if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + logger.warn( + `Browser still reports ${session.frameRate} Hz after accepting ${targetFrameRate} Hz; ` + + 'advertising the accepted target to CloudXR.' + ); + } else { + logger.info('WebXR frame rate applied before CloudXR negotiation:', targetFrameRate); + } + return targetFrameRate; +} From 34d5a5458c771161cb0bf3d603a173a99a5aec61 Mon Sep 17 00:00:00 2001 From: Asier Arranz Date: Fri, 17 Jul 2026 23:48:04 +0200 Subject: [PATCH 2/2] refactor(webxr): address review comments on frame-rate negotiation Type the helper against the real XRSession from @types/webxr and drop the hand-rolled FrameRateSession interface (partial test fakes carry ts-expect-error tags instead). Rewrite raceUpdateAgainstTimeout with Promise.race, name the repeated fallback rate and stale-rate checks, split applyTargetFrameRate into explicit apply/confirm phases, and add JSDoc plus explanatory comments on the non-obvious control flow. CloudXRComponent now calls webXRManager.getSession() through three's own types instead of an as-any probe. No behavior changes; suite still 23/23. Co-Authored-By: Claude Fable 5 Signed-off-by: Asier Arranz --- .../helpers/react/CloudXRComponent.tsx | 8 +- .../webxr_client/src/config/frameRate.test.ts | 34 +++-- .../webxr_client/src/config/frameRate.ts | 143 +++++++++++++----- 3 files changed, 127 insertions(+), 58 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx index 61f355966..b8419bfbc 100644 --- a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx +++ b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx @@ -42,7 +42,7 @@ import { useThree, useFrame } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import { useRef, useEffect } from 'react'; import type { WebGLRenderer } from 'three'; -import { applyTargetFrameRate, FrameRateSession } from '../../src/config/frameRate'; +import { applyTargetFrameRate } from '../../src/config/frameRate'; /** * Props for the CloudXRComponent. @@ -151,14 +151,12 @@ export default function CloudXRComponent({ if (webXRManager) { const handleSessionStart = async () => { - const xrSession: XRSession | null = (webXRManager as any).getSession - ? (webXRManager as any).getSession() - : null; + const xrSession = webXRManager.getSession(); // CloudXR must advertise the rate actually used by the headset. Wait for WebXR to // apply the configured rate before creating the CloudXR session to avoid a pacing race. const effectiveDeviceFrameRate = xrSession - ? await applyTargetFrameRate(xrSession as XRSession & FrameRateSession, config.deviceFrameRate) + ? await applyTargetFrameRate(xrSession, config.deviceFrameRate) : config.deviceFrameRate; // Explicitly request the desired reference space from the XRSession to avoid diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.test.ts b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts index 33c3f8606..e95822a1a 100644 --- a/deps/cloudxr/webxr_client/src/config/frameRate.test.ts +++ b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { applyTargetFrameRate, FrameRateLogger, FrameRateSession } from './frameRate'; +import { applyTargetFrameRate, FrameRateLogger } from './frameRate'; const logger = (): jest.Mocked => ({ info: jest.fn(), @@ -24,10 +24,12 @@ const logger = (): jest.Mocked => ({ describe('applyTargetFrameRate', () => { it('applies a supported rate and returns the effective rate', async () => { - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90, 120]), updateTargetFrameRate: jest.fn(async rate => { + // @ts-expect-error the fake mutates the readonly attribute to emulate the browser. session.frameRate = rate; }), }; @@ -38,7 +40,8 @@ describe('applyTargetFrameRate', () => { it('does not request an unsupported rate', async () => { const updateTargetFrameRate = jest.fn(async () => {}); - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([90, 120]), updateTargetFrameRate, @@ -49,13 +52,15 @@ describe('applyTargetFrameRate', () => { }); it('uses the current rate when the update API is unavailable', async () => { - const session: FrameRateSession = { frameRate: 90 }; + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90 }; await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); }); it('falls back to the current rate when the update is rejected', async () => { - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(async () => { @@ -71,7 +76,8 @@ describe('applyTargetFrameRate', () => { const update = new Promise(resolve => { releaseUpdate = resolve; }); - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(() => update), @@ -84,6 +90,7 @@ describe('applyTargetFrameRate', () => { await Promise.resolve(); expect(completed).toBe(false); + // @ts-expect-error the fake mutates the readonly attribute to emulate the browser. session.frameRate = 72; releaseUpdate(); await expect(applying).resolves.toBe(72); @@ -91,7 +98,8 @@ describe('applyTargetFrameRate', () => { it('does not block CloudXR when the update never settles', async () => { const log = logger(); - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(() => new Promise(() => {})), @@ -106,16 +114,18 @@ describe('applyTargetFrameRate', () => { it('waits for frameratechange when the attribute updates late', async () => { const listeners: Array<() => void> = []; const removeEventListener = jest.fn(); - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(async () => { setTimeout(() => { + // @ts-expect-error the fake mutates the readonly attribute to emulate the browser. session.frameRate = 72; listeners.forEach(listener => listener()); }, 5); }), - addEventListener: jest.fn((_type, listener) => { + addEventListener: jest.fn((_type: string, listener: () => void) => { listeners.push(listener); }), removeEventListener, @@ -129,7 +139,8 @@ describe('applyTargetFrameRate', () => { it('advertises the accepted target even when the attribute lags past the grace', async () => { const log = logger(); - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { frameRate: 90, supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(async () => {}), @@ -144,7 +155,8 @@ describe('applyTargetFrameRate', () => { }); it('trusts the accepted request when the browser does not expose frameRate', async () => { - const session: FrameRateSession = { + // @ts-expect-error partial XRSession fake; only the frame-rate members are exercised. + const session: XRSession = { supportedFrameRates: new Float32Array([72, 90]), updateTargetFrameRate: jest.fn(async () => {}), }; diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.ts b/deps/cloudxr/webxr_client/src/config/frameRate.ts index f0b5427b4..f5a024bc9 100644 --- a/deps/cloudxr/webxr_client/src/config/frameRate.ts +++ b/deps/cloudxr/webxr_client/src/config/frameRate.ts @@ -15,20 +15,15 @@ * limitations under the License. */ -/** The optional WebXR frame-rate members used by supported headsets. */ -export interface FrameRateSession { - frameRate?: number; - supportedFrameRates?: ArrayLike; - updateTargetFrameRate?: (rate: number) => Promise; - addEventListener?: (type: string, listener: () => void) => void; - removeEventListener?: (type: string, listener: () => void) => void; -} - +/** Logging sink used by {@link applyTargetFrameRate} to report how negotiation went. */ export interface FrameRateLogger { + /** Reports the applied rate on the happy path. */ info: (...args: unknown[]) => void; + /** Reports every fallback: invalid or unsupported rates, rejections, timeouts, stale attributes. */ warn: (...args: unknown[]) => void; } +/** Timing overrides for {@link applyTargetFrameRate}, mainly useful in tests. */ export interface ApplyTargetFrameRateOptions { /** Give up waiting for updateTargetFrameRate() after this long, without failing the session. */ updateTimeoutMs?: number; @@ -39,10 +34,23 @@ export interface ApplyTargetFrameRateOptions { const DEFAULT_UPDATE_TIMEOUT_MS = 2000; const DEFAULT_RATE_SETTLE_GRACE_MS = 500; +/** + * Narrows an unknown value to a number usable as a frame rate. + * + * @param value - Candidate value, typically session.frameRate or a configured rate. + * @returns True when the value is a finite number greater than zero. + */ function isFinitePositive(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value > 0; } +/** + * Checks whether a supported-rates list contains the target rate. + * + * @param values - session.supportedFrameRates when exposed; a Float32Array, hence ArrayLike. + * @param target - Frame rate to look for. + * @returns True when the target rate is present in the list. + */ function containsFrameRate(values: ArrayLike | undefined, target: number): boolean { if (!values) return false; for (let index = 0; index < values.length; index += 1) { @@ -51,35 +59,58 @@ function containsFrameRate(values: ArrayLike | undefined, target: number return false; } +/** How a bounded wait on updateTargetFrameRate() ended. */ type UpdateOutcome = | { kind: 'updated' } | { kind: 'timeout' } | { kind: 'rejected'; error: unknown }; +/** + * Awaits an updateTargetFrameRate() promise, but never longer than timeoutMs. + * + * The returned promise always resolves, never rejects: a hung updateTargetFrameRate() + * must not block CloudXR session creation (an unbounded wait left the Quest stuck at + * its loading screen), so a hang surfaces as a 'timeout' outcome and a rejection as + * a 'rejected' outcome for the caller to fall back on. The timer is cleared once the + * race settles so it cannot fire after the outcome is decided or leave a stray timer + * pending. + * + * @param update - Promise returned by session.updateTargetFrameRate(). + * @param timeoutMs - Upper bound on how long to wait for the update. + * @returns The outcome of the race; never a rejection. + */ function raceUpdateAgainstTimeout(update: Promise, timeoutMs: number): Promise { - return new Promise(resolve => { - const timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); - update.then( - () => { - clearTimeout(timer); - resolve({ kind: 'updated' }); - }, - (error: unknown) => { - clearTimeout(timer); - resolve({ kind: 'rejected', error }); - } - ); + let timer!: ReturnType; + const timeout = new Promise(resolve => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); }); + const settled = update.then( + (): UpdateOutcome => ({ kind: 'updated' }), + (error: unknown): UpdateOutcome => ({ kind: 'rejected', error }) + ); + return Promise.race([settled, timeout]).finally(() => clearTimeout(timer)); } /** + * Waits until session.frameRate reports the accepted target rate, bounded by graceMs. + * * The WebXR spec allows session.frameRate to keep its old value when - * updateTargetFrameRate() resolves; the attribute change is only observable via a - * later "frameratechange" event. Wait briefly for it so a stale value is not - * advertised to CloudXR. + * updateTargetFrameRate() resolves; the attribute change only becomes observable + * through a later "frameratechange" event. Waiting briefly for that event avoids + * advertising a stale rate to CloudXR. + * + * This promise bridges an event listener and a timer and owns the listener's + * lifecycle: the `done` flag makes finish() idempotent, so whichever of the event, + * the timer, or the synchronous re-check wins cannot resolve twice or leave the + * listener attached. + * + * @param session - Active WebXR session. + * @param targetFrameRate - Rate already accepted by updateTargetFrameRate(). + * @param graceMs - Upper bound on how long to wait for the event. + * @returns Resolves once the rate is reported or the grace period elapses. */ function waitForReportedFrameRate( - session: FrameRateSession, + session: XRSession, targetFrameRate: number, graceMs: number ): Promise { @@ -106,73 +137,101 @@ function waitForReportedFrameRate( } /** - * Apply a headset frame rate and wait for the browser before CloudXR negotiates its stream. - * Returns the effective rate to advertise to CloudXR, falling back without aborting the session. - * A hung updateTargetFrameRate() must not block CloudXR session creation, so the wait is bounded. + * Applies a headset frame rate and waits for the browser before CloudXR negotiates its stream. + * + * Works in two phases. Phase 1 applies the rate with a bounded wait: validate the + * request, ask the browser, and never wait longer than updateTimeoutMs. Phase 2 + * confirms what the session reports and chooses the honest rate to advertise. + * + * @param session - Active WebXR session. The frame-rate members are optional per + * spec, so every absence path falls back instead of failing the session. + * @param targetFrameRate - Desired rate from the client configuration. + * @param logger - Sink for negotiation progress; defaults to console. + * @param options - Timeout and grace overrides, mainly for tests. + * @returns The effective rate to advertise to CloudXR. */ export async function applyTargetFrameRate( - session: FrameRateSession, + session: XRSession, targetFrameRate: number, logger: FrameRateLogger = console, options: ApplyTargetFrameRateOptions = {} ): Promise { const updateTimeoutMs = options.updateTimeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; const rateSettleGraceMs = options.rateSettleGraceMs ?? DEFAULT_RATE_SETTLE_GRACE_MS; + + // The honest rate to advertise whenever the target cannot be applied: whatever the + // browser currently reports, or the requested target if nothing is reported. const currentFrameRate = isFinitePositive(session.frameRate) ? session.frameRate : undefined; + const fallbackFrameRate = currentFrameRate ?? targetFrameRate; + + // Phase 1: apply the rate, with every wait bounded. + // Reject values that cannot be a frame rate (NaN, zero, negatives). if (!isFinitePositive(targetFrameRate)) { logger.warn('Ignoring invalid target WebXR frame rate:', targetFrameRate); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } + // Browsers that do not expose the optional frame-rate API keep their default rate. if (!session.updateTargetFrameRate || !session.supportedFrameRates) { logger.warn( 'WebXR target frame-rate API is unavailable; using the current/default frame rate:', - currentFrameRate ?? targetFrameRate + fallbackFrameRate ); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } + // Never request a rate the device does not list; the browser would reject it. if (!containsFrameRate(session.supportedFrameRates, targetFrameRate)) { logger.warn( 'Requested WebXR frame rate is not supported by this device:', targetFrameRate ); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } + // updateTargetFrameRate() can throw synchronously as well as reject. let update: Promise; try { update = session.updateTargetFrameRate(targetFrameRate); } catch (error) { logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, error); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } + // Bounded wait: a hung update must not block CloudXR session creation. const outcome = await raceUpdateAgainstTimeout(update, updateTimeoutMs); if (outcome.kind === 'timeout') { logger.warn( `updateTargetFrameRate(${targetFrameRate}) did not settle within ${updateTimeoutMs} ms; ` + 'continuing so CloudXR negotiation is not blocked. Advertising the known rate:', - currentFrameRate ?? targetFrameRate + fallbackFrameRate ); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } if (outcome.kind === 'rejected') { logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, outcome.error); - return currentFrameRate ?? targetFrameRate; + return fallbackFrameRate; } - if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + // Phase 2: confirm the reported rate and choose what to advertise. + + // Per the WebXR spec the attribute may lag the accepted update until a + // "frameratechange" event fires; undefined means it is not exposed at all. + const reportsStaleRate = () => + session.frameRate !== undefined && session.frameRate !== targetFrameRate; + + // Give the attribute a bounded chance to settle before reading it. + if (reportsStaleRate()) { await waitForReportedFrameRate(session, targetFrameRate, rateSettleGraceMs); } - // A resolved updateTargetFrameRate() means the device accepted the rate change. - // session.frameRate may lag behind the acceptance (it only settles with a later - // frameratechange event), so the accepted target is the honest rate to advertise. - if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + // A resolved updateTargetFrameRate() means the device accepted the rate change, + // so the accepted target is the honest rate to advertise even when the attribute + // still lags past the grace period. + if (reportsStaleRate()) { logger.warn( `Browser still reports ${session.frameRate} Hz after accepting ${targetFrameRate} Hz; ` + 'advertising the accepted target to CloudXR.'