modelcontextprotocol / modelcontextprotocol/java-sdk

HttpClient resource leak causes thread accumulation and memory exhaustion

未關閉
#620 3 則留言 1 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

area/client area/transport bug
主要語言
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

  1. Create multiple HttpClientStreamableHttpTransport instances:
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
}
  1. Monitor system threads using jstack or thread monitoring tools
  2. Observe continuous growth of HttpClient-xxxx-SelectorManager threads
  3. 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-SelectorManager threads 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());
        }
    }
}

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

研究方向

先閱讀 HttpClientStreamableHttpTransport#build() 以及對應的 HttpClientSseClientTransport builder,接著追蹤 client.closeGracefully() 如何到達 transport shutdown。比較每個已建立 HttpClient 的生命週期與觀察到的 SelectorManager 執行緒。當重複建立與關閉 transport 不再累積這些執行緒或耗盡記憶體時,即表示完成。

由索引模型根據 Issue 內容生成。

評估

技術堆疊
java
領域
networking
Issue 類型
缺陷
難度
5/5
預估耗時
一週以上
活躍度
活躍
描述清晰度
基本清楚
新手友好度
38/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。