From 16c84edaeb6b39082f51db6ec153a017bdfb789e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:37:08 -0400 Subject: [PATCH 1/8] WPF tests run on one shared STA thread per test process (#5602) A shared background STA thread runs every WPF test body, one at a time, behind a caller-held gate, and drains the dispatcher after each body. A hung body fails its own test by name and the later ones fail fast. The 54 per-file STA helpers and WpfStaGate are gone, and StaThreadCensusTests (both test projects) keeps them from coming back. --- .github/workflows/build.yml | 2 +- .../AvailabilityGroupsTabRefreshTests.cs | 27 +- .../ColumnFilterPersistenceTests.cs | 44 +-- .../Darling.Tests/ColumnFilterPopupTests.cs | 40 +-- .../PlanViewerRuntimeSummaryNestingTests.cs | 23 +- .../RawWindowFloorViewerPortTests.cs | 31 +- .../ServerTabToolbarAndTooltipsTests.cs | 23 +- Darling/Darling.Tests/StaTestThread.cs | 277 ++++++++++++++++++ Darling/Darling.Tests/StaTestThreadTests.cs | 192 ++++++++++++ Darling/Darling.Tests/StaThreadCensusTests.cs | 141 +++++++++ .../StatementScrubViewerPlanWithheldTests.cs | 20 +- ...rBlockedProcessReportsCoverageLiveTests.cs | 20 +- ...rClutterAndMemoryPressureDataStartTests.cs | 20 +- .../ViewerCollectionHealthLoadTests.cs | 55 +--- .../ViewerCollectionLogDataStartLiveTests.cs | 38 +-- .../ViewerCollectorDurationHoverTests.cs | 38 +-- .../ViewerConfigChangesDataStartTests.cs | 21 +- .../ViewerDataStartBannerTests.cs | 24 +- .../ViewerDisabledListBoxThemeTests.cs | 25 +- .../ViewerEventDataStartTests.cs | 26 +- .../ViewerJobHistoryDataStartLiveTests.cs | 20 +- .../ViewerJobHistoryDataStartTests.cs | 20 +- .../ViewerLongQueriesDataStartTests.cs | 20 +- .../ViewerPlanCorrectionsDataStartTests.cs | 20 +- .../ViewerQueryGridsDataStartLiveTests.cs | 16 +- .../ViewerQueryHeatmapDataStartTests.cs | 20 +- ...ewerQueryStoreRegressionsDataStartTests.cs | 20 +- .../ViewerScreenReaderNamesFollowUpTests.cs | 31 +- .../ViewerScreenReaderNamesTests.cs | 25 +- .../ViewerScreenReaderRowNamesTests.cs | 31 +- .../ViewerShortWindowDataStartTests.cs | 20 +- .../ViewerStatsRowBlankCellTests.cs | 22 +- Darling/Darling.Tests/WpfStaGate.cs | 52 ---- .../Darling.Tests/WpfStaGateCensusTests.cs | 114 ------- Lite.Tests/AlertHistoryCountFilterTests.cs | 21 +- .../AvailabilityGroupsTabRefreshTests.cs | 24 +- Lite.Tests/BlockingChartsDataStartTests.cs | 25 +- .../CollectionLogDrillDataStartTests.cs | 24 +- Lite.Tests/CollectorDurationHoverTests.cs | 29 +- Lite.Tests/CpuHoverStoredWallClockTests.cs | 23 +- Lite.Tests/CustomTabHeaderInkTests.cs | 25 +- .../DataStartBannerBlockingEventsTests.cs | 25 +- .../DataStartBannerLatchSpinlockTests.cs | 19 +- .../DataStartBannerProbeFailureTests.cs | 31 +- Lite.Tests/DataStartBannerQueriesTabTests.cs | 31 +- Lite.Tests/DataStartBannerSurfaceTests.cs | 31 +- Lite.Tests/DataStartBannerTests.cs | 25 +- Lite.Tests/DisplayZoneRendererTests.cs | 24 +- Lite.Tests/JobHistoryDataStartTests.cs | 26 +- Lite.Tests/Lite.Tests.csproj | 4 +- Lite.Tests/MainWindowAccessKeyTests.cs | 25 +- .../OverviewBlockingLaneDataStartLiveTests.cs | 25 +- Lite.Tests/PlanViewer4632FallbackTests.cs | 23 +- Lite.Tests/QueryWindowTruncationTests.cs | 29 +- Lite.Tests/ScreenReaderNamesFollowUpTests.cs | 31 +- Lite.Tests/ScreenReaderNamesTests.cs | 27 +- Lite.Tests/ScreenReaderRowNamesTests.cs | 31 +- .../ServerTabEmptyStateAndNamesTests.cs | 41 +-- Lite.Tests/StaThreadCensusTests.cs | 138 +++++++++ .../StandalonePlanViewerPlusTabTests.cs | 47 +-- .../StatementScrubLivePlanDisplayTests.cs | 21 +- Lite.Tests/ThemeColorOverrideTests.cs | 28 +- Lite.Tests/WpfStaGateCensusTests.cs | 114 ------- 63 files changed, 928 insertions(+), 1557 deletions(-) create mode 100644 Darling/Darling.Tests/StaTestThread.cs create mode 100644 Darling/Darling.Tests/StaTestThreadTests.cs create mode 100644 Darling/Darling.Tests/StaThreadCensusTests.cs delete mode 100644 Darling/Darling.Tests/WpfStaGate.cs delete mode 100644 Darling/Darling.Tests/WpfStaGateCensusTests.cs create mode 100644 Lite.Tests/StaThreadCensusTests.cs delete mode 100644 Lite.Tests/WpfStaGateCensusTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1308f6926..9576e89c66 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2610,7 +2610,7 @@ jobs: - 'Darling/Darling.Tests/McpArgumentTypeRows.cs' - 'Darling/Darling.Tests/StatementScrubCanary.cs' - 'Darling/Darling.Tests/GuardStageScanner.cs' - - 'Darling/Darling.Tests/WpfStaGate.cs' + - 'Darling/Darling.Tests/StaTestThread.cs' # How much of the Lite suite THIS leg runs, decided from the filter answers alone (no build needed): # full - a Lite, Lite.Tests, core or root path changed, or a Darling.Tests file that Lite.Tests # compiles into itself, or the event is not a pull request (a push diff --git a/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs b/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs index 902fc182e2..af44a5fbd7 100644 --- a/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs +++ b/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs @@ -35,7 +35,7 @@ public void Render_SameRowsAcrossTwoCalls_KeepsTheSameCardInstances() /* The pin the ruling asks for: two refreshes over identical rows must not hand the ItemsControl a new object per card. Before the fix, Render assigns a brand new List (brand new instances) every call, so this fails on the pre-#4238 code — proven once, below. */ - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -56,7 +56,7 @@ public void Render_SameRowsAcrossTwoCalls_KeepsTheSameCardInstances() [Fact] public void Render_ChangedValue_UpdatesInPlace_SameInstanceNewValue() { - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -80,7 +80,7 @@ public void Render_ChangedValue_UpdatesInPlace_SameInstanceNewValue() [Fact] public void Render_AddedOrRemovedAg_AddsOrRemovesExactlyOneCard() { - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -129,7 +129,7 @@ public void Render_UnchangedRows_ReplacesNoCardReplicaOrDatabaseInstances() reconcile every card, replica and database IN PLACE and never rebuild any of them. A regression that tears down and rebuilds rows -- or an O(n^2) loop that amounts to the same thing -- shows up here as a nonzero replaced count, independent of how loaded the runner is. */ - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); var topology = BuildTopology(agCount: 42, replicasPerAg: 2, dbRowsPerAg: 9, extraDbRowsOnFirst: 20); @@ -253,23 +253,4 @@ private static AgTopologyDatabaseRow Database(int serverId, string serverName, s RedoRateKbPerSec = 0, }; - /// WPF objects require STA; same shape as RawWindowFloorViewerPortTests / Lite.Tests' MainWindowAccessKeyTests. - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs b/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs index 8dc69c9700..443612500d 100644 --- a/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs +++ b/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs @@ -509,24 +509,6 @@ private sealed class LoginRow public string? Detail { get; set; } } - private static T OnStaThread(Func body) - { - T result = default!; - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { result = body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - if (error is not null) - throw error; - return result; - } - private static DataGrid NewGrid(string? name, string? server) { var grid = new DataGrid { AutoGenerateColumns = false }; @@ -555,7 +537,7 @@ private static void Untick(IDataGridFilterManager manager, string column, string [Fact] public void A_text_match_on_a_query_text_column_lasts_the_session_but_is_not_stored() { - var kept = OnStaThread(() => + var kept = StaTestThread.Run(() => { var store = NewStore(); ColumnFilterStore.Current = store; @@ -591,7 +573,7 @@ public void A_text_match_on_a_query_text_column_lasts_the_session_but_is_not_sto [Fact] public void A_filter_survives_a_refresh_and_a_value_that_first_appears_later_still_shows() { - OnStaThread(() => + StaTestThread.Run(() => { var grid = NewGrid("G", null); var manager = new DataGridFilterManager(grid); @@ -610,7 +592,7 @@ public void A_filter_survives_a_refresh_and_a_value_that_first_appears_later_sti [Fact] public void The_value_list_comes_from_the_unfiltered_rows_not_the_ones_shown() { - OnStaThread(() => + StaTestThread.Run(() => { var grid = NewGrid("G", null); var manager = new DataGridFilterManager(grid); @@ -626,7 +608,7 @@ public void The_value_list_comes_from_the_unfiltered_rows_not_the_ones_shown() [Fact] public void Number_query_text_and_prose_columns_and_columns_the_row_lacks_get_no_list() { - OnStaThread(() => + StaTestThread.Run(() => { var grid = NewGrid("G", null); var manager = new DataGridFilterManager(grid); @@ -645,7 +627,7 @@ public void Number_query_text_and_prose_columns_and_columns_the_row_lacks_get_no [Fact] public void A_text_column_that_sorts_by_another_member_gets_no_list() { - OnStaThread(() => + StaTestThread.Run(() => { var grid = NewGrid("G", null); ((DataGridTextColumn)grid.Columns[0]).SortMemberPath = "SessionId"; @@ -660,7 +642,7 @@ public void A_text_column_that_sorts_by_another_member_gets_no_list() [Fact] public void A_column_with_a_value_over_256_characters_gets_no_list() { - OnStaThread(() => + StaTestThread.Run(() => { var grid = NewGrid("G", null); var manager = new DataGridFilterManager(grid); @@ -674,7 +656,7 @@ public void A_column_with_a_value_over_256_characters_gets_no_list() [Fact] public void A_filter_set_in_one_session_is_loaded_by_the_next_on_its_first_refresh() { - var shown = OnStaThread(() => + var shown = StaTestThread.Run(() => { var store = NewStore(); ColumnFilterStore.Current = store; @@ -700,7 +682,7 @@ public void A_filter_set_in_one_session_is_loaded_by_the_next_on_its_first_refre [Fact] public void A_cross_server_list_under_the_all_servers_scope_keeps_its_filters_across_a_restart() { - var shown = OnStaThread(() => + var shown = StaTestThread.Run(() => { var store = NewStore(); ColumnFilterStore.Current = store; @@ -723,7 +705,7 @@ public void A_cross_server_list_under_the_all_servers_scope_keeps_its_filters_ac [Fact] public void Another_servers_filters_do_not_apply_and_a_grid_without_a_name_or_scope_is_not_stored() { - OnStaThread(() => + StaTestThread.Run(() => { var store = NewStore(); ColumnFilterStore.Current = store; @@ -757,7 +739,7 @@ public void Another_servers_filters_do_not_apply_and_a_grid_without_a_name_or_sc [Fact] public void A_stored_filter_on_a_column_the_grid_no_longer_has_is_ignored() { - OnStaThread(() => + StaTestThread.Run(() => { var store = NewStore(); store.Save("srv-a", "G", new[] { Hide("RetiredColumn", "x"), Hide("LoginName", "job_svc") }); @@ -776,7 +758,7 @@ public void A_stored_filter_on_a_column_the_grid_no_longer_has_is_ignored() [Fact] public void Clear_all_filters_clears_the_grid_and_the_stored_copy_but_a_server_switch_does_not() { - OnStaThread(() => + StaTestThread.Run(() => { var store = NewStore(); ColumnFilterStore.Current = store; @@ -806,7 +788,7 @@ public void Clear_all_filters_clears_the_grid_and_the_stored_copy_but_a_server_s [Fact] public void A_server_switch_loads_the_new_servers_own_filters() { - OnStaThread(() => + StaTestThread.Run(() => { var store = NewStore(); store.Save("srv-b", "G", new[] { Hide("LoginName", "sa") }); @@ -828,7 +810,7 @@ public void A_server_switch_loads_the_new_servers_own_filters() [Fact] public void A_store_that_fails_never_blocks_the_grid() { - OnStaThread(() => + StaTestThread.Run(() => { File.WriteAllText(FilePath, "garbage"); ColumnFilterStore.Current = NewStore(); diff --git a/Darling/Darling.Tests/ColumnFilterPopupTests.cs b/Darling/Darling.Tests/ColumnFilterPopupTests.cs index 516e2fc500..0c0f2385a4 100644 --- a/Darling/Darling.Tests/ColumnFilterPopupTests.cs +++ b/Darling/Darling.Tests/ColumnFilterPopupTests.cs @@ -26,24 +26,6 @@ namespace Darling.Tests; /// public sealed class ColumnFilterPopupTests { - private static T OnStaThread(Func body) - { - T result = default!; - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { result = body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - if (error is not null) - throw error; - return result; - } - private static T Find(DependencyObject root, Func where) where T : DependencyObject { foreach (var child in LogicalTreeHelper.GetChildren(root).OfType()) @@ -65,7 +47,7 @@ private static Button ButtonNamed(ColumnFilterPopup popup, string content) => [Fact] public void The_list_section_shows_only_for_a_column_with_a_catalog() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); var panel = Find(popup, p => p.Name == "ValueListPanel"); @@ -84,7 +66,7 @@ public void The_list_section_shows_only_for_a_column_with_a_catalog() [Fact] public void The_search_box_narrows_the_list_and_Select_All_covers_what_it_shows() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); popup.Initialize("LoginName", null, Catalog("sa", "app", "job_svc", null)); @@ -113,7 +95,7 @@ public void The_search_box_narrows_the_list_and_Select_All_covers_what_it_shows( [Fact] public void Nothing_ticked_shows_the_note_and_the_cap_note_shows_for_a_long_list() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); popup.Initialize("LoginName", null, Catalog("a", "b")); @@ -134,7 +116,7 @@ public void Nothing_ticked_shows_the_note_and_the_cap_note_shows_for_a_long_list [Fact] public void An_existing_value_filter_comes_back_as_the_ticks_and_the_text_match_beside_it() { - OnStaThread(() => + StaTestThread.Run(() => { var existing = new ColumnFilterState { ColumnName = "LoginName", ValueMode = ColumnValueMode.Hide, Operator = FilterOperator.StartsWith, Value = "s" }; existing.Values.Add("job_svc"); @@ -158,7 +140,7 @@ public void An_existing_value_filter_comes_back_as_the_ticks_and_the_text_match_ [Fact] public void A_column_without_a_list_keeps_a_stored_value_filter_when_the_text_match_is_applied() { - OnStaThread(() => + StaTestThread.Run(() => { var existing = new ColumnFilterState { ColumnName = "LoginName", ValueMode = ColumnValueMode.ShowOnly }; existing.Values.Add("sa"); @@ -178,7 +160,7 @@ public void A_column_without_a_list_keeps_a_stored_value_filter_when_the_text_ma [Fact] public void A_text_only_apply_keeps_the_stored_value_part_even_when_todays_rows_have_drifted_from_it() { - OnStaThread(() => + StaTestThread.Run(() => { /* Scenario A: stored ShowOnly {sa}, but the rows now hold only sa. Re-deriving the mode would find nothing unticked, give None, and drop the filter. */ @@ -214,7 +196,7 @@ public void A_text_only_apply_keeps_the_stored_value_part_even_when_todays_rows_ [Fact] public void Select_All_unticked_shows_the_no_values_note_at_once() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); popup.Initialize("LoginName", null, Catalog("a", "b")); @@ -234,7 +216,7 @@ public void Select_All_unticked_shows_the_no_values_note_at_once() [Fact] public void A_stored_value_part_on_a_column_with_no_list_shows_read_only_with_a_way_to_clear_it() { - OnStaThread(() => + StaTestThread.Run(() => { var existing = new ColumnFilterState { ColumnName = "LoginName", ValueMode = ColumnValueMode.Hide }; existing.Values.Add("a"); @@ -276,7 +258,7 @@ public void A_stored_value_part_on_a_column_with_no_list_shows_read_only_with_a_ [Fact] public void Clear_all_filters_shows_only_when_the_grid_has_a_filter_and_raises_its_event() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); var button = ButtonNamed(popup, "Clear all filters"); @@ -297,7 +279,7 @@ public void Clear_all_filters_shows_only_when_the_grid_has_a_filter_and_raises_i [Fact] public void Clear_applies_an_empty_filter_including_the_value_part() { - OnStaThread(() => + StaTestThread.Run(() => { var existing = new ColumnFilterState { ColumnName = "LoginName", ValueMode = ColumnValueMode.Hide }; existing.Values.Add("a"); @@ -316,7 +298,7 @@ public void Clear_applies_an_empty_filter_including_the_value_part() [Fact] public void Esc_closes_the_popup_from_anywhere_in_it_and_the_list_is_virtualised_and_tab_stops_once() { - OnStaThread(() => + StaTestThread.Run(() => { var popup = new ColumnFilterPopup(); popup.Initialize("LoginName", null, Catalog("a", "b")); diff --git a/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs index e0d3d665f2..b72d5c528c 100644 --- a/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs +++ b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs @@ -43,7 +43,7 @@ public sealed class PlanViewerRuntimeSummaryNestingTests [Fact] public void RuntimeSummary_EarlyAbortUnderOptimization_IndentsItsLabelOnly() { - OnStaThread(() => + StaTestThread.Run(() => { var control = new PlanViewerControl(); try @@ -85,7 +85,7 @@ public void RuntimeSummary_EarlyAbortUnderOptimization_IndentsItsLabelOnly() [Fact] public void RuntimeSummary_EarlyAbortWithNoOptimizationRow_IsNotIndented() { - OnStaThread(() => + StaTestThread.Run(() => { var control = new PlanViewerControl(); try @@ -125,23 +125,4 @@ private static (TextBlock Label, TextBlock Value) RowOf(Grid grid, string label) return (labelText, valueText); } - /// WPF objects require STA; same shape as the other WPF tests here. - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs index c12aa5c229..527784c1cd 100644 --- a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs +++ b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs @@ -33,7 +33,7 @@ public sealed class RawWindowFloorViewerPortTests [Fact] public void UpdateTruncationBanner_ShowsSinceEffectiveStart_WhenTheFloorIsPastTheSlack() { - OnStaThread(() => + StaTestThread.Run(() => { var banner = new TextBlock(); var floor = RequestedStart.AddDays(3); @@ -48,7 +48,7 @@ public void UpdateTruncationBanner_ShowsSinceEffectiveStart_WhenTheFloorIsPastTh [Fact] public void UpdateTruncationBanner_StaysCollapsed_WhenTheFloorIsInsideTheSlack() { - OnStaThread(() => + StaTestThread.Run(() => { /* 30 minutes after the requested start — inside DurationTrendRouting.TruncationSlack's 90-minute allowance, so a normal cadence-start lag, not a retention cut (the #2364 / #4231 ruling). Seeded @@ -65,7 +65,7 @@ public void UpdateTruncationBanner_StaysCollapsed_WhenTheFloorIsInsideTheSlack() [Fact] public void UpdateTruncationBanner_StaysCollapsed_WhenTheProbeFoundNoFloorAtAll() { - OnStaThread(() => + StaTestThread.Run(() => { var banner = new TextBlock(); @@ -75,27 +75,6 @@ public void UpdateTruncationBanner_StaysCollapsed_WhenTheProbeFoundNoFloorAtAll( }); } - /// WPF objects require STA; same shape as Lite.Tests' MainWindowAccessKeyTests / Dashboard.Tests' - /// DataGridExport tests. - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } - private static string ViewerFile(string file) => ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", file); [Theory] @@ -115,7 +94,7 @@ makes the same check on the MCP reader. */ [Fact] public void UpdateTruncationBanner_TableServed_NamesTheEffectiveStartAndTheBound_AndTheRawSlicerFloor() { - OnStaThread(() => + StaTestThread.Run(() => { var wideStart = RequestedStart.AddDays(1); var plan = new QueryStoreIntervalWide.WideReadPlan( @@ -151,7 +130,7 @@ public void UpdateTruncationBanner_TableServed_NamesTheEffectiveStartAndTheBound [Fact] public void UpdateTruncationBanner_HourlyServed_NamesTheGridsStart_AndRawsFloorAsTheSlicers() { - OnStaThread(() => + StaTestThread.Run(() => { const string Suffix = " - aggregated hourly"; var banner = new TextBlock(); diff --git a/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs b/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs index e7680367a9..ad8d7a10aa 100644 --- a/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs +++ b/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs @@ -155,27 +155,6 @@ public void FinOpsFindingsGrid_DetailAndSavingsHeaders_HaveATooltip(string app) } } - private static T OnStaThread(Func body) - { - T result = default!; - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { result = body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - if (error is not null) - { - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error).Throw(); - } - - return result; - } - private static PlanNode Scan() => new() { PhysicalOp = "Clustered Index Scan", @@ -193,7 +172,7 @@ public void PlanOperatorBox_ExposesItsTooltipSummaryToAScreenReader() { var node = Scan(); - var (name, help, isControl) = OnStaThread(() => + var (name, help, isControl) = StaTestThread.Run(() => { var box = new PlanNodeBorder(); System.Windows.Automation.AutomationProperties.SetName(box, PlanNodeBorder.OperatorName(node)); diff --git a/Darling/Darling.Tests/StaTestThread.cs b/Darling/Darling.Tests/StaTestThread.cs new file mode 100644 index 0000000000..41585d1229 --- /dev/null +++ b/Darling/Darling.Tests/StaTestThread.cs @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using Xunit; + +/// +/// #5602: every test that needs WPF runs its body on ONE shared STA thread per test process, instead of starting a thread of its +/// own. WPF keeps process-wide state that is not thread-safe (#5559: the DependencyPropertyDescriptor cache and its +/// unsynchronised AddValueChanged dictionary) and per-thread state that is expensive to leave behind (#5596: a weak-event +/// table and a dispatcher per thread, 300 ms each at process exit). Fifty test files each starting their own thread meant both. +/// +/// One body at a time, end to end. A body that pumps (Dispatcher.PushFrame, a modal loop) lets a second queued +/// Invoke run inside it, which is #5559 again. So the CALLER takes a process-wide gate before it queues its body and +/// releases it after the body and its drain have returned; the dispatcher never has two bodies queued. The wait for the gate is +/// not charged to the hang guard. +/// +/// +/// Drain. After each body the thread closes any window the body left open and runs the dispatcher to application-idle, +/// so work one test queued does not run during the next. An exception thrown by queued work (a timer tick, a Loaded handler) +/// does not end the thread and the process: it is handed to the test whose body or drain ran it. +/// +/// +/// Hang guard. A body that does not return within fails the calling test, naming it. The shared +/// thread is then stuck inside that body and cannot be freed, so every later WPF test in the process fails at once with a +/// message naming the stuck test, instead of each waiting out its own 60 s. +/// +/// +/// Tests that must own a thread (a dispatcher shutdown, a thread that has to END while the test looks at the process) stay on +/// their own thread and take around it, so they cannot overlap a body running here. The allow list +/// is in StaThreadCensusTests, with a reason per file. +/// +/// This file lives in Darling.Tests and is compiled into Lite.Tests by a link (one host, two suites). It is in the global +/// namespace so the Lite classes, which sit in a different namespace, need no extra using. +/// +internal static class StaTestThread +{ + private static readonly StaHost s_host = new(); + + /// How long a body (and its drain) may take before the calling test fails. Settable so a test can shorten it. + internal static TimeSpan HangGuard { get => s_host.HangGuard; set => s_host.HangGuard = value; } + + /// Runs on the shared STA thread and returns when it and its drain are done. Its exception comes back here with its own stack. + internal static void Run(Action body) => s_host.Run(body); + + /// Runs on the shared STA thread and returns its result. + internal static T Run(Func body) => s_host.Run(body); + + /// Runs an async on the shared STA thread: its continuations come back to that thread's dispatcher. + internal static void Run(Func body) => s_host.Run(body); + + /// + /// For a test that must own its thread: waits for the gate and returns the token that releases it. Not re-entrant, and not to + /// be called from a body already running in . + /// + internal static IDisposable EnterExclusive() => s_host.EnterExclusive(); +} + +/// +/// The machinery behind : one lazily started background STA thread running a dispatcher, and the gate. +/// The suites use the single instance behind ; the host's own tests make their own, so the hang guard +/// can be proven without leaving the shared thread stuck for the rest of the process. +/// +internal sealed class StaHost +{ + internal TimeSpan HangGuard { get; set; } = TimeSpan.FromSeconds(60); + + /* A SemaphoreSlim, not a Monitor: a test may be async, so the releasing thread is not promised to be the taking one. */ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _startLock = new(); + private readonly List _strays = new(); + private Dispatcher? _dispatcher; + private volatile string? _stuckTest; + + internal string? StuckTest => _stuckTest; + + /// The dispatcher of the host thread (starting the thread if it is not yet). For the host's own tests. + internal Dispatcher HostDispatcher => EnsureThread(); + + internal void Run(Action body) => RunCore(() => { WithoutSyncContext(body); return Task.CompletedTask; }); + + internal T Run(Func body) + { + T result = default!; + RunCore(() => { WithoutSyncContext(() => result = body()); return Task.CompletedTask; }); + return result; + } + + /// + /// A synchronous body runs with no synchronization context, as it did on a thread of its own that never ran a dispatcher. Inside the + /// dispatcher's own callback the current context is the dispatcher's, and a body that blocks on a task (.GetAwaiter().GetResult()) + /// whose awaits capture it would wait for the very thread it is blocking. A frame the body pushes installs its own context for the + /// frame's life and restores this one, so a pumping body is unaffected. + /// + private static void WithoutSyncContext(Action body) + { + var prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + body(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + } + + internal void Run(Func body) => RunCore(body); + + internal IDisposable EnterExclusive() + { + _gate.Wait(); + return new Releaser(_gate); + } + + /// Ends the host thread's dispatcher. Only for a host a test made for itself. + internal void Shutdown() => _dispatcher?.BeginInvokeShutdown(DispatcherPriority.Send); + + private void RunCore(Func body) + { + FailIfStuck(); + _gate.Wait(); + try + { + FailIfStuck(); + var dispatcher = EnsureThread(); + ExceptionDispatchInfo? error = null; + lock (_strays) + { + _strays.Clear(); + } + + var finished = dispatcher.InvokeAsync(() => Job(body, e => error = e)).Task.Unwrap(); + if (!finished.Wait(HangGuard)) + { + var name = CurrentTestName(); + _stuckTest = name; + throw new TimeoutException( + $"WPF test body did not return within {HangGuard.TotalSeconds:0} s: {name}. The shared STA test thread is stuck inside it, " + + "so every later WPF test in this process fails at once naming this test (#5602)."); + } + + error?.Throw(); + lock (_strays) + { + if (_strays.Count > 0) + { + var strays = _strays.ToArray(); + _strays.Clear(); + throw new AggregateException("Work the test queued on the dispatcher threw while the shared STA thread ran it (#5602):", strays); + } + } + } + finally + { + _gate.Release(); + } + } + + /// The body, then the cleanup. Never throws: the body's exception is handed back for the caller to rethrow with its stack. + private async Task Job(Func body, Action fail) + { + try + { + await body(); + } + catch (Exception ex) + { + fail(ExceptionDispatchInfo.Capture(ex)); + } + + try + { + CloseLeftoverWindows(); + await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); + } + catch (Exception ex) + { + fail(ExceptionDispatchInfo.Capture(ex)); + } + } + + /// A test that failed before its own Close() leaves a window (and its HWND) behind; the next test must not meet it. + private static void CloseLeftoverWindows() + { + foreach (PresentationSource source in PresentationSource.CurrentSources) + { + if (source.RootVisual is Window window && window.Dispatcher == Dispatcher.CurrentDispatcher) + { + window.Close(); + } + } + } + + private void FailIfStuck() + { + var stuck = _stuckTest; + if (stuck is not null) + { + throw new InvalidOperationException( + $"The shared WPF STA test thread is stuck inside a body that never returned: {stuck}. This test did not run (#5602)."); + } + } + + private static string CurrentTestName() => TestContext.Current.Test?.TestDisplayName ?? "(test name unavailable)"; + + private Dispatcher EnsureThread() + { + var existing = _dispatcher; + if (existing is not null) + { + return existing; + } + + lock (_startLock) + { + if (_dispatcher is not null) + { + return _dispatcher; + } + + using var ready = new ManualResetEventSlim(); + Dispatcher? made = null; + var thread = new Thread(() => + { + var dispatcher = Dispatcher.CurrentDispatcher; + dispatcher.UnhandledException += (_, e) => + { + lock (_strays) + { + _strays.Add(e.Exception); + } + + e.Handled = true; + }; + made = dispatcher; + ready.Set(); + Dispatcher.Run(); + }) + { + Name = "StaTestThread", + /* Background: a stuck body must not keep the test process alive after the run returns. */ + IsBackground = true, + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(); + _dispatcher = made; + return made!; + } + } + + private sealed class Releaser(SemaphoreSlim gate) : IDisposable + { + private int _released; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + gate.Release(); + } + } + } +} diff --git a/Darling/Darling.Tests/StaTestThreadTests.cs b/Darling/Darling.Tests/StaTestThreadTests.cs new file mode 100644 index 0000000000..1d88c8d9e8 --- /dev/null +++ b/Darling/Darling.Tests/StaTestThreadTests.cs @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5602: the shared STA test thread. Bodies run in order on one thread, an exception keeps its stack, a body that pumps does not +/// let a second one in, the hang guard fires and names the test, and the drain runs the work a body queued before the next body +/// starts. The hang-guard test uses a host of its own: it leaves that host's thread stuck, which the shared one must never be. +/// +public sealed class StaTestThreadTests +{ + [Fact] + public void Bodies_RunOneAfterAnother_OnTheSameBackgroundStaThread() + { + var first = StaTestThread.Run(Describe); + var second = StaTestThread.Run(Describe); + + Assert.Equal(first.Id, second.Id); + Assert.NotEqual(Environment.CurrentManagedThreadId, first.Id); + Assert.Equal(ApartmentState.STA, first.Apartment); + Assert.True(first.Background, "the shared thread must be a background thread, or a stuck body keeps the test process alive"); + } + + [Fact] + public void ABodysException_ComesBackToTheCaller_WithItsOwnStack() + { + var thrown = Assert.Throws(() => StaTestThread.Run(() => ThrowFromNamedFrame())); + + Assert.Equal("boom from the body", thrown.Message); + Assert.Contains(nameof(ThrowFromNamedFrame), thrown.StackTrace); + + /* The thread survives the failure: the next body still runs. */ + Assert.Equal(5, StaTestThread.Run(() => 5)); + } + + [Fact] + public void ABodyThatPumps_DoesNotLetASecondBodyIn() + { + var inside = 0; + var most = 0; + var errors = new List(); + var callers = Enumerable.Range(0, 3).Select(_ => new Thread(() => + { + try + { + for (var i = 0; i < 3; i++) + { + StaTestThread.Run(() => + { + var now = Interlocked.Increment(ref inside); + InterlockedMax(ref most, now); + + /* A nested frame: while it runs, anything else queued on this dispatcher would run inside it. */ + var frame = new DispatcherFrame(); + var timer = new DispatcherTimer(TimeSpan.FromMilliseconds(15), DispatcherPriority.Normal, (_, _) => frame.Continue = false, Dispatcher.CurrentDispatcher); + timer.Start(); + Dispatcher.PushFrame(frame); + timer.Stop(); + + Interlocked.Decrement(ref inside); + }); + } + } + catch (Exception ex) + { + lock (errors) + { + errors.Add(ex); + } + } + })).ToList(); + + callers.ForEach(t => t.Start()); + callers.ForEach(t => Assert.True(t.Join(TimeSpan.FromSeconds(60)), "a caller never finished")); + + Assert.Empty(errors); + Assert.Equal(1, most); + } + + [Fact] + public void ABodyThatNeverReturns_FailsItsOwnTest_ByName_AndEveryLaterCallFailsAtOnce() + { + var host = new StaHost { HangGuard = TimeSpan.FromMilliseconds(300) }; + using var release = new ManualResetEventSlim(); + try + { + var timedOut = Assert.Throws(() => host.Run(() => release.Wait())); + Assert.Contains(nameof(ABodyThatNeverReturns_FailsItsOwnTest_ByName_AndEveryLaterCallFailsAtOnce), timedOut.Message); + Assert.Contains(nameof(ABodyThatNeverReturns_FailsItsOwnTest_ByName_AndEveryLaterCallFailsAtOnce), host.StuckTest); + + var ran = false; + var started = Environment.TickCount64; + var refused = Assert.Throws(() => host.Run(() => ran = true)); + Assert.Contains(nameof(ABodyThatNeverReturns_FailsItsOwnTest_ByName_AndEveryLaterCallFailsAtOnce), refused.Message); + Assert.False(ran); + Assert.True(Environment.TickCount64 - started < 250, "a later call must fail at once, not wait out its own guard"); + } + finally + { + release.Set(); + host.Shutdown(); + } + } + + [Fact] + public void AfterABody_TheDispatcherIsDrained_AndAWindowItLeftOpenIsClosed() + { + var queuedRan = false; + Window? left = null; + StaTestThread.Run(() => + { + Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => queuedRan = true)); + left = new Window { Width = 50, Height = 50, ShowInTaskbar = false }; + left.Show(); + }); + + Assert.True(queuedRan, "work the body queued must run before the next test, not during it"); + Assert.False(StaTestThread.Run(() => left!.IsVisible), "a window the body left open must be closed by the drain"); + } + + [Fact] + public void AnAsyncBody_ResumesOnTheSharedThread_AfterEachAwait() + { + var ids = new List(); + StaTestThread.Run(async () => + { + ids.Add(Environment.CurrentManagedThreadId); + await Task.Delay(10); + ids.Add(Environment.CurrentManagedThreadId); + await Dispatcher.Yield(DispatcherPriority.Background); + ids.Add(Environment.CurrentManagedThreadId); + }); + + Assert.Single(ids.Distinct()); + Assert.NotEqual(Environment.CurrentManagedThreadId, ids[0]); + } + + [Fact] + public void ExceptionFromWorkTheBodyQueued_ComesBackToTheTest_AndDoesNotEndTheThread() + { + var thrown = Assert.Throws(() => StaTestThread.Run(() => + Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => throw new FormatException("queued work failed"))))); + + Assert.Contains(thrown.InnerExceptions, e => e is FormatException { Message: "queued work failed" }); + Assert.Equal(7, StaTestThread.Run(() => 7)); + } + + [Fact] + public async Task EnterExclusive_KeepsABodyOut_UntilItIsDisposed() + { + var ran = new ManualResetEventSlim(); + Task caller; + using (StaTestThread.EnterExclusive()) + { + caller = Task.Run(() => StaTestThread.Run(() => ran.Set())); + Assert.False(ran.Wait(TimeSpan.FromMilliseconds(200)), "a body ran while a thread that owns its own thread held the gate"); + } + + await caller.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + Assert.True(ran.IsSet); + } + + private static (int Id, ApartmentState Apartment, bool Background) Describe() => + (Environment.CurrentManagedThreadId, Thread.CurrentThread.GetApartmentState(), Thread.CurrentThread.IsBackground); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowFromNamedFrame() => throw new InvalidOperationException("boom from the body"); + + private static void InterlockedMax(ref int target, int value) + { + int seen; + while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen) + { + } + } +} diff --git a/Darling/Darling.Tests/StaThreadCensusTests.cs b/Darling/Darling.Tests/StaThreadCensusTests.cs new file mode 100644 index 0000000000..26541785d6 --- /dev/null +++ b/Darling/Darling.Tests/StaThreadCensusTests.cs @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5602: every test that needs WPF runs its body on the one shared STA thread (StaTestThread.Run), not on a thread of its +/// own. About fifty test files used to start their own STA thread behind a gate (#5559): each left a dispatcher and a weak-event +/// table behind (#5596's 300 ms per thread at process exit), and a body that pumped could still overlap another. A new test that +/// starts its own thread, defines its own OnStaThread-style helper, or runs Dispatcher.Run() brings that back, so +/// this fails and names the file and the rule. A test that really must own its thread goes on with the +/// reason, and must take StaTestThread.EnterExclusive() around the thread so it cannot overlap a body on the shared one. +/// +[Trait("Stage", "Guard")] +public sealed class StaThreadCensusTests +{ + /* Built from parts so this file does not contain the text it scans for. */ + private static readonly string s_sta = "ApartmentState" + ".STA"; + private static readonly string s_run = "Dispatcher" + ".Run("; + private const string Exclusive = "StaTestThread.EnterExclusive()"; + + /// + /// The host and its own tests start the thread by design. Every other entry is a test that must own a thread, with the reason. + /// + private static readonly Dictionary Allowed = new(StringComparer.Ordinal) + { + ["StaTestThread.cs"] = "the host: the one place the shared STA thread is started", + ["StaTestThreadTests.cs"] = "the host's own tests assert the thread's apartment state", + ["StaThreadCensusTests.cs"] = "this census names the text it scans for", + ["WpfExitSwitchTests.cs"] = "#5598: it needs a WPF STA thread that has ENDED, to time what the process-exit handler does for it; the shared " + + "thread never ends. It takes StaTestThread.EnterExclusive() so it cannot overlap a body on the shared thread.", + }; + + /// Anti-vacuity: the suite has well over this many files that use the host, so a scan that stopped seeing them fails. + private const int HostUserFloor = 20; + + /// A method named like the per-file helpers this replaced: OnStaThread, OnStaThread<T>, RunOnStaThread, OnDispatcher. + private static readonly Regex s_helperDefinition = new( + @"\bstatic\s+[\w<>\[\]?,\.\s()]+?\b(?:Run)?On(?:Sta(?:Thread)?|Dispatcher|UiThread)\s*(?:<\w+>)?\s*\(", RegexOptions.Compiled); + + [Fact] + public void NoTestFileOutsideTheAllowList_StartsItsOwnStaThread_DefinesAnStaHelper_OrRunsADispatcher() + { + var violations = new List(); + var hostUsers = 0; + foreach (var (rel, text) in TestFiles()) + { + if (text.Contains("StaTestThread.Run", StringComparison.Ordinal)) + { + hostUsers++; + } + + if (Allowed.ContainsKey(Path.GetFileName(rel))) + { + continue; + } + + if (text.Contains(s_sta, StringComparison.Ordinal)) + { + violations.Add(rel + ": starts its own STA thread; run the body in StaTestThread.Run(...) instead"); + } + + if (s_helperDefinition.IsMatch(StripComments(text))) + { + violations.Add(rel + ": defines its own OnStaThread-style helper; call StaTestThread.Run(...) directly"); + } + + if (text.Contains(s_run, StringComparison.Ordinal)) + { + violations.Add(rel + ": runs its own dispatcher; use the async overload StaTestThread.Run(async () => ...)"); + } + } + + Assert.True( + violations.Count == 0, + "WPF tests share ONE STA thread per test process (#5602). Fix, or add the file to StaThreadCensusTests.Allowed with the reason it must " + + "own a thread:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + Assert.True(hostUsers >= HostUserFloor, $"only {hostUsers} test files use StaTestThread.Run; the scan has stopped seeing them"); + } + + [Fact] + public void ATestThatOwnsItsThread_TakesTheExclusiveGateAroundIt() + { + var missing = new List(); + foreach (var (rel, text) in TestFiles()) + { + var name = Path.GetFileName(rel); + if (Allowed.ContainsKey(name) && name is not ("StaTestThread.cs" or "StaTestThreadTests.cs" or "StaThreadCensusTests.cs") + && !text.Contains(Exclusive, StringComparison.Ordinal)) + { + missing.Add(rel); + } + } + + Assert.True( + missing.Count == 0, + "These tests own their STA thread but do not call " + Exclusive + " first, so they can overlap a body on the shared thread (#5559's race): " + + string.Join(", ", missing)); + } + + [Fact] + public void EveryAllowListEntry_SaysWhy() + { + Assert.All(Allowed, entry => Assert.True(entry.Value.Length >= 20, entry.Key + " is on the allow list without a reason")); + } + + private static IEnumerable<(string Rel, string Text)> TestFiles() + { + var dir = ProjectDir(); + foreach (var file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(dir, file); + if (rel.StartsWith("bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || rel.StartsWith("obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + continue; + } + + yield return (rel, File.ReadAllText(file)); + } + } + + /// Drops // and /// comment lines, so a comment that names the old helper is not a definition. + private static string StripComments(string text) => + string.Join('\n', text.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal))); + + private static string ProjectDir([CallerFilePath] string thisFile = "") => Path.GetDirectoryName(thisFile)!; +} diff --git a/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs b/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs index eb9470d889..955ff4d97d 100644 --- a/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs +++ b/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs @@ -32,7 +32,7 @@ public sealed class StatementScrubViewerPlanWithheldTests [Fact] public void APlanWithheldWhole_ShowsTheWithheldSentenceInTheViewer_NotAParseError() { - OnStaThread(() => + StaTestThread.Run(() => { var control = new PlanViewerControl(); try @@ -150,22 +150,4 @@ public void SaveXmlToFile_RefusesAWithheldReportBeforeTheSaveDialog_AndTheViewer Assert.DoesNotContain("File.WriteAllText", export, StringComparison.Ordinal); } - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs b/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs index d40a631785..4fc8ab4524 100644 --- a/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs +++ b/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs @@ -457,7 +457,7 @@ public static async Task CreateAsync(int rangeEndsDaysAgo, CancellationTo await probe.WaitAsync(ct); string? text = null; - OnStaThread(() => + StaTestThread.Run(() => { ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; /* Seeded visible, so a no-op cannot pass as a hidden banner. */ @@ -629,22 +629,4 @@ public async ValueTask DisposeAsync() } /* WPF objects require STA; same shape as ViewerEventDataStartTests. */ - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs b/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs index bf0668e3dc..10ffd6207b 100644 --- a/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs @@ -398,7 +398,7 @@ await Assert.ThrowsAsync(() => ViewerServerTab.AwaitR private static string? ReadBanner(Action raise) { string? text = null; - OnStaThread(() => + StaTestThread.Run(() => { ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; var banner = new TextBlock { Visibility = Visibility.Visible, Text = "stale" }; @@ -411,22 +411,4 @@ await Assert.ThrowsAsync(() => ViewerServerTab.AwaitR } /* WPF objects require STA; same shape as ViewerQueryHeatmapDataStartTests. */ - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs b/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs index 8f7a04959b..87ba94bc73 100644 --- a/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs +++ b/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs @@ -73,48 +73,13 @@ private static List Runs(int count, DateTime first) => private static readonly string[] AllFour = ["health", "log", "chart", "caveats"]; - /// Runs on an STA thread with a dispatcher, as the tab's own load runs, and rethrows what it threw. - private static void OnDispatcher(Func body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - var dispatcher = Dispatcher.CurrentDispatcher; - dispatcher.BeginInvoke(new Action(async () => - { - try - { - ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; - await body(); - } - catch (Exception ex) - { - error = ex; - } - finally - { - dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); - } - })); - Dispatcher.Run(); - }) - { - IsBackground = true, - }; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - - if (!thread.Join(TimeSpan.FromSeconds(60))) + /// Runs on the shared STA thread, whose dispatcher resumes its awaits as the tab's own load does (#5602), with the viewer's display mode set to UTC. + private static void OnTabDispatcher(Func body) => + StaTestThread.Run(async () => { - throw new TimeoutException("the tab's step did not finish: it is waiting on something it should not wait for"); - } - - if (error is not null) - { - ExceptionDispatchInfo.Capture(error).Throw(); - } - } + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + await body(); + }); /* The chart's read fails (here a real read against a store nothing listens on): the wrapper answers an empty list and logs it, the join the load runs does not throw, and the grids, the caveats and the note all draw, with the chart drawn empty. The coverage @@ -131,7 +96,7 @@ public async Task AChartReadThatFails_BlanksOnlyTheChart_TheGridsTheNoteAndTheCa await Task.WhenAll(logTask, trendTask); var drawn = new Drawn(); - OnDispatcher(async () => + OnTabDispatcher(async () => { drawn.Start(); await Step(drawn, logTask.Result, trendTask.Result, Task.FromResult(RangeStart.AddDays(2))); @@ -161,7 +126,7 @@ public void TheChartAndTheCaveats_DrawBeforeTheNoteAwaitsItsProbe() var probe = new TaskCompletionSource(); var drawn = new Drawn(); - OnDispatcher(async () => + OnTabDispatcher(async () => { drawn.Start(); var step = Step(drawn, Runs(3, RangeStart.AddDays(3)), [], probe.Task); @@ -187,7 +152,7 @@ public void AFullPage_NamesItsOldestRun_WithoutWaitingForAProbeThatNeverAnswers( var probe = new TaskCompletionSource(); var drawn = new Drawn(); - OnDispatcher(async () => + OnTabDispatcher(async () => { drawn.Start(); var step = Step(drawn, Runs(ViewerDataService.CollectionLogRowCap, oldest), [], probe.Task); @@ -208,7 +173,7 @@ public void AFullPage_StillLogsAProbeThatFailsLater() var probe = new TaskCompletionSource(); var warned = new List(); - OnDispatcher(async () => + OnTabDispatcher(async () => { var drawn = new Drawn(); drawn.Start(); diff --git a/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs b/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs index c6bbe3bd29..4d1cd9958b 100644 --- a/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs +++ b/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs @@ -220,7 +220,7 @@ private async Task TabAsync(int serverId, DateTime startUtc, DateTime await probe.WaitAsync(ct); string? text = null; - DataStartBannerReadout.OnStaThread(() => + StaTestThread.Run(() => { ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; /* Seeded visible, so a no-op cannot pass as a hidden banner. */ @@ -243,39 +243,13 @@ private DrillAnswer Drill(int serverId, string collector, DateTime? asOfUtc) var viewer = _store.Viewer!; DrillAnswer? answer = null; - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => + StaTestThread.Run(async () => { - var dispatcher = Dispatcher.CurrentDispatcher; - dispatcher.BeginInvoke(new Action(async () => - { - try - { - ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; - var banner = new TextBlock { Visibility = Visibility.Visible, Text = "stale" }; - var rows = await CollectionLogWindow.ReadDrillAsync(viewer, serverId, collector, banner, asOfUtc); - answer = new DrillAnswer(rows, banner.Visibility == Visibility.Visible ? banner.Text : null); - } - catch (Exception ex) - { - error = ex; - } - finally - { - dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); - } - })); - Dispatcher.Run(); + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + var banner = new TextBlock { Visibility = Visibility.Visible, Text = "stale" }; + var rows = await CollectionLogWindow.ReadDrillAsync(viewer, serverId, collector, banner, asOfUtc); + answer = new DrillAnswer(rows, banner.Visibility == Visibility.Visible ? banner.Text : null); }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } return answer!; } diff --git a/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs b/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs index 109c4cd7a8..7929df183f 100644 --- a/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs +++ b/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs @@ -43,39 +43,25 @@ private static List TwentyBucketsWithOneBreak() => Enumerable.Range(0, 20).Select(k => new CollectorDurationBucket( "wait_stats", Start.AddMinutes(10 * k + (k >= 5 ? 60 : 0)), 100 + 3 * k, 50 + k, k + 1)).ToList(); - /// Runs on an STA thread in US English (the chart is a WPF control), and rethrows what it threw. - private static T OnStaThread(Func body) - { - T result = default!; - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => + /// Runs on the shared STA thread in US English (the chart is a WPF control), then puts the thread's culture back so the next body does not inherit it (#5602). + private static T InEnglish(Func body) => + StaTestThread.Run(() => { + var saved = CultureInfo.CurrentCulture; try { CultureInfo.CurrentCulture = English; - result = body(); + return body(); } - catch (Exception ex) + finally { - error = ex; + CultureInfo.CurrentCulture = saved; } }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - ExceptionDispatchInfo.Capture(error).Throw(); - } - - return result; - } /// The hover text over each of the line's twenty points in turn (the mouse exactly on the point), in bucket order. private static List HoverOverEachBucket(List buckets) => - OnStaThread(() => + InEnglish(() => { var chart = new ScottPlot.WPF.WpfPlot(); var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc); @@ -109,7 +95,7 @@ same strings through its own method (CollectorDurationDetailTextTests), so the t [InlineData(12L, 0.5, "Slowest of 12 runs; average 0.5 ms")] public void TheSharedText_SaysTheRunCountAndTheAverage_InTheseExactWords(long runs, double average, string expected) { - var text = OnStaThread(() => CollectorDurationHoverText.Detail(runs, average)); + var text = InEnglish(() => CollectorDurationHoverText.Detail(runs, average)); Assert.Equal(expected, text); } @@ -153,7 +139,7 @@ public void TheSeriesLines_AreTheSharedWords_OverEachBucketsOwnRunsAndAverage_Ke { var line = Assert.Single(CollectorDurationSeries.Build(TwentyBucketsWithOneBreak())); - var byX = OnStaThread(() => line.DetailsByX()); + var byX = InEnglish(() => line.DetailsByX()); Assert.Equal(20, byX.Count); for (var k = 0; k < 20; k++) @@ -188,7 +174,7 @@ public void ABucketAfterABreak_ShowsItsOwnRunsAndAverage_NotTheNextBucketsAndThe [Fact] public void TheLinesAreKeyedByX_SoEveryDrawnBucketFindsItsOwn_AndThePointTheBreakInsertsFindsNone() { - var (line, drawn) = OnStaThread(() => + var (line, drawn) = InEnglish(() => { var chart = new ScottPlot.WPF.WpfPlot(); var series = Assert.Single(CollectorDurationSeries.Build(TwentyBucketsWithOneBreak())); @@ -198,7 +184,7 @@ public void TheLinesAreKeyedByX_SoEveryDrawnBucketFindsItsOwn_AndThePointTheBrea return (series, scatter.Data.GetScatterPoints().ToList()); }); - var byX = OnStaThread(() => line.DetailsByX()); + var byX = InEnglish(() => line.DetailsByX()); Assert.Equal(20, byX.Count); Assert.Equal(21, drawn.Count); diff --git a/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs b/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs index 12dde354dc..523143d1a5 100644 --- a/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs @@ -274,7 +274,7 @@ internal static class DataStartBannerReadout TimeDisplayMode mode = TimeDisplayMode.UTC, int? serverOffsetMinutes = null) { string? text = null; - OnStaThread(() => + StaTestThread.Run(() => { var savedMode = ViewerTimeHelper.CurrentDisplayMode; var savedClock = ViewerTimeHelper.ActiveServerClock; @@ -330,23 +330,4 @@ public static string Since(DateTime utc, TimeDisplayMode mode = TimeDisplayMode. } } - /// Runs on an STA thread (WPF objects require one) and rethrows what it threw. - public static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ViewerDataStartBannerTests.cs b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs index f0b716aa1b..f28dc4fdd0 100644 --- a/Darling/Darling.Tests/ViewerDataStartBannerTests.cs +++ b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs @@ -246,7 +246,7 @@ and the failure is logged with the surface and the cause. */ [Fact] public void AProbeThatThrows_HidesTheBanner_IsLogged_AndLetsTheLoadGoOn() { - OnStaThread(() => + StaTestThread.Run(() => { var banner = new TextBlock { Visibility = Visibility.Visible, Text = "Showing since 2026-09-04 00:00:00" }; var logged = new List<(string Source, string Message)>(); @@ -268,7 +268,7 @@ public void AProbeThatThrows_HidesTheBanner_IsLogged_AndLetsTheLoadGoOn() [Fact] public void AProbeThatAnswers_PassesTheAnswerOn_AndLogsNothing() { - OnStaThread(() => + StaTestThread.Run(() => { var banner = new TextBlock(); var logged = new List<(string Source, string Message)>(); @@ -296,7 +296,7 @@ public void TheBanner_NamesItsInstants_OnTheInvariantCulture(string cultureName) try { ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; - OnStaThread(() => + StaTestThread.Run(() => { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); /* The premise: this culture's own formatting does not give the invariant text, so the pin is not vacuous. */ @@ -329,22 +329,4 @@ public void TheBanner_NamesItsInstants_OnTheInvariantCulture(string cultureName) } /* WPF objects require STA; same shape as RawWindowFloorViewerPortTests. */ - private static void OnStaThread(Action body) - { - Exception? error = null; - using var staGate = WpfStaGate.Enter(); - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs b/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs index 280d6767c7..c3fdf86448 100644 --- a/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs +++ b/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs @@ -49,7 +49,7 @@ public sealed class ViewerDisabledListBoxThemeTests [InlineData("CoolBreezeTheme.xaml")] public void DisabledListBox_RendersTheThemedBackgroundDimmed_NotStockWhite(string themeFile) { - OnStaThread(() => + StaTestThread.Run(() => { var list = new ListBox { IsEnabled = false }; list.SetResourceReference(Control.BackgroundProperty, "BackgroundDarkBrush"); @@ -65,7 +65,7 @@ public void DisabledListBox_RendersTheThemedBackgroundDimmed_NotStockWhite(strin [InlineData("CoolBreezeTheme.xaml")] public void DisabledServerList_AsShipped_RendersTheThemedBackgroundDimmed(string themeFile) { - OnStaThread(() => + StaTestThread.Run(() => { var list = ParseServerList(); list.IsEnabled = false; @@ -80,7 +80,7 @@ public void DisabledServerList_AsShipped_RendersTheThemedBackgroundDimmed(string [InlineData("CoolBreezeTheme.xaml")] public void EnabledListBox_RendersIdenticallyToTheStockTemplate(string themeFile) { - OnStaThread(() => + StaTestThread.Run(() => { var themeStyle = Assert.IsType