Skip to content

xds/extproc: adds ClientInterceptor and ClientStream implementation for normal mode for gRFC A93#9174

Open
eshitachandwani wants to merge 36 commits into
grpc:masterfrom
eshitachandwani:newStream
Open

xds/extproc: adds ClientInterceptor and ClientStream implementation for normal mode for gRFC A93#9174
eshitachandwani wants to merge 36 commits into
grpc:masterfrom
eshitachandwani:newStream

Conversation

@eshitachandwani

Copy link
Copy Markdown
Member

This PR add implementation of NewStream and ClientStream for normal mode for A93: xds-ext-proc.
This PR does not include channel retention , metrics and observability mode.

#ext-proc-a93

RELEASE NOTES: None

@eshitachandwani eshitachandwani added this to the 1.83 Release milestone Jun 8, 2026
@eshitachandwani eshitachandwani added Type: Feature New features or improvements in behavior Area: xDS Includes everything xDS related, including LB policies used with xDS. labels Jun 8, 2026
@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.11958% with 235 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.00%. Comparing base (f8a85fa) to head (c45fb61).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
internal/xds/httpfilter/extproc/ext_proc.go 65.44% 164 Missing and 63 partials ⚠️
internal/xds/httpfilter/extconfig.go 95.95% 2 Missing and 2 partials ⚠️
...ternal/xds/httpfilter/extproc/internal/internal.go 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9174      +/-   ##
==========================================
- Coverage   83.30%   83.00%   -0.31%     
==========================================
  Files         421      422       +1     
  Lines       34061    34832     +771     
==========================================
+ Hits        28375    28911     +536     
- Misses       4261     4425     +164     
- Partials     1425     1496      +71     
Files with missing lines Coverage Δ
internal/resolver/config_selector.go 100.00% <ø> (ø)
internal/xds/httpfilter/extproc/config.go 89.47% <ø> (+5.26%) ⬆️
stream.go 81.96% <100.00%> (ø)
xds/xds.go 40.90% <ø> (ø)
internal/xds/httpfilter/extconfig.go 95.16% <95.95%> (+3.16%) ⬆️
...ternal/xds/httpfilter/extproc/internal/internal.go 0.00% <0.00%> (ø)
internal/xds/httpfilter/extproc/ext_proc.go 68.11% <65.44%> (-8.82%) ⬇️

... and 27 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread internal/resolver/config_selector.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
@eshitachandwani eshitachandwani removed their assignment Jul 1, 2026
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
req := cs.newProcessingRequest(true)
req.Request = &v3procservicepb.ProcessingRequest_RequestBody{
RequestBody: &v3procservicepb.HttpBody{
EndOfStream: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the "Messages Sent To the ext_proc Server" section of the gRFC, this field is documented as follows:

end_of_stream: For client messages, may be true if the client sent a half-close at the same time as the last message. For server messages, will always be false.

Given the above, shouldn't this be set to false (i.e., not set at all) because our CloseSend does not provide a way to half-close with a message at the same time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That is what I had earlier , but then the gRFC changed to say that for response , we will consider EOS without message only if EOS is also true , so I thought that should be true for request too.

end_of_stream_without_message :  Will be set to true to indicate a half-close with no message to send. Ignored if end_of_stream is false.

And I have a comment on the gRFC to clarify the same : grpc/proposal#484 (comment)
Let me know what you think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we ping that comment thread on the gRFC please?

Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
@mbissa mbissa removed their assignment Jul 8, 2026
@eshitachandwani
eshitachandwani requested a review from easwars July 8, 2026 21:30
@easwars easwars modified the milestones: 1.83 Release, 1.84 Release Jul 14, 2026
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
cs.failProcStream(fmt.Errorf("extproc: external processor returned invalid status instead of CONTINUE for response headers"))
return
}
if err = cs.config.mutationRules.ApplyAdditions(header.GetResponse().GetHeaderMutation().GetSetHeaders(), cs.responseHeader); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any update here?

Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
@eshitachandwani
eshitachandwani requested a review from easwars July 15, 2026 18:39
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc_ext_test.go Outdated
Comment thread internal/xds/httpfilter/extconfig.go Outdated
Comment thread internal/xds/httpfilter/extconfig.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go Outdated
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
Comment thread internal/xds/httpfilter/extproc/ext_proc.go
// type to send the data to the dataplane server.
if !cs.reqForwardingStarted {
cs.reqForwardingStarted = true
go cs.requestForwardingToDataplaneLoop(msg.ProtoReflect().Type())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we certain that this is always the same type for the life of an RPC?

(Less important): It is possible to get this type from some descriptor to avoid this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Are we certain that this is always the same type for the life of an RPC?

I am not sure what you mean, but I dont think it is possible to change the proto mid RPC.

(Less important): It is possible to get this type from some descriptor to avoid this?

I dont think there is an easy or direct way to get this from any descriptor, I dont see this message type being stored anywhere inside grpc go.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure what you mean, but I dont think it is possible to change the proto mid RPC.

What I meant only applies when someone is not using code generated using our protoc-gen-go-grpc. Then, they could call SendMsg with different type of proto messages over the life of a stream.

@easwars easwars assigned eshitachandwani and unassigned easwars Jul 16, 2026
req := cs.newProcessingRequest(true)
req.Request = &v3procservicepb.ProcessingRequest_RequestBody{
RequestBody: &v3procservicepb.HttpBody{
EndOfStream: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we ping that comment thread on the gRFC please?

Comment on lines +233 to +240
if strings.HasSuffix(key, "-bin") {
rawValue = []byte(base64.StdEncoding.EncodeToString(rawValue))
}
headerMap.Headers = append(headerMap.Headers, &v3corepb.HeaderValue{
Key: key,
RawValue: rawValue,
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Couple of simplifications here:

  • Make this block into a helper and call it from both here and down below and pass it a key of type []byte
  • When having to do base64 encoding, use the base64.StdEncoding.Encode function which works on byte slices instead of string and therefore you can avoid one conversion back from a string into a []byte

// type to send the data to the dataplane server.
if !cs.reqForwardingStarted {
cs.reqForwardingStarted = true
go cs.requestForwardingToDataplaneLoop(msg.ProtoReflect().Type())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure what you mean, but I dont think it is possible to change the proto mid RPC.

What I meant only applies when someone is not using code generated using our protoc-gen-go-grpc. Then, they could call SendMsg with different type of proto messages over the life of a stream.

}
cs.mutatedReqBuffer.Load()

if streamedResp.GetEndOfStream() && streamedResp.GetEndOfStreamWithoutMessage() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we only check streamedResp.GetEndOfStreamWithoutMessage here? The check for streamedResp.GetEndOfStream down below will catch the case where this entry in the buffer represents a message+EOS.

}

// createDataplaneStream initializes the underlying gRPC dataplane stream using
// the provided newStream function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we document that if dataplane stream creation fails, the top-level context used by the proc filter is cancelled and thereby all spawned goroutines will terminate.

// Signal that the response header is modified and ready to be sent to the
// client, so that if there is any buffered response body, it can be sent
// after the header.
cs.responseHeaderModified.Fire()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

By firing this event from here (and similarly for the trailers case below), how are we guaranteeing that ordering is maintained for response messages?

Other response messages to be sent to the client application are added to the mutatedRespBuffer and read from there when the application calls Recv. Shouldn't the headers and trailers go through that buffer as well?

case <-cs.ctx.Done():
return nil
}
return cs.responseTrailers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we move this return to be under the case <-cs.responseTrailerModified.Done() to be more explicit.

Comment on lines +417 to +420
if cs.trailersOnly {
return nil, nil
}
return cs.responseHeader, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we move this block to be under the case <-cs.responseHeaderModified.Done() to be more explicit.

Comment on lines +1225 to +1229
trailers := dataplaneStream.Trailer()
if len(trailers) > 0 {
header = trailers
cs.trailersOnly = true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Possible simplification:

if header == nil {
	// A trailers-only response returns nil from dataplaneStream.Header().
	headers = dataplaneStream.Trailer()
	if len(headers) > 0 {
		cs.trailersOnly = true
	}
}

}
cs.mutatedRespBuffer.Put(streamedResp)

case resp.GetResponseHeaders() != nil:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We probably need a little bit more safetey checks here I feel. What if the proc server already sent us a response headers message and we sent cs.responseHeader to the application. Now, the proc server sends one more response headers (clearly a misbehaving proc server), then we end up mutating the same metadata.MD that we've given the client application and that will lead to a data race.

}

func (cs *clientStream) RecvMsg(m any) error {
if cs.procStreamFailed.HasFired() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Based on my other comment where I requested the use of an atomic boolean to guard the inside of initiateResponseHeaderProcessing, can you also check if this condition procStreamFailed can be taken care of by that method. If we can do that (possibly by calling waitForDataplaneStream outside of the boolean guard), then we can directly call initiateResponseHeaderProcessing. Something to consider.

Comment on lines +515 to +517
if val := cs.procStreamErr.Load(); val != nil {
return val.(error)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is interesting that you use this pattern instead of the more commonly used pattern:

	if cs.procStreamFailed.HasFired() {
		return cs.procStreamErr.Load().(error)
	}

Any specific reason for this?

Comment on lines +518 to +519
cs.initiateResponseTrailerProcessing()
return cs.waitForTrailerProcessing(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we blocking the application's RecvMsg call on trailers? Shouldn't we return the error we received from the dataplane stream and have them call Trailer if they want to see the trailers?


// Start the background receiving loop on the first RecvMsg call to capture
// the type of message to be received.
if !cs.responseRecvStarted {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: should this be called responseForwardingStarted or respForwardingStarted, similar to reqForwardingStarted?


newMsg := msgType.New().Interface()
if err := dataplaneStream.RecvMsg(newMsg); err != nil {
cs.initiateResponseTrailerProcessing()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same concern about why we need to block for trailers here?

Comment on lines +544 to +554
if val := cs.procStreamErr.Load(); val != nil {
return val.(error)
}
s, err := cs.waitForDataplaneStream(cs.ctx)
if err != nil {
return err
}
if err := s.RecvMsg(m); err != nil {
cs.initiateResponseTrailerProcessing()
return cs.waitForTrailerProcessing(err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This block of code is very similar to the one up top where we handle receiving directly from the dataplane stream when some conditions are met. Maybe, we can find a way to share this code?

@easwars easwars assigned eshitachandwani and unassigned easwars Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: xDS Includes everything xDS related, including LB policies used with xDS. Type: Feature New features or improvements in behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants