fix(desktop): track only the latest database browse request

A request is now marked superseded before the previous one is
cancelled, so a query that completes synchronously on cancellation
returns quietly instead of clearing the page and surfacing a stale
cancellation error. NestBrowseSession.IsLoading follows only the latest
request, and the dialog's Previous/Next and Loading text use it rather
than a count that included superseded requests. Each request disposes
its own cancellation source when it ends.
This commit is contained in:
aj committed 2026-10-02 20:22:24 -04:00
1 parent eace08f351
commit 9e15d5dceb
3 files changed
+112 -16

No files matched your search

+23 -6
View File
@@ -40,6 +40,9 @@ public sealed class NestBrowseSession : IDisposable
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public NestPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
@@ -106,14 +109,17 @@ public sealed class NestBrowseSession : IDisposable
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Superseded sources are cancelled but not disposed: their request may still observe the token.
_pending?.Cancel();
var cancellation = new CancellationTokenSource();
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
@@ -139,6 +145,13 @@ public sealed class NestBrowseSession : IDisposable
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
@@ -155,7 +168,10 @@ public sealed class NestBrowseSession : IDisposable
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>Cancels any request in flight; the repository is not owned and stays open.</summary>
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the repository is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
@@ -163,8 +179,9 @@ public sealed class NestBrowseSession : IDisposable
_disposed = true;
_generation++;
_pending?.Cancel();
_pending?.Dispose();
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+82 -1
View File
@@ -145,6 +145,79 @@ public class NestBrowseSessionTests
Assert.Equal("Showing 11-20 of 20 nests", session.Summary);
}
[Fact]
public async Task SupersededRequest_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure()
{
var repository = new ControlledRepository { CompleteOnCancellation = true };
using var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
Assert.False(await older);
repository.Requests[1].Reply.SetResult(Page("ab result"));
Assert.True(await newer);
Assert.Equal("ab result", Assert.Single(session.Page!.Items).Name);
}
[Fact]
public async Task IsLoading_FollowsOnlyTheLatestRequest()
{
var repository = new ControlledRepository();
using var session = new NestBrowseSession(repository, pageSize: 1);
Assert.False(session.IsLoading);
// A superseded request finishing first leaves the latest one loading.
var first = session.SetSearchAsync("a");
var second = session.SetSearchAsync("ab");
repository.Requests[0].Reply.SetResult(Page("a result"));
Assert.False(await first);
Assert.True(session.IsLoading);
repository.Requests[1].Reply.SetResult(Page("ab result"));
Assert.True(await second);
Assert.False(session.IsLoading);
// The latest request finishing first ends loading although a superseded one is still out.
var third = session.SetSearchAsync("abc");
var fourth = session.SetSearchAsync("abcd");
var record = new NestRecord { Id = Guid.NewGuid(), Name = "abcd result" };
repository.Requests[3].Reply.SetResult(new NestPage { Items = new[] { record }, Total = 3, Limit = 1 });
Assert.True(await fourth);
Assert.False(session.IsLoading);
Assert.True(session.CanGoNext);
repository.Requests[2].Reply.SetResult(Page("abc result"));
Assert.False(await third);
Assert.False(session.IsLoading);
var failing = session.RefreshAsync();
Assert.True(session.IsLoading);
repository.Requests[4].Reply.SetException(new IOException("offline"));
await Assert.ThrowsAsync<IOException>(() => failing);
Assert.False(session.IsLoading);
}
[Fact]
public async Task EachRequestsCancellationSource_IsReleasedWhenThatRequestEnds()
{
var repository = new ControlledRepository();
var session = new NestBrowseSession(repository);
var older = session.SetSearchAsync("a");
var newer = session.SetSearchAsync("ab");
repository.Requests[1].Reply.SetResult(Page("ab"));
await newer;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[1].Token.WaitHandle);
repository.Requests[0].Reply.SetResult(Page("a"));
await older;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[0].Token.WaitHandle);
var pending = session.RefreshAsync();
session.Dispose();
repository.Requests[2].Reply.SetResult(Page("late"));
await pending;
Assert.Throws<ObjectDisposedException>(() => repository.Requests[2].Token.WaitHandle);
}
[Theory]
[InlineData("", "No nests on the server.")]
[InlineData("no such text", "No nests match the filter.")]
@@ -216,14 +289,22 @@ public class NestBrowseSessionTests
}
}
/// <summary>Leaves every query pending until the test completes it.</summary>
/// <summary>
/// Leaves every query pending until the test completes it. With
/// <see cref="CompleteOnCancellation"/>, cancellation completes the query synchronously
/// inside the canceller's call, as some HTTP handlers do.
/// </summary>
private sealed class ControlledRepository : RepositoryBase
{
public bool CompleteOnCancellation { get; init; }
public List<(NestQuery Query, TaskCompletionSource<NestPage> Reply, CancellationToken Token)> Requests { get; } = new();
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
var reply = new TaskCompletionSource<NestPage>();
if (CompleteOnCancellation)
cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken));
Requests.Add((query, reply, cancellationToken));
return reply.Task;
}
+7 -9
View File
@@ -26,7 +26,6 @@ public sealed class SavedNestsForm : Form
private readonly Button previousButton;
private readonly Button nextButton;
private readonly ToolStripStatusLabel statusLabel;
private int pendingRequests;
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
public Guid SelectedId { get; private set; }
@@ -168,15 +167,16 @@ public sealed class SavedNestsForm : Form
/// <summary>
/// Sends one browse request and renders its page. A request superseded by a newer one
/// renders nothing; a failure clears the rows and shows the error in the status line.
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
/// </summary>
private async Task RunAsync(Func<Task<bool>> request)
{
pendingRequests++;
UpdateNavigation();
statusLabel.Text = LoadingText;
try
{
if (await request() && !IsDisposed)
var started = request();
UpdateNavigation();
if (await started && !IsDisposed)
Populate(session.Page);
}
catch (Exception ex)
@@ -189,12 +189,11 @@ public sealed class SavedNestsForm : Form
}
finally
{
pendingRequests--;
if (!IsDisposed)
{
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (pendingRequests == 0 && statusLabel.Text == LoadingText)
if (!session.IsLoading && statusLabel.Text == LoadingText)
statusLabel.Text = session.Summary;
}
}
@@ -202,9 +201,8 @@ public sealed class SavedNestsForm : Form
private void UpdateNavigation()
{
var idle = pendingRequests == 0;
previousButton.Enabled = idle && session.CanGoPrevious;
nextButton.Enabled = idle && session.CanGoNext;
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
}
private void Populate(NestPage page)