Skip to content

Commit 31c0b90

Browse files
Fix stress test harness isolation (#392)
Isolate process-wide unit test state and make selftest timeouts abort the host so timed-out fixture tasks cannot contaminate later TAP output. Also make ContentDialog selftests wait for the dialog condition under stress load and preserve actionable selftest abort attribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5aadeb8 commit 31c0b90

7 files changed

Lines changed: 143 additions & 30 deletions

File tree

tests/Reactor.AppTests.Host/SelfTest/Fixtures/CoreCoverageFixtures.cs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,7 @@ public override async Task RunAsync()
334334
ContentDialog("AutoOpen", TextBlock("dialog-body"), "OK") with { IsOpen = true }
335335
));
336336

337-
await Harness.Render(100);
338-
339-
var dialog = FindOpenContentDialog(H, "AutoOpen");
337+
var dialog = await WaitForOpenContentDialog(H, "AutoOpen");
340338
H.Check("ContentDialog_OpensAtMount_#246", dialog is not null);
341339

342340
dialog?.Hide();
@@ -364,16 +362,30 @@ public override async Task RunAsync()
364362
H.Check("ContentDialog_NotOpenBeforeClick_#246", FindOpenContentDialog(H, "Toggled") is null);
365363

366364
H.ClickButton("Show");
367-
await Harness.Render(50);
368365

369-
var dialog = FindOpenContentDialog(H, "Toggled");
366+
var dialog = await WaitForOpenContentDialog(H, "Toggled");
370367
H.Check("ContentDialog_OpensOnStateFlip_#246", dialog is not null);
371368

372369
dialog?.Hide();
373370
await Harness.Render(50);
374371
}
375372
}
376373

374+
private static async Task<Microsoft.UI.Xaml.Controls.ContentDialog?> WaitForOpenContentDialog(
375+
Harness h,
376+
string title,
377+
int timeoutMs = 2_000)
378+
{
379+
for (var elapsed = 0; elapsed <= timeoutMs; elapsed += 50)
380+
{
381+
await Harness.Render(elapsed == 0 ? 0 : 34);
382+
var dialog = FindOpenContentDialog(h, title);
383+
if (dialog is not null) return dialog;
384+
}
385+
386+
return null;
387+
}
388+
377389
private static Microsoft.UI.Xaml.Controls.ContentDialog? FindOpenContentDialog(Harness h, string title)
378390
{
379391
var xamlRoot = (h.Window.Content as UIElement)?.XamlRoot

tests/Reactor.AppTests.Host/SelfTest/Fixtures/NativeDockingReliabilityFixture.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ public override async Task RunAsync()
523523
/// </summary>
524524
internal class EventSubscriptionLeakBaseline(Harness h) : SelfTestFixtureBase(h)
525525
{
526+
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);
527+
526528
public override async Task RunAsync()
527529
{
528530
var host = H.CreateHost();

tests/Reactor.AppTests.Host/SelfTest/Fixtures/NativeDockingSmokeFixture.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,7 @@ DockNode BuildInitial()
10481048
host.Mount(_ => new DockManager { Layout = liveLayout });
10491049
await Harness.Render();
10501050
// Visual-demo "let the eye register" pauses kept short so the
1051-
// 20-step walk stays well inside the §SelfTestRunner.FixtureTimeout
1051+
// 20-step walk stays well inside the default fixture timeout
10521052
// (15s) under CI load. Bump back up locally for slow-motion runs.
10531053
await Task.Delay(50);
10541054

@@ -1233,7 +1233,7 @@ public override async Task RunAsync()
12331233

12341234
host.Mount(_ => Build());
12351235
await Harness.Render();
1236-
// Timing budget (must fit under SelfTestRunner.FixtureTimeout = 15s):
1236+
// Timing budget (must fit under the default fixture timeout):
12371237
// initial settle: 700ms
12381238
// 5 loops × (5 nudges × 200ms + after-final 400ms): 7000ms
12391239
// total delay budget: ~7.7s

tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureBase.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ internal abstract class SelfTestFixtureBase
99

1010
protected SelfTestFixtureBase(Harness harness) => H = harness;
1111

12+
public virtual TimeSpan FixtureTimeout => TimeSpan.FromSeconds(15);
13+
1214
public abstract Task RunAsync();
1315
}

tests/Reactor.AppTests.Host/SelfTest/SelfTestRunner.cs

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,18 @@ internal static class SelfTestRunner
2525
public static bool SkipAotPatterns { get; set; } = true;
2626

2727
// Per-fixture watchdog. A managed hang used to lock up the whole run; now
28-
// we time out, mark it failed, and continue. (Note: native crashes under
29-
// AOT terminate the process before this can fire — use AotSkip patterns
30-
// to skip known-crashing fixtures.) Selftest fixtures normally complete
31-
// in milliseconds — 15s is generous.
32-
private static readonly TimeSpan FixtureTimeout = TimeSpan.FromSeconds(15);
33-
34-
// Off-dispatcher hang watchdog. The in-band FixtureTimeout above relies on
35-
// the dispatcher processing a Task.Delay continuation, so it cannot fire
36-
// when a fixture synchronously blocks the UI thread. This second watchdog
37-
// runs on a background Thread (immune to dispatcher starvation) and
38-
// declares a hang after HangTimeout of no progress in the fixture loop.
39-
// Threshold is well past FixtureTimeout so it only catches the
28+
// we time out, mark it failed, and abort the Host. Continuing in-process is
29+
// unsafe because the timed-out fixture task can keep mutating UI and
30+
// emitting TAP while later fixtures run. Selftest fixtures normally
31+
// complete in milliseconds; long-running reliability fixtures can override
32+
// SelfTestFixtureBase.FixtureTimeout explicitly.
33+
34+
// Off-dispatcher hang watchdog. The in-band fixture timeout relies on the
35+
// dispatcher processing a Task.Delay continuation, so it cannot fire when a
36+
// fixture synchronously blocks the UI thread. This second watchdog runs on
37+
// a background Thread (immune to dispatcher starvation) and declares a hang
38+
// after HangTimeout of no progress in the fixture loop.
39+
// Threshold is well past the per-fixture timeout so it only catches the
4040
// dispatcher-starvation case. Override via REACTOR_SELFTEST_HANG_TIMEOUT_SECONDS;
4141
// set to 0 or a negative value to disable entirely (useful when attaching
4242
// a debugger). Also auto-disabled when Debugger.IsAttached.
@@ -258,14 +258,22 @@ public static void RunAll()
258258
// hang to this fixture by name even if the
259259
// child terminates abruptly afterward.
260260
Console.Out.Flush();
261+
var timeout = fixture.FixtureTimeout;
261262
var runTask = fixture.RunAsync();
262-
var timeoutTask = Task.Delay(FixtureTimeout);
263+
var timeoutTask = Task.Delay(timeout);
263264
var completed = await Task.WhenAny(runTask, timeoutTask);
264-
if (completed == timeoutTask)
265+
if (completed == timeoutTask && !runTask.IsCompleted)
266+
{
267+
completed = await Task.WhenAny(runTask, Task.Delay(100));
268+
}
269+
270+
if (completed != runTask)
265271
{
266272
crashed = true;
267-
Console.WriteLine($"not ok {testIndex} {fixtureName}_TIMEOUT - exceeded {FixtureTimeout.TotalSeconds:0}s");
273+
Console.WriteLine($"not ok {testIndex} {fixtureName}_TIMEOUT - exceeded {timeout.TotalSeconds:0}s");
274+
Console.Out.Flush();
268275
harness.RecordFailure();
276+
Environment.Exit(1);
269277
}
270278
else
271279
{

tests/Reactor.SelfTests/SelfTestBatch.cs

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public static void RunSelfTests(TestContext context)
4444
if (!string.IsNullOrEmpty(stderr))
4545
_fullOutput += "\n--- stderr ---\n" + stderr;
4646

47-
ParseTap(stdout);
47+
var tap = ParseTap(stdout);
4848

4949
// Off-dispatcher watchdog in the Host emits a structured signal on
5050
// dispatcher-starvation hangs. Parse it from stdout *and* stderr (the
@@ -89,6 +89,8 @@ public static void RunSelfTests(TestContext context)
8989
_abortedReason = $"Run aborted by hang on fixture '{hangFixture}'";
9090
}
9191

92+
MarkEarlyAbortIfNeeded(exitCode, tap);
93+
9294
_initialized = true;
9395

9496
if (exitCode != 0 && _byFixture.IsEmpty)
@@ -131,7 +133,9 @@ private static string Tail(string s, int maxChars)
131133
return "..." + s[^maxChars..];
132134
}
133135

134-
private static void ParseTap(string stdout)
136+
private sealed record TapParseResult(string? LastRunningFixture, bool SawTotalFailures);
137+
138+
private static TapParseResult ParseTap(string stdout)
135139
{
136140
// Two TAP emitter sources:
137141
// Harness check: "ok <checkName>" / "not ok <checkName> - <reason>"
@@ -145,10 +149,15 @@ private static void ParseTap(string stdout)
145149
string? current = null;
146150
var failuresForCurrent = new List<string>();
147151
var sawChecksForCurrent = false;
152+
string? lastRunningFixture = null;
153+
var sawTotalFailures = false;
148154

149155
void Flush()
150156
{
151157
if (current is null) return;
158+
if (_byFixture.TryGetValue(current, out var existing) && !existing.Passed && failuresForCurrent.Count == 0)
159+
return;
160+
152161
var passed = failuresForCurrent.Count == 0 && sawChecksForCurrent;
153162
var detail = failuresForCurrent.Count == 0
154163
? (sawChecksForCurrent ? "" : "fixture emitted no TAP checks")
@@ -163,9 +172,14 @@ void Flush()
163172
{
164173
Flush();
165174
current = line["# Running: ".Length..].Trim();
175+
lastRunningFixture = current;
166176
failuresForCurrent = new List<string>();
167177
sawChecksForCurrent = false;
168178
}
179+
else if (line.StartsWith("# Total failures:", StringComparison.Ordinal))
180+
{
181+
sawTotalFailures = true;
182+
}
169183
else if (line.StartsWith("ok "))
170184
{
171185
// Harness-level pass; ignore payload, just note that current saw checks.
@@ -176,9 +190,17 @@ void Flush()
176190
var rest = line[7..].Trim();
177191
if (TryParseRunnerLevelFailure(rest, out var fixtureName, out var detail))
178192
{
179-
// Runner-level failure — attribute directly to the fixture name,
180-
// overriding any in-progress `current` bucket.
181-
_byFixture[fixtureName] = (false, detail);
193+
if (string.Equals(fixtureName, current, StringComparison.Ordinal))
194+
{
195+
sawChecksForCurrent = true;
196+
failuresForCurrent.Add(detail);
197+
}
198+
else
199+
{
200+
// Runner-level failure — attribute directly to the fixture name,
201+
// overriding any in-progress `current` bucket.
202+
_byFixture[fixtureName] = (false, detail);
203+
}
182204
}
183205
else
184206
{
@@ -191,6 +213,7 @@ void Flush()
191213
}
192214
}
193215
Flush();
216+
return new TapParseResult(lastRunningFixture, sawTotalFailures);
194217
}
195218

196219
private static bool TryParseRunnerLevelFailure(string rest, out string fixtureName, out string detail)
@@ -218,12 +241,57 @@ private static bool TryParseRunnerLevelFailure(string rest, out string fixtureNa
218241
}
219242

220243
if (namePart.Length == 0) return false;
221-
fixtureName = namePart.EndsWith("_CRASH", StringComparison.Ordinal)
222-
? namePart[..^"_CRASH".Length]
223-
: namePart;
244+
fixtureName = StripRunnerFailureSuffix(namePart);
224245
return true;
225246
}
226247

248+
private static string StripRunnerFailureSuffix(string namePart)
249+
{
250+
string[] suffixes = ["_CRASH", "_TIMEOUT"];
251+
foreach (var suffix in suffixes)
252+
{
253+
if (namePart.EndsWith(suffix, StringComparison.Ordinal))
254+
return namePart[..^suffix.Length];
255+
}
256+
257+
return namePart;
258+
}
259+
260+
private static void MarkEarlyAbortIfNeeded(int exitCode, TapParseResult tap)
261+
{
262+
if (_abortedReason is not null || exitCode == 0 && tap.SawTotalFailures)
263+
return;
264+
265+
var fixtureNames = FixtureNames.Value;
266+
var firstMissingIndex = Array.FindIndex(fixtureNames, name => !_byFixture.ContainsKey(name));
267+
if (firstMissingIndex < 0)
268+
return;
269+
270+
var hasReportedAfterMissing = fixtureNames
271+
.Skip(firstMissingIndex + 1)
272+
.Any(name => _byFixture.ContainsKey(name));
273+
if (hasReportedAfterMissing)
274+
return;
275+
276+
var attributed = tap.LastRunningFixture;
277+
if (attributed is not null)
278+
{
279+
if (!_byFixture.TryGetValue(attributed, out var existing) || existing.Passed)
280+
{
281+
_byFixture[attributed] = (false,
282+
$"Selftest Host exited before completing fixture '{attributed}'. " +
283+
$"Exit code: {exitCode}. Downstream fixtures were not executed.\n" +
284+
$"--- tail of full output ---\n{Tail(_fullOutput, 4000)}");
285+
}
286+
287+
_abortedReason = $"Run aborted after fixture '{attributed}'";
288+
}
289+
else
290+
{
291+
_abortedReason = $"Run aborted before fixture '{fixtureNames[firstMissingIndex]}'";
292+
}
293+
}
294+
227295
public static IEnumerable<object[]> AllFixtures => FixtureNames.Value.Select(n => new object[] { n });
228296

229297
[DataTestMethod]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Xunit;
2+
3+
namespace Microsoft.UI.Reactor.Tests;
4+
5+
/// <summary>
6+
/// xUnit collection marker for tests that replace process-wide console or trace
7+
/// state. These tests must not overlap any other collection because
8+
/// <see cref="Console.Out"/>, <see cref="Console.Error"/>, and
9+
/// <see cref="System.Diagnostics.Trace.Listeners"/> are global to the test process.
10+
/// </summary>
11+
[CollectionDefinition("ConsoleTests", DisableParallelization = true)]
12+
public sealed class ConsoleTestsCollection { }
13+
14+
/// <summary>
15+
/// xUnit collection marker for tests that subscribe to
16+
/// <see cref="TaskScheduler.UnobservedTaskException"/> and force finalization. The
17+
/// event is process-wide, so these tests need exclusive execution to avoid counting
18+
/// faulted tasks owned by unrelated tests.
19+
/// </summary>
20+
[CollectionDefinition("UnobservedTaskException", DisableParallelization = true)]
21+
public sealed class UnobservedTaskExceptionCollection { }

0 commit comments

Comments
 (0)