diff --git a/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java index fc3ce3a2723..20064af7844 100644 --- a/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java +++ b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java @@ -4,31 +4,25 @@ /** *
- * A service that can access and modify HTTP requests and responses
- * as part of a filter chain.
+ * A service that can access and modify HTTP requests and responses as part of a filter chain.
  * The overall external processing protocol works like this:
  * 1. The data plane sends to the service information about the HTTP request.
- * 2. The service sends back a ProcessingResponse message that directs
- *    the data plane to either stop processing, continue without it, or send
- *    it the next chunk of the message body.
- * 3. If so requested, the data plane sends the server the message body in
- *    chunks, or the entire body at once. In either case, the server may send
- *    back a ProcessingResponse for each message it receives, or wait for
- *    a certain amount of body chunks received before streaming back the
- *    ProcessingResponse messages.
- * 4. If so requested, the data plane sends the server the HTTP trailers,
- *    and the server sends back a ProcessingResponse.
- * 5. At this point, request processing is done, and we pick up again
- *    at step 1 when the data plane receives a response from the upstream
- *    server.
- * 6. At any point above, if the server closes the gRPC stream cleanly,
- *    then the data plane proceeds without consulting the server.
- * 7. At any point above, if the server closes the gRPC stream with an error,
- *    then the data plane returns a 500 error to the client, unless the filter
- *    was configured to ignore errors.
- * In other words, the process is a request/response conversation, but
- * using a gRPC stream to make it easier for the server to
- * maintain state.
+ * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either
+ *    stop processing, continue without it, or send it the next chunk of the message body.
+ * 3. If so requested, the data plane sends the server the message body in chunks, or the entire
+ *    body at once. In either case, the server may send back a ``ProcessingResponse`` for each
+ *    message it receives, or wait for a certain amount of body chunks to be received before
+ *    streaming back the ``ProcessingResponse`` messages.
+ * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back
+ *    a ``ProcessingResponse``.
+ * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane
+ *    receives a response from the upstream server.
+ * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane
+ *    proceeds without consulting the server.
+ * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane
+ *    returns a ``500`` error to the client, unless the filter was configured to ignore errors.
+ * In other words, the process is a request/response conversation, but using a gRPC stream to make
+ * it easier for the server to maintain state.
  * 
*/ @io.grpc.stub.annotations.GrpcGenerated @@ -131,31 +125,25 @@ public ExternalProcessorFutureStub newStub(io.grpc.Channel channel, io.grpc.Call /** *
-   * A service that can access and modify HTTP requests and responses
-   * as part of a filter chain.
+   * A service that can access and modify HTTP requests and responses as part of a filter chain.
    * The overall external processing protocol works like this:
    * 1. The data plane sends to the service information about the HTTP request.
-   * 2. The service sends back a ProcessingResponse message that directs
-   *    the data plane to either stop processing, continue without it, or send
-   *    it the next chunk of the message body.
-   * 3. If so requested, the data plane sends the server the message body in
-   *    chunks, or the entire body at once. In either case, the server may send
-   *    back a ProcessingResponse for each message it receives, or wait for
-   *    a certain amount of body chunks received before streaming back the
-   *    ProcessingResponse messages.
-   * 4. If so requested, the data plane sends the server the HTTP trailers,
-   *    and the server sends back a ProcessingResponse.
-   * 5. At this point, request processing is done, and we pick up again
-   *    at step 1 when the data plane receives a response from the upstream
-   *    server.
-   * 6. At any point above, if the server closes the gRPC stream cleanly,
-   *    then the data plane proceeds without consulting the server.
-   * 7. At any point above, if the server closes the gRPC stream with an error,
-   *    then the data plane returns a 500 error to the client, unless the filter
-   *    was configured to ignore errors.
-   * In other words, the process is a request/response conversation, but
-   * using a gRPC stream to make it easier for the server to
-   * maintain state.
+   * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either
+   *    stop processing, continue without it, or send it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in chunks, or the entire
+   *    body at once. In either case, the server may send back a ``ProcessingResponse`` for each
+   *    message it receives, or wait for a certain amount of body chunks to be received before
+   *    streaming back the ``ProcessingResponse`` messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back
+   *    a ``ProcessingResponse``.
+   * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane
+   *    receives a response from the upstream server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane
+   *    proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane
+   *    returns a ``500`` error to the client, unless the filter was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but using a gRPC stream to make
+   * it easier for the server to maintain state.
    * 
*/ public interface AsyncService { @@ -164,7 +152,7 @@ public interface AsyncService { *
      * This begins the bidirectional stream that the data plane will use to
      * give the server control over what the filter does. The actual
-     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * protocol is described by the ``ProcessingRequest`` and ``ProcessingResponse``
      * messages below.
      * 
*/ @@ -177,31 +165,25 @@ default io.grpc.stub.StreamObserver - * A service that can access and modify HTTP requests and responses - * as part of a filter chain. + * A service that can access and modify HTTP requests and responses as part of a filter chain. * The overall external processing protocol works like this: * 1. The data plane sends to the service information about the HTTP request. - * 2. The service sends back a ProcessingResponse message that directs - * the data plane to either stop processing, continue without it, or send - * it the next chunk of the message body. - * 3. If so requested, the data plane sends the server the message body in - * chunks, or the entire body at once. In either case, the server may send - * back a ProcessingResponse for each message it receives, or wait for - * a certain amount of body chunks received before streaming back the - * ProcessingResponse messages. - * 4. If so requested, the data plane sends the server the HTTP trailers, - * and the server sends back a ProcessingResponse. - * 5. At this point, request processing is done, and we pick up again - * at step 1 when the data plane receives a response from the upstream - * server. - * 6. At any point above, if the server closes the gRPC stream cleanly, - * then the data plane proceeds without consulting the server. - * 7. At any point above, if the server closes the gRPC stream with an error, - * then the data plane returns a 500 error to the client, unless the filter - * was configured to ignore errors. - * In other words, the process is a request/response conversation, but - * using a gRPC stream to make it easier for the server to - * maintain state. + * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either + * stop processing, continue without it, or send it the next chunk of the message body. + * 3. If so requested, the data plane sends the server the message body in chunks, or the entire + * body at once. In either case, the server may send back a ``ProcessingResponse`` for each + * message it receives, or wait for a certain amount of body chunks to be received before + * streaming back the ``ProcessingResponse`` messages. + * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back + * a ``ProcessingResponse``. + * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane + * receives a response from the upstream server. + * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane + * proceeds without consulting the server. + * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane + * returns a ``500`` error to the client, unless the filter was configured to ignore errors. + * In other words, the process is a request/response conversation, but using a gRPC stream to make + * it easier for the server to maintain state. * */ public static abstract class ExternalProcessorImplBase @@ -215,31 +197,25 @@ public static abstract class ExternalProcessorImplBase /** * A stub to allow clients to do asynchronous rpc calls to service ExternalProcessor. *
-   * A service that can access and modify HTTP requests and responses
-   * as part of a filter chain.
+   * A service that can access and modify HTTP requests and responses as part of a filter chain.
    * The overall external processing protocol works like this:
    * 1. The data plane sends to the service information about the HTTP request.
-   * 2. The service sends back a ProcessingResponse message that directs
-   *    the data plane to either stop processing, continue without it, or send
-   *    it the next chunk of the message body.
-   * 3. If so requested, the data plane sends the server the message body in
-   *    chunks, or the entire body at once. In either case, the server may send
-   *    back a ProcessingResponse for each message it receives, or wait for
-   *    a certain amount of body chunks received before streaming back the
-   *    ProcessingResponse messages.
-   * 4. If so requested, the data plane sends the server the HTTP trailers,
-   *    and the server sends back a ProcessingResponse.
-   * 5. At this point, request processing is done, and we pick up again
-   *    at step 1 when the data plane receives a response from the upstream
-   *    server.
-   * 6. At any point above, if the server closes the gRPC stream cleanly,
-   *    then the data plane proceeds without consulting the server.
-   * 7. At any point above, if the server closes the gRPC stream with an error,
-   *    then the data plane returns a 500 error to the client, unless the filter
-   *    was configured to ignore errors.
-   * In other words, the process is a request/response conversation, but
-   * using a gRPC stream to make it easier for the server to
-   * maintain state.
+   * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either
+   *    stop processing, continue without it, or send it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in chunks, or the entire
+   *    body at once. In either case, the server may send back a ``ProcessingResponse`` for each
+   *    message it receives, or wait for a certain amount of body chunks to be received before
+   *    streaming back the ``ProcessingResponse`` messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back
+   *    a ``ProcessingResponse``.
+   * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane
+   *    receives a response from the upstream server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane
+   *    proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane
+   *    returns a ``500`` error to the client, unless the filter was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but using a gRPC stream to make
+   * it easier for the server to maintain state.
    * 
*/ public static final class ExternalProcessorStub @@ -259,7 +235,7 @@ protected ExternalProcessorStub build( *
      * This begins the bidirectional stream that the data plane will use to
      * give the server control over what the filter does. The actual
-     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * protocol is described by the ``ProcessingRequest`` and ``ProcessingResponse``
      * messages below.
      * 
*/ @@ -273,31 +249,25 @@ public io.grpc.stub.StreamObserver - * A service that can access and modify HTTP requests and responses - * as part of a filter chain. + * A service that can access and modify HTTP requests and responses as part of a filter chain. * The overall external processing protocol works like this: * 1. The data plane sends to the service information about the HTTP request. - * 2. The service sends back a ProcessingResponse message that directs - * the data plane to either stop processing, continue without it, or send - * it the next chunk of the message body. - * 3. If so requested, the data plane sends the server the message body in - * chunks, or the entire body at once. In either case, the server may send - * back a ProcessingResponse for each message it receives, or wait for - * a certain amount of body chunks received before streaming back the - * ProcessingResponse messages. - * 4. If so requested, the data plane sends the server the HTTP trailers, - * and the server sends back a ProcessingResponse. - * 5. At this point, request processing is done, and we pick up again - * at step 1 when the data plane receives a response from the upstream - * server. - * 6. At any point above, if the server closes the gRPC stream cleanly, - * then the data plane proceeds without consulting the server. - * 7. At any point above, if the server closes the gRPC stream with an error, - * then the data plane returns a 500 error to the client, unless the filter - * was configured to ignore errors. - * In other words, the process is a request/response conversation, but - * using a gRPC stream to make it easier for the server to - * maintain state. + * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either + * stop processing, continue without it, or send it the next chunk of the message body. + * 3. If so requested, the data plane sends the server the message body in chunks, or the entire + * body at once. In either case, the server may send back a ``ProcessingResponse`` for each + * message it receives, or wait for a certain amount of body chunks to be received before + * streaming back the ``ProcessingResponse`` messages. + * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back + * a ``ProcessingResponse``. + * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane + * receives a response from the upstream server. + * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane + * proceeds without consulting the server. + * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane + * returns a ``500`` error to the client, unless the filter was configured to ignore errors. + * In other words, the process is a request/response conversation, but using a gRPC stream to make + * it easier for the server to maintain state. * */ public static final class ExternalProcessorBlockingV2Stub @@ -317,7 +287,7 @@ protected ExternalProcessorBlockingV2Stub build( *
      * This begins the bidirectional stream that the data plane will use to
      * give the server control over what the filter does. The actual
-     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * protocol is described by the ``ProcessingRequest`` and ``ProcessingResponse``
      * messages below.
      * 
*/ @@ -332,31 +302,25 @@ protected ExternalProcessorBlockingV2Stub build( /** * A stub to allow clients to do limited synchronous rpc calls to service ExternalProcessor. *
-   * A service that can access and modify HTTP requests and responses
-   * as part of a filter chain.
+   * A service that can access and modify HTTP requests and responses as part of a filter chain.
    * The overall external processing protocol works like this:
    * 1. The data plane sends to the service information about the HTTP request.
-   * 2. The service sends back a ProcessingResponse message that directs
-   *    the data plane to either stop processing, continue without it, or send
-   *    it the next chunk of the message body.
-   * 3. If so requested, the data plane sends the server the message body in
-   *    chunks, or the entire body at once. In either case, the server may send
-   *    back a ProcessingResponse for each message it receives, or wait for
-   *    a certain amount of body chunks received before streaming back the
-   *    ProcessingResponse messages.
-   * 4. If so requested, the data plane sends the server the HTTP trailers,
-   *    and the server sends back a ProcessingResponse.
-   * 5. At this point, request processing is done, and we pick up again
-   *    at step 1 when the data plane receives a response from the upstream
-   *    server.
-   * 6. At any point above, if the server closes the gRPC stream cleanly,
-   *    then the data plane proceeds without consulting the server.
-   * 7. At any point above, if the server closes the gRPC stream with an error,
-   *    then the data plane returns a 500 error to the client, unless the filter
-   *    was configured to ignore errors.
-   * In other words, the process is a request/response conversation, but
-   * using a gRPC stream to make it easier for the server to
-   * maintain state.
+   * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either
+   *    stop processing, continue without it, or send it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in chunks, or the entire
+   *    body at once. In either case, the server may send back a ``ProcessingResponse`` for each
+   *    message it receives, or wait for a certain amount of body chunks to be received before
+   *    streaming back the ``ProcessingResponse`` messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back
+   *    a ``ProcessingResponse``.
+   * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane
+   *    receives a response from the upstream server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane
+   *    proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane
+   *    returns a ``500`` error to the client, unless the filter was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but using a gRPC stream to make
+   * it easier for the server to maintain state.
    * 
*/ public static final class ExternalProcessorBlockingStub @@ -376,31 +340,25 @@ protected ExternalProcessorBlockingStub build( /** * A stub to allow clients to do ListenableFuture-style rpc calls to service ExternalProcessor. *
-   * A service that can access and modify HTTP requests and responses
-   * as part of a filter chain.
+   * A service that can access and modify HTTP requests and responses as part of a filter chain.
    * The overall external processing protocol works like this:
    * 1. The data plane sends to the service information about the HTTP request.
-   * 2. The service sends back a ProcessingResponse message that directs
-   *    the data plane to either stop processing, continue without it, or send
-   *    it the next chunk of the message body.
-   * 3. If so requested, the data plane sends the server the message body in
-   *    chunks, or the entire body at once. In either case, the server may send
-   *    back a ProcessingResponse for each message it receives, or wait for
-   *    a certain amount of body chunks received before streaming back the
-   *    ProcessingResponse messages.
-   * 4. If so requested, the data plane sends the server the HTTP trailers,
-   *    and the server sends back a ProcessingResponse.
-   * 5. At this point, request processing is done, and we pick up again
-   *    at step 1 when the data plane receives a response from the upstream
-   *    server.
-   * 6. At any point above, if the server closes the gRPC stream cleanly,
-   *    then the data plane proceeds without consulting the server.
-   * 7. At any point above, if the server closes the gRPC stream with an error,
-   *    then the data plane returns a 500 error to the client, unless the filter
-   *    was configured to ignore errors.
-   * In other words, the process is a request/response conversation, but
-   * using a gRPC stream to make it easier for the server to
-   * maintain state.
+   * 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either
+   *    stop processing, continue without it, or send it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in chunks, or the entire
+   *    body at once. In either case, the server may send back a ``ProcessingResponse`` for each
+   *    message it receives, or wait for a certain amount of body chunks to be received before
+   *    streaming back the ``ProcessingResponse`` messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back
+   *    a ``ProcessingResponse``.
+   * 5. At this point, request processing is done, and we pick up again at step 1 when the data plane
+   *    receives a response from the upstream server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane
+   *    proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error, then the data plane
+   *    returns a ``500`` error to the client, unless the filter was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but using a gRPC stream to make
+   * it easier for the server to maintain state.
    * 
*/ public static final class ExternalProcessorFutureStub diff --git a/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java index 7dd33f3f483..3a0a1c9fbc8 100644 --- a/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java @@ -186,11 +186,6 @@ ExternalProcessorFilterConfig getFilterConfig() { return filterConfig; } - @VisibleForTesting - ManagedChannel getExtProcChannel() { - return extProcChannel; - } - @Override @SuppressWarnings("unchecked") public ClientCall interceptCall( @@ -232,7 +227,6 @@ public ClientCall interceptCall( io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(extraHeaders)); } - // The filter chain is preceded by RawMessageClientInterceptor, so ReqT and RespT are // InputStream. MethodDescriptor rawMethod = @@ -290,6 +284,42 @@ private static class DataPlaneClientCall private final HeaderMutator mutator = HeaderMutator.create(); private final AtomicInteger pendingRequests = new AtomicInteger(0); private final ProcessingMode currentProcessingMode; + + // Default initial window size + private static final long DEFAULT_INITIAL_WINDOW_SIZE = 65536; + + // Outbound (sending) windows + private long downstreamToSidestreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + private long upstreamToSidestreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + + // Inbound (receiving) windows + private long sidestreamToUpstreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + private long sidestreamToDownstreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + + // Threshold to trigger standalone client window updates + private static final long WINDOW_UPDATE_THRESHOLD = DEFAULT_INITIAL_WINDOW_SIZE / 2; + + // Path 1: Pending/buffered request body messages from downstream + private final Queue pendingRequestBodyMessages = new ConcurrentLinkedQueue<>(); + + // Path 2: Buffered request body messages from ext_proc server to forward upstream + private final Queue pendingUpstreamBodyMessages = + new java.util.concurrent.ConcurrentLinkedQueue<>(); + // Path 4: Outstanding requests from downstream for pulling responses + private int downstreamRequestsPending = 0; + // Buffered mutated response bodies from ext_proc server + private final Queue pendingMutatedResponseBodies = + new java.util.concurrent.ConcurrentLinkedQueue<>(); + // Deferred half-close flag for upstream direction + private final AtomicBoolean pendingUpstreamHalfClose = new AtomicBoolean(false); + + // Accumulated client window updates to send to ext_proc + private long accumulatedWindowUpdateSidestreamToUpstream = 0; + private long accumulatedWindowUpdateSidestreamToDownstream = 0; + + // Flag to track if FlowControlInit was sent in the initial message + private boolean flowControlInitSent = false; + private final MethodDescriptor method; private final Channel channel; private final MetricRecorder metricsRecorder; @@ -316,6 +346,10 @@ private static class DataPlaneClientCall final AtomicBoolean pendingHalfClose = new AtomicBoolean(false); final AtomicBoolean bodyMessageSentToExtProc = new AtomicBoolean(false); + Object getStreamLock() { + return streamLock; + } + protected DataPlaneClientCall( DataPlaneDelayedCall delayedCall, ClientCall rawCall, @@ -343,8 +377,6 @@ protected DataPlaneClientCall( this.backendService = checkNotNull(backendService, "backendService"); } - - private void activateCall() { if ((extProcStreamState.get() == ExtProcStreamState.FAILED && !config.getFailureModeAllow() @@ -411,8 +443,6 @@ private boolean validateCompressionSupport(BodyResponse bodyResponse) { return true; } - - @Override public void start(Listener responseListener, Metadata headers) { this.callContext = Context.current(); @@ -439,6 +469,25 @@ public void onNext(ProcessingResponse response) { return; } + if (response.hasServerWindowUpdate()) { + ProcessingResponse.ServerWindowUpdate update = response.getServerWindowUpdate(); + boolean wasReady = isReady(); + synchronized (streamLock) { + downstreamToSidestreamWindow += update.getWindowIncrementDownstreamToSidestream(); + upstreamToSidestreamWindow += update.getWindowIncrementUpstreamToSidestream(); + drainPendingRequestBodyMessages(); + drainPendingRequests(); + if (wrappedListener != null) { + wrappedListener.drainSavedMessages(); + } + } + // If isReady() becomes true (depends on updated downstreamToSidestreamWindow), + // notify the client application via onReadyNotify() (runs unlocked). + if (!wasReady && isReady()) { + onReadyNotify(); + } + } + if (response.hasImmediateResponse()) { if (config.getDisableImmediateResponse()) { internalOnError(Status.UNAVAILABLE @@ -667,21 +716,94 @@ private void sendToExtProc(ProcessingRequest request) { requestToSend = ProcessingRequest.newBuilder(requestToSend) .setObservabilityMode(true) .build(); + } else if (!flowControlInitSent) { + requestToSend = ProcessingRequest.newBuilder(requestToSend) + .setFlowControlInit(ProcessingRequest.FlowControlInit.newBuilder() + .setInitialWindowDownstreamToSidestream(DEFAULT_INITIAL_WINDOW_SIZE) + .setInitialWindowSidestreamToUpstream(DEFAULT_INITIAL_WINDOW_SIZE) + .setInitialWindowUpstreamToSidestreama(DEFAULT_INITIAL_WINDOW_SIZE) + .setInitialWindowSidestreamToDownstream(DEFAULT_INITIAL_WINDOW_SIZE) + .build()) + .build(); + flowControlInitSent = true; } extProcClientCallRequestObserver.onNext(requestToSend); } } + void mergeAccumulatedWindowUpdates(ProcessingRequest.Builder requestBuilder) { + synchronized (streamLock) { + long incrementUpstream = accumulatedWindowUpdateSidestreamToUpstream; + long incrementDownstream = accumulatedWindowUpdateSidestreamToDownstream; + + if (incrementUpstream > 0 || incrementDownstream > 0) { + requestBuilder.setClientWindowUpdate( + ProcessingRequest.ClientWindowUpdate.newBuilder() + .setWindowIncrementSidestreamToUpstream(incrementUpstream) + .setWindowIncrementSidestreamToDownstream(incrementDownstream) + .build()); + accumulatedWindowUpdateSidestreamToUpstream -= incrementUpstream; + accumulatedWindowUpdateSidestreamToDownstream -= incrementDownstream; + sidestreamToUpstreamWindow += incrementUpstream; + sidestreamToDownstreamWindow += incrementDownstream; + } + } + } + + private void trySendAccumulatedWindowUpdates() { + synchronized (streamLock) { + if (extProcStreamState.get().isCompleted()) { + return; + } + long incrementUpstream = accumulatedWindowUpdateSidestreamToUpstream; + long incrementDownstream = accumulatedWindowUpdateSidestreamToDownstream; + + boolean shouldSend = (incrementUpstream > 0 || incrementDownstream > 0) && ( + (incrementUpstream >= WINDOW_UPDATE_THRESHOLD) + || (incrementDownstream >= WINDOW_UPDATE_THRESHOLD) + || (sidestreamToUpstreamWindow <= 0 && accumulatedWindowUpdateSidestreamToUpstream > 0) + || (sidestreamToDownstreamWindow <= 0 + && accumulatedWindowUpdateSidestreamToDownstream > 0) + ); + + if (shouldSend) { + accumulatedWindowUpdateSidestreamToUpstream -= incrementUpstream; + accumulatedWindowUpdateSidestreamToDownstream -= incrementDownstream; + sidestreamToUpstreamWindow += incrementUpstream; + sidestreamToDownstreamWindow += incrementDownstream; + + sendToExtProc(ProcessingRequest.newBuilder() + .setClientWindowUpdate(ProcessingRequest.ClientWindowUpdate.newBuilder() + .setWindowIncrementSidestreamToUpstream(incrementUpstream) + .setWindowIncrementSidestreamToDownstream(incrementDownstream) + .build()) + .build()); + } + } + } + private void onExtProcStreamReady() { drainPendingRequests(); onReadyNotify(); } - private void drainPendingRequests() { - int toRequest = pendingRequests.getAndSet(0); - if (toRequest > 0) { - super.request(toRequest); + void drainPendingRequests() { + synchronized (streamLock) { + if (config.getObservabilityMode() + || currentProcessingMode.getResponseBodyMode() != ProcessingMode.BodySendMode.GRPC) { + int toRequest = pendingRequests.getAndSet(0); + if (toRequest > 0) { + super.request(toRequest); + } + return; + } + + // Normal mode flow control: pull 1 message at a time + if (isSidecarReady() && upstreamToSidestreamWindow > 0 && pendingRequests.get() > 0) { + super.request(1); + pendingRequests.decrementAndGet(); + } } } @@ -730,7 +852,7 @@ private void onReadyNotify() { wrappedListener.onReadyNotify(); } - private boolean isSidecarReady() { + boolean isSidecarReady() { ExtProcStreamState state = extProcStreamState.get(); if (state.isCompleted()) { return true; @@ -755,11 +877,14 @@ public boolean isReady() { if (dataPlaneCallState.get() == DataPlaneCallState.IDLE && !config.getObservabilityMode()) { return false; } - boolean sidecarReady = isSidecarReady(); - if (config.getObservabilityMode()) { - return super.isReady() && sidecarReady; + synchronized (streamLock) { + boolean sidecarReady = isSidecarReady(); + if (config.getObservabilityMode()) { + return super.isReady() && sidecarReady; + } + return downstreamToSidestreamWindow > 0 && sidecarReady + && pendingRequestBodyMessages.isEmpty(); } - return sidecarReady; } @Override @@ -768,16 +893,43 @@ public void request(int numMessages) { super.request(numMessages); return; } - if (!config.getObservabilityMode() + if (!config.getObservabilityMode() && currentProcessingMode.getResponseBodyMode() != ProcessingMode.BodySendMode.GRPC) { super.request(numMessages); return; } - if (!isSidecarReady()) { - pendingRequests.addAndGet(numMessages); - return; + synchronized (streamLock) { + boolean sendResponseBodiesToExtProc = config.getObservabilityMode() + || currentProcessingMode.getResponseBodyMode() == ProcessingMode.BodySendMode.GRPC; + + if (!sendResponseBodiesToExtProc) { + // We do not send response bodies to ext_proc server at all. Bypassed. + super.request(numMessages); + return; + } + + // We send response bodies to ext_proc server (either in normal GRPC mode or + // observability mode). + // Gated by ext_proc server readiness. + // i.e. normal GRPC response body mode + boolean normalFlowControl = !config.getObservabilityMode(); + + if (normalFlowControl) { + pendingRequests.addAndGet(numMessages); + downstreamRequestsPending += numMessages; + drainPendingMutatedResponseBodies(); + if (isSidecarReady()) { + drainPendingRequests(); + } + } else { + // Observability mode: gate on readiness but pull all at once + if (isSidecarReady()) { + super.request(numMessages); + } else { + pendingRequests.addAndGet(numMessages); + } + } } - super.request(numMessages); } @Override @@ -812,29 +964,60 @@ public void sendMessage(InputStream message) { } return; } - } - if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { - super.sendMessage(message); - return; + if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { + super.sendMessage(message); + return; + } + + // Mode is GRPC + try { + ByteString bodyByteString = outboundStreamToByteString(message); + if (config.getObservabilityMode()) { + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setBody(bodyByteString) + .setEndOfStream(false) + .build()) + .build()); + bodyMessageSentToExtProc.set(true); + super.sendMessage(new KnownLengthInputStream(bodyByteString)); + } else { + if (downstreamToSidestreamWindow <= 0 || !pendingRequestBodyMessages.isEmpty()) { + pendingRequestBodyMessages.add(bodyByteString); + } else { + sendRequestBodyToExtProc(bodyByteString); + } + } + } catch (IOException e) { + rawCall.cancel("Failed to serialize message for External Processor", e); + } } + } - // Mode is GRPC - try { - ByteString bodyByteString = outboundStreamToByteString(message); - sendToExtProc(ProcessingRequest.newBuilder() + private void sendRequestBodyToExtProc(ByteString body) { + synchronized (streamLock) { + downstreamToSidestreamWindow -= body.size(); + ProcessingRequest.Builder builder = ProcessingRequest.newBuilder() .setRequestBody(HttpBody.newBuilder() - .setBody(bodyByteString) + .setBody(body) .setEndOfStream(false) - .build()) - .build()); + .build()); + mergeAccumulatedWindowUpdates(builder); + sendToExtProc(builder.build()); bodyMessageSentToExtProc.set(true); + } + } - if (config.getObservabilityMode()) { - super.sendMessage(new KnownLengthInputStream(bodyByteString)); + private void drainPendingRequestBodyMessages() { + synchronized (streamLock) { + while (downstreamToSidestreamWindow > 0 && !pendingRequestBodyMessages.isEmpty()) { + ByteString body = pendingRequestBodyMessages.poll(); + sendRequestBodyToExtProc(body); + } + if (pendingRequestBodyMessages.isEmpty() && pendingHalfClose.get()) { + halfClose(); } - } catch (IOException e) { - rawCall.cancel("Failed to serialize message for External Processor", e); } } @@ -887,11 +1070,18 @@ public void halfClose() { } // Mode is GRPC - sendToExtProc(ProcessingRequest.newBuilder() - .setRequestBody(HttpBody.newBuilder() - .setEndOfStreamWithoutMessage(true) - .build()) - .build()); + synchronized (streamLock) { + if (!pendingRequestBodyMessages.isEmpty()) { + return; + } + + ProcessingRequest.Builder builder = ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()); + mergeAccumulatedWindowUpdates(builder); + sendToExtProc(builder.build()); + } } @Override @@ -914,11 +1104,39 @@ private void handleRequestBodyResponse(BodyResponse bodyResponse) { if (mutation.hasStreamedResponse()) { StreamedBodyResponse streamed = mutation.getStreamedResponse(); if (!streamed.getEndOfStreamWithoutMessage()) { - super.sendMessage(new KnownLengthInputStream(streamed.getBody())); + com.google.protobuf.ByteString body = streamed.getBody(); + boolean sendImmediately = false; + synchronized (streamLock) { + if (sidestreamToUpstreamWindow <= 0) { + internalOnError(Status.INTERNAL + .withDescription( + "Flow control violation: received client body from ext_proc " + + "when window is closed") + .asRuntimeException()); + return; + } + sidestreamToUpstreamWindow -= body.size(); + if (super.isReady() && pendingUpstreamBodyMessages.isEmpty()) { + sendImmediately = true; + accumulatedWindowUpdateSidestreamToUpstream += body.size(); + } else { + pendingUpstreamBodyMessages.add(body); + } + } + if (sendImmediately) { + super.sendMessage(new KnownLengthInputStream(body)); + trySendAccumulatedWindowUpdates(); + } } if (streamed.getEndOfStream() || streamed.getEndOfStreamWithoutMessage()) { - if (requestSideClosed.compareAndSet(false, true)) { - proceedWithHalfClose(); + synchronized (streamLock) { + if (pendingUpstreamBodyMessages.isEmpty()) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } + } else { + pendingUpstreamHalfClose.set(true); + } } } } @@ -931,7 +1149,89 @@ private void handleResponseBodyResponse( BodyMutation mutation = bodyResponse.getResponse().getBodyMutation(); if (mutation.hasStreamedResponse()) { StreamedBodyResponse streamed = mutation.getStreamedResponse(); - listener.onExternalBody(streamed.getBody()); + com.google.protobuf.ByteString body = streamed.getBody(); + final int bodySize = body.size(); + synchronized (streamLock) { + if (sidestreamToDownstreamWindow <= 0) { + internalOnError(Status.INTERNAL + .withDescription( + "Flow control violation: received server body from ext_proc " + + "when window is closed") + .asRuntimeException()); + return; + } + sidestreamToDownstreamWindow -= bodySize; + } + deliverResponseBody(body, listener); + } + } + } + + private void deliverResponseBody(ByteString body, DataPlaneListener listener) { + synchronized (streamLock) { + if (downstreamRequestsPending > 0) { + downstreamRequestsPending--; + final int bodySize = body.size(); + callContext.run(() -> { + try { + listener.onExternalBody(body); + } finally { + synchronized (streamLock) { + accumulatedWindowUpdateSidestreamToDownstream += bodySize; + } + trySendAccumulatedWindowUpdates(); + } + }); + } else { + pendingMutatedResponseBodies.add(body); + } + } + } + + private void drainPendingMutatedResponseBodies() { + synchronized (streamLock) { + while (downstreamRequestsPending > 0 && !pendingMutatedResponseBodies.isEmpty()) { + ByteString body = pendingMutatedResponseBodies.poll(); + downstreamRequestsPending--; + pendingRequests.decrementAndGet(); + final int bodySize = body.size(); + callContext.run(() -> { + try { + wrappedListener.onExternalBody(body); + } finally { + synchronized (streamLock) { + accumulatedWindowUpdateSidestreamToDownstream += bodySize; + } + trySendAccumulatedWindowUpdates(); + } + }); + } + } + } + + void drainPendingUpstreamBodyMessages() { + while (true) { + ByteString body = null; + boolean triggerHalfClose = false; + synchronized (streamLock) { + if (super.isReady() && !pendingUpstreamBodyMessages.isEmpty()) { + body = pendingUpstreamBodyMessages.poll(); + accumulatedWindowUpdateSidestreamToUpstream += body.size(); + if (pendingUpstreamBodyMessages.isEmpty() + && pendingUpstreamHalfClose.compareAndSet(true, false)) { + triggerHalfClose = true; + } + } + } + if (body == null) { + break; + } + super.sendMessage(new KnownLengthInputStream(body)); + trySendAccumulatedWindowUpdates(); + if (triggerHalfClose) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } } } } @@ -1048,6 +1348,8 @@ AtomicBoolean getIsProcessingTrailers() { private static class DataPlaneListener extends SimpleForwardingClientCallListener { private final ClientCall rawCall; private final DataPlaneClientCall dataPlaneClientCall; + // Path 3: Upstream response bodies queued because upstream to sidestream window not available, + // response headers not cleared by ext_proc or ext_proc stream draining private final Queue savedMessages = new ConcurrentLinkedQueue<>(); private boolean inboundPassThrough = false; @Nullable private volatile Metadata savedHeaders; @@ -1085,6 +1387,8 @@ void setImmediateResponse(Status status, Metadata trailers) { @Override public void onReady() { + dataPlaneClientCall.drainPendingUpstreamBodyMessages(); + dataPlaneClientCall.trySendAccumulatedWindowUpdates(); dataPlaneClientCall.drainPendingRequests(); onReadyNotify(); } @@ -1104,7 +1408,7 @@ public void onHeaders(Metadata headers) { return; } - if (dataPlaneClientCall.getPassThroughMode().get() + if (dataPlaneClientCall.getPassThroughMode().get() || dataPlaneClientCall.getExtProcStreamState().get().isCompleted() || !sendResponseHeaders) { proceedWithHeaders(headers); @@ -1126,7 +1430,7 @@ public void onHeaders(Metadata headers) { @Override public void onMessage(InputStream message) { - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.getStreamLock()) { if (inboundPassThrough) { dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); return; @@ -1145,34 +1449,60 @@ public void onMessage(InputStream message) { } return; } - } - if (dataPlaneClientCall.getPassThroughMode().get()) { - dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); - return; - } + if (dataPlaneClientCall.getPassThroughMode().get()) { + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); + return; + } - if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() - || dataPlaneClientCall.getCurrentProcessingMode().getResponseBodyMode() - != ProcessingMode.BodySendMode.GRPC) { - dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); - return; - } + if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() + || dataPlaneClientCall.getCurrentProcessingMode().getResponseBodyMode() + != ProcessingMode.BodySendMode.GRPC) { + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); + return; + } - try { - ByteString bodyByteString = ByteString.readFrom(message); - sendResponseBodyToExtProc(bodyByteString, false); - dataPlaneClientCall.bodyMessageSentToExtProc.set(true); + try { + ByteString bodyByteString = ByteString.readFrom(message); + if (dataPlaneClientCall.getConfig().getObservabilityMode()) { + sendResponseBodyToExtProc(bodyByteString, false); + dataPlaneClientCall.bodyMessageSentToExtProc.set(true); + dataPlaneClientCall.getCallContext().run( + () -> delegate().onMessage(bodyByteString.newInput())); + } else { + if (dataPlaneClientCall.upstreamToSidestreamWindow <= 0 || !savedMessages.isEmpty()) { + savedMessages.add(new KnownLengthInputStream(bodyByteString)); + } else { + dataPlaneClientCall.upstreamToSidestreamWindow -= bodyByteString.size(); + sendResponseBodyToExtProc(bodyByteString, false); + dataPlaneClientCall.bodyMessageSentToExtProc.set(true); + } + dataPlaneClientCall.drainPendingRequests(); + } + } catch (IOException e) { + rawCall.cancel("Failed to read server response", e); + } + } + } - if (dataPlaneClientCall.getConfig().getObservabilityMode()) { - // If needed, downstream reading can be made more optimal by creating a wrapped - // Inputstream wraps the underlying bytestring and that implements HasByteBuffer, - // Detachable, KnownLength - dataPlaneClientCall.getCallContext().run( - () -> delegate().onMessage(bodyByteString.newInput())); + void drainSavedMessages() { + synchronized (dataPlaneClientCall.getStreamLock()) { + while (dataPlaneClientCall.isSidecarReady() + && dataPlaneClientCall.upstreamToSidestreamWindow > 0 + && !savedMessages.isEmpty()) { + InputStream msg = savedMessages.poll(); + if (msg != null) { + try { + ByteString bodyByteString = ByteString.readFrom(msg); + dataPlaneClientCall.upstreamToSidestreamWindow -= bodyByteString.size(); + sendResponseBodyToExtProc(bodyByteString, false); + dataPlaneClientCall.bodyMessageSentToExtProc.set(true); + } catch (IOException e) { + rawCall.cancel("Failed to read buffered response body", e); + } + } } - } catch (IOException e) { - rawCall.cancel("Failed to read server response", e); + dataPlaneClientCall.drainPendingRequests(); } } @@ -1231,7 +1561,7 @@ void onReadyNotify() { void proceedWithHeaders() { if (savedHeaders != null) { proceedWithHeaders(savedHeaders); - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.getStreamLock()) { savedHeaders = null; if (!dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { InputStream msg; @@ -1291,7 +1621,7 @@ void unblockAfterStreamComplete() { } private void proceedWithSavedMessages() { - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.getStreamLock()) { InputStream msg; while ((msg = savedMessages.poll()) != null) { final InputStream finalMsg = msg; @@ -1376,9 +1706,10 @@ private void sendResponseBodyToExtProc( } bodyBuilder.setEndOfStream(endOfStream); - dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() - .setResponseBody(bodyBuilder.build()) - .build()); + ProcessingRequest.Builder builder = ProcessingRequest.newBuilder() + .setResponseBody(bodyBuilder.build()); + dataPlaneClientCall.mergeAccumulatedWindowUpdates(builder); + dataPlaneClientCall.sendToExtProc(builder.build()); } } } diff --git a/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java b/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java index 2f7387c1f12..de507889ed5 100644 --- a/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java +++ b/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java @@ -71,11 +71,8 @@ import io.grpc.stub.StreamObserver; import io.grpc.testing.GrpcCleanupRule; import io.grpc.util.MutableHandlerRegistry; -import io.grpc.xds.ConfigOrError; import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterConfig; import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterOverrideConfig; -import io.grpc.xds.Filter; -import io.grpc.xds.XdsNameResolver; import io.grpc.xds.client.Bootstrapper; import io.grpc.xds.client.EnvoyProtoData.Node; import io.grpc.xds.internal.grpcservice.CachedChannelManager; @@ -91,6 +88,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; @@ -112,6 +110,10 @@ */ @RunWith(JUnit4.class) public class ExternalProcessorClientInterceptorTest { + private static final String INSECURE_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials"; + static { System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); } @@ -3461,7 +3463,6 @@ public void onClose(Status status, Metadata trailers) { long startTime = System.currentTimeMillis(); while (sidecarBodyLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } assertThat(capturedRequest.get().getResponseBody().getBody().toStringUtf8()) .isEqualTo("Server Message"); @@ -3469,7 +3470,6 @@ public void onClose(Status status, Metadata trailers) { while ((appMessageLatch.getCount() > 0 || appCloseLatch.getCount() > 0) && System.currentTimeMillis() - startTime < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } proxyCall.cancel("Cleanup", null); @@ -3619,16 +3619,13 @@ public void onClose(Status status, Metadata trailers) { long startTime = System.currentTimeMillis(); while (sidecarBodyLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } while (appMessageLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } assertThat(capturedMessage.get()).isEqualTo("Mutated Server"); while (appCloseLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } proxyCall.cancel("Cleanup", null); @@ -5946,7 +5943,6 @@ public void onReady() { // Wait for sidecar call to start and listener to be captured long startTime = System.currentTimeMillis(); while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(sidecarListenerRef.get()).isNotNull(); @@ -6270,7 +6266,6 @@ public void onClose(Status status, Metadata trailers) { assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); // Wait for the drain signal to be received and processed by client call - Thread.sleep(100); // Call is now in DRAINING state. // Send a message. Since request_body_mode is NONE, it should go directly to data plane. @@ -6769,13 +6764,12 @@ public void onMessage(String message) { assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); // Wait for the drain signal to be received and processed by client call - Thread.sleep(100); // Send response headers first (they bypass ext_proc because send mode is default SKIP, so // they proceed immediately) StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); upstreamResponseObserver.onNext("Dummy for headers"); - + // Now call is in DRAINING state, and savedHeaders is null. // Send response body message. Since response_body_mode is NONE, it should go directly // downstream. @@ -6894,7 +6888,6 @@ public void onHeaders(Metadata headers) { assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); // Wait for the drain signal to be received and processed by client call - Thread.sleep(100); // Call is in DRAINING state. // Send response headers from server. Since response_header_mode is SKIP, they should go @@ -7015,7 +7008,6 @@ public void onClose(Status status, Metadata trailers) { assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); // Wait for the drain signal to be received and processed by client call - Thread.sleep(100); // Call is in DRAINING state. // Complete the server call. Since response_trailer_mode is SKIP, onClose should trigger @@ -7120,7 +7112,6 @@ public void onCompleted() { // Use a small loop because of SerializingExecutor delay even with directExecutor. long start = System.currentTimeMillis(); while (proxyCall.isReady() && System.currentTimeMillis() - start < 2000) { - Thread.sleep(10); } assertThat(proxyCall.isReady()).isFalse(); @@ -7175,10 +7166,10 @@ public void onNext(ProcessingRequest request) { sidecarOnNextLatch.countDown(); try { if (sidecarFinishLatch.await(5, TimeUnit.SECONDS)) { - sidecarOnCompletedLatch.countDown(); synchronized (responseObserver) { responseObserver.onCompleted(); } + sidecarOnCompletedLatch.countDown(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -7280,10 +7271,6 @@ public void onReady() { // After sidecar stream completes, it should trigger onReady and become ready assertThat(onReadyLatch.await(5, TimeUnit.SECONDS)).isTrue(); - for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { - fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); - Thread.sleep(10); - } assertThat(proxyCall.isReady()).isTrue(); proxyCall.cancel("Cleanup", null); @@ -7519,7 +7506,6 @@ public void onMessage(String message) { // Wait for drain to be processed long startTime = System.currentTimeMillis(); while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(proxyCall.isReady()).isFalse(); @@ -7532,7 +7518,6 @@ public void onMessage(String message) { // Wait for it to become ready again startTime = System.currentTimeMillis(); while (!proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(proxyCall.isReady()).isTrue(); @@ -8512,7 +8497,6 @@ public void request(int numMessages) { // Wait for sidecar call to start long startTime = System.currentTimeMillis(); while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(sidecarListenerRef.get()).isNotNull(); @@ -8638,7 +8622,6 @@ public void request(int numMessages) { // Wait for drain to be processed long startTime = System.currentTimeMillis(); while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(proxyCall.isReady()).isFalse(); @@ -8776,7 +8759,6 @@ public void request(int numMessages) { // Wait for sidecar call to start long startTime = System.currentTimeMillis(); while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); } assertThat(sidecarListenerRef.get()).isNotNull(); @@ -9761,7 +9743,6 @@ public void onClose(Status status, Metadata trailers) { // Wait for sidecar to receive headers and filter to activate call for (int i = 0; i < 5000 && closedLatch.getCount() > 0; i++) { fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); - Thread.sleep(1); } // Trigger request body processing to hit the unsupported compression check @@ -9840,7 +9821,8 @@ public void onNext(ProcessingRequest request) { .build()) .build()); } else if (request.hasResponseBody()) { - // Simulate sidecar sending compressed body mutation (unsupported) for response body + // Simulate sidecar sending compressed body mutation (unsupported) for + // response body responseObserver.onNext(ProcessingResponse.newBuilder() .setResponseBody(BodyResponse.newBuilder() .setResponse(CommonResponse.newBuilder() @@ -10048,7 +10030,6 @@ public void onCompleted() { for (int i = 0; i < 1000 && finishLatch.getCount() > 0; i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -10069,12 +10050,138 @@ public void onCompleted() { || !extProcServer.isTerminated()); i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } channelManager.close(); } } + @Test + @SuppressWarnings("unchecked") + public void testObservabilityMode_ProceedsWithoutBlockingOnExtProcResponseHeaders() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + final AtomicReference extProcReceivedRequest = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasResponseHeaders()) { + extProcReceivedRequest.set(request); + extProcReceivedHeadersLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final List appReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + final CountDownLatch appMessageLatch = new CountDownLatch(1); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference appReceivedHeaders = new AtomicReference<>(); + final AtomicReference appReceivedStatus = new AtomicReference<>(); + + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + appReceivedHeaders.set(headers); + } + + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + appMessageLatch.countDown(); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appReceivedStatus.set(status); + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + proxyCall.request(1); + + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify data plane server received the request and processed it + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // In observability mode, the app should receive response headers and messages immediately + // without waiting for the external processor stream to complete. + assertThat(appMessageLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedHeaders.get()).isNotNull(); + assertThat(appReceivedMessages).containsExactly("Hello test"); + + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedStatus.get().isOk()).isTrue(); + + // Also verify that the external processor received the response headers in the background + assertThat(extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(extProcReceivedRequest.get().hasResponseHeaders()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + // --- Category 17: Immediate Response Handling --- @Test @@ -10390,7 +10497,6 @@ public void onCompleted() { for (int i = 0; i < 1000 && closedLatch.getCount() > 0; i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } // Verify app listener notified with an error (not the sidecar's UNAUTHENTICATED) assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -10404,7 +10510,6 @@ public void onCompleted() { i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } channelManager.close(); } @@ -10514,7 +10619,6 @@ public void onCompleted() { for (int i = 0; i < 1000 && closedLatch.getCount() > 0; i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } // Verify app listener notified with UNIMPLEMENTED because data plane connection succeeded // but the method was not registered, and it failed before the ext-proc stream failed @@ -10529,7 +10633,6 @@ public void onCompleted() { i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); i++) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); } channelManager.close(); } @@ -10786,7 +10889,6 @@ public void onCompleted() { // Wait for activation for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); - Thread.sleep(10); } assertThat(proxyCall.isReady()).isTrue(); @@ -10826,6 +10928,7 @@ public void givenObservabilityModeFalse_whenExtProcBusy_thenIsReadyReturnsFalse( new java.util.concurrent.CopyOnWriteArrayList<>(); // Sidecar server final CountDownLatch sidecarActionLatch = new CountDownLatch(1); + final CountDownLatch responseSentLatch = new CountDownLatch(1); ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override @@ -10845,6 +10948,7 @@ public void onNext(ProcessingRequest request) { .setRequestHeaders(HeadersResponse.newBuilder().build()) .build()); } + responseSentLatch.countDown(); } }).start(); } @@ -10928,10 +11032,7 @@ public boolean isReady() { // Wait for activation assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); - for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { - fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); - Thread.sleep(10); - } + assertThat(responseSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(proxyCall.isReady()).isTrue(); // Sidecar becomes busy -> proxyCall becomes busy @@ -10967,7 +11068,9 @@ public void givenObservabilityModeFalse_whenExtProcBusy_thenAppRequestsAreBuffer .build()) .build()) .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) .build()) .setObservabilityMode(false) @@ -10979,6 +11082,7 @@ public void givenObservabilityModeFalse_whenExtProcBusy_thenAppRequestsAreBuffer // Sidecar server final CountDownLatch sidecarActionLatch = new CountDownLatch(1); + final CountDownLatch responseSentLatch = new CountDownLatch(1); ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override @@ -10997,6 +11101,27 @@ public void onNext(ProcessingRequest request) { .setRequestHeaders(HeadersResponse.newBuilder().build()) .build()); } + responseSentLatch.countDown(); + } else if (request.hasResponseHeaders()) { + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } else if (request.hasResponseBody()) { + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } } }).start(); } @@ -11053,13 +11178,31 @@ public boolean isReady() { ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); - dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello"); - responseObserver.onCompleted(); - })) - .build()); + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); ManagedChannel dataPlaneChannel = grpcCleanup.register( @@ -11083,116 +11226,4783 @@ public void request(int numMessages) { CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + interceptCall(interceptor, METHOD_BIDI_STREAMING, callOptions, dataPlaneChannel); proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); // Bootstrap request for headers // Wait for activation assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); - for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { - fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); - Thread.sleep(10); - } + assertThat(responseSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(proxyCall.isReady()).isTrue(); // Sidecar busy -> request(5) should be buffered sidecarReady.set(false); proxyCall.request(5); - assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + assertThat(dataPlaneRequestCount.get()).isEqualTo(1); + // (Only the initial bootstrap request went through) // Sidecar becomes ready -> buffered requests should be drained sidecarReady.set(true); sidecarListenerRef.get().onReady(); long startTime2 = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 2 && System.currentTimeMillis() - startTime2 < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(2); + + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + + // Server sends response headers (Dummy) + upstreamResponseObserver.onNext("Dummy for headers"); + + startTime2 = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 3 && System.currentTimeMillis() - startTime2 < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(3); + + // Server sends first data message -> pulls next + upstreamResponseObserver.onNext("Msg 1"); + + startTime2 = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 4 && System.currentTimeMillis() - startTime2 < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(4); + + // Server sends second data message -> pulls next + upstreamResponseObserver.onNext("Msg 2"); + + startTime2 = System.currentTimeMillis(); while (dataPlaneRequestCount.get() < 5 && System.currentTimeMillis() - startTime2 < 5000) { fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); } assertThat(dataPlaneRequestCount.get()).isEqualTo(5); + // Server sends third data message -> pulls next (which drains the final pending request) + upstreamResponseObserver.onNext("Msg 3"); + + startTime2 = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 6 && System.currentTimeMillis() - startTime2 < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(6); + proxyCall.cancel("Cleanup", null); channelManager.close(); } - // --- Category 21: Streaming Completeness (Client & Bi-Di) --- - @Test - @SuppressWarnings({"unchecked", "FutureReturnValueIgnored"}) - public void givenClientStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveMutatedData() + @SuppressWarnings("unchecked") + public void givenResponseBodyModeNone_whenExtProcBusy_thenAppRequestsAreNotBuffered() throws Exception { - String uniqueExtProcServerName = - "extProc-client-stream-" + InProcessServerBuilder.generateName(); - String uniqueDataPlaneServerName = - "dataPlane-client-stream-" + InProcessServerBuilder.generateName(); - ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) .setProcessingMode(ProcessingMode.newBuilder() - .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) - .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) .build()) + .setObservabilityMode(false) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - final Metadata.Key reqKey = - Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); - - final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); - final CountDownLatch sidecarActionLatch = new CountDownLatch(6); - final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); - // External Processor Server + // Sidecar server + final CountDownLatch sidecarActionLatch = new CountDownLatch(1); + final CountDownLatch responseSentLatch = new CountDownLatch(1); ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override + @SuppressWarnings("unchecked") public StreamObserver process( final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); return new StreamObserver() { @Override public void onNext(ProcessingRequest request) { - sidecarResponseExecutor.submit(() -> { - synchronized (responseObserver) { - ProcessingResponse.Builder resp = ProcessingResponse.newBuilder(); - if (request.hasRequestHeaders()) { - receivedPhases.add("REQ_HEADERS"); - resp.setRequestHeaders( - HeadersResponse.newBuilder() - .setResponse( - CommonResponse.newBuilder() - .setHeaderMutation( - HeaderMutation.newBuilder() - .addSetHeaders( - io.envoyproxy.envoy.config.core.v3.HeaderValueOption - .newBuilder() - .setHeader( - io.envoyproxy.envoy.config.core.v3.HeaderValue - .newBuilder() - .setKey("req-mutated") - .setValue("true") - .build()) - .build()) - .build()) - .build()) - .build()); - } else if (request.hasRequestBody()) { - if (request.getRequestBody().getEndOfStream() - || request.getRequestBody().getEndOfStreamWithoutMessage()) { - receivedPhases.add("REQ_BODY_EOS"); - resp.setRequestBody( - BodyResponse.newBuilder() - .setResponse( - CommonResponse.newBuilder() - .setBodyMutation( + new Thread(() -> { + if (request.hasRequestHeaders()) { + sidecarActionLatch.countDown(); + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + responseSentLatch.countDown(); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void request(int numMessages) { + dataPlaneRequestCount.addAndGet(numMessages); + super.request(numMessages); + } + }; + } + }) + .build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); // Bootstrap request + + // Wait for activation + assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(responseSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar server busy + sidecarReady.set(false); + + // Since responseBodyMode is NONE and not in observabilityMode, request(5) should + // be passed upstream immediately + proxyCall.request(5); + + long startTime = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 6 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(6); // 1 bootstrap + 5 requested + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testFlowControlStateInitialization() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(2); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + sidecarLatch.countDown(); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.sendMessage("Message 1"); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedRequests).hasSize(2); + ProcessingRequest firstRequest = receivedRequests.get(0); + ProcessingRequest secondRequest = receivedRequests.get(1); + + assertThat(firstRequest.hasRequestHeaders()).isTrue(); + assertThat(firstRequest.hasFlowControlInit()).isTrue(); + assertThat(firstRequest.getFlowControlInit().getInitialWindowDownstreamToSidestream()) + .isEqualTo(65536); + assertThat(firstRequest.getFlowControlInit().getInitialWindowSidestreamToUpstream()) + .isEqualTo(65536); + + assertThat(secondRequest.hasRequestBody()).isTrue(); + assertThat(secondRequest.hasFlowControlInit()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testDownstreamToSidestreamFlowControl_EnforcesWindow() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch firstBodyLatch = new CountDownLatch(2); // Headers + First Body + final CountDownLatch secondBodyLatch = new CountDownLatch(1); + final AtomicReference> + responseObserverRef = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + firstBodyLatch.countDown(); + } else if (request.hasRequestBody()) { + if (request.getRequestBody().getEndOfStreamWithoutMessage()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()) + .build()) + .build()) + .build()) + .build()); + return; + } + if (firstBodyLatch.getCount() > 0) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + firstBodyLatch.countDown(); + } else { + // This is the second body (30000 bytes) + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + secondBodyLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List dataPlaneReceivedMessages = new CopyOnWriteArrayList<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_CLIENT_STREAMING, ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + dataPlaneReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final List dataPlaneResponseMessages = new CopyOnWriteArrayList<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + final AtomicReference callClosedStatus = new AtomicReference<>(); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + dataPlaneResponseMessages.add(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + callClosedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + + // Generate large messages + String largeMessage70k = new String(new char[70000]).replace('\0', 'a'); + String largeMessage30k = new String(new char[30000]).replace('\0', 'b'); + + // Send first message (70000 bytes) - fits in 65536 window + proxyCall.sendMessage(largeMessage70k); + assertThat(firstBodyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isFalse(); + + // Send second message (30000 bytes) - total 100000 > 65536, should buffer + proxyCall.sendMessage(largeMessage30k); + + // Call halfClose() while the second message is still buffered. + // This should NOT trigger immediate half-close, but mark pendingHalfClose = true. + proxyCall.halfClose(); + + // Assert that it is NOT delivered to ext_proc (delivery is synchronous on + // directExecutor, so we can check immediately) + assertThat(receivedRequests).hasSize(3); + // (Headers + First Body + Client Window Update (Path 2 replenishment)) + assertThat(proxyCall.isReady()).isFalse(); + + // Now send ServerWindowUpdate from ext_proc to interceptor to increment window by 40000 + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementDownstreamToSidestream(40000) + .build()) + .build()); + + // The second body should now be flushed and received by ext_proc, and then half-closed + assertThat(secondBodyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify both messages reached the backend service + assertThat(dataPlaneReceivedMessages) + .containsExactly(largeMessage70k, largeMessage30k).inOrder(); + + // Wait for the call to close successfully. + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(callClosedStatus.get().isOk()).isTrue(); + assertThat(dataPlaneResponseMessages).containsExactly("Response"); + + // The mock ext_proc should have received 5 requests: + // 1. Headers + // 2. First Body (70000) + // 3. Client Window Update + // 4. Second Body (30000) + // 5. EndOfStreamWithoutMessage (half-close) + assertThat(receivedRequests.size()).isEqualTo(5); + assertThat(receivedRequests.get(0).hasRequestHeaders()).isTrue(); + assertThat(receivedRequests.get(1).hasRequestBody()).isTrue(); + assertThat(receivedRequests.get(2).hasClientWindowUpdate()).isTrue(); + assertThat(receivedRequests.get(3).hasRequestBody()).isTrue(); + assertThat(receivedRequests.get(3).getRequestBody().getBody().size()).isEqualTo(30000); + assertThat(receivedRequests.get(4).hasRequestBody()).isTrue(); + assertThat(receivedRequests.get(4).getRequestBody().getEndOfStreamWithoutMessage()).isTrue(); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testUpstreamToSidestreamFlowControl_EnforcesWindow() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(4); + // (Headers, Request Body, Response Headers, Response Body 1) + final CountDownLatch secondResponseBodyLatch = new CountDownLatch(1); + final AtomicReference> + responseObserverRef = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + sidecarLatch.countDown(); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + com.google.protobuf.ByteString originalBody = request.getResponseBody().getBody(); + com.google.protobuf.ByteString bodyToSend = originalBody; + // Return the original 70,000 bytes as-is + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(bodyToSend) + .build()) + .build()) + .build()) + .build()) + .build()); + if (originalBody.size() == 30000) { + secondResponseBodyLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final List appReceivedMessages = new CopyOnWriteArrayList<>(); + final CountDownLatch messagesLatch2 = new CountDownLatch(2); + final CountDownLatch messagesLatch3 = new CountDownLatch(3); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + messagesLatch2.countDown(); + messagesLatch3.countDown(); + } + }; + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(appListener, new Metadata()); + proxyCall.request(10); + + // Send first dummy message to initialize headers and stream + proxyCall.sendMessage("Client Msg"); + + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + upstreamResponseObserver.onNext("Dummy for headers"); + + String largeMessage70k = new String(new char[70000]).replace('\0', 'a'); + String largeMessage30k = new String(new char[30000]).replace('\0', 'b'); + + // Wait for the initialization (headers, request body, response headers) to reach the ext_proc + // server + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Upstream sends 70k response chunk. Since window is 65,536, this drives the window + // negative (-4,464). + upstreamResponseObserver.onNext(largeMessage70k); + + // Verify Chunk 1 is successfully delivered (2 messages total in app: dummy and chunk 1) + assertThat(messagesLatch2.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessages).hasSize(2); + + // Upstream sends 30k response chunk. Since the window is negative, the filter + // must block/buffer this chunk. + upstreamResponseObserver.onNext(largeMessage30k); + + // Wait a brief period and verify that the 30k chunk has NOT been sent to the ext_proc server + assertThat(secondResponseBodyLatch.getCount()).isEqualTo(1); + assertThat(appReceivedMessages).hasSize(2); + + // Sidecar server sends a ServerWindowUpdate of 40k to the filter, unblocking the window. + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementUpstreamToSidestream(40000) + .build()) + .build()); + + // Once the window is unblocked, the filter immediately forwards the 30k chunk + // to the ext_proc server, which processes it. + assertThat(secondResponseBodyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify that Chunk 2 is now successfully delivered to the client application + assertThat(messagesLatch3.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessages).hasSize(3); + assertThat(appReceivedMessages.get(2)).isEqualTo(largeMessage30k); + assertThat(receivedRequests).isNotEmpty(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToDownstreamFlowControl_Violations() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final String mutatedMessageTooLarge = new String(new char[70000]).replace('\0', 'd'); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + // Send Chunk 1 (70k) -> Consumed by client app's pending request count of 1. + // Replenishes window. + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8(mutatedMessageTooLarge)) + .build()) + .build()) + .build()) + .build()) + .build()); + + // Send Chunk 2 (70k) -> Excess chunk. Stored in queue. Window stays negative. + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8(mutatedMessageTooLarge)) + .build()) + .build()) + .build()) + .build()) + .build()); + + // Send Chunk 3 (1 byte) -> Arrives when window is negative, triggering violation. + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("a")) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + + proxyCall.sendMessage("Client Msg"); + + // Send a response from upstream to trigger headers and then the body response + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + upstreamResponseObserver.onNext("Response Msg"); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The call should fail immediately with INTERNAL error code due to flow control violation + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getDescription()) + + .isEqualTo("External processor stream failed"); + assertThat(capturedStatus.get().getCause()).isInstanceOf(io.grpc.StatusRuntimeException.class); + assertThat(capturedStatus.get().getCause().getMessage()) + .contains("Flow control violation: received server body from ext_proc " + + "when window is closed"); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToUpstreamFlowControl_Violations() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final String mutatedMessageTooLarge = new String(new char[70000]).replace('\0', 'c'); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + ByteString original = request.getRequestBody().getBody(); + if (original.toStringUtf8().equals("Message 1")) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8(mutatedMessageTooLarge)) + .build()) + .build()) + .build()) + .build()) + .build()); + } else { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("a")) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_CLIENT_STREAMING, ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + // Build the data plane channel with a custom ClientInterceptor. + // This interceptor overrides isReady() to always return false. + // This simulates a blocked backend server + // (transport flow control buffer is full) and blocks the filter's upstream + // window replenishment. + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return false; + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + // Send first message. Window becomes negative. + proxyCall.sendMessage("Message 1"); + + // Send second message. Window is still negative, triggering flow control violation. + proxyCall.sendMessage("Message 2"); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The call should fail immediately with INTERNAL error code due to flow control violation + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getDescription()) + + .isEqualTo("External processor stream failed"); + assertThat(capturedStatus.get().getCause()).isInstanceOf(io.grpc.StatusRuntimeException.class); + assertThat(capturedStatus.get().getCause().getMessage()) + .contains("Flow control violation: received client body from ext_proc " + + "when window is closed"); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToUpstreamFlowControl_QueuingAndDraining() throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch finishLatch = new CountDownLatch(1); + final List serverReceivedBodies = new CopyOnWriteArrayList<>(); + final CountDownLatch serverReceivedLatch = new CountDownLatch(2); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + ByteString original = request.getRequestBody().getBody(); + boolean eos = + request.getRequestBody().getEndOfStream() + || request.getRequestBody().getEndOfStreamWithoutMessage(); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFromUtf8( + eos + ? "" + : "Mutated" + + original + .toStringUtf8())) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + serverReceivedBodies.add(value); + serverReceivedLatch.countDown(); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(false); + final AtomicReference> capturedListenerRef = new AtomicReference<>(); + + class TriggerableForwardingCall + extends io.grpc.ForwardingClientCall.SimpleForwardingClientCall { + TriggerableForwardingCall(ClientCall delegate) { + super(delegate); + } + + @Override + public void start(Listener responseListener, Metadata headers) { + capturedListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + } + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new TriggerableForwardingCall<>(next.newCall(method, callOptions)); + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + proxyCall.start( + new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + finishLatch.countDown(); + } + }, + new Metadata()); + proxyCall.request(1); + + // Send first message. This gets mutated to "MutatedOriginalRequest 1" by ext_proc. + proxyCall.sendMessage("OriginalRequest 1"); + + // Give some time to process and ensure the message is NOT received on the server side because + // transport is not ready + assertThat(serverReceivedLatch.await(500, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(serverReceivedBodies).isEmpty(); + + // Now make the transport ready (super.isReady() returns true), but do NOT trigger onReady drain + // yet. + transportReady.set(true); + + // Send second message. This gets mutated to "MutatedOriginalRequest 2" by ext_proc. + // Since transportReady is true but there's still a pending message in the queue, + // the second message should also be queued (to preserve order). + proxyCall.sendMessage("OriginalRequest 2"); + + // Ensure still no message is received on the server side (since we haven't triggered drain via + // onReady) + assertThat(serverReceivedLatch.await(500, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(serverReceivedBodies).isEmpty(); + + // Now trigger onReady callback to drain the queue. + ClientCall.Listener listener = capturedListenerRef.get(); + assertThat(listener).isNotNull(); + listener.onReady(); + + // Both messages should be drained and forwarded to the backend server in order. + assertThat(serverReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedBodies) + .containsExactly("MutatedOriginalRequest 1", "MutatedOriginalRequest 2") + .inOrder(); + + proxyCall.halfClose(); + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToUpstreamFlowControl_DelayedHalfClose() throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List serverReceivedBodies = new CopyOnWriteArrayList<>(); + final CountDownLatch serverReceivedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFromUtf8("Mutated1")) + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + serverReceivedBodies.add(value); + serverReceivedLatch.countDown(); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(false); + final AtomicReference> capturedListenerRef = new AtomicReference<>(); + final AtomicInteger halfCloseCallCount = new AtomicInteger(0); + + class DelayedHalfCloseForwardingCall + extends io.grpc.ForwardingClientCall.SimpleForwardingClientCall { + DelayedHalfCloseForwardingCall(ClientCall delegate) { + super(delegate); + } + + @Override + public void start(Listener responseListener, Metadata headers) { + capturedListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + + @Override + public void halfClose() { + halfCloseCallCount.incrementAndGet(); + super.halfClose(); + } + } + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new DelayedHalfCloseForwardingCall<>(next.newCall(method, callOptions)); + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + + // Send the first client message. This gets mutated to "Mutated1" by ext_proc. + proxyCall.sendMessage("OriginalRequest 1"); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Since transportReady is false, the mutated body is queued in pendingUpstreamBodyMessages. + // And since it was unilateral half-close, pendingUpstreamHalfClose is set to true. + // Verify that the call is NOT half-closed on transport yet. + assertThat(halfCloseCallCount.get()).isEqualTo(0); + assertThat(serverReceivedBodies).isEmpty(); + + // Now make the transport ready and trigger onReady callback + transportReady.set(true); + ClientCall.Listener listener = capturedListenerRef.get(); + assertThat(listener).isNotNull(); + listener.onReady(); + + // The queued message should be drained, forwarded to backend server, + // and the delayed half-close should be triggered. + assertThat(serverReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedBodies).containsExactly("Mutated1"); + assertThat(halfCloseCallCount.get()).isEqualTo(1); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToUpstreamFlowControl_FailOpenDuringDelayedHalfClose() + throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .setFailureModeAllow(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + // Unilaterally send a request body response containing mutated body and + // endOfStream = true + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFromUtf8("Mutated1")) + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(false); + final AtomicReference> capturedListenerRef = new AtomicReference<>(); + final AtomicInteger halfCloseCallCount = new AtomicInteger(0); + final AtomicInteger sendMessageCount = new AtomicInteger(0); + + class FailOpenDelayedHalfCloseForwardingCall + extends io.grpc.ForwardingClientCall.SimpleForwardingClientCall { + FailOpenDelayedHalfCloseForwardingCall(ClientCall delegate) { + super(delegate); + } + + @Override + public void start(Listener responseListener, Metadata headers) { + capturedListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + + @Override + public void sendMessage(ReqT message) { + sendMessageCount.incrementAndGet(); + super.sendMessage(message); + } + + @Override + public void halfClose() { + halfCloseCallCount.incrementAndGet(); + super.halfClose(); + } + } + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept( + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions callOptions, + Channel next) { + return new FailOpenDelayedHalfCloseForwardingCall<>( + next.newCall(method, callOptions)); + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + + // Call halfClose immediately. This sets pendingHalfClose = true. + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Since transportReady is false, the mutated body is queued. + // And since it was unilateral half-close, pendingUpstreamHalfClose is set to true. + // Verify that the call is NOT half-closed on transport yet. + assertThat(halfCloseCallCount.get()).isEqualTo(0); + + // Fail the ext_proc stream to trigger fail-open. + responseObserverRef.get().onError(Status.INTERNAL.asRuntimeException()); + + // Fail-open will see pendingHalfClose = true, set requestSideClosed = true, + // and immediately invoke transport.halfClose() + assertThat(halfCloseCallCount.get()).isEqualTo(1); + + // Now make transport ready and trigger onReady callback + transportReady.set(true); + ClientCall.Listener listener = capturedListenerRef.get(); + assertThat(listener).isNotNull(); + listener.onReady(); + + // The queued message should be drained, forwarded to backend server call. + // The delayed half-close is triggered, but since requestSideClosed was already true (via + // fail-open), + // it will evaluate to false in compareAndSet and NOT invoke transport.halfClose() again. + assertThat(sendMessageCount.get()).isEqualTo(1); + // Verify that transport.halfClose() was still called exactly once + assertThat(halfCloseCallCount.get()).isEqualTo(1); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToDownstreamFlowControl_QueuingAndWithholdingWindowUpdates() + throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch headersLatch = new CountDownLatch(1); + final CountDownLatch firstBodyResponseLatch = new CountDownLatch(1); + final CountDownLatch secondBodyResponseLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + headersLatch.countDown(); + } else if (request.hasResponseBody()) { + ByteString body = request.getResponseBody().getBody(); + boolean eos = request.getResponseBody().getEndOfStream(); + if (body.size() == 40001) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + firstBodyResponseLatch.countDown(); + + // Send second body (40002) - spoofed + ByteString body2 = + ByteString.copyFromUtf8(new String(new char[40002]).replace('\0', 'y')); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body2) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + secondBodyResponseLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + final List receivedResponses = Collections.synchronizedList(new ArrayList<>()); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + receivedResponses.add(message); + } + }, new Metadata()); + + // Wait for the headers handshake to complete and activate the call + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.request(1); + + String resp1 = new String(new char[40001]).replace('\0', 'x'); + dataPlaneResponseObserverRef.get().onNext(resp1); + + assertThat(firstBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedResponses).containsExactly(resp1); + + List windowUpdates = new ArrayList<>(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } + } + assertThat(windowUpdates).hasSize(1); + assertThat( + windowUpdates.get(0).getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + .isEqualTo(40001); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSidestreamToDownstreamFlowControl_DrainingAndSendingWindowUpdates() + throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch headersLatch = new CountDownLatch(1); + final CountDownLatch firstBodyResponseLatch = new CountDownLatch(1); + final CountDownLatch secondBodyResponseLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + headersLatch.countDown(); + } else if (request.hasResponseBody()) { + ByteString body = request.getResponseBody().getBody(); + boolean eos = request.getResponseBody().getEndOfStream(); + if (body.size() == 40001) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + firstBodyResponseLatch.countDown(); + + // Send second body (40002) - spoofed + ByteString body2 = + ByteString.copyFromUtf8(new String(new char[40002]).replace('\0', 'y')); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body2) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + secondBodyResponseLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + final List receivedResponses = Collections.synchronizedList(new ArrayList<>()); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + receivedResponses.add(message); + } + }, new Metadata()); + + // Wait for the headers handshake to complete and activate the call + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.request(1); + + String resp1 = new String(new char[40001]).replace('\0', 'x'); + String resp2 = new String(new char[40002]).replace('\0', 'y'); + dataPlaneResponseObserverRef.get().onNext(resp1); + + assertThat(firstBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedResponses).containsExactly(resp1); + + List windowUpdates = new ArrayList<>(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } + } + assertThat(windowUpdates).hasSize(1); + assertThat( + windowUpdates.get(0).getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + .isEqualTo(40001); + + proxyCall.request(1); + assertThat(receivedResponses).containsExactly(resp1, resp2); + + windowUpdates.clear(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } + } + assertThat(windowUpdates).hasSize(2); + assertThat( + windowUpdates.get(1).getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + .isEqualTo(40002); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testThresholdBasedWindowUpdates() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List> observers = new ArrayList<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + observers.add(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); + assertThat(filterClientRequests(receivedRequests).get(0).hasRequestHeaders()).isTrue(); + + // 1. Send body message. It should be sent immediately. + proxyCall.sendMessage("Msg 1"); // size = 5 bytes + + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + assertThat(filterClientRequests(receivedRequests).get(1).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(1) + .getRequestBody().getBody().toStringUtf8()) + + .isEqualTo("Msg 1"); + // No window updates were accumulated yet. + assertThat(filterClientRequests(receivedRequests).get(1).hasClientWindowUpdate()).isFalse(); + + // 2. Trigger window replenishment below threshold (e.g. 5 bytes from Msg 1 response). + // The interceptor processes the response, forwards it upstream, and increments + // accumulatedWindowUpdateSidestreamToUpstream. Since 5 < 32768, it won't send + // standalone updates. + // We send another message "Msg 2" to trigger piggybacking. + proxyCall.sendMessage("Msg 2"); + + assertThat(filterClientRequests(receivedRequests)).hasSize(3); + assertThat(filterClientRequests(receivedRequests).get(2).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getRequestBody().getBody().toStringUtf8()) + + .isEqualTo("Msg 2"); + // Verify accumulated 5 bytes update is piggybacked. + assertThat(filterClientRequests(receivedRequests).get(2).hasClientWindowUpdate()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(5); + + // 3. Accumulate past threshold (e.g. 35,000 bytes) without sending body messages. + // This should trigger an immediate standalone ClientWindowUpdate. + StreamObserver responseObserver = observers.get(0); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[35000])) + .build()) + .build()) + .build()) + .build()) + .build()); + + // standalone client window update received. + assertThat(filterClientRequests(receivedRequests)).hasSize(4); + assertThat(filterClientRequests(receivedRequests).get(3).hasClientWindowUpdate()).isTrue(); + assertThat( + filterClientRequests(receivedRequests) + .get(3) + .getClientWindowUpdate() + .getWindowIncrementSidestreamToUpstream()) + .isEqualTo(35005); + assertThat(filterClientRequests(receivedRequests).get(3).hasRequestBody()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testWindowUpdateWithheldWhenUpstreamCapacityExistsAndBelowThreshold() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(2); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + // Mutate request body and send back 10000 bytes (below threshold 32768) + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[10000])) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_CLIENT_STREAMING, ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Send 10k message to ext_proc. + String body10k = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(body10k); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Since the window has capacity (65536 - 10000 = 55536 > 0) and the increment (10000) + // is below the threshold, NO window update should be sent. + // receivedRequests should only contain Headers and RequestBody (size = 2). + assertThat(receivedRequests).hasSize(2); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testWindowUpdateWithheldWhenDownstreamCapacityExistsAndBelowThreshold() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = + createBaseProto(uniqueExtProcServerName) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch headersLatch = new CountDownLatch(1); + final CountDownLatch bodyResponseLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + headersLatch.countDown(); + } else if (request.hasResponseBody()) { + boolean eos = request.getResponseBody().getEndOfStream(); + // Mutate response body and send back 10000 bytes (below threshold 32768) + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[10000])) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + bodyResponseLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + final List receivedResponses = Collections.synchronizedList(new ArrayList<>()); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + receivedResponses.add(message); + } + }, new Metadata()); + + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.request(1); + + String resp1 = new String(new char[10000]).replace('\0', 'x'); + dataPlaneResponseObserverRef.get().onNext(resp1); + + assertThat(bodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The response body (10000) should be delivered. + assertThat(receivedResponses).hasSize(1); + + // Since the window has capacity (65536 - 10000 = 55536 > 0) and the increment (10000) + // is below the threshold, NO window update should be sent to the ext_proc. + // receivedRequests should only contain Headers and ResponseBody (size = 2). + assertThat(receivedRequests).hasSize(2); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testWindowUpdateWithheldOnUpstreamWindowExhaustionWithZeroIncrement() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = + createBaseProto(uniqueExtProcServerName) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch sidecarLatch = new CountDownLatch(2); + final CountDownLatch responseBodyProcessedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + sidecarLatch.countDown(); + // Mutate request body and send back 70000 bytes (exhausts upstream return + // window). + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[70000])) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseBody()) { + // Mutate response body and send back 20000 bytes (below threshold) + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[20000])) + .build()) + .build()) + .build()) + .build()) + .build()); + responseBodyProcessedLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(false); + final AtomicReference> dataPlaneListenerRef = new AtomicReference<>(); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + dataPlaneListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Send 10k message to ext_proc. + String body10k = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(body10k); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedRequests).hasSize(2); // Headers + RequestBody + + // Request 1 response body message to deliver. + proxyCall.request(1); + + // Send a response from data plane to trigger downstream delivery. + String responseMsg = new String(new char[20000]).replace('\0', 'y'); + dataPlaneResponseObserverRef.get().onNext(responseMsg); + + assertThat(responseBodyProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Since the window is exhausted but isReady() is false, and the drained upstream increment is + // 0, + // the trySendAccumulatedWindowUpdates() logic evaluates "iu > 0" (2nd) to False. + // So no window update is sent. + // receivedRequests should only contain Headers, RequestBody, and ResponseBody (size = 3). + assertThat(receivedRequests).hasSize(3); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testWindowUpdateWithheldOnDownstreamWindowExhaustionWithZeroIncrement() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = + createBaseProto(uniqueExtProcServerName) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch sidecarLatch = new CountDownLatch(1); // Headers + final CountDownLatch responseBodyReceivedLatch = new CountDownLatch(1); + final CountDownLatch requestBodyProcessedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseBody()) { + // Mutate response body and send back 70000 bytes (exhausts downstream return + // window). + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[70000])) + .build()) + .build()) + .build()) + .build()) + .build()); + responseBodyReceivedLatch.countDown(); + } else if (request.hasRequestBody()) { + // Mutate request body and send back 20000 bytes (below threshold). + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFrom(new byte[20000])) + .build()) + .build()) + .build()) + .build()) + .build()); + requestBodyProcessedLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(true); + @SuppressWarnings("rawtypes") + final AtomicReference dataPlaneListenerRef = new AtomicReference<>(); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + @SuppressWarnings("unchecked") + public void start(Listener responseListener, Metadata headers) { + dataPlaneListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // 1. Manually trigger onMessage on the client listener. + // Since proxyCall.request() was never called, downstreamRequestsPending is 0. + // So the mutated body from ext_proc will be queued in pendingMutatedResponseBodies, + // and accumulated downstream increment (id) will remain 0. + String responseMsg = new String(new char[10000]).replace('\0', 'y'); + InputStream responseStream = METHOD_BIDI_STREAMING.streamResponse(responseMsg); + dataPlaneListenerRef.get().onMessage(responseStream); + + assertThat(responseBodyReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // receivedRequests contains Headers + ResponseBody + assertThat(receivedRequests).hasSize(2); + + // 2. Now send a request body of size 10000. + // It will be sent to ext_proc, mutated to 20000 bytes (below threshold), and delivered + // immediately to raw call. + // During trySendAccumulatedWindowUpdates(), RHS evaluates to False (since id = 0, wd <= 0, but + // id > 0 (2nd) is False). + // So no window update is sent. + String requestMsg = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(requestMsg); + + assertThat(requestBodyProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // receivedRequests should only contain Headers, ResponseBody, and RequestBody (size = 3). + assertThat(receivedRequests).hasSize(3); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testExtProcUnilateralHalfClose_PreventsDuplicateHalfClose() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch extProcUnilateralLatch = new CountDownLatch(1); + final CountDownLatch clientHalfCloseProcessedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + HttpBody requestBody = request.getRequestBody(); + if (requestBody.getEndOfStreamWithoutMessage()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + clientHalfCloseProcessedLatch.countDown(); + } else { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(requestBody.getBody()) + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + extProcUnilateralLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicInteger halfCloseCallCount = new AtomicInteger(0); + final CountDownLatch serverResponseLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + new Thread( + () -> { + try { + serverResponseLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + }) + .start(); + } + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void halfClose() { + halfCloseCallCount.incrementAndGet(); + super.halfClose(); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + + // Send Message 1. This will trigger the unilateral half-close from mock ext_proc. + proxyCall.sendMessage("Message 1"); + + assertThat(extProcUnilateralLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify that the transport halfClose was called once due to unilateral half-close + assertThat(halfCloseCallCount.get()).isEqualTo(1); + + // Now call halfClose() on proxyCall. + // This should send endOfStreamWithoutMessage to ext_proc, and receive endOfStream response. + // However, it should NOT trigger a second halfClose() on the transport. + proxyCall.halfClose(); + + assertThat(clientHalfCloseProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify that the transport halfClose count remains 1 + assertThat(halfCloseCallCount.get()).isEqualTo(1); + + // Let the server complete now + serverResponseLatch.countDown(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testHalfClosePiggybacking() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); + + proxyCall.sendMessage("Last Message"); + + // Verify 12 bytes are accumulated but no standalone update is sent. + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + + proxyCall.halfClose(); + + // Verify halfClose sends EOF request piggybacking the accumulated 12 bytes update. + assertThat(filterClientRequests(receivedRequests)).hasSize(3); + assertThat(filterClientRequests(receivedRequests).get(2).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getRequestBody().getEndOfStreamWithoutMessage()) + + .isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2).hasClientWindowUpdate()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(12); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testPiggybackingOnRequestBody() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + // Mutate Msg 1 to be 15 bytes + if (request.getRequestBody().getBody().toStringUtf8().equals("Msg 1")) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[15])) + .build()) + .build()) + .build()) + .build()) + .build()); + } else { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + // Forward response body mutation (20 bytes) + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(10); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); // Headers request + + // Send Msg 1 (5 bytes). It is processed by ext_proc server and mutated to 15 bytes. + proxyCall.sendMessage("Msg 1"); + + // Wait until Msg 1 request and response are processed + long startTime = System.currentTimeMillis(); + while (filterClientRequests(receivedRequests).size() < 2 + && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + assertThat(filterClientRequests(receivedRequests).get(1).hasRequestBody()).isTrue(); + + // Trigger response headers and body (20 bytes) from upstream + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + upstreamResponseObserver.onNext("Dummy for headers"); + + // Wait until response headers are processed by ext_proc server + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 3 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + String msg20Bytes = new String(new char[20]).replace('\0', 's'); + upstreamResponseObserver.onNext(msg20Bytes); + + // Wait until response body is processed by ext_proc server + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 4 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + // Now send Msg 2 from app (5 bytes). This should piggyback the accumulated updates. + proxyCall.sendMessage("Msg 2"); + + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 6 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(receivedRequests).hasSize(6); + + // receivedRequests.get(3) is RespBody(Dummy for headers). + // It should piggyback the accumulated 15 bytes update. + ProcessingRequest dummyRespBodyReq = receivedRequests.get(3); + assertThat(dummyRespBodyReq.hasResponseBody()).isTrue(); + assertThat(dummyRespBodyReq.hasClientWindowUpdate()).isTrue(); + assertThat(dummyRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(15); + assertThat(dummyRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + + .isEqualTo(0); + + // receivedRequests.get(5) is ReqBody(Msg 2). + // It should piggyback the accumulated 20 bytes update. + ProcessingRequest msg2Req = receivedRequests.get(5); + assertThat(msg2Req.hasRequestBody()).isTrue(); + assertThat(msg2Req.getRequestBody().getBody().toStringUtf8()).isEqualTo("Msg 2"); + assertThat(msg2Req.hasClientWindowUpdate()).isTrue(); + assertThat(msg2Req.getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(0); + assertThat(msg2Req.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + + .isEqualTo(20); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testPiggybackingOnResponseBody() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasRequestBody()) { + // Mutate Msg 1 to be 15 bytes + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[15])) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + // Forward response body mutation + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(10); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); // Headers request + + // Send Msg 1 (5 bytes) from app. It is processed by ext_proc server and mutated to 15 bytes. + proxyCall.sendMessage("Msg 1"); + + // Wait until Msg 1 request and response are processed + long startTime = System.currentTimeMillis(); + while (filterClientRequests(receivedRequests).size() < 2 + && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + + // Trigger response headers and first response body (20 bytes) from upstream + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + upstreamResponseObserver.onNext("Dummy for headers"); + + // Wait until response headers are processed by ext_proc server + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 3 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + String msg20Bytes = new String(new char[20]).replace('\0', 's'); + upstreamResponseObserver.onNext(msg20Bytes); + + // Wait until response body is processed by ext_proc server + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 4 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + // Now send a second response body (5 bytes) from upstream. + upstreamResponseObserver.onNext("Msg 2"); + + startTime = System.currentTimeMillis(); + while (receivedRequests.size() < 6 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(receivedRequests).hasSize(6); + + // receivedRequests.get(3) is RespBody(Dummy for headers). + // It should piggyback the accumulated 15 bytes update. + ProcessingRequest dummyRespBodyReq = receivedRequests.get(3); + assertThat(dummyRespBodyReq.hasResponseBody()).isTrue(); + assertThat(dummyRespBodyReq.hasClientWindowUpdate()).isTrue(); + assertThat(dummyRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(15); + assertThat(dummyRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + + .isEqualTo(0); + + // receivedRequests.get(4) is RespBody(ssssssssssssssssssss). + // It should piggyback the accumulated 17 bytes update + // (from "Dummy for headers" which is 17 bytes). + ProcessingRequest ssssRespBodyReq = receivedRequests.get(4); + assertThat(ssssRespBodyReq.hasResponseBody()).isTrue(); + assertThat(ssssRespBodyReq.hasClientWindowUpdate()).isTrue(); + assertThat(ssssRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(0); + assertThat(ssssRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + + .isEqualTo(17); + + // receivedRequests.get(5) is RespBody(Msg 2). + // It should piggyback the accumulated 20 bytes update + // (from ssssssssssssssssssss). + ProcessingRequest msg2Req = receivedRequests.get(5); + assertThat(msg2Req.hasResponseBody()).isTrue(); + assertThat(msg2Req.getResponseBody().getBody().toStringUtf8()).isEqualTo("Msg 2"); + assertThat(msg2Req.hasClientWindowUpdate()).isTrue(); + assertThat(msg2Req.getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(0); + assertThat(msg2Req.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + + .isEqualTo(20); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testClientWindowUpdateDeferredUntilRequestBodySendMessage() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(2); // Headers + Request Body + final AtomicReference> + responseObserverRef = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + sidecarLatch.countDown(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + // Mutate request body and send back 40000 bytes. This triggers client window + // update replenishment. + sidecarLatch.countDown(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[40000])) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_CLIENT_STREAMING, ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final CountDownLatch blockSendMessageLatch = new CountDownLatch(1); + final CountDownLatch sendMessageEnteredLatch = new CountDownLatch(1); + final CountDownLatch sendMessageFinishedLatch = new CountDownLatch(1); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void sendMessage(ReqT message) { + sendMessageEnteredLatch.countDown(); + try { + blockSendMessageLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + super.sendMessage(message); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Send 40k message to ext_proc. + String body40k = new String(new char[40000]).replace('\0', 'a'); + + // Call sendMessage in a background thread to avoid blocking the main test execution thread + new Thread(() -> { + proxyCall.sendMessage(body40k); + sendMessageFinishedLatch.countDown(); + }).start(); + + // Wait until interceptor's super.sendMessage() enters the custom interceptor and blocks + assertThat(sendMessageEnteredLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // No standalone ClientWindowUpdate should be sent while blocked + assertThat(receivedRequests).hasSize(2); // Only Headers and Request Body requests sent so far + + // Unblock the sendMessage call + blockSendMessageLatch.countDown(); + + // Wait for the interceptor to complete super.sendMessage() and send the window update + assertThat(sendMessageFinishedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedRequests).hasSize(3); + assertThat(receivedRequests.get(2).hasClientWindowUpdate()).isTrue(); + assertThat(receivedRequests.get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(40000); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testClientWindowUpdateDeferredUntilResponseBodyOnMessage() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(3); + // (Request Headers, Response Headers, Response Body 1) + final AtomicReference> + responseObserverRef = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + sidecarLatch.countDown(); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + // Forward response body as-is. + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final CountDownLatch blockOnMessageLatch = new CountDownLatch(1); + final CountDownLatch onMessageEnteredLatch = new CountDownLatch(1); + final CountDownLatch onNextFinishedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onMessage(String message) { + if (message.length() == 40000) { + onMessageEnteredLatch.countDown(); + try { + blockOnMessageLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + }; + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(appListener, new Metadata()); + proxyCall.request(10); + + // Call client to activate + proxyCall.sendMessage("Client Msg"); + + StreamObserver upstreamResponseObserver = dataPlaneResponseObserverRef.get(); + upstreamResponseObserver.onNext("Dummy for headers"); + + // Trigger response body from upstream in a background thread to avoid blocking + // the main test execution thread + String response40k = new String(new char[40000]).replace('\0', 'a'); + new Thread(() -> { + upstreamResponseObserver.onNext(response40k); + onNextFinishedLatch.countDown(); + }).start(); + + // Wait until client app's onMessage enters and blocks + assertThat(onMessageEnteredLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // No standalone ClientWindowUpdate should be sent while blocked + assertThat(receivedRequests).hasSize(4); + // (Headers + Response Headers + Dummy Body + Response 40k Body) + + // Unblock the onMessage call + blockOnMessageLatch.countDown(); + + // Wait for the interceptor to complete onMessage processing and send the window update + assertThat(onNextFinishedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedRequests).hasSize(5); + assertThat(receivedRequests.get(4).hasClientWindowUpdate()).isTrue(); + assertThat( + receivedRequests + .get(4) + .getClientWindowUpdate() + .getWindowIncrementSidestreamToDownstream()) + .isEqualTo(40000); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testClientWindowUpdateSentImmediatelyOnSidestreamToUpstreamWindowExhaustion() + throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(2); // Headers + Request Body + final CountDownLatch windowUpdateLatch = new CountDownLatch(1); // Window Update + final AtomicReference> + responseObserverRef = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + sidecarLatch.countDown(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + sidecarLatch.countDown(); + // Mutate request body and send back 20000 bytes. + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[20000])) + .build()) + .build()) + .build()) + .build()) + .build()); + // Mutate request body and send back 50000 bytes. + // Total 70000 bytes completely exhausts the return window (starts at 65536). + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFrom(new byte[50000])) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasClientWindowUpdate()) { + windowUpdateLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + final AtomicBoolean transportReady = new AtomicBoolean(false); + final AtomicReference> dataPlaneListenerRef = new AtomicReference<>(); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept( + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions callOptions, + Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + dataPlaneListenerRef.set(responseListener); + super.start(responseListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + transportReady.set(false); + super.sendMessage(message); + } + + @Override + public boolean isReady() { + return transportReady.get(); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Send 10k message to ext_proc. + String body10k = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(body10k); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Since the window is exhausted but isReady() is false, no window update should be sent. + assertThat(receivedRequests).hasSize(2); + + // Now, trigger transport ready. This should flush only the first message (20000) and set ready + // to false. + transportReady.set(true); + dataPlaneListenerRef.get().onReady(); + + // Wait for the window update to be received by ext_proc + assertThat(windowUpdateLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedRequests).hasSize(3); + assertThat(receivedRequests.get(2).hasClientWindowUpdate()).isTrue(); + assertThat(receivedRequests.get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + .isEqualTo(20000); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testClientWindowUpdateSentImmediatelyOnSidestreamToDownstreamWindowExhaustion() + throws Exception { + ExternalProcessor proto = + ExternalProcessor.newBuilder() + .setGrpcService( + GrpcService.newBuilder() + .setGoogleGrpc( + GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin( + Any.newBuilder().setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL).build()) + .build()) + .build()) + .setProcessingMode( + ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch headersLatch = new CountDownLatch(1); + final CountDownLatch firstBodyResponseLatch = new CountDownLatch(1); + final CountDownLatch secondBodyResponseLatch = new CountDownLatch(1); + final CountDownLatch thirdBodyResponseLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + headersLatch.countDown(); + } else if (request.hasResponseBody()) { + ByteString body = request.getResponseBody().getBody(); + boolean eos = request.getResponseBody().getEndOfStream(); + if (body.size() == 50000) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + firstBodyResponseLatch.countDown(); + + // Send second body (20000) - spoofed + ByteString body2 = ByteString.copyFrom(new byte[20000]); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body2) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + secondBodyResponseLatch.countDown(); + + // Send third body (50000) - spoofed + ByteString body3 = ByteString.copyFrom(new byte[50000]); + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(body3) + .setEndOfStream(eos) + .build()) + .build()) + .build()) + .build()) + .build()); + thirdBodyResponseLatch.countDown(); + } + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + final List receivedResponses = Collections.synchronizedList(new ArrayList<>()); + proxyCall.start( + new ClientCall.Listener() { + @Override + public void onMessage(String message) { + receivedResponses.add(message); + } + }, + new Metadata()); + + // Wait for the headers handshake to complete and activate the call + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.request(1); + + String resp1 = new String(new char[50000]).replace('\0', 'x'); + dataPlaneResponseObserverRef.get().onNext(resp1); + + assertThat(firstBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(thirdBodyResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The first response body (50000) should be delivered. + assertThat(receivedResponses).containsExactly(resp1); + + // Let's filter out window updates received by mock ext_proc. + // There should be exactly 1 window update (for the first response body, since 50000 >= 32768). + List windowUpdates = new ArrayList<>(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } + } + assertThat(windowUpdates).hasSize(1); + assertThat( + windowUpdates.get(0).getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) + .isEqualTo(50000); + + // Now, request another message. + // This will deliver the second body (20000) which was queued. + // Since the window was exhausted (initial 65536 - 50000 - 20000 - 50000 = -54464 <= 0), + // delivering the second body (20000) should immediately trigger a window update of 20000, + // even though 20000 is less than the threshold (32768). + proxyCall.request(1); + + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(1).length()).isEqualTo(20000); + + windowUpdates.clear(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } + } + // We should now have 2 window updates. + assertThat(windowUpdates).hasSize(2); + assertThat( + windowUpdates + .get(1) + .getClientWindowUpdate() + .getWindowIncrementSidestreamToDownstream()) + .isEqualTo(20000); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 21: Streaming Completeness (Client & Bi-Di) --- + + @Test + @SuppressWarnings({"unchecked", "FutureReturnValueIgnored"}) + public void givenClientStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveMutatedData() + throws Exception { + String uniqueExtProcServerName = + "extProc-client-stream-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-client-stream-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final Metadata.Key reqKey = + Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); + + final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch sidecarActionLatch = new CountDownLatch(5); + final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + sidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + ProcessingResponse.Builder resp = ProcessingResponse.newBuilder(); + if (request.hasRequestHeaders()) { + receivedPhases.add("REQ_HEADERS"); + resp.setRequestHeaders( + HeadersResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setHeaderMutation( + HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("req-mutated") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + if (request.getRequestBody().getEndOfStreamWithoutMessage()) { + receivedPhases.add("REQ_BODY_EOS"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .setEndOfStreamWithoutMessage(true) + .build()) + .build()) + .build()) + .build()); + } else if (request.getRequestBody().getEndOfStream()) { + receivedPhases.add("REQ_BODY_MSG"); + receivedPhases.add("REQ_BODY_EOS"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( BodyMutation.newBuilder() .setStreamedResponse( StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8( + "MutatedRequest")) .setEndOfStream(true) - .setEndOfStreamWithoutMessage( - request.getRequestBody() - .getEndOfStreamWithoutMessage()) .build()) .build()) .build()) @@ -11342,6 +16152,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); final AtomicReference clientReceivedBody = new AtomicReference<>(); StreamObserver requestObserver = ClientCalls.asyncClientStreamingCall( interceptCall(interceptor, @@ -11356,6 +16167,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11372,7 +16184,10 @@ public void onCompleted() { throw new AssertionError("Sidecar actions failed. Received: " + receivedPhases); } assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); - + if (errorRef.get() != null) { + throw new AssertionError("RPC failed", errorRef.get()); + } + List expectedPhases = Arrays.asList( "REQ_HEADERS", @@ -11420,7 +16235,7 @@ public void givenBidiStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveMut Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); - final CountDownLatch sidecarBidiLatch = new CountDownLatch(6); + final CountDownLatch sidecarBidiLatch = new CountDownLatch(5); final ExecutorService bidiSidecarResponseExecutor = Executors.newSingleThreadExecutor(); // External Processor Server ExternalProcessorGrpc.ExternalProcessorImplBase bidiExtProcImpl; @@ -11456,8 +16271,24 @@ public void onNext(ProcessingRequest request) { .build()) .build()); } else if (request.hasRequestBody()) { - if (request.getRequestBody().getEndOfStream() - || request.getRequestBody().getEndOfStreamWithoutMessage()) { + if (request.getRequestBody().getEndOfStreamWithoutMessage()) { + receivedPhases.add("REQ_BODY_EOS"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .setEndOfStreamWithoutMessage(true) + .build()) + .build()) + .build()) + .build()); + } else if (request.getRequestBody().getEndOfStream()) { + receivedPhases.add("REQ_BODY_MSG"); receivedPhases.add("REQ_BODY_EOS"); resp.setRequestBody( BodyResponse.newBuilder() @@ -11467,10 +16298,9 @@ public void onNext(ProcessingRequest request) { BodyMutation.newBuilder() .setStreamedResponse( StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8( + "MutatedBidiReq")) .setEndOfStream(true) - .setEndOfStreamWithoutMessage( - request.getRequestBody() - .getEndOfStreamWithoutMessage()) .build()) .build()) .build()) @@ -11630,6 +16460,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); StreamObserver bidiRequestObserver = ClientCalls.asyncBidiStreamingCall( interceptCall(interceptor, METHOD_BIDI_STREAMING, @@ -11643,6 +16474,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11659,7 +16491,10 @@ public void onCompleted() { throw new AssertionError("Sidecar bidi actions failed. Received: " + receivedPhases); } assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); - + if (errorRef.get() != null) { + throw new AssertionError("RPC failed", errorRef.get()); + } + List expectedPhases = Arrays.asList( "REQ_HEADERS", @@ -15630,11 +20465,26 @@ public void onClose(Status status, Metadata trailers) { channelManager.close(); } + private static List filterClientRequests(List requests) { + List clientRequests = new ArrayList<>(); + for (ProcessingRequest r : requests) { + if (r.hasRequestHeaders() || r.hasRequestBody() + || (r.hasClientWindowUpdate() && !r.hasResponseBody() + && !r.hasResponseHeaders() && !r.hasResponseTrailers())) { + clientRequests.add(r); + } + } + return clientRequests; + } + private static ClientCall interceptCall( ExternalProcessorClientInterceptor interceptor, MethodDescriptor method, CallOptions callOptions, Channel next) { + if (callOptions.getExecutor() == null) { + callOptions = callOptions.withExecutor(MoreExecutors.directExecutor()); + } Channel intercepted = ClientInterceptors.interceptForward( next, Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto index b07811d5235..668ba0077ff 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto @@ -98,7 +98,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // ` object in a namespace matching the filter // name. // -// [#next-free-field: 26] +// [#next-free-field: 28] message ExternalProcessor { // Describes the route cache action to be taken when an external processor response // is received in response to request headers. @@ -285,14 +285,6 @@ message ExternalProcessor { // // 3. External processor may still close the stream to indicate that no more messages are needed. // - // .. warning:: - // - // Flow control is a necessary mechanism to prevent the fast sender (either downstream client or upstream server) - // from overwhelming the external processor when its processing speed is slower. - // This protective measure is being explored and developed but has not been ready yet, so please use your own - // discretion when enabling this feature. - // This work is currently tracked under https://github.com/envoyproxy/envoy/issues/33319. - // bool observability_mode = 17; // Prevents clearing the route-cache when the @@ -307,6 +299,17 @@ message ExternalProcessor { // received in response to request headers. It is recommended to set this field rather than set // :ref:`disable_clear_route_cache `. // Only one of ``disable_clear_route_cache`` or ``route_cache_action`` can be set. + // + // .. attention:: + // + // Clearing the route cache can cause Envoy to recompute route matching after earlier HTTP + // filters have already processed the request. This can be security-sensitive when filters + // that make route-dependent authorization decisions, such as the RBAC filter, run before + // ext_proc and ext_proc mutates route-matching inputs. + // + // Operators should only enable route cache clearing for trusted external processors, should + // carefully order route-dependent authorization filters, and should use mutation_rules to + // restrict sensitive mutations when appropriate. RouteCacheAction route_cache_action = 18 [(udpa.annotations.field_migrate).oneof_promotion = "clear_route_cache_type"]; @@ -369,6 +372,30 @@ message ExternalProcessor { // // The default status is ``HTTP 500 Internal Server Error``. type.v3.HttpStatus status_on_error = 24; + + // If true, the filter will not remove the ``content-length`` header from the request/response after external processing. + // It is typically used in + // :ref:`FULL_DUPLEX_STREAMED ` + // mode. If the original body has been modified, the external processing server needs to set the correct content-length header in HeaderMutation + // that matches the modified body length. + // + // .. warning:: + // + // This configuration should only be used if you are sure that the content length matches + // the body length after external processing. Otherwise, it may cause vulnerability issues such as + // request smuggling. Thus, please use your own discretion when enabling this feature. + // + bool allow_content_length_header = 26; + + // In ``FULL_DUPLEX_STREAMED`` body send mode, if the data plane does not + // receive the first response message on the ext_proc stream within this + // timeout, it will assume that the ext_proc server does not support flow + // control, and the stream will proceed without using flow control. + // This knob exists for backward compatibility with legacy ext_proc + // servers. + // + // [#not-implemented-hide:] + google.protobuf.Duration flow_control_init_timeout = 27; } // ExtProcHttpService is used for HTTP communication between the filter and the external processing service. diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto b/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto index 1c033c08d26..a02779033be 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto @@ -23,37 +23,31 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: External processing service] -// A service that can access and modify HTTP requests and responses -// as part of a filter chain. +// A service that can access and modify HTTP requests and responses as part of a filter chain. // The overall external processing protocol works like this: // // 1. The data plane sends to the service information about the HTTP request. -// 2. The service sends back a ProcessingResponse message that directs -// the data plane to either stop processing, continue without it, or send -// it the next chunk of the message body. -// 3. If so requested, the data plane sends the server the message body in -// chunks, or the entire body at once. In either case, the server may send -// back a ProcessingResponse for each message it receives, or wait for -// a certain amount of body chunks received before streaming back the -// ProcessingResponse messages. -// 4. If so requested, the data plane sends the server the HTTP trailers, -// and the server sends back a ProcessingResponse. -// 5. At this point, request processing is done, and we pick up again -// at step 1 when the data plane receives a response from the upstream -// server. -// 6. At any point above, if the server closes the gRPC stream cleanly, -// then the data plane proceeds without consulting the server. -// 7. At any point above, if the server closes the gRPC stream with an error, -// then the data plane returns a 500 error to the client, unless the filter -// was configured to ignore errors. +// 2. The service sends back a ``ProcessingResponse`` message that directs the data plane to either +// stop processing, continue without it, or send it the next chunk of the message body. +// 3. If so requested, the data plane sends the server the message body in chunks, or the entire +// body at once. In either case, the server may send back a ``ProcessingResponse`` for each +// message it receives, or wait for a certain amount of body chunks to be received before +// streaming back the ``ProcessingResponse`` messages. +// 4. If so requested, the data plane sends the server the HTTP trailers, and the server sends back +// a ``ProcessingResponse``. +// 5. At this point, request processing is done, and we pick up again at step 1 when the data plane +// receives a response from the upstream server. +// 6. At any point above, if the server closes the gRPC stream cleanly, then the data plane +// proceeds without consulting the server. +// 7. At any point above, if the server closes the gRPC stream with an error, then the data plane +// returns a ``500`` error to the client, unless the filter was configured to ignore errors. // -// In other words, the process is a request/response conversation, but -// using a gRPC stream to make it easier for the server to -// maintain state. +// In other words, the process is a request/response conversation, but using a gRPC stream to make +// it easier for the server to maintain state. service ExternalProcessor { // This begins the bidirectional stream that the data plane will use to // give the server control over what the filter does. The actual - // protocol is described by the ProcessingRequest and ProcessingResponse + // protocol is described by the ``ProcessingRequest`` and ``ProcessingResponse`` // messages below. rpc Process(stream ProcessingRequest) returns (stream ProcessingResponse) { } @@ -61,30 +55,98 @@ service ExternalProcessor { // This message specifies the filter protocol configurations which will be sent to the ext_proc // server in a :ref:`ProcessingRequest `. -// If the server does not support these protocol configurations, it may choose to close the gRPC stream. -// If the server supports these protocol configurations, it should respond based on the API specifications. +// If the server does not support these protocol configurations, it may choose to close the gRPC +// stream. If the server supports these protocol configurations, it should respond based on the +// API specifications. message ProtocolConfiguration { - // Specify the filter configuration :ref:`request_body_mode - // ` + // Specifies the filter configuration + // :ref:`request_body_mode `. envoy.extensions.filters.http.ext_proc.v3.ProcessingMode.BodySendMode request_body_mode = 1 [(validate.rules).enum = {defined_only: true}]; - // Specify the filter configuration :ref:`response_body_mode - // ` + // Specifies the filter configuration + // :ref:`response_body_mode `. envoy.extensions.filters.http.ext_proc.v3.ProcessingMode.BodySendMode response_body_mode = 2 [(validate.rules).enum = {defined_only: true}]; - // Specify the filter configuration :ref:`send_body_without_waiting_for_header_response - // ` - // If the client is waiting for a header response from the server, setting ``true`` means the client will send body to the server - // as they arrive. Setting ``false`` means the client will buffer the arrived data and not send it to the server immediately. + // Specifies the filter configuration + // :ref:`send_body_without_waiting_for_header_response `. + // If the client is waiting for a header response from the server, setting to ``true`` means the + // client will send the body to the server as it arrives. Setting to ``false`` means the client + // will buffer the arrived data and not send it to the server immediately. bool send_body_without_waiting_for_header_response = 3; } // This represents the different types of messages that the data plane can send // to an external processing server. -// [#next-free-field: 12] +// [#next-free-field: 14] message ProcessingRequest { + // Initial flow control window sizes for ``FULL_DUPLEX_STREAMED`` and + // ``GRPC`` body send modes. + // + // A sender starts with this amount of flow control window. Whenever + // it sends body data, it must decrement its flow control window by + // the number of bytes that it has sent. When its flow control + // window is less than or equal to the amount of body data it wishes + // to send, it may not send until it receives a window update causing + // its flow control window to be large enough. + // + // However, note that in ``GRPC`` body send mode, whenever the flow + // control window is greater than zero, a sender may send a single + // message, even if the size of that message exceeds the available flow + // control window. At that point, the flow control window will be negative + // and the sender must not send the next message until it becomes positive. + // + // Note that the initial size for the to-sidestream windows are set by + // the sender, not the receiver. This is because each sidestream may be + // routed to a different ext_proc server instance, but there is no + // connection-level handshake to set a default for that server + // instance, so the only alternative here would be to have the + // ext_proc server instance set this on a per-stream basis, which + // would require an additional round-trip and therefore hurt latency. + // This unfortunately means that the ext_proc server instance has a + // bit less control: as soon as it receives these initial values, it can + // immediately send a window update that reduces the window, but it + // must be prepared to handle any data that the sender has already sent. + // The initial sizes for the to-sidestream windows are generally + // expected to be in the range of 32K to 64K. + // + // In ``FULL_DUPLEX_STREAMED`` body send mode, for backward compatibility + // with existing ext_proc servers that do not support flow control, if + // the ext_proc server's first response does not include a window + // update, or if the data plane does not receive the first response + // from the ext_proc server within the configured timeout, then the + // data plane will assume that the ext_proc server does not support + // flow control, and it will proceed without it. + // + // [#not-implemented-hide:] + message FlowControlInit { + // Downstream-to-sidestream initial window size. + int64 initial_window_downstream_to_sidestream = 1; + + // Sidestream-to-upstream initial window size. + int64 initial_window_sidestream_to_upstream = 2; + + // Upstream-to-sidestream initial window size. + int64 initial_window_upstream_to_sidestreama = 3; + + // Sidestream-to-downstream initial window size. + int64 initial_window_sidestream_to_downstream = 4; + } + + // Flow control window update. Values may be positive or negative. The + // sender must immediately add these values to its flow control window, + // which governs how much data can be sent. + // + // [#not-implemented-hide:] + message ClientWindowUpdate { + // Window update for sidestream-to-upstream. + int64 window_increment_sidestream_to_upstream = 1; + + // Window update for sidestream-to-downstream. + int64 window_increment_sidestream_to_downstream = 2; + } + reserved 1; reserved "async_mode"; @@ -93,35 +155,33 @@ message ProcessingRequest { // ones are set for a particular HTTP request/response depend on the // processing mode. oneof request { - option (validate.required) = true; - // Information about the HTTP request headers, as well as peer info and additional // properties. Unless ``observability_mode`` is ``true``, the server must send back a - // HeaderResponse message, an ImmediateResponse message, or close the stream. + // ``HeaderResponse`` message, an ``ImmediateResponse`` message, or close the stream. HttpHeaders request_headers = 2; // Information about the HTTP response headers, as well as peer info and additional // properties. Unless ``observability_mode`` is ``true``, the server must send back a - // HeaderResponse message or close the stream. + // ``HeaderResponse`` message or close the stream. HttpHeaders response_headers = 3; - // A chunk of the HTTP request body. Unless ``observability_mode`` is true, the server must send back - // a BodyResponse message, an ImmediateResponse message, or close the stream. + // A chunk of the HTTP request body. Unless ``observability_mode`` is ``true``, the server must + // send back a ``BodyResponse`` message, an ``ImmediateResponse`` message, or close the stream. HttpBody request_body = 4; - // A chunk of the HTTP response body. Unless ``observability_mode`` is ``true``, the server must send back - // a BodyResponse message or close the stream. + // A chunk of the HTTP response body. Unless ``observability_mode`` is ``true``, the server must + // send back a ``BodyResponse`` message or close the stream. HttpBody response_body = 5; // The HTTP trailers for the request path. Unless ``observability_mode`` is ``true``, the server - // must send back a TrailerResponse message or close the stream. + // must send back a ``TrailerResponse`` message or close the stream. // // This message is only sent if the trailers processing mode is set to ``SEND`` and // the original downstream request has trailers. HttpTrailers request_trailers = 6; // The HTTP trailers for the response path. Unless ``observability_mode`` is ``true``, the server - // must send back a TrailerResponse message or close the stream. + // must send back a ``TrailerResponse`` message or close the stream. // // This message is only sent if the trailers processing mode is set to ``SEND`` and // the original upstream response has trailers. @@ -137,39 +197,75 @@ message ProcessingRequest { // :ref:`attributes ` supported in the data plane. map attributes = 9; - // Specify whether the filter that sent this request is running in :ref:`observability_mode - // ` - // and defaults to false. + // Specifies whether the filter that sent this request is running in + // :ref:`observability_mode `. // - // * A value of ``false`` indicates that the server must respond - // to this message by either sending back a matching ProcessingResponse message, - // or by closing the stream. + // * A value of ``false`` indicates that the server must respond to this message by either + // sending back a matching ``ProcessingResponse`` message, or by closing the stream. // * A value of ``true`` indicates that the server should not respond to this message, as any - // responses will be ignored. However, it may still close the stream to indicate that no more messages - // are needed. + // responses will be ignored. However, it may still close the stream to indicate that no more + // messages are needed. // + // Defaults to ``false``. bool observability_mode = 10; // Specify the filter protocol configurations to be sent to the server. // ``protocol_config`` is only encoded in the first ``ProcessingRequest`` message from the client to the server. ProtocolConfiguration protocol_config = 11; + + // Flow control initialization for ``FULL_DUPLEX_STREAMED`` and + // ``GRPC`` body send modes. + // + // Must be set in the initial message on the stream. Not used in + // subsequent messages. + // + // [#not-implemented-hide:] + FlowControlInit flow_control_init = 12; + + // Flow control updates for ``FULL_DUPLEX_STREAMED`` and ``GRPC`` body + // send modes. + // + // This message may be included in a response message that also + // populates one of the fields in the ``request`` oneof above, or it + // may be sent in a response message that does not set the + // ``request`` oneof. + // + // In ``FULL_DUPLEX_STREAMED`` body send mode, for backward + // compatibility with data planes that do not yet support flow control, + // the data plane must not send a message containing only this field + // (i.e., not setting the ``request`` oneof) unless the ext_proc server + // has sent a window update, thus indicating that it supports flow control. + // + // [#not-implemented-hide:] + ClientWindowUpdate client_window_update = 13; } // This represents the different types of messages the server may send back to the data plane -// when the ``observability_mode`` field in the received ProcessingRequest is set to false. +// when the ``observability_mode`` field in the received ``ProcessingRequest`` is set to ``false``. // // * If the corresponding ``BodySendMode`` in the // :ref:`processing_mode ` -// is not set to ``FULL_DUPLEX_STREAMED``, then for every received ProcessingRequest, -// the server must send back exactly one ProcessingResponse message. +// is not set to ``FULL_DUPLEX_STREAMED``, then for every received ``ProcessingRequest``, +// the server must send back exactly one ``ProcessingResponse`` message. // * If it is set to ``FULL_DUPLEX_STREAMED``, the server must follow the API defined -// for this mode to send the ProcessingResponse messages. -// [#next-free-field: 13] +// for this mode to send the ``ProcessingResponse`` messages. +// [#next-free-field: 14] message ProcessingResponse { + // Flow control window update. Values may be positive or negative. The + // sender must immediately add these values to its flow control window, + // which governs how much data can be sent. + // + // [#not-implemented-hide:] + message ServerWindowUpdate { + // Window update for downstream-to-sidestream. + int64 window_increment_downstream_to_sidestream = 1; + + // Window update for upstream-to-sidestream. + int64 window_increment_upstream_to_sidestream = 2; + } + // The response type that is sent by the server. oneof response { - option (validate.required) = true; - // The server must send back this message in response to a message with the // ``request_headers`` field set. HeadersResponse request_headers = 1; @@ -204,17 +300,19 @@ message ProcessingResponse { ImmediateResponse immediate_response = 7; // The server sends back this message to initiate or continue local response streaming. - // The server must initiate local response streaming with the ``headers_response`` in response to a ProcessingRequest - // with the ``request_headers`` only. - // The server may follow up with multiple messages containing ``body_response``. The server must indicate - // end of stream by setting ``end_of_stream`` to ``true`` in the ``headers_response`` + // The server must initiate local response streaming with the ``headers_response`` in response + // to a ``ProcessingRequest`` with the ``request_headers`` only. + // The server may follow up with multiple messages containing ``body_response``. The server must + // indicate end of stream by setting ``end_of_stream`` to ``true`` in the ``headers_response`` // or ``body_response`` message or by sending a ``trailers_response`` message. - // The client may send a ``request_body`` or ``request_trailers`` to the server depending on configuration. + // The client may send a ``request_body`` or ``request_trailers`` to the server depending on + // configuration. // The streaming local response can only be sent when the ``request_header_mode`` in the filter // :ref:`processing_mode ` - // is set to ``SEND``. The ext_proc server should not send StreamedImmediateResponse if it did not observe request headers, - // as it will result in the race with the upstream server response and reset of the client request. - // Presently only the FULL_DUPLEX_STREAMED or NONE body modes are supported. + // is set to ``SEND``. The ext_proc server should not send ``StreamedImmediateResponse`` if it + // did not observe request headers, as it will result in a race with the upstream server + // response and reset of the client request. + // Presently only the ``FULL_DUPLEX_STREAMED`` or ``NONE`` body modes are supported. StreamedImmediateResponse streamed_immediate_response = 11; } @@ -223,19 +321,17 @@ message ProcessingResponse { // field name(s) of the struct. google.protobuf.Struct dynamic_metadata = 8; - // Override how parts of the HTTP request and response are processed - // for the duration of this particular request/response only. Servers - // may use this to intelligently control how requests are processed - // based on the headers and other metadata that they see. - // This field is only applicable when servers responding to the header requests. - // If it is set in the response to the body or trailer requests, it will be ignored by the data plane. + // Override how parts of the HTTP request and response are processed for the duration of this + // particular request/response only. Servers may use this to intelligently control how requests + // are processed based on the headers and other metadata that they see. + // + // This field is only applicable when servers are responding to the header requests. If it is set + // in the response to the body or trailer requests, it will be ignored by the data plane. // It is also ignored by the data plane when the ext_proc filter config - // :ref:`allow_mode_override - // ` - // is set to false, or - // :ref:`send_body_without_waiting_for_header_response - // ` - // is set to true. + // :ref:`allow_mode_override ` + // is set to ``false``, or + // :ref:`send_body_without_waiting_for_header_response ` + // is set to ``true``. envoy.extensions.filters.http.ext_proc.v3.ProcessingMode mode_override = 9; // [#not-implemented-hide:] @@ -251,70 +347,87 @@ message ProcessingResponse { // client had already sent before it saw the ext_proc stream termination. bool request_drain = 12; - // When ext_proc server receives a request message, in case it needs more - // time to process the message, it sends back a ProcessingResponse message - // with a new timeout value. When the data plane receives this response - // message, it ignores other fields in the response, just stop the original - // timer, which has the timeout value specified in - // :ref:`message_timeout - // ` - // and start a new timer with this ``override_message_timeout`` value and keep the - // data plane ext_proc filter state machine intact. - // Has to be >= 1ms and <= - // :ref:`max_message_timeout ` - // Such message can be sent at most once in a particular data plane ext_proc filter processing state. - // To enable this API, one has to set ``max_message_timeout`` to a number >= 1ms. + // When the ext_proc server receives a request message and needs more time to process it, it + // sends back a ``ProcessingResponse`` message with a new timeout value. When the data plane + // receives this response message, it ignores other fields in the response, stops the original + // timer (which has the timeout value specified in + // :ref:`message_timeout `), + // and starts a new timer with this ``override_message_timeout`` value while keeping the data + // plane ext_proc filter state machine intact. + // + // The value must be >= 1ms and <= + // :ref:`max_message_timeout `. + // Such a message can be sent at most once in a particular data plane ext_proc filter processing + // state. To enable this API, ``max_message_timeout`` must be set to a value >= 1ms. google.protobuf.Duration override_message_timeout = 10; + + // Flow control updates for ``FULL_DUPLEX_STREAMED`` and ``GRPC`` body + // send modes. + // + // This message may be included in a response message that also + // populates one of the fields in the ``response`` oneof above, or it + // may be sent in a response message that does not set the + // ``response`` oneof. + // + // In ``FULL_DUPLEX_STREAMED`` body send mode, for backward + // compatibility with data planes that do not yet support flow control, + // the ext_proc server must not set this field unless the data plane + // sent initial window sizes in its initial message on the stream. + // Conversely, if the data plane did send initial window sizes in its + // initial message on the stream, the ext_proc server must send a + // window update immediately to let the data plane know that it also + // supports flow control. If the ext_proc server is sending a message + // immediately anyway (e.g., for a header or body chunk), it can include + // this field in that same message; otherwise, the ext_proc server must + // send a message containing only this field. + // + // [#not-implemented-hide:] + ServerWindowUpdate server_window_update = 13; } // The following are messages that are sent to the server. -// This message is sent to the external server when the HTTP request and responses +// This message is sent to the external server when the HTTP request and response headers // are first received. message HttpHeaders { - // The HTTP request headers. All header keys will be - // lower-cased, because HTTP header keys are case-insensitive. - // The header value is encoded in the + // The HTTP request headers. All header keys will be lower-cased, because HTTP header keys are + // case-insensitive. The header value is encoded in the // :ref:`raw_value ` field. config.core.v3.HeaderMap headers = 1; // [#not-implemented-hide:] - // This field is deprecated and not implemented. Attributes will be sent in - // the top-level :ref:`attributes ` field. map attributes = 2 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // If ``true``, then there is no message body associated with this - // request or response. + // If ``true``, then there is no message body associated with this request or response. bool end_of_stream = 3; } -// This message is sent to the external server when the HTTP request and -// response bodies are received. +// This message is sent to the external server when the HTTP request and response bodies are +// received. message HttpBody { - // The contents of the body in the HTTP request/response. Note that in - // streaming mode multiple ``HttpBody`` messages may be sent. + // The contents of the body in the HTTP request/response. Note that in streaming mode multiple + // ``HttpBody`` messages may be sent. // - // In ``GRPC`` body send mode, a separate ``HttpBody`` message will be - // sent for each message in the gRPC stream. + // In ``GRPC`` body send mode, a separate ``HttpBody`` message will be sent for each message in + // the gRPC stream. bytes body = 1; - // If ``true``, this will be the last ``HttpBody`` message that will be sent and no - // trailers will be sent for the current request/response. + // If ``true``, this will be the last ``HttpBody`` message that will be sent and no trailers + // will be sent for the current request/response. bool end_of_stream = 2; - // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is - // true and ``body`` is empty. Those values would normally indicate an - // empty message on the stream with the end-of-stream bit set. - // However, if the half-close happens after the last message on the - // stream was already sent, then this field will be true to indicate an - // end-of-stream with *no* message (as opposed to an empty message). + // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is ``true`` and ``body`` + // is empty. Those values would normally indicate an empty message on the stream with the + // end-of-stream bit set. However, if the half-close happens after the last message on the stream + // was already sent, then this field will be ``true`` to indicate an end-of-stream with *no* + // message (as opposed to an empty message). bool end_of_stream_without_message = 3; - // This field is used in ``GRPC`` body send mode to indicate whether - // the message is compressed. This will never be set to true by gRPC - // but may be set to true by a proxy like Envoy. + // This field is used in ``GRPC`` body send mode to indicate whether the message is compressed. + // This will never be set to ``true`` by gRPC but may be set to ``true`` by a proxy like Envoy. bool grpc_message_compressed = 4; } @@ -352,13 +465,14 @@ message TrailersResponse { HeaderMutation header_mutation = 1; } -// This message is sent by the external server to the data plane after ``HttpHeaders`` -// to initiate local response streaming. The server may follow up with multiple messages containing ``body_response``. -// The server must indicate end of stream by setting ``end_of_stream`` to ``true`` in the ``headers_response`` -// or ``body_response`` message or by sending a ``trailers_response`` message. +// This message is sent by the external server to the data plane after ``HttpHeaders`` to initiate +// local response streaming. The server may follow up with multiple messages containing +// ``body_response``. The server must indicate end of stream by setting ``end_of_stream`` to +// ``true`` in the ``headers_response`` or ``body_response`` message or by sending a +// ``trailers_response`` message. message StreamedImmediateResponse { oneof response { - // Response headers to be sent downstream. The ":status" header must be set. + // Response headers to be sent downstream. The ``:status`` header must be set. HttpHeaders headers_response = 1; // Response body to be sent downstream. @@ -384,7 +498,7 @@ message CommonResponse { // further messages for this request or response even if the processing // mode is configured to do so. // - // When used in response to a request_headers or response_headers message, + // When used in response to a ``request_headers`` or ``response_headers`` message, // this status makes it possible to either completely replace the body // while discarding the original body, or to add a body to a message that // formerly did not have one. @@ -401,23 +515,22 @@ message CommonResponse { ResponseStatus status = 1 [(validate.rules).enum = {defined_only: true}]; // Instructions on how to manipulate the headers. When responding to an - // HttpBody request, header mutations will only take effect if - // the current processing mode for the body is BUFFERED. + // ``HttpBody`` request, header mutations will only take effect if the current processing mode + // for the body is ``BUFFERED``. HeaderMutation header_mutation = 2; - // Replace the body of the last message sent to the remote server on this - // stream. If responding to an HttpBody request, simply replace or clear - // the body chunk that was sent with that request. Body mutations may take - // effect in response either to ``header`` or ``body`` messages. When it is - // in response to ``header`` messages, it only take effect if the + // Replace the body of the last message sent to the remote server on this stream. If responding + // to an ``HttpBody`` request, simply replace or clear the body chunk that was sent with that + // request. Body mutations may take effect in response either to ``header`` or ``body`` messages. + // When it is in response to ``header`` messages, it only takes effect if the // :ref:`status ` - // is set to CONTINUE_AND_REPLACE. + // is set to ``CONTINUE_AND_REPLACE``. BodyMutation body_mutation = 3; // [#not-implemented-hide:] - // Add new trailers to the message. This may be used when responding to either a - // HttpHeaders or HttpBody message, but only if this message is returned - // along with the CONTINUE_AND_REPLACE status. + // Add new trailers to the message. This may be used when responding to either an + // ``HttpHeaders`` or ``HttpBody`` message, but only if this message is returned + // along with the ``CONTINUE_AND_REPLACE`` status. // The header value is encoded in the // :ref:`raw_value ` field. config.core.v3.HeaderMap trailers = 4; @@ -429,34 +542,32 @@ message CommonResponse { bool clear_route_cache = 5; } -// This message causes the filter to attempt to create a locally -// generated response, send it downstream, stop processing -// additional filters, and ignore any additional messages received -// from the remote server for this request or response. If a response -// has already started, then this will either ship the reply directly -// to the downstream codec, or reset the stream. +// This message causes the filter to attempt to create a locally generated response, send it +// downstream, stop processing additional filters, and ignore any additional messages received +// from the remote server for this request or response. If a response has already started, then +// this will either ship the reply directly to the downstream codec, or reset the stream. // [#next-free-field: 6] message ImmediateResponse { // The response code to return. type.v3.HttpStatus status = 1 [(validate.rules).message = {required: true}]; - // Apply changes to the default headers, which will include content-type. + // Apply changes to the default headers, which will include ``content-type``. HeaderMutation headers = 2; // The message body to return with the response which is sent using the - // text/plain content type, or encoded in the grpc-message header. + // ``text/plain`` content type, or encoded in the ``grpc-message`` header. bytes body = 3; // If set, then include a gRPC status trailer. GrpcStatus grpc_status = 4; // A string detailing why this local reply was sent, which may be included - // in log and debug output (e.g. this populates the %RESPONSE_CODE_DETAILS% + // in log and debug output (e.g., this populates the ``%RESPONSE_CODE_DETAILS%`` // command operator field for use in access logging). string details = 5; } -// This message specifies a gRPC status for an ImmediateResponse message. +// This message specifies a gRPC status for an ``ImmediateResponse`` message. message GrpcStatus { // The actual gRPC status. uint32 status = 1; @@ -484,26 +595,24 @@ message StreamedBodyResponse { // a serialized gRPC message to be passed to the upstream/downstream by the data plane. bytes body = 1; - // The server sets this flag to true if it has received a body request with - // :ref:`end_of_stream ` set to true, - // and this is the last chunk of body responses. - // Note that in ``GRPC`` body send mode, this allows the ext_proc - // server to tell the data plane to send a half close after a client - // message, which will result in discarding any other messages sent by - // the client application. + // The server sets this flag to ``true`` if it has received a body request with + // :ref:`end_of_stream ` set to + // ``true``, and this is the last chunk of body responses. + // + // Note that in ``GRPC`` body send mode, this allows the ext_proc server to tell the data plane + // to send a half close after a client message, which will result in discarding any other + // messages sent by the client application. bool end_of_stream = 2; - // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is - // true and ``body`` is empty. Those values would normally indicate an - // empty message on the stream with the end-of-stream bit set. - // However, if the half-close happens after the last message on the - // stream was already sent, then this field will be true to indicate an - // end-of-stream with *no* message (as opposed to an empty message). + // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is ``true`` and ``body`` + // is empty. Those values would normally indicate an empty message on the stream with the + // end-of-stream bit set. However, if the half-close happens after the last message on the stream + // was already sent, then this field will be ``true`` to indicate an end-of-stream with *no* + // message (as opposed to an empty message). bool end_of_stream_without_message = 3; - // This field is used in ``GRPC`` body send mode to indicate whether - // the message is compressed. This will never be set to true by gRPC - // but may be set to true by a proxy like Envoy. + // This field is used in ``GRPC`` body send mode to indicate whether the message is compressed. + // This will never be set to ``true`` by gRPC but may be set to ``true`` by a proxy like Envoy. bool grpc_message_compressed = 4; } @@ -517,11 +626,10 @@ message BodyMutation { // is not set to ``FULL_DUPLEX_STREAMED`` or ``GRPC``. bytes body = 1; - // Clear the corresponding body chunk. - // Should only be used when the corresponding ``BodySendMode`` in the + // Clear the corresponding body chunk. Should only be used when the corresponding + // ``BodySendMode`` in the // :ref:`processing_mode ` // is not set to ``FULL_DUPLEX_STREAMED`` or ``GRPC``. - // Clear the corresponding body chunk. bool clear_body = 2; // Must be used when the corresponding ``BodySendMode`` in the