Renew overlay camera connections after silent stalls (#3213)

This commit is contained in:
Adam Spice
2026-10-01 15:16:59 +02:00
committed by GitHub
parent e982c71c1d
commit d5ee699489
4 changed files with 148 additions and 10 deletions
@@ -0,0 +1,77 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useOverlayCameraRecovery } from '../../hooks/useOverlayCameraRecovery';
describe('useOverlayCameraRecovery', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('renews the connection periodically without an error event', () => {
const { result } = renderHook(() => useOverlayCameraRecovery(true));
const initial = result.current.imageKey;
act(() => vi.advanceTimersByTime(59_999));
expect(result.current.imageKey).toBe(initial);
act(() => vi.advanceTimersByTime(1));
expect(result.current.imageKey).not.toBe(initial);
act(() => vi.advanceTimersByTime(60_000));
expect(result.current.imageKey).toBe(initial + 2);
});
it('deduplicates image errors and retries after three seconds', () => {
const { result } = renderHook(() => useOverlayCameraRecovery(true));
const initial = result.current.imageKey;
act(() => { result.current.handleStreamError(); result.current.handleStreamError(); });
act(() => vi.advanceTimersByTime(2_999));
expect(result.current.imageKey).toBe(initial);
act(() => vi.advanceTimersByTime(1));
expect(result.current.imageKey).toBe(initial + 1);
});
it('gives a connection opened by an error retry a full renewal period', () => {
const { result } = renderHook(() => useOverlayCameraRecovery(true));
const initial = result.current.imageKey;
act(() => vi.advanceTimersByTime(56_000));
act(() => result.current.handleStreamError());
act(() => vi.advanceTimersByTime(3_000));
expect(result.current.imageKey).toBe(initial + 1);
act(() => vi.advanceTimersByTime(59_999));
expect(result.current.imageKey).toBe(initial + 1);
act(() => vi.advanceTimersByTime(1));
expect(result.current.imageKey).toBe(initial + 2);
});
it('cancels a pending error retry when periodic renewal happens first', () => {
const { result } = renderHook(() => useOverlayCameraRecovery(true));
const initial = result.current.imageKey;
act(() => vi.advanceTimersByTime(59_000));
act(() => result.current.handleStreamError());
act(() => vi.advanceTimersByTime(1_000));
expect(result.current.imageKey).toBe(initial + 1);
act(() => vi.advanceTimersByTime(3_000));
expect(result.current.imageKey).toBe(initial + 1);
});
it('does not schedule recovery while the camera is disabled', () => {
const { result } = renderHook(() => useOverlayCameraRecovery(false));
const initial = result.current.imageKey;
act(() => result.current.handleStreamError());
expect(vi.getTimerCount()).toBe(0);
act(() => vi.advanceTimersByTime(120_000));
expect(result.current.imageKey).toBe(initial);
});
it('cancels all recovery timers when hidden or unmounted', () => {
const { result, rerender, unmount } = renderHook(
({ enabled }) => useOverlayCameraRecovery(enabled), { initialProps: { enabled: true } },
);
act(() => result.current.handleStreamError());
rerender({ enabled: false });
expect(vi.getTimerCount()).toBe(0);
act(() => result.current.handleStreamError());
expect(vi.getTimerCount()).toBe(0);
rerender({ enabled: true });
act(() => result.current.handleStreamError());
unmount();
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
import { act, screen, waitFor, render as rtlRender } from '@testing-library/react';
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@@ -106,6 +106,32 @@ describe('StreamOverlayPage', () => {
vi.unstubAllGlobals();
});
it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
...mockStatusIdle, camera_rotation: 90,
})));
const timers = vi.spyOn(globalThis, 'setTimeout');
const view = renderOverlayPage(1, `?token=overlay-test&fps=7${artwork}`);
try {
const image = await screen.findByAltText('Camera stream');
const source = image.getAttribute('src');
expect(source).toContain('token=overlay-test');
const renewal = timers.mock.calls.find(([, delay]) => delay === 60_000)?.[0];
expect(renewal).toBeTypeOf('function');
if (typeof renewal !== 'function') throw new Error('Missing camera renewal timer');
act(() => renewal());
const renewed = screen.getByAltText('Camera stream');
expect(renewed).not.toBe(image);
expect(renewed.getAttribute('src')).not.toBe(source);
expect(renewed.getAttribute('src')).toContain('token=overlay-test');
expect(renewed.getAttribute('src')).toContain('fps=7');
expect(renewed.style.transform).toBe(artwork ? 'translate(-50%, -50%) rotate(90deg)' : 'rotate(90deg)');
} finally {
view.unmount();
timers.mockRestore();
}
});
describe('updated artwork', () => {
it('maps legacy model codes in version 2', async () => {
server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
// MJPEG <img> connections can stop delivering frames without firing onError.
// Renew only the camera connection, keeping the overlay and its credentials.
// This also renews healthy connections; it does not measure frame freshness.
const STREAM_RENEWAL_MS = 60_000;
const ERROR_RETRY_MS = 3_000;
export function useOverlayCameraRecovery(enabled: boolean) {
const [imageKey, setImageKey] = useState(Date.now);
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const renew = useCallback(() => {
if (retryTimer.current !== null) {
clearTimeout(retryTimer.current);
retryTimer.current = null;
}
setImageKey((key) => key + 1);
}, []);
useEffect(() => {
if (!enabled) return;
// Start a fresh deadline after each retry so a newly opened connection
// isn't immediately replaced by the previous connection's renewal timer.
const renewalTimer = setTimeout(renew, STREAM_RENEWAL_MS);
return () => {
clearTimeout(renewalTimer);
if (retryTimer.current !== null) {
clearTimeout(retryTimer.current);
retryTimer.current = null;
}
};
}, [enabled, imageKey, renew]);
const handleStreamError = useCallback(() => {
if (!enabled || retryTimer.current !== null) return;
retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
}, [enabled, renew]);
return { imageKey, handleStreamError };
}
+3 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,7 @@ import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
import { api, ApiError, withStreamToken } from '../api/client';
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
import { mapModelCode } from '../utils/printerModel';
import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
type TFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -168,7 +169,6 @@ export function StreamOverlayPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const id = parseInt(printerId || '0', 10);
const [imageKey, setImageKey] = useState(Date.now());
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
const sizes = getSizeClasses(config.size);
@@ -224,6 +224,7 @@ export function StreamOverlayPage() {
config.showModel ? mapModelCode(printer?.model ?? null) : null,
].filter(Boolean).join(' · ');
const status = kiosk ? overlay : statusData;
const { imageKey, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
// WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
@@ -292,13 +293,6 @@ export function StreamOverlayPage() {
};
}, [printer, t]);
// Refresh stream on error
const handleStreamError = () => {
setTimeout(() => {
setImageKey(Date.now());
}, 3000);
};
if (!id) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">