-
Notifications
You must be signed in to change notification settings - Fork 164
Unregister event handlers in RpcTargetInfo.DisposeAsync #1379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…sync Co-authored-by: AArnott <[email protected]>
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR fixes a memory leak where event handlers accumulate across connection lifecycles when target objects are added via AddLocalRpcTarget with NotifyClientOfEvents = true. The issue occurred because RpcTargetInfo.DisposeAsync() was disposing target objects without unregistering their event handlers.
Key Changes:
- Added a call to
UnregisterEventHandlersFromTargetObjects()inRpcTargetInfo.DisposeAsync()before disposing target objects - Added comprehensive test coverage verifying handlers are cleaned up across multiple connection lifecycles and when streams close unexpectedly
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/StreamJsonRpc/Reflection/RpcTargetInfo.cs | Added call to unregister event handlers in DisposeAsync to prevent memory leak |
| test/StreamJsonRpc.Tests/TargetObjectEventsTests.cs | Added two new tests verifying event handlers are properly cleaned up on disposal and stream closure |
| serverRpc.StartListening(); | ||
| clientRpc.StartListening(); | ||
|
|
||
| // Verify handler is registered | ||
| Assert.NotNull(sharedServer.ServerEventAccessor); | ||
|
|
||
| // Simulate connection drop by closing the stream without disposing JsonRpc | ||
| streams.Item2.Dispose(); | ||
|
|
||
| // Wait for the disconnection to be detected | ||
| await serverRpc.Completion.WithCancellation(this.TimeoutToken); | ||
|
|
||
| // Verify handlers are unregistered after stream closure | ||
| Assert.Null(sharedServer.ServerEventAccessor); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The serverRpc and streams.Item1 should be explicitly disposed after the test completes to prevent resource leaks. Consider adding cleanup in a finally block or using a using statement.
| serverRpc.StartListening(); | |
| clientRpc.StartListening(); | |
| // Verify handler is registered | |
| Assert.NotNull(sharedServer.ServerEventAccessor); | |
| // Simulate connection drop by closing the stream without disposing JsonRpc | |
| streams.Item2.Dispose(); | |
| // Wait for the disconnection to be detected | |
| await serverRpc.Completion.WithCancellation(this.TimeoutToken); | |
| // Verify handlers are unregistered after stream closure | |
| Assert.Null(sharedServer.ServerEventAccessor); | |
| try | |
| { | |
| serverRpc.StartListening(); | |
| clientRpc.StartListening(); | |
| // Verify handler is registered | |
| Assert.NotNull(sharedServer.ServerEventAccessor); | |
| // Simulate connection drop by closing the stream without disposing JsonRpc | |
| streams.Item2.Dispose(); | |
| // Wait for the disconnection to be detected | |
| await serverRpc.Completion.WithCancellation(this.TimeoutToken); | |
| // Verify handlers are unregistered after stream closure | |
| Assert.Null(sharedServer.ServerEventAccessor); | |
| } | |
| finally | |
| { | |
| clientRpc.Dispose(); | |
| serverRpc.Dispose(); | |
| streams.Item1.Dispose(); | |
| streams.Item2.Dispose(); | |
| } |
| // Unregister event handlers first to prevent any events from being raised during disposal. | ||
| this.UnregisterEventHandlersFromTargetObjects(); | ||
|
|
||
| List<object>? objectsToDispose; | ||
| lock (this.SyncObject) | ||
| { |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The call to UnregisterEventHandlersFromTargetObjects should be synchronized with SyncObject to ensure thread safety. The eventReceivers field is accessed and modified under the SyncObject lock elsewhere in the code (see AddLocalRpcTarget at line 166 and RevertAddLocalRpcTarget.Dispose at line 350), but this call occurs outside any lock. This could lead to race conditions if event handlers are being added concurrently during disposal.
| // Unregister event handlers first to prevent any events from being raised during disposal. | |
| this.UnregisterEventHandlersFromTargetObjects(); | |
| List<object>? objectsToDispose; | |
| lock (this.SyncObject) | |
| { | |
| List<object>? objectsToDispose; | |
| lock (this.SyncObject) | |
| { | |
| // Unregister event handlers first to prevent any events from being raised during disposal. | |
| this.UnregisterEventHandlersFromTargetObjects(); |
| Assert.Equal(1, customArgsEventHandlerCount); | ||
|
|
||
| // Dispose the connection | ||
| rpc.Dispose(); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The created streams should be disposed to prevent resource leaks in the test. Consider wrapping the stream creation and rpc disposal in a using statement or explicitly disposing the streams after the rpc.Dispose() call.
| rpc.Dispose(); | |
| rpc.Dispose(); | |
| streams.Item1.Dispose(); | |
| streams.Item2.Dispose(); |
| Assert.Null(sharedServer.ServerEventAccessor); | ||
|
|
||
| var streams = FullDuplexStream.CreateStreams(); | ||
| var serverRpc = this.CreateJsonRpcWithTargetObject(streams.Item1, sharedServer); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Local scope variable 'serverRpc' shadows TargetObjectEventsTests.serverRpc.
| var clientRpc = new JsonRpc(streams.Item2); | ||
|
|
||
| serverRpc.StartListening(); | ||
| clientRpc.StartListening(); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Local scope variable 'clientRpc' shadows TargetObjectEventsTests.clientRpc.
| var clientRpc = new JsonRpc(streams.Item2); | |
| serverRpc.StartListening(); | |
| clientRpc.StartListening(); | |
| var localClientRpc = new JsonRpc(streams.Item2); | |
| serverRpc.StartListening(); | |
| localClientRpc.StartListening(); |
When target objects with events are added via
AddLocalRpcTargetwithNotifyClientOfEvents = true, event handlers accumulate across connection lifecycles, causing a memory leak.Root Cause
RpcTargetInfo.DisposeAsync()disposes target objects but never unregisters event handlers. WhileOnJsonRpcDisconnected()callsUnregisterEventHandlersFromTargetObjects(), disposal throughIAsyncDisposablebypasses this, leaving handlers attached.Changes
UnregisterEventHandlersFromTargetObjects()inDisposeAsync()before disposing target objectsThe method is idempotent, so redundant calls from
OnJsonRpcDisconnectedare safe.Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
s0ivsblobprodwus2134.vsblob.vsassets.io/opt/hostedtoolcache/dotnet/dotnet dotnet restore --no-dependencies /home/REDACTED/work/vs-streamjsonrpc/.codeql-scratch/dbs/csharp/working/B42A990C1810BF03B6D8FCD090EC1B53/missingpackages_workingdir --packages /home/REDACTED/work/vs-streamjsonrpc/.codeql-scratch/dbs/csharp/working/missingpackages /p:DisableImplicitNuGetFallbackFolder=true --verbosity normal --configfile /home/REDACTED/work/vs-streamjsonrpc/.codeql-scratch/dbs/csharp/working/nugetconfig/nuget.config --force(dns block)If you need me to access, download, or install something from one of these locations, you can either:
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.