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

7 files changed

Lines changed: 146 additions & 17 deletions

File tree

‎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
publicValueTaskOnHookRegistered(HookRegisteredContextcontext)
5555
{
56-
// Apply timeout to the hook method
57-
// This will be used by the hook execution infrastructure
56+
context.Timeout=Timeout;
5857
returndefault(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
publicclassHookRegisteredContext
99
{
10-
publicStaticHookMethodHookMethod{get;}
11-
publicstringHookName=>HookMethod.Name;
10+
privatereadonlyobject_hookMethod;
11+
privatereadonlystring_hookName;
12+
privateTimeSpan?_timeout;
13+
14+
publicStaticHookMethod?StaticHookMethod=>_hookMethodasStaticHookMethod;
15+
publicInstanceHookMethod?InstanceHookMethod=>_hookMethodasInstanceHookMethod;
16+
publicstringHookName=>_hookName;
17+
18+
/// <summary>
19+
/// Gets or sets the timeout for this hook
20+
/// </summary>
21+
publicTimeSpan?Timeout
22+
{
23+
get=>_timeout;
24+
set=>_timeout=value;
25+
}
1226

1327
publicHookRegisteredContext(StaticHookMethodhookMethod)
1428
{
15-
HookMethod=hookMethod;
29+
_hookMethod=hookMethod;
30+
_hookName=hookMethod.Name;
31+
}
32+
33+
publicHookRegisteredContext(InstanceHookMethodhookMethod)
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
publicTAttribute?GetAttribute<TAttribute>()whereTAttribute:Attribute=>Attributes.OfType<TAttribute>().FirstOrDefault();
2424

25-
publicTimeSpan?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+
publicTimeSpan?Timeout{get;internalset;}=TimeSpan.FromMinutes(5);
2630

2731
publicrequiredIHookExecutorHookExecutor{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
publicTAttribute?GetAttribute<TAttribute>()whereTAttribute:Attribute=>Attributes.OfType<TAttribute>().FirstOrDefault();
3434

35-
publicTimeSpan?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+
publicTimeSpan?Timeout{get;internalset;}=TimeSpan.FromMinutes(5);
3640

3741
publicrequiredIHookExecutorHookExecutor{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(newEngineCancellationToken());
8888

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

9192
ParallelLimitLockProvider=Register(newParallelLimitLockProvider());
9293

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

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

9898
// Detect execution mode from command line or environment
9999
varuseSourceGeneration=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+
publicasyncValueTaskInvokeHookRegistrationEventReceiversAsync(HookRegisteredContexthookContext,CancellationTokencancellationToken)
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+
elseif(hookContext.InstanceHookMethod!=null)
210+
{
211+
attributes=hookContext.InstanceHookMethod.Attributes;
212+
}
213+
else
214+
{
215+
return;// No hook method to process
216+
}
217+
218+
vareventReceivers=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+
varfilteredReceivers=ScopedAttributeFilter.FilterScopedAttributes(eventReceivers);
225+
226+
foreach(varreceiverinfilteredReceivers.OrderBy(r =>r.Order))
227+
{
228+
try
229+
{
230+
awaitreceiver.OnHookRegistered(hookContext);
231+
}
232+
catch(Exceptionex)
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+
elseif(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
internalsealedclassHookCollectionService:IHookCollectionService
1111
{
12+
privatereadonlyEventReceiverOrchestrator_eventReceiverOrchestrator;
1213
privatereadonlyConcurrentDictionary<Type,IReadOnlyList<Func<TestContext,CancellationToken,Task>>>_beforeTestHooksCache=new();
1314
privatereadonlyConcurrentDictionary<Type,IReadOnlyList<Func<TestContext,CancellationToken,Task>>>_afterTestHooksCache=new();
1415
privatereadonlyConcurrentDictionary<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
privatereadonlyConcurrentDictionary<Type,CompleteHookChain>_completeHookChainCache=new();
2122

23+
// Cache for processed hooks to avoid re-processing event receivers
24+
privatereadonlyConcurrentDictionary<object,bool>_processedHooks=new();
25+
26+
publicHookCollectionService(EventReceiverOrchestratoreventReceiverOrchestrator)
27+
{
28+
_eventReceiverOrchestrator=eventReceiverOrchestrator;
29+
}
30+
31+
privateasyncTaskProcessHookRegistrationAsync(objecthookMethod,CancellationTokencancellationToken=default)
32+
{
33+
// Only process each hook once
34+
if(!_processedHooks.TryAdd(hookMethod,true))
35+
{
36+
return;
37+
}
38+
39+
try
40+
{
41+
HookRegisteredContextcontext;
42+
43+
if(hookMethodisStaticHookMethodstaticHook)
44+
{
45+
context=newHookRegisteredContext(staticHook);
46+
}
47+
elseif(hookMethodisInstanceHookMethodinstanceHook)
48+
{
49+
context=newHookRegisteredContext(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
privatesealedclassCompleteHookChain
2366
{
2467
publicIReadOnlyList<Func<TestContext,CancellationToken,Task>>BeforeTestHooks{get;init;}=[
@@ -51,7 +94,7 @@ public ValueTask<IReadOnlyList<Func<TestContext, CancellationToken, Task>>> Coll
5194
{
5295
foreach(varhookinsourceHooks)
5396
{
54-
varhookFunc=CreateInstanceHookDelegate(hook);
97+
varhookFunc=awaitCreateInstanceHookDelegateAsync(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(varhookinopenTypeHooks)
66109
{
67-
varhookFunc=CreateInstanceHookDelegate(hook);
110+
varhookFunc=awaitCreateInstanceHookDelegateAsync(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(varhookinsourceHooks)
113156
{
114-
varhookFunc=CreateInstanceHookDelegate(hook);
157+
varhookFunc=awaitCreateInstanceHookDelegateAsync(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(varhookinopenTypeHooks)
126169
{
127-
varhookFunc=CreateInstanceHookDelegate(hook);
170+
varhookFunc=awaitCreateInstanceHookDelegateAsync(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(varhookinSources.BeforeEveryTestHooks)
165208
{
166-
varhookFunc=CreateStaticHookDelegate(hook);
209+
varhookFunc=awaitCreateStaticHookDelegateAsync(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(varhookinSources.AfterEveryTestHooks)
188231
{
189-
varhookFunc=CreateStaticHookDelegate(hook);
232+
varhookFunc=awaitCreateStaticHookDelegateAsync(hook);
190233
allHooks.Add((hook.Order,hook.RegistrationIndex,hookFunc));
191234
}
192235

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

516-
privatestaticFunc<TestContext,CancellationToken,Task>CreateInstanceHookDelegate(InstanceHookMethodhook)
559+
privateasyncTask<Func<TestContext,CancellationToken,Task>>CreateInstanceHookDelegateAsync(InstanceHookMethodhook)
517560
{
561+
// Process hook registration event receivers
562+
awaitProcessHookRegistrationAsync(hook);
563+
518564
returnasync(context,cancellationToken)=>
519565
{
520566
vartimeoutAction=HookTimeoutHelper.CreateTimeoutHookAction(
@@ -528,8 +574,11 @@ private static Func<TestContext, CancellationToken, Task> CreateInstanceHookDele
528574
};
529575
}
530576

531-
privatestaticFunc<TestContext,CancellationToken,Task>CreateStaticHookDelegate(StaticHookMethod<TestContext>hook)
577+
privateasyncTask<Func<TestContext,CancellationToken,Task>>CreateStaticHookDelegateAsync(StaticHookMethod<TestContext>hook)
532578
{
579+
// Process hook registration event receivers
580+
awaitProcessHookRegistrationAsync(hook);
581+
533582
returnasync(context,cancellationToken)=>
534583
{
535584
vartimeoutAction=HookTimeoutHelper.CreateTimeoutHookAction(

0 commit comments

Comments
 (0)