diff --git a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx index 3526eccadc..13dfbd13b1 100644 --- a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx +++ b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx @@ -338,8 +338,6 @@ export default function CloudXRComponent({ const warmupEndTimerRef = useRef | null>(null); // Fires IDLE_FRAMES_QUIET_MS after the last idle-frame log; see noteIdleFrame. const idleQuietTimerRef = useRef | null>(null); - // True from an idle-frame log until idleQuietTimerRef fires or the attempt ends. - const idleFramesActiveRef = useRef(false); const idleStatusRef = useRef({ framesSeen: 0, timestamp: 0, active: false }); // Per attempt: a RenderFramerate tick showed real, pose-backed video, so warm-up is over even if // the SDK never logged its completion line (a stream that starts warmed up). @@ -403,6 +401,7 @@ export default function CloudXRComponent({ (cxrSession: CloudXR.Session) => { // <= 0 disables detection; setTimeout(fn, 0) would fire immediately instead. if (streamAttachBaseTimeoutMs <= 0) return; + if (streamAttachTimerRef.current !== null) clearTimeout(streamAttachTimerRef.current); const attemptForThisTimer = reconnectAttemptRef.current; const attachTimeoutMs = Math.min( streamAttachBaseTimeoutMs * 2 ** attemptForThisTimer, @@ -445,6 +444,7 @@ export default function CloudXRComponent({ (cxrSession: CloudXR.Session) => { // warmupEndTimeoutMs <= 0 turns off all warm-up detection; the begin timer only hands off to the end timer. if (warmupBeginBaseTimeoutMs <= 0 || warmupEndBaseTimeoutMs <= 0) return; + if (warmupBeginTimerRef.current !== null) clearTimeout(warmupBeginTimerRef.current); // Same overflow guard as armStreamAttachTimer: an uncapped delay above setTimeout's // ~24.8-day int32 limit wraps and fires near-immediately instead of waiting. const timeoutMs = Math.min(warmupBeginBaseTimeoutMs, MAX_SET_TIMEOUT_MS); @@ -478,6 +478,7 @@ export default function CloudXRComponent({ const armWarmupEndTimer = useCallback( (cxrSession: CloudXR.Session) => { if (warmupEndBaseTimeoutMs <= 0) return; // disabled, see armStreamAttachTimer + if (warmupEndTimerRef.current !== null) clearTimeout(warmupEndTimerRef.current); const timeoutMs = Math.min(warmupEndBaseTimeoutMs, MAX_SET_TIMEOUT_MS); warmupEndTimerRef.current = setTimeout(() => { warmupEndTimerRef.current = null; @@ -508,8 +509,7 @@ export default function CloudXRComponent({ clearTimeout(idleQuietTimerRef.current); idleQuietTimerRef.current = null; } - if (!idleFramesActiveRef.current) return; - idleFramesActiveRef.current = false; + if (!idleStatusRef.current.active) return; idleStatusRef.current = { ...idleStatusRef.current, timestamp: Date.now(), active: false }; onIdleStatusRef.current?.(idleStatusRef.current); }, []); @@ -522,7 +522,6 @@ export default function CloudXRComponent({ * the end timer would start on them. */ const noteIdleFrame = useCallback(() => { - idleFramesActiveRef.current = true; idleStatusRef.current = { framesSeen: idleStatusRef.current.framesSeen + 1, timestamp: Date.now(), @@ -620,10 +619,17 @@ export default function CloudXRComponent({ // no deadline for the rest of this attempt. if (cxrSessionRef.current && !hasStreamStartedRef.current) { armStreamAttachTimer(cxrSessionRef.current); - } else if (!headless && cxrSessionRef.current && !warmupBegunRef.current) { - armWarmupBeginTimer(cxrSessionRef.current); - } else if (!headless && cxrSessionRef.current && !warmupStatusRef.current.completed) { - armWarmupEndTimer(cxrSessionRef.current); + } else if ( + !headless && + cxrSessionRef.current && + !idleStatusRef.current.active && // the idle quiet timer resumes the watchdog itself + !realVideoSeenRef.current + ) { + if (!warmupBegunRef.current) { + armWarmupBeginTimer(cxrSessionRef.current); + } else if (!warmupStatusRef.current.completed) { + armWarmupEndTimer(cxrSessionRef.current); + } } if (webXRManager) { @@ -793,7 +799,7 @@ export default function CloudXRComponent({ !completeMatch && !headless && cxrSessionRef.current && - !idleFramesActiveRef.current + !idleStatusRef.current.active ) { armWarmupEndTimer(cxrSessionRef.current); } diff --git a/deps/cloudxr/webxr_client/tests/mock/StreamAttachTimeoutTest.tsx b/deps/cloudxr/webxr_client/tests/mock/StreamAttachTimeoutTest.tsx index a5ff150800..d29cc01b87 100644 --- a/deps/cloudxr/webxr_client/tests/mock/StreamAttachTimeoutTest.tsx +++ b/deps/cloudxr/webxr_client/tests/mock/StreamAttachTimeoutTest.tsx @@ -164,6 +164,10 @@ let pendingIdleConfig: Pick< > | null = null; // Status and idle events in the order they happened, for ordering assertions. let timeline: string[] = []; +// Wall-clock time of each status in statusMessages, for timing assertions. +let statusTimes: number[] = []; +// Set by Scene: replaces the CloudXRComponent config object, as an operator edit would. +let changeConfig: (() => void) | null = null; let streamingMetricsCount = 0; let statusMessages: string[] = []; let warmupStatuses: WarmupStatus[] = []; @@ -176,11 +180,18 @@ let streamingMetricsFiredDuringWarmup = false; let streamingMetricsFiredAfterWarmupComplete = false; function Scene() { + const [sceneConfig, setSceneConfig] = useState(config); + useEffect(() => { + changeConfig = () => setSceneConfig(c => ({ ...c })); + return () => { + changeConfig = null; + }; + }, []); return ( { statusMessages.push(status); + statusTimes.push(Date.now()); timeline.push(`status:${status}`); appendLog(`[status] connected=${isConnected} ${status}`); if (status === 'Connected') { @@ -420,6 +432,77 @@ async function runRealVideoAfterIdleTest(): Promise { store.getState().session?.end(); } +// Scenarios ?scenario=idle-silence-never-began|idle-silence-stalled|idle-config-change: idle frames +// stop with no real video, so the warm-up watchdog must resume. It must fire no earlier than the +// quiet period plus the phase budget after the last idle frame, however often the config changes +// meanwhile: a config change must neither re-arm a watchdog while idle is active nor leave an +// orphaned timer behind. +const IDLE_QUIET_MS = 1000; +interface IdleSilenceScenario { + /** Feed a warm-up frame at the start, so warm-up has begun (then stalls) when idle stops. */ + warmupBegun: boolean; + idleForMs: number; + configChangesAtMs: number[]; +} +const IDLE_SILENCE_SCENARIOS: Record = { + 'idle-silence-never-began': { warmupBegun: false, idleForMs: 600, configChangesAtMs: [] }, + 'idle-silence-stalled': { warmupBegun: true, idleForMs: 600, configChangesAtMs: [] }, + // Config changes while idle runs (300, 1200) and inside the quiet window after it (2250). + 'idle-config-change': { + warmupBegun: true, + idleForMs: 2000, + configChangesAtMs: [300, 1200, 2250], + }, +}; + +async function runIdleSilenceScenario(name: string, sc: IdleSilenceScenario): Promise { + appendLog(`=== ${name}: idle frames then silence must time out after quiet + budget ===`); + statusMessages = []; + statusTimes = []; + warmupStatuses = []; + idleStatuses = []; + timeline = []; + attemptConfigQueue = [ + { + connectWaitMs: 100, + warmupTotalFrames: null, + frameCallCount: sc.warmupBegun ? 1 : 0, + idleFramesForMs: sc.idleForMs, + }, + ]; + pendingFrameCallCount = 0; + pendingIdleConfig = null; + await startSession(); + const connected = await waitUntil(() => statusMessages.includes('Connected'), 3000); + for (const at of sc.configChangesAtMs) { + setTimeout(() => changeConfig?.(), at); + } + const retried = await waitUntil( + () => statusMessages.some(s => s.startsWith('Reconnecting')), + 8000 + ); + const retryAt = statusTimes[statusMessages.findIndex(s => s.startsWith('Reconnecting'))] ?? 0; + const lastIdleAt = Math.max(0, ...idleStatuses.filter(s => s.active).map(s => s.timestamp)); + const gapMs = retryAt - lastIdleAt; + // Expected gap: IDLE_QUIET_MS + the 500 ms phase budget. Early means a watchdog ran during idle + // or its quiet window; the upper bound only guards against it never resuming properly. + const timedOutAfterQuiet = retried && gapMs >= IDLE_QUIET_MS + WARMUP_BEGIN_TIMEOUT_MS - 100; + const timedOutPromptly = retried && gapMs <= IDLE_QUIET_MS + 2500; + const firstRetry = timeline.findIndex(t => t.startsWith('status:Reconnecting')); + const idleEndedFirst = + timeline.includes('idle:false') && timeline.indexOf('idle:false') < firstRetry; + const pass = connected && timedOutAfterQuiet && timedOutPromptly && idleEndedFirst; + appendLog( + pass + ? `[result] PASS: ${name} timed out ${gapMs} ms after the last idle frame` + : `[result] FAIL: ${name} connected=${connected} retried=${retried} gapMs=${gapMs} ` + + `timedOutAfterQuiet=${timedOutAfterQuiet} timedOutPromptly=${timedOutPromptly} ` + + `idleEndedFirst=${idleEndedFirst} timeline=${JSON.stringify(timeline)}` + ); + await sleep(300); + store.getState().session?.end(); +} + function App() { const [running, setRunning] = useState(false); const runningRef = useRef(false); @@ -431,8 +514,11 @@ function App() { runningRef.current = true; setRunning(true); try { - if (new URLSearchParams(window.location.search).get('scenario') === 'real-video-after-idle') { + const scenario = new URLSearchParams(window.location.search).get('scenario') ?? ''; + if (scenario === 'real-video-after-idle') { await runRealVideoAfterIdleTest(); + } else if (scenario in IDLE_SILENCE_SCENARIOS) { + await runIdleSilenceScenario(scenario, IDLE_SILENCE_SCENARIOS[scenario]); } else { await runTest(); } diff --git a/deps/cloudxr/webxr_client/tests/playwright/StreamAttachTimeoutTest.spec.js b/deps/cloudxr/webxr_client/tests/playwright/StreamAttachTimeoutTest.spec.js index a7d36b48ae..c788882e5b 100644 --- a/deps/cloudxr/webxr_client/tests/playwright/StreamAttachTimeoutTest.spec.js +++ b/deps/cloudxr/webxr_client/tests/playwright/StreamAttachTimeoutTest.spec.js @@ -64,3 +64,25 @@ test('StreamAttachTimeoutTest: real video after idle frames does not re-arm a wa const resultLine = lines.find(l => l.startsWith('[result]')); expect(resultLine, `captured lines:\n${lines.join('\n')}`).toContain('[result] PASS'); }); + +for (const scenario of ['idle-silence-never-began', 'idle-silence-stalled', 'idle-config-change']) { + test(`StreamAttachTimeoutTest: ${scenario} times out after the idle quiet period plus the phase budget`, async ({ + page, + }) => { + test.setTimeout(30000); + + const lines = collectLogLines(page); + await page.goto(`/StreamAttachTimeoutTest.html?scenario=${scenario}`); + await page.click('#startButton'); + + await expect + .poll(() => lines.some(l => l.startsWith('[result]')), { + timeout: 20000, + message: () => `scenario did not complete; captured so far:\n${lines.join('\n')}`, + }) + .toBe(true); + + const resultLine = lines.find(l => l.startsWith('[result]')); + expect(resultLine, `captured lines:\n${lines.join('\n')}`).toContain('[result] PASS'); + }); +}