Montage review created 86 stream connkeys in five minutes while only two of
them were ever polled, and each stream painted a single frame before being
replaced about 1.3s later.
The status poll for a connkey whose zms had exited returned result=Error,
recover() restarted the stream under a new connkey, and the reply already
queued for the old one arrived afterwards and restarted the new stream too.
Every restart painted its first frame, which reset consecutiveErrors and
recoveryDelay in img.onload, so the backoff never grew and the attempt limit
was never reached.
- Tag each ajax/stream.php exchange with the connkey it was issued for and
ignore a reply, success or failure, once that connkey has been replaced.
stream.php does not echo the connkey back, so the client tracks it.
- Restart only on reason=no_socket, the one class that means zms is gone,
matching streamErrorIsFatal() in MonitorStream.js. The helper is duplicated
as EventStream.errorIsFatal() because montagereview.php does not load
MonitorStream.js.
- Start the poll timer next to the src that created the connkey instead of in
img.onload, which is not a dependable per-restart signal for a
multipart/x-mixed-replace img, and let the first query wait a full interval
so a stream that is still starting is not read as a missing socket.
- Reset the recovery counters only on a successful status reply, so the
exponential backoff and the attempt limit both work.
Also stop appending a second '?' to UrlToZMS, which already carries
'?monitor=N', so the logs no longer show monitor=24?source=event.
tests/js/eventstream-connkey.test.js covers the stale-reply rules, the fatal
classification and the URL separator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YEe1M3PWVMqYWihUeAa5N
The page kept two copies of the same secret: auth_relay, the query fragment
every AJAX call is authenticated with, and auth_hash, the bare hash stamped
into stream <img> URLs. Different responses updated different copies, so
they could drift, and a drifted auth_hash produced stream URLs that zms
rejects.
ZMAuth stores only the relay and derives the hash from it, so the two cannot
disagree. Its helpers cover the four shapes the call sites used:
zmAuth.hash derived, '' under the plain/none relay forms
zmAuth.update(data) absorb the auth fields of any response
zmAuth.appendTo(url) authenticate a url, no-op when auth is off
zmAuth.applyTo(src) point a stream url at the current credential
appendTo also removes the `x ? '&'+x : ''` guard repeated at every call
site, some of which had omitted it and emitted a dangling '?'.
Migrates all call sites across web/js and the classic skin, and drops both
globals from skin.js.php.
Tests: tests/js/auth-helpers.test.js, 44 passing.
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>
When CMD_STOP is sent via AJAX using ZMS MJPEG streaming, the response
was incorrectly returning paused=true instead of indicating a stopped
state (paused=false).
Changes:
- Add `stopped` boolean to StreamBase (zm_stream.h)
- MonitorStream: CMD_STOP now sets stopped=true, paused=false instead
of paused=true; run loop skips frame sending when stopped
- EventStream: CMD_STOP sets stopped=true (was already setting
paused=false); run loop skips frame sending when stopped
- All other play/pause commands reset stopped=false
- Both streams include stopped field in the status response struct
- stream.php unpacks the new stopped field from MSG_DATA_WATCH and
MSG_DATA_EVENT responses
- MonitorStream.js handles stopped status in UI (shows 'Stopped' mode)
- EventStream.js tracks stopped state from server response
Fixes issue: CMD_STOP response paused=true should be paused=false
Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ba9cb47a-a3e8-4e13-aec7-c9cd258e2a3d
Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com>
- Integrate EventStream.js into montagereview.js for persistent MJPEG
streaming in replay mode (speeds >= 1x), falling back to per-frame
mode=single for speeds < 1x where zms lacks slow-motion support
- Add EventStream recovery logic: detect zms death via AJAX failures,
img.onerror, and error responses; auto-restart with new connkey and
exponential backoff (max 5 retries)
- Fix zms crash on bulk/interpolated frames: add stat() check in
sendFrame() for SaveJPEGs & 1 path, fall through to ffmpeg_input
when JPEG file doesn't exist on disk; send "No frame available"
text frame instead of terminating when no source available
- Center monitor canvases: text-align in CSS for scale mode,
horizontal offset calculation in maxfit2() for fit mode
- Reduce console noise: comment out high-frequency debug logs,
convert error-path logs to console.warn/error
- Fix ESLint sourceType to "script" for traditional non-module JS
files, resolving false-positive no-unused-vars on global functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A detached Image() object does not reliably decode multipart MJPEG
streams — browsers only update a DOM-attached <img> with each frame
from a multipart/x-mixed-replace response. Switch to creating a
hidden DOM <img> element and use requestAnimationFrame to copy
decoded frames to the canvas.
Also properly clean up the img element from the DOM in stop() and
switchEvent(), and guard drawFrame() against drawing before the
first frame has been decoded.
Tested: start, pause, play, seek, setRate, stop all verified
working against a live zms event stream.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce EventStream constructor that manages a persistent zms MJPEG
connection for event playback, replacing the per-frame mode=single HTTP
request pattern. Frames arrive via a hidden Image element and are drawn
to a caller-supplied canvas on each onload.
Supports seek, pause, play, rate control, scale changes, and event
switching via the existing zms command-socket protocol. Follows the
same constructor-function pattern as MonitorStream.js.
Include the script in montagereview.php for upcoming integration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>