diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da47ea2303..e1308f6926 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2610,6 +2610,7 @@ jobs: - 'Darling/Darling.Tests/McpArgumentTypeRows.cs' - 'Darling/Darling.Tests/StatementScrubCanary.cs' - 'Darling/Darling.Tests/GuardStageScanner.cs' + - 'Darling/Darling.Tests/WpfStaGate.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 fcda327969..902fc182e2 100644 --- a/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs +++ b/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs @@ -257,6 +257,7 @@ private static AgTopologyDatabaseRow Database(int serverId, string serverName, s private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs index 07ab7fcb17..e0d3d665f2 100644 --- a/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs +++ b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs @@ -129,6 +129,7 @@ private static (TextBlock Label, TextBlock Value) RowOf(Grid grid, string label) private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs index 2484e7574a..c12aa5c229 100644 --- a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs +++ b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs @@ -80,6 +80,7 @@ public void UpdateTruncationBanner_StaysCollapsed_WhenTheProbeFoundNoFloorAtAll( private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs b/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs index 4c63ed976c..eb9470d889 100644 --- a/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs +++ b/Darling/Darling.Tests/StatementScrubViewerPlanWithheldTests.cs @@ -153,6 +153,7 @@ public void SaveXmlToFile_RefusesAWithheldReportBeforeTheSaveDialog_AndTheViewer private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs b/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs index 128119e611..d40a631785 100644 --- a/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs +++ b/Darling/Darling.Tests/ViewerBlockedProcessReportsCoverageLiveTests.cs @@ -632,6 +632,7 @@ public async ValueTask DisposeAsync() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs b/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs index 48d7e34dcb..bf0668e3dc 100644 --- a/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerClutterAndMemoryPressureDataStartTests.cs @@ -414,6 +414,7 @@ await Assert.ThrowsAsync(() => ViewerServerTab.AwaitR private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs b/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs index 6ef1b6c785..8f7a04959b 100644 --- a/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs +++ b/Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs @@ -77,6 +77,7 @@ private static List Runs(int count, DateTime first) => private static void OnDispatcher(Func body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { var dispatcher = Dispatcher.CurrentDispatcher; diff --git a/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs b/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs index 8a87ea3adf..c6bbe3bd29 100644 --- a/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs +++ b/Darling/Darling.Tests/ViewerCollectionLogDataStartLiveTests.cs @@ -244,6 +244,7 @@ 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(() => { var dispatcher = Dispatcher.CurrentDispatcher; diff --git a/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs b/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs index 5610535441..109c4cd7a8 100644 --- a/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs +++ b/Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs @@ -48,6 +48,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try diff --git a/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs b/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs index f1ed7d313c..12dde354dc 100644 --- a/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs @@ -334,6 +334,7 @@ public static string Since(DateTime utc, TimeDisplayMode mode = TimeDisplayMode. public static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerDataStartBannerTests.cs b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs index afdeb821fe..f0b716aa1b 100644 --- a/Darling/Darling.Tests/ViewerDataStartBannerTests.cs +++ b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs @@ -332,6 +332,7 @@ public void TheBanner_NamesItsInstants_OnTheInvariantCulture(string cultureName) private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs b/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs index 4a43a1eecd..280d6767c7 100644 --- a/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs +++ b/Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs @@ -194,6 +194,7 @@ private static ListBox ParseServerList() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerEventDataStartTests.cs b/Darling/Darling.Tests/ViewerEventDataStartTests.cs index 04b86763a4..6f20dc297c 100644 --- a/Darling/Darling.Tests/ViewerEventDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerEventDataStartTests.cs @@ -454,6 +454,7 @@ public void TheProbes_GoThroughTheSharedFloor_OverTheCollectorTheGridIsNamedFor( private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs b/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs index 295a695e7f..66aa875ff2 100644 --- a/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs +++ b/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs @@ -287,6 +287,7 @@ public async ValueTask DisposeAsync() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs b/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs index 6bd8b51faa..ba340e3b2a 100644 --- a/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs @@ -282,6 +282,7 @@ public void AProbeThatThrows_ShowsNoNote_AndTheStepDoesNotThrow() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs b/Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs index 75cda4a675..a6abd5bd58 100644 --- a/Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs @@ -185,6 +185,7 @@ public void AFullPage_RaisesTheNotice_AtItsOldestRow_AndOneRowFewerRaisesNone() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerPlanCorrectionsDataStartTests.cs b/Darling/Darling.Tests/ViewerPlanCorrectionsDataStartTests.cs index 1273ee3ac0..e57bf73956 100644 --- a/Darling/Darling.Tests/ViewerPlanCorrectionsDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerPlanCorrectionsDataStartTests.cs @@ -183,6 +183,7 @@ public void AFullPage_RaisesTheNotice_AtItsOldestRow_AndOneRowFewerRaisesNone() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerQueryGridsDataStartLiveTests.cs b/Darling/Darling.Tests/ViewerQueryGridsDataStartLiveTests.cs index 154b4b4ae8..2d1d888be6 100644 --- a/Darling/Darling.Tests/ViewerQueryGridsDataStartLiveTests.cs +++ b/Darling/Darling.Tests/ViewerQueryGridsDataStartLiveTests.cs @@ -547,6 +547,7 @@ public static DateTime NowToTheMinute() { string? text = null; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { var savedMode = ViewerTimeHelper.CurrentDisplayMode; diff --git a/Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs b/Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs index 0375b92fca..969bf554dc 100644 --- a/Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs @@ -175,6 +175,7 @@ public void AnEmptyChart_NamesTheCoverage_AndAFailedProbeNamesNothing() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerQueryStoreRegressionsDataStartTests.cs b/Darling/Darling.Tests/ViewerQueryStoreRegressionsDataStartTests.cs index f43c62be28..60a3250874 100644 --- a/Darling/Darling.Tests/ViewerQueryStoreRegressionsDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerQueryStoreRegressionsDataStartTests.cs @@ -197,6 +197,7 @@ public void AProbeThatThrows_HidesTheBanner_InsteadOfThrowing() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerScreenReaderNamesFollowUpTests.cs b/Darling/Darling.Tests/ViewerScreenReaderNamesFollowUpTests.cs index 3fb0fe6638..8e6a4488a4 100644 --- a/Darling/Darling.Tests/ViewerScreenReaderNamesFollowUpTests.cs +++ b/Darling/Darling.Tests/ViewerScreenReaderNamesFollowUpTests.cs @@ -33,6 +33,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Darling/Darling.Tests/ViewerScreenReaderNamesTests.cs b/Darling/Darling.Tests/ViewerScreenReaderNamesTests.cs index 343c1b8ec2..f4fdf9cc41 100644 --- a/Darling/Darling.Tests/ViewerScreenReaderNamesTests.cs +++ b/Darling/Darling.Tests/ViewerScreenReaderNamesTests.cs @@ -36,6 +36,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Darling/Darling.Tests/ViewerScreenReaderRowNamesTests.cs b/Darling/Darling.Tests/ViewerScreenReaderRowNamesTests.cs index df970af7d5..94f77c5620 100644 --- a/Darling/Darling.Tests/ViewerScreenReaderRowNamesTests.cs +++ b/Darling/Darling.Tests/ViewerScreenReaderRowNamesTests.cs @@ -31,6 +31,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs b/Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs index 51c430a0ae..17d89db876 100644 --- a/Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs @@ -249,6 +249,7 @@ public void EveryQueriesTabFloorRead_GoesThroughTheViewersMethod_NeverTheSharedP private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/ViewerStatsRowBlankCellTests.cs b/Darling/Darling.Tests/ViewerStatsRowBlankCellTests.cs index d5c4a4cb23..232bb21e85 100644 --- a/Darling/Darling.Tests/ViewerStatsRowBlankCellTests.cs +++ b/Darling/Darling.Tests/ViewerStatsRowBlankCellTests.cs @@ -151,6 +151,7 @@ private static string CellText(object row, string path) private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Darling/Darling.Tests/WpfStaGate.cs b/Darling/Darling.Tests/WpfStaGate.cs new file mode 100644 index 0000000000..e03e81b526 --- /dev/null +++ b/Darling/Darling.Tests/WpfStaGate.cs @@ -0,0 +1,52 @@ +/* + * 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.Threading; + +/// +/// #5559: one gate for every test that builds WPF elements on its own STA thread. WPF keeps process-wide state that is +/// not thread-safe: DependencyPropertyDescriptor.FromProperty hands every caller the same descriptor out of a static +/// cache, and that descriptor's AddValueChanged writes an unsynchronised per-descriptor Dictionary. The app has +/// one UI thread, so the product never races. xUnit runs test classes in parallel and about fifty of them start their own +/// STA thread, so two of them reaching AccessibleNames.ActivateCell at once corrupted that dictionary +/// ("Operations that change non-concurrent collections must have exclusive access") in CI, a different class each time. +/// +/// Every STA helper calls using var staGate = WpfStaGate.Enter(); on the CALLING thread, before it creates the +/// thread, and keeps it until it has joined the thread. Only one STA test body then runs at a time. Taking the gate on the +/// calling thread, not inside the new thread, keeps a timed Join honest: the wait for the gate is not charged to it. +/// WpfStaGateCensusTests fails when a test file starts an STA thread without calling . +/// +/// This file lives in Darling.Tests and is compiled into Lite.Tests by a link (one gate, 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 WpfStaGate +{ + /* A SemaphoreSlim, not a Monitor: the holder may be released from a different thread than took it if a helper is async. */ + private static readonly SemaphoreSlim s_gate = new(1, 1); + + /// Waits for the gate and returns the token that releases it. Not re-entrant: do not call it from inside a body that already holds it. + internal static IDisposable Enter() + { + s_gate.Wait(); + return new Releaser(); + } + + private sealed class Releaser : IDisposable + { + private int _released; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + s_gate.Release(); + } + } + } +} diff --git a/Darling/Darling.Tests/WpfStaGateCensusTests.cs b/Darling/Darling.Tests/WpfStaGateCensusTests.cs new file mode 100644 index 0000000000..9dfd972fd8 --- /dev/null +++ b/Darling/Darling.Tests/WpfStaGateCensusTests.cs @@ -0,0 +1,114 @@ +/* + * 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 System.Threading; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5559: every Darling.Tests test that starts its own STA thread takes first. WPF's +/// DependencyPropertyDescriptor cache hands every STA thread in the process the same descriptor, and that descriptor's +/// AddValueChanged writes an unsynchronised dictionary, so two test classes in parallel corrupted it in CI. A new +/// test file that starts an STA thread without the gate brings the flake back, so this fails and names the file. +/// +[Trait("Stage", "Guard")] +public sealed class WpfStaGateCensusTests +{ + /* Built from parts so this file does not contain the text it scans for. */ + private static readonly string s_sta = "ApartmentState" + ".STA"; + private const string Gate = "WpfStaGate.Enter()"; + + /// Anti-vacuity: the suite has well over this many STA files, so a scan that stopped seeing them fails. + private const int StaFileFloor = 20; + + [Fact] + public void EveryStaThreadStart_IsPrecededByItsOwnGateEntry() + { + var missing = new List(); + var staFiles = 0; + foreach (var file in Directory.EnumerateFiles(ProjectDir(), "*.cs", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(ProjectDir(), file); + if (rel.StartsWith("bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || rel.StartsWith("obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.GetFileName(file) is "WpfStaGate.cs" or "WpfStaGateCensusTests.cs") + { + continue; + } + + var text = File.ReadAllText(file); + if (!text.Contains(s_sta, StringComparison.Ordinal)) + { + continue; + } + + staFiles++; + + /* Each STA start needs its OWN gate entry after the previous start: Enter, thread, STA, Enter, thread, STA. */ + var starts = Regex.Matches(text, Regex.Escape(s_sta)).Select(m => m.Index).ToList(); + var gates = Regex.Matches(text, Regex.Escape(Gate)).Select(m => m.Index).ToList(); + var previous = -1; + foreach (var start in starts) + { + if (!gates.Any(g => g > previous && g < start)) + { + missing.Add(rel); + break; + } + + previous = start; + } + } + + Assert.True( + missing.Count == 0, + "These test files start an STA thread without taking WpfStaGate first (put `using var staGate = WpfStaGate.Enter();` " + + "before the helper creates the thread, #5559): " + string.Join(", ", missing)); + Assert.True(staFiles >= StaFileFloor, $"only {staFiles} STA test files were found; the scan has stopped seeing them"); + } + + [Fact] + public void TheGate_LetsOnlyOneHolderIn_AtATime() + { + var inside = 0; + var most = 0; + var threads = Enumerable.Range(0, 3).Select(_ => new Thread(() => + { + for (var i = 0; i < 10; i++) + { + using var staGate = WpfStaGate.Enter(); + var now = Interlocked.Increment(ref inside); + InterlockedMax(ref most, now); + Thread.Sleep(1); + Interlocked.Decrement(ref inside); + } + })).ToList(); + + threads.ForEach(t => t.Start()); + threads.ForEach(t => Assert.True(t.Join(TimeSpan.FromSeconds(30)), "a gate holder never finished")); + + Assert.Equal(1, most); + } + + 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) + { + } + } + + private static string ProjectDir([CallerFilePath] string thisFile = "") => Path.GetDirectoryName(thisFile)!; +} diff --git a/Lite.Tests/AlertHistoryCountFilterTests.cs b/Lite.Tests/AlertHistoryCountFilterTests.cs index d1563b8553..9895ee033c 100644 --- a/Lite.Tests/AlertHistoryCountFilterTests.cs +++ b/Lite.Tests/AlertHistoryCountFilterTests.cs @@ -52,6 +52,7 @@ public void AColumnFilter_RebuildsTheCountTextFromWhatTheGridNowShows_AndKeepsTh private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Lite.Tests/AvailabilityGroupsTabRefreshTests.cs b/Lite.Tests/AvailabilityGroupsTabRefreshTests.cs index ebf0977925..a1947c7260 100644 --- a/Lite.Tests/AvailabilityGroupsTabRefreshTests.cs +++ b/Lite.Tests/AvailabilityGroupsTabRefreshTests.cs @@ -193,6 +193,7 @@ private static List BuildTopology(int agCount, int replicasPerAg private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Lite.Tests/BlockingChartsDataStartTests.cs b/Lite.Tests/BlockingChartsDataStartTests.cs index 9faf3ab8ef..6636692a2c 100644 --- a/Lite.Tests/BlockingChartsDataStartTests.cs +++ b/Lite.Tests/BlockingChartsDataStartTests.cs @@ -527,6 +527,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/CollectionLogDrillDataStartTests.cs b/Lite.Tests/CollectionLogDrillDataStartTests.cs index 21ae723631..756ec4a971 100644 --- a/Lite.Tests/CollectionLogDrillDataStartTests.cs +++ b/Lite.Tests/CollectionLogDrillDataStartTests.cs @@ -405,6 +405,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/CollectorDurationHoverTests.cs b/Lite.Tests/CollectorDurationHoverTests.cs index d95c873e7d..910ce1a93e 100644 --- a/Lite.Tests/CollectorDurationHoverTests.cs +++ b/Lite.Tests/CollectorDurationHoverTests.cs @@ -226,6 +226,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/CpuHoverStoredWallClockTests.cs b/Lite.Tests/CpuHoverStoredWallClockTests.cs index c944719b9c..d1ae07cce3 100644 --- a/Lite.Tests/CpuHoverStoredWallClockTests.cs +++ b/Lite.Tests/CpuHoverStoredWallClockTests.cs @@ -427,6 +427,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/CustomTabHeaderInkTests.cs b/Lite.Tests/CustomTabHeaderInkTests.cs index f1ef736e6d..da8e733793 100644 --- a/Lite.Tests/CustomTabHeaderInkTests.cs +++ b/Lite.Tests/CustomTabHeaderInkTests.cs @@ -181,6 +181,7 @@ public void ViewerCollectorHealth_UsesLiveReferences_NoOneTimeCopy() private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Lite.Tests/DataStartBannerBlockingEventsTests.cs b/Lite.Tests/DataStartBannerBlockingEventsTests.cs index 0384d350dc..80405fb1e6 100644 --- a/Lite.Tests/DataStartBannerBlockingEventsTests.cs +++ b/Lite.Tests/DataStartBannerBlockingEventsTests.cs @@ -312,6 +312,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DataStartBannerLatchSpinlockTests.cs b/Lite.Tests/DataStartBannerLatchSpinlockTests.cs index 7ada5cf774..3a9e51ef52 100644 --- a/Lite.Tests/DataStartBannerLatchSpinlockTests.cs +++ b/Lite.Tests/DataStartBannerLatchSpinlockTests.cs @@ -175,6 +175,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DataStartBannerProbeFailureTests.cs b/Lite.Tests/DataStartBannerProbeFailureTests.cs index 4f87f98d93..d2c5189124 100644 --- a/Lite.Tests/DataStartBannerProbeFailureTests.cs +++ b/Lite.Tests/DataStartBannerProbeFailureTests.cs @@ -170,6 +170,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DataStartBannerQueriesTabTests.cs b/Lite.Tests/DataStartBannerQueriesTabTests.cs index c3091390ee..7dbc1bbebc 100644 --- a/Lite.Tests/DataStartBannerQueriesTabTests.cs +++ b/Lite.Tests/DataStartBannerQueriesTabTests.cs @@ -1294,6 +1294,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs index d55ad23bca..16c7bc050e 100644 --- a/Lite.Tests/DataStartBannerSurfaceTests.cs +++ b/Lite.Tests/DataStartBannerSurfaceTests.cs @@ -1389,6 +1389,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DataStartBannerTests.cs b/Lite.Tests/DataStartBannerTests.cs index 08d55983b8..ed5d2bdb82 100644 --- a/Lite.Tests/DataStartBannerTests.cs +++ b/Lite.Tests/DataStartBannerTests.cs @@ -393,6 +393,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/DisplayZoneRendererTests.cs b/Lite.Tests/DisplayZoneRendererTests.cs index db3260eca7..7cba1f1f09 100644 --- a/Lite.Tests/DisplayZoneRendererTests.cs +++ b/Lite.Tests/DisplayZoneRendererTests.cs @@ -33,6 +33,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/JobHistoryDataStartTests.cs b/Lite.Tests/JobHistoryDataStartTests.cs index 9f928f5e51..727795b9b4 100644 --- a/Lite.Tests/JobHistoryDataStartTests.cs +++ b/Lite.Tests/JobHistoryDataStartTests.cs @@ -639,6 +639,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 6753fc6ed0..6134f2a376 100644 --- a/Lite.Tests/Lite.Tests.csproj +++ b/Lite.Tests/Lite.Tests.csproj @@ -50,6 +50,8 @@ + + diff --git a/Lite.Tests/MainWindowAccessKeyTests.cs b/Lite.Tests/MainWindowAccessKeyTests.cs index 9b5cb5bffa..7e5fd1ff2c 100644 --- a/Lite.Tests/MainWindowAccessKeyTests.cs +++ b/Lite.Tests/MainWindowAccessKeyTests.cs @@ -287,6 +287,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs b/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs index e949d279ed..1271e81ff0 100644 --- a/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs +++ b/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs @@ -355,6 +355,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/PlanViewer4632FallbackTests.cs b/Lite.Tests/PlanViewer4632FallbackTests.cs index 28bb9d97e2..1c438988e4 100644 --- a/Lite.Tests/PlanViewer4632FallbackTests.cs +++ b/Lite.Tests/PlanViewer4632FallbackTests.cs @@ -164,6 +164,7 @@ private static SolidColorBrush CriticalOrangeBrushOf(PlanViewerControl control) private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs index 947a2fca01..5d8221472a 100644 --- a/Lite.Tests/QueryWindowTruncationTests.cs +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -610,6 +610,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/ScreenReaderNamesFollowUpTests.cs b/Lite.Tests/ScreenReaderNamesFollowUpTests.cs index d03e34d3bb..30d3ab802e 100644 --- a/Lite.Tests/ScreenReaderNamesFollowUpTests.cs +++ b/Lite.Tests/ScreenReaderNamesFollowUpTests.cs @@ -34,6 +34,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/ScreenReaderNamesTests.cs b/Lite.Tests/ScreenReaderNamesTests.cs index 5a32453b9d..6a62235f7b 100644 --- a/Lite.Tests/ScreenReaderNamesTests.cs +++ b/Lite.Tests/ScreenReaderNamesTests.cs @@ -36,6 +36,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/ScreenReaderRowNamesTests.cs b/Lite.Tests/ScreenReaderRowNamesTests.cs index be89a32ce5..616eeb162f 100644 --- a/Lite.Tests/ScreenReaderRowNamesTests.cs +++ b/Lite.Tests/ScreenReaderRowNamesTests.cs @@ -32,6 +32,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/ServerTabEmptyStateAndNamesTests.cs b/Lite.Tests/ServerTabEmptyStateAndNamesTests.cs index 68839582f3..e9c727a0ef 100644 --- a/Lite.Tests/ServerTabEmptyStateAndNamesTests.cs +++ b/Lite.Tests/ServerTabEmptyStateAndNamesTests.cs @@ -31,6 +31,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/StandalonePlanViewerPlusTabTests.cs b/Lite.Tests/StandalonePlanViewerPlusTabTests.cs index a38b1ff3df..b4830ad9be 100644 --- a/Lite.Tests/StandalonePlanViewerPlusTabTests.cs +++ b/Lite.Tests/StandalonePlanViewerPlusTabTests.cs @@ -287,6 +287,7 @@ public void UiaSelect(TabItem tab) private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try diff --git a/Lite.Tests/StatementScrubLivePlanDisplayTests.cs b/Lite.Tests/StatementScrubLivePlanDisplayTests.cs index e6698a7d4d..97c7f036e5 100644 --- a/Lite.Tests/StatementScrubLivePlanDisplayTests.cs +++ b/Lite.Tests/StatementScrubLivePlanDisplayTests.cs @@ -265,6 +265,7 @@ public void SaveXmlToFile_RefusesAWithheldReportBeforeTheSaveDialog_AndLitesDown private static void OnStaThread(Action body) { Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { body(); } diff --git a/Lite.Tests/ThemeColorOverrideTests.cs b/Lite.Tests/ThemeColorOverrideTests.cs index aae6dd81ef..7741348822 100644 --- a/Lite.Tests/ThemeColorOverrideTests.cs +++ b/Lite.Tests/ThemeColorOverrideTests.cs @@ -999,6 +999,7 @@ private static T OnStaThread(Func body) { T result = default!; Exception? error = null; + using var staGate = WpfStaGate.Enter(); var thread = new Thread(() => { try { result = body(); } diff --git a/Lite.Tests/WpfStaGateCensusTests.cs b/Lite.Tests/WpfStaGateCensusTests.cs new file mode 100644 index 0000000000..e1c268f9db --- /dev/null +++ b/Lite.Tests/WpfStaGateCensusTests.cs @@ -0,0 +1,114 @@ +/* + * 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 System.Threading; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #5559: every Lite.Tests test that starts its own STA thread takes first. WPF's +/// DependencyPropertyDescriptor cache hands every STA thread in the process the same descriptor, and that descriptor's +/// AddValueChanged writes an unsynchronised dictionary, so two test classes in parallel corrupted it in CI. A new +/// test file that starts an STA thread without the gate brings the flake back, so this fails and names the file. +/// +[Trait("Stage", "Guard")] +public sealed class WpfStaGateCensusTests +{ + /* Built from parts so this file does not contain the text it scans for. */ + private static readonly string s_sta = "ApartmentState" + ".STA"; + private const string Gate = "WpfStaGate.Enter()"; + + /// Anti-vacuity: the suite has well over this many STA files, so a scan that stopped seeing them fails. + private const int StaFileFloor = 20; + + [Fact] + public void EveryStaThreadStart_IsPrecededByItsOwnGateEntry() + { + var missing = new List(); + var staFiles = 0; + foreach (var file in Directory.EnumerateFiles(ProjectDir(), "*.cs", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(ProjectDir(), file); + if (rel.StartsWith("bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || rel.StartsWith("obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.GetFileName(file) is "WpfStaGate.cs" or "WpfStaGateCensusTests.cs") + { + continue; + } + + var text = File.ReadAllText(file); + if (!text.Contains(s_sta, StringComparison.Ordinal)) + { + continue; + } + + staFiles++; + + /* Each STA start needs its OWN gate entry after the previous start: Enter, thread, STA, Enter, thread, STA. */ + var starts = Regex.Matches(text, Regex.Escape(s_sta)).Select(m => m.Index).ToList(); + var gates = Regex.Matches(text, Regex.Escape(Gate)).Select(m => m.Index).ToList(); + var previous = -1; + foreach (var start in starts) + { + if (!gates.Any(g => g > previous && g < start)) + { + missing.Add(rel); + break; + } + + previous = start; + } + } + + Assert.True( + missing.Count == 0, + "These test files start an STA thread without taking WpfStaGate first (put `using var staGate = WpfStaGate.Enter();` " + + "before the helper creates the thread, #5559): " + string.Join(", ", missing)); + Assert.True(staFiles >= StaFileFloor, $"only {staFiles} STA test files were found; the scan has stopped seeing them"); + } + + [Fact] + public void TheGate_LetsOnlyOneHolderIn_AtATime() + { + var inside = 0; + var most = 0; + var threads = Enumerable.Range(0, 3).Select(_ => new Thread(() => + { + for (var i = 0; i < 10; i++) + { + using var staGate = WpfStaGate.Enter(); + var now = Interlocked.Increment(ref inside); + InterlockedMax(ref most, now); + Thread.Sleep(1); + Interlocked.Decrement(ref inside); + } + })).ToList(); + + threads.ForEach(t => t.Start()); + threads.ForEach(t => Assert.True(t.Join(TimeSpan.FromSeconds(30)), "a gate holder never finished")); + + Assert.Equal(1, most); + } + + 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) + { + } + } + + private static string ProjectDir([CallerFilePath] string thisFile = "") => Path.GetDirectoryName(thisFile)!; +}