perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711)#5728
perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711)#5728
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
Summary
This is a well-targeted performance improvement. The core idea — removing per-test + + overhead for the vast majority of tests that have no explicit timeout — is sound, and the implementation is clean. No previous review comments to address.
Concern 1: Static singleton access in (architectural)
In TestCoordinator.ExecuteTestLifecycleAsync:
var timeouts = TUnitSettings.Default.Timeouts;
TimeSpan? testTimeout = test.Context.Metadata.TestDetails.Timeout;
if (testTimeout is null && timeouts.DefaultTestTimeoutExplicitlySet)
{
testTimeout = timeouts.DefaultTestTimeout;
}TUnitSettings.Default is pulled from the static singleton inside an instance method, which is inconsistent with how TestCoordinator receives everything else (via constructor DI). This makes unit testing this path awkward — you can't substitute a different TimeoutSettings without mutating the global singleton.
Better approach: Inject TimeoutSettings (or just TUnitSettings) in the constructor alongside the other dependencies, and store it as a field:
// constructor
private readonly TimeoutSettings _timeoutSettings;
public TestCoordinator(..., TimeoutSettings timeoutSettings)
{
...
_timeoutSettings = timeoutSettings;
}
// usage
TimeSpan? testTimeout = test.Context.Metadata.TestDetails.Timeout;
if (testTimeout is null && _timeoutSettings.DefaultTestTimeoutExplicitlySet)
{
testTimeout = _timeoutSettings.DefaultTestTimeout;
}Why it matters: The static singleton call embeds a hidden dependency that won't show up in the constructor signature. It also means any future test that exercises this code path without the real singleton in place will silently use the wrong settings.
Concern 2: Public API doc gap
The XML doc on DefaultTestTimeout still says only:
Default: 30 minutes. Overridden per-test by
TimeoutAttribute.
But the behavior has meaningfully changed: leaving DefaultTestTimeout unset now means tests run with no timeout wrapping at all, not with a 30-minute ceiling. A user who discovers a stuck test and reads this doc will expect a 30-minute backstop exists — it no longer does.
Suggested addition to the existing summary:
If this property is never set, tests without a
[Timeout]attribute run without any timeout wrapper (no overhead). The 30-minute fallback only applies when this property is explicitly assigned.
This is especially important because DefaultTestTimeoutExplicitlySet is internal — users have no programmatic way to discover whether the opt-in is active.
Minor: Issue references in code comments
Two comments reference #5711:
TimeoutSettings.cs:// When false, tests without [Timeout] bypass TimeoutHelper entirely (#5711)TestCoordinator.cs:// The built-in 30-minute default is intentionally not applied per-test (#5711)
Per project style, issue numbers belong in commit/PR messages, not in source comments — they rot and become dead links as the repo moves. The why (applying the 30-minute built-in default to every test would be pure overhead) is the valuable part; that can stand alone without the ticket reference.
What's good
- The
DefaultTestTimeoutExplicitlySetflag is the right mechanism: a simple bool on the setter, no extra API surface, no change to the publicDefaultTestTimeouttype. - Removing the seeding from four
TestDetailsconstruction sites consistently is clean — no partial states possible. - The
TestExecutornull-timeout fast path was already in place, so the engine-side change (inTestCoordinator) is the only new logic. - The test plan in the PR description is thorough and covers both the positive path (no-op for unset) and the timeout-fires path (
TimeoutCancellationTokenTests).
There was a problem hiding this comment.
Code Review
Summary
This is a well-targeted performance improvement. The core idea — removing per-test CancellationTokenSource + TaskCompletionSource + Task.WhenAny overhead for the vast majority of tests that have no explicit timeout — is sound, and the implementation is clean. No previous review comments to address.
Concern 1: Static singleton access in TestCoordinator (architectural)
In TestCoordinator.ExecuteTestLifecycleAsync:
var timeouts = TUnitSettings.Default.Timeouts;
TimeSpan? testTimeout = test.Context.Metadata.TestDetails.Timeout;
if (testTimeout is null && timeouts.DefaultTestTimeoutExplicitlySet)
{
testTimeout = timeouts.DefaultTestTimeout;
}TUnitSettings.Default is pulled from the static singleton inside an instance method, which is inconsistent with how TestCoordinator receives everything else (via constructor DI). This makes unit testing this path awkward — you cannot substitute a different TimeoutSettings without mutating the global singleton.
Better approach: Inject TimeoutSettings (or TUnitSettings) in the constructor alongside the other dependencies and store it as a field:
// constructor
private readonly TimeoutSettings _timeoutSettings;
public TestCoordinator(..., TimeoutSettings timeoutSettings)
{
_timeoutSettings = timeoutSettings;
}
// usage
TimeSpan? testTimeout = test.Context.Metadata.TestDetails.Timeout;
if (testTimeout is null && _timeoutSettings.DefaultTestTimeoutExplicitlySet)
{
testTimeout = _timeoutSettings.DefaultTestTimeout;
}Why it matters: The static singleton call embeds a hidden dependency that won't show up in the constructor signature. Any future test exercising this code path without the real singleton in place will silently use the wrong settings.
Concern 2: Public API doc gap
The XML doc on DefaultTestTimeout still says only:
Default: 30 minutes. Overridden per-test by TimeoutAttribute.
But the behavior has meaningfully changed: leaving DefaultTestTimeout unset now means tests run with no timeout wrapping at all, not with a 30-minute ceiling. A user who discovers a hung test and reads this doc will expect a 30-minute backstop — it no longer exists.
Suggested addition to the existing summary:
If this property is never explicitly set, tests without a [Timeout] attribute run without any timeout wrapper. The 30-minute fallback only applies when this property is explicitly assigned.
This is especially important because DefaultTestTimeoutExplicitlySet is internal — users have no programmatic way to discover whether the opt-in is active.
Minor: Issue references in code comments
Two comments reference #5711:
TimeoutSettings.cs:// When false, tests without [Timeout] bypass TimeoutHelper entirely (#5711)TestCoordinator.cs:// The built-in 30-minute default is intentionally not applied per-test (#5711)
Issue numbers belong in commit/PR messages, not in source comments — they become dead references as the repo evolves. The why (applying the 30-minute built-in default to every test would be pure overhead) is the valuable part and can stand alone without the ticket reference.
What's good
- The
DefaultTestTimeoutExplicitlySetflag is the right mechanism: a simple bool set in the property setter, no extra public API surface, no change to theDefaultTestTimeouttype. - Removing the timeout seeding from all four
TestDetailsconstruction sites consistently is clean — no partial states possible. - The
TestExecutornull-timeout fast path was already in place, so the engine-side change inTestCoordinatoris the only new logic needed. - The test plan in the PR description is thorough and covers both the positive path (no overhead for unset) and the timeout-fires path (TimeoutCancellationTokenTests).
|
Pushed
Concern 1 (DI of Verified locally:
Codacy reported 1 minor CodeStyle issue; annotations aren't exposed through GitHub or public API so I couldn't pinpoint it exactly. The revised block replaces the previous mixed |
There was a problem hiding this comment.
Code Review
This is a clean, well-motivated performance optimization. The removal of the per-test CTS + TCS + Task.WhenAny overhead for the common no-timeout case is straightforward and correct. The previous review feedback has been addressed: the XML doc now clearly states the behavioral change, issue-reference comments are gone from source, and the static singleton lookup is deferred until it's actually needed.
What works well
- The null-first check in
TestCoordinatorshort-circuits cleanly:[Timeout]still wins, and the static singleton is only touched whentestTimeout is null. TimeoutAttribute.OnTestStartstill writes toTestDetails.Timeoutdirectly, so the attribute path is completely unaffected.- The fast path in
TestExecutor(lines 213-216) is already in place; this PR correctly feeds itnullwhen no timeout is wanted, so no changes toTestExecutorwere needed. - Removing the 5× repeated
Timeout = TUnitSettings.Default.Timeouts.DefaultTestTimeoutfrom the object initialisers is an unambiguous improvement.
Concern: dual-flag pattern vs. nullable backing field
The _defaultTestTimeoutExplicitlySet boolean exists solely to distinguish "never assigned" from "assigned to 30 minutes". An alternative that eliminates the flag entirely:
// TimeoutSettings.cs
private TimeSpan? _defaultTestTimeout; // null = unset; non-null = user-assigned
public TimeSpan DefaultTestTimeout
{
get => _defaultTestTimeout ?? TimeSpan.FromMinutes(30);
set { ValidatePositive(value); _defaultTestTimeout = value; }
}
internal TimeSpan? ExplicitDefaultTestTimeout => _defaultTestTimeout;// TestCoordinator.cs
testTimeout ??= TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;This removes the boolean flag and makes the intent self-evident: null means "not configured", which is a well-understood .NET idiom. The public get still returns 30 minutes for any user code that reads it programmatically (so it's not a breaking change for consumers), and the internal property surfaces the raw optional.
That said, the current dual-flag approach is not wrong — it's just carrying slightly more complexity than necessary.
Minor: memory visibility of _defaultTestTimeoutExplicitlySet
_defaultTestTimeoutExplicitlySet is a plain bool field with no volatile keyword. In the normal TUnit flow, settings are configured before tests run on separate threads, so in practice there will be a happens-before boundary (likely the thread-pool task handoff). But if a user ever sets DefaultTestTimeout concurrently with test execution starting, the write might not be visible to the reading thread under the ECMA memory model.
Marking it volatile would be the safest fix and has zero runtime cost for a single bool:
private volatile bool _defaultTestTimeoutExplicitlySet;Test coverage gap
The existing tests cover:
- No timeout path (
TimeoutDoesNotFireTests) - Explicit
[Timeout(...)]attribute path
The new code path — TUnitSettings.Timeouts.DefaultTestTimeout explicitly set, no [Timeout] on the test — does not appear to have a dedicated test. The DefaultTestTimeoutExplicitlySet branch in TestCoordinator (lines 324-330) is the novel logic in this PR and is worth covering with a test that:
- Sets
TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromSeconds(1) - Runs a test that takes longer than 1 second
- Asserts it times out
Without this, a regression in that branch would be invisible.
DI deferral acknowledged
The TUnitSettings.Default static access in TestCoordinator is consistent with the rest of the engine and the deferral rationale (half-converted DI would be worse) is sound. +1 for a follow-up issue.
Overall this is a good change — the optimization is real, the correctness reasoning is solid, and the documentation is now accurate. The nullable-backing-field approach and volatile annotation are worth considering, and a test for the DefaultTestTimeout-explicit path would close the coverage gap.
…ield Replaces the dual-state (TimeSpan _defaultTestTimeout + bool _defaultTestTimeoutExplicitlySet) with a single TimeSpan? backing field. The public getter still reports TimeSpan.FromMinutes(30) as the documented default; the new internal ExplicitDefaultTestTimeout exposes the raw optional so the engine's wrap-skip path reads "null means unset" directly via a null-coalescing chain. Addresses the non-blocking review suggestion on #5728 and cleans up the style inconsistency Codacy flagged.
|
Pushed Non-blocking review suggestion (nullable backing field). Replaced the
Codacy ACTION_REQUIRED (1 minor CodeStyle). Same commit. The specific rule isn't exposed through the GitHub annotations pipeline or the public Codacy API for this account, but the rewrite removes the most plausible candidates: the mixed-intent dual-field declaration, the nested Verified locally on net10.0:
|
There was a problem hiding this comment.
Code Review — Round 4
This is a clean, focused performance optimization. After three rounds of review the implementation is solid. Here's the current state:
✅ Previous Concerns Addressed
- Nullable backing field (
TimeSpan? _defaultTestTimeout) cleanly replaced the dualTimeSpan + boolpair — idiomatic .NET and self-documenting. - XML doc on
DefaultTestTimeoutnow clearly explains that leaving the property unset means no timeout wrapper is applied. - Issue references removed from source comments (proper Git-history discipline).
- The
TUnitSettings.Defaultsingleton lookup was moved inside thetestTimeout is nullbranch, so tests with an explicit[Timeout]attribute skip the static read entirely. - DI deferral rationale accepted — consistent with
HookTimeoutHelper,TestScheduler,TestRunner, andTUnitMessageBuswhich all useTUnitSettings.Defaultdirectly.
⚠️ Remaining Concern: Test Coverage Gap
The new logic branch in TestCoordinator.ExecuteTestLifecycleAsync —
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;— still has no dedicated test exercising the right-hand side of the ??. That is: ExplicitDefaultTestTimeout is non-null (user assigned TUnitSettings.Timeouts.DefaultTestTimeout) but no [Timeout] attribute is on the test.
Without this test, a future refactor that accidentally short-circuits ExplicitDefaultTestTimeout won't be caught. Since the whole performance benefit is contingent on getting the precedence right, a test that asserts the explicit default is honoured would make this change more robust.
Observation: Behavioral Change Visibility
This is a silent removal of the implicit 30-minute safety net. Tests that currently rely on that ceiling (e.g. runaway integration tests or accidental infinite loops) will now spin indefinitely unless DefaultTestTimeout is explicitly configured. The change is intentional and well-documented in the DefaultTestTimeout XML doc — worth a prominent entry in the changelog/release notes so adopters aren't surprised when a stuck test no longer self-terminates.
Everything Else Looks Good
- All five
TestDetailsinitialisers consistently drop theTimeout = ...assignment — no stray seeding left behind. ExplicitDefaultTestTimeoutexposes only what's needed (internal, read-only) without leakingTimeSpan?into the public surface.- The comment in
TestCoordinatorcorrectly explains the why (opt-in vs. implicit overhead). - Dual-mode compatibility:
TestDetails.Timeoutis populated by the[Timeout]attribute processor in both source-gen and reflection paths, so null-as-default works correctly in both modes.
Summary: Solid PR with one outstanding item (test for the explicit DefaultTestTimeout path) and one doc recommendation (changelog note). Approve once the test gap is addressed or explicitly deferred with a tracking issue.
|
Added test coverage for the
All three tests reuse the existing |
There was a problem hiding this comment.
Code Review — Round 5
Previous Concerns Status
All concerns from Rounds 3–4 have been addressed:
- ✅ Nullable backing field () replaced the pair
- ✅ internal property is clean and self-documenting
- ✅ XML doc on accurately describes the new behavior
- ✅ Issue references removed from source comments
- ✅ Test coverage added via
TUnitSettingsTests
🐛 Bug: No-retry fast path does NOT apply ExplicitDefaultTestTimeout
TestCoordinator has two separate execution paths, and they handle the timeout inconsistently:
No-retry fast path (line 154, the common case for most tests):
var testTimeout = test.Context.Metadata.TestDetails.Timeout;
// ^ ExplicitDefaultTestTimeout is never consulted here
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);Retry path via ExecuteTestLifecycleAsync (line 379–380):
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout; // ✅ correctBecause the no-retry path was inlined as a hot-path optimisation (introduced in #5714), it needs to be kept in sync with the logic in ExecuteTestLifecycleAsync. Right now it isn't. The effect:
- A user sets
TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromSeconds(30)to catch runaway tests. - Their tests have no
[Retry]attribute (the common case —retryLimit == 0). - They go through the no-retry fast path, which passes
nulltoTestExecutor, and the timeout is silently ignored. - Only tests that also have
[RetryN]actually honour the configured default.
Fix — apply the same coalesce in the inlined fast path:
// line 154 area — no-retry fast path
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);This is the only structural issue remaining.
⚠️ RestoreSettings() cannot fully restore the null state
SnapshotSettings() reads through the public getter:
_savedTestTimeout = TUnitSettings.Default.Timeouts.DefaultTestTimeout;
// returns _defaultTestTimeout ?? TimeSpan.FromMinutes(30) — always non-nullRestoreSettings() then writes back that value through the public setter, which sets _defaultTestTimeout to TimeSpan.FromMinutes(30) (non-null) rather than restoring it to null. After TUnitSettingsTests runs, TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout is permanently TimeSpan.FromMinutes(30) for the rest of the process.
The test comment acknowledges this and works around it by using new TimeoutSettings() — that's correct for ExplicitDefaultTestTimeout_Is_Null_When_Unset. But once the bug above (no-retry path) is fixed, every subsequent test without [Timeout] running in the same process will see a 30-minute timeout applied via ExplicitDefaultTestTimeout.
A clean fix would be an internal reset escape hatch:
// TimeoutSettings.cs
internal void ResetDefaultTestTimeout() => _defaultTestTimeout = null;// TUnitSettingsTests.cs — RestoreSettings()
if (_savedExplicitTestTimeout.HasValue)
TUnitSettings.Default.Timeouts.DefaultTestTimeout = _savedExplicitTestTimeout.Value;
else
TUnitSettings.Default.Timeouts.ResetDefaultTestTimeout();Changelog note (carried from Round 4)
The silent removal of the implicit 30-minute safety net is still worth a release-notes entry. Users who relied on that backstop to surface stuck tests will find them spinning indefinitely unless they opt in via DefaultTestTimeout.
What's good
- The new tests are well-structured:
ExplicitDefaultTestTimeout_Is_Null_When_Unsetcorrectly uses a fresh instance to sidestep the singleton-restore problem. hangCts.Cancel()in thefinallyblock ofConfigured_Default_Timeout_Fires_On_Hanging_Testprevents a 30-second leak — good hygiene.- The
[NotInParallel]class attribute prevents concurrent singleton mutation. Correct. - The internal-only
ExplicitDefaultTestTimeoutproperty keeps the nullable state from leaking into the public API.
Summary: One real bug (no-retry fast path skips ExplicitDefaultTestTimeout) and one test-isolation issue (restore cannot recover null). The no-retry bug should be fixed before merge; the isolation issue becomes important only once that fix lands.
There was a problem hiding this comment.
Code Review — Round 5
Previous Concerns Status
All concerns from Rounds 3–4 have been addressed:
- Nullable backing field (TimeSpan? _defaultTestTimeout) replaced the TimeSpan + bool pair
- ExplicitDefaultTestTimeout internal property is clean and self-documenting
- XML doc on DefaultTestTimeout accurately describes the new behavior
- Issue references removed from source comments
- Test coverage added via TUnitSettingsTests
Bug: No-retry fast path does NOT apply ExplicitDefaultTestTimeout
TestCoordinator has two separate execution paths, and they handle the timeout inconsistently.
No-retry fast path (line 154, the common case for most tests):
var testTimeout = test.Context.Metadata.TestDetails.Timeout;
// ExplicitDefaultTestTimeout is never consulted here
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);Retry path via ExecuteTestLifecycleAsync (line 379-380):
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout; // correctBecause the no-retry path was inlined as a hot-path optimization (introduced in #5714), it needs to be kept in sync with the logic in ExecuteTestLifecycleAsync. Right now it is not. The effect:
- A user sets
TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromSeconds(30)to catch runaway tests. - Their tests have no
[Retry]attribute (the common case -retryLimit == 0). - They go through the no-retry fast path, which passes null to
TestExecutor, and the configured timeout is silently ignored. - Only tests that also carry
[RetryN]actually honour the configured default.
Fix - apply the same coalesce in the inlined fast path (around line 154):
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);This is the only structural issue remaining.
RestoreSettings() cannot fully restore the null state
SnapshotSettings() reads through the public getter:
_savedTestTimeout = TUnitSettings.Default.Timeouts.DefaultTestTimeout;
// returns _defaultTestTimeout ?? TimeSpan.FromMinutes(30) — always non-nullRestoreSettings() then writes that value back through the public setter, setting _defaultTestTimeout to TimeSpan.FromMinutes(30) (non-null) rather than restoring it to null. After TUnitSettingsTests runs, TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout is permanently TimeSpan.FromMinutes(30) for the rest of the process lifetime.
The test comment acknowledges this and works around it by using new TimeoutSettings() - that is the right move for ExplicitDefaultTestTimeout_Is_Null_When_Unset. But once the bug above (no-retry path) is fixed, every subsequent test without [Timeout] running in the same process after this class will silently see a 30-minute timeout applied via ExplicitDefaultTestTimeout, defeating the optimization.
A clean fix would be an internal reset escape hatch:
// TimeoutSettings.cs
internal void ResetDefaultTestTimeout() => _defaultTestTimeout = null;Then in SnapshotSettings() capture ExplicitDefaultTestTimeout (nullable) and in RestoreSettings() call ResetDefaultTestTimeout() when the snapshot was null, or assign the saved value when it was not.
Changelog note (carried from Round 4, still not addressed)
The silent removal of the implicit 30-minute safety net is worth a release-notes entry. Users who relied on that backstop to surface stuck integration tests will find them spinning indefinitely unless they opt in via DefaultTestTimeout.
What's good
- The new tests are well-structured:
ExplicitDefaultTestTimeout_Is_Null_When_Unsetcorrectly uses a fresh instance to sidestep the singleton-restore limitation. hangCts.Cancel()in thefinallyblock ofConfigured_Default_Timeout_Fires_On_Hanging_Testprevents a 30-second task leak.- The
[NotInParallel]class attribute prevents concurrent singleton mutation. Correct. ExplicitDefaultTestTimeoutbeing internal keeps the nullable state from leaking into the public API.
Summary: One real bug (no-retry fast path skips ExplicitDefaultTestTimeout) and one test-isolation issue (restore cannot recover null state). The no-retry bug should be fixed before merge; the isolation issue becomes important to fix once that bug is corrected.
|
Round 5 review follow-up — 7cc64e7. Primary finding (inlined no-retry fast path in TestCoordinator) — not reproducible. var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;Both branches of the Secondary finding (Snapshot/Restore leaks state) — real, fixed in 7cc64e7.
Verified
If the "line ~154 inlined fast path" finding was based on a local branch or a different PR, please point me at the exact commit/file and I'll take another look. |
There was a problem hiding this comment.
Code Review: PR #5728 — perf: skip TimeoutHelper wrap when no explicit [Timeout] is set
Summary
The goal of this PR is sound and the performance motivation is real: eliminating a LinkedCancellationTokenSource + TaskCompletionSource + Task.WhenAny triplet per test for a 30-minute backstop that almost nobody triggers is a worthwhile optimization. The overall shape of the solution — nullable backing field, ExplicitDefaultTestTimeout internal getter, public getter that coalesces to the documented 30-minute default, and SetExplicitDefaultTestTimeout for test-harness restore — is clean and well-reasoned. The snapshot/restore regression fix and the new unit tests add genuine value.
That said, there is one confirmed bug that silently breaks a user-visible contract, and a secondary reporting gap that will cause confusing output in tooling.
Bug 1 (Blocker): No-retry fast path ignores DefaultTestTimeout entirely
File: TUnit.Engine/Services/TestExecution/TestCoordinator.cs
The two execution paths in ExecuteTestAsync are not equivalent:
// No-retry fast path (retryLimit == 0) — THE HOT PATH, used by the vast majority of tests
var testTimeout = test.Context.Metadata.TestDetails.Timeout; // ← only [Timeout] attribute
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);
// Retry path — dispatches through ExecuteTestLifecycleAsync
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout; // ← correct coalesce
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout);Impact: A user who writes:
// In a [Before(HookType.TestDiscovery)] hook:
context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5);...gets the timeout only on tests that also carry [Retry]. Plain tests without [Retry] — the overwhelming majority — get no timeout at all, despite the user's explicit opt-in. retryLimit == 0 is literally the "everyone by default" path.
Fix — one-line change to the no-retry path:
var testTimeout = test.Context.Metadata.TestDetails.Timeout
?? TUnitSettings.Default.Timeouts.ExplicitDefaultTestTimeout;Note: The new Configured_Default_Timeout_Fires_On_Hanging_Test test calls TimeoutHelper.ExecuteWithTimeoutAsync directly and validates the ExplicitDefaultTestTimeout property value — it does not route through TestCoordinator and therefore cannot catch this regression. A meaningful integration test would set DefaultTestTimeout, register a hanging test body, and verify it times out via the coordinator's no-retry path.
Bug 2 (Minor): Timeout failures via DefaultTestTimeout are misreported as generic errors
File: TUnit.Engine/TUnitMessageBus.cs
if (category == FailureCategory.Timeout
&& testContext.Metadata.TestDetails.Timeout != null // ← null when timeout came from DefaultTestTimeout
&& duration >= testContext.Metadata.TestDetails.Timeout.Value)
{
return new TimeoutTestNodeStateProperty(...);
}When a test times out via DefaultTestTimeout (rather than a [Timeout] attribute), TestDetails.Timeout is null because the timeout value is resolved at TestCoordinator level and never written back to TestDetails. The condition fails and the test is reported as ErrorTestNodeStateProperty rather than TimeoutTestNodeStateProperty. Downstream consumers — JUnit XML writer, GitHub reporter, HTML report — key off TimeoutTestNodeStateProperty for categorisation and will silently misclassify the failure as a generic error.
Recommended fix (Option A — preferred): Write the effective timeout back to TestDetails at the coordinator, right after resolving it:
// After resolving testTimeout in both paths:
test.Context.Metadata.TestDetails.Timeout ??= testTimeout; // [Timeout] attribute wins; fills the gap for DefaultTestTimeoutThis is self-contained — the timeout value is already known at coordination time and belongs on the TestDetails it describes, avoiding a second static-singleton access inside the message bus.
Design Note: SetExplicitDefaultTestTimeout naming
File: TUnit.Core/Settings/TimeoutSettings.cs
The method is internal and commented // Test-only seam, which is appropriate. A cleaner name would make the intent unambiguous and avoid the question of whether callers should ever pass a non-null value:
internal void ResetDefaultTestTimeout() => _defaultTestTimeout = null;Non-blocking — just a readability/discoverability note.
What is good about this PR
- The nullable backing field (
TimeSpan? _defaultTestTimeout) is the right primitive for modelling "explicitly unset". The previous bool-flag pattern was redundant state. ExplicitDefaultTestTimeoutcleanly separates the "engine needs to know if it was set" concern from the "user reads the documented default" concern — no magic numbers duplicated.- The snapshot/restore fix in
TUnitSettingsTestsis a real bug fix (the public getter was permanently pinning the backing field after the first test ran), andSnapshotRestore_Preserves_Unset_DefaultTestTimeoutis a solid regression guard. - The XML-doc update on
DefaultTestTimeoutnow correctly describes the "no wrap unless explicitly set" semantic. - Removing the
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeoutinitialisation from all fiveTestDetailsconstruction sites is the right layering decision — pre-seeding at build time was wrong; the coordinator is the correct place to apply a project-level default. - No AOT or source-gen concerns: the new accessors are pure data with no reflection.
Recommended actions before merge
- Fix
TestCoordinator.csno-retry path (Bug 1) — one-line change, hard blocker. - Fix
TUnitMessageBus.cstimeout categorisation (Bug 2) — Option A recommended. - Add an integration test that goes through the coordinator's no-retry path with
DefaultTestTimeoutset, to prevent Bug 1 from regressing silently.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.39.0 to 1.40.5. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.40.5 <!-- Release notes generated using configuration in .github/release.yml at v1.40.5 --> ## What's Changed ### Other Changes * Fix reflection property injection reuse by @thomhurst in thomhurst/TUnit#5763 * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (#5765) by @thomhurst in thomhurst/TUnit#5767 ### Dependencies * chore(deps): update tunit to 1.40.0 by @thomhurst in thomhurst/TUnit#5762 **Full Changelog**: thomhurst/TUnit@v1.40.0...v1.40.5 ## 1.40.0 <!-- Release notes generated using configuration in .github/release.yml at v1.40.0 --> ## What's Changed ### Other Changes * perf(engine): collapse async forwarding wrappers in test execution (#5714) by @thomhurst in thomhurst/TUnit#5725 * perf(engine): skip Console.Out/Err FlushAsync when no output captured (#5712) by @thomhurst in thomhurst/TUnit#5724 * perf(engine): collapse async state machines on hook cache-hit / empty-hook path (#5713) by @thomhurst in thomhurst/TUnit#5726 * perf: eliminate per-test closure + GetOrAdd factory alloc (#5710) by @thomhurst in thomhurst/TUnit#5727 * perf(engine): replace global lock in EventReceiverRegistry with lock-free CAS by @thomhurst in thomhurst/TUnit#5731 * perf(engine): batch per-test overhead cleanups (#5719) by @thomhurst in thomhurst/TUnit#5730 * #5733 handling all arguments for Fact and Theory by @inyutin-maxim in thomhurst/TUnit#5734 * fix(assertions): prefer string overload of Member() over IEnumerable<char> (#5702) by @thomhurst in thomhurst/TUnit#5721 * fix(migration): preserve comments/XML docs when removing sole attributes (#5698) by @thomhurst in thomhurst/TUnit#5739 * perf(build): trim test TFMs and skip viewer dump by default by @thomhurst in thomhurst/TUnit#5741 * fix(pipeline): skip TestBaseModule frameworks with missing binaries by @thomhurst in thomhurst/TUnit#5752 * feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo (#5732) by @thomhurst in thomhurst/TUnit#5747 * fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302 (#5722) by @thomhurst in thomhurst/TUnit#5746 * perf(engine): lazy hook metadata registration (#5448) by @thomhurst in thomhurst/TUnit#5750 * chore(templates): unify TUnit version pinning to 1.* (#5709) by @thomhurst in thomhurst/TUnit#5743 * fix(templates): floating TUnit.Aspire version (#5708) by @thomhurst in thomhurst/TUnit#5742 * fix(assertions): preserve specialised source in .Count(itemAssertion) (#5707) by @thomhurst in thomhurst/TUnit#5749 * feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720) by @thomhurst in thomhurst/TUnit#5751 * feat(aspire): add ability to manually remove resources by @Odonno in thomhurst/TUnit#5586 * fix(fscheck): register default CancellationToken arbitrary that surfaces TestContext token by @JohnVerheij in thomhurst/TUnit#5758 * fix(engine): allow keyed NotInParallel tests to run alongside unconstrained tests (#5700) by @thomhurst in thomhurst/TUnit#5740 * perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711) by @thomhurst in thomhurst/TUnit#5728 ### Dependencies * chore(deps): update tunit to 1.39.0 by @thomhurst in thomhurst/TUnit#5701 * chore(deps): update aspire to 13.2.4 by @thomhurst in thomhurst/TUnit#5735 * chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by @dependabot[bot] in thomhurst/TUnit#5736 * chore(deps): update dependency fscheck to 3.3.3 by @thomhurst in thomhurst/TUnit#5760 ## New Contributors * @inyutin-maxim made their first contribution in thomhurst/TUnit#5734 * @Odonno made their first contribution in thomhurst/TUnit#5586 **Full Changelog**: thomhurst/TUnit@v1.39.0...v1.40.0 Commits viewable in [compare view](thomhurst/TUnit@v1.39.0...v1.40.5). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.37.0 to 1.40.10. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.40.10 <!-- Release notes generated using configuration in .github/release.yml at v1.40.10 --> ## What's Changed ### Other Changes * refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit by @thomhurst in thomhurst/TUnit#5774 ### Dependencies * chore(deps): update tunit to 1.40.5 by @thomhurst in thomhurst/TUnit#5769 **Full Changelog**: thomhurst/TUnit@v1.40.5...v1.40.10 ## 1.40.5 <!-- Release notes generated using configuration in .github/release.yml at v1.40.5 --> ## What's Changed ### Other Changes * Fix reflection property injection reuse by @thomhurst in thomhurst/TUnit#5763 * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (#5765) by @thomhurst in thomhurst/TUnit#5767 ### Dependencies * chore(deps): update tunit to 1.40.0 by @thomhurst in thomhurst/TUnit#5762 **Full Changelog**: thomhurst/TUnit@v1.40.0...v1.40.5 ## 1.40.0 <!-- Release notes generated using configuration in .github/release.yml at v1.40.0 --> ## What's Changed ### Other Changes * perf(engine): collapse async forwarding wrappers in test execution (#5714) by @thomhurst in thomhurst/TUnit#5725 * perf(engine): skip Console.Out/Err FlushAsync when no output captured (#5712) by @thomhurst in thomhurst/TUnit#5724 * perf(engine): collapse async state machines on hook cache-hit / empty-hook path (#5713) by @thomhurst in thomhurst/TUnit#5726 * perf: eliminate per-test closure + GetOrAdd factory alloc (#5710) by @thomhurst in thomhurst/TUnit#5727 * perf(engine): replace global lock in EventReceiverRegistry with lock-free CAS by @thomhurst in thomhurst/TUnit#5731 * perf(engine): batch per-test overhead cleanups (#5719) by @thomhurst in thomhurst/TUnit#5730 * #5733 handling all arguments for Fact and Theory by @inyutin-maxim in thomhurst/TUnit#5734 * fix(assertions): prefer string overload of Member() over IEnumerable<char> (#5702) by @thomhurst in thomhurst/TUnit#5721 * fix(migration): preserve comments/XML docs when removing sole attributes (#5698) by @thomhurst in thomhurst/TUnit#5739 * perf(build): trim test TFMs and skip viewer dump by default by @thomhurst in thomhurst/TUnit#5741 * fix(pipeline): skip TestBaseModule frameworks with missing binaries by @thomhurst in thomhurst/TUnit#5752 * feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo (#5732) by @thomhurst in thomhurst/TUnit#5747 * fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302 (#5722) by @thomhurst in thomhurst/TUnit#5746 * perf(engine): lazy hook metadata registration (#5448) by @thomhurst in thomhurst/TUnit#5750 * chore(templates): unify TUnit version pinning to 1.* (#5709) by @thomhurst in thomhurst/TUnit#5743 * fix(templates): floating TUnit.Aspire version (#5708) by @thomhurst in thomhurst/TUnit#5742 * fix(assertions): preserve specialised source in .Count(itemAssertion) (#5707) by @thomhurst in thomhurst/TUnit#5749 * feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720) by @thomhurst in thomhurst/TUnit#5751 * feat(aspire): add ability to manually remove resources by @Odonno in thomhurst/TUnit#5586 * fix(fscheck): register default CancellationToken arbitrary that surfaces TestContext token by @JohnVerheij in thomhurst/TUnit#5758 * fix(engine): allow keyed NotInParallel tests to run alongside unconstrained tests (#5700) by @thomhurst in thomhurst/TUnit#5740 * perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711) by @thomhurst in thomhurst/TUnit#5728 ### Dependencies * chore(deps): update tunit to 1.39.0 by @thomhurst in thomhurst/TUnit#5701 * chore(deps): update aspire to 13.2.4 by @thomhurst in thomhurst/TUnit#5735 * chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by @dependabot[bot] in thomhurst/TUnit#5736 * chore(deps): update dependency fscheck to 3.3.3 by @thomhurst in thomhurst/TUnit#5760 ## New Contributors * @inyutin-maxim made their first contribution in thomhurst/TUnit#5734 * @Odonno made their first contribution in thomhurst/TUnit#5586 **Full Changelog**: thomhurst/TUnit@v1.39.0...v1.40.0 ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 ## 1.37.10 <!-- Release notes generated using configuration in .github/release.yml at v1.37.10 --> ## What's Changed ### Other Changes * docs(test-filters): add migration callout for --filter → --treenode-filter by @johnkattenhorn in thomhurst/TUnit#5628 * fix: re-enable RPC tests and modernize harness (#5540) by @thomhurst in thomhurst/TUnit#5632 * fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch (#5626) by @JohnVerheij in thomhurst/TUnit#5631 * ci: use setup-dotnet built-in NuGet cache by @thomhurst in thomhurst/TUnit#5635 * feat(playwright): propagate W3C trace context into browser contexts by @thomhurst in thomhurst/TUnit#5636 ### Dependencies * chore(deps): update tunit to 1.37.0 by @thomhurst in thomhurst/TUnit#5625 ## New Contributors * @johnkattenhorn made their first contribution in thomhurst/TUnit#5628 * @JohnVerheij made their first contribution in thomhurst/TUnit#5631 **Full Changelog**: thomhurst/TUnit@v1.37.0...v1.37.10 Commits viewable in [compare view](thomhurst/TUnit@v1.37.0...v1.40.10). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Closes #5711
Summary
Every test was running through
TimeoutHelper.ExecuteWithTimeoutAsyncusing the built-in 30-minute default, paying a linkedCancellationTokenSource+TaskCompletionSource+Task.WhenAnycost per test for a ceiling almost no one relies on.TestBuilderandTestBuilderPipelineno longer seedTestDetails.Timeoutfrom the default.TestCoordinatoronly feedsTestExecutora non-null timeout when either a[Timeout]attribute setTestDetails.Timeout, or the user opted into a session-wide default viaTUnitSettings.Timeouts.DefaultTestTimeout.TestExecutor's existing null-timeout fast path then invokes the test directly with no wrapping overhead.User-visible behavior is preserved:
[Timeout(...)]still enforces its duration (verified againstTimeoutCancellationTokenTests).DefaultTestTimeoutoverrides are tracked via a new internalDefaultTestTimeoutExplicitlySetflag so they continue to apply to every test.Test plan
dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release -f net10.0— cleanTUnit.Core.SourceGenerator.Tests— 116 passed, 0 failed (1 pre-existing skip)TUnit.TestProjectBasicTestsfilter — 3 passedTimeoutDoesNotFireTests(positive path) — passesTimeoutCancellationTokenTests/BasicTestwith explicit[Timeout(5000)]— still correctly times out viaTimeoutHelper