diff --git a/MinecraftClient/ChatBots/AutoRelog.cs b/MinecraftClient/ChatBots/AutoRelog.cs index 1c975b38..b56c922a 100644 --- a/MinecraftClient/ChatBots/AutoRelog.cs +++ b/MinecraftClient/ChatBots/AutoRelog.cs @@ -131,7 +131,7 @@ namespace MinecraftClient.ChatBots else if (CanReconnect()) { message = GetVerbatim(message); - string comp = message.ToLower(); + string comp = message.ToLowerInvariant(); LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message)); diff --git a/MinecraftClient/ClassicConsoleBackend.cs b/MinecraftClient/ClassicConsoleBackend.cs index 38411413..222e9ff1 100644 --- a/MinecraftClient/ClassicConsoleBackend.cs +++ b/MinecraftClient/ClassicConsoleBackend.cs @@ -1,5 +1,6 @@ using System; using System.Text.RegularExpressions; +using System.Threading; namespace MinecraftClient { @@ -109,9 +110,32 @@ namespace MinecraftClient public void StopReadThread() { - ConsoleInteractive.ConsoleReader.StopReadThread(); - ConsoleInteractive.ConsoleReader.MessageReceived -= ForwardMessage; - ConsoleInteractive.ConsoleReader.OnInputChange -= ForwardInputChange; + Thread stopThread = new(() => + { + try + { + ConsoleInteractive.ConsoleReader.StopReadThread(); + } + catch + { + // Best-effort shutdown; do not block the caller thread during reconnect/restart. + } + + try + { + ConsoleInteractive.ConsoleReader.MessageReceived -= ForwardMessage; + ConsoleInteractive.ConsoleReader.OnInputChange -= ForwardInputChange; + } + catch + { + // Ignore detach failures. + } + }) + { + IsBackground = true, + Name = "ClassicConsoleBackend.StopReadThread" + }; + stopThread.Start(); } public string RequestImmediateInput() diff --git a/MinecraftClient/ConsoleIO.cs b/MinecraftClient/ConsoleIO.cs index 2c5a168c..ac75207a 100644 --- a/MinecraftClient/ConsoleIO.cs +++ b/MinecraftClient/ConsoleIO.cs @@ -92,6 +92,29 @@ namespace MinecraftClient return Backend.RequestImmediateInput(); } + /// + /// Read a line from the standard input but return quickly if the backend stalls. + /// This is used by the offline prompt so disconnect/reconnect handling cannot hang forever. + /// + public static string ReadLineWithTimeout(TimeSpan timeout) + { + if (BasicIO) + return Console.ReadLine() ?? String.Empty; + + try + { + var inputTask = Task.Run(() => Backend.RequestImmediateInput()); + if (inputTask.Wait(timeout)) + return inputTask.Result; + } + catch + { + // Ignore backend stalls and fall back to an empty input so the reconnect flow can continue. + } + + return string.Empty; + } + /// /// Debug routine: print all keys pressed in the console /// diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 753de486..2c4d5446 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -232,6 +232,7 @@ namespace MinecraftClient Tuple? timeoutdetector = null; private Thread? basicIOReadThread; private int transferInProgress = 0; + private int disconnectInProgress = 0; private bool consoleReadThreadOwned = false; private bool consoleHandlersAttached = false; @@ -568,6 +569,7 @@ namespace MinecraftClient if (ConsoleIO.BasicIO || ConsoleIO.Backend is null) { cmdprompt?.Cancel(); + cmdprompt = null; basicIOReadThread = null; consoleReadThreadOwned = false; consoleHandlersAttached = false; @@ -576,15 +578,36 @@ namespace MinecraftClient if (consoleHandlersAttached) { - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + try + { + ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; + ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + } + catch + { + // Best effort; a stale subscription should not block teardown. + } consoleHandlersAttached = false; } if (consoleReadThreadOwned) + { + try + { + ConsoleIO.Backend.StopReadThread(); + } + catch + { + // Best effort; a stalled read thread should not block disconnect handling. + } + finally + { + consoleReadThreadOwned = false; + } + } + else { ConsoleIO.Backend.StopReadThread(); - consoleReadThreadOwned = false; } } @@ -866,36 +889,60 @@ namespace MinecraftClient /// public void Disconnect() { - instance = null; + if (Interlocked.Exchange(ref disconnectInProgress, 1) != 0) + return; - 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) + try { - handler.Disconnect(); - handler.Dispose(); - } + try + { + StopConsoleSession(); + } + catch + { + // Best-effort; disconnect should not be blocked by console teardown failures. + } - if (cmdprompt is not null) + bool clearSharedState = ReferenceEquals(instance, this); + if (clearSharedState) + instance = null; + + if (ReferenceEquals(CmdResult.currentHandler, this)) + CmdResult.currentHandler = 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) + { + handler.Disconnect(); + handler.Dispose(); + } + + if (cmdprompt is not null) + { + cmdprompt.Cancel(); + cmdprompt = null; + } + + if (timeoutdetector is not null) + { + timeoutdetector.Item2.Cancel(); + timeoutdetector = null; + } + + if (client is not null) + client.Close(); + } + finally { - cmdprompt.Cancel(); - cmdprompt = null; + Interlocked.Exchange(ref disconnectInProgress, 0); } - - if (timeoutdetector is not null) - { - timeoutdetector.Item2.Cancel(); - timeoutdetector = null; - } - - if (client is not null) - client.Close(); } /// @@ -903,71 +950,95 @@ namespace MinecraftClient /// public void OnConnectionLost(ChatBot.DisconnectReason reason, string message) { - instance = null; + if (Interlocked.Exchange(ref disconnectInProgress, 1) != 0) + return; - ConsoleIO.CancelAutocomplete(); - - handler.Dispose(); - - world.Clear(); - ClearKnownSigns(); - - if (timeoutdetector is not null) - { - if (timeoutdetector is not null && Thread.CurrentThread != timeoutdetector.Item1) - timeoutdetector.Item2.Cancel(); - timeoutdetector = null; - } - - bool will_restart = false; - - switch (reason) - { - 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; - - case ChatBot.DisconnectReason.UserLogout: - throw new InvalidOperationException(Translations.exception_user_logout); - } - - //Process AutoRelog last to make sure other bots can perform their cleanup tasks first (issue #1517) - List onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList(); - onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog)); - - foreach (ChatBot bot in onDisconnectBotList) + try { try { - will_restart |= bot.OnDisconnect(reason, message); + StopConsoleSession(); } - catch (Exception e) + catch { - if (e is not ThreadAbortException) + // Best-effort; disconnect should not be blocked by console teardown failures. + } + + bool clearSharedState = ReferenceEquals(instance, this); + if (clearSharedState) + instance = null; + + if (ReferenceEquals(CmdResult.currentHandler, this)) + CmdResult.currentHandler = null; + + ConsoleIO.CancelAutocomplete(); + + handler.Dispose(); + + world.Clear(); + ClearKnownSigns(); + + if (timeoutdetector is not null) + { + if (timeoutdetector is not null && Thread.CurrentThread != timeoutdetector.Item1) + timeoutdetector.Item2.Cancel(); + timeoutdetector = null; + } + + bool will_restart = false; + + switch (reason) + { + 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; + + case ChatBot.DisconnectReason.UserLogout: + throw new InvalidOperationException(Translations.exception_user_logout); + } + + //Process AutoRelog last to make sure other bots can perform their cleanup tasks first (issue #1517) + List onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList(); + onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog)); + + foreach (ChatBot bot in onDisconnectBotList) + { + try { - Log.Warn("OnDisconnect: Got error from " + bot.ToString() + ": " + e.ToString()); + will_restart |= bot.OnDisconnect(reason, message); } - else throw; //ThreadAbortException should not be caught + catch (Exception e) + { + if (e is not ThreadAbortException) + { + Log.Warn("OnDisconnect: Got error from " + bot.ToString() + ": " + e.ToString()); + } + else throw; //ThreadAbortException should not be caught + } + } + + SentrySdk.EndSession(); + + if (!will_restart) + { + StopConsoleSession(); + Program.HandleFailure(null, false, reason); } } - - SentrySdk.EndSession(); - - if (!will_restart) + finally { - StopConsoleSession(); - Program.HandleFailure(null, false, reason); + Interlocked.Exchange(ref disconnectInProgress, 0); } } diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index 71ba3142..00056e50 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -56,6 +56,13 @@ namespace MinecraftClient private static bool useMcVersionOnce = false; private static Thread? _restartThread = null; private static readonly object _restartLock = new(); + private static int _exitInProgress = 0; + private static int _restartInProgress = 0; + private static int _failureInProgress = 0; + private static int _restartAttemptGeneration = 0; + private static int _restartPending = 0; + private static int _pendingRestartDelaySeconds = 0; + private static bool _pendingRestartKeepAccountAndServerSettings = false; private static string settingsIniPath = "MinecraftClient.ini"; // [SENTRY] @@ -914,22 +921,91 @@ namespace MinecraftClient internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerSettings = false) { + if (Interlocked.Exchange(ref _restartInProgress, 1) != 0) + { + lock (_restartLock) + { + Interlocked.Exchange(ref _restartPending, 1); + _pendingRestartDelaySeconds = delaySeconds; + _pendingRestartKeepAccountAndServerSettings = keepAccountAndServerSettings; + } + return true; + } + + int attemptGeneration = Interlocked.Increment(ref _restartAttemptGeneration); + lock (_restartLock) { if (HasRestartPendingForAnotherThreadNoLock()) + { + Interlocked.Exchange(ref _restartInProgress, 0); + Interlocked.Decrement(ref _restartAttemptGeneration); return false; + } + + try + { + if (ConsoleIO.Backend is not null) + ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; + } + catch { } + + try + { + ConsoleIO.Backend?.StopReadThread(); + } + catch { } + + try + { + ConsoleIO.CancelAutocomplete(); + } + catch { } + + try + { + ConsoleIO.Reset(); + } + catch { } - ConsoleIO.Backend?.StopReadThread(); var thread = new Thread(new ThreadStart(delegate { try { - if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); } + McClient? activeClient = client; + if (activeClient is not null) + { + activeClient.Disconnect(); + if (ReferenceEquals(client, activeClient)) + client = null; + ConsoleIO.Reset(); + } + try + { + ConsoleIO.CancelAutocomplete(); + ConsoleIO.Reset(); + } + catch { } 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(); + try + { + offlinePrompt.Item2.Cancel(); + if (offlinePrompt.Item1.IsAlive) + { + if (!offlinePrompt.Item1.Join(TimeSpan.FromSeconds(1))) + offlinePrompt.Item1.Interrupt(); + } + } + catch (Exception) + { + // Best-effort cleanup; the reconnect loop must continue. + } + finally + { + offlinePrompt = null; + ConsoleIO.Reset(); + } } if (delaySeconds > 0) { @@ -942,11 +1018,30 @@ namespace MinecraftClient } finally { + Interlocked.Exchange(ref _restartInProgress, 0); lock (_restartLock) { if (_restartThread == Thread.CurrentThread) _restartThread = null; } + + if (Volatile.Read(ref _restartPending) != 0) + { + int nextDelaySeconds = 0; + bool nextKeepAccountAndServerSettings = false; + lock (_restartLock) + { + if (Volatile.Read(ref _restartPending) != 0) + { + nextDelaySeconds = _pendingRestartDelaySeconds; + nextKeepAccountAndServerSettings = _pendingRestartKeepAccountAndServerSettings; + Interlocked.Exchange(ref _restartPending, 0); + } + } + + if (Volatile.Read(ref _restartPending) == 0) + TryRestart(nextDelaySeconds, nextKeepAccountAndServerSettings); + } } })); _restartThread = thread; @@ -957,29 +1052,65 @@ namespace MinecraftClient private static bool HasRestartPendingForAnotherThreadNoLock() { - return _restartThread is not null - && _restartThread.IsAlive - && _restartThread != Thread.CurrentThread; + return Volatile.Read(ref _restartPending) != 0 + || (_restartThread is not null + && _restartThread.IsAlive + && _restartThread != Thread.CurrentThread); } public static void DoExit(int exitcode = 0) { - WriteBackSettings(); - ConsoleIO.WriteLineFormatted("§a" + string.Format(Translations.config_saving, settingsIniPath)); + if (Interlocked.Exchange(ref _exitInProgress, 1) != 0) + return; + + Interlocked.Exchange(ref _failureInProgress, 1); + Interlocked.Exchange(ref _restartInProgress, 1); + + try + { + WriteBackSettings(); + ConsoleIO.WriteLineFormatted("§a" + string.Format(Translations.config_saving, settingsIniPath)); + } + catch { } + + try + { + McClient? activeClient = client; + if (activeClient is not null) + { + activeClient.Disconnect(); + if (ReferenceEquals(client, activeClient)) + client = null; + ConsoleIO.Reset(); + } + } + catch { } - 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(); + try + { + if (ConsoleIO.Backend is not null) + ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; + offlinePrompt.Item2.Cancel(); + if (offlinePrompt.Item1.IsAlive) + { + if (!offlinePrompt.Item1.Join(TimeSpan.FromSeconds(1))) + offlinePrompt.Item1.Interrupt(); + } + } + catch { } + finally + { + offlinePrompt = null; + ConsoleIO.Reset(); + } } if (Config.Main.Advanced.PlayerHeadAsIcon && OperatingSystem.IsWindows()) { ConsoleIcon.RevertToMCCIcon(); } - ConsoleIO.Backend?.Shutdown(); + try { ConsoleIO.Backend?.Shutdown(); } catch { } + Interlocked.Exchange(ref _failureInProgress, 0); + Interlocked.Exchange(ref _restartInProgress, 0); + Interlocked.Exchange(ref _exitInProgress, 0); Environment.Exit(exitcode); } @@ -988,7 +1119,7 @@ namespace MinecraftClient /// public static void Exit(int exitcode = 0) { - new Thread(() => { DoExit(exitcode); }).Start(); + DoExit(exitcode); } /// @@ -1000,143 +1131,158 @@ namespace MinecraftClient /// If set, the error message will be processed by the AutoRelog bot public static void HandleFailure(string? errorMessage = null, bool versionError = false, ChatBot.DisconnectReason? disconnectReason = null) { - bool autoRelogHandled = false; + if (Volatile.Read(ref _restartInProgress) != 0 || Volatile.Read(ref _restartPending) != 0) + return; - if (!string.IsNullOrEmpty(errorMessage)) + if (Interlocked.Exchange(ref _failureInProgress, 1) != 0) + return; + + try { - ConsoleIO.Reset(); - if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) + bool autoRelogHandled = false; + + if (!string.IsNullOrEmpty(errorMessage)) { - try + ConsoleIO.Reset(); + if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) { - while (Console.KeyAvailable) - Console.ReadKey(true); - } - catch { } - } - ConsoleIO.WriteLine(errorMessage); - - if (disconnectReason.HasValue) - { - autoRelogHandled = true; - if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage)) - return; - } - } - - if (InternalConfig.InteractiveMode) - { - if (versionError) - { - ConsoleIO.WriteLine(Translations.mcc_server_version); - InternalConfig.MinecraftVersion = ConsoleIO.ReadLine(); - if (InternalConfig.MinecraftVersion != "") - { - useMcVersionOnce = true; - Restart(0, true); - return; - } - } - - if (!autoRelogHandled && disconnectReason.HasValue) - { - if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!)) - return; - } - - if (offlinePrompt is null) - { - 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) + try { - 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); + while (Console.KeyAvailable) + Console.ReadKey(true); } - })), cancellationTokenSource); - offlinePrompt.Item1.Start(); - } - } - else - { - // Not in interactive mode, just exit and let the calling script handle the failure - if (disconnectReason.HasValue) - { - // Return distinct exit codes for known failures. - if (disconnectReason.Value == ChatBot.DisconnectReason.UserLogout) Exit(1); - if (disconnectReason.Value == ChatBot.DisconnectReason.InGameKick) Exit(2); - if (disconnectReason.Value == ChatBot.DisconnectReason.ConnectionLost) Exit(3); - if (disconnectReason.Value == ChatBot.DisconnectReason.LoginRejected) Exit(4); - } - Exit(); - } + catch { } + } + ConsoleIO.WriteLine(errorMessage); + if (disconnectReason.HasValue) + { + autoRelogHandled = true; + if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage)) + return; + } + } + + if (InternalConfig.InteractiveMode) + { + if (versionError) + { + ConsoleIO.WriteLine(Translations.mcc_server_version); + InternalConfig.MinecraftVersion = ConsoleIO.ReadLine(); + if (InternalConfig.MinecraftVersion != "") + { + useMcVersionOnce = true; + Restart(0, true); + return; + } + } + + if (!autoRelogHandled && disconnectReason.HasValue) + { + if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!)) + return; + } + + if (offlinePrompt is null) + { + 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.ReadLineWithTimeout(TimeSpan.FromSeconds(1)).Trim(); + + if (command.Length == 0) + { + if (ConsoleIO.Backend is not Tui.TuiConsoleBackend) + { + try { Commands.Exit.DoExit(Config.AppVar.ExpandVars(command)); } + catch { } + } + 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(); + } + } + else + { + // Not in interactive mode, just exit and let the calling script handle the failure + if (disconnectReason.HasValue) + { + // Return distinct exit codes for known failures. + if (disconnectReason.Value == ChatBot.DisconnectReason.UserLogout) Exit(1); + if (disconnectReason.Value == ChatBot.DisconnectReason.InGameKick) Exit(2); + if (disconnectReason.Value == ChatBot.DisconnectReason.ConnectionLost) Exit(3); + if (disconnectReason.Value == ChatBot.DisconnectReason.LoginRejected) Exit(4); + } + Exit(); + } + } + finally + { + Interlocked.Exchange(ref _failureInProgress, 0); + } } /// diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 2d0bb40f..951be73e 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -4085,17 +4085,38 @@ namespace MinecraftClient.Protocol.Handlers if (netMain is not null) { netMain.Item2.Cancel(); + netMain = null; } if (netReader is not null) { netReader.Item2.Cancel(); - socketWrapper.Disconnect(); + netReader = null; } } catch { } + finally + { + try + { + socketWrapper.Disconnect(); + } + catch + { + // Best effort; the old protocol loop should not block shutdown. + } + + try + { + packetQueue.CompleteAdding(); + } + catch + { + // Best effort; the queue may already be completed. + } + } } /// diff --git a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs index a4f451b1..4bf54198 100644 --- a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs +++ b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Net.Sockets; using MinecraftClient.Crypto; @@ -61,10 +62,31 @@ namespace MinecraftClient.Protocol.Handlers int read = 0; while (read < offset) { - if (encrypted) - read += s!.Read(buffer, start + read, offset - read); - else - read += c.Client.Receive(buffer, start + read, offset - read, f); + int received; + try + { + if (encrypted) + received = s!.Read(buffer, start + read, offset - read); + else + received = c.Client.Receive(buffer, start + read, offset - read, f); + } + catch (SocketException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (IOException) + { + throw; + } + + if (received <= 0) + throw new IOException("Socket closed while reading from the server."); + + read += received; } } diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index a5ce88af..947bccbf 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -151,6 +152,15 @@ namespace MinecraftClient public string? LegacyBackupPath { get; init; } } + private static Mutex AcquireSettingsFileMutex(string filepath) + { + string fullPath = Path.GetFullPath(filepath); + string normalizedPath = fullPath.Replace(Path.DirectorySeparatorChar, '/').ToLowerInvariant(); + byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedPath)); + string mutexName = "MCC.Settings." + Convert.ToHexString(hashBytes)[..16]; + return new Mutex(false, mutexName); + } + public static ConfigLoadResult LoadFromFile(string filepath, bool keepAccountAndServerSettings = false) { bool keepAccountSettings = InternalConfig.KeepAccountSettings; @@ -158,118 +168,142 @@ namespace MinecraftClient if (keepAccountAndServerSettings) InternalConfig.KeepAccountSettings = InternalConfig.KeepServerSettings = true; - Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; - TomlDocument document; + using var settingsMutex = AcquireSettingsFileMutex(filepath); + settingsMutex.WaitOne(); try { - document = TomlParser.ParseFile(filepath); - Thread.CurrentThread.CurrentCulture = Program.ActualCulture; - - Config = TomletMain.To(document); - } - catch (Exception ex) - { - Thread.CurrentThread.CurrentCulture = Program.ActualCulture; + Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; + TomlDocument document; try { - string configString = File.ReadAllText(filepath); - if (configString.Contains("Some settings missing here after an upgrade?")) - { - string newFilePath = Path.ChangeExtension(filepath, ".old.ini"); - File.Copy(filepath, newFilePath, true); - return new ConfigLoadResult - { - Success = false, - NeedWriteDefault = true, - IsLegacyUpgrade = true, - LegacyBackupPath = newFilePath - }; - } + document = TomlParser.ParseFile(filepath); + Thread.CurrentThread.CurrentCulture = Program.ActualCulture; + + Config = TomletMain.To(document); } - catch { } - return new ConfigLoadResult + catch (Exception ex) { - Success = false, - NeedWriteDefault = false, - ErrorMessage = ex.GetFullMessage() - }; + Thread.CurrentThread.CurrentCulture = Program.ActualCulture; + try + { + string configString = File.ReadAllText(filepath); + if (configString.Contains("Some settings missing here after an upgrade?")) + { + string newFilePath = Path.ChangeExtension(filepath, ".old.ini"); + File.Copy(filepath, newFilePath, true); + return new ConfigLoadResult + { + Success = false, + NeedWriteDefault = true, + IsLegacyUpgrade = true, + LegacyBackupPath = newFilePath + }; + } + } + catch { } + return new ConfigLoadResult + { + Success = false, + NeedWriteDefault = false, + ErrorMessage = ex.GetFullMessage() + }; + } + finally + { + if (!keepAccountSettings) + InternalConfig.KeepAccountSettings = false; + if (!keepServerSettings) + InternalConfig.KeepServerSettings = false; + } + return new ConfigLoadResult { Success = true, NeedWriteDefault = false }; } finally { - if (!keepAccountSettings) - InternalConfig.KeepAccountSettings = false; - if (!keepServerSettings) - InternalConfig.KeepServerSettings = false; + settingsMutex.ReleaseMutex(); } - return new ConfigLoadResult { Success = true, NeedWriteDefault = false }; } public static void WriteToFile(string filepath, bool backupOldFile) { - Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; - string tomlString = TomletMain.TomlStringFrom(Config); - Thread.CurrentThread.CurrentCulture = Program.ActualCulture; - - string[] tomlList = tomlString.Split('\n'); - StringBuilder newConfig = new(); - foreach (string line in tomlList) + using var settingsMutex = AcquireSettingsFileMutex(filepath); + settingsMutex.WaitOne(); + try { - Match matchComment = CommentRegex.Match(line); - if (matchComment.Success && matchComment.Groups.Count == 3) + Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; + string tomlString = TomletMain.TomlStringFrom(Config); + Thread.CurrentThread.CurrentCulture = Program.ActualCulture; + + string[] tomlList = tomlString.Split('\n'); + StringBuilder newConfig = new(); + foreach (string line in tomlList) { - string config = matchComment.Groups[1].Value, comment = matchComment.Groups[2].Value; - if (config.Length > 0) - newConfig.Append(config).Append(' ', Math.Max(1, CommentsAlignPosition - config.Length) - 1); - string? comment_trans = ConfigComments.ResourceManager.GetString(comment); - if (string.IsNullOrEmpty(comment_trans)) - newConfig.Append("# ").AppendLine(comment.ReplaceLineEndings()); + Match matchComment = CommentRegex.Match(line); + if (matchComment.Success && matchComment.Groups.Count == 3) + { + string config = matchComment.Groups[1].Value, comment = matchComment.Groups[2].Value; + if (config.Length > 0) + newConfig.Append(config).Append(' ', Math.Max(1, CommentsAlignPosition - config.Length) - 1); + string? comment_trans = ConfigComments.ResourceManager.GetString(comment); + if (string.IsNullOrEmpty(comment_trans)) + newConfig.Append("# ").AppendLine(comment.ReplaceLineEndings()); + else + newConfig.Append("# ").AppendLine(comment_trans.Replace("\n", "\n# ").ReplaceLineEndings()); + } else - newConfig.Append("# ").AppendLine(comment_trans.Replace("\n", "\n# ").ReplaceLineEndings()); - } - else - { - newConfig.AppendLine(line); - } - } - - bool needUpdate = true; - byte[] newConfigByte = Encoding.UTF8.GetBytes(newConfig.ToString()); - if (File.Exists(filepath)) - { - try - { - if (new FileInfo(filepath).Length == newConfigByte.Length) - if (File.ReadAllBytes(filepath).SequenceEqual(newConfigByte)) - needUpdate = false; - } - catch { } - } - - if (needUpdate) - { - bool backupSuccessed = true; - if (backupOldFile && File.Exists(filepath)) - { - string backupFilePath = Path.ChangeExtension(filepath, ".backup.ini"); - try { File.Copy(filepath, backupFilePath, true); } - catch (Exception ex) { - backupSuccessed = false; - ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.config_backup_fail, backupFilePath)); - ConsoleIO.WriteLine(ex.Message); + newConfig.AppendLine(line); } } - if (backupSuccessed) + bool needUpdate = true; + byte[] newConfigByte = Encoding.UTF8.GetBytes(newConfig.ToString()); + if (File.Exists(filepath)) { - try { File.WriteAllBytes(filepath, newConfigByte); } - catch (Exception ex) + try { - ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.config_write_fail, filepath)); - ConsoleIO.WriteLine(ex.Message); + if (new FileInfo(filepath).Length == newConfigByte.Length) + if (File.ReadAllBytes(filepath).SequenceEqual(newConfigByte)) + needUpdate = false; + } + catch { } + } + + if (needUpdate) + { + bool backupSuccessed = true; + if (backupOldFile && File.Exists(filepath)) + { + string backupFilePath = Path.ChangeExtension(filepath, ".backup.ini"); + try { File.Copy(filepath, backupFilePath, true); } + catch (Exception ex) + { + backupSuccessed = false; + ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.config_backup_fail, backupFilePath)); + ConsoleIO.WriteLine(ex.Message); + } + } + + if (backupSuccessed) + { + string tempFilePath = filepath + ".tmp"; + try + { + File.WriteAllBytes(tempFilePath, newConfigByte); + File.Move(tempFilePath, filepath, true); + } + catch (Exception ex) + { + try { File.Delete(tempFilePath); } catch { } + ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.config_write_fail, filepath)); + ConsoleIO.WriteLine(ex.Message); + } } } } + finally + { + settingsMutex.ReleaseMutex(); + } } ///