diff --git a/src/Opc.Ua.Server/Subscription/Subscription.cs b/src/Opc.Ua.Server/Subscription/Subscription.cs index 2153507289..87b431e6eb 100644 --- a/src/Opc.Ua.Server/Subscription/Subscription.cs +++ b/src/Opc.Ua.Server/Subscription/Subscription.cs @@ -781,10 +781,20 @@ namespace Opc.Ua.Server return PublishingState.Idle; } - // set next expiry time, strictly after the current time. - while (m_publishTimerExpiry <= currentTime) + // set next expiry time. The publish timer sweep runs on its own cadence, and + // when it runs at the publishing interval a sweep that wakes a little late + // can find the subscription a whole interval behind. Advancing by one + // interval keeps that expiry due for the next sweep instead of dropping a + // publishing cycle; only a subscription that is still behind by a whole + // interval after that (a stalled sweep) skips the missed expiries. + long publishingInterval = (long)m_publishingInterval; + m_publishTimerExpiry += publishingInterval; + if (m_publishTimerExpiry <= currentTime - publishingInterval) { - m_publishTimerExpiry += (long)m_publishingInterval; + while (m_publishTimerExpiry <= currentTime) + { + m_publishTimerExpiry += publishingInterval; + } } // OPC 10000-4 ยง5.14.1.2 row 27: the LifetimeCounter is decremented when the diff --git a/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs b/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs index ac22198e2f..90ff700eb5 100644 --- a/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs +++ b/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs @@ -156,6 +156,63 @@ namespace Opc.Ua.Server.Tests Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo((uint)steps.Length)); } + /// + /// A sweep that runs at the publishing interval and wakes a little early or late + /// around the deadline does not drop publishing cycles. The deadline moves by one + /// interval per expiry, so a sweep that finds the subscription a whole interval + /// behind because of timer jitter keeps the next expiry due for the next sweep. + /// + [Test] + public void SweepJitterAroundTheDeadlineDoesNotDropCycles() + { + var clock = new FakeTimeProvider(); + using Subscription subscription = CreateSubscription( + clock, + maxLifetimeCount: 1000, + maxKeepAliveCount: 300); + + // deadlines at 100, 200, 300, ...; sweeps at 95, 205, 295, 405, 495, 605, 695, 805. + long[] steps = [95, 110, 90, 110, 90, 110, 90, 110]; + foreach (long step in steps) + { + clock.Advance(TimeSpan.FromMilliseconds(step)); + subscription.PublishTimerExpired(); + } + + // the sweep at 95 is early; every later sweep processes one expiry. + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(7u)); + } + + /// + /// A subscription that fell behind by more than a publishing interval (a stalled + /// sweep) skips the missed expiries instead of catching up with a burst. + /// + [Test] + public void StalledSweepSkipsMissedExpiries() + { + var clock = new FakeTimeProvider(); + using Subscription subscription = CreateSubscription( + clock, + maxLifetimeCount: 1000, + maxKeepAliveCount: 300); + + clock.Advance(TimeSpan.FromMilliseconds(101)); + subscription.PublishTimerExpired(); + + // the sweep stalls for ten intervals; the late sweep processes one expiry. + clock.Advance(TimeSpan.FromMilliseconds(1000)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(2u)); + + // the next expiry is the first deadline after the late sweep (1200 ms). + clock.Advance(TimeSpan.FromMilliseconds(50)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(2u)); + clock.Advance(TimeSpan.FromMilliseconds(50)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(3u)); + } + /// /// M1-1: a client that keeps answering keep-alives never lets the lifetime expire. ///