mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Fix Auto Relog reconnect lifecycle
This commit is contained in:
parent
c19fdd6634
commit
64c1dc55e0
20 changed files with 1243 additions and 415 deletions
145
MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs
Normal file
145
MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
MinecraftClient.Tests/MinecraftClient.Tests.csproj
Normal file
22
MinecraftClient.Tests/MinecraftClient.Tests.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MinecraftClient\MinecraftClient.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
61
MinecraftClient.Tests/RestartCoordinatorTests.cs
Normal file
61
MinecraftClient.Tests/RestartCoordinatorTests.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
34
MinecraftClient.Tests/SocketWrapperTests.cs
Normal file
34
MinecraftClient.Tests/SocketWrapperTests.cs
Normal file
|
|
@ -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<TcpClient> 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<EndOfStreamException>(
|
||||
() => Task.Run(() => wrapper.ReadDataRAW(1)).WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
MinecraftClient.Tests/Usings.cs
Normal file
1
MinecraftClient.Tests/Usings.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
global using Xunit;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
|
|
|
|||
108
MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs
Normal file
108
MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs
Normal file
|
|
@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,22 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
public static string ReadLine()
|
||||
{
|
||||
if (ConsoleInputRouter.IsStarted)
|
||||
return ConsoleInputRouter.ReadLine();
|
||||
|
||||
if (BasicIO)
|
||||
return Console.ReadLine() ?? String.Empty;
|
||||
return Backend.RequestImmediateInput();
|
||||
|
|
|
|||
187
MinecraftClient/ConsoleInputRouter.cs
Normal file
187
MinecraftClient/ConsoleInputRouter.cs
Normal file
|
|
@ -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<string>? messageRoute;
|
||||
private static EventHandler<ConsoleInputBuffer>? inputChangeRoute;
|
||||
private static TaskCompletionSource<string>? 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<string> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
EnsureStarted();
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
messageRoute = handler;
|
||||
inputChangeRoute = ConsoleIO.OfflineAutocompleteHandler;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ClearOfflineRoute(Action<string> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
if (messageRoute == handler)
|
||||
{
|
||||
messageRoute = null;
|
||||
inputChangeRoute = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ReadLine()
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
TaskCompletionSource<string> 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<ConsoleInputBuffer>? route;
|
||||
lock (StateLock)
|
||||
route = inputChangeRoute;
|
||||
|
||||
route?.Invoke(sender, input);
|
||||
}
|
||||
|
||||
private static void DispatchMessage(string message)
|
||||
{
|
||||
TaskCompletionSource<string>? readCompletion;
|
||||
Action<string>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Thread, CancellationTokenSource>? 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<bool> 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
|
|||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -903,74 +825,133 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
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<ChatBot> 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<ChatBot> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1221,6 +1199,23 @@ namespace MinecraftClient
|
|||
InvokeOnMainThread(() => { task(); return true; });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue work for the network main thread without blocking the calling thread.
|
||||
/// </summary>
|
||||
internal void InvokeOnMainThreadAsync(Action task)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(task);
|
||||
|
||||
if (!InvokeRequired)
|
||||
{
|
||||
task();
|
||||
return;
|
||||
}
|
||||
|
||||
lock (threadTasksLock)
|
||||
threadTasks.Enqueue(task);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all tasks
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ namespace MinecraftClient
|
|||
public const string MCHighestVersion = "26.2";
|
||||
public static readonly string? BuildInfo = null;
|
||||
|
||||
private static Tuple<Thread, CancellationTokenSource>? 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
|
|||
/// </summary>
|
||||
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
|
|||
/// <param name="keepAccountAndServerSettings">Optional, keep account and server settings</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using MinecraftClient.Crypto;
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
/// <returns>TRUE if data is available to read</returns>
|
||||
public bool HasDataAvailable()
|
||||
{
|
||||
return c.Client.Available > 0;
|
||||
return c.Client.Available > 0 || c.Client.Poll(0, SelectMode.SelectRead);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -663,7 +663,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Ignore_Kick_Message {
|
||||
get {
|
||||
|
|
@ -672,7 +672,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Kick_Messages {
|
||||
get {
|
||||
|
|
@ -681,7 +681,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Retries {
|
||||
get {
|
||||
|
|
|
|||
|
|
@ -352,13 +352,13 @@ You can use "/fish" to control the bot manually.
|
|||
<value>The delay time before joining the server. (in seconds)</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Ignore_Kick_Message" xml:space="preserve">
|
||||
<value>When set to true, autorelog will reconnect regardless of kick messages.</value>
|
||||
<value>Reconnect after any server kick or login rejection. Network interruptions always trigger Auto Relog.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Kick_Messages" xml:space="preserve">
|
||||
<value>If the kickout message matches any of the strings, then autorelog will be triggered.</value>
|
||||
<value>Case-insensitive text fragments that trigger Auto Relog for server kicks and login rejections.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Retries" xml:space="preserve">
|
||||
<value>Retries when failing to relog to the server. use -1 for unlimited retries.</value>
|
||||
<value>Exact retry limit. Use 0 to disable retries or -1 for unlimited retries. The count resets after 60 seconds online.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRespond" xml:space="preserve">
|
||||
<value>Run commands or send messages automatically when a specified pattern is detected in chat
|
||||
|
|
|
|||
119
MinecraftClient/RestartCoordinator.cs
Normal file
119
MinecraftClient/RestartCoordinator.cs
Normal file
|
|
@ -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<RestartRequest> requests;
|
||||
private readonly CancellationTokenSource shutdown = new();
|
||||
private readonly Func<RestartRequest, CancellationToken, Task> restart;
|
||||
private readonly Action<Exception> reportFailure;
|
||||
private readonly Task worker;
|
||||
private readonly HashSet<long> pendingAttempts = [];
|
||||
private long highestScheduledAttempt = -1;
|
||||
private bool stopped;
|
||||
|
||||
internal RestartCoordinator(
|
||||
Func<RestartRequest, CancellationToken, Task> restart,
|
||||
Action<Exception> reportFailure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(restart);
|
||||
ArgumentNullException.ThrowIfNull(reportFailure);
|
||||
|
||||
this.restart = restart;
|
||||
this.reportFailure = reportFailure;
|
||||
requests = Channel.CreateUnbounded<RestartRequest>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = <seconds (double)>, max = <seconds (double)> }`
|
||||
|
||||
|
|
@ -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:** `[ "<keyword>", "<keyword>", ... ]`
|
||||
|
||||
|
|
|
|||
179
tools/testing/auto_relog_fault_proxy.py
Executable file
179
tools/testing/auto_relog_fault_proxy.py
Executable file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue