Skip to content

Too much happening on GUI thread #1327

Description

@JoshuaRowePhantom

Summary

During each streaming LLM run, StreamingPersistenceMiddleware.GetStreamingResponseAsync performs an unbounded IList<ChatResponseUpdate>.ToChatResponse() coalescing pass on every yielded update (line 64), and then does persistence-store I/O on the same call stack. In GUI hosts this call stack runs on the Avalonia dispatcher thread: the profiler stack shows

System.GC.AllocateUninitializedArray<char>            (managed→native)
Microsoft.Extensions.AI ...MergeText / CoalesceContent / FinalizeResponse / ToChatResponse
Phantom.Workspaces.Llm.StreamingPersistenceMiddleware.GetStreamingResponseAsync  line 64
Microsoft.Agents.AI.ChatClientAgent.RunCoreStreamingAsync
Phantom.Workspaces.Llm.AgentChat.RunProcessLoopAsync                             line 1770
Avalonia.Threading.SendOrPostCallbackDispatcherOperation.InvokeCore
Avalonia Dispatcher.MainLoop

The heavy per-update work (a fresh char[] allocation and full text concatenation of the entire accumulated assistant message, plus a persistence-store round-trip) blocks input, rendering and animation on the UI thread for the duration of every LLM stream. The MEAI coalescing and persistence I/O are pure compute / I/O — they must run on a worker thread. Only the view-model mutations that Avalonia binds to (running-item collections, chat history) legitimately need the foreground context.

Root Cause

There are two collaborating causes; both must be understood before fixing.

Cause 1 — the process loop is bound to the foreground (UI) scheduler by design

AgentChat.StartProcessingLoop explicitly schedules RunProcessLoopAsync on this.foregroundScheduler, which in the GUI is a SynchronizationContextTaskScheduler over the Avalonia UI SynchronizationContext:

features/Phantom.Workspaces.Llm.Core/AgentChat.cs:2233-2262

private void StartProcessingLoop()
{
    ...
    // Run the process loop on the same foreground scheduler used for running-item mutations.
    // In production this is the captured UI synchronization context ...
    this.processTask = Task.Factory.StartNew(
        () => this.acceptsUserInput
            ? this.RunProcessLoopAsync(this.cts.Token)
            : this.RunHostedProcessLoopAsync(this.cts.Token),
        this.cts.Token,
        TaskCreationOptions.DenyChildAttach,
        this.foregroundScheduler).Unwrap();
}

foregroundScheduler is captured in the constructor from the current SynchronizationContext when no explicit scheduler is supplied:

features/Phantom.Workspaces.Llm.Core/AgentChat.cs:139-142

this.foregroundScheduler = request.ForegroundScheduler
    ?? (SynchronizationContext.Current is not null
        ? TaskScheduler.FromCurrentSynchronizationContext()
        : this.foregroundSchedulerPair.ExclusiveScheduler);

This binding is intentional: the loop mutates non-thread-safe, UI-observed collections (RunningItems, History, etc.) via CreateRunningItem / UpdateRunningItem / CompleteRunningItem, and those mutations must happen on the foreground context.

Cause 2 — the streaming enumeration runs synchronously on the loop's thread

RunProcessLoopAsync pumps the provider enumerator directly and does not offload it:

features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1759-1785

providerEnumerator = this.StartRun(
        chatMessagesToSubmit.ToArray(),
        currentSession,
        runCancellation.Token)
    .GetAsyncEnumerator(runCancellation.Token);

while (true)
{
    pendingMoveNext = providerEnumerator.MoveNextAsync().AsTask();          // line 1770
    if (await WasCanceledBeforeCompletingAsync(pendingMoveNext, runCancellation.Token))
        throw new OperationCanceledException(runCancellation.Token);

    var hasNext = await pendingMoveNext;                                    // no ConfigureAwait(false)
    ...
    partialResponses.Notify(providerEnumerator.Current);
}

Each MoveNextAsync is invoked from the UI thread (because the loop resumes on the foreground scheduler), and any part of it that runs synchronously — or that resumes synchronously after an already-completed inner await — runs on the UI thread. The middleware body is:

features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:48-78

public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options = null,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var buffer = new List<ChatResponseUpdate>();
    var persistedCount = 0;

    await foreach (var update in this.inner.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
    {
        buffer.Add(update);

        var response = buffer.ToChatResponse();                              // line 64 — heavy
        var stableCount = update.FinishReason is not null
            ? response.Messages.Count
            : Math.Max(0, response.Messages.Count - 1);

        for (var i = persistedCount; i < stableCount; i++)
        {
            await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false);  // I/O
        }

        persistedCount = stableCount;

        yield return update;
    }
}

Note that ConfigureAwait(false) here only affects continuations inside the async iterator — it does not change which thread the consumer's MoveNextAsync runs on. When the inner client returns an already-buffered update (a fast local await, or the very first synchronous fast-path of a streaming SDK), execution stays on the caller's thread all the way through buffer.ToChatResponse() and into PersistMessageAsync. Because the caller resumed on the Avalonia dispatcher (Cause 1), the heavy MEAI coalescing (FinalizeResponseCoalesceContentMergeTextAllocateUninitializedArray<char>) and the persistence-store write execute on the UI thread — exactly the stack shown by the profiler.

Additionally, ToChatResponse() is called on every update, not just at end-of-stream. It re-coalesces the entire growing buffer each time, so per-update UI-thread cost grows with response length — a very large final response coalesces its full text on the UI thread on every intermediate update as well.

Affected Files

File Role
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs Runs ToChatResponse coalescing + persistence I/O on the caller's thread; must offload to a worker.
features/Phantom.Workspaces.Llm.Core/AgentChat.cs (lines ~1690–1830, and StartProcessingLoop at ~2233–2262) Schedules RunProcessLoopAsync on the foreground scheduler and pumps the provider enumerator directly on that thread. Fix must ensure the streaming enumeration + coalescing does not run on the foreground context, while UI-observable mutations (CreateRunningItem / UpdateRunningItem / CompleteRunningItem, partialResponses.Notify) are still marshalled back to it.
features/Phantom.Workspaces.Llm.Core/PartialResponseConflator.cs (indirectly) Receives per-update notifications; already marshals VM mutations, so it is the natural boundary between "background streaming" and "foreground VM updates".

Design / Fix

Guiding principle (per the owner's comment: "the StreamingPersistenceMiddleware needs to be mindful of foreground / background operations"): keep binding-affecting mutations on the foreground context; move pure compute and I/O off it.

Primary fix — offload the coalescing + persistence work inside StreamingPersistenceMiddleware

Wrap the stream-consumption body in a Task.Run so the heavy per-update work runs on the thread pool regardless of who called MoveNextAsync. The idiomatic shape for an IAsyncEnumerable-yielding middleware is a channel + background pump:

public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options = null,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var channel = Channel.CreateUnbounded<ChatResponseUpdate>(
        new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });

    // Run the enumeration, coalescing and persistence on the thread pool, never on the caller's
    // (potentially UI) thread. The producer yields updates through the channel; the caller
    // consumes them on whatever context it prefers.
    var pump = Task.Run(async () =>
    {
        try
        {
            var buffer = new List<ChatResponseUpdate>();
            var persistedCount = 0;

            await foreach (var update in this.inner
                .GetStreamingResponseAsync(messages, options, cancellationToken)
                .ConfigureAwait(false))
            {
                buffer.Add(update);

                var response = buffer.ToChatResponse();               // now on thread pool
                var stableCount = update.FinishReason is not null
                    ? response.Messages.Count
                    : Math.Max(0, response.Messages.Count - 1);

                for (var i = persistedCount; i < stableCount; i++)
                {
                    await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false);
                }
                persistedCount = stableCount;

                await channel.Writer.WriteAsync(update, cancellationToken).ConfigureAwait(false);
            }
            channel.Writer.Complete();
        }
        catch (Exception ex)
        {
            channel.Writer.TryComplete(ex);
        }
    }, cancellationToken);

    await foreach (var update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
    {
        yield return update;
    }

    await pump.ConfigureAwait(false);
}

Ordering constraint preserved: the middleware still writes each stable message before the corresponding update is delivered to the consumer, because the pump only writes to the channel after PersistMessageAsync completes. The consumer still sees updates incrementally (not batched to end-of-stream); the coalescing/persistence cost is simply no longer on the UI thread.

Alternative — offload once at the process-loop level

If a wider fix is preferred, AgentChat.StartProcessingLoop can launch the enumeration portion of RunProcessLoopAsync on the thread pool while keeping the running-item mutations marshalled to the foreground scheduler (e.g. via RunOnForegroundAsync, already used elsewhere in this file — see comment at lines 388–406). The primary fix above is preferred because it is local to the middleware, cannot regress the existing foreground-context invariants (issues #909 / #1068), and directly addresses the owner's note.

What must remain on the foreground context

  • CreateRunningItem / UpdateRunningItem / CompleteRunningItem and any runningItem.Items mutation — Avalonia binds to these collections.
  • History.Add / AppendUserMessagesToHistory.
  • PartialResponseConflator.Notify (which internally marshals VM edits) — the loop still calls this from the foreground context; only the upstream production of ChatResponseUpdates moves to the pool.

Optional follow-up — avoid per-update full coalescing

buffer.ToChatResponse() is O(total text) per update. Once the heavy work is off the UI thread this is no longer a UX blocker, but a follow-up may want to compute the "message boundary" using role/finish-reason transitions on the buffer directly, calling ToChatResponse() only when a boundary or end-of-stream is observed. Out of scope for this fix.

Expected Tests

Add to features/Phantom.Workspaces.Llm.Core.Tests/StreamingPersistenceMiddlewareTests.cs:

Test Scenario / expectation
StreamingPersistenceMiddleware_CoalescingRunsOffCapturedSynchronizationContext Install a tracking SynchronizationContext on the calling thread, invoke GetStreamingResponseAsync from that thread, feed several ChatResponseUpdates through a fake inner client, and assert that every observed SynchronizationContext.Current sampled inside the ChatResponseExtensions.ToChatResponse path (via an AIContent/inner-client hook that records SynchronizationContext.Current at coalesce time) is either null or the default (thread-pool) context — never the tracking context.
StreamingPersistenceMiddleware_PersistMessageAsyncRunsOffCapturedSynchronizationContext Same tracking-context harness; assert IAgentPersistenceStore.StoreAsync observes SynchronizationContext.Current off the tracking context on every stable-message write.
StreamingPersistenceMiddleware_YieldOrdering_StableMessagePersistedBeforeNextUpdateYielded Existing invariant (already covered by TwoUpdates_FirstPersistedBeforeSecondYielded) must continue to hold after offloading — verify explicitly that when message N becomes stable, StoreAsync(N) completes before MoveNextAsync returns update N+1 to the consumer.
StreamingPersistenceMiddleware_CancellationTokenPropagates_ToInnerEnumerationAndPump When the caller cancels, the offloaded pump observes cancellation and the channel completes with OperationCanceledException (or equivalent), matching the current behaviour.
AgentChat_StreamingRun_ForegroundContextObservesOnlyVmMutations Integration-style: with a foreground SynchronizationContextTaskScheduler installed, run a scripted streaming response through AgentChat; assert that RunningItems/History observable mutations run on the foreground context (existing invariant), while a hook placed on the inner chat client records that ToChatResponse/StoreAsync did not run on it.

Naming follows the Subject_Scenario_ExpectedOutcome PascalCase convention already used in StreamingPersistenceMiddlewareTests (e.g. SingleUpdate_FinishReason_PersistedOnStreamEnd, TwoUpdates_FirstPersistedBeforeSecondYielded).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiednext-upverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions