modelcontextprotocol / modelcontextprotocol/java-sdk
HttpClient resource leak causes thread accumulation and memory exhaustion
还没有人认领这个 Issue。
- 主要语言
- Java
- 星标
- 3.7k
- 派生
- 1.1k
- 平均合并
- 1 天 15 小时
- 30 天内合并 PR
- 9
描述
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());
}
}
}
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
调研方向
首先阅读 HttpClientStreamableHttpTransport#build() 以及对应的 HttpClientSseClientTransport builder,然后跟踪 client.closeGracefully() 如何到达 transport shutdown。将每个已创建 HttpClient 的生命周期与观察到的 SelectorManager 线程进行比较。当重复创建和关闭 transport 不再累积这些线程或耗尽内存时,即表示完成。
由索引模型根据 Issue 内容生成。
评估
- 技术栈
- java
- 领域
- networking
- Issue 类型
- 缺陷
- 难度
- 5/5
- 预计耗时
- 一周以上
- 活跃度
- 活跃
- 描述清晰度
- 基本清楚
- 新手友好度
- 38/100