Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clientconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ func chainStreamClientInterceptors(cc *ClientConn) {
if cc.dopts.streamInt != nil {
interceptors = append([]StreamClientInterceptor{cc.dopts.streamInt}, interceptors...)
}
interceptors = append([]StreamClientInterceptor{defaultStreamInterceptor}, interceptors...)
interceptors = append(interceptors, defaultStreamInterceptor)
var chainedInt StreamClientInterceptor
if len(interceptors) == 0 {
chainedInt = nil
Expand Down
10 changes: 6 additions & 4 deletions stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,12 @@ func (w *clientStreamWrapper) SendMsg(m any) error {
if err != nil {
return err
}
// CloseSend is needed because in some scenarios (e.g., xDS), the same
// interceptors are used to process both unary and streaming RPCs. Calling
// CloseSend signals to those interceptors that no more messages are on the
// way.
// In some scenarios (e.g., xDS), the same interceptors process both unary and
// streaming RPCs, relying on CloseSend to signal that no more messages are on
// the way. Although protobuf-generated stubs already invoke CloseSend for
// server-streaming RPCs, it is explicitly called here to ensure downstream
// interceptors are also notified when callers interact with the ClientStream
// API directly.
if err := w.ClientStream.CloseSend(); err != nil && err != io.EOF {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that CloseSend will be called twice for the xds filters incase of non client streaming RPCs (when generated code is used)?? if so , shouldn't we say that filters should make is safe to be called multiple times, or ensure that we do a check here and call CloseSend just once for the filter chain ?

return err
}
Expand Down
38 changes: 22 additions & 16 deletions stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ func (s) TestUnaryClient_ServerStreamingMismatch(t *testing.T) {
// CloseSend hooks across downstream interceptors.
type interceptorStream struct {
grpc.ClientStream
recvMsgCount int
closeSend bool
recvMsgCount int
closeSendCount int
}

func (s *interceptorStream) RecvMsg(m any) error {
Expand All @@ -161,14 +161,15 @@ func (s *interceptorStream) RecvMsg(m any) error {
}

func (s *interceptorStream) CloseSend() error {
s.closeSend = true
s.closeSendCount++
return s.ClientStream.CloseSend()
}

// TestDefaultStreamInterceptor verifies that defaultStreamInterceptor
// automatically triggers CloseSend on non-client-streaming RPCs right after
// SendMsg, and calls RecvMsg a second time on non-server-streaming RPCs to
// consume trailers and io.EOF.
// TestDefaultStreamInterceptor verifies that defaultStreamInterceptor's
// behavior of automatically triggering CloseSend on non-client-streaming RPCs
// right after SendMsg, and calling RecvMsg a second time on
// non-server-streaming RPCs to consume trailers and io.EOF, are not visible to
// user-defined interceptors.
func (s) TestDefaultStreamInterceptor(t *testing.T) {
var iStream *interceptorStream
// Define a client-side stream interceptor that wraps the ClientStream to
Expand Down Expand Up @@ -207,10 +208,11 @@ func (s) TestDefaultStreamInterceptor(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()

// Make a client-streaming RPC. When CloseAndRecv invokes RecvMsg once to get
// the single reply message on a non-server-streaming RPC,
// Make a client-streaming RPC. When CloseAndRecv invokes RecvMsg once to
// get the single reply message on a non-server-streaming RPC,
// defaultStreamInterceptor automatically calls a second RecvMsg on the
// underlying client stream to consume io.EOF and receive trailers.
// underlying client stream to consume io.EOF and receive trailers. But this
// should not be visible to user interceptors.
stream, err := ss.Client.StreamingInputCall(ctx)
if err != nil {
t.Fatal("Error calling StreamingInputCall:", err)
Expand All @@ -221,17 +223,21 @@ func (s) TestDefaultStreamInterceptor(t *testing.T) {
if _, err := stream.CloseAndRecv(); err != nil {
t.Fatal("Error running CloseAndRecv:", err)
}
if iStream.recvMsgCount != 2 {
t.Fatalf("StreamingInputCall RecvMsg was called %v times, want 2 times", iStream.recvMsgCount)
if iStream.recvMsgCount != 1 {
t.Fatalf("RecvMsg was called %v times on user interceptor stream, want 1 time", iStream.recvMsgCount)
}

// Make a server-streaming RPC. Since StreamingOutputCall is not
// client-streaming, defaultStreamInterceptor immediately invokes CloseSend
// right after sending the request message to signal downstream interceptors.
// client-streaming, the proto generated code invokes CloseSend after
// sending the single message. defaultStreamInterceptor also invokes
// CloseSend right after sending the request message (to handle cases where
// the user is using the ClientStream API instead of the proto generated
// code) to signal downstream interceptors, which in this case are xDS
// filters. The user interceptor should not see this CloseSend call.
if _, err := ss.Client.StreamingOutputCall(ctx, &testpb.StreamingOutputCallRequest{}); err != nil {
t.Fatal("Error calling StreamingOutputCall:", err)
}
if !iStream.closeSend {
t.Fatal("CloseSend not called after SendMsg on non-client-streaming RPC")
if iStream.closeSendCount != 1 {
t.Fatalf("CloseSend called %v times on user interceptor stream, want 1 times", iStream.closeSendCount)
}
}
244 changes: 244 additions & 0 deletions test/xds/xds_client_filter_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*
*
* Copyright 2026 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 xds_test

import (
"context"
"fmt"
"io"
"testing"

v3xdsxdstypepb "github.com/cncf/xds/go/xds/type/v3"
v3clusterpb "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
v3endpointpb "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/stubserver"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/internal/testutils/xds/e2e/setup"
"google.golang.org/grpc/internal/xds/httpfilter"

testgrpc "google.golang.org/grpc/interop/grpc_testing"
testpb "google.golang.org/grpc/interop/grpc_testing"
)

// Tests the interaction between the defaultStreamInterceptor and xDS filters.
// It verifies that the SendMsg, RecvMsg, and CloseSend methods are called the
// expected number of times for different RPC types (unary, client streaming,
// server streaming, and bidi streaming) when an xDS filter is registered and
// used in the call chain.
func (s) TestDefaultStreamInterceptor_InteractionWithXDSFilters(t *testing.T) {
// Register a custom xDS filter builder for the test.
testFilterTypeURL := t.Name()
filterBuilder := newTrackingHTTPFilterBuilder(testFilterTypeURL)
httpfilter.Register(filterBuilder)
defer httpfilter.UnregisterForTesting(testFilterTypeURL)

// Setup a test backend implementing both all four RPC types.
testServer := &stubserver.StubServer{
EmptyCallF: func(context.Context, *testpb.Empty) (*testpb.Empty, error) {
return &testpb.Empty{}, nil
},
StreamingOutputCallF: func(_ *testpb.StreamingOutputCallRequest, stream testgrpc.TestService_StreamingOutputCallServer) error {
return stream.Send(&testpb.StreamingOutputCallResponse{})
},
StreamingInputCallF: func(stream testgrpc.TestService_StreamingInputCallServer) error {
for {
_, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return stream.SendAndClose(&testpb.StreamingInputCallResponse{})
},
FullDuplexCallF: func(stream testgrpc.TestService_FullDuplexCallServer) error {
for {
_, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := stream.Send(&testpb.StreamingOutputCallResponse{}); err != nil {
return err
}
}
},
}
if err := testServer.Start(nil); err != nil {
t.Fatal("Error starting server:", err)
}
defer testServer.Stop()

// Start an xDS management server.
mgmtServer, nodeID, _, xdsResolver := setup.ManagementServerAndResolver(t)

const serviceName = "my-service-xds"
clusterSpec := &v3routepb.RouteAction_Cluster{Cluster: "cluster-A"}
hcm := &v3httppb.HttpConnectionManager{
RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{
RouteConfig: &v3routepb.RouteConfiguration{
Name: "route-" + serviceName,
VirtualHosts: []*v3routepb.VirtualHost{{
Domains: []string{serviceName},
Routes: []*v3routepb.Route{{
Match: &v3routepb.RouteMatch{PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: ""}},
Action: &v3routepb.Route_Route{Route: &v3routepb.RouteAction{
ClusterSpecifier: clusterSpec,
}},
}},
}},
},
},
HttpFilters: []*v3httppb.HttpFilter{
{
Name: "tracking-filter",
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3xdsxdstypepb.TypedStruct{
TypeUrl: testFilterTypeURL,
}),
},
},
e2e.RouterHTTPFilter,
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{{Name: serviceName, ApiListener: &v3listenerpb.ApiListener{ApiListener: testutils.MarshalAny(t, hcm)}}},
Clusters: []*v3clusterpb.Cluster{e2e.DefaultCluster("cluster-A", "cluster-A", e2e.SecurityLevelNone)},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint("cluster-A", "localhost", []uint32{testutils.ParsePort(t, testServer.Address)})},
}

ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}

cc, err := grpc.NewClient(fmt.Sprintf("xds:///%s", serviceName), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)

tests := []struct {
name string
run func(ctx context.Context, client testgrpc.TestServiceClient)
wantSendMsg int32
wantRecvMsg int32
wantCloseSend int32
}{
{
name: "unary",
run: func(ctx context.Context, client testgrpc.TestServiceClient) {
if _, err := client.EmptyCall(ctx, &testpb.Empty{}); err != nil {
t.Fatalf("EmptyCall() failed: %v", err)
}
},
wantSendMsg: 1, // 1 request
wantRecvMsg: 2, // 1 response + 1 trailers
wantCloseSend: 1, // CloseSend called by invoke
},
{
name: "client_streaming",
run: func(ctx context.Context, client testgrpc.TestServiceClient) {
stream, err := client.StreamingInputCall(ctx)
if err != nil {
t.Fatalf("StreamingInputCall() failed: %v", err)
}
for i := 0; i < 2; i++ {
if err := stream.Send(&testpb.StreamingInputCallRequest{}); err != nil {
t.Fatalf("Send() failed: %v", err)
}
}
if _, err := stream.CloseAndRecv(); err != nil {
t.Fatalf("CloseAndRecv() failed: %v", err)
}
},
wantSendMsg: 2, // 2 requests
wantRecvMsg: 2, // 1 response + 1 trailers
wantCloseSend: 1, // CloseSend called by CloseAndRecv
},
{
name: "server_streaming",
run: func(ctx context.Context, client testgrpc.TestServiceClient) {
stream, err := client.StreamingOutputCall(ctx, &testpb.StreamingOutputCallRequest{})
if err != nil {
t.Fatalf("StreamingOutputCall() failed: %v", err)
}
if _, err := stream.Recv(); err != nil {
t.Fatalf("Recv() failed: %v", err)
}
},
wantSendMsg: 1, // 1 request
wantRecvMsg: 1, // 1 response (trailers not yet consumed since stream not read to EOF)
wantCloseSend: 2, // 1 from defaultStreamInterceptor + 1 from proto generated code
},
{
name: "bidi_streaming",
run: func(ctx context.Context, client testgrpc.TestServiceClient) {
stream, err := client.FullDuplexCall(ctx)
if err != nil {
t.Fatalf("FullDuplexCall() failed: %v", err)
}
if err := stream.Send(&testpb.StreamingOutputCallRequest{}); err != nil {
t.Fatalf("Send() failed: %v", err)
}
if _, err := stream.Recv(); err != nil {
t.Fatalf("Recv() failed: %v", err)
}
if err := stream.CloseSend(); err != nil {
t.Fatalf("CloseSend() failed: %v", err)
}
},
wantSendMsg: 1, // 1 request
wantRecvMsg: 1, // 1 response
wantCloseSend: 1, // 1 explicit CloseSend
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
filterBuilder.sendMsgCount.Store(0)
filterBuilder.recvMsgCount.Store(0)
filterBuilder.closeSendCount.Store(0)

tc.run(ctx, client)

if got := filterBuilder.sendMsgCount.Load(); got != tc.wantSendMsg {
t.Fatalf("SendMsg() count = %d, want %d", got, tc.wantSendMsg)
}
if got := filterBuilder.recvMsgCount.Load(); got != tc.wantRecvMsg {
t.Fatalf("RecvMsg() count = %d, want %d", got, tc.wantRecvMsg)
}
if got := filterBuilder.closeSendCount.Load(); got != tc.wantCloseSend {
t.Fatalf("CloseSend() count = %d, want %d", got, tc.wantCloseSend)
}
})
}
}
Loading
Loading