Skip to content

First-in-session/assembly event receivers registered mid-run can miss the one-shot event #6557

Description

@thomhurst

Context

Follow-up from PR #6556 review (Codex P1 finding on injected-property event receivers).

Problem

IFirstTestInTestSessionEventReceiver / IFirstTestInAssemblyEventReceiver invocations are one-shot: EventReceiverOrchestrator memoizes the invocation task (keyed on session/assembly) and enumerates _registry.GetReceiversOfType<T>() at the moment the first gated test reaches it. Object-scoped receivers (class instances, constructor/method arguments, injected properties, and even attributes of later tests) are only registered into the EventReceiverRegistry when their own test enters TestCoordinator.ExecuteTestAsync.

So when receiver R (any object-scoped source) belongs to a test that is not first, and another receiver of the same interface was registered by an earlier test, the earlier test's invocation memoizes the one-shot event with a receiver list that predates R — R's callback never fires.

Notes on scope:

  • The gate-false path (HasFirstTestInSessionReceivers() etc.) returns without memoizing, so when the late-registered receiver is the only one of its interface, it still fires — just parented to its own test rather than the chronologically first test. The miss requires a second same-interface receiver earlier in the run.
  • This is pre-existing and source-agnostic — it affected class-instance receivers (RegisterClassInstanceReceiver runs even later, in PrepareTest) and ctor/method-argument receivers before fix: run event receivers on injected properties #6556; that PR merely brought injected properties to parity with the other eligible-object sources for per-test events.
  • A regression test can't currently be written reliably: TUnit.TestProject already contains IFirstTestInAssemblyEventReceiver implementors, so any "receiver on a later-running class" test is racy in full-suite runs — which is itself a demonstration of the problem.

Proposed direction

Perform an eager RegisterReceivers pass over all executable test contexts before execution begins (natural spot: alongside EventReceiverOrchestrator.InitializeTestCounts), so the registry is complete before any one-shot event can be memoized. The receiver caches are already built once per test during registration (GetTestRegisteredReceivers in TestFilterService), so the incremental cost is bounded; needs measuring against large suites since it moves work ahead of first-test start.

/cc PR #6556

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions