mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-03 14:52:11 -04:00
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:
1 parent
eace08f351
commit
9e15d5dceb
3 files changed
+112
-16
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user