Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/reference/programmatic-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`

| Property | Type | Default | Description |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/// <remarks>The System attribute only exists on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be a system directory", InlineMethodBody = true)]
public static bool IsSystemDirectory(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/// <remarks>The System attribute only exists on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be a system file", InlineMethodBody = true)]
public static bool IsSystemFile(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
/// <remarks>The Archive attribute is only maintained on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be archived", InlineMethodBody = true)]
public static bool IsArchived(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.Archive) == true;
}
28 changes: 25 additions & 3 deletions src/TUnit.Assertions/Conditions/FileSystemAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<Directory
}

// FileInfo assertions - unique ones that don't conflict with FileInfoAssertionExtensions
/// <remarks>
/// The System attribute only exists on Windows; on Linux and macOS this assertion always passes.
/// </remarks>
[AssertionExtension("IsNotSystem")]
public class FileIsNotSystemAssertion : Assertion<FileInfo>
{
Expand Down Expand Up @@ -132,9 +135,15 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<FileInfo>
protected override string GetExpectation() => "to not be a system file";
}

/// <remarks>
/// 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).
/// </remarks>
[AssertionExtension("IsNotExecutable")]
public class FileIsNotExecutableAssertion : Assertion<FileInfo>
{
private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"];

public FileIsNotExecutableAssertion(
AssertionContext<FileInfo> context)
: base(context)
Expand Down Expand Up @@ -162,10 +171,23 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<FileInfo>
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})"));
}

return AssertionResult._passedTask;
}
#endif

if (isExecutable)
if (ExecutableExtensions.Contains(value.Extension, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (extension: {value.Extension})"));
}
Expand Down
3 changes: 1 addition & 2 deletions src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ public override Task<bool> 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
;
Expand Down
6 changes: 4 additions & 2 deletions src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ public override Task<bool> 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);
Expand All @@ -65,6 +66,7 @@ public override Task<bool> ShouldSkip(TestRegisteredContext context)
return Task.FromResult(false);
}

#if NET
// Check for Browser platform (WebAssembly)
if (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser())
{
Expand Down
160 changes: 137 additions & 23 deletions src/TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#if NET
using System.Runtime.InteropServices;
#endif
using System.Runtime.Versioning;
using TUnit.Core.Settings;

Expand All @@ -20,16 +23,33 @@ 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
private PosixSignalRegistration? _sigtermRegistration;
private volatile bool _terminationSignalReceived;
#endif

/// <summary>
/// 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.
/// </summary>
internal bool TerminationSignalReceived =>
#if NET
_terminationSignalReceived;
#else
false;
#endif

public EngineCancellationToken()
{
Token = CancellationTokenSource.Token;
}

/// <summary>
/// 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 <c>CancellationToken</c> threaded into
Expand Down Expand Up @@ -67,7 +87,83 @@ internal void Initialise(CancellationToken platformCancellationToken = default)
#if NET5_0_OR_GREATER
}
#endif

#if NET
_sigtermRegistration = TryRegisterSigterm();
#endif
}

#if NET
/// <summary>
/// SIGTERM is how Unix asks a process to stop (<c>docker stop</c>, Kubernetes, CI cancellation,
/// <c>timeout</c>). Left to the runtime it only raises <see cref="AppDomain.ProcessExit"/>, which
/// gives After hooks <see cref="TimeoutSettings.ProcessExitHookDelay"/> before the process
/// dies. Handling it like Ctrl+C instead cancels the run and allows the full
/// <see cref="TimeoutSettings.ForcefulExitTimeout"/> for cleanup. Windows is left alone:
/// there the signal maps to console close/shutdown events, which the runtime already handles.
/// </summary>
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)
{
// 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();
Comment thread
Quattordici marked this conversation as resolved.
Resolved
}

/// <returns>Whether a live session handled the signal (and armed the forceful-exit timer).</returns>
internal bool OnTerminationSignal()
{
// 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(armForcefulExit: false);
}
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.
}

return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
#endif

/// <summary>
/// Subscribes to <see cref="Console.CancelKeyPress"/>. Platforms without console signals
Expand Down Expand Up @@ -111,40 +207,49 @@ 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)
{
return;
// 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)
Comment thread
Quattordici marked this conversation as resolved.
Resolved
{
_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)
{
// 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)
Expand All @@ -164,7 +269,16 @@ private void OnProcessExit(object? sender, EventArgs e)
/// </summary>
public void Dispose()
{
lock (_forcefulExitGate)
{
_disposed = true;
}

_platformRegistration.Dispose();
#if NET
_sigtermRegistration?.Dispose();
_sigtermRegistration = null;
#endif

// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
Expand Down
14 changes: 13 additions & 1 deletion src/TUnit.Core/Executors/STAThreadExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Runtime.Versioning;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using TUnit.Core.Exceptions;

namespace TUnit.Core;

Expand All @@ -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);
}
}
7 changes: 5 additions & 2 deletions src/TUnit.Engine/Exceptions/TUnitFailedException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ internal static string FilterStackTrace(string? stackTrace)
ReadOnlySpan<char> 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))
Expand Down
Loading
Loading