HttpClient resource leak causes thread accumulation and memory exhaustion
Bug description
When using HttpClientStreamableHttpTransport and HttpClientSseClientTransport, the application experiences continuous accumulation of HttpClient-xxxx-SelectorManager threads that are never cleaned up, eventually leading to memory exhaustion and application instability.
The root cause is that each transport builder creates a new HttpClient instance via HttpClient.Builder.build(), but these HttpClient instances are never properly closed when the transport shuts down. Each HttpClient spawns dedicated SelectorManager threads for network I/O operations, and since OpenJDK's HttpClient lacks public APIs for resource cleanup, these threads remain active indefinitely.
Technical Details: Tracing through HttpClientStreamableHttpTransport#build() reveals that each HttpClient instantiation triggers the creation of a SelectorManager thread in the OpenJDK 17 source code:
SelectorManager(HttpClientImpl ref) throws IOException { super(null, null, "HttpClient-" + ref.id + "-SelectorManager", 0, false); owner = ref; debug = ref.debug; debugtimeout = ref.debugtimeout; pool = ref.connectionPool(); registrations = new ArrayList<>(); deregistrations = new ArrayList<>(); selector = Selector.open(); }
Source: OpenJDK 17 HttpClientImpl.java
This constructor shows how each HttpClient creates a uniquely named SelectorManager thread ("HttpClient-" + ref.id + "-SelectorManager"), which explains the observed thread naming pattern in production environments.
Environment
- Spring MCP Version: Latest (current main branch)
- Java Version: OpenJDK 17+ (tested on OpenJDK 17.0.14)
- Operating System: macOS 14.6.0 (also reproducible on Linux)
- Transport Types:
HttpClientStreamableHttpTransport,HttpClientSseClientTransport - Related OpenJDK Issue: JDK-8308364
Steps to reproduce
- Create multiple
HttpClientStreamableHttpTransportinstances:
for (int i = 0; i < 10; i++) { HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport .builder("http://localhost:8080") .build(); McpSyncClient client = McpClient.sync(transport).build(); client.initialize(); client.closeGracefully(); // This doesn't clean up HttpClient threads }
- Monitor system threads using
jstackor thread monitoring tools - Observe continuous growth of
HttpClient-xxxx-SelectorManagerthreads - Repeat the process multiple times to see thread accumulation
Expected behavior
- When
transport.closeGracefully()is called, all associated HttpClient resources should be cleaned up HttpClient-xxxx-SelectorManagerthreads should be terminated and not accumulate- Memory usage should remain stable across multiple transport creation/destruction cycles
- No thread leakage should occur in long-running applications
Minimal Complete Reproducible example
import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; public class HttpClientLeakDemo { public static void main(String[] args) throws InterruptedException { System.out.println("Initial thread count: " + Thread.activeCount()); // Create and close multiple transports for (int i = 0; i < 20; i++) { System.out.println("\n=== Creating transport " + (i + 1) + " ==="); var transport = HttpClientSseClientTransport .builder("http://127.0.0.1:8002") // your sse mcp server base url .build(); McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(5)) .build(); try { // This will fail but still creates the HttpClient client.initialize(); } catch (Exception e) { System.out.println("Expected initialization failure: " + e.getMessage()); } // Close the client - this should clean up resources but doesn't client.closeGracefully(); System.out.println("Thread count after closing transport " + (i + 1) + ": " + Thread.activeCount()); // List HttpClient threads Thread.getAllStackTraces().keySet().stream() .filter(t -> t.getName().contains("HttpClient") && t.getName().contains("SelectorManager")) .forEach(t -> System.out.println(" - " + t.getName())); } System.out.println("\nFinal thread count: " + Thread.activeCount()); System.out.println("HttpClient SelectorManager threads are still running and will never be cleaned up!"); // Force GC to confirm threads are not cleaned up while (true) { Thread.getAllStackTraces().keySet().stream() .filter(t -> t.getName().contains("HttpClient") && t.getName().contains("SelectorManager")) .forEach(t -> System.out.println(" - " + t.getName())); System.gc(); Thread.sleep(1000); System.out.println("Thread count after GC: " + Thread.activeCount()); } } }