From 7b22ff829cc2ef47d9df79961f08844c5c679fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 20:55:27 +0200 Subject: [PATCH 1/8] fix(runtime): correct Windows-shaped assumptions on Linux and macOS SIGTERM, the normal "please stop" signal on Unix (docker stop, Kubernetes, CI cancellation), was left to the runtime: it raised ProcessExit, which gave After hooks 500ms before the process died with 143. ForcefulExitTimeout is documented as covering SIGTERM but never applied. SIGTERM is now handled like Ctrl+C through PosixSignalRegistration on Unix. Microsoft.Testing.Platform does not observe SIGTERM, so its run token stays live and a re-thrown cancellation would crash the host with 134; the session is failed cleanly instead. Also: - STAThreadExecutor throws a TUnitException naming the remedy instead of a bare PlatformNotSupportedException off Windows. - RunOn/ExcludeOn checked Linux and macOS only under #if NET, so the netstandard2.0 build (.NET Framework on Mono) ignored them. - PathValidator's containment check folded case on every platform and accepted /foo/barbaz as inside /foo/bar. - Report writers' file-lock retry never recognised Unix contention: HResult is the raw errno there, and EWOULDBLOCK is 11 on Linux but 35 on macOS. The three copies are now one FileLockDetection helper. - GitHubReporter appended to GITHUB_STEP_SUMMARY under a shared lock, so on Unix parallel test processes could write at the same offset; it now takes the file exclusively and retries. Link text for a Windows-built path is the file name. - IsNotExecutable judged by extension on Unix; it now reads the mode bits. IsSystem/IsArchived/IsNotSystem document that they only mean something on Windows. - Stack-trace filtering split on Environment.NewLine, so a trace captured on another OS was not filtered. - The mocks publicizer de-duplicated reference paths case-insensitively on case-sensitive file systems. - Templates: drop the Windows-only IIS Express profile from the ASP.NET template; the Playwright template fails loudly when browser install fails. Every new test was checked to fail against the previous code. Co-Authored-By: Claude Opus 5.5 --- .../DirectoryInfoAssertionExtensions.cs | 1 + .../Conditions/FileInfoAssertionExtensions.cs | 2 + .../Conditions/FileSystemAssertions.cs | 28 ++++++- .../TestMetadata/ExcludeOnAttribute.cs | 3 +- .../Attributes/TestMetadata/RunOnAttribute.cs | 6 +- src/TUnit.Core/EngineCancellationToken.cs | 69 ++++++++++++++++- src/TUnit.Core/Executors/STAThreadExecutor.cs | 14 +++- .../Exceptions/TUnitFailedException.cs | 7 +- .../Framework/TUnitTestFramework.cs | 13 +++- src/TUnit.Engine/Helpers/FileLockDetection.cs | 39 ++++++++++ src/TUnit.Engine/Helpers/PathValidator.cs | 27 ++++++- src/TUnit.Engine/Reporters/GitHubReporter.cs | 29 ++++--- .../Reporters/Html/HtmlReporter.cs | 15 +--- src/TUnit.Engine/Reporters/JUnitReporter.cs | 13 +--- .../PublicizeAssemblyReferences.cs | 5 +- .../WebApp/Properties/launchSettings.json | 16 ---- .../content/TUnit.Playwright/Hooks.cs | 7 +- .../FileSystemAssertionTests.cs | 27 +++++++ .../TUnit.Engine.Tests/GitHubReporterTests.cs | 71 ++++++++++++++++++ tests/TUnit.Engine.Tests/SigtermTests.cs | 75 +++++++++++++++++++ .../StackTraceFilterTests.cs | 17 +++++ .../PublicizeAssemblyReferencesTaskTests.cs | 26 +++++++ .../WebApp/Properties/launchSettings.json | 16 ---- .../TUnit.Playwright/Hooks.cs | 7 +- .../SigtermCancellationTests.cs | 39 ++++++++++ .../TUnit.UnitTests/FileLockDetectionTests.cs | 43 +++++++++++ .../OperatingSystemAttributeTests.cs | 56 ++++++++++++++ tests/TUnit.UnitTests/PathValidatorTests.cs | 50 +++++++++++++ .../TUnit.UnitTests/STAThreadExecutorTests.cs | 23 ++++++ 29 files changed, 654 insertions(+), 90 deletions(-) create mode 100644 src/TUnit.Engine/Helpers/FileLockDetection.cs create mode 100644 tests/TUnit.Engine.Tests/SigtermTests.cs create mode 100644 tests/TUnit.TestProject/SigtermCancellationTests.cs create mode 100644 tests/TUnit.UnitTests/FileLockDetectionTests.cs create mode 100644 tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs create mode 100644 tests/TUnit.UnitTests/STAThreadExecutorTests.cs diff --git a/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs b/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs index 4bc0f8e3be7..10be28209b8 100644 --- a/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs +++ b/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs @@ -24,6 +24,7 @@ file static partial class DirectoryInfoAssertionExtensions public static bool IsHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == true; [GenerateAssertion(ExpectationMessage = "to not be hidden", InlineMethodBody = true)] public static bool IsNotHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == false; + /// The System attribute only exists on Windows; on Linux and macOS this never holds. [GenerateAssertion(ExpectationMessage = "to be a system directory", InlineMethodBody = true)] public static bool IsSystemDirectory(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true; } diff --git a/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs b/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs index fdea55dcebb..7dc6a814f67 100644 --- a/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs +++ b/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs @@ -26,8 +26,10 @@ file static partial class FileInfoAssertionExtensions public static bool IsEmpty(this FileInfo value) => value?.Length == 0; [GenerateAssertion(ExpectationMessage = "to not be empty", InlineMethodBody = true)] public static bool IsNotEmpty(this FileInfo value) => value != null && value.Length > 0; + /// The System attribute only exists on Windows; on Linux and macOS this never holds. [GenerateAssertion(ExpectationMessage = "to be a system file", InlineMethodBody = true)] public static bool IsSystemFile(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true; + /// The Archive attribute is only maintained on Windows; on Linux and macOS this never holds. [GenerateAssertion(ExpectationMessage = "to be archived", InlineMethodBody = true)] public static bool IsArchived(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.Archive) == true; } diff --git a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs index a81eb8f5a32..d8e86e55861 100644 --- a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -91,6 +91,9 @@ protected override Task CheckAsync(EvaluationMetadata +/// The System attribute only exists on Windows; on Linux and macOS this assertion always passes. +/// [AssertionExtension("IsNotSystem")] public class FileIsNotSystemAssertion : Assertion { @@ -132,6 +135,10 @@ protected override Task CheckAsync(EvaluationMetadata protected override string GetExpectation() => "to not be a system file"; } +/// +/// On Linux and macOS a file counts as executable when any execute permission bit is set. On Windows, +/// and on .NET Framework, it is judged by extension (.exe, .bat, .cmd, .com, .sh, .ps1). +/// [AssertionExtension("IsNotExecutable")] public class FileIsNotExecutableAssertion : Assertion { @@ -162,10 +169,23 @@ protected override Task CheckAsync(EvaluationMetadata return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' does not exist")); } - var executableExtensions = new[] { ".exe", ".bat", ".cmd", ".com", ".sh", ".ps1" }; - var isExecutable = executableExtensions.Contains(value.Extension.ToLowerInvariant()); +#if NET + // On Unix, executability is the file mode, not the name: a 0755 "myapp" is executable and + // a 0644 "notes.sh" is not. + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsBrowser()) + { + const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + + if ((value.UnixFileMode & anyExecute) != 0) + { + return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (mode: {value.UnixFileMode})")); + } - if (isExecutable) + return AssertionResult._passedTask; + } +#endif + + if (ExecutableExtensions.Contains(value.Extension, StringComparer.OrdinalIgnoreCase)) { return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (extension: {value.Extension})")); } @@ -173,6 +193,8 @@ protected override Task CheckAsync(EvaluationMetadata return AssertionResult._passedTask; } + private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"]; + protected override string GetExpectation() => "to not be executable"; } diff --git a/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs b/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs index dff08478920..ca96bbf1cb7 100644 --- a/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs +++ b/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs @@ -53,10 +53,9 @@ public override Task ShouldSkip(TestRegisteredContext context) // Check if the current platform matches any of the excluded operating systems var shouldSkip = OperatingSystem.HasFlag(OS.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows) -#if NET - // Only validate Linux and macOS on .NET 5+ where these OS flags are available || (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) || (OperatingSystem.HasFlag(OS.MacOs) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) +#if NET || (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser()) #endif ; diff --git a/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs b/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs index 45444c9c512..5022f994b4a 100644 --- a/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs +++ b/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs @@ -53,8 +53,9 @@ public override Task ShouldSkip(TestRegisteredContext context) { return Task.FromResult(false); } -#if NET - // Only validate Linux and macOS on .NET 5+ where these OS flags are available + + // RuntimeInformation.IsOSPlatform is available on netstandard2.0 too, so .NET Framework + // test projects running under Mono on Linux or macOS must reach these checks. if (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { return Task.FromResult(false); @@ -65,6 +66,7 @@ public override Task ShouldSkip(TestRegisteredContext context) return Task.FromResult(false); } +#if NET // Check for Browser platform (WebAssembly) if (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser()) { diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index b4715fdbf9e..c0911efb6e7 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -1,3 +1,6 @@ +#if NET +using System.Runtime.InteropServices; +#endif using System.Runtime.Versioning; using TUnit.Core.Settings; @@ -22,6 +25,21 @@ public class EngineCancellationToken : IDisposable private volatile bool _forcefulExitStarted; private CancellationTokenRegistration _platformRegistration; private bool _cancelKeyPressSubscribed; +#if NET + private PosixSignalRegistration? _sigtermRegistration; + private volatile bool _terminationSignalReceived; +#endif + + /// + /// Whether the run was cancelled by SIGTERM. Microsoft.Testing.Platform does not observe that + /// signal, so unlike Ctrl+C its own run token is not cancelled alongside ours. + /// + internal bool TerminationSignalReceived => +#if NET + _terminationSignalReceived; +#else + false; +#endif public EngineCancellationToken() { @@ -29,7 +47,7 @@ public EngineCancellationToken() } /// - /// Hooks up process-wide cancellation signals (Ctrl+C / ProcessExit) the first time it's + /// Hooks up process-wide cancellation signals (Ctrl+C / SIGTERM / ProcessExit) the first time it's /// called for this instance. Idempotent — subsequent calls are no-ops so that concurrent /// MTP server-mode RPCs against one session don't clobber each other's cancellation chain. /// Per-call cancellation flows through the explicit CancellationToken threaded into @@ -67,8 +85,50 @@ internal void Initialise(CancellationToken platformCancellationToken = default) #if NET5_0_OR_GREATER } #endif + +#if NET + _sigtermRegistration = TryRegisterSigterm(); +#endif + } + +#if NET + /// + /// SIGTERM is how Unix asks a process to stop (docker stop, Kubernetes, CI cancellation, + /// timeout). Left to the runtime it only raises , which + /// gives After hooks before the process + /// dies. Handling it like Ctrl+C instead cancels the run and allows the full + /// for cleanup. Windows is left alone: + /// there the signal maps to console close/shutdown events, which the runtime already handles. + /// + private PosixSignalRegistration? TryRegisterSigterm() + { + // Android, iOS and tvOS do not deliver POSIX signals to managed code. + if (OperatingSystem.IsWindows() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi() + || OperatingSystem.IsAndroid() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS()) + { + return null; + } + + try + { + return PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSigterm); + } + catch (PlatformNotSupportedException) + { + return null; + } } + private void OnSigterm(PosixSignalContext context) + { + // Prevent the default behaviour (immediate termination); the forceful-exit timer still + // guarantees the process ends. + context.Cancel = true; + _terminationSignalReceived = true; + Cancel(); + } +#endif + /// /// Subscribes to . Platforms without console signals /// (such as Android, iOS and tvOS) throw ; there the @@ -144,7 +204,8 @@ private void Cancel(bool armForcefulExit = true) private void OnProcessExit(object? sender, EventArgs e) { - // Process is exiting (SIGTERM, kill, etc.) - trigger cancellation to execute After hooks + // Process is exiting (Environment.Exit, end of Main, or a signal not handled above) - trigger + // cancellation to execute After hooks. // Note: ProcessExit runs on a background thread with limited time (~3 seconds on Windows) // The After hooks registered via CancellationToken.Register() will execute when we cancel if (!CancellationTokenSource.IsCancellationRequested) @@ -165,6 +226,10 @@ private void OnProcessExit(object? sender, EventArgs e) public void Dispose() { _platformRegistration.Dispose(); +#if NET + _sigtermRegistration?.Dispose(); + _sigtermRegistration = null; +#endif // Console.CancelKeyPress is not supported on browser platforms #if NET5_0_OR_GREATER diff --git a/src/TUnit.Core/Executors/STAThreadExecutor.cs b/src/TUnit.Core/Executors/STAThreadExecutor.cs index 86be08a7ce7..cc4b063cb4a 100644 --- a/src/TUnit.Core/Executors/STAThreadExecutor.cs +++ b/src/TUnit.Core/Executors/STAThreadExecutor.cs @@ -1,4 +1,6 @@ -using System.Runtime.Versioning; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using TUnit.Core.Exceptions; namespace TUnit.Core; @@ -7,6 +9,16 @@ public class STAThreadExecutor : DedicatedThreadExecutor { protected override void ConfigureThread(Thread thread) { + // [SupportedOSPlatform] only warns at compile time; without this guard a test run on + // Linux or macOS gets a bare PlatformNotSupportedException that names neither the + // executor nor the fix. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + throw new TUnitException( + $"{nameof(STAThreadExecutor)} requires Windows: STA apartment threads are not supported on {RuntimeInformation.OSDescription}. " + + "Restrict the test with [RunOn(OS.Windows)] or use a different executor on this platform."); + } + thread.SetApartmentState(ApartmentState.STA); } } diff --git a/src/TUnit.Engine/Exceptions/TUnitFailedException.cs b/src/TUnit.Engine/Exceptions/TUnitFailedException.cs index cd18f06b7b9..566e4a3bec4 100644 --- a/src/TUnit.Engine/Exceptions/TUnitFailedException.cs +++ b/src/TUnit.Engine/Exceptions/TUnitFailedException.cs @@ -77,9 +77,12 @@ internal static string FilterStackTrace(string? stackTrace) ReadOnlySpan pendingSeparator = default; var hasPendingSeparator = false; - foreach (var range in span.Split(Environment.NewLine)) + // Split on '\n' and trim '\r' rather than splitting on Environment.NewLine: a trace captured + // on another OS (CRLF read on Unix, LF read on Windows) would otherwise be one long line and + // nothing would be filtered. + foreach (var range in span.Split('\n')) { - var slice = span[range]; + var slice = span[range].TrimEnd('\r'); var trimmed = slice.Trim(); if (IsTUnitInternalFrame(trimmed)) diff --git a/src/TUnit.Engine/Framework/TUnitTestFramework.cs b/src/TUnit.Engine/Framework/TUnitTestFramework.cs index 0158f893ca0..068f15a94e9 100644 --- a/src/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/src/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -80,10 +80,21 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) } catch (Exception e) when (IsCancellationException(e)) { + var serviceProvider = GetOrCreateServiceProvider(context); + + if (!context.CancellationToken.IsCancellationRequested && serviceProvider.CancellationToken.TerminationSignalReceived) + { + // MTP only treats the exception as a cancellation when its own token fired, which + // SIGTERM does not do; re-throwing would crash the host. Fail the session instead. + await serviceProvider.Logger.LogErrorAsync("The test run was cancelled by SIGTERM."); + serviceProvider.SessionFailed = true; + return; + } + var message = context.CancellationToken.IsCancellationRequested ? "The test run was cancelled." : "Test execution stopped due to fail-fast."; - await GetOrCreateServiceProvider(context).Logger.LogErrorAsync(message); + await serviceProvider.Logger.LogErrorAsync(message); // Re-throw is safe here — MTP handles OperationCanceledException specially. throw; diff --git a/src/TUnit.Engine/Helpers/FileLockDetection.cs b/src/TUnit.Engine/Helpers/FileLockDetection.cs new file mode 100644 index 00000000000..71e28952aa4 --- /dev/null +++ b/src/TUnit.Engine/Helpers/FileLockDetection.cs @@ -0,0 +1,39 @@ +using System.Runtime.InteropServices; + +namespace TUnit.Engine.Helpers; + +/// +/// Recognises the thrown when another process holds a file, so report +/// writers can retry instead of giving up. +/// +internal static class FileLockDetection +{ + // Windows: the low word of the HResult is the Win32 error. + private const int ErrorSharingViolation = 0x20; + private const int ErrorLockViolation = 0x21; + + // Unix: .NET takes an advisory flock for FileShare and reports contention with the raw errno + // as the HResult. EWOULDBLOCK (== EAGAIN) differs between platforms. + private const int EWouldBlockLinux = 11; + private const int EWouldBlockMacOS = 35; + + internal static bool IsFileLocked(IOException exception) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var errorCode = exception.HResult & 0xFFFF; + + if (errorCode is ErrorSharingViolation or ErrorLockViolation) + { + return true; + } + } + else if (exception.HResult == (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? EWouldBlockMacOS : EWouldBlockLinux)) + { + return true; + } + + // Fallback heuristic for hosts that map the error differently. + return exception.Message.Contains("being used by another process"); + } +} diff --git a/src/TUnit.Engine/Helpers/PathValidator.cs b/src/TUnit.Engine/Helpers/PathValidator.cs index ac6ca09946c..7539ac2acf6 100644 --- a/src/TUnit.Engine/Helpers/PathValidator.cs +++ b/src/TUnit.Engine/Helpers/PathValidator.cs @@ -1,6 +1,7 @@ #if NET8_0_OR_GREATER using System.Buffers; #endif +using System.Runtime.InteropServices; namespace TUnit.Engine.Helpers; @@ -92,7 +93,7 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa { var currentDir = Path.GetFullPath(Directory.GetCurrentDirectory()); - if (!fullPath.StartsWith(currentDir, StringComparison.OrdinalIgnoreCase)) + if (!IsWithinDirectory(fullPath, currentDir)) { throw new ArgumentException( $"Relative path resolves outside the current working directory and is not allowed: '{validatedPath}'", @@ -103,6 +104,30 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa return fullPath; } + /// + /// Whether is itself or lies beneath it. + /// Requires a separator at the boundary, so /foo/barbaz is not inside /foo/bar, and + /// folds case only on Windows: on a case-sensitive filesystem /foo/Bar is a different directory. + /// + internal static bool IsWithinDirectory(string fullPath, string directory) + { + var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + // A root ("/" or "C:\") trims to "" or "C:", after which the boundary check still holds. + var root = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (!fullPath.StartsWith(root, comparison)) + { + return false; + } + + return fullPath.Length == root.Length + || fullPath[root.Length] == Path.DirectorySeparatorChar + || fullPath[root.Length] == Path.AltDirectorySeparatorChar; + } + private static bool ContainsPathTraversal(string path) { // Check for ".." segments which indicate path traversal diff --git a/src/TUnit.Engine/Reporters/GitHubReporter.cs b/src/TUnit.Engine/Reporters/GitHubReporter.cs index 9348d130211..bdf9ebcafe1 100644 --- a/src/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/src/TUnit.Engine/Reporters/GitHubReporter.cs @@ -532,14 +532,22 @@ private async Task WriteFile(string contents) { try { + // FileShare.None rather than File.AppendAllText: on Unix the latter takes only a shared + // lock, so parallel test processes could append at the same offset and overwrite each + // other. An exclusive lock makes contenders fail and go through the retry below. + var bytes = Encoding.UTF8.GetBytes(contents); + using (var stream = new FileStream(_outputSummaryFilePath, FileMode.Append, FileAccess.Write, FileShare.None)) + { #if NET - await File.AppendAllTextAsync(_outputSummaryFilePath, contents, Encoding.UTF8); + await stream.WriteAsync(bytes); #else - File.AppendAllText(_outputSummaryFilePath, contents, Encoding.UTF8); + await stream.WriteAsync(bytes, 0, bytes.Length); #endif + } + return; } - catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) + catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex)) { var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); var jitter = random.Next(0, EngineDefaults.MaxRetryJitterMs); @@ -551,17 +559,6 @@ private async Task WriteFile(string contents) } } - private static bool IsFileLocked(IOException exception) - { - // Check if the exception is due to the file being locked/in use - // HResult 0x80070020 is ERROR_SHARING_VIOLATION on Windows - // HResult 0x80070021 is ERROR_LOCK_VIOLATION on Windows - var errorCode = exception.HResult & 0xFFFF; - return errorCode == 0x20 || errorCode == 0x21 || - exception.Message.Contains("being used by another process") || - exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); - } - private string GetDetails(IProperty? stateProperty, PropertyBag properties) { #pragma warning disable CS0618, MTP0001 // Retained for TUnit's GitHub cancellation reporting @@ -665,7 +662,9 @@ private static string GetTestDisplayName(TestNode testNode) // TUnit stores source line numbers 1-based (via [CallerLineNumber] / Roslyn span + 1), // and they flow into LineSpan.Start.Line unchanged — so it is already 1-based here. var line = fileLocation.LineSpan.Start.Line; - var fileName = Path.GetFileName(fileLocation.FilePath); + // Normalise first: an assembly built on Windows and run on Linux carries a backslash-separated + // path, which Path.GetFileName does not split there. + var fileName = Path.GetFileName(fileLocation.FilePath.Replace('\\', '/')); return $"[{fileName}:{line}]({serverUrl}/{repo}/blob/{sha}/{filePath}#L{line})"; } diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 1622dbebf63..3691685d03c 100644 --- a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -1119,7 +1119,7 @@ private static async Task WriteFileAsync(string path, string content, Canc Console.WriteLine($"HTML test report written to: {path}"); return true; } - catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) + catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex)) { var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs); @@ -1134,19 +1134,6 @@ private static async Task WriteFileAsync(string path, string content, Canc return false; } - private static bool IsFileLocked(IOException exception) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var errorCode = exception.HResult & 0xFFFF; - return errorCode is 0x20 or 0x21; // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION - } - - // On POSIX, concurrent writers are less common; fallback to message heuristic - return exception.Message.Contains("being used by another process") || - exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); - } - /// The uploaded artifact's URL, when the in-process upload succeeded. private async Task TryGitHubIntegrationAsync(string filePath, CancellationToken cancellationToken) { diff --git a/src/TUnit.Engine/Reporters/JUnitReporter.cs b/src/TUnit.Engine/Reporters/JUnitReporter.cs index 1aa23f0218b..0af6384dac3 100644 --- a/src/TUnit.Engine/Reporters/JUnitReporter.cs +++ b/src/TUnit.Engine/Reporters/JUnitReporter.cs @@ -150,7 +150,7 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell Console.WriteLine($"JUnit XML report written to: {path}"); return; } - catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) + catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex)) { var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs); @@ -163,15 +163,4 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell Console.WriteLine($"Failed to write JUnit XML report to: {path} after {maxAttempts} attempts"); } - - private static bool IsFileLocked(IOException exception) - { - // Check if the exception is due to the file being locked/in use - // HResult 0x80070020 is ERROR_SHARING_VIOLATION on Windows - // HResult 0x80070021 is ERROR_LOCK_VIOLATION on Windows - var errorCode = exception.HResult & 0xFFFF; - return errorCode == 0x20 || errorCode == 0x21 || - exception.Message.Contains("being used by another process") || - exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs index 93fc08ce911..34928892e2d 100644 --- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs +++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs @@ -107,7 +107,7 @@ public override bool Execute() // has no way to single one out. Same-identity duplicates collapse onto one copy. var (identityGroups, unreadableMatches) = GroupMatchesByIdentity(matches); - if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) + if (matches.Select(m => m.ItemSpec).Distinct(PathComparer).Count() > 1) { var unreadableNote = unreadableMatches.Count > 0 ? " References whose assembly identity cannot be read are left in place: " + @@ -499,6 +499,9 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference) StringComparison.OrdinalIgnoreCase; #endif + private static StringComparer PathComparer => + PathComparison == StringComparison.OrdinalIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + private static bool IsReferenceAssembly(string path) { using var module = ModuleDefinition.ReadModule(path); diff --git a/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json b/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json index ae6d71d792c..0c9f604f9c9 100644 --- a/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json +++ b/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json @@ -1,13 +1,5 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:24820", - "sslPort": 44340 - } - }, "profiles": { "http": { "commandName": "Project", @@ -28,14 +20,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs b/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs index 33b3d22be84..421477b8c2a 100644 --- a/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs +++ b/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs @@ -12,6 +12,11 @@ public static void InstallPlaywright() Environment.SetEnvironmentVariable("PWDEBUG", "1"); } - Microsoft.Playwright.Program.Main(["install"]); + var exitCode = Microsoft.Playwright.Program.Main(["install"]); + if (exitCode != 0) + { + // On Linux, browsers also need system libraries: run `playwright install --with-deps` once. + throw new InvalidOperationException($"Playwright browser installation failed with exit code {exitCode}."); + } } } diff --git a/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs b/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs index 601e16b35a2..7eec6125c32 100644 --- a/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs +++ b/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs @@ -111,6 +111,33 @@ public async Task Test_FileInfo_IsNotExecutable() await Assert.That(file).IsNotExecutable(); } +#if NET + [Test] + [ExcludeOn(OS.Windows)] + [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] + public async Task Test_FileInfo_IsNotExecutable_Fails_For_Executable_Mode_Without_Extension() + { + var file = new FileInfo(Path.Combine(_testDirectory, "myapp")); + File.WriteAllText(file.FullName, "#!/bin/sh"); + File.SetUnixFileMode(file.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + await Assert.That(async () => await Assert.That(file).IsNotExecutable()) + .Throws(); + } + + [Test] + [ExcludeOn(OS.Windows)] + [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] + public async Task Test_FileInfo_IsNotExecutable_Passes_For_Script_Without_Execute_Bit() + { + var file = new FileInfo(Path.Combine(_testDirectory, "notes.sh")); + File.WriteAllText(file.FullName, "echo hi"); + File.SetUnixFileMode(file.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + await Assert.That(file).IsNotExecutable(); + } +#endif + [Test] public async Task Test_FileInfo_HasExtension() { diff --git a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs index d8c31e7d26b..07b7407549f 100644 --- a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs +++ b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs @@ -392,6 +392,77 @@ public async Task AfterRunAsync_SourceLink_Uses_OneBased_Line_Without_Extra_Incr } } + [Test] + public async Task AfterRunAsync_SourceLink_Text_Is_The_File_Name_For_A_Windows_Built_Path() + { + // An assembly built on Windows and run on Linux/macOS carries a backslash-separated source + // path, which Path.GetFileName does not split there. + var (reporter, outputFile) = await SetupReporter(); + Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", "thomhurst/TUnit"); + Environment.SetEnvironmentVariable("GITHUB_SHA", "abc123"); + Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", "https://github.com"); + + try + { + var message = new TestNodeUpdateMessage( + sessionUid: new SessionUid("test-session"), + testNode: new TestNode + { + Uid = new TestNodeUid("loc-win"), + DisplayName = "FailingTest", + Properties = new PropertyBag( + new FailedTestNodeStateProperty(new Exception("boom"), "boom"), + new TestMethodIdentifierProperty( + @namespace: "TestNamespace", + assemblyFullName: "TestAssembly", + typeName: "SampleTests", + methodName: "FailingTest", + parameterTypeFullNames: [], + returnTypeFullName: "System.Void", + methodArity: 0), + new TestFileLocationProperty( + @"D:\a\TUnit\src\SampleTests.cs", + new LinePositionSpan(new LinePosition(12, 0), new LinePosition(20, 0)))) + }); + + await FeedTestMessages(reporter, message); + await reporter.AfterRunAsync(1, CancellationToken.None); + + var output = await File.ReadAllTextAsync(outputFile); + output.ShouldContain("[SampleTests.cs:12]("); + } + finally + { + Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", null); + Environment.SetEnvironmentVariable("GITHUB_SHA", null); + Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", null); + } + } + + [Test] + public async Task AfterRunAsync_Waits_For_Another_Writer_Holding_The_Summary_File() + { + // Parallel test processes append to one GITHUB_STEP_SUMMARY. The reporter must take the + // file exclusively and retry, rather than append alongside another writer: on Unix a + // shared-lock append from two processes can land at the same offset and overwrite. + var (reporter, outputFile) = await SetupReporter(); + await FeedTestMessages(reporter, CreateFailedTestMessage("w1", "FailingTest", "SampleTests", new Exception("boom"))); + + Task afterRun; + using (new FileStream(outputFile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + { + afterRun = reporter.AfterRunAsync(1, CancellationToken.None); + await Task.Delay(100); + + afterRun.IsCompleted.ShouldBeFalse("the reporter wrote while another writer held the file"); + } + + await afterRun; + + var output = await File.ReadAllTextAsync(outputFile); + output.ShouldContain("FailingTest"); + } + private string CreateTempFile() { var path = Path.GetTempFileName(); diff --git a/tests/TUnit.Engine.Tests/SigtermTests.cs b/tests/TUnit.Engine.Tests/SigtermTests.cs new file mode 100644 index 00000000000..f3ea40445a4 --- /dev/null +++ b/tests/TUnit.Engine.Tests/SigtermTests.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Text; +using CliWrap; +using Shouldly; +using TUnit.Core.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// SIGTERM is how Unix asks a process to stop (docker stop, Kubernetes, CI cancellation). It must +/// cancel the run gracefully like Ctrl+C — After hooks get the full shutdown window — rather than +/// leave the runtime to end the process after a brief ProcessExit delay. +/// +[ExcludeOn(OS.Windows)] +public class SigtermTests +{ + private static readonly string NetVersion = Environment.GetEnvironmentVariable("NET_VERSION") ?? "net10.0"; + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Sigterm_Cancels_Gracefully_And_Runs_After_Hooks(bool reflection, CancellationToken cancellationToken) + { + var markerDirectory = Path.Combine(Path.GetTempPath(), $"TUnit_Sigterm_{Guid.NewGuid():N}"); + var startedMarker = Path.Combine(markerDirectory, "started.txt"); + var afterMarker = Path.Combine(markerDirectory, "after.txt"); + + var testProject = Sourcy.DotNet.Projects.TUnit_TestProject; + var executable = Path.Combine(testProject.DirectoryName!, "bin", "Release", NetVersion, "TUnit.TestProject"); + + var environmentVariables = EnvironmentVariables.DisableHtmlReporterForChildProcess(); + environmentVariables["TUNIT_SIGTERM_MARKER_DIR"] = markerDirectory; + + var output = new StringBuilder(); + using var forcefulCancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + var commandTask = Cli.Wrap(executable) + .WithArguments(["--treenode-filter", "/*/*/SigtermCancellationTests/*", .. reflection ? ["--reflection"] : Array.Empty()]) + .WithWorkingDirectory(testProject.DirectoryName!) + .WithEnvironmentVariables(environmentVariables) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(output)) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(output)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(forcefulCancellation.Token); + + try + { + while (!File.Exists(startedMarker)) + { + commandTask.Task.IsCompleted.ShouldBeFalse($"The test process exited before the test started:{Environment.NewLine}{output}"); + await Task.Delay(100, cancellationToken); + } + + await Cli.Wrap("kill") + .WithArguments(["-TERM", commandTask.ProcessId.ToString(CultureInfo.InvariantCulture)]) + .ExecuteAsync(cancellationToken); + + var result = await commandTask; + + File.Exists(afterMarker).ShouldBeTrue($"The After hook did not complete:{Environment.NewLine}{output}"); + output.ToString().ShouldContain("The test run was cancelled by SIGTERM."); + + // 128 + signal: 143 = killed by SIGTERM itself, 134 = aborted on an unhandled exception. + result.ExitCode.ShouldNotBe(143); + result.ExitCode.ShouldNotBe(134); + } + finally + { + if (Directory.Exists(markerDirectory)) + { + Directory.Delete(markerDirectory, recursive: true); + } + } + } +} diff --git a/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs b/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs index 23148ee6cb7..de47b227b4c 100644 --- a/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs +++ b/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs @@ -233,4 +233,21 @@ public void FilteredTrace_HintHasCorrectFormat() lines[0].ShouldContain("MyApp.Tests.MyTest"); lines[1].ShouldContain("--detailed-stacktrace"); } + + [Test] + [Arguments("\r\n")] + [Arguments("\n")] + public void Trace_With_Either_Line_Ending_Is_Filtered(string lineEnding) + { + // A trace captured on another OS keeps that OS's line endings. + var stackTrace = string.Join(lineEnding, + " at MyApp.Tests.MyTest() in /src/Tests.cs:line 15", + " at TUnit.Core.RunHelpers.RunAsync()"); + + var result = TUnitFailedException.FilterStackTrace(stackTrace); + + result.ShouldContain("MyApp.Tests.MyTest"); + result.ShouldNotContain("TUnit.Core.RunHelpers"); + result.ShouldNotContain("\r" + Environment.NewLine); + } } diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs index 700aa682e98..4c8ed5125ec 100644 --- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs +++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs @@ -199,6 +199,32 @@ public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004() await Assert.That(supersededPaths).Contains(duplicate); } + [Test] + public async Task Paths_Differing_Only_In_Case_Are_Distinct_References_On_A_Case_Sensitive_FileSystem() + { + var root = NewScratchDirectory(); + var lowerDirectory = Path.Combine(root, "deps"); + var upperDirectory = Path.Combine(root, "DEPS"); + Directory.CreateDirectory(lowerDirectory); + + // Windows, and macOS's default APFS volume, fold case: there both paths name one file. + Skip.When(Directory.Exists(upperDirectory), "Requires a case-sensitive file system."); + + Directory.CreateDirectory(upperDirectory); + var lower = Path.Combine(lowerDirectory, TargetLibName + ".dll"); + var upper = Path.Combine(upperDirectory, TargetLibName + ".dll"); + File.Copy(TargetLibPath, lower); + File.Copy(TargetLibPath, upper); + + var engine = new StubBuildEngine(); + var task = CreateTask(NewScratchDirectory(), TargetLibName); + task.BuildEngine = engine; + task.ReferencePaths = [new TaskItem(lower), new TaskItem(upper)]; + + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(engine.Warnings.Select(w => w.Code)).Contains("TUMIA004"); + } + [Test] public async Task Duplicate_Requests_Produce_Single_Publicized_Reference() { diff --git a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json index ae6d71d792c..0c9f604f9c9 100644 --- a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json +++ b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json @@ -1,13 +1,5 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:24820", - "sslPort": 44340 - } - }, "profiles": { "http": { "commandName": "Project", @@ -28,14 +20,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs index fdd5131d43d..a6e603b111f 100644 --- a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs +++ b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs @@ -12,6 +12,11 @@ public static void InstallPlaywright() Environment.SetEnvironmentVariable("PWDEBUG", "1"); } - Microsoft.Playwright.Program.Main(["install"]); + var exitCode = Microsoft.Playwright.Program.Main(["install"]); + if (exitCode != 0) + { + // On Linux, browsers also need system libraries: run `playwright install --with-deps` once. + throw new InvalidOperationException($"Playwright browser installation failed with exit code {exitCode}."); + } } } diff --git a/tests/TUnit.TestProject/SigtermCancellationTests.cs b/tests/TUnit.TestProject/SigtermCancellationTests.cs new file mode 100644 index 00000000000..890f3f93039 --- /dev/null +++ b/tests/TUnit.TestProject/SigtermCancellationTests.cs @@ -0,0 +1,39 @@ +namespace TUnit.TestProject; + +/// +/// Driven by TUnit.Engine.Tests' SigtermTests, which sets TUNIT_SIGTERM_MARKER_DIR and sends SIGTERM +/// once the test has started. Without that variable the test returns immediately. +/// +[Timeout(300_000)] +public class SigtermCancellationTests +{ + private static readonly string? MarkerDirectory = Environment.GetEnvironmentVariable("TUNIT_SIGTERM_MARKER_DIR"); + + [Test] + public async Task Test_That_Runs_Until_Sigterm(CancellationToken cancellationToken) + { + if (MarkerDirectory is null) + { + return; + } + + Directory.CreateDirectory(MarkerDirectory); + await File.WriteAllTextAsync(Path.Combine(MarkerDirectory, "started.txt"), "Test started", cancellationToken); + + await Task.Delay(TimeSpan.FromHours(1), cancellationToken); + } + + [After(Test)] + public async Task Slow_Cleanup() + { + if (MarkerDirectory is null) + { + return; + } + + // Longer than ProcessExitHookDelay (500ms), so it only completes when SIGTERM is handled as a + // graceful cancellation rather than left to the runtime's process-exit path. + await Task.Delay(TimeSpan.FromSeconds(2)); + await File.WriteAllTextAsync(Path.Combine(MarkerDirectory, "after.txt"), "After hook executed"); + } +} diff --git a/tests/TUnit.UnitTests/FileLockDetectionTests.cs b/tests/TUnit.UnitTests/FileLockDetectionTests.cs new file mode 100644 index 00000000000..5d6fbcd2464 --- /dev/null +++ b/tests/TUnit.UnitTests/FileLockDetectionTests.cs @@ -0,0 +1,43 @@ +using TUnit.Assertions.Extensions; +using TUnit.Engine.Helpers; + +namespace TUnit.UnitTests; + +public class FileLockDetectionTests +{ + [Test] + public async Task Contention_On_An_Exclusively_Held_File_Is_Detected() + { + var path = Path.Combine(Path.GetTempPath(), $"tunit-lock-{Guid.NewGuid():N}.txt"); + + try + { + using var holder = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + + IOException? caught = null; + try + { + using var contender = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.None); + } + catch (IOException e) + { + caught = e; + } + + await Assert.That(caught).IsNotNull(); + await Assert.That(FileLockDetection.IsFileLocked(caught!)).IsTrue(); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task Unrelated_IOException_Is_Not_Treated_As_Locked() + { + var exception = new FileNotFoundException("missing", "nope.txt"); + + await Assert.That(FileLockDetection.IsFileLocked(exception)).IsFalse(); + } +} diff --git a/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs b/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs new file mode 100644 index 00000000000..1adfdb19fd0 --- /dev/null +++ b/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs @@ -0,0 +1,56 @@ +using System.Runtime.InteropServices; +using TUnit.Assertions.Extensions; +using TUnit.Core.Enums; + +namespace TUnit.UnitTests; + +public class OperatingSystemAttributeTests +{ + private static OS? CurrentOS => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OS.Windows + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? OS.Linux + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OS.MacOs + : null; + + private static OS OtherDesktopOSes(OS current) => (OS.Windows | OS.Linux | OS.MacOs) & ~current; + + [Test] + public async Task RunOn_Current_OS_Does_Not_Skip() + { + Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS."); + + var skip = await new RunOnAttribute(CurrentOS!.Value).ShouldSkip(null!); + + await Assert.That(skip).IsFalse(); + } + + [Test] + public async Task RunOn_Other_OSes_Skips() + { + Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS."); + + var skip = await new RunOnAttribute(OtherDesktopOSes(CurrentOS!.Value)).ShouldSkip(null!); + + await Assert.That(skip).IsTrue(); + } + + [Test] + public async Task ExcludeOn_Current_OS_Skips() + { + Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS."); + + var skip = await new ExcludeOnAttribute(CurrentOS!.Value).ShouldSkip(null!); + + await Assert.That(skip).IsTrue(); + } + + [Test] + public async Task ExcludeOn_Other_OSes_Does_Not_Skip() + { + Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS."); + + var skip = await new ExcludeOnAttribute(OtherDesktopOSes(CurrentOS!.Value)).ShouldSkip(null!); + + await Assert.That(skip).IsFalse(); + } +} diff --git a/tests/TUnit.UnitTests/PathValidatorTests.cs b/tests/TUnit.UnitTests/PathValidatorTests.cs index 40c143d19ca..63dc2ae5d2d 100644 --- a/tests/TUnit.UnitTests/PathValidatorTests.cs +++ b/tests/TUnit.UnitTests/PathValidatorTests.cs @@ -1,4 +1,5 @@ using TUnit.Assertions.Extensions; +using TUnit.Core.Enums; using TUnit.Engine.Helpers; namespace TUnit.UnitTests; @@ -71,4 +72,53 @@ public async Task SanitizeFileName_LongNameWithInvalidChar_ExercisesHeapBranch() await Assert.That(result).IsEqualTo(prefix); } + + [Test] + public async Task IsWithinDirectory_Child_IsInside() + { + var root = Path.GetFullPath(Path.GetTempPath()); + var child = Path.Combine(root, "reports", "out.html"); + + await Assert.That(PathValidator.IsWithinDirectory(child, root)).IsTrue(); + } + + [Test] + public async Task IsWithinDirectory_DirectoryItself_IsInside() + { + var root = Path.GetFullPath(Path.GetTempPath()); + + await Assert.That(PathValidator.IsWithinDirectory(root, root)).IsTrue(); + } + + [Test] + public async Task IsWithinDirectory_SiblingSharingPrefix_IsOutside() + { + var parent = Path.GetFullPath(Path.GetTempPath()); + var directory = Path.Combine(parent, "bar"); + var sibling = Path.Combine(parent, "barbaz", "file.txt"); + + await Assert.That(PathValidator.IsWithinDirectory(sibling, directory)).IsFalse(); + } + + [Test] + [ExcludeOn(OS.Windows)] + public async Task IsWithinDirectory_DifferentlyCasedSibling_IsOutside_On_CaseSensitive_Platforms() + { + await Assert.That(PathValidator.IsWithinDirectory("/work/Project/file.txt", "/work/project")).IsFalse(); + } + + [Test] + [RunOn(OS.Windows)] + public async Task IsWithinDirectory_FoldsCase_On_Windows() + { + await Assert.That(PathValidator.IsWithinDirectory(@"C:\Work\Project\file.txt", @"c:\work\project")).IsTrue(); + } + + [Test] + public async Task IsWithinDirectory_FileSystemRoot_ContainsEverything() + { + var root = Path.GetPathRoot(Path.GetFullPath(Path.GetTempPath()))!; + + await Assert.That(PathValidator.IsWithinDirectory(Path.GetFullPath(Path.GetTempPath()), root)).IsTrue(); + } } diff --git a/tests/TUnit.UnitTests/STAThreadExecutorTests.cs b/tests/TUnit.UnitTests/STAThreadExecutorTests.cs new file mode 100644 index 00000000000..0bce69bca29 --- /dev/null +++ b/tests/TUnit.UnitTests/STAThreadExecutorTests.cs @@ -0,0 +1,23 @@ +using TUnit.Assertions.Extensions; +using TUnit.Core.Enums; +using TUnit.Core.Exceptions; + +namespace TUnit.UnitTests; + +public class STAThreadExecutorTests +{ + [Test] + [ExcludeOn(OS.Windows)] + public async Task Off_Windows_Throws_A_TUnitException_Naming_The_Remedy() + { +#pragma warning disable CA1416 // Deliberately exercising the Windows-only executor off Windows. + var executor = new STAThreadExecutor(); + + var exception = await Assert.That(() => executor.ExecuteBeforeTestDiscoveryHook(null!, null!, () => default).AsTask()) + .Throws(); +#pragma warning restore CA1416 + + await Assert.That(exception!.Message).Contains(nameof(STAThreadExecutor)); + await Assert.That(exception.Message).Contains("[RunOn(OS.Windows)]"); + } +} From b8c01a91ddc98630c408c331737f17cf93be4f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:22:03 +0200 Subject: [PATCH 2/8] fix(engine): treat FreeBSD's EWOULDBLOCK like macOS's; document SIGTERM exit FreeBSD shares the BSD errno numbering, so its EWOULDBLOCK is 35 as on macOS, not Linux's 11. OSPlatform.FreeBSD is not in netstandard2.0, hence OSPlatform.Create("FREEBSD"). The programmatic-configuration reference now states that on Linux and macOS SIGTERM cancels like Ctrl+C and ends the run as a failed session (exit code 10). Co-Authored-By: Claude Opus 5.5 --- docs/docs/reference/programmatic-configuration.md | 2 ++ src/TUnit.Engine/Helpers/FileLockDetection.cs | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index ad9ee85c755..ac0509d3fc3 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -57,6 +57,8 @@ Settings are accessed exclusively through `context.Settings` in the discovery ho | `ForcefulExitTimeout` | `TimeSpan` | 30 seconds | Grace period before the process is forcefully terminated after a cancellation. | | `ProcessExitHookDelay` | `TimeSpan` | 500 ms | Delay before process-exit hooks run, allowing pending I/O to flush. | +On Linux and macOS, SIGTERM (sent by `docker stop`, Kubernetes, and CI cancellation) cancels the run the same way as Ctrl+C: After hooks run within `ForcefulExitTimeout`, and the run ends as a failed test session with exit code 10. + ### `context.Settings.Parallelism` | Property | Type | Default | Description | diff --git a/src/TUnit.Engine/Helpers/FileLockDetection.cs b/src/TUnit.Engine/Helpers/FileLockDetection.cs index 71e28952aa4..f183abee608 100644 --- a/src/TUnit.Engine/Helpers/FileLockDetection.cs +++ b/src/TUnit.Engine/Helpers/FileLockDetection.cs @@ -13,9 +13,12 @@ internal static class FileLockDetection private const int ErrorLockViolation = 0x21; // Unix: .NET takes an advisory flock for FileShare and reports contention with the raw errno - // as the HResult. EWOULDBLOCK (== EAGAIN) differs between platforms. + // as the HResult. EWOULDBLOCK (== EAGAIN) is 11 on Linux and 35 on the BSD family (macOS, FreeBSD). private const int EWouldBlockLinux = 11; - private const int EWouldBlockMacOS = 35; + private const int EWouldBlockBsd = 35; + + // OSPlatform.FreeBSD is not part of netstandard2.0. + private static readonly OSPlatform FreeBSD = OSPlatform.Create("FREEBSD"); internal static bool IsFileLocked(IOException exception) { @@ -28,7 +31,7 @@ internal static bool IsFileLocked(IOException exception) return true; } } - else if (exception.HResult == (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? EWouldBlockMacOS : EWouldBlockLinux)) + else if (exception.HResult == (IsBsd() ? EWouldBlockBsd : EWouldBlockLinux)) { return true; } @@ -36,4 +39,7 @@ internal static bool IsFileLocked(IOException exception) // Fallback heuristic for hosts that map the error differently. return exception.Message.Contains("being used by another process"); } + + private static bool IsBsd() => + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(FreeBSD); } From 77820d70d594eee64ba99ef0d96d86c6a924a83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:26:58 +0200 Subject: [PATCH 3/8] fix(core): arm the forceful-exit timer before running cancellation callbacks Cancel() cancelled the token first and armed the forceful-exit timer after. CancellationTokenSource.Cancel runs registered callbacks, including After hooks, synchronously, so a hook that blocked delayed the very deadline meant to bound it. This applied to Ctrl+C already and now also to SIGTERM. The timer is now armed first. Arming is a virtual method so a test can observe the ordering without terminating the test process; the new test fails with the old order. Co-Authored-By: Claude Opus 5.5 --- src/TUnit.Core/EngineCancellationToken.cs | 47 ++++++++++--------- .../EngineCancellationTokenTests.cs | 38 +++++++++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index c0911efb6e7..53e2e28420a 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -124,6 +124,11 @@ private void OnSigterm(PosixSignalContext context) // Prevent the default behaviour (immediate termination); the forceful-exit timer still // guarantees the process ends. context.Cancel = true; + OnTerminationSignal(); + } + + internal void OnTerminationSignal() + { _terminationSignalReceived = true; Cancel(); } @@ -171,35 +176,35 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) private void Cancel(bool armForcefulExit = true) { - // Cancel the test execution - if (!CancellationTokenSource.IsCancellationRequested) - { - CancellationTokenSource.Cancel(); - } - - // The forceful-exit timer is only for signal-driven cancellation (Ctrl+C), where we + // The forceful-exit timer is only for signal-driven cancellation (Ctrl+C / SIGTERM), where we // suppressed the runtime's default termination and must guarantee the process still dies. // Platform-driven cancellation manages its own shutdown, so it opts out. - if (!armForcefulExit) + // Arm it BEFORE cancelling: Cancel() runs registered callbacks (After hooks) synchronously, + // and one that blocks must not postpone the deadline that is meant to bound it. + if (armForcefulExit && !_forcefulExitStarted) { - return; + _forcefulExitStarted = true; + ArmForcefulExit(); } - // Only start the forceful exit timer once - if (!_forcefulExitStarted) + // Cancel the test execution + if (!CancellationTokenSource.IsCancellationRequested) { - _forcefulExitStarted = true; + CancellationTokenSource.Cancel(); + } + } - // Start a new forceful exit timer - _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + // Virtual so tests can observe arming without terminating the test process. + internal virtual void ArmForcefulExit() + { + _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + { + if (!t.IsCanceled) { - if (!t.IsCanceled) - { - Console.WriteLine("Forcefully terminating the process due to cancellation request."); - Environment.Exit(1); - } - }, TaskScheduler.Default); - } + Console.WriteLine("Forcefully terminating the process due to cancellation request."); + Environment.Exit(1); + } + }, TaskScheduler.Default); } private void OnProcessExit(object? sender, EventArgs e) diff --git a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs index 99b9cfbeb8f..ae0b974b730 100644 --- a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs +++ b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs @@ -56,6 +56,44 @@ public async Task Initialise_PropagatesOtherSubscriptionFailures() await Assert.That(() => token.Initialise()).Throws(); } +#if NET + [Test] + public async Task TerminationSignal_Arms_Forceful_Exit_Before_Cancellation_Callbacks_Run() + { + // Cancellation callbacks (After hooks) run synchronously inside Cancel(); a blocking one + // must not delay the deadline that bounds shutdown. + using var token = new RecordingForcefulExitToken(); + bool? armedWhenCallbackRan = null; + token.Token.Register(() => armedWhenCallbackRan = token.ForcefulExitArmed); + + token.OnTerminationSignal(); + + await Assert.That(armedWhenCallbackRan).IsEqualTo(true); + await Assert.That(token.TerminationSignalReceived).IsTrue(); + await Assert.That(token.Token.IsCancellationRequested).IsTrue(); + } +#endif + + [Test] + public async Task PlatformCancellation_Does_Not_Arm_Forceful_Exit() + { + using var platform = new CancellationTokenSource(); + using var token = new RecordingForcefulExitToken(); + token.Initialise(platform.Token); + + platform.Cancel(); + + await Assert.That(token.Token.IsCancellationRequested).IsTrue(); + await Assert.That(token.ForcefulExitArmed).IsFalse(); + } + + private sealed class RecordingForcefulExitToken : RecordingCancelKeyPressToken + { + public bool ForcefulExitArmed { get; private set; } + + internal override void ArmForcefulExit() => ForcefulExitArmed = true; + } + private class RecordingCancelKeyPressToken : EngineCancellationToken { public int SubscribeCalls { get; private set; } From f51a67cfbb12f42e470e54603c589563f8a50c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 06:37:24 +0200 Subject: [PATCH 4/8] fix: harden SIGTERM handling per review - A SIGTERM the runtime had already snapshotted can be dispatched after Dispose; Cancel() on the disposed source then threw ObjectDisposedException out of the signal handler. It is now caught there. - A run stopped by SIGTERM is marked as a failed session even when cancellation lets it finish without an exception reaching ExecuteRequestAsync. - SigtermTests asserts exit code 10, and kills the child before deleting its marker directory if the test bails out early. - FileLockDetection gets synthetic-HResult tests for each platform. - The SIGTERM docs are qualified as .NET 8+; Mono keeps the runtime default. - ExecutableExtensions moves to the top of its class. Co-Authored-By: Claude Opus 5.5 --- .../reference/programmatic-configuration.md | 2 +- .../Conditions/FileSystemAssertions.cs | 4 ++-- src/TUnit.Core/EngineCancellationToken.cs | 12 ++++++++++- .../Framework/TUnitTestFramework.cs | 12 ++++++++++- tests/TUnit.Engine.Tests/SigtermTests.cs | 20 +++++++++++++++--- .../TUnit.UnitTests/FileLockDetectionTests.cs | 21 +++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index ac0509d3fc3..d896f4e84cc 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -57,7 +57,7 @@ Settings are accessed exclusively through `context.Settings` in the discovery ho | `ForcefulExitTimeout` | `TimeSpan` | 30 seconds | Grace period before the process is forcefully terminated after a cancellation. | | `ProcessExitHookDelay` | `TimeSpan` | 500 ms | Delay before process-exit hooks run, allowing pending I/O to flush. | -On Linux and macOS, SIGTERM (sent by `docker stop`, Kubernetes, and CI cancellation) cancels the run the same way as Ctrl+C: After hooks run within `ForcefulExitTimeout`, and the run ends as a failed test session with exit code 10. +On Linux and macOS with .NET 8 or later, SIGTERM (sent by `docker stop`, Kubernetes, and CI cancellation) cancels the run the same way as Ctrl+C: After hooks run within `ForcefulExitTimeout`, and the run ends as a failed test session with exit code 10. .NET Framework test projects running on Mono keep the runtime's default handling, so After hooks there only get `ProcessExitHookDelay`. ### `context.Settings.Parallelism` diff --git a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs index d8e86e55861..cde229327e5 100644 --- a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs +++ b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs @@ -142,6 +142,8 @@ protected override Task CheckAsync(EvaluationMetadata [AssertionExtension("IsNotExecutable")] public class FileIsNotExecutableAssertion : Assertion { + private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"]; + public FileIsNotExecutableAssertion( AssertionContext context) : base(context) @@ -193,8 +195,6 @@ protected override Task CheckAsync(EvaluationMetadata return AssertionResult._passedTask; } - private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"]; - protected override string GetExpectation() => "to not be executable"; } diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index 53e2e28420a..ec9b0b581bc 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -130,7 +130,17 @@ private void OnSigterm(PosixSignalContext context) internal void OnTerminationSignal() { _terminationSignalReceived = true; - Cancel(); + + try + { + Cancel(); + } + catch (ObjectDisposedException) + { + // Disposing the registration does not stop a dispatch the runtime had already + // snapshotted, so the signal can land after the session's token was disposed. + // An exception escaping the signal handler would crash the process. + } } #endif diff --git a/src/TUnit.Engine/Framework/TUnitTestFramework.cs b/src/TUnit.Engine/Framework/TUnitTestFramework.cs index 068f15a94e9..d12c099e236 100644 --- a/src/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/src/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -77,6 +77,14 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) serviceProvider.CancellationToken.Initialise(context.CancellationToken); await _requestHandler.HandleRequestAsync((TestExecutionRequest) context.Request, serviceProvider, context, GetFilter(context)); + + // Cancellation does not always surface as an exception (a run can wind down cleanly), + // but a run stopped by SIGTERM must never report success. + if (serviceProvider.CancellationToken.TerminationSignalReceived) + { + await serviceProvider.Logger.LogErrorAsync(SigtermMessage); + serviceProvider.SessionFailed = true; + } } catch (Exception e) when (IsCancellationException(e)) { @@ -86,7 +94,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) { // MTP only treats the exception as a cancellation when its own token fired, which // SIGTERM does not do; re-throwing would crash the host. Fail the session instead. - await serviceProvider.Logger.LogErrorAsync("The test run was cancelled by SIGTERM."); + await serviceProvider.Logger.LogErrorAsync(SigtermMessage); serviceProvider.SessionFailed = true; return; } @@ -145,6 +153,8 @@ private TUnitServiceProvider GetOrCreateServiceProvider(ExecuteRequestContext co _capabilities)); } + private const string SigtermMessage = "The test run was cancelled by SIGTERM."; + private static bool IsCancellationException(Exception e) { return e is TaskCanceledException or OperationCanceledException; diff --git a/tests/TUnit.Engine.Tests/SigtermTests.cs b/tests/TUnit.Engine.Tests/SigtermTests.cs index f3ea40445a4..c447c78361a 100644 --- a/tests/TUnit.Engine.Tests/SigtermTests.cs +++ b/tests/TUnit.Engine.Tests/SigtermTests.cs @@ -60,12 +60,26 @@ await Cli.Wrap("kill") File.Exists(afterMarker).ShouldBeTrue($"The After hook did not complete:{Environment.NewLine}{output}"); output.ToString().ShouldContain("The test run was cancelled by SIGTERM."); - // 128 + signal: 143 = killed by SIGTERM itself, 134 = aborted on an unhandled exception. - result.ExitCode.ShouldNotBe(143); - result.ExitCode.ShouldNotBe(134); + // A failed session; not 143 (killed by SIGTERM itself) or 134 (aborted on an unhandled exception). + result.ExitCode.ShouldBe(10); } finally { + // If the test bailed out before the child finished, stop it before deleting the + // directory its After hook writes to. + if (!commandTask.Task.IsCompleted) + { + forcefulCancellation.Cancel(); + try + { + await commandTask; + } + catch (OperationCanceledException) + { + // Expected: the child was killed. + } + } + if (Directory.Exists(markerDirectory)) { Directory.Delete(markerDirectory, recursive: true); diff --git a/tests/TUnit.UnitTests/FileLockDetectionTests.cs b/tests/TUnit.UnitTests/FileLockDetectionTests.cs index 5d6fbcd2464..5414898004f 100644 --- a/tests/TUnit.UnitTests/FileLockDetectionTests.cs +++ b/tests/TUnit.UnitTests/FileLockDetectionTests.cs @@ -1,4 +1,5 @@ using TUnit.Assertions.Extensions; +using TUnit.Core.Enums; using TUnit.Engine.Helpers; namespace TUnit.UnitTests; @@ -33,6 +34,26 @@ public async Task Contention_On_An_Exclusively_Held_File_Is_Detected() } } + [Test] + [RunOn(OS.Windows)] + public async Task Windows_Sharing_And_Lock_Violations_Are_Detected() + { + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070020)))).IsTrue(); + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070021)))).IsTrue(); + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070005)))).IsFalse(); + } + + [Test] + [ExcludeOn(OS.Windows)] + public async Task Unix_EWouldBlock_For_This_Platform_Is_Detected() + { + // EWOULDBLOCK is 35 on macOS (and FreeBSD), 11 on Linux; the other value means something else here. + var (ewouldblock, other) = OperatingSystem.IsMacOS() ? (35, 11) : (11, 35); + + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", ewouldblock))).IsTrue(); + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", other))).IsFalse(); + } + [Test] public async Task Unrelated_IOException_Is_Not_Treated_As_Locked() { From 9da65f559d05483f86a23093201e28c5dad05a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 07:28:14 +0200 Subject: [PATCH 5/8] fix: close the remaining SIGTERM windows per review - A signal dispatched after Dispose no longer arms the forceful-exit timer: arming and Dispose share a gate, so a persistent host can't be killed 30s later by a session it already closed. Covered by a new test. - CloseTestSessionAsync also fails the session when SIGTERM arrived, closing the window between the end of the request and session close. - FileLockDetectionTests expects errno 35 on FreeBSD as well as macOS. - The message fallback in FileLockDetection is documented as best-effort. Co-Authored-By: Claude Opus 5.5 --- src/TUnit.Core/EngineCancellationToken.cs | 21 ++++++++++++++++--- .../Framework/TUnitTestFramework.cs | 4 +++- src/TUnit.Engine/Helpers/FileLockDetection.cs | 3 ++- .../EngineCancellationTokenTests.cs | 11 ++++++++++ .../TUnit.UnitTests/FileLockDetectionTests.cs | 4 ++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index ec9b0b581bc..700bdac5a4f 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -23,6 +23,8 @@ public class EngineCancellationToken : IDisposable private int _initialised; private volatile bool _forcefulExitStarted; + private readonly object _forcefulExitGate = new(); + private bool _disposed; private CancellationTokenRegistration _platformRegistration; private bool _cancelKeyPressSubscribed; #if NET @@ -191,10 +193,18 @@ private void Cancel(bool armForcefulExit = true) // Platform-driven cancellation manages its own shutdown, so it opts out. // Arm it BEFORE cancelling: Cancel() runs registered callbacks (After hooks) synchronously, // and one that blocks must not postpone the deadline that is meant to bound it. - if (armForcefulExit && !_forcefulExitStarted) + if (armForcefulExit) { - _forcefulExitStarted = true; - ArmForcefulExit(); + // Under the gate shared with Dispose: a signal the runtime dispatches after the session + // was disposed must not arm a timer that would later kill a host still in use. + lock (_forcefulExitGate) + { + if (!_disposed && !_forcefulExitStarted) + { + _forcefulExitStarted = true; + ArmForcefulExit(); + } + } } // Cancel the test execution @@ -240,6 +250,11 @@ private void OnProcessExit(object? sender, EventArgs e) /// public void Dispose() { + lock (_forcefulExitGate) + { + _disposed = true; + } + _platformRegistration.Dispose(); #if NET _sigtermRegistration?.Dispose(); diff --git a/src/TUnit.Engine/Framework/TUnitTestFramework.cs b/src/TUnit.Engine/Framework/TUnitTestFramework.cs index d12c099e236..0370fca9c46 100644 --- a/src/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/src/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -129,7 +129,9 @@ public async Task CloseTestSessionAsync(CloseTestSession if (_serviceProvidersPerSession.TryRemove(context.SessionUid.Value, out var serviceProvider)) { - if (serviceProvider.SessionFailed) + // TerminationSignalReceived too: SIGTERM can arrive after the request finished but before + // the host closes the session, and a terminated run must never report success. + if (serviceProvider.SessionFailed || serviceProvider.CancellationToken.TerminationSignalReceived) { isSuccess = false; } diff --git a/src/TUnit.Engine/Helpers/FileLockDetection.cs b/src/TUnit.Engine/Helpers/FileLockDetection.cs index f183abee608..829caccf7e4 100644 --- a/src/TUnit.Engine/Helpers/FileLockDetection.cs +++ b/src/TUnit.Engine/Helpers/FileLockDetection.cs @@ -36,7 +36,8 @@ internal static bool IsFileLocked(IOException exception) return true; } - // Fallback heuristic for hosts that map the error differently. + // Best-effort fallback for hosts that map the error differently. The runtime's message is + // English on .NET (Core); a localized .NET Framework message simply won't match. return exception.Message.Contains("being used by another process"); } diff --git a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs index ae0b974b730..294c1538a13 100644 --- a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs +++ b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs @@ -72,6 +72,17 @@ public async Task TerminationSignal_Arms_Forceful_Exit_Before_Cancellation_Callb await Assert.That(token.TerminationSignalReceived).IsTrue(); await Assert.That(token.Token.IsCancellationRequested).IsTrue(); } + + [Test] + public async Task TerminationSignal_After_Dispose_Neither_Throws_Nor_Arms_Forceful_Exit() + { + // The runtime may dispatch a signal it captured before Dispose unregistered the handler. + var token = new RecordingForcefulExitToken(); + token.Dispose(); + + await Assert.That(() => token.OnTerminationSignal()).ThrowsNothing(); + await Assert.That(token.ForcefulExitArmed).IsFalse(); + } #endif [Test] diff --git a/tests/TUnit.UnitTests/FileLockDetectionTests.cs b/tests/TUnit.UnitTests/FileLockDetectionTests.cs index 5414898004f..6a337e27870 100644 --- a/tests/TUnit.UnitTests/FileLockDetectionTests.cs +++ b/tests/TUnit.UnitTests/FileLockDetectionTests.cs @@ -47,8 +47,8 @@ public async Task Windows_Sharing_And_Lock_Violations_Are_Detected() [ExcludeOn(OS.Windows)] public async Task Unix_EWouldBlock_For_This_Platform_Is_Detected() { - // EWOULDBLOCK is 35 on macOS (and FreeBSD), 11 on Linux; the other value means something else here. - var (ewouldblock, other) = OperatingSystem.IsMacOS() ? (35, 11) : (11, 35); + // EWOULDBLOCK is 35 on macOS and FreeBSD, 11 on Linux; the other value means something else here. + var (ewouldblock, other) = OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD() ? (35, 11) : (11, 35); await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", ewouldblock))).IsTrue(); await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", other))).IsFalse(); From 184ff8b9414c52a9f03917c64003f1023234f7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:41:49 +0200 Subject: [PATCH 6/8] fix(core): keep the default SIGTERM termination once the session is disposed OnSigterm suppressed the runtime's default termination unconditionally. A signal landing after disposal then neither armed the forceful-exit timer nor let the process stop, so a host that outlived the session could keep running. OnTerminationSignal now checks disposal and arms the timer under the gate Dispose takes, and reports whether it handled the signal; only then is the default termination suppressed. Co-Authored-By: Claude Opus 5.5 --- src/TUnit.Core/EngineCancellationToken.cs | 33 +++++++++++++++---- .../EngineCancellationTokenTests.cs | 8 +++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index 700bdac5a4f..b1d15ec7538 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -123,19 +123,36 @@ internal void Initialise(CancellationToken platformCancellationToken = default) private void OnSigterm(PosixSignalContext context) { - // Prevent the default behaviour (immediate termination); the forceful-exit timer still - // guarantees the process ends. - context.Cancel = true; - OnTerminationSignal(); + // Suppress the default behaviour (immediate termination) only when a live session took the + // signal: the forceful-exit timer it armed still guarantees the process ends. A signal that + // lands after disposal keeps the runtime's default, so the process still stops. + context.Cancel = OnTerminationSignal(); } - internal void OnTerminationSignal() + /// Whether a live session handled the signal (and armed the forceful-exit timer). + internal bool OnTerminationSignal() { - _terminationSignalReceived = true; + // Check-and-arm under the gate Dispose takes: either the session is already disposed and the + // signal is not handled, or the timer is armed before Dispose can complete. + lock (_forcefulExitGate) + { + if (_disposed) + { + return false; + } + + _terminationSignalReceived = true; + + if (!_forcefulExitStarted) + { + _forcefulExitStarted = true; + ArmForcefulExit(); + } + } try { - Cancel(); + Cancel(armForcefulExit: false); } catch (ObjectDisposedException) { @@ -143,6 +160,8 @@ internal void OnTerminationSignal() // snapshotted, so the signal can land after the session's token was disposed. // An exception escaping the signal handler would crash the process. } + + return true; } #endif diff --git a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs index 294c1538a13..d66a742720f 100644 --- a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs +++ b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs @@ -66,8 +66,9 @@ public async Task TerminationSignal_Arms_Forceful_Exit_Before_Cancellation_Callb bool? armedWhenCallbackRan = null; token.Token.Register(() => armedWhenCallbackRan = token.ForcefulExitArmed); - token.OnTerminationSignal(); + var handled = token.OnTerminationSignal(); + await Assert.That(handled).IsTrue(); await Assert.That(armedWhenCallbackRan).IsEqualTo(true); await Assert.That(token.TerminationSignalReceived).IsTrue(); await Assert.That(token.Token.IsCancellationRequested).IsTrue(); @@ -77,10 +78,13 @@ public async Task TerminationSignal_Arms_Forceful_Exit_Before_Cancellation_Callb public async Task TerminationSignal_After_Dispose_Neither_Throws_Nor_Arms_Forceful_Exit() { // The runtime may dispatch a signal it captured before Dispose unregistered the handler. + // Unhandled, so OnSigterm leaves the runtime's default termination in place. var token = new RecordingForcefulExitToken(); token.Dispose(); - await Assert.That(() => token.OnTerminationSignal()).ThrowsNothing(); + var handled = token.OnTerminationSignal(); + + await Assert.That(handled).IsFalse(); await Assert.That(token.ForcefulExitArmed).IsFalse(); } #endif From 79427a569d59724c6bd10a461a3593341a746303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 17:07:02 +0200 Subject: [PATCH 7/8] fix(core): never clear another handler's SIGTERM suppression Every registered handler for a signal shares one PosixSignalContext, so assigning false from a disposed session could undo a live session's suppression and let the runtime terminate it mid-cleanup. The handler now only ever sets Cancel to true. Co-Authored-By: Claude Opus 5.5 --- src/TUnit.Core/EngineCancellationToken.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index b1d15ec7538..2ab9fc75c7a 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -126,7 +126,12 @@ private void OnSigterm(PosixSignalContext context) // Suppress the default behaviour (immediate termination) only when a live session took the // signal: the forceful-exit timer it armed still guarantees the process ends. A signal that // lands after disposal keeps the runtime's default, so the process still stops. - context.Cancel = OnTerminationSignal(); + // Only ever set it: every registered handler shares this context, and assigning false here + // would undo another, live session's suppression. + if (OnTerminationSignal()) + { + context.Cancel = true; + } } /// Whether a live session handled the signal (and armed the forceful-exit timer). From 13e42aadec14f74bee49df338cfe741d281bdbdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 17:23:24 +0200 Subject: [PATCH 8/8] refactor(engine): accept both EWOULDBLOCK numberings on any Unix Linux uses 11 and the BSDs (macOS, FreeBSD, NetBSD, OpenBSD) use 35. Accepting both everywhere drops the OS probing and covers NetBSD/OpenBSD; the other value is EDEADLK on each family, and treating it as contention only costs a bounded retry. Also documents that SIGTERM cancellation runs After hooks on the signal-dispatch thread, bounded by the already-armed timer. Co-Authored-By: Claude Opus 5.5 --- src/TUnit.Core/EngineCancellationToken.cs | 2 ++ src/TUnit.Engine/Helpers/FileLockDetection.cs | 12 ++++-------- tests/TUnit.UnitTests/FileLockDetectionTests.cs | 12 ++++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs index 2ab9fc75c7a..6d92c3b29ac 100644 --- a/src/TUnit.Core/EngineCancellationToken.cs +++ b/src/TUnit.Core/EngineCancellationToken.cs @@ -155,6 +155,8 @@ internal bool OnTerminationSignal() } } + // Runs cancellation callbacks (After hooks) synchronously on the signal-dispatch thread. A + // slow hook blocks that thread, which is acceptable: the timer armed above bounds it. try { Cancel(armForcefulExit: false); diff --git a/src/TUnit.Engine/Helpers/FileLockDetection.cs b/src/TUnit.Engine/Helpers/FileLockDetection.cs index 829caccf7e4..1e1bb6402b4 100644 --- a/src/TUnit.Engine/Helpers/FileLockDetection.cs +++ b/src/TUnit.Engine/Helpers/FileLockDetection.cs @@ -13,13 +13,12 @@ internal static class FileLockDetection private const int ErrorLockViolation = 0x21; // Unix: .NET takes an advisory flock for FileShare and reports contention with the raw errno - // as the HResult. EWOULDBLOCK (== EAGAIN) is 11 on Linux and 35 on the BSD family (macOS, FreeBSD). + // as the HResult. EWOULDBLOCK (== EAGAIN) is 11 on Linux and 35 on the BSDs (macOS, FreeBSD, + // NetBSD, OpenBSD). Both are accepted everywhere rather than probing the OS: the other value is + // EDEADLK on each family, and treating that as contention only costs a bounded retry. private const int EWouldBlockLinux = 11; private const int EWouldBlockBsd = 35; - // OSPlatform.FreeBSD is not part of netstandard2.0. - private static readonly OSPlatform FreeBSD = OSPlatform.Create("FREEBSD"); - internal static bool IsFileLocked(IOException exception) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -31,7 +30,7 @@ internal static bool IsFileLocked(IOException exception) return true; } } - else if (exception.HResult == (IsBsd() ? EWouldBlockBsd : EWouldBlockLinux)) + else if (exception.HResult is EWouldBlockLinux or EWouldBlockBsd) { return true; } @@ -40,7 +39,4 @@ internal static bool IsFileLocked(IOException exception) // English on .NET (Core); a localized .NET Framework message simply won't match. return exception.Message.Contains("being used by another process"); } - - private static bool IsBsd() => - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(FreeBSD); } diff --git a/tests/TUnit.UnitTests/FileLockDetectionTests.cs b/tests/TUnit.UnitTests/FileLockDetectionTests.cs index 6a337e27870..ca326c79f18 100644 --- a/tests/TUnit.UnitTests/FileLockDetectionTests.cs +++ b/tests/TUnit.UnitTests/FileLockDetectionTests.cs @@ -45,13 +45,13 @@ public async Task Windows_Sharing_And_Lock_Violations_Are_Detected() [Test] [ExcludeOn(OS.Windows)] - public async Task Unix_EWouldBlock_For_This_Platform_Is_Detected() + public async Task Unix_EWouldBlock_Is_Detected_For_Linux_And_Bsd_Numbering() { - // EWOULDBLOCK is 35 on macOS and FreeBSD, 11 on Linux; the other value means something else here. - var (ewouldblock, other) = OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD() ? (35, 11) : (11, 35); - - await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", ewouldblock))).IsTrue(); - await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", other))).IsFalse(); + // EWOULDBLOCK is 11 on Linux and 35 on the BSDs; both are accepted on any Unix. + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 11))).IsTrue(); + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 35))).IsTrue(); + // EACCES is a permission error, not contention. + await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 13))).IsFalse(); } [Test]