ui: start the main loop just-in-time - #2448
Draft
mjcheetham wants to merge 8 commits into
Draft
mjcheetham wants to merge 8 commits into
mjcheetham wants to merge 8 commits into
Conversation
A dispatcher job that threw left its TaskCompletionSource uncompleted, so a caller awaiting InvokeAsync waited forever. The exception then unwound the queue loop and tore down the dispatcher thread with it, so no further main thread work could run either. Complete the task with the exception instead, so failures surface where the work was requested rather than on whichever loop happens to be pumping the dispatcher thread. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
TaskCompletionSource runs its continuations synchronously by default, so a caller awaiting InvokeAsync resumes inline on the dispatcher thread, inside the loop that is meant to be draining the job queue. Whatever the caller does next - including blocking - delays every other job posted to the main thread. Ask for asynchronous continuations so the dispatcher thread returns to pumping as soon as the job itself is done. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
Program.Main starts the application thread and only then runs the dispatcher, so the application thread can reach shutdown before the main thread has reached Run. Shutdown treated that as misuse and threw, which would have surfaced as an unhandled exception on the application thread for an invocation that did nothing wrong - just one that finished unusually quickly. Accept it instead, and have Run return immediately when it finds the dispatcher already stopping. Neither thread has to win the race. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
Passing an async lambda to InvokeAsync bound to the plain Func<T> overload with T inferred as Task, so the returned task completed when the work first yielded rather than when it finished. The broker call site had to notice that and await twice to get the real result. Anyone who missed it got a task that completed early. Add overloads that take task-returning work and unwrap it, so the returned task tracks the work to completion. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
mjcheetham
requested review from
dscho and
mpysson
and
a balanced review from Copilot
September 17, 2026 09:05
There was a problem hiding this comment.
🟡 Changes recommended
Handed-off jobs can remain incomplete if the platform main loop fails before executing them.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Moves platform main-loop ownership into the dispatcher to prevent macOS UI/broker deadlocks.
Changes:
- Starts Avalonia lazily when dispatcher work first arrives.
- Routes UI and interactive macOS broker work through the dispatcher.
- Adds dispatcher lifecycle and shutdown tests.
File summaries
| File | Description |
|---|---|
src/Core/UI/IMainLoop.cs |
Defines the platform-loop abstraction. |
src/Core/UI/Dispatcher.cs |
Implements lazy handoff, async jobs, and shutdown. |
src/Core/UI/AvaloniaUi.cs |
Routes window creation through the dispatcher. |
src/Core/UI/AvaloniaMainLoop.cs |
Implements the Avalonia-backed main loop. |
src/Core/Authentication/Entra/EntraAuthentication.PublicClient.cs |
Dispatches interactive macOS broker authentication. |
src/Core.Tests/UI/DispatcherTests.cs |
Tests startup and shutdown behavior. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Showing UI and using the macOS MSAL broker both need the process entry thread, for different reasons: macOS requires UI controls to be created there, and the broker requires a running NSApplication. Avalonia supplied the former by running its main loop as a dispatcher job, which never returns. Anything posted afterwards queued up behind it and never ran, so main thread work that followed showing a window deadlocked. MSAL makes the ordering matter a second time. It decides once per process whether it is a "console app" by testing whether NSApplication is running, and caches that answer for the lifetime of the process. Without NSApplication it requires the interactive broker call to run on managed thread 1 and then seizes that thread with its own polling loop, which cannot coexist with Avalonia's. With NSApplication running it requires neither. So whichever of the two ran first silently decided whether the second could work at all. Move the main loop into the dispatcher and start it lazily, on the first job posted. Needing the main thread now implies a running main loop, and since the hand-over completes before any job is dispatched, every job is guaranteed to run with NSApplication already up. There is no escalation call for a caller to forget and no ordering left to get wrong. Invocations that need neither UI nor the broker still pay nothing: the thread parks on the job queue and shuts down again without ever initialising Avalonia. Shutting down abandons work that is still outstanding, which is worth being deliberate about. Draining instead would look tidier but deadlocks the obvious case: a window shown without anyone awaiting it stays open, so nothing would ever complete the work being waited for. The Avalonia bootstrap moves behind IMainLoop so that the dispatcher carries no UI framework dependency and the hand-over stays testable with a fake. The existing shutdown tests are moved on to that fake so they can also assert that the fast path never starts the loop, and tests are added for the hand-over itself and for abandoned work. One consequence of the move is that the avn_init trace region is now recorded on the main thread rather than on the calling thread, since initialisation no longer has a caller to attribute it to. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
The comment claimed the broker needs the main thread "to display UI", which sends anyone reading it looking for a window parenting problem. The real constraint is that the macOS broker needs a running NSApplication, and that MSAL decides once per process whether it has one and caches that answer. Without NSApplication it requires interactive calls to run on managed thread 1 and then takes that thread over with its own polling loop. Record why dispatching is what avoids that, why the silent attempts above deliberately do not dispatch, and which MSAL version the reasoning was checked against, since an upgrade could move the decision to another code path. The variable carrying the platform check is dropped: it read as though the main thread were a hard requirement of the call, when it is really how we guarantee NSApplication is up. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
The state machine declared a Stopped state that nothing ever entered, so every branch handling it was unreachable and the dispatcher could not tell "shut down before Run was reached" from "Run has already returned". Those need to be told apart. Run tolerates the first because the application thread can legitimately finish before the main thread gets that far, but the second is a caller trying to reuse a dispatcher whose thread has already been released, and used to be accepted in silence. Enter Stopped when Run is about to return, and reject running again. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
mjcheetham
force-pushed
the
macbroker-fix
branch
from
September 17, 2026 11:31
d946a6d to
7d7acdf
Compare
The dispatcher carries a lot of load-bearing subtlety that is hard to recover from the code alone: why the main loop starts on the first job rather than on request, why the hand-over flips a flag and drains the queue under one lock, why two collections track work, and why shutdown abandons outstanding jobs instead of draining them. Write it down, with diagrams for the thread interaction, the state machine, how work is routed, and what moves where during the hand-over. Include the rules a caller needs to follow, since the cost of getting them wrong is a hang rather than an obvious failure. Assisted-by: Claude Opus 5 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
mjcheetham
marked this pull request as draft
September 17, 2026 15:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On macOS, GCM hangs if it shows any UI before an interactive Entra sign-in that uses the broker. Cloning a repository that prompts for a credential and then needs broker authentication is enough to reproduce it. The process never exits and Git waits on it forever.
Why it happens
GCM keeps the process entry thread free so that platform APIs which insist on "thread 1" can be served, and runs the application itself on a second thread. Two features need that thread, for unrelated reasons:
NSApplication.Avalonia satisfied the first by posting its main loop to our dispatcher as a job. That job never returns, so from the moment a window was shown the dispatcher's queue stopped being drained. Anything posted afterwards — such as the broker call marshalling itself to the main thread — queued up behind a job that would never finish.
There is a second, less obvious half to this. MSAL decides once per process whether it is a "console app", by testing whether
NSApplicationis running, and caches the answer for the lifetime of the process:Without
NSApplicationit requires interactive calls to run on managed thread 1 and then takes that thread over with its ownThread.Sleep(10)polling loop, which cannot coexist with a UI main loop. WithNSApplicationrunning it requires neither. So whichever of UI and broker happened to run first silently decided whether the second could work at all — and the "broker first, UI later, broker again" ordering would have frozen the UI even once the queue starvation was fixed.The fix
Ownership of the entry thread moves into the dispatcher, which now starts the main loop lazily, on the first job posted, and hands the thread over to it.
Needing the main thread therefore implies a running main loop, and because the hand-over completes before any job can be dispatched, every job is guaranteed to run with
NSApplicationalready up. MSAL always sees a GUI app. There is no "start the UI first" call for a caller to forget, and no ordering left to get wrong: UI and broker can now be used in either order, both, or neither.Startup cost is unchanged for the common case. An invocation that shows no UI and does not use the broker posts no jobs at all: the thread parks on the job queue and shuts down again without ever initialising Avalonia. The silent and default-account authentication probes deliberately stay off the dispatcher for the same reason — only interactive calls decide MSAL's mode, so routing the silent ones through it would pay for Avalonia startup and buy nothing.
Series
Reviewing commit by commit is recommended; each builds and tests green.
Run()was reached threw on the application thread, and async work handed back a task that completed at its firstawaitrather than at completion.Testing
src/Core.Tests/UI/DispatcherTests.cscovers the hand-over and the shutdown paths through a fake main loop: shutting down beforeRun(), with no work posted, while the loop is running, and with work still outstanding. The last of these pins down the deliberate decision to abandon pending work at shutdown rather than drain it — draining would deadlock the obvious case, where a window is shown without anyone awaiting it and so nothing ever completes the work being waited on.Notes for reviewers
Tip
Read the "docs/dispatcher.md" documentation (rendered) about how the dispatcher works first as this will help understand the design and operation at a higher level.
Important
The MSAL behaviour this depends on is version-specific. The call sites that evaluate the console/GUI decision are noted in a comment against 4.85.2;
DesktopOsHelper.IsMacConsoleAppis worth re-checking on an MSAL upgrade.One behavioural consequence worth being aware of: the
avn_initTrace2 region is now recorded on the main thread rather than on the calling thread, because initialisation no longer has a caller to attribute it to.Windows and Linux are unaffected. Their brokers never used the dispatcher, and the only other consumer of it is the UI, which needs the main loop regardless.