From c49a843dd682d832480b8452d86c5165e9fc29ef Mon Sep 17 00:00:00 2001 From: Sean Riggin Date: Thu, 22 Oct 2026 14:16:37 -0600 Subject: [PATCH 1/2] Recycle HTTP client lease when an unsent request is reset or cancelled Resetting or cancelling an HttpClientRequest before anything was written did not close the stream on HTTP/2 and HTTP/3, so the connection lease was never recycled. Each such request permanently consumed a concurrency slot; once exhausted, new requests waited in the pool until the connection closed. Neither the idle timeout nor the keep-alive expiry reclaims these slots. - HTTP/2: close the stream when it is reset before stream allocation. - HTTP/3: when the request headers were not sent, also abort the inbound side and close the stream channel, since Netty does not close it after a reset. Add reset and cancel tests covering all HTTP versions. Assisted-by: Anthropic Claude (claude-opus-5-5) Signed-off-by: Sean Riggin --- .../http/impl/http2/DefaultHttp2Stream.java | 1 + .../http/impl/http3/Http3ClientStream.java | 27 +++++++++++++++ .../core/http/impl/http3/Http3Stream.java | 4 +++ .../java/io/vertx/tests/http/HttpTest.java | 33 +++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java index fb6cfa9236a..c1c9a8fb011 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java @@ -501,6 +501,7 @@ private void writeReset0(long code, Promise promise) { } else { // Reset happening before stream allocation handleReset(code); + onClose(null); promise.complete(); } } diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3ClientStream.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3ClientStream.java index 09a864e1a6c..562e143f182 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3ClientStream.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3ClientStream.java @@ -25,6 +25,7 @@ import io.vertx.core.http.impl.headers.HttpResponseHeaders; import io.vertx.core.http.impl.observability.ClientStreamObserver; import io.vertx.core.internal.ContextInternal; +import io.vertx.core.internal.PromiseInternal; import io.vertx.core.internal.net.QuicStreamInternal; import java.util.Map; @@ -47,6 +48,32 @@ public Http3ClientStream(Http3ClientConnection connection, QuicStreamInternal st this.observer = observer; } + @Override + public Future writeReset(long code) { + boolean unsent = !isHeadersSent(); + Future fut = super.writeReset(code); + return unsent ? fut.compose(v -> closeUnsent((int) code)) : fut; + } + + @Override + public Future cancel() { + boolean unsent = !isHeadersSent(); + Future fut = super.cancel(); + return unsent ? fut.compose(res -> closeUnsent(Http3ErrorCode.H3_REQUEST_CANCELLED.code()).map(res)) : fut; + } + + /** + * Close a stream reset before the request headers were sent, so the stream closes and its connection lease + * is recycled: abort the inbound side as well, and close the channel since Netty does not close it after a reset. + */ + private Future closeUnsent(int code) { + return stream.abort(code).compose(v -> { + PromiseInternal promise = context.promise(); + stream.channelHandlerContext().channel().close().addListener(promise); + return promise.future(); + }); + } + @Override protected HttpHeaders headOf(Http3Headers headers) { return new HttpResponseHeaders(headers); diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3Stream.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3Stream.java index 67545732063..48b3b498bef 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3Stream.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http3/Http3Stream.java @@ -360,6 +360,10 @@ public Future writeReset(long code) { return stream.reset((int)code); } + final boolean isHeadersSent() { + return headersSent; + } + public final Object metric() { return observer != null ? observer.metric() : null; } diff --git a/vertx-core/src/test/java/io/vertx/tests/http/HttpTest.java b/vertx-core/src/test/java/io/vertx/tests/http/HttpTest.java index 34330045872..88c5975e12f 100644 --- a/vertx-core/src/test/java/io/vertx/tests/http/HttpTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/http/HttpTest.java @@ -5788,6 +5788,39 @@ public void testResetClientRequestResponseInProgress(Checkpoint checkpoint) thro })); } + @Test + public void testResetClientRequestNotYetSentRecyclesLease() throws Exception { + testClientRequestNotYetSentRecyclesLease(req -> req.reset()); + } + + @Test + public void testCancelClientRequestNotYetSentRecyclesLease() throws Exception { + testClientRequestNotYetSentRecyclesLease(HttpClientRequest::cancel); + } + + private void testClientRequestNotYetSentRecyclesLease(Function> abort) throws Exception { + // Allow a single request at a time, the second request can only proceed when the lease is recycled + HttpServerConfig serverConfig = config.forServer().config(); + if (serverConfig.getHttp2Config() != null) { + serverConfig.getHttp2Config().getInitialSettings().setMaxConcurrentStreams(1); + } + serverConfig.getQuicConfig().getTransportConfig().setInitialMaxStreamsBidi(1); + server = vertx.httpServerBuilder() + .with(serverConfig) + .with(config.forServer().sslOptions()) + .build(); + server.requestHandler(req -> req.response().end()); + startServer(testAddress); + PoolOptions poolOptions = new PoolOptions().setHttp1MaxSize(1).setHttp2MaxSize(1).setHttp3MaxSize(1); + client = config.forClient().create(vertx, poolOptions); + HttpClientRequest request = client.request(requestOptions).await(); + HttpConnection connection = request.connection(); + abort.apply(request).await(); + HttpClientRequest next = client.request(requestOptions).await(10, TimeUnit.SECONDS); + assertSame(connection, next.connection()); + next.send().compose(HttpClientResponse::end).await(10, TimeUnit.SECONDS); + } + @Test public void testResetPartialClientRequest(Checkpoint checkpoint) throws Exception { server.requestHandler(req -> { From 7d2439b43839635f84c691fc41ff2b2e767e9cbb Mon Sep 17 00:00:00 2001 From: Sean Riggin Date: Wed, 28 Oct 2026 09:39:36 -0600 Subject: [PATCH 2/2] make observation of close optional --- .../vertx/core/http/impl/http2/DefaultHttp2Stream.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java index c1c9a8fb011..033585fde56 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http2/DefaultHttp2Stream.java @@ -182,8 +182,12 @@ public void init(int id, boolean writable) { } public void onClose(GoAway goAway) { + close(goAway, true); + } + + private void close(GoAway goAway, boolean observe) { if (!trailersSent || !trailersReceived) { - StreamObserver observer = observer(); + StreamObserver observer = observe ? observer() : null; if (observer != null) { observer.observeReset(); } @@ -501,7 +505,8 @@ private void writeReset0(long code, Promise promise) { } else { // Reset happening before stream allocation handleReset(code); - onClose(null); + // Nothing was sent, so the observer did not begin the request + close(null, false); promise.complete(); } }