diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs new file mode 100644 index 0000000000..893376f766 --- /dev/null +++ b/TUnit.Core/Defaults.cs @@ -0,0 +1,32 @@ +namespace TUnit.Core; + +/// +/// Default values shared across TUnit.Core and TUnit.Engine. +/// Centralizes magic numbers so they can be tuned in a single place. +/// +public static class Defaults +{ + /// + /// Default timeout applied to individual tests when no [Timeout] attribute is specified. + /// Can be overridden per-test via . + /// + public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); + + /// + /// Default timeout applied to hook methods (Before/After at every level) + /// when no explicit timeout is configured. + /// + public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); + + /// + /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) + /// before the process is forcefully terminated. + /// + public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); + + /// + /// Brief delay during process exit to allow After hooks registered via + /// to execute before the process terminates. + /// + public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index 7cdb43edad..4236d62080 100644 --- a/TUnit.Core/EngineCancellationToken.cs +++ b/TUnit.Core/EngineCancellationToken.cs @@ -62,7 +62,7 @@ private void Cancel() _forcefulExitStarted = true; // Start a new forceful exit timer - _ = Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None).ContinueWith(t => + _ = Task.Delay(Defaults.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => { if (!t.IsCanceled) { @@ -86,7 +86,7 @@ private void OnProcessExit(object? sender, EventArgs e) // ProcessExit has limited time (~3s on Windows), so we can only wait briefly. // Thread.Sleep is appropriate here: we're on a synchronous event handler thread // and just need a simple delay — no need to involve the task scheduler. - Thread.Sleep(500); + Thread.Sleep(Defaults.ProcessExitHookDelay); } } diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index 96b0fe1246..d12c6f80c5 100644 --- a/TUnit.Core/Executors/DedicatedThreadExecutor.cs +++ b/TUnit.Core/Executors/DedicatedThreadExecutor.cs @@ -349,12 +349,12 @@ public override void Send(SendOrPostCallback d, object? state) var waitTask = Task.Run(async () => { // For .NET Standard 2.0 compatibility, use Task.Delay for timeout - var timeoutTask = Task.Delay(TimeSpan.FromMinutes(30)); + var timeoutTask = Task.Delay(Defaults.TestTimeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); if (completedTask == timeoutTask) { - throw new TimeoutException("Synchronous operation on dedicated thread timed out after 30 minutes"); + throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {Defaults.TestTimeout.TotalMinutes} minutes"); } // Await the actual task to get its result or exception diff --git a/TUnit.Core/Hooks/HookMethod.cs b/TUnit.Core/Hooks/HookMethod.cs index ab73dc6236..9de33ba695 100644 --- a/TUnit.Core/Hooks/HookMethod.cs +++ b/TUnit.Core/Hooks/HookMethod.cs @@ -28,7 +28,7 @@ public abstract record HookMethod /// Gets the timeout for this hook method. This will be set during hook registration /// by the event receiver infrastructure, falling back to the default 5-minute timeout. /// - public TimeSpan? Timeout { get; internal set; } = TimeSpan.FromMinutes(5); + public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout; public required IHookExecutor HookExecutor { get; init; } diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 6d308f8d8f..d34cb77a91 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1040,7 +1040,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM AttributesByType = attributes.ToAttributeDictionary(), MethodGenericArguments = testData.ResolvedMethodGenericArguments, ClassGenericArguments = testData.ResolvedClassGenericArguments, - Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -1133,7 +1133,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = TimeSpan.FromMinutes(30) + Timeout = Core.Defaults.TestTimeout }; } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 2e0b78536a..879384ed8d 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -263,7 +263,7 @@ private async Task GenerateDynamicTests(TestMetadata m ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -391,7 +391,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad ReturnType = typeof(Task), MethodMetadata = resolvedMetadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set Timeout and RetryLimit here - let discovery event receivers set them }; @@ -471,7 +471,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout + Timeout = Core.Defaults.TestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( @@ -523,7 +523,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout + Timeout = Core.Defaults.TestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( diff --git a/TUnit.Engine/Constants/EngineDefaults.cs b/TUnit.Engine/Constants/EngineDefaults.cs new file mode 100644 index 0000000000..25ab46670b --- /dev/null +++ b/TUnit.Engine/Constants/EngineDefaults.cs @@ -0,0 +1,97 @@ +namespace TUnit.Engine.Constants; + +/// +/// Default configuration values for the TUnit test engine. +/// Centralizes magic numbers so they can be tuned in a single place. +/// +internal static class EngineDefaults +{ + // ── Discovery ─────────────────────────────────────────────────────── + + /// + /// Maximum time allowed for test discovery before the operation is cancelled. + /// + public static readonly TimeSpan DiscoveryTimeout = TimeSpan.FromMinutes(5); + + /// + /// Maximum time allowed for test data generation in the discovery circuit breaker + /// before it trips. + /// + public static readonly TimeSpan MaxGenerationTime = TimeSpan.FromMinutes(2); + + /// + /// Maximum proportion of available memory that test data generation may consume + /// before the discovery circuit breaker trips. + /// + public const double MaxMemoryPercentage = 0.7; + + /// + /// Conservative fallback for total available memory when the runtime cannot determine + /// the actual value (e.g., on older .NET runtimes). + /// + public const long FallbackAvailableMemoryBytes = 1024L * 1024L * 1024L; // 1 GB + + /// + /// Maximum number of retry passes when resolving test dependencies. + /// + public const int DependencyResolutionMaxRetries = 3; + + // ── Event Batching ────────────────────────────────────────────────── + + /// + /// Default number of events collected before a batch is flushed. + /// + public const int DefaultEventBatchSize = 100; + + /// + /// Minimum batch delay used when the caller specifies . + /// Prevents a tight spin loop in the batching consumer. + /// + public static readonly TimeSpan MinBatchDelay = TimeSpan.FromMilliseconds(10); + + /// + /// Maximum time to wait for the background processing task to complete + /// during dispose / shutdown. + /// + public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); + + // ── Timeout Handling ──────────────────────────────────────────────── + + /// + /// Grace period given to a timed-out task to handle cancellation + /// before a is thrown. + /// + public static readonly TimeSpan TimeoutGracePeriod = TimeSpan.FromSeconds(1); + + // ── IDE Streaming ─────────────────────────────────────────────────── + + /// + /// Interval at which cumulative test output snapshots are streamed to the IDE. + /// + public static readonly TimeSpan IdeStreamingThrottleInterval = TimeSpan.FromSeconds(1); + + // ── File-Write Retry (Reporters) ──────────────────────────────────── + + /// + /// Maximum number of attempts when writing a report file that may be locked + /// by another process. + /// + public const int FileWriteMaxAttempts = 5; + + /// + /// Base delay in milliseconds for exponential back-off when retrying a locked file write. + /// Actual delay = BaseRetryDelayMs * 2^(attempt-1) + jitter. + /// + public const int BaseRetryDelayMs = 50; + + /// + /// Maximum random jitter in milliseconds added to retry delays to prevent thundering-herd effects. + /// + public const int MaxRetryJitterMs = 50; + + /// + /// Maximum file size in bytes for the GitHub Step Summary file. + /// GitHub imposes a 1 MB limit on step summary files. + /// + public const long GitHubSummaryMaxFileSizeBytes = 1L * 1024L * 1024L; // 1 MB +} diff --git a/TUnit.Engine/Events/EventBatcher.cs b/TUnit.Engine/Events/EventBatcher.cs index fe48107938..e1a35cbf9e 100644 --- a/TUnit.Engine/Events/EventBatcher.cs +++ b/TUnit.Engine/Events/EventBatcher.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using TUnit.Engine.Constants; namespace TUnit.Engine.Events; @@ -14,7 +15,7 @@ internal sealed class EventBatcher : IAsyncDisposable, IDisposable where public EventBatcher( Func, ValueTask> batchProcessor, - int batchSize = 100, + int batchSize = EngineDefaults.DefaultEventBatchSize, TimeSpan maxBatchDelay = default) { _batchProcessor = batchProcessor; @@ -23,10 +24,10 @@ public EventBatcher( SingleReader = true, SingleWriter = false }); - + _processingTask = ProcessBatchesAsync( - batchSize, - maxBatchDelay == TimeSpan.Zero ? TimeSpan.FromMilliseconds(10) : maxBatchDelay, + batchSize, + maxBatchDelay == TimeSpan.Zero ? EngineDefaults.MinBatchDelay : maxBatchDelay, _shutdownCts.Token); } @@ -164,10 +165,10 @@ public async ValueTask DisposeAsync() { // Properly await the task with timeout #if NET6_0_OR_GREATER - await _processingTask.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + await _processingTask.WaitAsync(EngineDefaults.ShutdownTimeout, CancellationToken.None); #else // For .NET Framework, use Task.WhenAny to implement timeout - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(EngineDefaults.ShutdownTimeout); var completedTask = await Task.WhenAny(_processingTask, Task.Delay(Timeout.Infinite, cts.Token)).ConfigureAwait(false); if (completedTask == _processingTask) { diff --git a/TUnit.Engine/Helpers/TimeoutHelper.cs b/TUnit.Engine/Helpers/TimeoutHelper.cs index 2e21204d9f..81ed952d31 100644 --- a/TUnit.Engine/Helpers/TimeoutHelper.cs +++ b/TUnit.Engine/Helpers/TimeoutHelper.cs @@ -1,3 +1,5 @@ +using TUnit.Engine.Constants; + namespace TUnit.Engine.Helpers; /// @@ -8,7 +10,7 @@ internal static class TimeoutHelper /// /// Grace period to allow tasks to handle cancellation before throwing timeout exception. /// - private static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(1); + private static readonly TimeSpan GracePeriod = EngineDefaults.TimeoutGracePeriod; /// /// Executes a ValueTask-returning operation with an optional timeout. diff --git a/TUnit.Engine/Logging/IdeStreamingSink.cs b/TUnit.Engine/Logging/IdeStreamingSink.cs index fc729dab10..0d2e82d6cf 100644 --- a/TUnit.Engine/Logging/IdeStreamingSink.cs +++ b/TUnit.Engine/Logging/IdeStreamingSink.cs @@ -2,6 +2,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using TUnit.Core; using TUnit.Core.Logging; +using TUnit.Engine.Constants; #pragma warning disable TPEXP @@ -33,7 +34,7 @@ internal sealed class IdeStreamingSink : ILogSink, IAsyncDisposable { private readonly TUnitMessageBus _messageBus; private readonly ConcurrentDictionary _activeTests = new(); - private readonly TimeSpan _throttleInterval = TimeSpan.FromSeconds(1); + private readonly TimeSpan _throttleInterval = EngineDefaults.IdeStreamingThrottleInterval; public IdeStreamingSink(TUnitMessageBus messageBus) { diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index eedd2dbc6e..e49c7f9d68 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -7,6 +7,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestHost; using TUnit.Engine.Configuration; +using TUnit.Engine.Constants; using TUnit.Engine.Framework; namespace TUnit.Engine.Reporters; @@ -19,7 +20,7 @@ public enum GitHubReporterStyle public class GitHubReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver { - private const long MaxFileSizeInBytes = 1 * 1024 * 1024; // 1MB + private const long MaxFileSizeInBytes = EngineDefaults.GitHubSummaryMaxFileSizeBytes; private string _outputSummaryFilePath = null!; private GitHubReporterStyle _reporterStyle = GitHubReporterStyle.Collapsible; @@ -248,9 +249,9 @@ private async Task WriteFile(string contents) return; } - const int maxAttempts = 5; + const int maxAttempts = EngineDefaults.FileWriteMaxAttempts; var random = new Random(); - + for (int attempt = 1; attempt <= maxAttempts; attempt++) { try @@ -264,8 +265,8 @@ private async Task WriteFile(string contents) } catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) { - var baseDelay = 50 * Math.Pow(2, attempt - 1); - var jitter = random.Next(0, 50); + var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); + var jitter = random.Next(0, EngineDefaults.MaxRetryJitterMs); var delay = (int)(baseDelay + jitter); Console.WriteLine($"GitHub Summary file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})"); diff --git a/TUnit.Engine/Reporters/JUnitReporter.cs b/TUnit.Engine/Reporters/JUnitReporter.cs index 070b1d4c12..0fda21bca1 100644 --- a/TUnit.Engine/Reporters/JUnitReporter.cs +++ b/TUnit.Engine/Reporters/JUnitReporter.cs @@ -5,6 +5,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestHost; using TUnit.Engine.Configuration; +using TUnit.Engine.Constants; using TUnit.Engine.Framework; using TUnit.Engine.Xml; @@ -123,7 +124,7 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell Directory.CreateDirectory(directory); } - const int maxAttempts = 5; + const int maxAttempts = EngineDefaults.FileWriteMaxAttempts; for (int attempt = 1; attempt <= maxAttempts; attempt++) { @@ -139,8 +140,8 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell } catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) { - var baseDelay = 50 * Math.Pow(2, attempt - 1); - var jitter = Random.Shared.Next(0, 50); + var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); + var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs); var delay = (int)(baseDelay + jitter); Console.WriteLine($"JUnit XML file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})"); diff --git a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs index 67f30787b8..133e9c4a84 100644 --- a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs +++ b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using TUnit.Engine.Constants; namespace TUnit.Engine.Services; @@ -17,11 +18,11 @@ public sealed class DiscoveryCircuitBreaker /// Creates a new discovery circuit breaker with intelligent limits /// public DiscoveryCircuitBreaker( - double maxMemoryPercentage = 0.7, // Use up to 70% of available memory - TimeSpan? maxGenerationTime = null) // Default 2 minutes + double maxMemoryPercentage = EngineDefaults.MaxMemoryPercentage, + TimeSpan? maxGenerationTime = null) { _maxMemoryBytes = (long)(GetAvailableMemoryBytes() * maxMemoryPercentage); - _maxGenerationTime = maxGenerationTime ?? TimeSpan.FromMinutes(2); + _maxGenerationTime = maxGenerationTime ?? EngineDefaults.MaxGenerationTime; _stopwatch = Stopwatch.StartNew(); // Track initial memory to calculate growth @@ -104,7 +105,7 @@ private static long GetAvailableMemoryBytes() } // Conservative fallback: assume 1GB available - return 1024L * 1024L * 1024L; + return EngineDefaults.FallbackAvailableMemoryBytes; } public void Dispose() diff --git a/TUnit.Engine/Services/TestDependencyResolver.cs b/TUnit.Engine/Services/TestDependencyResolver.cs index a8f477affe..e08476a887 100644 --- a/TUnit.Engine/Services/TestDependencyResolver.cs +++ b/TUnit.Engine/Services/TestDependencyResolver.cs @@ -1,5 +1,6 @@ using TUnit.Core; using TUnit.Core.Helpers; +using TUnit.Engine.Constants; namespace TUnit.Engine.Services; @@ -272,7 +273,7 @@ public void ResolveAllDependencies() } } - var maxRetries = 3; + var maxRetries = EngineDefaults.DependencyResolutionMaxRetries; for (var retry = 0; retry < maxRetries && _testsWithPendingDependencies.Count > 0; retry++) { ResolvePendingDependencies(); diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 14d4f3aff3..0d7121abe4 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -6,6 +6,7 @@ using TUnit.Core; using TUnit.Engine.Building; using TUnit.Engine.Building.Interfaces; +using TUnit.Engine.Constants; using TUnit.Engine.Services; namespace TUnit.Engine; @@ -83,7 +84,7 @@ public async Task DiscoverTests(string testSessionId, ITest // Build tests directly from the pre-collected metadata (avoid re-collecting) // Apply 5-minute discovery timeout matching the streaming path (#4715) using var filterCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - filterCts.CancelAfter(TimeSpan.FromMinutes(5)); + filterCts.CancelAfter(EngineDefaults.DiscoveryTimeout); var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null); var tests = await _testBuilderPipeline.BuildTestsFromMetadataAsync( @@ -160,7 +161,7 @@ private async IAsyncEnumerable DiscoverTestsStreamAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromMinutes(5)); + cts.CancelAfter(EngineDefaults.DiscoveryTimeout); var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, metadataFilter: null, cts.Token).ConfigureAwait(false);