Skip to content

Conversation

@TsengX
Copy link

@TsengX TsengX commented Oct 9, 2025

This pull request addresses the HttpClient resource leak issue described in #620.

Fixes #620

  • Add support for external HttpClient instances in HttpClientStreamableHttpTransport and HttpClientSseClientTransport builders
  • Implement proper HttpClient resource cleanup using reflection to close SelectorManager threads
  • Add shouldCloseHttpClient flag to control resource management lifecycle
  • Prevent thread leaks caused by unclosed HttpClient instances created via HttpClient.Builder.build()
  • Add comprehensive tests for external HttpClient usage and resource cleanup

Fixes thread accumulation issue where HttpClient-xxxx-SelectorManager threads would continuously grow, leading to memory exhaustion. This addresses the underlying JDK issue documented in JDK-8308364.

Related: https://bugs.openjdk.org/browse/JDK-8308364

Motivation and Context

The HttpClientStreamableHttpTransport and HttpClientSseClientTransport classes create new HttpClient instances via HttpClient.Builder.build() without properly managing their lifecycle. This leads to accumulation of HttpClient-xxxx-SelectorManager threads that are never cleaned up, eventually causing memory exhaustion.

Root cause analysis traced this to the OpenJDK 17 HttpClientImpl implementation (JDK-8308364), where each HttpClient spawns dedicated SelectorManager threads for network I/O but lacks public APIs for proper resource cleanup.

This change introduces two solutions:

  1. External HttpClient support - Allow users to provide pre-created HttpClient instances for resource sharing across multiple transports
  2. Automatic resource cleanup - Properly clean up internally-created HttpClient resources using reflection to access internal components

References

How Has This Been Tested?

  • Added tests for external HttpClient usage in HttpClientStreamableHttpSyncClientTests and HttpSseMcpSyncClientTests
  • Updated existing transport test to verify proper resource cleanup during graceful shutdown
  • Verified backward compatibility with existing test suite
  • All existing tests continue to pass without modification

Breaking Changes

No breaking changes. This is an additive change that maintains full API compatibility. Existing code will continue to work as before, but will now benefit from proper resource management and the option to use external HttpClient instances.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

- Add support for external HttpClient instances in HttpClientStreamableHttpTransport and HttpClientSseClientTransport builders
- Implement proper HttpClient resource cleanup using reflection to close SelectorManager threads
- Add shouldCloseHttpClient flag to control resource management lifecycle
- Prevent thread leaks caused by unclosed HttpClient instances created via HttpClient.Builder.build()
- Add comprehensive tests for external HttpClient usage and resource cleanup

Fixes thread accumulation issue where HttpClient-xxxx-SelectorManager threads
would continuously grow, leading to memory exhaustion. This addresses the
underlying JDK issue documented in JDK-8308364.

Related: https://bugs.openjdk.org/browse/JDK-8308364
@Kehrlann Kehrlann self-assigned this Oct 14, 2025
@Kehrlann Kehrlann self-requested a review October 14, 2025 19:09
@Kehrlann Kehrlann added the bug Something isn't working label Oct 14, 2025
Copy link
Contributor

@Kehrlann Kehrlann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @TsengX 👋

Thanks for the report, and for the proposed fix. A few notes on this PR:

  1. On the HttpClientImpl, from JDK 21 onwards, there is a #close() method, and we should try to use this is available:
    for (var method : httpClient.getClass().getMethods()) {
        if (method.getName().equals("close")) {
            method.invoke(httpClient);
            return;
        }
    }
    We'd only fall back to reflection if there's no close.
  2. Do you have a strong case for passing an HTTP client from the outside? It'd like to keep the API surface smaller, and not have conflicting HttpClient.Builder vs HttpClient.
  3. If there's a strong case for the point above, I would prefer having a Consumer<HttpClient> in the builder for "on close" operations rather than a boolean. It would default to the "close client resources" implementation you proposed (or calling close if available).

// External HttpClient should still be usable after transport closes
// (This is a basic test - in practice you'd verify the client is still
// functional)
assertThat(externalHttpClient).isNotNull();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[issue] This test passes even if the client is closed.

});

// This test verifies that internal HttpClient resources are cleaned up
// The actual verification happens during the graceful close process
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] We should try to make an HTTP request here and ensure the client is closed.

@TsengX
Copy link
Author

TsengX commented Oct 17, 2025

Hi @TsengX 👋

Thanks for the report, and for the proposed fix. A few notes on this PR:

  1. On the HttpClientImpl, from JDK 21 onwards, there is a #close() method, and we should try to use this is available:

    for (var method : httpClient.getClass().getMethods()) {
        if (method.getName().equals("close")) {
            method.invoke(httpClient);
            return;
        }
    }

    We'd only fall back to reflection if there's no close.

  2. Do you have a strong case for passing an HTTP client from the outside? It'd like to keep the API surface smaller, and not have conflicting HttpClient.Builder vs HttpClient.

  3. If there's a strong case for the point above, I would prefer having a Consumer<HttpClient> in the builder for "on close" operations rather than a boolean. It would default to the "close client resources" implementation you proposed (or calling close if available).

Hi @Kehrlann,

Thank you for the detailed feedback! After reconsidering the design, I agree with your concerns about API complexity.

Addressing your points:

  1. JDK 21+ close() method: Absolutely! I'll implement the fallback strategy you suggested.

  2. Rationale for external HttpClient support: In enterprise AI Agent platform development scenarios, we often create 50+ (sometimes even 100+) MCP connections. Creating a shared HttpClient instance at application startup avoids duplicate resource initialization and connection pool setup, with proper cleanup only when the application lifecycle ends.

  3. Regarding Consumer approach: I agree this is an elegant design. However, I have concerns about your suggestion that "it would default to the 'close client resources' implementation." If this means the external HttpClient gets closed when transport.closeGracefully() is called, it could be problematic for the enterprise use case mentioned above. When one transport closes, it would shut down a globally shared HttpClient that may still be in use by other parts of the application. This is why I believe a boolean flag might be more appropriate to explicitly control whether the SDK should manage the HttpClient lifecycle.

About Test improvements: I'll add proper HTTP requests to verify client state and resource cleanup.

I'd appreciate any suggestions or feedback on this approach!

@Kehrlann Kehrlann self-requested a review October 17, 2025 14:02
@Kehrlann
Copy link
Contributor

Thanks for the insights @TsengX . I'm OK with injecting a fully built HttpClient.

I propose we do the follwing:

  1. Add an .httpClient method to both builders.
  2. Remove .clientBuilder, .customizeClient and .connectTimeout methods entirely.
  3. Add a .onCloseClient(Consumer<HttpClient>) which defaults to your reflection closing.

However, there's an unresolved issue here. The current closeGracefully() implementation actually creates a new session. So with the current implementation, it's possible to do the following:

HttpClientStreamableHttpTransport transport = ...;
transport.sendMessage(...)
    .then(transport.closeGracefully())
    .then(transport.sendMessage(...));

And it works. With you proposed change, this would not work anymore ... But I think the proposed implementation respects the contract from McpTransport better. Let me discuss with the team.

曾响 and others added 2 commits October 28, 2025 21:22
…ntextprotocol#610)

Replace shouldCloseHttpClient boolean with Consumer<HttpClient> pattern.

- Remove .clientBuilder, .customizeClient, .connectTimeout methods
- Add .onCloseClient(Consumer<HttpClient>) with reflection cleanup default
- Replace boolean flag with Consumer pattern in constructors
- Use sun.misc.Unsafe to bypass JDK module restrictions
- Support both JDK 21+ close() and JDK 17 SelectorManager reflection
- Update tests with proper HTTP request validation
@TsengX
Copy link
Author

TsengX commented Oct 28, 2025

Thanks for the insights @TsengX . I'm OK with injecting a fully built HttpClient.

I propose we do the follwing:

  1. Add an .httpClient method to both builders.
  2. Remove .clientBuilder, .customizeClient and .connectTimeout methods entirely.
  3. Add a .onCloseClient(Consumer<HttpClient>) which defaults to your reflection closing.

However, there's an unresolved issue here. The current closeGracefully() implementation actually creates a new session. So with the current implementation, it's possible to do the following:

HttpClientStreamableHttpTransport transport = ...;
transport.sendMessage(...)
    .then(transport.closeGracefully())
    .then(transport.sendMessage(...));

And it works. With you proposed change, this would not work anymore ... But I think the proposed implementation respects the contract from McpTransport better. Let me discuss with the team.

Thanks for being patient @Kehrlann,

I've implemented the changes you suggested:

Implemented Changes

  • Remove .clientBuilder, .customizeClient and .connectTimeout methods entirely
  • Add .onCloseClient(Consumer<HttpClient>) which defaults to reflection closing
  • Replace shouldCloseHttpClient boolean with Consumer pattern

API Design:

  • External HttpClient: onCloseClient = null (no cleanup)
  • Internal HttpClient: onCloseClient = default reflection cleanup
  • Custom cleanup: user-provided Consumer

Solution implemented:

  • Use sun.misc.Unsafe to bypass module system temporarily
  • Fallback strategy: JDK 21+ close() → JDK 17 SelectorManager reflection
  • Comprehensive error handling for stability

Test Updates

  • Added proper HTTP requests to verify client functionality
  • Enhanced validation for both external and internal HttpClient scenarios
  • All tests pass successfully

Discussion Point

After further investigation of JDK 21's close() method, I discovered that while the method exists, it's still on internal classes (HttpClientFacade) that are not accessible due to module restrictions. We can use sun.misc.Unsafe to bypass module system temporarily, and it works.

However, I think we need to focus on discussing whether this poses security risks. Is this approach acceptable for the SDK context, or do you have better suggestions?

Copy link
Contributor

@Kehrlann Kehrlann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your code does not compile. The merge went wrong. Please review the merge, and then I'll do a second review.


private Duration connectTimeout = Duration.ofSeconds(10);

private Consumer<HttpClient> onCloseClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a default value here.

if (externalHttpClient != null) {
// Use external HttpClient, use custom close handler or no-op
httpClient = externalHttpClient;
closeHandler = onCloseClient; // null means no cleanup
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not use null, use a no-op lambda instead, client -> {}.

this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer;
this.onCloseClient = onCloseClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assert that onCloseClient is not null.

Comment on lines +526 to +534
// unsafe
Class<?> UnsafeClass = Class.forName("sun.misc.Unsafe");
Field unsafeField = UnsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
Module ObjectModule = Object.class.getModule();
Class<HttpClientSseClientTransport> currentClass = HttpClientSseClientTransport.class;
long addr = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
unsafe.getAndSetObject(currentClass, addr, ObjectModule);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should need unsafe for this. Is there another way?

Comment on lines +233 to +237
DefaultMcpTransportSession currentSession = this.activeSession.getAndSet(createTransportSession());
Mono<Void> sessionClose = currentSession != null ? currentSession.closeGracefully() : Mono.empty();

if (onCloseClient != null) {
return sessionClose.then(Mono.fromRunnable(() -> onCloseClient.accept(httpClient)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HttpClient resource leak causes thread accumulation and memory exhaustion

2 participants