From 0b0f5184d250de0841cd3d0eece9c7528ec0ad8d Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Thu, 12 Mar 2026 14:55:14 -0400 Subject: [PATCH] Validate essential files early --- Source/AppScaffolding/LibationScaffolding.cs | 8 +- Source/ApplicationServices/DbContexts.cs | 10 ++ Source/LibationAvalonia/Program.cs | 4 + .../EssentialFileValidator.cs | 119 ++++++++++++++++++ Source/LibationFileManager/LibationFiles.cs | 2 + Source/LibationUiBase/LibationSetup.cs | 2 + Source/LibationWinForms/Program.cs | 3 + 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 Source/LibationFileManager/EssentialFileValidator.cs diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 72e5d3a1..c6ab21f7 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -294,6 +294,10 @@ public static class LibationScaffolding // begin logging session with a form feed Log.Logger.Information("\r\n\f"); + // Validate log file was created and is writable (OS may delay availability) + var logFilePath = Path.Combine(config.LibationFiles.Location, "Log.log"); + EssentialFileValidator.ValidateCreatedAndReport(logFilePath); + static int fileCount(FileManager.LongPath? longPath) { if (longPath is null) @@ -389,8 +393,8 @@ public static class LibationScaffolding } catch (Exception ex) { - // different text to make it easier to identify in logs, vs the AggregateException case above - Log.Logger.Error(ex, "Version check failed. General exception"); + // different text to make it easier to identify in logs, vs the AggregateException case above + Log.Logger.Error(ex, "Version check failed. General exception"); } return (null, null, null, false, false); } diff --git a/Source/ApplicationServices/DbContexts.cs b/Source/ApplicationServices/DbContexts.cs index 55198181..4ede0548 100644 --- a/Source/ApplicationServices/DbContexts.cs +++ b/Source/ApplicationServices/DbContexts.cs @@ -7,6 +7,8 @@ namespace ApplicationServices; public static class DbContexts { + private static bool _sqliteDbValidated; + /// Use for fully functional context, incl. SaveChanges(). For query-only, use the other method public static LibationContext GetContext() { @@ -14,6 +16,14 @@ public static class DbContexts ? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString) : LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString); context.Database.Migrate(); + + // Validate SQLite DB file was created and is accessible (once per process; OS may delay availability) + if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)) + { + EssentialFileValidator.ValidateCreatedAndReport(SqliteStorage.DatabasePath); + _sqliteDbValidated = true; + } + return context; } diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs index 1862045e..9e3491d0 100644 --- a/Source/LibationAvalonia/Program.cs +++ b/Source/LibationAvalonia/Program.cs @@ -6,6 +6,7 @@ using Avalonia.Threading; using FileManager; using LibationAvalonia.Dialogs; using LibationFileManager; +using LibationUiBase.Forms; using ReactiveUI.Avalonia; using System; using System.Diagnostics; @@ -40,6 +41,9 @@ static class Program } AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + // When essential file validation fails and the error cannot be written to the log, show the user + EssentialFileValidator.ShowUserWhenLogUnavailable = msg => Dispatcher.UIThread.Post(() => _ = MessageBoxBase.Show(null, msg, "Libation - Essential File Error", MessageBoxButtons.OK, MessageBoxIcon.Warning)); + //***********************************************// // // // do not use Configuration before this line // diff --git a/Source/LibationFileManager/EssentialFileValidator.cs b/Source/LibationFileManager/EssentialFileValidator.cs new file mode 100644 index 00000000..9f4f8d7b --- /dev/null +++ b/Source/LibationFileManager/EssentialFileValidator.cs @@ -0,0 +1,119 @@ +using Serilog; +using System; +using System.IO; +using System.Threading; + +namespace LibationFileManager; + +/// +/// Validates that essential files were created correctly after creation, with retries to allow for OS delay. +/// Callers should use to log errors and show the user when the log cannot be written. +/// +public static class EssentialFileValidator +{ + /// + /// Called when an essential file validation fails and the error could not be written to the log. + /// Set by the host (e.g. LibationAvalonia, LibationWinForms) to display the message to the user. + /// + public static Action? ShowUserWhenLogUnavailable { get; set; } + + /// + /// Default retry total duration (ms) when checking that a file is available after creation. + /// + public const int DefaultMaxRetriesMs = 1000; + + /// + /// Default delay (ms) between retries. + /// + public const int DefaultDelayMs = 50; + + /// + /// Validates that the file at exists and is readable and writable, + /// with retries to allow for OS delay between create and availability. + /// Error messages use the file name portion of . + /// + /// Full path to the file. + /// Total time to retry (ms). + /// Delay between retries (ms). + /// (true, null) if valid; (false, errorMessage) if validation failed. + public static (bool success, string? errorMessage) ValidateCreated( + string path, + int maxRetriesMs = DefaultMaxRetriesMs, + int delayMs = DefaultDelayMs) + { + if (string.IsNullOrWhiteSpace(path)) + return (false, "(unknown file): path is null or empty."); + + var displayName = Path.GetFileName(path); + if (string.IsNullOrWhiteSpace(displayName)) + displayName = path; + + var stopAt = DateTime.UtcNow.AddMilliseconds(maxRetriesMs); + Exception? lastEx = null; + + while (DateTime.UtcNow < stopAt) + { + try + { + if (!File.Exists(path)) + { + lastEx = new FileNotFoundException($"File not found after creation: {path}"); + Thread.Sleep(delayMs); + continue; + } + + using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) + { + // ensure we can open for read and write + } + + return (true, null); + } + catch (Exception ex) + { + lastEx = ex; + Thread.Sleep(delayMs); + } + } + + var msg = lastEx is not null + ? $"{displayName} could not be validated at \"{path}\": {lastEx.Message}" + : $"{displayName} could not be validated at \"{path}\" (file not found or not accessible)."; + return (false, msg); + } + + /// + /// Validates that the file was created correctly and, if validation fails, reports the failure (log and optionally user). + /// Equivalent to calling then when the result is not valid. + /// + /// True if the file is valid; false if validation failed (and failure has been reported). + public static bool ValidateCreatedAndReport( + string path, + int maxRetriesMs = DefaultMaxRetriesMs, + int delayMs = DefaultDelayMs) + { + var (success, errorMessage) = ValidateCreated(path, maxRetriesMs, delayMs); + if (!success && errorMessage is not null) + ReportValidationFailure(errorMessage); + return success; + } + + /// + /// Reports a validation failure: tries to log the error; if logging fails, invokes . + /// The message is prefixed with a strongly worded error notice for both log and user display. + /// + /// Message to log and optionally show to the user. + public static void ReportValidationFailure(string errorMessage) + { + var fullMessage = $"Critical error! Essential file validation failed: {errorMessage}"; + try + { + // Maybe change log level to Fatal later to stand out more + Log.Logger.Error(fullMessage); + } + catch + { + ShowUserWhenLogUnavailable?.Invoke(fullMessage); + } + } +} diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index 24e59788..e0a593c6 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -133,6 +133,7 @@ public class LibationFiles try { File.WriteAllText(settingsFile, "{}"); + EssentialFileValidator.ValidateCreatedAndReport(settingsFile); } catch (Exception createEx) { @@ -221,6 +222,7 @@ public class LibationFiles { Directory.CreateDirectory(dir); File.WriteAllText(appsettingsFile, endingContents); + EssentialFileValidator.ValidateCreatedAndReport(appsettingsFile); return appsettingsFile; } catch (Exception ex) diff --git a/Source/LibationUiBase/LibationSetup.cs b/Source/LibationUiBase/LibationSetup.cs index 6855566a..3d45ae2c 100644 --- a/Source/LibationUiBase/LibationSetup.cs +++ b/Source/LibationUiBase/LibationSetup.cs @@ -237,5 +237,7 @@ public class LibationSetup }; var contents = JsonConvert.SerializeObject(jObj, Formatting.Indented); File.WriteAllText(settingsFilePath, contents); + + EssentialFileValidator.ValidateCreatedAndReport(settingsFilePath); } } diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index fddd2bb3..28edd8ed 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -36,6 +36,9 @@ static class Program ApplicationConfiguration.Initialize(); + // When essential file validation fails and the error cannot be written to the log, show the user + EssentialFileValidator.ShowUserWhenLogUnavailable = msg => MessageBox.Show(msg, "Libation - Essential File Error", MessageBoxButtons.OK, MessageBoxIcon.Warning); + //***********************************************// // // // do not use Configuration before this line //