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/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 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);