Files
onvif/event/stream/reconnect_test.go
Sebastian Norling 82f98cb824 feat(event/stream): recreate subscription after consecutive pull failures
Adds automatic CreatePullPointSubscription recreation when the pull
loop hits ReconnectAfterFailures (default 3) consecutive errors.
Mirrors what production ONVIF clients (Home Assistant event_manager,
Milestone integration) do because pull points die for many reasons
none of which surface as a clean SOAP fault: camera reboot, NAT
session timeout, subscription garbage-collected after a renew miss,
firmware bug. Recreating is the only reliable recovery; Renew alone
cannot save an already-dropped subscription.

Two new options
---------------
* ReconnectAfterFailures int (default 3) — how many consecutive pull
  failures trigger recreate. Conservative default; tunable for
  always-on cameras vs flaky NAT.
* RetryBackoff time.Duration (default 1s) — base sleep between pull
  retries; recreate failures double this up to a 30s cap so a
  permanently broken camera does not hammer the network.

Lifecycle changes
-----------------
* Stream.pullPoint is now mutex-protected — the renew goroutine reads
  it concurrently with the pull loop installing a new address after
  recreate. getPullPoint/setPullPoint accessors keep the locking
  contained.
* On successful recreate, failure count and backoff reset to defaults
  so the loop is back to its happy-path cadence.
* On recreate failure, the loop continues retrying (until ctx cancel)
  with exponentially increasing sleep — never blocks Close.

Tests cover: post-failure recreate hits a different SubscriptionRef
Address and subsequent events come from the new endpoint; exponential
backoff drives multiple recreate attempts when the camera stays down;
defaults match production-sensible 3 failures / 1s backoff. -race
clean.
2026-05-21 14:40:42 +02:00

121 lines
3.9 KiB
Go

package stream
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createPullPointRespAlt mirrors the first fixture but returns a
// different SubscriptionReference Address so a test can prove that
// subsequent pulls hit the recreated endpoint.
const createPullPointRespAlt = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body>
<tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera.local/onvif/Events/PullSub_2</wsa:Address>
</tev:SubscriptionReference>
<tev:CurrentTime>2026-05-21T10:30:10Z</tev:CurrentTime>
<tev:TerminationTime>2026-05-21T10:31:10Z</tev:TerminationTime>
</tev:CreatePullPointSubscriptionResponse>
</env:Body>
</env:Envelope>`
func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) {
fc := newFakeCaller()
// Initial subscription.
fc.queueCallMethod(createPullPointResp, nil)
// Recreated subscription returns a *different* endpoint.
fc.queueCallMethod(createPullPointRespAlt, nil)
// First pull fails. With ReconnectAfterFailures=1 this triggers a
// recreate; subsequent pulls go to PullSub_2 which we'll observe.
fc.queueSendSoap("", errors.New("transient failure"))
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
DeviceID: "cam-1",
PullTimeout: 50 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second, // keep renew quiet
})
require.NoError(t, err)
defer s.Close()
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
fc.mu.Lock()
defer fc.mu.Unlock()
require.Len(t, fc.callMethodCalls, 2,
"expected exactly 2 CallMethod calls (initial + recreate)")
// The PullMessages call that delivered the motion event must
// target the new endpoint.
var newEndpointPulls int
for _, c := range fc.sendSoapCalls {
if c[0] == "http://camera.local/onvif/Events/PullSub_2" {
newEndpointPulls++
}
}
assert.GreaterOrEqual(t, newEndpointPulls, 1,
"expected pulls against the recreated subscription endpoint")
}
func TestStream_BackoffWhenRecreateFails(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// After the initial successful create, every CallMethod (recreate)
// and SendSoap (pull) fails. The loop should keep retrying with
// exponential backoff rather than blocking forever or spinning.
fc.mu.Lock()
fc.defaultCall = fakeResp{err: errors.New("recreate fail")}
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 10 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(2 * time.Second)
var calls atomic.Int32
for time.Now().Before(deadline) {
fc.mu.Lock()
calls.Store(int32(len(fc.callMethodCalls)))
fc.mu.Unlock()
if calls.Load() >= 4 {
break
}
time.Sleep(20 * time.Millisecond)
}
assert.GreaterOrEqual(t, calls.Load(), int32(4),
"expected stream to retry recreate (>=3 retries on top of the initial create)")
}
func TestStream_ReconnectAfterFailuresDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 3, o.ReconnectAfterFailures)
}
func TestStream_RetryBackoffDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, time.Second, o.RetryBackoff)
}