Files
zoneminder/web/js/EventStream.js
Isaac Connor e43d9db759 fix: stop EventStream restart storm in montage review
Recovery from a transient stream error could spiral into a permanent
loop that spawned and abandoned zms processes and produced endless
"giving up" errors.

Three faults are fixed:

- recover() dropped the dead connection only on the retry path, so the
  give-up branch returned with the status-poll timer and rAF loop still
  running. Each poll reply was another Error, each Error re-entered
  recover(), which gave up again forever. Tear down before the give-up
  check so giving up leaves nothing running.

- recover() had no guard against being called while a retry was already
  scheduled. Errors arriving during the backoff wait inflated the attempt
  counter until it gave up without ever retrying once. Ignore recover()
  while a recoveryTimer is pending.

- The recovery timer and start() both reused the existing <img>, and a
  consumer polling for a live stream (montagereview's outputUpdate) would
  call start() during the backoff window. The delayed recovery then
  overwrote the freshly started stream's src, aborting a healthy stream
  and orphaning its zms, which fired onerror straight back into recover().
  start() now tears down any existing connection first, and the recovery
  timer bails if the stream was already restarted.

Factor the duplicated teardown in stop()/recover()/switchEvent() into a
teardown() helper. switchEvent() now restarts via start() directly instead
of a timer, closing the same started=false race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:11:37 -04:00

501 lines
17 KiB
JavaScript

"use strict";
/**
* EventStream - Manages a persistent zms MJPEG connection for event playback.
*
* Mirrors the MonitorStream.js constructor-function pattern. Frames arrive via
* a hidden <img> receiving a multipart MJPEG stream from zms and are drawn to
* a caller-supplied <canvas> on each img.onload.
*
* Commands (seek, pause, play, rate changes) are sent to zms over its existing
* command-socket protocol via AJAX, exactly like MonitorStream and event.js.
*
* @param {Object} config
* @param {number} config.monitorId
* @param {number} config.monitorWidth - Native monitor width
* @param {number} config.monitorHeight - Native monitor height
* @param {string} config.url - URL to index.php (for command AJAX)
* @param {string} config.url_to_zms - PathToZMS base URL
* @param {HTMLCanvasElement} config.canvas
* @param {number} [config.scale=100] - Scale percentage
*/
function EventStream(config) {
this.monitorId = config.monitorId;
this.monitorWidth = config.monitorWidth;
this.monitorHeight = config.monitorHeight;
this.url = config.url;
this.url_to_zms = config.url_to_zms;
this.canvas = config.canvas;
this.scale = config.scale ? parseInt(config.scale) : 100;
this.connKey = null;
this.img = null;
this.started = false;
this.paused = false;
this.stopped = false;
this.currentEventId = null;
this.rate = 100;
this.status = null;
this.streamCmdTimer = null;
this.ajaxQueue = null;
this.rafId = null;
// Recovery state
this.consecutiveErrors = 0;
this.maxRecoveryAttempts = 5;
this.recoveryDelay = 1000; // ms, doubles on each retry
this.recoveryTimer = null;
this.lastOptions = null; // saved for restart after recovery
// Callbacks — set by the consumer
this.onStatus = null;
this.onError = null;
// How often to poll zms for status (ms). Use the global if available,
// otherwise fall back to a sensible default.
this.statusInterval = (typeof statusRefreshTimeout !== 'undefined')
? statusRefreshTimeout
: (typeof streamTimeout !== 'undefined') ? streamTimeout : 2000;
// Command parameters template — matches MonitorStream / event.js protocol
this.streamCmdParms = {
view: 'request',
request: 'stream',
connkey: null
};
// -------------------------------------------------------------------------
// connKey generation (identical to MonitorStream)
// -------------------------------------------------------------------------
this.genConnKey = function() {
return (Math.floor((Math.random() * 999999) + 1))
.toLocaleString('en-US', {minimumIntegerDigits: 6, useGrouping: false});
};
// -------------------------------------------------------------------------
// start(eventId, options) — Begin streaming an event
// -------------------------------------------------------------------------
/**
* @param {number|string} eventId
* @param {Object} [options]
* @param {number} [options.time] - Epoch seconds to start at
* @param {number} [options.frame=1] - Frame ID to start at
* @param {number} [options.rate=100] - Playback rate (100 = 1x)
* @param {string} [options.replay='none']
* @param {number} [options.maxfps] - Max FPS for the stream
*/
this.start = function(eventId, options) {
options = options || {};
// An explicit start supersedes any pending recovery.
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
// Tear down any existing connection first. Re-using a live <img> would
// abort its MJPEG stream behind zms's back and orphan the zms process.
if (this.started || this.img) this.teardown(this.started);
this.currentEventId = eventId;
this.rate = (options.rate !== undefined) ? options.rate : 100;
this.paused = false;
this.stopped = false;
this.lastOptions = Object.assign({}, options);
// Fresh connkey for this stream
this.connKey = this.genConnKey();
this.streamCmdParms.connkey = this.connKey;
// Build zms URL
var src = this.url_to_zms +
'?source=event' +
'&mode=jpeg' +
'&event=' + eventId +
'&monitor=' + this.monitorId +
'&scale=' + this.scale +
'&rate=' + this.rate +
'&maxfps=' + (options.maxfps || 5) +
'&replay=' + (options.replay || 'none') +
'&connkey=' + this.connKey;
if (options.frame) {
src += '&frame=' + options.frame;
}
if (options.time) {
src += '&time=' + options.time;
}
// Auth
if (typeof auth_relay !== 'undefined' && auth_relay) {
src += '&' + auth_relay;
}
// Use a DOM <img> element for MJPEG reception. Browsers natively
// update a DOM <img> with each frame from a multipart/x-mixed-replace
// response, but a detached Image() object does not reliably trigger
// onload per frame. We position it off-screen and draw from it to
// the canvas on a requestAnimationFrame loop.
if (!this.img) {
this.img = document.createElement('img');
this.img.style.cssText = 'position:absolute;left:-9999px;top:-9999px;' +
'width:1px;height:1px;visibility:hidden;';
document.body.appendChild(this.img);
}
var self = this;
this.img.onerror = function() {
console.warn('EventStream: MJPEG stream error for event ' +
self.currentEventId + ' (monitor ' + self.monitorId + ')');
self.streamCmdTimer = clearInterval(self.streamCmdTimer);
if (self.rafId) {
cancelAnimationFrame(self.rafId);
self.rafId = null;
}
// Attempt recovery — zms likely died
self.recover();
};
// onload fires once when the first MJPEG frame arrives, confirming
// the zms process is running and the command socket is ready.
this.img.onload = function() {
// Successful frame — reset error counter
self.consecutiveErrors = 0;
self.recoveryDelay = 1000;
if (!self.streamCmdTimer) {
self.streamCmdQuery();
self.streamCmdTimer = setInterval(
self.streamCmdQuery.bind(self), self.statusInterval
);
}
};
// Start the rAF draw loop — draws whenever the browser has
// decoded a new MJPEG frame into the img element.
this.startDrawLoop();
// Setting src starts the MJPEG connection
this.img.src = src;
this.started = true;
};
// -------------------------------------------------------------------------
// teardown(quit) — Drop the connection, timers and draw loop.
// Pass quit=false when zms is already gone, so we don't ask a dead process
// to exit. Detaching the img handlers before clearing src keeps our own
// teardown from firing onerror and looking like a stream failure.
// -------------------------------------------------------------------------
this.teardown = function(quit) {
if (quit && this.started) this.streamCommand(CMD_QUIT);
this.streamCmdTimer = clearInterval(this.streamCmdTimer);
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.img) {
this.img.onload = null;
this.img.onerror = null;
this.img.src = '';
if (this.img.parentNode) {
this.img.parentNode.removeChild(this.img);
}
this.img = null;
}
this.started = false;
this.connKey = null;
this.streamCmdParms.connkey = null;
};
// -------------------------------------------------------------------------
// stop() — Stop the current stream
// -------------------------------------------------------------------------
this.stop = function() {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
if (!this.started) return;
this.teardown(true);
this.paused = false;
this.stopped = false;
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
};
// -------------------------------------------------------------------------
// recover() — Attempt to restart after zms death
// -------------------------------------------------------------------------
this.recover = function() {
// A recovery is already scheduled. Without this, every error arriving
// while we wait to retry (the status poll keeps firing, and each reply
// is another Error) would queue another restart and inflate the attempt
// count until we give up on a stream that was never retried once.
if (this.recoveryTimer) return;
this.consecutiveErrors++;
var self = this;
var eventId = this.currentEventId;
var opts = Object.assign({}, this.lastOptions || {});
opts.rate = this.rate;
// Drop the dead connection before deciding whether to retry, so that
// giving up leaves nothing running. zms is already gone, so no CMD_QUIT.
this.teardown(false);
this.stopped = false;
if (this.consecutiveErrors > this.maxRecoveryAttempts) {
console.error('EventStream: max recovery attempts reached for monitor ' +
this.monitorId + ', giving up');
if (this.onError) this.onError('Stream recovery failed');
return;
}
console.warn('EventStream: recovery attempt ' + this.consecutiveErrors +
'/' + this.maxRecoveryAttempts + ' for monitor ' + this.monitorId);
// Delay before restarting — exponential backoff
this.recoveryTimer = setTimeout(function() {
self.recoveryTimer = null;
// teardown() cleared started, so a consumer polling for a live stream
// may have restarted us while we waited. Restarting again here would
// abort that healthy stream and orphan its zms, which fails the img
// and lands us straight back in recover().
if (self.started) return;
self.start(eventId, opts);
}, this.recoveryDelay);
this.recoveryDelay = Math.min(this.recoveryDelay * 2, 10000);
};
// -------------------------------------------------------------------------
// seek(offset) — Seek within the current event (seconds from start)
// -------------------------------------------------------------------------
this.seek = function(offset) {
if (!this.started) return;
this.streamCommand({command: CMD_SEEK, offset: offset});
};
// -------------------------------------------------------------------------
// seekToTime(epochSecs) — Seek by wall-clock time
// -------------------------------------------------------------------------
this.seekToTime = function(epochSecs) {
if (!this.started || !this.status) return;
// status.event gives us the current event ID; we need the event's
// start time to compute an offset. If the caller hasn't provided
// event metadata we fall back to duration-based estimation.
//
// For montagereview integration the caller will typically have the
// event start time available in the global `events` object.
var eventStartSecs = null;
if (typeof events !== 'undefined' && events[this.currentEventId]) {
eventStartSecs = events[this.currentEventId].StartTimeSecs;
}
if (eventStartSecs) {
var offset = epochSecs - eventStartSecs;
if (offset < 0) offset = 0;
this.seek(offset);
}
};
// -------------------------------------------------------------------------
// setRate(rate) — Change playback rate (100 = 1x realtime)
// -------------------------------------------------------------------------
this.setRate = function(rate) {
this.rate = rate;
if (!this.started) return;
this.streamCommand({command: CMD_VARPLAY, rate: rate});
};
// -------------------------------------------------------------------------
// pause() / play()
// -------------------------------------------------------------------------
this.pause = function() {
if (!this.started) return;
this.paused = true;
this.streamCommand(CMD_PAUSE);
};
this.play = function() {
if (!this.started) return;
this.paused = false;
this.streamCommand(CMD_PLAY);
};
// -------------------------------------------------------------------------
// setScale(scale) — Change the stream scale
// -------------------------------------------------------------------------
this.setScale = function(scale) {
this.scale = scale;
if (!this.started) return;
this.streamCommand({command: CMD_SCALE, scale: scale});
};
// -------------------------------------------------------------------------
// switchEvent(eventId, options) — Switch to a different event
// -------------------------------------------------------------------------
this.switchEvent = function(eventId, options) {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
// Reset recovery state for fresh event
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
// start() tears the old stream down (sending CMD_QUIT) and brings the new
// event up in one step. Doing the teardown here instead and starting from
// a timer would leave started=false in between, and a consumer polling for
// a live stream would start its own before the timer fired.
this.start(eventId, options);
};
// -------------------------------------------------------------------------
// streamCommand(command) — Send a command to zms via AJAX
// -------------------------------------------------------------------------
this.streamCommand = function(command) {
if (!this.started) {
return;
}
var params = Object.assign({}, this.streamCmdParms);
if (typeof command === 'object') {
for (var key in command) {
if (command.hasOwnProperty(key)) params[key] = command[key];
}
} else {
params.command = command;
}
this.streamCmdReq(params);
};
// -------------------------------------------------------------------------
// streamCmdReq(params) — Low-level AJAX to the command socket
// -------------------------------------------------------------------------
this.streamCmdReq = function(params) {
var self = this;
this.ajaxQueue = jQuery.ajaxQueue({
url: this.url + (auth_relay ? '?' + auth_relay : ''),
xhrFields: {withCredentials: true},
data: params,
dataType: 'json'
})
.done(function(respObj) {
self.getStreamCmdResponse(respObj);
})
.fail(function(jqXHR, textStatus) {
if (textStatus === 'abort') return;
console.warn('EventStream: AJAX failed for monitor ' +
self.monitorId + ': ' + textStatus);
// AJAX failure likely means zms has died (socket gone)
self.recover();
});
};
// -------------------------------------------------------------------------
// streamCmdQuery() — Periodic CMD_QUERY for status updates
// -------------------------------------------------------------------------
this.streamCmdQuery = function() {
if (this.started) {
var params = Object.assign({}, this.streamCmdParms);
params.command = CMD_QUERY;
this.streamCmdReq(params);
}
};
// -------------------------------------------------------------------------
// getStreamCmdResponse(respObj) — Handle CMD_QUERY / command responses
// -------------------------------------------------------------------------
this.getStreamCmdResponse = function(respObj) {
if (!respObj) return;
if (respObj.result === 'Error' || respObj.result === 'Err') {
console.warn('EventStream: command error for monitor ' +
this.monitorId);
// Error response means stream.php couldn't talk to zms — recover
this.recover();
return;
}
// Successful response — reset error counter
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
if (!respObj.status) return;
this.status = respObj.status;
// Update auth hash if the server sent a fresh one
if (this.status.auth) {
if (typeof auth_hash !== 'undefined' && this.status.auth !== auth_hash) {
auth_hash = this.status.auth;
}
if (typeof auth_relay !== 'undefined' && this.status.auth_relay) {
auth_relay = this.status.auth_relay;
}
}
// Track paused and stopped state from server
if (this.status.paused !== undefined) {
this.paused = !!this.status.paused;
}
if (this.status.stopped !== undefined) {
this.stopped = !!this.status.stopped;
}
// Notify consumer
if (this.onStatus) {
this.onStatus(this.status);
}
};
// -------------------------------------------------------------------------
// startDrawLoop() — rAF loop that copies the MJPEG img to the canvas
// -------------------------------------------------------------------------
this.startDrawLoop = function() {
var self = this;
function loop() {
if (!self.started) return;
self.drawFrame();
self.rafId = requestAnimationFrame(loop);
}
this.rafId = requestAnimationFrame(loop);
};
// -------------------------------------------------------------------------
// drawFrame() — Draw the current MJPEG frame to the canvas
// -------------------------------------------------------------------------
this.drawFrame = function() {
if (!this.canvas || !this.img) return;
// Only draw if the img has decoded at least one frame
if (!this.img.naturalWidth) return;
var ctx = this.canvas.getContext('2d');
ctx.drawImage(this.img, 0, 0, this.canvas.width, this.canvas.height);
if (this.onFrameDrawn) this.onFrameDrawn(this.canvas);
};
}