diff --git a/MinecraftClient/ChatBots/Mailer.cs b/MinecraftClient/ChatBots/Mailer.cs index 3295b27d..aa51b4f3 100644 --- a/MinecraftClient/ChatBots/Mailer.cs +++ b/MinecraftClient/ChatBots/Mailer.cs @@ -24,7 +24,7 @@ namespace MinecraftClient.ChatBots public static IgnoreList FromFile(string filePath) { IgnoreList ignoreList = new IgnoreList(); - foreach (string line in File.ReadAllLines(filePath)) + foreach (string line in FileMonitor.ReadAllLinesWithRetries(filePath)) { if (!line.StartsWith("#")) { @@ -46,7 +46,7 @@ namespace MinecraftClient.ChatBots lines.Add("#Ignored Players"); foreach (string player in this) lines.Add(player); - File.WriteAllLines(filePath, lines); + FileMonitor.WriteAllLinesWithRetries(filePath, lines); } } @@ -63,7 +63,7 @@ namespace MinecraftClient.ChatBots public static MailDatabase FromFile(string filePath) { MailDatabase database = new MailDatabase(); - Dictionary> iniFileDict = INIFile.ParseFile(filePath); + Dictionary> iniFileDict = INIFile.ParseFile(FileMonitor.ReadAllLinesWithRetries(filePath)); foreach (KeyValuePair> iniSection in iniFileDict) { //iniSection.Key is "mailXX" but we don't need it here @@ -96,7 +96,7 @@ namespace MinecraftClient.ChatBots iniSection["anonymous"] = mail.Anonymous.ToString(); iniFileDict["mail" + mailCount] = iniSection; } - INIFile.WriteFile(filePath, iniFileDict, "Mail Database"); + FileMonitor.WriteAllLinesWithRetries(filePath, INIFile.Generate(iniFileDict, "Mail Database")); } } @@ -147,6 +147,9 @@ namespace MinecraftClient.ChatBots private DateTime nextMailSend = DateTime.Now; private MailDatabase mailDatabase = new MailDatabase(); private IgnoreList ignoreList = new IgnoreList(); + private FileMonitor mailDbFileMonitor; + private FileMonitor ignoreListFileMonitor; + private object readWriteLock = new object(); /// /// Initialization of the Mailer bot @@ -194,11 +197,18 @@ namespace MinecraftClient.ChatBots new IgnoreList().SaveToFile(Settings.Mailer_IgnoreListFile); } - LogDebugToConsole("Loading database file: " + Path.GetFullPath(Settings.Mailer_DatabaseFile)); - mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile); + lock (readWriteLock) + { + LogDebugToConsole("Loading database file: " + Path.GetFullPath(Settings.Mailer_DatabaseFile)); + mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile); - LogDebugToConsole("Loading ignore list: " + Path.GetFullPath(Settings.Mailer_IgnoreListFile)); - ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile); + LogDebugToConsole("Loading ignore list: " + Path.GetFullPath(Settings.Mailer_IgnoreListFile)); + ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile); + } + + //Initialize file monitors. In case the bot needs to unload for some reason in the future, do not forget to .Dispose() them + mailDbFileMonitor = new FileMonitor(Path.GetDirectoryName(Settings.Mailer_DatabaseFile), Path.GetFileName(Settings.Mailer_DatabaseFile), FileMonitorCallback); + ignoreListFileMonitor = new FileMonitor(Path.GetDirectoryName(Settings.Mailer_IgnoreListFile), Path.GetFileName(Settings.Mailer_IgnoreListFile), FileMonitorCallback); RegisterChatBotCommand("mailer", "Subcommands: getmails, addignored, getignored, removeignored", ProcessInternalCommand); } @@ -249,8 +259,11 @@ namespace MinecraftClient.ChatBots { Mail mail = new Mail(username, recipient, message, anonymous, DateTime.Now); LogToConsole("Saving message: " + mail.ToString()); - mailDatabase.Add(mail); - mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile); + lock (readWriteLock) + { + mailDatabase.Add(mail); + mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile); + } SendPrivateMessage(username, "Message saved!"); } else SendPrivateMessage(username, "Your message cannot be longer than " + maxMessageLength + " characters."); @@ -277,10 +290,6 @@ namespace MinecraftClient.ChatBots { LogDebugToConsole("Looking for mails to send @ " + DateTime.Now); - // Reload mail and ignore list database in case several instances are sharing the same database - mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile); - ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile); - // Process at most 3 mails at a time to avoid spamming. Other mails will be processed on next mail send HashSet onlinePlayersLowercase = new HashSet(GetOnlinePlayers().Select(name => name.ToLower())); foreach (Mail mail in mailDatabase.Where(mail => !mail.Delivered && onlinePlayersLowercase.Contains(mail.RecipientLowercase)).Take(3)) @@ -291,14 +300,31 @@ namespace MinecraftClient.ChatBots LogDebugToConsole("Delivered: " + mail.ToString()); } - mailDatabase.RemoveAll(mail => mail.Delivered); - mailDatabase.RemoveAll(mail => mail.DateSent.AddDays(Settings.Mailer_MailRetentionDays) < DateTime.Now); - mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile); + lock (readWriteLock) + { + mailDatabase.RemoveAll(mail => mail.Delivered); + mailDatabase.RemoveAll(mail => mail.DateSent.AddDays(Settings.Mailer_MailRetentionDays) < DateTime.Now); + mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile); + } nextMailSend = dateNow.AddSeconds(10); } } + /// + /// Called when the Mail Database or Ignore list has changed on disk + /// + /// + /// + private void FileMonitorCallback(object sender, FileSystemEventArgs e) + { + lock (readWriteLock) + { + mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile); + ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile); + } + } + /// /// Interprets local commands. /// @@ -322,19 +348,25 @@ namespace MinecraftClient.ChatBots string username = args[1].ToLower(); if (commandName == "addignored") { - if (!ignoreList.Contains(username)) + lock (readWriteLock) { - ignoreList.Add(username); - ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile); + if (!ignoreList.Contains(username)) + { + ignoreList.Add(username); + ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile); + } } return "Added " + args[1] + " to the ignore list!"; } else { - if (ignoreList.Contains(username)) + lock (readWriteLock) { - ignoreList.Remove(username); - ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile); + if (ignoreList.Contains(username)) + { + ignoreList.Remove(username); + ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile); + } } return "Removed " + args[1] + " from the ignore list!"; } diff --git a/MinecraftClient/FileMonitor.cs b/MinecraftClient/FileMonitor.cs index 00cedf77..d5765c14 100644 --- a/MinecraftClient/FileMonitor.cs +++ b/MinecraftClient/FileMonitor.cs @@ -1,27 +1,32 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Text; using System.Threading; -namespace MinecraftClient.Protocol.Session +namespace MinecraftClient { /// - /// Monitor session file changes on disk + /// Monitor file changes on disk /// - class SessionFileMonitor + public class FileMonitor : IDisposable { - private FileSystemWatcher monitor; - private Thread polling; + private FileSystemWatcher monitor = null; + private Thread polling = null; /// - /// Create a new SessionFileMonitor and start monitoring + /// Create a new FileMonitor and start monitoring /// /// Folder to monitor /// Filename inside folder /// Callback for file changes - public SessionFileMonitor(string folder, string filename, FileSystemEventHandler handler) + public FileMonitor(string folder, string filename, FileSystemEventHandler handler) { if (Settings.DebugMessages) - ConsoleIO.WriteLineFormatted("§8Initializing disk session cache using FileSystemWatcher"); + { + string callerClass = new System.Diagnostics.StackFrame(1).GetMethod().DeclaringType.Name; + ConsoleIO.WriteLineFormatted(String.Format("§8[{0}] Initializing FileSystemWatcher for file: {1}", callerClass, Path.Combine(folder, filename))); + } try { @@ -36,13 +41,29 @@ namespace MinecraftClient.Protocol.Session catch { if (Settings.DebugMessages) - ConsoleIO.WriteLineFormatted("§8Failed to initialize FileSystemWatcher, retrying using Polling"); + { + string callerClass = new System.Diagnostics.StackFrame(1).GetMethod().DeclaringType.Name; + ConsoleIO.WriteLineFormatted(String.Format("§8[{0}] Failed to initialize FileSystemWatcher, retrying using Polling", callerClass)); + } + monitor = null; polling = new Thread(() => PollingThread(folder, filename, handler)); + polling.Name = String.Format("{0} Polling thread: {1}", this.GetType().Name, Path.Combine(folder, filename)); polling.Start(); } } + /// + /// Stop monitoring and dispose the inner resources + /// + public void Dispose() + { + if (monitor != null) + monitor.Dispose(); + if (polling != null) + polling.Abort(); + } + /// /// Fallback polling thread for use when operating system does not support FileSystemWatcher /// @@ -51,7 +72,7 @@ namespace MinecraftClient.Protocol.Session /// Callback when file changes private void PollingThread(string folder, string filename, FileSystemEventHandler handler) { - string filePath = String.Concat(folder, Path.DirectorySeparatorChar, filename); + string filePath = Path.Combine(folder, filename); DateTime lastWrite = GetLastWrite(filePath); while (true) { @@ -79,5 +100,62 @@ namespace MinecraftClient.Protocol.Session } else return DateTime.MinValue; } + + /// + /// Opens a text file, reads all lines of the file, and then closes the file. Retry several times if the file is in use + /// + /// The file to open for reading + /// Maximum read attempts + /// Encoding (default is UTF8) + /// Thrown when failing to read the file despite multiple retries + /// All lines + public static string[] ReadAllLinesWithRetries(string filePath, int maxTries = 3, Encoding encoding = null) + { + int attempt = 0; + if (encoding == null) + encoding = Encoding.UTF8; + while (true) + { + try + { + return File.ReadAllLines(filePath, encoding); + } + catch (IOException) + { + attempt++; + if (attempt < maxTries) + Thread.Sleep(new Random().Next(50, 100) * attempt); // Back-off like CSMA/CD + else throw; + } + } + } + + /// + /// Creates a new file, writes a collection of strings to the file, and then closes the file. + /// + /// The file to open for writing + /// The lines to write to the file + /// Maximum read attempts + /// Encoding (default is UTF8) + public static void WriteAllLinesWithRetries(string filePath, IEnumerable lines, int maxTries = 3, Encoding encoding = null) + { + int attempt = 0; + if (encoding == null) + encoding = Encoding.UTF8; + while (true) + { + try + { + File.WriteAllLines(filePath, lines, encoding); + } + catch (IOException) + { + attempt++; + if (attempt < maxTries) + Thread.Sleep(new Random().Next(50, 100) * attempt); // Back-off like CSMA/CD + else throw; + } + } + } } } diff --git a/MinecraftClient/INIFile.cs b/MinecraftClient/INIFile.cs index a6bcfb75..712bc205 100644 --- a/MinecraftClient/INIFile.cs +++ b/MinecraftClient/INIFile.cs @@ -8,7 +8,7 @@ namespace MinecraftClient { /// /// INI File tools for parsing and generating user-friendly INI files - /// By ORelio (c) 2014 - CDDL 1.0 + /// By ORelio (c) 2014-2020 - CDDL 1.0 /// static class INIFile { @@ -21,9 +21,21 @@ namespace MinecraftClient /// If failed to read the file /// Parsed data from INI file public static Dictionary> ParseFile(string iniFile, bool lowerCase = true) + { + return ParseFile(File.ReadAllLines(iniFile, Encoding.UTF8), lowerCase); + } + + /// + /// Parse a INI file into a dictionary. + /// Values can be accessed like this: dict["section"]["setting"] + /// + /// INI file content to parse + /// INI sections and keys will be converted to lowercase unless this parameter is set to false + /// If failed to read the file + /// Parsed data from INI file + public static Dictionary> ParseFile(IEnumerable lines, bool lowerCase = true) { var iniContents = new Dictionary>(); - string[] lines = File.ReadAllLines(iniFile, Encoding.UTF8); string iniSection = "default"; foreach (string lineRaw in lines) { @@ -62,6 +74,18 @@ namespace MinecraftClient /// INI file description, inserted as a comment on first line of the INI file /// Automatically change first char of section and keys to uppercase public static void WriteFile(string iniFile, Dictionary> contents, string description = null, bool autoCase = true) + { + File.WriteAllLines(iniFile, Generate(contents, description, autoCase), Encoding.UTF8); + } + + /// + /// Generate given data into the INI format + /// + /// Data to put into the INI format + /// INI file description, inserted as a comment on first line of the INI file + /// Automatically change first char of section and keys to uppercase + /// Lines of the INI file + public static string[] Generate(Dictionary> contents, string description = null, bool autoCase = true) { List lines = new List(); if (!String.IsNullOrWhiteSpace(description)) @@ -78,7 +102,7 @@ namespace MinecraftClient lines.Add((autoCase ? char.ToUpper(item.Key[0]) + item.Key.Substring(1) : item.Key) + '=' + item.Value); } } - File.WriteAllLines(iniFile, lines, Encoding.UTF8); + return lines.ToArray(); } /// diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index ba2b95dd..91b921c2 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1771,12 +1771,14 @@ namespace MinecraftClient public void OnPlayerLeave(Guid uuid) { string username = null; - if (onlinePlayers.ContainsKey(uuid)) - username = onlinePlayers[uuid]; lock (onlinePlayers) { - onlinePlayers.Remove(uuid); + if (onlinePlayers.ContainsKey(uuid)) + { + username = onlinePlayers[uuid]; + onlinePlayers.Remove(uuid); + } } DispatchBotEvent(bot => bot.OnPlayerLeave(uuid, username)); diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index cfac0d44..0b6964ed 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -150,7 +150,7 @@ - + diff --git a/MinecraftClient/Protocol/Session/SessionCache.cs b/MinecraftClient/Protocol/Session/SessionCache.cs index 279e4375..2038c8e9 100644 --- a/MinecraftClient/Protocol/Session/SessionCache.cs +++ b/MinecraftClient/Protocol/Session/SessionCache.cs @@ -23,7 +23,7 @@ namespace MinecraftClient.Protocol.Session "launcher_profiles.json" ); - private static SessionFileMonitor cachemonitor; + private static FileMonitor cachemonitor; private static Dictionary sessions = new Dictionary(); private static Timer updatetimer = new Timer(100); private static List> pendingadds = new List>(); @@ -81,7 +81,7 @@ namespace MinecraftClient.Protocol.Session /// TRUE if session tokens are seeded from file public static bool InitializeDiskCache() { - cachemonitor = new SessionFileMonitor(AppDomain.CurrentDomain.BaseDirectory, SessionCacheFilePlaintext, new FileSystemEventHandler(OnChanged)); + cachemonitor = new FileMonitor(AppDomain.CurrentDomain.BaseDirectory, SessionCacheFilePlaintext, new FileSystemEventHandler(OnChanged)); updatetimer.Elapsed += HandlePending; return LoadFromDisk(); } @@ -189,11 +189,11 @@ namespace MinecraftClient.Protocol.Session } catch (IOException ex) { - ConsoleIO.WriteLineFormatted("§8Failed to read session cache from disk: " + ex.Message); + ConsoleIO.WriteLineFormatted("§8Failed to read serialized session cache from disk: " + ex.Message); } catch (SerializationException ex2) { - ConsoleIO.WriteLineFormatted("§8Got malformed data while reading session cache from disk: " + ex2.Message); + ConsoleIO.WriteLineFormatted("§8Got malformed data while reading serialized session cache from disk: " + ex2.Message); } } @@ -203,33 +203,40 @@ namespace MinecraftClient.Protocol.Session if (Settings.DebugMessages) ConsoleIO.WriteLineFormatted("§8Loading session cache from disk: " + SessionCacheFilePlaintext); - foreach (string line in File.ReadAllLines(SessionCacheFilePlaintext)) + try { - if (!line.Trim().StartsWith("#")) + foreach (string line in FileMonitor.ReadAllLinesWithRetries(SessionCacheFilePlaintext)) { - string[] keyValue = line.Split('='); - if (keyValue.Length == 2) + if (!line.Trim().StartsWith("#")) { - try + string[] keyValue = line.Split('='); + if (keyValue.Length == 2) { - string login = keyValue[0].ToLower(); - SessionToken session = SessionToken.FromString(keyValue[1]); - if (Settings.DebugMessages) - ConsoleIO.WriteLineFormatted("§8Loaded session: " + login + ':' + session.ID); - sessions[login] = session; + try + { + string login = keyValue[0].ToLower(); + SessionToken session = SessionToken.FromString(keyValue[1]); + if (Settings.DebugMessages) + ConsoleIO.WriteLineFormatted("§8Loaded session: " + login + ':' + session.ID); + sessions[login] = session; + } + catch (InvalidDataException e) + { + if (Settings.DebugMessages) + ConsoleIO.WriteLineFormatted("§8Ignoring session token string '" + keyValue[1] + "': " + e.Message); + } } - catch (InvalidDataException e) + else if (Settings.DebugMessages) { - if (Settings.DebugMessages) - ConsoleIO.WriteLineFormatted("§8Ignoring session token string '" + keyValue[1] + "': " + e.Message); + ConsoleIO.WriteLineFormatted("§8Ignoring invalid session token line: " + line); } } - else if (Settings.DebugMessages) - { - ConsoleIO.WriteLineFormatted("§8Ignoring invalid session token line: " + line); - } } } + catch (IOException e) + { + ConsoleIO.WriteLineFormatted("§8Failed to read session cache from disk: " + e.Message); + } } return sessions.Count > 0; @@ -243,33 +250,20 @@ namespace MinecraftClient.Protocol.Session if (Settings.DebugMessages) ConsoleIO.WriteLineFormatted("§8Saving session cache to disk"); - bool fileexists = File.Exists(SessionCacheFilePlaintext); - IOException lastEx = null; - int attempt = 1; + List sessionCacheLines = new List(); + sessionCacheLines.Add("# Generated by MCC v" + Program.Version + " - Edit at own risk!"); + sessionCacheLines.Add("# Login=SessionID,PlayerName,UUID,ClientID"); + foreach (KeyValuePair entry in sessions) + sessionCacheLines.Add(entry.Key + '=' + entry.Value.ToString()); - while (attempt < 4) + try { - try - { - List sessionCacheLines = new List(); - sessionCacheLines.Add("# Generated by MCC v" + Program.Version + " - Edit at own risk!"); - sessionCacheLines.Add("# Login=SessionID,PlayerName,UUID,ClientID"); - foreach (KeyValuePair entry in sessions) - sessionCacheLines.Add(entry.Key + '=' + entry.Value.ToString()); - File.WriteAllLines(SessionCacheFilePlaintext, sessionCacheLines); - //if (File.Exists(SessionCacheFileSerialized)) - // File.Delete(SessionCacheFileSerialized); - return; - } - catch (IOException ex) - { - lastEx = ex; - attempt++; - System.Threading.Thread.Sleep(new Random().Next(150, 350) * attempt); //CSMA/CD :) - } + FileMonitor.WriteAllLinesWithRetries(SessionCacheFilePlaintext, sessionCacheLines); + } + catch (IOException e) + { + ConsoleIO.WriteLineFormatted("§8Failed to write session cache to disk: " + e.Message); } - - ConsoleIO.WriteLineFormatted("§8Failed to write session cache to disk" + (lastEx != null ? ": " + lastEx.Message : "")); } } }