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.
///