diff --git a/app/api/machines/[machineId]/camera/route.ts b/app/api/machines/[machineId]/camera/route.ts index 8d5f429..232a799 100644 --- a/app/api/machines/[machineId]/camera/route.ts +++ b/app/api/machines/[machineId]/camera/route.ts @@ -12,6 +12,19 @@ interface RouteContext { export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +// This route is deployed to Vercel, where it runs as a serverless function with +// a wall-clock budget - and an MJPEG feed is a response that deliberately never +// ends. Without this the platform default (as little as 10s on some plans) cuts +// the picture off seconds after it appears, which reads as a broken camera +// rather than a platform limit. +// +// 300s is the documented ceiling, not a target: the stream is still killed when +// it expires. The panel's onError handler catches that and offers a reconnect, +// so a viewer watching a long print sees a brief interruption rather than a +// dead frame. A genuinely unbroken feed would mean not proxying through a +// serverless function at all. +export const maxDuration = 300 + const FALLBACK_DETAIL = 'The camera stream is unavailable.' /** diff --git a/components/production/machine-camera-panel.tsx b/components/production/machine-camera-panel.tsx index 4060f46..7615334 100644 --- a/components/production/machine-camera-panel.tsx +++ b/components/production/machine-camera-panel.tsx @@ -23,6 +23,11 @@ interface MachineCameraPanelProps { * (multipart/x-mixed-replace), which browsers render natively in an image tag * and not at all in a video element. */ +// Bounded so a stream that fails instantly cannot spin. Enough to cover a long +// watch across several serverless-function expiries, few enough that a genuine +// fault surfaces to the operator instead of retrying forever. +const MAX_AUTO_RECONNECTS = 20 + /** The proxy URL for one machine's feed. `t` defeats the browser's cache so a * retry opens a new connection instead of reusing a dead one. */ function cameraUrl(machineId: string, attempt: number): string { @@ -35,12 +40,16 @@ export function MachineCameraPanel({ }: MachineCameraPanelProps): JSX.Element { const [live, setLive] = useState(false) const [error, setError] = useState(null) + // Consecutive automatic reconnects. Reset by a successful start and by the + // operator toggling, so a long watch does not slowly exhaust the budget. + const [reconnects, setReconnects] = useState(0) // Bumped on each start so the browser re-requests instead of reusing a dead // connection it already has cached for this URL. const [attempt, setAttempt] = useState(0) function toggle(): void { setError(null) + setReconnects(0) if (live) { setLive(false) return @@ -76,9 +85,20 @@ export function MachineCameraPanel({ }) if (res.ok) { probe.abort() - // It answers when asked directly, so the stream itself dropped rather - // than being refused - a stall or a network blip, worth just retrying. - setError('The camera stream dropped. Turn it off and on again to reconnect.') + // The endpoint answers when asked directly, so nothing is misconfigured + // - the stream itself ended. On Vercel that is expected roughly every + // five minutes, because the proxy is a serverless function with a + // wall-clock budget and a live feed never finishes on its own. Silently + // reconnecting is the honest response to a limit we know about; making + // the operator re-toggle every five minutes would be pretending it is + // their problem. + if (reconnects < MAX_AUTO_RECONNECTS) { + setReconnects(n => n + 1) + setAttempt(n => n + 1) + setError(null) + return + } + setError('The camera stream keeps dropping. Turn it off and on again to retry.') return } const body: unknown = await res.json().catch(() => null)