test(desktop): fail WinForms tests on UI-callback exceptions per STA thread

TestAssemblySetup set the unhandled-exception mode and a ThreadException
handler from a module initializer. WinForms stores both per thread
(ThreadContext/NativeWindow thread statics), so they applied only to the
runner thread that loaded the assembly, never to the STA threads the
tests run on. There, an exception escaping a window message or a
BeginInvoke/SynchronizationContext callback still opened a modal
ThreadExceptionDialog in an interactive session, or was silently ignored
otherwise; the handler would also have logged and swallowed it.

Replace the fifteen copied RunSta helpers with StaTestThread.Run, which
selects UnhandledExceptionMode.ThrowException on each STA thread before
it creates a window, so the exception propagates to the test body and
fails the test. Each class keeps its own timeout and message; all
threads are now background threads. Remove TestAssemblySetup.

StaTestThreadTests covers a throwing BeginInvoke callback through
StaTestThread (fails with the original exception) and, as a control,
the default mode on a plain STA thread (the exception goes to the
thread's handler and never reaches the test body). Cross-compiled on
Linux; Windows execution is the windows-desktop CI job.
This commit is contained in:
aj committed 2026-10-04 18:40:48 -04:00
1 parent 67fb80e660
commit a15c54fbaf
18 files changed
+142 -290

No files matched your search

@@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Controls;
@@ -86,19 +85,6 @@ public class ActionSelectAreaCutOffTests
public void ToggleSelectionDirection() => OnKeyUp(new KeyEventArgs(Keys.Space));
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The selection/fill test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The selection/fill test did not complete.");
}
+2 -16
View File
@@ -1,7 +1,6 @@
using System.Drawing;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
@@ -48,19 +47,6 @@ public class ArchUnitsTests
// Actual timeout execution is covered by the shared Regex mechanism in FractionTests;
// these desktop tests deliberately avoid timing-sensitive oversized-input fixtures.
internal static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
internal static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
}
@@ -1,6 +1,5 @@
using System.Drawing;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.CNC;
@@ -292,19 +291,6 @@ public class ProgramEditorControlTests
private static void ApplyHighlighting(ProgramEditorControl control) => typeof(ProgramEditorControl)
.GetMethod("ApplyHighlighting", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(control, null);
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "The STA test did not complete.");
}
@@ -1,6 +1,5 @@
using System.Drawing;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Controls;
@@ -109,19 +108,6 @@ public class SeparatorPenLifetimeTests
}
}
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "The STA test did not complete.");
}
@@ -2,7 +2,6 @@ using System.Collections;
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.Forms;
@@ -231,19 +230,6 @@ public class AutoNestFormStockOptionsTests
private static void SetValue(object row, string property, object value) =>
TypeDescriptor.GetProperties(row)[property]!.SetValue(row, value);
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
}
@@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
@@ -354,24 +353,6 @@ public class AutomaticCutOffFormTests
typeof(AutomaticCutOffForm).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, Timeout.InfiniteTimeSpan, "The STA test did not complete.");
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Collections;
@@ -408,19 +407,6 @@ public class BestFitViewerOperationLifetimeTests
private static void Invoke(BestFitViewerForm form, string name, params object[] args) =>
typeof(BestFitViewerForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "STA test did not complete.");
}
@@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Forms;
@@ -146,25 +145,6 @@ public class EditNestFocusTraversalTests
tabControl.SelectedTab = Assert.IsType<TabPage>(plates.Parent);
}
private static void RunSta(System.Action testBody)
{
Exception? testFailure = null;
var staThread = new Thread(() =>
{
try
{
testBody();
}
catch (Exception error)
{
testFailure = error;
}
})
{ IsBackground = true };
staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();
Assert.True(staThread.Join(TimeSpan.FromSeconds(60)), "The focus traversal STA test did not complete.");
if (testFailure != null)
ExceptionDispatchInfo.Capture(testFailure).Throw();
}
private static void RunSta(System.Action testBody) =>
StaTestThread.Run(testBody, TimeSpan.FromSeconds(60), "The focus traversal STA test did not complete.");
}
@@ -1,4 +1,3 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Forms;
@@ -40,25 +39,6 @@ public class LeadInSidePanelTests
Assert.Equal("Select", form.PlateView.Status);
});
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
}
@@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
@@ -299,25 +298,6 @@ public class NestReportExportTests
internal override void ReportSuccess(string destination) => Successes.Add(destination);
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(90), "The STA test did not complete.");
}
@@ -1,4 +1,3 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Forms;
@@ -62,14 +61,6 @@ public class NestValidationFormTests
});
}
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() => { try { action(); } catch (Exception ex) { error = ex; } });
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)));
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
}
@@ -1,7 +1,6 @@
using System.Drawing;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using OpenNest.CNC;
using OpenNest.Data;
using OpenNest.Forms;
@@ -453,25 +452,6 @@ public class PlateExportTests
public void Dispose() => Directory.Delete(DirectoryPath, true);
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(90), "The STA test did not complete.");
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
@@ -283,25 +282,6 @@ public class PlateViewFillLifetimeTests
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
}
@@ -206,18 +206,6 @@ public class PostVerificationFormTests
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "STA verification test timed out.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "STA verification test timed out.");
}
@@ -1,7 +1,6 @@
using System.Collections.Concurrent;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
@@ -1183,19 +1182,6 @@ public class PlateOverlapOverlayTests
return new Part(new Drawing("same name", program), new Vector(x, 0));
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
}
+48
View File
@@ -0,0 +1,48 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
namespace OpenNest.WinForms.Tests;
/// <summary>
/// Runs a test body on its own STA thread and rethrows its failure on the
/// calling test thread.
/// </summary>
/// <remarks>
/// By default WinForms catches an exception raised while dispatching a window
/// message or a BeginInvoke/SynchronizationContext callback and hands it to the
/// thread's ThreadException handler. With none registered it opens a modal
/// ThreadExceptionDialog in an interactive session, which blocks the test until
/// its join timeout, and silently ignores the exception otherwise. Both the
/// unhandled-exception mode and the handler are per-thread, so they cannot be
/// configured once for the assembly: each STA thread selects
/// <see cref="UnhandledExceptionMode.ThrowException"/> before it creates a
/// window, and the exception then propagates to the code pumping messages
/// (Show, DoEvents, ShowDialog) and fails the test.
/// </remarks>
internal static class StaTestThread
{
public static void Run(Action body, TimeSpan timeout, string timeoutMessage)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
body();
}
catch (Exception ex)
{
failure = ex;
}
})
{
IsBackground = true,
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(timeout), timeoutMessage);
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
@@ -0,0 +1,64 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
namespace OpenNest.WinForms.Tests;
public class StaTestThreadTests
{
private const string CallbackMessage = "thrown from a queued UI callback";
[Fact]
public void QueuedCallbackExceptionFailsTheTestInsteadOfOpeningADialog()
{
var error = Assert.Throws<InvalidOperationException>(() =>
StaTestThread.Run(
ThrowFromQueuedCallback,
TimeSpan.FromSeconds(30),
"The STA test did not complete."
)
);
Assert.Equal(CallbackMessage, error.Message);
}
[Fact]
public void WithoutThrowExceptionModeTheCallbackExceptionNeverReachesTheTestBody()
{
// Control: the default mode routes the same exception to the thread's
// ThreadException handler (or, with none, to a modal dialog), so the
// test body itself completes without seeing it.
Exception? routed = null;
Exception? bodyFailure = null;
var thread = new Thread(() =>
{
Application.ThreadException += (_, e) => routed = e.Exception;
try
{
ThrowFromQueuedCallback();
}
catch (Exception ex)
{
bodyFailure = ex;
}
})
{
IsBackground = true,
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The control STA thread did not complete.");
if (bodyFailure != null)
ExceptionDispatchInfo.Capture(bodyFailure).Throw();
Assert.IsType<InvalidOperationException>(routed);
Assert.Equal(CallbackMessage, routed!.Message);
}
private static void ThrowFromQueuedCallback()
{
using var control = new Control();
_ = control.Handle;
control.BeginInvoke(new Action(() => throw new InvalidOperationException(CallbackMessage)));
Application.DoEvents();
}
}
@@ -1,22 +0,0 @@
using System.Runtime.CompilerServices;
using System.Windows.Forms;
namespace OpenNest.WinForms.Tests;
internal static class TestAssemblySetup
{
// SetUnhandledExceptionMode only changes which *default* dialog style WinForms would
// show; it still shows one. A registered ThreadException handler is what actually
// suppresses it (WinForms always prefers the handler over the default dialog), which
// matters here because none of our tests call Application.Run() to install one.
// Without this, an exception thrown inside a WndProc callback (e.g. a DataGridView
// commit failure during Show()) pops a modal ThreadExceptionDialog that blocks the
// desktop until someone dismisses it by hand, instead of failing the test normally.
[ModuleInitializer]
internal static void Initialize()
{
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
Application.ThreadException += (_, e) =>
Console.Error.WriteLine($"Suppressed ThreadExceptionDialog: {e.Exception}");
}
}