namespace AIStudio.Chat; /// /// Keeps a number up to date which nothing announces. /// /// /// A conversation is a plain list of plain objects. Nothing raises an event when a block is added, /// when a document is attached, or when an answer grows by another sentence -- so a number derived /// from all of that cannot be wired to the places which change it. It was tried: fifteen call sites, /// and four review rounds each found another one which was missing. /// /// So the number is recomputed instead of notified. Whoever thinks something may have changed nudges /// this tracker, and the tracker decides when to do the work: many nudges in a row become one run, a /// nudge arriving during a run becomes exactly one further run, and a minimum distance keeps a burst /// of them from turning into a burst of counting. /// /// The heartbeat is not distrust of the nudges. Attachments are read from disk every time they are /// sent, so a file somebody edits in another program changes what the next message costs without /// anything happening in AI Studio which anyone could nudge from. /// /// Does the actual work. Gets a token which ends it when the tracker goes away. /// /// How long to stay quiet after a run before honouring the next nudge. Asked again each time, /// because what is reasonable depends on what is going on: a person who just switched a profile is /// waiting for the number, while an answer being written moves it with every word and wants a /// slower pace than the words arrive at. /// /// How long to wait for a nudge before running anyway. public sealed class ConversationTokenTracker(Func recount, Func quietTime, TimeSpan heartbeat) : IAsyncDisposable { /// /// How long a tracker which is going away waits for its own loop. /// /// /// The loop ends on cancellation, so this is only ever reached when something it called does /// not. Whoever is leaving the screen must not be the one who waits for that. /// private static readonly TimeSpan SHUTDOWN_PATIENCE = TimeSpan.FromSeconds(2); private readonly SemaphoreSlim wakeUp = new(0, 1); private readonly CancellationTokenSource stopping = new(); private Task? loop; /// /// Starts the loop. Calling this twice does nothing the second time. /// public void Start() => this.loop ??= Task.Run(this.RunAsync); /// /// Says that something may have changed. /// /// /// Cheap on purpose, because it is called from the render path. It says "maybe", never "yes": /// asking for a run which turns out to change nothing costs a few lookups, while missing one is /// the bug this whole class exists to make impossible. /// public void Nudge() { // // One pending wake-up is all a loop can act on. A second one would only make it run again // with the same answer. // if (this.wakeUp.CurrentCount > 0) return; try { this.wakeUp.Release(); } catch (SemaphoreFullException) { // // Two threads got past the check above at the same time. The one which won left the // wake-up we wanted, so there is nothing left to do here. // } catch (ObjectDisposedException) { // The tracker is going away, and a number nobody will look at needs no update. } } private async Task RunAsync() { var token = this.stopping.Token; while (!token.IsCancellationRequested) { try { // // Sleeps until somebody nudges -- or until the heartbeat is due, which is what the // timeout returning false means. Both lead to the same run, so the result is not // even looked at. // await this.wakeUp.WaitAsync(heartbeat, token); if (token.IsCancellationRequested) return; // // Deliberately without draining further wake-ups first. A nudge which arrives while // this run reads the conversation may well be about a change this run is already // seeing -- and then the extra run costs a few lookups. Draining would risk the // other case, where the change comes after the read and nobody asks again. // try { await recount(token); } catch (Exception) when (!token.IsCancellationRequested) { // // One failed run must not end the loop: a tracker which died on a single bad // answer would leave a stale number standing forever, which is the failure this // class was built to rule out. Saying what went wrong is the job of the work // itself, which is the only side that has a logger. // } // // The quiet time is kept after the work, not before it: the first nudge of a burst // is answered at once, and the rest of the burst collapses into the single run which // follows this delay. // // It is also what paces a run which feeds itself. Showing a new number renders, and // a render nudges -- so while something changes continuously, this delay is the // whole cadence. // await Task.Delay(quietTime(), token); } catch (OperationCanceledException) { return; } catch (ObjectDisposedException) { // The tracker was disposed underneath this loop, which is another way of stopping. return; } } } #region Implementation of IAsyncDisposable public async ValueTask DisposeAsync() { await this.stopping.CancelAsync(); if (this.loop is not null) { try { // // Awaited rather than abandoned, so that nothing is still counting into a component // which is already gone. The counting itself takes the same token, so a run which // sits in an IPC call ends with it -- and the patience is there for the case where // it does not, because a chat being closed is not worth hanging on to. // await this.loop.WaitAsync(SHUTDOWN_PATIENCE); } catch (Exception) { // The loop ends on cancellation; whatever else it carries out is of no use here. } } this.stopping.Dispose(); this.wakeUp.Dispose(); } #endregion }