Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,65 @@ built. `AModuleWithCircuitBoundServicesLoadsAtBootTest` and
`ModuleRootServicesSwapTest.AnUpdateWithCircuitBoundServices_SwapsLive` carry the Radzen shape, against
a root-scoped stand-in that throws exactly as `RemoteNavigationManager` does.

### A module's scoped service is the caller's (MeshWeaver#6584)

Not building a scoped registration at boot only moved its construction to its first resolve — and
every resolve of a module service used to land in the module container's OWN root: one instance per
generation, shared by every circuit, built against a root child scope no circuit initialised. Radzen's
`ThemeService` was one instance for every user (one user's theme was the next user's), and
`DialogService` threw `'RemoteNavigationManager' has not been initialized` on its first real resolve.

So a resolve now carries its **caller** — the root-side scope it is made in (a Blazor circuit's, a
request's, a hub's; null for the root container itself, i.e. a singleton or a resolve off the root
provider) — down every road the root reaches a module by: `ModuleOwnedRootSource`'s stand-ins, the
`Current` forwarder, a `Proxy` over a scoped or transient registration (one proxy per caller scope,
forwarding for it; a singleton keeps its one stable proxy), and a module type asked from ANOTHER
module's container (`ModuleFallbackSource`, which passes on the caller its own scope serves). A
registration that is not a singleton is then resolved in a module scope **owned by that caller**
(`ModuleServiceProvider.InCallerScope`, `ModuleCallerScopes`): begun on first use, tagged with the
caller, and handed to the caller's own disposer, so it ends when the caller ends — asynchronously, as a
circuit's scope is disposed. Its `RootResolutionScope` is the caller's own scope, so every root
registration it takes (`NavigationManager`, `IJSRuntime`) is that caller's instance. A singleton is
still the generation's one instance, built before published as above.

**Which ends first.** A caller can outlive a generation (a hub scope across a live swap): the
generation's container, as it ends, ends every caller's module scope FIRST — before its own singletons
— and lets go of it, so the caller's disposer holds nothing of the retired generation; the caller's own
end afterwards finds nothing to do (`ACallersModuleScopeEndsWithTheGenerationTest`).
`AModulesScopedServicesAreTheCallersTest` measures two circuits through both the module-declared and
the platform-class road; its negative control resolves the same registration with no caller and gets
the uninitialised-state throw.

**A forwarder owns nothing it returns (MeshWeaver#6588).** Every root forwarder of a module's
platform-typed service — `Current`, and `Proxy` at every lifetime — is a `NotOwnedServiceDescriptor`,
which every container MeshWeaver builds registers **externally owned** (`PopulateMeshWeaver`). An
ordinary factory registration is owned by the scope that resolves it, so the forwarder used to hand
ownership of a module's instance to whoever asked: the first circuit to end disposed a module
SINGLETON under every other user, and disposed a module SCOPED instance a second time after the module
scope that circuit owned had already disposed it; a `Proxy` over an `IDisposable` interface forwarded
the same disposal. Ownership stays where the instance was built — the generation's container for a
singleton, the caller-owned module scope for a scoped one. The forwarder is registered at its own
position in the collection while `Populate` walks it, so the default registration of a service and the
order of an `IEnumerable` of it are unchanged (`ANotOwnedForwarderKeepsItsPositionTest`).
`AModuleForwarderOwnsNothingItReturnsTest` measures one circuit ending on the test mesh, on an
`IHostApplicationBuilder` host and on a legacy `IHostBuilder` host: the singleton serves the other
circuit undisposed, each scoped instance (class and proxy route) is disposed exactly once.

**One lock owner per caller.** Autofac builds a scope's shared instances under that scope's one
shared-instance lock. A caller-owned module scope is a scope of the MODULE container, and its root
resolutions are made in the caller's own scope, so the two locks were taken in both orders: a
caller-scoped ROOT service whose construction resolves a module's scoped service (caller, then module
scope), and that module service's construction taking a caller-scoped root service not built yet
(module scope, then caller) — two first builds overlapping in those orders deadlocked. So a
caller-owned module scope, and every scope begun from it, takes its CALLER's lock as its own
(`CallerSharedInstanceLock`): one monitor for the caller and everything it owns, re-entered by the
thread that already holds it, so there is no order to invert — whichever road reaches the module scope,
including a module service that keeps its `IServiceProvider` and resolves later. Autofac offers no API
to hand a scope its lock, so the lock field is set before the scope is handed out; Autofac is pinned, and
a version without that field fails loudly at the first caller rather than silently bringing the
deadlock back. `CallerAndModuleScopedServicesBuildInOneLockTest` forces the overlap; its negative
control gives the same module scope its own lock back and deadlocks.

**Open: a withdrawn module's `InstalledModuleAssembly` (MeshWeaver#6557).** A module withdrawn at boot
keeps the transient `InstalledModuleAssembly` registration it was given while the mesh was configured,
and once its boot assembly is collected that registration throws "no longer held by this mesh's module
Expand Down
18 changes: 13 additions & 5 deletions src/MeshWeaver.Mesh.Contract/ModuleContexts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,18 @@ public ModuleContexts Attach(CollectibleContextUnloads? collectibleUnloads, ILog

/// <summary>The instance behind forwarded registration <paramref name="index"/> of
/// <paramref name="module"/>'s CURRENT generation — what every root forwarder resolves.</summary>
public object ResolveModuleService(string module, int index) =>
public object ResolveModuleService(string module, int index) => ResolveModuleService(module, index, null);

/// <summary>As <see cref="ResolveModuleService(string, int)"/>, for <paramref name="caller"/> — the
/// root-side scope resolving (a Blazor circuit's, a request's, a hub's), or null (MeshWeaver#6584,
/// <see cref="ModuleServices.Resolve(IServiceProvider, int, Autofac.ILifetimeScope?)"/>).</summary>
/// <param name="module">The module's entry-assembly name.</param>
/// <param name="index">The registration's index.</param>
/// <param name="caller">The root-side scope resolving, or null.</param>
public object ResolveModuleService(string module, int index, Autofac.ILifetimeScope? caller) =>
(Current(module)?.Services ?? throw new InvalidOperationException(
$"Module {module} holds no service scope in this mesh."))
.Resolve(Root, index);
.Resolve(Root, index, caller);

/// <summary>The scope of <paramref name="module"/>'s CURRENT generation.</summary>
public IServiceProvider? ModuleScope(string module) =>
Expand All @@ -198,11 +206,11 @@ stage is null
internal IServiceProvider? ModuleScopeSeenFrom(string module, ModuleSwapStage? stage) =>
(stage?.Get(module) ?? Current(module))?.Services?.Scope(Root);

/// <summary>As <see cref="ResolveModuleService"/>, seen from a container of <paramref name="stage"/>.</summary>
internal object ResolveModuleServiceSeenFrom(string module, int index, ModuleSwapStage? stage) =>
/// <summary>As <see cref="ResolveModuleService(string, int, Autofac.ILifetimeScope?)"/>, seen from a container of <paramref name="stage"/>.</summary>
internal object ResolveModuleServiceSeenFrom(string module, int index, ModuleSwapStage? stage, Autofac.ILifetimeScope? caller) =>
((stage?.Get(module) ?? Current(module))?.Services ?? throw new InvalidOperationException(
$"Module {module} holds no service scope in this mesh."))
.Resolve(Root, index);
.Resolve(Root, index, caller);

/// <summary>
/// 🚨 <b>Built before published</b> (MeshWeaver#6542): builds every shared instance of
Expand Down
12 changes: 8 additions & 4 deletions src/MeshWeaver.Mesh.Contract/ModuleOwnedRootSource.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Autofac;
using Autofac.Builder;
using Autofac.Core;
using MeshWeaver.ServiceProvider;
Expand Down Expand Up @@ -42,21 +43,24 @@ public IEnumerable<IComponentRegistration> RegistrationsFor(
.SingleInstance()
.CreateRegistration())
.ToArray();
var resolvers = ModuleServices.RegistrationsElsewhere(contexts, null, typed.ServiceType);
// Each resolved for the CALLER — the hub, circuit or request scope the stand-in is resolved in —
// so a module's scoped registration is that caller's instance (MeshWeaver#6584).
var resolvers = ModuleServices.RegistrationsElsewhereForCallers(contexts, null, typed.ServiceType);
return resolvers.Count == 0
? []
: ModuleServiceProvider.StandIns(typed.ServiceType, resolvers, registrationAccessor(service).Any());
: ModuleServiceProvider.StandIns(typed.ServiceType, resolvers, registrationAccessor(service).Any(),
ModuleServiceProvider.CallerOfRootScope);
}

// A module's KEYED registration of a type it declares, asked for by its key — a module keeps the key
// in its own container, so a consumer resolving by key on the mesh hub or a per-node hub gets the
// module's (core #6128: keyed module services are forwarded under their own key, not refused).
private IEnumerable<IComponentRegistration> KeyedStandIns(Type type, object key, bool explicitRegistrationsExist)
{
var resolvers = ModuleServices.KeyedRegistrationsElsewhere(contexts, null, type, key);
var resolvers = ModuleServices.KeyedRegistrationsElsewhereForCallers(contexts, null, type, key);
var standIns = resolvers
.Select(resolve => RegistrationBuilder
.ForDelegate(type, (_, _) => resolve())
.ForDelegate(type, (context, _) => resolve(ModuleServiceProvider.CallerOfRootScope(context.Resolve<ILifetimeScope>())))
.Keyed(key, type)
.ExternallyOwned()
.InstancePerDependency()
Expand Down
61 changes: 52 additions & 9 deletions src/MeshWeaver.Mesh.Contract/ModuleServiceForwarders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,32 @@ public class ModuleServiceProxy : DispatchProxy
private string module = "";
private int index;
private string service = "";
private Autofac.ILifetimeScope? caller;

/// <summary>Creates the proxy for <paramref name="serviceType"/>.</summary>
public static object Create(Type serviceType, ModuleContexts contexts, string module, int index)
=> Create(serviceType, contexts, module, index, null);

/// <summary>
/// Creates the proxy for <paramref name="serviceType"/> that forwards for <paramref name="caller"/> —
/// the root-side scope it was resolved in. A module's SCOPED (or transient) registration of a platform
/// interface is forwarded by one proxy per caller scope, each answered by that caller's instance in the
/// module scope the caller owns (MeshWeaver#6584); a singleton's proxy has no caller.
/// </summary>
/// <param name="serviceType">The platform interface.</param>
/// <param name="contexts">The mesh's module registry.</param>
/// <param name="module">The module's entry-assembly name.</param>
/// <param name="index">The registration's index.</param>
/// <param name="caller">The root-side scope the proxy serves, or null.</param>
public static object Create(Type serviceType, ModuleContexts contexts, string module, int index, Autofac.ILifetimeScope? caller)
{
ArgumentNullException.ThrowIfNull(serviceType);
var proxy = (ModuleServiceProxy)DispatchProxy.Create(serviceType, typeof(ModuleServiceProxy));
proxy.contexts = contexts;
proxy.module = module;
proxy.index = index;
proxy.service = serviceType.Name;
proxy.caller = caller;
return proxy;
}

Expand Down Expand Up @@ -75,7 +91,7 @@ public static string Label(object service)
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
ArgumentNullException.ThrowIfNull(targetMethod);
var target = contexts!.ResolveModuleService(module, index);
var target = contexts!.ResolveModuleService(module, index, caller);
try
{
return targetMethod.Invoke(target, args);
Expand Down Expand Up @@ -159,6 +175,13 @@ internal static class ModuleServiceForwarding
/// <summary>
/// Adds to the ROOT collection the forwarder for every platform-typed registration the module made
/// — exactly the registrations the root would have held — and nothing that names a module type.
///
/// <para>🚨 Every forwarder is a <see cref="NotOwnedServiceDescriptor"/>: the root scope that resolves it
/// never disposes what it returns. The instance belongs to the module — its generation's container
/// (a singleton) or the module scope the caller owns (scoped, MeshWeaver#6584) — and a forwarder the
/// caller's scope owned disposed a module SINGLETON when the first circuit ended, under every other
/// user, and a scoped instance twice. A proxy is not owned either: disposing a proxy over an
/// <see cref="IDisposable"/> interface forwards the call to the module's instance.</para>
/// </summary>
public static IServiceCollection AddForwarders(IServiceCollection root, ModuleServices services)
{
Expand All @@ -173,13 +196,20 @@ public static IServiceCollection AddForwarders(IServiceCollection root, ModuleSe
var key = registration.Descriptor.ServiceKey;
switch (registration.Route)
{
case ModuleServiceRoute.Proxy when registration.Descriptor.Lifetime == ServiceLifetime.Singleton:
root.Add(NotOwnedServiceDescriptor.KeyedForwarder(type, key, (sp, _) => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index), ServiceLifetime.Singleton));
break;
case ModuleServiceRoute.Proxy:
root.Add(ServiceDescriptor.KeyedSingleton(type, key, (sp, _) => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index)));
// Scoped or transient: one proxy per caller, forwarding for it (MeshWeaver#6584).
root.Add(NotOwnedServiceDescriptor.KeyedForwarder(type, key, (sp, _) => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index,
ModuleServiceProvider.CallerOfRootScope(sp)), registration.Descriptor.Lifetime));
break;
case ModuleServiceRoute.Current:
root.Add(ServiceDescriptor.KeyedTransient(type, key, (sp, _) =>
sp.GetRequiredService<ModuleContexts>().ResolveModuleService(module, index)));
root.Add(NotOwnedServiceDescriptor.KeyedForwarder(type, key, (sp, _) =>
sp.GetRequiredService<ModuleContexts>().ResolveModuleService(module, index,
ModuleServiceProvider.CallerOfRootScope(sp)), ServiceLifetime.Transient));
break;
}
continue;
Expand All @@ -191,14 +221,27 @@ public static IServiceCollection AddForwarders(IServiceCollection root, ModuleSe
root.Add(ServiceDescriptor.Singleton(type, sp => new ModuleHealthCheckOptionsForwarder(
sp.GetRequiredService<ModuleContexts>(), module, index)));
break;
case ModuleServiceRoute.Proxy:
case ModuleServiceRoute.Proxy when registration.Descriptor.Lifetime == ServiceLifetime.Singleton:
// Singleton: ONE stable proxy, so every consumer holds the same forwarder.
// Resolving the registry through the container is what attaches it to the root.
root.Add(ServiceDescriptor.Singleton(type, sp => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index)));
root.Add(NotOwnedServiceDescriptor.Forwarder(type, sp => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index), ServiceLifetime.Singleton));
break;
case ModuleServiceRoute.Proxy:
// Scoped or transient: one proxy per caller scope, forwarding to that caller's instance
// in the module scope it owns — a singleton proxy would serve every circuit one
// instance (MeshWeaver#6584).
root.Add(NotOwnedServiceDescriptor.Forwarder(type, sp => ModuleServiceProxy.Create(
type, sp.GetRequiredService<ModuleContexts>(), module, index,
ModuleServiceProvider.CallerOfRootScope(sp)), registration.Descriptor.Lifetime));
break;
case ModuleServiceRoute.Current:
root.Add(ServiceDescriptor.Transient(type, sp => sp.GetRequiredService<ModuleContexts>().ResolveModuleService(module, index)));
// Resolved for the CALLER — the scope this transient forwarder is resolved in — so a
// module's scoped registration is that circuit's / request's / hub's instance, built
// against its NavigationManager and IJSRuntime (MeshWeaver#6584).
root.Add(NotOwnedServiceDescriptor.Forwarder(type, sp => sp.GetRequiredService<ModuleContexts>()
.ResolveModuleService(module, index, ModuleServiceProvider.CallerOfRootScope(sp)),
ServiceLifetime.Transient));
break;
}
}
Expand Down
Loading