Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,6 @@ export default function CloudXRComponent({
const warmupEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fires IDLE_FRAMES_QUIET_MS after the last idle-frame log; see noteIdleFrame.
const idleQuietTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// True from an idle-frame log until idleQuietTimerRef fires or the attempt ends.
const idleFramesActiveRef = useRef(false);
const idleStatusRef = useRef<IdleStatus>({ 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).
Expand Down Expand 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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}, []);
Expand All @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -793,7 +799,7 @@ export default function CloudXRComponent({
!completeMatch &&
!headless &&
cxrSessionRef.current &&
!idleFramesActiveRef.current
!idleStatusRef.current.active
) {
armWarmupEndTimer(cxrSessionRef.current);
}
Expand Down
90 changes: 88 additions & 2 deletions deps/cloudxr/webxr_client/tests/mock/StreamAttachTimeoutTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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 (
<XR store={store}>
<XROrigin />
<CloudXRComponent
config={config}
config={sceneConfig}
applicationName="StreamAttachTimeoutTest"
iceServers={{ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }}
reconnect={{ maxAttempts: 4, delayMs: 300 }}
Expand All @@ -189,6 +200,7 @@ function Scene() {
warmupEndTimeoutMs={WARMUP_END_TIMEOUT_MS}
onStatusChange={(isConnected, status) => {
statusMessages.push(status);
statusTimes.push(Date.now());
timeline.push(`status:${status}`);
appendLog(`[status] connected=${isConnected} ${status}`);
if (status === 'Connected') {
Expand Down Expand Up @@ -420,6 +432,77 @@ async function runRealVideoAfterIdleTest(): Promise<void> {
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<string, IdleSilenceScenario> = {
'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<void> {
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);
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
}
Loading