|
4 | 4 | namespace Microsoft.VisualStudio.TestTools.UnitTesting; |
5 | 5 |
|
6 | 6 | /// <summary> |
7 | | -/// The TaskRun test method attribute. |
| 7 | +/// Test method attribute that runs tests in a Task.Run with non-cooperative timeout handling. |
8 | 8 | /// </summary> |
9 | 9 | /// <remarks> |
10 | 10 | /// <para> |
11 | | -/// This attribute is designed to handle test method execution with timeout by running the test code within a <see cref="Task.Run"/>. |
12 | | -/// This allows the test runner to stop watching the task in case of timeout, preventing dangling tasks that can lead to |
13 | | -/// confusion or errors because the test method is still running in the background. |
| 11 | +/// This attribute runs test methods in <see cref="Task.Run"/> and implements non-cooperative timeout handling. |
| 12 | +/// When a timeout occurs, the test is marked as timed out and the test runner stops awaiting the task, |
| 13 | +/// allowing it to complete in the background. This prevents blocking but may lead to dangling tasks. |
14 | 14 | /// </para> |
15 | 15 | /// <para> |
16 | | -/// When a timeout occurs: |
17 | | -/// <list type="bullet"> |
18 | | -/// <item><description>The test is marked as timed out.</description></item> |
19 | | -/// <item><description>The cancellation token from <see cref="TestContext.CancellationToken"/> is canceled.</description></item> |
20 | | -/// <item><description>The test runner stops awaiting the test task, allowing it to complete in the background.</description></item> |
21 | | -/// </list> |
22 | | -/// </para> |
23 | | -/// <para> |
24 | | -/// For best results, test methods should observe the cancellation token and cancel cooperatively. |
25 | | -/// If the test method does not handle cancellation properly, the task may continue running after the timeout, |
26 | | -/// which can still lead to issues, but the test runner will not block waiting for it to complete. |
| 16 | +/// For cooperative timeout handling where tests are awaited until completion, use <see cref="TimeoutAttribute"/> |
| 17 | +/// with <c>CooperativeCancellation = true</c> instead. |
27 | 18 | /// </para> |
28 | 19 | /// </remarks> |
| 20 | +[AttributeUsage(AttributeTargets.Method, Inherited = false)] |
29 | 21 | public sealed class TaskRunTestMethodAttribute : TestMethodAttribute |
30 | 22 | { |
31 | 23 | private readonly TestMethodAttribute? _testMethodAttribute; |
32 | 24 |
|
33 | 25 | /// <summary> |
34 | 26 | /// Initializes a new instance of the <see cref="TaskRunTestMethodAttribute"/> class. |
35 | 27 | /// </summary> |
36 | | - public TaskRunTestMethodAttribute([CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = -1) |
| 28 | + /// <param name="timeout">The timeout in milliseconds. If not specified or 0, no timeout is applied.</param> |
| 29 | + public TaskRunTestMethodAttribute(int timeout = 0, [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = -1) |
37 | 30 | : base(callerFilePath, callerLineNumber) |
38 | 31 | { |
| 32 | + Timeout = timeout; |
39 | 33 | } |
40 | 34 |
|
41 | 35 | /// <summary> |
42 | 36 | /// Initializes a new instance of the <see cref="TaskRunTestMethodAttribute"/> class. |
43 | 37 | /// This constructor is intended to be called by test class attributes to wrap an existing test method attribute. |
44 | 38 | /// </summary> |
45 | 39 | /// <param name="testMethodAttribute">The wrapped test method.</param> |
46 | | - public TaskRunTestMethodAttribute(TestMethodAttribute testMethodAttribute) |
| 40 | + /// <param name="timeout">The timeout in milliseconds. If not specified or 0, no timeout is applied.</param> |
| 41 | + public TaskRunTestMethodAttribute(TestMethodAttribute testMethodAttribute, int timeout = 0) |
47 | 42 | : base(testMethodAttribute.DeclaringFilePath, testMethodAttribute.DeclaringLineNumber ?? -1) |
48 | | - => _testMethodAttribute = testMethodAttribute; |
| 43 | + { |
| 44 | + _testMethodAttribute = testMethodAttribute; |
| 45 | + Timeout = timeout; |
| 46 | + } |
49 | 47 |
|
50 | 48 | /// <summary> |
51 | | - /// Executes a test method by wrapping it in a <see cref="Task.Run"/> to allow timeout handling. |
| 49 | + /// Gets the timeout in milliseconds. |
| 50 | + /// </summary> |
| 51 | + public int Timeout { get; } |
| 52 | + |
| 53 | + /// <summary> |
| 54 | + /// Executes a test method with non-cooperative timeout handling. |
52 | 55 | /// </summary> |
53 | 56 | /// <param name="testMethod">The test method to execute.</param> |
54 | 57 | /// <returns>An array of TestResult objects that represent the outcome(s) of the test.</returns> |
55 | 58 | public override async Task<TestResult[]> ExecuteAsync(ITestMethod testMethod) |
56 | 59 | { |
57 | 60 | if (_testMethodAttribute is not null) |
58 | 61 | { |
59 | | - return await ExecuteWithTaskRunAsync(() => _testMethodAttribute.ExecuteAsync(testMethod)).ConfigureAwait(false); |
| 62 | + return await ExecuteWithTimeoutAsync(() => _testMethodAttribute.ExecuteAsync(testMethod), testMethod).ConfigureAwait(false); |
60 | 63 | } |
61 | 64 |
|
62 | | - return await ExecuteWithTaskRunAsync(async () => |
| 65 | + return await ExecuteWithTimeoutAsync(async () => |
63 | 66 | { |
64 | 67 | TestResult result = await testMethod.InvokeAsync(null).ConfigureAwait(false); |
65 | 68 | return new[] { result }; |
66 | | - }).ConfigureAwait(false); |
| 69 | + }, testMethod).ConfigureAwait(false); |
67 | 70 | } |
68 | 71 |
|
69 | | - private static async Task<TestResult[]> ExecuteWithTaskRunAsync(Func<Task<TestResult[]>> executeFunc) |
| 72 | + private async Task<TestResult[]> ExecuteWithTimeoutAsync(Func<Task<TestResult[]>> executeFunc, ITestMethod testMethod) |
70 | 73 | { |
71 | | - // Run the test method in Task.Run so that we can stop awaiting it on timeout |
72 | | - // while allowing it to complete in the background |
73 | | - Task<TestResult[]> testTask = Task.Run(executeFunc); |
74 | | - TestResult[] results = await testTask.ConfigureAwait(false); |
75 | | - return results; |
| 74 | + if (Timeout <= 0) |
| 75 | + { |
| 76 | + // No timeout, run directly with Task.Run |
| 77 | + return await RunOnThreadPoolOrCustomThreadAsync(executeFunc).ConfigureAwait(false); |
| 78 | + } |
| 79 | + |
| 80 | + // Run with timeout |
| 81 | + Task<TestResult[]> testTask = RunOnThreadPoolOrCustomThreadAsync(executeFunc); |
| 82 | + Task completedTask = await Task.WhenAny(testTask, Task.Delay(Timeout)).ConfigureAwait(false); |
| 83 | + |
| 84 | + if (completedTask == testTask) |
| 85 | + { |
| 86 | + // Test completed before timeout |
| 87 | + return await testTask.ConfigureAwait(false); |
| 88 | + } |
| 89 | + |
| 90 | + // Timeout occurred - return timeout result and let task continue in background |
| 91 | + return |
| 92 | + [ |
| 93 | + new TestResult |
| 94 | + { |
| 95 | + Outcome = UnitTestOutcome.Timeout, |
| 96 | + TestFailureException = new TestFailedException( |
| 97 | + UnitTestOutcome.Timeout, |
| 98 | + string.Format( |
| 99 | + CultureInfo.InvariantCulture, |
| 100 | + "Test '{0}.{1}' exceeded timeout of {2}ms.", |
| 101 | + testMethod.TestClassName, |
| 102 | + testMethod.TestMethodName, |
| 103 | + Timeout)), |
| 104 | + }, |
| 105 | + ]; |
| 106 | + } |
| 107 | + |
| 108 | + private static Task<TestResult[]> RunOnThreadPoolOrCustomThreadAsync(Func<Task<TestResult[]>> executeFunc) |
| 109 | + { |
| 110 | + // Check if we need to handle STA threading |
| 111 | + // If current thread is STA and we're on Windows, create a new STA thread |
| 112 | + // Otherwise, use Task.Run (thread pool) |
| 113 | +#if NETFRAMEWORK |
| 114 | + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) |
| 115 | + { |
| 116 | + return RunOnSTAThreadAsync(executeFunc); |
| 117 | + } |
| 118 | +#else |
| 119 | + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && |
| 120 | + Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) |
| 121 | + { |
| 122 | + return RunOnSTAThreadAsync(executeFunc); |
| 123 | + } |
| 124 | +#endif |
| 125 | + |
| 126 | + // Use thread pool for non-STA scenarios |
| 127 | + return Task.Run(executeFunc); |
| 128 | + } |
| 129 | + |
| 130 | + private static Task<TestResult[]> RunOnSTAThreadAsync(Func<Task<TestResult[]>> executeFunc) |
| 131 | + { |
| 132 | + var tcs = new TaskCompletionSource<TestResult[]>(); |
| 133 | + var thread = new Thread(() => |
| 134 | + { |
| 135 | + try |
| 136 | + { |
| 137 | + TestResult[] result = executeFunc().GetAwaiter().GetResult(); |
| 138 | + tcs.SetResult(result); |
| 139 | + } |
| 140 | + catch (Exception ex) |
| 141 | + { |
| 142 | + tcs.SetException(ex); |
| 143 | + } |
| 144 | + }) |
| 145 | + { |
| 146 | + Name = "TaskRunTestMethodAttribute STA thread", |
| 147 | + }; |
| 148 | + |
| 149 | + thread.SetApartmentState(ApartmentState.STA); |
| 150 | + thread.Start(); |
| 151 | + |
| 152 | + return tcs.Task; |
76 | 153 | } |
77 | 154 | } |
0 commit comments