From 8acba9b3cff19de7e3feec00b4699a9011bef8ac Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 23 Mar 2026 17:13:41 +0300 Subject: [PATCH 01/35] Prevent vertical shifting of #content on the Watch page when displaying the stream status overlay. (watch.php) If Scale=auto is enabled and the page is scrolled down slightly, then when the stream is stopped and then restarted, the page (#content) shifts slightly vertically. I couldn't figure out why this happens, despite extensively studying the page layout. Adding a DIV will avoid this issue. It's probably a hack, but it works. --- web/skins/classic/views/watch.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index 284a18900..672c595e0 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -408,6 +408,7 @@ echo htmlSelect('cyclePeriod', $cyclePeriodOptions, $period, array('id'=>'cycleP ?> +
<--! REQUIRED -->
From 91a94d1dbef1b4aeef99ca6937369f500e932f60 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 23 Mar 2026 17:15:53 +0300 Subject: [PATCH 02/35] Added a link to the PR (watch.php) --- web/skins/classic/views/watch.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index 672c595e0..fe3749d94 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -408,7 +408,7 @@ echo htmlSelect('cyclePeriod', $cyclePeriodOptions, $period, array('id'=>'cycleP ?> -
<--! REQUIRED --> +
<--! REQUIRED https://github.com/ZoneMinder/zoneminder/pull/4721 -->
From e2d4b3792569a209076b90122b92a5cfa538e206 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 27 Mar 2026 15:57:59 +0300 Subject: [PATCH 03/35] Update web/skins/classic/views/watch.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/skins/classic/views/watch.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index fe3749d94..2fe803c14 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -408,7 +408,7 @@ echo htmlSelect('cyclePeriod', $cyclePeriodOptions, $period, array('id'=>'cycleP ?> -
<--! REQUIRED https://github.com/ZoneMinder/zoneminder/pull/4721 --> +
From 8963f7c99df00632e09d243ab9ce1d198a7a3e75 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 27 Mar 2026 23:55:09 +0300 Subject: [PATCH 04/35] Revert watch.php --- web/skins/classic/views/watch.php | 1 - 1 file changed, 1 deletion(-) diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index 2fe803c14..284a18900 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -408,7 +408,6 @@ echo htmlSelect('cyclePeriod', $cyclePeriodOptions, $period, array('id'=>'cycleP ?> -
From d8ada88da7173700c2806f048a126502868ab3b6 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 27 Mar 2026 23:57:50 +0300 Subject: [PATCH 05/35] Update MonitorStream.js --- web/js/MonitorStream.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index c386ad3ba..932bce7ee 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -708,7 +708,10 @@ function MonitorStream(monitorData) { imgInfoBlock.style.height = '100%'; imgInfoBlock.style.zIndex = 10000; imgInfoBlock.style.pointerEvents = 'none'; - this.getElement().parentNode.appendChild(imgInfoBlock); + const imageFeed = document.getElementById('imageFeed'+this.id); + if (imageFeed) { + imageFeed.appendChild(infoBlock); + } currentImg = imgInfoBlock; } this.setSrcInfoBlock(); From bbe1b6f00e308a6e18f58a563fc85b8b2b5fd839 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 28 Mar 2026 00:00:44 +0300 Subject: [PATCH 06/35] Update MonitorStream.js --- web/js/MonitorStream.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 932bce7ee..dcc0341a2 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -708,10 +708,7 @@ function MonitorStream(monitorData) { imgInfoBlock.style.height = '100%'; imgInfoBlock.style.zIndex = 10000; imgInfoBlock.style.pointerEvents = 'none'; - const imageFeed = document.getElementById('imageFeed'+this.id); - if (imageFeed) { - imageFeed.appendChild(infoBlock); - } + this.getElement().parentNode.appendChild(imgInfoBlock); currentImg = imgInfoBlock; } this.setSrcInfoBlock(); @@ -731,7 +728,10 @@ function MonitorStream(monitorData) { infoBlock.style.left = '50%'; infoBlock.style.transform = 'translate(-50%, -50%)'; infoBlock.style.pointerEvents = 'none'; - this.getElement().parentNode.appendChild(infoBlock); + const imageFeed = document.getElementById('imageFeed'+this.id); + if (imageFeed) { + imageFeed.appendChild(infoBlock); + } currentInfoBlock = infoBlock; } return currentInfoBlock; From 692b77b4da7f96e230b3e009dd5c123e8a2c77fd Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 31 Mar 2026 12:27:56 -0400 Subject: [PATCH 07/35] fix: guard against empty auth_relay producing double && in zms URLs When auth is disabled or auth_relay is empty, appending '&'+auth_relay produces a trailing '&' which results in double '&&' when the next parameter is appended (e.g. ?monitor=2&&scale=41&mode=single). Guard all 4 places in MonitorStream.js where auth_relay is concatenated into URLs, consistent with EventStream.js which already guards this. Co-Authored-By: Claude Opus 4.6 --- web/js/MonitorStream.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index c386ad3ba..d75a6e936 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -188,7 +188,7 @@ function MonitorStream(monitorData) { this.show = function() { const stream = this.getElement(); if (!stream.src) { - stream.src = this.url_to_zms+"&mode=single&scale="+this.scale+"&connkey="+this.connKey+'&'+auth_relay; + stream.src = this.url_to_zms+"&mode=single&scale="+this.scale+"&connkey="+this.connKey+(auth_relay?'&'+auth_relay:''); } }; @@ -605,9 +605,9 @@ function MonitorStream(monitorData) { this.streamCommand(CMD_PLAY); } else { let src = this.url_to_zms.replace(/mode=single/i, 'mode=jpeg'); - if (-1 == src.search('auth')) { + if (-1 == src.search('auth') && auth_relay) { src += '&'+auth_relay; - } else { + } else if (-1 != src.search('auth')) { src = src.replace(/auth=\w+/i, 'auth='+auth_hash); } if (-1 == src.search('connkey')) { @@ -647,9 +647,9 @@ function MonitorStream(monitorData) { if (!imgInfoBlock) return null; let src = this.url_to_zms.replace(/mode=jpeg/i, 'mode=single'); - if (-1 == src.search('auth')) { + if (-1 == src.search('auth') && auth_relay) { src += '&'+auth_relay; - } else { + } else if (-1 != src.search('auth')) { src = src.replace(/auth=\w+/i, 'auth='+auth_hash); } if (-1 == src.search('scale=')) { @@ -1601,7 +1601,7 @@ function MonitorStream(monitorData) { }; // this.getStatusCmdResponse this.statusCmdQuery = function() { - $j.getJSON(this.url + '?view=request&request=status&entity=monitor&element[]=Status&element[]=CaptureFPS&element[]=AnalysisFPS&element[]=Analysing&element[]=Recording&id='+this.id+'&'+auth_relay) + $j.getJSON(this.url + '?view=request&request=status&entity=monitor&element[]=Status&element[]=CaptureFPS&element[]=AnalysisFPS&element[]=Analysing&element[]=Recording&id='+this.id+(auth_relay?'&'+auth_relay:'')) .done(this.getStatusCmdResponse.bind(this)) .fail(logAjaxFail); From 6fe23d8737ceba04b17872b0a43a8b7e2566ca42 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 31 Mar 2026 23:54:08 +0300 Subject: [PATCH 08/35] Do not use the "form-control-sm" class for rows. (options.php) --- web/skins/classic/views/options.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/options.php b/web/skins/classic/views/options.php index 5f63c3afe..0eda657c9 100644 --- a/web/skins/classic/views/options.php +++ b/web/skins/classic/views/options.php @@ -338,7 +338,7 @@ foreach (array_map('basename', glob('skins/'.$skin.'/css/*', GLOB_ONLYDIR)) as $ echo '

Note: This value has been overriden via configuration files in '.ZM_CONFIG. ' or ' . ZM_CONFIG_SUBDIR.'.
The overriden value is: '.constant($name).'

'.PHP_EOL; } ?> - +
Date: Wed, 1 Apr 2026 00:27:27 +0300 Subject: [PATCH 09/35] Update options.css --- web/skins/classic/css/base/views/options.css | 31 ++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/web/skins/classic/css/base/views/options.css b/web/skins/classic/css/base/views/options.css index a869d0b3d..153f37ab4 100644 --- a/web/skins/classic/css/base/views/options.css +++ b/web/skins/classic/css/base/views/options.css @@ -121,6 +121,11 @@ input[name="newStorage[Url]"] { margin-bottom: 20px; margin-right: 5px; border-bottom: 1px solid #e7e7e7; + padding-bottom: 0.75rem; +} + +#options .form-group span.form-text { + margin-top: 0.5rem; } #options .col-md { text-align: left; @@ -131,32 +136,34 @@ form { /* flex-direction: column;*/ width: 100%; height: 100%; + margin-left: 9px; /* padding-top: 2rem; */ } @media screen and (max-width:767px) { #options label, - label.col-form-label { + label.col-form-label { text-align: left; + padding-bottom: 5px; } } div.dnsmasq, .dnsmasq .config { -text-align: left; + text-align: left; } .dnsmasq .container { -margin-left: 0; + margin-left: 0; } .dnsmasq .config .row > label { -width: 150px; -text-align: right; + width: 150px; + text-align: right; } .dnsmasq .config .row { -min-height:36px; -text-align: left; -display: flex; -align-content: space-around; + min-height:36px; + text-align: left; + display: flex; + align-content: space-around; } #leasesTable td, #leasesTable th { @@ -197,7 +204,7 @@ body.sticky .fixed-table-container { } body.sticky #controlTable thead { - position: sticky; - top: 0; - box-shadow: 0 0px 0, 0 -3px 0 #dfe4ea; + position: sticky; + top: 0; + box-shadow: 0 0px 0, 0 -3px 0 #dfe4ea; } From af33926aa349e352284c2caa9b0daac4845db9c9 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 2 Apr 2026 13:45:08 -0400 Subject: [PATCH 10/35] fix: reset last_write_index in Pause to restore DECODING_ONDEMAND bootstrap Pause() did not restore last_write_index to the sentinel value (image_buffer_count). After a Pause/Play cycle, the DECODING_ONDEMAND fallback condition (last_write_index == image_buffer_count) was dead, making decoding depend entirely on hasViewers(). This created a timing gap where the decoder skipped packets after Play before zms called setLastViewed, causing the decoder to fall behind capture. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_monitor.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index be1d5df1a..13caef80c 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -3659,6 +3659,7 @@ int Monitor::Pause() { convert_context = nullptr; } decoding_image_count = 0; + if (shared_data) shared_data->last_write_index = image_buffer_count; } if (analysis_thread) { Debug(1, "Joining analysis"); From 34949769b015572f40548c5e41b603c57a945a21 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 10:49:58 -0400 Subject: [PATCH 11/35] fix: add Warn() as exported alias for Warning() in Logger Warn() is a natural shorthand that's easy to reach for. Its absence caused a silent crash in zmfilter when Warn() resolved to Perl's built-in warn(), which wrote to a closed stderr under zmdc and killed the process. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Logger.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm b/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm index 73484df3d..f774a9376 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm @@ -76,6 +76,7 @@ our %EXPORT_TAGS = ( Dump Debug Info + Warn Warning Error Fatal @@ -741,6 +742,7 @@ sub info { $log->logPrint(INFO, @_, caller); } +sub Warn { fetch()->logPrint(WARNING, @_, caller); } sub Warning { fetch()->logPrint(WARNING, @_, caller); } sub warn { my $log = shift; From b141c9e18aacafb64d0d32b7b09faa20a0b6f771 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 10:52:00 -0400 Subject: [PATCH 12/35] Warn() -> Warning(). Fixes #4724 --- scripts/zmfilter.pl.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 72eea0882..db65b575f 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -177,7 +177,7 @@ while (!$zm_terminate) { my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : 0)); my $filter_delay = $$filter{ExecuteInterval} - $elapsed; - Warn("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval.") if $filter_delay < 0; + Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval.") if $filter_delay < 0; if (!defined($delay) or $filter_delay < $delay) { $delay = $filter_delay > 0 ? $filter_delay : 0; Debug("Setting delay to $delay because ExecuteInterval=$$filter{ExecuteInterval} and $elapsed have elapsed for $$filter{Name}"); From fcd925900b77d81bf566d88941c9af6afe55d6be Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 11:45:25 -0400 Subject: [PATCH 13/35] fix: redirect stdin/stdout/stderr to /dev/null instead of closing them Bug 376 (2006) closed all FDs starting from 0 when daemonizing. This caused FD reuse problems: libx264 writing to a reused stderr FD led to memory corruption (fixed in child spawn by 66f11435b, but not in the parent's run()). It also meant children inherited closed FDs 0-2, so any Perl die/warn output was silently lost, making daemon crashes impossible to diagnose. Redirect 0-2 to /dev/null (standard daemon practice) and close only FDs 3+ for inherited sockets/DB connections. Children now inherit valid FDs that won't crash or corrupt on write. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/zmdc.pl.in | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/zmdc.pl.in b/scripts/zmdc.pl.in index ee1d2b715..64254abca 100644 --- a/scripts/zmdc.pl.in +++ b/scripts/zmdc.pl.in @@ -277,9 +277,16 @@ sub run { die 'Can\'t open pid file at '.ZM_PID."\n"; } - my $fd = 0; + # Redirect stdin/stdout/stderr to /dev/null rather than closing them. + # Closing them causes the FDs to be reused, which led to memory corruption + # when libx264 wrote to a reused stderr FD (66f11435b). Redirecting to + # /dev/null keeps valid FDs that children inherit safely. + open(STDIN, '<', '/dev/null') or die "Can't redirect STDIN: $!"; + open(STDOUT, '>', '/dev/null') or die "Can't redirect STDOUT: $!"; + open(STDERR, '>', '/dev/null') or die "Can't redirect STDERR: $!"; - # This also closes dbh and CLIENT and SERVER + # Close all remaining FDs (dbh, CLIENT, SERVER, etc.) + my $fd = 3; while ( $fd < POSIX::sysconf(&POSIX::_SC_OPEN_MAX) ) { POSIX::close($fd++); } From 82261c047e7ec35ed23935a2df535059367b5eab Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 11:54:03 -0400 Subject: [PATCH 14/35] Use now instead of 0 when no last_ran so that elapsesd=0 on initial run, and we don't log a warning. --- scripts/zmfilter.pl.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index db65b575f..990d353f6 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -175,7 +175,7 @@ while (!$zm_terminate) { foreach my $filter (@filters) { last if $zm_terminate; - my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : 0)); + my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : $now)); my $filter_delay = $$filter{ExecuteInterval} - $elapsed; Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval.") if $filter_delay < 0; if (!defined($delay) or $filter_delay < $delay) { From c06dab727e5cd57e3ff0a6b1777085a750db61d0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 13:11:20 -0400 Subject: [PATCH 15/35] Include ExecuteInterval in warning --- scripts/zmfilter.pl.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 990d353f6..7f1a5bd6f 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -177,7 +177,7 @@ while (!$zm_terminate) { my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : $now)); my $filter_delay = $$filter{ExecuteInterval} - $elapsed; - Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval.") if $filter_delay < 0; + Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval $$filter{ExecuteInterval}.") if $filter_delay < 0; if (!defined($delay) or $filter_delay < $delay) { $delay = $filter_delay > 0 ? $filter_delay : 0; Debug("Setting delay to $delay because ExecuteInterval=$$filter{ExecuteInterval} and $elapsed have elapsed for $$filter{Name}"); From 4df52e6a4197452b859b1892d97092a8fb623d69 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 17:48:35 -0400 Subject: [PATCH 16/35] Don't use Fatal when there is an error preparing the sql. handle PostSQLConditions being an empty array --- scripts/ZoneMinder/lib/ZoneMinder/Filter.pm | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm index 546c90516..9d65a883e 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm @@ -98,22 +98,24 @@ sub Execute { $sql =~ s/zmSystemLoad/$load/g; } - - Debug("Filter::Execute SQL ($sql)"); - my $sth = $ZoneMinder::Database::dbh->prepare($sql) - or Fatal("Can't prepare '$sql': ".$ZoneMinder::Database::dbh->errstr()); + my $sth = $ZoneMinder::Database::dbh->prepare($sql); + if (!$sth) { + Error("Can't prepare '$sql': ".$ZoneMinder::Database::dbh->errstr()); + return; + } my $res = $sth->execute(); if ( !$res ) { Error("Can't execute filter '$sql', ignoring: ".$sth->errstr()); return; } + Debug("Filter::Execute SQL ($sql)"); my @results; while ( my $event = $sth->fetchrow_hashref() ) { push @results, $event; } $sth->finish(); Debug('Loaded ' . @results . ' events for filter '.$$self{Name}.' using query ('.$sql.')"'); - if ( $self->{PostSQLConditions} ) { + if ($self->{PostSQLConditions} and @{$self->{PostSQLConditions}}) { my @filtered_events; foreach my $term ( @{$$self{PostSQLConditions}} ) { if ( $$term{attr} eq 'ExistsInFileSystem' ) { From 5ff625a899b88b9f3f12c07613952624b1a8e57b Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 5 Apr 2026 13:58:52 -0400 Subject: [PATCH 17/35] fix: fd leak in MonitorLink::disconnect() when connect() fails disconnect() was guarded by `if (connected)`, but connected is only set to true as the last step of a successful connect(). Every error path in connect() called disconnect() before connected was true, so map_fd was never closed and mmap was never unmapped. Each failed connect attempt leaked one fd, eventually causing "Too many open files" errors when opening new events. Fix by cleaning up based on actual resource state (map_fd >= 0, mem_ptr != nullptr) instead of the connected flag. Also fix MAP_FAILED check, null out derived pointers, and fix shm_id being zeroed before its IPC_RMID call. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_monitor_monitorlink.cpp | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/zm_monitor_monitorlink.cpp b/src/zm_monitor_monitorlink.cpp index 48060c86a..8673b1485 100644 --- a/src/zm_monitor_monitorlink.cpp +++ b/src/zm_monitor_monitorlink.cpp @@ -160,27 +160,25 @@ bool Monitor::MonitorLink::connect() { } // end bool Monitor::MonitorLink::connect() bool Monitor::MonitorLink::disconnect() { - if (connected) { - connected = false; + connected = false; #if ZM_MEM_MAPPED - if (mem_ptr > (void *)0) { - msync(mem_ptr, mem_size, MS_ASYNC); - munmap(mem_ptr, mem_size); - } - if (map_fd >= 0) - close(map_fd); + if (mem_ptr != nullptr && mem_ptr != MAP_FAILED) { + msync(mem_ptr, mem_size, MS_ASYNC); + munmap(mem_ptr, mem_size); + } + if (map_fd >= 0) + close(map_fd); - map_fd = -1; + map_fd = -1; #else // ZM_MEM_MAPPED + if (mem_ptr != nullptr) { struct shmid_ds shm_data; if (shmctl(shm_id, IPC_STAT, &shm_data) < 0) { Debug(3, "Can't shmctl: %s", strerror(errno)); return false; } - shm_id = 0; - if (shm_data.shm_nattch <= 1) { if (shmctl(shm_id, IPC_RMID, 0) < 0) { Debug(3, "Can't shmctl: %s", strerror(errno)); @@ -192,10 +190,15 @@ bool Monitor::MonitorLink::disconnect() { Debug(3, "Can't shmdt: %s", strerror(errno)); return false; } -#endif // ZM_MEM_MAPPED - mem_size = 0; - mem_ptr = nullptr; } + shm_id = 0; +#endif // ZM_MEM_MAPPED + mem_size = 0; + mem_ptr = nullptr; + shared_data = nullptr; + trigger_data = nullptr; + zone_scores = nullptr; + return true; } From d22f8450e02954bcfe57b3242e58d7b8ee56b355 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 5 Apr 2026 15:30:18 -0400 Subject: [PATCH 18/35] fix: free video_out_ctx after failed avcodec_open2 in new_extradata path When avcodec_open2 failed in the new_extradata code path, video_out_ctx was left allocated but half-initialized. Later flush_codecs() would call avcodec_send_frame on this context, causing a segfault (null deref at offset 0x28, likely ctx->internal). Free the context on failure and guard the subsequent avcodec_parameters_from_context call. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_videostore.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index f69f38743..e720c3d64 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -239,12 +239,15 @@ bool VideoStore::open() { if ((ret = avcodec_open2(video_out_ctx, video_out_codec, &opts)) < 0) { Warning("Can't open video codec (%s) %s", video_out_codec->name, av_make_error_string(ret).c_str()); video_out_codec = nullptr; + avcodec_free_context(&video_out_ctx); } } // end if video_out_codec - ret = avcodec_parameters_from_context(video_out_stream->codecpar, video_out_ctx); - if (ret < 0) { - Error("Could not initialize stream parameters"); + if (video_out_ctx) { + ret = avcodec_parameters_from_context(video_out_stream->codecpar, video_out_ctx); + if (ret < 0) { + Error("Could not initialize stream parameters"); + } } av_dict_free(&opts); // Reload it for next attempt and/or avformat open From f112ead9dd871dcae21f075418af605bebc06300 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Apr 2026 13:11:20 -0400 Subject: [PATCH 19/35] Include ExecuteInterval in warning --- scripts/zmfilter.pl.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 990d353f6..7f1a5bd6f 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -177,7 +177,7 @@ while (!$zm_terminate) { my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : $now)); my $filter_delay = $$filter{ExecuteInterval} - $elapsed; - Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval.") if $filter_delay < 0; + Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval $$filter{ExecuteInterval}.") if $filter_delay < 0; if (!defined($delay) or $filter_delay < $delay) { $delay = $filter_delay > 0 ? $filter_delay : 0; Debug("Setting delay to $delay because ExecuteInterval=$$filter{ExecuteInterval} and $elapsed have elapsed for $$filter{Name}"); From d8f30811cf038987591d0d0849c12b419f1b26d8 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 4 Apr 2026 11:17:06 -0400 Subject: [PATCH 20/35] Rename last_action to last_reload as that is what it actually is --- scripts/zmfilter.pl.in | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 7f1a5bd6f..de5b84d52 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -161,14 +161,13 @@ if ( ! ( $filter_name or $filter_id ) ) { } my @filters; -my $last_action = 0; +my $last_reload = 0; while (!$zm_terminate) { my $delay; my $now = time; - if (($now - $last_action) > $Config{ZM_FILTER_RELOAD_DELAY}) { - Debug('Reloading filters'); - $last_action = $now; + if (($now - $last_reload) > $Config{ZM_FILTER_RELOAD_DELAY}) { + $last_reload = $now; @filters = getFilters({ Name=>$filter_name, Id=>$filter_id }); } From 252ac03b77cc3fc1cfe31aec3aa86c7caf0ddcb5 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 5 Apr 2026 13:31:34 -0400 Subject: [PATCH 21/35] Move the delay calculation code to after we run the filter so that the time taken running the filter is included --- scripts/zmfilter.pl.in | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index de5b84d52..39e5e29dd 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -174,14 +174,6 @@ while (!$zm_terminate) { foreach my $filter (@filters) { last if $zm_terminate; - my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : $now)); - my $filter_delay = $$filter{ExecuteInterval} - $elapsed; - Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval $$filter{ExecuteInterval}.") if $filter_delay < 0; - if (!defined($delay) or $filter_delay < $delay) { - $delay = $filter_delay > 0 ? $filter_delay : 0; - Debug("Setting delay to $delay because ExecuteInterval=$$filter{ExecuteInterval} and $elapsed have elapsed for $$filter{Name}"); - } - if ($$filter{Concurrent} and !($filter_id or $filter_name)) { my ( $proc ) = $0 =~ /(\S+)/; my ( $id ) = $$filter{Id} =~ /(\d+)/; @@ -192,6 +184,15 @@ while (!$zm_terminate) { checkFilter($filter); $$filter{last_ran} = $now; } + + $now = time; + my $elapsed = ($now - ($$filter{last_ran} ? $$filter{last_ran} : $now)); + my $filter_delay = $$filter{ExecuteInterval} - $elapsed; + Warning("Filter $$filter{Name} is taking ".(-$filter_delay)." seconds longer than execute interval $$filter{ExecuteInterval}.") if $filter_delay < 0; + if (!defined($delay) or $filter_delay < $delay) { + $delay = $filter_delay > 0 ? $filter_delay : 0; + Debug("Setting delay to $delay because ExecuteInterval=$$filter{ExecuteInterval} and $elapsed have elapsed for $$filter{Name}"); + } } # end foreach filter last if (!$daemon and ($filter_name or $filter_id)) or $zm_terminate; From 0e51fb67882f6da379cfeab75c1add26effc8ba0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 7 Apr 2026 20:48:42 -0400 Subject: [PATCH 22/35] fix: call disconnect() in MonitorLink::connect() before reopening map_fd MonitorLink::connect() opened a new map_fd on each invocation without closing any previously-opened one. Token::score() in zm_monitorlink_token.h calls connect() on every analysis cycle when the linked monitor is unavailable, causing rapid file descriptor accumulation and eventual "Too many open files" errors in zmc. Call disconnect() first to release any prior map_fd, mmap, and shared state before re-establishing the link. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_monitor_monitorlink.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/zm_monitor_monitorlink.cpp b/src/zm_monitor_monitorlink.cpp index 8673b1485..e3c7a054f 100644 --- a/src/zm_monitor_monitorlink.cpp +++ b/src/zm_monitor_monitorlink.cpp @@ -85,6 +85,9 @@ bool Monitor::MonitorLink::connect() { if (!last_connect_time || (now - std::chrono::system_clock::from_time_t(last_connect_time)) > Seconds(1)) { last_connect_time = std::chrono::system_clock::to_time_t(now); + // Clean up any existing resources before reconnecting to avoid fd leaks + disconnect(); + mem_size = sizeof(SharedData) + sizeof(TriggerData); Debug(1, "link.mem.size=%jd", static_cast(mem_size)); From 1e4ec3d251fa69e5117ed9454161746c1f10ae6c Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 7 Apr 2026 20:48:57 -0400 Subject: [PATCH 23/35] fix: treat ENOTTY like EINVAL when querying V4L2 JPEG compression options Some V4L2 drivers return ENOTTY (rather than EINVAL) when VIDIOC_G_JPEGCOMP is unsupported. Treat both as "feature unavailable" and log at debug level instead of warning, and include the errno string for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_local_camera.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zm_local_camera.cpp b/src/zm_local_camera.cpp index 7605ea987..e17bd0b5b 100644 --- a/src/zm_local_camera.cpp +++ b/src/zm_local_camera.cpp @@ -676,8 +676,8 @@ int LocalCamera::Initialise() { if (palette == V4L2_PIX_FMT_JPEG || palette == V4L2_PIX_FMT_MJPEG) { v4l2_jpegcompression jpeg_comp; if (vidioctl(vid_fd, VIDIOC_G_JPEGCOMP, &jpeg_comp) < 0) { - if (errno == EINVAL) { - Debug(2, "JPEG compression options are not available"); + if (errno == EINVAL || errno == ENOTTY) { + Debug(2, "JPEG compression options are not available: %s", strerror(errno)); } else { Warning("Failed to get JPEG compression options: %s", strerror(errno)); } From ea40e86f8615075bbd2065d0b9f1510e4f5d2c90 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 7 Apr 2026 20:50:58 -0400 Subject: [PATCH 24/35] fix: add missing dash separator in Content-Range header The HTTP Content-Range header for partial content must use the form "bytes start-end/total". output_file() was emitting "bytes startend/total" with no separator, producing an invalid header that breaks range requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/includes/functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index 427b8ef55..9dae2966b 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2378,7 +2378,7 @@ function output_file($path, $chunkSize=1024) { $new_length = $size - $range; header('HTTP/1.1 206 Partial Content'); header("Content-Length: $new_length"); - header("Content-Range: bytes $range$size2/$size"); + header("Content-Range: bytes $range-$size2/$size"); } else { $size2 = $size - 1; header("Content-Range: bytes 0-$size2/$size"); From eff087a5326daecc4d12976a1ebbc4c9c1359413 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 7 Apr 2026 20:51:07 -0400 Subject: [PATCH 25/35] fix: limit filter Name input to 64 characters The Filters.Name column is VARCHAR(64). Add a matching maxlength on the Name input to prevent users from entering values that would be truncated or rejected on save. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/skins/classic/views/filter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/filter.php b/web/skins/classic/views/filter.php index 0a5cb12b8..d70faaaec 100644 --- a/web/skins/classic/views/filter.php +++ b/web/skins/classic/views/filter.php @@ -172,7 +172,7 @@ if ( (null !== $filter->Concurrent()) and $filter->Concurrent() )

- +

Date: Wed, 8 Apr 2026 08:44:07 -0400 Subject: [PATCH 26/35] fix: only validate Device path for Local monitors Follow-up to 419846c87 (GHSA-g66m-77fq-79v9). The Device path check was applied to all monitor Types in three places, but the Device column is only passed to a shell for Type='Local'. Non-Local monitors (Ffmpeg, Remote, Libvlc, cURL, VNC) may legitimately hold legacy values such as an RTSP URL in that column and should not be rejected or warned about. - scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm: control() dropped the spurious Warning for non-Local monitors that was flooding zmwatch logs. The Error/early-return path is preserved for Local. - web/includes/actions/monitor.php: save action only runs validDevicePath() when Type=='Local'. - web/api/app/Model/Monitor.php: replaced the unconditional regex rule with a validDevicePath() method that checks Type before enforcing the /dev/ pattern. Also add client-side validation matching the server rule, so Local monitors get immediate feedback instead of a round-trip error: - web/skins/classic/views/monitor.php: HTML5 pattern attribute on the Device input. Escaped for the v-flag regex engine used by pattern=. - web/skins/classic/views/js/monitor.js.php: validateForm() now also rejects Device values that don't match the /dev/ pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm | 11 +++++------ web/api/app/Model/Monitor.php | 19 ++++++++++++++++++- web/includes/actions/monitor.php | 6 ++++-- web/skins/classic/views/js/monitor.js.php | 2 ++ web/skins/classic/views/monitor.php | 2 ++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm b/scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm index f9c21daec..0ae650e27 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm @@ -347,12 +347,11 @@ sub control { my $command = shift; my $process = shift; - my $valid_device = (defined $monitor->{Device} and $monitor->{Device} =~ /^\/dev\/[\w\/.\-]+$/); - if ($monitor->{Type} eq 'Local' and !$valid_device) { - Error("Invalid device path rejected: $monitor->{Device}"); - return; - } elsif (!$valid_device and defined $monitor->{Device} and length($monitor->{Device})) { - Warning("Monitor $$monitor{Id} has invalid device path: $monitor->{Device}"); + if ($monitor->{Type} eq 'Local') { + if (!defined $monitor->{Device} or $monitor->{Device} !~ /^\/dev\/[\w\/.\-]+$/) { + Error("Invalid device path rejected: $monitor->{Device}"); + return; + } } if ($command eq 'stop') { diff --git a/web/api/app/Model/Monitor.php b/web/api/app/Model/Monitor.php index 8fdfb88a7..62f3b8c38 100644 --- a/web/api/app/Model/Monitor.php +++ b/web/api/app/Model/Monitor.php @@ -63,7 +63,7 @@ class Monitor extends AppModel { ), 'Device' => array( 'validPath' => array( - 'rule' => array('custom', '#^(/dev/[\w/.\-]+)?$#'), + 'rule' => array('validDevicePath'), 'message' => 'Invalid device path. Must be a valid /dev/ path (e.g. /dev/video0).', 'allowEmpty' => true, 'required' => false, @@ -72,6 +72,23 @@ class Monitor extends AppModel { ); + /** + * Validate the Device field. Only Local monitors pass Device to a shell, + * so the /dev/ restriction only applies when Type == 'Local'. Other Types + * may legitimately hold legacy values (e.g. an RTSP URL) in this column. + */ + public function validDevicePath($check) { + $value = reset($check); + if ($value === null || $value === '') { + return true; + } + $type = isset($this->data['Monitor']['Type']) ? $this->data['Monitor']['Type'] : null; + if ($type !== 'Local') { + return true; + } + return (bool)preg_match('#^/dev/[\w/.\-]+$#', $value); + } + //The Associations below have been created with all possible keys, those that are not needed can be removed /** diff --git a/web/includes/actions/monitor.php b/web/includes/actions/monitor.php index 0ef133d41..048eb0942 100644 --- a/web/includes/actions/monitor.php +++ b/web/includes/actions/monitor.php @@ -58,8 +58,10 @@ if ($action == 'save') { # For convenience $newMonitor = $_REQUEST['newMonitor']; - # Validate Device path to prevent command injection (CVE-worthy) - if (!empty($newMonitor['Device'])) { + # Validate Device path to prevent command injection (CVE-worthy). + # Only Local monitors pass Device to a shell; for other Types the field + # is unused and may legitimately hold legacy values (e.g. an RTSP URL). + if (!empty($newMonitor['Device']) and isset($newMonitor['Type']) and $newMonitor['Type'] == 'Local') { $newMonitor['Device'] = validDevicePath($newMonitor['Device']); if ($newMonitor['Device'] === '') { $error_message .= 'Invalid device path. Must be a valid /dev/ path (e.g. /dev/video0).
'; diff --git a/web/skins/classic/views/js/monitor.js.php b/web/skins/classic/views/js/monitor.js.php index b4bc2d342..8e79d7336 100644 --- a/web/skins/classic/views/js/monitor.js.php +++ b/web/skins/classic/views/js/monitor.js.php @@ -63,6 +63,8 @@ function validateForm(form) { errors[errors.length] = ""; if ( !form.elements['newMonitor[Device]'].value ) errors[errors.length] = ""; + else if ( !form.elements['newMonitor[Device]'].value.match(/^\/dev\/[\w\/.\-]+$/) ) + errors[errors.length] = ""; if ( !form.elements['newMonitor[Channel]'] || !form.elements['newMonitor[Channel]'].value || !form.elements['newMonitor[Channel]'].value.match( /^\d+$/ ) ) errors[errors.length] = ""; if ( !form.elements['newMonitor[Format]'] || !form.elements['newMonitor[Format]'].value || !form.elements['newMonitor[Format]'].value.match( /^\d+$/ ) ) diff --git a/web/skins/classic/views/monitor.php b/web/skins/classic/views/monitor.php index ca7b7ac09..675aa3dc7 100644 --- a/web/skins/classic/views/monitor.php +++ b/web/skins/classic/views/monitor.php @@ -669,6 +669,8 @@ switch ($name) { 1 ? htmlSelect('newMonitor[Devices]', $devices, $monitor->Device()) : ''; ?> 1) ? 'style="display: none;"' : '' ?> autocomplete="off" + pattern="/dev/[\w\/.\-]+" + title=" (e.g. /dev/video0)" /> Date: Wed, 8 Apr 2026 08:44:16 -0400 Subject: [PATCH 27/35] fix: run form validation on Save button click The Save button's click handler called saveMonitorData() directly via AJAX, bypassing validateForm() entirely. Only Save & Close ran the validation. As a result every monitor form validation rule (BadName, BadChannel, BadPath, Device pattern, etc.) was silently skipped when users clicked Save. Now both buttons share the same validate-then-save gate. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/skins/classic/views/js/monitor.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/monitor.js b/web/skins/classic/views/js/monitor.js index 006a4475c..944079443 100644 --- a/web/skins/classic/views/js/monitor.js +++ b/web/skins/classic/views/js/monitor.js @@ -368,7 +368,10 @@ function initPage() { // Manage the SAVE Button document.getElementById("saveBtn").addEventListener("click", function onSaveClick(evt) { - saveMonitorData(); + const form = document.getElementById('contentForm'); + if (validateForm(form)) { + saveMonitorData(); + } }); // Manage the SAVE AND CLOSE Button - use AJAX instead of native form From d9301df2311f52024fc4dc2ee8c0ed5cde38bef3 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 8 Apr 2026 08:54:08 -0400 Subject: [PATCH 28/35] fix: show Device text input when no device is selected on load When the V4L2 device dropdown rendered, the text input was always hidden until the user changed the dropdown selection. On a new monitor or any monitor with an empty Device, the dropdown defaulted to "Other" but the input stayed hidden, so there was nowhere to type a path. Only hide the input on initial render when Device is non-empty, which means the dropdown has a real selection. The devices_onchange handler already toggles visibility correctly once the user interacts with the dropdown; this fixes only the initial-render state. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/skins/classic/views/monitor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/monitor.php b/web/skins/classic/views/monitor.php index 675aa3dc7..87d3f1759 100644 --- a/web/skins/classic/views/monitor.php +++ b/web/skins/classic/views/monitor.php @@ -668,7 +668,7 @@ switch ($name) { 1 ? htmlSelect('newMonitor[Devices]', $devices, $monitor->Device()) : ''; ?> 1) ? 'style="display: none;"' : '' ?> autocomplete="off" + 1 and $monitor->Device() != '') ? 'style="display: none;"' : '' ?> autocomplete="off" pattern="/dev/[\w\/.\-]+" title=" (e.g. /dev/video0)" /> From 7a39b2d750cdd6de11de6df2174debae5b482d5e Mon Sep 17 00:00:00 2001 From: Daniel Caujolle-Bert Date: Wed, 8 Apr 2026 15:28:41 +0200 Subject: [PATCH 29/35] Fix "top" command parsing on FreeBSD (tested in FreeBSD 13.5 jail, running 1.38.1). --- scripts/ZoneMinder/lib/ZoneMinder/Server.pm | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm index 5e8addae4..9f3910c2d 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm @@ -24,6 +24,7 @@ package ZoneMinder::Server; use 5.006; use strict; use warnings; +use Config; require ZoneMinder::Base; require ZoneMinder::Config; @@ -127,7 +128,13 @@ sub CpuUsage { } else { # Get CPU utilization percentages - my $top_output = `top -b -n 1 | grep -i "^%Cpu(s)" | awk '{print \$2, \$4, \$6, \$8}'`; + my $top_output = ''; + ## FreeBSD + if (@Config{qw(uname)} == 'freebsd') { + $top_output = `top -b -n 1 | grep "^CPU" | sed 's/%//g' | awk '{print \$2, \$6, \$4, \$10}'`; + } else { + $top_output = `top -b -n 1 | grep -i "^%Cpu(s)" | awk '{print \$2, \$4, \$6, \$8}'`; + } my ($user, $system, $nice, $idle) = split(/ /, $top_output); $user =~ s/[^\d\.]//g; $system =~ s/[^\d\.]//g; From 47e6f2f4f483929e98449952804b410f8f004a4c Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 8 Apr 2026 16:31:52 -0400 Subject: [PATCH 30/35] fix: exclude orig tarball from deb uploads to zmrepo Two changes that together stop the .orig.tar.gz from landing in mini-dinstall's incoming dir and causing cross-distro filename collisions: - do_debian_package.sh: quote DEBUILD assignment so the -b flag is actually passed to debuild. Without quotes, bash parsed it as "run -b with DEBUILD=debuild as one-shot env", dropping the binary flag and falling back to a full source build that included the orig tarball in .changes. - build-deb-packages{,-aarch64}.yml: drop *.dsc, *.tar.xz, *.tar.gz from the artifact collection mv. Only .deb, .buildinfo, and .changes are needed for binary uploads. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-deb-packages-aarch64.yml | 6 +++++- .github/workflows/build-deb-packages.yml | 6 +++++- utils/do_debian_package.sh | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-deb-packages-aarch64.yml b/.github/workflows/build-deb-packages-aarch64.yml index 7673d57a0..08016b3dc 100644 --- a/.github/workflows/build-deb-packages-aarch64.yml +++ b/.github/workflows/build-deb-packages-aarch64.yml @@ -116,7 +116,11 @@ jobs: run: | set -eux mkdir -p artifacts/deb - mv ../*.deb ../*.buildinfo ../*.changes ../*.dsc ../*.tar.xz ../*.tar.gz artifacts/deb/ || true + # Only collect binary artifacts. Source files (.dsc, .orig.tar.*, + # .debian.tar.*) are not needed for binary uploads and the orig + # tarball has the same filename across distros which confuses + # mini-dinstall. + mv ../*.deb ../*.buildinfo ../*.changes artifacts/deb/ || true # quick verify signatures (non-fatal) gpg --verify artifacts/deb/*.changes || true gpg --verify artifacts/deb/*.buildinfo || true diff --git a/.github/workflows/build-deb-packages.yml b/.github/workflows/build-deb-packages.yml index 64877097c..9e2a8b8e8 100644 --- a/.github/workflows/build-deb-packages.yml +++ b/.github/workflows/build-deb-packages.yml @@ -112,7 +112,11 @@ jobs: set -eux mkdir -p artifacts/deb ls -l ../ - mv ../*.deb ../*.buildinfo ../*.changes ../*.dsc ../*.tar.xz ../*.tar.gz artifacts/deb/ || true + # Only collect binary artifacts. Source files (.dsc, .orig.tar.*, + # .debian.tar.*) are not needed for binary uploads and the orig + # tarball has the same filename across distros which confuses + # mini-dinstall. + mv ../*.deb ../*.buildinfo ../*.changes artifacts/deb/ || true # quick verify signatures (non-fatal) gpg --verify artifacts/deb/*.changes || true gpg --verify artifacts/deb/*.buildinfo || true diff --git a/utils/do_debian_package.sh b/utils/do_debian_package.sh index 207f244f9..afaee88dc 100755 --- a/utils/do_debian_package.sh +++ b/utils/do_debian_package.sh @@ -315,7 +315,7 @@ EOF sudo apt-get install devscripts equivs sudo mk-build-deps -ir $DIRECTORY.orig/debian/control echo "Status: $?" - DEBUILD=debuild -b -uc -us + DEBUILD="debuild -b -uc -us" else if [ $TYPE == "local" ]; then # Auto-install all ZoneMinder's dependencies using the Debian control file From d3e82fde622334e73c4f008513c0aa313c2d485b Mon Sep 17 00:00:00 2001 From: Daniel Caujolle-Bert Date: Thu, 9 Apr 2026 15:04:08 +0200 Subject: [PATCH 31/35] Use ZoneMinder::Config. --- scripts/ZoneMinder/lib/ZoneMinder/Server.pm | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm index 9f3910c2d..689fd53eb 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm @@ -24,7 +24,6 @@ package ZoneMinder::Server; use 5.006; use strict; use warnings; -use Config; require ZoneMinder::Base; require ZoneMinder::Config; @@ -130,7 +129,7 @@ sub CpuUsage { # Get CPU utilization percentages my $top_output = ''; ## FreeBSD - if (@Config{qw(uname)} == 'freebsd') { + if ($ZoneMinder::Config{uname} == 'freebsd') { $top_output = `top -b -n 1 | grep "^CPU" | sed 's/%//g' | awk '{print \$2, \$6, \$4, \$10}'`; } else { $top_output = `top -b -n 1 | grep -i "^%Cpu(s)" | awk '{print \$2, \$4, \$6, \$8}'`; From 83ed47abf429bdc54e952988a06d7253bf1b3eac Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 9 Apr 2026 09:33:52 -0400 Subject: [PATCH 32/35] fix: scan-line polygon fill incorrectly filled non-convex polygon gaps Image::Fill(Polygon) implements scan-line polygon fill but iterated through active edges one at a time instead of in pairs. For convex polygons (always exactly 2 active edges per scan line) this happened to work, but for non-convex polygons it would fill the gaps between concave sections. A banana-shaped zone, for example, would have its inner concave area incorrectly marked as inside the zone, causing motion detection to trigger on the area the user explicitly drew the zone to avoid. Fix by stepping the iterator by 2 to fill between pairs of edges following the standard parity rule for scan-line polygon fill. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_image.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/zm_image.cpp b/src/zm_image.cpp index 8c7189e6b..3fbb458f5 100644 --- a/src/zm_image.cpp +++ b/src/zm_image.cpp @@ -2720,9 +2720,12 @@ void Image::Fill(Rgb colour, int density, const Polygon &polygon) { std::sort(active_edges.begin(), active_edges.end(), PolygonFill::Edge::CompareX); if (!(scan_line % density)) { - for (auto it = active_edges.begin(); it < active_edges.end() - 1; ++it) { + // Fill between pairs of active edges (parity rule). Stepping one + // edge at a time would incorrectly fill the gaps between arms of + // a non-convex polygon (e.g. a banana shape). + for (auto it = active_edges.begin(); it + 1 < active_edges.end(); it += 2) { int32 lo_x = static_cast(it->min_x); - int32 hi_x = static_cast(std::next(it)->min_x); + int32 hi_x = static_cast((it + 1)->min_x); if (colours == ZM_COLOUR_GRAY8) { uint8 *p = &buffer[(scan_line * width) + lo_x]; From 48fd036d633624e236c37bf48cb59f2bf6c500e9 Mon Sep 17 00:00:00 2001 From: Daniel Caujolle-Bert Date: Fri, 10 Apr 2026 14:36:36 +0200 Subject: [PATCH 33/35] Change ::Config{uname} to ::Config{ZM_PATH_UNAME} --- scripts/ZoneMinder/lib/ZoneMinder/Server.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm index 689fd53eb..1d8249acf 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm @@ -129,7 +129,7 @@ sub CpuUsage { # Get CPU utilization percentages my $top_output = ''; ## FreeBSD - if ($ZoneMinder::Config{uname} == 'freebsd') { + if ($ZoneMinder::Config{ZM_PATH_UNAME} == 'freebsd') { $top_output = `top -b -n 1 | grep "^CPU" | sed 's/%//g' | awk '{print \$2, \$6, \$4, \$10}'`; } else { $top_output = `top -b -n 1 | grep -i "^%Cpu(s)" | awk '{print \$2, \$4, \$6, \$8}'`; From 7bcdfae0d8fb345f954c9bd4bb672a1fe3edc6e0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 10 Apr 2026 08:40:26 -0400 Subject: [PATCH 34/35] fix: prevent empty events in ONDEMAND mode and fix VideoStore fd/codec leaks The ONDEMAND capture mode rapidly cycled between Pause() and Play() because Pause() resets the write index, making the GetLastWriteIndex() guard false, which fell through to Play(). This created ~2 empty events per second. Remove the write index guard so monitors stay paused when nobody is watching. In VideoStore, fix three resource management issues: - Free the codec context opened in the PASSTHROUGH+new_extradata path immediately after extracting stream parameters, preventing flush_codecs from crashing on an encoder that never received frames. - Clean up video_out_ctx, opts dict, and hw_device_ctx when setup_hwaccel() fails, preventing fd accumulation. - Track whether frames were actually sent to the encoder and skip flush_codecs when none were, avoiding segfaults in avcodec_send_frame. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/zm_videostore.cpp | 17 ++++++++++++++++- src/zm_videostore.h | 1 + src/zmc.cpp | 6 +++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index e720c3d64..99a6b0849 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -57,6 +57,7 @@ VideoStore::VideoStore( audio_out_ctx(nullptr), packets_written(0), frame_count(0), + video_encoded(false), hw_device_ctx(nullptr), resample_ctx(nullptr), fifo(nullptr), @@ -248,6 +249,12 @@ bool VideoStore::open() { if (ret < 0) { Error("Could not initialize stream parameters"); } + // Free the codec context now — it was only opened to generate new + // extradata for the stream parameters and is not used for encoding + // in PASSTHROUGH mode. Leaving it alive causes flush_codecs() to + // attempt flushing a codec that never received any frames, which + // crashes in avcodec_send_frame with newer FFmpeg. + avcodec_free_context(&video_out_ctx); } av_dict_free(&opts); // Reload it for next attempt and/or avformat open @@ -348,6 +355,11 @@ bool VideoStore::open() { } if (setup_hwaccel(video_out_ctx, chosen_codec_data, hw_device_ctx, monitor->EncoderHWAccelDevice(), monitor->Width(), monitor->Height())) { + avcodec_free_context(&video_out_ctx); + av_dict_free(&opts); + if (hw_device_ctx) { + av_buffer_unref(&hw_device_ctx); + } continue; } @@ -569,7 +581,9 @@ void VideoStore::flush_codecs() { } // I got crashes if the codec didn't do DELAY, so let's test for it. - if (video_out_ctx && video_out_ctx->codec && (video_out_ctx->codec->capabilities & AV_CODEC_CAP_DELAY)) { + // Also skip if no frames were ever sent — some encoders crash on flush + // when their internal state was never initialized by a real frame. + if (video_out_ctx && video_encoded && video_out_ctx->codec && (video_out_ctx->codec->capabilities & AV_CODEC_CAP_DELAY)) { // First drain any pending packets before entering flush mode // This prevents hangs when the encoder's internal buffer is full Debug(1, "Draining pending packets before flush"); @@ -1300,6 +1314,7 @@ int VideoStore::writeVideoFramePacket(const std::shared_ptr zm_packet) Debug(3, "Got EAGAIN"); } } else { + video_encoded = true; break; } } while (!zm_terminate); diff --git a/src/zm_videostore.h b/src/zm_videostore.h index b245d9a4f..fbb49a4a2 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -54,6 +54,7 @@ class VideoStore { SWScale swscale; unsigned int packets_written; unsigned int frame_count; + bool video_encoded; // true once at least one frame has been sent to the video encoder AVBufferRef *hw_device_ctx; diff --git a/src/zmc.cpp b/src/zmc.cpp index 8a4e91a76..9b778e98b 100644 --- a/src/zmc.cpp +++ b/src/zmc.cpp @@ -303,7 +303,11 @@ int main(int argc, char *argv[]) { time_t last_viewed = monitors[i]->getLastViewed(); int64 since_last_view = static_cast(std::chrono::duration_cast(now.time_since_epoch()).count()) - last_viewed; Debug(1, "Last view %jd= %" PRId64 " seconds since last view", last_viewed, since_last_view); - if (((!last_viewed) or (since_last_view > 10)) and (monitors[i]->GetLastWriteIndex() != -1)) { + if (!last_viewed or (since_last_view > 10)) { + // Nobody is watching — pause if running, otherwise stay paused. + // The previous GetLastWriteIndex() != -1 guard caused a + // Pause/Play cycle because Pause() resets the write index, + // making the guard false and falling through to Play(). if (monitors[i]->getCamera()->isPrimed()) { monitors[i]->Pause(); } From b8183e2642ea6197df930deb5c375541b74a715f Mon Sep 17 00:00:00 2001 From: Daniel Caujolle-Bert Date: Fri, 10 Apr 2026 16:17:03 +0200 Subject: [PATCH 35/35] Use qx() lc() and chomp() for command execution and result. Use eq in the if() statement, instead of '=='. --- scripts/ZoneMinder/lib/ZoneMinder/Server.pm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm index 1d8249acf..3ed39412c 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Server.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Server.pm @@ -128,8 +128,10 @@ sub CpuUsage { } else { # Get CPU utilization percentages my $top_output = ''; + my $uname_output = lc(qx($ZoneMinder::Config{ZM_PATH_UNAME} -s)); + chomp($uname_output); ## FreeBSD - if ($ZoneMinder::Config{ZM_PATH_UNAME} == 'freebsd') { + if ($uname_output eq "freebsd") { $top_output = `top -b -n 1 | grep "^CPU" | sed 's/%//g' | awk '{print \$2, \$6, \$4, \$10}'`; } else { $top_output = `top -b -n 1 | grep -i "^%Cpu(s)" | awk '{print \$2, \$4, \$6, \$8}'`;