Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ await Assert.ThrowsAsync<InvalidOperationException>(() => ViewerServerTab.AwaitR
private static void OnStaThread(Action body)
{
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try { body(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerCollectionHealthLoadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ private static List<CollectionLogRow> Runs(int count, DateTime first) =>
private static void OnDispatcher(Func<Task> body)
{
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
var dispatcher = Dispatcher.CurrentDispatcher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerCollectorDurationHoverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerConfigChangesDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerDataStartBannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerEventDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try { result = body(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerScreenReaderNamesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try { result = body(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerScreenReaderRowNamesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try { result = body(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/ViewerStatsRowBlankCellTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
52 changes: 52 additions & 0 deletions Darling/Darling.Tests/WpfStaGate.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #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: <c>DependencyPropertyDescriptor.FromProperty</c> hands every caller the same descriptor out of a static
/// cache, and that descriptor's <c>AddValueChanged</c> writes an unsynchronised per-descriptor <c>Dictionary</c>. 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 <c>AccessibleNames.ActivateCell</c> at once corrupted that dictionary
/// ("Operations that change non-concurrent collections must have exclusive access") in CI, a different class each time.
/// <para>
/// Every STA helper calls <c>using var staGate = WpfStaGate.Enter();</c> 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 <c>Join</c> honest: the wait for the gate is not charged to it.
/// <c>WpfStaGateCensusTests</c> fails when a test file starts an STA thread without calling <see cref="Enter"/>.
/// </para>
/// 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 <c>using</c>.
/// </summary>
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);

/// <summary>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.</summary>
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();
}
}
}
}
114 changes: 114 additions & 0 deletions Darling/Darling.Tests/WpfStaGateCensusTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #5559: every Darling.Tests test that starts its own STA thread takes <see cref="WpfStaGate"/> first. WPF's
/// <c>DependencyPropertyDescriptor</c> cache hands every STA thread in the process the same descriptor, and that descriptor's
/// <c>AddValueChanged</c> 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.
/// </summary>
[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()";

/// <summary>Anti-vacuity: the suite has well over this many STA files, so a scan that stopped seeing them fails.</summary>
private const int StaFileFloor = 20;

[Fact]
public void EveryStaThreadStart_IsPrecededByItsOwnGateEntry()
{
var missing = new List<string>();
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)!;
}
1 change: 1 addition & 0 deletions Lite.Tests/AlertHistoryCountFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down
1 change: 1 addition & 0 deletions Lite.Tests/AvailabilityGroupsTabRefreshTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ private static List<AgTopologyCard> 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(); }
Expand Down
1 change: 1 addition & 0 deletions Lite.Tests/BlockingChartsDataStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
using var staGate = WpfStaGate.Enter();
var thread = new Thread(() =>
{
try { result = body(); }
Expand Down
Loading
Loading