Hub Service Layers

Every hub is a level of one container: a scope that holds the descriptors the hub itself declared and the instances it built — and nothing of its parents'. The container is MeshWeaver.ServiceProvider.MeshServiceProvider: one class for the mesh's root container, each hub's level, each module generation's own container, and every plain scope begun from any of them (a Blazor circuit, a request, a module scope a caller owns). It implements the standard Microsoft.Extensions.DependencyInjection contracts over ServiceDescriptors, so every library that registers into an IServiceCollection runs on it unchanged.

It replaced Autofac (MeshWeaver#7145). The defect it removed is in The Portal Heap Is Hubs → Per-hub DI and type metadata: a hub that was an Autofac child scope with registrations of its own answered every service its parents provided by wrapping each parent registration in a wrapper registration with its own resolve pipeline, kept for the life of the hub — about 120 wrappers, ≈58 KB, per hub in the memex OOM dump, multiplied by ~100,000 hubs.

Why not the standard provider alone

The platform needs three things the standard provider cannot give, and the reason a container of our own exists is exactly these three — nothing else differs from the standard contracts:

  1. A hub EXTENDS the container it is built under. Its own registrations win in the hub, and a parent's SCOPED or TRANSIENT service resolved in the hub is built with the HUB's dependencies — the per-hub INodeValidators (AddScoped<INodeValidator, RlsNodeValidator>() at the root) take the hub's own IMessageHub. A standard scope cannot carry registrations of its own.
  2. Module types are answered at the last step of a lookup. A live-updatable module's services live in a container of their own, and the root answers the types a module declares from the module's CURRENT generation (ModuleOwnedRootSource), so the mesh hub, every per-node hub and every Blazor circuit see them. The standard provider has no hook, and it hands its own scope factory to the host — so a circuit's scope could never reach a module's types.
  3. Scopes are hierarchical. A scope begun from a hub sees the hub's registrations.

Lookup and lifetimes

Threads, cycles and faults

Registrations are frozen when a level is built and read without locks. A shared instance is built at most once, under its own slot's lock — taken synchronously, never awaited, and never covering more than that one instance. Two first builds therefore wait for each other only when one really needs the other's instance; the scope-wide lock of the container this replaced is what made the root↔module and caller↔module lock-order deadlocks possible (MeshWeaver#6432, #6542, #6584), and its absence is pinned by RootAndModuleSingletonsBuildInOneLockOrderTest, AScopedRootServiceIsNeverBuiltInTheRootTest and CallerAndModuleScopedServicesBuildInOneLockTest, each forcing the formerly deadlocking interleaving and asserting that both constructions were inside their builds at once.

A cycle is refused by name: through constructors by the resolve chain, through a factory where the same thread re-enters a shared instance it is building. A fault is not cached: the next resolve builds afresh, so a transient construction fault never becomes a permanent one (which is why a slot is a lock and a flag, not a Lazy<T> — a Lazy in ExecutionAndPublication mode caches the exception).

Ownership and disposal

A scope disposes only what it built, last first, when it ends — never a provided instance, a NotOwnedServiceDescriptor forwarder's result, or a source's stand-in. Ending is raised first, after the scope is already marked disposed, so from that instant every resolve throws ObjectDisposedException (object name MeshWeaver.ServiceScope; the platform classifies it through HubDisposingException.IsDisposedContainer). An instance that is only asynchronously disposable is disposed asynchronously by a synchronous Dispose — never blocked on; its fault is logged. A parent does not track its children: a scope nobody closes is garbage once nobody holds it.

A level's cost is therefore its own descriptors (one small registration object each, in a frozen lookup) and a slot per instance it built — no per-hub copy of anything a parent registered. The container keeps no process-static cache: an activation plan lives on the registration it belongs to, so there is nothing to evict when a collectible context unloads (the hand-maintained eviction of the container it replaced is gone).

Deliberate differences from the standard provider

The standard Microsoft.Extensions.DependencyInjection.Specification.Tests suite, run against this container directly, through an empty level, and with every registration on a level, passes except:

Measured

See The Portal Heap Is Hubs → Per-hub DI and type metadata for the 516-hub test-mesh measurement before and after the replacement.

Speed

Measured against Autofac with the same stopwatch harness on both sides (MeshWeaver#7147):