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(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _queue.AddJob(new DispatcherJob(work, tcs)); return tcs.Task; } public Task InvokeAsync(Func work) { - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _queue.AddJob(new DispatcherJob(work, tcs)); return tcs.Task; } + /// + /// Execute asynchronous work on the thread associated with this dispatcher. + /// + /// Work to be run. + /// A task that completes when the work completes, not when it first yields. + /// + /// The work starts on the dispatcher thread, and because the main loop installs a + /// synchronization context its continuations resume there too, unless the work + /// opts out with . + /// + public Task InvokeAsync(Func work) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _queue.AddJob(new DispatcherJob(work, tcs)); + return tcs.Task.Unwrap(); + } + + /// + public Task InvokeAsync(Func> work) + { + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + _queue.AddJob(new DispatcherJob>(work, tcs)); + return tcs.Task.Unwrap(); + } + private interface IDispatcherJob { void Execute(CancellationToken ct); + + void Fail(Exception ex); } private class DispatcherJob : IDispatcherJob @@ -101,9 +160,20 @@ public DispatcherJob(Action work, TaskCompletionSource _tcs?.TrySetException(ex); } private class DispatcherJob : IDispatcherJob @@ -119,15 +189,26 @@ public DispatcherJob(Func work, TaskCompletionSource public void Execute(CancellationToken ct) { - TResult result = _work(ct); - _tcs?.SetResult(result); + try + { + TResult result = _work(ct); + _tcs?.TrySetResult(result); + } + catch (Exception ex) when (_tcs is not null) + { + _tcs.TrySetException(ex); + } } + + public void Fail(Exception ex) => _tcs?.TrySetException(ex); } private class DispatcherJobQueue { private readonly Queue _queue = new(); + private readonly HashSet _outstandingJobs = new(); private readonly CancellationTokenSource _cts = new(); + private readonly IMainLoop _mainLoop; private enum State { @@ -139,6 +220,14 @@ private enum State private State _state = State.NotStarted; + private bool _isMainLoopRunning; + private Exception _mainLoopFault; + + public DispatcherJobQueue(IMainLoop mainLoop) + { + _mainLoop = mainLoop; + } + public void Run() { lock (_queue) @@ -147,18 +236,36 @@ public void Run() { case State.Started: throw new InvalidOperationException("Dispatcher has already started."); - case State.Stopping: - throw new InvalidOperationException("Dispatcher is shutting down."); case State.Stopped: throw new InvalidOperationException("Dispatcher has shut down."); + case State.Stopping: + // Shut down before we got here, so there is nothing left to run. + _state = State.Stopped; + return; } _state = State.Started; } - while (TryTake(out IDispatcherJob job)) + try { - job.Execute(_cts.Token); + // Park cheaply until the main thread is actually needed. An invocation + // that never shows UI and never talks to the macOS broker must not pay + // to start the main loop. + if (!WaitForWork()) + { + // We were shut down before any work arrived. + return; + } + + RunMainLoop(); + } + finally + { + lock (_queue) + { + _state = State.Stopped; + } } } @@ -168,21 +275,29 @@ public void Shutdown() { switch (_state) { - case State.NotStarted: - throw new InvalidOperationException("Dispatcher is not running."); case State.Stopping: throw new InvalidOperationException("Dispatcher is already shutting down."); case State.Stopped: throw new InvalidOperationException("Dispatcher has already shut down."); } + + // Shutting down before Run() has been reached is legitimate: the + // application thread can finish before the main thread gets there. + // Run() sees this and returns without starting anything. _state = State.Stopping; - _cts.Cancel(); - Monitor.Pulse(_queue); + Monitor.PulseAll(_queue); } + + // Cancel outside of the queue lock: this runs the main loop's own + // cancellation callbacks, which take its locks. + _cts.Cancel(); } public void AddJob(IDispatcherJob job) { + bool post = false; + Exception fault; + lock (_queue) { switch (_state) @@ -193,33 +308,172 @@ public void AddJob(IDispatcherJob job) throw new InvalidOperationException("Dispatcher has shut down."); } - _queue.Enqueue(job); - Monitor.Pulse(_queue); + fault = _mainLoopFault; + if (fault is null) + { + _outstandingJobs.Add(job); + if (_isMainLoopRunning) + { + // Our own loop no longer pumps the thread, so queuing here would + // strand the job; hand it to the main loop instead. + post = true; + } + else + { + _queue.Enqueue(job); + Monitor.Pulse(_queue); + } + } + } + + // Complete outside of the queue lock; the main loop takes its own locks. + if (fault is not null) + { + job.Fail(fault); + } + else if (post) + { + try + { + PostToMainLoop(job); + } + catch (Exception ex) + { + FailAllJobs(ex); + } + } + } + + /// + /// Start the platform main loop and hand the dispatcher thread over to it. + /// Runs on the dispatcher thread and does not return until shutdown. + /// + private void RunMainLoop() + { + try + { + // Initialize the main loop on this thread. Once this returns its own + // dispatcher exists and accepts posted work, even though the main loop + // is not running yet. + _mainLoop.Initialize(); + + IDispatcherJob[] pending; + lock (_queue) + { + // Hand over and drain in a single atomic step so that no job can be + // enqueued into a queue that will never be pumped again. Outstanding + // jobs stay tracked until their callbacks finish. + _isMainLoopRunning = true; + pending = _queue.ToArray(); + _queue.Clear(); + } + + // Queue.ToArray returns items in dequeue order, so FIFO is preserved. + // These cannot run until the main loop below is pumping, which is exactly + // the guarantee callers rely on: on macOS the MSAL broker requires a + // running NSApplication, which only exists from that point onwards. + foreach (IDispatcherJob job in pending) + { + PostToMainLoop(job); + } + + // Owns the dispatcher thread until shutdown. + _mainLoop.Run(_cts.Token); + } + catch (Exception ex) + { + // The main loop is unusable, so no main thread work can ever run. + // Fail outstanding and future jobs so callers see the error instead of + // hanging, then keep this thread parked: the application thread still + // needs to unwind and shut us down so the process exits cleanly. + FailAllJobs(ex); + WaitForShutdown(); } } - private bool TryTake(out IDispatcherJob job) + private void PostToMainLoop(IDispatcherJob job) => _mainLoop.Post(() => + { + lock (_queue) + { + // A posting failure can invalidate callbacks already in the platform queue. + if (_mainLoopFault is not null) + { + return; + } + } + + try + { + job.Execute(_cts.Token); + } + finally + { + lock (_queue) + { + _outstandingJobs.Remove(job); + } + } + }); + + private void FailAllJobs(Exception ex) + { + IDispatcherJob[] pending; + lock (_queue) + { + if (_mainLoopFault is not null) + { + return; + } + + _mainLoopFault = ex; + _isMainLoopRunning = false; + pending = new IDispatcherJob[_outstandingJobs.Count]; + _outstandingJobs.CopyTo(pending); + _outstandingJobs.Clear(); + _queue.Clear(); + } + + foreach (IDispatcherJob job in pending) + { + job.Fail(ex); + } + } + + /// + /// Block until a job is queued, or until shutdown. + /// + /// True if there is work to do, false if the dispatcher is shutting down. + private bool WaitForWork() { lock (_queue) { while (_queue.Count == 0) { - // Only check for stopping state when the queue is empty - // to allow remaining jobs to drain. We check for the stopping - // state in AddJob to ensure no more jobs can be added. - if (_state == State.Stopping) + // Only check for stopping state when the queue is empty so that any + // remaining jobs still get to run. AddJob rejects new jobs once we + // are stopping. + if (_state is State.Stopping or State.Stopped) { - job = null; return false; } Monitor.Wait(_queue); } - job = _queue.Dequeue(); return true; } } + + private void WaitForShutdown() + { + lock (_queue) + { + while (_state is not (State.Stopping or State.Stopped)) + { + Monitor.Wait(_queue); + } + } + } } } } diff --git a/src/Core/UI/IMainLoop.cs b/src/Core/UI/IMainLoop.cs new file mode 100644 index 000000000..7cec10d94 --- /dev/null +++ b/src/Core/UI/IMainLoop.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; + +namespace GitCredentialManager.UI +{ + /// + /// A platform main loop that owns the thread. + /// + /// + /// All members except are called on the dispatcher thread. + /// + internal interface IMainLoop + { + /// + /// Initialize the main loop on the calling thread, but do not start running it. + /// + /// + /// Once this returns, must accept work even though + /// has not been called yet. + /// + void Initialize(); + + /// + /// Post work to the main loop's job queue. Callable from any thread. + /// + /// Work to be run. + void Post(Action work); + + /// + /// Run the main loop, returning only once is cancelled. + /// + /// Token signalling that the main loop should exit. + void Run(CancellationToken ct); + } +}