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..a3bde9984
--- /dev/null
+++ b/docs/dispatcher.md
@@ -0,0 +1,228 @@
+# 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.
+
+It also extends somewhere less obvious. 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 on the job queue no UI framework started
+ App->>Main: InvokeAsync(work) - the first job
+ Note over Main: IMainLoop.Initialize() hand over and drain the queue IMainLoop.Run(token) owns the thread
+ 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.
+
+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
+
+All of these are guarded by the lock taken on `_queue`.
+
+Name|Purpose
+-|-
+`_queue`|Jobs accepted before the main loop is running. Drained into the main loop at hand-over. Also serves as the monitor for parking and for every state change.
+`_outstandingJobs`|Every accepted job, from acceptance until its callback finishes. This is what makes a main loop failure recoverable - see [Failure handling](#failure-handling).
+`_isMainLoopRunning`|Whether `AddJob` should post to the main loop rather than enqueue.
+`_mainLoopFault`|The first fault seen, if any. Once set, the dispatcher is permanently unusable.
+`_state`|See [States](#states).
+
+The main loop has its own separate queue - Avalonia's dispatcher queue - which
+the dispatcher can only add to, via `IMainLoop.Post`.
+
+## 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.
+
+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 unwrap the nested
+task, so the result always tracks the work to completion.
+
+> **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.
+
+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 (_queue)"}
+ C -->|"state is Stopping or Stopped"| D["throw InvalidOperationException"]
+ C -->|"_mainLoopFault is set"| E["job.Fail(fault)"]
+ C -->|"main loop not running"| F["_queue.Enqueue(job) Monitor.Pulse(_queue)"]
+ C -->|"main loop running"| G["IMainLoop.Post(job)"]
+```
+
+The three accepting paths also add the job to `_outstandingJobs`. Completing 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.
+
+## Hand-over
+
+The hand-over is the delicate part. It must not leave a job sitting in a queue
+that nobody will ever pump again.
+
+```mermaid
+flowchart TD
+ A["Run() - state is Started"] --> B["WaitForWork() parked until the first job arrives"]
+ B --> C["RunMainLoop()"]
+ C --> D["IMainLoop.Initialize() the loop queue now exists, but nothing is pumping it yet"]
+ D --> E["lock (_queue) _isMainLoopRunning = true pending = _queue.ToArray() _queue.Clear()"]
+ E --> F["post each pending job dequeue order, so FIFO is preserved"]
+ F --> G["IMainLoop.Run(token) owns the thread until shutdown"]
+ G --> H["only now do the posted jobs run"]
+```
+
+Flipping `_isMainLoopRunning` and draining `_queue` under a single lock closes
+the window in which a concurrent `AddJob` could enqueue into a queue that has
+already been drained.
+
+ |before hand-over|after hand-over
+-|-|-
+`_queue`|`[j1] [j2] [j3]`|empty
+`_outstandingJobs`|`{j1, j2, j3}`|`{j1, j2, j3}` - unchanged
+main loop queue|not started|`[j1] [j2] [j3]`
+
+Jobs stay in `_outstandingJobs` across the hand-over. They are removed only once
+their callback has actually run.
+
+Note the ordering guarantee this buys. A job cannot run until `IMainLoop.Run` is
+pumping, and on macOS that 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.
+
+## 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` - which covers
+work still in `_queue` *and* work already handed to the main loop - records the
+fault, and fails any later arrivals immediately. It is idempotent: the first
+fault wins, so concurrent failures report a single, consistent cause.
+
+Two further details matter:
+
+- 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.
+
+## Shutdown
+
+`Shutdown()` may be called from any thread. It moves to `Stopping`, wakes
+anything parked, and 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.** Its tasks never complete. This
+is deliberate: `Shutdown()` is called only once the application has finished
+everything it cares about, so anything still in flight is fire-and-forget by
+definition. Waiting for it 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].
+- **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.
+- **Never wait on a dispatcher task from inside a job.** The continuation needs
+ the very loop that the job itself is occupying.
+- `CheckAccess()` and `VerifyAccess()` report whether the calling thread is the
+ dispatcher thread, which is useful for avoiding a needless round trip.
+
+## Testing
+
+`IMainLoop` is an internal seam with a single production implementation,
+`AvaloniaMainLoop`. An internal `Dispatcher.Initialize(IMainLoop)` overload lets
+tests substitute a fake, so hand-over, failure, and shutdown behaviour can be
+exercised without a real UI framework and without a real thread 1. 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..b1ca188b3
--- /dev/null
+++ b/src/Core.Tests/UI/DispatcherTests.cs
@@ -0,0 +1,464 @@
+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_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);
+ }
+
+ [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);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Dispatcher_InvokeAsync_MainLoopFails_FaultsPendingAndFutureJobs(bool failInitialize)
+ {
+ var failure = new InvalidOperationException("The main loop failed.");
+ Action fail = () => throw failure;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforeInitialize = failInitialize ? fail : null,
+ BeforeRun = failInitialize ? null : fail,
+ };
+ using var initialized = new ManualResetEventSlim();
+ using var mayRun = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized, () => mayRun.Wait(Timeout));
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+ Task[] tasks =
+ {
+ dispatcher.InvokeAsync(_ => { workRan = true; }),
+ dispatcher.InvokeAsync(_ => { workRan = true; return 42; }),
+ dispatcher.InvokeAsync(_ => { workRan = true; return Task.CompletedTask; }),
+ dispatcher.InvokeAsync(_ => { workRan = true; return Task.FromResult(42); }),
+ };
+ mayRun.Set();
+
+ foreach (Task task in tasks)
+ {
+ 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
+ {
+ mayRun.Set();
+ dispatcher.Shutdown();
+ Assert.True(thread.Join(Timeout));
+ }
+ }
+
+ [Fact]
+ public async Task Dispatcher_InvokeAsync_PostFailsDuringHandoff_FaultsAllJobs()
+ {
+ var failure = new InvalidOperationException("Posting to the main loop failed.");
+ int postCount = 0;
+ var mainLoop = new FakeMainLoop
+ {
+ BeforePost = () =>
+ {
+ if (Interlocked.Increment(ref postCount) == 2)
+ {
+ throw failure;
+ }
+ },
+ };
+ using var initialized = new ManualResetEventSlim();
+ using var mayRun = new ManualResetEventSlim();
+
+ Thread thread = StartDispatcherThread(mainLoop, initialized, () => mayRun.Wait(Timeout));
+ Assert.True(initialized.Wait(Timeout));
+ Dispatcher dispatcher = Dispatcher.MainThread;
+
+ try
+ {
+ bool workRan = false;
+ Task[] tasks =
+ {
+ dispatcher.InvokeAsync(_ => { workRan = true; }),
+ dispatcher.InvokeAsync(_ => { workRan = true; }),
+ dispatcher.InvokeAsync(_ => { workRan = true; }),
+ };
+ mayRun.Set();
+
+ // Cover work already posted, the rejected post, and work not yet posted.
+ foreach (Task task in tasks)
+ {
+ 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);
+ Assert.False(mainLoop.Ran);
+ Assert.Equal(2, postCount);
+ }
+ finally
+ {
+ mayRun.Set();
+ 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 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 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)
+ {
+ BeforePost?.Invoke();
+ _work.Add(work);
+ }
+
+ public void Run(CancellationToken ct)
+ {
+ Ran = true;
+ BeforeRun?.Invoke();
+ try
+ {
+ if (PumpPostedWork)
+ {
+ foreach (Action work in _work.GetConsumingEnumerable(ct))
+ {
+ work();
+ AfterWork?.Invoke();
+ }
+ }
+ else
+ {
+ ct.WaitHandle.WaitOne();
+ ct.ThrowIfCancellationRequested();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ RunWasCancelled = true;
+ }
+ }
+ }
+}
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/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..f16ee74b8 100644
--- a/src/Core/UI/Dispatcher.cs
+++ b/src/Core/UI/Dispatcher.cs
@@ -5,9 +5,27 @@
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. Because the hand-over completes
+ /// before any job can be dispatched, work posted here is guaranteed to run with the
+ /// main loop - and therefore NSApplication - already running.
+ ///
+ ///
public class Dispatcher
{
- private readonly DispatcherJobQueue _queue = new();
+ private readonly DispatcherJobQueue _queue;
private readonly Thread _thread;
public static Dispatcher MainThread { get; private set; }
@@ -15,14 +33,17 @@ public class Dispatcher
///
/// 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, mainLoop);
}
- private Dispatcher(Thread thread)
+ private Dispatcher(Thread thread, IMainLoop mainLoop)
{
_thread = thread;
+ _queue = new DispatcherJobQueue(mainLoop);
}
public void Run()
@@ -33,6 +54,17 @@ public void Run()
_queue.Run();
}
+ ///
+ /// Stop the dispatcher and release the main thread, causing to return.
+ ///
+ ///
+ /// Work that has been posted but has not yet completed is abandoned, and the tasks
+ /// returned for it never complete. 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.
@@ -71,21 +103,48 @@ public void Post(Action work)
/// Work to be run.
public Task InvokeAsync(Action work)
{
- var tcs = new TaskCompletionSource