Skip to content

Conversation

@jaikiran
Copy link
Member

@jaikiran jaikiran commented Nov 11, 2025

Can I please get a review for this fix which addresses a connection leak in HttpClient when dealing with HTTP/2 requests?

I have added a comment in https://bugs.openjdk.org/browse/JDK-8326498 which explains what the issue is. The fix here addresses the issue by cleaning up the Http2Connection closing logic and centralizing it to a connection terminator. The terminator then ensures that the right resources are closed (including the underlying SocketChannel) when the termination happens.

A new jtreg test has been introduced which reproduces the issue and verifies the fix.


Progress

  • Change must be properly reviewed (1 review required, with at least 1 Reviewer)
  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue

Issue

  • JDK-8326498: java.net.http.HttpClient connection leak using http/2 (Bug - P3)

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.org/jdk.git pull/28233/head:pull/28233
$ git checkout pull/28233

Update a local copy of the PR:
$ git checkout pull/28233
$ git pull https://git.openjdk.org/jdk.git pull/28233/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 28233

View PR using the GUI difftool:
$ git pr show -t 28233

Using diff file

Download this PR as a diff file:
https://git.openjdk.org/jdk/pull/28233.diff

Using Webrev

Link to Webrev Comment

@bridgekeeper
Copy link

bridgekeeper bot commented Nov 11, 2025

👋 Welcome back jpai! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk
Copy link

openjdk bot commented Nov 11, 2025

❗ This change is not yet ready to be integrated.
See the Progress checklist in the description for automated requirements.

@openjdk openjdk bot added the net net-dev@openjdk.org label Nov 11, 2025
@openjdk
Copy link

openjdk bot commented Nov 11, 2025

@jaikiran The following label will be automatically applied to this pull request:

  • net

When this pull request is ready to be reviewed, an "RFR" email will be sent to the corresponding mailing list. If you would like to change these labels, use the /label pull request command.

@openjdk openjdk bot added the rfr Pull request is ready for review label Nov 11, 2025
@mlbridge
Copy link

mlbridge bot commented Nov 11, 2025

Webrevs

/**
* Closes the connection normally (with a NO_ERROR termination cause), if not already closed.
*/
final void close() {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should mark the Http2Connection as Closeable.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think that's reasonable. I've updated the PR to include this change.

Comment on lines +901 to +904
if (!isOpen()) {
final Http2TerminationCause tc = this.connTerminator.getTerminationCause();
assert tc != null : "termination cause is null for a closed connection";
return Optional.of(tc.getCloseCause());
Copy link
Member

Choose a reason for hiding this comment

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

Please remove this code block. The comment seems to imply that it fixes the race, but it doesn't.

I assume you verified that all callers of getTerminationException are properly synchronized, so that we don't leak resources if the method returns an empty optional while the connection is closed in another thread.

* {@linkplain NetworkChannel#isOpen() channel is open}. false otherwise.
*/
final boolean isOpen() {
return this.connTerminator.terminationCause.get() == null && connection.channel().isOpen();
Copy link
Member

Choose a reason for hiding this comment

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

Can we ever observe a situation where channel is not open but termination cause is not set?

As far as I could tell, channel.isOpen only returns false after close() is called, and close() is only called from doTerminate after the termination cause is set. What am I missing?

Copy link
Member

@dfuch dfuch Nov 12, 2025

Choose a reason for hiding this comment

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

A channel can be closed asynchronously by the peer. So it may be closed even if close() has not been called.

* is not considered as an erroneous termination and this method returns {@code false} for
* such cases.
*/
public abstract boolean isErroneousClose();
Copy link
Member

Choose a reason for hiding this comment

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

nit: can we use a different word here? "Erroneous close" feels vague here; would "is(Non)Graceful", "isAbrupt" or "hasErrorCode" capture the intent?

Copy link
Contributor

Choose a reason for hiding this comment

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

That that erroneous close has been used in several other contexts; in code, in comments, etc. If this gets updated, I'd appreciate other relevant occurrences get updated too.

Copy link
Contributor

@vy vy left a comment

Choose a reason for hiding this comment

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

I personally liked the clean-up of the state changes in Http2Connection, yet the change is non-trivial. I leave the judgement of that to reviewers more versed in Http2Connection.

Comment on lines -714 to -715
if (!cached)
c.close();
Copy link
Contributor

Choose a reason for hiding this comment

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

We remove the if (!cached) c.close() logic. Where do we restore this functionality? If not, why not?

Copy link
Member

Choose a reason for hiding this comment

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

If the terminationException is not null the connection is already closed (or being closed by another thread) so there's no need to call close() again.

if (debug.on()) {
debug.log("Unexpected state %s, skipping idle connection shutdown",
describeClosedState(closedState));
debug.log("Not initiating idle connection close");
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
debug.log("Not initiating idle connection close");
debug.log("Connection is already cancelled, skipping idle connection close");

* being idle.
*/
public static Http2TerminationCause idleTimedOut() {
return new NoError("HTTP/2 connection idle timed out", "idle timed out");
Copy link
Contributor

Choose a reason for hiding this comment

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

Any particular reason this is not cached in NoError.IDLE_TIMED_OUT in a similar manner to NoError.INSTANCE? I could not see a place where its stack trace would be of value.

import jdk.internal.net.http.frame.ErrorFrame;

/**
* Termination cause for a {@linkplain Http2Connection HTTP/2 connection}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Termination cause for a {@linkplain Http2Connection HTTP/2 connection}
* Termination cause for an {@linkplain Http2Connection HTTP/2 connection}

Comment on lines +69 to +71
* Returns the IOException that is considered the cause of the connection termination.
* Even a normal {@linkplain #isErroneousClose() non-erroneous} termination will have
* a IOException associated with it, so this method will always return a non-null instance.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Returns the IOException that is considered the cause of the connection termination.
* Even a normal {@linkplain #isErroneousClose() non-erroneous} termination will have
* a IOException associated with it, so this method will always return a non-null instance.
* Returns the {@link IOException} that is considered the cause of the connection termination.
* Even a normal {@linkplain #isErroneousClose() non-erroneous} termination will have
* an {@code IOException} associated with it, so this method will always return a non-null instance.

// if the underlying SocketChannel isn't open, then terminate the connection.
// that way when Http2Connection.isOpen() returns false in that situation, then this
// getTerminationCause() will return a termination cause.
terminate(Http2TerminationCause.forException(new IOException("channel is not open")));
Copy link
Contributor

Choose a reason for hiding this comment

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

Terminating the connection in a getter doesn't feel all right. Would you mind elaborating on the rationale, the absence of a better alternative, please?


private static Field openConnections; // Set<> jdk.internal.net.http.HttpClientImpl#openedConnections

private static SSLContext sslContext;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is SSL a necessity for this test? Put another way, does SSL play a role in the connection leakage?

}

/*
* Issues a burst of 100 HTTP/2 requests to the same server (host/port) and expects all of
Copy link
Contributor

Choose a reason for hiding this comment

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

numRequests is actually 20, not 100:

Suggested change
* Issues a burst of 100 HTTP/2 requests to the same server (host/port) and expects all of
* Issues a burst of HTTP/2 requests to the same server (host/port) and expects all of


// using reflection, return the jdk.internal.net.http.HttpClientImpl instance held
// by the given client
private static Object reflectHttpClientImplInstance(final HttpClient client) throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead, you can use Http3ConnectionAccess::impl too.

* HttpClientImpl to verify that the client is holding on to at most 1 connection.
*/
@Test
void testOpenConnections() throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shall we also introduce following tests?

  1. Verify secondary request bursts always reuse the pooled connection, and don't change the state of the pool. (Remember that, as reported in the associated ticket, the secondary bursts were causing the orphan connection pile up.)
  2. Repeat all tests with a small idle timeout, say, 5s, and ensure that after 5s pool is completely emptied.

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

Labels

net net-dev@openjdk.org rfr Pull request is ready for review

Development

Successfully merging this pull request may close these issues.

4 participants