Repository navigation
Fix open generic multi-service registrations overriding later defaults - #1499
Conversation
8fcfb8c to
f7d5d47
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1499 +/- ##
===========================================
+ Coverage 78.23% 78.39% +0.15%
===========================================
Files 217 217
Lines 5928 5965 +37
Branches 1269 1281 +12
===========================================
+ Hits 4638 4676 +38
Misses 753 753
+ Partials 537 536 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
f7d5d47 to
48e3c59
Compare
#1465) A registration source providing one component for several services applied it to all of them immediately. Services that had not yet drained that source took the component ahead of the higher-priority sources they still had to query, so whichever service was resolved second got the shared component rather than its own overriding registration. Closed types were unaffected. The implementation is now held against the source that produced it and applied when the service reaches that source in its own queue, so source priority rather than resolution order decides the default. Deduplication is unchanged: the source is queried once and a shared component stays shared. Only a component exposing more than one service can hold anything, so that state lives on the tracker instead of on ServiceRegistrationInfo, which stays the size it was. Containers that never register such a component pay a null field read per source drained. Swapping SkipSource for IsSourceQueued also drops a LINQ query and a queue allocation from the existing deduplication path.
008b79c to
61e230f
Compare
Codecov flagged 50% branch coverage on the guard. Three of its four conditions could never be false: CompleteInitialization nulls the source queue, so the IsInitialized check was redundant with the null check, and Contains already returns false for an empty queue. Reuse IsInitializing for what remains and unit test the type directly, taking the line to 100%. Also use see langword for true/false/null in the XML docs added here.
… affects Holding an implementation for another service crosses threads: one thread records it, another applies it when that service drains the source. Four gaps came out of review. The record was published before the registration's pipeline was built, so a concurrent drain could apply it and resolve through an unbuilt pipeline. Build first; building is idempotent, so AddRegistration's later call does nothing. Deciding to hold and recording it were not atomic against the drain, which could pass the source in between and leave the implementation held with nothing to apply it. Both now happen under the other service's monitor, taken without waiting - two threads resolving two services of one component each already hold the monitor the other wants, so blocking there would deadlock. Failing to take it applies the implementation immediately, as before this fix existed. The held list was mutated in place, so a drain could observe a half-built entry. Append onto a new array instead and drop the locks. A per-scope source skipped for an isolated service left its entry unreachable for the tracker's lifetime; discard it there. Also pass the map to the helpers rather than dereferencing a field the callers guard, matching GetEphemeralServiceInfo; say plainly in the comments that the field is only a field read until the first implementation is held; and add the benchmark for the multi-service shape, which no existing benchmark reaches.
Review responseThanks — four real problems, and chasing finding 1 turned up a fifth that the tests I added caught. Pushed in 31136ad. Finding 1 — the cross-thread handoffConfirmed and fixed, though not the suggested way. Taking Two more gaps in the same handoff:
On severity, one correction worth having. I wrote the concurrency tests the finding asked for, then ran them against What it still does not do: guarantee that concurrent first resolution of two services of one multi-service component shares a single component or honours the override. That needs the source query itself memoised per (source, service), which is a larger change than this fix. It is pre-existing, no worse here, and worth its own issue — the only test kept is the one this PR actually satisfies, since the suite has no skipped tests. Finding 2 — the cost once latchedFair, and the framing was wrong. You were also right that the earlier table proved nothing about the triggered path, because every benchmark in it registers single-service components.
So first resolution of a multi-service open generic container costs about 8% more, warm resolution is unchanged, and containers without that shape are unaffected. That is the price of the fix, now measured rather than asserted. Finding 3 — the
|
Codecov flagged the new lines. Two guards depend on a race and cannot be driven deterministically, so state them positively - taking the monitor, and the source still being queued - which leaves no unreached line. Add a source returning two components for one service to cover holding more than one implementation. Drop the discard for a per-scope source skipped in an isolated resolve. It is only reachable through a per-scope source that exposes several services, queried through an isolated scope, which needs a load-context test to demonstrate; the entry it reclaims sits on a cache that already grows on that axis. Better left out than added untested. Keep UseEphemeralAdditionalInfoIfNeeded where it was so the diff does not present its existing uncovered ephemeral branch as new.
…ted service A per-scope source that exposes several services can hold an implementation for one of them, and a scope-isolated query then skips that source deliberately. The implementation must not be applied there, but the entry was also never released, so it outlived the service info it keys on once that info was dropped for having no registrations. Discard it in the isolation branch. Tested against the tracker directly rather than through a load context: a per-scope source exposing two services, the second queried as isolated. The private field is read the way Assertions.cs already reads one, because releasing the entry has no observable effect on resolution - which is the whole reason it went unnoticed.
|
Finding 4 is now fixed and tested — To correct what I said earlier: I had called this pre-existing, and that was wrong. The entry is now released in the isolation branch. It is still not applied there, which is the behaviour you identified as correct. Tested against the tracker directly instead of through a load context: a per-scope source exposing two services, the first queried normally so the implementation is held for the second, then the second queried as a The assertion reads the private field the way 892 + 504 tests pass on net8.0 and net10.0, Release build clean, |
|
Filed #1500 for the concurrency hole, with the measured rates and the reason a tracker-wide gate around source draining would not close it. To restate the trade this PR is taking, since it is the one thing #1500 records against it: leaving the source in the other service's queue is what makes the ordering fix work, and it also makes the duplication case somewhat more likely under concurrent first resolution — 1.10% to 1.63% of completed attempts in a 4,000-attempt probe. In exchange, sequential resolution goes from 0/200 to 200/200 correct, override loss under the same race drops from 4.86% to 3.15%, and exceptions across 8,000 attempts drop from 2,071 to 1. Every failure mode #1500 describes predates this PR. No code change from this; the PR is unchanged at |
Fixes #1465
A registration source that provides one component for several services applied that component to every one of those services immediately. Services that had not yet drained the source took it ahead of the higher-priority sources they still had to query, and since the first source implementation is the default, whichever service was resolved second got the shared component rather than its own overriding registration.
That made the result depend on resolution order, so two identical containers could disagree:
Closed types were unaffected, because they never go through the source-query path.
Proposed Changes
AddRegistration'soriginatedFromDynamicSourceflag with the originatingIRegistrationSource. The flag was only ever true when a source produced the registration, so the source being non-null carries the same information and the two cannot drift apart.ExcludeSource,InitializeOrSkipSource, andServiceRegistrationInfo.SkipSource, which this replaces.Keeping the common case free
Only a component exposing more than one service can hold anything back, which is rare, so none of the new state lives on
ServiceRegistrationInfo— that type is left exactly the size it was. The held implementations live in a lazily created dictionary on the tracker, so a container that never registers such a component pays a single null field read per source drained, and allocates nothing.An earlier revision did put a field on
ServiceRegistrationInfo. It measured a real cost, so it was reworked:ChildScopeResolve.ResolveResolveNeverRegisteredFromChildResolveNeverRegisteredFromChildGen0ChildScopeResolve.ResolveallocatedGen0 and Gen1 counts are now identical to
developon every benchmark measured, and allocation is within 20 bytes everywhere. Being slightly ahead ofdevelopis consistent withSkipSourcehaving used a LINQ query plus a queue allocation thatIsSourceQueueddoes not.Measured with two worktrees running the same filter in interleaved rounds, since the harness only compares source against a released package. Single runs on this machine drift up to ~3% — warm steady-state resolves that this change cannot touch (
NoDependencies,ResolveOpenGeneric) moved that much between runs — so only deltas reproducing across paired rounds are treated as real.Notes
All of this is internal (
IRegisteredServicesTrackerandServiceRegistrationInfoareinternal, andInternalsVisibleTois limited toAutofac.Test), so there is no public API change.Four of the nine new tests fail on
developand pass with the fix; the other five assert the deduplication and no-override invariants and pass either way. 1387 tests pass on net8.0 and net10.0.