using System.Threading.Channels; using DodoSSH.Client.Domain; using DodoSSH.Client.Sync; using DodoSSH.Contracts; namespace DodoSSH.Client.Session; /// /// Records keychain changes into the vault they happened in, without making the save wait. /// /// /// /// 's shape, and the reason is the same one stated a different way: the /// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost /// into every edit. So posts to a bounded channel and returns, and one background task /// does the work. /// /// /// Session-scoped, unlike the connection recorder. This one is created with the vault and dies with /// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it /// is recorded. That is why it is owned by rather than by the shell. /// /// /// Every failure is swallowed. A log write that failed and surfaced would fail a save, and the whole /// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops /// something is one advisory line. /// /// internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable { /// private const int QueueDepth = 512; private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2); private readonly Channel pending = Channel.CreateBounded( new BoundedChannelOptions(QueueDepth) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, }); private readonly ActivityLogRepository log; private readonly Guid vaultId; private readonly Guid actorUserId; private readonly string deviceName; private readonly TimeProvider clock; private readonly CancellationTokenSource lifetime = new(); private readonly Task drain; private int disposed; /// Where entries go. /// The vault they belong to. /// Which account is making them. /// What this machine calls itself. /// Time source. internal ActivityRecorder( ActivityLogRepository log, Guid vaultId, Guid actorUserId, string deviceName, TimeProvider clock) { this.log = log; this.vaultId = vaultId; this.actorUserId = actorUserId; this.deviceName = deviceName; this.clock = clock; drain = DrainAsync(lifetime.Token); } /// public void Record( Guid vaultId, SyncEntityType kind, Guid entityId, string label, ActivityOperation operation, IReadOnlyList changedFields) { ArgumentNullException.ThrowIfNull(changedFields); if (vaultId != this.vaultId) { // A write to a vault this recorder is not for. Not currently reachable — one session, one active // vault — and refused rather than filed under the wrong one, because that is the failure that // would be hardest to notice once shared vaults land. return; } var entry = new ActivityLogSecret { // The name rather than the number, so a build that has never heard of a kind still shows // something a person can read. See ActivityLogSecretCodec. ItemKind = Enum.GetName(kind) ?? kind.ToString(), ItemId = entityId, ItemLabel = label, Operation = operation, ChangedFields = string.Join(", ", changedFields), At = clock.GetUtcNow(), DeviceName = deviceName, ActorUserId = actorUserId, }; pending.Writer.TryWrite(entry); } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 1) { return; } pending.Writer.TryComplete(); try { await drain.WaitAsync(FlushTimeout).ConfigureAwait(false); } catch (Exception exception) when (exception is TimeoutException or OperationCanceledException) { // Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes. } await lifetime.CancelAsync().ConfigureAwait(false); try { await drain.ConfigureAwait(false); } catch (OperationCanceledException) { // Expected: cancelling is how the loop is asked to stop. } lifetime.Dispose(); } private async Task DrainAsync(CancellationToken cancellationToken) { try { await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { try { await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { // Swallowed. There is no caller left to tell, and the realistic failure is a cache that // has gone away underneath a session being disposed. } } } catch (OperationCanceledException) { // Shutting down. } } }