Public Access
Merge branch 'claude/vault-realtime-push-d64c61'
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A server's push channel, driven by a test rather than by a socket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The real <c>VaultEventStream</c> is a reconnection policy wrapped round a WebSocket, and none of
|
||||
/// that is what the shell's behaviour depends on: what the shell does with a notice is the same
|
||||
/// whether it arrived over a healthy socket, after four reconnections, or from this. Driving it by
|
||||
/// hand is what makes "the loop synchronised because it was told to, not because a minute passed" a
|
||||
/// test that finishes in milliseconds and cannot flake.
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultEventStream : IVaultEventStream
|
||||
{
|
||||
private readonly Channel<VaultEvent> notices = Channel.CreateUnbounded<VaultEvent>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => true;
|
||||
|
||||
/// <summary>How many times the shell has waited on this. Proves the loop is watching at all.</summary>
|
||||
internal int Reads { get; private set; }
|
||||
|
||||
/// <summary>Delivers a notice, as a server would.</summary>
|
||||
internal void Push(Guid vaultId, long sequence = 1) =>
|
||||
notices.Writer.TryWrite(
|
||||
new VaultEvent(VaultEventKinds.VaultChanged, vaultId, sequence));
|
||||
|
||||
/// <summary>Delivers the notice that says the caller's vault list has changed.</summary>
|
||||
internal void PushAccessChanged() =>
|
||||
notices.Writer.TryWrite(new VaultEvent(VaultEventKinds.VaultsChanged));
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<VaultEvent> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Reads++;
|
||||
|
||||
return notices.Reader.ReadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRead(out VaultEvent notice) => notices.Reader.TryRead(out notice!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => notices.Writer.TryComplete();
|
||||
}
|
||||
@@ -39,6 +39,16 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
|
||||
internal int PushCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How many delta reads this server has served.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one observable a synchronisation pass always produces. <see cref="PushCount"/> only moves when
|
||||
/// there is something queued, so a test asking "did a pass run" — which is what the push channel's
|
||||
/// whole purpose comes down to — has to count pulls.
|
||||
/// </remarks>
|
||||
internal int PullCount { get; private set; }
|
||||
|
||||
internal bool IsEnrolled => statement is not null;
|
||||
|
||||
/// <summary>
|
||||
@@ -99,6 +109,19 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => this;
|
||||
|
||||
/// <summary>
|
||||
/// The push channel, which a test drives by hand.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A real queue rather than an idle stand-in, because the behaviour worth covering here is the one
|
||||
/// the socket exists for: a notice arriving makes the background loop synchronise without waiting
|
||||
/// out its minute. See <see cref="FakeVaultEventStream.Push"/>.
|
||||
/// </remarks>
|
||||
internal FakeVaultEventStream Notices { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultEventStream Events => Notices;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => SyncOptions.Default;
|
||||
|
||||
@@ -238,6 +261,8 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PullCount++;
|
||||
|
||||
if (SyncFailure is { } failure)
|
||||
{
|
||||
return Task.FromException<SyncPullResponse>(failure);
|
||||
|
||||
@@ -523,6 +523,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("bad day");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The whole point of the push channel: a pass that did not wait for the minute.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The timing is what makes this an assertion rather than a hope. The background timer is a full
|
||||
/// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been
|
||||
/// caused by the notice — there is no interval at which the timer could have produced it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault
|
||||
/// this session can reach, so the loop reads the notice as "there is something to fetch" and never
|
||||
/// as "fetch this one". A test that seeded a real id would imply a targeting this does not do.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
// The unlock starts the loop, whose first act is a pass; waited out so the count below is a
|
||||
// baseline rather than a race with it.
|
||||
await EventuallyAsync(
|
||||
() => server.PullCount > 0,
|
||||
"the pass on open should have run");
|
||||
|
||||
var before = server.PullCount;
|
||||
|
||||
server.Notices.Push(Guid.CreateVersion7());
|
||||
|
||||
await EventuallyAsync(
|
||||
() => server.PullCount > before,
|
||||
"a notice should have woken the loop long before the one-minute timer");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
|
||||
/// losing: <c>PeriodicTimer</c> throws if a second wait is started while one is outstanding, and an
|
||||
/// abandoned channel read stays registered and swallows the next notice written. Either defect
|
||||
/// leaves the first notice working and every one after it silently lost, which is why one notice is
|
||||
/// not enough to prove this.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NoticesKeepWakingTheLoop_NotJustTheFirst()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run");
|
||||
|
||||
for (var round = 1; round <= 3; round++)
|
||||
{
|
||||
var before = server.PullCount;
|
||||
|
||||
server.Notices.Push(Guid.CreateVersion7());
|
||||
|
||||
await EventuallyAsync(
|
||||
() => server.PullCount > before,
|
||||
$"notice {round} should have woken the loop as the first one did");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
|
||||
/// <remarks>
|
||||
/// Polled rather than signalled because the thing under test is a loop nobody hands a completion
|
||||
/// source to. The bound is generous — this is not measuring latency, only proving that the timer
|
||||
/// cannot be what caused the result.
|
||||
/// </remarks>
|
||||
private static async Task EventuallyAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
|
||||
|
||||
while (TimeProvider.System.GetUtcNow() < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
|
||||
}
|
||||
|
||||
throw new ShouldAssertException(because);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
|
||||
|
||||
Reference in New Issue
Block a user