From 14e4ea4592e77a276e39c40c8fa1aa8b4cfa3df8 Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Thu, 12 Mar 2026 13:53:27 -0400 Subject: [PATCH 1/4] Resolve to an absolute path when reading --- Source/LibationFileManager/LibationFiles.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index 1268dc88..31c839f5 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -248,6 +248,17 @@ public class LibationFiles libationFiles = runShellCommand("echo " + libationFiles) ?? libationFiles; } + // Resolve relative paths to absolute using the appsettings.json directory as base, + // so that Location is consistent everywhere (e.g. avoids different resolution when + // loading Serilog config vs. checking SettingsAreValid). Fixes Linux crash when + // appsettings in process dir contains "./LibationFiles" (e.g. issue #1677). + if (!string.IsNullOrWhiteSpace(libationFiles) && !Path.IsPathRooted(libationFiles)) + { + var appSettingsDir = Path.GetDirectoryName(appsettingsPath.Path); + var basePath = !string.IsNullOrEmpty(appSettingsDir) ? appSettingsDir : Configuration.ProcessDirectory; + libationFiles = Path.GetFullPath(libationFiles, basePath); + } + return libationFiles; static string? runShellCommand(string command) From 24d825607ddfc537df830389833848db5f68a4a7 Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Thu, 12 Mar 2026 13:57:39 -0400 Subject: [PATCH 2/4] * Reads relative paths from appsettings and resolves them to absolute once. * Writes only absolute paths for LibationFiles, so future reads and all callers see a single, consistent path. --- Source/LibationFileManager/LibationFiles.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index 31c839f5..24e59788 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -66,19 +66,30 @@ public class LibationFiles /// /// Set the location of the Libation Files directory, updating appsettings.json. + /// Never persists a relative path; always writes an absolute path so resolution is consistent on all platforms. /// public void SetLibationFiles(LongPath libationFilesDirectory) { + var pathToPersist = libationFilesDirectory.Path; + if (!string.IsNullOrWhiteSpace(pathToPersist) && !Path.IsPathRooted(pathToPersist)) + { + var basePath = AppsettingsJsonFile is not null ? Path.GetDirectoryName(AppsettingsJsonFile) : null; + pathToPersist = Path.GetFullPath(pathToPersist, !string.IsNullOrEmpty(basePath) ? basePath : Configuration.ProcessDirectory); + } + if (AppsettingsJsonFile is null) { - Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, libationFilesDirectory); + Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, pathToPersist); + Location = pathToPersist; return; } + Location = pathToPersist; + var startingContents = File.ReadAllText(AppsettingsJsonFile); var jObj = JObject.Parse(startingContents); - jObj[LIBATION_FILES_KEY] = (string)(Location = libationFilesDirectory); + jObj[LIBATION_FILES_KEY] = pathToPersist; var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented); if (startingContents == endingContents) @@ -88,11 +99,11 @@ public class LibationFiles { // now it's set in the file again but no settings have moved yet File.WriteAllText(AppsettingsJsonFile, endingContents); - Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, libationFilesDirectory }); + Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist }); } catch (Exception ex) { - Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, libationFilesDirectory }); + Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist }); } } From 0b0f5184d250de0841cd3d0eece9c7528ec0ad8d Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Thu, 12 Mar 2026 14:55:14 -0400 Subject: [PATCH 3/4] 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 // From 0a576069df26e06830ac55f472ca9fdc40a38492 Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Thu, 12 Mar 2026 15:27:04 -0400 Subject: [PATCH 4/4] clean up essential file validation --- Source/AppScaffolding/LibationScaffolding.cs | 91 +++++++++---------- .../EssentialFileValidator.cs | 5 +- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index c6ab21f7..5ab979a8 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -282,67 +282,64 @@ public static class LibationScaffolding } private static void logStartupState(Configuration config) - { + { #if DEBUG - var mode = "Debug"; + var mode = "Debug"; #else var mode = "Release"; #endif - if (System.Diagnostics.Debugger.IsAttached) - mode += " (Debugger attached)"; + if (Debugger.IsAttached) + mode += " (Debugger attached)"; - // begin logging session with a form feed - Log.Logger.Information("\r\n\f"); + // 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) + return -1; + try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); } + catch { return -1; } + } - static int fileCount(FileManager.LongPath? longPath) - { - if (longPath is null) - return -1; - try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); } - catch { return -1; } - } + Log.Logger.Information("Begin. {@DebugInfo}", new + { + AppName = EntryAssembly?.GetName().Name, + Version = BuildVersion?.ToString(), + ReleaseIdentifier, + Configuration.OS, + Environment.OSVersion, + InteropFactory.InteropFunctionsType, + Mode = mode, + LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), + LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), + LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), + LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), + LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), + LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), - Log.Logger.Information("Begin. {@DebugInfo}", new - { - AppName = EntryAssembly?.GetName().Name, - Version = BuildVersion?.ToString(), - ReleaseIdentifier, - Configuration.OS, - Environment.OSVersion, - InteropFactory.InteropFunctionsType, - Mode = mode, - LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), - LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), - LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), - LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), - LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), - LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), + config.AutoScan, + config.BetaOptIn, + config.UseCoverAsFolderIcon, + config.LibationFiles, + AudibleFileStorage.BooksDirectory, - config.AutoScan, - config.BetaOptIn, - config.UseCoverAsFolderIcon, - config.LibationFiles, - AudibleFileStorage.BooksDirectory, + config.InProgress, - config.InProgress, + AudibleFileStorage.DownloadsInProgressDirectory, + DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory), - AudibleFileStorage.DownloadsInProgressDirectory, - DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory), + AudibleFileStorage.DecryptInProgressDirectory, + DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory), - AudibleFileStorage.DecryptInProgressDirectory, - DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory), + disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value), + }); - disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value), - }); + if (InteropFactory.InteropFunctionsType is null) + Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null"); + } - if (InteropFactory.InteropFunctionsType is null) - Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null"); - } - private static void wireUpSystemEvents(Configuration configuration) + private static void wireUpSystemEvents(Configuration configuration) { LibraryCommands.LibrarySizeChanged += (object? _, List libraryBooks) => SearchEngineCommands.FullReIndex(libraryBooks); diff --git a/Source/LibationFileManager/EssentialFileValidator.cs b/Source/LibationFileManager/EssentialFileValidator.cs index 9f4f8d7b..4a8eb5ef 100644 --- a/Source/LibationFileManager/EssentialFileValidator.cs +++ b/Source/LibationFileManager/EssentialFileValidator.cs @@ -67,6 +67,7 @@ public static class EssentialFileValidator // ensure we can open for read and write } + Log.Logger.Debug("Essential file validated: {DisplayName} at \"{Path}\"", displayName, path); return (true, null); } catch (Exception ex) @@ -108,8 +109,8 @@ public static class EssentialFileValidator 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); + Log.Logger.Error("Critical error! Essential file validation failed: {ErrorMessage}. Call stack: {StackTrace}", + errorMessage, Environment.StackTrace); } catch {