diff --git a/docs/architecture.md b/docs/architecture.md
index ed319051a..3bd8d6668 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -98,6 +98,11 @@ GCM makes use of the `async`/`await` model of .NET and C# in almost all
parts of the codebase where appropriate as usually requests end up going to the
network at some point.
+Work that must run on the process entry thread - creating UI controls, or
+using the macOS identity broker - is marshalled there by the main thread
+dispatcher. See the [main thread dispatcher][gcm-dispatcher] documentation for
+how that works and the rules for posting to it.
+
## Command execution
```text
@@ -282,5 +287,6 @@ to the trace object in most places of GCM.
[credential-provider]: configuration.md#credentialprovider
[issue-113]: https://github.com/git-ecosystem/git-credential-manager/issues/113
[issue-136]: https://github.com/git-ecosystem/git-credential-manager/issues/136
+[gcm-dispatcher]: dispatcher.md
[gcm-provider]: environment.md#GCM_PROVIDER
[msal]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet
diff --git a/docs/dispatcher.md b/docs/dispatcher.md
new file mode 100644
index 000000000..b9d351258
--- /dev/null
+++ b/docs/dispatcher.md
@@ -0,0 +1,328 @@
+# Main thread dispatcher
+
+## Why it exists
+
+Some platform APIs may only be used from the thread that started the process -
+"thread 1", the *main thread*. On macOS this includes creating any UI control.
+
+The main-thread requirement also affects the macOS MSAL broker. MSAL decides
+**once per process** whether an `NSApplication` is running, and caches that
+answer. If one is not running it requires interactive broker calls to be made
+on thread 1, and then takes that thread over with its own polling loop - which
+cannot coexist with a UI main loop. If one *is* running it requires neither.
+
+Serving these by simply running GCM on the main thread is not an option:
+starting a UI framework costs far more than a typical GCM invocation, and most
+invocations never show a window at all. A `get` request served from the
+credential store should not pay for a graphical toolkit.
+
+GCM therefore splits the two roles:
+
+- **The main thread** hosts the `Dispatcher` and serves work that must run on
+ thread 1.
+- **The `AppMain` thread** runs the application itself - command dispatch,
+ provider selection, authentication, and everything else.
+
+The dispatcher keeps the main thread parked cheaply until somebody actually
+needs it, and only then starts the platform main loop.
+
+## Thread layout
+
+```mermaid
+sequenceDiagram
+ participant Main as main thread
+ participant App as AppMain thread
+
+ Main->>Main: Dispatcher.Initialize()
+ Main->>App: start AppMain thread
+ Main->>Main: Dispatcher.MainThread.Run()
+ Note over Main: parked, waiting to be needed no UI framework started
+ App->>Main: InvokeAsync(work) - the first job
+ Note over App: blocked until the loop can accept work
+ Note over Main: IMainLoop.Initialize()
+ Main-->>App: released
+ Note over Main: IMainLoop.Run(token) owns the thread
+ App->>Main: IMainLoop.Post(job)
+ Main-->>App: job runs and the awaited task completes
+ Note over App: _exitCode = ... dispose app and context
+ App->>Main: Shutdown() - cancels the token
+ Note over Main: main loop exits Run() returns
+ Main->>Main: Trace2.Stop(_exitCode) Environment.Exit(_exitCode)
+```
+
+The main loop is started by the *first job posted*, not by an explicit call.
+This is deliberate: it makes "work on the main thread implies a running main
+loop" true by construction. There is no start-up call for a caller to forget,
+and no ordering for a caller to get wrong.
+
+What waits is the *posting*, not the work. A caller that arrives before the
+main loop is initialized blocks inside `AddJob` until it can accept work, and
+then hands its own job to the loop. The first caller therefore pays the cost of
+starting the UI framework - which is reasonable, since it is the one that
+asked for the main thread.
+
+An invocation that posts no jobs never initialises the UI framework at all. The
+main thread parks, `Shutdown()` wakes it, and `Run()` returns.
+
+## States
+
+```mermaid
+stateDiagram-v2
+ [*] --> NotStarted: Initialize()
+ NotStarted --> Started: Run()
+ NotStarted --> Stopping: Shutdown() wins the race
+ Started --> Stopping: Shutdown()
+ Stopping --> Stopped: Run() returns, or Run() finds the dispatcher already stopping
+ Stopped --> [*]
+```
+
+`NotStarted -> Stopping` is a legitimate race, not misuse. `Program.Main` starts
+the `AppMain` thread *before* calling `Run()`, so a fast invocation can finish
+and shut down first. `Run()` detects this and returns without starting anything.
+
+The distinction between `Stopping` and `Stopped` is what allows that tolerance
+without also silently accepting a genuine error. Calling `Run()` once the thread
+has been released is a programming error, and throws.
+
+## Data structures
+
+`_lock`, a `System.Threading.Lock`, guards the lifecycle state, fault, and
+outstanding jobs. Two one-shot `ManualResetEventSlim` gates handle waiting,
+always outside that lock:
+
+Name|Purpose
+-|-
+`_workRequested`|Opened by the first work request or shutdown. Releases the parked dispatcher thread, which checks whether it should initialize the main loop or exit.
+`_mainLoopReady`|Opened when initialization succeeds, the main loop fails, or the dispatcher shuts down. Releases callers waiting in `AddJob`, which inspect the outcome under `_lock` before accepting work.
+`_outstandingJobs`|Every accepted job, tracked until its completion task settles, including time spent awaiting asynchronous work. This is what makes a main loop failure recoverable - see [Failure handling](#failure-handling).
+`_mainLoopFault`|The first fault seen, if any. Once set, the dispatcher is permanently unusable.
+`_state`|See [States](#states).
+
+Neither gate is ever reset. Signals survive until a thread waits, and every
+current or future waiter is released once a gate opens. An open gate is a
+reason to inspect the state, not a promise that work can run.
+
+`_workRequested` uses a zero spin count to park the main thread without
+spinning while it waits to be needed. `_mainLoopReady` uses the default spin
+count, allowing a brief spin before blocking.
+
+The gates have the dispatcher's lifetime, not just `Run()`'s. They are not
+disposed by `Shutdown()` or `Run()`, since posting callers can still be waiting
+or signalling as those methods return. Neither gate's `WaitHandle` is used, so
+their native wait handles are never materialized.
+
+The dispatcher deliberately keeps **no queue of its own**. The only queue is the
+main loop's - Avalonia's dispatcher queue - which the dispatcher can add to via
+`IMainLoop.Post`. A job therefore never sits anywhere that nothing is pumping.
+
+## Posting work
+
+`Dispatcher` exposes one fire-and-forget method and four awaitable ones:
+
+Method|Returns|Completes when
+-|-|-
+`Post(Action)`|`void`|n/a - the task is discarded
+`InvokeAsync(Action)`|`Task`|the delegate returns
+`InvokeAsync(Func)`|`Task`|the delegate returns
+`InvokeAsync(Func)`|`Task`|the returned task completes
+`InvokeAsync(Func>)`|`Task`|the returned task completes
+
+The `CancellationToken` passed to the delegate is signalled at shutdown.
+
+Each job owns its work delegate and completion source. `DispatcherJob`
+handles synchronous work; `AsyncDispatcherJob` and `AsyncDispatcherJob`
+handle task-returning work. Their shared `DispatcherJob` base restores the
+execution context, applies Trace2 attribution, and catches synchronous
+exceptions from invoking the delegate.
+
+The last two overloads exist because an `async` delegate returns at its first
+yielding `await`. Without them `async _ => ...` binds to `Func` with `T`
+inferred as `Task<...>`, and the caller gets back a task that completes when the
+work *starts* rather than when it finishes. These overloads forward the work's
+task outcome into a completion source owned by the dispatcher. The task
+returned to the caller is that completion source's task, not an `Unwrap()`
+proxy, so `RunContinuationsAsynchronously` applies to the caller's
+continuations as well. Results, exceptions, and cancellation are preserved.
+
+Each async job's `CompleteAsync` helper awaits the work's task without
+capturing the main loop's synchronization context. `SuppressThrowing` lets it
+observe completion before forwarding the original outcome with
+`TrySetFromTask`, including all exceptions and the cancellation token.
+Failures are transferred to the caller's task, not discarded.
+
+The queue tracks `IDispatcherJob.Completion`, without needing to distinguish
+sync and async jobs. An asynchronous job remains in `_outstandingJobs` after
+its first yield, so a later main-loop failure can still fault the caller's
+task even when the work's own continuation can no longer run. Removal from
+the set does not depend on the main loop being available. Cleanup observes
+the registered job's completion, including faults, without rethrowing or
+reporting them; the caller's task keeps its original outcome.
+
+> **Note**
+>
+> `Post` discards the task, so a job that throws has nowhere to report the
+> failure. Prefer `InvokeAsync` unless the result genuinely does not matter.
+> It is also not quite fire-and-forget: like every method here it blocks until
+> the main loop can accept work; only waiting for the work's completion is
+> omitted.
+
+Work always *begins* on the main thread. Because the main loop installs its own
+synchronization context, continuations after an `await` resume there too, unless
+the delegate opts out with `ConfigureAwait(false)`.
+
+### Routing
+
+```mermaid
+flowchart TD
+ A["InvokeAsync(work) / Post(work)"] --> B["AddJob(job)"]
+ B --> C{"lock (_lock)"}
+ C -->|"state is Stopping or Stopped"| D["throw InvalidOperationException"]
+ C -->|"on the dispatcher thread, startup still pending"| E["throw InvalidOperationException"]
+ C -->|"otherwise"| F["_workRequested.Set() release the lock"]
+ F --> G["_mainLoopReady.Wait() outside the lock"]
+ G --> H{"lock (_lock) inspect the outcome"}
+ H -->|"fault"| I["job.Fail(fault) outside the lock"]
+ H -->|"no fault, but stopping or stopped"| D
+ H -->|"initialized and accepting work"| J["_outstandingJobs.Add(job)"]
+ J --> K["IMainLoop.Post(job) outside the lock"]
+```
+
+Opening `_workRequested` requests initialization. Waiting on
+`_mainLoopReady` lets each caller inspect the outcome under `_lock`: fault
+the job, reject shutdown, or accept it into `_outstandingJobs`. A caller that
+has already passed the initial lifecycle check observes a recorded fault even
+if shutdown follows it; without a fault, it must re-check the lifecycle before
+accepting work.
+
+Failing or posting a job happens *outside* the lock, because both run code that
+takes other locks - the main loop's, or the caller's continuation. Gate waits
+also happen outside the lock: waiting on an event does not release a held lock.
+
+The second rejection above is the one worth knowing about. Posting from the
+dispatcher thread itself while initialization is pending would block the only
+thread that could ever release it, so it is rejected rather than left to
+deadlock. Once initialization succeeds the dispatcher thread may post freely.
+
+## Starting the main loop
+
+```mermaid
+flowchart TD
+ A["Run() - state is Started"] --> B["WaitForWork() parked on _workRequested"]
+ B -->|"shutting down"| X["return without initializing"]
+ B -->|"work requested"| C["RunMainLoop()"]
+ C --> D["IMainLoop.Initialize() the loop queue now exists, but nothing is pumping it yet"]
+ D --> E["_mainLoopReady.Set() waiting callers can post their jobs"]
+ E --> F["IMainLoop.Run(token) owns the thread until shutdown"]
+ F --> G["only now do the posted jobs run"]
+```
+
+Every job is handed to the main loop by the caller that wanted it, which is why
+callers wait for the loop rather than leaving work for the dispatcher thread to
+forward. Posting on the caller's own stack keeps the job's [context](#context)
+attached to it, and it means there is never a moment where accepted work is
+sitting somewhere that nothing pumps.
+
+On the successful path, `_mainLoopReady` is set only once
+`IMainLoop.Initialize` has returned. No job can *run* until `IMainLoop.Run` is
+pumping - which on macOS is the point at which `NSApplication` starts. Work
+posted to the dispatcher is therefore guaranteed to run with `NSApplication`
+already up, so MSAL always sees a GUI application no matter which happens first.
+
+## Context
+
+A job runs on the main thread but conceptually belongs to the caller, and the
+two want opposite things from the ambient state.
+
+- **Execution context.** The caller's is captured when the job is created and
+ restored around the work, so anything flowed by `AsyncLocal` - such as
+ `Activity.Current` - is still there inside the job.
+- **Trace2 context.** Deliberately the other way round. Trace2 records the
+ thread work *ran on*, so the dispatcher's own context is applied on top of the
+ restored one, and the caller is recorded as a `dispatcher`/`caller` data event
+ so the link back is not lost.
+
+The Trace2 switch has to happen *inside* the restored execution context:
+restoring replaces the whole `AsyncLocal` map, so a switch made around it
+would simply be shadowed. For the same reason the dispatcher captures its own
+Trace2 context when it is constructed rather than reading it when a job runs -
+by then the caller's context is in place and would be the one observed.
+
+## Failure handling
+
+If the main loop cannot be started, or stops unexpectedly, no main thread work
+can ever run. Callers waiting on a job would otherwise wait forever, and GCM
+would hang with Git waiting on it.
+
+`FailAllJobs` therefore faults everything in `_outstandingJobs`, records the
+fault, and fails any later arrivals immediately. It is idempotent: the first
+fault wins, so concurrent failures report a single, consistent cause.
+
+Three further details matter:
+
+- Publishing `_mainLoopFault` and opening `_mainLoopReady` releases callers
+ blocked in `AddJob`. They see the fault instead of the loop they were waiting
+ for, so a loop that fails to start cannot strand them. The gate stays open so
+ later arrivals also see the fault without waiting.
+- A callback already sitting in the main loop's queue re-checks `_mainLoopFault`
+ before executing, so work never runs after its task has been faulted.
+- The dispatcher thread then parks until shutdown rather than propagating. The
+ `AppMain` thread still needs to observe its faulted task, unwind, and shut the
+ dispatcher down so that the process exits with the right code. Only this
+ failure-path wait materializes `_cts.Token.WaitHandle`, and it returns
+ immediately if shutdown already cancelled the token.
+
+## Shutdown
+
+`Shutdown()` may be called from any thread. It moves to `Stopping` and opens
+both gates, releasing the parked dispatcher thread and any callers waiting to
+post. Callers check the outcome rather than treating the signal as successful
+initialization. It then cancels the token, outside the lock since cancellation
+runs the main loop's own callbacks. Cancelling is the *only* stop signal the
+main loop gets; `IMainLoop` has no separate shutdown method.
+
+**Outstanding work is abandoned, not drained.** Tasks that still depend on the
+stopped loop may never complete. Already-started asynchronous work can still
+settle independently, for example after `ConfigureAwait(false)` or cooperative
+cancellation. `Shutdown()` must therefore be called only once the application
+has finished everything it cares about. Draining would hang the common case
+of a window shown without anyone awaiting it - there would be nothing left to
+close the window, and so nothing to wait for.
+
+## Rules for contributors
+
+- **Anything needing the main thread goes through the dispatcher.** Do not reach
+ for the UI framework's own dispatcher directly.
+- **Post only what genuinely needs thread 1.** Posting the first job is what
+ pays for starting the UI framework. This is why the silent authentication
+ paths deliberately stay off the dispatcher - see
+ [`EntraAuthentication.PublicClient.cs`][entra-public-client].
+- **Expect the first post to block.** Starting the main loop happens on the
+ posting caller's time. Do not post the first job from anywhere that cannot
+ afford to wait for a UI framework to initialise.
+- **Do not block the main thread.** A job that blocks stops the loop pumping,
+ which stalls every other job, the UI, and any platform work the loop drives.
+- **Do not synchronously block on a dispatcher task from inside a job.**
+ `Wait()`, `.Result`, and `GetAwaiter().GetResult()` can block the loop needed
+ to finish that work. Asynchronously awaiting nested dispatcher work is
+ supported; it yields the loop while the task is pending.
+- `CheckAccess()` and `VerifyAccess()` report whether the calling thread is the
+ dispatcher thread, which is useful for avoiding a needless round trip. Before
+ the main loop is initialized such a round trip is not merely wasteful but
+ rejected, since the dispatcher thread would be waiting on itself.
+
+## Testing
+
+`IMainLoop` is an internal seam with a single production implementation,
+`AvaloniaMainLoop`. An internal `Dispatcher.Initialize(IMainLoop)` overload lets
+tests substitute a fake, so start-up, failure, and shutdown behaviour can be
+exercised without a real UI framework and without a real thread 1. The fake can
+also hold `Initialize` or `Run` open on demand, which is what makes the
+rendezvous between a posting caller and the starting main loop testable. Its
+synchronization context queues asynchronous continuations back to the fake
+loop, so failures after a job yields can be exercised too. Tests cover signals
+preceding waits, concurrent posters, asynchronous completion, and shutdown
+while initialization is still blocked, whether it later succeeds or fails. See
+[`DispatcherTests`][dispatcher-tests].
+
+[dispatcher-tests]: ../src/Core.Tests/UI/DispatcherTests.cs
+[entra-public-client]: ../src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs
diff --git a/src/Core.Tests/UI/DispatcherTests.cs b/src/Core.Tests/UI/DispatcherTests.cs
new file mode 100644
index 000000000..0ac5a653c
--- /dev/null
+++ b/src/Core.Tests/UI/DispatcherTests.cs
@@ -0,0 +1,1173 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using GitCredentialManager.UI;
+using Xunit;
+
+namespace GitCredentialManager.Tests.UI;
+
+public class DispatcherTests
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ [Fact]
+ public void Dispatcher_Shutdown_BeforeRunIsReached_RunReturnsWithoutStartingMainLoop()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+ var mayRun = new ManualResetEventSlim();
+
+ // Hold the dispatcher thread between Initialize and Run so we can shut down in
+ // the window that the application thread can genuinely hit in Program.Main.
+ Thread thread = StartDispatcherThread(mainLoop, initialized, () => mayRun.Wait(Timeout));
+ Assert.True(initialized.Wait(Timeout));
+
+ Dispatcher.MainThread.Shutdown();
+ mayRun.Set();
+
+ Assert.True(thread.Join(Timeout));
+ Assert.False(mainLoop.Initialized);
+ Assert.False(mainLoop.Ran);
+ }
+
+ [Fact]
+ public void Dispatcher_Run_AfterRunHasReturned_Throws()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+ Exception secondRun = null;
+
+ // Run must be called from the dispatcher thread, so the second call has to be
+ // made there too rather than from the test thread.
+ var thread = new Thread(() =>
+ {
+ Dispatcher.Initialize(mainLoop);
+ initialized.Set();
+ Dispatcher.MainThread.Run();
+ secondRun = Record.Exception(() => Dispatcher.MainThread.Run());
+ })
+ {
+ IsBackground = true,
+ Name = nameof(Dispatcher_Run_AfterRunHasReturned_Throws),
+ };
+ thread.Start();
+
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher.MainThread.Shutdown();
+ Assert.True(thread.Join(Timeout));
+
+ // Running again once the thread has been released is a programming error, and is
+ // distinct from the tolerated race where shutdown beats Run to the dispatcher.
+ Assert.IsType(secondRun);
+ }
+
+ [Fact]
+ public void Dispatcher_Shutdown_NoWorkPosted_NeverStartsMainLoop()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+
+ Dispatcher.MainThread.Shutdown();
+
+ Assert.True(thread.Join(Timeout));
+ Assert.False(mainLoop.Initialized);
+ Assert.False(mainLoop.Ran);
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_FirstJob_StartsMainLoopAndRunsWork()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ Task task = dispatcher.InvokeAsync(_ => 42);
+
+ Assert.Equal(42, await task.WaitAsync(Timeout));
+ Assert.True(mainLoop.Initialized);
+ Assert.True(mainLoop.Ran);
+
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_BeforeRunIsReached_PreservesWorkRequest()
+ {
+ var mainLoop = new FakeMainLoop();
+ using var initialized = new ManualResetEventSlim();
+ using var mayRun = new ManualResetEventSlim();
+ using var posting = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized, () => mayRun.Wait());
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ Thread postingThread = null;
+ bool workRan = false;
+ Task submission = Submit(() =>
+ {
+ postingThread = Thread.CurrentThread;
+ posting.Set();
+ return dispatcher.InvokeAsync(_ => { workRan = true; });
+ });
+
+ Assert.True(posting.Wait(Timeout));
+
+ // Let the caller block in AddJob before Run can reach its wait, so the
+ // work request has to survive being signalled before anybody waits on it.
+ Assert.True(SpinWait.SpinUntil(
+ () => (postingThread.ThreadState & ThreadState.WaitSleepJoin) != 0, Timeout));
+ Assert.False(submission.IsCompleted);
+ Assert.False(mainLoop.Initialized);
+
+ mayRun.Set();
+ await (await submission.WaitAsync(Timeout)).WaitAsync(Timeout);
+
+ Assert.True(workRan);
+ Assert.True(mainLoop.Ran);
+ }
+ finally
+ {
+ mayRun.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_RunsWorkInThePostingCallersExecutionContext()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ // Set the value only once the dispatcher thread exists so that it cannot have
+ // inherited it; the value can now only reach a job by flowing from the call that
+ // posted it.
+ var state = new AsyncLocal();
+ state.Value = "caller";
+
+ // The first job is posted while the main loop is still starting, and the second
+ // once it is already running: two different paths through AddJob.
+ string cold = null;
+ await dispatcher.InvokeAsync(_ => { cold = state.Value; }).WaitAsync(Timeout);
+ Assert.Equal("caller", cold);
+
+ string warm = await dispatcher.InvokeAsync(_ => state.Value).WaitAsync(Timeout);
+ Assert.Equal("caller", warm);
+
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Dispatcher_InvokeAsync_AsyncWork_DoesNotInlineCallerContinuations(bool returnsValue)
+ {
+ using var callbackReturned = new ManualResetEventSlim();
+ var mainLoop = new FakeMainLoop
+ {
+ AfterWork = () => callbackReturned.Set(),
+ // Make the outer task complete before InvokeAsync returns. Unwrap can
+ // then return the inner task directly, losing the outer task's options.
+ AfterPost = () => Assert.True(callbackReturned.Wait(Timeout)),
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ // Deliberately allow the work's own task to run continuations inline.
+ var work = new TaskCompletionSource();
+ Task task = returnsValue
+ ? dispatcher.InvokeAsync(_ => work.Task)
+ : dispatcher.InvokeAsync(_ => (Task)work.Task);
+ Task continuation = task.ContinueWith(
+ _ => dispatcher.CheckAccess(),
+ CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
+
+ await dispatcher.InvokeAsync(_ => work.SetResult(42)).WaitAsync(Timeout);
+
+ Assert.False(await continuation.WaitAsync(Timeout));
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Theory]
+ [InlineData(false, false, TaskStatus.RanToCompletion)]
+ [InlineData(false, true, TaskStatus.RanToCompletion)]
+ [InlineData(true, false, TaskStatus.RanToCompletion)]
+ [InlineData(true, true, TaskStatus.RanToCompletion)]
+ [InlineData(false, false, TaskStatus.Faulted)]
+ [InlineData(false, true, TaskStatus.Faulted)]
+ [InlineData(true, false, TaskStatus.Faulted)]
+ [InlineData(true, true, TaskStatus.Faulted)]
+ [InlineData(false, false, TaskStatus.Canceled)]
+ [InlineData(false, true, TaskStatus.Canceled)]
+ [InlineData(true, false, TaskStatus.Canceled)]
+ [InlineData(true, true, TaskStatus.Canceled)]
+ public async Task Dispatcher_InvokeAsync_AsyncWork_PreservesTaskOutcome(
+ bool returnsValue, bool alreadyCompleted, TaskStatus outcome)
+ {
+ using var callbackReturned = new ManualResetEventSlim();
+ var mainLoop = new FakeMainLoop { AfterWork = () => callbackReturned.Set() };
+ using var initialized = new ManualResetEventSlim();
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ var work = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Exception[] failures =
+ {
+ new InvalidOperationException("First work failure."),
+ new ArgumentException("Second work failure."),
+ };
+
+ void CompleteWork()
+ {
+ switch (outcome)
+ {
+ case TaskStatus.RanToCompletion:
+ work.SetResult(42);
+ break;
+ case TaskStatus.Faulted:
+ work.SetException(failures);
+ break;
+ case TaskStatus.Canceled:
+ work.SetCanceled(cancellation.Token);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(outcome));
+ }
+ }
+
+ if (alreadyCompleted)
+ {
+ CompleteWork();
+ }
+
+ Task task = returnsValue
+ ? dispatcher.InvokeAsync(_ => work.Task)
+ : dispatcher.InvokeAsync(_ => (Task)work.Task);
+ Assert.True(callbackReturned.Wait(Timeout));
+
+ if (!alreadyCompleted)
+ {
+ Assert.False(task.IsCompleted);
+ CompleteWork();
+ }
+
+ switch (outcome)
+ {
+ case TaskStatus.RanToCompletion:
+ await task.WaitAsync(Timeout);
+ if (returnsValue)
+ {
+ Assert.Equal(42, await (Task)task);
+ }
+ break;
+ case TaskStatus.Faulted:
+ Assert.Same(failures[0],
+ await Assert.ThrowsAsync(() => task.WaitAsync(Timeout)));
+ Assert.Equal(failures, task.Exception.InnerExceptions);
+ break;
+ case TaskStatus.Canceled:
+ OperationCanceledException error =
+ await Assert.ThrowsAnyAsync(() => task.WaitAsync(Timeout));
+ Assert.Equal(cancellation.Token, error.CancellationToken);
+ Assert.True(task.IsCanceled);
+ break;
+ }
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Dispatcher_InvokeAsync_AsyncWork_PreservesContextAcrossAwait(bool returnsValue)
+ {
+ var mainLoop = new FakeMainLoop();
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+ var state = new AsyncLocal { Value = "caller" };
+
+ try
+ {
+ async Task Work(CancellationToken ct)
+ {
+ Assert.True(dispatcher.CheckAccess());
+ Assert.Equal("caller", state.Value);
+ SynchronizationContext context = SynchronizationContext.Current;
+ Assert.NotNull(context);
+
+ state.Value = "job";
+ await Task.Yield();
+
+ Assert.True(dispatcher.CheckAccess());
+ Assert.Same(context, SynchronizationContext.Current);
+ Assert.Equal("job", state.Value);
+ return 42;
+ }
+
+ Task task = returnsValue
+ ? dispatcher.InvokeAsync(Work)
+ : dispatcher.InvokeAsync(ct => (Task)Work(ct));
+ await task.WaitAsync(Timeout);
+ Assert.Equal("caller", state.Value);
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Dispatcher_InvokeAsync_DelegateThrows_FaultsTaskWithoutFailingLoop(bool throwsCancellation)
+ {
+ var mainLoop = new FakeMainLoop();
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ Exception failure = throwsCancellation
+ ? new OperationCanceledException()
+ : new InvalidOperationException("The delegate failed.");
+ void ActionWork(CancellationToken ct) => throw failure;
+ int ValueWork(CancellationToken ct) => throw failure;
+ Task AsyncWork(CancellationToken ct) => throw failure;
+ Task AsyncValueWork(CancellationToken ct) => throw failure;
+
+ Task[] tasks =
+ {
+ dispatcher.InvokeAsync(ActionWork),
+ dispatcher.InvokeAsync(ValueWork),
+ dispatcher.InvokeAsync(AsyncWork),
+ dispatcher.InvokeAsync(AsyncValueWork),
+ };
+
+ foreach (Task task in tasks)
+ {
+ Assert.Same(failure, await Record.ExceptionAsync(() => task.WaitAsync(Timeout)));
+ Assert.True(task.IsFaulted);
+ }
+
+ Assert.Equal(42, await dispatcher.InvokeAsync(_ => 42).WaitAsync(Timeout));
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Dispatcher_InvokeAsync_AsyncWorkReturnsNull_CancelsTask(bool returnsValue)
+ {
+ var mainLoop = new FakeMainLoop();
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ Task Work(CancellationToken ct) => null;
+ Task task = returnsValue
+ ? dispatcher.InvokeAsync(Work)
+ : dispatcher.InvokeAsync(ct => (Task)Work(ct));
+
+ await Assert.ThrowsAnyAsync(() => task.WaitAsync(Timeout));
+ Assert.True(task.IsCanceled);
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_FirstJob_BlocksUntilMainLoopIsInitialized()
+ {
+ using var mayInitialize = new ManualResetEventSlim();
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeInitialize = () => Assert.True(mayInitialize.Wait(Timeout)),
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ Task submission = Submit(() => dispatcher.InvokeAsync(_ => { }));
+
+ // Callers hand their own work to the main loop so that it runs with their ambient
+ // state, which means they cannot return until the loop is there to take it.
+ Task delay = Task.Delay(TimeSpan.FromMilliseconds(250));
+ Assert.Same(delay, await Task.WhenAny(submission, delay));
+
+ mayInitialize.Set();
+ await (await submission.WaitAsync(Timeout)).WaitAsync(Timeout);
+
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_BeforeMainLoopPumps_AcceptsButDoesNotRunWork()
+ {
+ using var reachedRun = new ManualResetEventSlim();
+ using var mayPump = new ManualResetEventSlim();
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeRun = () =>
+ {
+ reachedRun.Set();
+ Assert.True(mayPump.Wait(Timeout));
+ },
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+ Task task = await Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; })).WaitAsync(Timeout);
+ Assert.True(reachedRun.Wait(Timeout));
+ Assert.True(mainLoop.Initialized);
+ Assert.False(task.IsCompleted);
+ Assert.False(workRan);
+
+ mayPump.Set();
+ await task.WaitAsync(Timeout);
+ Assert.True(workRan);
+ }
+ finally
+ {
+ mayPump.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_ConcurrentFirstJobs_ReleasesAllCallers()
+ {
+ using var initializing = new ManualResetEventSlim();
+ using var mayInitialize = new ManualResetEventSlim();
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeInitialize = () =>
+ {
+ initializing.Set();
+ Assert.True(mayInitialize.Wait(Timeout));
+ },
+ BeforePost = () => Interlocked.Increment(ref postCount),
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ int workCount = 0;
+ Task[] submissions =
+ {
+ Submit(() => dispatcher.InvokeAsync(_ => { Interlocked.Increment(ref workCount); })),
+ Submit(() => dispatcher.InvokeAsync(_ => { Interlocked.Increment(ref workCount); return 42; })),
+ Submit(() => dispatcher.InvokeAsync(_ => { Interlocked.Increment(ref workCount); return Task.CompletedTask; })),
+ Submit(() => dispatcher.InvokeAsync(_ => { Interlocked.Increment(ref workCount); return Task.FromResult(42); })),
+ };
+
+ Assert.True(initializing.Wait(Timeout));
+ Assert.All(submissions, submission => Assert.False(submission.IsCompleted));
+ Assert.Equal(0, Volatile.Read(ref postCount));
+
+ mayInitialize.Set();
+ foreach (Task submission in submissions)
+ {
+ await (await submission.WaitAsync(Timeout)).WaitAsync(Timeout);
+ }
+
+ Assert.Equal(4, workCount);
+ Assert.Equal(4, postCount);
+
+ // The open gate also admits late callers and posts from the dispatcher
+ // thread itself, without another signal or a reset.
+ Task subsequent = await Submit(() => dispatcher.InvokeAsync(_ =>
+ dispatcher.InvokeAsync(_ => { Interlocked.Increment(ref workCount); }))).WaitAsync(Timeout);
+ await subsequent.WaitAsync(Timeout);
+
+ Assert.Equal(5, workCount);
+ Assert.Equal(6, postCount);
+ }
+ finally
+ {
+ mayInitialize.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public void Dispatcher_InvokeAsync_FromDispatcherThreadBeforeMainLoop_Throws()
+ {
+ var mainLoop = new FakeMainLoop();
+ using var initialized = new ManualResetEventSlim();
+ using var posted = new ManualResetEventSlim();
+ Exception error = null;
+
+ // Waiting here would be waiting on the only thread that can start the main loop.
+ Thread thread = StartDispatcherThread(mainLoop, initialized, () =>
+ {
+ error = Record.Exception(() => Dispatcher.MainThread.Post(_ => { }));
+ posted.Set();
+ });
+
+ Assert.True(initialized.Wait(Timeout));
+ Assert.True(posted.Wait(Timeout));
+ Assert.IsType(error);
+
+ Dispatcher.MainThread.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ Assert.False(mainLoop.Initialized);
+ }
+
+ [Fact]
+ public async Task Dispatcher_Shutdown_MainLoopRunning_StopsItByCancellation()
+ {
+ var mainLoop = new FakeMainLoop();
+ var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ await dispatcher.InvokeAsync(_ => { }).WaitAsync(Timeout);
+
+ dispatcher.Shutdown();
+
+ // Cancelling the token handed to Run is the only shutdown signal the loop gets.
+ Assert.True(thread.Join(Timeout));
+ Assert.True(mainLoop.RunWasCancelled);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Dispatcher_Shutdown_WhileInitializing_ReleasesWaitingCallers(bool failInitialize)
+ {
+ using var initializing = new ManualResetEventSlim();
+ using var mayInitialize = new ManualResetEventSlim();
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeInitialize = () =>
+ {
+ initializing.Set();
+ Assert.True(mayInitialize.Wait(Timeout));
+ if (failInitialize)
+ {
+ throw new InvalidOperationException("Initialization failed after shutdown.");
+ }
+ },
+ BeforePost = () => Interlocked.Increment(ref postCount),
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+ bool shutdownRequested = false;
+
+ try
+ {
+ Task[] submissions =
+ {
+ Submit(() => dispatcher.InvokeAsync(_ => { })),
+ Submit(() => dispatcher.InvokeAsync(_ => 42)),
+ Submit(() => dispatcher.InvokeAsync(_ => Task.CompletedTask)),
+ Submit(() => dispatcher.InvokeAsync(_ => Task.FromResult(42))),
+ };
+
+ Assert.True(initializing.Wait(Timeout));
+ dispatcher.Shutdown();
+ shutdownRequested = true;
+
+ // Shutdown must release callers before Initialize returns, without letting
+ // any of their work reach a loop that is not ready to accept it.
+ foreach (Task submission in submissions)
+ {
+ InvalidOperationException error = await Assert.ThrowsAsync(
+ () => submission.WaitAsync(Timeout));
+ Assert.Equal("Dispatcher is shutting down.", error.Message);
+ }
+
+ Assert.False(mainLoop.Initialized);
+ Assert.False(mainLoop.Ran);
+ Assert.Equal(0, Volatile.Read(ref postCount));
+
+ mayInitialize.Set();
+ Assert.True(thread.Join(Timeout));
+ Assert.Equal(!failInitialize, mainLoop.RunWasCancelled);
+
+ // A failure after shutdown must not wait for another shutdown signal.
+ InvalidOperationException stopped = await Assert.ThrowsAsync(
+ () => Submit(() => dispatcher.InvokeAsync(_ => { })).WaitAsync(Timeout));
+ Assert.Equal("Dispatcher has shut down.", stopped.Message);
+ Assert.Equal(0, postCount);
+ }
+ finally
+ {
+ if (!shutdownRequested)
+ {
+ dispatcher.Shutdown();
+ }
+
+ mayInitialize.Set();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public void Dispatcher_Shutdown_WorkStillPending_AbandonsIt()
+ {
+ var mainLoop = new FakeMainLoop { PumpPostedWork = false };
+ var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ Task task = dispatcher.InvokeAsync(_ => { });
+ Assert.True(SpinWait.SpinUntil(() => mainLoop.Ran, Timeout));
+
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+
+ // Outstanding work is dropped rather than drained. Draining instead would hang
+ // shutdown whenever a window is left open with nobody awaiting it.
+ Assert.False(task.IsCompleted);
+ }
+
+ [Fact]
+ public void Dispatcher_Shutdown_AfterAsyncWorkYields_AbandonsIt()
+ {
+ using var callbackReturned = new ManualResetEventSlim();
+ var mainLoop = new FakeMainLoop { AfterWork = () => callbackReturned.Set() };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+ Task task;
+
+ try
+ {
+ var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ task = dispatcher.InvokeAsync(async _ => { await pending.Task; });
+ Assert.True(callbackReturned.Wait(Timeout));
+ Assert.False(task.IsCompleted);
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+
+ Assert.False(task.IsCompleted);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Dispatcher_InvokeAsync_MainLoopFails_FaultsPendingAndFutureJobs(bool failInitialize)
+ {
+ var failure = new InvalidOperationException("The main loop failed.");
+ using var reachedFailure = new ManualResetEventSlim();
+ using var mayFail = new ManualResetEventSlim();
+ Action fail = () =>
+ {
+ reachedFailure.Set();
+ Assert.True(mayFail.Wait(Timeout));
+ throw failure;
+ };
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeInitialize = failInitialize ? fail : null,
+ BeforeRun = failInitialize ? null : fail,
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+
+ // Submitting blocks until the main loop is running, so it cannot be done from
+ // the thread that has to release it.
+ Task[] submissions =
+ {
+ Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; })),
+ Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; return 42; })),
+ Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; return Task.CompletedTask; })),
+ Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; return Task.FromResult(42); })),
+ };
+
+ // Getting as far as the failure proves the dispatcher thread has seen the
+ // work, so at least one caller is waiting on the loop it is about to fail.
+ Assert.True(reachedFailure.Wait(Timeout));
+ mayFail.Set();
+
+ foreach (Task submission in submissions)
+ {
+ Task task = await submission.WaitAsync(Timeout);
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => task.WaitAsync(Timeout)));
+ }
+
+ Task future = dispatcher.InvokeAsync(_ => { workRan = true; });
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => future.WaitAsync(Timeout)));
+ Assert.False(workRan);
+ }
+ finally
+ {
+ mayFail.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task Dispatcher_InvokeAsync_FailureAfterAsyncWorkYields_FaultsRunningJob(
+ bool returnsValue, bool failPost)
+ {
+ var failure = new InvalidOperationException("The main loop failed after work yielded.");
+ using var callbackReturned = new ManualResetEventSlim();
+ using var mayFail = new ManualResetEventSlim();
+ var resumeWork = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var workResumed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforePost = () =>
+ {
+ if (Interlocked.Increment(ref postCount) == 2 && failPost)
+ {
+ throw failure;
+ }
+ },
+ AfterWork = () =>
+ {
+ callbackReturned.Set();
+ if (!failPost)
+ {
+ Assert.True(mayFail.Wait(Timeout));
+ throw failure;
+ }
+ },
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ async Task Work(CancellationToken ct)
+ {
+ await resumeWork.Task;
+ Assert.True(dispatcher.CheckAccess());
+ workResumed.SetResult();
+ return 42;
+ }
+
+ Task task = returnsValue
+ ? dispatcher.InvokeAsync(Work)
+ : dispatcher.InvokeAsync(ct => (Task)Work(ct));
+ Assert.True(callbackReturned.Wait(Timeout));
+ Assert.False(task.IsCompleted);
+
+ if (failPost)
+ {
+ Task rejected = dispatcher.InvokeAsync(_ => { });
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => rejected.WaitAsync(Timeout)));
+ }
+ else
+ {
+ mayFail.Set();
+ }
+
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => task.WaitAsync(Timeout)));
+
+ Task future = dispatcher.InvokeAsync(_ => Task.CompletedTask);
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => future.WaitAsync(Timeout)));
+
+ if (failPost)
+ {
+ // A posting failure leaves this fake loop pumping. Finishing the
+ // already-running operation must not overwrite its reported failure.
+ resumeWork.SetResult();
+ await workResumed.Task.WaitAsync(Timeout);
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => task.WaitAsync(Timeout)));
+ }
+ }
+ finally
+ {
+ mayFail.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_PostFails_FaultsPostedAndFutureJobs()
+ {
+ var failure = new InvalidOperationException("Posting to the main loop failed.");
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ // Nothing may run: the jobs have to fault rather than complete.
+ PumpPostedWork = false,
+ BeforePost = () =>
+ {
+ if (Interlocked.Increment(ref postCount) == 2)
+ {
+ throw failure;
+ }
+ },
+ };
+ using var initialized = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+
+ // Submit one at a time so the failing post is reliably the second, covering
+ // work already posted, the rejected post, and work submitted afterwards.
+ Task posted = await Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; })).WaitAsync(Timeout);
+ Task rejected = await Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; })).WaitAsync(Timeout);
+ Task future = await Submit(() => dispatcher.InvokeAsync(_ => { workRan = true; })).WaitAsync(Timeout);
+
+ foreach (Task task in new[] { posted, rejected, future })
+ {
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => task.WaitAsync(Timeout)));
+ }
+
+ Assert.False(workRan);
+ Assert.Equal(2, postCount);
+ }
+ finally
+ {
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_PostFailsWhileRunning_SkipsFaultedCallbacks()
+ {
+ var failure = new InvalidOperationException("Posting to the running main loop failed.");
+ using var initialized = new ManualResetEventSlim();
+ using var running = new ManualResetEventSlim();
+ using var mayPump = new ManualResetEventSlim();
+ using var callbackPumped = new ManualResetEventSlim();
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforePost = () =>
+ {
+ if (Interlocked.Increment(ref postCount) == 2)
+ {
+ throw failure;
+ }
+ },
+ BeforeRun = () =>
+ {
+ running.Set();
+ Assert.True(mayPump.Wait(Timeout));
+ },
+ AfterWork = () => callbackPumped.Set(),
+ };
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+ Task pending = dispatcher.InvokeAsync(_ => { workRan = true; });
+ Assert.True(running.Wait(Timeout));
+
+ // Bound the submission itself: a posting caller must not wait for shutdown
+ // or throw synchronously instead of returning the faulted task.
+ Task submission = Task.Factory.StartNew(
+ () => dispatcher.InvokeAsync(_ => { workRan = true; }),
+ CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
+ Task rejected = await submission.WaitAsync(Timeout);
+
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => pending.WaitAsync(Timeout)));
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => rejected.WaitAsync(Timeout)));
+
+ Task future = dispatcher.InvokeAsync(_ => { workRan = true; });
+ Assert.Same(failure,
+ await Assert.ThrowsAsync(() => future.WaitAsync(Timeout)));
+
+ mayPump.Set();
+ Assert.True(callbackPumped.Wait(Timeout));
+ Assert.False(workRan);
+ }
+ finally
+ {
+ mayPump.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_ConcurrentPostsFail_PreservesFirstFailure()
+ {
+ var firstFailure = new InvalidOperationException("The first post failed.");
+ var secondFailure = new InvalidOperationException("The second post failed.");
+ using var initialized = new ManualResetEventSlim();
+ using var running = new ManualResetEventSlim();
+ using var firstPosting = new ManualResetEventSlim();
+ using var secondPosting = new ManualResetEventSlim();
+ using var mayFailFirst = new ManualResetEventSlim();
+ using var mayFailSecond = new ManualResetEventSlim();
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ PumpPostedWork = false,
+ BeforeRun = () => running.Set(),
+ BeforePost = () =>
+ {
+ switch (Interlocked.Increment(ref postCount))
+ {
+ case 2:
+ firstPosting.Set();
+ Assert.True(mayFailFirst.Wait(Timeout));
+ throw firstFailure;
+ case 3:
+ secondPosting.Set();
+ Assert.True(mayFailSecond.Wait(Timeout));
+ throw secondFailure;
+ }
+ },
+ };
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized);
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ Task pending = dispatcher.InvokeAsync(_ => { });
+ Assert.True(running.Wait(Timeout));
+
+ Task firstSubmission = Task.Factory.StartNew(
+ () => dispatcher.InvokeAsync(_ => { }),
+ CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
+ Assert.True(firstPosting.Wait(Timeout));
+
+ Task secondSubmission = Task.Factory.StartNew(
+ () => dispatcher.InvokeAsync(_ => { }),
+ CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
+ Assert.True(secondPosting.Wait(Timeout));
+
+ mayFailFirst.Set();
+ Task first = await firstSubmission.WaitAsync(Timeout);
+ Assert.Same(firstFailure,
+ await Assert.ThrowsAsync(() => pending.WaitAsync(Timeout)));
+ Assert.Same(firstFailure,
+ await Assert.ThrowsAsync(() => first.WaitAsync(Timeout)));
+
+ mayFailSecond.Set();
+ Task second = await secondSubmission.WaitAsync(Timeout);
+ Assert.Same(firstFailure,
+ await Assert.ThrowsAsync(() => second.WaitAsync(Timeout)));
+
+ Task future = dispatcher.InvokeAsync(_ => { });
+ Assert.Same(firstFailure,
+ await Assert.ThrowsAsync(() => future.WaitAsync(Timeout)));
+ }
+ finally
+ {
+ mayFailFirst.Set();
+ mayFailSecond.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ private static Task Submit(Func submission) =>
+ Task.Factory.StartNew(
+ submission, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
+
+ private static Thread StartDispatcherThread(
+ IMainLoop mainLoop, ManualResetEventSlim initialized, Action beforeRun = null)
+ {
+ // The dispatcher binds to the thread that initializes it and must be run from
+ // that same thread, so both have to happen here rather than in the test.
+ var thread = new Thread(() =>
+ {
+ Dispatcher.Initialize(mainLoop);
+ initialized.Set();
+ beforeRun?.Invoke();
+ Dispatcher.MainThread.Run();
+ })
+ {
+ IsBackground = true,
+ Name = nameof(StartDispatcherThread),
+ };
+
+ thread.Start();
+ return thread;
+ }
+
+ private sealed class FakeMainLoop : IMainLoop
+ {
+ private readonly BlockingCollection _work = new();
+
+ public Action BeforeInitialize { get; init; }
+ public Action BeforePost { get; init; }
+ public Action AfterPost { get; init; }
+ public Action BeforeRun { get; init; }
+ public Action AfterWork { get; init; }
+
+ public bool Initialized { get; private set; }
+ public bool Ran { get; private set; }
+ public bool RunWasCancelled { get; private set; }
+
+ ///
+ /// False to accept posted work but never run it, modelling a loop that is shut
+ /// down while work is still outstanding.
+ ///
+ public bool PumpPostedWork { get; init; } = true;
+
+ public void Initialize()
+ {
+ BeforeInitialize?.Invoke();
+ Initialized = true;
+ }
+
+ public void Post(Action work)
+ {
+ Assert.True(Initialized);
+ BeforePost?.Invoke();
+ _work.Add(work);
+ AfterPost?.Invoke();
+ }
+
+ public void Run(CancellationToken ct)
+ {
+ Ran = true;
+ BeforeRun?.Invoke();
+ SynchronizationContext previous = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(new MainLoopSynchronizationContext(this));
+ try
+ {
+ if (PumpPostedWork)
+ {
+ foreach (Action work in _work.GetConsumingEnumerable(ct))
+ {
+ work();
+ AfterWork?.Invoke();
+ }
+ }
+ else
+ {
+ ct.WaitHandle.WaitOne();
+ ct.ThrowIfCancellationRequested();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ RunWasCancelled = true;
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+ }
+
+ private sealed class MainLoopSynchronizationContext(FakeMainLoop mainLoop) : SynchronizationContext
+ {
+ public override void Post(SendOrPostCallback callback, object state) =>
+ mainLoop.Post(() => callback(state));
+ }
+ }
+}
diff --git a/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs b/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs
index 87d8f74c6..06ece660d 100644
--- a/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs
+++ b/src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs
@@ -255,21 +255,27 @@ await UseDefaultAccountAsync(result.Account.Username, ct))
Context.Trace.WriteLine("Using broker for interactive authentication...");
- // On some platforms the broker requires the use of the main thread to display UI.
- // If we are on some other thread, we need to dispatch the interactive auth call to the main thread.
- bool isMainThreadRequired = PlatformUtils.IsMacOS();
- if (isMainThreadRequired && !Dispatcher.MainThread.CheckAccess())
+ // The macOS broker requires a running NSApplication. MSAL decides once per process
+ // whether it has one and caches the answer: with NSApplication running it delegates
+ // threading to the broker, but without it MSAL demands that interactive calls run on
+ // managed thread 1 and then seizes that thread with its own polling loop - which
+ // cannot coexist with our main loop.
+ //
+ // Running this on the dispatcher avoids that entirely: it owns the entry thread and
+ // has started the main loop, and therefore NSApplication, by the time our work runs.
+ // Note that only interactive calls decide the mode, so the silent attempts above must
+ // stay off the dispatcher, or they would pay to start Avalonia for nothing.
+ // Verified against MSAL 4.85.2; re-check DesktopOsHelper.IsMacConsoleApp on upgrade.
+ if (PlatformUtils.IsMacOS() && !Dispatcher.MainThread.CheckAccess())
{
Context.Trace.WriteLine("Dispatching interactive broker authentication to main thread...");
- Task mainThreadTask = await Dispatcher.MainThread.InvokeAsync(async _ =>
- await app.AcquireTokenInteractive(scopes)
+ return await Dispatcher.MainThread.InvokeAsync(
+ async _ => await app.AcquireTokenInteractive(scopes)
.ExecuteAsync(ct)
);
-
- return await mainThreadTask;
}
- // Run the auth on the current thread
+ // Already on the main thread, or on a platform whose broker does not care
return await app.AcquireTokenInteractive(scopes)
.ExecuteAsync(ct);
}
diff --git a/src/Core/Tracing/Trace2.cs b/src/Core/Tracing/Trace2.cs
index 50334bd1a..20aa87cd6 100644
--- a/src/Core/Tracing/Trace2.cs
+++ b/src/Core/Tracing/Trace2.cs
@@ -35,12 +35,12 @@ public static class Trace2
private static readonly Lock WritersLock = new();
private static readonly List Writers = new();
- private static readonly AsyncLocal ThreadContext = new();
+ private static readonly AsyncLocal ThreadContext = new();
private static bool _initialized;
private static DateTimeOffset _applicationStartTime;
private static Trace2Settings _settings;
- private static Trace2ExecutionContext _mainContext;
+ private static Trace2Context _mainContext;
private static string _sid;
private static int _depth;
@@ -78,7 +78,7 @@ public static void Initialize(
InitializeWriters();
// The main thread context is ambiently created with the process and Trace2 init
- _mainContext = new Trace2ExecutionContext(MainThreadName, _applicationStartTime);
+ _mainContext = new Trace2Context(MainThreadName, _applicationStartTime);
ThreadContext.Value = _mainContext;
_initialized = true;
@@ -122,15 +122,51 @@ internal static int GetProcessDepth(string sid)
return count;
}
- private static void SetContext(Trace2ExecutionContext context)
+ ///
+ /// Sets the Trace2 context of the current logical thread.
+ ///
+ /// The context to attribute subsequent events to.
+ ///
+ ///
+ /// Use this together with to attribute work to a logical
+ /// thread other than the one that scheduled it, such as work handed to another thread
+ /// to run on its behalf. The context is stored in an , so
+ /// the change is scoped to the current execution context and is undone when that
+ /// context is left. Callers that need it undone sooner must restore the previous
+ /// context themselves.
+ ///
+ ///
+ /// Prefer when the work is a genuinely new logical thread
+ /// rather than a continuation of an existing one.
+ ///
+ ///
+ /// The call is ignored when Trace2 has not been initialized, or when
+ /// is null, so that a context captured before
+ /// initialization can be restored without a null check.
+ ///
+ ///
+ public static void SetCurrentContext(Trace2Context context)
{
+ if (!_initialized || context is null) return;
+
ThreadContext.Value = context;
}
- private static Trace2ExecutionContext GetCurrentContext()
+ ///
+ /// Gets the Trace2 context of the current logical thread.
+ ///
+ ///
+ /// The current context, or null if Trace2 has not been initialized.
+ ///
+ ///
+ /// The returned handle is opaque; pass it to to attribute
+ /// events elsewhere to this logical thread.
+ ///
+ public static Trace2Context GetCurrentContext()
{
- Trace2ExecutionContext context = ThreadContext.Value;
- Debug.Assert(context is not null, "Trace2 event emitted without an execution context.");
+ Trace2Context context = ThreadContext.Value;
+ Debug.Assert(context is not null || !_initialized,
+ "Trace2 event emitted without an execution context.");
// Fall back to the main thread context if we are missing one.
// This can happen when ExecutionContext flow is suppressed, an unsafe
// ThreadPool API is used, or work runs on a manually created thread without
@@ -138,14 +174,6 @@ private static Trace2ExecutionContext GetCurrentContext()
return context ?? _mainContext;
}
- public static IDisposable UseMainContext()
- {
- if (!_initialized)
- return NoOpDisposable.Instance;
-
- return new ContextScope(_mainContext);
- }
-
private static void Start(string appPath,
string[] args,
string filePath,
@@ -545,7 +573,7 @@ public static IDisposable StartRegion(
if (!_initialized)
return NoOpDisposable.Instance;
- Trace2ExecutionContext context = GetCurrentContext();
+ Trace2Context context = GetCurrentContext();
return new RegionScope(context, category, label, filePath, lineNumber, message);
}
@@ -572,7 +600,7 @@ public static void WriteData(
value ??= string.Empty;
DateTimeOffset now = DateTimeOffset.UtcNow;
- Trace2ExecutionContext context = GetCurrentContext();
+ Trace2Context context = GetCurrentContext();
DateTimeOffset relativeStart = context.RegionStartTime.Value ?? context.StartTime;
WriteMessage(new DataMessage
@@ -641,7 +669,7 @@ public static void WriteData(
}
DateTimeOffset now = DateTimeOffset.UtcNow;
- Trace2ExecutionContext context = GetCurrentContext();
+ Trace2Context context = GetCurrentContext();
DateTimeOffset relativeStart = context.RegionStartTime.Value ?? context.StartTime;
WriteMessage(new DataJsonMessage
@@ -1026,44 +1054,12 @@ private class NoOpDisposable : IDisposable
public void Dispose(){}
}
- private class Trace2ExecutionContext(
- string threadName,
- DateTimeOffset? startTime = null)
- {
- public AsyncLocal RegionNesting { get; } = new();
- public AsyncLocal RegionStartTime { get; } = new();
- public DateTimeOffset StartTime { get; } = startTime ?? DateTimeOffset.UtcNow;
- public string ThreadName { get; } = threadName;
- }
-
- private class ContextScope : DisposableObject
- {
- private readonly Trace2ExecutionContext _context;
- private readonly Trace2ExecutionContext _previousContext;
-
- public ContextScope(Trace2ExecutionContext context)
- {
- _context = context;
- _previousContext = Trace2.GetCurrentContext();
- Trace2.SetContext(_context);
- }
-
- protected override void ReleaseManagedResources()
- {
- Debug.Assert(
- ReferenceEquals(Trace2.GetCurrentContext(), _context),
- "Trace2 contexts must be disposed in LIFO order.");
-
- Trace2.SetContext(_previousContext);
- }
- }
-
private class ThreadScope : DisposableObject
{
private readonly string _filePath;
private readonly int _lineNumber;
- private readonly Trace2ExecutionContext _context;
- private readonly Trace2ExecutionContext _prevContext;
+ private readonly Trace2Context _context;
+ private readonly Trace2Context _prevContext;
private readonly DateTimeOffset _startTime;
public ThreadScope(string threadName, string filePath, int lineNumber)
@@ -1072,8 +1068,8 @@ public ThreadScope(string threadName, string filePath, int lineNumber)
_filePath = filePath;
_lineNumber = lineNumber;
- _context = new Trace2ExecutionContext(threadName);
- Trace2.SetContext(_context);
+ _context = new Trace2Context(threadName);
+ Trace2.SetCurrentContext(_context);
_startTime = Trace2.WriteThreadStart(_context.ThreadName, _filePath, _lineNumber);
}
@@ -1090,14 +1086,14 @@ protected override void ReleaseManagedResources()
ReferenceEquals(Trace2.GetCurrentContext(), _context),
"Trace2 threads must be disposed in LIFO order.");
- Trace2.SetContext(_prevContext);
+ Trace2.SetCurrentContext(_prevContext);
}
}
}
private class RegionScope : DisposableObject
{
- private readonly Trace2ExecutionContext _context;
+ private readonly Trace2Context _context;
private readonly string _category;
private readonly string _label;
private readonly string _filePath;
@@ -1108,7 +1104,7 @@ private class RegionScope : DisposableObject
private readonly DateTimeOffset _startTime;
internal RegionScope(
- Trace2ExecutionContext context,
+ Trace2Context context,
string category,
string label,
string filePath,
@@ -1165,3 +1161,24 @@ protected override void ReleaseManagedResources()
}
}
}
+
+///
+/// An opaque handle to the Trace2 context of a logical thread.
+///
+///
+/// Capture one with and apply it with
+/// .
+///
+public sealed class Trace2Context
+{
+ internal Trace2Context(string threadName, DateTimeOffset? startTime = null)
+ {
+ ThreadName = threadName;
+ StartTime = startTime ?? DateTimeOffset.UtcNow;
+ }
+
+ internal AsyncLocal RegionNesting { get; } = new();
+ internal AsyncLocal RegionStartTime { get; } = new();
+ internal DateTimeOffset StartTime { get; }
+ internal string ThreadName { get; }
+}
diff --git a/src/Core/UI/AvaloniaMainLoop.cs b/src/Core/UI/AvaloniaMainLoop.cs
new file mode 100644
index 000000000..4ec3e1e27
--- /dev/null
+++ b/src/Core/UI/AvaloniaMainLoop.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Threading;
+using AvnDispatcher = Avalonia.Threading.Dispatcher;
+
+namespace GitCredentialManager.UI
+{
+ ///
+ /// The Avalonia application main loop.
+ ///
+ internal class AvaloniaMainLoop : IMainLoop
+ {
+ private static bool _win32SoftwareRendering;
+ private static bool _isStarted;
+
+ ///
+ /// Configure the Avalonia application.
+ ///
+ /// True to enable software rendering on Windows, false otherwise.
+ /// The application has already been started.
+ public static void Configure(bool win32SoftwareRendering)
+ {
+ if (_isStarted)
+ {
+ throw new InvalidOperationException(
+ "Avalonia must be configured before the application is started.");
+ }
+
+ _win32SoftwareRendering = win32SoftwareRendering;
+ }
+
+ public void Initialize()
+ {
+ _isStarted = true;
+
+ using (Trace2.StartRegion("ui", "avn_init"))
+ {
+ AppBuilder appBuilder = AppBuilder.Configure();
+
+ // Set custom rendering options and modes if required
+ if (PlatformUtils.IsWindows() && _win32SoftwareRendering)
+ {
+ Trace2.WriteData("ui", "win32/software_rendering", "true");
+ appBuilder.With(new Win32PlatformOptions
+ { RenderingMode = new[] { Win32RenderingMode.Software } });
+ }
+
+ appBuilder
+ .UsePlatformDetect()
+ .LogToTrace()
+ .SetupWithoutStarting();
+ }
+ }
+
+ public void Post(Action work) => AvnDispatcher.UIThread.Post(work, DispatcherPriority.Send);
+
+ public void Run(CancellationToken ct) => AvnDispatcher.UIThread.MainLoop(ct);
+ }
+}
diff --git a/src/Core/UI/AvaloniaUi.cs b/src/Core/UI/AvaloniaUi.cs
index 361780f05..bdf88421c 100644
--- a/src/Core/UI/AvaloniaUi.cs
+++ b/src/Core/UI/AvaloniaUi.cs
@@ -3,7 +3,6 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Threading;
using GitCredentialManager.Interop.Windows.Native;
using GitCredentialManager.UI.Controls;
using GitCredentialManager.UI.ViewModels;
@@ -13,9 +12,6 @@ namespace GitCredentialManager.UI
{
public static class AvaloniaUi
{
- private static bool _isAppStarted;
- private static bool _win32SoftwareRendering;
-
///
/// Configure the Avalonia application.
///
@@ -25,12 +21,7 @@ public static class AvaloniaUi
///
public static void Initialize(bool win32SoftwareRendering)
{
- if (_isAppStarted)
- {
- throw new InvalidOperationException("Setup must be called before the Avalonia application is started.");
- }
-
- _win32SoftwareRendering = win32SoftwareRendering;
+ AvaloniaMainLoop.Configure(win32SoftwareRendering);
}
public static Task ShowViewAsync(Func viewFunc, WindowViewModel viewModel, IntPtr parentHandle, CancellationToken ct) =>
@@ -53,51 +44,11 @@ public static Task ShowWindowAsync(object dataContext, IntPtr parentHan
public static Task ShowWindowAsync(Func windowFunc, object dataContext, IntPtr parentHandle, CancellationToken ct)
{
- if (!_isAppStarted)
- {
- _isAppStarted = true;
-
- var appInitialized = new ManualResetEventSlim();
-
- // Keep the trace region to outside the dispatcher's lambda so we can attribute the
- // UI init cost to the caller's thread, rather than the main thread.
- using (Trace2.StartRegion("ui", "avn_init"))
- {
- // Fire and forget the Avalonia app main loop over to our dispatcher (running on the main/entry thread).
- // This action only returns on our dispatcher shutdown.
- Dispatcher.MainThread.Post(appCancelToken =>
- {
- var appBuilder = AppBuilder.Configure();
-
- // Set custom rendering options and modes if required
- if (PlatformUtils.IsWindows() && _win32SoftwareRendering)
- {
- Trace2.WriteData("ui", "win32/software_rendering", "true");
- appBuilder.With(new Win32PlatformOptions
- { RenderingMode = new[] { Win32RenderingMode.Software } });
- }
-
- appBuilder
- .UsePlatformDetect()
- .LogToTrace()
- .SetupWithoutStarting();
-
- appInitialized.Set();
-
- // Run the application loop (only exit when the dispatcher is shutting down)
- AvnDispatcher.UIThread.MainLoop(appCancelToken);
- });
-
- // Wait for the action posted above to be dequeued from the dispatcher's job queue
- // and for the Avalonia framework (and their dispatcher) to be initialized.
- appInitialized.Wait();
- }
- }
-
- // Post the window action to the Avalonia dispatcher (which should be running)
- return AvnDispatcher.UIThread.InvokeAsync(
- () => ShowWindowInternal(windowFunc, dataContext, parentHandle, ct),
- DispatcherPriority.Send
+ // The dispatcher owns the main thread and starts the Avalonia application
+ // just-in-time, so by the time this job runs we are on the Avalonia UI thread
+ // with its main loop already pumping.
+ return Dispatcher.MainThread.InvokeAsync(
+ _ => ShowWindowInternal(windowFunc, dataContext, parentHandle, ct)
);
}
diff --git a/src/Core/UI/Dispatcher.cs b/src/Core/UI/Dispatcher.cs
index 9dd991438..b34c1e519 100644
--- a/src/Core/UI/Dispatcher.cs
+++ b/src/Core/UI/Dispatcher.cs
@@ -5,37 +5,76 @@
namespace GitCredentialManager.UI
{
+ ///
+ /// Owns the process entry thread (the "main thread") and runs work posted to it.
+ ///
+ ///
+ ///
+ /// Some platform APIs must be used from the process entry thread: macOS requires UI
+ /// controls to be created there, and the macOS MSAL broker requires a running
+ /// NSApplication. Both of those need a platform main loop, which is expensive to
+ /// start and most GCM invocations never need.
+ ///
+ ///
+ /// The dispatcher therefore parks the main thread cheaply until the first job is
+ /// posted, and only then starts the main loop just-in-time and hands the thread over
+ /// to it for the remaining lifetime of the process. Posting blocks until the main
+ /// loop can accept work, and that work only runs once the loop is pumping.
+ ///
+ ///
public class Dispatcher
{
- private readonly DispatcherJobQueue _queue = new();
+ // The queue of work to run on the dispatcher thread; the queue owns a loop that pumps work.
+ private readonly DispatcherJobQueue _queue;
+
+ // The thread that owns this dispatcher; work posted to the dispatcher must run on this thread.
private readonly Thread _thread;
+ // Work run by this dispatcher is reported against this context rather than
+ // against whoever posted it, since this is the thread it executes on.
+ private readonly Trace2Context _traceContext;
+
public static Dispatcher MainThread { get; private set; }
///
/// Initialize the dispatcher associated to the current thread. See .
///
- public static void Initialize()
+ public static void Initialize() => Initialize(new AvaloniaMainLoop());
+
+ internal static void Initialize(IMainLoop mainLoop)
{
- MainThread = new Dispatcher(Thread.CurrentThread);
+ MainThread = new Dispatcher(Thread.CurrentThread, Trace2.GetCurrentContext(), mainLoop);
}
- private Dispatcher(Thread thread)
+ private Dispatcher(Thread thread, Trace2Context traceContext, IMainLoop mainLoop)
{
_thread = thread;
+ _traceContext = traceContext;
+ _queue = new DispatcherJobQueue(this, mainLoop);
}
public void Run()
{
- // Should only run the dispatcher job queue from the thread that
- // created the dispatcher.
+ // Should only run the dispatcher job queue from the thread that created the dispatcher.
VerifyAccess();
_queue.Run();
}
+ ///
+ /// Stop the dispatcher and release the main thread, causing to return.
+ ///
+ ///
+ /// Shutdown does not drain outstanding work. Tasks for work that still needs the
+ /// main loop may never complete once it stops; already-started asynchronous work
+ /// may still finish independently. Callers must therefore only shut down once all
+ /// work they care about has finished. This is why the application thread shuts the
+ /// dispatcher down after running to completion, rather than the other way around:
+ /// waiting for outstanding work instead would hang whenever a window is still open
+ /// with nobody left to close it.
+ ///
public void Shutdown()
{
- // Can shutdown the dispatcher from any thread.
+ // Can shut down the dispatcher from any thread.
_queue.Shutdown();
}
@@ -45,7 +84,7 @@ public void Shutdown()
/// Ensure the calling thread is the thread associated with this dispatcher.
///
///
- /// The calling thread does not have access this dispatcher.
+ /// The calling thread does not have access to this dispatcher.
///
public void VerifyAccess()
{
@@ -59,76 +98,254 @@ public void VerifyAccess()
/// Post work to be run on the thread associated with this dispatcher.
///
/// Work to be run.
+ ///
+ /// The first call to the dispatcher starts the main loop, and blocks until it can
+ /// accept posted work.
+ ///
public void Post(Action work)
{
Task _ = InvokeAsync(work);
}
///
- /// Execute work to be run on the thread associated with this dispatcher and wait
- /// synchronously until the work is complete.
+ /// Execute synchronous work on the thread associated with this dispatcher and
+ /// return a task that completes when the work is done.
///
/// Work to be run.
+ ///
+ /// The first call to the dispatcher starts the main loop, and blocks until it can
+ /// accept posted work.
+ ///
public Task InvokeAsync(Action work)
{
- var tcs = new TaskCompletionSource