fix: run event receivers on injected properties - #6556
Conversation
The per-context event receiver caches are built and locked in when ITestRegisteredEventReceiver instances are gathered during registration, which deliberately happens before injected property values are resolved (so SkipAttribute can short-circuit expensive data sources). The caches were never invalidated after property resolution, so the eligible event-object set fed to the EventReceiverRegistry at execution time was stale and missing injected property values. When the only ITestStartEventReceiver/ITestEndEventReceiver in the run was an injected property, the registry-level fast-path gates reported no receivers and the events never fired. Invalidate the caches after property resolution in RegisterTestAsync so the next access rebuilds them with the injected values included. Fixes #6554
Greptile SummaryThe PR makes property-injected event receivers visible after property resolution and eagerly registers scheduled receivers before one-shot lifecycle events can run.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/TUnit.Core/TestContext.cs | Adds a per-context marker allowing scheduled tests to avoid redundant receiver registration while preserving separate class-instance handling. |
| src/TUnit.Engine/Services/EventReceiverOrchestrator.cs | Extends pre-execution preparation to populate the receiver registry before one-shot events begin. |
| src/TUnit.Engine/Services/ObjectLifecycleService.cs | Invalidates receiver caches after injected-property resolution, including exceptional partial-resolution paths. |
| src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs | Retains receiver registration as a fallback for tests that bypass the eager preparation pass. |
| src/TUnit.Engine/TestSessionCoordinator.cs | Invokes receiver preparation after deferred test expansion and before scheduling. |
| tests/TUnit.Engine.Tests/PartialInjectionFailure6554Tests.cs | Adds integration coverage ensuring a resolved sibling receiver observes test end after another injected property fails. |
| tests/TUnit.TestProject/Bugs/_6554/PartialInjectionFailureTests.cs | Defines the partial property-injection failure regression scenario. |
| tests/TUnit.TestProject/Bugs/_6554/Tests.cs | Covers start and end callbacks on a session-shared injected receiver. |
| tests/TUnit.TestProject/Bugs/_6557/Tests.cs | Covers eagerly registered first-in-session and first-in-assembly receivers. |
Sequence Diagram
sequenceDiagram
participant Registration as Test registration
participant Injection as Property injection
participant Context as TestContext caches
participant Orchestrator as EventReceiverOrchestrator
participant Execution as Test execution
Registration->>Context: Build registration receiver caches
Registration->>Injection: Resolve injected properties
Injection->>Context: Cache injected values
Injection->>Context: Invalidate receiver caches
Orchestrator->>Context: Rebuild eligible receiver objects
Orchestrator->>Orchestrator: Register all scheduled receivers
Execution->>Orchestrator: Invoke lifecycle events
Reviews (5): Last reviewed commit: "perf: gate per-test receiver registratio..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cef18832f1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
First-in-session/assembly/class events are one-shot: they memoize per scope key and enumerate the EventReceiverRegistry at the moment the first gated test reaches them. Receivers were registered lazily when their own test entered TestCoordinator, so an object-scoped receiver (injected property, ctor/method argument) on a later-scheduled test missed the one-shot whenever an earlier test with a same-interface receiver had already triggered the memoized invocation. Rename InitializeTestCounts to PrepareForExecution and register every test's eligible receivers in its existing pre-execution loop over all executable tests. This runs after argument/property resolution and deferred placeholder expansion, so the eligible-object sets are complete. The per-test registration in TestCoordinator stays as a fallback for dynamically queued tests that bypass the eager pass; for scheduled tests it is a cheap dedup no-op. Fixes #6557
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bda1ca8da0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewSummary: Well-scoped fix for #6554/#6557. I traced the full call chain ( One architectural concern worth addressing:
Before this PR that call site was safe because Previously, this same cache-building work only ran per-test inside Suggested fix: either move the Secondary, non-blocking note: |
Property resolution runs a test's injected properties in parallel (Task.WhenAll), so when one property's data source throws, siblings may still have resolved and cached values that implement event receiver interfaces. The test is marked failed at registration and TestCoordinator invokes ITestEndEventReceiver for it — but the cache invalidation only ran on the success path, leaving the receiver invisible to the stale pre-injection cache. Move the invalidation into a finally block so the failed-registration end-receiver invocation sees successfully-created receivers.
There was a problem hiding this comment.
Code review (re-check)
I independently re-traced the ordering guarantees for both fixes and they hold: HandleRunRequestAsync fully awaits DiscoverTests(isForExecution: true) (which runs TestFilterService.RegisterTestsAsync → ObjectLifecycleService.RegisterTestAsync) before TestSessionCoordinator.ExecuteTests is even called, and DeferredTestExpander.ExpandAsync awaits RegisterTestsAsync for its children before ExpandDeferredPlaceholdersAsync returns — so by the time PrepareForExecution's eager loop runs, every test's injected-property caches are already invalidated/rebuilt-on-demand. ParallelTaskHelper.ForEachAsync uses Task.WhenAll (not Parallel.ForEachAsync), so siblings really do finish caching before a faulting property's exception propagates — confirms the finally-based invalidation (06c863e) is deterministic, not racy. _initializedObjects/_registeredFirstEventReceiverTypes dedup correctly prevents the retained TestCoordinator.RegisterReceivers fallback from double-invoking anything the eager pass already registered. Both #6554 and #6557 repros look correct and the codex-flagged partial-injection-failure gap is properly closed.
Previously-raised concern is still unresolved: my earlier review on this PR (2026-08-07T09:35) flagged that TestSessionCoordinator.ExecuteTests calls InitializeEventReceivers → PrepareForExecution before the try/finally that wraps execution and cleanup. That's still true at the current head (06c863e). PrepareForExecution now runs RegisterReceivers synchronously for every scheduled test, which builds caches via List<T>.Sort on the user-overridable Order property and ScopedAttributeFilter.FilterScopedAttributes on IScopedAttribute.ScopeType — both can throw on a bad user implementation. Pre-PR, that same work only ran inside TestCoordinator.ExecuteTestAsync's own try/catch(Exception) { MarkFailed }, so one bad receiver failed only its test. Now it runs ahead of and outside the session try/finally, so one bad receiver aborts the whole run and skips the finally cleanup (tracked-object disposal sweep, TestDataContainer.Reset(), artifact reporting).
Suggested fix (unchanged from before): move the InitializeEventReceivers(testList, cancellationToken); call inside the try block, ahead of PrepareTestOrchestrator, so finally still runs if a receiver's Order/ScopeType getter throws. This is a minimal, low-risk change and doesn't affect the correctness fixes in this PR.
Non-blocking: PrepareForExecution also moved cache-building from implicitly-parallel (each test built its cache lazily on its own execution slot) to single-threaded, up-front, for every scheduled test. Probably fine, but worth watching in CI timing for very large suites.
PartialInjectionFailureObserverTests was tagged Pass but DependsOn dragged Fails_During_Registration into the EngineTest=Pass filtered run, failing ExpectedStateTests.Pass on all OSes. Tag it Failure and verify the pair via a dedicated engine test (1 pass, 1 fail).
Code review (re-check #3)Re-verified against current head ( Previously-raised concern is still unresolved. InitializeEventReceivers(testList, cancellationToken); // line 64 — outside try
try
{
await PrepareTestOrchestrator(testList, cancellationToken);
await ExecuteTestsCore(testList, cancellationToken);
...
}
finally
{
// disposal sweep, TestDataContainer.Reset(), artifact reporting
}
Both can throw on a bad user implementation. Pre-PR, this same cache-building work only ran per-test inside Suggested fix (either works, first is simplest): move the Everything else holds up on this re-check: the ordering guarantees for both #6554 and #6557 are correct, the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db042cbd3a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…flag The eager PrepareForExecution pass already registers every scheduled test's eligible event objects; the unconditional per-test call in TestCoordinator repeated the full eligible-object scan and concurrent hash probes for every test. RegisterReceivers now stamps the context, and the coordinator fallback only runs for contexts that bypassed the eager pass (e.g. dynamically queued tests).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3f7913d9f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code review (re-check #4)Re-verified against current head ( This new commit looks correct. Previously-raised concern is still unresolved (4th round).
Suggested fix (unchanged, still minimal and low-risk): move the Everything else continues to hold up: ordering guarantees for both #6554 and #6557 are correct, and the new perf-gating change doesn't introduce a regression. This remains the one outstanding item before merge. |
Summary
Fixes #6554 and #6557 — two related gaps in event receiver registration:
[ClassDataSource<T>]properties) were never invoked.Root causes
#6554 — stale per-context caches.
TestFilterService.RegisterTestcallsGetTestRegisteredReceivers(), building all per-context receiver caches (EventReceiversBuilt = true) — deliberately before property resolution soSkipAttributecan short-circuit expensive data sources.ObjectLifecycleService.RegisterTestAsyncthen resolves injected property values, but the caches were never invalidated, so the eligible-event-object set consumed later was stale and missing the injected values. The registry-level fast-path gates (HasTestStartReceivers()etc.) then reported no receivers and the events were skipped entirely.#6557 — one-shot events vs lazy registration. First-in-session/assembly/class invocations memoize per scope key and enumerate the
EventReceiverRegistrywhen the first gated test reaches them. Receivers were registered lazily when their own test enteredTestCoordinator, so an object-scoped receiver on a later-scheduled test missed the one-shot whenever an earlier same-interface receiver had already triggered the memoized invocation. Pre-existing and source-agnostic (ctor/method args had the same exposure); surfaced by Codex review on this PR.Fixes
ObjectLifecycleService.RegisterTestAsync: invalidate the per-context receiver caches after property resolution (only when injected properties exist) — cache coherence at the single mutation point. O(1), off the hot path, both source-gen and reflection modes.EventReceiverOrchestrator.InitializeTestCounts→ renamedPrepareForExecution: registers every test's eligible receivers in its existing pre-execution loop over all executable tests. Runs after argument/property resolution and deferred placeholder expansion (TestSessionCoordinatorordering), so eligible-object sets are complete and the registry is fully populated before any one-shot event can fire.TestCoordinator's per-testRegisterReceiversstays as a fallback for dynamically queued tests that bypass the eager pass; for scheduled tests it's a cheap dedup no-op.RegisterClassInstanceReceiver(unchanged).Note:
ITestRegisteredEventReceiveron injected properties remains unsupported by design —OnTestRegisteredintentionally fires before data sources are materialized so skip attributes can prevent their creation.Testing
Bugs/_6554: injectedPerTestSessionproperty implementing start/end receivers. Fails without the invalidation fix, passes with it — both source-gen and reflection modes.Bugs/_6557: injected property implementing first-in-session/assembly receivers, deterministic under the eager pass (every test awaits the memoized one-shot invocations before its body; the pre-fix behavior was scheduling-dependent, which is exactly the bug).*EventReceiver*and*PropertyInjection*suites: failure sets identical with and without these changes in both parallel and sequential (--maximum-parallel-tests 1) runs — the handful of filtered-run failures pre-exist on main.