From 64c1dc55e0da7e49096273d0b2c28946a32cca72 Mon Sep 17 00:00:00 2001 From: Anon Date: Sun, 19 Jul 2026 14:42:10 +0200 Subject: [PATCH] Fix Auto Relog reconnect lifecycle --- .../AutoRelogRetryPolicyTests.cs | 145 +++++++++ .../MinecraftClient.Tests.csproj | 22 ++ .../RestartCoordinatorTests.cs | 61 ++++ MinecraftClient.Tests/SocketWrapperTests.cs | 34 ++ MinecraftClient.Tests/Usings.cs | 1 + MinecraftClient.sln | 14 + MinecraftClient/ChatBots/AutoRelog.cs | 124 ++----- .../ChatBots/AutoRelogRetryPolicy.cs | 108 +++++++ MinecraftClient/ConsoleIO.cs | 19 ++ MinecraftClient/ConsoleInputRouter.cs | 187 +++++++++++ MinecraftClient/McClient.cs | 303 +++++++++--------- MinecraftClient/Program.cs | 283 ++++++++-------- MinecraftClient/Properties/AssemblyInfo.cs | 3 + .../Protocol/Handlers/Protocol18.cs | 15 +- .../Protocol/Handlers/SocketWrapper.cs | 13 +- .../ConfigComments/ConfigComments.Designer.cs | 6 +- .../ConfigComments/ConfigComments.resx | 6 +- MinecraftClient/RestartCoordinator.cs | 119 +++++++ docs/guide/chat-bots.md | 16 +- tools/testing/auto_relog_fault_proxy.py | 179 +++++++++++ 20 files changed, 1243 insertions(+), 415 deletions(-) create mode 100644 MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs create mode 100644 MinecraftClient.Tests/MinecraftClient.Tests.csproj create mode 100644 MinecraftClient.Tests/RestartCoordinatorTests.cs create mode 100644 MinecraftClient.Tests/SocketWrapperTests.cs create mode 100644 MinecraftClient.Tests/Usings.cs create mode 100644 MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs create mode 100644 MinecraftClient/ConsoleInputRouter.cs create mode 100644 MinecraftClient/RestartCoordinator.cs create mode 100755 tools/testing/auto_relog_fault_proxy.py diff --git a/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs b/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs new file mode 100644 index 00000000..18416d09 --- /dev/null +++ b/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs @@ -0,0 +1,145 @@ +using MinecraftClient.ChatBots; +using MinecraftClient.Scripting; + +namespace MinecraftClient.Tests; + +public sealed class AutoRelogRetryPolicyTests +{ + [Fact] + public void DefaultConfigurationUsesUnlimitedRetries() + { + Assert.Equal(-1, new AutoRelog.Configs().Retries); + } + + [Fact] + public void UnlimitedRetriesNeverExhaust() + { + var policy = new AutoRelogRetryPolicy(new ManualTimeProvider()); + + for (int attempt = 1; attempt <= 100; attempt++) + { + Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft)); + Assert.Equal(-1, retriesLeft); + Assert.Equal(attempt, policy.Attempts); + } + } + + [Fact] + public void ZeroRetriesDisablesReconnect() + { + var policy = new AutoRelogRetryPolicy(new ManualTimeProvider()); + + Assert.False(policy.TryReserveAttempt(0, out int retriesLeft)); + Assert.Equal(0, retriesLeft); + Assert.Equal(0, policy.Attempts); + } + + [Fact] + public void FiniteRetryLimitIsExact() + { + var policy = new AutoRelogRetryPolicy(new ManualTimeProvider()); + + Assert.True(policy.TryReserveAttempt(3, out int firstRetriesLeft)); + Assert.True(policy.TryReserveAttempt(3, out int secondRetriesLeft)); + Assert.True(policy.TryReserveAttempt(3, out int thirdRetriesLeft)); + Assert.False(policy.TryReserveAttempt(3, out int exhaustedRetriesLeft)); + + Assert.Equal(2, firstRetriesLeft); + Assert.Equal(1, secondRetriesLeft); + Assert.Equal(0, thirdRetriesLeft); + Assert.Equal(0, exhaustedRetriesLeft); + } + + [Fact] + public void RejectedRestartDoesNotConsumeRetry() + { + var policy = new AutoRelogRetryPolicy(new ManualTimeProvider()); + + Assert.True(policy.TryReserveAttempt(1, out _)); + policy.RollBackReservedAttempt(); + + Assert.True(policy.TryReserveAttempt(1, out int retriesLeft)); + Assert.Equal(0, retriesLeft); + } + + [Fact] + public void StableConnectionResetsRetryBudget() + { + var timeProvider = new ManualTimeProvider(); + var policy = new AutoRelogRetryPolicy(timeProvider); + Assert.True(policy.TryReserveAttempt(1, out _)); + + policy.MarkJoined(); + timeProvider.Advance(AutoRelogRetryPolicy.StableConnectionThreshold - TimeSpan.FromMilliseconds(1)); + Assert.False(policy.ResetAfterStableConnection()); + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + Assert.True(policy.ResetAfterStableConnection()); + Assert.Equal(0, policy.Attempts); + Assert.True(policy.TryReserveAttempt(1, out _)); + } + + [Fact] + public void TransportLossAlwaysReconnectsWhenEnabled() + { + bool reconnect = AutoRelogRetryPolicy.ShouldReconnect( + ChatBot.DisconnectReason.ConnectionLost, + "A transport-specific error without a configured phrase", + ignoreKickMessage: false, + ["Server is restarting"], + out string? matchedMessage); + + Assert.True(reconnect); + Assert.Null(matchedMessage); + } + + [Theory] + [InlineData(ChatBot.DisconnectReason.InGameKick)] + [InlineData(ChatBot.DisconnectReason.LoginRejected)] + public void ServerMessageMatchingIsCaseInsensitive(ChatBot.DisconnectReason reason) + { + bool reconnect = AutoRelogRetryPolicy.ShouldReconnect( + reason, + "THE SERVER IS RESTARTING NOW", + ignoreKickMessage: false, + ["server is restarting"], + out string? matchedMessage); + + Assert.True(reconnect); + Assert.Equal("server is restarting", matchedMessage); + } + + [Fact] + public void UserLogoutNeverReconnects() + { + Assert.False(AutoRelogRetryPolicy.ShouldReconnect( + ChatBot.DisconnectReason.UserLogout, + "Server is restarting", + ignoreKickMessage: true, + ["Server is restarting"], + out _)); + } + + [Fact] + public void IgnoreKickMessageAllowsNonmatchingServerKick() + { + Assert.True(AutoRelogRetryPolicy.ShouldReconnect( + ChatBot.DisconnectReason.InGameKick, + "Administrative removal", + ignoreKickMessage: true, + [], + out _)); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset utcNow = DateTimeOffset.UnixEpoch; + + public override DateTimeOffset GetUtcNow() => utcNow; + + internal void Advance(TimeSpan duration) + { + utcNow += duration; + } + } +} diff --git a/MinecraftClient.Tests/MinecraftClient.Tests.csproj b/MinecraftClient.Tests/MinecraftClient.Tests.csproj new file mode 100644 index 00000000..84440e31 --- /dev/null +++ b/MinecraftClient.Tests/MinecraftClient.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/MinecraftClient.Tests/RestartCoordinatorTests.cs b/MinecraftClient.Tests/RestartCoordinatorTests.cs new file mode 100644 index 00000000..41e7d81b --- /dev/null +++ b/MinecraftClient.Tests/RestartCoordinatorTests.cs @@ -0,0 +1,61 @@ +namespace MinecraftClient.Tests; + +public sealed class RestartCoordinatorTests +{ + [Fact] + public async Task CoalescesSameAttemptAndQueuesNewerAttempt() + { + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var coordinator = new RestartCoordinator( + async (request, cancellationToken) => + { + if (request.ConnectionAttempt == 10) + { + firstStarted.SetResult(); + await releaseFirst.Task.WaitAsync(cancellationToken); + } + else if (request.ConnectionAttempt == 11) + { + secondCompleted.SetResult(); + } + }, + exception => throw new Xunit.Sdk.XunitException(exception.ToString())); + + Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true))); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true))); + Assert.True(coordinator.TrySchedule(new RestartRequest(11, TimeSpan.Zero, true))); + Assert.True(coordinator.HasScheduledRestart(11)); + + releaseFirst.SetResult(); + await secondCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void RejectsStaleAttempt() + { + using var coordinator = new RestartCoordinator( + (_, _) => Task.CompletedTask, + exception => throw new Xunit.Sdk.XunitException(exception.ToString())); + + Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true))); + Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true))); + } + + [Fact] + public void TerminalStopRejectsFurtherRestarts() + { + using var coordinator = new RestartCoordinator( + (_, _) => Task.CompletedTask, + exception => throw new Xunit.Sdk.XunitException(exception.ToString())); + + coordinator.Stop(); + + Assert.False(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true))); + Assert.False(coordinator.HasScheduledRestart(1)); + } +} diff --git a/MinecraftClient.Tests/SocketWrapperTests.cs b/MinecraftClient.Tests/SocketWrapperTests.cs new file mode 100644 index 00000000..c04fb9b4 --- /dev/null +++ b/MinecraftClient.Tests/SocketWrapperTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Net.Sockets; +using MinecraftClient.Protocol.Handlers; + +namespace MinecraftClient.Tests; + +public sealed class SocketWrapperTests +{ + [Fact] + public async Task GracefulPeerCloseEndsReadInsteadOfSpinning() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + using var client = new TcpClient(); + Task acceptTask = listener.AcceptTcpClientAsync(); + await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint); + using TcpClient peer = await acceptTask; + var wrapper = new SocketWrapper(client); + + peer.Client.Shutdown(SocketShutdown.Both); + peer.Close(); + + Assert.True(SpinWait.SpinUntil(wrapper.HasDataAvailable, TimeSpan.FromSeconds(5))); + await Assert.ThrowsAsync( + () => Task.Run(() => wrapper.ReadDataRAW(1)).WaitAsync(TimeSpan.FromSeconds(5))); + } + finally + { + listener.Stop(); + } + } +} diff --git a/MinecraftClient.Tests/Usings.cs b/MinecraftClient.Tests/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/MinecraftClient.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/MinecraftClient.sln b/MinecraftClient.sln index ebdf1f09..3f24b221 100644 --- a/MinecraftClient.sln +++ b/MinecraftClient.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "Debug EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinecraftClient.Tests", "MinecraftClient.Tests\MinecraftClient.Tests.csproj", "{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,18 @@ Global {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.ActiveCfg = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.Build.0 = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.ActiveCfg = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.Build.0 = Debug|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.Build.0 = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.ActiveCfg = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.Build.0 = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.ActiveCfg = Release|Any CPU + {44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MinecraftClient/ChatBots/AutoRelog.cs b/MinecraftClient/ChatBots/AutoRelog.cs index 1c975b38..af3b3e71 100644 --- a/MinecraftClient/ChatBots/AutoRelog.cs +++ b/MinecraftClient/ChatBots/AutoRelog.cs @@ -1,5 +1,4 @@ using System; -using System.Threading; using MinecraftClient.Scripting; using Tomlet.Attributes; @@ -24,7 +23,7 @@ namespace MinecraftClient.ChatBots public Range Delay = new(3); [TomlInlineComment("$ChatBot.AutoRelog.Retries$")] - public int Retries = 3; + public int Retries = -1; [TomlInlineComment("$ChatBot.AutoRelog.Ignore_Kick_Message$")] public bool Ignore_Kick_Message = false; @@ -32,27 +31,27 @@ namespace MinecraftClient.ChatBots [TomlPrecedingComment("$ChatBot.AutoRelog.Kick_Messages$")] public string[] Kick_Messages = new string[] { "Connection has been lost", "Server is restarting", "Server is full", "Too Many people" }; - [NonSerialized] - public static int _BotRecoAttempts = 0; - public void OnSettingUpdate() { + Kick_Messages ??= Array.Empty(); + + if (!double.IsFinite(Delay.min)) + Delay.min = 0.1; + if (!double.IsFinite(Delay.max)) + Delay.max = 0.1; + Delay.min = Math.Max(0.1, Delay.min); Delay.max = Math.Max(0.1, Delay.max); - double maxDelaySeconds = int.MaxValue / (double)Settings.ClientTicksPerSecond; + double maxDelaySeconds = (uint.MaxValue - 1) / 1000D; Delay.min = Math.Min(maxDelaySeconds, Delay.min); Delay.max = Math.Min(maxDelaySeconds, Delay.max); if (Delay.min > Delay.max) (Delay.min, Delay.max) = (Delay.max, Delay.min); - if (Retries == -1) - Retries = int.MaxValue; - - if (Enabled) - for (int i = 0; i < Kick_Messages.Length; i++) - Kick_Messages[i] = Kick_Messages[i].ToLower(); + if (Retries < -1) + Retries = -1; } public struct Range @@ -78,9 +77,7 @@ namespace MinecraftClient.ChatBots } } - private static readonly Lock s_reconnectStateLock = new(); - private static readonly TimeSpan s_stableJoinBeforeRetryReset = TimeSpan.FromSeconds(60); - private static DateTime? s_lastJoinUtc; + private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System); /// /// This bot automatically re-join the server if kick message contains predefined string @@ -100,8 +97,7 @@ namespace MinecraftClient.ChatBots public override void AfterGameJoined() { - lock (s_reconnectStateLock) - s_lastJoinUtc = DateTime.UtcNow; + s_retryPolicy.MarkJoined(); } public override void Update() @@ -123,96 +119,40 @@ namespace MinecraftClient.ChatBots if (reason == DisconnectReason.UserLogout) { LogDebugToConsole(Translations.bot_autoRelog_ignore_user_logout); + return false; } - else if (Program.HasRestartPendingForAnotherThread) + + message = GetVerbatim(message); + LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message)); + + if (!AutoRelogRetryPolicy.ShouldReconnect( + reason, + message, + Config.Ignore_Kick_Message, + Config.Kick_Messages, + out string? matchedMessage)) { - return true; - } - else if (CanReconnect()) - { - message = GetVerbatim(message); - string comp = message.ToLower(); - - LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message)); - - if (Config.Ignore_Kick_Message) - { - return LaunchDelayedReconnection(null); - } - - foreach (string msg in Config.Kick_Messages) - { - if (comp.Contains(msg)) - { - return LaunchDelayedReconnection(msg); - } - } - LogDebugToConsole(Translations.bot_autoRelog_reconnect_ignore); + return false; } - return false; - } - - private static bool CanReconnect() - { - lock (s_reconnectStateLock) - return Config.Retries < 0 || Configs._BotRecoAttempts < Config.Retries; + return LaunchDelayedReconnection(matchedMessage); } private static void ResetRetriesAfterStableJoin() { - lock (s_reconnectStateLock) - { - if (Configs._BotRecoAttempts <= 0 || s_lastJoinUtc is not DateTime lastJoinUtc) - return; - - if (DateTime.UtcNow - lastJoinUtc < s_stableJoinBeforeRetryReset) - return; - - Configs._BotRecoAttempts = 0; - s_lastJoinUtc = null; + if (s_retryPolicy.ResetAfterStableConnection()) McClient.ReconnectionAttemptsLeft = Config.Retries; - } - } - - private static bool TryConsumeReconnectAttempt(out int retriesLeft) - { - lock (s_reconnectStateLock) - { - bool unlimitedRetries = HasUnlimitedRetries(); - if (!unlimitedRetries && Configs._BotRecoAttempts >= Config.Retries) - { - retriesLeft = 0; - return false; - } - - Configs._BotRecoAttempts++; - s_lastJoinUtc = null; - retriesLeft = unlimitedRetries ? int.MaxValue : Config.Retries - Configs._BotRecoAttempts; - if (retriesLeft < 0) - retriesLeft = 0; - return true; - } } private static bool HasUnlimitedRetries() { - return Config.Retries < 0 || Config.Retries == int.MaxValue; - } - - private static void RollBackReconnectAttempt() - { - lock (s_reconnectStateLock) - { - if (Configs._BotRecoAttempts > 0) - Configs._BotRecoAttempts--; - } + return Config.Retries == -1; } private bool LaunchDelayedReconnection(string? msg) { - if (!TryConsumeReconnectAttempt(out int retriesLeft)) + if (!s_retryPolicy.TryReserveAttempt(Config.Retries, out int retriesLeft)) return false; double delay = Random.Shared.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min; @@ -223,14 +163,14 @@ namespace MinecraftClient.ChatBots : retriesLeft.ToString(); McClient.ReconnectionAttemptsLeft = retriesLeft; - if (Program.TryRestart((int)Math.Floor(delay), true)) + if (Program.TryRestart(TimeSpan.FromSeconds(delay), true)) { LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay)); return true; } - RollBackReconnectAttempt(); - return true; + s_retryPolicy.RollBackReservedAttempt(); + return Program.HasRestartPending; } public static bool OnDisconnectStatic(DisconnectReason reason, string message) diff --git a/MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs b/MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs new file mode 100644 index 00000000..259b7079 --- /dev/null +++ b/MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using MinecraftClient.Scripting; + +namespace MinecraftClient.ChatBots +{ + internal sealed class AutoRelogRetryPolicy + { + internal static readonly TimeSpan StableConnectionThreshold = TimeSpan.FromSeconds(60); + + private readonly Lock stateLock = new(); + private readonly TimeProvider timeProvider; + private int attempts; + private DateTimeOffset? joinedAt; + + internal AutoRelogRetryPolicy(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + this.timeProvider = timeProvider; + } + + internal int Attempts + { + get + { + lock (stateLock) + return attempts; + } + } + + internal void MarkJoined() + { + lock (stateLock) + joinedAt = timeProvider.GetUtcNow(); + } + + internal bool ResetAfterStableConnection() + { + lock (stateLock) + { + if (attempts == 0 || joinedAt is not DateTimeOffset connectionStart) + return false; + + if (timeProvider.GetUtcNow() - connectionStart < StableConnectionThreshold) + return false; + + attempts = 0; + joinedAt = null; + return true; + } + } + + internal bool TryReserveAttempt(int retryLimit, out int retriesLeft) + { + lock (stateLock) + { + bool unlimited = retryLimit == -1; + if (!unlimited && attempts >= retryLimit) + { + retriesLeft = 0; + return false; + } + + attempts++; + joinedAt = null; + retriesLeft = unlimited ? -1 : Math.Max(0, retryLimit - attempts); + return true; + } + } + + internal void RollBackReservedAttempt() + { + lock (stateLock) + { + if (attempts > 0) + attempts--; + } + } + + internal static bool ShouldReconnect( + ChatBot.DisconnectReason reason, + string message, + bool ignoreKickMessage, + ReadOnlySpan kickMessages, + out string? matchedMessage) + { + matchedMessage = null; + + if (reason == ChatBot.DisconnectReason.UserLogout) + return false; + + if (reason == ChatBot.DisconnectReason.ConnectionLost || ignoreKickMessage) + return true; + + foreach (string candidate in kickMessages) + { + if (!string.IsNullOrEmpty(candidate) + && message.Contains(candidate, StringComparison.OrdinalIgnoreCase)) + { + matchedMessage = candidate; + return true; + } + } + + return false; + } + } +} diff --git a/MinecraftClient/ConsoleIO.cs b/MinecraftClient/ConsoleIO.cs index 2c5a168c..ee1072e3 100644 --- a/MinecraftClient/ConsoleIO.cs +++ b/MinecraftClient/ConsoleIO.cs @@ -77,6 +77,22 @@ namespace MinecraftClient /// public static string? ReadPassword() { + if (ConsoleInputRouter.IsStarted) + { + if (BasicIO || Backend is null) + return ConsoleInputRouter.ReadLine(); + + Backend.SetInputVisible(false); + try + { + return ConsoleInputRouter.ReadLine(); + } + finally + { + Backend.SetInputVisible(true); + } + } + if (BasicIO) return Console.ReadLine(); return Backend.ReadPassword(); @@ -87,6 +103,9 @@ namespace MinecraftClient /// public static string ReadLine() { + if (ConsoleInputRouter.IsStarted) + return ConsoleInputRouter.ReadLine(); + if (BasicIO) return Console.ReadLine() ?? String.Empty; return Backend.RequestImmediateInput(); diff --git a/MinecraftClient/ConsoleInputRouter.cs b/MinecraftClient/ConsoleInputRouter.cs new file mode 100644 index 00000000..3343e4b2 --- /dev/null +++ b/MinecraftClient/ConsoleInputRouter.cs @@ -0,0 +1,187 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MinecraftClient +{ + internal static class ConsoleInputRouter + { + private static readonly Lock StateLock = new(); + private static readonly CancellationTokenSource Shutdown = new(); + private static bool started; + private static Action? messageRoute; + private static EventHandler? inputChangeRoute; + private static TaskCompletionSource? pendingRead; + + internal static bool IsStarted + { + get + { + lock (StateLock) + return started; + } + } + + internal static void EnsureStarted() + { + lock (StateLock) + { + if (started) + return; + + started = true; + if (ConsoleIO.BasicIO || ConsoleIO.Backend is null) + { + var readThread = new Thread(() => ReadBasicInput(Shutdown.Token)) + { + Name = "MCC console input router", + }; + readThread.Start(); + return; + } + + try + { + ConsoleIO.Backend.MessageReceived += OnMessageReceived; + ConsoleIO.Backend.OnInputChange += OnInputChanged; + ConsoleIO.Backend.BeginReadThread(); + } + catch + { + ConsoleIO.Backend.MessageReceived -= OnMessageReceived; + ConsoleIO.Backend.OnInputChange -= OnInputChanged; + started = false; + throw; + } + } + } + + internal static void RouteToClient(McClient client) + { + ArgumentNullException.ThrowIfNull(client); + EnsureStarted(); + + lock (StateLock) + { + messageRoute = client.RouteConsoleInput; + inputChangeRoute = ConsoleIO.AutocompleteHandler; + } + } + + internal static void ClearClient(McClient client) + { + ArgumentNullException.ThrowIfNull(client); + + lock (StateLock) + { + if (messageRoute == client.RouteConsoleInput) + { + messageRoute = null; + inputChangeRoute = null; + } + } + } + + internal static void RouteOffline(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + EnsureStarted(); + + lock (StateLock) + { + messageRoute = handler; + inputChangeRoute = ConsoleIO.OfflineAutocompleteHandler; + } + } + + internal static void ClearOfflineRoute(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + lock (StateLock) + { + if (messageRoute == handler) + { + messageRoute = null; + inputChangeRoute = null; + } + } + } + + internal static string ReadLine() + { + EnsureStarted(); + + TaskCompletionSource readCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); + lock (StateLock) + { + if (pendingRead is not null) + throw new InvalidOperationException(); + + pendingRead = readCompletion; + } + + return readCompletion.Task.GetAwaiter().GetResult(); + } + + internal static void ShutdownRouter() + { + lock (StateLock) + { + messageRoute = null; + inputChangeRoute = null; + pendingRead?.TrySetCanceled(); + pendingRead = null; + Shutdown.Cancel(); + } + } + + private static void ReadBasicInput(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + string? input = Console.ReadLine(); + if (input is null) + return; + + DispatchMessage(input); + } + } + + private static void OnMessageReceived(object? sender, string message) + { + DispatchMessage(message); + } + + private static void OnInputChanged(object? sender, ConsoleInputBuffer input) + { + EventHandler? route; + lock (StateLock) + route = inputChangeRoute; + + route?.Invoke(sender, input); + } + + private static void DispatchMessage(string message) + { + TaskCompletionSource? readCompletion; + Action? route; + + lock (StateLock) + { + readCompletion = pendingRead; + if (readCompletion is not null) + pendingRead = null; + route = messageRoute; + } + + if (readCompletion is not null) + { + readCompletion.TrySetResult(message); + return; + } + + route?.Invoke(message); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 82dfa741..c20b03dd 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Tasks; using Brigadier.NET; using Brigadier.NET.Exceptions; using MinecraftClient.ChatBots; @@ -228,12 +229,11 @@ namespace MinecraftClient TcpClient client = null!; IMinecraftCom handler = null!; SessionToken _sessionToken; - CancellationTokenSource? cmdprompt = null; Tuple? timeoutdetector = null; - private Thread? basicIOReadThread; private int transferInProgress = 0; - private bool consoleReadThreadOwned = false; - private bool consoleHandlersAttached = false; + private int disconnectState; + private int disconnectOwnerThreadId; + private readonly TaskCompletionSource disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); public ILogger Log; public DialogManager Dialogs { get; } @@ -530,75 +530,12 @@ namespace MinecraftClient private void StartConsoleSession() { - cmdprompt = new CancellationTokenSource(); - - if (ConsoleIO.BasicIO || ConsoleIO.Backend is null) - { - if (!consoleReadThreadOwned) - { - CancellationToken token = cmdprompt.Token; - basicIOReadThread = new Thread(() => BasicIOReadLoop(token)) - { - IsBackground = true, - Name = "MCC BasicIO read thread" - }; - basicIOReadThread.Start(); - consoleReadThreadOwned = true; - } - - return; - } - - if (!consoleReadThreadOwned) - { - ConsoleIO.Backend.BeginReadThread(); - consoleReadThreadOwned = true; - } - - if (!consoleHandlersAttached) - { - ConsoleIO.Backend.MessageReceived += ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange += ConsoleIO.AutocompleteHandler; - consoleHandlersAttached = true; - } + ConsoleInputRouter.RouteToClient(this); } private void StopConsoleSession() { - if (ConsoleIO.BasicIO || ConsoleIO.Backend is null) - { - cmdprompt?.Cancel(); - basicIOReadThread = null; - consoleReadThreadOwned = false; - consoleHandlersAttached = false; - return; - } - - if (consoleHandlersAttached) - { - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; - consoleHandlersAttached = false; - } - - if (consoleReadThreadOwned) - { - ConsoleIO.Backend.StopReadThread(); - consoleReadThreadOwned = false; - } - } - - private void BasicIOReadLoop(CancellationToken token) - { - while (!token.IsCancellationRequested) - { - string? input = Console.ReadLine(); - if (input is null) - return; - - if (!token.IsCancellationRequested) - ConsoleReaderOnMessageReceived(this, input); - } + ConsoleInputRouter.ClearClient(this); } private void ResetStateForTransfer() @@ -866,36 +803,21 @@ namespace MinecraftClient /// public void Disconnect() { - instance = null; - - DispatchBotEvent(bot => bot.OnDisconnect(ChatBot.DisconnectReason.UserLogout, "")); - - foreach (ChatBot bot in bots.Where(bot => bot.ScriptOwnerKey is not null).ToList()) - BotUnLoad(bot); - - botsOnHold.Clear(); - botsOnHold.AddRange(bots.Where(bot => bot.ScriptOwnerKey is null)); - - if (handler is not null) + if (!TryBeginDisconnect()) { - handler.Disconnect(); - handler.Dispose(); + if (Volatile.Read(ref disconnectOwnerThreadId) != Environment.CurrentManagedThreadId) + disconnectCompletion.Task.GetAwaiter().GetResult(); + return; } - if (cmdprompt is not null) + try { - cmdprompt.Cancel(); - cmdprompt = null; + DispatchBotEvent(bot => bot.OnDisconnect(ChatBot.DisconnectReason.UserLogout, string.Empty)); } - - if (timeoutdetector is not null) + finally { - timeoutdetector.Item2.Cancel(); - timeoutdetector = null; + CompleteDisconnect(sendDisconnectPacket: true); } - - if (client is not null) - client.Close(); } /// @@ -903,74 +825,133 @@ namespace MinecraftClient /// public void OnConnectionLost(ChatBot.DisconnectReason reason, string message) { - instance = null; + if (reason == ChatBot.DisconnectReason.UserLogout) + throw new InvalidOperationException(Translations.exception_user_logout); - ConsoleIO.CancelAutocomplete(); + if (!TryBeginDisconnect()) + return; - handler.Dispose(); - - world.Clear(); - ClearKnownSigns(); - - if (timeoutdetector is not null) + bool restartScheduled = false; + try { - if (timeoutdetector is not null && Thread.CurrentThread != timeoutdetector.Item1) - timeoutdetector.Item2.Cancel(); - timeoutdetector = null; - } + ConsoleIO.CancelAutocomplete(); - bool exitOnFailure = Program.PrepareExitOnFailure(); - bool will_restart = false; + world.Clear(); + ClearKnownSigns(); - switch (reason) - { - case ChatBot.DisconnectReason.ConnectionLost: - message = Translations.mcc_disconnect_lost; - Log.Info(message); - break; + bool exitOnFailure = Program.PrepareExitOnFailure(); - case ChatBot.DisconnectReason.InGameKick: - Log.Info(Translations.mcc_disconnect_server); - Log.Info(message); - break; - - case ChatBot.DisconnectReason.LoginRejected: - Log.Info(Translations.mcc_disconnect_login); - Log.Info(message); - break; - - case ChatBot.DisconnectReason.UserLogout: - throw new InvalidOperationException(Translations.exception_user_logout); - } - - //Process AutoRelog last to make sure other bots can perform their cleanup tasks first (issue #1517) - List onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList(); - onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog)); - - foreach (ChatBot bot in onDisconnectBotList) - { - try + switch (reason) { - bool botWillRestart = bot.OnDisconnect(reason, message); - if (!exitOnFailure) - will_restart |= botWillRestart; + case ChatBot.DisconnectReason.ConnectionLost: + message = Translations.mcc_disconnect_lost; + Log.Info(message); + break; + + case ChatBot.DisconnectReason.InGameKick: + Log.Info(Translations.mcc_disconnect_server); + Log.Info(message); + break; + + case ChatBot.DisconnectReason.LoginRejected: + Log.Info(Translations.mcc_disconnect_login); + Log.Info(message); + break; } - catch (Exception e) + + // Process AutoRelog last so every other bot can complete cleanup first. + List onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList(); + onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog)); + + foreach (ChatBot bot in onDisconnectBotList) { - if (e is not ThreadAbortException) + try { - Log.Warn("OnDisconnect: Got error from " + bot.ToString() + ": " + e.ToString()); + _ = bot.OnDisconnect(reason, message); + } + catch (Exception exception) when (exception is not ThreadAbortException) + { + Log.Warn("OnDisconnect: Got error from " + bot + ": " + exception); } - else throw; //ThreadAbortException should not be caught } + + restartScheduled = !exitOnFailure && Program.HasRestartPending; + } + finally + { + CompleteDisconnect(sendDisconnectPacket: false); } - SentrySdk.EndSession(); - - if (!will_restart) - { - StopConsoleSession(); + if (!restartScheduled) Program.HandleFailure(null, false, reason); + } + + private bool TryBeginDisconnect() + { + if (Interlocked.CompareExchange(ref disconnectState, 1, 0) != 0) + return false; + + Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId); + instance = null; + StopConsoleSession(); + return true; + } + + private void CompleteDisconnect(bool sendDisconnectPacket) + { + try + { + foreach (ChatBot bot in bots.Where(bot => bot.ScriptOwnerKey is not null).ToList()) + { + try + { + BotUnLoad(bot); + } + catch (Exception exception) when (exception is not ThreadAbortException) + { + Log.Warn(exception.ToString()); + } + } + + botsOnHold.Clear(); + botsOnHold.AddRange(bots.Where(bot => bot.ScriptOwnerKey is null)); + + if (timeoutdetector is not null) + { + CancellationTokenSource timeoutCancellation = timeoutdetector.Item2; + timeoutdetector = null; + RunDisconnectCleanup(timeoutCancellation.Cancel); + } + + if (handler is not null) + { + if (sendDisconnectPacket) + RunDisconnectCleanup(handler.Disconnect); + RunDisconnectCleanup(handler.Dispose); + } + + if (client is not null) + RunDisconnectCleanup(client.Close); + ClearTasks(); + RunDisconnectCleanup(() => SentrySdk.EndSession()); + } + finally + { + Volatile.Write(ref disconnectState, 2); + Volatile.Write(ref disconnectOwnerThreadId, 0); + disconnectCompletion.TrySetResult(true); + } + } + + private void RunDisconnectCleanup(Action cleanup) + { + try + { + cleanup(); + } + catch (Exception exception) when (exception is not ThreadAbortException) + { + Log.Warn(exception.ToString()); } } @@ -980,19 +961,16 @@ namespace MinecraftClient private void ConsoleReaderOnMessageReceived(object? sender, string e) { - if (client.Client is null) return; if (client.Client.Connected) - { - new Thread(() => - { - InvokeOnMainThread(() => HandleCommandPromptText(e)); - }).Start(); - } - else - return; + InvokeOnMainThreadAsync(() => HandleCommandPromptText(e)); + } + + internal void RouteConsoleInput(string input) + { + ConsoleReaderOnMessageReceived(this, input); } /// @@ -1221,6 +1199,23 @@ namespace MinecraftClient InvokeOnMainThread(() => { task(); return true; }); } + /// + /// Queue work for the network main thread without blocking the calling thread. + /// + internal void InvokeOnMainThreadAsync(Action task) + { + ArgumentNullException.ThrowIfNull(task); + + if (!InvokeRequired) + { + task(); + return; + } + + lock (threadTasksLock) + threadTasks.Enqueue(task); + } + /// /// Clear all tasks /// diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index 5408f6b7..35d6f1fb 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -51,11 +51,11 @@ namespace MinecraftClient public const string MCHighestVersion = "26.2"; public static readonly string? BuildInfo = null; - private static Tuple? offlinePrompt = null; private static IDisposable? _sentrySdk = null; private static bool useMcVersionOnce = false; - private static Thread? _restartThread = null; - private static readonly object _restartLock = new(); + private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure); + private static long connectionAttempt; + private static int offlinePromptActive; private static int exitOnFailurePending; private static string settingsIniPath = "MinecraftClient.ini"; @@ -608,6 +608,8 @@ namespace MinecraftClient /// private static void InitializeClient() { + Interlocked.Increment(ref connectionAttempt); + // Ensure that we use the provided Minecraft version if we can't connect automatically. // // useMcVersionOnce is set to true on HandleFailure() @@ -737,6 +739,8 @@ namespace MinecraftClient Config.Main.SetServerIP(new MainConfigHelper.MainConfig.ServerInfoConfig(addressInput), true); } + ConsoleInputRouter.EnsureStarted(); + //Get server version int protocolversion = 0; ForgeInfo? forgeInfo = null; @@ -901,69 +905,60 @@ namespace MinecraftClient /// Optional, keep account and server settings public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettings = false) { - TryRestart(delaySeconds, keepAccountAndServerSettings); + TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings); } - internal static bool HasRestartPendingForAnotherThread - { - get - { - lock (_restartLock) - return HasRestartPendingForAnotherThreadNoLock(); - } - } + internal static bool HasRestartPending => restartCoordinator.HasScheduledRestart(Volatile.Read(ref connectionAttempt)); internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerSettings = false) { - lock (_restartLock) - { - if (Volatile.Read(ref exitOnFailurePending) != 0) - return false; - - if (HasRestartPendingForAnotherThreadNoLock()) - return false; - - ConsoleIO.Backend?.StopReadThread(); - var thread = new Thread(new ThreadStart(delegate - { - try - { - if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); } - if (offlinePrompt is not null) - { - if (ConsoleIO.Backend is not null) - ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; - offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset(); - } - if (delaySeconds > 0) - { - ConsoleIO.WriteLine(string.Format(Translations.mcc_restart_delay, delaySeconds)); - Thread.Sleep(delaySeconds * 1000); - } - ConsoleIO.WriteLine(Translations.mcc_restart); - ReloadSettings(keepAccountAndServerSettings); - InitializeClient(); - } - finally - { - lock (_restartLock) - { - if (_restartThread == Thread.CurrentThread) - _restartThread = null; - } - } - })); - _restartThread = thread; - thread.Start(); - return true; - } + return TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings); } - private static bool HasRestartPendingForAnotherThreadNoLock() + internal static bool TryRestart(TimeSpan delay, bool keepAccountAndServerSettings = false) { - return _restartThread is not null - && _restartThread.IsAlive - && _restartThread != Thread.CurrentThread; + if (Volatile.Read(ref exitOnFailurePending) != 0) + return false; + + if (delay < TimeSpan.Zero) + delay = TimeSpan.Zero; + + return restartCoordinator.TrySchedule(new RestartRequest( + Volatile.Read(ref connectionAttempt), + delay, + keepAccountAndServerSettings)); + } + + private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken) + { + McClient? disconnectedClient = client; + if (disconnectedClient is not null) + { + disconnectedClient.Disconnect(); + if (ReferenceEquals(client, disconnectedClient)) + client = null; + } + + EndOfflinePrompt(); + ConsoleIO.Reset(); + + if (request.Delay > TimeSpan.Zero) + { + ConsoleIO.WriteLine(string.Format(Translations.mcc_restart_delay, request.Delay.TotalSeconds)); + await Task.Delay(request.Delay, TimeProvider.System, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + ConsoleIO.WriteLine(Translations.mcc_restart); + ReloadSettings(request.KeepAccountAndServerSettings); + InitializeClient(); + } + + private static void ReportRestartFailure(Exception exception) + { + SentrySdk.CaptureException(exception); + ConsoleIO.WriteLine(exception.ToString()); + HandleFailure(); } /// @@ -976,6 +971,7 @@ namespace MinecraftClient return false; Interlocked.Exchange(ref exitOnFailurePending, 1); + restartCoordinator.Stop(); return true; } @@ -984,17 +980,10 @@ namespace MinecraftClient WriteBackSettings(); ConsoleIO.WriteLineFormatted("§a" + string.Format(Translations.config_saving, settingsIniPath)); + restartCoordinator.Stop(); if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); } - if (offlinePrompt is not null) - { - if (ConsoleIO.Backend is not null) - ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; - offlinePrompt.Item2.Cancel(); - if (Thread.CurrentThread != offlinePrompt.Item1) - offlinePrompt.Item1.Join(1000); - offlinePrompt = null; - ConsoleIO.Reset(); - } + EndOfflinePrompt(); + ConsoleInputRouter.ShutdownRouter(); if (Config.Main.Advanced.PlayerHeadAsIcon && OperatingSystem.IsWindows()) { ConsoleIcon.RevertToMCCIcon(); } ConsoleIO.Backend?.Shutdown(); Environment.Exit(exitcode); @@ -1022,7 +1011,7 @@ namespace MinecraftClient if (!string.IsNullOrEmpty(errorMessage)) { ConsoleIO.Reset(); - if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) + if (!ConsoleInputRouter.IsStarted && ConsoleIO.Backend is not Tui.TuiConsoleBackend) { try { @@ -1067,85 +1056,89 @@ namespace MinecraftClient return; } - if (offlinePrompt is null) + BeginOfflinePrompt(); + } + } + + private static void BeginOfflinePrompt() + { + if (Interlocked.CompareExchange(ref offlinePromptActive, 1, 0) != 0) + return; + + ConsoleInputRouter.RouteOffline(HandleOfflineCommand); + ConsoleIO.WriteLine(string.Empty); + ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString())); + if (ConsoleIO.Backend is Tui.TuiConsoleBackend) + ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString())); + else + ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true); + } + + private static void EndOfflinePrompt() + { + if (Interlocked.Exchange(ref offlinePromptActive, 0) == 0) + return; + + ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand); + ConsoleIO.Reset(); + } + + private static void HandleOfflineCommand(string input) + { + string command = input.Trim(); + if (command.Length == 0) + { + if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) + Commands.Exit.DoExit(Config.AppVar.ExpandVars(command)); + return; + } + + if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' ' + && command[0] == Config.Main.Advanced.InternalCmdChar.ToChar()) + { + command = command[1..]; + } + + string message = string.Empty; + if (command.StartsWith("reco", StringComparison.Ordinal)) + { + message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command)); + if (message.Length == 0) { - ConsoleIO.Backend?.StopReadThread(); - if (ConsoleIO.Backend is not null) - ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler; - - var cancellationTokenSource = new CancellationTokenSource(); - offlinePrompt = new(new Thread(new ThreadStart(delegate - { - bool exitThread = false; - string command = " "; - ConsoleIO.WriteLine(string.Empty); - ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString())); - if (ConsoleIO.Backend is Tui.TuiConsoleBackend) - ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString())); - else - ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true); - - while (!cancellationTokenSource.IsCancellationRequested) - { - if (exitThread) - return; - - command = ConsoleIO.ReadLine().Trim(); - - if (command.Length == 0) - { - if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) - Commands.Exit.DoExit(Config.AppVar.ExpandVars(command)); - continue; - } - - string message = ""; - - if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' ' - && command[0] == Config.Main.Advanced.InternalCmdChar.ToChar()) - command = command[1..]; - - if (command.StartsWith("reco")) - { - message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command)); - if (message == "") - { - exitThread = true; - continue; - } - } - else if (command.StartsWith("connect")) - { - message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command)); - if (message == "") - { - exitThread = true; - continue; - } - } - else if (command.StartsWith("exit") || command.StartsWith("quit")) - { - message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command)); - } - else if (command.StartsWith("help")) - { - ConsoleIO.WriteLineFormatted("§8MCC: " + - Config.Main.Advanced.InternalCmdChar.ToLogString() + - new Commands.Reco().GetCmdDescTranslated()); - ConsoleIO.WriteLineFormatted("§8MCC: " + - Config.Main.Advanced.InternalCmdChar.ToLogString() + - new Commands.Connect().GetCmdDescTranslated()); - } - else - ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0])); - - if (message != "") - ConsoleIO.WriteLineFormatted("§8MCC: " + message); - } - })), cancellationTokenSource); - offlinePrompt.Item1.Start(); + EndOfflinePrompt(); + return; } } + else if (command.StartsWith("connect", StringComparison.Ordinal)) + { + message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command)); + if (message.Length == 0) + { + EndOfflinePrompt(); + return; + } + } + else if (command.StartsWith("exit", StringComparison.Ordinal) + || command.StartsWith("quit", StringComparison.Ordinal)) + { + message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command)); + } + else if (command.StartsWith("help", StringComparison.Ordinal)) + { + ConsoleIO.WriteLineFormatted("§8MCC: " + + Config.Main.Advanced.InternalCmdChar.ToLogString() + + new Commands.Reco().GetCmdDescTranslated()); + ConsoleIO.WriteLineFormatted("§8MCC: " + + Config.Main.Advanced.InternalCmdChar.ToLogString() + + new Commands.Connect().GetCmdDescTranslated()); + } + else + { + ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0])); + } + + if (message.Length != 0) + ConsoleIO.WriteLineFormatted("§8MCC: " + message); } private static int GetFailureExitCode(ChatBot.DisconnectReason? disconnectReason) diff --git a/MinecraftClient/Properties/AssemblyInfo.cs b/MinecraftClient/Properties/AssemblyInfo.cs index 99b66052..7e608e53 100644 --- a/MinecraftClient/Properties/AssemblyInfo.cs +++ b/MinecraftClient/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +[assembly: InternalsVisibleTo("MinecraftClient.Tests")] + // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 2d0bb40f..968e6465 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -4082,20 +4082,19 @@ namespace MinecraftClient.Protocol.Handlers { try { - if (netMain is not null) + netMain?.Item2.Cancel(); + } + finally + { + try { - netMain.Item2.Cancel(); + netReader?.Item2.Cancel(); } - - if (netReader is not null) + finally { - netReader.Item2.Cancel(); socketWrapper.Disconnect(); } } - catch - { - } } /// diff --git a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs index a4f451b1..7aaac71c 100644 --- a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs +++ b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Net.Sockets; using MinecraftClient.Crypto; @@ -38,7 +39,7 @@ namespace MinecraftClient.Protocol.Handlers /// TRUE if data is available to read public bool HasDataAvailable() { - return c.Client.Available > 0; + return c.Client.Available > 0 || c.Client.Poll(0, SelectMode.SelectRead); } /// @@ -61,10 +62,16 @@ namespace MinecraftClient.Protocol.Handlers int read = 0; while (read < offset) { + int bytesRead; if (encrypted) - read += s!.Read(buffer, start + read, offset - read); + bytesRead = s!.Read(buffer, start + read, offset - read); else - read += c.Client.Receive(buffer, start + read, offset - read, f); + bytesRead = c.Client.Receive(buffer, start + read, offset - read, f); + + if (bytesRead == 0) + throw new EndOfStreamException(); + + read += bytesRead; } } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index cdd5d4b1..41c1eee0 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -663,7 +663,7 @@ namespace MinecraftClient { } /// - /// Looks up a localized string similar to When set to true, autorelog will reconnect regardless of kick messages.. + /// Looks up a localized string similar to Reconnect after any server kick or login rejection. Network interruptions always trigger Auto Relog.. /// internal static string ChatBot_AutoRelog_Ignore_Kick_Message { get { @@ -672,7 +672,7 @@ namespace MinecraftClient { } /// - /// Looks up a localized string similar to If the kickout message matches any of the strings, then autorelog will be triggered.. + /// Looks up a localized string similar to Case-insensitive text fragments that trigger Auto Relog for server kicks and login rejections.. /// internal static string ChatBot_AutoRelog_Kick_Messages { get { @@ -681,7 +681,7 @@ namespace MinecraftClient { } /// - /// Looks up a localized string similar to Retries when failing to relog to the server. use -1 for unlimited retries.. + /// Looks up a localized string similar to Exact retry limit. Use 0 to disable retries or -1 for unlimited retries. The count resets after 60 seconds online.. /// internal static string ChatBot_AutoRelog_Retries { get { diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index d1ebbcb2..42e3ee0c 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -352,13 +352,13 @@ You can use "/fish" to control the bot manually. The delay time before joining the server. (in seconds) - When set to true, autorelog will reconnect regardless of kick messages. + Reconnect after any server kick or login rejection. Network interruptions always trigger Auto Relog. - If the kickout message matches any of the strings, then autorelog will be triggered. + Case-insensitive text fragments that trigger Auto Relog for server kicks and login rejections. - Retries when failing to relog to the server. use -1 for unlimited retries. + Exact retry limit. Use 0 to disable retries or -1 for unlimited retries. The count resets after 60 seconds online. Run commands or send messages automatically when a specified pattern is detected in chat diff --git a/MinecraftClient/RestartCoordinator.cs b/MinecraftClient/RestartCoordinator.cs new file mode 100644 index 00000000..3193d933 --- /dev/null +++ b/MinecraftClient/RestartCoordinator.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace MinecraftClient +{ + internal readonly record struct RestartRequest( + long ConnectionAttempt, + TimeSpan Delay, + bool KeepAccountAndServerSettings); + + internal sealed class RestartCoordinator : IDisposable + { + private readonly Lock stateLock = new(); + private readonly Channel requests; + private readonly CancellationTokenSource shutdown = new(); + private readonly Func restart; + private readonly Action reportFailure; + private readonly Task worker; + private readonly HashSet pendingAttempts = []; + private long highestScheduledAttempt = -1; + private bool stopped; + + internal RestartCoordinator( + Func restart, + Action reportFailure) + { + ArgumentNullException.ThrowIfNull(restart); + ArgumentNullException.ThrowIfNull(reportFailure); + + this.restart = restart; + this.reportFailure = reportFailure; + requests = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false, + }); + worker = ProcessRequestsAsync(); + } + + internal bool HasScheduledRestart(long connectionAttempt) + { + lock (stateLock) + return !stopped && pendingAttempts.Contains(connectionAttempt); + } + + internal bool TrySchedule(RestartRequest request) + { + lock (stateLock) + { + if (stopped || request.ConnectionAttempt <= highestScheduledAttempt) + return false; + + highestScheduledAttempt = request.ConnectionAttempt; + pendingAttempts.Add(request.ConnectionAttempt); + if (requests.Writer.TryWrite(request)) + return true; + + pendingAttempts.Remove(request.ConnectionAttempt); + return false; + } + } + + internal void Stop() + { + lock (stateLock) + { + if (stopped) + return; + + stopped = true; + pendingAttempts.Clear(); + requests.Writer.TryComplete(); + shutdown.Cancel(); + } + } + + private async Task ProcessRequestsAsync() + { + try + { + await foreach (RestartRequest request in requests.Reader.ReadAllAsync(shutdown.Token).ConfigureAwait(false)) + { + try + { + await restart(request, shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdown.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + reportFailure(exception); + } + finally + { + lock (stateLock) + pendingAttempts.Remove(request.ConnectionAttempt); + } + } + } + catch (OperationCanceledException) when (shutdown.IsCancellationRequested) + { + } + } + + public void Dispose() + { + Stop(); + worker.GetAwaiter().GetResult(); + shutdown.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/docs/guide/chat-bots.md b/docs/guide/chat-bots.md index 23658d54..d93eb210 100644 --- a/docs/guide/chat-bots.md +++ b/docs/guide/chat-bots.md @@ -1318,7 +1318,9 @@ redirectFrom: - **Description:** - Make MCC automatically relog when disconnected by the server, for example because the server is restating. + Make MCC reconnect after a network interruption or a matching server kick. + + A lost TCP connection always triggers Auto Relog when the bot is enabled. `Kick_Messages` only filters server kick and login rejection messages. Logging out with an MCC command never triggers Auto Relog. - **Settings:** @@ -1343,9 +1345,9 @@ redirectFrom: - **Description:** - The delay time before joining the server. + The delay before the next connection attempt. - If the `min` and `max` are the same, the time will be consistent, however, if you want a random time, you can set `min` and `max` to different values to get a random time. The time format is in seconds, and the type is double. (eg. `37.0`) + If `min` and `max` are equal, every attempt uses that delay. Otherwise, MCC picks a random value in the range. Values are seconds and may include a fractional part, such as `0.5` or `37.0`. - **Format:** `{ min = , max = }` @@ -1365,9 +1367,9 @@ redirectFrom: - **Description:** - Number of retries. + Number of connection attempts after a disconnect. `0` disables retries, and a positive value is used as an exact limit. - Use `-1` for infinite retries. + Use `-1` for unlimited retries. MCC resets the count after the connection has remained stable for 60 seconds. A restart request that MCC rejects as a duplicate does not consume an attempt. - **Default:** `-1` @@ -1375,7 +1377,7 @@ redirectFrom: - **Description:** - This settings specifies if the `Kick_Messages` setting will be ignored, if set to `true` it will auto relog regardless of the kick messages. + Reconnect after any server kick or login rejection instead of checking `Kick_Messages`. This setting does not affect network interruptions, which always trigger Auto Relog. - **Type:** `boolean` @@ -1385,7 +1387,7 @@ redirectFrom: - **Description:** - A list of words which should trigger the Auto Reconnect Chat Bot. + Text fragments that trigger Auto Relog for server kicks and login rejections. Matching is case-insensitive. - **Format:** `[ "", "", ... ]` diff --git a/tools/testing/auto_relog_fault_proxy.py b/tools/testing/auto_relog_fault_proxy.py new file mode 100755 index 00000000..63e58249 --- /dev/null +++ b/tools/testing/auto_relog_fault_proxy.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""TCP proxy for repeatable Auto Relog connection-loss tests.""" + +from __future__ import annotations + +import argparse +import asyncio +import socket +import struct +import time +from contextlib import suppress + + +class FaultProxy: + def __init__( + self, + upstream_host: str, + upstream_port: int, + drop_after: float, + outage_seconds: float, + cycles: int, + reset_connection: bool, + ) -> None: + self.upstream_host = upstream_host + self.upstream_port = upstream_port + self.drop_after = drop_after + self.outage_seconds = outage_seconds + self.remaining_cycles = cycles + self.reset_connection = reset_connection + self.outage_until = 0.0 + self.state_lock = asyncio.Lock() + + async def handle_connection( + self, + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + ) -> None: + peer = client_writer.get_extra_info("peername") + if time.monotonic() < self.outage_until: + print(f"reject peer={peer} reason=outage", flush=True) + await self.close_writer(client_writer) + return + + try: + server_reader, server_writer = await asyncio.open_connection( + self.upstream_host, + self.upstream_port, + ) + except OSError as exception: + print(f"reject peer={peer} reason=upstream error={exception}", flush=True) + await self.close_writer(client_writer) + return + + async with self.state_lock: + inject_fault = self.remaining_cycles != 0 + if self.remaining_cycles > 0: + self.remaining_cycles -= 1 + + print(f"connected peer={peer} inject_fault={inject_fault}", flush=True) + drop_task = ( + asyncio.create_task(self.drop_connection(client_writer, server_writer)) + if inject_fault + else None + ) + + relays = [ + asyncio.create_task(self.relay(client_reader, server_writer)), + asyncio.create_task(self.relay(server_reader, client_writer)), + ] + try: + await asyncio.wait(relays, return_when=asyncio.FIRST_COMPLETED) + finally: + for task in relays: + task.cancel() + if drop_task is not None: + drop_task.cancel() + await asyncio.gather(*relays, return_exceptions=True) + if drop_task is not None: + await asyncio.gather(drop_task, return_exceptions=True) + await self.close_writer(client_writer) + await self.close_writer(server_writer) + + async def drop_connection( + self, + client_writer: asyncio.StreamWriter, + server_writer: asyncio.StreamWriter, + ) -> None: + await asyncio.sleep(self.drop_after) + async with self.state_lock: + self.outage_until = max( + self.outage_until, + time.monotonic() + self.outage_seconds, + ) + + mode = "reset" if self.reset_connection else "graceful" + print(f"drop mode={mode} outage_seconds={self.outage_seconds}", flush=True) + if self.reset_connection: + self.set_reset_on_close(client_writer) + self.set_reset_on_close(server_writer) + client_writer.close() + server_writer.close() + + @staticmethod + async def relay( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + while data := await reader.read(64 * 1024): + writer.write(data) + await writer.drain() + + @staticmethod + def set_reset_on_close(writer: asyncio.StreamWriter) -> None: + raw_socket = writer.get_extra_info("socket") + if raw_socket is not None: + raw_socket.setsockopt( + socket.SOL_SOCKET, + socket.SO_LINGER, + struct.pack("ii", 1, 0), + ) + + @staticmethod + async def close_writer(writer: asyncio.StreamWriter) -> None: + writer.close() + with suppress(ConnectionError, OSError): + await writer.wait_closed() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--listen-host", default="127.0.0.1") + parser.add_argument("--listen-port", type=int, required=True) + parser.add_argument("--upstream-host", default="127.0.0.1") + parser.add_argument("--upstream-port", type=int, required=True) + parser.add_argument("--drop-after", type=float, default=5.0) + parser.add_argument("--outage-seconds", type=float, default=10.0) + parser.add_argument( + "--cycles", + type=int, + default=1, + help="Connections to drop. Use -1 to drop every forwarded connection.", + ) + parser.add_argument( + "--mode", + choices=("graceful", "reset"), + default="graceful", + ) + args = parser.parse_args() + if args.drop_after < 0 or args.outage_seconds < 0 or args.cycles < -1: + parser.error("drop and outage values must be nonnegative; cycles must be -1 or greater") + return args + + +async def main() -> None: + args = parse_args() + proxy = FaultProxy( + args.upstream_host, + args.upstream_port, + args.drop_after, + args.outage_seconds, + args.cycles, + args.mode == "reset", + ) + server = await asyncio.start_server( + proxy.handle_connection, + args.listen_host, + args.listen_port, + ) + addresses = ", ".join(str(sock.getsockname()) for sock in server.sockets or []) + print(f"listening addresses={addresses}", flush=True) + async with server: + await server.serve_forever() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass