fix: bound the IPSpeaker http timeout instead of leaving it at LWP's default

LWP::UserAgent defaults to 180 seconds. Actions for a monitor are serialised
through one control daemon, so a speaker that has dropped off the network holds
that daemon for three minutes per attempt and every command queued behind it
waits. AMLink already sets a timeout; this did not.

Ten seconds, matching AMLink. The speaker answers in milliseconds when it is
reachable at all, so this only ever bounds the case where it is not - which is
the case right now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
This commit is contained in:
Isaac ConnorandClaude Opus 5 committed 2026-09-20 18:17:43 -05:00
1 parent 70836e7c83
commit 8b14fba2db
2 files changed
+21 -2

No files matched your search

@@ -57,6 +57,10 @@ use constant VOLUME_MIN => 0;
use constant VOLUME_MAX => 100;
use constant VOLUME_STEP => 5;
# Seconds. The speaker answers in milliseconds when it is there at all, so this
# only ever bounds the case where it is not.
use constant HTTP_TIMEOUT => 10;
# config=audio.set replaces the whole audio section: any field left out of the
# POST reverts to a firmware default, silently taking the microphone, codec
# list and echo-cancellation settings with it. Every write therefore reads the
@@ -121,7 +125,11 @@ sub open {
my $self = shift;
$self->loadMonitor();
$self->{ua} = LWP::UserAgent->new;
# Actions are fire-and-forget from the monitor's point of view, but they are
# serialised through this one control daemon, so a speaker that has dropped
# off the network must not hold it. LWP's default is 180s, long enough for a
# single unreachable speaker to stall every later command behind it.
$self->{ua} = LWP::UserAgent->new(timeout => HTTP_TIMEOUT);
$self->{ua}->agent('ZoneMinder Control Agent/'.ZoneMinder::Base::ZM_VERSION());
if (!$self->guess_credentials()) {
+12 -1
View File
@@ -1,6 +1,6 @@
use strict;
use warnings;
use Test::More tests => 32;
use Test::More tests => 35;
require_ok('ZoneMinder::Control::IPSpeaker');
@@ -77,3 +77,14 @@ my $sparse_form = $P->can('audio_set_form')->(\%sparse, outvolume => 0);
is_deeply([sort keys %$sparse_form], [qw(micvolume outvolume)],
'fields absent from the device response are not invented');
is($sparse_form->{outvolume}, 0, 'a zero override is applied, not treated as absent');
# --- the http timeout -------------------------------------------------------
# A speaker that drops off the network must not stall the control daemon. LWP
# defaults to 180s, and every action for this monitor queues behind the one in
# flight, so an absent speaker would hold up everything that followed it.
is(ZoneMinder::Control::IPSpeaker::HTTP_TIMEOUT(), 10, 'the http timeout is set, not left to LWP');
cmp_ok(ZoneMinder::Control::IPSpeaker::HTTP_TIMEOUT(), '<', 180,
'and is well under the LWP default that caused the stall');
cmp_ok(ZoneMinder::Control::IPSpeaker::HTTP_TIMEOUT(), '>', 0,
'and is a real timeout rather than "no wait"');