From 48e22578db211d35aa5ceb7d970419237d25c70e Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 3 Apr 2026 07:34:57 +0200 Subject: [PATCH] Configurable HTTP/2 PING ACK timeout --- .../hc/core5/http2/config/H2Config.java | 34 ++++++++++++-- .../impl/nio/AbstractH2StreamMultiplexer.java | 2 +- .../hc/core5/http2/config/H2ConfigTest.java | 11 +++-- .../nio/TestAbstractH2StreamMultiplexer.java | 44 +++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java index 89b538860d..06c656f4d2 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java @@ -31,6 +31,7 @@ import org.apache.hc.core5.annotation.ThreadingBehavior; import org.apache.hc.core5.http2.frame.FrameConsts; import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; /** * HTTP/2 protocol configuration. @@ -51,10 +52,12 @@ public class H2Config { private final int maxHeaderListSize; private final boolean compressionEnabled; private final int maxContinuations; + private final Timeout pingAckTimeout; H2Config(final int headerTableSize, final boolean pushEnabled, final int maxConcurrentStreams, final int initialWindowSize, final int maxFrameSize, final int maxHeaderListSize, - final boolean compressionEnabled, final int maxContinuations) { + final boolean compressionEnabled, final int maxContinuations, + final Timeout pingAckTimeout) { super(); this.headerTableSize = headerTableSize; this.pushEnabled = pushEnabled; @@ -64,6 +67,7 @@ public class H2Config { this.maxHeaderListSize = maxHeaderListSize; this.compressionEnabled = compressionEnabled; this.maxContinuations = maxContinuations; + this.pingAckTimeout = pingAckTimeout; } public int getHeaderTableSize() { @@ -98,6 +102,15 @@ public int getMaxContinuations() { return maxContinuations; } + /** + * Returns the timeout for waiting for a PING ACK during idle connection validation. + * + * @since 5.5 + */ + public Timeout getPingAckTimeout() { + return pingAckTimeout; + } + @Override public String toString() { final StringBuilder builder = new StringBuilder(); @@ -109,6 +122,7 @@ public String toString() { .append(", maxHeaderListSize=").append(this.maxHeaderListSize) .append(", compressionEnabled=").append(this.compressionEnabled) .append(", maxContinuations=").append(this.maxContinuations) + .append(", pingAckTimeout=").append(this.pingAckTimeout) .append("]"); return builder.toString(); } @@ -142,7 +156,8 @@ public static H2Config.Builder copy(final H2Config config) { .setInitialWindowSize(config.getInitialWindowSize()) .setMaxFrameSize(config.getMaxFrameSize()) .setMaxHeaderListSize(config.getMaxHeaderListSize()) - .setCompressionEnabled(config.isCompressionEnabled()); + .setCompressionEnabled(config.isCompressionEnabled()) + .setPingAckTimeout(config.getPingAckTimeout()); } public static class Builder { @@ -155,6 +170,7 @@ public static class Builder { private int maxHeaderListSize; private boolean compressionEnabled; private int maxContinuations; + private Timeout pingAckTimeout; Builder() { this.headerTableSize = INIT_HEADER_TABLE_SIZE * 2; @@ -165,6 +181,7 @@ public static class Builder { this.maxHeaderListSize = FrameConsts.MAX_FRAME_SIZE; this.compressionEnabled = true; this.maxContinuations = 100; + this.pingAckTimeout = Timeout.ofSeconds(5); } public Builder setHeaderTableSize(final int headerTableSize) { @@ -216,6 +233,16 @@ public Builder setMaxContinuations(final int maxContinuations) { return this; } + /** + * Sets the timeout for waiting for a PING ACK during idle connection validation. + * + * @since 5.5 + */ + public Builder setPingAckTimeout(final Timeout pingAckTimeout) { + this.pingAckTimeout = Args.notNull(pingAckTimeout, "PING ACK timeout"); + return this; + } + public H2Config build() { return new H2Config( headerTableSize, @@ -225,7 +252,8 @@ public H2Config build() { maxFrameSize, maxHeaderListSize, compressionEnabled, - maxContinuations); + maxContinuations, + pingAckTimeout); } } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java index 8e9171b57e..14d4e7a488 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java @@ -543,7 +543,7 @@ public final void onOutput() throws HttpException, IOException { final boolean hasBeenIdleTooLong = t > 0 && System.currentTimeMillis() - lastActivityTime > t; if (hasBeenIdleTooLong && ioSession.hasCommands() && pingHandlers.isEmpty()) { final Timeout socketTimeout = ioSession.getSocketTimeout(); - ioSession.setSocketTimeout(Timeout.ofSeconds(5)); + ioSession.setSocketTimeout(localConfig.getPingAckTimeout()); executePing(new PingCommand(new BasicPingHandler(result -> { // restore timeout ioSession.setSocketTimeout(socketTimeout); diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java index a6a2859d5f..548a66efa6 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java @@ -32,28 +32,29 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.apache.hc.core5.util.Timeout; import org.junit.jupiter.api.Test; class H2ConfigTest { @Test void builder() { - // Create and start requester final H2Config h2Config = H2Config.custom() .setPushEnabled(false) .build(); assertNotNull(h2Config); + assertEquals(Timeout.ofSeconds(5), h2Config.getPingAckTimeout()); } @Test void checkValues() { - // Create and start requester final H2Config h2Config = H2Config.custom() .setHeaderTableSize(1) .setMaxConcurrentStreams(1) .setMaxFrameSize(16384) .setPushEnabled(true) .setCompressionEnabled(true) + .setPingAckTimeout(Timeout.ofSeconds(10)) .build(); assertEquals(1, h2Config.getHeaderTableSize()); @@ -61,17 +62,18 @@ void checkValues() { assertEquals(16384, h2Config.getMaxFrameSize()); assertTrue(h2Config.isPushEnabled()); assertTrue(h2Config.isCompressionEnabled()); + assertEquals(Timeout.ofSeconds(10), h2Config.getPingAckTimeout()); } @Test void copy() { - // Create and start requester final H2Config h2Config = H2Config.custom() .setHeaderTableSize(1) .setMaxConcurrentStreams(1) .setMaxFrameSize(16384) .setPushEnabled(true) .setCompressionEnabled(true) + .setPingAckTimeout(Timeout.ofSeconds(15)) .build(); final H2Config.Builder builder = H2Config.copy(h2Config); @@ -82,7 +84,8 @@ void copy() { () -> assertEquals(h2Config.getInitialWindowSize(), h2Config2.getInitialWindowSize()), () -> assertEquals(h2Config.getMaxConcurrentStreams(), h2Config2.getMaxConcurrentStreams()), () -> assertEquals(h2Config.getMaxFrameSize(), h2Config2.getMaxFrameSize()), - () -> assertEquals(h2Config.getMaxHeaderListSize(), h2Config2.getMaxHeaderListSize()) + () -> assertEquals(h2Config.getMaxHeaderListSize(), h2Config2.getMaxHeaderListSize()), + () -> assertEquals(h2Config.getPingAckTimeout(), h2Config2.getPingAckTimeout()) ); } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java index 6c9e88e4f1..3185f1f39d 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java @@ -1428,6 +1428,50 @@ void testValidateAfterInactivityPingAckRestoresPreviousTimeout() throws Exceptio Mockito.verify(protocolIOSession, Mockito.atLeastOnce()).setSocketTimeout(ArgumentMatchers.eq(previousTimeout)); } + @Test + void testValidateAfterInactivityUsesConfiguredPingAckTimeout() throws Exception { + final List writes = new ArrayList<>(); + Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class))) + .thenAnswer(inv -> { + final ByteBuffer b = inv.getArgument(0, ByteBuffer.class); + final byte[] copy = new byte[b.remaining()]; + b.get(copy); + writes.add(copy); + return copy.length; + }); + Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt()); + Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt()); + + Mockito.when(protocolIOSession.hasCommands()).thenReturn(true); + Mockito.when(protocolIOSession.getSocketTimeout()).thenReturn(Timeout.ofSeconds(30)); + + final Timeout customPingAckTimeout = Timeout.ofSeconds(15); + final H2Config h2Config = H2Config.custom() + .setPingAckTimeout(customPingAckTimeout) + .build(); + final Timeout validateAfterInactivity = Timeout.ofMilliseconds(1); + + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, h2Config, h2StreamListener, () -> streamHandler, + validateAfterInactivity); + + mux.onConnect(); + completeSettingsHandshake(mux); + + writes.clear(); + makeMuxIdle(mux, validateAfterInactivity); + + mux.onOutput(); + + Mockito.verify(protocolIOSession, Mockito.atLeastOnce()) + .setSocketTimeout(ArgumentMatchers.eq(customPingAckTimeout)); + + final List frames = parseFrames(concat(writes)); + Assertions.assertTrue(frames.stream().anyMatch(f -> f.isPing() && !f.isAck()), + "Must emit pre-flight PING"); + } + @Test void testKeepAliveAckTimeoutShutsDownAndFailsStreams() throws Exception { final List writes = new ArrayList<>();