Fix Auto Relog reconnect lifecycle

This commit is contained in:
Anon 2026-07-19 14:42:10 +02:00
parent c19fdd6634
commit 64c1dc55e0
20 changed files with 1243 additions and 415 deletions

View 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;
}
}
}

View 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>

View 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));
}
}

View 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();
}
}
}

View file

@ -0,0 +1 @@
global using Xunit;

View file

@ -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

View file

@ -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)

View 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;
}
}
}

View file

@ -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();

View 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);
}
}
}

View file

@ -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>

View file

@ -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)

View file

@ -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.

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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

View 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);
}
}
}

View file

@ -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>", ... ]`

View 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