Skip to content

Commit dc3e186

Browse files
claude[bot]claude
andcommitted
Implement proper hook timeout functionality with event receiver pattern
- Fix TimeoutAttribute.OnHookRegistered to actually set timeout on context - Extend HookRegisteredContext to support both static and instance hook methods - Update hook method classes to use settable timeout property instead of direct attribute access - Add hook registration event receiver infrastructure to EventReceiverOrchestrator - Modify HookCollectionService to trigger hook registration events during delegate creation - Reorder service initialization to inject EventReceiverOrchestrator into HookCollectionService This implementation follows the established event receiver pattern used for tests, ensuring that TimeoutAttribute and other hook attributes can modify hook properties during registration rather than relying on direct attribute access. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fa96072 commit dc3e186

File tree

7 files changed

+146
-17
lines changed

7 files changed

+146
-17
lines changed

TUnit.Core/Attributes/TestMetadata/TimeoutAttribute.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ public ValueTask OnTestDiscovered(DiscoveredTestContext context)
5353
/// <inheritdoc />
5454
public ValueTask OnHookRegistered(HookRegisteredContext context)
5555
{
56-
// Apply timeout to the hook method
57-
// This will be used by the hook execution infrastructure
56+
context.Timeout = Timeout;
5857
return default(ValueTask);
5958
}
6059
}

TUnit.Core/Contexts/HookRegisteredContext.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,32 @@ namespace TUnit.Core;
77
/// </summary>
88
public class HookRegisteredContext
99
{
10-
public StaticHookMethod HookMethod { get; }
11-
public string HookName => HookMethod.Name;
10+
private readonly object _hookMethod;
11+
private readonly string _hookName;
12+
private TimeSpan? _timeout;
13+
14+
public StaticHookMethod? StaticHookMethod => _hookMethod as StaticHookMethod;
15+
public InstanceHookMethod? InstanceHookMethod => _hookMethod as InstanceHookMethod;
16+
public string HookName => _hookName;
17+
18+
/// <summary>
19+
/// Gets or sets the timeout for this hook
20+
/// </summary>
21+
public TimeSpan? Timeout
22+
{
23+
get => _timeout;
24+
set => _timeout = value;
25+
}
1226

1327
public HookRegisteredContext(StaticHookMethod hookMethod)
1428
{
15-
HookMethod = hookMethod;
29+
_hookMethod = hookMethod;
30+
_hookName = hookMethod.Name;
31+
}
32+
33+
public HookRegisteredContext(InstanceHookMethod hookMethod)
34+
{
35+
_hookMethod = hookMethod;
36+
_hookName = hookMethod.Name;
1637
}
1738
}

TUnit.Core/Hooks/InstanceHookMethod.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ public record InstanceHookMethod : IExecutableHook<TestContext>
2222

2323
public TAttribute? GetAttribute<TAttribute>() where TAttribute : Attribute => Attributes.OfType<TAttribute>().FirstOrDefault();
2424

25-
public TimeSpan? Timeout => GetAttribute<TimeoutAttribute>()?.Timeout ?? TimeSpan.FromMinutes(5);
25+
/// <summary>
26+
/// Gets or sets the timeout for this hook method. This will be set during hook registration
27+
/// by the event receiver infrastructure, falling back to the default 5-minute timeout.
28+
/// </summary>
29+
public TimeSpan? Timeout { get; internal set; } = TimeSpan.FromMinutes(5);
2630

2731
public required IHookExecutor HookExecutor { get; init; }
2832

TUnit.Core/Hooks/StaticHookMethod.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ public abstract record StaticHookMethod
3232

3333
public TAttribute? GetAttribute<TAttribute>() where TAttribute : Attribute => Attributes.OfType<TAttribute>().FirstOrDefault();
3434

35-
public TimeSpan? Timeout => GetAttribute<TimeoutAttribute>()?.Timeout ?? TimeSpan.FromMinutes(5);
35+
/// <summary>
36+
/// Gets the timeout for this hook method. This will be set during hook registration
37+
/// by the event receiver infrastructure, falling back to the default 5-minute timeout.
38+
/// </summary>
39+
public TimeSpan? Timeout { get; internal set; } = TimeSpan.FromMinutes(5);
3640

3741
public required IHookExecutor HookExecutor { get; init; }
3842

TUnit.Engine/Framework/TUnitServiceProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,14 @@ public TUnitServiceProvider(IExtension extension,
8686

8787
CancellationToken = Register(new EngineCancellationToken());
8888

89-
HookCollectionService = Register<IHookCollectionService>(new HookCollectionService());
89+
EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger));
90+
HookCollectionService = Register<IHookCollectionService>(new HookCollectionService(EventReceiverOrchestrator));
9091

9192
ParallelLimitLockProvider = Register(new ParallelLimitLockProvider());
9293

9394
ContextProvider = Register(new ContextProvider(this, TestSessionId, Filter?.ToString()));
9495

9596
HookOrchestrator = Register(new HookOrchestrator(HookCollectionService, Logger, ContextProvider, this));
96-
EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger));
9797

9898
// Detect execution mode from command line or environment
9999
var useSourceGeneration = GetUseSourceGeneration(CommandLineOptions);

TUnit.Engine/Services/EventReceiverOrchestrator.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,58 @@ public async ValueTask InvokeTestDiscoveryEventReceiversAsync(TestContext contex
197197
}
198198
}
199199

200+
public async ValueTask InvokeHookRegistrationEventReceiversAsync(HookRegisteredContext hookContext, CancellationToken cancellationToken)
201+
{
202+
// Get event receivers from the hook method's attributes
203+
IEnumerable<Attribute> attributes;
204+
205+
if (hookContext.StaticHookMethod != null)
206+
{
207+
attributes = hookContext.StaticHookMethod.Attributes;
208+
}
209+
else if (hookContext.InstanceHookMethod != null)
210+
{
211+
attributes = hookContext.InstanceHookMethod.Attributes;
212+
}
213+
else
214+
{
215+
return; // No hook method to process
216+
}
217+
218+
var eventReceivers = attributes
219+
.OfType<IHookRegisteredEventReceiver>()
220+
.OrderBy(r => r.Order)
221+
.ToList();
222+
223+
// Filter scoped attributes to ensure only the highest priority one of each type is invoked
224+
var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(eventReceivers);
225+
226+
foreach (var receiver in filteredReceivers.OrderBy(r => r.Order))
227+
{
228+
try
229+
{
230+
await receiver.OnHookRegistered(hookContext);
231+
}
232+
catch (Exception ex)
233+
{
234+
await _logger.LogErrorAsync($"Error in hook registration event receiver: {ex.Message}");
235+
}
236+
}
237+
238+
// Apply the timeout from the context back to the hook method
239+
if (hookContext.Timeout.HasValue)
240+
{
241+
if (hookContext.StaticHookMethod != null)
242+
{
243+
hookContext.StaticHookMethod.Timeout = hookContext.Timeout;
244+
}
245+
else if (hookContext.InstanceHookMethod != null)
246+
{
247+
hookContext.InstanceHookMethod.Timeout = hookContext.Timeout;
248+
}
249+
}
250+
}
251+
200252

201253
// First/Last event methods with fast-path checks
202254
[MethodImpl(MethodImplOptions.AggressiveInlining)]

TUnit.Engine/Services/HookCollectionService.cs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace TUnit.Engine.Services;
99

1010
internal sealed class HookCollectionService : IHookCollectionService
1111
{
12+
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
1213
private readonly ConcurrentDictionary<Type, IReadOnlyList<Func<TestContext, CancellationToken, Task>>> _beforeTestHooksCache = new();
1314
private readonly ConcurrentDictionary<Type, IReadOnlyList<Func<TestContext, CancellationToken, Task>>> _afterTestHooksCache = new();
1415
private readonly ConcurrentDictionary<Type, IReadOnlyList<Func<TestContext, CancellationToken, Task>>> _beforeEveryTestHooksCache = new();
@@ -19,6 +20,48 @@ internal sealed class HookCollectionService : IHookCollectionService
1920
// Cache for complete hook chains to avoid repeated lookups
2021
private readonly ConcurrentDictionary<Type, CompleteHookChain> _completeHookChainCache = new();
2122

23+
// Cache for processed hooks to avoid re-processing event receivers
24+
private readonly ConcurrentDictionary<object, bool> _processedHooks = new();
25+
26+
public HookCollectionService(EventReceiverOrchestrator eventReceiverOrchestrator)
27+
{
28+
_eventReceiverOrchestrator = eventReceiverOrchestrator;
29+
}
30+
31+
private async Task ProcessHookRegistrationAsync(object hookMethod, CancellationToken cancellationToken = default)
32+
{
33+
// Only process each hook once
34+
if (!_processedHooks.TryAdd(hookMethod, true))
35+
{
36+
return;
37+
}
38+
39+
try
40+
{
41+
HookRegisteredContext context;
42+
43+
if (hookMethod is StaticHookMethod staticHook)
44+
{
45+
context = new HookRegisteredContext(staticHook);
46+
}
47+
else if (hookMethod is InstanceHookMethod instanceHook)
48+
{
49+
context = new HookRegisteredContext(instanceHook);
50+
}
51+
else
52+
{
53+
return; // Unknown hook type
54+
}
55+
56+
await _eventReceiverOrchestrator.InvokeHookRegistrationEventReceiversAsync(context, cancellationToken);
57+
}
58+
catch (Exception)
59+
{
60+
// Ignore errors during hook registration event processing to avoid breaking hook execution
61+
// The EventReceiverOrchestrator already logs errors internally
62+
}
63+
}
64+
2265
private sealed class CompleteHookChain
2366
{
2467
public IReadOnlyList<Func<TestContext, CancellationToken, Task>> BeforeTestHooks { get; init; } = [
@@ -51,7 +94,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
5194
{
5295
foreach (var hook in sourceHooks)
5396
{
54-
var hookFunc = CreateInstanceHookDelegate(hook);
97+
var hookFunc = await CreateInstanceHookDelegateAsync(hook);
5598
typeHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
5699
}
57100
}
@@ -64,7 +107,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
64107
{
65108
foreach (var hook in openTypeHooks)
66109
{
67-
var hookFunc = CreateInstanceHookDelegate(hook);
110+
var hookFunc = await CreateInstanceHookDelegateAsync(hook);
68111
typeHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
69112
}
70113
}
@@ -111,7 +154,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
111154
{
112155
foreach (var hook in sourceHooks)
113156
{
114-
var hookFunc = CreateInstanceHookDelegate(hook);
157+
var hookFunc = await CreateInstanceHookDelegateAsync(hook);
115158
typeHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
116159
}
117160
}
@@ -124,7 +167,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
124167
{
125168
foreach (var hook in openTypeHooks)
126169
{
127-
var hookFunc = CreateInstanceHookDelegate(hook);
170+
var hookFunc = await CreateInstanceHookDelegateAsync(hook);
128171
typeHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
129172
}
130173
}
@@ -163,7 +206,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
163206
// Collect all global BeforeEvery hooks
164207
foreach (var hook in Sources.BeforeEveryTestHooks)
165208
{
166-
var hookFunc = CreateStaticHookDelegate(hook);
209+
var hookFunc = await CreateStaticHookDelegateAsync(hook);
167210
allHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
168211
}
169212

@@ -186,7 +229,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
186229
// Collect all global AfterEvery hooks
187230
foreach (var hook in Sources.AfterEveryTestHooks)
188231
{
189-
var hookFunc = CreateStaticHookDelegate(hook);
232+
var hookFunc = await CreateStaticHookDelegateAsync(hook);
190233
allHooks.Add((hook.Order, hook.RegistrationIndex, hookFunc));
191234
}
192235

@@ -513,8 +556,11 @@ public ValueTask<IReadOnlyList<Func<AssemblyHookContext, CancellationToken, Task
513556
return new ValueTask<IReadOnlyList<Func<AssemblyHookContext, CancellationToken, Task>>>(hooks);
514557
}
515558

516-
private static Func<TestContext, CancellationToken, Task> CreateInstanceHookDelegate(InstanceHookMethod hook)
559+
private async Task<Func<TestContext, CancellationToken, Task>> CreateInstanceHookDelegateAsync(InstanceHookMethod hook)
517560
{
561+
// Process hook registration event receivers
562+
await ProcessHookRegistrationAsync(hook);
563+
518564
return async (context, cancellationToken) =>
519565
{
520566
var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction(
@@ -528,8 +574,11 @@ private static Func<TestContext, CancellationToken, Task> CreateInstanceHookDele
528574
};
529575
}
530576

531-
private static Func<TestContext, CancellationToken, Task> CreateStaticHookDelegate(StaticHookMethod<TestContext> hook)
577+
private async Task<Func<TestContext, CancellationToken, Task>> CreateStaticHookDelegateAsync(StaticHookMethod<TestContext> hook)
532578
{
579+
// Process hook registration event receivers
580+
await ProcessHookRegistrationAsync(hook);
581+
533582
return async (context, cancellationToken) =>
534583
{
535584
var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction(

0 commit comments

Comments
 (0)