diff --git a/CHANGELOG.md b/CHANGELOG.md index 444418439..95befe6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Change Log +## [1.2.12](https://github.com/ably/ably-dotnet/tree/1.2.12) (2023-06-29) + +[Full Changelog](https://github.com/ably/ably-dotnet/compare/1.2.11...1.2.12) + +**Implemented enhancements:** + +- Implement incremental backoff and jitter [\#1156](https://github.com/ably/ably-dotnet/issues/1156) + +**Closed issues:** + +- in maui, android, getting nullreference exception when creating ablyRest and AblyRealtime in Release [\#1241](https://github.com/ably/ably-dotnet/issues/1241) +- Update git submodules to `main` branch [\#1234](https://github.com/ably/ably-dotnet/issues/1234) +- Add tests + doc for batch publish [\#1232](https://github.com/ably/ably-dotnet/issues/1232) +- Fix SDK version for unity package exporter [\#1228](https://github.com/ably/ably-dotnet/issues/1228) +- Implement RTN20C: Handle os connectivity event while `CONNECTING` [\#1218](https://github.com/ably/ably-dotnet/issues/1218) +- Update README with logging [\#1217](https://github.com/ably/ably-dotnet/issues/1217) +- Add unity specific agent header [\#1208](https://github.com/ably/ably-dotnet/issues/1208) +- Expunge references of 'ably.io' from URLs etc [\#1112](https://github.com/ably/ably-dotnet/issues/1112) + +**Merged pull requests:** + +- Fix README supported platforms + flaky tests [\#1236](https://github.com/ably/ably-dotnet/pull/1236) ([sacOO7](https://github.com/sacOO7)) +- Update/git submodules [\#1235](https://github.com/ably/ably-dotnet/pull/1235) ([sacOO7](https://github.com/sacOO7)) +- Fix ably io urls [\#1233](https://github.com/ably/ably-dotnet/pull/1233) ([sacOO7](https://github.com/sacOO7)) +- Update doc+tests for batch publish [\#1230](https://github.com/ably/ably-dotnet/pull/1230) ([sacOO7](https://github.com/sacOO7)) +- Handle connectivity event [\#1226](https://github.com/ably/ably-dotnet/pull/1226) ([sacOO7](https://github.com/sacOO7)) +- Unity agent header [\#1220](https://github.com/ably/ably-dotnet/pull/1220) ([sacOO7](https://github.com/sacOO7)) +- Add incremental backoff and jitter [\#1176](https://github.com/ably/ably-dotnet/pull/1176) ([sacOO7](https://github.com/sacOO7)) + ## [1.2.11](https://github.com/ably/ably-dotnet/tree/1.2.11) (2023-05-22) [Full Changelog](https://github.com/ably/ably-dotnet/compare/1.2.10...1.2.11) diff --git a/build-script/build.fs b/build-script/build.fs index 793a12339..d97bab9f1 100644 --- a/build-script/build.fs +++ b/build-script/build.fs @@ -44,6 +44,7 @@ Usage: Options: -t Target -v Version + -d Define Compilation Constant -f Target Framework Moniker (TFM) """ @@ -64,6 +65,11 @@ let initTargets (argv) = | None -> "" | Some version -> version + let compilationConstant = + match DocoptResult.tryGetArgument "-d" parsedArguments with + | None -> "" + | Some compilationConstant -> compilationConstant + let framework: string option = match DocoptResult.tryGetArgument "-f" parsedArguments with | None -> None @@ -356,7 +362,12 @@ let initTargets (argv) = runFrameworkTests (Method test) TestRunnerErrorLevel.Error |> ignore) Target.create "NetStandard - Build" (fun _ -> - DotNet.build (fun opts -> { opts with Configuration = configuration }) NetStandardSolution) + DotNet.build + (fun opts -> + { opts with + Configuration = configuration + MSBuildParams = { opts.MSBuildParams with Properties = [ "DefineConstants", compilationConstant ] } }) + NetStandardSolution) Target.create "NetStandard - Unit Tests" (fun _ -> runStandardTests UnitTests |> ignore) diff --git a/lib/UnityEngine.dll b/lib/UnityEngine.dll new file mode 100644 index 000000000..1b030d27b Binary files /dev/null and b/lib/UnityEngine.dll differ diff --git a/src/CommonAssemblyInfo.cs b/src/CommonAssemblyInfo.cs index b28828119..1e6b570e3 100644 --- a/src/CommonAssemblyInfo.cs +++ b/src/CommonAssemblyInfo.cs @@ -4,13 +4,13 @@ [assembly: AssemblyCompany("Ably")] [assembly: AssemblyProduct("Ably .NET Library")] -[assembly: AssemblyVersion("1.2.11")] -[assembly: AssemblyFileVersion("1.2.11")] +[assembly: AssemblyVersion("1.2.12")] +[assembly: AssemblyFileVersion("1.2.12")] namespace System { internal static class AssemblyVersionInformation { internal const System.String AssemblyCompany = "Ably"; internal const System.String AssemblyProduct = "Ably .NET Library"; - internal const System.String AssemblyVersion = "1.2.11"; - internal const System.String AssemblyFileVersion = "1.2.11"; + internal const System.String AssemblyVersion = "1.2.12"; + internal const System.String AssemblyFileVersion = "1.2.12"; } } diff --git a/src/IO.Ably.NETStandard20/IO.Ably.NETStandard20.csproj b/src/IO.Ably.NETStandard20/IO.Ably.NETStandard20.csproj index 47c26c065..47eefd81e 100644 --- a/src/IO.Ably.NETStandard20/IO.Ably.NETStandard20.csproj +++ b/src/IO.Ably.NETStandard20/IO.Ably.NETStandard20.csproj @@ -59,4 +59,10 @@ + + + ..\..\lib\UnityEngine.dll + + + diff --git a/src/IO.Ably.NETStandard20/Properties/AssemblyInfo.cs b/src/IO.Ably.NETStandard20/Properties/AssemblyInfo.cs index e7d4b0922..675168a73 100644 --- a/src/IO.Ably.NETStandard20/Properties/AssemblyInfo.cs +++ b/src/IO.Ably.NETStandard20/Properties/AssemblyInfo.cs @@ -13,6 +13,8 @@ [assembly: InternalsVisibleTo("IO.Ably.Push.iOS, PublicKey=002400000480000094000000060200000024000052534131000400000100010001394bb0af9eb8e04f43676c91691de20f2137847e153e27bb96cf2dedf43bce3073f699ca136fb7f9eea0d9b9c6748e9c0be5543761945e101062f8770129512c4c397a08c1b459357e7a49a4dfd7e16ac9c84d1ab3fe1177b3e7741ea10eba746433691bbf1ad643bdf25bcf397a384f96e8d138b129bdb663189200d33dcf")] #if !PACKAGE [assembly: InternalsVisibleTo("IO.Ably.Tests.DotNET")] +#endif +#if UNITY_PACKAGE [assembly: InternalsVisibleTo("Unity.Assets.Tests.AblySandbox")] [assembly: InternalsVisibleTo("Unity.Assets.Tests.EditMode")] [assembly: InternalsVisibleTo("Unity.Assets.Tests.PlayMode")] diff --git a/src/IO.Ably.Shared/Agent.cs b/src/IO.Ably.Shared/Agent.cs index 536b51370..071460559 100644 --- a/src/IO.Ably.Shared/Agent.cs +++ b/src/IO.Ably.Shared/Agent.cs @@ -2,6 +2,10 @@ using System.Collections.Generic; using System.Runtime.InteropServices; +#if NETSTANDARD2_0_OR_GREATER && UNITY_PACKAGE +using UnityEngine; // lib/UnityEngine.dll - 2019.4.40 LTS compile time, at runtime unity player version will be used. +#endif + namespace IO.Ably { internal static class Agent @@ -82,14 +86,80 @@ string DotnetRuntimeVersion() dotnetRuntimeName : $"{dotnetRuntimeName}/{dotnetRuntimeVersion}"; } - // Note - MAUI OS detection requires maui specific dependencies, https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/device/information?view=net-maui-7.0&tabs=windows +#if NETSTANDARD2_0_OR_GREATER && UNITY_PACKAGE + internal static string UnityPlayerIdentifier() + { + return Application.unityVersion.IsEmpty() ? + "unity" : $"unity/{Application.unityVersion}"; + } + + public static class UnityOS + { + public const string Windows = "unity-windows"; + public const string MacOS = "unity-macOS"; + public const string Linux = "unity-linux"; + public const string Android = "unity-android"; + public const string IOS = "unity-iOS"; + public const string TvOS = "unity-tvOS"; + public const string WebGL = "unity-webGL"; + public const string Switch = "unity-nintendo-switch"; + public const string PS4 = "unity-PS4"; + public const string PS5 = "unity-PS5"; + public const string Xbox = "unity-xbox"; + } + + internal static string UnityOsIdentifier() + { + try + { + // lib/UnityEngine.dll - 2019.4.40 LTS compile time. + // Added cases for consistent platforms for future versions of unity. + // At runtime unity player version >= 2019.x.x will be used. + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + return UnityOS.MacOS; + case RuntimePlatform.OSXPlayer: + return UnityOS.MacOS; + case RuntimePlatform.WindowsPlayer: + return UnityOS.Windows; + case RuntimePlatform.WindowsEditor: + return UnityOS.Windows; + case RuntimePlatform.IPhonePlayer: + return UnityOS.IOS; + case RuntimePlatform.Android: + return UnityOS.Android; + case RuntimePlatform.LinuxPlayer: + return UnityOS.Linux; + case RuntimePlatform.LinuxEditor: + return UnityOS.Linux; + case RuntimePlatform.WebGLPlayer: + return UnityOS.WebGL; + case RuntimePlatform.PS4: + return UnityOS.PS4; + case RuntimePlatform.XboxOne: + return UnityOS.Xbox; + case RuntimePlatform.tvOS: + return UnityOS.TvOS; + case RuntimePlatform.Switch: + return UnityOS.Switch; + case RuntimePlatform.PS5: + return UnityOS.PS5; + } + } + catch + { + // ignored, If enum case is not found for future versions of unity + } + + return string.Empty; + } +#endif + internal static string OsIdentifier() { switch (IoC.PlatformId) { - // For windows only dotnet-framework, return windows OS => https://dotnet.microsoft.com/en-us/download/dotnet-framework - case PlatformRuntime.Framework: - return OS.Windows; case PlatformRuntime.XamarinAndroid: return OS.Android; case PlatformRuntime.XamarinIos: @@ -140,9 +210,14 @@ internal static string OsIdentifier() } #endif - // If netstandard target is used by .Net Core App, https://mariusschulz.com/blog/detecting-the-operating-system-in-net-core +#if NETSTANDARD2_0_OR_GREATER && UNITY_PACKAGE + return UnityOsIdentifier(); +#endif + +#pragma warning disable CS0162 // Disable code unreachable warning when above conditional statement is true try { + // If netstandard target is used by .Net Core App, https://mariusschulz.com/blog/detecting-the-operating-system-in-net-core if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { return OS.Linux; @@ -157,28 +232,29 @@ internal static string OsIdentifier() { return OS.Windows; } + + // If netframework/netstandard target is used by .Net Mono App, http://docs.go-mono.com/?link=P%3aSystem.Environment.OSVersion + // https://stackoverflow.com/questions/9129491/c-sharp-compiled-in-mono-detect-os + switch (Environment.OSVersion.Platform) + { + case PlatformID.Win32NT: + case PlatformID.Win32S: + case PlatformID.Win32Windows: + case PlatformID.WinCE: + return OS.Windows; + case PlatformID.Unix: + return OS.Linux; + case PlatformID.MacOSX: + return OS.MacOS; + } } catch { - // ignored - } - - // If netstandard target is used by .Net Mono App, http://docs.go-mono.com/?link=P%3aSystem.Environment.OSVersion - // https://stackoverflow.com/questions/9129491/c-sharp-compiled-in-mono-detect-os - switch (Environment.OSVersion.Platform) - { - case PlatformID.Win32NT: - case PlatformID.Win32S: - case PlatformID.Win32Windows: - case PlatformID.WinCE: - return OS.Windows; - case PlatformID.Unix: - return OS.Linux; - case PlatformID.MacOSX: - return OS.MacOS; + // ignored, if above code throws runtime exception for class/type/enum not found } return string.Empty; +#pragma warning restore CS0162 } internal static string AblyAgentIdentifier(Dictionary additionalAgents) @@ -199,6 +275,11 @@ void AddAgentIdentifier(ICollection currentAgentComponents, string produ var agentComponents = new List(); AddAgentIdentifier(agentComponents, AblySdkIdentifier); AddAgentIdentifier(agentComponents, DotnetRuntimeIdentifier()); + +#if NETSTANDARD2_0_OR_GREATER && UNITY_PACKAGE + AddAgentIdentifier(agentComponents, UnityPlayerIdentifier()); +#endif + AddAgentIdentifier(agentComponents, OsIdentifier()); if (additionalAgents == null) diff --git a/src/IO.Ably.Shared/ClientOptions.cs b/src/IO.Ably.Shared/ClientOptions.cs index b608dbbad..544a2a386 100644 --- a/src/IO.Ably.Shared/ClientOptions.cs +++ b/src/IO.Ably.Shared/ClientOptions.cs @@ -389,7 +389,11 @@ public bool UseBinaryProtocol /// which used to prevent the library from initialising. /// Default: true. /// +#if NETSTANDARD2_0_OR_GREATER && UNITY_PACKAGE + public bool AutomaticNetworkStateMonitoring { get; set; } = false; +#else public bool AutomaticNetworkStateMonitoring { get; set; } = true; +#endif /// /// Allows developers to control how often (in milliseconds) the heartbeat is checked to determine if the server @@ -432,7 +436,7 @@ internal Func NowFunc internal bool SkipInternetCheck { get; set; } - internal TimeSpan RealtimeRequestTimeout { get; set; } = Defaults.DefaultRealtimeTimeout; + internal TimeSpan RealtimeRequestTimeout { get; set; } = Defaults.RealtimeRequestTimeout; /// /// Default constructor for ClientOptions. diff --git a/src/IO.Ably.Shared/Defaults.cs b/src/IO.Ably.Shared/Defaults.cs index 878081b6f..c32dde171 100644 --- a/src/IO.Ably.Shared/Defaults.cs +++ b/src/IO.Ably.Shared/Defaults.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text; using IO.Ably.Transport; namespace IO.Ably @@ -46,7 +42,7 @@ internal static string GetVersion() public static readonly TimeSpan HttpMaxRetryDuration = TimeSpan.FromSeconds(15); public static readonly TimeSpan MaxHttpRequestTimeout = TimeSpan.FromSeconds(10); public static readonly TimeSpan MaxHttpOpenTimeout = TimeSpan.FromSeconds(4); - public static readonly TimeSpan DefaultRealtimeTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan RealtimeRequestTimeout = TimeSpan.FromSeconds(10); public static readonly TimeSpan DisconnectedRetryTimeout = TimeSpan.FromSeconds(15); public static readonly TimeSpan SuspendedRetryTimeout = TimeSpan.FromSeconds(30); public static readonly TimeSpan ConnectionStateTtl = TimeSpan.FromSeconds(60); diff --git a/src/IO.Ably.Shared/IO.Ably.Shared.projitems b/src/IO.Ably.Shared/IO.Ably.Shared.projitems index 3d5c5227c..4b7ee91e1 100644 --- a/src/IO.Ably.Shared/IO.Ably.Shared.projitems +++ b/src/IO.Ably.Shared/IO.Ably.Shared.projitems @@ -181,6 +181,7 @@ + diff --git a/src/IO.Ably.Shared/Realtime/RealtimeChannel.cs b/src/IO.Ably.Shared/Realtime/RealtimeChannel.cs index 9e3395b27..d040c7eb7 100644 --- a/src/IO.Ably.Shared/Realtime/RealtimeChannel.cs +++ b/src/IO.Ably.Shared/Realtime/RealtimeChannel.cs @@ -8,6 +8,7 @@ using IO.Ably.MessageEncoders; using IO.Ably.Push; using IO.Ably.Rest; +using IO.Ably.Shared.Utils; using IO.Ably.Transport; using IO.Ably.Types; using IO.Ably.Utils; @@ -25,6 +26,7 @@ internal class RealtimeChannel : EventEmitter, private ChannelOptions _options; private ChannelState _state; private readonly PushChannel _pushChannel; + private int _retryCount = 0; /// /// True when the channel moves to the @ATTACHED@ state, and False @@ -658,7 +660,7 @@ private void HandleStateChange(ChannelState state, ErrorInfo error, ProtocolMess Logger.Debug($"HandleStateChange state change from {State} to {state}"); } - var oldState = State; + var previousState = State; State = state; switch (state) @@ -671,12 +673,13 @@ private void HandleStateChange(ChannelState state, ErrorInfo error, ProtocolMess AttachResume = false; break; case ChannelState.Attached: + _retryCount = 0; AttachResume = true; Presence.ChannelAttached(protocolMessage); break; case ChannelState.Detached: /* RTL13a check for unexpected detach */ - switch (oldState) + switch (previousState) { /* (RTL13a) If the channel is in the @ATTACHED@ or @SUSPENDED@ states, an attempt to reattach the channel should be made immediately */ @@ -688,6 +691,8 @@ an attempt to reattach the channel should be made immediately */ break; case ChannelState.Attaching: + // Since attachtimeout will transition state to suspended, no need to suspend it twice + AttachedAwaiter.Fail(new ErrorInfo("Channel transitioned to suspended", ErrorCodes.InternalError)); /* RTL13b says we need to become suspended, but continue to retry */ Logger.Debug($"Server initiated detach for channel {Name} whilst attaching; moving to suspended"); SetChannelState(ChannelState.Suspended, error, protocolMessage); @@ -709,6 +714,7 @@ an attempt to reattach the channel should be made immediately */ break; case ChannelState.Failed: + _retryCount = 0; AttachResume = false; AttachedAwaiter.Fail(error); DetachedAwaiter.Fail(error); @@ -743,15 +749,21 @@ private void Reattach(ErrorInfo error, ProtocolMessage msg) } /// - /// should only be called when the channel is SUSPENDED. + /// should only be called when the channel gets into SUSPENDED. + /// RTL13b. /// private void ReattachAfterTimeout(ErrorInfo error, ProtocolMessage msg) { + _retryCount++; + + var retryTimeout = TimeSpan.FromMilliseconds(ReconnectionStrategy. + GetRetryTime(RealtimeClient.Options.ChannelRetryTimeout.TotalMilliseconds, _retryCount)); + // We capture the task but ignore it to make sure an error doesn't take down // the thread _ = Task.Run(async () => { - await Task.Delay(RealtimeClient.Options.ChannelRetryTimeout); + await Task.Delay(retryTimeout); // only retry if the connection is connected (RTL13c) if (Connection.State == ConnectionState.Connected) diff --git a/src/IO.Ably.Shared/Realtime/Workflows/RealtimeCommands.cs b/src/IO.Ably.Shared/Realtime/Workflows/RealtimeCommands.cs index 973ed6f88..84529ade3 100644 --- a/src/IO.Ably.Shared/Realtime/Workflows/RealtimeCommands.cs +++ b/src/IO.Ably.Shared/Realtime/Workflows/RealtimeCommands.cs @@ -223,7 +223,7 @@ protected override string ExplainData() " ClearConnectionKey: " + ClearConnectionKey; } - public static SetDisconnectedStateCommand Create ( + public static SetDisconnectedStateCommand Create( ErrorInfo error, bool retryInstantly = false, bool skipAttach = false, diff --git a/src/IO.Ably.Shared/Realtime/Workflows/RealtimeState.cs b/src/IO.Ably.Shared/Realtime/Workflows/RealtimeState.cs index 8f0231710..a8641dcb9 100644 --- a/src/IO.Ably.Shared/Realtime/Workflows/RealtimeState.cs +++ b/src/IO.Ably.Shared/Realtime/Workflows/RealtimeState.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using IO.Ably.Shared.Utils; using IO.Ably.Transport; using IO.Ably.Transport.States.Connection; using IO.Ably.Types; @@ -136,8 +137,8 @@ public long IncrementSerial() public readonly List WaitingForAck = new List(); - public void AddAckMessage(ProtocolMessage message, Action callback) - => WaitingForAck.Add(new MessageAndCallback(message, callback)); + public void AddAckMessage(ProtocolMessage message, Action callback) => + WaitingForAck.Add(new MessageAndCallback(message, callback)); public RealtimeState() : this(null) diff --git a/src/IO.Ably.Shared/Transport/ConnectionManager.cs b/src/IO.Ably.Shared/Transport/ConnectionManager.cs index 3795ad799..5daef36a0 100644 --- a/src/IO.Ably.Shared/Transport/ConnectionManager.cs +++ b/src/IO.Ably.Shared/Transport/ConnectionManager.cs @@ -160,12 +160,12 @@ internal async Task OnAuthUpdated(TokenDetails tokenDetails, bool wait) { while (true) { - var (success, newState) = await waiter.Wait(Defaults.DefaultRealtimeTimeout); + var (success, newState) = await waiter.Wait(Defaults.RealtimeRequestTimeout); if (success == false) { throw new AblyException( new ErrorInfo( - $"Connection state didn't change after Auth updated within {Defaults.DefaultRealtimeTimeout}", + $"Connection state didn't change after Auth updated within {Defaults.RealtimeRequestTimeout}", 40140)); } @@ -305,6 +305,7 @@ public void HandleNetworkStateChange(NetworkState state) switch (state) { case NetworkState.Online: + // RTN20b if (ConnectionState == ConnectionState.Disconnected || ConnectionState == ConnectionState.Suspended) { @@ -316,7 +317,20 @@ public void HandleNetworkStateChange(NetworkState state) ExecuteCommand(ConnectCommand.Create().TriggeredBy("ConnectionManager.HandleNetworkStateChange(Online)")); } + // RTN20c + if (ConnectionState == ConnectionState.Connecting) + { + if (Logger.IsDebug) + { + Logger.Debug("Network state is Online. Attempting reconnect."); + } + + ExecuteCommand(SetConnectingStateCommand.Create().TriggeredBy("ConnectionManager.HandleNetworkStateChange(Online)")); + } + break; + + // RTN20a case NetworkState.Offline: if (ConnectionState == ConnectionState.Connected || ConnectionState == ConnectionState.Connecting) diff --git a/src/IO.Ably.Shared/Transport/States/Connection/ConnectionConnectingState.cs b/src/IO.Ably.Shared/Transport/States/Connection/ConnectionConnectingState.cs index 44ec34ad3..d5c3ce993 100644 --- a/src/IO.Ably.Shared/Transport/States/Connection/ConnectionConnectingState.cs +++ b/src/IO.Ably.Shared/Transport/States/Connection/ConnectionConnectingState.cs @@ -36,7 +36,7 @@ public override void Close() TransitionState(SetClosingStateCommand.Create().TriggeredBy("ConnectingState.Close()")); } - public override async Task OnMessageReceived(ProtocolMessage message, RealtimeState state) + public override Task OnMessageReceived(ProtocolMessage message, RealtimeState state) { if (message == null) { @@ -53,25 +53,25 @@ public override async Task OnMessageReceived(ProtocolMessage message, Real .TriggeredBy("ConnectingState.OnMessageReceived(Connected)")); } - return true; + return Task.FromResult(true); } case ProtocolMessage.MessageAction.Disconnected: { Context.ExecuteCommand(HandleConnectingDisconnectedCommand.Create(message.Error) .TriggeredBy("ConnectingState.OnMessageReceived(Disconnected)")); - return true; + return Task.FromResult(true); } case ProtocolMessage.MessageAction.Error: { Context.ExecuteCommand(HandleConnectingErrorCommand.Create(message.Error) .TriggeredBy("ConnectingState.OnMessageReceived(Error)")); - return true; + return Task.FromResult(true); } } - return false; + return Task.FromResult(false); } public override void AbortTimer() diff --git a/src/IO.Ably.Shared/Transport/States/Connection/ConnectionDisconnectedState.cs b/src/IO.Ably.Shared/Transport/States/Connection/ConnectionDisconnectedState.cs index 108202ea8..a8d4bd624 100644 --- a/src/IO.Ably.Shared/Transport/States/Connection/ConnectionDisconnectedState.cs +++ b/src/IO.Ably.Shared/Transport/States/Connection/ConnectionDisconnectedState.cs @@ -1,5 +1,7 @@ -using IO.Ably.Realtime; +using System; +using IO.Ably.Realtime; using IO.Ably.Realtime.Workflow; +using IO.Ably.Shared.Utils; namespace IO.Ably.Transport.States.Connection { @@ -44,11 +46,16 @@ public override void AbortTimer() _timer.Abort(); } + // RTN14d public override void StartTimer() { + var retryInterval = Context.RetryTimeout.TotalMilliseconds; + var noOfAttempts = Context.Connection.RealtimeClient?.State?.AttemptsInfo?.NumberOfAttempts ?? 0 + 1; // First attempt should start with 1 instead of 0. + var retryIn = TimeSpan.FromMilliseconds(ReconnectionStrategy.GetRetryTime(retryInterval, noOfAttempts)); + if (RetryInstantly == false) { - _timer.Start(Context.RetryTimeout, OnTimeOut); + _timer.Start(retryIn, OnTimeOut); } } diff --git a/src/IO.Ably.Shared/Utils/ReconnectionStrategy.cs b/src/IO.Ably.Shared/Utils/ReconnectionStrategy.cs new file mode 100644 index 000000000..af553c951 --- /dev/null +++ b/src/IO.Ably.Shared/Utils/ReconnectionStrategy.cs @@ -0,0 +1,30 @@ +using System; + +namespace IO.Ably.Shared.Utils +{ + // RTB1 + internal class ReconnectionStrategy + { + private static readonly Random Random = new Random(); + + public static double GetBackoffCoefficient(int count) + { + return Math.Min((count + 2) / 3d, 2d); + } + + public static double GetJitterCoefficient() + { + return 1 - (Random.NextDouble() * 0.2); + } + + // Generates retryTimeout value for given timeout and retryAttempt. + // If x is the value generated then + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Lower bound < x < Upper bound + public static double GetRetryTime(double initialTimeout, int retryAttempt) + { + return initialTimeout * GetBackoffCoefficient(retryAttempt) * GetJitterCoefficient(); + } + } +} diff --git a/src/IO.Ably.Tests.Shared/IO.Ably.Tests.Shared.projitems b/src/IO.Ably.Tests.Shared/IO.Ably.Tests.Shared.projitems index 3883abbfd..69a5c73b1 100644 --- a/src/IO.Ably.Tests.Shared/IO.Ably.Tests.Shared.projitems +++ b/src/IO.Ably.Tests.Shared/IO.Ably.Tests.Shared.projitems @@ -154,5 +154,6 @@ + \ No newline at end of file diff --git a/src/IO.Ably.Tests.Shared/Infrastructure/FakeConnectionContext.cs b/src/IO.Ably.Tests.Shared/Infrastructure/FakeConnectionContext.cs index 8f8161a86..d90e696dc 100644 --- a/src/IO.Ably.Tests.Shared/Infrastructure/FakeConnectionContext.cs +++ b/src/IO.Ably.Tests.Shared/Infrastructure/FakeConnectionContext.cs @@ -21,7 +21,7 @@ public FakeConnectionContext() public bool ShouldWeRenewTokenValue { get; set; } - public TimeSpan DefaultTimeout { get; set; } = Defaults.DefaultRealtimeTimeout; + public TimeSpan DefaultTimeout { get; set; } = Defaults.RealtimeRequestTimeout; public TimeSpan RetryTimeout { get; set; } = Defaults.DisconnectedRetryTimeout; diff --git a/src/IO.Ably.Tests.Shared/Infrastructure/TestTransportWrapper.cs b/src/IO.Ably.Tests.Shared/Infrastructure/TestTransportWrapper.cs index 7c55886b7..30954183b 100644 --- a/src/IO.Ably.Tests.Shared/Infrastructure/TestTransportWrapper.cs +++ b/src/IO.Ably.Tests.Shared/Infrastructure/TestTransportWrapper.cs @@ -95,6 +95,8 @@ public void OnTransportEvent(Guid transportId, TransportState state, Exception e public Action BeforeDataProcessed; public Action AfterDataReceived; + public bool KeepInConnectingState { get; set; } + public TestTransportWrapper(ITransport wrappedTransport, Protocol protocol) { WrappedTransport = wrappedTransport; @@ -126,6 +128,11 @@ public void Connect() throw new SocketException(); } + if (KeepInConnectingState) + { + return; + } + WrappedTransport.Connect(); } diff --git a/src/IO.Ably.Tests.Shared/MessageEncodes/CipherEncoderTests.cs b/src/IO.Ably.Tests.Shared/MessageEncodes/CipherEncoderTests.cs index df06acab5..c27746be3 100644 --- a/src/IO.Ably.Tests.Shared/MessageEncodes/CipherEncoderTests.cs +++ b/src/IO.Ably.Tests.Shared/MessageEncodes/CipherEncoderTests.cs @@ -31,7 +31,11 @@ public CipherEncoderTests(int keyLength = Crypto.DefaultKeylength, bool encrypt private byte[] GenerateKey(int keyLength) { +#if NET6_0_OR_GREATER + var keyGen = new Rfc2898DeriveBytes("password", 8, 8, HashAlgorithmName.SHA256); +#else var keyGen = new Rfc2898DeriveBytes("password", 8); +#endif return keyGen.GetBytes(keyLength / 8); } @@ -66,7 +70,11 @@ public void WithInvalidKey_Throws() [Fact] public void WithInvalidAlgorithm_Throws() { +#if NET6_0_OR_GREATER + var keyGen = new Rfc2898DeriveBytes("password", 8, 8, HashAlgorithmName.SHA256); +#else var keyGen = new Rfc2898DeriveBytes("password", 8); +#endif var key = keyGen.GetBytes(Crypto.DefaultKeylength / 8); var options = new ChannelOptions(new CipherParams("mgg", key)); diff --git a/src/IO.Ably.Tests.Shared/MessageEncodes/MessageDecodingAcceptanceTests.cs b/src/IO.Ably.Tests.Shared/MessageEncodes/MessageDecodingAcceptanceTests.cs index 6dba2a39b..eb692cf64 100644 --- a/src/IO.Ably.Tests.Shared/MessageEncodes/MessageDecodingAcceptanceTests.cs +++ b/src/IO.Ably.Tests.Shared/MessageEncodes/MessageDecodingAcceptanceTests.cs @@ -72,7 +72,11 @@ public void WithFailedEncoding_ShouldLeaveOriginalDataAndEncodingInPayload() private byte[] GenerateKey(int keyLength) { +#if NET6_0_OR_GREATER + var keyGen = new Rfc2898DeriveBytes("password", 8, 8, HashAlgorithmName.SHA256); +#else var keyGen = new Rfc2898DeriveBytes("password", 8); +#endif return keyGen.GetBytes(keyLength / 8); } } diff --git a/src/IO.Ably.Tests.Shared/Realtime/ChannelSandboxSpecs.cs b/src/IO.Ably.Tests.Shared/Realtime/ChannelSandboxSpecs.cs index 4ffa72aed..5aa8c564b 100644 --- a/src/IO.Ably.Tests.Shared/Realtime/ChannelSandboxSpecs.cs +++ b/src/IO.Ably.Tests.Shared/Realtime/ChannelSandboxSpecs.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Threading; @@ -1122,6 +1123,121 @@ await WaitFor(done => client.Close(); } + [Theory] + [ProtocolData] + [Trait("spec", "RTL13b")] + public async Task WhenChannelSuspended_ShouldRetryUsingIncrementalBackoffAfterRetryWhenFirstDetachReceived(Protocol protocol) + { + // reduce timeouts to speed up test + var client = await GetRealtimeClient(protocol, (options, settings) => + { + options.RealtimeRequestTimeout = TimeSpan.FromSeconds(2); + options.ChannelRetryTimeout = TimeSpan.FromSeconds(5); + }); + + await client.WaitForState(); + + var channelName = "RTL13a".AddRandomSuffix(); + var channel = client.Channels.Get(channelName); + + // block attach messages being sent, keeping channel in attaching state + client.GetTestTransport().BlockSendActions.Add(ProtocolMessage.MessageAction.Attach); + + channel.Attach(); + + // Send first detach message, it will keep retrying until attached state is reached. + var detachedMessage = new ProtocolMessage(ProtocolMessage.MessageAction.Detached, channelName) + { + Error = new ErrorInfo("fake error") + }; + client.GetTestTransport().FakeReceivedMessage(detachedMessage); + + var stopWatch = new Stopwatch(); + var channelRetryTimeouts = new List(); + do + { + await channel.WaitForState(ChannelState.Suspended); + stopWatch.Start(); + + await channel.WaitForState(ChannelState.Attaching, TimeSpan.FromSeconds(10)); + channelRetryTimeouts.Add(stopWatch.Elapsed.TotalSeconds); + stopWatch.Reset(); + } + while (channelRetryTimeouts.Count < 8); + + Output.WriteLine(channelRetryTimeouts.ToJson()); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Adding 20ms delay to accomodate start and stop + channelRetryTimeouts[0].Should().BeInRange(4, 5 + 0.20); + channelRetryTimeouts[1].Should().BeInRange(5.33, 6.66 + 0.20); + channelRetryTimeouts[2].Should().BeInRange(6.66, 8.33 + 0.20); + for (var i = 3; i < channelRetryTimeouts.Count; i++) + { + channelRetryTimeouts[i].Should().BeInRange(8, 10 + 0.20); + } + + client.Close(); + } + + [Theory] + [ProtocolData] + [Trait("spec", "RTL13b")] + public async Task WhenChannelSuspended_ShouldRetryUsingIncrementalBackoffForConsistentDetachReceived(Protocol protocol) + { + // reduce timeouts to speed up test + var client = await GetRealtimeClient(protocol, (options, settings) => + { + options.ChannelRetryTimeout = TimeSpan.FromSeconds(5); + }); + + await client.WaitForState(); + + var channelName = "RTL13a".AddRandomSuffix(); + var channel = client.Channels.Get(channelName); + + // block attach messages being sent, keeping channel in attaching state + client.GetTestTransport().BlockSendActions.Add(ProtocolMessage.MessageAction.Attach); + + channel.Attach(); + + var detachedMessage = new ProtocolMessage(ProtocolMessage.MessageAction.Detached, channelName) + { + Error = new ErrorInfo("fake error") + }; + + var stopWatch = new Stopwatch(); + var channelRetryTimeouts = new List(); + do + { + client.GetTestTransport().FakeReceivedMessage(detachedMessage); + + await channel.WaitForState(ChannelState.Suspended); + stopWatch.Start(); + + await channel.WaitForState(ChannelState.Attaching, TimeSpan.FromSeconds(10)); + channelRetryTimeouts.Add(stopWatch.Elapsed.TotalSeconds); + stopWatch.Reset(); + } + while (channelRetryTimeouts.Count < 8); + + Output.WriteLine(channelRetryTimeouts.ToJson()); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Adding 20ms delay to accomodate start and stop + channelRetryTimeouts[0].Should().BeInRange(4, 5 + 0.20); + channelRetryTimeouts[1].Should().BeInRange(5.33, 6.66 + 0.20); + channelRetryTimeouts[2].Should().BeInRange(6.66, 8.33 + 0.20); + for (var i = 3; i < channelRetryTimeouts.Count; i++) + { + channelRetryTimeouts[i].Should().BeInRange(8, 10 + 0.20); + } + + client.Close(); + } + [Theory] [ProtocolData] [Trait("spec", "RTL13b")] diff --git a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandBoxSpecs.cs b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandBoxSpecs.cs index b9db7d766..de27d73d8 100644 --- a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandBoxSpecs.cs +++ b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandBoxSpecs.cs @@ -9,6 +9,7 @@ using IO.Ably.Realtime; using IO.Ably.Realtime.Workflow; using IO.Ably.Tests.Infrastructure; +using IO.Ably.Tests.Shared.Utils; using IO.Ably.Transport; using IO.Ably.Transport.States.Connection; using IO.Ably.Types; @@ -993,8 +994,11 @@ await WaitFor(60000, done => client.Workflow.QueueCommand(SetDisconnectedStateCommand.Create(ErrorInfo.ReasonDisconnected)); }); - var interval = reconnectedAt - disconnectedAt; - interval.TotalMilliseconds.Should().BeGreaterThan(5000 - 10 /* Allow 10 milliseconds */); + var reconnectedInTime = reconnectedAt - disconnectedAt; + + var (lowerBound, _) = ReconnectionStrategyTest.Bounds(1, 5000); + reconnectedInTime.TotalMilliseconds.Should().BeGreaterThan(lowerBound); + initialConnectionId.Should().NotBeNullOrEmpty(); initialConnectionId.Should().NotBe(newConnectionId); connectionStateTtl.Should().Be(TimeSpan.FromSeconds(1)); diff --git a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandboxOperatingSystemEventsForNetworkSpecs.cs b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandboxOperatingSystemEventsForNetworkSpecs.cs index cdf213711..7dd28ac50 100644 --- a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandboxOperatingSystemEventsForNetworkSpecs.cs +++ b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSandboxOperatingSystemEventsForNetworkSpecs.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using IO.Ably.Realtime; @@ -93,6 +94,37 @@ public async Task await WaitForState(client, ConnectionState.Connecting); } + [Theory] + [ProtocolData] + [Trait("spec", "RTN20c")] + public async Task + WhenOperatingSystemNetworkBecomesAvailableAndStateIsConnecting_ShouldTransitionToConnectingAndRenewsTransport(Protocol protocol) + { + var transportFactory = new TestTransportFactory(transport => { transport.KeepInConnectingState = true; }); + + var client = await GetRealtimeClient(protocol, (options, _) => + { + options.AutoConnect = false; + options.TransportFactory = transportFactory; + }); + + client.Connection.On(stateChange => Output.WriteLine("State Changed: " + stateChange.Current + " From: " + stateChange.Previous)); + client.Connect(); + + await WaitForState(client, ConnectionState.Connecting); + await client.ProcessCommands(); // waits for workflow command to finish so transport can be created for connecting state + + var transportId = client.ConnectionManager.Transport.Id; + + Connection.NotifyOperatingSystemNetworkState(NetworkState.Online, Logger); + await client.ProcessCommands(); + var newTransportId = client.ConnectionManager.Transport.Id; + + newTransportId.Should().NotBe(transportId); + + await client.WaitForState(ConnectionState.Connecting); + } + [Theory] [ProtocolData] [Trait("spec", "RTN22")] diff --git a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSpecs/ConnectionFailureSpecs.cs b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSpecs/ConnectionFailureSpecs.cs index 33267f332..417f0a58f 100644 --- a/src/IO.Ably.Tests.Shared/Realtime/ConnectionSpecs/ConnectionFailureSpecs.cs +++ b/src/IO.Ably.Tests.Shared/Realtime/ConnectionSpecs/ConnectionFailureSpecs.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net; @@ -7,6 +8,7 @@ using IO.Ably.Realtime; using IO.Ably.Realtime.Workflow; using IO.Ably.Tests.Infrastructure; +using IO.Ably.Tests.Shared.Utils; using IO.Ably.Transport; using IO.Ably.Types; using Xunit; @@ -166,8 +168,9 @@ public async Task WithTokenErrorTwice_ShouldNotRenewAndRaiseErrorAndTransitionTo [Trait("spec", "TR2")] public async Task WhenTransportFails_ShouldTransitionToDisconnectedAndEmitErrorWithRetry() { + // this will keep it in connecting state FakeTransportFactory.InitialiseFakeTransport = - transport => transport.OnConnectChangeStateToConnected = false; // this will keep it in connecting state + transport => transport.OnConnectChangeStateToConnected = false; ClientOptions options = null; var client = GetClientWithFakeTransport(opts => @@ -190,9 +193,8 @@ public async Task WhenTransportFails_ShouldTransitionToDisconnectedAndEmitErrorW Done(); }); - // Let the connecting state complete it's logic otherwise by the time we get to here - // The transport is not created yet as this is done on a separate thread - await Task.Delay(1000); + // Let the connecting state complete and create transport, otherwise LastCreatedTransport.Id throws exception + await client.ProcessCommands(); LastCreatedTransport.Listener.OnTransportEvent(LastCreatedTransport.Id, TransportState.Closing, new Exception()); @@ -270,6 +272,45 @@ public async Task WhenInSuspendedState_ShouldTryAndReconnectAfterSuspendRetryTim elapsed.Should().BeCloseTo(client.Options.SuspendedRetryTimeout, TimeSpan.FromMilliseconds(1000)); } + [Fact] + [Trait("spec", "RTN14d")] + public async Task WhenInDisconnectedState_ReconnectUsingIncrementalBackoffTimeout() + { + // this will keep it in connecting state when connect is called + FakeTransportFactory.InitialiseFakeTransport = + transport => transport.OnConnectChangeStateToConnected = false; + + var client = GetClientWithFakeTransport(opts => + { + opts.DisconnectedRetryTimeout = TimeSpan.FromSeconds(5); + }); + + // wait for transport to be created for first connecting + await client.WaitForState(ConnectionState.Connecting); + await client.ProcessCommands(); + + var disconnectedRetryTimeouts = new List(); + do + { + client.ExecuteCommand(SetDisconnectedStateCommand.Create(ErrorInfo.ReasonDisconnected)); + await client.WaitForState(ConnectionState.Disconnected); + var elapsed = await client.WaitForState(ConnectionState.Connecting); + disconnectedRetryTimeouts.Add(elapsed.TotalSeconds); + } + while ((disconnectedRetryTimeouts.Sum() + 10) < client.Connection.ConnectionStateTtl.TotalSeconds); + Output.WriteLine(disconnectedRetryTimeouts.ToJson()); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + disconnectedRetryTimeouts[0].Should().BeInRange(4, 5); + disconnectedRetryTimeouts[1].Should().BeInRange(5.33, 6.66); + disconnectedRetryTimeouts[2].Should().BeInRange(6.66, 8.33); + for (var i = 3; i < disconnectedRetryTimeouts.Count; i++) + { + disconnectedRetryTimeouts[i].Should().BeInRange(8, 10); + } + } + private static Task WaitForConnectingOrSuspended(AblyRealtime client) { return new ConnectionAwaiter(client.Connection, ConnectionState.Connecting, ConnectionState.Suspended).Wait(); diff --git a/src/IO.Ably.Tests.Shared/Realtime/PresenceSandboxSpecs.cs b/src/IO.Ably.Tests.Shared/Realtime/PresenceSandboxSpecs.cs index cd46e60e1..ae976710f 100644 --- a/src/IO.Ably.Tests.Shared/Realtime/PresenceSandboxSpecs.cs +++ b/src/IO.Ably.Tests.Shared/Realtime/PresenceSandboxSpecs.cs @@ -1702,7 +1702,7 @@ public async Task ChannelStateCondition_WhenQueueMessagesIsFalse_ShouldFailAckQu tsc.SetCompleted(); }); - await WaitFor(1000, done => + await WaitFor(done => { // Ack Queue has one presence message if (channel.RealtimeClient.State.WaitingForAck.Count == 1) @@ -1723,7 +1723,7 @@ await WaitFor(1000, done => // No pending message queue, since QueueMessages=false channel.RealtimeClient.State.PendingMessages.Should().HaveCount(0); - await WaitFor(1000, done => + await WaitFor(done => { // Ack cleared after flushing the queue for transport disconnection, because QueueMessages=false if (channel.RealtimeClient.State.WaitingForAck.Count == 0) diff --git a/src/IO.Ably.Tests.Shared/Utils/ReconnectionStrategyTest.cs b/src/IO.Ably.Tests.Shared/Utils/ReconnectionStrategyTest.cs new file mode 100644 index 000000000..556446afa --- /dev/null +++ b/src/IO.Ably.Tests.Shared/Utils/ReconnectionStrategyTest.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using FluentAssertions; +using IO.Ably.Shared.Utils; +using Xunit; + +namespace IO.Ably.Tests.Shared.Utils +{ + public class ReconnectionStrategyTest + { + [Fact] + [Trait("spec", "RTB1")] + public void ShouldCalculateRetryTimeoutsUsingBackOffAndJitter() + { + var retryAttempts = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var initialTimeoutValue = 15; + + var retryTimeouts = retryAttempts.Select(attempt => + ReconnectionStrategy.GetRetryTime(initialTimeoutValue, attempt)).ToList(); + + retryTimeouts.Distinct().Count().Should().Be(10); + retryTimeouts.FindAll(timeout => timeout >= 30).Should().BeEmpty(); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + retryTimeouts[0].Should().BeInRange(12, 15); + retryTimeouts[1].Should().BeInRange(16, 20); + retryTimeouts[2].Should().BeInRange(20, 25); + for (var i = 3; i < retryTimeouts.Count; i++) + { + retryTimeouts[i].Should().BeInRange(24, 30); + } + + void AssertBoundsUsingFormula(double retryTimeout, int attempt) + { + var (lowerBound, upperBound) = Bounds(attempt, initialTimeoutValue); + retryTimeout.Should().BeInRange(lowerBound, upperBound); + } + + for (var i = 0; i < retryTimeouts.Count; i++) + { + AssertBoundsUsingFormula(retryTimeouts[i], retryAttempts[i]); + } + } + + public static (double LowerBound, double UpperBound) Bounds(int retryAttempt, int initialTimeout) + { + var upperBound = Math.Min((retryAttempt + 2) / 3d, 2d) * initialTimeout; + var lowerBound = 0.8 * upperBound; + return (lowerBound, upperBound); + } + } +} diff --git a/unity-plugins-updater.cmd b/unity-plugins-updater.cmd index 842b0569d..0ba5b0548 100644 --- a/unity-plugins-updater.cmd +++ b/unity-plugins-updater.cmd @@ -1,6 +1,6 @@ @echo off if "%~1"=="" (echo "Provide latest version number like unity-plugins-updater.cmd 1.2.8") else ( - .\build.cmd Build.NetStandard + .\build.cmd Build.NetStandard -d UNITY_PACKAGE copy src\IO.Ably.NETStandard20\bin\Release\netstandard2.0\IO.Ably.dll unity\Assets\Ably\Plugins copy src\IO.Ably.NETStandard20\bin\Release\netstandard2.0\IO.Ably.pdb unity\Assets\Ably\Plugins copy src\IO.Ably.NETStandard20\bin\Release\netstandard2.0\IO.Ably.DeltaCodec.dll unity\Assets\Ably\Plugins diff --git a/unity-plugins-updater.sh b/unity-plugins-updater.sh index 2392fa6d8..ff2e06527 100755 --- a/unity-plugins-updater.sh +++ b/unity-plugins-updater.sh @@ -2,7 +2,7 @@ if [ $# -eq 0 ] then echo "Provide latest version number like unity-plugins-updater.sh 1.2.8" else - ./build.sh Build.NetStandard + ./build.sh Build.NetStandard -d UNITY_PACKAGE cp src/IO.Ably.NETStandard20/bin/Release/netstandard2.0/IO.Ably.dll unity/Assets/Ably/Plugins cp src/IO.Ably.NETStandard20/bin/Release/netstandard2.0/IO.Ably.pdb unity/Assets/Ably/Plugins cp src/IO.Ably.NETStandard20/bin/Release/netstandard2.0/IO.Ably.DeltaCodec.dll unity/Assets/Ably/Plugins diff --git a/unity/Assets/Ably/Plugins/IO.Ably.DeltaCodec.dll b/unity/Assets/Ably/Plugins/IO.Ably.DeltaCodec.dll index d1ce7a0f3..11062f1e4 100644 Binary files a/unity/Assets/Ably/Plugins/IO.Ably.DeltaCodec.dll and b/unity/Assets/Ably/Plugins/IO.Ably.DeltaCodec.dll differ diff --git a/unity/Assets/Ably/Plugins/IO.Ably.dll b/unity/Assets/Ably/Plugins/IO.Ably.dll index 7fd591869..34cb84625 100644 Binary files a/unity/Assets/Ably/Plugins/IO.Ably.dll and b/unity/Assets/Ably/Plugins/IO.Ably.dll differ diff --git a/unity/Assets/Ably/version.txt b/unity/Assets/Ably/version.txt index d4c5a7617..1d0051039 100644 --- a/unity/Assets/Ably/version.txt +++ b/unity/Assets/Ably/version.txt @@ -1 +1 @@ -1.2.11 +1.2.12 diff --git a/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs b/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs new file mode 100644 index 000000000..989fa3dc3 --- /dev/null +++ b/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Assets.Tests.AblySandbox +{ + public class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Action _sendAsyncAction; + private readonly Func _getResponse; + + public HttpRequestMessage LastRequest { get; private set; } + + public List Requests { get; } = new List(); + + public FakeHttpMessageHandler(HttpResponseMessage response, Action sendAsyncAction = null) + { + _getResponse = request => response; + _sendAsyncAction = sendAsyncAction; + } + + public FakeHttpMessageHandler(Func getResponse) + { + _getResponse = getResponse; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + NumberOfRequests++; + Requests.Add(request); + LastRequest = request; + var responseTask = Task.FromResult(_getResponse(request)); + _sendAsyncAction?.Invoke(); + return responseTask; + } + + public int NumberOfRequests { get; private set; } + } +} diff --git a/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs.meta b/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs.meta new file mode 100644 index 000000000..493492da2 --- /dev/null +++ b/unity/Assets/Tests/AblySandbox/FakeHttpMessageHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e43b6dfed00a9804397917bacdf55cfb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Tests/EditMode/AblyInterfaceSpecs.cs b/unity/Assets/Tests/EditMode/AblyInterfaceSpecs.cs index 708811ade..2af995283 100644 --- a/unity/Assets/Tests/EditMode/AblyInterfaceSpecs.cs +++ b/unity/Assets/Tests/EditMode/AblyInterfaceSpecs.cs @@ -1,8 +1,14 @@ +using System; using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; using Assets.Tests.AblySandbox; using Cysharp.Threading.Tasks; +using FluentAssertions; using IO.Ably; using IO.Ably.Realtime; using NUnit.Framework; @@ -205,6 +211,43 @@ public IEnumerator TestChannelPresence([ValueSource(nameof(_protocols))] Protoco }); } + [UnityTest] + public IEnumerator TestHttpUnityAgentHeader([ValueSource(nameof(_protocols))] Protocol protocol) + { + return UniTask.ToCoroutine(async () => + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent("Success") }; + var handler = new FakeHttpMessageHandler(response); + var client = new AblyHttpClient(new AblyHttpOptions(), handler); + + await client.Execute(new AblyRequest("/test", HttpMethod.Get)); + string[] values = handler.LastRequest.Headers.GetValues("Ably-Agent").ToArray(); + values.Should().HaveCount(1); + string[] agentValues = values[0].Split(' '); + + Agent.OsIdentifier().Should().StartWith("unity-"); + Agent.UnityPlayerIdentifier().Should().StartWith("unity/"); + + var keys = new List() + { + "ably-dotnet/", + Agent.DotnetRuntimeIdentifier(), + Agent.UnityPlayerIdentifier(), + Agent.OsIdentifier() + }; + + Agent.DotnetRuntimeIdentifier().Split('/').Length.Should().Be(2); + + keys.RemoveAll(s => s.IsEmpty()); + + agentValues.Should().HaveCount(keys.Count); + for (var i = 0; i < keys.Count; ++i) + { + agentValues[i].StartsWith(keys[i]).Should().BeTrue($"'{agentValues[i]}' should start with '{keys[i]}'"); + } + }); + } + private static void AssertResultOk(Result result) { Assert.True(result.IsSuccess); @@ -212,4 +255,4 @@ private static void AssertResultOk(Result result) Assert.Null(result.Error); } } -} \ No newline at end of file +} diff --git a/unity/Assets/Tests/PlayMode/AblyInterfaceSpecs.cs b/unity/Assets/Tests/PlayMode/AblyInterfaceSpecs.cs index ec81c7d49..7b9e08815 100644 --- a/unity/Assets/Tests/PlayMode/AblyInterfaceSpecs.cs +++ b/unity/Assets/Tests/PlayMode/AblyInterfaceSpecs.cs @@ -1,8 +1,11 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Net; using Assets.Tests.AblySandbox; using Cysharp.Threading.Tasks; +using FluentAssertions; using IO.Ably; using IO.Ably.Realtime; using NUnit.Framework; @@ -205,6 +208,43 @@ public IEnumerator TestChannelPresence([ValueSource(nameof(_protocols))] Protoco }); } + [UnityTest] + public IEnumerator TestHttpUnityAgentHeader([ValueSource(nameof(_protocols))] Protocol protocol) + { + return UniTask.ToCoroutine(async () => + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent("Success") }; + var handler = new FakeHttpMessageHandler(response); + var client = new AblyHttpClient(new AblyHttpOptions(), handler); + + await client.Execute(new AblyRequest("/test", HttpMethod.Get)); + string[] values = handler.LastRequest.Headers.GetValues("Ably-Agent").ToArray(); + values.Should().HaveCount(1); + string[] agentValues = values[0].Split(' '); + + Agent.OsIdentifier().Should().StartWith("unity-"); + Agent.UnityPlayerIdentifier().Should().StartWith("unity/"); + + var keys = new List() + { + "ably-dotnet/", + Agent.DotnetRuntimeIdentifier(), + Agent.UnityPlayerIdentifier(), + Agent.OsIdentifier() + }; + + Agent.DotnetRuntimeIdentifier().Split('/').Length.Should().Be(2); + + keys.RemoveAll(s => s.IsEmpty()); + + agentValues.Should().HaveCount(keys.Count); + for (var i = 0; i < keys.Count; ++i) + { + agentValues[i].StartsWith(keys[i]).Should().BeTrue($"'{agentValues[i]}' should start with '{keys[i]}'"); + } + }); + } + private static void AssertResultOk(Result result) { Assert.True(result.IsSuccess);