Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,9 @@ public MainWindowViewModel(
ToggleFlagCommand = new AsyncCommand(ToggleFlagAsync, CanRunSelectedMailAction, allowConcurrent: true);
TogglePinCommand = new AsyncCommand(TogglePinAsync, CanRunSelectedMailAction, allowConcurrent: true);
MoveToFolderCommand = new AsyncCommand<MailFolderItem>(MoveSelectionToFolderAsync, CanMoveSelectionToFolder, allowConcurrent: true);
ShowUnifiedInboxCommand = new AsyncCommand(ShowUnifiedInboxAsync);
ShowPinnedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Pinned));
ShowFlaggedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Flagged));
ShowUnifiedInboxCommand = new AsyncCommand(ShowUnifiedInboxAsync, allowConcurrent: true);
ShowPinnedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Pinned), allowConcurrent: true);
ShowFlaggedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Flagged), allowConcurrent: true);
ShowDraftsCommand = new AsyncCommand(ShowDraftsAsync);
RecoverBusyActionCommand = new AsyncCommand<MailAction>(RecoverBusyActionAsync, static action => action.CanRecover);
RetryBusyActionCommand = new AsyncCommand<MailAction>(RetryBusyActionAsync, static action => action.CanRetry);
Expand Down Expand Up @@ -930,6 +930,13 @@ private set
return;
}

if (value != "Mail")
{
_messageLoadCancellation?.Cancel();
++_messageLoadVersion;
IsLoadingMessages = false;
}

RaisePropertyChanged(nameof(IsMailModule));
RaisePropertyChanged(nameof(IsWorkspaceModule));
RaisePropertyChanged(nameof(IsCalendarModule));
Expand Down Expand Up @@ -2965,7 +2972,7 @@ private async Task LoadMessagesAsync(bool showLoading = true)
((AsyncCommand)LoadMoreMessagesCommand).Refresh();
IsLoadingMessages = false;
_ = RepairMissingSubjectsAsync(page.Messages);
if (requestedFolder is null) await RefreshUnifiedCountsAsync();
if (requestedFolder is null) _ = RefreshUnifiedCountsAsync(token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
finally
Expand Down Expand Up @@ -3395,7 +3402,7 @@ private async Task RefreshNextCalendarEventAsync()
}
}

private async Task RefreshUnifiedCountsAsync()
private async Task RefreshUnifiedCountsAsync(CancellationToken cancellationToken)
{
if (_store is null)
{
Expand All @@ -3404,7 +3411,18 @@ private async Task RefreshUnifiedCountsAsync()
var inboxFolders = Folders.Where(static folder => folder.WellKnownName == "inbox")
.Select(static folder => new MailFolderKey(folder.MailboxId, folder.ProviderId))
.ToArray();
var counts = await _store.GetMessageFilterCountsAsync(inboxFolders);
MailMessageFilterCounts counts;
try
{
counts = await _store.GetMessageFilterCountsAsync(inboxFolders, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; }
catch (Exception exception)
{
if (!cancellationToken.IsCancellationRequested) Error = exception.Message;
return;
}
if (cancellationToken.IsCancellationRequested) return;
_unifiedMessageCount = counts.All;
_pinnedMessageCount = counts.Pinned;
_flaggedMessageCount = counts.Flagged;
Expand Down
62 changes: 62 additions & 0 deletions src/BetterMail.Core/EncryptedMailStore.WorkspaceReads.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Microsoft.Data.Sqlite;

namespace BetterMail.Core;

public sealed partial class EncryptedMailStore
{
private readonly SemaphoreSlim _workspaceReadGate = new(1, 1);
private SqliteConnection? _workspaceReadConnection;
private bool _workspaceReaderDisposed;

// Keep workspace queries and aggregate counts off both the writer and folder-navigation gates.
// Private cache avoids shared-cache table locks; reuse the encrypted connection to avoid
// repeating SQLCipher key derivation on every workspace query.
private async Task<T> WithWorkspaceReadAsync<T>(Func<SqliteConnection, Task<T>> action, CancellationToken token)
{
await _workspaceReadGate.WaitAsync(token).ConfigureAwait(false);
try
{
return await Task.Run(async () =>
{
ObjectDisposedException.ThrowIf(_workspaceReaderDisposed, this);
_ = GetConnection(); // Require initialization, including schema migrations.
if (_workspaceReadConnection 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);
_workspaceReadConnection = connection;
}
catch { await connection.DisposeAsync().ConfigureAwait(false); throw; }
}
try { return await action(_workspaceReadConnection).ConfigureAwait(false); }
catch (SqliteException) when (token.IsCancellationRequested)
{
throw new OperationCanceledException(token);
}
}, token).ConfigureAwait(false);
}
finally { _workspaceReadGate.Release(); }
}

private async Task DisposeWorkspaceReaderAsync()
{
await _workspaceReadGate.WaitAsync().ConfigureAwait(false);
try
{
_workspaceReaderDisposed = true;
if (_workspaceReadConnection is not null)
{
await _workspaceReadConnection.DisposeAsync().ConfigureAwait(false);
_workspaceReadConnection = null;
}
}
finally { _workspaceReadGate.Release(); }
}
}
10 changes: 7 additions & 3 deletions src/BetterMail.Core/EncryptedMailStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -570,9 +570,10 @@ public Task<int> GetFilteredMessageCountAsync(
public Task<MailMessageFilterCounts> GetMessageFilterCountsAsync(
IReadOnlyList<MailFolderKey> folders,
CancellationToken cancellationToken = default) =>
WithLockAsync<MailMessageFilterCounts>(async connection =>
WithWorkspaceReadAsync<MailMessageFilterCounts>(async connection =>
{
await using var command = connection.CreateCommand();
using var cancellation = cancellationToken.Register(command.Cancel);
var folderClauses = new List<string>(folders.Count);
for (var index = 0; index < folders.Count; index++)
{
Expand Down Expand Up @@ -760,10 +761,11 @@ public Task<IReadOnlyList<DiscoveredPerson>> GetDiscoveredPeopleAsync(
string query = "",
int limit = 500,
CancellationToken cancellationToken = default, IReadOnlyList<string>? mailboxIds = null) =>
WithLockAsync<IReadOnlyList<DiscoveredPerson>>(async connection =>
WithWorkspaceReadAsync<IReadOnlyList<DiscoveredPerson>>(async connection =>
{
var groups = new List<(string Email, string Name, string MailboxId, int Count, DateTimeOffset Last)>();
await using var command = connection.CreateCommand();
using var cancellation = cancellationToken.Register(command.Cancel);
command.CommandText = _correspondentsReady
? """
SELECT email, display_name, mailbox_id, count(*), max(contacted_at)
Expand Down Expand Up @@ -1411,6 +1413,7 @@ public async ValueTask DisposeAsync()
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await DisposeWorkspaceReaderAsync().ConfigureAwait(false);
await DisposeFolderReaderAsync().ConfigureAwait(false);
await DisposeMessageReaderAsync().ConfigureAwait(false);
if (_connection is not null)
Expand Down Expand Up @@ -1537,10 +1540,11 @@ private Task<IReadOnlyList<T>> QueryWorkspaceItemsAsync<T>(
CancellationToken cancellationToken,
string extraWhere = "",
params (string Name, object Value)[] extraParameters) =>
WithLockAsync<IReadOnlyList<T>>(async connection =>
WithWorkspaceReadAsync<IReadOnlyList<T>>(async connection =>
{
var values = new List<T>();
await using var command = connection.CreateCommand();
using var cancellation = cancellationToken.Register(command.Cancel);
var filters = new List<string> { "kind = $kind" };
if (!string.IsNullOrWhiteSpace(accountId))
{
Expand Down
7 changes: 7 additions & 0 deletions tests/BetterMail.Tests/FolderReadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public async Task FolderPageReadsCommittedSnapshotWithoutWaitingForSyncWriter()
command.Transaction = transaction;
command.CommandText = "UPDATE messages SET subject='Uncommitted subject';";
await command.ExecuteNonQueryAsync(token);
// Counts and People cache reads must not wait behind an active sync writer.
await store.GetMessageFilterCountsAsync([new(mailbox.Id, "inbox")], token)
.WaitAsync(TimeSpan.FromSeconds(5), token);
await store.GetDiscoveredPeopleAsync(cancellationToken: token)
.WaitAsync(TimeSpan.FromSeconds(5), token);
await store.GetWorkspaceItemsAsync<ContactInfo>("contact", "account", cancellationToken: token)
.WaitAsync(TimeSpan.FromSeconds(5), token);
var timer = System.Diagnostics.Stopwatch.StartNew();
var page = await store.GetMessagesPageAsync([new(mailbox.Id, "inbox")], cancellationToken: token)
.WaitAsync(TimeSpan.FromSeconds(5), token);
Expand Down
71 changes: 71 additions & 0 deletions tests/BetterMail.Tests/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,77 @@ namespace BetterMail.Tests;

public sealed class MainWindowViewModelTests
{
[Fact]
public async Task PendingInboxLoadCanBeLeftForPeopleAndReentered()
{
var token = TestContext.Current.CancellationToken;
var directory = Path.Combine(Path.GetTempPath(), "bettermail-navigation-" + Guid.NewGuid());
try
{
await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('A', 64));
await store.InitializeAsync(token);
var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null,
new RecordingProvider(), workspaceProvider: new FakeWorkspaceProvider());
var account = new MailAccount("microsoft365", "account", "tenant", "me@example.test", "Me",
ProviderCapabilities.Mail | ProviderCapabilities.Contacts);
vm.Accounts.Add(account);
var gate = (SemaphoreSlim)typeof(EncryptedMailStore).GetField("_folderReadGate",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(store)!;
await gate.WaitAsync(token);
Task returnToMail;
try
{
var first = ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync();
Assert.False(first.IsCompleted);
Assert.True(vm.ShowUnifiedInboxCommand.CanExecute(null));
await ((AsyncCommand)vm.ShowContactsCommand).ExecuteAsync().WaitAsync(TimeSpan.FromSeconds(5), token);
await first.WaitAsync(TimeSpan.FromSeconds(5), token);
Assert.True(vm.IsContactsModule);
Assert.False(vm.IsLoadingMessages);
returnToMail = ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync();
Assert.True(vm.IsMailModule);
Assert.True(vm.IsLoadingMessages);
var latest = ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync();
await returnToMail.WaitAsync(TimeSpan.FromSeconds(5), token);
returnToMail = latest;
}
finally { gate.Release(); }
await returnToMail.WaitAsync(TimeSpan.FromSeconds(5), token);
await vm.PeopleBackgroundRefresh;
Assert.True(vm.IsMailModule);
Assert.False(vm.IsLoadingMessages);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}

[Fact]
public async Task InboxNavigationDoesNotWaitForBackgroundCounts()
{
var token = TestContext.Current.CancellationToken;
var directory = Path.Combine(Path.GetTempPath(), "bettermail-counts-" + Guid.NewGuid());
try
{
await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('A', 64));
await store.InitializeAsync(token);
var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null);
var gate = (SemaphoreSlim)typeof(EncryptedMailStore).GetField("_workspaceReadGate",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(store)!;
await gate.WaitAsync(token);
try
{
foreach (var command in new[] { vm.ShowUnifiedInboxCommand, vm.ShowPinnedCommand, vm.ShowFlaggedCommand })
{
await ((AsyncCommand)command).ExecuteAsync().WaitAsync(TimeSpan.FromSeconds(5), token);
Assert.True(vm.IsMailModule);
Assert.False(vm.IsLoadingMessages);
Assert.True(command.CanExecute(null));
}
}
finally { gate.Release(); }
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand Down
Loading