From eec3174dc8e885f2a0e7e9b1456875ee986eddbb 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. --- .../grpc/netty/GrpcHttp2HeadersEncoder.java | 52 +++++++ .../io/grpc/netty/NettyChannelBuilder.java | 22 +++ .../io/grpc/netty/NettyClientHandler.java | 10 +- .../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 | 10 +- .../io/grpc/netty/NettyServerTransport.java | 4 + .../netty/GrpcHttp2HeadersEncoderTest.java | 89 ++++++++++++ .../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, 412 insertions(+), 6 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..ce747dbb8f2 --- /dev/null +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java @@ -0,0 +1,52 @@ +/* + * 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(boolean disableDynamicTable) { + super( + Http2HeadersEncoder.NEVER_SENSITIVE, + 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 8ad67f8f14e..5932b5f4f75 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -105,6 +105,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 eventLoopGroupPool = DEFAULT_EVENT_LOOP_GROUP_POOL; private boolean autoFlowControl = DEFAULT_AUTO_FLOW_CONTROL; private int flowControlWindow = DEFAULT_FLOW_CONTROL_WINDOW; + 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; @@ -434,6 +435,21 @@ public NettyChannelBuilder flowControlWindow(int flowControlWindow) { 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 @@ -626,6 +642,7 @@ ClientTransportFactory buildTransportFactory() { eventLoopGroupPool, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -769,6 +786,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final EventLoopGroup group; private final boolean autoFlowControl; private final int flowControlWindow; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -790,6 +808,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto ObjectPool groupPool, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -807,6 +826,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.group = groupPool.getObject(); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -856,6 +876,7 @@ public void run() { localNegotiator, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -895,6 +916,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch groupPool, autoFlowControl, flowControlWindow, + 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 14a1d7535ad..6ab9d2953fb 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; @@ -158,6 +157,7 @@ static NettyClientHandler newHandler( @Nullable KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -171,8 +171,7 @@ 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( - Http2HeadersEncoder.NEVER_SENSITIVE, false, 16, Integer.MAX_VALUE); + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(disableHpackDynamicTable); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -189,6 +188,7 @@ static NettyClientHandler newHandler( keepAliveManager, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, stopwatchFactory, @@ -210,6 +210,7 @@ static NettyClientHandler newHandler( KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -257,6 +258,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 6585df42df3..1fec68bcca7 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -85,6 +85,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final AsciiString userAgent; private final boolean autoFlowControl; private final int flowControlWindow; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -120,6 +121,7 @@ class NettyClientTransport implements ConnectionClientTransport, ProtocolNegotiator negotiator, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -145,6 +147,7 @@ class NettyClientTransport implements ConnectionClientTransport, this.channelOptions = Preconditions.checkNotNull(channelOptions, "channelOptions"); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -247,6 +250,7 @@ public Runnable start(Listener transportListener) { keepAliveManager, autoFlowControl, flowControlWindow, + 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 2bb6b2c5921..baf3cd4809c 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServer.java +++ b/netty/src/main/java/io/grpc/netty/NettyServer.java @@ -91,6 +91,7 @@ class NettyServer implements InternalServer, InternalWithLogId { private final ChannelGroup channelGroup; private final boolean autoFlowControl; private final int flowControlWindow; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -129,6 +130,7 @@ class NettyServer implements InternalServer, InternalWithLogId { int maxStreamsPerConnection, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -160,6 +162,7 @@ class NettyServer implements InternalServer, InternalWithLogId { this.maxStreamsPerConnection = maxStreamsPerConnection; this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -265,6 +268,7 @@ public void initChannel(Channel ch) { maxStreamsPerConnection, autoFlowControl, flowControlWindow, + 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 4ef14b0e933..843e9cee14f 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java @@ -105,6 +105,7 @@ public final class NettyServerBuilder extends ForwardingServerBuilderHPACK 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 @@ -729,6 +745,7 @@ NettyServer buildTransportServers( maxConcurrentCallsPerConnection, autoFlowControl, flowControlWindow, + 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 58166f50f7e..d082d0c69b2 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; @@ -164,6 +163,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -184,8 +184,7 @@ static NettyServerHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ServerHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new Http2InboundFrameLogger( new DefaultHttp2FrameReader(headersDecoder), frameLogger); - Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( - Http2HeadersEncoder.NEVER_SENSITIVE, false, 16, Integer.MAX_VALUE); + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(disableHpackDynamicTable); Http2FrameWriter frameWriter = new Http2OutboundFrameLogger(new DefaultHttp2FrameWriter(encoder), frameLogger); return newHandler( @@ -198,6 +197,7 @@ static NettyServerHandler newHandler( maxStreams, autoFlowControl, flowControlWindow, + disableHpackDynamicTable, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, @@ -225,6 +225,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -282,6 +283,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 c0e52b75876..9829c3958b8 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java @@ -69,6 +69,7 @@ class NettyServerTransport implements ServerTransport { private boolean terminated; private final boolean autoFlowControl; private final int flowControlWindow; + private final boolean disableHpackDynamicTable; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -95,6 +96,7 @@ class NettyServerTransport implements ServerTransport { int maxStreams, boolean autoFlowControl, int flowControlWindow, + boolean disableHpackDynamicTable, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -118,6 +120,7 @@ class NettyServerTransport implements ServerTransport { this.maxStreams = maxStreams; this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.disableHpackDynamicTable = disableHpackDynamicTable; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -281,6 +284,7 @@ private NettyServerHandler createHandler( maxStreams, autoFlowControl, flowControlWindow, + 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..f23767cc2d8 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java @@ -0,0 +1,89 @@ +/* + * 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.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(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(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 95d54d13b82..067b1481505 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -49,6 +49,13 @@ public class NettyChannelBuilderTest { private final SslContext noSslContext = null; + @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 9f6be9a2f3e..2661c9efca7 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java @@ -128,6 +128,7 @@ public class NettyClientHandlerTest extends NettyHandlerTestBase() { @Override @@ -228,6 +231,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 { @@ -1158,6 +1186,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 ef8d2e5efda..935b0580530 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -237,6 +237,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept newNegotiator(), false, DEFAULT_WINDOW_SIZE, + false, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -513,6 +514,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { newNegotiator(), false, DEFAULT_WINDOW_SIZE, + false, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1147,6 +1149,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max negotiator, false, DEFAULT_WINDOW_SIZE, + false, maxMsgSize, maxHeaderListSize, maxHeaderListSize, @@ -1196,6 +1199,7 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, maxStreamsPerConnection, false, DEFAULT_WINDOW_SIZE, + 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 f3b73a515b5..f8ceca0f757 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -40,6 +40,11 @@ public class NettyServerBuilderTest { private NettyServerBuilder builder = NettyServerBuilder.forPort(8080); + @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 e81008d029e..72a974a29b8 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerTest.java @@ -149,6 +149,7 @@ class NoHandlerProtocolNegotiator implements ProtocolNegotiator { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -206,6 +207,7 @@ public void multiPortStartStopGet() throws Exception { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -286,6 +288,7 @@ public void multiPortConnections() throws Exception { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -354,6 +357,7 @@ public void getPort_notStarted() { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -435,6 +439,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -489,6 +494,7 @@ public void channelzListenSocket() throws Exception { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore @@ -637,6 +643,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { 1, // ignore false, // ignore 1, // ignore + false, // disableHpackDynamicTable 1, // ignore 1, // ignore 1, // ignore From 9f1747080be4168a11af40abdcdb2b0441b3a180 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 cdc56347fb48b4043da0e9ce9b08a65eadad3cdf 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 | 24 ++++++++++++-- .../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, 170 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 ce747dbb8f2..434c9707b6c 100644 --- a/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java @@ -17,29 +17,31 @@ 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(boolean disableDynamicTable) { + GrpcHttp2HeadersEncoder(int configuredMaxDynamicTableSize) { super( Http2HeadersEncoder.NEVER_SENSITIVE, 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); } } @@ -47,6 +49,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 5932b5f4f75..17aa2402303 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -105,7 +105,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 eventLoopGroupPool = DEFAULT_EVENT_LOOP_GROUP_POOL; private boolean autoFlowControl = DEFAULT_AUTO_FLOW_CONTROL; private int flowControlWindow = DEFAULT_FLOW_CONTROL_WINDOW; - 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; @@ -436,17 +436,17 @@ public NettyChannelBuilder flowControlWindow(int flowControlWindow) { } /** - * 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; } @@ -642,7 +642,7 @@ ClientTransportFactory buildTransportFactory() { eventLoopGroupPool, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -786,7 +786,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final EventLoopGroup group; private final boolean autoFlowControl; private final int flowControlWindow; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -808,7 +808,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto ObjectPool groupPool, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -826,7 +826,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.group = groupPool.getObject(); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -876,7 +876,7 @@ public void run() { localNegotiator, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -916,7 +916,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch groupPool, autoFlowControl, flowControlWindow, - 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 6ab9d2953fb..48e76d02ca7 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -157,7 +157,7 @@ static NettyClientHandler newHandler( @Nullable KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -171,7 +171,7 @@ static NettyClientHandler newHandler( Preconditions.checkArgument(maxHeaderListSize > 0, "maxHeaderListSize must be positive"); Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); - Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(disableHpackDynamicTable); + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(hpackDynamicTableSize); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -188,7 +188,7 @@ static NettyClientHandler newHandler( keepAliveManager, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, stopwatchFactory, @@ -210,7 +210,7 @@ static NettyClientHandler newHandler( KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -258,8 +258,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 1fec68bcca7..abc0646ce66 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -85,7 +85,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final AsciiString userAgent; private final boolean autoFlowControl; private final int flowControlWindow; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -121,7 +121,7 @@ class NettyClientTransport implements ConnectionClientTransport, ProtocolNegotiator negotiator, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -147,7 +147,7 @@ class NettyClientTransport implements ConnectionClientTransport, this.channelOptions = Preconditions.checkNotNull(channelOptions, "channelOptions"); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -250,7 +250,7 @@ public Runnable start(Listener transportListener) { keepAliveManager, autoFlowControl, flowControlWindow, - 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 baf3cd4809c..8d314fb5e6d 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServer.java +++ b/netty/src/main/java/io/grpc/netty/NettyServer.java @@ -91,7 +91,7 @@ class NettyServer implements InternalServer, InternalWithLogId { private final ChannelGroup channelGroup; private final boolean autoFlowControl; private final int flowControlWindow; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -130,7 +130,7 @@ class NettyServer implements InternalServer, InternalWithLogId { int maxStreamsPerConnection, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -162,7 +162,7 @@ class NettyServer implements InternalServer, InternalWithLogId { this.maxStreamsPerConnection = maxStreamsPerConnection; this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -268,7 +268,7 @@ public void initChannel(Channel ch) { maxStreamsPerConnection, autoFlowControl, flowControlWindow, - 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 843e9cee14f..519b0ba893c 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java @@ -105,7 +105,7 @@ public final class NettyServerBuilder extends ForwardingServerBuilderHPACK 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; } @@ -745,7 +745,7 @@ NettyServer buildTransportServers( maxConcurrentCallsPerConnection, autoFlowControl, flowControlWindow, - 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 d082d0c69b2..619dd2ff47a 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java @@ -163,7 +163,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -184,7 +184,7 @@ static NettyServerHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ServerHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new Http2InboundFrameLogger( new DefaultHttp2FrameReader(headersDecoder), frameLogger); - Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(disableHpackDynamicTable); + Http2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(hpackDynamicTableSize); Http2FrameWriter frameWriter = new Http2OutboundFrameLogger(new DefaultHttp2FrameWriter(encoder), frameLogger); return newHandler( @@ -197,7 +197,7 @@ static NettyServerHandler newHandler( maxStreams, autoFlowControl, flowControlWindow, - disableHpackDynamicTable, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, @@ -225,7 +225,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -283,8 +283,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 9829c3958b8..e9fd032c6b1 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java @@ -69,7 +69,7 @@ class NettyServerTransport implements ServerTransport { private boolean terminated; private final boolean autoFlowControl; private final int flowControlWindow; - private final boolean disableHpackDynamicTable; + private final int hpackDynamicTableSize; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -96,7 +96,7 @@ class NettyServerTransport implements ServerTransport { int maxStreams, boolean autoFlowControl, int flowControlWindow, - boolean disableHpackDynamicTable, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -120,7 +120,7 @@ class NettyServerTransport implements ServerTransport { this.maxStreams = maxStreams; this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; - this.disableHpackDynamicTable = disableHpackDynamicTable; + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -284,7 +284,7 @@ private NettyServerHandler createHandler( maxStreams, autoFlowControl, flowControlWindow, - 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 f23767cc2d8..ef953e2d690 100644 --- a/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java +++ b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java @@ -35,7 +35,8 @@ public class GrpcHttp2HeadersEncoderTest { @Test public void dynamicTableEnabledByDefault() throws Exception { - GrpcHttp2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(false); + GrpcHttp2HeadersEncoder encoder = + new GrpcHttp2HeadersEncoder(GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE); ByteBuf first = Unpooled.buffer(); ByteBuf second = Unpooled.buffer(); try { @@ -55,7 +56,7 @@ public void dynamicTableEnabledByDefault() throws Exception { @Test public void dynamicTableDisabledPermanently_staticTableStillUsed() throws Exception { - GrpcHttp2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(true); + GrpcHttp2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(0); DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder(); ByteBuf first = Unpooled.buffer(); ByteBuf second = Unpooled.buffer(); @@ -86,4 +87,23 @@ public void dynamicTableDisabledPermanently_staticTableStillUsed() throws Except encoder.close(); } } + + @Test + public void configuredDynamicTableSizeCapsPeerSetting() throws Exception { + GrpcHttp2HeadersEncoder encoder = new GrpcHttp2HeadersEncoder(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 067b1481505..a08f20240ae 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -50,10 +50,17 @@ public class NettyChannelBuilderTest { private final SslContext noSslContext = null; @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 2661c9efca7..c5a3e583a7f 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java @@ -128,7 +128,7 @@ public class NettyClientHandlerTest extends NettyHandlerTestBase() { @Override @@ -247,6 +252,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); @@ -1186,7 +1209,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 935b0580530..21e19248350 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -237,7 +237,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept newNegotiator(), false, DEFAULT_WINDOW_SIZE, - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -514,7 +514,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { newNegotiator(), false, DEFAULT_WINDOW_SIZE, - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1149,7 +1149,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max negotiator, false, DEFAULT_WINDOW_SIZE, - false, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, maxMsgSize, maxHeaderListSize, maxHeaderListSize, @@ -1199,7 +1199,7 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, maxStreamsPerConnection, false, DEFAULT_WINDOW_SIZE, - 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 f8ceca0f757..d9444c062a6 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -41,8 +41,13 @@ public class NettyServerBuilderTest { private NettyServerBuilder builder = NettyServerBuilder.forPort(8080); @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 72a974a29b8..cef63208f67 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerTest.java @@ -149,7 +149,7 @@ class NoHandlerProtocolNegotiator implements ProtocolNegotiator { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -207,7 +207,7 @@ public void multiPortStartStopGet() throws Exception { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -288,7 +288,7 @@ public void multiPortConnections() throws Exception { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -357,7 +357,7 @@ public void getPort_notStarted() { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -439,7 +439,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -494,7 +494,7 @@ public void channelzListenSocket() throws Exception { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -643,7 +643,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { 1, // ignore false, // ignore 1, // ignore - false, // disableHpackDynamicTable + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore