Public Access
Merge branch 'claude/vault-realtime-push-d64c61'
This commit is contained in:
@@ -10,6 +10,7 @@ using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
@@ -1066,13 +1067,29 @@ internal sealed partial class VaultViewModel(
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
|
||||
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
|
||||
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unchanged by the push channel, and deliberately so. The socket makes a pass <em>early</em>; this is
|
||||
/// what makes one happen at all, for a client whose network eats WebSockets, whose server has the
|
||||
/// feature off, or whose notice was dropped. See <see cref="WaitForWorkAsync"/> and ADR 0012.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>How long a pushed notice waits, in case more are on their way.</summary>
|
||||
/// <remarks>
|
||||
/// A quarter of a second, which is below what anybody perceives and above the gap between the
|
||||
/// notices one person's save produces — a host and its activity log entry are two items in one
|
||||
/// push, and a colleague clearing a folder is a burst. Without it each notice would run its own
|
||||
/// full pass, and the pass a burst deserves is one.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan NoticeDebounce = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>How often the logs are pruned, at most.</summary>
|
||||
/// <remarks>
|
||||
/// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
|
||||
@@ -4289,10 +4306,16 @@ internal sealed partial class VaultViewModel(
|
||||
/// <para>
|
||||
/// <b>This is the whole of how a shared vault arrives.</b> Sharing is two acts on two machines: the
|
||||
/// person sharing wraps the vault key to the recipient, and the recipient's own client has to notice.
|
||||
/// The recipient is handed nothing — there is no push channel — so without this the vault list stayed
|
||||
/// exactly as it was cached at sign-in, and a vault shared with somebody appeared on their machine only
|
||||
/// if they happened to sign in through the browser again. Everything else was already right, which is
|
||||
/// why it looked like sharing was broken rather than like a list that was never re-read.
|
||||
/// Without this the vault list stayed exactly as it was cached at sign-in, and a vault shared with
|
||||
/// somebody appeared on their machine only if they happened to sign in through the browser again.
|
||||
/// Everything else was already right, which is why it looked like sharing was broken rather than like a
|
||||
/// list that was never re-read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server now says when this is worth doing — a <c>vaults.changed</c> notice wakes the pass, so the
|
||||
/// vault turns up as it is shared rather than within the minute — but that only decides <em>when</em>.
|
||||
/// This call is still what discovers the vault, on the notice and on every timed pass alike, because a
|
||||
/// client with no socket has to arrive at the same place. See ADR 0012.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A failure is left to the caller, which treats it as the pass failing: the call is to the same server
|
||||
@@ -4339,6 +4362,7 @@ internal sealed partial class VaultViewModel(
|
||||
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(AutoSyncInterval);
|
||||
var waits = new AutoSyncWaits();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -4353,7 +4377,7 @@ internal sealed partial class VaultViewModel(
|
||||
// user is doing something.
|
||||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
while (await WaitForWorkAsync(timer, waits, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -4364,6 +4388,98 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the timer to come round, or for the server to say there is something to fetch.
|
||||
/// </summary>
|
||||
/// <returns>Whether to run a pass. False means the loop is over.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The timer is unchanged and is still what guarantees a pass. The socket only makes one
|
||||
/// <em>early</em>, which is why nothing here treats its absence as a problem: no connection, a
|
||||
/// server without the feature, a network that eats WebSockets, or a notice dropped under
|
||||
/// backpressure all leave a loop that behaves exactly as it did before this existed. See ADR 0012.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both waits are held across iterations, and that is load-bearing rather than an
|
||||
/// optimisation.</b> <see cref="PeriodicTimer"/> permits only one outstanding
|
||||
/// <c>WaitForNextTickAsync</c> and throws on a second, and an abandoned channel read stays
|
||||
/// registered and consumes the next notice written — which would silently lose exactly the wake-up
|
||||
/// this is for. Whichever wait did not win is kept and awaited again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<bool> WaitForWorkAsync(
|
||||
PeriodicTimer timer,
|
||||
AutoSyncWaits waits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Re-read every time, because signing out and back in replaces the connection — and with it
|
||||
// the stream. A read still pending against the old one is left to be cancelled with it.
|
||||
var stream = connection()?.Events;
|
||||
|
||||
if (!ReferenceEquals(stream, waits.Watching))
|
||||
{
|
||||
waits.Watching = stream;
|
||||
waits.Notice = null;
|
||||
}
|
||||
|
||||
waits.Tick ??= timer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
waits.Notice ??= stream?.ReadAsync(cancellationToken).AsTask();
|
||||
|
||||
if (waits.Notice is null)
|
||||
{
|
||||
var only = waits.Tick;
|
||||
waits.Tick = null;
|
||||
|
||||
return await only.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
var first = await Task.WhenAny(waits.Tick, waits.Notice).ConfigureAwait(true);
|
||||
|
||||
if (ReferenceEquals(first, waits.Tick))
|
||||
{
|
||||
var ticked = waits.Tick;
|
||||
waits.Tick = null;
|
||||
|
||||
return await ticked.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// Observed so a faulted read does not go unhandled, and so a stream that has been disposed
|
||||
// ends this wait rather than being asked again.
|
||||
await waits.Notice.ConfigureAwait(true);
|
||||
waits.Notice = null;
|
||||
|
||||
// A burst — one person's save is two items, and a colleague tidying a folder is a dozen —
|
||||
// deserves one pass rather than one each.
|
||||
await Task.Delay(NoticeDebounce, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
while (stream!.TryRead(out _))
|
||||
{
|
||||
// Swallowed on purpose. Every notice means the same thing, which is what the pass about to
|
||||
// run already does; what they say about *which* vault is not read, because a pass syncs
|
||||
// every vault this session can reach anyway.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The two waits the background loop keeps alive between passes.</summary>
|
||||
/// <remarks>
|
||||
/// A class rather than three locals because <see cref="WaitForWorkAsync"/> has to hand them back
|
||||
/// changed, and a method that took three <c>ref</c> parameters could not be <c>async</c>. See that
|
||||
/// method for why abandoning either of them is a defect rather than a tidiness question.
|
||||
/// </remarks>
|
||||
private sealed class AutoSyncWaits
|
||||
{
|
||||
/// <summary>The pending timer tick, or null when the last one has been consumed.</summary>
|
||||
internal Task<bool>? Tick { get; set; }
|
||||
|
||||
/// <summary>The pending read from the server's push channel.</summary>
|
||||
internal Task<VaultEvent>? Notice { get; set; }
|
||||
|
||||
/// <summary>The stream <see cref="Notice"/> was taken from, to notice a reconnection.</summary>
|
||||
internal IVaultEventStream? Watching { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shows one kind of item, if nothing is being edited.</summary>
|
||||
/// <remarks>
|
||||
/// Takes the section rather than there being one command per kind, so a third kind is an enum member and
|
||||
|
||||
Reference in New Issue
Block a user