setStreamScale was called once while the monitors were being started, before
changeScale had sized the feed, so it computed from an unsized frame, hit the
lower clamp and asked for scale=25. On a 1920x1080 monitor shown at 747px wide
that served a 480px image for the browser to enlarge, which looked soft.
changeScale now sets the scale from the width it just fitted, rounded down to
the nearest 5 the way scaleToFit does.
The three profiles' settings were aliased by a switch with one arm per
profile, each repeating the same 18 define() calls against a different
ZM_WEB_<H|M|L>_ prefix. Sixty three lines in which only a single letter
differed, and nothing held the arms in step: a setting added to one and not
the others is undefined for two thirds of users, which is the same blank page
the missing default case caused, just narrower.
Name the settings once and build both sides from the prefix. The two settings
that carried a defined() guard keep it, as a separate list with the fallback
each one uses, so a genuinely absent setting is still distinguishable from a
mistyped one - the rest go through constant() and fail loudly.
Looking up an unknown profile now yields the low prefix instead of skipping
every define, so the skin config no longer depends on skin.php having clamped
the cookie first; that clamp remains the place a bad value is corrected.
Verified by diffing every resulting ZM_WEB_ constant against the previous
implementation for each of the three profiles: 49 constants, identical values.
The test drops the checks that only made sense against the switch and gains
ones for the new shape. It loads the skin config in a child process, once per
profile probed, because constants cannot be redefined and loading it is itself
what can fail. It now catches a mistyped setting name, a removed fallback, a
profile the whitelist does not know, and a setting dropped from the alias list
- the last by way of the per-profile config options, which are the authority
on which settings exist and are independent of the lists under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
web/skins/classic/includes/config.php defines all 18 ZM_WEB_* constants inside
a switch on $_COOKIE['zmBandwidth'] with cases for high, medium and low and no
default. On any other value none of them are defined, and skin.js.php - emitted
in the footer of every page - reads ZM_WEB_VIEWING_TIMEOUT, ZM_WEB_AJAX_TIMEOUT
and ZM_WEB_REFRESH_NAVBAR. On PHP 8 an undefined constant is a fatal Error, so
every page including login stops rendering until the cookie is cleared, which
cannot be done from inside the interface.
skin.php only tested the value for empty, and nothing else validated it:
- the cookie is set client side by skin.js, so any value survives
- action=bandwidth put $_REQUEST['newBandwidth'] through validStr, which is
only strip_tags, and persisted it
- ZM_BANDWIDTH_DEFAULT is a free-form string in ConfigData. The Options UI
renders it as a select, but loadConfig lets a conf.d file override the
database, so a typo there locks out everyone with no cookie yet
skin.php now validates both the cookie and ZM_BANDWIDTH_DEFAULT before falling
back to low, and the action rejects a value it does not recognise rather than
storing it.
tests/php/test_bandwidth_clamp.php checks the whitelist against the switch it
guards - the two must name the same profiles, since a value in one and not the
other reopens this - and that no arm of the switch defines a constant the
others do not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Put a native input type=color beside the R/G/B number inputs, the same
control monitor.php uses for WebColour and SignalCheckColour, seeded from
the zone's AlarmRGB.
The picker has no name, so it is neither submitted nor part of the change
detection snapshot; the R/G/B inputs stay the source of truth for
validateForm and submitForm. alarmColourFromInputs keeps the swatch and
its disabled state in step with those inputs, and is called from
applyZoneType and resetChanges; alarmColourToInputs writes the picked
colour back into them and re-runs the dirty check.
Move Save, Reset and Cancel out of the column below the video and into a
float-right div in the header, as btn-normal icon buttons with tooltips,
matching the snapshot and report views. Back and Refresh stay on the left.
saveChanges now takes the form from document.zoneForm rather than
element.form, because the Save button no longer sits inside the form.
changeScale uses the stream controls as the scaleToFit bottom element now
that the button row is gone, and sets their width and left margin from the
video, so their left edge lines up with the image.
Drop the right margin from #zonePoints. It is full width inside the settings
panel, so the margin pushed it past the panel and gave the panel a
horizontal scrollbar.
Widen the settings column from 480px to 540px and move the zone point
table into it, below the settings table, so the left hand column holds
only the video, the stream controls and the Save/Reset/Cancel buttons.
Size the feed with scaleToFit(), the same helper the watch view uses,
passing the button row as the bottom element. skin.js already calls a
global changeScale() from its resize handler, so the video re-fits when
the window changes size instead of pushing the buttons out of the
#page overflow.
Give the settings table a fixed layout with wrapping row headers, and
narrow the pixel inputs, so its three columns stay within 540px. Add
pointIndex/pointX/pointY/pointAction classes to the point table cells,
both in the template and in drawZonePoints, and give the action column a
fixed 80px so the + and - buttons do not wrap onto separate lines.
Columns still stack below 900px wide.
Add a Reset button beside Save/Cancel in the zone edit view. It stays
disabled until the form differs from the state loaded from the database,
and restores every field, the zone points and the derived disabled states
when clicked.
Change detection serialises all named form fields, including the
per-point X/Y inputs, and compares against a snapshot taken at the end of
initPage, so manually undoing an edit disables the button again. Number
inputs are compared with parseFloat because updateX/limitPointValue
rewrite 100.00 as 100, which otherwise reads as a change.
Two snapshots are kept: initialValues holds the raw values used to
restore field formatting, initialState holds the normalised string used
for comparison. Point inputs are excluded from the restore because
drawZonePoints re-creates them from the restored points array.
Coordinate inputs of a point that differs from the loaded zone get a
'changed' class with a yellow background, so dragging a point shows which
coordinates moved.
The monitor attribute filters and the event filter terms were unrelated
pieces of UI that happened to sit next to each other. They are not
independent: Name, Capturing, Analysing, Recording, Status and Source do
not filter events at all, they narrow which monitors exist to choose
between - which is to say they decide what the Monitor term should offer.
Anything they exclude cannot appear in the events either, so offering it
in the term is offering a choice that returns nothing.
Filter carries the restriction rather than the widget taking it as an
argument: monitor_options() is a get/set in the same shape as terms(), so a
view states the rule on the filter and then asks that filter for a widget.
Unset, the Monitor term offers every monitor the user can view, so
events.php and any other caller is unchanged.
montagereview sets the monitors that survived its filter bar.
Verified on the install this repo matches: the events view still lists all
monitors in its Monitor term, montage review renders its event filters as
Monitor, Date Time, Date Time, Archive Status, Tags, Notes with Name,
Capturing, Analysing, Recording, Status and Source collapsed, and the
events, console and filter views all still load.
Montage review drew the monitor attribute filters inline - Name, Capturing,
Analysing, Recording, Status, Source - taking rows of the filter bar for
controls that choose which cameras appear and are rarely touched once set.
Put them behind their own control, collapsed by default.
The block keeps its place in the layout rather than being display:none:
skin.js applies hidden-shift from data-initial-state-icon, which moves it
off screen while leaving it measurable so Chosen still initialises the
selects inside it. applyChosen runs when it is opened.
The monitor selection itself is the result of those filters rather than one
of them, so it stays visible with the event filter terms. buildMonitorsFilters()
now also returns the selection on its own and the attribute filters without
it; filterBar is unchanged, so the other five callers are unaffected. The
selection is spliced into simple_widget()'s own flex row so it sits on the
same line as Date Time and the rest; emitted as a sibling it would take a
row of its own. If that markup ever changes, the replace count is checked
and it falls back to its own row with a logged warning rather than
disappearing.
A monitor selector was also being drawn twice: _monitor_filters.php renders
MonitorId[] for the session selection, and montagereview.php added the same
selection again as a Monitor IN filter term, which the filter widget drew as
a second control. Its sibling branch added a run of MonitorId = terms and
was marked "this should be redundant" when written. Both are gone; neither
is needed for the query, since the bar has already reduced $displayMonitors
and loadEventData() asks for those ids.
Verified on the install this repo matches:
event filters row Monitor, Date Time, Date Time, Archive Status, Tags, Notes
collapsed Name, Capturing, Analysing, Recording, Status, Source
one monitor selector, on the same row as the first term, starting collapsed
FilterComponent::buildFilter() checked the operator against an allow list
but never the field, so an unrecognised name went straight into the SQL:
GET /api/events/index/MonitorName =:Monitor-1.json
PDOException SQLSTATE[42S22]: Column not found: 1054
Unknown column 'MonitorName' in 'where clause' -> HTTP 500
A caller filtering on a monitor-scoped attribute (MonitorName, Monitor,
Rack, Experiment, Server, Storage) or simply mistyping a column got an
opaque 500 with a stack trace and nothing naming the offending field.
Take an optional list of permitted fields and reject anything else with
BadRequestException. EventsController::index() passes the Events columns
from the model schema plus the two names it resolves itself: DateTime, the
pseudo-attribute it turns into an overlap test, and GroupId, served by the
Groups_Monitors join. A field written Model.Field is checked on the column.
The two other callers of buildFilter pass no list and are unchanged.
The invalid-argument and invalid-operator paths threw a plain Exception,
which was also a 500 for what is bad input; they throw BadRequestException
now too.
Verified against a live install:
MonitorName / Rack / NotAColumn 400, "Unknown filter field: <name>"
DateTime >=/<= over a window 200, 7 rows, overlap intact
Cause, Event.Cause, GroupId 200
?limit=2&sort=...&direction=desc 200, 2 rows, pagination unaffected
Plus an assert script over the validation itself, including that a caller
passing no list keeps the previous pass-through behaviour.
Filters sent as a query string rather than named parameters go through a
different branch, on raw $_REQUEST, and are deliberately left unvalidated:
that array also carries auth and cache-busting parameters, so rejecting
unknown names there would break existing callers. ?MonitorName=x is still
a 500 and wants its own change.
a3b1ab558 translated the DateTime filter term to StartDateTime before
putting it in the events API URL, on the belief that DateTime was not a
real column and was being silently ignored. That was wrong.
EventsController::index() treats DateTime as a pseudo-attribute meaning
"the event was running then" and rewrites a window over it into an overlap
test, using an effective end date that falls back to StartDateTime + Length
and then to NOW() for an event with no EndDateTime (0d7abadb61).
StartDateTime is a plain column, so the same window becomes a containment
test and every event that was already recording when the window opened is
dropped. With continuous recording the window nearly always opens
mid-event, so montage review began with a blank stretch of timeline.
Measured against a live install, one monitor, 2026-06-23 16:05 to 17:05:
DateTime >=/<= 7 rows, earliest 16:01:28, includes the straddler
StartDateTime >=/<= 6 rows, earliest 16:11:39, straddler dropped
The evidence that led to a3b1ab558 was misread: a DateTime query returning
rows from over a year earlier looked like an ignored filter, but those are
crash-orphaned events with no EndDateTime and no Length, whose effective
end is NOW() and which therefore genuinely do overlap every window.
Keep the Monitor to MonitorId rewrite and the local-variable approach from
df6f48334; only the DateTime line goes.
logState() counted every row with Level < INFO and folded anything at or
below PANIC into the FATAL bucket. AUDIT is -5, so audit entries were
counted as fatals and pushed the state to alert/alarm.
Bound the count query at PANIC so AUDIT and NOLOG are left out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAFKf86P78WqniPEP2b45J
a3b1ab558 translated DateTime to StartDateTime by assigning to attr.value,
which edits the filter form itself, not just the query being built. The
form is submitted whenever a filter changes, and montagereview.php decides
whether the range terms are already there with
if (!$filter->has_term('DateTime', '>=')) (montagereview.php:193)
if (!$filter->has_term('DateTime', '<=')) (montagereview.php:196)
so a form arriving with StartDateTime failed both checks and PHP appended a
second pair. Selecting a monitor left the filter bar showing four date
terms.
Verified against a live install by submitting the same filter both ways:
terms as StartDateTime -> 7 terms, 4 date terms
terms as DateTime -> 5 terms, 2 date terms
Compute the API attribute into a local and leave attr.value alone. The
pre-existing Monitor to MonitorId rewrite moves too: it has the same shape
and montagereview.php has its own 'Monitor' handling.
The URL is unchanged, so the DateTime fix still holds; only the side effect
on the submitted form is gone.
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
loadEventData() fires one request per monitor but assigned them all to a
single `ajax` variable, so only the last was ever abortable. The
`if (ajax) ajax.abort()` guarding re-entry cancelled one of them; the rest
stayed in flight, landed after the new query, and drew events for a window
the user had already scrubbed past. This was the //FIXME sitting above
that line.
Track the requests in an array and abort all of them on re-entry. Also
stop reporting an abort as a failure: jQuery calls the error handler with
textStatus 'abort' for a request we cancelled ourselves, which would
otherwise log one console error per monitor on every scrub.
Checked with an assert script: twelve tracked requests all abort and the
list clears, the old single-variable pattern aborts only one of twelve,
an 'abort' status is not logged while a real error still is, and the list
re-arms for the next query.
loadEventData() builds the API URL straight from the filter form's terms
and translated only one attribute name, Monitor to MonitorId. But
montagereview.php expresses both of its time bounds as the DateTime
pseudo-attribute, which is a filter-engine concept rather than an Events
column: Filter.php resolves 'DateTime' and 'StartDateTime' to the same
E.StartDateTime, and groups them together everywhere else it special-cases
date attributes.
The API takes real column names and silently ignores a term it cannot
resolve, so the two bounds were dropped and the view got page one of every
event ever recorded instead of the requested window. Both requests return
200, which is why nothing surfaced. Measured against a live install for
the 2026-08-28 00:00:00 to 01:00:00 window:
DateTime >=/<= -> 757 rows, first event 2025-04-26 16:29:55
StartDateTime >=/<= -> 300 rows, 00:00:59 to 00:59:59
Translate DateTime the same way Monitor is already translated. Rewriting
the form element is safe for the PHP path too, since Filter.php treats the
two names identically.
Firefox reports an error location as "line:column", for example 1500:28.
ajax/log.php passed that through validInt(), which is
preg_replace('/[^\-\d]/', '', $input)
so it only removes the colon and splices the two numbers into 150028.
Logs.Line is smallint(5) unsigned, max 65535, so the insert died with
PDOException SQLSTATE[22003]: Out of range value for column 'Line'
includes/logger.php:438 <- ajax/log.php:81
and the request returned 500. The endpoint failed on the very error it
was sent to report, so a js error surfaced as "AJAX execution error:
Internal Server Error [500]" instead of a log entry. Any Firefox error
past roughly line 655 hits this, on any view.
Parse the leading line number instead and clamp it to what the column
holds. validInt() is left alone; it is used widely and its callers rely
on the strip-everything behaviour, the defect is handing it a line:column
string.
Checked with an assert script over 1500:28, 1789:8, a plain 1500, an
over-range 70000 and 99999:1, empty, non-numeric, and array input; the
first three give the line, the next two clamp to 65535, the rest stay NULL.
Two cases produced a Warning for a request that needs no CORS headers at
all, so the log filled with noise that pointed at nothing wrong.
An empty Origin header satisfies isset(), so CORSHeaders() walked the
servers list, matched nothing, and logged " is not found in servers list."
with no value to print. Treat an empty Origin the same as no Origin.
Browsers also send Origin on same-origin POST and fetch. Such a request
needs no headers, but not finding the host in the Servers table still
warned, so any install reached on a hostname the Servers table does not
list warned on ordinary use. Log that at Debug instead. Genuine
cross-origin requests still warn.
Headers are unchanged; this only affects logging and the empty-Origin
short circuit. The comparison ignores the scheme, so http:// against an
https-served HTTP_HOST counts as same-origin - that only suppresses a log
line, no header is emitted either way.
Checked with a standalone assert script over same-origin with and without
a port on both schemes, differing port, differing host, a suffix near-miss
(hamburg.local vs hamburg) and a missing HTTP_HOST; only the first three
go quiet.
Every request through index.php starts a session and always dirties it:
zm_session_set_remote_addr() writes remoteAddr, and index.php stores skin,
css and navbar_type. ZMSessionHandler::write() then persisted that session
unconditionally, so any request arriving without a ZMSESSID cookie left a
Sessions row behind that nothing would ever load again.
Viewing an event polls the event's server every ZM_WEB_REFRESH_STATUS
seconds via monitorUrl, which is absolute when the monitor has a Server
row. Those cross-origin ajax polls carry auth in the URL and no cookie, so
each one added a Sessions row every few seconds. Bot scans of the login
page did the same.
Persist a session only when the client presented our cookie, or when
zm_session_persist() marks it as one we are issuing: login, and the
postLoginQuery stashed before redirecting to the login page.
Verified on a live install by logging row counts from the save handler:
three cookieless requests skipped the write and left the count unchanged,
while a cookie-jar run wrote on the request that returned the cookie.
php -l clean on both files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAFKf86P78WqniPEP2b45J
SIGHUP means reload. zmc responds by closing its events, disconnecting the
camera and reconnecting; the perl daemons respond by exiting so zmdc restarts
them. zmdc.pl logrot hupped every managed process, so the nightly logrotate
run cost about 8 seconds of capture on a default install.
Rotating a log file only needs the daemon to drop its file handle, so use a
separate signal for it. SIGWINCH is otherwise unused, is ignored by default and
exists on every supported platform.
- Logger (C++) installs a SIGWINCH handler beside its USR1/USR2 handler. The
handler only sets a flag; the next logPrint closes the file and the write
reopens it at the original path. This covers every C++ binary without
touching any daemon's main loop.
- Logger.pm registers WINCH alongside HUP in logSetSignal, which logInit
already calls, so the scripts that install their own HUP handler still
rotate.
- zmdc.pl logrot sends WINCH. The logrotate config is unchanged - it still
calls zmpkg.pl logrot.
Filter.pm and FilterTerm.php justified MAX_EVENT_DAYS by events not outliving
the nightly HUP, which is no longer what bounds them; cite SectionLength.
Tests: tests/zm_logger_rotate.cpp and tests/perl/test_log_rotate_signal.pl both
log, rename the file out from under the process, confirm writes still land in
the renamed file, signal WINCH and confirm the original path is written again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
- Instead of Math.random(), we'll use Date.now().
This will make the code cleaner and more understandable.
- We also now use refreshStreamSrc() when stopping stream playback.
A DateTime lower bound was written as
COALESCE(E.EndDateTime, '9999-12-31 23:59:59') >= T1
reading "an event that has not ended yet never ends". EndDateTime is NULL for
an event that is still recording, but also for one zmc was killed part way
through, and that stays NULL forever - so every abandoned event matched every
window from then on. Wrapping the column in a function also meant no index
could range over it: with the only MonitorId key being MonitorId alone, every
montage review request read all of that monitor's events and filtered them in
memory, however narrow the window.
Length tells the two cases apart. It is flushed during recording, so a live
event's effective end keeps advancing while an abandoned one's is frozen at
whatever was recorded. Use the same CASE the SELECT list in ajax/events.php
already uses, and bound it below by StartDateTime.
The lower bound now emits three conjuncts:
E.StartDateTime >= DATE_SUB(T1, INTERVAL 1 DAY)
AND (E.EndDateTime IS NULL OR E.EndDateTime >= T1)
AND <effective end> >= T1
The floor bounds the scan for a window in the past and drops events abandoned
more than a day earlier - events do not outlive the nightly logrotate SIGHUP,
which stops and restarts them, so a day is comfortably beyond any real event.
The middle conjunct is implied by the third and exists only to give the
optimiser a second indexable handle: it is narrow exactly when the floor is
wide, so between them a window at either end of the retention period has
something cheap to range over. Verified the optimiser picks correctly for both,
unprompted. The third is the residual that gets the semantics right.
Add the two composite keys those conjuncts need. Two range columns cannot both
narrow one B-tree, and these are mirror images - EndDateTime >= T1 is
open-ended upwards, StartDateTime <= T2 downwards:
Events_MonitorId_StartDateTime_idx (MonitorId, StartDateTime)
Events_EndDateTime_MonitorId_idx (EndDateTime, MonitorId)
MonitorId leads the first because it is the equality; past a range column later
columns can no longer narrow the scan, and (StartDateTime, MonitorId) measured
3.5x slower for the same rows. EndDateTime leads the second so it also covers
zmaudit's hunt for events that were never closed, which has no monitor to scope
it - 9 rows with a key against a 22,948 row table scan without.
Both replacements are added before the keys they supersede are dropped:
Events_MonitorId_idx (MonitorId) is now a leftmost prefix of the new key.
Events_EndDateTime_DiskSpace (EndDateTime, DiskSpace) existed for the scan
that hunted events with no DiskSpace set; DiskSpace is set when the event is
finalised in C++, so nothing scans for it any more.
Net index count on Events is unchanged.
Measured on a 7,167 event monitor. Default one hour window: 7,055 rows
examined / 25.4ms -> 173 rows / 4ms. Window scrubbed a week back, which
persists in the zmFilter_StartDateTime cookie: 6,995 rows / 28ms -> 19 rows /
0.15ms.
Adds migration db/zm_update-1.39.22.sql. Extends t/filter_sql.t; the PHP and Perl were
checked to emit identical SQL, and the semantics checked against a table of
seven event shapes - abandoned long ago, abandoned recently, overlapping,
inside, before, still recording, and still recording for 17 hours.
A filter with LockRows set selected its entire result set FOR UPDATE inside one
transaction, so every lock the per-event work went on to take was held until
the run committed:
Events[Id] -> Events_Hour/Day/Week/Month[EventId] -> Event_Summaries[MonitorId] -> Storage[Id]
Because the locks accumulated across events, two filters deadlocked: one held
Event_Summaries for a monitor while waiting on a Storage row, the other held
that Storage row while waiting on Event_Summaries for its next event. No
per-event lock ordering can fix that while both rows stay locked for the length
of the batch.
Holding Event_Summaries for the whole run also blocked zmc from opening a new
event on any monitor the filter had touched, since creating an event updates
that row. The transaction spanned ffmpeg encodes, uploads and executed commands
as well, so it could be held open for minutes.
zmfilter now claims one event at a time in Events_Lock and releases it when it
is done with that event, so no InnoDB lock is held across the work. The
per-event body moves into checkFilterEvent.
skip_locked now adds NOT EXISTS over Events_Lock to the filter query rather
than SKIP LOCKED. The exclusion has to happen in the query: a filter whose
whole result set was held elsewhere would otherwise fill its LIMIT with events
it could only skip, and make no progress. It no longer depends on MariaDB 10.6
/ MySQL 8.0.1, so the UI no longer disables the option on older servers.
Also drops the two dbh->commit() calls in the AutoCopy branch. With no
transaction open they would warn, and before this they were silently ending the
batch transaction mid-loop, so AutoCopy filters never had the guarantee
LockRows was supposed to give them.
filterdebug.php was appending a bare ' SKIP LOCKED' after the LIMIT, which is
not valid SQL; it now renders the real clause in the right position.
Adds t/event_lock.t and t/filter_sql.t.
- When closing a socket, check the session before clearing the WebSocket, not after.
- Execute resetCountStreamErrors() when playback starts successfully.
- Recheck the session inside videoEl.onplay after the 500ms timeout.