diff --git a/src/Wemogy.Core.Tests/DynamicProxies/AsyncInterceptorExtensionsTests.cs b/src/Wemogy.Core.Tests/DynamicProxies/AsyncInterceptorExtensionsTests.cs new file mode 100644 index 0000000..7daa8b3 --- /dev/null +++ b/src/Wemogy.Core.Tests/DynamicProxies/AsyncInterceptorExtensionsTests.cs @@ -0,0 +1,33 @@ +using System; +using FluentAssertions; +using Polly.Contrib.WaitAndRetry; +using Wemogy.Core.DynamicProxies; +using Wemogy.Core.DynamicProxies.Extensions; +using Wemogy.Core.Errors.Exceptions; +using Wemogy.Core.Tests.DynamicProxies.TestData; +using Xunit; + +namespace Wemogy.Core.Tests.DynamicProxies; + +public class AsyncInterceptorExtensionsTests +{ + [Fact] + public void Wrap_ShouldReuseTheProxyTypeAcrossCalls() + { + // Arrange + var retryProxy = new RetryProxy( + Backoff.ExponentialBackoff(TimeSpan.FromMilliseconds(10), 1)); + + // Act + var first = retryProxy.Wrap(new FlakyService(0, () => new Exception())); + var second = retryProxy.Wrap(new FlakyService(0, () => new Exception())); + var untyped = retryProxy.Wrap(typeof(IFlakyService), new FlakyService(0, () => new Exception())); + + // Assert + // A new ProxyGenerator per call emits a new dynamic assembly with a new proxy type every time. + // Those types can never be unloaded, so a wrapper created per request (e.g. a scoped + // repository) grows the process until it runs out of memory. + second.GetType().Should().BeSameAs(first.GetType()); + untyped.GetType().Should().BeSameAs(first.GetType()); + } +} diff --git a/src/Wemogy.Core/DynamicProxies/Extensions/AsyncInterceptorExtensions.cs b/src/Wemogy.Core/DynamicProxies/Extensions/AsyncInterceptorExtensions.cs index 003e288..59f924b 100644 --- a/src/Wemogy.Core/DynamicProxies/Extensions/AsyncInterceptorExtensions.cs +++ b/src/Wemogy.Core/DynamicProxies/Extensions/AsyncInterceptorExtensions.cs @@ -5,14 +5,20 @@ namespace Wemogy.Core.DynamicProxies.Extensions { public static class AsyncInterceptorExtensions { + /// + /// Shared on purpose. Every ProxyGenerator owns its own module scope, so a new instance per call + /// emits a new dynamic assembly with a new proxy type each time — types that can never be + /// unloaded. Wrapping per request (a scoped repository, for example) then leaks memory until + /// the process is killed. One generator caches the proxy type per interface and is thread-safe. + /// + private static readonly ProxyGenerator ProxyGenerator = new ProxyGenerator(); + public static TInterface Wrap( this IAsyncInterceptor proxy, object target) where TInterface : class { - var proxyGenerator = new ProxyGenerator(); - - var wrappedImplementation = (TInterface)proxyGenerator.CreateInterfaceProxyWithTarget( + var wrappedImplementation = (TInterface)ProxyGenerator.CreateInterfaceProxyWithTarget( typeof(TInterface), target, proxy.ToInterceptor()); @@ -25,9 +31,7 @@ public static object Wrap( Type interfaceType, object target) { - var proxyGenerator = new ProxyGenerator(); - - var wrappedImplementation = proxyGenerator.CreateInterfaceProxyWithTarget( + var wrappedImplementation = ProxyGenerator.CreateInterfaceProxyWithTarget( interfaceType, target, proxy.ToInterceptor());