Skip to content

Resolve executors before registration and include them in the normal event receiver pipeline #6775

Description

@thomhurst

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  • Built-in executors are resolved before registration receivers are collected, without constructing overridden executor candidates.
  • Executor registration and subsequent supported lifecycle notifications use the normal receiver pipeline.
  • Reference identity prevents duplicate per-test notifications across test/hook roles and other eligible-object sources, while preserving distinct equal instances.
  • Scope selection and callback ordering are deterministic and tested independently.
  • Explicit limiter versus executor default produces the same result in either callback order, with coverage for method/class/assembly precedence.
  • Legacy custom installers have a documented and tested migration or compatibility contract, including replacement and chained installation.
  • Tests cover source-generated and reflection discovery, retries, dynamically added tests, and relevant filtering/skip/discovery-only behavior.
  • Public API snapshots and documentation describe the new lifecycle contract; Native AOT/trimming compatibility is preserved and relevant registration allocation costs are checked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestv2Deferred to v2 release

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions