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
13 changes: 13 additions & 0 deletions app/api/machines/[machineId]/camera/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'

/**
Expand Down
26 changes: 23 additions & 3 deletions components/production/machine-camera-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -35,12 +40,16 @@ export function MachineCameraPanel({
}: MachineCameraPanelProps): JSX.Element {
const [live, setLive] = useState(false)
const [error, setError] = useState<string | null>(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
Expand Down Expand Up @@ -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)
Expand Down
Loading