Skip to content

fix(transport): dispose TcpClient/ClientWebSocket on failed connect in StreamFactory (fixes #216)#243

Merged
Kiryuumaru merged 2 commits into
masterfrom
fix/client-factory-leaks
May 21, 2026
Merged

fix(transport): dispose TcpClient/ClientWebSocket on failed connect in StreamFactory (fixes #216)#243
Kiryuumaru merged 2 commits into
masterfrom
fix/client-factory-leaks

Conversation

@Kiryuumaru
Copy link
Copy Markdown
Owner

Fixes #216.

Problem

The TCP and WebSocket client StreamFactory implementations constructed a TcpClient / ClientWebSocket and immediately awaited ConnectAsync without wrapping the construct-through-connect span in try/Dispose. Any throw from ConnectAsync — connection refused, host unreachable, DNS failure, TLS error, timeout, OperationCanceledException — leaked the just-allocated client. The underlying Socket was only released through GC finalization.

Under the multiplexer's reconnect loop pointed at an unreachable endpoint, every retry leaked one FD/handle, accumulating to socket / ephemeral-port exhaustion within minutes.

UDP and QUIC factories in the same project already use the correct try { ... } catch { client.Dispose(); throw; } pattern; TCP and WebSocket diverged.

Fix

Wrap connect-through-return in try/catch+Dispose, mirroring UDP:

  • src/NetConduit.Transport.Tcp/TcpMultiplexer.cs — both CreateOptions(string host, int port) and CreateOptions(IPEndPoint endpoint) overloads.
  • src/NetConduit.Transport.WebSocket/WebSocketMultiplexer.csCreateOptions(Uri, ...) (the string url overload delegates).
  • src/NetConduit.Transport.WebSocket/WebSocketMuxListener.csCreateReconnectableClientOptions(Uri, ...).

CreateServerOptions in both transports is unaffected (server side does not construct outbound clients).

Regression test

tests/NetConduit.Transport.Tcp.IntegrationTests/TcpMultiplexerFactoryLeakTests.cs — two tests, one per CreateOptions overload:

  1. Snapshot Process.HandleCount after warmup + GC.
  2. Invoke the StreamFactory 50 times against RFC 5737 TEST-NET-1 (192.0.2.1:1) with a 50ms per-attempt CancellationToken (deterministic across platforms — ConnectAsync blocks on a non-routable address until cancelled).
  3. Snapshot Process.HandleCount again without intervening GC.
  4. Assert growth < iterations/2.

Pre-fix measurement (verified by git stash-ing the production change and re-running): handle count grew by 55–57 over 50 iterations — almost exactly 1:1, confirming each leaked TcpClient retains one OS handle until finalization. Test fails loudly.

Post-fix measurement: tests pass with negligible handle growth.

No equivalent WebSocket test included — ClientWebSocket connect leak observability is muddied by its internal HttpMessageInvoker handle accounting, but the fix is structurally identical to the TCP fix and reviewed by diff.

Verification

  • dotnet build -c Debug --nologo — 0 warnings, 0 errors.
  • dotnet test -c Debug --nologo (full sweep) — 611 total, 610 succeeded, 1 pre-existing skip (MemoryLeak_SubMuxChaos_MemoryStaysBounded), 0 failed.
  • Targeted leak suite: 2/2 pass.

Kiryuumaru and others added 2 commits May 21, 2026 12:42
…n StreamFactory (fixes #216)

TCP and WebSocket client StreamFactories constructed a TcpClient/ClientWebSocket and immediately awaited ConnectAsync without wrapping the construct-through-connect span in try/dispose. Any throw from ConnectAsync (connection refused, host unreachable, DNS failure, TLS error, timeout, OperationCanceledException) leaked the just-allocated client: the underlying Socket was only released through finalization. Under the multiplexer reconnect loop targeting an unreachable endpoint, every retry leaked one FD/handle.

UDP and QUIC factories in the same project already use the correct try/catch+Dispose pattern; TCP and WebSocket diverged.

Fix (src/NetConduit.Transport.Tcp/TcpMultiplexer.cs, src/NetConduit.Transport.WebSocket/WebSocketMultiplexer.cs, src/NetConduit.Transport.WebSocket/WebSocketMuxListener.cs.CreateReconnectableClientOptions):

- Wrap connect-through-return in try { ... } catch { client.Dispose(); throw; } mirroring UDP.

- Applies to TcpMultiplexer.CreateOptions(string,int), CreateOptions(IPEndPoint), WebSocketMultiplexer.CreateOptions(Uri,...) (string overload delegates), and WebSocketMuxListener.CreateReconnectableClientOptions.

Regression test (tests/NetConduit.Transport.Tcp.IntegrationTests/TcpMultiplexerFactoryLeakTests.cs):

- Two tests cover both CreateOptions overloads. Each invokes the StreamFactory 50 times against RFC 5737 TEST-NET-1 (192.0.2.1:1) with a 50ms per-attempt CancellationToken, then asserts Process.HandleCount grew by < iterations/2.

- Verified pre-fix: handle count grew by 55-57 over 50 iterations (1:1 leak ratio) — tests fail loudly. Post-fix: tests pass with negligible handle growth.

Build: 0 warnings, 0 errors. Full sweep: 610 passed / 1 pre-existing skip / 0 failed (611 total, including the 2 new leak tests).
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.

TCP / WebSocket client StreamFactory leaks TcpClient / ClientWebSocket on every failed connect — socket exhaustion under retry loop

1 participant