Skip to content

Commit 6fdb7e2

Browse files
larsbrubakerclaude
andcommitted
Stage 4: await returns to the main loop by default on desktop
New MainLoopSynchronizationContext routes Post through the one existing UiThread queue, so posted continuations and hand written RunOnIdle work share a single FIFO drained by the single existing pump. Send runs inline on the main loop and, from any other thread, queues and waits with a bounded timeout plus a loud diagnostic naming the context - that shape is legacy and blocks a thread on the UI, so it is reported rather than hidden. CreateCopy hands back the one instance; OperationStarted/Completed stay base no-ops because the platform hosts own their loops' lifetime. Installed on the thread that pumps: the WinForms idle handler (both the marshalled and direct branches), the Mac and X11 idle drains, and the automation runner. The runner installs for a scope and restores on the way out, because it borrows a harness thread that is reused for later tests - leaving the context latched there would route unrelated awaits into a queue nobody pumps. Nothing installs on wasm; Blazor's dispatcher is already this model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 47fe49a commit 6fdb7e2

6 files changed

Lines changed: 552 additions & 3 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
Copyright (c) 2026, Lars Brubaker
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
2. Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
14+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
18+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
21+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
25+
The views and conclusions contained in the software and documentation are those
26+
of the authors and should not be interpreted as representing official policies,
27+
either expressed or implied, of the FreeBSD Project.
28+
*/
29+
30+
using System;
31+
using System.Runtime.ExceptionServices;
32+
using System.Threading;
33+
34+
namespace MatterHackers.Agg.UI
35+
{
36+
/// <summary>
37+
/// Makes <c>await</c> resume on the application's main loop by default, the way it already does in a
38+
/// browser. Installed on the thread that pumps <see cref="UiThread.InvokePendingActions"/>, so a
39+
/// continuation captured anywhere on the UI thread comes back to the UI thread without the caller
40+
/// having to marshal it by hand with <see cref="UiThread.RunOnIdle(Action)"/>.
41+
/// </summary>
42+
/// <remarks>
43+
/// <para>There is exactly one queue and one pump: <see cref="Post"/> enqueues through
44+
/// <see cref="UiThread.RunOnIdle(Action)"/>, so posted continuations and hand written RunOnIdle work
45+
/// share a single FIFO. A continuation posted while the pump is running the current batch executes on
46+
/// the NEXT pump, never re-entrantly inside the current one.</para>
47+
/// <para>On WebAssembly nothing installs this - Blazor supplies its own single threaded dispatcher and
48+
/// that is already the model this context imitates.</para>
49+
/// <para><see cref="SynchronizationContext.OperationStarted"/> and
50+
/// <see cref="SynchronizationContext.OperationCompleted"/> are deliberately not overridden. They exist
51+
/// so a context can keep an async void operation alive against a loop that would otherwise exit; the
52+
/// platform hosts own their message loops' lifetime, so the base no-ops are correct here (Blazor's
53+
/// dispatcher takes the same position).</para>
54+
/// </remarks>
55+
public sealed class MainLoopSynchronizationContext : SynchronizationContext
56+
{
57+
private MainLoopSynchronizationContext()
58+
{
59+
}
60+
61+
/// <summary>
62+
/// The single instance. There is only one main loop, so there is only ever one context - and
63+
/// <see cref="CreateCopy"/> hands back this same object rather than a clone.
64+
/// </summary>
65+
public static MainLoopSynchronizationContext Instance { get; } = new MainLoopSynchronizationContext();
66+
67+
/// <summary>
68+
/// How long <see cref="Send"/> from a thread other than the main loop will wait for the pump before
69+
/// giving up. Bounded on purpose: a dead or blocked pump must fail loudly rather than park the
70+
/// calling thread forever.
71+
/// </summary>
72+
public static TimeSpan SendFromOtherThreadTimeout { get; set; } = TimeSpan.FromSeconds(10);
73+
74+
/// <summary>
75+
/// Raised (on the calling thread) every time <see cref="Send"/> is used from off the main loop.
76+
/// That shape is legacy - it blocks a thread on the UI - so it is reported rather than hidden.
77+
/// </summary>
78+
public static event Action<string> BlockingSendObserved;
79+
80+
/// <summary>
81+
/// Installs this context on the calling thread if it is not already installed. Called by each
82+
/// platform host from the thread that pumps the idle queue; cheap and idempotent, so it can sit
83+
/// directly on the pump path rather than needing a separate one-time startup hook.
84+
/// </summary>
85+
public static void InstallOnPumpThread()
86+
{
87+
if (Current is MainLoopSynchronizationContext)
88+
{
89+
return;
90+
}
91+
92+
SetSynchronizationContext(Instance);
93+
}
94+
95+
/// <summary>
96+
/// Installs the context for the duration of the returned scope and restores whatever was current
97+
/// when the scope is disposed. For hosts that BORROW a thread rather than own it for the life of
98+
/// the process - the test harness borrows a runner thread per test - because leaving the context
99+
/// latched on a borrowed thread would route later, unrelated awaits into a queue nobody pumps.
100+
/// </summary>
101+
public static IDisposable InstallForScope()
102+
{
103+
var previous = Current;
104+
SetSynchronizationContext(Instance);
105+
106+
return new InstallScope(previous);
107+
}
108+
109+
private sealed class InstallScope : IDisposable
110+
{
111+
private readonly SynchronizationContext previous;
112+
113+
internal InstallScope(SynchronizationContext previous)
114+
{
115+
this.previous = previous;
116+
}
117+
118+
public void Dispose()
119+
{
120+
SetSynchronizationContext(previous);
121+
}
122+
}
123+
124+
/// <summary>
125+
/// Queues work for the next pump of the main loop. Never runs inline, even when called from the
126+
/// main loop thread - that is what makes await continuations serialize behind whatever the loop is
127+
/// already doing instead of re-entering it.
128+
/// </summary>
129+
public override void Post(SendOrPostCallback d, object state)
130+
{
131+
UiThread.RunOnIdle(() => d(state));
132+
}
133+
134+
/// <summary>
135+
/// Runs work on the main loop and waits for it. Inline when already on the main loop; from any
136+
/// other thread this queues and blocks, bounded by <see cref="SendFromOtherThreadTimeout"/>.
137+
/// </summary>
138+
public override void Send(SendOrPostCallback d, object state)
139+
{
140+
if (UiThread.IsUiThread)
141+
{
142+
d(state);
143+
return;
144+
}
145+
146+
ReportBlockingSend();
147+
148+
ExceptionDispatchInfo failure = null;
149+
var completed = new ManualResetEventSlim(false);
150+
151+
UiThread.RunOnIdle(() =>
152+
{
153+
try
154+
{
155+
d(state);
156+
}
157+
catch (Exception sentWorkException)
158+
{
159+
failure = ExceptionDispatchInfo.Capture(sentWorkException);
160+
}
161+
finally
162+
{
163+
completed.Set();
164+
}
165+
});
166+
167+
if (!completed.Wait(SendFromOtherThreadTimeout))
168+
{
169+
// Deliberately not disposed: the work is still queued and will call Set on the pump thread
170+
// if the loop ever recovers. Disposing here would turn that into an ObjectDisposedException
171+
// on the UI thread, which is a far worse failure than leaking one event.
172+
throw new TimeoutException(
173+
$"{nameof(MainLoopSynchronizationContext)}.{nameof(Send)} waited {SendFromOtherThreadTimeout.TotalSeconds:0.##}s"
174+
+ " for the main loop to pump and gave up. The main loop is blocked, or has not started, or has already exited.");
175+
}
176+
177+
completed.Dispose();
178+
179+
failure?.Throw();
180+
}
181+
182+
/// <inheritdoc/>
183+
public override SynchronizationContext CreateCopy()
184+
{
185+
return this;
186+
}
187+
188+
private static void ReportBlockingSend()
189+
{
190+
var thread = Thread.CurrentThread;
191+
var message = $"{nameof(MainLoopSynchronizationContext)}.{nameof(Send)} was called from thread"
192+
+ $" {thread.ManagedThreadId} ('{thread.Name ?? "unnamed"}'), which is not the main loop."
193+
+ " This is a legacy shaped blocking marshal: the calling thread is parked until the main loop"
194+
+ " pumps, and it deadlocks outright if the main loop is waiting on this thread. Await work that"
195+
+ " resumes on the main loop instead.";
196+
197+
Console.Error.WriteLine(message);
198+
199+
try
200+
{
201+
BlockingSendObserved?.Invoke(message);
202+
}
203+
catch
204+
{
205+
// A diagnostic listener must never change the outcome of the Send it is reporting on.
206+
}
207+
}
208+
}
209+
}

GuiAutomation/AutomationRunner.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,7 +1919,15 @@ void CaptureUiThreadException(Exception exception)
19191919
try
19201920
{
19211921
DebugLogger.LogMessage("AutomationRunner", "CALLING ShowAsSystemWindow");
1922-
initialSystemWindow.ShowAsSystemWindow();
1922+
1923+
// This thread is about to become the message loop, so it is the thread await has to come back
1924+
// to. Installing here rather than relying on the platform host means the context is in place
1925+
// before the window's first idle tick, and covers hosts that never reach an idle timer. Scoped
1926+
// because this thread belongs to the test harness, which reuses it once the loop has exited.
1927+
using (MainLoopSynchronizationContext.InstallForScope())
1928+
{
1929+
initialSystemWindow.ShowAsSystemWindow();
1930+
}
19231931
}
19241932
catch (Exception ex)
19251933
{
@@ -1981,8 +1989,12 @@ void CaptureUiThreadException(Exception exception)
19811989
// IMPORTANT: Reset UiThread LAST after window is fully closed to avoid clearing CloseOnIdle actions
19821990
try
19831991
{
1984-
// Let any remaining RunOnIdle actions complete first
1985-
UiThread.InvokePendingActions();
1992+
// Let any remaining RunOnIdle actions complete first, still under the main loop context so a
1993+
// continuation queued by this final drain lands in the same queue we are draining.
1994+
using (MainLoopSynchronizationContext.InstallForScope())
1995+
{
1996+
UiThread.InvokePendingActions();
1997+
}
19861998

19871999
// Now reset UiThread static state for the next test
19882000
UiThread.ResetForTests();

PlatformLinux/linux/X11SystemWindow.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,7 @@ private static void InvokeIdleActions()
10161016

10171017
try
10181018
{
1019+
MainLoopSynchronizationContext.InstallOnPumpThread();
10191020
UiThread.InvokePendingActions();
10201021
}
10211022
finally

PlatformMac/mac/MacSystemWindow.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,7 @@ private static void InvokeIdleActions()
12101210

12111211
try
12121212
{
1213+
MainLoopSynchronizationContext.InstallOnPumpThread();
12131214
UiThread.InvokePendingActions();
12141215
}
12151216
finally

PlatformWin32/win32/WinformsSystemWindow.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,15 @@ private void InvokePendingOnIdleActions(object sender, ElapsedEventArgs e)
488488
Invoke(new Action(() =>
489489
{
490490
reachedUiThread = true;
491+
MainLoopSynchronizationContext.InstallOnPumpThread();
491492
UiThread.InvokePendingActions();
492493
FlushPendingAggInvalidates();
493494
}));
494495
}
495496
else
496497
{
497498
reachedUiThread = true;
499+
MainLoopSynchronizationContext.InstallOnPumpThread();
498500
UiThread.InvokePendingActions();
499501
FlushPendingAggInvalidates();
500502
}

0 commit comments

Comments
 (0)