#1823 - Address full disk better. Fail clearly and early when possible, fail safely when space cannot be queried, and do not take down the UI or spam the user when the disk is full.

This commit is contained in:
rmcrackan
2026-05-18 09:20:51 -04:00
parent 9f1d4a383b
commit 77f518f8d3
13 changed files with 362 additions and 29 deletions

View File

@@ -18,20 +18,17 @@ partial class MainVM
public async Task BackupAllBooks()
{
var books = await Task.Run(DbContexts.GetUnliberated_Flat_NoTracking);
BackupAllBooks(books);
await BackupAllBooksAsync(books);
}
private void BackupAllBooks(IEnumerable<LibraryBook> books)
private async Task BackupAllBooksAsync(IEnumerable<LibraryBook> books)
{
try
{
var unliberated = books.UnLiberated().ToArray();
Dispatcher.UIThread.Invoke(() =>
{
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
setQueueCollapseState(false);
});
if (await ProcessQueue.QueueDownloadDecryptAsync(unliberated))
setQueueCollapseState(false);
}
catch (Exception ex)
{

View File

@@ -34,7 +34,7 @@ partial class MainVM
{
try
{
if (ProcessQueue.QueueDownloadDecrypt(libraryBooks, config))
if (await ProcessQueue.QueueDownloadDecryptAsync(libraryBooks, config))
setQueueCollapseState(false);
else if (libraryBooks.Count == 1 && libraryBooks[0].Book.AudioExists)
{
@@ -53,13 +53,13 @@ partial class MainVM
}
}
public void LiberateSeriesClicked(SeriesEntry series)
public async void LiberateSeriesClicked(SeriesEntry series)
{
try
{
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
if (ProcessQueue.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
if (await ProcessQueue.QueueDownloadDecryptAsync(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
setQueueCollapseState(false);
}
catch (Exception ex)

View File

@@ -68,11 +68,11 @@ partial class MainVM
=> await setLiberatedVisibleMenuItemAsync();
public void LiberateVisible()
public async void LiberateVisible()
{
try
{
if (ProcessQueue.QueueDownloadDecrypt(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray()))
if (await ProcessQueue.QueueDownloadDecryptAsync(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray()))
setQueueCollapseState(false);
}
catch (Exception ex)

View File

@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace LibationFileManager;
/// <summary>
/// Detects disk-full I/O failures and reports free space for Libation backup paths.
/// Preflight uses <see cref="DriveInfo"/> when available; runtime detection uses actual write failures
/// (works even when free space cannot be queried, e.g. some UNC/SMB shares).
/// </summary>
public static class DiskSpaceHelper
{
/// <summary>Conservative per-title estimate (download + decrypt temp + final file) for bulk preflight.</summary>
public const long EstimatedBytesPerAudiobookBackup = 400_000_000L;
/// <summary>Below this free space on a relevant drive, bulk backup is blocked (no Continue).</summary>
public const long CriticalFreeBytes = 100_000_000L;
private const int HResultDiskFull = unchecked((int)0x80070070);
public static bool IsDiskFullException(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is IOException && current.HResult == HResultDiskFull)
return true;
if (ErrorMessageIndicatesDiskFull(current.Message))
return true;
}
return false;
}
/// <summary>
/// Matches Windows-style disk-full text from logs and StatusHandler errors.
/// Does not cover quota-specific wording from some NAS/cloud providers; those fall through to normal retry UI.
/// </summary>
public static bool ErrorMessageIndicatesDiskFull(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return false;
return message.Contains("not enough space on the disk", StringComparison.OrdinalIgnoreCase)
|| message.Contains("disk was full", StringComparison.OrdinalIgnoreCase)
|| message.Contains("there is not enough space on the disk", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Returns free bytes for the volume containing <paramref name="path"/>, or null if unknown.
/// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path).
/// On Windows, <see cref="Path.GetPathRoot"/> yields drive letters (C:\) or UNC roots (\\server\share\).
/// </summary>
public static long? TryGetAvailableFreeBytes(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
var fullPath = Path.GetFullPath(path);
var root = Path.GetPathRoot(fullPath);
if (string.IsNullOrWhiteSpace(root))
return null;
var drive = new DriveInfo(root);
// IsReady is false for disconnected network drives; AvailableFreeSpace may be wrong on some NAS reporting.
return drive.IsReady ? drive.AvailableFreeSpace : null;
}
catch
{
// DriveInfo can throw for invalid roots; treat as unknown rather than failing backup setup.
return null;
}
}
public static IReadOnlyList<BackupDriveSpace> GetBackupDriveSpaces(Configuration config, int bookCount)
{
var requiredBytes = Math.Max(0, bookCount) * EstimatedBytesPerAudiobookBackup;
var pathsByRoot = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
void addPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return;
string fullPath;
try
{
fullPath = Path.GetFullPath(path);
}
catch
{
return;
}
var root = Path.GetPathRoot(fullPath);
if (string.IsNullOrWhiteSpace(root))
return;
// Same physical share via Z: vs \\server\share appears as two roots; each gets the full estimate (conservative).
if (!pathsByRoot.TryGetValue(root, out var list))
{
list = [];
pathsByRoot[root] = list;
}
if (!list.Contains(fullPath, StringComparer.OrdinalIgnoreCase))
list.Add(fullPath);
}
addPath(config.Books?.Path);
addPath(config.InProgress);
return pathsByRoot
.Select(kvp => new BackupDriveSpace(
kvp.Key,
kvp.Value,
TryGetAvailableFreeBytes(kvp.Key),
requiredBytes))
.ToList();
}
/// <summary>
/// True when every root is unknown or has enough reported space. All-unknown => no preflight dialog.
/// </summary>
public static bool HasSufficientSpaceForBulkBackup(IReadOnlyList<BackupDriveSpace> drives)
=> drives.All(d => d.AvailableBytes is null || d.AvailableBytes >= d.RequiredBytes);
/// <summary>
/// Only applies when free space was read successfully; unknown (null) never hard-blocks.
/// </summary>
public static bool AnyDriveCriticallyLow(IReadOnlyList<BackupDriveSpace> drives)
=> drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < CriticalFreeBytes);
public readonly record struct BackupDriveSpace(
/// <summary>Path root from <see cref="Path.GetPathRoot"/> (e.g. C:\ or \\nas\library\).</summary>
string DriveRoot,
IReadOnlyList<string> Paths,
/// <summary>Null when <see cref="TryGetAvailableFreeBytes"/> could not query this root.</summary>
long? AvailableBytes,
long RequiredBytes);
}

View File

@@ -172,7 +172,9 @@ public static class FilePathCache
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}");
throw;
// Keep in-memory cache; rethrow other I/O errors (permissions, network drop). Disk full must not take down the UI.
if (!DiskSpaceHelper.IsDiskFullException(ex))
throw;
}
}
}

View File

@@ -0,0 +1,67 @@
using LibationFileManager;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace LibationUiBase;
/// <summary>
/// User-facing copy when backups fail or are blocked due to insufficient disk space.
/// </summary>
public static class DiskFullUserMessage
{
public const string DialogCaption = "Not enough disk space";
public static string BuildQueueStoppedBody()
=> """
Libation stopped the backup queue because the disk ran out of free space.
Download and decrypt use temporary files under your "In progress" folder, then write finished audiobooks to your Books location. Both need enough free space on their drives.
Free disk space (or move Books / In progress to a larger drive in Settings), delete partial files under your In progress folder if needed, then retry your backups in smaller batches.
""";
public static string BuildPreflightBlockedBody(IReadOnlyList<DiskSpaceHelper.BackupDriveSpace> drives, int bookCount)
{
var sb = new StringBuilder();
sb.AppendLine($"You are about to back up {bookCount} books, but a drive Libation uses does not have enough free space to continue safely.");
sb.AppendLine();
AppendDriveLines(sb, drives);
sb.AppendLine();
sb.Append("Free space or change the Books and In progress locations in Settings, then try again.");
return sb.ToString();
}
public static string BuildPreflightWarningBody(IReadOnlyList<DiskSpaceHelper.BackupDriveSpace> drives, int bookCount)
{
var sb = new StringBuilder();
sb.AppendLine($"You are about to back up {bookCount} books. Libation estimates you may need on the order of {FormatBytes(bookCount * DiskSpaceHelper.EstimatedBytesPerAudiobookBackup)} total, plus extra room for temporary files during each download.");
sb.AppendLine();
AppendDriveLines(sb, drives);
sb.AppendLine();
sb.Append("Continue anyway?");
return sb.ToString();
}
private static void AppendDriveLines(StringBuilder sb, IReadOnlyList<DiskSpaceHelper.BackupDriveSpace> drives)
{
foreach (var drive in drives)
{
// "unknown" when DriveInfo could not report space (typical for some network paths); user may still have chosen Continue.
var free = drive.AvailableBytes is null ? "unknown" : FormatBytes(drive.AvailableBytes.Value);
var needed = FormatBytes(drive.RequiredBytes);
sb.AppendLine($"{drive.DriveRoot} Free: {free} Estimated needed: {needed}");
foreach (var path in drive.Paths)
sb.AppendLine($" {path}");
}
}
private static string FormatBytes(long bytes)
{
const long gb = 1024L * 1024 * 1024;
if (bytes >= gb)
return $"{bytes / (double)gb:F1} GB";
const long mb = 1024 * 1024;
return $"{bytes / (double)mb:F0} MB";
}
}

View File

@@ -0,0 +1,48 @@
using LibationFileManager;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibationUiBase;
/// <summary>
/// Optional bulk-backup gate before titles are queued. Skipped for single-book backups.
/// Network paths that do not report free space pass through with no dialog; failures are handled at download time.
/// </summary>
public static class DiskSpaceBackupPreflight
{
private const int BulkBackupBookThreshold = 2;
public static async Task<bool> ConfirmBulkBackupAsync(int bookCount, Configuration config)
{
if (bookCount < BulkBackupBookThreshold)
return true;
var drives = DiskSpaceHelper.GetBackupDriveSpaces(config, bookCount);
// All roots unknown, or all have enough reported space: queue without prompting.
if (DiskSpaceHelper.HasSufficientSpaceForBulkBackup(drives))
return true;
// Known space below CriticalFreeBytes: do not offer Continue (avoids starting a huge queue on a full local disk).
if (DiskSpaceHelper.AnyDriveCriticallyLow(drives))
{
await MessageBoxBase.Show(
DiskFullUserMessage.BuildPreflightBlockedBody(drives, bookCount),
DiskFullUserMessage.DialogCaption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return false;
}
// At least one root reported space below estimate but above critical (or mixed known/unknown with a shortfall).
var result = await MessageBoxBase.Show(
DiskFullUserMessage.BuildPreflightWarningBody(drives, bookCount),
DiskFullUserMessage.DialogCaption,
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
return result == DialogResult.Yes;
}
}

View File

@@ -24,7 +24,9 @@ public enum ProcessBookResult
FailedSkip,
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage
LicenseDeniedPossibleOutage,
/// <summary>Volume full on write; queue should stop (see ProcessQueueViewModel queue loop).</summary>
DiskFull
}
public enum ProcessBookStatus
@@ -69,6 +71,7 @@ public class ProcessBookViewModel : ReactiveObject
(ProcessBookResult.LicenseDenied, true) => "License denied (Plus; often temporary)",
(ProcessBookResult.LicenseDenied, false) => "License Denied",
(ProcessBookResult.LicenseDeniedPossibleOutage, _) => "Possible Service Interruption",
(ProcessBookResult.DiskFull, _) => "Disk full, queue stopped",
_ => Status.ToString(),
};
@@ -152,6 +155,13 @@ public class ProcessBookViewModel : ReactiveObject
{
foreach (var errorMessage in statusHandler.Errors)
LogError($"{procName}: {errorMessage}");
// Prefer disk-full detection over generic retry; avoids treating truncated .aaxc as a normal failure.
if (statusHandler.Errors.Any(DiskSpaceHelper.ErrorMessageIndicatesDiskFull))
{
LogInfo($"{procName}: Disk is full. Free space or change Books / In progress in Settings. - {LibraryBook.Book}");
result = ProcessBookResult.DiskFull;
}
}
}
catch (ContentLicenseDeniedException ldex)
@@ -173,6 +183,13 @@ public class ProcessBookViewModel : ReactiveObject
result = ProcessBookResult.LicenseDenied;
}
}
// HRESULT 0x80070070 / known messages from the OS when a volume is full (including many SMB shares).
catch (Exception ex) when (DiskSpaceHelper.IsDiskFullException(ex))
{
Serilog.Log.Logger.Error(ex, "Disk full during {ProcName} for {{@Book}}", procName, LibraryBook.LogFriendly());
LogInfo($"{procName}: Disk is full. Free space or change Books / In progress in Settings. - {LibraryBook.Book}");
result = ProcessBookResult.DiskFull;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"Unhandled exception in {procName} for {{@Book}}", LibraryBook.LogFriendly());
@@ -180,6 +197,7 @@ public class ProcessBookViewModel : ReactiveObject
}
finally
{
// DiskFull skips the per-book Abort/Retry/Ignore dialog; the queue shows one disk-full message instead.
if (result == ProcessBookResult.None)
result = await GetFailureActionAsync(LibraryBook);
@@ -345,6 +363,9 @@ public class ProcessBookViewModel : ReactiveObject
{
foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed"))
LogError(errorMessage);
if (result.Errors.Any(DiskSpaceHelper.ErrorMessageIndicatesDiskFull))
LogInfo("Disk is full. Free space or change Books / In progress in Settings.");
}
}

View File

@@ -73,8 +73,13 @@ public class ProcessQueueViewModel : ReactiveObject
private void Queue_CompletedCountChanged(object? sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
var errCount = Queue.Completed.Count(p => p.Result
is ProcessBookResult.FailedAbort
or ProcessBookResult.FailedSkip
or ProcessBookResult.FailedRetry
or ProcessBookResult.ValidationFail
or ProcessBookResult.DiskFull);
var completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
@@ -127,7 +132,7 @@ public class ProcessQueueViewModel : ReactiveObject
return false;
}
public bool QueueDownloadDecrypt(IList<LibraryBook> libraryBooks, Configuration? config = null)
public async Task<bool> QueueDownloadDecryptAsync(IList<LibraryBook> libraryBooks, Configuration? config = null)
{
config ??= Configuration.Instance;
if (!IsBooksDirectoryValid(config))
@@ -182,6 +187,10 @@ public class ProcessQueueViewModel : ReactiveObject
if (toLiberate.Length > 0)
{
// May no-op when free space is unknown (common on UNC); see DiskSpaceBackupPreflight.
if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(toLiberate.Length, config))
return false;
Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length);
AddDownloadDecrypt(toLiberate, config);
return true;
@@ -299,6 +308,7 @@ public class ProcessQueueViewModel : ReactiveObject
ProgressBarVisible = true;
var startingTime = DateTime.Now;
bool shownLicenseGuidanceMessage = false;
bool shownDiskFullMessage = false;
using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500);
@@ -321,6 +331,20 @@ public class ProcessQueueViewModel : ReactiveObject
Queue.ClearCurrent();
else if (result == ProcessBookResult.FailedAbort)
Queue.ClearQueue();
// Stop the whole queue on first real disk-full write (local or network); do not retry hundreds of titles.
else if (result == ProcessBookResult.DiskFull)
{
if (!shownDiskFullMessage)
{
await MessageBoxBase.Show(
DiskFullUserMessage.BuildQueueStoppedBody(),
DiskFullUserMessage.DialogCaption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
shownDiskFullMessage = true;
}
Queue.ClearQueue();
}
else if (result == ProcessBookResult.FailedSkip)
await nextBook.LibraryBook.UpdateBookStatusAsync(LiberatedStatus.Error);
else if (!shownLicenseGuidanceMessage

View File

@@ -19,16 +19,13 @@ public partial class Form1
BackupAllBooks(library);
}
private void BackupAllBooks(IEnumerable<LibraryBook> books)
private async void BackupAllBooks(IEnumerable<LibraryBook> books)
{
try
{
var unliberated = books.UnLiberated().ToArray();
Invoke(() =>
{
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
SetQueueCollapseState(false);
});
if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(unliberated))
SetQueueCollapseState(false);
}
catch (Exception ex)
{

View File

@@ -23,11 +23,11 @@ public partial class Form1
this.Width = width;
}
private void ProductsDisplay_LiberateClicked(object sender, System.Collections.Generic.IList<LibraryBook> libraryBooks, Configuration config)
private async void ProductsDisplay_LiberateClicked(object sender, System.Collections.Generic.IList<LibraryBook> libraryBooks, Configuration config)
{
try
{
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks, config))
if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(libraryBooks, config))
SetQueueCollapseState(false);
else if (libraryBooks.Count == 1 && libraryBooks[0].Book.AudioExists)
{
@@ -46,13 +46,13 @@ public partial class Form1
}
}
private void ProductsDisplay_LiberateSeriesClicked(object sender, SeriesEntry series)
private async void ProductsDisplay_LiberateSeriesClicked(object sender, SeriesEntry series)
{
try
{
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
SetQueueCollapseState(false);
}
catch (Exception ex)

View File

@@ -54,11 +54,11 @@ public partial class Form1
});
}
private void liberateVisible(object sender, EventArgs e)
private async void liberateVisible(object sender, EventArgs e)
{
try
{
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray()))
if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(productsDisplay.GetVisible().UnLiberated().ToArray()))
SetQueueCollapseState(false);
}
catch (Exception ex)

View File

@@ -0,0 +1,32 @@
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
namespace LibationFileManager.Tests;
[TestClass]
public class DiskSpaceHelperTests
{
[TestMethod]
public void IsDiskFullException_detects_message()
{
var ex = new IOException("There is not enough space on the disk. : 'C:\\temp\\x.aaxc'.");
Assert.IsTrue(DiskSpaceHelper.IsDiskFullException(ex));
}
[TestMethod]
public void IsDiskFullException_detects_message_in_aggregate()
{
var inner = new IOException("Failed to create file because the disk was full.");
var ex = new AggregateException(inner);
Assert.IsTrue(DiskSpaceHelper.IsDiskFullException(ex));
}
[TestMethod]
public void ErrorMessageIndicatesDiskFull_matches_common_phrases()
{
Assert.IsTrue(DiskSpaceHelper.ErrorMessageIndicatesDiskFull("There is not enough space on the disk. : 'C:\\temp\\x.aaxc'."));
Assert.IsFalse(DiskSpaceHelper.ErrorMessageIndicatesDiskFull("Unable to read beyond the end of the stream."));
}
}