Files
zoneminder/scripts/ZoneMinder/t/control_keepalive.t
T
Isaac ConnorandClaude Opus 5 b4599c16f2 fix: keep the control session alive while a monitor is busy, not only while idle
zmcontrol sent keepAlive only from the branch that runs when select() times
out. A monitor taking a steady stream of commands never reaches that branch,
so it never pinged at all, and cameras that expire a session on a timer
refresh it on the keepAlive rather than on ordinary requests. The monitors
with the most traffic were therefore the ones that lost their session.

Measured on monitor 1: it pinged twice while idle, then took a command every
three seconds for three minutes and the camera rejected the next one 181
seconds after the last ping. Its light commands were being dropped for as long
as the motion kept coming, which is exactly when they matter.

Move the decision into Control::keepAliveDue, timed from the last ping, and
check it at the top of the loop so the 'next' paths in the command handling
cannot skip it. A clock step backwards resets the baseline rather than holding
the ping off until real time catches up.

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00

66 lines
2.6 KiB
Perl

use strict;
use warnings;
use Test::More tests => 14;
require_ok('ZoneMinder::Control');
# zmcontrol used to send keepAlive only when its select() timed out. A monitor
# under steady command traffic never reaches that branch, so it never pinged,
# and the camera expired the session while the monitor was at its busiest.
# Observed on 2026-09-11: monitor 1 pinged twice while idle, then took a
# command every ~3s for three minutes and lost its session 181s after the last
# ping. Timing from the last ping is what fixes it.
my $c = bless {}, 'ZoneMinder::Control';
# The first call sets the baseline instead of firing: open() has just
# established the session, so an immediate ping would be wasted.
is($c->keepAliveDue(30, 1000), 0, 'nothing is due on the first check');
is($c->keepAliveDue(30, 1029), 0, 'nor one second before the interval');
is($c->keepAliveDue(30, 1030), 1, 'due exactly on the interval');
is($c->keepAliveDue(30, 1031), 0, 'and not again immediately after');
is($c->keepAliveDue(30, 1059), 0, 'the clock restarts from the ping');
is($c->keepAliveDue(30, 1060), 1, 'so the next one is an interval later');
# The whole point: a busy monitor pings on schedule. These stand in for
# commands arriving every few seconds, which never let select() time out.
{
my $busy = bless {}, 'ZoneMinder::Control';
$busy->keepAliveDue(30, 0);
my $pings = 0;
for my $t (1 .. 180) { # three minutes, a check every second
$pings++ if $busy->keepAliveDue(30, $t);
}
is($pings, 6, 'six pings in three minutes regardless of command traffic');
}
# Before the fix the camera expired at ~180s of no ping. Nothing may ever
# leave a gap that long.
{
my $o = bless {}, 'ZoneMinder::Control';
$o->keepAliveDue(30, 0);
my $last = 0;
my $worst = 0;
for my $t (1 .. 600) {
if ($o->keepAliveDue(30, $t)) { $worst = $t - $last if $t - $last > $worst; $last = $t }
}
cmp_ok($worst, '<=', 30, 'no gap between pings ever exceeds the interval');
cmp_ok($worst, '<', 180, 'and stays well inside the observed session timeout');
}
# A clock that jumps backwards (ntp step) must not wedge it forever.
{
my $o = bless {}, 'ZoneMinder::Control';
$o->keepAliveDue(30, 5000);
is($o->keepAliveDue(30, 4000), 0, 'a backwards jump does not fire immediately');
is($o->keepAliveDue(30, 4030), 1, 'and it recovers an interval after the new time');
}
# A long stall produces one ping, not a burst making up for lost time.
{
my $o = bless {}, 'ZoneMinder::Control';
$o->keepAliveDue(30, 0);
is($o->keepAliveDue(30, 100000), 1, 'a long gap fires once');
is($o->keepAliveDue(30, 100000), 0, 'and not repeatedly at the same instant');
}