From 5dce4bd36b32c7a68c6e01f45e0319a0316e9a2c Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:47:36 +0000 Subject: [PATCH] Keep mail search and startup responsive --- src/BetterMail.App/MainWindowViewModel.cs | 96 +++++----- .../EncryptedMailStore.Search.cs | 18 +- .../EncryptedMailStore.SearchReads.cs | 75 ++++++++ src/BetterMail.Core/EncryptedMailStore.cs | 14 +- .../Microsoft365AuthService.cs | 40 +++- .../AccountInitializationTests.cs | 111 +++++++++++ tests/BetterMail.Tests/SearchReadTests.cs | 177 ++++++++++++++++++ 7 files changed, 474 insertions(+), 57 deletions(-) create mode 100644 src/BetterMail.Core/EncryptedMailStore.SearchReads.cs create mode 100644 tests/BetterMail.Tests/AccountInitializationTests.cs create mode 100644 tests/BetterMail.Tests/SearchReadTests.cs diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index c6894c5..89da09d 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -47,6 +47,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly Func _waitForMarkRead; private readonly NewMailNotificationCoordinator _newMailNotifications; private readonly ConcurrentDictionary _draftSyncLocks = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _providerInitializationLocks = new(StringComparer.Ordinal); private readonly Dictionary _accountProviders = new(StringComparer.Ordinal); private readonly Dictionary _mailProviders = new(StringComparer.Ordinal); private Microsoft365AuthService? _microsoftAuthentication; @@ -1505,27 +1506,6 @@ await RunBusyAsync("Loading mailbox…", async () => await LoadFoldersAsync(); await PrimeNewMailNotificationsAsync(); await LoadMessagesAsync(); - - try - { - if (_provider is null) - { - foreach (var providerId in Accounts.Select(static account => account.ProviderId).Distinct(StringComparer.Ordinal)) - { - await EnsureProviderAsync(providerId); - } - RefreshMailProviderRouter(); - } - ((AsyncCommand)SyncCommand).Refresh(); - ((AsyncCommand)ToggleReadCommand).Refresh(); - RefreshWorkspaceCommands(); - await LoadAttachmentsAsync(SelectedMessage); - _ = RepairMissingSubjectsAsync(Messages); - } - catch (InvalidOperationException exception) - { - Error = exception.Message; - } }); RaisePropertyChanged(nameof(SettingsAccounts)); RaisePropertyChanged(nameof(SettingsMailboxes)); @@ -1533,6 +1513,33 @@ await RunBusyAsync("Loading mailbox…", async () => RaisePropertyChanged(nameof(HasAccounts)); RaisePropertyChanged(nameof(ShowOnboarding)); RaisePropertyChanged(nameof(ShowFullScreenLoader)); + + // Local mail is ready. Account/keyring setup must not keep the full-window loader open. + try + { + if (_provider is null) + { + foreach (var providerId in Accounts.Select(static account => account.ProviderId).Distinct(StringComparer.Ordinal).ToArray()) + { + try { await EnsureProviderAsync(providerId); } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Error = exception.Message; + Status = "Cached mail ready — account setup needs attention"; + } + } + RefreshMailProviderRouter(); + } + ((AsyncCommand)SyncCommand).Refresh(); + ((AsyncCommand)ToggleReadCommand).Refresh(); + RefreshWorkspaceCommands(); + await LoadAttachmentsAsync(SelectedMessage); + _ = RepairMissingSubjectsAsync(Messages); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Error = exception.Message; + } await RefreshNextCalendarEventAsync(); StartAutoSync(); _ = ReconcileAllDraftsInBackgroundAsync(); @@ -1613,29 +1620,34 @@ private async Task AddConnectedAccountAsync(MailAccount account) private async Task EnsureProviderAsync(string providerId, CancellationToken cancellationToken = default) { - if (_store is null || _accountProviders.ContainsKey(providerId)) - { - return; - } - switch (providerId) + if (_store is null) return; + // Cached mail is usable during startup, so a concurrent retry must share provider setup. + var gate = _providerInitializationLocks.GetOrAdd(providerId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try { - case Microsoft365AuthService.Id: - _microsoftAuthentication = await Microsoft365AuthService.CreateAsync( - Microsoft365Options.Create(_dataDirectory), cancellationToken); - _accountProviders[providerId] = _microsoftAuthentication; - _mailProviders[providerId] = new Microsoft365MailProvider(_microsoftAuthentication); - _workspaceProvider ??= new Microsoft365WorkspaceProvider(_microsoftAuthentication); - break; - case GoogleAuthService.Id: - cancellationToken.ThrowIfCancellationRequested(); - _googleAuthentication = new GoogleAuthService(GoogleOptions.Create(), _store); - _accountProviders[providerId] = _googleAuthentication; - _mailProviders[providerId] = new GoogleGmailProvider(_googleAuthentication); - break; - default: - throw new InvalidOperationException($"The account provider '{providerId}' is not installed."); + if (_accountProviders.ContainsKey(providerId)) return; + switch (providerId) + { + case Microsoft365AuthService.Id: + _microsoftAuthentication = await Microsoft365AuthService.CreateAsync( + Microsoft365Options.Create(_dataDirectory), cancellationToken); + _accountProviders[providerId] = _microsoftAuthentication; + _mailProviders[providerId] = new Microsoft365MailProvider(_microsoftAuthentication); + _workspaceProvider ??= new Microsoft365WorkspaceProvider(_microsoftAuthentication); + break; + case GoogleAuthService.Id: + cancellationToken.ThrowIfCancellationRequested(); + _googleAuthentication = new GoogleAuthService(GoogleOptions.Create(), _store); + _accountProviders[providerId] = _googleAuthentication; + _mailProviders[providerId] = new GoogleGmailProvider(_googleAuthentication); + break; + default: + throw new InvalidOperationException($"The account provider '{providerId}' is not installed."); + } + RefreshMailProviderRouter(); } - RefreshMailProviderRouter(); + finally { gate.Release(); } } private void RefreshMailProviderRouter() diff --git a/src/BetterMail.Core/EncryptedMailStore.Search.cs b/src/BetterMail.Core/EncryptedMailStore.Search.cs index 3555c97..7617f03 100644 --- a/src/BetterMail.Core/EncryptedMailStore.Search.cs +++ b/src/BetterMail.Core/EncryptedMailStore.Search.cs @@ -49,13 +49,7 @@ public Task> SearchFilteredMailAsync(SearchQuery quer string Add(object value) { var name = "$p" + parameters.Count; parameters.Add((name, value)); return name; } if (allowedMailboxIds is not null) clauses.Add($"mailbox_id IN (SELECT value FROM json_each({Add(JsonSerializer.Serialize(allowedMailboxIds))}))"); - if (query.Terms.Count > 0) - { - var parameter = Add(query.FtsText); - clauses.Add(_optimizedSearch - ? $"rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {parameter})" - : $"rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH {parameter} UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {parameter})"); - } + var ftsParameter = query.Terms.Count > 0 ? Add(query.FtsText) : null; foreach (var key in new[] { "from", "to", "cc", "subject" }) { if (query[key] is not { } value) continue; @@ -98,6 +92,14 @@ public Task> SearchFilteredMailAsync(SearchQuery quer else clauses.Add($"julianday(received_at) {date.Operator} {Julian(instant)}"); } } - return QueryMessagesAsync("WHERE " + string.Join(" AND ", clauses), limit, false, cancellationToken, parameters.ToArray()); + return QuerySearchMessagesAsync(optimized => + { + var where = "WHERE " + string.Join(" AND ", clauses); + if (ftsParameter is not null) + where += optimized + ? $" AND rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {ftsParameter})" + : $" AND rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH {ftsParameter} UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {ftsParameter})"; + return where; + }, limit, false, cancellationToken, parameters.ToArray()); } } diff --git a/src/BetterMail.Core/EncryptedMailStore.SearchReads.cs b/src/BetterMail.Core/EncryptedMailStore.SearchReads.cs new file mode 100644 index 0000000..74f3a24 --- /dev/null +++ b/src/BetterMail.Core/EncryptedMailStore.SearchReads.cs @@ -0,0 +1,75 @@ +using Microsoft.Data.Sqlite; + +namespace BetterMail.Core; + +public sealed partial class EncryptedMailStore +{ + private readonly SemaphoreSlim _searchReadGate = new(1, 1); + private SqliteConnection? _searchReadConnection; + private bool _searchReaderDisposed; + + // Searches must neither wait for sync nor block writes or other interactive readers. + private async Task WithSearchReadAsync(Func> action, CancellationToken token) + { + await _searchReadGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(async () => + { + ObjectDisposedException.ThrowIf(_searchReaderDisposed, this); + _ = GetConnection(); + if (_searchReadConnection is null) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = databasePath, Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Private, Pooling = false, Password = ValidateHexKey(key) + }.ToString()); + try + { + await connection.OpenAsync(token).ConfigureAwait(false); + await ExecuteAsync(connection, "PRAGMA query_only = ON; PRAGMA busy_timeout = 1000;", token).ConfigureAwait(false); + _searchReadConnection = connection; + } + catch { await connection.DisposeAsync().ConfigureAwait(false); throw; } + } + var reader = _searchReadConnection; + // SqliteCommand.Cancel is a no-op. Interrupt this dedicated connection instead. + // Dispose the registration before releasing the gate so cancellation cannot hit a later search. + using var cancellation = token.Register(() => SQLitePCL.raw.sqlite3_interrupt(reader.Handle)); + try + { + token.ThrowIfCancellationRequested(); + using var transaction = reader.BeginTransaction(deferred: true); + // Reading the marker pins a WAL snapshot: index selection and the query see + // the same schema even if maintenance drops the legacy index concurrently. + var optimized = await MigrationCompleteAsync(reader, "message-search-v2", token).ConfigureAwait(false); + var result = await action(reader, optimized).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + transaction.Commit(); + return result; + } + catch (SqliteException) when (token.IsCancellationRequested) + { + throw new OperationCanceledException(token); + } + }, token).ConfigureAwait(false); + } + finally { _searchReadGate.Release(); } + } + + private async Task DisposeSearchReaderAsync() + { + await _searchReadGate.WaitAsync().ConfigureAwait(false); + try + { + _searchReaderDisposed = true; + if (_searchReadConnection is not null) + { + await _searchReadConnection.DisposeAsync().ConfigureAwait(false); + _searchReadConnection = null; + } + } + finally { _searchReadGate.Release(); } + } +} diff --git a/src/BetterMail.Core/EncryptedMailStore.cs b/src/BetterMail.Core/EncryptedMailStore.cs index c653caa..a780ded 100644 --- a/src/BetterMail.Core/EncryptedMailStore.cs +++ b/src/BetterMail.Core/EncryptedMailStore.cs @@ -691,13 +691,14 @@ public Task> SearchMailboxAsync(string? mailboxId, st { if (string.IsNullOrWhiteSpace(query)) { - return GetMessagesAsync(mailboxId: mailboxId, limit: limit, cancellationToken: cancellationToken); + return QuerySearchMessagesAsync(_ => "WHERE ($mailbox IS NULL OR mailbox_id = $mailbox)", + limit, true, cancellationToken, ("$mailbox", (object?)mailboxId ?? DBNull.Value)); } var ftsQuery = string.Join(' ', query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(static term => $"\"{term.Replace("\"", "\"\"")}\"*")); - return QueryMessagesAsync( - (_optimizedSearch + return QuerySearchMessagesAsync( + optimized => (optimized ? "WHERE rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH $query)" : "WHERE rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH $query UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH $query)") + " AND ($mailbox IS NULL OR mailbox_id = $mailbox)", @@ -1413,6 +1414,7 @@ public async ValueTask DisposeAsync() await _gate.WaitAsync().ConfigureAwait(false); try { + await DisposeSearchReaderAsync().ConfigureAwait(false); await DisposeWorkspaceReaderAsync().ConfigureAwait(false); await DisposeFolderReaderAsync().ConfigureAwait(false); await DisposeMessageReaderAsync().ConfigureAwait(false); @@ -1436,6 +1438,12 @@ private Task> QueryMessagesAsync( params (string Name, object Value)[] parameters) => WithLockAsync(connection => QueryMessagesOnConnectionAsync(connection, where, limit, includeBody, cancellationToken, parameters), cancellationToken); + private Task> QuerySearchMessagesAsync( + Func where, int limit, bool includeBody, CancellationToken cancellationToken, + params (string Name, object Value)[] parameters) => + WithSearchReadAsync((connection, optimized) => QueryMessagesOnConnectionAsync( + connection, where(optimized), limit, includeBody, cancellationToken, parameters), cancellationToken); + private Task> QueryReadingPaneMessagesAsync( string where, int limit, bool includeBody, CancellationToken cancellationToken, params (string Name, object Value)[] parameters) => diff --git a/src/BetterMail.Microsoft365/Microsoft365AuthService.cs b/src/BetterMail.Microsoft365/Microsoft365AuthService.cs index 6dd8a3c..dbf78e3 100644 --- a/src/BetterMail.Microsoft365/Microsoft365AuthService.cs +++ b/src/BetterMail.Microsoft365/Microsoft365AuthService.cs @@ -53,10 +53,41 @@ private Microsoft365AuthService(IPublicClientApplication client, MsalCacheHelper ProviderCapabilities.Files | ProviderCapabilities.Notes; - public static async Task CreateAsync( + public static Task CreateAsync( Microsoft365Options options, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + CreateAsync(options, storage => MsalCacheHelper.CreateAsync(storage), TimeSpan.FromSeconds(15), cancellationToken); + + internal static async Task CreateAsync( + Microsoft365Options options, + Func> createCache, + TimeSpan timeout, + CancellationToken cancellationToken) { + using var initialization = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + initialization.CancelAfter(timeout); + var token = initialization.Token; + try + { + // MSAL cache creation and verification can block on native keyrings or file locks. + // ConfigureAwait alone does not move their synchronous work off the calling UI thread. + return await Task.Run(() => CreateCoreAsync(options, createCache, token), token) + .WaitAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "Microsoft account setup timed out waiting for secure storage. Your cached mail is available. " + + "Unlock your system keyring or Keychain, then retry from Settings > Accounts > Re-authenticate."); + } + } + + private static async Task CreateCoreAsync( + Microsoft365Options options, + Func> createCache, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); Directory.CreateDirectory(options.DataDirectory); var client = PublicClientApplicationBuilder .Create(options.ClientId) @@ -74,10 +105,11 @@ public static async Task CreateAsync( .WithMacKeyChain("com.bettermail.tokens", "BetterMail") .Build(); - var cache = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); + var cache = await createCache(storage).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); cache.VerifyPersistence(); - cache.RegisterCache(client.UserTokenCache); cancellationToken.ThrowIfCancellationRequested(); + cache.RegisterCache(client.UserTokenCache); return new Microsoft365AuthService(client, cache); } diff --git a/tests/BetterMail.Tests/AccountInitializationTests.cs b/tests/BetterMail.Tests/AccountInitializationTests.cs new file mode 100644 index 0000000..59a2e51 --- /dev/null +++ b/tests/BetterMail.Tests/AccountInitializationTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; +using System.Reflection; +using BetterMail.App; +using BetterMail.Core; +using BetterMail.Microsoft365; +using Microsoft.Identity.Client.Extensions.Msal; + +namespace BetterMail.Tests; + +public sealed class AccountInitializationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SynchronousCacheWaitReturnsControlAndCanBeCancelledOrTimedOut(bool timeout) + { + var token = TestContext.Current.CancellationToken; + var directory = Path.Combine(Path.GetTempPath(), "bettermail-account-init-" + Guid.NewGuid()); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(token); + try + { + var options = new Microsoft365Options("11111111-1111-1111-1111-111111111111", directory); + // Simulate a native secure-store call that cannot itself accept cancellation. + var initialization = Microsoft365AuthService.CreateAsync(options, _ => + { + entered.TrySetResult(); + release.Wait(TimeSpan.FromSeconds(10), token); + var cache = MsalCacheHelper.CreateAsync(new StorageCreationPropertiesBuilder("test.cache", directory) + .WithUnprotectedFile().Build()).GetAwaiter().GetResult(); + exited.TrySetResult(); + return Task.FromResult(cache); + }, timeout ? TimeSpan.FromSeconds(2) : TimeSpan.FromSeconds(10), cancelled.Token); + Assert.False(initialization.IsCompleted); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + if (timeout) + { + var error = await Assert.ThrowsAsync(() => initialization.WaitAsync(TimeSpan.FromSeconds(5), token)); + Assert.Contains("timed out", error.Message); + Assert.Contains("cached mail is available", error.Message); + Assert.Contains("Re-authenticate", error.Message); + } + else + { + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => initialization.WaitAsync(TimeSpan.FromSeconds(5), token)); + } + Assert.False(exited.Task.IsCompleted); + } + finally + { + release.Set(); + // Wait for the fake native call to finish before cleaning up its temporary files. + await exited.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + } + + [Fact] + public async Task CachedMailIsAccessibleBeforeProviderSetupCompletesAndAfterItFails() + { + var token = TestContext.Current.CancellationToken; + var directory = Path.Combine(Path.GetTempPath(), "bettermail-local-startup-" + Guid.NewGuid()); + try + { + await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('D', 64)); + await store.InitializeAsync(token); + var account = new MailAccount("unavailable-provider", "account", "tenant", "me@example.test", "Me", ProviderCapabilities.Mail); + var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Me"); + await store.SaveAccountAsync(account, token); + await store.SaveMailboxAsync(mailbox, token); + await store.SaveFoldersAsync(mailbox.Id, [new(mailbox.Id, "inbox", "Inbox", 0, 1, "inbox")], token); + var message = new MailMessage(mailbox.Id, "message", null, null, "inbox", "Cached mail", + new("Sender", "sender@example.test"), [], DateTimeOffset.UtcNow, "Preview", "Body", + false, true, false, MailImportance.Normal, [], null); + await store.ApplySyncPageAsync("seed", new([message], null, false), token); + var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null); + var gates = (ConcurrentDictionary)typeof(MainWindowViewModel) + .GetField("_providerInitializationLocks", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(vm)!; + var gate = gates.GetOrAdd(account.ProviderId, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(token); + Task initialization; + try + { + initialization = vm.InitializeAsync(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + vm.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(vm.ShowFullScreenLoader) && !vm.ShowFullScreenLoader) ready.TrySetResult(); + }; + if (!vm.ShowFullScreenLoader) ready.TrySetResult(); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.False(initialization.IsCompleted); + Assert.False(vm.IsBusy); + Assert.Equal("Cached mail", Assert.Single(vm.Messages).Subject); + vm.SearchText = "Cached type:mail"; + await ((AsyncCommand)vm.SearchCommand).ExecuteAsync().WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.Contains(vm.GlobalSearchResults, item => item.Value is MailMessage); + } + finally { gate.Release(); } + await initialization.WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.False(vm.ShowFullScreenLoader); + Assert.Contains("not installed", vm.Error); + Assert.Equal("Cached mail", Assert.Single(vm.Messages).Subject); + vm.SelectedMessage = null; + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } +} diff --git a/tests/BetterMail.Tests/SearchReadTests.cs b/tests/BetterMail.Tests/SearchReadTests.cs new file mode 100644 index 0000000..24b2739 --- /dev/null +++ b/tests/BetterMail.Tests/SearchReadTests.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using BetterMail.Core; +using Microsoft.Data.Sqlite; + +namespace BetterMail.Tests; + +public sealed class SearchReadTests +{ + private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + + [Fact] + public async Task SearchesReadCommittedMailWhileSyncWriterAndOtherReadersAreBlocked() + { + await WithStoreAsync(async (store, mailbox, token) => + { + var gate = Field(store, "_gate"); + var folderGate = Field(store, "_folderReadGate"); + var workspaceGate = Field(store, "_workspaceReadGate"); + await gate.WaitAsync(token); + await folderGate.WaitAsync(token); + await workspaceGate.WaitAsync(token); + try + { + using var transaction = Field(store, "_connection").BeginTransaction(); + using var command = transaction.Connection!.CreateCommand(); + command.CommandText = "UPDATE messages SET subject = 'Uncommitted';"; + await command.ExecuteNonQueryAsync(token); + foreach (var search in new[] + { + store.SearchAsync("Budget", cancellationToken: token), + store.SearchMailboxAsync(mailbox.Id, "Budget", cancellationToken: token), + store.SearchMailboxAsync(mailbox.Id, "", cancellationToken: token), + store.SearchFilteredMailAsync(SearchQuery.Parse("Budget type:mail"), + [new(mailbox.Id, "inbox")], cancellationToken: token) + }) + { + var messages = await search.WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.Equal("Budget", Assert.Single(messages).Subject); + } + } + finally { workspaceGate.Release(); folderGate.Release(); gate.Release(); } + }); + } + + [Fact] + public async Task SearchesQueuedBeforeIndexMigrationUseTheCommittedIndexWhenTheyRun() + { + await WithStoreAsync(async (store, mailbox, token) => + { + // Warm the search connection, then migrate its pending reads across the schema change. + Assert.Single(await store.SearchAsync("Budget", cancellationToken: token)); + Assert.True(await store.RunMaintenanceBatchAsync(token)); + var searchGate = Field(store, "_searchReadGate"); + await searchGate.WaitAsync(token); + Task> mailboxSearch; + Task> filteredSearch; + try + { + mailboxSearch = store.SearchMailboxAsync(mailbox.Id, "Budget", cancellationToken: token); + filteredSearch = store.SearchFilteredMailAsync(SearchQuery.Parse("Budget type:mail"), + [new(mailbox.Id, "inbox")], cancellationToken: token); + Assert.True(await store.RunMaintenanceBatchAsync(token).WaitAsync(TimeSpan.FromSeconds(5), token)); + } + finally { searchGate.Release(); } + Assert.Single(await mailboxSearch.WaitAsync(TimeSpan.FromSeconds(5), token)); + Assert.Single(await filteredSearch.WaitAsync(TimeSpan.FromSeconds(5), token)); + }); + } + + [Fact] + public async Task LegacySearchSnapshotSurvivesIndexRemovalWhileTheQueryIsBeingBuilt() + { + await WithStoreAsync(async (store, _, token) => + { + Assert.True(await store.RunMaintenanceBatchAsync(token)); + var snapshotReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + var search = QueryAsync(store, optimized => + { + Assert.False(optimized); + snapshotReady.SetResult(); + release.Wait(TimeSpan.FromSeconds(5), token); + return "WHERE rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH 'Budget')"; + }, token); + try + { + await snapshotReady.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.True(await store.RunMaintenanceBatchAsync(token).WaitAsync(TimeSpan.FromSeconds(5), token)); + } + finally { release.Set(); } + Assert.Single(await search.WaitAsync(TimeSpan.FromSeconds(5), token)); + Assert.Single(await store.SearchAsync("Budget", cancellationToken: token)); + }); + } + + [Fact] + public async Task CancellingExecutingSqlInterruptsSearchWithoutBlockingWritesOrTheNextSearch() + { + await WithStoreAsync(async (store, mailbox, token) => + { + await store.SearchAsync("Budget", cancellationToken: token); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Field(store, "_searchReadConnection").CreateFunction("review_started", () => + { + started.TrySetResult(); + return 1; + }); + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(token); + var obsolete = QueryAsync(store, _ => """ + WHERE rowid IN ( + WITH RECURSIVE numbers(n) AS ( + VALUES(review_started()) UNION ALL SELECT n + 1 FROM numbers WHERE n < 100000000 + ) SELECT sum(n) FROM numbers + ) + """, cancelled.Token); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + // An executing search must not retain the writer gate, even before cancellation. + await store.SaveMailboxAsync(mailbox, token).WaitAsync(TimeSpan.FromSeconds(5), token); + var next = store.SearchAsync("Budget", cancellationToken: token); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => obsolete.WaitAsync(TimeSpan.FromSeconds(5), token)); + Assert.Single(await next.WaitAsync(TimeSpan.FromSeconds(5), token)); + } + finally { cancelled.Cancel(); } + }); + } + + [Fact] + public async Task CancellingAQueuedSearchDoesNotPoisonTheFollowingSearch() + { + await WithStoreAsync(async (store, _, token) => + { + var gate = Field(store, "_searchReadGate"); + await gate.WaitAsync(token); + try + { + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(token); + var obsolete = store.SearchAsync("Budget", cancellationToken: cancelled.Token); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => obsolete); + } + finally { gate.Release(); } + Assert.Single(await store.SearchAsync("Budget", cancellationToken: token)); + }); + } + + private static Task> QueryAsync(EncryptedMailStore store, Func where, CancellationToken token) => + (Task>)typeof(EncryptedMailStore).GetMethod("QuerySearchMessagesAsync", PrivateInstance)! + .Invoke(store, [where, 500, false, token, Array.Empty<(string Name, object Value)>()])!; + + private static T Field(EncryptedMailStore store, string name) => + (T)typeof(EncryptedMailStore).GetField(name, PrivateInstance)!.GetValue(store)!; + + private static async Task WithStoreAsync(Func test) + { + var directory = Path.Combine(Path.GetTempPath(), "bettermail-search-read-" + Guid.NewGuid()); + var token = TestContext.Current.CancellationToken; + try + { + await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('D', 64)); + await store.InitializeAsync(token); + var account = new MailAccount("microsoft365", "account", "tenant", "me@example.test", "Me", ProviderCapabilities.Mail); + var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Me"); + await store.SaveAccountAsync(account, token); + await store.SaveMailboxAsync(mailbox, token); + await store.SaveFoldersAsync(mailbox.Id, [new(mailbox.Id, "inbox", "Inbox", 0, 1, "inbox")], token); + var message = new MailMessage(mailbox.Id, "message", null, null, "inbox", "Budget", + new("Sender", "sender@example.test"), [], DateTimeOffset.UtcNow, "Budget", "Budget", + false, false, false, MailImportance.Normal, [], null); + await store.ApplySyncPageAsync("seed", new([message], null, false), token); + await test(store, mailbox, token); + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } +}