From 8d69aeaeeaaf5a4c8a65afafc1231409439b24bf Mon Sep 17 00:00:00 2001 From: Peter Marsh Date: Thu, 6 Aug 2026 16:48:52 +0200 Subject: [PATCH 1/3] netty: Add option to disable HPACK dynamic table Add client and server builder controls that disable HPACK dynamic table use in both directions. Advertise a zero header table size and keep the encoder table pinned at zero when the peer changes its setting. Add encoder, handler, builder, transport, and interoperability coverage. AI assistance: OpenAI Codex (GPT-5) was used to review these HPACK changes and strengthen the tests. Co-authored-by: Codex --- .../grpc/netty/GrpcHttp2HeadersEncoder.java | 54 ++++++++ .../io/grpc/netty/NettyChannelBuilder.java | 22 +++ .../io/grpc/netty/NettyClientHandler.java | 11 +- .../io/grpc/netty/NettyClientTransport.java | 4 + .../main/java/io/grpc/netty/NettyServer.java | 4 + .../io/grpc/netty/NettyServerBuilder.java | 17 +++ .../io/grpc/netty/NettyServerHandler.java | 13 +- .../io/grpc/netty/NettyServerTransport.java | 4 + .../netty/GrpcHttp2HeadersEncoderTest.java | 92 +++++++++++++ .../netty/HpackDynamicTableInteropTest.java | 128 ++++++++++++++++++ .../grpc/netty/NettyChannelBuilderTest.java | 7 + .../io/grpc/netty/NettyClientHandlerTest.java | 29 ++++ .../grpc/netty/NettyClientTransportTest.java | 4 + .../io/grpc/netty/NettyHandlerTestBase.java | 6 + .../io/grpc/netty/NettyServerBuilderTest.java | 5 + .../io/grpc/netty/NettyServerHandlerTest.java | 20 +++ .../java/io/grpc/netty/NettyServerTest.java | 7 + 17 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java create mode 100644 netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java create mode 100644 netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java diff --git a/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java new file mode 100644 index 00000000000..38b705576b1 --- /dev/null +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.netty; + +import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2Exception; +import io.netty.handler.codec.http2.Http2HeadersEncoder; + +/** HTTP/2 headers encoder with gRPC's HPACK configuration. */ +final class GrpcHttp2HeadersEncoder extends DefaultHttp2HeadersEncoder { + private static final int DEFAULT_DYNAMIC_TABLE_ARRAY_SIZE_HINT = 16; + private static final int MIN_DYNAMIC_TABLE_ARRAY_SIZE_HINT = 2; + + private final boolean disableDynamicTable; + + GrpcHttp2HeadersEncoder( + Http2HeadersEncoder.SensitivityDetector sensitivityDetector, + boolean disableDynamicTable) { + super( + sensitivityDetector, + false, + disableDynamicTable + ? MIN_DYNAMIC_TABLE_ARRAY_SIZE_HINT : DEFAULT_DYNAMIC_TABLE_ARRAY_SIZE_HINT, + Integer.MAX_VALUE); + this.disableDynamicTable = disableDynamicTable; + if (disableDynamicTable) { + try { + super.maxHeaderTableSize(0); + } catch (Http2Exception e) { + // Zero is always a valid HPACK dynamic table size. + throw new AssertionError(e); + } + } + } + + @Override + public void maxHeaderTableSize(long max) throws Http2Exception { + super.maxHeaderTableSize(disableDynamicTable ? 0 : max); + } +} diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java index 33289018d99..b6ab15d1b89 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -110,6 +110,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 neverIndexedMetadataKeys = new HashSet<>(); + private boolean disableHpackDynamicTable; private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; @@ -476,6 +477,21 @@ public NettyChannelBuilder neverIndexMetadataKeys( return this; } + /** + * Disables use of the HPACK dynamic table for HTTP/2 header compression. + * + *

HPACK itself remains enabled, as required by HTTP/2. Static table references may still be + * used. Disabling the dynamic table reduces per-connection memory usage, but can increase the + * size of header blocks. The inbound dynamic table is disabled after the peer acknowledges the + * corresponding HTTP/2 setting, and requires a peer that correctly implements that setting. By + * default, the dynamic table is enabled. + */ + @CanIgnoreReturnValue + public NettyChannelBuilder disableHpackDynamicTable() { + disableHpackDynamicTable = true; + return this; + } + /** * Sets the maximum size of header list allowed to be received. This is cumulative size of the * headers with some overhead, as defined for @@ -669,6 +685,7 @@ ClientTransportFactory buildTransportFactory() { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -813,6 +830,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -835,6 +853,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -854,6 +873,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Collections.unmodifiableSet( new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -904,6 +924,7 @@ public void run() { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -944,6 +965,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java index 4ccc8747bb5..bbb31017b10 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -59,7 +59,6 @@ import io.netty.handler.codec.http2.DefaultHttp2ConnectionEncoder; import io.netty.handler.codec.http2.DefaultHttp2FrameReader; import io.netty.handler.codec.http2.DefaultHttp2FrameWriter; -import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; import io.netty.handler.codec.http2.DefaultHttp2LocalFlowController; import io.netty.handler.codec.http2.DefaultHttp2RemoteFlowController; import io.netty.handler.codec.http2.Http2CodecUtil; @@ -162,6 +161,7 @@ static NettyClientHandler newHandler( boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -175,8 +175,8 @@ static NettyClientHandler newHandler( Preconditions.checkArgument(maxHeaderListSize > 0, "maxHeaderListSize must be positive"); Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); - Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( - sensitivityDetector(neverIndexedMetadataKeys), false, 16, Integer.MAX_VALUE); + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder( + sensitivityDetector(neverIndexedMetadataKeys), disableHpackDynamicTable); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -193,6 +193,7 @@ static NettyClientHandler newHandler( keepAliveManager, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, stopwatchFactory, @@ -214,6 +215,7 @@ static NettyClientHandler newHandler( KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -261,6 +263,9 @@ static NettyClientHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(0); settings.maxHeaderListSize(maxHeaderListSize); + if (disableHpackDynamicTable) { + settings.headerTableSize(0); + } return new NettyClientHandler( decoder, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java index 2d7873f0d7b..42647a1f166 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -87,6 +87,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -123,6 +124,7 @@ class NettyClientTransport implements ConnectionClientTransport, boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -150,6 +152,7 @@ class NettyClientTransport implements ConnectionClientTransport, this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -253,6 +256,7 @@ public Runnable start(Listener transportListener) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, GrpcUtil.STOPWATCH_SUPPLIER, diff --git a/netty/src/main/java/io/grpc/netty/NettyServer.java b/netty/src/main/java/io/grpc/netty/NettyServer.java index d8dd016ddec..31f4107cde5 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServer.java +++ b/netty/src/main/java/io/grpc/netty/NettyServer.java @@ -95,6 +95,7 @@ class NettyServer implements InternalServer, InternalWithLogId { private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -134,6 +135,7 @@ class NettyServer implements InternalServer, InternalWithLogId { boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -167,6 +169,7 @@ class NettyServer implements InternalServer, InternalWithLogId { this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Collections.unmodifiableSet( new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -273,6 +276,7 @@ public void initChannel(Channel ch) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java index 99e2627451a..07bd98d14af 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java @@ -111,6 +111,7 @@ public final class NettyServerBuilder extends ForwardingServerBuilder neverIndexedMetadataKeys = new HashSet<>(); + private boolean disableHpackDynamicTable; private int maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE; private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; @@ -483,6 +484,21 @@ public NettyServerBuilder neverIndexMetadataKeys( return this; } + /** + * Disables use of the HPACK dynamic table for HTTP/2 header compression. + * + *

HPACK itself remains enabled, as required by HTTP/2. Static table references may still be + * used. Disabling the dynamic table reduces per-connection memory usage, but can increase the + * size of header blocks. The inbound dynamic table is disabled after the peer acknowledges the + * corresponding HTTP/2 setting, and requires a peer that correctly implements that setting. By + * default, the dynamic table is enabled. + */ + @CanIgnoreReturnValue + public NettyServerBuilder disableHpackDynamicTable() { + disableHpackDynamicTable = true; + return this; + } + /** * Sets the maximum message size allowed to be received on the server. If not called, * defaults to 4 MiB. The default provides protection to services who haven't considered the @@ -773,6 +789,7 @@ NettyServer buildTransportServers( autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java index 846c45cd459..c88394b5f3e 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java @@ -70,7 +70,6 @@ import io.netty.handler.codec.http2.DefaultHttp2FrameReader; import io.netty.handler.codec.http2.DefaultHttp2FrameWriter; import io.netty.handler.codec.http2.DefaultHttp2Headers; -import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; import io.netty.handler.codec.http2.DefaultHttp2LocalFlowController; import io.netty.handler.codec.http2.DefaultHttp2RemoteFlowController; import io.netty.handler.codec.http2.EmptyHttp2Headers; @@ -166,6 +165,7 @@ static NettyServerHandler newHandler( boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -186,11 +186,9 @@ static NettyServerHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ServerHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new Http2InboundFrameLogger( new DefaultHttp2FrameReader(headersDecoder), frameLogger); - Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder( NettyClientHandler.sensitivityDetector(neverIndexedMetadataKeys), - false, - 16, - Integer.MAX_VALUE); + disableHpackDynamicTable); Http2FrameWriter frameWriter = new Http2OutboundFrameLogger(new DefaultHttp2FrameWriter(encoder), frameLogger); return newHandler( @@ -203,6 +201,7 @@ static NettyServerHandler newHandler( maxStreams, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, @@ -230,6 +229,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -287,6 +287,9 @@ static NettyServerHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(maxStreams); settings.maxHeaderListSize(maxHeaderListSize); + if (disableHpackDynamicTable) { + settings.headerTableSize(0); + } return new NettyServerHandler( channelUnused, diff --git a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java index 4eb68d38461..12c47c8f25a 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java @@ -72,6 +72,7 @@ class NettyServerTransport implements ServerTransport { private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -99,6 +100,7 @@ class NettyServerTransport implements ServerTransport { boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -124,6 +126,7 @@ class NettyServerTransport implements ServerTransport { this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -288,6 +291,7 @@ private NettyServerHandler createHandler( autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, diff --git a/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java new file mode 100644 index 00000000000..afbd981ed22 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.netty; + +import static com.google.common.truth.Truth.assertThat; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersDecoder; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.util.AsciiString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GrpcHttp2HeadersEncoderTest { + private static final AsciiString CUSTOM_NAME = AsciiString.cached("custom-key"); + private static final AsciiString CUSTOM_VALUE = AsciiString.cached("custom-value"); + + @Test + public void dynamicTableEnabledByDefault() throws Exception { + GrpcHttp2HeadersEncoder encoder = + new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, false); + ByteBuf first = Unpooled.buffer(); + ByteBuf second = Unpooled.buffer(); + try { + Http2Headers headers = new DefaultHttp2Headers().add(CUSTOM_NAME, CUSTOM_VALUE); + + encoder.encodeHeaders(1, headers, first); + encoder.encodeHeaders(3, headers, second); + + assertThat(first.getUnsignedByte(first.readerIndex()) & 0xC0).isEqualTo(0x40); + assertThat(second.getUnsignedByte(second.readerIndex()) & 0x80).isEqualTo(0x80); + } finally { + first.release(); + second.release(); + encoder.close(); + } + } + + @Test + public void dynamicTableDisabledPermanently_staticTableStillUsed() throws Exception { + GrpcHttp2HeadersEncoder encoder = + new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, true); + DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder(); + ByteBuf first = Unpooled.buffer(); + ByteBuf second = Unpooled.buffer(); + ByteBuf staticHeader = Unpooled.buffer(); + try { + assertThat(encoder.maxHeaderTableSize()).isEqualTo(0); + encoder.maxHeaderTableSize(4096); + assertThat(encoder.maxHeaderTableSize()).isEqualTo(0); + + Http2Headers headers = new DefaultHttp2Headers().add(CUSTOM_NAME, CUSTOM_VALUE); + encoder.encodeHeaders(1, headers, first); + Http2Headers firstDecoded = decoder.decodeHeaders(1, first); + assertThat(firstDecoded.get(CUSTOM_NAME).toString()).isEqualTo(CUSTOM_VALUE.toString()); + assertThat(decoder.configuration().maxHeaderTableSize()).isEqualTo(0); + + encoder.encodeHeaders(3, headers, second); + assertThat(second.getUnsignedByte(second.readerIndex()) & 0x80).isEqualTo(0); + Http2Headers secondDecoded = decoder.decodeHeaders(3, second); + assertThat(secondDecoded.get(CUSTOM_NAME).toString()).isEqualTo(CUSTOM_VALUE.toString()); + + encoder.encodeHeaders(5, new DefaultHttp2Headers().method(AsciiString.cached("GET")), + staticHeader); + assertThat(staticHeader.getUnsignedByte(staticHeader.readerIndex())).isEqualTo(0x82); + } finally { + first.release(); + second.release(); + staticHeader.release(); + encoder.close(); + } + } +} diff --git a/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java b/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java new file mode 100644 index 00000000000..db516283892 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.netty; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerInterceptors; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class HpackDynamicTableInteropTest { + private static final int RPC_COUNT = 10; + private static final Metadata.Key REQUEST_METADATA_KEY = + Metadata.Key.of("x-hpack-request", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key RESPONSE_METADATA_KEY = + Metadata.Key.of("x-hpack-response", Metadata.ASCII_STRING_MARSHALLER); + private static final String REQUEST_METADATA_VALUE = "repeated-request-metadata-value"; + private static final String RESPONSE_METADATA_VALUE = "repeated-response-metadata-value"; + + @Parameters(name = "clientDisabled={0}, serverDisabled={1}") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {false, false}, {false, true}, {true, false}, {true, true} + }); + } + + @Parameter(0) + public boolean clientDisabled; + + @Parameter(1) + public boolean serverDisabled; + + private Server server; + private ManagedChannel channel; + + @After + public void tearDown() throws Exception { + if (channel != null) { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void unaryRpcInteroperates() throws Exception { + Metadata responseMetadata = new Metadata(); + responseMetadata.put(RESPONSE_METADATA_KEY, RESPONSE_METADATA_VALUE); + NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(0) + .addService( + ServerInterceptors.intercept( + new SimpleServiceImpl(), + MetadataUtils.newAttachMetadataServerInterceptor(responseMetadata))); + if (serverDisabled) { + serverBuilder.disableHpackDynamicTable(); + } + server = serverBuilder.build().start(); + + NettyChannelBuilder channelBuilder = NettyChannelBuilder + .forAddress("localhost", server.getPort()) + .usePlaintext(); + if (clientDisabled) { + channelBuilder.disableHpackDynamicTable(); + } + channel = channelBuilder.build(); + + Metadata requestMetadata = new Metadata(); + requestMetadata.put(REQUEST_METADATA_KEY, REQUEST_METADATA_VALUE); + AtomicReference headersCapture = new AtomicReference<>(); + AtomicReference trailersCapture = new AtomicReference<>(); + SimpleServiceGrpc.SimpleServiceBlockingStub stub = + SimpleServiceGrpc.newBlockingStub(channel) + .withInterceptors( + MetadataUtils.newAttachHeadersInterceptor(requestMetadata), + MetadataUtils.newCaptureMetadataInterceptor(headersCapture, trailersCapture)); + + for (int i = 0; i < RPC_COUNT; i++) { + SimpleResponse response = + stub.withDeadlineAfter(10, TimeUnit.SECONDS) + .unaryRpc(SimpleRequest.getDefaultInstance()); + assertEquals(SimpleResponse.getDefaultInstance(), response); + assertNotNull(headersCapture.get()); + assertEquals(RESPONSE_METADATA_VALUE, headersCapture.get().get(RESPONSE_METADATA_KEY)); + } + } + + private static final class SimpleServiceImpl extends SimpleServiceGrpc.SimpleServiceImplBase { + @Override + public void unaryRpc(SimpleRequest request, StreamObserver responseObserver) { + responseObserver.onNext(SimpleResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + } +} diff --git a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java index 5a9875c3cc1..4351889d677 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -118,6 +118,13 @@ public void neverIndexMetadataKeysRejectsNullElementWithoutPartialMutation() thr assertThat((Set) field.get(builder)).isEmpty(); } + @Test + public void disableHpackDynamicTableIsFluent() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + assertThat(builder.disableHpackDynamicTable()).isSameInstanceAs(builder); + } + private void shutdown(ManagedChannel mc) throws Exception { mc.shutdownNow(); assertTrue(mc.awaitTermination(1, TimeUnit.SECONDS)); diff --git a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java index 73050bc93d7..8d4eb4cd37d 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java @@ -130,6 +130,7 @@ public class NettyClientHandlerTest extends NettyHandlerTestBase() { @Override @@ -230,6 +233,31 @@ public Void answer(InvocationOnMock invocation) throws Throwable { channel().releaseOutbound(); } + @Test + public void shouldAdvertiseZeroHpackDynamicTable() throws Exception { + ArgumentCaptor captor = ArgumentCaptor.forClass(Http2Settings.class); + verifyWrite().writeSettings( + any(ChannelHandlerContext.class), captor.capture(), any(ChannelPromise.class)); + + assertThat(captor.getValue().headerTableSize()).isEqualTo(0); + assertThat(frameReader().configuration().headersConfiguration().maxHeaderTableSize()) + .isEqualTo(4096); + + channelRead(serializeSettingsAck()); + + assertThat(frameReader().configuration().headersConfiguration().maxHeaderTableSize()) + .isEqualTo(0); + } + + @Test + public void shouldNotAdvertiseHpackDynamicTableSizeByDefault() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Http2Settings.class); + verifyWrite().writeSettings( + any(ChannelHandlerContext.class), captor.capture(), any(ChannelPromise.class)); + + assertThat(captor.getValue().headerTableSize()).isNull(); + } + @Test @SuppressWarnings("InlineMeInliner") public void sendLargerThanSoftLimitHeaderMayFail() throws Exception { @@ -1209,6 +1237,7 @@ public Stopwatch get() { mockKeepAliveManager, false, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, stopwatchSupplier, diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index 8cb5cca9861..5089d098b15 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -238,6 +238,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), + false, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -515,6 +516,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), + false, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1150,6 +1152,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), + false, maxMsgSize, maxHeaderListSize, maxHeaderListSize, @@ -1200,6 +1203,7 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), + false, DEFAULT_MAX_MESSAGE_SIZE, maxHeaderListSize, maxHeaderListSize, diff --git a/netty/src/test/java/io/grpc/netty/NettyHandlerTestBase.java b/netty/src/test/java/io/grpc/netty/NettyHandlerTestBase.java index c971294fbb6..66082f91334 100644 --- a/netty/src/test/java/io/grpc/netty/NettyHandlerTestBase.java +++ b/netty/src/test/java/io/grpc/netty/NettyHandlerTestBase.java @@ -322,6 +322,12 @@ protected final ByteBuf serializeSettings(Http2Settings settings) { return captureWrite(ctx); } + protected final ByteBuf serializeSettingsAck() { + ChannelHandlerContext ctx = newMockContext(); + new DefaultHttp2FrameWriter().writeSettingsAck(ctx, newPromise()); + return captureWrite(ctx); + } + protected final ByteBuf windowUpdate(int streamId, int delta) { ChannelHandlerContext ctx = newMockContext(); new DefaultHttp2FrameWriter().writeWindowUpdate(ctx, streamId, delta, newPromise()); diff --git a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java index 54e305d1e20..c702b5ca1a0 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -100,6 +100,11 @@ private static Set neverIndexedMetadataKeys(NettyServerBuilder buil return (Set) field.get(builder); } + @Test + public void disableHpackDynamicTableIsFluent() { + assertThat(builder.disableHpackDynamicTable()).isSameInstanceAs(builder); + } + @Test public void addMultipleListenAddresses() { builder.addListenAddress(new InetSocketAddress(8081)); diff --git a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java index 84a1a48b37f..a4dcb3b55da 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java @@ -138,6 +138,7 @@ public class NettyServerHandlerTest extends NettyHandlerTestBase captor = ArgumentCaptor.forClass(Http2Settings.class); + verifyWrite().writeSettings( + any(ChannelHandlerContext.class), captor.capture(), any(ChannelPromise.class)); + assertEquals(0, captor.getValue().headerTableSize().longValue()); + assertEquals(4096, + frameReader().configuration().headersConfiguration().maxHeaderTableSize()); + + channelRead(serializeSettingsAck()); + + assertEquals(0, frameReader().configuration().headersConfiguration().maxHeaderTableSize()); } @Test @@ -1425,6 +1444,7 @@ protected NettyServerHandler newHandler() { maxConcurrentStreams, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, DEFAULT_MAX_MESSAGE_SIZE, diff --git a/netty/src/test/java/io/grpc/netty/NettyServerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerTest.java index 0acdb4c08d0..2ffeb01fb46 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerTest.java @@ -150,6 +150,7 @@ class NoHandlerProtocolNegotiator implements ProtocolNegotiator { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -208,6 +209,7 @@ public void multiPortStartStopGet() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -289,6 +291,7 @@ public void multiPortConnections() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -358,6 +361,7 @@ public void getPort_notStarted() { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -440,6 +444,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -495,6 +500,7 @@ public void channelzListenSocket() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -644,6 +650,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { false, // ignore 1, // ignore Collections.emptySet(), + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore From 349b4eccd7ffd87014c9fb934ee477ebcbc762b3 Mon Sep 17 00:00:00 2001 From: Peter Marsh Date: Thu, 6 Aug 2026 16:49:29 +0200 Subject: [PATCH 2/3] okhttp: Honor peer HPACK table-size settings Apply SETTINGS_HEADER_TABLE_SIZE to the outbound HPACK writer before acknowledging it, so the next header block emits the required dynamic table size update. Stop applying the peer encoder setting to the inbound decoder. Add framed unit coverage and bidirectional OkHttp-Netty regression tests. The tests verify that repeated calls remain on one transport. AI assistance: OpenAI Codex (GPT-5) was used to implement and test this grpc-okhttp compatibility fix. --- .../HpackDynamicTableInteropTest.java | 149 ++++++++++++++++++ .../io/grpc/okhttp/internal/framed/Hpack.java | 9 +- .../io/grpc/okhttp/internal/framed/Http2.java | 7 +- .../okhttp/internal/framed/Http2Test.java | 45 ++++++ 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java new file mode 100644 index 00000000000..b9c8983ccbc --- /dev/null +++ b/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.testing.integration; + +import static com.google.common.truth.Truth.assertThat; + +import io.grpc.Attributes; +import io.grpc.InsecureServerCredentials; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerTransportFilter; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.okhttp.OkHttpChannelBuilder; +import io.grpc.okhttp.OkHttpServerBuilder; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Interoperability tests for disabling the HPACK dynamic table. */ +@RunWith(JUnit4.class) +public final class HpackDynamicTableInteropTest { + private static final int CALL_COUNT = 3; + private static final String REQUEST_METADATA_VALUE = "repeated-request-metadata-value"; + private static final String RESPONSE_METADATA_VALUE = "repeated-response-metadata-value"; + private static final Metadata.Key REQUEST_METADATA_KEY = + Metadata.Key.of("hpack-request-metadata", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key RESPONSE_METADATA_KEY = + Metadata.Key.of("hpack-response-metadata", Metadata.ASCII_STRING_MARSHALLER); + private static final EmptyProtos.Empty EMPTY = EmptyProtos.Empty.getDefaultInstance(); + + @Rule public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + private final AtomicInteger serverTransportCount = new AtomicInteger(); + private final AtomicInteger requestsWithExpectedMetadata = new AtomicInteger(); + + @Test + public void defaultOkHttpClient_interoperatesWithDisabledNettyServer() throws Exception { + Server server = startServer( + NettyServerBuilder.forPort(0, InsecureServerCredentials.create()) + .disableHpackDynamicTable()); + ManagedChannel channel = grpcCleanup.register( + OkHttpChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .build()); + + makeRepeatedCalls(channel); + } + + @Test + public void disabledNettyClient_interoperatesWithDefaultOkHttpServer() throws Exception { + Server server = startServer( + OkHttpServerBuilder.forPort(0, InsecureServerCredentials.create())); + ManagedChannel channel = grpcCleanup.register( + NettyChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .disableHpackDynamicTable() + .build()); + + makeRepeatedCalls(channel); + } + + private Server startServer(ServerBuilder serverBuilder) throws Exception { + Metadata responseMetadata = new Metadata(); + responseMetadata.put(RESPONSE_METADATA_KEY, RESPONSE_METADATA_VALUE); + + Server server = serverBuilder + .addTransportFilter(new ServerTransportFilter() { + @Override + public Attributes transportReady(Attributes transportAttrs) { + serverTransportCount.incrementAndGet(); + return transportAttrs; + } + }) + .addService(ServerInterceptors.intercept( + new TestService(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + if (REQUEST_METADATA_VALUE.equals(headers.get(REQUEST_METADATA_KEY))) { + requestsWithExpectedMetadata.incrementAndGet(); + } + return next.startCall(call, headers); + } + }, + MetadataUtils.newAttachMetadataServerInterceptor(responseMetadata))) + .build(); + return grpcCleanup.register(server).start(); + } + + private void makeRepeatedCalls(ManagedChannel channel) { + Metadata requestMetadata = new Metadata(); + requestMetadata.put(REQUEST_METADATA_KEY, REQUEST_METADATA_VALUE); + AtomicReference responseHeaders = new AtomicReference<>(); + AtomicReference responseTrailers = new AtomicReference<>(); + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(channel) + .withInterceptors( + MetadataUtils.newAttachHeadersInterceptor(requestMetadata), + MetadataUtils.newCaptureMetadataInterceptor(responseHeaders, responseTrailers)); + + for (int i = 0; i < CALL_COUNT; i++) { + assertThat(stub.withDeadlineAfter(10, TimeUnit.SECONDS).emptyCall(EMPTY)).isEqualTo(EMPTY); + assertThat(responseHeaders.get()).isNotNull(); + assertThat(responseHeaders.get().get(RESPONSE_METADATA_KEY)) + .isEqualTo(RESPONSE_METADATA_VALUE); + } + assertThat(requestsWithExpectedMetadata.get()).isEqualTo(CALL_COUNT); + assertThat(serverTransportCount.get()).isEqualTo(1); + } + + private static final class TestService extends TestServiceGrpc.TestServiceImplBase { + @Override + public void emptyCall( + EmptyProtos.Empty request, StreamObserver responseObserver) { + responseObserver.onNext(EMPTY); + responseObserver.onCompleted(); + } + } +} diff --git a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java index 3155d6d533a..c275a27d08c 100644 --- a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java +++ b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java @@ -155,10 +155,11 @@ int maxDynamicTableByteCount() { } /** - * Called by the reader when the peer sent {@link Settings#HEADER_TABLE_SIZE}. - * While this establishes the maximum dynamic table size, the - * {@link #maxDynamicTableByteCount} set during processing may limit the - * table size to a smaller amount. + * Updates the limit for header blocks received from the peer. This corresponds to a + * {@link Settings#HEADER_TABLE_SIZE} advertised by the local endpoint, not one received from + * the peer. While this establishes the maximum dynamic table size, the + * {@link #maxDynamicTableByteCount} set during processing may limit the table size to a smaller + * amount. *

Evicts entries or clears the table as needed. */ void headerTableSizeSetting(int headerTableSizeSetting) { diff --git a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Http2.java b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Http2.java index 0eb49b9f076..e2a5e0ab9ef 100644 --- a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Http2.java +++ b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Http2.java @@ -312,9 +312,6 @@ private void readSettings(Handler handler, int length, byte flags, int streamId) settings.set(id, 0, value); } handler.settings(false, settings); - if (settings.getHeaderTableSize() >= 0) { - hpackReader.headerTableSizeSetting(settings.getHeaderTableSize()); - } } private void readPushPromise(Handler handler, int length, byte flags, int streamId) @@ -397,6 +394,10 @@ static final class Writer implements io.grpc.okhttp.internal.framed.FrameWriter @Override public synchronized void ackSettings(io.grpc.okhttp.internal.framed.Settings peerSettings) throws IOException { if (closed) throw new IOException("closed"); this.maxFrameSize = peerSettings.getMaxFrameSize(maxFrameSize); + int headerTableSize = peerSettings.getHeaderTableSize(); + if (headerTableSize >= 0) { + hpackWriter.resizeHeaderTable(headerTableSize); + } int length = 0; byte type = TYPE_SETTINGS; byte flags = FLAG_ACK; diff --git a/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/Http2Test.java b/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/Http2Test.java index 5631a18515d..132452254ea 100644 --- a/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/Http2Test.java +++ b/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/Http2Test.java @@ -20,6 +20,7 @@ import static io.grpc.okhttp.internal.framed.Http2.FLAG_PADDED; import static io.grpc.okhttp.internal.framed.Http2.TYPE_DATA; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -75,6 +76,50 @@ public void dataFramePadding() throws IOException { assertEquals(2037 - 125, bufferIn.size()); } + @Test + public void ackSettingsHeaderTableSizeZeroUpdatesWriter() throws IOException { + Buffer sink = new Buffer(); + Http2.Writer writer = new Http2.Writer(sink, true); + Settings settings = new Settings().set(Settings.HEADER_TABLE_SIZE, 0, 0); + + writer.ackSettings(settings); + assertEquals(9, sink.size()); + sink.skip(9); // SETTINGS ACK frame. + + writer.headers(false, 3, Arrays.asList(new Header("custom-key", "custom-value"))); + sink.skip(9); // HEADERS frame header. + + assertEquals(0x20, sink.readByte() & 0xff); // Dynamic table size update to zero. + } + + @Test + public void ackSettingsWithoutHeaderTableSizeDoesNotUpdateWriter() throws IOException { + Buffer sink = new Buffer(); + Http2.Writer writer = new Http2.Writer(sink, true); + + writer.ackSettings(new Settings()); + assertEquals(9, sink.size()); + sink.skip(9); // SETTINGS ACK frame. + + writer.headers(false, 3, Arrays.asList(new Header("custom-key", "custom-value"))); + sink.skip(9); // HEADERS frame header. + + assertEquals(0x40, sink.readByte() & 0xff); // Literal with incremental indexing. + } + + @Test + public void peerHeaderTableSizeDoesNotChangeInboundDecoder() throws IOException { + Buffer frames = new Buffer(); + Http2.Writer peerWriter = new Http2.Writer(frames, false); + peerWriter.settings(new Settings().set(Settings.HEADER_TABLE_SIZE, 0, 0)); + Http2.Reader reader = new Http2.Reader(frames, 4096, true); + + assertTrue(reader.nextFrame(mockHandler)); + + // The peer's setting limits our encoder; it does not limit decoding the peer's headers. + assertEquals(4096, reader.hpackReader.maxDynamicTableByteCount()); + } + private Buffer createData(int flag, int length, int paddingLength) throws IOException { Buffer sink = new Buffer(); writeLength(sink, length); From 4fbbd76bf9e5c3943d7e406da6d8533abaa6bada Mon Sep 17 00:00:00 2001 From: Peter Marsh Date: Fri, 14 Aug 2026 00:03:29 +0200 Subject: [PATCH 3/3] netty: Make HPACK dynamic table size configurable Replace the disable-only Netty client and server options with a byte-size configuration. Keep the default at 4096 bytes, allow zero to disable the dynamic table, and reject negative values. Cap the encoder at the configured size and advertise non-default sizes to the peer. Co-authored-by: Codex --- .../HpackDynamicTableInteropTest.java | 10 +++--- .../grpc/netty/GrpcHttp2HeadersEncoder.java | 18 ++++++----- .../io/grpc/netty/NettyChannelBuilder.java | 30 +++++++++--------- .../io/grpc/netty/NettyClientHandler.java | 12 +++---- .../io/grpc/netty/NettyClientTransport.java | 8 ++--- .../main/java/io/grpc/netty/NettyServer.java | 8 ++--- .../io/grpc/netty/NettyServerBuilder.java | 20 ++++++------ .../io/grpc/netty/NettyServerHandler.java | 12 +++---- .../io/grpc/netty/NettyServerTransport.java | 8 ++--- .../netty/GrpcHttp2HeadersEncoderTest.java | 26 ++++++++++++++-- .../netty/HpackDynamicTableInteropTest.java | 17 +++++----- .../grpc/netty/NettyChannelBuilderTest.java | 11 +++++-- .../io/grpc/netty/NettyClientHandlerTest.java | 31 ++++++++++++++++--- .../grpc/netty/NettyClientTransportTest.java | 8 ++--- .../io/grpc/netty/NettyServerBuilderTest.java | 9 ++++-- .../io/grpc/netty/NettyServerHandlerTest.java | 26 ++++++++++++++-- .../java/io/grpc/netty/NettyServerTest.java | 14 ++++----- 17 files changed, 172 insertions(+), 96 deletions(-) diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java index b9c8983ccbc..3dfe33aac2c 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/HpackDynamicTableInteropTest.java @@ -44,7 +44,7 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -/** Interoperability tests for disabling the HPACK dynamic table. */ +/** Interoperability tests for configuring the HPACK dynamic table. */ @RunWith(JUnit4.class) public final class HpackDynamicTableInteropTest { private static final int CALL_COUNT = 3; @@ -62,10 +62,10 @@ public final class HpackDynamicTableInteropTest { private final AtomicInteger requestsWithExpectedMetadata = new AtomicInteger(); @Test - public void defaultOkHttpClient_interoperatesWithDisabledNettyServer() throws Exception { + public void defaultOkHttpClient_interoperatesWithZeroTableNettyServer() throws Exception { Server server = startServer( NettyServerBuilder.forPort(0, InsecureServerCredentials.create()) - .disableHpackDynamicTable()); + .hpackDynamicTableSize(0)); ManagedChannel channel = grpcCleanup.register( OkHttpChannelBuilder.forAddress("localhost", server.getPort()) .usePlaintext() @@ -75,13 +75,13 @@ public void defaultOkHttpClient_interoperatesWithDisabledNettyServer() throws Ex } @Test - public void disabledNettyClient_interoperatesWithDefaultOkHttpServer() throws Exception { + public void zeroTableNettyClient_interoperatesWithDefaultOkHttpServer() throws Exception { Server server = startServer( OkHttpServerBuilder.forPort(0, InsecureServerCredentials.create())); ManagedChannel channel = grpcCleanup.register( NettyChannelBuilder.forAddress("localhost", server.getPort()) .usePlaintext() - .disableHpackDynamicTable() + .hpackDynamicTableSize(0) .build()); makeRepeatedCalls(channel); diff --git a/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java index 38b705576b1..3e692452311 100644 --- a/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java @@ -17,31 +17,33 @@ package io.grpc.netty; import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2CodecUtil; import io.netty.handler.codec.http2.Http2Exception; import io.netty.handler.codec.http2.Http2HeadersEncoder; /** HTTP/2 headers encoder with gRPC's HPACK configuration. */ final class GrpcHttp2HeadersEncoder extends DefaultHttp2HeadersEncoder { + static final int DEFAULT_DYNAMIC_TABLE_SIZE = Http2CodecUtil.DEFAULT_HEADER_TABLE_SIZE; private static final int DEFAULT_DYNAMIC_TABLE_ARRAY_SIZE_HINT = 16; private static final int MIN_DYNAMIC_TABLE_ARRAY_SIZE_HINT = 2; - private final boolean disableDynamicTable; + private final int configuredMaxDynamicTableSize; GrpcHttp2HeadersEncoder( Http2HeadersEncoder.SensitivityDetector sensitivityDetector, - boolean disableDynamicTable) { + int configuredMaxDynamicTableSize) { super( sensitivityDetector, false, - disableDynamicTable + configuredMaxDynamicTableSize == 0 ? MIN_DYNAMIC_TABLE_ARRAY_SIZE_HINT : DEFAULT_DYNAMIC_TABLE_ARRAY_SIZE_HINT, Integer.MAX_VALUE); - this.disableDynamicTable = disableDynamicTable; - if (disableDynamicTable) { + this.configuredMaxDynamicTableSize = configuredMaxDynamicTableSize; + if (configuredMaxDynamicTableSize < DEFAULT_DYNAMIC_TABLE_SIZE) { try { - super.maxHeaderTableSize(0); + super.maxHeaderTableSize(configuredMaxDynamicTableSize); } catch (Http2Exception e) { - // Zero is always a valid HPACK dynamic table size. + // Non-negative configured sizes are valid HPACK dynamic table sizes. throw new AssertionError(e); } } @@ -49,6 +51,6 @@ final class GrpcHttp2HeadersEncoder extends DefaultHttp2HeadersEncoder { @Override public void maxHeaderTableSize(long max) throws Http2Exception { - super.maxHeaderTableSize(disableDynamicTable ? 0 : max); + super.maxHeaderTableSize(Math.min(configuredMaxDynamicTableSize, max)); } } diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java index b6ab15d1b89..c09feb32050 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -110,7 +110,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 neverIndexedMetadataKeys = new HashSet<>(); - private boolean disableHpackDynamicTable; + private int hpackDynamicTableSize = GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE; private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; @@ -478,17 +478,17 @@ public NettyChannelBuilder neverIndexMetadataKeys( } /** - * Disables use of the HPACK dynamic table for HTTP/2 header compression. + * Sets the maximum HPACK dynamic table size, in bytes, for both directions of a connection. + * The peer may advertise a smaller maximum for headers encoded by this endpoint. A value of + * zero disables the dynamic table while retaining HPACK static-table references and Huffman + * encoding. By default, HTTP/2's standard 4 KiB capacity is used. * - *

HPACK itself remains enabled, as required by HTTP/2. Static table references may still be - * used. Disabling the dynamic table reduces per-connection memory usage, but can increase the - * size of header blocks. The inbound dynamic table is disabled after the peer acknowledges the - * corresponding HTTP/2 setting, and requires a peer that correctly implements that setting. By - * default, the dynamic table is enabled. + * @throws IllegalArgumentException if {@code bytes} is negative */ @CanIgnoreReturnValue - public NettyChannelBuilder disableHpackDynamicTable() { - disableHpackDynamicTable = true; + public NettyChannelBuilder hpackDynamicTableSize(int bytes) { + checkArgument(bytes >= 0, "hpackDynamicTableSize must not be negative: %s", bytes); + hpackDynamicTableSize = bytes; return this; } @@ -685,7 +685,7 @@ ClientTransportFactory buildTransportFactory() { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -830,7 +830,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -853,7 +853,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -873,7 +873,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Collections.unmodifiableSet( new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -924,7 +924,7 @@ public void run() { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -965,7 +965,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java index bbb31017b10..537bec70589 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -161,7 +161,7 @@ static NettyClientHandler newHandler( boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -176,7 +176,7 @@ static NettyClientHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder( - sensitivityDetector(neverIndexedMetadataKeys), disableHpackDynamicTable); + sensitivityDetector(neverIndexedMetadataKeys), hpackDynamicTableSize); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -193,7 +193,7 @@ static NettyClientHandler newHandler( keepAliveManager, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, stopwatchFactory, @@ -215,7 +215,7 @@ static NettyClientHandler newHandler( KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -263,8 +263,8 @@ static NettyClientHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(0); settings.maxHeaderListSize(maxHeaderListSize); - if (disableHpackDynamicTable) { - settings.headerTableSize(0); + if (hpackDynamicTableSize != GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE) { + settings.headerTableSize(hpackDynamicTableSize); } return new NettyClientHandler( diff --git a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java index 42647a1f166..50efca8ad4d 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -87,7 +87,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -124,7 +124,7 @@ class NettyClientTransport implements ConnectionClientTransport, boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -152,7 +152,7 @@ class NettyClientTransport implements ConnectionClientTransport, this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -256,7 +256,7 @@ public Runnable start(Listener transportListener) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, GrpcUtil.STOPWATCH_SUPPLIER, diff --git a/netty/src/main/java/io/grpc/netty/NettyServer.java b/netty/src/main/java/io/grpc/netty/NettyServer.java index 31f4107cde5..b2aece6f958 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServer.java +++ b/netty/src/main/java/io/grpc/netty/NettyServer.java @@ -95,7 +95,7 @@ class NettyServer implements InternalServer, InternalWithLogId { private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -135,7 +135,7 @@ class NettyServer implements InternalServer, InternalWithLogId { boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -169,7 +169,7 @@ class NettyServer implements InternalServer, InternalWithLogId { this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Collections.unmodifiableSet( new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -276,7 +276,7 @@ public void initChannel(Channel ch) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java index 07bd98d14af..c5c2543f3a8 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java @@ -111,7 +111,7 @@ public final class NettyServerBuilder extends ForwardingServerBuilder neverIndexedMetadataKeys = new HashSet<>(); - private boolean disableHpackDynamicTable; + private int hpackDynamicTableSize = GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE; private int maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE; private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; @@ -485,17 +485,17 @@ public NettyServerBuilder neverIndexMetadataKeys( } /** - * Disables use of the HPACK dynamic table for HTTP/2 header compression. + * Sets the maximum HPACK dynamic table size, in bytes, for both directions of a connection. + * The peer may advertise a smaller maximum for headers encoded by this endpoint. A value of + * zero disables the dynamic table while retaining HPACK static-table references and Huffman + * encoding. By default, HTTP/2's standard 4 KiB capacity is used. * - *

HPACK itself remains enabled, as required by HTTP/2. Static table references may still be - * used. Disabling the dynamic table reduces per-connection memory usage, but can increase the - * size of header blocks. The inbound dynamic table is disabled after the peer acknowledges the - * corresponding HTTP/2 setting, and requires a peer that correctly implements that setting. By - * default, the dynamic table is enabled. + * @throws IllegalArgumentException if {@code bytes} is negative */ @CanIgnoreReturnValue - public NettyServerBuilder disableHpackDynamicTable() { - disableHpackDynamicTable = true; + public NettyServerBuilder hpackDynamicTableSize(int bytes) { + checkArgument(bytes >= 0, "hpackDynamicTableSize must not be negative: %s", bytes); + hpackDynamicTableSize = bytes; return this; } @@ -789,7 +789,7 @@ NettyServer buildTransportServers( autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java index c88394b5f3e..68d4b516327 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java @@ -165,7 +165,7 @@ static NettyServerHandler newHandler( boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -188,7 +188,7 @@ static NettyServerHandler newHandler( new DefaultHttp2FrameReader(headersDecoder), frameLogger); Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder( NettyClientHandler.sensitivityDetector(neverIndexedMetadataKeys), - disableHpackDynamicTable); + hpackDynamicTableSize); Http2FrameWriter frameWriter = new Http2OutboundFrameLogger(new DefaultHttp2FrameWriter(encoder), frameLogger); return newHandler( @@ -201,7 +201,7 @@ static NettyServerHandler newHandler( maxStreams, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, @@ -229,7 +229,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -287,8 +287,8 @@ static NettyServerHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(maxStreams); settings.maxHeaderListSize(maxHeaderListSize); - if (disableHpackDynamicTable) { - settings.headerTableSize(0); + if (hpackDynamicTableSize != GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE) { + settings.headerTableSize(hpackDynamicTableSize); } return new NettyServerHandler( diff --git a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java index 12c47c8f25a..ab7a4265a7a 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java @@ -72,7 +72,7 @@ class NettyServerTransport implements ServerTransport { private final boolean autoFlowControl; private final int flowControlWindow; private final Set neverIndexedMetadataKeys; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -100,7 +100,7 @@ class NettyServerTransport implements ServerTransport { boolean autoFlowControl, int flowControlWindow, Set neverIndexedMetadataKeys, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -126,7 +126,7 @@ class NettyServerTransport implements ServerTransport { this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -291,7 +291,7 @@ private NettyServerHandler createHandler( autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, diff --git a/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java index afbd981ed22..81cb9db8fe7 100644 --- a/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java +++ b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java @@ -37,7 +37,9 @@ public class GrpcHttp2HeadersEncoderTest { @Test public void dynamicTableEnabledByDefault() throws Exception { GrpcHttp2HeadersEncoder encoder = - new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, false); + new GrpcHttp2HeadersEncoder( + Http2HeadersEncoder.NEVER_SENSITIVE, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE); ByteBuf first = Unpooled.buffer(); ByteBuf second = Unpooled.buffer(); try { @@ -58,7 +60,7 @@ public void dynamicTableEnabledByDefault() throws Exception { @Test public void dynamicTableDisabledPermanently_staticTableStillUsed() throws Exception { GrpcHttp2HeadersEncoder encoder = - new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, true); + new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, 0); DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder(); ByteBuf first = Unpooled.buffer(); ByteBuf second = Unpooled.buffer(); @@ -89,4 +91,24 @@ public void dynamicTableDisabledPermanently_staticTableStillUsed() throws Except encoder.close(); } } + + @Test + public void configuredDynamicTableSizeCapsPeerSetting() throws Exception { + GrpcHttp2HeadersEncoder encoder = + new GrpcHttp2HeadersEncoder(Http2HeadersEncoder.NEVER_SENSITIVE, 8192); + try { + assertThat(encoder.maxHeaderTableSize()).isEqualTo(4096); + + encoder.maxHeaderTableSize(8192); + assertThat(encoder.maxHeaderTableSize()).isEqualTo(8192); + + encoder.maxHeaderTableSize(16384); + assertThat(encoder.maxHeaderTableSize()).isEqualTo(8192); + + encoder.maxHeaderTableSize(2048); + assertThat(encoder.maxHeaderTableSize()).isEqualTo(2048); + } finally { + encoder.close(); + } + } } diff --git a/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java b/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java index db516283892..be91c2d6dd1 100644 --- a/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java +++ b/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java @@ -48,18 +48,19 @@ public class HpackDynamicTableInteropTest { private static final String REQUEST_METADATA_VALUE = "repeated-request-metadata-value"; private static final String RESPONSE_METADATA_VALUE = "repeated-response-metadata-value"; - @Parameters(name = "clientDisabled={0}, serverDisabled={1}") + @Parameters(name = "clientTableSize={0}, serverTableSize={1}") public static Iterable data() { return Arrays.asList(new Object[][] { - {false, false}, {false, true}, {true, false}, {true, true} + {4096, 4096}, {4096, 8192}, {8192, 4096}, {8192, 8192}, + {0, 4096}, {4096, 0}, {0, 0} }); } @Parameter(0) - public boolean clientDisabled; + public int clientTableSize; @Parameter(1) - public boolean serverDisabled; + public int serverTableSize; private Server server; private ManagedChannel channel; @@ -85,17 +86,13 @@ public void unaryRpcInteroperates() throws Exception { ServerInterceptors.intercept( new SimpleServiceImpl(), MetadataUtils.newAttachMetadataServerInterceptor(responseMetadata))); - if (serverDisabled) { - serverBuilder.disableHpackDynamicTable(); - } + serverBuilder.hpackDynamicTableSize(serverTableSize); server = serverBuilder.build().start(); NettyChannelBuilder channelBuilder = NettyChannelBuilder .forAddress("localhost", server.getPort()) .usePlaintext(); - if (clientDisabled) { - channelBuilder.disableHpackDynamicTable(); - } + channelBuilder.hpackDynamicTableSize(clientTableSize); channel = channelBuilder.build(); Metadata requestMetadata = new Metadata(); diff --git a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java index 4351889d677..566a5d35b95 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -119,10 +119,17 @@ public void neverIndexMetadataKeysRejectsNullElementWithoutPartialMutation() thr } @Test - public void disableHpackDynamicTableIsFluent() { + public void hpackDynamicTableSizeAllowsZeroAndIsFluent() { NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); - assertThat(builder.disableHpackDynamicTable()).isSameInstanceAs(builder); + assertThat(builder.hpackDynamicTableSize(0)).isSameInstanceAs(builder); + } + + @Test + public void hpackDynamicTableSizeRejectsNegative() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + assertThrows(IllegalArgumentException.class, () -> builder.hpackDynamicTableSize(-1)); } private void shutdown(ManagedChannel mc) throws Exception { diff --git a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java index 8d4eb4cd37d..fafeecc695a 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java @@ -130,7 +130,7 @@ public class NettyClientHandlerTest extends NettyHandlerTestBase() { @Override @@ -249,6 +254,24 @@ public void shouldAdvertiseZeroHpackDynamicTable() throws Exception { .isEqualTo(0); } + @Test + public void shouldAdvertiseEightKiBHpackDynamicTable() throws Exception { + ArgumentCaptor captor = ArgumentCaptor.forClass(Http2Settings.class); + verifyWrite().writeSettings( + any(ChannelHandlerContext.class), captor.capture(), any(ChannelPromise.class)); + + assertThat(captor.getValue().headerTableSize()).isEqualTo(8192); + assertThat(frameReader().configuration().headersConfiguration().maxHeaderTableSize()) + .isEqualTo(4096); + + channelRead(serializeSettingsAck()); + + // The allowed maximum is now 8 KiB, but Netty reports the current capacity. It remains at the + // RFC default until the peer sends an HPACK dynamic table size update. + assertThat(frameReader().configuration().headersConfiguration().maxHeaderTableSize()) + .isEqualTo(4096); + } + @Test public void shouldNotAdvertiseHpackDynamicTableSizeByDefault() { ArgumentCaptor captor = ArgumentCaptor.forClass(Http2Settings.class); @@ -1237,7 +1260,7 @@ public Stopwatch get() { mockKeepAliveManager, false, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, stopwatchSupplier, diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index 5089d098b15..d16593e8432 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -238,7 +238,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -516,7 +516,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1152,7 +1152,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, maxMsgSize, maxHeaderListSize, maxHeaderListSize, @@ -1203,7 +1203,7 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, DEFAULT_MAX_MESSAGE_SIZE, maxHeaderListSize, maxHeaderListSize, diff --git a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java index c702b5ca1a0..09429dbd3a9 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -101,8 +101,13 @@ private static Set neverIndexedMetadataKeys(NettyServerBuilder buil } @Test - public void disableHpackDynamicTableIsFluent() { - assertThat(builder.disableHpackDynamicTable()).isSameInstanceAs(builder); + public void hpackDynamicTableSizeAllowsZeroAndIsFluent() { + assertThat(builder.hpackDynamicTableSize(0)).isSameInstanceAs(builder); + } + + @Test + public void hpackDynamicTableSizeRejectsNegative() { + assertThrows(IllegalArgumentException.class, () -> builder.hpackDynamicTableSize(-1)); } @Test diff --git a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java index a4dcb3b55da..783ebc03148 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java @@ -138,7 +138,7 @@ public class NettyServerHandlerTest extends NettyHandlerTestBase captor = ArgumentCaptor.forClass(Http2Settings.class); @@ -493,6 +493,26 @@ public void shouldAdvertiseZeroHpackDynamicTable() throws Exception { assertEquals(0, frameReader().configuration().headersConfiguration().maxHeaderTableSize()); } + @Test + public void shouldAdvertiseEightKiBHpackDynamicTable() throws Exception { + hpackDynamicTableSize = 8192; + manualSetUp(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Http2Settings.class); + verifyWrite().writeSettings( + any(ChannelHandlerContext.class), captor.capture(), any(ChannelPromise.class)); + assertEquals(8192, captor.getValue().headerTableSize().longValue()); + assertEquals(4096, + frameReader().configuration().headersConfiguration().maxHeaderTableSize()); + + channelRead(serializeSettingsAck()); + + // The allowed maximum is now 8 KiB, but Netty reports the current capacity. It remains at the + // RFC default until the peer sends an HPACK dynamic table size update. + assertEquals(4096, + frameReader().configuration().headersConfiguration().maxHeaderTableSize()); + } + @Test public void connectionWindowShouldBeOverridden() throws Exception { flowControlWindow = 1048576; // 1MiB @@ -1444,7 +1464,7 @@ protected NettyServerHandler newHandler() { maxConcurrentStreams, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, DEFAULT_MAX_MESSAGE_SIZE, diff --git a/netty/src/test/java/io/grpc/netty/NettyServerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerTest.java index 2ffeb01fb46..d55e862a5a2 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerTest.java @@ -150,7 +150,7 @@ class NoHandlerProtocolNegotiator implements ProtocolNegotiator { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -209,7 +209,7 @@ public void multiPortStartStopGet() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -291,7 +291,7 @@ public void multiPortConnections() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -361,7 +361,7 @@ public void getPort_notStarted() { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -444,7 +444,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -500,7 +500,7 @@ public void channelzListenSocket() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -650,7 +650,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { false, // ignore 1, // ignore Collections.emptySet(), - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore