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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,8 @@ jobs:
- 'Lite/**/*.csproj'
# #4966: Darling.Tests' CommentFilterAdoptionTests reads this Lite.Tests file, so an edit to it has to reach the Darling suite.
- 'Lite.Tests/DataStartBannerQueriesTabTests.cs'
# #5596: Darling.Tests' WpfExitSwitchPinTests reads this project file for the WPF exit switch, so an edit to it has to reach the Darling suite.
- 'Lite.Tests/Lite.Tests.csproj'
# #5459 cut 3a: the part of `darling` that is NOT a Lite file. A diff that reaches `darling` through
# these (or core, root, a release) runs the whole Darling suite; a diff that reaches it only through the
# Lite entries above runs the Stage=Guard classes and the classes tagged Reads=Lite, which are the ones
Expand Down Expand Up @@ -2611,6 +2613,7 @@ jobs:
- 'Darling/Darling.Tests/StatementScrubCanary.cs'
- 'Darling/Darling.Tests/GuardStageScanner.cs'
- 'Darling/Darling.Tests/WpfStaGate.cs'
- 'Darling/Darling.Tests/WpfExitSwitchTests.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
12 changes: 12 additions & 0 deletions Darling/Darling.Tests/Darling.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@
<NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CS4014;NU1701;CA1001;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;xUnit1051</NoWarn>
</PropertyGroup>

<ItemGroup>
<!-- #5596: WPF builds one WeakEventTable per STA thread that touches a WeakEventManager, and each table
registers a ProcessExit handler. At exit that handler runs on the main thread, and when the table's
own thread is gone it asks the dead dispatcher to purge the table with a 300 ms timeout nobody ever
answers: about 0.3 seconds per STA test thread, spent after the run has returned. About fifty test files
start their own STA thread, so the exit stage grew past the 11 seconds xUnit v3 gives a process after the
run returns, and the runner printed "Foreground threads were left running" with exit code 1 and 0 failed
tests. This WPF switch (WindowsBase, BaseAppContextSwitches) makes the handler purge the table directly.
WpfExitSwitchTests pins that the switch is set, and that the runtime still knows its name. -->
<RuntimeHostConfigurationOption Include="Switch.MS.Internal.DoNotInvokeInWeakEventTableShutdownListener" Value="true" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="xunit.v3" />
<!-- #4128: a live HTTP proof of the web host's gates (Host-allowlist, network-mode token/CIDR) through
Expand Down
50 changes: 50 additions & 0 deletions Darling/Darling.Tests/WpfExitSwitchPinTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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.IO;
using System.Runtime.CompilerServices;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #5596: both test projects start WPF STA threads, so both declare the WPF switch that keeps the process exit short
/// (see <see cref="WpfExitSwitchTests"/>). A project that drops it, or a new WPF test project that never had it, goes back
/// to paying about 0.3 seconds per STA test thread after the run returns, and the runner then fails a green run with
/// "Foreground threads were left running". Each project's own runtime configuration is checked by
/// <see cref="WpfExitSwitchTests"/> inside its own process; this reads the project files so the failure names the project
/// before any shard runs.
/// </summary>
[Trait("Stage", "Guard")]
[Trait("Reads", "Lite")]
public sealed class WpfExitSwitchPinTests
{
[Theory]
[InlineData("Darling/Darling.Tests/Darling.Tests.csproj")]
[InlineData("Lite.Tests/Lite.Tests.csproj")]
public void Each_test_project_that_starts_WPF_STA_threads_sets_the_weak_event_table_exit_switch(string project)
{
var path = Path.Combine(RepoRoot(), project.Replace('/', Path.DirectorySeparatorChar));
var text = File.ReadAllText(path);
Assert.Contains(
"<RuntimeHostConfigurationOption Include=\"" + WpfExitSwitchTests.SwitchName + "\" Value=\"true\"",
text);
}

private static string RepoRoot([CallerFilePath] string thisFile = "")
{
var dir = Path.GetDirectoryName(thisFile)!;
while (dir is not null && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Path.GetDirectoryName(dir);
}

Assert.False(dir is null, "could not locate the repo root from the test source path");
return dir!;
}
}
134 changes: 134 additions & 0 deletions Darling/Darling.Tests/WpfExitSwitchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #5596: a test process must not spend about 0.3 seconds per WPF STA test thread when it exits. Every STA thread that
/// touches a <c>WeakEventManager</c> gets a <c>WeakEventTable</c> that registers a <c>ProcessExit</c> handler. At exit the
/// handler runs on the main thread, finds the table's own thread gone (its dispatcher never shut down), and asks that
/// dispatcher to purge the table with a 300 ms timeout nobody answers. About fifty test files start STA threads, so the
/// exit stage of the whole-tree run passed the 11 seconds xUnit v3 allows after the run returns, and the runner printed
/// "Foreground threads were left running, forcing process exit" (exit code 1, 0 failed tests) although no thread was
/// left running. The fix is a WPF switch in each test project's runtime configuration; these tests prove it is in
/// effect in this process.
/// <para>
/// This file lives in Darling.Tests and is compiled into Lite.Tests by a link (one rule, two suites), so each test
/// process checks its own runtime configuration.
/// </para>
/// </summary>
public sealed class WpfExitSwitchTests
{
/// <summary>WindowsBase's name for the switch that makes the weak-event table's exit handler purge directly.</summary>
internal const string SwitchName = "Switch.MS.Internal.DoNotInvokeInWeakEventTableShutdownListener";

[Fact]
public void This_process_runs_with_the_weak_event_table_exit_switch_on()
{
Assert.True(
AppContext.TryGetSwitch(SwitchName, out var enabled) && enabled,
$"{SwitchName} is not true in this test process, so each WPF STA test thread costs ~0.3 s at exit (#5596). "
+ "Add <RuntimeHostConfigurationOption Include=\"" + SwitchName + "\" Value=\"true\" /> to the test project.");
}

[Fact]
public void The_installed_WPF_still_knows_the_switch()
{
/* A switch the runtime no longer reads is a silent no-op: the exit gets slow again and nothing says why. The
name is a string literal inside WindowsBase, so the assembly the tests run against is searched for it. */
var windowsBase = typeof(System.Windows.Threading.Dispatcher).Assembly.Location;
var bytes = File.ReadAllBytes(windowsBase);
var needle = Encoding.Unicode.GetBytes(SwitchName);
Assert.True(
bytes.AsSpan().IndexOf(needle) >= 0,
$"{Path.GetFileName(windowsBase)} no longer contains \"{SwitchName}\": WPF dropped or renamed the switch, so the ~0.3 s "
+ "per STA thread exit cost (#5596) is back. Find its replacement in dotnet/wpf (BaseAppContextSwitches) and update the test projects.");
}

[Fact]
public void A_weak_event_tables_exit_handler_for_an_ended_STA_thread_returns_at_once_not_after_the_300_ms_dispatcher_wait()
{
/* The exit handler's own work, run here on purpose and timed: the table is made on its own STA thread by a ListBox
over an observable collection (the usual WeakEventManager user), the thread ends without shutting its dispatcher
down, and that table's ProcessExit handler then runs on this thread, exactly as it does at process exit. Without
the switch it waits out Dispatcher.Invoke's 300 ms timeout every time; with it, it purges an empty table. The
least of three runs is asserted, so one scheduling hiccup on a busy runner cannot fail it, while the old shape
(300 ms or more, every time) always does. Only this test's own table is touched: the handler is picked out by
the table it holds, so other tests' tables, still in use in parallel, are left alone. */
var tableType = typeof(System.Windows.Threading.Dispatcher).Assembly.GetType("MS.Internal.WeakEventTable");
Assert.NotNull(tableType);
var current = tableType!.GetProperty("CurrentWeakEventTable", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(current);
var exitField = typeof(AppDomain).GetField("ProcessExit", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.True(exitField is not null, "AppDomain no longer keeps its ProcessExit handlers in a field named ProcessExit: update this test to read them (#5596).");

var fastest = TimeSpan.MaxValue;
for (var attempt = 0; attempt < 3; attempt++)
{
object? table = null;
Exception? error = null;
using (var staGate = WpfStaGate.Enter())
{
var thread = new Thread(() =>
{
try
{
var list = new System.Collections.ObjectModel.ObservableCollection<int>();
var box = new System.Windows.Controls.ListBox { ItemsSource = list };
box.Measure(new System.Windows.Size(100, 100));
table = current!.GetValue(null);
}
catch (Exception ex)
{
error = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
}

if (error is not null)
{
throw error;
}

Assert.NotNull(table);
Delegate? handler = null;
foreach (var registered in (exitField!.GetValue(AppDomain.CurrentDomain) as Delegate)?.GetInvocationList() ?? Array.Empty<Delegate>())
{
if (registered.Target is WeakReference listener && ReferenceEquals(listener.Target, table))
{
handler = registered;
}
}

Assert.True(handler is not null, "no ProcessExit handler holds the WeakEventTable the STA thread made: WPF changed how a table registers for exit (#5596).");
var clock = Stopwatch.StartNew();
((EventHandler)handler!)(null, EventArgs.Empty);
clock.Stop();
if (clock.Elapsed < fastest)
{
fastest = clock.Elapsed;
}
}

Assert.True(
fastest < TimeSpan.FromMilliseconds(250),
$"A WeakEventTable's exit handler took {fastest.TotalMilliseconds:0} ms at best for a table whose thread had ended: it is waiting out the "
+ "300 ms dispatcher invoke at process exit again, once per STA test thread (#5596). Check " + SwitchName + " in the test project's runtime configuration.");
}
}
11 changes: 11 additions & 0 deletions Lite.Tests/Lite.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
fails, with the same message, rather than hanging the suite. The shipped app declares nothing
here and keeps LocalDataService.DefaultWriteLockBudget. -->
<RuntimeHostConfigurationOption Include="PerformanceMonitorLite.WriteLockBudgetSeconds" Value="120" />
<!-- #5596: WPF builds one WeakEventTable per STA thread that touches a WeakEventManager, and each table
registers a ProcessExit handler. At exit that handler runs on the main thread, and when the table's
own thread is gone it asks the dead dispatcher to purge the table with a 300 ms timeout nobody ever
answers: about 0.3 seconds per STA test thread, spent after the run has returned. About fifty test files
start their own STA thread, so the exit stage grew past the 11 seconds xUnit v3 gives a process after the
run returns, and the runner printed "Foreground threads were left running" with exit code 1 and 0 failed
tests. This WPF switch (WindowsBase, BaseAppContextSwitches) makes the handler purge the table directly.
WpfExitSwitchTests pins that the switch is set, and that the runtime still knows its name. -->
<RuntimeHostConfigurationOption Include="Switch.MS.Internal.DoNotInvokeInWeakEventTableShutdownListener" Value="true" />
</ItemGroup>

<ItemGroup>
Expand All @@ -52,6 +61,8 @@
<Compile Include="..\Darling\Darling.Tests\GuardStageScanner.cs" Link="GuardStageScanner.cs" />
<!-- #5559: the one gate every STA test helper takes, so no two test classes touch WPF's shared descriptor caches at once. -->
<Compile Include="..\Darling\Darling.Tests\WpfStaGate.cs" Link="WpfStaGate.cs" />
<!-- #5596: the test that proves this process runs with the WPF exit switch on (the switch itself is declared above), so both suites check their own process. -->
<Compile Include="..\Darling\Darling.Tests\WpfExitSwitchTests.cs" Link="WpfExitSwitchTests.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading