Skip to content

Commit c5f251d

Browse files
thomhurstclaude
andauthored
fix: propagate CancellationToken through property injection to prevent hangs (#4715) (#4732)
When IAsyncInitializer.InitializeAsync() hangs during property injection in a chained ClassDataSource dependency pattern, the test runner stalls indefinitely because CancellationToken.None is passed through the entire property injection chain. This threads the 5-minute discovery timeout CancellationToken from TestDiscoveryService through PropertyInjector, ObjectLifecycleService, TestBuilder, and TestArgumentRegistrationService so that EnsureInitializedAsync can be cancelled when the timeout fires. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ef894a commit c5f251d

12 files changed

Lines changed: 454 additions & 94 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Shouldly;
2+
using TUnit.Engine.Tests.Enums;
3+
4+
namespace TUnit.Engine.Tests;
5+
6+
public class PropertyInjectionInitFailureTests(TestMode testMode) : InvokableTestBase(testMode)
7+
{
8+
[Test]
9+
public async Task Test()
10+
{
11+
await RunTestsWithFilter(
12+
"/*/*/PropertyInjectionInitFailureTests/*",
13+
[
14+
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
15+
result => result.ResultSummary.Counters.Total.ShouldBe(1),
16+
result => result.ResultSummary.Counters.Passed.ShouldBe(0),
17+
result => result.ResultSummary.Counters.Failed.ShouldBe(1),
18+
result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0)
19+
]);
20+
}
21+
}

TUnit.Engine/Building/Interfaces/ITestBuilder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ internal interface ITestBuilder
1616
/// <param name="testBuilderContext"></param>
1717
/// <param name="isReusingDiscoveryInstance">Whether this test is reusing the discovery instance</param>
1818
/// <returns>An executable test ready for execution</returns>
19-
Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false);
19+
Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false, CancellationToken cancellationToken = default);
2020

2121
/// <summary>
2222
/// Builds all executable tests from a single TestMetadata using its DataCombinationGenerator delegate.
@@ -28,7 +28,7 @@ internal interface ITestBuilder
2828
#if NET6_0_OR_GREATER
2929
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
3030
#endif
31-
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext);
31+
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext, CancellationToken cancellationToken = default);
3232

3333
/// <summary>
3434
/// Streaming version that yields tests as they're built without buffering

TUnit.Engine/Building/TestBuilder.cs

Lines changed: 43 additions & 33 deletions
Large diffs are not rendered by default.

TUnit.Engine/Building/TestBuilderPipeline.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsStreamingAsync(
126126
}
127127

128128
return await collectedMetadata
129-
.SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken)
129+
.SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext, cancellationToken), cancellationToken: cancellationToken)
130130
.ProcessInParallel(cancellationToken: cancellationToken);
131131
}
132132

@@ -170,7 +170,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
170170
}
171171
else
172172
{
173-
results.Add(await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false));
173+
results.Add(await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext, cancellationToken).ConfigureAwait(false));
174174
}
175175
}
176176
catch (Exception ex)
@@ -194,7 +194,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
194194
return await GenerateDynamicTests(metadata).ConfigureAwait(false);
195195
}
196196

197-
return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false);
197+
return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext, cancellationToken).ConfigureAwait(false);
198198
}
199199
catch (Exception ex)
200200
{
@@ -308,7 +308,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
308308
#if NET6_0_OR_GREATER
309309
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
310310
#endif
311-
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
311+
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
312312
{
313313
TestMetadata resolvedMetadata;
314314
Exception? resolutionError = null;
@@ -430,7 +430,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
430430
else
431431
{
432432
// Normal test metadata goes through the standard test builder
433-
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false);
433+
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext, cancellationToken).ConfigureAwait(false);
434434
testsToYield = new List<AbstractExecutableTest>(testsFromMetadata);
435435
}
436436
}

TUnit.Engine/Services/IObjectRegistry.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ Task RegisterObjectAsync(
2222
object instance,
2323
ConcurrentDictionary<string, object?> objectBag,
2424
MethodMetadata? methodMetadata,
25-
TestContextEvents events);
25+
TestContextEvents events,
26+
CancellationToken cancellationToken = default);
2627

2728
/// <summary>
2829
/// Registers multiple argument objects during the registration phase.
@@ -31,5 +32,6 @@ Task RegisterArgumentsAsync(
3132
object?[] arguments,
3233
ConcurrentDictionary<string, object?> objectBag,
3334
MethodMetadata? methodMetadata,
34-
TestContextEvents events);
35+
TestContextEvents events,
36+
CancellationToken cancellationToken = default);
3537
}

TUnit.Engine/Services/ObjectLifecycleService.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public ObjectLifecycleService(
5252
/// Tracks the resolved objects so reference counting works correctly across all tests.
5353
/// Does NOT call IAsyncInitializer (deferred to execution).
5454
/// </summary>
55-
public async Task RegisterTestAsync(TestContext testContext)
55+
public async Task RegisterTestAsync(TestContext testContext, CancellationToken cancellationToken = default)
5656
{
5757
var objectBag = testContext.StateBag.Items;
5858
var methodMetadata = testContext.Metadata.TestDetails.MethodMetadata;
@@ -61,7 +61,7 @@ public async Task RegisterTestAsync(TestContext testContext)
6161

6262
// Resolve property values (creating shared objects) and cache them WITHOUT setting on placeholder instance
6363
// This ensures shared objects are created once and tracked with the correct reference count
64-
await PropertyInjector.ResolveAndCachePropertiesAsync(testClassType, objectBag, methodMetadata, events, testContext);
64+
await PropertyInjector.ResolveAndCachePropertiesAsync(testClassType, objectBag, methodMetadata, events, testContext, cancellationToken);
6565

6666
// Track the cached objects so they get the correct reference count
6767
_objectTracker.TrackObjects(testContext);
@@ -75,15 +75,16 @@ public Task RegisterObjectAsync(
7575
object instance,
7676
ConcurrentDictionary<string, object?> objectBag,
7777
MethodMetadata? methodMetadata,
78-
TestContextEvents events)
78+
TestContextEvents events,
79+
CancellationToken cancellationToken = default)
7980
{
8081
if (instance == null)
8182
{
8283
throw new ArgumentNullException(nameof(instance));
8384
}
8485

8586
// Inject properties during registration
86-
return PropertyInjector.InjectPropertiesAsync(instance, objectBag, methodMetadata, events);
87+
return PropertyInjector.InjectPropertiesAsync(instance, objectBag, methodMetadata, events, cancellationToken);
8788
}
8889

8990
/// <summary>
@@ -93,7 +94,8 @@ public Task RegisterArgumentsAsync(
9394
object?[] arguments,
9495
ConcurrentDictionary<string, object?> objectBag,
9596
MethodMetadata? methodMetadata,
96-
TestContextEvents events)
97+
TestContextEvents events,
98+
CancellationToken cancellationToken = default)
9799
{
98100
if (arguments == null || arguments.Length == 0)
99101
{
@@ -106,7 +108,7 @@ public Task RegisterArgumentsAsync(
106108
{
107109
if (argument != null)
108110
{
109-
tasks.Append(RegisterObjectAsync(argument, objectBag, methodMetadata, events));
111+
tasks.Append(RegisterObjectAsync(argument, objectBag, methodMetadata, events, cancellationToken));
110112
}
111113
}
112114

@@ -290,7 +292,8 @@ public async ValueTask<T> InjectPropertiesAsync<T>(
290292
T obj,
291293
ConcurrentDictionary<string, object?>? objectBag = null,
292294
MethodMetadata? methodMetadata = null,
293-
TestContextEvents? events = null) where T : notnull
295+
TestContextEvents? events = null,
296+
CancellationToken cancellationToken = default) where T : notnull
294297
{
295298
if (obj == null)
296299
{
@@ -301,7 +304,7 @@ public async ValueTask<T> InjectPropertiesAsync<T>(
301304
events ??= new TestContextEvents();
302305

303306
// Only inject properties, do not call IAsyncInitializer
304-
await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events);
307+
await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events, cancellationToken);
305308

306309
return obj;
307310
}
@@ -391,7 +394,7 @@ private async Task InitializeObjectCoreAsync(
391394
// This aligns with ObjectInitializer behavior and provides cleaner stack traces
392395

393396
// Step 1: Inject properties
394-
await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events);
397+
await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events, cancellationToken);
395398

396399
// Step 2: Initialize nested objects depth-first (discovery-only)
397400
await InitializeNestedObjectsForDiscoveryAsync(obj, cancellationToken);

0 commit comments

Comments
 (0)