diff --git a/.pubnub.yml b/.pubnub.yml index 9e06b6f55..e120d4739 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,8 +1,17 @@ name: c-sharp -version: "8.3.3" +version: "8.3.4" schema: 1 scm: github.com/pubnub/c-sharp changelog: + - date: 2026-07-29 + version: v8.3.4 + changes: + - type: bug + text: "Fixed issue related to Reconnect to retain subscription state." + - type: improvement + text: "Removed value cap from maxRetry in Linear/Exponential policies." + - type: improvement + text: "Emit status PNUnexpectedDisconnectCategory when retry attempts exhausted. Instead every retry attempt failures." - date: 2026-07-20 version: v8.3.3 changes: @@ -1009,14 +1018,14 @@ features: - QUERY-PARAM supported-platforms: - - version: Pubnub 'C#' 8.3.3 + version: Pubnub 'C#' 8.3.4 platforms: - Windows 10 and up - Windows Server 2008 and up frameworks: - .Net Framework 4.5+ - - version: PubnubPCL 'C#' 8.3.3 + version: PubnubPCL 'C#' 8.3.4 platforms: - Xamarin.Android - Xamarin.iOS @@ -1028,7 +1037,7 @@ supported-platforms: frameworks: - .Net 4.5+ - - version: PubnubUWP 'C#' 8.3.3 + version: PubnubUWP 'C#' 8.3.4 platforms: - Windows Phone 10 - Universal Windows Apps @@ -1052,7 +1061,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: Pubnub - location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.4 requires: - name: ".Net" @@ -1293,7 +1302,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubNubPCL - location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.4 requires: - name: ".Net" @@ -1644,7 +1653,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubnubUWP - location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.4 requires: - name: "Universal Windows Platform Development" diff --git a/CHANGELOG b/CHANGELOG index 38a74cb2a..3eb1bdd40 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,10 @@ +v8.3.4 - July 29 2026 +----------------------------- +- Fixed: fixed issue related to Reconnect to retain subscription state. + +- Modified: removed value cap from maxRetry in Linear/Exponential policies. +- Modified: emit status PNUnexpectedDisconnectCategory when retry attempts exhausted. Instead every retry attempt failures. + v8.3.3 - July 20 2026 ----------------------------- - Added: obscure exceptions thrown by cryptors to be available in logs only. diff --git a/src/Api/PubnubApi/EventEngine/Subscribe/States/HandshakeReconnectingState.cs b/src/Api/PubnubApi/EventEngine/Subscribe/States/HandshakeReconnectingState.cs index 88e491091..ff662cf53 100644 --- a/src/Api/PubnubApi/EventEngine/Subscribe/States/HandshakeReconnectingState.cs +++ b/src/Api/PubnubApi/EventEngine/Subscribe/States/HandshakeReconnectingState.cs @@ -39,6 +39,9 @@ public override TransitionResult Transition(IEvent e) Events.HandshakeReconnectGiveUpEvent handshakeReconnectGiveUp => new HandshakeFailedState() { Channels = this.Channels, ChannelGroups = this.ChannelGroups, + // Preserve the cursor so a Reconnect() after handshake retries are exhausted + // resumes from the last known timetoken instead of dropping it. + Cursor = this.Cursor, }.With( new EmitStatusInvocation(new PNStatus(handshakeReconnectGiveUp.Status) ) ), diff --git a/src/Api/PubnubApi/EventEngine/Subscribe/States/ReceiveReconnectingState.cs b/src/Api/PubnubApi/EventEngine/Subscribe/States/ReceiveReconnectingState.cs index 3c3ab5249..52e75386c 100644 --- a/src/Api/PubnubApi/EventEngine/Subscribe/States/ReceiveReconnectingState.cs +++ b/src/Api/PubnubApi/EventEngine/Subscribe/States/ReceiveReconnectingState.cs @@ -70,16 +70,19 @@ public override TransitionResult Transition(IEvent e) Cursor = this.Cursor, AttemptedRetries = this.AttemptedRetries + 1, Reason = receiveReconnectFailure.Status - }.With(new EmitStatusInvocation(PNStatusCategory.PNUnexpectedDisconnectCategory)), + }, Events.ReceiveReconnectGiveUpEvent receiveReconnectGiveUp => new ReceiveFailedState() { - Channels = receiveReconnectGiveUp.Channels, - ChannelGroups = receiveReconnectGiveUp.ChannelGroups, - Cursor = receiveReconnectGiveUp.Cursor, - - }.With(new EmitStatusInvocation(receiveReconnectGiveUp.Status)), + // Read from the current state, not the give-up event: the event is enqueued + // by ReceivingReconnectEffectHandler with only Status set (channels/groups/cursor + // are null), whereas 'this' preserves them across every retry. Carrying them here + // lets Reconnect() restore the subscription and resume from the last timetoken. + Channels = this.Channels, + ChannelGroups = this.ChannelGroups, + Cursor = this.Cursor, + }.With(new EmitStatusInvocation(PNStatusCategory.PNUnexpectedDisconnectCategory)), _ => null }; } diff --git a/src/Api/PubnubApi/Properties/AssemblyInfo.cs b/src/Api/PubnubApi/Properties/AssemblyInfo.cs index 051029cd9..9f95298f0 100644 --- a/src/Api/PubnubApi/Properties/AssemblyInfo.cs +++ b/src/Api/PubnubApi/Properties/AssemblyInfo.cs @@ -11,8 +11,8 @@ [assembly: AssemblyProduct("Pubnub C# SDK")] [assembly: AssemblyCopyright("Copyright © 2021")] [assembly: AssemblyTrademark("")] -[assembly: AssemblyVersion("8.3.3")] -[assembly: AssemblyFileVersion("8.3.3")] +[assembly: AssemblyVersion("8.3.4")] +[assembly: AssemblyFileVersion("8.3.4")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. diff --git a/src/Api/PubnubApi/PubnubApi.csproj b/src/Api/PubnubApi/PubnubApi.csproj index fd244ce0e..b455db7c0 100644 --- a/src/Api/PubnubApi/PubnubApi.csproj +++ b/src/Api/PubnubApi/PubnubApi.csproj @@ -14,7 +14,7 @@ Pubnub - 8.3.3 + 8.3.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,7 +22,9 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Obscure exceptions thrown by cryptors to be available in logs only. + Fixed issue related to Reconnect to retain subscription state. +Removed value cap from maxRetry in Linear/Exponential policies. +Emit status PNUnexpectedDisconnectCategory when retry attempts exhausted. Instead every retry attempt failures. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApi/RetryConfiguration.cs b/src/Api/PubnubApi/RetryConfiguration.cs index 65284effd..e5d5ca95a 100644 --- a/src/Api/PubnubApi/RetryConfiguration.cs +++ b/src/Api/PubnubApi/RetryConfiguration.cs @@ -32,7 +32,7 @@ internal class LinearRetryPolicy : IRetryPolicy public LinearRetryPolicy(int delay, int maxRetry) { this.delay = Math.Max(2, delay); - this.maxRetry = Math.Min(10, maxRetry); + this.maxRetry = maxRetry; } @@ -65,14 +65,14 @@ public ExponentialRetryPolicy(int minDelay, int maxDelay, int maxRetry) { this.minDelay = Math.Max(2, minDelay); this.maxDelay = Math.Min(150, maxDelay); - this.maxRetry = Math.Min(6, maxRetry); + this.maxRetry = maxRetry; } public int GetDelay(int attemptedRetries, PNStatus status, int? retryAfter) { if (status.StatusCode == 429 && retryAfter.HasValue && retryAfter > 0) return (int)retryAfter; if (attemptedRetries == 0) return minDelay * 1000 + numGenerator.Next(1000); - return Math.Min((int)(Math.Pow(2, attemptedRetries) * 1000 + numGenerator.Next(1000)), maxDelay * 1000); + return (int)Math.Min(Math.Pow(2, attemptedRetries) * 1000 + numGenerator.Next(1000), maxDelay * 1000.0); } public bool ShouldRetry(int attemptedRetries, PNStatus status) diff --git a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj index f46ca1d65..a45e34c8b 100644 --- a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj +++ b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj @@ -14,7 +14,7 @@ PubnubPCL - 8.3.3 + 8.3.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,7 +22,9 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Obscure exceptions thrown by cryptors to be available in logs only. + Fixed issue related to Reconnect to retain subscription state. +Removed value cap from maxRetry in Linear/Exponential policies. +Emit status PNUnexpectedDisconnectCategory when retry attempts exhausted. Instead every retry attempt failures. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj index 9f22cfca2..c33d75fd7 100644 --- a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj +++ b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj @@ -16,7 +16,7 @@ PubnubUWP - 8.3.3 + 8.3.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -24,7 +24,9 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Obscure exceptions thrown by cryptors to be available in logs only. + Fixed issue related to Reconnect to retain subscription state. +Removed value cap from maxRetry in Linear/Exponential policies. +Emit status PNUnexpectedDisconnectCategory when retry attempts exhausted. Instead every retry attempt failures. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj index d4d7741f6..708108fc8 100644 --- a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj +++ b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj @@ -15,7 +15,7 @@ PubnubApiUnity - 8.3.3 + 8.3.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub diff --git a/src/UnitTests/PubnubApi.Tests/EventEngine/HandshakeReconnectingStateTransition.cs b/src/UnitTests/PubnubApi.Tests/EventEngine/HandshakeReconnectingStateTransition.cs index e1496ccf8..03a0245ee 100644 --- a/src/UnitTests/PubnubApi.Tests/EventEngine/HandshakeReconnectingStateTransition.cs +++ b/src/UnitTests/PubnubApi.Tests/EventEngine/HandshakeReconnectingStateTransition.cs @@ -47,13 +47,45 @@ public void HandshakeReconnectingState_OnEvent_TransitionToHandshakingState( private HandshakeReconnectingState CreateHandshakeReconnectingState() { - return new HandshakeReconnectingState() - { - Channels = new string[] { "ch1", "ch2" }, + return new HandshakeReconnectingState() + { + Channels = new string[] { "ch1", "ch2" }, ChannelGroups = new string[] { "cg1", "cg2" } , }; } + [Test] + public void HandshakeReconnectingState_OnHandshakeReconnectGiveupEvent_PreservesCursorInHandshakeFailedState() + { + // Regression: the cursor must survive give-up so a Reconnect() after handshake retries + // are exhausted resumes from the last known timetoken instead of dropping it. + //Arrange + var currentState = new HandshakeReconnectingState() + { + Channels = new string[] { "ch1", "ch2" }, + ChannelGroups = new string[] { "cg1", "cg2" }, + Cursor = new SubscriptionCursor() { Region = 1, Timetoken = 1234567890 } + }; + var eventToTriggerTransition = new HandshakeReconnectGiveUpEvent() { }; + var expectedState = new HandshakeFailedState() + { + Channels = new string[] { "ch1", "ch2" }, + ChannelGroups = new string[] { "cg1", "cg2" }, + Cursor = new SubscriptionCursor() { Region = 1, Timetoken = 1234567890 } + }; + + //Act + var result = currentState.Transition(@eventToTriggerTransition); + + //Assert + Assert.IsInstanceOf(result.State); + CollectionAssert.AreEqual(expectedState.Channels, ((HandshakeFailedState)result.State).Channels); + CollectionAssert.AreEqual(expectedState.ChannelGroups, ((HandshakeFailedState)result.State).ChannelGroups); + Assert.IsNotNull(((HandshakeFailedState)result.State).Cursor); + Assert.AreEqual(expectedState.Cursor.Region, ((HandshakeFailedState)result.State).Cursor.Region); + Assert.AreEqual(expectedState.Cursor.Timetoken, ((HandshakeFailedState)result.State).Cursor.Timetoken); + } + [Test] public void HandshakeReconnectingState_OnHandshakeReconnectFailureEvent_TransitionToHandshakeReconnectingState() { diff --git a/src/UnitTests/PubnubApi.Tests/EventEngine/ReceiveReconnectingStateTransition.cs b/src/UnitTests/PubnubApi.Tests/EventEngine/ReceiveReconnectingStateTransition.cs index b89085f4b..8eb1ba64a 100644 --- a/src/UnitTests/PubnubApi.Tests/EventEngine/ReceiveReconnectingStateTransition.cs +++ b/src/UnitTests/PubnubApi.Tests/EventEngine/ReceiveReconnectingStateTransition.cs @@ -153,7 +153,61 @@ public void ReceiveReconnectingState_OnReceiveReconnectGiveupEvent_TransitionToR Assert.AreEqual(expectedState.Cursor.Region, ((ReceiveFailedState)result.State).Cursor.Region); Assert.AreEqual(expectedState.Cursor.Timetoken, ((ReceiveFailedState)result.State).Cursor.Timetoken); Assert.IsInstanceOf(result.Invocations.ElementAt(0)); - Assert.AreEqual(PNStatusCategory.PNUnknownCategory, ((EmitStatusInvocation)result.Invocations.ElementAt(0)).StatusCategory); + Assert.AreEqual(PNStatusCategory.PNUnexpectedDisconnectCategory, ((EmitStatusInvocation)result.Invocations.ElementAt(0)).StatusCategory); + } + + [Test] + public void ReceiveReconnectingState_OnReceiveReconnectGiveupEvent_EmitsUnexpectedDisconnectStatus() + { + // The give-up transition always emits a PNUnexpectedDisconnectCategory status, + // regardless of the category carried on the give-up event's own Status. + //Arrange + var currentState = CreateReceiveReconnectingState(); + var eventToTriggerTransition = new ReceiveReconnectGiveUpEvent() + { + Status = new PNStatus(null, PNOperationType.PNSubscribeOperation, PNStatusCategory.PNUnknownCategory) + }; + + //Act + var result = currentState.Transition(eventToTriggerTransition); + + //Assert + Assert.IsInstanceOf(result.State); + Assert.IsInstanceOf(result.Invocations.ElementAt(0)); + Assert.AreEqual(PNStatusCategory.PNUnexpectedDisconnectCategory, ((EmitStatusInvocation)result.Invocations.ElementAt(0)).StatusCategory); + } + + [Test] + public void ReceiveReconnectingState_OnReceiveReconnectGiveupEventWithEmptyEventData_TransitionToReceiveFailedStatePreservingStateData() + { + // Regression: the give-up event enqueued by ReceivingReconnectEffectHandler only sets + // Status; its Channels/ChannelGroups/Cursor are null. The failed state must therefore + // carry the reconnecting state's own channels/groups/cursor so that a subsequent + // Reconnect() can restore the subscription and resume from the last timetoken. + //Arrange + var currentState = CreateReceiveReconnectingState(); + var eventToTriggerTransition = new ReceiveReconnectGiveUpEvent() + { + // Channels, ChannelGroups and Cursor intentionally left null, mirroring the real event. + Status = new PNStatus(null, PNOperationType.PNSubscribeOperation, PNStatusCategory.PNUnexpectedDisconnectCategory) + }; + var expectedState = new ReceiveFailedState() + { + Channels = new string[] { "ch1", "ch2" }, + ChannelGroups = new string[] { "cg1", "cg2" }, + Cursor = new SubscriptionCursor() { Region = 1, Timetoken = 1234567890 } + }; + + //Act + var result = currentState.Transition(eventToTriggerTransition); + + //Assert + Assert.IsInstanceOf(result.State); + CollectionAssert.AreEqual(expectedState.Channels, ((ReceiveFailedState)result.State).Channels); + CollectionAssert.AreEqual(expectedState.ChannelGroups, ((ReceiveFailedState)result.State).ChannelGroups); + Assert.IsNotNull(((ReceiveFailedState)result.State).Cursor); + Assert.AreEqual(expectedState.Cursor.Region, ((ReceiveFailedState)result.State).Cursor.Region); + Assert.AreEqual(expectedState.Cursor.Timetoken, ((ReceiveFailedState)result.State).Cursor.Timetoken); } [Test] diff --git a/src/UnitTests/PubnubApi.Tests/EventEngine/RetryConfigurationTest.cs b/src/UnitTests/PubnubApi.Tests/EventEngine/RetryConfigurationTest.cs new file mode 100644 index 000000000..56aea4bc2 --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/EventEngine/RetryConfigurationTest.cs @@ -0,0 +1,58 @@ +using NUnit.Framework; +using PubnubApi; + +namespace PubnubApi.Tests.EventEngine +{ + internal class RetryConfigurationTest + { + private static PNStatus RetryableStatus() => + new PNStatus(null, PNOperationType.PNSubscribeOperation, PNStatusCategory.PNUnexpectedDisconnectCategory); + + // ---- Linear policy: maxRetry cap (previously Math.Min(10, maxRetry)) is removed ---- + + [Test] + public void Linear_MaxRetryAboveOldCap_IsHonored() + { + // maxRetry = 20 was previously clamped to 10; it must now be respected. + var policy = RetryConfiguration.Linear(5, 20).RetryPolicy; + + // At attempt 15 (which the old cap of 10 would have rejected) it should still retry. + Assert.IsTrue(policy.ShouldRetry(15, RetryableStatus())); + // At attempt 19 it should still retry, at 20 it should stop (attemptedRetries < maxRetry). + Assert.IsTrue(policy.ShouldRetry(19, RetryableStatus())); + Assert.IsFalse(policy.ShouldRetry(20, RetryableStatus())); + } + + [Test] + public void Linear_ForbiddenStatus_NeverRetries() + { + var policy = RetryConfiguration.Linear(5, 20).RetryPolicy; + // A non-null exception is required so PNStatus keeps the 403 (Error must be true). + var forbidden = new PNStatus(new System.Exception("forbidden"), PNOperationType.PNSubscribeOperation, PNStatusCategory.PNAccessDeniedCategory, null, null, 403); + Assert.IsFalse(policy.ShouldRetry(0, forbidden)); + } + + // ---- Exponential policy: maxRetry cap (previously Math.Min(6, maxRetry)) is removed ---- + + [Test] + public void Exponential_MaxRetryAboveOldCap_IsHonored() + { + // maxRetry = 15 was previously clamped to 6; it must now be respected. + var policy = RetryConfiguration.Exponential(2, 150, 15).RetryPolicy; + + // At attempt 10 (which the old cap of 6 would have rejected) it should still retry. + Assert.IsTrue(policy.ShouldRetry(10, RetryableStatus())); + Assert.IsTrue(policy.ShouldRetry(14, RetryableStatus())); + Assert.IsFalse(policy.ShouldRetry(15, RetryableStatus())); + } + + [Test] + public void Exponential_ForbiddenStatus_NeverRetries() + { + var policy = RetryConfiguration.Exponential(2, 150, 15).RetryPolicy; + // A non-null exception is required so PNStatus keeps the 403 (Error must be true). + var forbidden = new PNStatus(new System.Exception("forbidden"), PNOperationType.PNSubscribeOperation, PNStatusCategory.PNAccessDeniedCategory, null, null, 403); + Assert.IsFalse(policy.ShouldRetry(0, forbidden)); + } + } +} diff --git a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj index 416d6d060..452604622 100644 --- a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj +++ b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj @@ -59,6 +59,7 @@ +