mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
Merge pull request #1951 from rmcrackan/cursor/heal-corrupt-lucene-search-index-3c5b
Heal a search index Lucene cannot open, and say which account needs a login
This commit is contained in:
24 files changed
+1065
-97
No files matched your search
@@ -341,10 +341,10 @@ public static class LibationScaffolding
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
|
||||
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||
=> SearchEngineCommands.UpdateBooks(books);
|
||||
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
|
||||
}
|
||||
|
||||
public static VersionCheckResult GetLatestRelease()
|
||||
|
||||
@@ -44,9 +44,41 @@ public static class SearchEngineCommands
|
||||
|
||||
public static event EventHandler? SearchEngineUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the index could not be updated even after automatic repair, so it needs the user's help.
|
||||
/// </summary>
|
||||
public static event EventHandler<Exception>? UpdateFailed;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
/// <summary>Updates the index after books were added to or removed from the library.</summary>
|
||||
public static void OnLibrarySizeChanged(List<LibraryBook> libraryBooks)
|
||||
=> tryUpdate(() => FullReIndex(libraryBooks));
|
||||
|
||||
/// <summary>Updates the index after book details, tags or statuses were committed.</summary>
|
||||
public static void OnBookUserDefinedItemCommitted(IEnumerable<LibraryBook> books)
|
||||
=> tryUpdate(() => UpdateBooks(books));
|
||||
|
||||
/// <summary>
|
||||
/// The database change that triggers an update is committed before the update runs, and this index is derived
|
||||
/// from that database, so a failure here is reported instead of propagated. Letting it escape reported a
|
||||
/// successful scan as "Error importing library", and, since this is the first subscriber to those events,
|
||||
/// stopped the handlers that refresh the grid and the backup counts from running at all.
|
||||
/// </summary>
|
||||
private static void tryUpdate(Action update)
|
||||
{
|
||||
try
|
||||
{
|
||||
update();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds.");
|
||||
UpdateFailed?.Invoke(null, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateBooks(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>Describes whether an account has usable stored Audible tokens.</summary>
|
||||
public static class AccountCredentialStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged
|
||||
/// in or its credentials were cleared, rather than merely holding an expired access token.
|
||||
/// </summary>
|
||||
public static bool LooksLikeMissingCredentials(Account? account)
|
||||
{
|
||||
if (account?.IdentityTokens is not { } tokens)
|
||||
return true;
|
||||
|
||||
if (tokens.IsValid)
|
||||
return false;
|
||||
|
||||
// without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry
|
||||
return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to
|
||||
/// public issue reports, which is what <see cref="Account.MaskedLogEntry"/> is for.
|
||||
/// </summary>
|
||||
public static string FormatAccountLabel(Account? account)
|
||||
{
|
||||
if (account is null)
|
||||
return "an Audible account";
|
||||
|
||||
return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId)
|
||||
? $"'{account.AccountId}'"
|
||||
: $"'{account.AccountName}' ({account.AccountId})";
|
||||
}
|
||||
}
|
||||
@@ -59,19 +59,27 @@ public class ApiExtended
|
||||
{
|
||||
if (!allowInteractiveLogin || LoginChoiceFactory is null)
|
||||
{
|
||||
// an account that was never logged in needs a first login, not a renewal, and saying so saves the
|
||||
// user hunting for an expired session that never existed
|
||||
var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
|
||||
|
||||
Serilog.Log.Logger.Error(ex,
|
||||
"Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
|
||||
missingCredentials
|
||||
? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}"
|
||||
: "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
|
||||
new
|
||||
{
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name,
|
||||
AllowInteractiveLogin = allowInteractiveLogin,
|
||||
LoginChoiceFactorySet = LoginChoiceFactory is not null
|
||||
LoginChoiceFactorySet = LoginChoiceFactory is not null,
|
||||
LooksLikeMissingCredentials = missingCredentials
|
||||
});
|
||||
|
||||
throw new AuthenticationRequiredException(
|
||||
account,
|
||||
message: $"Stored credentials for '{account.AccountId}' could not be used"
|
||||
message: $"Stored credentials for '{account.AccountId}' "
|
||||
+ (missingCredentials ? "are missing or incomplete" : "could not be used")
|
||||
+ (LoginChoiceFactory is null
|
||||
? " and interactive login is not available in this context (CLI/Docker)."
|
||||
: " and interactive login was not allowed.")
|
||||
|
||||
@@ -24,4 +24,28 @@ public static class AuthenticationExceptionHelper
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the <see cref="AuthenticationRequiredException"/> in <paramref name="ex"/> or its inner chain, which is
|
||||
/// the one that knows which account needs a login.
|
||||
/// </summary>
|
||||
public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex)
|
||||
{
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException auth)
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
{
|
||||
if (FindAuthenticationRequired(inner) is { } found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
@@ -56,22 +57,42 @@ partial class MainVM
|
||||
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);
|
||||
public async Task PerformFilter(QuickFilters.NamedFilter? namedFilter)
|
||||
{
|
||||
SelectedNamedFilter = namedFilter;
|
||||
var tryFilter = namedFilter?.Filter;
|
||||
|
||||
var failure = await applyFilterAsync(namedFilter);
|
||||
if (failure is null)
|
||||
return;
|
||||
|
||||
Serilog.Log.Logger.Error(failure, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
|
||||
|
||||
if (SearchIndexRecovery.IsIndexUnavailable(failure))
|
||||
await MessageBox.Show(SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
else
|
||||
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// Restore the last filter that worked, then give up on filtering entirely. Recursing into PerformFilter
|
||||
// here never terminated when the search index rather than the query was at fault, because that fails for
|
||||
// every filter including the one being restored. An empty filter never reaches the search engine.
|
||||
if (lastGoodSearch.Length > 0 && await applyFilterAsync(lastGoodFilter) is null)
|
||||
return;
|
||||
|
||||
await applyFilterAsync(new(string.Empty, null));
|
||||
}
|
||||
|
||||
/// <summary>Applies a filter, returning the exception that stopped it, or null when it worked.</summary>
|
||||
private async Task<Exception?> applyFilterAsync(QuickFilters.NamedFilter? namedFilter)
|
||||
{
|
||||
SelectedNamedFilter = namedFilter;
|
||||
|
||||
try
|
||||
{
|
||||
await ProductsDisplay.Filter(tryFilter);
|
||||
await ProductsDisplay.Filter(namedFilter?.Filter);
|
||||
lastGoodSearch = namedFilter?.Filter ?? "";
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
|
||||
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// re-apply last good filter
|
||||
namedFilter = (namedFilter ?? new(string.Empty, null)) with { Filter = lastGoodSearch };
|
||||
await PerformFilter(namedFilter);
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,12 @@ partial class MainVM
|
||||
Configuration.Instance.PropertyChanged += startAutoScan;
|
||||
}
|
||||
|
||||
private async Task notifyAutoScanAuthRequiredAsync()
|
||||
private async Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
|
||||
{
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
"Libation could not refresh your Audible library because your login session expired.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
|
||||
"Auto-scan paused - login required",
|
||||
AutoScanAuthPrompt.FormatBody(ex),
|
||||
AutoScanAuthPrompt.Caption,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ApplicationServices;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
|
||||
partial class MainVM
|
||||
{
|
||||
private void Configure_SearchIndex()
|
||||
=> SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
|
||||
|
||||
private async void searchIndexUpdateFailed(object? sender, Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!SearchIndexRecovery.ShouldNotify())
|
||||
return;
|
||||
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
SearchIndexRecovery.ManualRecoveryInstructions,
|
||||
SearchIndexRecovery.Caption,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
catch (Exception dialogEx)
|
||||
{
|
||||
// nothing above this is allowed to fail: the library change that got us here already succeeded
|
||||
Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public partial class MainVM : ViewModelBase
|
||||
Configure_Liberate();
|
||||
Configure_ProcessQueue();
|
||||
Configure_ScanAuto();
|
||||
Configure_SearchIndex();
|
||||
Configure_Settings();
|
||||
Configure_VisibleBooks();
|
||||
}
|
||||
|
||||
@@ -80,58 +80,105 @@ public class SearchEngine
|
||||
/// <summary>create new. ie: full re-index</summary>
|
||||
public void CreateNewIndex(IEnumerable<LibraryBook> library, bool overwrite = true)
|
||||
{
|
||||
const int maxRetries = 5;
|
||||
const int baseDelayMs = 400;
|
||||
var libraryList = library.ToList();
|
||||
// Corruption (e.g. checksum mismatch in segments) is not fixed by waiting; clear and rebuild immediately.
|
||||
var corruptRebuildAttemptsRemaining = 2;
|
||||
|
||||
// Exponential backoff retry: 400 ms, 800 ms, 1600 ms, etc
|
||||
// Total wait time before giving up: 12.4 sec
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
// location of index/create the index
|
||||
using var index = getIndex();
|
||||
|
||||
// analyzer for tokenizing text. same analyzer should be used for indexing and searching
|
||||
using var analyzer = new StandardAnalyzer(Version);
|
||||
using var ixWriter = openWriterForRebuild(index, analyzer, overwrite);
|
||||
|
||||
foreach (var libraryBook in libraryList)
|
||||
{
|
||||
var doc = createBookIndexDocument(libraryBook);
|
||||
ixWriter.AddDocument(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the writer for a full re-index, waiting out lock conflicts and repairing an index Lucene cannot open.
|
||||
/// </summary>
|
||||
private IndexWriter openWriterForRebuild(Lucene.Net.Store.Directory index, StandardAnalyzer analyzer, bool overwrite)
|
||||
{
|
||||
// Exponential backoff for lock conflicts: 400 ms, 800 ms, 1600 ms, 3200 ms. 6.4 sec total before giving up.
|
||||
const int maxAttempts = 5;
|
||||
const int baseDelayMs = 400;
|
||||
var repairsRemaining = 1;
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
createNewIndexCore(libraryList, overwrite);
|
||||
return;
|
||||
var createNewIndex = overwrite || !IndexReader.IndexExists(index);
|
||||
return new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
|
||||
}
|
||||
catch (CorruptIndexException ex) when (corruptRebuildAttemptsRemaining-- > 0)
|
||||
catch (Exception ex) when (isTransientLockConflict(ex) && attempt < maxAttempts)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Lucene search index corrupt at {Path}. Clearing for rebuild.", SearchEngineDirectory);
|
||||
deleteAllSearchIndexFiles(SearchEngineDirectory);
|
||||
attempt--;
|
||||
}
|
||||
catch (IOException ex) when (attempt < maxRetries - 1 && ex is not CorruptIndexException)
|
||||
{
|
||||
var delayMs = baseDelayMs * (1 << attempt);
|
||||
// write.lock can be held by another process (e.g. second Libation instance, antivirus) or a prior writer that did not release. Retry after delay.
|
||||
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
|
||||
var delayMs = baseDelayMs * (1 << (attempt - 1));
|
||||
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt, maxAttempts, delayMs);
|
||||
Thread.Sleep(delayMs);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex) when (attempt < maxRetries - 1)
|
||||
catch (Exception ex) when (!isTransientLockConflict(ex) && repairsRemaining-- > 0)
|
||||
{
|
||||
var delayMs = baseDelayMs * (1 << attempt);
|
||||
// Windows may report "file in use" as UnauthorizedAccessException
|
||||
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
|
||||
Thread.Sleep(delayMs);
|
||||
// Passing overwrite/create to IndexWriter does not repair an unreadable index: its IndexFileDeleter
|
||||
// reads every segments_* file in the directory and only tolerates missing ones, so a single unreadable
|
||||
// segments file -- even a stale one from an older commit -- keeps the whole directory unusable until it
|
||||
// is deleted. This is a full re-index, so nothing on disk is worth preserving.
|
||||
// warning, not error: the index is a cache of the database, so rebuilding it resolves this
|
||||
Serilog.Log.Logger.Warning(ex, "Search index at {Path} could not be opened. Deleting it and rebuilding from the library.", SearchEngineDirectory);
|
||||
|
||||
if (deleteAllSearchIndexFiles(SearchEngineDirectory) is { Count: > 0 } undeletable)
|
||||
throw new IOException($"The search index at '{SearchEngineDirectory}' is damaged and could not be deleted automatically. Close Libation, delete that folder, then restart. Undeletable file(s): {string.Join(", ", undeletable)}", ex);
|
||||
|
||||
// the repair, not the failed open, gets a fresh allowance of lock retries
|
||||
attempt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lucene 3 parses <c>segments_*</c> filenames in the index directory. Cloud sync (e.g. OneDrive) can leave debris
|
||||
/// or conflict copies whose names break that parser, throwing <see cref="ArgumentException"/> with this message shape.
|
||||
/// Actual error is likely to be something like: Invalid or unsupported character in number, hence this string check.
|
||||
/// <see cref="CorruptIndexException"/> (e.g. checksum mismatch in segments) is also recoverable by deleting the index and rebuilding.
|
||||
/// True when the index could not be opened because something else is holding it -- a second Libation instance,
|
||||
/// antivirus, a backup agent -- which is worth waiting out rather than repairing.
|
||||
/// <see cref="LockObtainFailedException"/> derives from <see cref="IOException"/>, so a bare
|
||||
/// <c>IOException</c> check cannot tell a lock conflict apart from a damaged index. Nor can the type alone:
|
||||
/// Windows raises a sharing violation on the lock file before Lucene turns it into a
|
||||
/// <see cref="LockObtainFailedException"/>, so a plain <see cref="IOException"/> naming the lock file counts
|
||||
/// too. Matching the file name rather than the wording keeps that working on non-English Windows.
|
||||
/// </summary>
|
||||
private static bool isTransientLockConflict(Exception ex)
|
||||
=> ex is LockObtainFailedException
|
||||
|| (ex is IOException && ex.Message.Contains(IndexWriter.WRITE_LOCK_NAME, StringComparison.OrdinalIgnoreCase))
|
||||
// Windows may report "file in use" as UnauthorizedAccessException
|
||||
|| ex is UnauthorizedAccessException;
|
||||
|
||||
/// <summary>
|
||||
/// True when Lucene cannot read the index and the only cure is deleting it and rebuilding from the database.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="CorruptIndexException"/>: eg. checksum mismatch in the segments file.</item>
|
||||
/// <item>A truncated or zero-length <c>segments_*</c> file, which Lucene 3 reports as a plain
|
||||
/// <see cref="IOException"/> from <c>BufferedIndexInput.Refill</c> rather than as a
|
||||
/// <see cref="CorruptIndexException"/>. Matched by message because the type is shared with
|
||||
/// <see cref="LockObtainFailedException"/>, which must keep retrying instead.</item>
|
||||
/// <item>Lucene 3 parses <c>segments_*</c> filenames in the index directory. Cloud sync (eg. OneDrive) can leave
|
||||
/// debris or conflict copies whose names break that parser, throwing <see cref="ArgumentException"/> with a
|
||||
/// message like "Invalid or unsupported character in number", hence this string check.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static bool IsRecoverableCorruptIndexException(Exception ex)
|
||||
=> ex is CorruptIndexException
|
||||
|| (ex is IOException && !isTransientLockConflict(ex) && ex.Message.Contains("read past EOF", StringComparison.OrdinalIgnoreCase))
|
||||
|| (ex is ArgumentException aex && aex.Message.Contains("character in number", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void deleteAllSearchIndexFiles(string searchEngineDirectory)
|
||||
/// <summary>
|
||||
/// Best-effort delete of everything under the index directory. Returns the <c>segments_*</c> files that survived:
|
||||
/// Lucene rebuilds happily in an empty directory, and ignores leftover segment data, but any segments file it
|
||||
/// cannot read keeps the directory permanently unusable.
|
||||
/// </summary>
|
||||
private static List<string> deleteAllSearchIndexFiles(string searchEngineDirectory)
|
||||
{
|
||||
if (!System.IO.Directory.Exists(searchEngineDirectory))
|
||||
return;
|
||||
return [];
|
||||
|
||||
foreach (var file in System.IO.Directory.GetFiles(searchEngineDirectory, "*", SearchOption.AllDirectories))
|
||||
FileUtility.TrySaferDelete(file);
|
||||
@@ -147,39 +194,10 @@ public class SearchEngine
|
||||
Serilog.Log.Logger.Warning(ex, "Could not remove search index subdirectory {Dir}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void createNewIndexCore(List<LibraryBook> library, bool overwrite)
|
||||
{
|
||||
bool indexExists;
|
||||
using (var indexProbe = getIndex())
|
||||
{
|
||||
try
|
||||
{
|
||||
indexExists = IndexReader.IndexExists(indexProbe);
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableCorruptIndexException(ex))
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Lucene search index at {Path} is unreadable or corrupt (often cloud-sync debris or a partial write). Clearing it for rebuild.", SearchEngineDirectory);
|
||||
indexExists = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!indexExists)
|
||||
deleteAllSearchIndexFiles(SearchEngineDirectory);
|
||||
|
||||
// location of index/create the index
|
||||
using var index = getIndex();
|
||||
var createNewIndex = overwrite || !indexExists;
|
||||
|
||||
// analyzer for tokenizing text. same analyzer should be used for indexing and searching
|
||||
using var analyzer = new StandardAnalyzer(Version);
|
||||
using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
|
||||
foreach (var libraryBook in library)
|
||||
{
|
||||
var doc = createBookIndexDocument(libraryBook);
|
||||
ixWriter.AddDocument(doc);
|
||||
}
|
||||
return System.IO.Directory.Exists(searchEngineDirectory)
|
||||
? [.. System.IO.Directory.GetFiles(searchEngineDirectory, "segments*", SearchOption.AllDirectories)]
|
||||
: [];
|
||||
}
|
||||
|
||||
public SearchEngine(string? directory = null)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using AudibleUtilities;
|
||||
using System;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>Shared copy for the dialog shown when auto-scan pauses itself waiting for a login.</summary>
|
||||
public static class AutoScanAuthPrompt
|
||||
{
|
||||
public const string Caption = "Auto-scan paused - login required";
|
||||
|
||||
public static string FormatBody(AuthenticationRequiredException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
var account = AccountCredentialStatus.FormatAccountLabel(ex.Account);
|
||||
var cause = AccountCredentialStatus.LooksLikeMissingCredentials(ex.Account)
|
||||
? "that account has never been logged in, or its stored credentials are missing"
|
||||
: "the stored login for that account expired";
|
||||
|
||||
return $"Libation could not refresh the Audible library for {account} because {cause}.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in for that account and resume periodic scans.";
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public sealed class AutoScanRunner
|
||||
private readonly Func<bool> isAutoScanEnabled;
|
||||
private readonly Action pauseTimer;
|
||||
private readonly Action resumeTimer;
|
||||
private readonly Func<Task>? notifyAuthRequired;
|
||||
private readonly Func<AuthenticationRequiredException, Task>? notifyAuthRequired;
|
||||
|
||||
private bool pausedForAuthentication;
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class AutoScanRunner
|
||||
Func<bool> isAutoScanEnabled,
|
||||
Action pauseTimer,
|
||||
Action resumeTimer,
|
||||
Func<Task>? notifyAuthRequired = null)
|
||||
Func<AuthenticationRequiredException, Task>? notifyAuthRequired = null)
|
||||
{
|
||||
this.isAutoScanEnabled = isAutoScanEnabled;
|
||||
this.pauseTimer = pauseTimer;
|
||||
@@ -72,7 +72,11 @@ public sealed class AutoScanRunner
|
||||
}
|
||||
catch (Exception ex) when (AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
await pauseForAuthenticationAsync(ex);
|
||||
// LoginFailedException and the "ADP token is null" case do not name an account, so fall back to one
|
||||
// that at least carries the original failure
|
||||
await pauseForAuthenticationAsync(
|
||||
AuthenticationExceptionHelper.FindAuthenticationRequired(ex)
|
||||
?? new AuthenticationRequiredException(account: null, message: ex.Message, innerException: ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -80,7 +84,7 @@ public sealed class AutoScanRunner
|
||||
}
|
||||
}
|
||||
|
||||
private async Task pauseForAuthenticationAsync(Exception ex)
|
||||
private async Task pauseForAuthenticationAsync(AuthenticationRequiredException ex)
|
||||
{
|
||||
if (pausedForAuthentication)
|
||||
return;
|
||||
@@ -88,9 +92,12 @@ public sealed class AutoScanRunner
|
||||
pausedForAuthentication = true;
|
||||
pauseTimer();
|
||||
|
||||
Log.Warning(ex, "Auto-scan paused: Audible login is required. Log in with Import > Scan Library to resume background scans.");
|
||||
// masked, not the label the dialog uses: log files get attached to public issue reports
|
||||
Log.Warning(ex,
|
||||
"Auto-scan paused: Audible login is required for {Account}. Log in with Import > Scan Library to resume background scans.",
|
||||
ex.Account?.MaskedLogEntry ?? "[unknown account]");
|
||||
|
||||
if (notifyAuthRequired is not null)
|
||||
await notifyAuthRequired();
|
||||
await notifyAuthRequired(ex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using LibationSearchEngine;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>
|
||||
/// Shared copy for telling the user that Libation's search index needs to be deleted by hand.
|
||||
/// Reached only after the automatic delete-and-rebuild has already failed.
|
||||
/// </summary>
|
||||
public static class SearchIndexRecovery
|
||||
{
|
||||
public const string Caption = "Search index needs attention";
|
||||
|
||||
public const string ManualRecoveryInstructions
|
||||
= "Libation could not use its search index, and could not repair it automatically. "
|
||||
+ "Your library itself is fine; only searching and filtering are affected.\n\n"
|
||||
+ "To fix it by hand:\n"
|
||||
+ "1. In Settings, click 'Open log folder'\n"
|
||||
+ "2. Close Libation\n"
|
||||
+ "3. Delete the SearchEngine folder you find there\n"
|
||||
+ "4. Start Libation again";
|
||||
|
||||
/// <summary>
|
||||
/// True when a search failed because the index could not be reached, rather than because the query was
|
||||
/// malformed. A damaged index, a held write.lock and a permission problem all arrive as IO-family exceptions,
|
||||
/// plus the cloud-sync debris that Lucene reports as an <see cref="ArgumentException"/>; a query Lucene cannot
|
||||
/// parse arrives as none of those. Getting this backwards would send the user hunting for a typo in a query
|
||||
/// that was fine.
|
||||
/// </summary>
|
||||
public static bool IsIndexUnavailable(Exception ex)
|
||||
=> ex is IOException or UnauthorizedAccessException
|
||||
|| SearchEngine.IsRecoverableCorruptIndexException(ex);
|
||||
|
||||
private static int notified;
|
||||
|
||||
/// <summary>
|
||||
/// True the first time the index fails in this session, false afterwards. A damaged index fails on every
|
||||
/// library change, and these steps only need following once.
|
||||
/// </summary>
|
||||
public static bool ShouldNotify() => Interlocked.Exchange(ref notified, 1) == 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationWinForms.Dialogs;
|
||||
using LibationUiBase;
|
||||
using LibationWinForms.Dialogs;
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -29,6 +30,28 @@ public partial class Form1
|
||||
|
||||
private string? lastGoodFilter = null;
|
||||
private void performFilter(string? filterString)
|
||||
{
|
||||
if (applyFilter(filterString) is not Exception failure)
|
||||
return;
|
||||
|
||||
Serilog.Log.Logger.Error(failure, "Error performing filtering. {@DebugInfo}", new { filterString, lastGoodFilter });
|
||||
|
||||
if (SearchIndexRecovery.IsIndexUnavailable(failure))
|
||||
MessageBox.Show(this, SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
else
|
||||
MessageBox.Show(this, $"Bad filter string:\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// Restore the last filter that worked, then give up on filtering entirely. Recursing into performFilter
|
||||
// here never terminated when the search index rather than the query was at fault, because that fails for
|
||||
// every filter including the one being restored. An empty filter never reaches the search engine.
|
||||
if (!string.IsNullOrEmpty(lastGoodFilter) && applyFilter(lastGoodFilter) is null)
|
||||
return;
|
||||
|
||||
applyFilter(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Applies a filter, returning the exception that stopped it, or null when it worked.</summary>
|
||||
private Exception? applyFilter(string? filterString)
|
||||
{
|
||||
this.filterSearchTb.Text = filterString;
|
||||
|
||||
@@ -36,13 +59,11 @@ public partial class Form1
|
||||
{
|
||||
productsDisplay.Filter(filterString);
|
||||
lastGoodFilter = filterString;
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// re-apply last good filter
|
||||
performFilter(lastGoodFilter);
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,26 +39,25 @@ public partial class Form1
|
||||
Configuration.Instance.PropertyChanged += Configuration_PropertyChanged;
|
||||
}
|
||||
|
||||
private void notifyAutoScanAuthRequired()
|
||||
private void notifyAutoScanAuthRequired(AuthenticationRequiredException ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Libation could not refresh your Audible library because your login session expired.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
|
||||
"Auto-scan paused - login required",
|
||||
AutoScanAuthPrompt.FormatBody(ex),
|
||||
AutoScanAuthPrompt.Caption,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
private Task notifyAutoScanAuthRequiredAsync()
|
||||
private Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
Invoke(notifyAutoScanAuthRequired);
|
||||
Invoke(() => notifyAutoScanAuthRequired(ex));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
notifyAutoScanAuthRequired();
|
||||
notifyAutoScanAuthRequired(ex);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using ApplicationServices;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms;
|
||||
|
||||
public partial class Form1
|
||||
{
|
||||
private void Configure_SearchIndex()
|
||||
=> SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
|
||||
|
||||
private void searchIndexUpdateFailed(object? sender, Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!SearchIndexRecovery.ShouldNotify())
|
||||
return;
|
||||
|
||||
if (InvokeRequired)
|
||||
{
|
||||
BeginInvoke(showSearchIndexRecoveryInstructions);
|
||||
return;
|
||||
}
|
||||
|
||||
showSearchIndexRecoveryInstructions();
|
||||
}
|
||||
catch (Exception dialogEx)
|
||||
{
|
||||
// nothing above this is allowed to fail: the library change that got us here already succeeded
|
||||
Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
|
||||
}
|
||||
}
|
||||
|
||||
private void showSearchIndexRecoveryInstructions()
|
||||
=> MessageBox.Show(
|
||||
this,
|
||||
SearchIndexRecovery.ManualRecoveryInstructions,
|
||||
SearchIndexRecovery.Caption,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public partial class Form1 : Form
|
||||
// eg: if one of these init'd productsGrid, then another can't reliably subscribe to it
|
||||
Configure_BackupCounts();
|
||||
Configure_ScanAuto();
|
||||
Configure_SearchIndex();
|
||||
Configure_ScanNotification();
|
||||
Configure_VisibleBooks();
|
||||
Configure_QuickFilters();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using ApplicationServices;
|
||||
using AssertionHelper;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace SearchIndexUpdateGuardTests;
|
||||
|
||||
/// <summary>
|
||||
/// A library change is committed to the database before the search index is updated, so a failure to update that
|
||||
/// index must be reported rather than propagated. Letting it escape reported a successful scan as
|
||||
/// "Error importing library" and starved the event's remaining subscribers.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class SearchIndexUpdateGuardTests
|
||||
{
|
||||
private string tempLibationFiles = string.Empty;
|
||||
private readonly List<Exception> reportedFailures = [];
|
||||
|
||||
[TestInitialize]
|
||||
public void Initialize()
|
||||
{
|
||||
tempLibationFiles = Path.Combine(Path.GetTempPath(), $"libation-search-index-guard-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempLibationFiles);
|
||||
|
||||
// A fresh Configuration resolves LibationFiles from this variable, so the index lands in the temp dir.
|
||||
Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, tempLibationFiles);
|
||||
Configuration.CreateMockInstance();
|
||||
|
||||
SearchEngineCommands.UpdateFailed += recordFailure;
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
SearchEngineCommands.UpdateFailed -= recordFailure;
|
||||
Configuration.RestoreSingletonInstance();
|
||||
Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, null);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempLibationFiles, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// a leftover temp directory is not worth failing a test over
|
||||
}
|
||||
}
|
||||
|
||||
private void recordFailure(object? sender, Exception ex) => reportedFailures.Add(ex);
|
||||
|
||||
/// <summary>Occupying the SearchEngine path with a file leaves the engine nowhere to build its index.</summary>
|
||||
private void blockTheIndexDirectory()
|
||||
=> File.WriteAllText(Path.Combine(tempLibationFiles, "SearchEngine"), "not a directory");
|
||||
|
||||
private static List<LibraryBook> library()
|
||||
{
|
||||
var contributor = Contributor.GetEmpty();
|
||||
var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
|
||||
return [new LibraryBook(book, new DateTime(2026, 8, 15), "account")];
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_library_size_change_survives_an_unusable_search_index()
|
||||
{
|
||||
blockTheIndexDirectory();
|
||||
|
||||
SearchEngineCommands.OnLibrarySizeChanged(library());
|
||||
|
||||
reportedFailures.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_book_detail_change_survives_an_unusable_search_index()
|
||||
{
|
||||
blockTheIndexDirectory();
|
||||
|
||||
SearchEngineCommands.OnBookUserDefinedItemCommitted(library());
|
||||
|
||||
reportedFailures.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_successful_update_reports_no_failure()
|
||||
{
|
||||
SearchEngineCommands.OnLibrarySizeChanged(library());
|
||||
|
||||
reportedFailures.Should().HaveCount(0);
|
||||
Directory.Exists(Path.Combine(tempLibationFiles, "SearchEngine")).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using AssertionHelper;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using AudibleUtilities;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AudibleUtilities.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class AccountCredentialStatusTests
|
||||
{
|
||||
private static Account registeredAccount()
|
||||
{
|
||||
var identity = new Identity(Localization.Get("us"));
|
||||
identity.Update(
|
||||
new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
|
||||
new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
|
||||
new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new RefreshToken("Atnr|_CHAR_REFRESH_"),
|
||||
new List<KeyValuePair<string, string?>> { new("session-id", "cookie-value") },
|
||||
deviceSerialNumber: "device-serial",
|
||||
deviceType: "device-type",
|
||||
amazonAccountId: "amzn-account",
|
||||
deviceName: "device-name",
|
||||
storeAuthenticationCookie: "store-auth-cookie");
|
||||
|
||||
return new Account("user@example.com") { AccountName = "Jade", IdentityTokens = identity };
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void no_account_looks_like_missing_credentials()
|
||||
=> AccountCredentialStatus.LooksLikeMissingCredentials(null).Should().BeTrue();
|
||||
|
||||
[TestMethod]
|
||||
public void an_account_that_was_never_logged_in_looks_like_missing_credentials()
|
||||
=> AccountCredentialStatus.LooksLikeMissingCredentials(new Account("user@example.com")).Should().BeTrue();
|
||||
|
||||
/// <summary>A bare Identity has a locale but no tokens to renew from, so it needs a first login.</summary>
|
||||
[TestMethod]
|
||||
public void tokens_without_a_refresh_token_look_like_missing_credentials()
|
||||
{
|
||||
var account = new Account("user@example.com") { IdentityTokens = new Identity(Localization.Get("us")) };
|
||||
|
||||
AccountCredentialStatus.LooksLikeMissingCredentials(account).Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_registered_account_does_not_look_like_missing_credentials()
|
||||
=> AccountCredentialStatus.LooksLikeMissingCredentials(registeredAccount()).Should().BeFalse();
|
||||
|
||||
[TestMethod]
|
||||
public void the_label_pairs_a_friendly_name_with_the_id()
|
||||
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "Jade" })
|
||||
.Should().Be("'Jade' (user@example.com)");
|
||||
|
||||
[TestMethod]
|
||||
public void the_label_falls_back_to_the_id_alone()
|
||||
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com"))
|
||||
.Should().Be("'user@example.com'");
|
||||
|
||||
/// <summary>Accounts default their name to their id, and "'x' (x)" reads like a mistake.</summary>
|
||||
[TestMethod]
|
||||
public void the_label_does_not_repeat_an_id_used_as_the_name()
|
||||
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "USER@example.com" })
|
||||
.Should().Be("'user@example.com'");
|
||||
|
||||
[TestMethod]
|
||||
public void the_label_stays_readable_without_an_account()
|
||||
=> AccountCredentialStatus.FormatAccountLabel(null).Should().Be("an Audible account");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using AssertionHelper;
|
||||
using AudibleUtilities;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
|
||||
namespace AudibleUtilities.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The scan wraps failures on the way up, so the exception that knows which account needs a login has to be dug
|
||||
/// back out before the auto-scan dialog can name it.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class FindAuthenticationRequiredTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void the_exception_itself_is_returned()
|
||||
{
|
||||
var auth = new AuthenticationRequiredException(new Account("user@example.com"));
|
||||
|
||||
AuthenticationExceptionHelper.FindAuthenticationRequired(auth).Should().BeSameAs(auth);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_wrapped_exception_is_found()
|
||||
{
|
||||
var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
|
||||
var wrapped = new Exception("Error importing library", new Exception("inner", auth));
|
||||
|
||||
AuthenticationExceptionHelper.FindAuthenticationRequired(wrapped).Should().BeSameAs(auth);
|
||||
}
|
||||
|
||||
/// <summary>Scanning several accounts at once surfaces failures as an AggregateException.</summary>
|
||||
[TestMethod]
|
||||
public void an_aggregated_exception_is_found()
|
||||
{
|
||||
var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
|
||||
var aggregate = new AggregateException(new InvalidOperationException("unrelated"), auth);
|
||||
|
||||
AuthenticationExceptionHelper.FindAuthenticationRequired(aggregate).Should().BeSameAs(auth);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void an_unrelated_exception_yields_nothing()
|
||||
=> AuthenticationExceptionHelper.FindAuthenticationRequired(new InvalidOperationException("unrelated"))
|
||||
.Should().BeNull();
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AssertionHelper;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
using Lucene.Net.Index;
|
||||
using Lucene.Net.Store;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Directory = System.IO.Directory;
|
||||
|
||||
namespace SearchEngineTests;
|
||||
|
||||
/// <summary>
|
||||
/// A damaged search index used to block library imports forever: Lucene 3 reports an unreadable segments file as a
|
||||
/// plain <see cref="IOException"/>, which Libation mistook for a write.lock conflict and retried instead of repairing,
|
||||
/// and passing create/overwrite to <see cref="IndexWriter"/> does not repair it either.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class CorruptIndexRecoveryTests
|
||||
{
|
||||
private string indexDirectory = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Initialize()
|
||||
{
|
||||
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(indexDirectory);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(indexDirectory))
|
||||
Directory.Delete(indexDirectory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Windows refuses to delete a file Lucene still holds open, and a leftover temp directory is not
|
||||
// worth failing a test over
|
||||
}
|
||||
}
|
||||
|
||||
private static LibraryBook book(string asin, string title)
|
||||
{
|
||||
var contributor = Contributor.GetEmpty();
|
||||
var b = new Book(new AudibleProductId(asin), title, null, null, 1, ContentType.Product, [contributor], [contributor], "us");
|
||||
return new LibraryBook(b, new DateTime(2026, 8, 15), "account");
|
||||
}
|
||||
|
||||
private static readonly List<LibraryBook> library
|
||||
= [book("B0TEST0001", "Hound of the Baskervilles"), book("B0TEST0002", "Sign of the Four")];
|
||||
|
||||
private SearchEngine reIndex()
|
||||
{
|
||||
var engine = new SearchEngine(indexDirectory);
|
||||
engine.CreateNewIndex(library);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private void assertIndexIsUsable()
|
||||
{
|
||||
var engine = new SearchEngine(indexDirectory);
|
||||
engine.Search("Baskervilles").Docs.Select(d => d.ProductId).Should().BeEquivalentTo(["B0TEST0001"]);
|
||||
engine.Search(SearchEngine.ALL_QUERY).Docs.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private string currentSegmentsFile()
|
||||
=> Directory.GetFiles(indexDirectory, "segments_*").OrderBy(f => f.Length).ThenBy(f => f).Last();
|
||||
|
||||
/// <summary>Reproduces the state Lucene leaves behind when a commit is interrupted, eg. by a crash or power loss.</summary>
|
||||
private void truncateCurrentSegmentsFile(int keepBytes)
|
||||
{
|
||||
var file = currentSegmentsFile();
|
||||
File.WriteAllBytes(file, File.ReadAllBytes(file).Take(keepBytes).ToArray());
|
||||
}
|
||||
|
||||
private static Exception exceptionFromOpeningWriter(string directory)
|
||||
{
|
||||
using var index = FSDirectory.Open(directory);
|
||||
using var analyzer = new Lucene.Net.Analysis.Standard.StandardAnalyzer(SearchEngine.Version);
|
||||
return Assert.ThrowsExactly<IOException>(() => new IndexWriter(index, analyzer, true, IndexWriter.MaxFieldLength.UNLIMITED).Dispose());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void full_reindex_builds_a_searchable_index()
|
||||
{
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_a_zero_length_segments_file()
|
||||
{
|
||||
reIndex();
|
||||
truncateCurrentSegmentsFile(0);
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_a_partially_written_segments_file()
|
||||
{
|
||||
reIndex();
|
||||
truncateCurrentSegmentsFile(File.ReadAllBytes(currentSegmentsFile()).Length / 2);
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nastiest variant: the current commit is intact, so the index looks fine, but IndexWriter's IndexFileDeleter
|
||||
/// reads every segments_* file it finds and one unreadable leftover poisons the whole directory.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_a_stale_unreadable_segments_file_beside_a_valid_commit()
|
||||
{
|
||||
reIndex();
|
||||
File.WriteAllBytes(Path.Combine(indexDirectory, "segments_9"), []);
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_a_checksum_mismatch()
|
||||
{
|
||||
reIndex();
|
||||
var file = currentSegmentsFile();
|
||||
var bytes = File.ReadAllBytes(file);
|
||||
bytes[^1] ^= 0xFF;
|
||||
File.WriteAllBytes(file, bytes);
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
/// <summary>Cloud sync (eg. OneDrive) leaves conflict copies whose names Lucene 3's segments parser rejects.</summary>
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_cloud_sync_debris()
|
||||
{
|
||||
reIndex();
|
||||
File.WriteAllText(Path.Combine(indexDirectory, "segments_2 (1)"), "conflict copy");
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A garbled segments.gen sends Lucene looking for an absurd generation, and Lucene 3's base-36 filename
|
||||
/// formatter overruns its buffer on the way. Damage does not always announce itself as an IOException.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void full_reindex_repairs_a_segments_gen_pointing_at_a_bogus_generation()
|
||||
{
|
||||
reIndex();
|
||||
var gen = Path.Combine(indexDirectory, "segments.gen");
|
||||
// format(int) then the generation as a long, written twice; corrupt the high bytes of both copies
|
||||
var bytes = File.ReadAllBytes(gen);
|
||||
bytes[7] = 0x63;
|
||||
bytes[15] = 0x63;
|
||||
File.WriteAllBytes(gen, bytes);
|
||||
|
||||
reIndex();
|
||||
assertIndexIsUsable();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void search_repairs_a_zero_length_segments_file()
|
||||
{
|
||||
reIndex();
|
||||
truncateCurrentSegmentsFile(0);
|
||||
|
||||
// the query path recovers via SearchEngineCommands, which keys off IsRecoverableCorruptIndexException
|
||||
var ex = Assert.ThrowsExactly<IOException>(() => new SearchEngine(indexDirectory).Search("Baskervilles"));
|
||||
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void an_unreadable_segments_file_is_classified_as_recoverable_corruption()
|
||||
{
|
||||
reIndex();
|
||||
truncateCurrentSegmentsFile(0);
|
||||
|
||||
var ex = exceptionFromOpeningWriter(indexDirectory);
|
||||
|
||||
(ex is CorruptIndexException).Should().BeFalse();
|
||||
ex.Message.Should().Be("read past EOF");
|
||||
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A held write.lock is transient and must keep being retried. It derives from <see cref="IOException"/>, so
|
||||
/// widening the corruption check to cover IOException must not swallow it and delete a healthy index.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void a_write_lock_conflict_is_not_classified_as_corruption()
|
||||
{
|
||||
SearchEngine.IsRecoverableCorruptIndexException(new LockObtainFailedException("Lock obtain timed out")).Should().BeFalse();
|
||||
SearchEngine.IsRecoverableCorruptIndexException(new UnauthorizedAccessException()).Should().BeFalse();
|
||||
|
||||
// Windows raises the sharing violation on the lock file before Lucene can turn it into a
|
||||
// LockObtainFailedException, so this arrives as a plain IOException. Mistaking it for corruption would
|
||||
// delete the index the other holder is using.
|
||||
SearchEngine.IsRecoverableCorruptIndexException(
|
||||
new IOException(@"The process cannot access the file 'C:\Users\me\Libation\SearchEngine\write.lock' because it is being used by another process."))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The risk in repairing anything that is not a lock conflict is over-reach, so a held write.lock has to keep
|
||||
/// being retried and must leave the index alone. The lock is held for the whole retry budget rather than
|
||||
/// released part way through, which would race with Lucene 3's own lock bookkeeping.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void a_write_lock_conflict_is_retried_and_leaves_the_index_intact()
|
||||
{
|
||||
reIndex();
|
||||
var indexedBefore = indexFiles();
|
||||
|
||||
using var index = FSDirectory.Open(indexDirectory);
|
||||
var writeLock = index.MakeLock(IndexWriter.WRITE_LOCK_NAME);
|
||||
writeLock.Obtain().Should().BeTrue();
|
||||
|
||||
try
|
||||
{
|
||||
// Linux surfaces this as Lucene's LockObtainFailedException, Windows as a sharing violation on the
|
||||
// lock file. Either way it must not be read as corruption, and the index must survive.
|
||||
var ex = Assert.Throws<IOException>(() => reIndex());
|
||||
|
||||
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeFalse();
|
||||
indexFiles().Should().BeEquivalentTo(indexedBefore);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// a competing NativeFSLock.Obtain can delete the lock file it failed to take, leaving Release nothing
|
||||
// to delete. Lucene 3's lock bookkeeping is not what this test is about.
|
||||
try { writeLock.Release(); }
|
||||
catch (Exception ex) when (ex is LockReleaseFailedException or IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> indexFiles()
|
||||
=> [.. Directory.GetFiles(indexDirectory)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.Where(f => f != IndexWriter.WRITE_LOCK_NAME)
|
||||
.OrderBy(f => f)];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using AssertionHelper;
|
||||
using LibationSearchEngine;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using DataLayer;
|
||||
using Directory = System.IO.Directory;
|
||||
|
||||
namespace SearchEngineTests;
|
||||
|
||||
/// <summary>
|
||||
/// The filter box has to tell a query the user mistyped apart from an index Libation cannot read: one deserves
|
||||
/// "bad filter string", the other deserves the recovery steps, and only the latter is worth rebuilding over.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class QueryFailureShapeTests
|
||||
{
|
||||
private string indexDirectory = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Initialize()
|
||||
{
|
||||
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(indexDirectory);
|
||||
|
||||
var contributor = Contributor.GetEmpty();
|
||||
var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
|
||||
new SearchEngine(indexDirectory).CreateNewIndex(new List<LibraryBook> { new(book, new DateTime(2026, 8, 15), "account") });
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(indexDirectory))
|
||||
Directory.Delete(indexDirectory, recursive: true);
|
||||
}
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("title:[unclosed")]
|
||||
[DataRow("*")]
|
||||
[DataRow("AND OR")]
|
||||
[DataRow("(((")]
|
||||
[DataRow(@"title:""unbalanced")]
|
||||
public void a_malformed_query_does_not_look_like_an_unreachable_index(string searchString)
|
||||
{
|
||||
var engine = new SearchEngine(indexDirectory);
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
engine.Search(searchString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
}
|
||||
|
||||
// some of these parse fine and simply match nothing, which is also acceptable
|
||||
if (thrown is null)
|
||||
return;
|
||||
|
||||
// what must never happen is a parse failure being read as index trouble and triggering a rebuild
|
||||
(thrown is IOException).Should().BeFalse();
|
||||
SearchEngine.IsRecoverableCorruptIndexException(thrown).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using AudibleUtilities;
|
||||
using LibationUiBase;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LibationUiBase.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class AutoScanAuthPromptTests
|
||||
{
|
||||
private static Account registeredAccount()
|
||||
{
|
||||
var identity = new Identity(Localization.Get("us"));
|
||||
identity.Update(
|
||||
new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
|
||||
new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
|
||||
new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new RefreshToken("Atnr|_CHAR_REFRESH_"),
|
||||
new List<KeyValuePair<string, string?>> { new("session-id", "cookie-value") },
|
||||
deviceSerialNumber: "device-serial",
|
||||
deviceType: "device-type",
|
||||
amazonAccountId: "amzn-account",
|
||||
deviceName: "device-name",
|
||||
storeAuthenticationCookie: "store-auth-cookie");
|
||||
|
||||
return new Account("jade@example.com") { AccountName = "Jade", IdentityTokens = identity };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reporter's log paused auto-scan on a second account that had never been logged in, while the dialog
|
||||
/// blamed an expired session and named no account at all.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void an_account_that_was_never_logged_in_is_named_and_explained()
|
||||
{
|
||||
var account = new Account("jade@example.com") { AccountName = "Jade" };
|
||||
|
||||
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account, "missing"));
|
||||
|
||||
StringAssert.Contains(body, "'Jade' (jade@example.com)");
|
||||
StringAssert.Contains(body, "never been logged in");
|
||||
StringAssert.Contains(body, "Import > Scan Library");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void an_account_with_stored_tokens_is_told_its_login_expired()
|
||||
{
|
||||
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(registeredAccount(), "expired"));
|
||||
|
||||
StringAssert.Contains(body, "'Jade' (jade@example.com)");
|
||||
StringAssert.Contains(body, "expired");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void an_unattributed_failure_still_reads_sensibly()
|
||||
{
|
||||
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account: null, "auth failed"));
|
||||
|
||||
StringAssert.Contains(body, "an Audible account");
|
||||
StringAssert.Contains(body, "Import > Scan Library");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void a_missing_exception_is_rejected()
|
||||
=> Assert.ThrowsExactly<ArgumentNullException>(() => AutoScanAuthPrompt.FormatBody(null!));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using LibationUiBase;
|
||||
using Lucene.Net.Index;
|
||||
using Lucene.Net.QueryParsers;
|
||||
using Lucene.Net.Store;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LibationUiBase.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class SearchIndexRecoveryTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void instructions_name_the_folder_and_how_to_find_it()
|
||||
{
|
||||
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "SearchEngine");
|
||||
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "Open log folder");
|
||||
// the whole point of the guard is that the library survived
|
||||
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "library itself is fine");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every way of failing to reach the index. Reporting one of these as a bad filter string sends the user
|
||||
/// looking for a typo in a query that was fine.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void failures_to_reach_the_index_are_told_apart_from_a_bad_query()
|
||||
{
|
||||
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new IOException("read past EOF")));
|
||||
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new CorruptIndexException("checksum mismatch in segments file")));
|
||||
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new LockObtainFailedException("Lock obtain timed out")));
|
||||
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new UnauthorizedAccessException()));
|
||||
// cloud-sync debris, which Lucene reports while parsing segments file names
|
||||
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("Invalid or unsupported character in number: )")));
|
||||
|
||||
Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ParseException("Cannot parse '[unclosed'")));
|
||||
Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("some other argument problem")));
|
||||
}
|
||||
|
||||
/// <summary>A damaged index fails on every library change, and these steps only need following once.</summary>
|
||||
[TestMethod]
|
||||
public void the_user_is_told_once_per_session()
|
||||
{
|
||||
Assert.IsTrue(SearchIndexRecovery.ShouldNotify());
|
||||
Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
|
||||
Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user