Validate essential files early

This commit is contained in:
rmcrackan
2026-03-12 14:55:14 -04:00
parent 24d825607d
commit 0b0f5184d2
7 changed files with 146 additions and 2 deletions

View File

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

View File

@@ -7,6 +7,8 @@ namespace ApplicationServices;
public static class DbContexts
{
private static bool _sqliteDbValidated;
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
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;
}

View File

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

View File

@@ -0,0 +1,119 @@
using Serilog;
using System;
using System.IO;
using System.Threading;
namespace LibationFileManager;
/// <summary>
/// Validates that essential files were created correctly after creation, with retries to allow for OS delay.
/// Callers should use <see cref="ReportValidationFailure"/> to log errors and show the user when the log cannot be written.
/// </summary>
public static class EssentialFileValidator
{
/// <summary>
/// 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.
/// </summary>
public static Action<string>? ShowUserWhenLogUnavailable { get; set; }
/// <summary>
/// Default retry total duration (ms) when checking that a file is available after creation.
/// </summary>
public const int DefaultMaxRetriesMs = 1000;
/// <summary>
/// Default delay (ms) between retries.
/// </summary>
public const int DefaultDelayMs = 50;
/// <summary>
/// Validates that the file at <paramref name="path"/> 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 <paramref name="path"/>.
/// </summary>
/// <param name="path">Full path to the file.</param>
/// <param name="maxRetriesMs">Total time to retry (ms).</param>
/// <param name="delayMs">Delay between retries (ms).</param>
/// <returns>(true, null) if valid; (false, errorMessage) if validation failed.</returns>
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);
}
/// <summary>
/// Validates that the file was created correctly and, if validation fails, reports the failure (log and optionally user).
/// Equivalent to calling <see cref="ValidateCreated"/> then <see cref="ReportValidationFailure"/> when the result is not valid.
/// </summary>
/// <returns>True if the file is valid; false if validation failed (and failure has been reported).</returns>
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;
}
/// <summary>
/// Reports a validation failure: tries to log the error; if logging fails, invokes <see cref="ShowUserWhenLogUnavailable"/>.
/// The message is prefixed with a strongly worded error notice for both log and user display.
/// </summary>
/// <param name="errorMessage">Message to log and optionally show to the user.</param>
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);
}
}
}

View File

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

View File

@@ -237,5 +237,7 @@ public class LibationSetup
};
var contents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
File.WriteAllText(settingsFilePath, contents);
EssentialFileValidator.ValidateCreatedAndReport(settingsFilePath);
}
}

View File

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