Skip to content

AOP proxies: release targets and pop the exposed proxy when the call throws - #391

Merged
lahma merged 3 commits into
mainfrom
4x/aop-release-target
Nov 3, 2026
Merged

lahma merged 3 commits into
mainfrom
4x/aop-release-target

Conversation

@lahma

@lahma lahma commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

AOP proxies didn't always undo what they set up when the advised call threw. This fixes both cases.

Pooled and borrowed targets.

  • The emitted proxy method (TargetAopProxyMethodBuilder, used by both composition and decorator proxies) called ReleaseTarget without a finally. A throwing call left a SimplePoolTargetSource object checked out, so a small pool ran dry and blocked.
    • The method logic now runs in try { … } finally { TargetSource.ReleaseTarget(target) }.
    • The return value and ref/out results go through locals, and the single ret comes after the block.
  • BaseCompositionAopProxy.Equals, GetHashCode and ToString borrowed and released the target the same way. They now use try/finally.
  • AbstractAutoProxyCreator.CreateProxy called targetSource.GetTarget() into a local that nothing read. For a pooled source that object was never returned. For a prototype or lazy source it created or initialised a target too early. The borrow is gone.
    • Behaviour note: auto-proxy creation no longer touches the target. A broken target definition behind a custom target source now fails on the first call, not when the container starts.

The exposed proxy.

  • With ExposeProxy set, BeginMethod pushed the proxy onto the thread's AopContext stack and EndMethod popped it, again without a finally. Each throwing call left one entry behind, so AopContext.CurrentProxy could return the wrong proxy.
  • The pop now runs in GenerateMethod's outer finally for the composition, decorator, inheritance and introduction builders. The push happens just before the try.
  • ExposeProxy is read once per call instead of twice, so the pop always matches the push.
  • The two finally blocks unwind in reverse order of acquisition: the target is released first, then the proxy is popped.

Compatibility. Public signatures don't change, but the protected contract of the public IL builders does, for anyone who writes their own:

  • GenerateMethodLogic now runs inside protected regions. It must leave the stack empty and not emit ret.
  • EndMethod no longer pops.

The XML docs state this. There is no BreakingChanges entry, because the change only affects custom IL builders. The happy path costs the same or less per call.

Tests.

  • New ReleaseTargetTests covers composition and decorator proxies, each with and without an interceptor:
    • get/release balance with exact counts, for void, reference, int and struct returns, ref/out parameters and generic methods;
    • a throwing target and a throwing advice;
    • a real SimplePoolTargetSource with MaxSize = 1;
    • the composition Equals/GetHashCode/ToString;
    • AopContext balance after throwing calls, including nested proxies and introduced methods.
  • InheritanceAopProxyTests gets an exposed-proxy case.
  • AutoProxyTargetSourceBorrowTests checks that proxy creation borrows nothing.
  • Every case that exercises a throwing path fails without the fix, and all of them pass with it.
  • Spring.Aop.Tests: 555 on net10.0 and 573 on net472, 0 failed. Spring.Core, Data, Web, Mvc5 and Testing.NUnit tests are green, apart from the locale-dependent FormatUsingDefaults.
  • spring-reviewer checked the IL block structure, stack balance, branches inside the regions and the hot path. It accepted the change with follow-ups, which are in the last commit.

Found during the analysis of #239; that leak itself was fixed in #347.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LGkLQuAuUjvRjrU1AcTsmk

lahma and others added 3 commits November 4, 2026 00:49
…throws

TargetAopProxyMethodBuilder emitted GetTarget ... ReleaseTarget without a
try/finally, so a throwing advised method leaked pooled targets (the pool
eventually ran dry and blocked). The invocation is now wrapped in an IL
try/finally. BaseCompositionAopProxy.Equals/GetHashCode/ToString get the
same treatment in C#. AbstractAutoProxyCreator.CreateProxy borrowed a
target it never used or released; the borrow is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGkLQuAuUjvRjrU1AcTsmk
Signed-off-by: Marko Lahma <marko.lahma@gmail.com>
AbstractAopProxyMethodBuilder pushed the proxy on the AopContext stack when
ExposeProxy was set and popped it at the end of the method, outside any
finally, so every call that threw left a stale entry on the thread's stack.
The method body now runs in an IL try whose finally pops the proxy. The push
moves to the end of BeginMethod (after the local initialisation, before the
try), and ExposeProxy is read once into a local so the pop mirrors the push.
The target release (inner finally) runs before the pop (outer finally).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGkLQuAuUjvRjrU1AcTsmk
Signed-off-by: Marko Lahma <marko.lahma@gmail.com>
…er docs)

Add tests for the inheritance-based proxy with ExposeProxy and a throwing
method, and for generic methods (get/release balance and AopContext pop) on
composition and decorator proxies. Document the try/finally contract of
GenerateMethod/GenerateMethodLogic/EndMethod, and that
AbstractAopProxyTypeBuilder.PushTarget borrows without releasing. No
behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGkLQuAuUjvRjrU1AcTsmk
Signed-off-by: Marko Lahma <marko.lahma@gmail.com>
@lahma
lahma force-pushed the 4x/aop-release-target branch from 0fc74cb to 3aadb40 Compare November 3, 2026 22:49
@lahma
lahma merged commit 66454bb into main Nov 3, 2026
4 checks passed
@lahma
lahma deleted the 4x/aop-release-target branch November 3, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant