Skip to content

Coalesce JoinableTaskFactory dispatcher posts - #1639

Open
Andrew Arnott (AArnott) wants to merge 18 commits into
mainfrom
aarnott-fix-issue-1272
Open

Coalesce JoinableTaskFactory dispatcher posts#1639
Andrew Arnott (AArnott) wants to merge 18 commits into
mainfrom
aarnott-fix-issue-1272

Conversation

@AArnott

@AArnott Andrew Arnott (AArnott) commented Aug 19, 2026

Copy link
Copy Markdown
Member

Repeated main-thread transitions can leave large numbers of already-executed work items in the underlying dispatcher queue, consuming memory and CPU until the dispatcher eventually processes them.

This change adds a private per-factory queue for pending main-thread callbacks. It keeps at most one lower-level synchronization-context message outstanding, removes callbacks that were already executed through another JTF avenue, and reposts one item at a time to preserve dispatcher fairness. The existing virtual posting hook remains intact, so dispatcher-priority and delegating factories retain their scheduling behavior.

Tests cover conservative posting, stale callback removal, dispatcher priority, and delegating factories across the configured target frameworks.

Fixes #1272

Validation

Queue pending main-thread callbacks per factory so only one underlying synchronization-context message is outstanding, while pruning callbacks already executed through another avenue.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 19, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates JoinableTaskFactory to coalesce posts to the underlying SynchronizationContext by maintaining a per-factory pending-callback queue and ensuring at most one “driver” message is outstanding at a time. This reduces memory/CPU overhead from large backlogs of already-executed work items accumulating in dispatcher queues while preserving fairness by reposting one item at a time.

Changes:

  • Add a private per-factory queue for pending underlying-synchronization-context callbacks and a single “driver” callback that executes one pending item and reposts as needed.
  • Opportunistically trim already-executed callbacks before dequeuing/executing the next item.
  • Add unit tests validating conservative posting (one outstanding message) and removal of callbacks already executed via other JTF avenues.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs Adds tests and a small test factory to validate conservative posting and stale-callback trimming behavior.
src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs Implements the coalescing queue + driver-post mechanism for underlying SynchronizationContext posting.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs Outdated
Release completed callbacks and the empty private queue when the underlying synchronization context rejects a post.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs:707

  • When the last pending callback is drained, the private Queue instance is left allocated. If the queue ever grows large (the scenario this PR targets), its backing array capacity can remain large even after Count returns to 0, retaining memory indefinitely. Consider releasing (or trimming) the queue when it becomes empty so bursty workloads don’t permanently increase per-factory memory footprint.
            {
                this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
                postAnotherCallback = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0;
                this.underlyingSynchronizationContextCallbackPending = postAnotherCallback;
            }

test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs:344

  • If the provided callback throws, callbackCompleted is never completed, so the JoinableTask can hang forever (and the test suite may deadlock). Completing callbackCompleted in a try/catch avoids the hang and correctly propagates the failure.
                this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(delegate
                {
                    callback();
                    callbackCompleted.SetResult(null);
                });

Drop the private queue after each drained burst and make the test callback harness propagate exceptions instead of hanging.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 19, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs:433

  • Potential lost-driver race when PostToUnderlyingSynchronizationContext throws: the code sets underlyingSynchronizationContextCallbackPending=true before actually posting. If the post attempt throws and another thread enqueues callbacks while the flag is still true, that other thread will skip posting and return successfully; after the catch resets the flag to false, the queue can contain pending callbacks with no underlying message to drive draining until another callback is posted later. This also means exceptions from a failing underlying post may no longer propagate consistently to all enqueuers.
                this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
                this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue(callback);
                postCallback = !this.underlyingSynchronizationContextCallbackPending;
                this.underlyingSynchronizationContextCallbackPending = true;
            }

Clear the secondary dispatch queue when no underlying message can be established, preventing concurrent enqueuers from leaving an undriven queue. Each callback remains available through its owning joinable task queue.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 19, 2026 22:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs
Keep existing JoinableTaskFactory overrides on their prior direct-post path while the base and WPF dispatcher implementations explicitly opt into the private coalescing queue. Add coverage for no-op derived factories.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 20, 2026 01:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Use a one-shot state holder for lower-level driver messages so synchronization contexts that retain processed messages do not keep the JoinableTaskFactory alive.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 20, 2026 01:27
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs:364

  • This test schedules work via SwitchToMainThreadAsync().GetAwaiter().OnCompleted(...) but never calls GetResult() on the awaiter. If the main-thread transition is canceled/faults, the exception is never observed and the test may incorrectly pass or hang. Capture the awaiter and call GetResult() inside the continuation so failures are surfaced reliably.
                this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(delegate
                {
                    try
                    {
                        callback();
                        callbackCompleted.SetResult(null);
                    }
                    catch (Exception ex)
                    {
                        callbackCompleted.SetException(ex);
                    }
                });

Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs
Make concurrent enqueuers observe the in-flight lower-level post result while avoiding self-deadlock for synchronization contexts that invoke Post callbacks inline.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 20, 2026 01:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs
Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs
Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs Outdated
Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 19:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs:503

  • maximumPostDepth can be reduced under concurrency: Interlocked.Exchange(ref maximumPostDepth, Math.Max(VolatileRead, depth)) is not an atomic max update, so another thread may have already recorded a larger depth and this write can overwrite it with a smaller value. Even though the current tests likely run single-threaded, this helper becomes unreliable if it’s ever used from multiple threads, potentially masking recursion regressions. Consider updating the maximum using a CompareExchange loop that only ever increases the value.
            int depth = Interlocked.Increment(ref this.currentPostDepth);
            Interlocked.Exchange(ref this.maximumPostDepth, Math.Max(this.MaximumPostDepth, depth));

Comment thread src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 20:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 20:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

docfx/docs/threading_rules.md:334

  • The sample implementation uses this.dispatcher.Post(callback, state), which isn’t a common API surface (e.g., WPF Dispatcher doesn’t have Post). Consider using a compilable example (and match the protected internal accessibility on the override).
protected override void PostToUnderlyingSynchronizationContextCore(
    SendOrPostCallback callback,
    object state)
{
    this.dispatcher.Post(callback, state);
}

Comment thread docfx/docs/threading_rules.md
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 20:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread docfx/docs/threading_rules.md Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@lifengl Lifeng Lu (lifengl) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me.

@lifengl

Copy link
Copy Markdown
Member

I ran a Copilot session to review this PR, and it posted an interesting issue:

[warning] Although  SynchronizationContext.Post  does not universally guarantee  ExecutionContext  flow, this coalescing changes the behavior of contexts that do flow it per post, including  SingleThreadedSynchronizationContext .  MainThreadAwaiter  also deliberately suppresses flow for  UnsafeOnCompleted  but not  OnCompleted .
Since only the first queued callback causes an underlying post, successor posts capture the first driver’s context and later callbacks run directly within that context. A second  OnCompleted  callback may therefore observe the first callback’s  AsyncLocal  values, while an  UnsafeOnCompleted  callback may unexpectedly inherit them.
Could the queue preserve each callback’s captured context or suppressed-flow state, with driver posts suppressing flow? Tests covering safe-to-safe, safe-to-unsafe, and unsafe-to-safe registrations would verify the existing awaiter behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 21, 2026 23:30
@AArnott

Copy link
Copy Markdown
Member Author

Although SynchronizationContext.Post does not universally guarantee ExecutionContext flow, this coalescing changes the behavior of contexts that do flow it per post...

Fixed in f5df5c2. Each queued callback now stores its own captured ExecutionContext (or null when flow was suppressed), while driver posts suppress context flow. The drain reapplies only that callback's context, preserving safe-to-safe, safe-to-unsafe, and unsafe-to-safe registration behavior; regression coverage exercises all three sequences across the supported target frameworks.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dispatcher-based JoinableTaskFactory should post messages more conservatively

4 participants