Skip to content

Static ProxyGenerator pins proxied types, blocking AssemblyLoadContext unload #69

Description

@tillig

Problem

RegistrationExtensions holds the proxy generator in a static field:

private static readonly ProxyGenerator _proxyGenerator = new();

Castle's ModuleScope keeps a SynchronizedDictionary<CacheKey, Type> typeCache that strongly holds every generated proxy type, keyed on the type that was proxied. Because the generator is static, those entries are rooted for the life of the process and survive container disposal, so any type that has ever been class- or interface-intercepted can never be unloaded. DynamicProxyGenAssembly2 is also a non-collectible dynamic assembly.

The practical effect: a plugin host that loads types into a collectible AssemblyLoadContext and intercepts any of them cannot unload that context, even after disposing the container and calling ReflectionCacheSet.Shared.Clear(). Autofac core went to some trouble to make its reflection caches clearable for exactly this scenario (IReflectionCache, ReflectionCacheSet, weak rooting of Shared and of externally-registered caches); this package doesn't participate in that at all.

Evidence

Reflecting into ModuleScope.typeCache after generating a single class proxy, entries persist across a full GC:

before: typeCache holds 0 entries
after class proxy: typeCache holds 1 entries
    value=Castle.Proxies.SampleProxy (assembly DynamicProxyGenAssembly2)
after GC: typeCache holds 1 entries
    value=Castle.Proxies.SampleProxy (assembly DynamicProxyGenAssembly2)

Not urgent

No one has reported this. Opening it to track a limitation we know about.

Tradeoffs to weigh if we do address it

  • A per-container (or otherwise scoped) ModuleScope would stop sharing generated proxy types across containers. Sharing is a deliberate performance choice - proxy type generation is expensive - so this would be a real regression for anyone building containers repeatedly in one process.
  • It is observable behavior: today two containers proxying the same type get the same generated type. Changing that is arguably breaking.
  • An opt-in escape hatch (a way to supply the ProxyGenerator/ModuleScope, or to participate in ReflectionCacheSet clearing) may be the cheaper path than changing the default.

Not related

Per-registration state added in #68 (ProxiedDefaultValueParameter) is not part of this. That dictionary is keyed on the same ParameterInfo instances ConstructorBinder already holds per registration, and its lifetime is the container, so it does not pin anything the container isn't pinning already.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions