Skip to content

Commit

Permalink
Add support for TimeoutAttribute for .NET 8.0
Browse files Browse the repository at this point in the history
Also support Abort/Kill on NET8

.NET 6.0 now throws PlatformNotSupportedException
  • Loading branch information
manfred-brands committed Mar 29, 2024
1 parent e10e269 commit 4bc7f34
Show file tree
Hide file tree
Showing 25 changed files with 475 additions and 223 deletions.
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ dotnet_diagnostic.IDE0044.severity = warning
# IDE0055: Fix formatting (Skipping this as it doesnt say WHAT is wrong. Rely on StyleCop instead)
dotnet_diagnostic.IDE0055.severity = none

# IDE0079: Remove unnecessary suppression
# This rule implementation is flaky. It claims it can be removed,
# but when the file is actually opened. The violation goes away.
dotnet_diagnostic.IDE0079.severity = none

# IDE1006: Naming Styles
dotnet_diagnostic.IDE1006.severity = warning

Expand All @@ -177,3 +182,10 @@ dotnet_diagnostic.CSIsNull001.severity = warning

# CSIsNull002: Use `is object` for non-null checks
dotnet_diagnostic.CSIsNull002.severity = warning

##################################################################################
# NET8 (or later) Analyzers

# SYSLIB1045: Convert to 'GeneratedRegexAttribute'.

dotnet_diagnostic.SYSLIB1045.severity = silent
2 changes: 1 addition & 1 deletion src/NUnitFramework/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
</PropertyGroup>

<PropertyGroup>
<DefineConstants Condition="$(TargetFramework.StartsWith('net4'))">$(DefineConstants);THREAD_ABORT</DefineConstants>
<DefineConstants Condition="!$(TargetFramework.StartsWith('net6'))">$(DefineConstants);THREAD_ABORT</DefineConstants>
</PropertyGroup>

<!-- We always want a good debugging experience in tests -->
Expand Down
4 changes: 2 additions & 2 deletions src/NUnitFramework/framework/Attributes/TimeoutAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ namespace NUnit.Framework
/// When applied to a class or assembly, the default timeout is set for all contained test methods.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
#if !NETFRAMEWORK
[Obsolete(".NET No longer supports aborting threads as it is not a safe thing to do. Update your tests to use CancelAfterAttribute instead")]
#if !THREAD_ABORT
[Obsolete("Current SDK does not supports aborting threads as it is not a safe thing to do. Update your tests to use CancelAfterAttribute instead")]
#endif
public class TimeoutAttribute : PropertyAttribute, IApplyToContext
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class AfterTestActionCommand : AfterTestCommand
public AfterTestActionCommand(TestCommand innerCommand, TestActionItem action)
: base(innerCommand)
{
Guard.ArgumentValid(innerCommand.Test is TestSuite, "BeforeTestActionCommand may only apply to a TestSuite", nameof(innerCommand));
Guard.ArgumentValid(innerCommand.Test is TestSuite, "AfterTestActionCommand may only apply to a TestSuite", nameof(innerCommand));
Guard.ArgumentNotNull(action, nameof(action));

AfterTest = context =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public override TestResult Execute(TestExecutionContext context)
context.CurrentResult = innerCommand.Execute(context);
});

AfterTest(context);
RunTestMethodInThreadAbortSafeZone(context, () =>
{
AfterTest(context);
});

return context.CurrentResult;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class CancelAfterCommand : DelegatingTestCommand
private readonly IDebugger _debugger;

/// <summary>
/// Initializes a new instance of the <see cref="TimeoutCommand"/> class.
/// Initializes a new instance of the <see cref="CancelAfterCommand"/> class.
/// </summary>
/// <param name="innerCommand">The inner command</param>
/// <param name="timeout">Timeout value</param>
Expand All @@ -32,11 +32,7 @@ internal CancelAfterCommand(TestCommand innerCommand, int timeout, IDebugger deb
Guard.ArgumentNotNull(debugger, nameof(debugger));
}

/// <summary>
/// Runs the test, saving a TestResult in the supplied TestExecutionContext.
/// </summary>
/// <param name="context">The context in which the test should run.</param>
/// <returns>A TestResult</returns>
/// <inheritdoc/>
public override TestResult Execute(TestExecutionContext context)
{
// Because of the debugger possibly attaching after the test method is started
Expand All @@ -54,7 +50,7 @@ public override TestResult Execute(TestExecutionContext context)

try
{
innerCommand.Execute(context);
ExecuteInnerCommand(context);
}
catch (OperationCanceledException ex)
{
Expand All @@ -67,5 +63,14 @@ public override TestResult Execute(TestExecutionContext context)

return context.CurrentResult;
}

/// <summary>
/// Execute the 'inner command' using the <paramref name="context"/>.
/// </summary>
/// <param name="context">The TestExecutionContext to be used for running the test.</param>
protected virtual void ExecuteInnerCommand(TestExecutionContext context)
{
innerCommand.Execute(context);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt

using System;
#if THREAD_ABORT
using System.Threading;
#endif

namespace NUnit.Framework.Internal.Commands
{
Expand Down Expand Up @@ -42,10 +39,6 @@ protected static void RunTestMethodInThreadAbortSafeZone(TestExecutionContext co
}
catch (Exception ex)
{
#if THREAD_ABORT
if (ex is ThreadAbortException)
Thread.ResetAbort();
#endif
context.CurrentResult.RecordException(ex);
}
}
Expand Down
118 changes: 10 additions & 108 deletions src/NUnitFramework/framework/Internal/Commands/TimeoutCommand.cs
Original file line number Diff line number Diff line change
@@ -1,130 +1,32 @@
// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt

#if THREAD_ABORT
using System.Threading;
#else
using System;
using System.Threading.Tasks;
#endif
using NUnit.Framework.Interfaces;
using System.Runtime;
using NUnit.Framework.Internal.Abstractions;

namespace NUnit.Framework.Internal.Commands
{
/// <summary>
/// <see cref="TimeoutCommand"/> creates a timer in order to cancel
/// a test if it exceeds a specified time and adjusts
/// the test result if it did time out.
/// Unlike <see cref="CancelAfterCommand"/> this command will try to Abort the current executing Thread after the timeout.
/// </summary>
public class TimeoutCommand : BeforeAndAfterTestCommand
public class TimeoutCommand : CancelAfterCommand
{
private readonly int _timeout;
private readonly IDebugger _debugger;
#if THREAD_ABORT
private Timer? _commandTimer;
private bool _commandTimedOut;
#endif

/// <summary>
/// Initializes a new instance of the <see cref="TimeoutCommand"/> class.
/// </summary>
/// <param name="innerCommand">The inner command</param>
/// <param name="timeout">Timeout value</param>
/// <param name="debugger">An <see cref="IDebugger" /> instance</param>
internal TimeoutCommand(TestCommand innerCommand, int timeout, IDebugger debugger) : base(innerCommand)
internal TimeoutCommand(TestCommand innerCommand, int timeout, IDebugger debugger)
: base(innerCommand, timeout, debugger)
{
_timeout = timeout;
_debugger = debugger;

Guard.ArgumentValid(innerCommand.Test is TestMethod, "TimeoutCommand may only apply to a TestMethod", nameof(innerCommand));
Guard.ArgumentValid(timeout > 0, "Timeout value must be greater than zero", nameof(timeout));
Guard.ArgumentNotNull(debugger, nameof(debugger));

#if THREAD_ABORT
BeforeTest = _ =>
{
var testThread = Thread.CurrentThread;
var nativeThreadId = ThreadUtility.GetCurrentThreadNativeId();
// Create a timer to cancel the current thread
_commandTimer = new Timer(
o =>
{
if (_debugger.IsAttached)
{
return;
}
_commandTimedOut = true;
ThreadUtility.Abort(testThread, nativeThreadId);
// No join here, since the thread doesn't really terminate
},
null,
timeout,
Timeout.Infinite);
};

AfterTest = (context) =>
{
_commandTimer?.Dispose();
// If the timer cancelled the current thread, change the result
if (_commandTimedOut)
{
var message = $"Test exceeded Timeout value of {timeout}ms";
context.CurrentResult.SetResult(
ResultState.Failure,
message);
}
};
#else
BeforeTest = _ => { };
AfterTest = _ => { };
#endif
}

#if !THREAD_ABORT
/// <summary>
/// Runs the test, saving a TestResult in the supplied TestExecutionContext.
/// </summary>
/// <param name="context">The context in which the test should run.</param>
/// <returns>A TestResult</returns>
public override TestResult Execute(TestExecutionContext context)
{
try
{
var testExecution = RunTestOnSeparateThread(context);
if (Task.WaitAny(new Task[] { testExecution }, _timeout) != -1
|| _debugger.IsAttached)
{
context.CurrentResult = testExecution.GetAwaiter().GetResult();
}
else
{
string message = $"Test exceeded Timeout value of {_timeout}ms";

context.CurrentResult.SetResult(
ResultState.Failure,
message);
}
}
catch (Exception exception)
{
context.CurrentResult.RecordException(exception, FailureSite.Test);
}

return context.CurrentResult;
}

private Task<TestResult> RunTestOnSeparateThread(TestExecutionContext context)
/// <inheritdoc/>
protected override void ExecuteInnerCommand(TestExecutionContext context)
{
var separateContext = new TestExecutionContext(context)
{
CurrentResult = context.CurrentTest.MakeTestResult()
};
return Task.Run(() => innerCommand.Execute(separateContext));
#pragma warning disable SYSLIB0046 // Type or member is obsolete
ControlledExecution.Run(() => base.ExecuteInnerCommand(context), context.CancellationToken);
#pragma warning restore SYSLIB0046 // Type or member is obsolete
}
#endif
}
}
54 changes: 54 additions & 0 deletions src/NUnitFramework/framework/Internal/ControlledExecution.Net6.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt
//
// Adapted from original:
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#if NET6_0

using System.Threading;

namespace System.Runtime
{
/// <summary>
/// Allows to run code and abort it asynchronously.
/// </summary>
internal static class ControlledExecution
{
/// <summary>
/// Runs code that may be aborted asynchronously.
/// </summary>
/// <param name="action">The delegate that represents the code to execute.</param>
/// <param name="cancellationToken">The cancellation token that may be used to abort execution.</param>
/// <exception cref="PlatformNotSupportedException">The method is not supported on this platform.</exception>
/// <exception cref="ArgumentNullException">The <paramref name="action"/> argument is null.</exception>
/// <exception cref="InvalidOperationException">
/// The current thread is already running the <see cref="Run"/> method.
/// </exception>
/// <exception cref="OperationCanceledException">The execution was aborted.</exception>
/// <remarks>
/// <para>This method enables aborting arbitrary managed code in a non-cooperative manner by throwing an exception
/// in the thread executing that code. While the exception may be caught by the code, it is re-thrown at the end
/// of `catch` blocks until the execution flow returns to the `ControlledExecution.Run` method.</para>
/// <para>Execution of the code is not guaranteed to abort immediately, or at all. This situation can occur, for
/// example, if a thread is stuck executing unmanaged code or the `catch` and `finally` blocks that are called as
/// part of the abort procedure, thereby indefinitely delaying the abort. Furthermore, execution may not be
/// aborted immediately if the thread is currently executing a `catch` or `finally` block.</para>
/// <para>Aborting code at an unexpected location may corrupt the state of data structures in the process and lead
/// to unpredictable results. For that reason, this method should not be used in production code and calling it
/// produces a compile-time warning.</para>
/// </remarks>
public static void Run(Action action, CancellationToken cancellationToken)
{
if (action is null)
{
throw new ArgumentNullException(nameof(action));
}

throw new PlatformNotSupportedException(
".NET 6.0 has no Thread.Abort functionality, use cooperative cancellation using the CancelAfterAttribute");
}
}
}

#endif

0 comments on commit 4bc7f34

Please sign in to comment.