Repository navigation
Concurrent first resolution of two services of one multi-service component can duplicate the component or miss an override #1500
Description
Describe the Bug
When one component exposes several services and two of those services are resolved for the first time on different threads at once, the two resolutions can each query the registration source independently. Because a source mints a new component per query, the result is either two distinct components for what should be one shared component, or the wrong default for one of the services.
This is long-standing, not a regression. It is being filed as a follow-up to #1465 / #1499, which fixed the single-threaded ordering defect and removed most of the exceptions this race used to throw, but did not close the race itself.
Why It Happens
Service info initialization is serialized per service — GetInitializedServiceInfo takes Monitor.Enter(info) for the service being resolved. But when a source produces a component exposing several services, the tracker has to reach into a different service's info to record that the component belongs to it too. That second info is not covered by the monitor the thread holds, so the handoff between "one thread records the component for the other service" and "the other thread drains that source" is not atomic. Miss it, and the second thread queries the source itself and mints a second component.
The obvious fix — one tracker-wide lock around source draining — does not work. Sources call the registrationAccessor they are handed, which is ServiceRegistrationsFor → GetInitializedServiceInfo → Monitor.Enter(otherInfo). A gate held across draining would therefore want an info monitor while another thread holding an info monitor wants the gate, which is the same lock-order cycle in a new place. Closing this properly needs either a deterministic acquisition order across service infos, or a single gate hoisted above every Monitor.Enter(info) so that all service info initialization serializes. Both change how registration concurrency works in general and would need their own performance validation.
Steps to Reproduce
interface IA<T> { }
interface IB<T> { }
class AB<T> : IA<T>, IB<T> { }
var builder = new ContainerBuilder();
builder
.RegisterGeneric(typeof(AB<>))
.As(typeof(IA<>))
.As(typeof(IB<>))
.SingleInstance();
using var container = builder.Build();
// First resolution of each service, concurrently.
var first = Task.Run(() => (object)container.Resolve<IA<int>>());
var second = Task.Run(() => (object)container.Resolve<IB<int>>());
var resolved = await Task.WhenAll(first, second);
// Intermittently fails: SingleInstance is expected to share one instance across both services.
Assert.Same(resolved[0], resolved[1]);Swap the SingleInstance() for a second RegisterGeneric(typeof(BOnly<>)).As(typeof(IB<>)) and the same interleaving intermittently resolves IB<int> to AB<int> instead of the overriding BOnly<int>.
Both need a container per attempt, since the race is only in first resolution. A few thousand attempts is enough to see it.
Measured Rates
4,000 attempts per scenario, per branch, classifying each attempt as correct, wrong, or thrown:
9.3.2 (develop before #1499) |
with #1499 | |
|---|---|---|
| Duplicated component | 37 of 3,358 completed (1.10%) | 65 of 3,999 (1.63%) |
| Override lost | 125 of 2,571 completed (4.86%) | 126 of 4,000 (3.15%) |
| Exceptions across 8,000 attempts | 2,071 | 1 |
| Same scenario resolved sequentially | 0 of 200 correct | 200 of 200 correct |
Before #1499 the exceptions were mostly InvalidOperationException: Collection was modified; enumeration operation may not execute. out of ServiceRegistrationInfo.SkipSource (a queue rebuilt while another thread enumerated it), plus smaller numbers of The operation is only valid during initialization., Component pipeline has not yet been built., and ArgumentNullException on source. #1499 removes SkipSource and nearly all of those.
Note that #1499 makes the duplication case somewhat more likely — 1.10% to 1.63% — because it deliberately leaves the source in the other service's queue so the component can be applied in source order, where the old code removed it. That is the known cost of the ordering fix and is recorded here rather than left implicit.
Expected Behavior
A component exposing several services should resolve to one component regardless of which of its services is resolved first, or whether they are resolved concurrently, and a later registration overriding one of those services should win in either case.
Workaround
Resolve one of the affected services once during startup, before concurrent traffic. The race is only in first resolution; once the service info is initialized, resolution is unaffected.
Priority
Low. It needs a component exposing several services through a registration source (open generics via RegisterGeneric(...).As(...).As(...), or AnyConcreteTypeNotAlreadyRegisteredSource configured to add a service), two of those services first resolved concurrently, and an unlucky interleaving. Single-threaded resolution is correct as of #1499, and a startup warm-up avoids it entirely.
Dependency Versions
Autofac: present in 9.3.2 and earlier; still present, at a different rate, with #1499.
Activity
tillig commentedon Sep 18, 2026
Updating the rates here, since #1503 / #1504 changed which exception dominates.
Component pipeline has not yet been builtis the residual failure in this race: one thread applies a held implementation between the hold and the point whereAddRegistrationbuilds the pipeline. Over 4,000 concurrent first-resolve attempts:9.3.3 suppressed it by building the pipeline during the hold, which is what broke #1503, so #1504 removes that and the rate returns to roughly where it was before the hold mechanism existed. Total exceptions across the same 4,000 attempts are 582 on 9.3.2 and 6 after #1504.
A targeted fix for this particular symptom, short of the broader serialization this issue describes: publish the held implementation only after
AddRegistrationhas raisedRegisteredand built the pipeline, which needs the skip decision passed intoAddRegistrationseparately from the hold record. That would close the unbuilt-pipeline window without touching lock ordering, though it would not address the duplication or lost-override outcomes above.Trim the issue comments and stop asserting a property the resolve lacks
Do not build a held registration's pipeline early (#1504)