From 029bad159ef07451f2a80daa3c7dc7ef2daa7879 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 17 Apr 2023 19:24:17 +0200 Subject: [PATCH] Lib refactoring (#18) * editorconfig and codeformatting * rewrite * docs * throttle-event-args * another check to not reconnect while disconnecting/closing * naming * docs * changelog * reusable TaskHelper * docs * docs * downgrade from version 7 to version 5 of Microsoft.Extensions.Logging.Abstractions * downgrade from net7.0 to net5.0 * fix from integration-test * upgrade to net6 * reduced some overheaad, as mentioned/suggested by Bukk94 * ChangeLog * whispers are obsolete * ChangeLog * ChangeLog: Consideration/Proposal * fixing reconnectionpolicy-issue * fixing reconnectionpolicy-issue * ThrottlingPeriod * get MessageType-values outside while-loop the values wont change at runtime * logging * ToString() * moved and added tests * tests * usings * naming * diagnostic messages * suppress diagnostic messages * OnData has never been used * docs * todo removed, cause it got unnecessary * checked * naming * use build variable * remove * remove * remove * formatting * visibility * removed OnReconnectedEventArgs OnConnectedEventArgs is used, cause what happens is indicated by the EventHandler that is called * update version from 1.0.6 to 1.1.0 * push up ThrottlerService to TwitchClient and update version to 2.2.0 PubSub has its own PING/PONG-Timers PubSub only subscribes to Events PubSub only receives push-messages from twitch * WaitOneDuration * return-value for throttlerservice re-introduced... * changelog * assertion * remove whisper stuff * started changelog * rename method SendIRC to SpecificClientSend changed visibility from internal to protected comments updated * Send can be and is called by several methods so send has to be synchronized/locked * changelog * missed the important thing - sry * removed: event EventHandler OnStateChanged * removed comments locking is done in ABaseClient.Send() * give MonitorTaskAction a chance to catch cancellation * Addressed PR comments, renamed properties based on guidelines * Formatted code, updated logic for ConnectionWatchDog * Code formatting for unit tests * Added github workflow and improvements from Syzuna * Updated SSL port for TCP Client, added logging abstractions * Properly updated properties, improved naming, removed duplicate close on streams * Remove lingering option from TCP Client * Fixed code path for net6 and higher * Reworked NoReconnection policy, further code improvements * Removed broken test --------- Co-authored-by: CMR --- .github/workflows/check-buildstatus.yml | 9 +- .github/workflows/tests-linux.yml | 25 + .github/workflows/tests-windows.yml | 25 + .gitignore | 5 +- ChangeLog.md | 101 ++++ TwitchLib.Communication.sln | 6 +- .../Clients/ClientTestsBase.cs | 185 +++++++ .../Clients/TcpClientTests.cs | 10 + .../Clients/WebSocketClientTests.cs | 8 + .../Helpers/TestLogHelper.cs | 69 +++ .../Logs/TcpClient/.gitkeep | 0 .../Logs/WebSocketClient/.gitkeep | 0 .../Models/ReconnectionPolicyTests.cs | 40 ++ .../TcpClientTests.cs | 113 ----- .../TwitchLib.Communication.Tests.csproj | 15 +- .../WebSocketClientTests.cs | 113 ----- .../Clients/ClientBase.cs | 375 ++++++++++++++ .../Clients/TcpClient.cs | 454 +++++------------ .../Clients/WebsocketClient.cs | 478 ++++++------------ .../Enums/ClientType.cs | 2 +- .../Events/OnConnectedEventArgs.cs | 3 +- .../Events/OnDataEventArgs.cs | 9 - .../Events/OnDisconnectedEventArgs.cs | 3 +- .../Events/OnFatalErrorEventArgs.cs | 14 +- .../Events/OnMessageThrottledEventArgs.cs | 12 - .../Events/OnReconnectedEventArgs.cs | 7 - .../Events/OnStateChangedEventArgs.cs | 10 - .../Events/OnWhisperThrottledEventArgs.cs | 12 - .../Extensions/LogExtensions.cs | 53 ++ .../Helpers/TaskHelper.cs | 18 + .../Interfaces/IClient.cs | 138 +++-- .../Interfaces/IClientOptions.cs | 54 +- .../Models/ClientOptions.cs | 49 +- .../Models/NoReconnectionPolicy.cs | 15 + .../Models/ReconnectionPolicy.cs | 167 ++++-- .../Services/ConnectionWatchDog.cs | 122 +++++ .../Services/NetworkServices.cs | 54 ++ .../Services/Throttlers.cs | 203 -------- .../TwitchLib.Communication.csproj | 47 +- 39 files changed, 1663 insertions(+), 1360 deletions(-) create mode 100644 .github/workflows/tests-linux.yml create mode 100644 .github/workflows/tests-windows.yml create mode 100644 ChangeLog.md create mode 100644 src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs create mode 100644 src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs create mode 100644 src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs create mode 100644 src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs create mode 100644 src/TwitchLib.Communication.Tests/Logs/TcpClient/.gitkeep create mode 100644 src/TwitchLib.Communication.Tests/Logs/WebSocketClient/.gitkeep create mode 100644 src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs delete mode 100644 src/TwitchLib.Communication.Tests/TcpClientTests.cs delete mode 100644 src/TwitchLib.Communication.Tests/WebSocketClientTests.cs create mode 100644 src/TwitchLib.Communication/Clients/ClientBase.cs delete mode 100644 src/TwitchLib.Communication/Events/OnDataEventArgs.cs delete mode 100644 src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs delete mode 100644 src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs delete mode 100644 src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs delete mode 100644 src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs create mode 100644 src/TwitchLib.Communication/Extensions/LogExtensions.cs create mode 100644 src/TwitchLib.Communication/Helpers/TaskHelper.cs create mode 100644 src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs create mode 100644 src/TwitchLib.Communication/Services/ConnectionWatchDog.cs create mode 100644 src/TwitchLib.Communication/Services/NetworkServices.cs delete mode 100644 src/TwitchLib.Communication/Services/Throttlers.cs diff --git a/.github/workflows/check-buildstatus.yml b/.github/workflows/check-buildstatus.yml index 917a7c0..3ce8c7d 100644 --- a/.github/workflows/check-buildstatus.yml +++ b/.github/workflows/check-buildstatus.yml @@ -8,14 +8,19 @@ jobs: check-buildstatus: runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [ '6.0.x' ] steps: - uses: actions/checkout@v2 - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: ${{ matrix.dotnet-version }} - name: Restore dependencies run: dotnet restore - name: Build TwitchLib.Communication - run: dotnet build --no-restore + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal \ No newline at end of file diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml new file mode 100644 index 0000000..9c3768b --- /dev/null +++ b/.github/workflows/tests-linux.yml @@ -0,0 +1,25 @@ +name: Test TwitchLib.Communication Linux + +on: + [push] + +jobs: + tests: + + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [ '6.0.x' ] + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: ${{ matrix.dotnet-version }} + - name: Restore dependencies + run: dotnet restore + - name: Build TwitchLib.Communication + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml new file mode 100644 index 0000000..022ce06 --- /dev/null +++ b/.github/workflows/tests-windows.yml @@ -0,0 +1,25 @@ +name: Test TwitchLib.Communication Windows + +on: + [push] + +jobs: + tests: + + runs-on: windows-latest + strategy: + matrix: + dotnet-version: [ '6.0.x' ] + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: ${{ matrix.dotnet-version }} + - name: Restore dependencies + run: dotnet restore + - name: Build TwitchLib.Communication + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.gitignore b/.gitignore index 89ad0e0..cc7fed1 100644 --- a/.gitignore +++ b/.gitignore @@ -233,4 +233,7 @@ $RECYCLE.BIN/ *.msp # Windows shortcuts -*.lnk \ No newline at end of file +*.lnk + +# Rider files +.idea/* \ No newline at end of file diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100644 index 0000000..dcef01d --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,101 @@ +# Changelog + +## Version 2.0.0 +### Addresses +##### Issues +- https://github.com/TwitchLib/TwitchLib/issues/1093 +- https://github.com/TwitchLib/TwitchLib.Client/issues/206 +- https://github.com/TwitchLib/TwitchLib/issues/1104 +- https://github.com/TwitchLib/TwitchLib.Communication/issues/13 +- https://github.com/TwitchLib/TwitchLib.Communication/issues/7 + +##### Pull Requests +- none + +--- + +### Changes + +--- + +#### IClient +##### Changed +- now extends `IDisposable` +- `event EventHandler OnReconnected;` + - to `event EventHandler OnReconnected;` + - now the `event`handlers argument is `OnConnectedEventArgs` instead of `OnReconnectedEventArgs` + - the specific `event`handler itself, determines wether the args are in context of connect or reconnect +- `IClient.Send(string message)` is now synchronized because + - `ThrottlerService` got removed + - https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks +##### Added +- none +##### Removed +- see also: https://discuss.dev.twitch.tv/t/deprecation-of-chat-commands-through-irc/40486 + - `bool SendWhisper(string message);` + - `void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs);` +- `event EventHandler OnData;` + - as far as i got it right, + - binary data is not received + - it has never ever been used/raised +- `event EventHandler OnMessageThrottled;` + - because `ThrottlerService` is now part of `TwitchLib.Client` +- `event EventHandler OnStateChanged;` + - neither used by `TwitchLib.Client` nor by `TwitchLib.PubSub` +--- + +#### ClientOptions +##### Changed +- `value`s for properties can only be passed by `ctor` +- `ctor` also takes an argument for `ReconnectionPolicy` + - by leaving it `null`, a `default` `ReconnectionPolicy` is created, that attempts to reconnect every 3_000 milliseconds for ten times +- `DisconnectWait` became an unsigned integer (`uint`), to ensure only positive values are used for it +##### Removed +- see also: https://discuss.dev.twitch.tv/t/deprecation-of-chat-commands-through-irc/40486 + - `TimeSpan WhisperThrottlingPeriod { get; set; }` + - `int WhispersAllowedInPeriod { get; set; }` + - `int WhisperQueueCapacity { get; set; }` +##### Moved +- the following properties went to `TwitchLib.Client.Models.SendOptions` + - `int SendQueueCapacity { get; set; }` + - `TimeSpan SendCacheItemTimeout { get; set; }` + - `ushort SendDelay { get; set; }` + - `TimeSpan ThrottlingPeriod { get; set; }` + - `int MessagesAllowedInPeriod { get; set; }` + +--- + +#### ConnectionWatchDog +- now the `ConnectionWatchDog` enforces reconnect according to the `ReconnectionPolicy` +- `ConnectionWatchDog` does not send `PING :tmi.twitch.tv`-messages anymore + - `TwitchLib.Client` receives `PING :tmi.twitch.tv`-messages and has to reply with `PONG :tmi.twitch.tv` + - https://dev.twitch.tv/docs/irc/#keepalive-messages + - `TwitchLib.Client` does so + - it handles received PING-messages + - `TwitchLib.PubSub` has to send `PING :tmi.twitch.tv` within at least every five minutes + - https://dev.twitch.tv/docs/pubsub/#connection-management + - `TwitchLib.PubSub` does so + - it has its own PING- and PONG-Timer + +--- + +#### Throttling/ThrottlerService +- `TwitchLib.Communication.IClient` doesnt throttle messages anymore + - `TwitchLib.PubSub` does not need it + - only `TwitchLib.Client` needs it + - so, throttling went to `TwitchLib.Client.Services.ThrottlerService` in combination with `TwitchLib.Client.Services.Throttler` +- everything related to throttling got removed + - `TwitchLib.Communication.Events.OnMessageThrottledEventArgs` + - `TwitchLib.Communication.Interfaces.IClientOptions` + - see also [ClientOptions.Moved](#ClientOptions.Moved) + - `int SendQueueCapacity { get; set; }` + - `TimeSpan SendCacheItemTimeout { get; set; }` + - `ushort SendDelay { get; set; }` + - `TimeSpan ThrottlingPeriod { get; set; }` + - `int MessagesAllowedInPeriod { get; set; }` + +--- + +#### OnStateChangedEventArgs +- removed +- neither used by `TwitchLib.Client` nor by `TwitchLib.PubSub` \ No newline at end of file diff --git a/TwitchLib.Communication.sln b/TwitchLib.Communication.sln index 793ca03..a635a11 100644 --- a/TwitchLib.Communication.sln +++ b/TwitchLib.Communication.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27703.2035 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchLib.Communication", "src\TwitchLib.Communication\TwitchLib.Communication.csproj", "{5DBA3070-744D-45EF-84EA-D5ECF3C71CFE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchLib.Communication.Tests", "src\TwitchLib.Communication.Tests\TwitchLib.Communication.Tests.csproj", "{8945B40A-7E9A-423E-964F-E215C112DA56}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchLib.Communication.Tests", "src\TwitchLib.Communication.Tests\TwitchLib.Communication.Tests.csproj", "{8945B40A-7E9A-423E-964F-E215C112DA56}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs b/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs new file mode 100644 index 0000000..1fd81de --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs @@ -0,0 +1,185 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Interfaces; +using TwitchLib.Communication.Models; +using TwitchLib.Communication.Tests.Helpers; +using Xunit; + +namespace TwitchLib.Communication.Tests.Clients +{ + /// + /// bundles -Tests in one container + /// + /// + /// + /// + /// + /// + /// + /// + /// + public abstract class ClientTestsBase where T : IClient + { + private static uint WaitAfterDispose => 3; + private static TimeSpan WaitOneDuration => TimeSpan.FromSeconds(5); + private static IClientOptions Options; + + public ClientTestsBase(IClientOptions options = null) + { + Options = options; + } + + [Fact] + public void Client_Raises_OnConnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + ILogger logger = TestLogHelper.GetLogger(); + T? client = GetClient(logger, Options); + Assert.NotNull(client); + try + { + ManualResetEvent pauseConnected = new ManualResetEvent(false); + + Assert.Raises( + h => client.OnConnected += h, + h => client.OnConnected -= h, + () => + { + client.OnConnected += (sender, e) => pauseConnected.Set(); + client.Open(); + Assert.True(pauseConnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + Cleanup(client); + } + } + + [Fact] + public void Client_Raises_OnDisconnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + ILogger logger = TestLogHelper.GetLogger(); + T? client = GetClient(logger, Options); + Assert.NotNull(client); + try + { + ManualResetEvent pauseDisconnected = new ManualResetEvent(false); + + Assert.Raises( + h => client.OnDisconnected += h, + h => client.OnDisconnected -= h, + () => + { + client.OnConnected += (sender, e) => + { + Task.Delay(WaitOneDuration).GetAwaiter().GetResult(); + client.Close(); + }; + client.OnDisconnected += (sender, e) => pauseDisconnected.Set(); + client.Open(); + Assert.True(pauseDisconnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + Cleanup(client); + } + } + + [Fact] + public void Client_Raises_OnReconnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + ILogger logger = TestLogHelper.GetLogger(); + T? client = GetClient(logger, Options); + Assert.NotNull(client); + try + { + ManualResetEvent pauseReconnected = new ManualResetEvent(false); + + Assert.Raises( + h => client.OnReconnected += h, + h => client.OnReconnected -= h, + () => + { + client.OnConnected += (s, e) => client.Reconnect(); + + client.OnReconnected += (s, e) => pauseReconnected.Set(); + client.Open(); + + Assert.True(pauseReconnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + Cleanup(client); + } + } + + [Fact] + public void Dispose_Client_Before_Connecting_IsOK() + { + // create one logger per test-method! - cause one file per test-method is generated + ILogger logger = TestLogHelper.GetLogger(); + IClient? client = null; + try + { + client = GetClient(logger, Options); + Assert.NotNull(client); + client.Dispose(); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + Cleanup((T?)client); + } + } + + private static void Cleanup(T? client) + { + client?.Dispose(); + Task.Delay(TimeSpan.FromSeconds(WaitAfterDispose)).GetAwaiter().GetResult(); + } + + private static TClient? GetClient(ILogger logger, IClientOptions? options = null) + { + Type[] constructorParameterTypes = new Type[] + { + typeof(IClientOptions), + typeof(ILogger) + }; + ConstructorInfo? constructor = typeof(TClient).GetConstructor(constructorParameterTypes); + object[] constructorParameters = new object[] + { + options ?? new ClientOptions(), + logger + }; + return (TClient?)constructor?.Invoke(constructorParameters); + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs b/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs new file mode 100644 index 0000000..007f058 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs @@ -0,0 +1,10 @@ +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Models; + +namespace TwitchLib.Communication.Tests.Clients +{ + public class TcpClientTests : ClientTestsBase + { + public TcpClientTests() : base(new ClientOptions(useSsl: false)) { } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs b/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs new file mode 100644 index 0000000..fe9a507 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs @@ -0,0 +1,8 @@ +using TwitchLib.Communication.Clients; + +namespace TwitchLib.Communication.Tests.Clients +{ + public class WebSocketClientTests : ClientTestsBase + { + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs b/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs new file mode 100644 index 0000000..ce5f44a --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs @@ -0,0 +1,69 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Exceptions; + +namespace TwitchLib.Communication.Tests.Helpers +{ + internal static class TestLogHelper + { + private static readonly string OUTPUT_TEMPLATE = + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u}] {Message:lj}{NewLine}{Exception}{NewLine}"; + + private static readonly string NEW_TEST_RUN_INDICATOR; + + static TestLogHelper() + { + StringBuilder builder = new StringBuilder(); + builder.AppendLine(); + builder.AppendLine(new string('-', 80)); + builder.Append(new string(' ', 34)); + builder.AppendLine("new Test-Run"); + builder.AppendLine(new string('-', 80)); + NEW_TEST_RUN_INDICATOR = builder.ToString(); + } + + internal static Microsoft.Extensions.Logging.ILogger GetLogger( + LogEventLevel logEventLevel = LogEventLevel.Verbose, + [CallerMemberName] string callerMemberName = "TestMethod") + { + Serilog.ILogger logger = GetSerilogLogger(typeof(T).Name, + callerMemberName, + logEventLevel); + Microsoft.Extensions.Logging.ILoggerFactory loggerFactory = + new Serilog.Extensions.Logging.SerilogLoggerFactory(logger); + return loggerFactory.CreateLogger(); + } + + private static Serilog.ILogger GetSerilogLogger(string typeName, + string callerMemberName, + LogEventLevel logEventLevel) + { + Serilog.LoggerConfiguration loggerConfiguration = GetConfiguration(typeName, + callerMemberName, + logEventLevel); + Serilog.ILogger logger = loggerConfiguration.CreateLogger().ForContext(); + logger.Information(NEW_TEST_RUN_INDICATOR); + return logger; + } + + private static Serilog.LoggerConfiguration GetConfiguration(string typeName, + string callerMemberName, + LogEventLevel logEventLevel) + { + Serilog.LoggerConfiguration loggerConfiguration = new Serilog.LoggerConfiguration(); + loggerConfiguration.MinimumLevel.Verbose(); + string path = $"../../../Logs/{typeName}/{callerMemberName}.log"; + loggerConfiguration.WriteTo.File( + path: path, + restrictedToMinimumLevel: logEventLevel, + outputTemplate: OUTPUT_TEMPLATE + ); + loggerConfiguration.Enrich.WithExceptionDetails(); + loggerConfiguration.Enrich.FromLogContext(); + return loggerConfiguration; + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/Logs/TcpClient/.gitkeep b/src/TwitchLib.Communication.Tests/Logs/TcpClient/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/TwitchLib.Communication.Tests/Logs/WebSocketClient/.gitkeep b/src/TwitchLib.Communication.Tests/Logs/WebSocketClient/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs b/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs new file mode 100644 index 0000000..e67a286 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs @@ -0,0 +1,40 @@ +using System; +using TwitchLib.Communication.Models; +using Xunit; + +namespace TwitchLib.Communication.Tests.Models +{ + public class ReconnectionPolicyTests + { + /// + /// Checks + ///

+ /// + ///

+ /// + ///
+ [Fact] + public void ReconnectionPolicy_OmitReconnect() + { + try + { + ReconnectionPolicy reconnectionPolicy = new NoReconnectionPolicy(); + Assert.False(reconnectionPolicy.AreAttemptsComplete()); + reconnectionPolicy.ProcessValues(); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + // in case of a normal connect, we expect the ReconnectionPolicy to be reset + reconnectionPolicy.Reset(false); + Assert.False(reconnectionPolicy.AreAttemptsComplete()); + reconnectionPolicy.ProcessValues(); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + // in case of a reconnect, we expect the ReconnectionPolicy not to be reset + reconnectionPolicy.Reset(true); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + } + catch (Exception e) + { + Assert.Fail(e.ToString()); + } + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/TcpClientTests.cs b/src/TwitchLib.Communication.Tests/TcpClientTests.cs deleted file mode 100644 index dce736d..0000000 --- a/src/TwitchLib.Communication.Tests/TcpClientTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Models; -using Xunit; - -namespace TwitchLib.Communication.Tests -{ - public class TcpClientTests - { - [Fact] - public void Client_Raises_OnConnected_EventArgs() - { - - var client = new TcpClient(); - var pauseConnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnConnected += h, - h => client.OnConnected -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - client.Open(); - Assert.True(pauseConnected.WaitOne(5000)); - }); - } - - [Fact] - public void Client_Raises_OnDisconnected_EventArgs() - { - var client = new TcpClient(new ClientOptions() {DisconnectWait = 100}); - var pauseDisconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnDisconnected += h, - h => client.OnDisconnected -= h, - () => - { - client.OnConnected += async (sender, e) => - { - await Task.Delay(2000); - client.Close(); - }; - client.OnDisconnected += (sender, e) => - { - pauseDisconnected.Set(); - }; - client.Open(); - Assert.True(pauseDisconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Client_Raises_OnReconnected_EventArgs() - { - var client = new TcpClient(new ClientOptions(){ReconnectionPolicy = null}); - var pauseReconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnReconnected += h, - h => client.OnReconnected -= h, - () => - { - client.OnConnected += async (s, e) => - { - await Task.Delay(2000); - client.Reconnect(); - }; - - client.OnReconnected += (s, e) => { pauseReconnected.Set(); }; - client.Open(); - - Assert.True(pauseReconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Dispose_Client_Before_Connecting_IsOK() - { - var tcpClient = new TcpClient(); - tcpClient.Dispose(); - } - - [Fact] - public void Client_Can_SendAndReceive_Messages() - { - var client = new TcpClient(); - var pauseConnected = new ManualResetEvent(false); - var pauseReadMessage = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnMessage += h, - h => client.OnMessage -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - - client.OnMessage += (sender, e) => - { - pauseReadMessage.Set(); - Assert.Equal("PONG :tmi.twitch.tv", e.Message); - }; - - client.Open(); - client.Send("PING"); - Assert.True(pauseConnected.WaitOne(5000)); - Assert.True(pauseReadMessage.WaitOne(5000)); - }); - } - } -} diff --git a/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj b/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj index 859c2cc..baed66a 100644 --- a/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj +++ b/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj @@ -3,13 +3,21 @@ net6.0 false + enable + disable + + + + + + - all + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,4 +27,9 @@ + + + + + diff --git a/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs b/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs deleted file mode 100644 index b4736ba..0000000 --- a/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Models; -using Xunit; - -namespace TwitchLib.Communication.Tests -{ - public class WebSocketClientTests - { - [Fact] - public void Client_Raises_OnConnected_EventArgs() - { - var client = new WebSocketClient(); - var pauseConnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnConnected += h, - h => client.OnConnected -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - client.Open(); - Assert.True(pauseConnected.WaitOne(5000)); - }); - } - - [Fact] - public void Client_Raises_OnDisconnected_EventArgs() - { - var client = new WebSocketClient(new ClientOptions() {DisconnectWait = 5000}); - var pauseDisconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnDisconnected += h, - h => client.OnDisconnected -= h, - () => - { - client.OnConnected += async (sender, e) => - { - await Task.Delay(2000); - client.Close(); - }; - client.OnDisconnected += (sender, e) => - { - pauseDisconnected.Set(); - }; - client.Open(); - Assert.True(pauseDisconnected.WaitOne(200000)); - }); - } - - [Fact] - public void Client_Raises_OnReconnected_EventArgs() - { - var client = new WebSocketClient(new ClientOptions(){ReconnectionPolicy = null}); - var pauseReconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnReconnected += h, - h => client.OnReconnected -= h, - () => - { - client.OnConnected += async (s, e) => - { - await Task.Delay(2000); - client.Reconnect(); - }; - - client.OnReconnected += (s, e) => { pauseReconnected.Set(); }; - client.Open(); - - Assert.True(pauseReconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Dispose_Client_Before_Connecting_IsOK() - { - var client = new WebSocketClient(); - client.Dispose(); - } - - - [Fact] - public void Client_Can_SendAndReceive_Messages() - { - var client = new WebSocketClient(); - var pauseConnected = new ManualResetEvent(false); - var pauseReadMessage = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnMessage += h, - h => client.OnMessage -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - - client.OnMessage += (sender, e) => - { - pauseReadMessage.Set(); - Assert.Equal("PONG :tmi.twitch.tv", e.Message); - }; - - client.Open(); - client.Send("PING"); - Assert.True(pauseConnected.WaitOne(5000)); - Assert.True(pauseReadMessage.WaitOne(5000)); - }); - } - } -} diff --git a/src/TwitchLib.Communication/Clients/ClientBase.cs b/src/TwitchLib.Communication/Clients/ClientBase.cs new file mode 100644 index 0000000..6b75074 --- /dev/null +++ b/src/TwitchLib.Communication/Clients/ClientBase.cs @@ -0,0 +1,375 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; +using TwitchLib.Communication.Interfaces; +using TwitchLib.Communication.Models; +using TwitchLib.Communication.Services; + +namespace TwitchLib.Communication.Clients +{ + /// + /// This bundles almost everything that and have in common + /// to be able to + /// + /// + /// pass instances of this to and + /// + /// + /// and to access Methods of this instance within and + /// + /// + /// + public abstract class ClientBase : IClient where T : IDisposable + { + private static readonly object Lock = new object(); + private readonly NetworkServices _networkServices; + private CancellationTokenSource _cancellationTokenSource; + + /// + /// This is used for + /// whenever a call to is made + /// + internal CancellationToken Token => _cancellationTokenSource.Token; + + internal static TimeSpan TimeOutEstablishConnection => TimeSpan.FromSeconds(15); + + protected ILogger Logger { get; } + + protected abstract string Url { get; } + + /// + /// The underlying client. + /// + public T Client { get; private set; } + + public abstract bool IsConnected { get; } + + public IClientOptions Options { get; } + + public event EventHandler OnConnected; + public event EventHandler OnDisconnected; + public event EventHandler OnError; + public event EventHandler OnFatality; + public event EventHandler OnMessage; + public event EventHandler OnSendFailed; + public event EventHandler OnReconnected; + + internal ClientBase( + IClientOptions options = null, + ILogger logger = null) + { + Logger = logger; + _cancellationTokenSource = new CancellationTokenSource(); + Options = options ?? new ClientOptions(); + _networkServices = new NetworkServices(this, logger); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + private void RaiseSendFailed(OnSendFailedEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + OnSendFailed?.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal void RaiseError(OnErrorEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + OnError?.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + private void RaiseReconnected() + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + OnReconnected?.Invoke(this, new OnConnectedEventArgs()); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal void RaiseMessage(OnMessageEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + OnMessage?.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal void RaiseFatal(Exception e = null) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + var onFatalErrorEventArgs = new OnFatalErrorEventArgs("Fatal network error."); + if (e != null) + { + onFatalErrorEventArgs = new OnFatalErrorEventArgs(e); + } + + OnFatality?.Invoke(this, onFatalErrorEventArgs); + } + + private void RaiseDisconnected() + { + Logger?.TraceMethodCall(GetType()); + OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); + } + + private void RaiseConnected() + { + Logger?.TraceMethodCall(GetType()); + OnConnected?.Invoke(this, new OnConnectedEventArgs()); + } + + public bool Send(string message) + { + Logger?.TraceMethodCall(GetType()); + try + { + lock (Lock) + { + ClientSend(message); + return true; + } + } + catch (Exception e) + { + RaiseSendFailed(new OnSendFailedEventArgs() { Exception = e, Data = message }); + return false; + } + } + + public bool Open() + { + Logger?.TraceMethodCall(GetType()); + return OpenPrivate(false); + } + + public void Close() + { + Logger?.TraceMethodCall(GetType()); + + // Network services has to be stopped first so that it wont reconnect + _networkServices.Stop(); + + // ClosePrivate() also handles IClientOptions.DisconnectWait + ClosePrivate(); + } + + /// + /// + /// + public void Dispose() + { + Logger?.TraceMethodCall(GetType()); + Close(); + GC.SuppressFinalize(this); + } + + public bool Reconnect() + { + Logger?.TraceMethodCall(GetType()); + + // Stops everything (including NetworkServices) + if (IsConnected) + { + Close(); + } + + // interface IClient doesnt declare a return value for Reconnect() + // so we can suppress IDE0058 of ReconnectInternal() + return ReconnectInternal(); + } + + private bool OpenPrivate(bool isReconnect) + { + Logger?.TraceMethodCall(GetType()); + try + { + if (Token.IsCancellationRequested) + { + return false; + } + + if (IsConnected) + { + return true; + } + + // Always create new client when opening new connection + Client = CreateClient(); + + var first = true; + Options.ReconnectionPolicy.Reset(isReconnect); + while (!IsConnected + && !Options.ReconnectionPolicy.AreAttemptsComplete()) + { + Logger?.TraceAction(GetType(), "try to connect"); + if (!first) + { + Task.Delay(Options.ReconnectionPolicy.GetReconnectInterval(), CancellationToken.None) + .GetAwaiter().GetResult(); + } + + ConnectClient(); + Options.ReconnectionPolicy.ProcessValues(); + first = false; + } + + if (!IsConnected) + { + Logger?.TraceAction(GetType(), "Client couldn't establish a connection"); + RaiseFatal(); + return false; + } + + Logger?.TraceAction(GetType(), "Client established a connection"); + _networkServices.Start(); + if (!isReconnect) + { + RaiseConnected(); + } + + return true; + } + catch (Exception ex) + { + Logger?.LogExceptionAsError(GetType(), ex); + RaiseError(new OnErrorEventArgs { Exception = ex }); + RaiseFatal(); + return false; + } + } + + /// + /// Stops + /// by calling + ///

+ /// and enforces the + ///

+ /// afterwards it waits for the via given amount of milliseconds + ///

+ ///

+ /// will keep running, + /// because itself issued this call by calling + ///
+ private void ClosePrivate() + { + Logger?.TraceMethodCall(GetType()); + + // This cancellation traverse up to NetworkServices.ListenTask + _cancellationTokenSource.Cancel(); + Logger?.TraceAction(GetType(), + $"{nameof(_cancellationTokenSource)}.{nameof(_cancellationTokenSource.Cancel)} is called"); + + CloseClient(); + RaiseDisconnected(); + _cancellationTokenSource = new CancellationTokenSource(); + + Task.Delay(TimeSpan.FromMilliseconds(Options.DisconnectWait), CancellationToken.None) + .GetAwaiter().GetResult(); + } + + /// + /// Send method for the client. + /// + /// + /// Message to be send + /// + protected abstract void ClientSend(string message); + + /// + /// Instantiate the underlying client. + /// + protected abstract T CreateClient(); + + /// + /// one of the following specific methods + /// + /// + /// + /// + /// + /// + /// + /// + /// calls to one of the methods mentioned above, + /// also Dispose() the respective client, + /// so no additional Dispose() is needed + /// + protected abstract void CloseClient(); + + /// + /// Connect the client. + /// + protected abstract void ConnectClient(); + + /// + /// To issue a reconnect + ///

+ /// especially for the + ///

+ /// it stops all but ! + ///

+ ///

+ /// see also : + ///

+ /// + ///
+ /// + /// if a connection could be established, otherwise + /// + internal bool ReconnectInternal() + { + Logger?.TraceMethodCall(GetType()); + ClosePrivate(); + var reconnected = OpenPrivate(true); + if (reconnected) + { + RaiseReconnected(); + } + + return reconnected; + } + + /// + /// just the Action that listens for new Messages + /// the corresponding is held by + /// + internal abstract void ListenTaskAction(); + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Clients/TcpClient.cs b/src/TwitchLib.Communication/Clients/TcpClient.cs index 3f39532..7467280 100644 --- a/src/TwitchLib.Communication/Clients/TcpClient.cs +++ b/src/TwitchLib.Communication/Clients/TcpClient.cs @@ -1,393 +1,179 @@ using System; using System.IO; -using System.Linq; using System.Net.Security; -using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; using TwitchLib.Communication.Interfaces; -using TwitchLib.Communication.Models; -using TwitchLib.Communication.Services; namespace TwitchLib.Communication.Clients { - public class TcpClient : IClient + public class TcpClient : ClientBase { - private int NotConnectedCounter; - public TimeSpan DefaultKeepAliveInterval { get; set; } - public int SendQueueLength => _throttlers.SendQueue.Count; - public int WhisperQueueLength => _throttlers.WhisperQueue.Count; - public bool IsConnected => Client?.Connected ?? false; - public IClientOptions Options { get; } + protected override string Url => "irc.chat.twitch.tv"; - public event EventHandler OnConnected; - public event EventHandler OnData; - public event EventHandler OnDisconnected; - public event EventHandler OnError; - public event EventHandler OnFatality; - public event EventHandler OnMessage; - public event EventHandler OnMessageThrottled; - public event EventHandler OnWhisperThrottled; - public event EventHandler OnSendFailed; - public event EventHandler OnStateChanged; - public event EventHandler OnReconnected; + private int Port => Options.UseSsl ? 6697 : 6667; + private StreamReader Reader { get; set; } + private StreamWriter Writer { get; set; } - private readonly string _server = "irc.chat.twitch.tv"; - private int Port => Options != null ? Options.UseSsl ? 443 : 80 : 0; - public System.Net.Sockets.TcpClient Client { get; private set; } - private StreamReader _reader; - private StreamWriter _writer; - private readonly Throttlers _throttlers; - private CancellationTokenSource _tokenSource = new CancellationTokenSource(); - private bool _stopServices; - private bool _networkServicesRunning; - private Task[] _networkTasks; - private Task _monitorTask; + public override bool IsConnected => Client?.Connected ?? false; - public TcpClient(IClientOptions options = null) + public TcpClient( + IClientOptions options = null, + ILogger logger = null) + : base(options, logger) { - Options = options ?? new ClientOptions(); - _throttlers = - new Throttlers(this, Options.ThrottlingPeriod, Options.WhisperThrottlingPeriod) - { - TokenSource = _tokenSource - }; - InitializeClient(); } - private void InitializeClient() + internal override void ListenTaskAction() { - // check if services should stop - if (_stopServices) { return; } - - Client = new System.Net.Sockets.TcpClient(); - - if (_monitorTask == null) + Logger?.TraceMethodCall(GetType()); + if (Reader == null) { - _monitorTask = StartMonitorTask(); - return; + Exception ex = new InvalidOperationException($"{nameof(Reader)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + RaiseFatal(ex); + throw ex; } - if (_monitorTask.IsCompleted) _monitorTask = StartMonitorTask(); - } - - public bool Open() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Open() - return _Open(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private bool _Open() - { - // check if services should stop - if (_stopServices) { return false; } - - try + while (IsConnected) { - if (IsConnected) return true; - - Task.Run(() => { - InitializeClient(); - Client.Connect(_server, Port); - if (Options.UseSsl) - { - var ssl = new SslStream(Client.GetStream(), false); - ssl.AuthenticateAsClient(_server); - _reader = new StreamReader(ssl); - _writer = new StreamWriter(ssl); - } - else + try + { + var input = Reader.ReadLine(); + if (input is null) { - _reader = new StreamReader(Client.GetStream()); - _writer = new StreamWriter(Client.GetStream()); + continue; } - }).Wait(10000); - - if (!IsConnected) return _Open(); - - StartNetworkServices(); - return true; - - } - catch (Exception) - { - InitializeClient(); - return false; - } - } - - public void Close(bool callDisconnect = true) - { - _reader?.Dispose(); - _writer?.Dispose(); - Client?.Close(); - _stopServices = callDisconnect; - CleanupServices(); - InitializeClient(); - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } - - public void Reconnect() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Reconnect() - _Reconnect(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private void _Reconnect() - { - // check if services should stop - if (_stopServices) { return; } - - Task.Run(() => - { - Task.Delay(20).Wait(); - Close(); - if(Open()) + RaiseMessage(new OnMessageEventArgs { Message = input }); + } + catch (Exception ex) when (ex.GetType() == typeof(TaskCanceledException) || + ex.GetType() == typeof(OperationCanceledException)) { - OnReconnected?.Invoke(this, new OnReconnectedEventArgs()); + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); } - }); - } - - public bool Send(string message) - { - try - { - if (!IsConnected || SendQueueLength >= Options.SendQueueCapacity) + catch (Exception ex) { - return false; + Logger?.LogExceptionAsError(GetType(), ex); + RaiseError(new OnErrorEventArgs { Exception = ex }); + break; } - - _throttlers.SendQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - throw; } } - public bool SendWhisper(string message) + protected override void ClientSend(string message) { - try - { - if (!IsConnected || WhisperQueueLength >= Options.WhisperQueueCapacity) - { - return false; - } + Logger?.TraceMethodCall(GetType()); - _throttlers.WhisperQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; - } - catch (Exception ex) + // this is not thread safe + // this method should only be called from 'ClientBase.Send()' + // where its call gets synchronized/locked + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks + if (Writer == null) { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - throw; + Exception ex = new InvalidOperationException($"{nameof(Writer)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + RaiseFatal(ex); + throw ex; } - } - private void StartNetworkServices() - { - _networkServicesRunning = true; - _networkTasks = new[] - { - StartListenerTask(), - _throttlers.StartSenderTask(), - _throttlers.StartWhisperSenderTask() - }.ToArray(); - - if (!_networkTasks.Any(c => c.IsFaulted)) return; - _networkServicesRunning = false; - CleanupServices(); + Writer.WriteLine(message); + Writer.Flush(); } - public Task SendAsync(string message) + protected override void ConnectClient() { - return Task.Run(async () => + Logger?.TraceMethodCall(GetType()); + if (Client == null) { - await _writer.WriteLineAsync(message); - await _writer.FlushAsync(); - }); - } + Exception ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } - private Task StartListenerTask() - { - return Task.Run(async () => + try { - while (IsConnected && _networkServicesRunning) + // https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios +#if NET6_0_OR_GREATER + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following answer + // NET6_0_OR_GREATER: https://stackoverflow.com/a/68998339 + + var connectTask = Client.ConnectAsync(Url, + Port); + var waitTask = connectTask.WaitAsync(TimeOutEstablishConnection, + Token); + Task.WhenAny(connectTask, waitTask).GetAwaiter().GetResult(); +#else + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following two answers: + // https://stackoverflow.com/a/11191070 + // https://stackoverflow.com/a/22078975 + + using (var delayTaskCancellationTokenSource = new System.Threading.CancellationTokenSource()) { - try - { - var input = await _reader.ReadLineAsync(); - - if (input is null && IsConnected) - { - Send("PING"); - Task.Delay(500).Wait(); - } - - OnMessage?.Invoke(this, new OnMessageEventArgs {Message = input}); - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - } + var connectTask = Client.ConnectAsync(Url, Port); + var delayTask = Task.Delay((int)TimeOutEstablishConnection.TotalMilliseconds, + delayTaskCancellationTokenSource.Token); + + Task.WhenAny(connectTask, delayTask).GetAwaiter().GetResult(); + delayTaskCancellationTokenSource?.Cancel(); } - }); - } - - private Task StartMonitorTask() - { - return Task.Run(() => - { - var needsReconnect = false; - var checkConnectedCounter = 0; - try +#endif + if (!Client.Connected) { - var lastState = IsConnected; - while (!_tokenSource.IsCancellationRequested) - { - if (lastState == IsConnected) - { - Thread.Sleep(200); - - if (!IsConnected) - NotConnectedCounter++; - else - checkConnectedCounter++; - - if (checkConnectedCounter >= 300) //Check every 60s for Response - { - Send("PING"); - checkConnectedCounter = 0; - } - - switch (NotConnectedCounter) - { - case 25: //Try Reconnect after 5s - case 75: //Try Reconnect after extra 10s - case 150: //Try Reconnect after extra 15s - case 300: //Try Reconnect after extra 30s - case 600: //Try Reconnect after extra 60s - _Reconnect(); - break; - default: - { - if (NotConnectedCounter >= 1200 && NotConnectedCounter % 600 == 0) //Try Reconnect after every 120s from this point - _Reconnect(); - break; - } - } - - if (NotConnectedCounter != 0 && IsConnected) - NotConnectedCounter = 0; - - continue; - } - OnStateChanged?.Invoke(this, new OnStateChangedEventArgs { IsConnected = IsConnected, WasConnected = lastState }); - - if (IsConnected) - OnConnected?.Invoke(this, new OnConnectedEventArgs()); - - if (!IsConnected && !_stopServices) - { - if (lastState && Options.ReconnectionPolicy != null && !Options.ReconnectionPolicy.AreAttemptsComplete()) - { - needsReconnect = true; - break; - } - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } - - lastState = IsConnected; - } + Logger?.TraceAction(GetType(), "Client couldn't establish connection"); + return; } - catch (Exception ex) + + Logger?.TraceAction(GetType(), "Client established connection successfully"); + if (Options.UseSsl) { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); + SslStream ssl = new SslStream(Client.GetStream(), false); + ssl.AuthenticateAsClient(Url); + Reader = new StreamReader(ssl); + Writer = new StreamWriter(ssl); } - - if (needsReconnect && !_stopServices) - _Reconnect(); - }, _tokenSource.Token); - } - - private void CleanupServices() - { - _tokenSource.Cancel(); - _tokenSource = new CancellationTokenSource(); - _throttlers.TokenSource = _tokenSource; - - if (!_stopServices) return; - if (!(_networkTasks?.Length > 0)) return; - if (Task.WaitAll(_networkTasks, 15000)) return; - - OnFatality?.Invoke(this, - new OnFatalErrorEventArgs + else { - Reason = "Fatal network error. Network services fail to shut down." - }); - - // moved to Reset() - //_stopServices = false; - //_throttlers.Reconnecting = false; - //_networkServicesRunning = false; - } - - private void Reset() - { - _stopServices = false; - _throttlers.Reconnecting = false; - _networkServicesRunning = false; - } - - public void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs) - { - OnWhisperThrottled?.Invoke(this, eventArgs); - } - - public void MessageThrottled(OnMessageThrottledEventArgs eventArgs) - { - OnMessageThrottled?.Invoke(this, eventArgs); + Reader = new StreamReader(Client.GetStream()); + Writer = new StreamWriter(Client.GetStream()); + } + } + catch (Exception ex) when (ex.GetType() == typeof(TaskCanceledException) || + ex.GetType() == typeof(OperationCanceledException)) + { + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); + } + catch (Exception ex) + { + Logger?.LogExceptionAsError(GetType(), ex); + } } - public void SendFailed(OnSendFailedEventArgs eventArgs) + protected override System.Net.Sockets.TcpClient CreateClient() { - OnSendFailed?.Invoke(this, eventArgs); - } + Logger?.TraceMethodCall(GetType()); - public void Error(OnErrorEventArgs eventArgs) - { - OnError?.Invoke(this, eventArgs); + return new System.Net.Sockets.TcpClient + { + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.tcpclient.lingerstate?view=netstandard-2.0#remarks + LingerState = new System.Net.Sockets.LingerOption(true, 0) + }; } - public void Dispose() + protected override void CloseClient() { - Close(); - _throttlers.ShouldDispose = true; - _tokenSource.Cancel(); - Thread.Sleep(500); - _tokenSource.Dispose(); + Logger?.TraceMethodCall(GetType()); + Reader?.Dispose(); + Writer?.Dispose(); Client?.Dispose(); - GC.Collect(); } } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Clients/WebsocketClient.cs b/src/TwitchLib.Communication/Clients/WebsocketClient.cs index 93c5777..d88dd90 100644 --- a/src/TwitchLib.Communication/Clients/WebsocketClient.cs +++ b/src/TwitchLib.Communication/Clients/WebsocketClient.cs @@ -1,51 +1,27 @@ using System; -using System.Linq; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using TwitchLib.Communication.Enums; using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; using TwitchLib.Communication.Interfaces; -using TwitchLib.Communication.Models; -using TwitchLib.Communication.Services; namespace TwitchLib.Communication.Clients { - public class WebSocketClient : IClient + public class WebSocketClient : ClientBase { - private int NotConnectedCounter; - public TimeSpan DefaultKeepAliveInterval { get; set; } - public int SendQueueLength => _throttlers.SendQueue.Count; - public int WhisperQueueLength => _throttlers.WhisperQueue.Count; - public bool IsConnected => Client?.State == WebSocketState.Open; - public IClientOptions Options { get; } - public ClientWebSocket Client { get; private set; } + protected override string Url { get; } - public event EventHandler OnConnected; - public event EventHandler OnData; - public event EventHandler OnDisconnected; - public event EventHandler OnError; - public event EventHandler OnFatality; - public event EventHandler OnMessage; - public event EventHandler OnMessageThrottled; - public event EventHandler OnWhisperThrottled; - public event EventHandler OnSendFailed; - public event EventHandler OnStateChanged; - public event EventHandler OnReconnected; + public override bool IsConnected => Client?.State == WebSocketState.Open; - private string Url { get; } - private readonly Throttlers _throttlers; - private CancellationTokenSource _tokenSource = new CancellationTokenSource(); - private bool _stopServices; - private bool _networkServicesRunning; - private Task[] _networkTasks; - private Task _monitorTask; - - public WebSocketClient(IClientOptions options = null) + public WebSocketClient( + IClientOptions options = null, + ILogger logger = null) + : base(options, logger) { - Options = options ?? new ClientOptions(); - switch (Options.ClientType) { case ClientType.Chat: @@ -55,348 +31,176 @@ public WebSocketClient(IClientOptions options = null) Url = Options.UseSsl ? "wss://pubsub-edge.twitch.tv:443" : "ws://pubsub-edge.twitch.tv:80"; break; default: - throw new ArgumentOutOfRangeException(); + Exception ex = new ArgumentOutOfRangeException(nameof(Options.ClientType)); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; } - - _throttlers = new Throttlers(this, Options.ThrottlingPeriod, Options.WhisperThrottlingPeriod) { TokenSource = _tokenSource }; } - private void InitializeClient() + internal override void ListenTaskAction() { - // check if services should stop - if (_stopServices) { return; } - - Client?.Abort(); - Client = new ClientWebSocket(); - - if (_monitorTask == null) + Logger?.TraceMethodCall(GetType()); + if (Client == null) { - _monitorTask = StartMonitorTask(); - return; + Exception ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + RaiseFatal(ex); + throw ex; } - if (_monitorTask.IsCompleted) _monitorTask = StartMonitorTask(); - } - public bool Open() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Open() - return _Open(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private bool _Open() - { - // check if services should stop - if (_stopServices) { return false; } - - try - { - if (IsConnected) return true; - - InitializeClient(); - Client.ConnectAsync(new Uri(Url), _tokenSource.Token).Wait(10000); - if (!IsConnected) return _Open(); - - StartNetworkServices(); - return true; - } - catch (WebSocketException) + var message = ""; + while (IsConnected) { - InitializeClient(); - return false; - } - } - - public void Close(bool callDisconnect = true) - { - Client?.Abort(); - _stopServices = callDisconnect; - CleanupServices(); - - if (!callDisconnect) - InitializeClient(); - - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } - - public void Reconnect() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Reconnect() - _Reconnect(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private void _Reconnect() - { - // check if services should stop - if (_stopServices) { return; } - - Task.Run(() => - { - Task.Delay(20).Wait(); - Close(); - if(Open()) + WebSocketReceiveResult result; + var buffer = new byte[1024]; + try { - OnReconnected?.Invoke(this, new OnReconnectedEventArgs()); + result = Client.ReceiveAsync(new ArraySegment(buffer), Token).GetAwaiter().GetResult(); + if (result == null) + { + continue; + } } - }); - } - - public bool Send(string message) - { - try - { - if (!IsConnected || SendQueueLength >= Options.SendQueueCapacity) + catch (Exception ex) when (ex.GetType() == typeof(TaskCanceledException) || + ex.GetType() == typeof(OperationCanceledException)) { - return false; + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); + break; } - - _throttlers.SendQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); - throw; - } - } - - public bool SendWhisper(string message) - { - try - { - if (!IsConnected || WhisperQueueLength >= Options.WhisperQueueCapacity) + catch (Exception ex) { - return false; + Logger?.LogExceptionAsError(GetType(), ex); + RaiseError(new OnErrorEventArgs { Exception = ex }); + break; } - _throttlers.WhisperQueue.Add(new Tuple(DateTime.UtcNow, message)); + switch (result.MessageType) + { + case WebSocketMessageType.Close: + Close(); + break; + case WebSocketMessageType.Text when !result.EndOfMessage: + message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); - return true; - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); - throw; - } - } - - private void StartNetworkServices() - { - _networkServicesRunning = true; - _networkTasks = new[] - { - StartListenerTask(), - _throttlers.StartSenderTask(), - _throttlers.StartWhisperSenderTask() - }.ToArray(); + // continue while, to receive more message-parts + continue; - if (!_networkTasks.Any(c => c.IsFaulted)) return; - _networkServicesRunning = false; - CleanupServices(); - } + case WebSocketMessageType.Text: + message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); + RaiseMessage(new OnMessageEventArgs() { Message = message }); + break; + case WebSocketMessageType.Binary: + break; + default: + Exception ex = new ArgumentOutOfRangeException(); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } - public Task SendAsync(byte[] message) - { - return Client.SendAsync(new ArraySegment(message), WebSocketMessageType.Text, true, _tokenSource.Token); + // clear/reset message + message = ""; + } } - private Task StartListenerTask() + protected override void ClientSend(string message) { - return Task.Run(async () => - { - var message = ""; + Logger?.TraceMethodCall(GetType()); - while (IsConnected && _networkServicesRunning) - { - WebSocketReceiveResult result; - var buffer = new byte[1024]; + // this is not thread safe + // this method should only be called from 'ClientBase.Send()' + // where its call gets synchronized/locked + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks - try - { - result = await Client.ReceiveAsync(new ArraySegment(buffer), _tokenSource.Token); - } - catch - { - InitializeClient(); - break; - } - - if (result == null) continue; + // https://stackoverflow.com/a/59619916 + // links from within this thread: + // the 4th point: https://www.codetinkerer.com/2018/06/05/aspnet-core-websockets.html + // https://github.com/dotnet/corefx/blob/d6b11250b5113664dd3701c25bdf9addfacae9cc/src/Common/src/System/Net/WebSockets/ManagedWebSocket.cs#L22-L28 + if (Client == null) + { + Exception ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + RaiseFatal(ex); + throw ex; + } - switch (result.MessageType) - { - case WebSocketMessageType.Close: - Close(); - break; - case WebSocketMessageType.Text when !result.EndOfMessage: - message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); - continue; - case WebSocketMessageType.Text: - message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); - OnMessage?.Invoke(this, new OnMessageEventArgs(){Message = message}); - break; - case WebSocketMessageType.Binary: - break; - default: - throw new ArgumentOutOfRangeException(); - } - - message = ""; - } - }); + var bytes = Encoding.UTF8.GetBytes(message); + var sendTask = Client.SendAsync(new ArraySegment(bytes), + WebSocketMessageType.Text, + true, + Token); + sendTask.GetAwaiter().GetResult(); } - private Task StartMonitorTask() + protected override void ConnectClient() { - return Task.Run(() => + Logger?.TraceMethodCall(GetType()); + if (Client == null) { - var needsReconnect = false; - var checkConnectedCounter = 0; - try - { - var lastState = IsConnected; - while (!_tokenSource.IsCancellationRequested) - { - if (lastState == IsConnected) - { - Thread.Sleep(200); - - if (!IsConnected) - NotConnectedCounter++; - else - checkConnectedCounter++; - - if (checkConnectedCounter >= 300) //Check every 60s for Response - { - Send("PING"); - checkConnectedCounter = 0; - } - - switch (NotConnectedCounter) - { - case 25: //Try Reconnect after 5s - case 75: //Try Reconnect after extra 10s - case 150: //Try Reconnect after extra 15s - case 300: //Try Reconnect after extra 30s - case 600: //Try Reconnect after extra 60s - _Reconnect(); - break; - default: - { - if (NotConnectedCounter >= 1200 && NotConnectedCounter % 600 == 0) //Try Reconnect after every 120s from this point - _Reconnect(); - break; - } - } - - if (NotConnectedCounter != 0 && IsConnected) - NotConnectedCounter = 0; - - continue; - } - OnStateChanged?.Invoke(this, new OnStateChangedEventArgs { IsConnected = Client.State == WebSocketState.Open, WasConnected = lastState}); - - if (IsConnected) - OnConnected?.Invoke(this, new OnConnectedEventArgs()); - - if (!IsConnected && !_stopServices) - { - if (lastState && Options.ReconnectionPolicy != null && !Options.ReconnectionPolicy.AreAttemptsComplete()) - { - needsReconnect = true; - break; - } - - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - if (Client.CloseStatus != null && Client.CloseStatus != WebSocketCloseStatus.NormalClosure) - OnError?.Invoke(this, new OnErrorEventArgs { Exception = new Exception(Client.CloseStatus + " " + Client.CloseStatusDescription) }); - } + Exception ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + RaiseFatal(ex); + throw ex; + } - lastState = IsConnected; - } - } - catch (Exception ex) + try + { + // https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios +#if NET6_0_OR_GREATER + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following answer + // NET6_0_OR_GREATER: https://stackoverflow.com/a/68998339 + var connectTask = Client.ConnectAsync(new Uri(Url), Token); + var waitTask = connectTask.WaitAsync(TimeOutEstablishConnection, Token); + // GetAwaiter().GetResult() to avoid async in method-signature 'protected override void SpecificClientConnect()'; + Task.WhenAny(connectTask, waitTask).GetAwaiter().GetResult(); +#else + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following two answers: + // https://stackoverflow.com/a/11191070 + // https://stackoverflow.com/a/22078975 + + using (var delayTaskCancellationTokenSource = new CancellationTokenSource()) { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); + var connectTask = Client.ConnectAsync(new Uri(Url), + Token); + var delayTask = Task.Delay((int)TimeOutEstablishConnection.TotalMilliseconds, + delayTaskCancellationTokenSource.Token); + + Task.WhenAny(connectTask, delayTask).GetAwaiter().GetResult(); + delayTaskCancellationTokenSource.Cancel(); } - - if (needsReconnect && !_stopServices) - _Reconnect(); - }, _tokenSource.Token); - } - - private void CleanupServices() - { - _tokenSource.Cancel(); - _tokenSource = new CancellationTokenSource(); - _throttlers.TokenSource = _tokenSource; - - if (!_stopServices) return; - if (!(_networkTasks?.Length > 0)) return; - if (Task.WaitAll(_networkTasks, 15000)) return; - - OnFatality?.Invoke(this, - new OnFatalErrorEventArgs +#endif + if (!IsConnected) { - Reason = "Fatal network error. Network services fail to shut down." - }); - - // moved to Reset() - //_stopServices = false; - //_throttlers.Reconnecting = false; - //_networkServicesRunning = false; - } - - private void Reset() - { - this._stopServices = false; - this._throttlers.Reconnecting = false; - this._networkServicesRunning = false; - } - - public void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs) - { - OnWhisperThrottled?.Invoke(this, eventArgs); - } - - public void MessageThrottled(OnMessageThrottledEventArgs eventArgs) - { - OnMessageThrottled?.Invoke(this, eventArgs); - } - - public void SendFailed(OnSendFailedEventArgs eventArgs) - { - OnSendFailed?.Invoke(this, eventArgs); + Logger?.TraceAction(GetType(), "Client couldn't establish connection"); + } + } + catch (Exception ex) when (ex.GetType() == typeof(TaskCanceledException) || + ex.GetType() == typeof(OperationCanceledException)) + { + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); + } + catch (Exception ex) + { + Logger?.LogExceptionAsError(GetType(), ex); + } } - public void Error(OnErrorEventArgs eventArgs) + protected override ClientWebSocket CreateClient() { - OnError?.Invoke(this, eventArgs); + Logger?.TraceMethodCall(GetType()); + return new ClientWebSocket(); } - public void Dispose() + protected override void CloseClient() { - Close(); - _throttlers.ShouldDispose = true; - _tokenSource.Cancel(); - Thread.Sleep(500); - _tokenSource.Dispose(); + Logger?.TraceMethodCall(GetType()); + Client?.Abort(); Client?.Dispose(); - GC.Collect(); } } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Enums/ClientType.cs b/src/TwitchLib.Communication/Enums/ClientType.cs index 706cdcc..dee4b24 100644 --- a/src/TwitchLib.Communication/Enums/ClientType.cs +++ b/src/TwitchLib.Communication/Enums/ClientType.cs @@ -5,4 +5,4 @@ public enum ClientType Chat, PubSub } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs index dc1e053..7d96d52 100644 --- a/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs @@ -2,6 +2,5 @@ namespace TwitchLib.Communication.Events { - public class OnConnectedEventArgs : EventArgs - { } + public class OnConnectedEventArgs : EventArgs { } } diff --git a/src/TwitchLib.Communication/Events/OnDataEventArgs.cs b/src/TwitchLib.Communication/Events/OnDataEventArgs.cs deleted file mode 100644 index a470734..0000000 --- a/src/TwitchLib.Communication/Events/OnDataEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnDataEventArgs : EventArgs - { - public byte[] Data; - } -} diff --git a/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs index 9beb085..da3b830 100644 --- a/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs @@ -2,6 +2,5 @@ namespace TwitchLib.Communication.Events { - public class OnDisconnectedEventArgs : EventArgs - { } + public class OnDisconnectedEventArgs : EventArgs { } } diff --git a/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs b/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs index f473d1d..4f51fd8 100644 --- a/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs @@ -4,6 +4,16 @@ namespace TwitchLib.Communication.Events { public class OnFatalErrorEventArgs : EventArgs { - public string Reason; + public string Reason { get; } + + public OnFatalErrorEventArgs(string reason) + { + Reason = reason; + } + + public OnFatalErrorEventArgs(Exception e) + { + Reason = e.ToString(); + } } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs b/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs deleted file mode 100644 index ce7e692..0000000 --- a/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnMessageThrottledEventArgs : EventArgs - { - public string Message { get; set; } - public int SentMessageCount { get; set; } - public TimeSpan Period { get; set; } - public int AllowedInPeriod { get; set; } - } -} diff --git a/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs deleted file mode 100644 index 89a496c..0000000 --- a/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnReconnectedEventArgs : EventArgs - { } -} diff --git a/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs b/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs deleted file mode 100644 index f3bf3f1..0000000 --- a/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnStateChangedEventArgs : EventArgs - { - public bool IsConnected; - public bool WasConnected; - } -} diff --git a/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs b/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs deleted file mode 100644 index 4c985de..0000000 --- a/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnWhisperThrottledEventArgs : EventArgs - { - public string Message { get; set; } - public int SentWhisperCount { get; set; } - public TimeSpan Period { get; set; } - public int AllowedInPeriod { get; set; } - } -} diff --git a/src/TwitchLib.Communication/Extensions/LogExtensions.cs b/src/TwitchLib.Communication/Extensions/LogExtensions.cs new file mode 100644 index 0000000..997a1a0 --- /dev/null +++ b/src/TwitchLib.Communication/Extensions/LogExtensions.cs @@ -0,0 +1,53 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace TwitchLib.Communication.Extensions +{ + /// + /// expensive Extensions of the + /// + internal static class LogExtensions + { + public static void TraceMethodCall(this ILogger logger, + Type type, + [CallerMemberName] string callerMemberName = "", + [CallerLineNumber] int callerLineNumber = 0) + { + // because of the code-formatting, 2 line is subtracted from the callerLineNumber + // cant be done inline! + callerLineNumber -= 2; + logger?.LogTrace("{FullName}.{callerMemberName} at line {callerLineNumber} is called", + type.FullName, callerMemberName, callerLineNumber); + } + public static void LogExceptionAsError(this ILogger logger, + Type type, + Exception exception, + [CallerMemberName] string callerMemberName = "", + [CallerLineNumber] int callerLineNumber = 0) + { + logger?.LogError(exception, + "Exception in {FullName}.{callerMemberName} at line {callerLineNumber}:", + type.FullName, callerMemberName, callerLineNumber); + } + public static void LogExceptionAsInformation(this ILogger logger, + Type type, + Exception exception, + [CallerMemberName] string callerMemberName = "", + [CallerLineNumber] int callerLineNumber = 0) + { + logger?.LogInformation(exception, + "Exception in {FullName}.{callerMemberName} at line {callerLineNumber}:", + type.FullName, callerMemberName, callerLineNumber); + } + public static void TraceAction(this ILogger logger, + Type type, + string action, + [CallerMemberName] string callerMemberName = "", + [CallerLineNumber] int callerLineNumber = 0) + { + logger?.LogTrace("{FullName}.{callerMemberName} at line {callerLineNumber}: {action}", + type.FullName, callerMemberName, callerLineNumber, action); + } + } +} diff --git a/src/TwitchLib.Communication/Helpers/TaskHelper.cs b/src/TwitchLib.Communication/Helpers/TaskHelper.cs new file mode 100644 index 0000000..015a48c --- /dev/null +++ b/src/TwitchLib.Communication/Helpers/TaskHelper.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace TwitchLib.Communication.Helpers +{ + internal static class TaskHelper + { + internal static bool IsTaskRunning(this Task task) + { + return task != null + && !task.IsFaulted + && !task.IsCompleted +#if NET + && !task.IsCompletedSuccessfully +#endif + && !task.IsCanceled; + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Interfaces/IClient.cs b/src/TwitchLib.Communication/Interfaces/IClient.cs index 691be31..da77570 100644 --- a/src/TwitchLib.Communication/Interfaces/IClient.cs +++ b/src/TwitchLib.Communication/Interfaces/IClient.cs @@ -3,127 +3,103 @@ namespace TwitchLib.Communication.Interfaces { - public interface IClient + public interface IClient : IDisposable { /// - /// Keep alive period for the Connection. Not needed in TCP. - /// - TimeSpan DefaultKeepAliveInterval { get; set; } - - /// - /// The current number of items waiting to be sent. - /// - int SendQueueLength { get; } - - /// - /// The current number of Whispers waiting to be sent. - /// - int WhisperQueueLength { get; } - - /// - /// The current state of the connection. + /// The current state of the connection. /// bool IsConnected { get; } /// - /// Client Configuration Options + /// Client Configuration Options /// - IClientOptions Options {get;} + IClientOptions Options { get; } /// - /// Fires when the Client has connected + /// Fires when the Client has connected /// event EventHandler OnConnected; - - /// - /// Fires when Data (ByteArray) is received. - /// - event EventHandler OnData; /// - /// Fires when the Client disconnects + /// Fires when the Client disconnects /// event EventHandler OnDisconnected; /// - /// Fires when An Exception Occurs in the client + /// Fires when An Exception Occurs in the client /// event EventHandler OnError; /// - /// Fires when a Fatal Error Occurs. + /// Fires when a Fatal Error Occurs. /// event EventHandler OnFatality; /// - /// Fires when a Message/ group of messages is received. + /// Fires when a Message/ group of messages is received. /// event EventHandler OnMessage; /// - /// Fires when a Message has been throttled. - /// - event EventHandler OnMessageThrottled; - - /// - /// Fires when a Whisper has been throttled. - /// - event EventHandler OnWhisperThrottled; - - /// - /// Fires when a message Send event failed. + /// Fires when a message Send event failed. /// event EventHandler OnSendFailed; /// - /// Fires when the connection state changes + /// Fires when the client reconnects automatically /// - event EventHandler OnStateChanged; + event EventHandler OnReconnected; /// - /// Fires when the client reconnects automatically + /// tries to connect to twitch according to ! /// - event EventHandler OnReconnected; - - /// - /// Disconnect the Client from the Server - /// Set disconnect called in the client. Used in test cases. (default true) - /// - void Close(bool callDisconnect = true); - - /// - /// Dispose the Client. Forces the Send Queue to be destroyed, resulting in Message Loss. - /// - void Dispose(); - - /// - /// Connect the Client to the requested Url. - /// - /// Returns True if Connected, False if Failed to Connect. + /// + /// if a connection could be established, otherwise + /// bool Open(); /// - /// Queue a Message to Send to the server as a String. - /// - /// The Message To Queue - /// Returns True if was successfully queued. False if it fails. + /// if the underlying Client is connected, + ///

+ /// is invoked + ///

+ /// before it makes a call to and + ///

+ ///

+ /// this Method is also used by 'TwitchLib.Client.TwitchClient' + ///

+ /// whenever it receives a Reconnect-Message + ///

+ ///

+ /// so, if the twitch-servers want us to reconnect, + ///

+ /// we have to close the connection and establish a new ones + ///

+ ///

+ /// can also be used for a manual reconnect + /// + /// + /// , if the client reconnected; otherwise + /// + bool Reconnect(); + + /// + /// stops everything + /// and waits for the via given amount of milliseconds + /// + void Close(); + + /// + /// sends the given irc- + /// + /// + /// irc-message to send + /// + /// + /// , if the message should be sent + ///

+ /// otherwise + ///
bool Send(string message); - - /// - /// Queue a Whisper to Send to the server as a String. - /// - /// The Whisper To Queue - /// Returns True if was successfully queued. False if it fails. - bool SendWhisper(string message); - - /// - /// Manually reconnects the client. - /// - void Reconnect(); - - void MessageThrottled(OnMessageThrottledEventArgs eventArgs); - void SendFailed(OnSendFailedEventArgs eventArgs); - void Error(OnErrorEventArgs eventArgs); - void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs); } } \ No newline at end of file diff --git a/src/TwitchLib.Communication/Interfaces/IClientOptions.cs b/src/TwitchLib.Communication/Interfaces/IClientOptions.cs index 20d373f..fc8a049 100644 --- a/src/TwitchLib.Communication/Interfaces/IClientOptions.cs +++ b/src/TwitchLib.Communication/Interfaces/IClientOptions.cs @@ -1,5 +1,4 @@ -using System; -using TwitchLib.Communication.Enums; +using TwitchLib.Communication.Enums; using TwitchLib.Communication.Models; namespace TwitchLib.Communication.Interfaces @@ -9,63 +8,22 @@ public interface IClientOptions /// /// Type of the Client to Create. Possible Types Chat or PubSub. /// - ClientType ClientType { get; set; } + ClientType ClientType { get; } /// - /// How long to wait on a clean disconnect [in ms] (default 20000ms). + /// How long to wait on a clean disconnect [in ms] (default 1_500ms). /// - int DisconnectWait { get; set; } - - /// - /// Number of Messages Allowed Per Instance of the Throttling Period. (default 100) - /// - int MessagesAllowedInPeriod { get; set; } + uint DisconnectWait { get; } /// /// Reconnection Policy Settings. Reconnect without Losing data etc. /// The Default Policy applied is 10 reconnection attempts with 3 seconds between each attempt. /// - ReconnectionPolicy ReconnectionPolicy { get; set; } - - /// - /// The amount of time an object can wait to be sent before it is considered dead, and should be skipped (default 30 minutes). - /// A dead item will be ignored and removed from the send queue when it is hit. - /// - TimeSpan SendCacheItemTimeout { get; set; } - - /// - /// Minimum time between sending items from the queue [in ms] (default 50ms). - /// - ushort SendDelay { get; set; } - - /// - /// Maximum number of Queued outgoing messages (default 10000). - /// - int SendQueueCapacity { get; set; } - - /// - /// Period Between each reset of the throttling instance window. (default 30s) - /// - TimeSpan ThrottlingPeriod { get; set; } + ReconnectionPolicy ReconnectionPolicy { get; } /// /// Use Secure Connection [SSL] (default: true) /// - bool UseSsl { get; set; } - - /// - /// Period Between each reset of the whisper throttling instance window. (default 60s) - /// - TimeSpan WhisperThrottlingPeriod { get; set; } - - /// - /// Number of Whispers Allowed to be sent Per Instance of the Throttling Period. (default 100) - /// - int WhispersAllowedInPeriod { get; set; } - - /// - /// Maximum number of Queued outgoing Whispers (default 10000). - /// - int WhisperQueueCapacity { get; set; } + bool UseSsl { get; } } } \ No newline at end of file diff --git a/src/TwitchLib.Communication/Models/ClientOptions.cs b/src/TwitchLib.Communication/Models/ClientOptions.cs index 6ad91f7..8dd1e96 100644 --- a/src/TwitchLib.Communication/Models/ClientOptions.cs +++ b/src/TwitchLib.Communication/Models/ClientOptions.cs @@ -1,22 +1,41 @@ -using System; -using TwitchLib.Communication.Enums; +using TwitchLib.Communication.Enums; using TwitchLib.Communication.Interfaces; namespace TwitchLib.Communication.Models { public class ClientOptions : IClientOptions { - public int SendQueueCapacity { get; set; } = 10000; - public TimeSpan SendCacheItemTimeout { get; set; } = TimeSpan.FromMinutes(30); - public ushort SendDelay { get; set; } = 50; - public ReconnectionPolicy ReconnectionPolicy { get; set; } = new ReconnectionPolicy(3000, maxAttempts: 10); - public bool UseSsl { get; set; } = true; - public int DisconnectWait { get; set; } = 20000; - public ClientType ClientType { get; set; } = ClientType.Chat; - public TimeSpan ThrottlingPeriod { get; set; } = TimeSpan.FromSeconds(30); - public int MessagesAllowedInPeriod { get; set; } = 100; - public TimeSpan WhisperThrottlingPeriod { get; set; } = TimeSpan.FromSeconds(60); - public int WhispersAllowedInPeriod { get; set; } = 100; - public int WhisperQueueCapacity { get; set; } = 10000; + public ReconnectionPolicy ReconnectionPolicy { get; } + public bool UseSsl { get; } + public uint DisconnectWait { get; } + public ClientType ClientType { get; } + + /// + /// + /// + /// your own + ///

+ /// by leaving it , a , that makes every 3_000ms one attempt to connect for ten times, is going to be applied + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ClientOptions( + ReconnectionPolicy reconnectionPolicy = null, + bool useSsl = true, + uint disconnectWait = 1_500, + ClientType clientType = ClientType.Chat) + { + ReconnectionPolicy = reconnectionPolicy ?? new ReconnectionPolicy(3_000, maxAttempts: 10); + UseSsl = useSsl; + DisconnectWait = disconnectWait; + ClientType = clientType; + } } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs b/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs new file mode 100644 index 0000000..63e3bfd --- /dev/null +++ b/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs @@ -0,0 +1,15 @@ +namespace TwitchLib.Communication.Models +{ + /// + /// This policy should be used to omit reconnect-attempts. + /// + public class NoReconnectionPolicy : ReconnectionPolicy + { + public NoReconnectionPolicy() + : base( + reconnectInterval: 0, + maxAttempts: 1) + { + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs b/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs index 1041670..b81a5cd 100644 --- a/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs +++ b/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs @@ -1,42 +1,82 @@ namespace TwitchLib.Communication.Models { + /// + /// Connection/Reconnection-Policy + ///

+ ///

+ /// controls the attempts to make to connect and to reconnect to twitch + ///

+ ///

+ /// to omit reconnects and to only make one attempt to connect to twitch, please use + ///
public class ReconnectionPolicy { private readonly int _reconnectStepInterval; private readonly int? _initMaxAttempts; - private int _minReconnectInterval; + private int _currentReconnectInterval; private readonly int _maxReconnectInterval; private int? _maxAttempts; private int _attemptsMade; + /// + /// the or + /// infinitely + /// attempts to reconnect + ///

+ ///

+ /// with each attempt, the reconnect interval increases by 3_000 milliseconds + /// until it reaches 30_000 milliseconds + ///

+ /// + ///

+ ///

+ /// Example: + ///

+ /// try to connect -> couldn't connect -> wait 3_000 milliseconds -> try to connect -> couldn't connect -> wait 6_000 milliseconds -> and so on + ///
public ReconnectionPolicy() { - _reconnectStepInterval = 3000; - _minReconnectInterval = 3000; - _maxReconnectInterval = 30000; + _reconnectStepInterval = 3_000; + _currentReconnectInterval = _reconnectStepInterval; + _maxReconnectInterval = 30_000; _maxAttempts = null; _initMaxAttempts = null; _attemptsMade = 0; } - public void SetMaxAttempts(int attempts) - { - _maxAttempts = attempts; - } - - public void Reset() - { - _attemptsMade = 0; - _minReconnectInterval = _reconnectStepInterval; - _maxAttempts = _initMaxAttempts; - } - - public void SetAttemptsMade(int count) => _attemptsMade = count; - - public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval, int? maxAttempts) + /// + /// the or + /// attempts to reconnect for times + ///

+ ///

+ /// with each attempt, the reconnect interval increases by the amount of + /// until it reaches + ///

+ ///

+ /// Example: + ///

+ /// = 3_000 + ///

+ /// = 30_000 + ///

+ /// try to connect -> couldnt connect -> wait 3_000 milliseconds -> try to connect -> couldnt connect -> wait 6_000 milliseconds -> and so on + ///
+ /// + /// minimum interval in milliseconds + /// + /// + /// maximum interval in milliseconds + /// + /// + /// means infinite; it never stops to try to reconnect + /// + public ReconnectionPolicy( + int minReconnectInterval, + int maxReconnectInterval, + int maxAttempts) { _reconnectStepInterval = minReconnectInterval; - _minReconnectInterval = minReconnectInterval > maxReconnectInterval + _currentReconnectInterval = minReconnectInterval > maxReconnectInterval ? maxReconnectInterval : minReconnectInterval; _maxReconnectInterval = maxReconnectInterval; @@ -45,10 +85,36 @@ public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval, in _attemptsMade = 0; } - public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval) + /// + /// the or + /// infinitely + /// attempts to reconnect + ///

+ ///

+ /// with each attempt, the reconnect interval increases by the amount of + /// until it reaches + ///

+ ///

+ /// Example: + ///

+ /// = 3_000 + ///

+ /// = 30_000 + ///

+ /// try to connect -> couldn't connect -> wait 3_000 milliseconds -> try to connect -> couldn't connect -> wait 6_000 milliseconds -> and so on + ///
+ /// + /// minimum interval in milliseconds + /// + /// + /// maximum interval in milliseconds + /// + public ReconnectionPolicy( + int minReconnectInterval, + int maxReconnectInterval) { _reconnectStepInterval = minReconnectInterval; - _minReconnectInterval = minReconnectInterval > maxReconnectInterval + _currentReconnectInterval = minReconnectInterval > maxReconnectInterval ? maxReconnectInterval : minReconnectInterval; _maxReconnectInterval = maxReconnectInterval; @@ -57,37 +123,76 @@ public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval) _attemptsMade = 0; } + /// + /// the or + /// infinitely + /// attempts to reconnect every -milliseconds + /// + /// + /// Interval in milliseconds between trying to reconnect + /// public ReconnectionPolicy(int reconnectInterval) { _reconnectStepInterval = reconnectInterval; - _minReconnectInterval = reconnectInterval; + _currentReconnectInterval = reconnectInterval; _maxReconnectInterval = reconnectInterval; _maxAttempts = null; _initMaxAttempts = null; _attemptsMade = 0; } - public ReconnectionPolicy(int reconnectInterval, int? maxAttempts) + /// + /// the or + /// attempts to reconnect every -milliseconds for times + /// + /// + /// Interval in milliseconds between trying to reconnect + /// + /// + /// means infinite; it never stops to try to reconnect + /// + public ReconnectionPolicy( + int reconnectInterval, + int? maxAttempts) { _reconnectStepInterval = reconnectInterval; - _minReconnectInterval = reconnectInterval; + _currentReconnectInterval = reconnectInterval; _maxReconnectInterval = reconnectInterval; _maxAttempts = maxAttempts; _initMaxAttempts = maxAttempts; _attemptsMade = 0; } + internal void Reset(bool isReconnect) + { + if (isReconnect) return; + _attemptsMade = 0; + _currentReconnectInterval = _reconnectStepInterval; + _maxAttempts = _initMaxAttempts; + } + internal void ProcessValues() { _attemptsMade++; - if (_minReconnectInterval < _maxReconnectInterval) - _minReconnectInterval += _reconnectStepInterval; - if (_minReconnectInterval > _maxReconnectInterval) - _minReconnectInterval = _maxReconnectInterval; + if (_currentReconnectInterval < _maxReconnectInterval) + { + _currentReconnectInterval += _reconnectStepInterval; + } + + if (_currentReconnectInterval > _maxReconnectInterval) + { + _currentReconnectInterval = _maxReconnectInterval; + } } - public int GetReconnectInterval() => _minReconnectInterval; + public int GetReconnectInterval() + { + return _currentReconnectInterval; + } - public bool AreAttemptsComplete() => _attemptsMade == _maxAttempts; + public bool AreAttemptsComplete() + { + return _attemptsMade == _maxAttempts; + } } } \ No newline at end of file diff --git a/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs b/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs new file mode 100644 index 0000000..c2b8a9e --- /dev/null +++ b/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; + +namespace TwitchLib.Communication.Services +{ + /// + /// Service that checks connection state. + /// + internal class ConnectionWatchDog where T : IDisposable + { + private readonly ILogger _logger; + private readonly ClientBase _client; + + /// + /// + /// + /// should only be set to a new instance in + /// + /// + /// should only be set to in + /// + /// + /// + private CancellationTokenSource _cancellationTokenSource; + + private const int MonitorTaskDelayInMilliseconds = 200; + + internal ConnectionWatchDog( + ClientBase client, + ILogger logger = null) + { + _logger = logger; + _client = client; + } + + internal Task StartMonitorTask() + { + _logger?.TraceMethodCall(GetType()); + // We dont want to start more than one WatchDog + if (_cancellationTokenSource != null) + { + Exception ex = new InvalidOperationException("Monitor Task cant be started more than once!"); + _logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } + + // This should be the only place where a new instance of CancellationTokenSource is set + _cancellationTokenSource = new CancellationTokenSource(); + + return Task.Run(MonitorTaskAction, _cancellationTokenSource.Token); + } + + internal void Stop() + { + _logger?.TraceMethodCall(GetType()); + _cancellationTokenSource?.Cancel(); + // give MonitorTaskAction a chance to catch cancellation + // otherwise it may result in an Exception + Task.Delay(MonitorTaskDelayInMilliseconds * 2).GetAwaiter().GetResult(); + _cancellationTokenSource?.Dispose(); + // set it to null for the check within this.StartMonitorTask() + _cancellationTokenSource = null; + } + + private void MonitorTaskAction() + { + _logger?.TraceMethodCall(GetType()); + try + { + while (_cancellationTokenSource != null && !_cancellationTokenSource.Token.IsCancellationRequested) + { + // we expect the client is connected, + // when this monitor task starts + // cause BaseClient.Open() starts NetworkServices after a connection could be established + if (!_client.IsConnected) + { + _logger?.TraceAction(GetType(), "Client isn't connected anymore"); + // no call to close needed, + // ReconnectInternal() calls the correct Close-Method within the Client + // ReconnectInternal() makes attempts to reconnect according to the ReconnectionPolicy within the IClientOptions + _logger?.TraceAction(GetType(), "Try to reconnect"); + var connected = _client.ReconnectInternal(); + if (!connected) + { + _logger?.TraceAction(GetType(), "Client couldn't reconnect"); + // if the ReconnectionPolicy is set up to be finite + // and no connection could be established + // a call to Client.Close() is made + // that public Close() also shuts down this ConnectionWatchDog + _client.Close(); + break; + } + + _logger?.TraceAction(GetType(), "Client reconnected"); + } + + Task.Delay(MonitorTaskDelayInMilliseconds).GetAwaiter().GetResult(); + } + } + catch (Exception ex) when (ex.GetType() == typeof(TaskCanceledException) || + ex.GetType() == typeof(OperationCanceledException)) + { + // Occurs if the Tasks are canceled by the CancellationTokenSource.Token + _logger?.LogExceptionAsInformation(GetType(), ex); + } + catch (Exception ex) + { + _logger?.LogExceptionAsError(GetType(), ex); + _client.RaiseError(new OnErrorEventArgs { Exception = ex }); + _client.RaiseFatal(); + + // To ensure CancellationTokenSource is set to null again call Stop(); + Stop(); + } + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Services/NetworkServices.cs b/src/TwitchLib.Communication/Services/NetworkServices.cs new file mode 100644 index 0000000..db27298 --- /dev/null +++ b/src/TwitchLib.Communication/Services/NetworkServices.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Extensions; +using TwitchLib.Communication.Helpers; + +namespace TwitchLib.Communication.Services +{ + /// + /// to bundle Network-Service-s + /// + internal class NetworkServices where T : IDisposable + { + private Task _listenTask; + private Task _monitorTask; + private readonly ClientBase _client; + private readonly ILogger _logger; + private readonly ConnectionWatchDog _connectionWatchDog; + + private CancellationToken Token => _client.Token; + + internal NetworkServices( + ClientBase client, + ILogger logger = null) + { + _logger = logger; + _client = client; + _connectionWatchDog = new ConnectionWatchDog(_client, logger); + } + + internal void Start() + { + _logger?.TraceMethodCall(GetType()); + if (_monitorTask == null || !_monitorTask.IsTaskRunning()) + { + // this task is probably still running + // may be in case of a network connection loss + // all other Tasks haven't been started or have been canceled! + // ConnectionWatchDog is the only one, that has a seperate CancellationTokenSource! + _monitorTask = _connectionWatchDog.StartMonitorTask(); + } + + _listenTask = Task.Run(_client.ListenTaskAction, Token); + } + + internal void Stop() + { + _logger?.TraceMethodCall(GetType()); + _connectionWatchDog.Stop(); + } + } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Services/Throttlers.cs b/src/TwitchLib.Communication/Services/Throttlers.cs deleted file mode 100644 index 10a98b7..0000000 --- a/src/TwitchLib.Communication/Services/Throttlers.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Interfaces; - -namespace TwitchLib.Communication.Services -{ - public class Throttlers - { - public readonly BlockingCollection> SendQueue = - new BlockingCollection>(); - - public readonly BlockingCollection> WhisperQueue = - new BlockingCollection>(); - - public bool Reconnecting { get; set; } = false; - public bool ShouldDispose { get; set; } = false; - public CancellationTokenSource TokenSource { get; set; } - public bool ResetThrottlerRunning; - public bool ResetWhisperThrottlerRunning; - public int SentCount = 0; - public int WhispersSent = 0; - public Task ResetThrottler; - public Task ResetWhisperThrottler; - - private readonly TimeSpan _throttlingPeriod; - private readonly TimeSpan _whisperThrottlingPeriod; - private readonly IClient _client; - - public Throttlers(IClient client, TimeSpan throttlingPeriod, TimeSpan whisperThrottlingPeriod) - { - _throttlingPeriod = throttlingPeriod; - _whisperThrottlingPeriod = whisperThrottlingPeriod; - _client = client; - } - - public void StartThrottlingWindowReset() - { - ResetThrottler = Task.Run(async () => - { - ResetThrottlerRunning = true; - while (!ShouldDispose && !Reconnecting) - { - Interlocked.Exchange(ref SentCount, 0); - await Task.Delay(_throttlingPeriod, TokenSource.Token); - } - - ResetThrottlerRunning = false; - return Task.CompletedTask; - }); - } - - public void StartWhisperThrottlingWindowReset() - { - ResetWhisperThrottler = Task.Run(async () => - { - ResetWhisperThrottlerRunning = true; - while (!ShouldDispose && !Reconnecting) - { - Interlocked.Exchange(ref WhispersSent, 0); - await Task.Delay(_whisperThrottlingPeriod, TokenSource.Token); - } - - ResetWhisperThrottlerRunning = false; - return Task.CompletedTask; - }); - } - - public void IncrementSentCount() - { - Interlocked.Increment(ref SentCount); - } - - public void IncrementWhisperCount() - { - Interlocked.Increment(ref WhispersSent); - } - - public Task StartSenderTask() - { - StartThrottlingWindowReset(); - - return Task.Run(async () => - { - try - { - while (!ShouldDispose) - { - await Task.Delay(_client.Options.SendDelay); - - if (SentCount == _client.Options.MessagesAllowedInPeriod) - { - _client.MessageThrottled(new OnMessageThrottledEventArgs - { - Message = - "Message Throttle Occured. Too Many Messages within the period specified in WebsocketClientOptions.", - AllowedInPeriod = _client.Options.MessagesAllowedInPeriod, - Period = _client.Options.ThrottlingPeriod, - SentMessageCount = Interlocked.CompareExchange(ref SentCount, 0, 0) - }); - - continue; - } - - if (!_client.IsConnected || ShouldDispose) continue; - - var msg = SendQueue.Take(TokenSource.Token); - if (msg.Item1.Add(_client.Options.SendCacheItemTimeout) < DateTime.UtcNow) continue; - - try - { - switch (_client) - { - case WebSocketClient ws: - await ws.SendAsync(Encoding.UTF8.GetBytes(msg.Item2)); - break; - case TcpClient tcp: - await tcp.SendAsync(msg.Item2); - break; - } - - IncrementSentCount(); - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = msg.Item2, Exception = ex}); - break; - } - } - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = "", Exception = ex}); - _client.Error(new OnErrorEventArgs {Exception = ex}); - } - }); - } - - public Task StartWhisperSenderTask() - { - StartWhisperThrottlingWindowReset(); - - return Task.Run(async () => - { - try - { - while (!ShouldDispose) - { - await Task.Delay(_client.Options.SendDelay); - - if (WhispersSent == _client.Options.WhispersAllowedInPeriod) - { - _client.WhisperThrottled(new OnWhisperThrottledEventArgs() - { - Message = - "Whisper Throttle Occured. Too Many Whispers within the period specified in ClientOptions.", - AllowedInPeriod = _client.Options.WhispersAllowedInPeriod, - Period = _client.Options.WhisperThrottlingPeriod, - SentWhisperCount = Interlocked.CompareExchange(ref WhispersSent, 0, 0) - }); - - continue; - } - - if (!_client.IsConnected || ShouldDispose) continue; - - var msg = WhisperQueue.Take(TokenSource.Token); - if (msg.Item1.Add(_client.Options.SendCacheItemTimeout) < DateTime.UtcNow) continue; - - try - { - switch (_client) - { - case WebSocketClient ws: - await ws.SendAsync(Encoding.UTF8.GetBytes(msg.Item2)); - break; - case TcpClient tcp: - await tcp.SendAsync(msg.Item2); - break; - } - - IncrementWhisperCount(); - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = msg.Item2, Exception = ex}); - break; - } - } - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = "", Exception = ex}); - _client.Error(new OnErrorEventArgs {Exception = ex}); - } - }); - } - } -} diff --git a/src/TwitchLib.Communication/TwitchLib.Communication.csproj b/src/TwitchLib.Communication/TwitchLib.Communication.csproj index 6f7f1a9..25c76c5 100644 --- a/src/TwitchLib.Communication/TwitchLib.Communication.csproj +++ b/src/TwitchLib.Communication/TwitchLib.Communication.csproj @@ -1,24 +1,29 @@ - + - - netstandard2.0 - 1.0.6 - $(VersionSuffix) - swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin - swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin + + netstandard2.0 + 2.0.0 + $(VersionSuffix) + swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin + swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin Connection library used throughout TwitchLib to replace third party depedencies. - Copyright 2022 - https://opensource.org/licenses/MIT - https://github.com/TwitchLib/TwitchLib.Communication - https://cdn.syzuna-programs.de/images/twitchlib.png - https://github.com/TwitchLib/TwitchLib.Communication - Git - twitch twitchlib library irc chat c# csharp api events pubsub net standard 2.0 - Fix reconnect loop on disconnect - en-US - 1.0.6 - 1.0.6 - true - - + Copyright 2022 + https://opensource.org/licenses/MIT + https://github.com/TwitchLib/TwitchLib.Communication + https://cdn.syzuna-programs.de/images/twitchlib.png + https://github.com/TwitchLib/TwitchLib.Communication + Git + twitch twitchlib library irc chat c# csharp api events pubsub net standard 2.0 + Fix reconnect loop on disconnect + en-US + 2.0.0 + 2.0.0 + true + + + + + + +