From 4aae877a62b3be5549b9fdd1f3e88598a1ca34b7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:54 +0100 Subject: [PATCH 1/6] test: make the ParallelMap sibling-stop test deterministic The test let 10,000 items race the faulting worker. When that worker was preempted between the fault and parking the cursor, its siblings processed every item and the test failed with 'received 10000'. Each worker now holds one item, the test faults item 0 through a value-task source whose continuation runs inline, then releases the siblings, so exactly one item per worker runs. --- tests/TUnit.UnitTests/ParallelMapTests.cs | 78 ++++++++++++++++++----- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/tests/TUnit.UnitTests/ParallelMapTests.cs b/tests/TUnit.UnitTests/ParallelMapTests.cs index f0a00bf4100..b87cbf7477f 100644 --- a/tests/TUnit.UnitTests/ParallelMapTests.cs +++ b/tests/TUnit.UnitTests/ParallelMapTests.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks.Sources; using TUnit.Engine.Utilities; namespace TUnit.UnitTests; @@ -107,29 +108,48 @@ public async Task SelectorException_PropagatesToCaller() [Test] public async Task SelectorException_StopsSiblingsPullingNewItems() { + // Every worker takes one item and suspends on it, so the test decides the order: + // first item 0 faults, then the siblings finish. Letting the items race instead + // made this flaky: a worker preempted between the fault and parking the cursor + // let its siblings process all items. const int count = 10_000; + const int workers = 4; var invoked = 0; + var allWorkersBusy = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var siblingsGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var faultingItem = new ObservableValueTaskSource(); - await Assert.ThrowsAsync(() => - ParallelMap.ForParallelAsync( - count, - async i => + var run = ParallelMap.ForParallelValueAsync( + count, + i => + { + if (Interlocked.Increment(ref invoked) == workers) { - Interlocked.Increment(ref invoked); + allWorkersBusy.SetResult(); + } - if (i == 0) - { - throw new InvalidOperationException("fail fast"); - } + return i == 0 ? faultingItem.ValueTask : new ValueTask(WaitForGateAsync(i)); + }, + workers); - await Task.Yield(); - return i; - }, - Environment.ProcessorCount)); + await allWorkersBusy.Task; + await faultingItem.Awaited; - // The parked cursor must prevent the full list from being processed. - // In-flight items may still complete, so allow generous slack. - await Assert.That(invoked).IsLessThan(count); + // The source runs continuations synchronously, so the worker's catch block has + // parked the cursor by the time SetException returns. + faultingItem.SetException(new InvalidOperationException("fail fast")); + siblingsGate.SetResult(); + + await Assert.ThrowsAsync(() => run); + + // Only the items taken before the fault ran; the parked cursor stopped the rest. + await Assert.That(invoked).IsEqualTo(workers); + + async Task WaitForGateAsync(int i) + { + await siblingsGate.Task; + return i; + } } [Test] @@ -192,4 +212,30 @@ private static void InterlockedMax(ref int location, int value) } } while (Interlocked.CompareExchange(ref location, value, snapshot) != snapshot); } + + /// + /// A value-task source that reports when its awaiter suspends on it and, once completed, + /// runs that awaiter's continuation synchronously on the completing thread. + /// + private sealed class ObservableValueTaskSource : IValueTaskSource + { + private readonly TaskCompletionSource _awaited = new(TaskCreationOptions.RunContinuationsAsynchronously); + private ManualResetValueTaskSourceCore _core; + + public ValueTask ValueTask => new(this, _core.Version); + + public Task Awaited => _awaited.Task; + + public void SetException(Exception exception) => _core.SetException(exception); + + public T GetResult(short token) => _core.GetResult(token); + + public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token); + + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + _core.OnCompleted(continuation, state, token, flags); + _awaited.TrySetResult(); + } + } } From 1fb11886c39720dfaf1dbbd704b68f4197764049 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:54 +0100 Subject: [PATCH 2/6] test: fail every stop until the disposal-failure test has seen it A minimal-hosting app stops its host twice, concurrently: once from WebApplicationFactory.DisposeAsync and once from app.Run(). The hosted service failed only its first stop, so when app.Run() made that call, DisposeFactoryAsync saw a clean stop and threw nothing. The service now fails every stop until the test turns the failure off, which keeps the session-end re-disposal working. --- .../CreateFactoryAsyncTests.cs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/TUnit.AspNetCore.Tests/CreateFactoryAsyncTests.cs b/tests/TUnit.AspNetCore.Tests/CreateFactoryAsyncTests.cs index 471b2974bf8..cdcbe74cb76 100644 --- a/tests/TUnit.AspNetCore.Tests/CreateFactoryAsyncTests.cs +++ b/tests/TUnit.AspNetCore.Tests/CreateFactoryAsyncTests.cs @@ -133,7 +133,8 @@ public async Task Concurrent_Calls_Track_Every_Factory() /// /// /// Uses its own factory type, so it gets its own session-shared global factory. That factory disposes -/// its derived factories again at session end; the failing hosted service only fails its first stop, and +/// its derived factories again at session end, so the failing hosted service stops failing once the test +/// has seen its failure. /// is off /// because its wrapper replays the first stop result, which would fail that second disposal too. /// @@ -142,14 +143,23 @@ public class PerTestWebApplicationTestDisposalFailureTests : PerTestWebApplicati [Test] public async Task DisposeFactoryAsync_Disposes_Remaining_Factories_When_One_Throws() { - var failing = await CreateFactoryAsync(builder => - builder.ConfigureTestServices(services => services.AddHostedService())); + var stopFailure = new ThrowingOnStopService(); + await CreateFactoryAsync(builder => + builder.ConfigureTestServices(services => services.AddHostedService(_ => stopFailure))); var healthy = await CreateFactoryAsync(builder => builder.ConfigureTestServices(services => services.AddSingleton())); var probe = healthy.Services.GetRequiredService(); - await Assert.That(DisposeFactoryAsync).Throws().WithMessageContaining(ThrowingOnStopService.Message); + try + { + await Assert.That(DisposeFactoryAsync).Throws().WithMessageContaining(ThrowingOnStopService.Message); + } + finally + { + stopFailure.StopFailing(); + } + await Assert.That(probe.IsDisposed).IsTrue(); } } @@ -217,18 +227,27 @@ public sealed class DisposalProbe : IDisposable } /// -/// Fails the first stop only, so the global factory's second disposal at session end succeeds. +/// Fails every stop until is called, so the global factory's second +/// disposal at session end succeeds. /// +/// +/// It must not fail only the first stop. A minimal-hosting app stops its host twice, concurrently: +/// once from WebApplicationFactory.DisposeAsync and once from its own app.Run(), +/// which wakes on ApplicationStopping. When app.Run() made the first call, the +/// disposal saw a clean stop and the test failed. +/// public sealed class ThrowingOnStopService : Microsoft.Extensions.Hosting.IHostedService { public const string Message = "Stop failed on purpose."; - private int _stopCalls; + private volatile bool _failStops = true; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => - Interlocked.Increment(ref _stopCalls) == 1 + _failStops ? throw new InvalidOperationException(Message) : Task.CompletedTask; + + public void StopFailing() => _failStops = false; } From 68a9f0e6bbda8f1510e579964062b95417618e6a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:54 +0100 Subject: [PATCH 3/6] test: keep HtmlReporter session-span tests out of each other's way A reporter's activity collector listens to every activity in the process. When the test that emits a session span ran during the run-duration test, BuildReportData used that span's ~1 microsecond duration instead of the 250 ms test timing. Both tests now share a NotInParallel key. --- tests/TUnit.Engine.Tests/HtmlReporterTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs index b23916a43f4..c62e46f4ba5 100644 --- a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -20,6 +20,11 @@ namespace TUnit.Engine.Tests; public class HtmlReporterTests { + // A reporter's activity collector listens process-wide, so it also records "test session" + // spans that other tests emit, and BuildReportData then reports that span's duration as the + // run duration. Tests that emit a session span or assert the run duration share this key. + private const string SessionSpanKey = "HtmlReporterSessionSpan"; + [Test] public void CommandProvider_DefaultMode_Exposes_Legacy_Options() { @@ -95,6 +100,7 @@ public void HtmlReporter_DataTypesProduced_Contains_SessionFileArtifact() } [Test] + [NotInParallel(SessionSpanKey)] public async Task Stopping_Activity_Collection_Preserves_Spans_For_Report_Data(CancellationToken cancellationToken) { using var reporter = new HtmlReporter(new MockExtension()); @@ -575,12 +581,13 @@ public async Task BuildReportData_Reconstructs_Attempts_From_RetryAttemptsProper } [Test] + [NotInParallel(SessionSpanKey)] public async Task BuildReportData_Ignores_Default_Start_Timestamps() { // #6942: tests that never started (e.g. skipped before execution) carry a default // TimingInfo whose StartTime is 0001-01-01. Neither the run duration nor the per-test // StartTime may be derived from it, or the report shows ~17,757,303 hours. - var reporter = new HtmlReporter(new MockExtension()); + using var reporter = new HtmlReporter(new MockExtension()); var start = DateTimeOffset.UtcNow; var ranNode = CreateTimedNode("ran-1", "RanTest", PassedTestNodeStateProperty.CachedInstance, From 86e7455981d63eaf6c6a0ad6315058172144044d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:55 +0100 Subject: [PATCH 4/6] test: bind OTLP upstream listeners through the retrying factory The two forwarding tests probed a free port and bound it later, so another listener could take the port in between and Start() threw. They now use LoopbackHttpListenerFactory.Create, which retries that case. --- .../OtlpReceiverIngestionTests.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/tests/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs index 6b43e7f94ac..7bea54bfb41 100644 --- a/tests/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs +++ b/tests/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs @@ -147,10 +147,9 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess() // Mock upstream stands in for the Aspire dashboard — gates on the api-key header // so we can prove the receiver actually propagated it instead of just dropping body // bytes onto a permissive endpoint. - using var upstreamListener = new HttpListener(); - var upstreamPort = LoopbackHttpListenerFactory.FindFreePort(); - upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/"); - upstreamListener.Start(); + // Create retries when another listener takes the probed port before this one binds it. + var (upstreamListener, upstreamPort) = LoopbackHttpListenerFactory.Create(); + using var upstreamListenerScope = upstreamListener; var receivedAuth = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var listenerTask = Task.Run(async () => @@ -196,10 +195,9 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess() [Test] public async Task Receiver_DrainAsync_WaitsForInFlightForwardBeforeReturning() { - using var upstreamListener = new HttpListener(); - var upstreamPort = LoopbackHttpListenerFactory.FindFreePort(); - upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/"); - upstreamListener.Start(); + // Create retries when another listener takes the probed port before this one binds it. + var (upstreamListener, upstreamPort) = LoopbackHttpListenerFactory.Create(); + using var upstreamListenerScope = upstreamListener; var upstreamReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseUpstream = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); From ab2a5792731e4597ca57093530926713a7ebbd0c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:55 +0100 Subject: [PATCH 5/6] test: bound ASP.NET startup time by setup instead of a 500 ms margin WebApplicationTest starts the server in its Before(Test) hook, before the test body runs, so a timestamp taken in the body can be later than startup. The test passed only while that gap stayed under its 500 ms margin. It now checks that startup falls between SetupAsync and CreateClient returning. --- .../PerTestServerStartupTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/TUnit.Example.Asp.Net.TestProject/PerTestServerStartupTests.cs b/examples/TUnit.Example.Asp.Net.TestProject/PerTestServerStartupTests.cs index a37e14be1df..3e82c7d0d29 100644 --- a/examples/TUnit.Example.Asp.Net.TestProject/PerTestServerStartupTests.cs +++ b/examples/TUnit.Example.Asp.Net.TestProject/PerTestServerStartupTests.cs @@ -26,18 +26,18 @@ await Assert.That(StartupCalledAt).IsNotNull() [DisplayName("Server startup time is recorded accurately")] public async Task Server_Startup_Time_Is_Recorded() { - // Add small buffer to account for DateTime precision differences - var beforeCall = DateTime.UtcNow.AddMilliseconds(-500); - + // The Before(Test) hook that builds Factory starts the server before this body runs, + // so a timestamp taken here can be later than startup. SetupAsync runs before that build. _ = Factory.CreateClient(); - var afterCall = DateTime.UtcNow.AddMilliseconds(500); + var afterCall = DateTime.UtcNow; + await Assert.That(SetupCalledAt).IsNotNull(); await Assert.That(StartupCalledAt).IsNotNull(); - await Assert.That(StartupCalledAt!.Value).IsGreaterThanOrEqualTo(beforeCall) - .Because("Server startup should occur around when we call CreateClient"); + await Assert.That(StartupCalledAt!.Value).IsGreaterThanOrEqualTo(SetupCalledAt!.Value) + .Because("Server startup happens after SetupAsync, when the factory is built"); await Assert.That(StartupCalledAt!.Value).IsLessThanOrEqualTo(afterCall) - .Because("Server startup should complete around when CreateClient returns"); + .Because("Server startup should complete before CreateClient returns"); } [Test] From 14f9f7244af76ade1b8768d5de583a349b93461a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:39:55 +0100 Subject: [PATCH 6/6] ci: run the Apple smoke tests on the Xcode 27 image The rc.2 SDK installs the 27.x Apple workloads, which require Xcode 27, but macos-26 has only Xcode 26.x, so the iOS, tvOS and Mac Catalyst builds have failed on every PR since the SDK update. Move them to the xcode-27 runner and its iOS 27.0 and tvOS 27.0 simulators. --- .github/workflows/platform-smoke.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml index 42b1a869e61..29edd512ce4 100644 --- a/.github/workflows/platform-smoke.yml +++ b/.github/workflows/platform-smoke.yml @@ -27,18 +27,19 @@ jobs: - platform: Android os: ubuntu-latest workload: android + # The .NET 11 Apple workloads (27.x) require Xcode 27, which only the xcode-27 image has. - platform: iOS - os: macos-26 + os: xcode-27 workload: ios device: iPhone 17 - simulator: iOS-26-5 + simulator: iOS-27-0 - platform: tvOS - os: macos-26 + os: xcode-27 workload: tvos - device: Apple TV - simulator: tvOS-26-5 + device: Apple TV 4K (3rd generation) + simulator: tvOS-27-0 - platform: MacCatalyst - os: macos-26 + os: xcode-27 workload: maccatalyst - platform: WASI os: ubuntu-latest @@ -59,7 +60,7 @@ jobs: restore-keys: platform-smoke-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}- - name: Select the installed Xcode if: runner.os == 'macOS' - run: sudo xcode-select --switch /Applications/Xcode_26.6.app + run: sudo xcode-select --switch /Applications/Xcode_27.0.app - name: Start the installed Apple simulator if: matrix.platform == 'iOS' || matrix.platform == 'tvOS' run: |