Problem
Executors are currently installed during ITestRegisteredEventReceiver.OnTestRegistered, usually by an executor attribute. The engine collects registration receivers before invoking those callbacks, so the executor does not yet appear in the collected receiver set. An executor that implements ITestRegisteredEventReceiver therefore misses its registration notification.
Issue #6767 and PR #6768 expose this problem. The PR introduces a deferred executor queue to accommodate the existing API. That creates a second dispatch source with its own identity tracking and ordering semantics. Executors are also absent from the normal eligible-object collection, so fixing their registration callback alone does not make their other lifecycle interfaces participate in ordinary dispatch.
The v2 target is to resolve executors before registration notification, then use the existing event receiver pipeline.
Proposed design
- Separate executor resolution from registration notification. Introduce a small executor provider/resolution contract for built-in executor attributes and extension authors. Select the applicable providers using explicit scope rules before creating their executors. Preserve the intended executor instance lifetime and sharing between test and hook execution; do not instantiate overridden providers merely to enumerate candidates.
- Include resolved executors in the normal eligible-object collection. Classify their supported event receiver interfaces through the same path used for attributes, arguments, and other eligible objects. Deduplicate by reference identity within each test's participant set: one instance used as both test and hook executor, or also supplied as an argument, must not receive the same per-test notification twice. Distinct instances that compare equal must remain distinct.
- Keep registration dispatch in the engine's event orchestration layer.
TestFilterService should delegate registration notification to the orchestrator. Once executor resolution is complete, dispatch a complete receiver set using the normal asynchronous collect/filter/order/invoke pattern, without a separate executor queue on the normal v2 path.
- Separate scope selection, callback ordering, and configuration precedence. Scope determines the applicable provider/attribute.
Order determines callback invocation order, with a deterministic tie rule. Explicit limiter configuration should override an executor-provided default independently of callback order. Resolve competing explicit limiter attributes by method > class > assembly. Do not implement this by globally moving scoped receivers to the beginning or end of every event sequence.
- Integrate with lifecycle and cache management. Ensure resolved executors are visible to subsequent supported lifecycle events and registry presence checks. Define invalidation/replacement behavior across retries and dynamically added tests. Preserve the existing filtering, skip, discovery-only, initialization, and disposal boundaries; resolving an executor must not accidentally start execution resources. Do not replay lifecycle events that have already occurred.
Compatibility and decisions to settle
Existing third-party callbacks can call SetTestExecutor or SetHookExecutor during registration, potentially using state produced by earlier callbacks. A provider contract alone cannot preserve that behavior.
For v2, explicitly choose and document either a migration away from mutation during notification or a bounded compatibility path for legacy installers. Define how replacement and chained installation behave, including whether an executor replaced before use receives notifications. If compatibility handling remains, share receiver identity tracking with normal dispatch rather than introducing independent callback histories. Do not silently drop legacy installations.
The executor provider interface shape is intentionally left open. Likewise, distinguish an overridable executor default from a mandatory execution constraint before designing the configuration API. This issue does not propose automatically treating every write made by an executor callback as a default.
The short-term compatibility fix in #6768 can ship independently of this v2 work.
Existing implementation to build on
src/TUnit.Engine/Extensions/TestContextExtensions.cs: eligible-object collection and typed receiver caches.
src/TUnit.Engine/Services/EventReceiverOrchestrator.cs: ordinary lifecycle dispatch and registration of objects that become available later.
src/TUnit.Engine/Services/ObjectLifecycleService.cs: receiver cache invalidation after property resolution.
src/TUnit.Core/Attributes/Executors/: existing executor attributes and instance sharing.
The existing late-object mechanisms make objects available to subsequent events; they do not solve the registration dependency cycle by themselves. The existing scoped filter's output order should not become the v2 ordering contract by accident.
Acceptance criteria
Problem
Executors are currently installed during
ITestRegisteredEventReceiver.OnTestRegistered, usually by an executor attribute. The engine collects registration receivers before invoking those callbacks, so the executor does not yet appear in the collected receiver set. An executor that implementsITestRegisteredEventReceivertherefore misses its registration notification.Issue #6767 and PR #6768 expose this problem. The PR introduces a deferred executor queue to accommodate the existing API. That creates a second dispatch source with its own identity tracking and ordering semantics. Executors are also absent from the normal eligible-object collection, so fixing their registration callback alone does not make their other lifecycle interfaces participate in ordinary dispatch.
The v2 target is to resolve executors before registration notification, then use the existing event receiver pipeline.
Proposed design
TestFilterServiceshould delegate registration notification to the orchestrator. Once executor resolution is complete, dispatch a complete receiver set using the normal asynchronous collect/filter/order/invoke pattern, without a separate executor queue on the normal v2 path.Orderdetermines callback invocation order, with a deterministic tie rule. Explicit limiter configuration should override an executor-provided default independently of callback order. Resolve competing explicit limiter attributes by method > class > assembly. Do not implement this by globally moving scoped receivers to the beginning or end of every event sequence.Compatibility and decisions to settle
Existing third-party callbacks can call
SetTestExecutororSetHookExecutorduring registration, potentially using state produced by earlier callbacks. A provider contract alone cannot preserve that behavior.For v2, explicitly choose and document either a migration away from mutation during notification or a bounded compatibility path for legacy installers. Define how replacement and chained installation behave, including whether an executor replaced before use receives notifications. If compatibility handling remains, share receiver identity tracking with normal dispatch rather than introducing independent callback histories. Do not silently drop legacy installations.
The executor provider interface shape is intentionally left open. Likewise, distinguish an overridable executor default from a mandatory execution constraint before designing the configuration API. This issue does not propose automatically treating every write made by an executor callback as a default.
The short-term compatibility fix in #6768 can ship independently of this v2 work.
Existing implementation to build on
src/TUnit.Engine/Extensions/TestContextExtensions.cs: eligible-object collection and typed receiver caches.src/TUnit.Engine/Services/EventReceiverOrchestrator.cs: ordinary lifecycle dispatch and registration of objects that become available later.src/TUnit.Engine/Services/ObjectLifecycleService.cs: receiver cache invalidation after property resolution.src/TUnit.Core/Attributes/Executors/: existing executor attributes and instance sharing.The existing late-object mechanisms make objects available to subsequent events; they do not solve the registration dependency cycle by themselves. The existing scoped filter's output order should not become the v2 ordering contract by accident.
Acceptance criteria