Skip to content

Commit f186cb7

Browse files
larsbrubakerclaude
andcommitted
Stage 4 fixes: nested pumps drain, timed out Send is abandoned
The Mac and X11 hosts guard their idle drain against reentry, and CaptureScreenshot's recovery spin runs underneath a queued action - so its drains were no-ops. With the context installed a genuinely suspending capture continuation is Posted to that same queue, which nothing would then run: a silent screenshot failure. The spins now call the new UiThread.DrainForNestedPump, which skips the guard on purpose and is safe to nest because InvokePendingActions works from a private copy of the queue. The guard keeps protecting ordinary reentry (modal dialogs); the two RunEventLoop call sites are the outermost loop, never nested, so they still drain normally. A Send that times out now marks its queued work abandoned before it throws, so the work does not apply late over whatever the caller did instead, and a throw from work that slipped through goes to UiThread.ReportUnhandledException rather than a field no caller will ever read. Post and Send reject a null callback at the call site, per the BCL contract. Comments corrected where they now asserted the opposite of reality, plus a note on Post latency: a resumption costs up to one idle tick, so an N deep chain of suspending awaits can cost N. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6fdb7e2 commit f186cb7

6 files changed

Lines changed: 301 additions & 16 deletions

File tree

Gui/MainLoopSynchronizationContext.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ namespace MatterHackers.Agg.UI
4444
/// <see cref="UiThread.RunOnIdle(Action)"/>, so posted continuations and hand written RunOnIdle work
4545
/// share a single FIFO. A continuation posted while the pump is running the current batch executes on
4646
/// the NEXT pump, never re-entrantly inside the current one.</para>
47+
/// <para>That costs latency: a resumption waits for the next pump - up to one idle tick, about 10ms on
48+
/// Windows Forms - so an N deep chain of genuinely suspending awaits can take up to N ticks to unwind.
49+
/// Predictable ordering is worth that, but work that cannot afford it should not be hopping the loop
50+
/// once per await.</para>
4751
/// <para>On WebAssembly nothing installs this - Blazor supplies its own single threaded dispatcher and
4852
/// that is already the model this context imitates.</para>
4953
/// <para><see cref="SynchronizationContext.OperationStarted"/> and
@@ -128,15 +132,36 @@ public void Dispose()
128132
/// </summary>
129133
public override void Post(SendOrPostCallback d, object state)
130134
{
135+
// Checked here rather than left to blow up on the pump: the BCL contract is an
136+
// ArgumentNullException, and a NullReferenceException raised one tick later on the main loop
137+
// names neither the caller nor the mistake.
138+
if (d == null)
139+
{
140+
throw new ArgumentNullException(nameof(d));
141+
}
142+
131143
UiThread.RunOnIdle(() => d(state));
132144
}
133145

134146
/// <summary>
135147
/// Runs work on the main loop and waits for it. Inline when already on the main loop; from any
136148
/// other thread this queues and blocks, bounded by <see cref="SendFromOtherThreadTimeout"/>.
137149
/// </summary>
150+
/// <remarks>
151+
/// A Send that times out does NOT run its work afterwards. The queued item stays in the queue - it
152+
/// cannot be pulled back out - but it checks on the way in whether the caller has already given up,
153+
/// and if so does nothing. Otherwise a caller that timed out and retried, or fell back to another
154+
/// path, would have the abandoned work applied a second time whenever the loop recovered. Work that
155+
/// had already passed that check when the wait gave up still runs to completion - the check closes
156+
/// the window that lasts as long as the timeout, not the instant at the end of it.
157+
/// </remarks>
138158
public override void Send(SendOrPostCallback d, object state)
139159
{
160+
if (d == null)
161+
{
162+
throw new ArgumentNullException(nameof(d));
163+
}
164+
140165
if (UiThread.IsUiThread)
141166
{
142167
d(state);
@@ -148,15 +173,34 @@ public override void Send(SendOrPostCallback d, object state)
148173
ExceptionDispatchInfo failure = null;
149174
var completed = new ManualResetEventSlim(false);
150175

176+
// Volatile because it is written by this thread and read by the pump thread. Set before the
177+
// TimeoutException below is thrown, so the throw and the abandonment are one decision.
178+
bool abandoned = false;
179+
151180
UiThread.RunOnIdle(() =>
152181
{
153182
try
154183
{
184+
if (Volatile.Read(ref abandoned))
185+
{
186+
return;
187+
}
188+
155189
d(state);
156190
}
157191
catch (Exception sentWorkException)
158192
{
159-
failure = ExceptionDispatchInfo.Capture(sentWorkException);
193+
if (Volatile.Read(ref abandoned))
194+
{
195+
// The caller is long gone and its `failure` will never be read, so reporting it
196+
// through the field would swallow the exception entirely. This is the same channel
197+
// any other throw out of a queued action takes.
198+
UiThread.ReportUnhandledException(sentWorkException);
199+
}
200+
else
201+
{
202+
failure = ExceptionDispatchInfo.Capture(sentWorkException);
203+
}
160204
}
161205
finally
162206
{
@@ -166,6 +210,8 @@ public override void Send(SendOrPostCallback d, object state)
166210

167211
if (!completed.Wait(SendFromOtherThreadTimeout))
168212
{
213+
Volatile.Write(ref abandoned, true);
214+
169215
// Deliberately not disposed: the work is still queued and will call Set on the pump thread
170216
// if the loop ever recovers. Disposing here would turn that into an ObjectDisposedException
171217
// on the UI thread, which is a far worse failure than leaking one event.

Gui/UiThread.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,25 @@ public static void InvokePendingActions()
248248
}
249249
}
250250

251+
/// <summary>
252+
/// Drains the queue from inside a nested pump - a loop that is itself running underneath a queued
253+
/// action and cannot finish until an awaited continuation makes progress.
254+
/// </summary>
255+
/// <remarks>
256+
/// The platform hosts guard their ordinary idle drain with a re-entrancy flag, which is the right
257+
/// protection for an idle action that runs a message loop of its own and the wrong one for a loop
258+
/// that is spinning precisely because it is waiting on work this queue now owns: a suspended await
259+
/// resumes by posting through <see cref="MainLoopSynchronizationContext"/> into this very queue, so
260+
/// a guarded (no-op) drain would leave such a loop waiting forever for the one thing that could
261+
/// release it. Those loops call this instead. Nesting is safe because
262+
/// <see cref="InvokePendingActions"/> runs from a private copy of the queue rather than the live
263+
/// list.
264+
/// </remarks>
265+
public static void DrainForNestedPump()
266+
{
267+
InvokePendingActions();
268+
}
269+
251270
public class DeferredAction
252271
{
253272
protected Action action;

PlatformLinux/linux/X11SystemWindow.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -842,11 +842,17 @@ public void CaptureScreenshot(string path)
842842
// The native read-back completes inside the paint (wgpu's buffer map is polled to
843843
// completion there), so this is normally already set. It is only not set if the await in
844844
// CaptureThenPresent genuinely suspended, in which case its continuation is queued to the
845-
// idle pump - hence pumping rather than blocking, which would deadlock.
845+
// idle pump by MainLoopSynchronizationContext - hence pumping rather than blocking, which
846+
// would deadlock.
847+
//
848+
// DrainForNestedPump, not InvokeIdleActions: this loop very often runs underneath an idle
849+
// action already (the off-thread branch above marshals through RunOnIdle), and the guarded
850+
// drain is a no-op while that is true - which would leave this spinning for a continuation
851+
// only a drain can run.
846852
for (int spin = 0; spin < ScreenshotPumpSpins && !this.screenshotComplete.IsSet; spin++)
847853
{
848854
PumpEvents();
849-
InvokeIdleActions();
855+
UiThread.DrainForNestedPump();
850856
}
851857
}
852858
finally
@@ -1000,7 +1006,9 @@ private static unsafe void InstallErrorHandlers()
10001006

10011007
/// <summary>
10021008
/// Drains the RunOnIdle queue. Guarded because an idle action can run a nested loop (a modal
1003-
/// dialog, or <see cref="CaptureScreenshot"/>'s pump) and re-enter this.
1009+
/// dialog) and re-enter this. A nested loop that must instead let awaited continuations run -
1010+
/// <see cref="CaptureScreenshot"/>'s spin - calls <see cref="UiThread.DrainForNestedPump"/>, which
1011+
/// deliberately skips this guard.
10041012
/// </summary>
10051013
private static void InvokeIdleActions()
10061014
{

PlatformMac/mac/MacSystemWindow.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -822,15 +822,18 @@ public void CaptureScreenshot(string path)
822822

823823
// The native read-back completes inside the paint (wgpu's buffer map is polled to
824824
// completion there), so this is normally already set. It is only not set if the await in
825-
// CaptureThenPresent genuinely suspended. This host installs no SynchronizationContext, so
826-
// that continuation resumes on a thread-pool thread rather than back here - but the work it
827-
// is waiting on is driven by this window's frames and idle queue, so pumping (rather than
828-
// blocking the UI thread, which would stop the very frames it needs) is still what lets it
829-
// finish.
825+
// CaptureThenPresent genuinely suspended, in which case its continuation is queued to the
826+
// idle pump by MainLoopSynchronizationContext - hence pumping rather than blocking the UI
827+
// thread, which would stop the very frames and continuations it is waiting on.
828+
//
829+
// DrainForNestedPump, not InvokeIdleActions: this loop very often runs underneath an idle
830+
// action already (the off-thread branch above marshals through RunOnIdle), and the guarded
831+
// drain is a no-op while that is true - which would leave this spinning for a continuation
832+
// only a drain can run.
830833
for (int spin = 0; spin < ScreenshotPumpSpins && !completed.IsSet; spin++)
831834
{
832835
PumpEvents();
833-
InvokeIdleActions();
836+
UiThread.DrainForNestedPump();
834837
}
835838
}
836839
finally
@@ -944,9 +947,10 @@ public async Task CaptureScreenshotAsync(string path)
944947
}
945948
finally
946949
{
947-
// Only clear what still belongs to this request. There is no SynchronizationContext in this
948-
// host, so this cleanup can run on a thread-pool thread well after the request was given up
949-
// on, by which point the fields may already have been claimed by the next request.
950+
// Only clear what still belongs to this request. The continuation that gets here resumes on
951+
// the main loop (MainLoopSynchronizationContext), but on a LATER pump - so this cleanup can
952+
// still run well after the request was given up on, by which point the fields may already
953+
// have been claimed by the next request.
950954
if (ReferenceEquals(this.screenshotCompletion, completion))
951955
{
952956
this.pendingScreenshotPath = null;
@@ -1194,7 +1198,9 @@ private static void OnIdleTimer(IntPtr self, IntPtr cmd, IntPtr timer)
11941198

11951199
/// <summary>
11961200
/// Drains the RunOnIdle queue. Guarded because an idle action can run a nested loop (a modal
1197-
/// dialog, or <see cref="CaptureScreenshot"/>'s pump) and re-enter this.
1201+
/// dialog) and re-enter this. A nested loop that must instead let awaited continuations run -
1202+
/// <see cref="CaptureScreenshot"/>'s spin - calls <see cref="UiThread.DrainForNestedPump"/>, which
1203+
/// deliberately skips this guard.
11981204
/// </summary>
11991205
private static void InvokeIdleActions()
12001206
{

0 commit comments

Comments
 (0)