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..3dfe33aac2c --- /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 configuring 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_interoperatesWithZeroTableNettyServer() throws Exception { + Server server = startServer( + NettyServerBuilder.forPort(0, InsecureServerCredentials.create()) + .hpackDynamicTableSize(0)); + ManagedChannel channel = grpcCleanup.register( + OkHttpChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .build()); + + makeRepeatedCalls(channel); + } + + @Test + public void zeroTableNettyClient_interoperatesWithDefaultOkHttpServer() throws Exception { + Server server = startServer( + OkHttpServerBuilder.forPort(0, InsecureServerCredentials.create())); + ManagedChannel channel = grpcCleanup.register( + NettyChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .hpackDynamicTableSize(0) + .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/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..3e692452311 --- /dev/null +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2HeadersEncoder.java @@ -0,0 +1,56 @@ +/* + * 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.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 int configuredMaxDynamicTableSize; + + GrpcHttp2HeadersEncoder( + Http2HeadersEncoder.SensitivityDetector sensitivityDetector, + int configuredMaxDynamicTableSize) { + super( + sensitivityDetector, + false, + configuredMaxDynamicTableSize == 0 + ? MIN_DYNAMIC_TABLE_ARRAY_SIZE_HINT : DEFAULT_DYNAMIC_TABLE_ARRAY_SIZE_HINT, + Integer.MAX_VALUE); + this.configuredMaxDynamicTableSize = configuredMaxDynamicTableSize; + if (configuredMaxDynamicTableSize < DEFAULT_DYNAMIC_TABLE_SIZE) { + try { + super.maxHeaderTableSize(configuredMaxDynamicTableSize); + } catch (Http2Exception e) { + // Non-negative configured sizes are valid HPACK dynamic table sizes. + throw new AssertionError(e); + } + } + } + + @Override + public void maxHeaderTableSize(long max) throws Http2Exception { + 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 33289018d99..c09feb32050 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 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; @@ -476,6 +477,21 @@ public NettyChannelBuilder neverIndexMetadataKeys( return this; } + /** + * 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. + * + * @throws IllegalArgumentException if {@code bytes} is negative + */ + @CanIgnoreReturnValue + public NettyChannelBuilder hpackDynamicTableSize(int bytes) { + checkArgument(bytes >= 0, "hpackDynamicTableSize must not be negative: %s", bytes); + hpackDynamicTableSize = bytes; + 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, + hpackDynamicTableSize, 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 int hpackDynamicTableSize; 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, + int hpackDynamicTableSize, 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.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -904,6 +924,7 @@ public void run() { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + hpackDynamicTableSize, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -944,6 +965,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + 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 4ccc8747bb5..537bec70589 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, + int hpackDynamicTableSize, 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), hpackDynamicTableSize); 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, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, stopwatchFactory, @@ -214,6 +215,7 @@ static NettyClientHandler newHandler( KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -261,6 +263,9 @@ static NettyClientHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(0); settings.maxHeaderListSize(maxHeaderListSize); + if (hpackDynamicTableSize != GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE) { + settings.headerTableSize(hpackDynamicTableSize); + } 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..50efca8ad4d 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 int hpackDynamicTableSize; 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, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -150,6 +152,7 @@ class NettyClientTransport implements ConnectionClientTransport, this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -253,6 +256,7 @@ public Runnable start(Listener transportListener) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + 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 d8dd016ddec..b2aece6f958 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 int hpackDynamicTableSize; 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, + int hpackDynamicTableSize, 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.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -273,6 +276,7 @@ public void initChannel(Channel ch) { autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + 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 99e2627451a..c5c2543f3a8 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 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; @@ -483,6 +484,21 @@ public NettyServerBuilder neverIndexMetadataKeys( return this; } + /** + * 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. + * + * @throws IllegalArgumentException if {@code bytes} is negative + */ + @CanIgnoreReturnValue + public NettyServerBuilder hpackDynamicTableSize(int bytes) { + checkArgument(bytes >= 0, "hpackDynamicTableSize must not be negative: %s", bytes); + hpackDynamicTableSize = bytes; + 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, + 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 846c45cd459..68d4b516327 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, + int hpackDynamicTableSize, 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); + hpackDynamicTableSize); Http2FrameWriter frameWriter = new Http2OutboundFrameLogger(new DefaultHttp2FrameWriter(encoder), frameLogger); return newHandler( @@ -203,6 +201,7 @@ static NettyServerHandler newHandler( maxStreams, autoFlowControl, flowControlWindow, + hpackDynamicTableSize, maxHeaderListSize, softLimitHeaderListSize, maxMessageSize, @@ -230,6 +229,7 @@ static NettyServerHandler newHandler( int maxStreams, boolean autoFlowControl, int flowControlWindow, + int hpackDynamicTableSize, int maxHeaderListSize, int softLimitHeaderListSize, int maxMessageSize, @@ -287,6 +287,9 @@ static NettyServerHandler newHandler( settings.initialWindowSize(flowControlWindow); settings.maxConcurrentStreams(maxStreams); settings.maxHeaderListSize(maxHeaderListSize); + if (hpackDynamicTableSize != GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE) { + settings.headerTableSize(hpackDynamicTableSize); + } 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..ab7a4265a7a 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 int hpackDynamicTableSize; 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, + int hpackDynamicTableSize, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -124,6 +126,7 @@ class NettyServerTransport implements ServerTransport { this.flowControlWindow = flowControlWindow; this.neverIndexedMetadataKeys = Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); + this.hpackDynamicTableSize = hpackDynamicTableSize; this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -288,6 +291,7 @@ private NettyServerHandler createHandler( autoFlowControl, flowControlWindow, neverIndexedMetadataKeys, + 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 new file mode 100644 index 00000000000..81cb9db8fe7 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/GrpcHttp2HeadersEncoderTest.java @@ -0,0 +1,114 @@ +/* + * 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, + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE); + 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, 0); + 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(); + } + } + + @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 new file mode 100644 index 00000000000..be91c2d6dd1 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/HpackDynamicTableInteropTest.java @@ -0,0 +1,125 @@ +/* + * 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 = "clientTableSize={0}, serverTableSize={1}") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {4096, 4096}, {4096, 8192}, {8192, 4096}, {8192, 8192}, + {0, 4096}, {4096, 0}, {0, 0} + }); + } + + @Parameter(0) + public int clientTableSize; + + @Parameter(1) + public int serverTableSize; + + 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))); + serverBuilder.hpackDynamicTableSize(serverTableSize); + server = serverBuilder.build().start(); + + NettyChannelBuilder channelBuilder = NettyChannelBuilder + .forAddress("localhost", server.getPort()) + .usePlaintext(); + channelBuilder.hpackDynamicTableSize(clientTableSize); + 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..566a5d35b95 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -118,6 +118,20 @@ public void neverIndexMetadataKeysRejectsNullElementWithoutPartialMutation() thr assertThat((Set) field.get(builder)).isEmpty(); } + @Test + public void hpackDynamicTableSizeAllowsZeroAndIsFluent() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + 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 { 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..fafeecc695a 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 +238,49 @@ 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 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); + 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 +1260,7 @@ public Stopwatch get() { mockKeepAliveManager, false, flowControlWindow, + 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 8cb5cca9861..d16593e8432 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(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 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(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 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(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, maxMsgSize, maxHeaderListSize, maxHeaderListSize, @@ -1200,6 +1203,7 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, false, DEFAULT_WINDOW_SIZE, Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 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..09429dbd3a9 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -100,6 +100,16 @@ private static Set neverIndexedMetadataKeys(NettyServerBuilder buil return (Set) field.get(builder); } + @Test + public void hpackDynamicTableSizeAllowsZeroAndIsFluent() { + assertThat(builder.hpackDynamicTableSize(0)).isSameInstanceAs(builder); + } + + @Test + public void hpackDynamicTableSizeRejectsNegative() { + assertThrows(IllegalArgumentException.class, () -> builder.hpackDynamicTableSize(-1)); + } + @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..783ebc03148 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 + 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 @@ -1425,6 +1464,7 @@ protected NettyServerHandler newHandler() { maxConcurrentStreams, autoFlowControl, flowControlWindow, + 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 0acdb4c08d0..d55e862a5a2 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(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -208,6 +209,7 @@ public void multiPortStartStopGet() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -289,6 +291,7 @@ public void multiPortConnections() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -358,6 +361,7 @@ public void getPort_notStarted() { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -440,6 +444,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -495,6 +500,7 @@ public void channelzListenSocket() throws Exception { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore @@ -644,6 +650,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { false, // ignore 1, // ignore Collections.emptySet(), + GrpcHttp2HeadersEncoder.DEFAULT_DYNAMIC_TABLE_SIZE, 1, // ignore 1, // ignore 1, // ignore 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);