fix: GenericTcpIpClient opens a duplicate socket per connect and never disposes it - #198
Conversation
Connect() stopped the retry timer and then re-armed it two lines later:
RetryTimer.Stop();
_client = new TCPClient(Hostname, Port, BufferSize);
...
RetryTimer.Reset(); // <-- introduced in v1.4.3
_client.ConnectToServerAsync(ConnectToServerCallback);
RetryTimer's callback is Reconnect(), so every deliberate connect also
scheduled a second connect one AutoReconnectIntervalMs later. Reconnect()
does guard on IsConnected, but ConnectToServerAsync is still in flight at
that point so IsConnected is false and it proceeds, opening a second
socket. The comment directly above ("Stop retry timer if running") shows
the intent.
Measured on a bench against three PJLink projectors, v1.4.3 vs v1.3.2:
v1.3.2 2856 connects, 0 idle-timeout closes 3 sockets per poll cycle
v1.4.3 24 connects, 9 idle-timeout closes 6 sockets per poll cycle
38% of connections were opened, never used, and force-closed by the
projector on its 30s idle timeout (PJLink spec v1.04 5.4). This matters
beyond wasted sockets: PJLink devices commonly cap concurrent
connections, so doubling the count risks the controller being refused.
Removing the Reset() does not affect genuine reconnection. The retry
timer is still armed in WaitAndTryReconnect(), which is called from both
failure paths - ConnectToServerCallback() when a connect attempt fails,
and Client_SocketStatusChange() on any non-connected status.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Connect() replaced _client with a new TCPClient on every call and the
previous instance was never disposed:
_client = new TCPClient(Hostname, Port, BufferSize);
DisconnectClient() only calls DisconnectFromServer(), which closes the
connection but does not release the object. Crestron's TCPClient is
disposable - the SDK documents TCPClient.Dispose as "free resources and
disconnect" - and it holds unmanaged socket resources, so disconnecting
without disposing leaves those resources outstanding.
This matters because the controller reconnects constantly rather than
holding a session. PJLink spec v1.04 5.4 requires the controller to close
as soon as its command batch completes, so a bench with three projectors
measured ~360 connects/hour. Every one of those orphaned a TCPClient.
Adds DisposeClient(), which unsubscribes the socket status handler,
disconnects if still connected, disposes, and nulls the field. It is
called from two places:
- Connect(), before allocating the replacement
- Deactivate(), which previously unsubscribed and disconnected but left
the object undisposed
Also null-guards the Connected property. It dereferenced _client without
a check, which was already an NPE waiting for any caller that read it
before the first connect, and is now reachable because _client is nulled
on dispose. IsConnected and ClientStatus were already guarded.
Exceptions during dispose are caught and logged - a socket the platform
has already torn down can throw, and that must never block a reconnect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new disposal/nulling of _client introduces concurrency risk (notably in socket status callbacks) and Deactivate() should be synchronized with the existing connectLock pattern to prevent races/use-after-dispose.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes connection-lifecycle issues in GenericTcpIpClient that caused (1) a duplicate TCP socket to be opened during deliberate connects due to erroneously re-arming the reconnect timer, and (2) unmanaged socket resources to leak because replaced TCPClient instances were never disposed.
Changes:
- Adds safe client teardown via a new
DisposeClient()helper, and uses it when reconnecting/replacing the socket and duringDeactivate(). - Removes the reconnect-timer re-arming during
Connect()to prevent an unintended second concurrent connect attempt. - Null-guards the
Connectedproperty to avoid dereferencing_clientbefore the first connection / after disposal.
File summaries
| File | Description |
|---|---|
src/Comm/GenericTcpIpClient.cs |
Prevents duplicate connects by not re-arming the reconnect timer in Connect(), and disposes old TCPClient instances to avoid socket/resource leaks. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…callback Three review comments on the disposal change, all valid. 1. Deactivate() disposed RetryTimer and the client without holding connectLock, so it could race Connect()/Disconnect()/Reconnect() and WaitAndTryReconnect's lock-protected RetryTimer.Reset. Now wrapped in connectLock/try-finally like the other lifecycle methods. Verified no same-thread re-entry: nothing DisposeClient() calls takes the lock. 2. Client_SocketStatusChange's connected path called _client.ReceiveDataAsync(...) on the field rather than the callback's own TCPClient. Since DisposeClient() now nulls _client, a queued status change could arrive afterwards and throw. It now uses the `client` argument, guarded with a null check and a ReferenceEquals test so events from a superseded socket don't re-arm receive on a dead client. The ConnectionChange event still fires as before — the guard only skips the receive re-arm, not the notification. 3. The dispose catch logged ex.Message, dropping the stack trace and any inner exception. Now logs the full exception, which is exactly what a disposal race needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pose-socket-review-fixes fix: address PR #198 review — lock Deactivate, avoid stale _client in status callback
Two independent defects in
GenericTcpIpClient, both found while chasing a memory leak on asite running three PJLink projectors, and both verified on hardware.
1. Duplicate socket on every connect (regression in v1.4.3)
Connect()stops the retry timer and then re-arms it two lines later:RetryTimer's callback isReconnect(), so every deliberate connect also scheduled a secondconnect one
AutoReconnectIntervalMslater.Reconnect()guards onIsConnected, butConnectToServerAsyncis still in flight at that point, so it proceeds and opens a secondsocket. The comment directly above (
//Stop retry timer if running) shows the intent.Removing it does not affect genuine reconnection — the retry timer is still armed in
WaitAndTryReconnect(), called from both failure paths (ConnectToServerCallbackon a failedattempt,
Client_SocketStatusChangeon any non-connected status).2.
TCPClientis never disposedConnect()replaced_clientwith a newTCPClienton every call and the old instance wasnever released.
DisconnectClient()only callsDisconnectFromServer(), which closes theconnection but does not free the object. The SDK documents
TCPClient.Disposeas"free resources and disconnect", and it holds unmanaged socket resources.
This matters because controllers reconnect constantly rather than holding a session — PJLink
spec v1.04 §5.4 requires the controller to close as soon as its command batch completes, which
measured ~360 connects/hour on this bench. Every one orphaned a
TCPClient.Adds
DisposeClient()(unsubscribe, disconnect if connected, dispose, null), called fromConnect()before allocating the replacement and fromDeactivate(). Exceptions duringdispose are caught and logged so a socket the platform has already torn down can never block a
reconnect.
Also null-guards the
Connectedproperty, which dereferenced_clientunchecked — already anNPE for any caller reading it before the first connect, and now reachable since
_clientisnulled on dispose.
IsConnectedandClientStatuswere already guarded.Measured on hardware
Three PJLink projectors, 2-second poll, prerelease
.clzbuilt by CI on each branch.1,1,1,3,5..9With both fixes, all 30 sampled connections authenticated, carried exactly their 5-command
batch, and closed cleanly.
Memory, same rig, warm-up excluded, windows of 12h+:
v1.4.3 alone cut the leak ~81% and the memory now plateaus after ~4 hours. A soak on this
branch is in progress to quantify the disposal contribution; the remaining background rate
measured with the client idle is ~0.16 MB/h.
Note
Defect 1 is a regression in shipped v1.4.3 and will affect anyone using
GenericTcpIpClientagainst a device that reconnects frequently.