Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3df3c9d
perf(engine): skip TimeoutHelper wrap when no explicit [Timeout] is s…
thomhurst Apr 24, 2026
308eda7
docs(timeout): clarify unset-DefaultTestTimeout behavior; drop issue …
thomhurst Apr 24, 2026
c4c2752
refactor(timeout): collapse explicit-set bool into nullable backing f…
thomhurst Apr 24, 2026
7f0445d
test(engine): cover ExplicitDefaultTestTimeout fallback in TestCoordi…
thomhurst Apr 24, 2026
8e5a8f6
test(settings): preserve unset DefaultTestTimeout across snapshot/res…
thomhurst Apr 24, 2026
5ff1032
fix(engine): classify DefaultTestTimeout failures as timeouts, not er…
thomhurst Apr 24, 2026
c6cac32
test(5728): gate discovery hook on filter string, not env var
thomhurst Apr 24, 2026
86396ab
test(5728): read filter from GlobalContext.Current in discovery hook
thomhurst Apr 25, 2026
a351c29
fix(engine): make GlobalContext.Current process-wide, not AsyncLocal
thomhurst Apr 26, 2026
978a669
diag(5728): emit hook execution markers to bisect CI failure
thomhurst Apr 26, 2026
568dd90
Revert "diag(5728): emit hook execution markers to bisect CI failure"
thomhurst Apr 26, 2026
c96146b
fix(engine): pass explicit factory to LazyInitializer for AOT safety
thomhurst Apr 26, 2026
28b247d
chore: trim verbose comments on GlobalContext + TUnitTestFramework
thomhurst Apr 26, 2026
3b68619
test: drop flaky DefaultTimeoutClassification engine + fixture
thomhurst Apr 26, 2026
f53549c
Revert "test: drop flaky DefaultTimeoutClassification engine + fixture"
thomhurst Apr 26, 2026
9a0aeb4
fix(engine): apply DefaultTestTimeout on the no-retry fast path too
thomhurst Apr 26, 2026
75c88a9
refactor(timeout): centralise [Timeout] + DefaultTestTimeout coalesce
thomhurst Apr 26, 2026
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
14 changes: 8 additions & 6 deletions TUnit.Core/Models/GlobalContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ namespace TUnit.Core;

public class GlobalContext : Context
{
private static readonly AsyncLocal<GlobalContext?> Contexts = new();
// Static, not AsyncLocal: a lazy-creating AsyncLocal getter poisons the first
// reading branch with a fresh empty instance, hiding the framework's later
// Current = ... assignment from that branch.
private static GlobalContext? _current;
public static new GlobalContext Current
{
get
{
return Contexts.Value ??= new GlobalContext();
}
internal set => Contexts.Value = value;
// Factory overload — the parameterless one uses Activator.CreateInstance<T>(),
// which AOT trimming may not preserve for GlobalContext's internal ctor.
get => LazyInitializer.EnsureInitialized(ref _current, static () => new GlobalContext())!;
internal set => Volatile.Write(ref _current, value);
}

internal GlobalContext() : base(null)
Expand Down
34 changes: 30 additions & 4 deletions TUnit.Core/Settings/TimeoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,46 @@ public sealed class TimeoutSettings
internal TimeoutSettings() { }

/// <summary>
/// Default timeout for individual tests. Default: 30 minutes.
/// Overridden per-test by <see cref="TimeoutAttribute"/>.
/// Default timeout for individual tests. Overridden per-test by <see cref="TimeoutAttribute"/>.
/// Precedence: CLI/env var (N/A for test timeout) → TUnitSettings → built-in default.
/// <para>
/// If this property is never explicitly set, tests without a <see cref="TimeoutAttribute"/>
/// run without any timeout wrapper. The 30-minute fallback only applies when this
/// property is explicitly assigned.
/// </para>
/// </summary>
public TimeSpan DefaultTestTimeout
{
get => _defaultTestTimeout;
get => _defaultTestTimeout ?? TimeSpan.FromMinutes(30);
set
{
ValidatePositive(value);
_defaultTestTimeout = value;
}
}

// Null means the user never assigned a project-level default, so tests without [Timeout]
// bypass TimeoutHelper entirely instead of paying wrap overhead for a 30-minute backstop.
internal TimeSpan? ExplicitDefaultTestTimeout => _defaultTestTimeout;

// Coalesces the per-test [Timeout] attribute value with the project-wide opt-in
// DefaultTestTimeout. Null when neither is set — TestCoordinator's null fast path
// then skips the TimeoutHelper wrap entirely.
internal TimeSpan? GetEffectiveTestTimeout(TimeSpan? attributeTimeout)
=> attributeTimeout ?? _defaultTestTimeout;

// Test-only seam: the public setter validates positive-only and can't write null, which
// leaves the harness unable to restore the "unset" state after a snapshot/restore cycle.
internal void SetExplicitDefaultTestTimeout(TimeSpan? value)
{
if (value is { } positive)
{
ValidatePositive(positive);
}

_defaultTestTimeout = value;
}

/// <summary>
/// Default timeout for hook methods (Before/After at every level). Default: 5 minutes.
/// Overridden per-hook by <see cref="TimeoutAttribute"/>.
Expand Down Expand Up @@ -71,7 +97,7 @@ public TimeSpan ProcessExitHookDelay
}
}

private TimeSpan _defaultTestTimeout = TimeSpan.FromMinutes(30);
private TimeSpan? _defaultTestTimeout;
private TimeSpan _defaultHookTimeout = TimeSpan.FromMinutes(5);
private TimeSpan _forcefulExitTimeout = TimeSpan.FromSeconds(30);
private TimeSpan _processExitHookDelay = TimeSpan.FromMilliseconds(500);
Expand Down
47 changes: 47 additions & 0 deletions TUnit.Engine.Tests/DefaultTimeoutClassificationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// Integration test for PR #5728 Bug 2: a timeout that fires via
/// <c>TUnitSettings.Default.Timeouts.DefaultTestTimeout</c> (instead of a <c>[Timeout]</c>
/// attribute) must surface as a timeout failure end-to-end, not a generic error.
///
/// Before the fix, <c>TestDetails.Timeout</c> was left null when the timeout was resolved
/// from settings, so <c>TUnitMessageBus.GetFailureStateProperty</c> fell through to
/// <c>ErrorTestNodeStateProperty</c> — which caused JUnit/GitHub/HTML reporters to label
/// real timeouts as errors.
/// </summary>
public class DefaultTimeoutClassificationTests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Hanging_Test_Fails_With_Timeout_Message_When_Only_DefaultTestTimeout_Is_Set()
{
// The matching [Before(TestDiscovery)] hook in TestProject detects this class name
// in the filter string and programmatically sets DefaultTestTimeout=200ms for the
// subprocess — so a hanging test timing out via DefaultTestTimeout can be observed
// end-to-end. The gate is the filter (not an env var) because Microsoft.Testing.Platform's
// test-host-controller mode under --hangdump does not reliably propagate env vars
// through to the test host process on all CI runners.
await RunTestsWithFilter(
"/*/*/DefaultTimeoutClassificationTests/Hanging_Test_With_DefaultTestTimeout_Should_Timeout",
[
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(1),
// Test body sleeps 10s but the 200ms default timeout must fire well before that —
// bounds guard against the timeout silently not firing (would sleep the full 10s).
result => TimeSpan.Parse(result.Results[0].Duration).ShouldBeLessThan(TimeSpan.FromSeconds(5)),
result =>
{
// Confirm the failure was classified as a timeout rather than a generic error.
// "Timed out" appears in TimeoutTestNodeStateProperty's explanation; a generic
// error path would surface the raw exception message without the "timed out" phrase.
var errorMessage = result.Results.First().Output?.ErrorInfo?.Message;
errorMessage.ShouldNotBeNull("Expected an error message for the timed-out test");
errorMessage!.ToLowerInvariant().ShouldContain("timed out");
}
]);
}
}
6 changes: 2 additions & 4 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,8 +1085,7 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
MethodMetadata = metadata.MethodMetadata,
AttributesByType = attributes.ToAttributeDictionary(),
MethodGenericArguments = testData.ResolvedMethodGenericArguments,
ClassGenericArguments = testData.ResolvedClassGenericArguments,
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
ClassGenericArguments = testData.ResolvedClassGenericArguments
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -1178,8 +1177,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
AttributesByType = AttributeDictionaryHelper.Empty
};
}

Expand Down
12 changes: 4 additions & 8 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = attributes.ToAttributeDictionary(),
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
AttributesByType = attributes.ToAttributeDictionary()
};

var testBuilderContext = CreateTestBuilderContext(metadata);
Expand Down Expand Up @@ -383,8 +382,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
TestLineNumber = resolvedMetadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = resolvedMetadata.MethodMetadata,
AttributesByType = attributes.ToAttributeDictionary(),
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
AttributesByType = attributes.ToAttributeDictionary()
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -462,8 +460,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
AttributesByType = AttributeDictionaryHelper.Empty
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -515,8 +512,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
AttributesByType = AttributeDictionaryHelper.Empty
};

var context = _contextProvider.CreateTestContext(
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public TUnitServiceProvider(IExtension extension,

ParallelLimitLockProvider = Register(new ParallelLimitLockProvider());

ContextProvider = Register(new ContextProvider(this, TestSessionId, Filter?.ToString()));
ContextProvider = Register(new ContextProvider(this, TestSessionId, FilterParser.StringifyFilter(Filter)));

var hookExecutor = Register(new HookExecutor(HookDelegateBuilder, ContextProvider, EventReceiverOrchestrator));
var lifecycleCoordinator = Register(new TestLifecycleCoordinator());
Expand Down
10 changes: 6 additions & 4 deletions TUnit.Engine/Framework/TUnitTestFramework.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,18 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
{
var serviceProvider = GetOrCreateServiceProvider(context);

serviceProvider.Initializer.Initialize(context);

await serviceProvider.HookDelegateBuilder.InitializeAsync();

// Install contexts before init runs so anything reading GlobalContext.Current
// sees the populated instance instead of the lazy fallback (null TestFilter).
GlobalContext.Current = serviceProvider.ContextProvider.GlobalContext;
GlobalContext.Current.GlobalLogger = serviceProvider.Logger;
BeforeTestDiscoveryContext.Current = serviceProvider.ContextProvider.BeforeTestDiscoveryContext;
TestDiscoveryContext.Current = serviceProvider.ContextProvider.TestDiscoveryContext;
TestSessionContext.Current = serviceProvider.ContextProvider.TestSessionContext;

serviceProvider.Initializer.Initialize(context);

await serviceProvider.HookDelegateBuilder.InitializeAsync();

serviceProvider.CancellationToken.Initialise(context.CancellationToken);

await _requestHandler.HandleRequestAsync((TestExecutionRequest) context.Request, serviceProvider, context, GetFilter(context));
Expand Down
3 changes: 2 additions & 1 deletion TUnit.Engine/Services/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ internal class FilterParser
}

#pragma warning disable TPEXP
public static string? StringifyFilter(ITestExecutionFilter filter)
public static string? StringifyFilter(ITestExecutionFilter? filter)
{
return filter switch
{
null => null,
NopFilter => null,
TestNodeUidListFilter testNodeUidListFilter => string.Join(',',
testNodeUidListFilter.TestNodeUids.Select(x => x.Value)),
Expand Down
19 changes: 17 additions & 2 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using TUnit.Core;
using TUnit.Core.Exceptions;
using TUnit.Core.Logging;
using TUnit.Core.Settings;
using TUnit.Core.Tracking;
using TUnit.Engine.Helpers;
using TUnit.Engine.Interfaces;
Expand Down Expand Up @@ -150,7 +151,7 @@ public async ValueTask ExecuteTestAsync(AbstractExecutableTest test, Cancellatio
{
_testInitializer.PrepareTest(test);
test.Context.RestoreExecutionContext();
var testTimeout = test.Context.Metadata.TestDetails.Timeout;
var testTimeout = ResolveAndPropagateTestTimeout(test);
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout).ConfigureAwait(false);
}
finally
Expand Down Expand Up @@ -373,7 +374,7 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
{
_testInitializer.PrepareTest(test);
test.Context.RestoreExecutionContext();
var testTimeout = test.Context.Metadata.TestDetails.Timeout;
var testTimeout = ResolveAndPropagateTestTimeout(test);
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout).ConfigureAwait(false);
}
finally
Expand All @@ -382,6 +383,20 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
}
}

// Propagation is required so TUnitMessageBus.GetFailureStateProperty (which gates on
// TestDetails.Timeout != null) classifies a DefaultTestTimeout-triggered failure as
// TimeoutTestNodeStateProperty rather than a generic error.
private static TimeSpan? ResolveAndPropagateTestTimeout(AbstractExecutableTest test)
{
var details = test.Context.Metadata.TestDetails;
var testTimeout = TUnitSettings.Default.Timeouts.GetEffectiveTestTimeout(details.Timeout);
if (testTimeout is not null && details.Timeout is null)
{
details.Timeout = testTimeout;
}
return testTimeout;
}

/// <summary>
/// Disposes the test instance and fires OnDispose callbacks, wrapped in an OpenTelemetry
/// activity span for trace timeline visibility.
Expand Down
54 changes: 54 additions & 0 deletions TUnit.TestProject/Bugs/5728/DefaultTimeoutClassificationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._5728;

/// <summary>
/// Reproduction for PR #5728 Bug 2: timeout failures triggered by
/// <c>TimeoutSettings.DefaultTestTimeout</c> were misclassified as generic errors
/// instead of timeouts because <c>TestDetails.Timeout</c> was left null when the timeout
/// was resolved from settings rather than a <c>[Timeout]</c> attribute.
///
/// Only the targeted engine-test filter runs this class — its <c>EngineTest=Failure</c>
/// marker excludes it from the default pass-only harness run.
/// </summary>
public class DefaultTimeoutClassificationHooks
{
// Gated on the filter string so this process-wide setting only takes effect when the
// engine-test harness targets this class — otherwise discovery hooks fire for every
// run of TUnit.TestProject and would shrink every test's default timeout to 200ms.
//
// Read the filter from GlobalContext.Current rather than the BeforeTestDiscoveryContext
// parameter — TUnitTestFramework installs GlobalContext.Current with the stringified
// filter before any hooks run, and it survives the controller-mode + AOT codepaths
// where the hook-parameter's required-init TestFilter has come through empty in CI.
//
// Method name must contain "Before" for the source generator to match it against the
// BeforeTestDiscoveryContext parameter (see HookMetadataGenerator.IsValidHookMethod).
[Before(TestDiscovery)]
public static void BeforeDiscovery_ConfigureDefaultTimeoutWhenTargeted(BeforeTestDiscoveryContext context)
{
var filter = GlobalContext.Current.TestFilter;
if (filter is null ||
!filter.Contains("DefaultTimeoutClassificationTests", StringComparison.Ordinal))
{
return;
}

context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromMilliseconds(200);
}
}

public class DefaultTimeoutClassificationTests
{
/// <summary>
/// No <c>[Timeout]</c> attribute — timeout is inherited from <c>DefaultTestTimeout</c>.
/// Uses a non-cancellable delay so the timeout path (not cooperative cancellation)
/// fires deterministically, matching the scenario that originally misclassified.
/// </summary>
[Test]
[EngineTest(ExpectedResult.Failure)]
public async Task Hanging_Test_With_DefaultTestTimeout_Should_Timeout()
{
await Task.Delay(TimeSpan.FromSeconds(10));
}
}
Loading
Loading