Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s

This commit is contained in:
2026-08-04 16:38:42 +02:00
31 changed files with 3285 additions and 13 deletions
@@ -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