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..4dc49ccf1bf 100644 --- a/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java @@ -77,6 +77,7 @@ import io.grpc.xds.internal.headermutations.HeaderMutator; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Queue; @@ -89,6 +90,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; /** * Client-side interceptor for external processing filter. @@ -186,11 +188,6 @@ ExternalProcessorFilterConfig getFilterConfig() { return filterConfig; } - @VisibleForTesting - ManagedChannel getExtProcChannel() { - return extProcChannel; - } - @Override @SuppressWarnings("unchecked") public ClientCall interceptCall( @@ -232,7 +229,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 = @@ -278,11 +274,12 @@ private static class DataPlaneClientCall private final ClientCall rawCall; private final DataPlaneDelayedCall delayedCall; private final ScheduledExecutorService scheduler; - private final Object streamLock = new Object(); + final Object streamLock = new Object(); @Nullable private volatile EventType expectedRequestResponse; @Nullable private volatile EventType expectedResponseResponse; @Nullable private volatile ClientCallStreamObserver extProcClientCallRequestObserver; + @GuardedBy("streamLock") private final Queue pendingDrainingMessages = new ConcurrentLinkedQueue<>(); @Nullable private volatile DataPlaneListener wrappedListener; @@ -290,6 +287,53 @@ 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 + @GuardedBy("streamLock") + private long downstreamToSidestreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + @GuardedBy("streamLock") + private long upstreamToSidestreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + + // Inbound (receiving) windows + @GuardedBy("streamLock") + private long sidestreamToUpstreamWindow = DEFAULT_INITIAL_WINDOW_SIZE; + @GuardedBy("streamLock") + 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 + @GuardedBy("streamLock") + private final Queue pendingRequestBodyMessages = new ConcurrentLinkedQueue<>(); + // Deferred half-close flag for upstream direction + private final AtomicBoolean pendingUpstreamHalfClose = new AtomicBoolean(false); + + // Path 2: Buffered request body messages from ext_proc server to forward upstream + @GuardedBy("streamLock") + private final Queue pendingUpstreamBodyMessages = + new java.util.concurrent.ConcurrentLinkedQueue<>(); + // Path 4: Outstanding requests from downstream for pulling responses + @GuardedBy("streamLock") + private int downstreamRequestsPending = 0; + // Buffered mutated response bodies from ext_proc server + @GuardedBy("streamLock") + private final Queue pendingMutatedResponseBodies = + new java.util.concurrent.ConcurrentLinkedQueue<>(); + + // Accumulated client window updates to send to ext_proc + @GuardedBy("streamLock") + private long accumulatedWindowUpdateSidestreamToUpstream = 0; + @GuardedBy("streamLock") + private long accumulatedWindowUpdateSidestreamToDownstream = 0; + + // Flag to track if FlowControlInit was sent in the initial message + @GuardedBy("streamLock") + private boolean flowControlInitSent = false; + private final MethodDescriptor method; private final Channel channel; private final MetricRecorder metricsRecorder; @@ -343,8 +387,6 @@ protected DataPlaneClientCall( this.backendService = checkNotNull(backendService, "backendService"); } - - private void activateCall() { if ((extProcStreamState.get() == ExtProcStreamState.FAILED && !config.getFailureModeAllow() @@ -411,8 +453,6 @@ private boolean validateCompressionSupport(BodyResponse bodyResponse) { return true; } - - @Override public void start(Listener responseListener, Metadata headers) { this.callContext = Context.current(); @@ -439,6 +479,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 +726,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); } } + @GuardedBy("streamLock") + void mergeAccumulatedWindowUpdates(ProcessingRequest.Builder requestBuilder) { + 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 + || extProcStreamState.get().isCompleted()) { + 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 +862,39 @@ private void onReadyNotify() { wrappedListener.onReadyNotify(); } - private boolean isSidecarReady() { + void onReady() { + boolean isPassThrough; + boolean isCompleted; + boolean isDraining; + + synchronized (streamLock) { + isPassThrough = passThroughMode.get(); + ExtProcStreamState state = extProcStreamState.get(); + isCompleted = state.isCompleted(); + isDraining = state.isDraining(); + } + + if (isPassThrough) { + onReadyNotify(); + return; + } + + if (isCompleted) { + drainPendingDrainingMessages(); + return; + } + + // Normal or Draining operation + drainPendingUpstreamBodyMessages(); + if (!isDraining) { + trySendAccumulatedWindowUpdates(); + } + drainPendingRequests(); + onReadyNotify(); + } + + @GuardedBy("streamLock") + boolean isSidecarReady() { ExtProcStreamState state = extProcStreamState.get(); if (state.isCompleted()) { return true; @@ -738,10 +902,8 @@ private boolean isSidecarReady() { if (state.isDraining()) { return false; } - synchronized (streamLock) { - ClientCallStreamObserver observer = extProcClientCallRequestObserver; - return observer != null && observer.isReady(); - } + ClientCallStreamObserver observer = extProcClientCallRequestObserver; + return observer != null && observer.isReady(); } @Override @@ -755,11 +917,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 +933,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 +1004,58 @@ 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() - .setRequestBody(HttpBody.newBuilder() - .setBody(bodyByteString) - .setEndOfStream(false) - .build()) - .build()); - bodyMessageSentToExtProc.set(true); + @GuardedBy("streamLock") + private void sendRequestBodyToExtProc(ByteString body) { + downstreamToSidestreamWindow -= body.size(); + ProcessingRequest.Builder builder = ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setBody(body) + .setEndOfStream(false) + .build()); + mergeAccumulatedWindowUpdates(builder); + sendToExtProc(builder.build()); + bodyMessageSentToExtProc.set(true); + } - if (config.getObservabilityMode()) { - super.sendMessage(new KnownLengthInputStream(bodyByteString)); - } - } catch (IOException e) { - rawCall.cancel("Failed to serialize message for External Processor", e); + @GuardedBy("streamLock") + private void drainPendingRequestBodyMessages() { + while (downstreamToSidestreamWindow > 0 && !pendingRequestBodyMessages.isEmpty()) { + ByteString body = pendingRequestBodyMessages.poll(); + sendRequestBodyToExtProc(body); + } + if (pendingRequestBodyMessages.isEmpty() && pendingHalfClose.get()) { + halfClose(); } } @@ -887,11 +1108,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 +1142,31 @@ 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) { + sidestreamToUpstreamWindow -= body.size(); + if (pendingUpstreamBodyMessages.isEmpty() && super.isReady()) { + 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 +1179,102 @@ 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) { + sidestreamToDownstreamWindow -= bodySize; + } + deliverResponseBody(body, listener); + } + } + } + + private void deliverResponseBody(ByteString body, DataPlaneListener listener) { + boolean shouldDeliver = false; + synchronized (streamLock) { + if (downstreamRequestsPending > 0) { + downstreamRequestsPending--; + shouldDeliver = true; + } else { + pendingMutatedResponseBodies.add(body); + } + } + if (shouldDeliver) { + final int bodySize = body.size(); + callContext.run(() -> { + try { + listener.onExternalBody(body); + } finally { + synchronized (streamLock) { + accumulatedWindowUpdateSidestreamToDownstream += bodySize; + } + trySendAccumulatedWindowUpdates(); + } + }); + } + } + + private void drainPendingMutatedResponseBodies() { + List toDeliver = new ArrayList<>(); + synchronized (streamLock) { + while (downstreamRequestsPending > 0 && !pendingMutatedResponseBodies.isEmpty()) { + ByteString body = pendingMutatedResponseBodies.poll(); + downstreamRequestsPending--; + pendingRequests.decrementAndGet(); + toDeliver.add(body); + } + } + for (ByteString body : toDeliver) { + final int bodySize = body.size(); + callContext.run(() -> { + try { + wrappedListener.onExternalBody(body); + } finally { + synchronized (streamLock) { + accumulatedWindowUpdateSidestreamToDownstream += bodySize; + } + trySendAccumulatedWindowUpdates(); + } + }); + } + } + + void drainPendingMutatedResponseBodiesDirect(DataPlaneListener listener) { + List toDeliver = new ArrayList<>(); + synchronized (streamLock) { + ByteString body; + while ((body = pendingMutatedResponseBodies.poll()) != null) { + toDeliver.add(body); + } + } + for (ByteString body : toDeliver) { + listener.onExternalBody(body); + } + } + + void drainPendingUpstreamBodyMessages() { + while (true) { + ByteString body = null; + boolean triggerHalfClose = false; + synchronized (streamLock) { + if (!pendingUpstreamBodyMessages.isEmpty() && super.isReady()) { + 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(); + } } } } @@ -965,16 +1308,51 @@ private void handleImmediateResponse(ImmediateResponse immediate, DataPlaneListe } private void drainPendingDrainingMessages() { - synchronized (streamLock) { - InputStream msg; - while ((msg = pendingDrainingMessages.poll()) != null) { - super.sendMessage(msg); + while (true) { + Object msg = null; // Can be ByteString or InputStream + boolean isMutated = false; + boolean triggerHalfClose = false; + + synchronized (streamLock) { + if (!pendingUpstreamBodyMessages.isEmpty() && super.isReady()) { + msg = pendingUpstreamBodyMessages.poll(); + isMutated = true; + } else if (pendingUpstreamBodyMessages.isEmpty() + && !pendingRequestBodyMessages.isEmpty() && super.isReady()) { + msg = pendingRequestBodyMessages.poll(); + isMutated = true; + } else if (pendingUpstreamBodyMessages.isEmpty() + && pendingRequestBodyMessages.isEmpty() + && !pendingDrainingMessages.isEmpty() && super.isReady()) { + msg = pendingDrainingMessages.poll(); + isMutated = false; + } + + if (msg == null) { + if (pendingUpstreamBodyMessages.isEmpty() + && pendingRequestBodyMessages.isEmpty() + && pendingDrainingMessages.isEmpty()) { + passThroughMode.set(true); + if (pendingHalfClose.get()) { + triggerHalfClose = true; + } + } + } } - passThroughMode.set(true); - if (pendingHalfClose.get()) { - if (requestSideClosed.compareAndSet(false, true)) { - proceedWithHalfClose(); + + if (msg == null) { + if (triggerHalfClose) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } } + break; + } + + if (isMutated) { + super.sendMessage(new KnownLengthInputStream((ByteString) msg)); + } else { + super.sendMessage((InputStream) msg); } } } @@ -1048,6 +1426,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,8 +1465,7 @@ void setImmediateResponse(Status status, Metadata trailers) { @Override public void onReady() { - dataPlaneClientCall.drainPendingRequests(); - onReadyNotify(); + dataPlaneClientCall.onReady(); } @Override @@ -1104,7 +1483,7 @@ public void onHeaders(Metadata headers) { return; } - if (dataPlaneClientCall.getPassThroughMode().get() + if (dataPlaneClientCall.getPassThroughMode().get() || dataPlaneClientCall.getExtProcStreamState().get().isCompleted() || !sendResponseHeaders) { proceedWithHeaders(headers); @@ -1126,7 +1505,7 @@ public void onHeaders(Metadata headers) { @Override public void onMessage(InputStream message) { - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.streamLock) { if (inboundPassThrough) { dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); return; @@ -1145,34 +1524,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.streamLock) { + 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 +1636,7 @@ void onReadyNotify() { void proceedWithHeaders() { if (savedHeaders != null) { proceedWithHeaders(savedHeaders); - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.streamLock) { savedHeaders = null; if (!dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { InputStream msg; @@ -1285,13 +1690,17 @@ void onExternalBody(ByteString body) { void unblockAfterStreamComplete() { proceedWithHeaders(); + // 1. Drain mutated responses first + dataPlaneClientCall.drainPendingMutatedResponseBodiesDirect(this); + // 2. Drain raw responses proceedWithSavedMessages(); + // 3. Drain outbound requests dataPlaneClientCall.drainPendingDrainingMessages(); proceedWithClose(); } private void proceedWithSavedMessages() { - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.streamLock) { InputStream msg; while ((msg = savedMessages.poll()) != null) { final InputStream finalMsg = msg; @@ -1361,6 +1770,7 @@ private void triggerCloseHandshake() { } } + @GuardedBy("dataPlaneClientCall.streamLock") private void sendResponseBodyToExtProc( @Nullable ByteString bodyByteString, boolean endOfStream) { if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() @@ -1376,9 +1786,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..096dc636140 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"); } @@ -274,7 +276,6 @@ private ExternalProcessor.Builder createBaseProto(String targetName) { .build()); } - // --- Category 1: Configuration Override --- @Test @@ -3461,7 +3462,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 +3469,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 +3618,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 +5942,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 +6265,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 +6763,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 +6887,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 +7007,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 +7111,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 +7165,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 +7270,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 +7505,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 +7517,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(); @@ -8378,182 +8362,158 @@ public void onClose(Status status, Metadata trailers) { channelManager.close(); } - // --- Category 15: Inbound Backpressure (request(n) / pendingRequests) --- + // --- Category 15: Ext-proc fail-open draining of flow-control queues --- @Test @SuppressWarnings("unchecked") - public void givenObservabilityTrue_whenExtProcBusy_thenAppRequestsBuffered() - throws Exception { - 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()) + public void testFailOpen_DrainsInboundQueuesInOrder() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setFailureModeAllow(true) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) .build()) - .setObservabilityMode(true) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // External Processor Server - ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; - extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { - @Override - @SuppressWarnings("unchecked") - public StreamObserver process( - StreamObserver responseObserver) { - ((ServerCallStreamObserver) responseObserver).request(100); - return new StreamObserver() { - @Override - public void onNext(ProcessingRequest request) { - } + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onError(Throwable t) { - } + 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()); + extProcReceivedHeadersLatch.countDown(); + } + } - @Override - public void onCompleted() { + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); - final AtomicBoolean sidecarReady = new AtomicBoolean(true); - final AtomicReference> sidecarListenerRef = - new AtomicReference<>(); 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< - ReqT, RespT>(next.newCall(method, callOptions)) { - @Override - public void start(Listener responseListener, Metadata headers) { - sidecarListenerRef.set((Listener) responseListener); - super.start(responseListener, headers); - } - - @Override - public boolean isReady() { - return sidecarReady.get(); - } - }; - } - }) - .build()); + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); - final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); + final CountDownLatch backendSentMessage2Latch = new CountDownLatch(1); dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, 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) { - } + .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + // Send message 1 (70k to close window) + String largeMessage70k = new String(new char[70000]).replace('\0', 'a'); + call.sendMessage(largeMessage70k); - @Override - public void onCompleted() { - responseObserver.onCompleted(); - } - }; + new Thread(() -> { + try { + if (extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)) { + // Send message 2 (unsolicited, will be buffered in savedMessages) + call.sendMessage("backend-msg-2"); + backendSentMessage2Latch.countDown(); } - })) + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + + return new ServerCall.Listener() { + @Override + public void onMessage(String message) {} + + @Override + public void onHalfClose() {} + + @Override + public void onCancel() {} + }; + }) .build()); + final List appReceivedMessages = new CopyOnWriteArrayList<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + 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()); + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - // 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(); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + } - // Sidecar is busy - sidecarReady.set(false); - assertThat(proxyCall.isReady()).isFalse(); + @Override + public void onClose(Status status, Metadata trailers) { + callClosedLatch.countDown(); + } + }, new Metadata()); - proxyCall.request(5); + proxyCall.request(10); - // Verify data plane call NOT requested yet (due to observability mode and sidecar busy) - assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + // Wait for backend to send message 2 + assertThat(backendSentMessage2Latch.await(5, TimeUnit.SECONDS)).isTrue(); - // Sidecar becomes ready - sidecarReady.set(true); - sidecarListenerRef.get().onReady(); + // Verify app received nothing yet (buffered in savedMessages) + assertThat(appReceivedMessages).isEmpty(); + + // Trigger fail-open by error on ext_proc stream + responseObserverRef.get().onError(Status.UNAVAILABLE.asException()); + + // Verify call is NOT closed + assertThat(callClosedLatch.getCount()).isEqualTo(1); + + // Verify all buffered messages are drained in order: largeMessage70k then backend-msg-2 + String largeMessage70k = new String(new char[70000]).replace('\0', 'a'); + assertThat(appReceivedMessages).containsExactly(largeMessage70k, "backend-msg-2").inOrder(); - // After sidecar becomes ready, pending requests should be drained to data plane. - assertThat(dataPlaneRequestCount.get()).isEqualTo(5); - assertThat(proxyCall.isReady()).isTrue(); - proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenRequestDrainActive_whenAppRequestsMessages_thenRequestsBuffered() - throws Exception { - 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()) + public void testFailOpen_DrainsBlockedRequests() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setFailureModeAllow(true) .setProcessingMode(ProcessingMode.newBuilder() - .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) .build()) .build(); ConfigOrError configOrError = @@ -8561,258 +8521,438 @@ public void givenRequestDrainActive_whenAppRequestsMessages_thenRequestsBuffered assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // External Processor Server - 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) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestDrain(true) - .build()); - } - } + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onError(Throwable t) { + 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()) { + // Respond with headers AND negative window update to block outbound body + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementDownstreamToSidestream(-65536) // Reduce window to 0 + .build()) + .build()); + extProcReceivedHeadersLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; } + }; - @Override - public void onCompleted() { - } - }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); + final List sentToBackend = new CopyOnWriteArrayList<>(); dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); - })) + .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(100); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) {} + + @Override + public void onHalfClose() {} + + @Override + public void onCancel() {} + }; + }) .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()); + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientInterceptor backendInterceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + ClientCall delegateCall = next.newCall(method, callOptions); + return new SimpleForwardingClientCall(delegateCall) { + @Override + public void sendMessage(ReqT message) { + try { + InputStream is = (InputStream) message; + byte[] bytes = com.google.common.io.ByteStreams.toByteArray(is); + String str = new String(bytes, StandardCharsets.UTF_8); + sentToBackend.add(str); + } catch (Exception e) { + throw new RuntimeException(e); + } + super.sendMessage(message); + } + }; + } + }; + Channel interceptedChannel = + ClientInterceptors.intercept(dataPlaneChannel, backendInterceptor); - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + interceptedChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); - // Wait for drain to be processed - long startTime = System.currentTimeMillis(); - while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { - Thread.sleep(10); - } - assertThat(proxyCall.isReady()).isFalse(); + assertThat(extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // App requests more messages - proxyCall.request(3); + // These should now be buffered because window is 0 + proxyCall.sendMessage("msg-1"); + proxyCall.sendMessage("msg-2"); + + assertThat(sentToBackend).isEmpty(); + + // Trigger fail-open + responseObserverRef.get().onError(Status.UNAVAILABLE.asException()); + + // Verify messages are drained + assertThat(sentToBackend).containsExactly("msg-1", "msg-2").inOrder(); - // Verify requests are buffered and not sent to data plane - assertThat(dataPlaneRequestCount.get()).isEqualTo(0); - // proxyCall.isReady() should remain false during drain - assertThat(proxyCall.isReady()).isFalse(); - proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenBufferedRequests_whenExtProcStreamBecomesReady_thenDataPlaneDrained() - throws Exception { - 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()) + public void testFailOpen_DrainsDrainingRequests() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setFailureModeAllow(true) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) .build()) - .setObservabilityMode(true) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // External Processor Server - 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) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder().build()) - .build()); - } - } + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onError(Throwable t) { - } + 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()) { + // Respond with headers AND request_drain = true to trigger DRAINING state + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .setRequestDrain(true) + .build()); + extProcReceivedHeadersLatch.countDown(); + } + } - @Override - public void onCompleted() { + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); - final AtomicBoolean sidecarReady = new AtomicBoolean(true); - final AtomicReference> sidecarListenerRef = - new AtomicReference<>(); 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< - ReqT, RespT>(next.newCall(method, callOptions)) { - @Override - public void start(Listener responseListener, Metadata headers) { - sidecarListenerRef.set((Listener) responseListener); - super.start(responseListener, headers); - } - - @Override - public boolean isReady() { - return sidecarReady.get(); - } - }; - } - }) - .build()); + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); + final List sentToBackend = new CopyOnWriteArrayList<>(); dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); - })) + .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(100); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) {} + + @Override + public void onHalfClose() {} + + @Override + public void onCancel() {} + }; + }) .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()); + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientInterceptor backendInterceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + ClientCall delegateCall = next.newCall(method, callOptions); + return new SimpleForwardingClientCall(delegateCall) { + @Override + public void sendMessage(ReqT message) { + try { + InputStream is = (InputStream) message; + byte[] bytes = com.google.common.io.ByteStreams.toByteArray(is); + String str = new String(bytes, StandardCharsets.UTF_8); + sentToBackend.add(str); + } catch (Exception e) { + throw new RuntimeException(e); + } + super.sendMessage(message); + } + }; + } + }; + Channel interceptedChannel = + ClientInterceptors.intercept(dataPlaneChannel, backendInterceptor); - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + interceptedChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); - // 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(); + assertThat(extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // Sidecar is busy initially - sidecarReady.set(false); - - // Request from application - proxyCall.request(10); - assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + // Send msg-1. Since state is DRAINING, it should be buffered in pendingDrainingMessages. + proxyCall.sendMessage("msg-1"); - // Sidecar becomes ready - sidecarReady.set(true); - sidecarListenerRef.get().onReady(); + assertThat(sentToBackend).isEmpty(); + + // Trigger fail-open + responseObserverRef.get().onError(Status.UNAVAILABLE.asException()); + + // Verify message is drained + assertThat(sentToBackend).containsExactly("msg-1"); - // Verify buffered request drained - assertThat(dataPlaneRequestCount.get()).isEqualTo(10); - assertThat(proxyCall.isReady()).isTrue(); - proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenExtProcStreamCompleted_whenAppRequestsMessages_thenRequestsForwarded() - throws Exception { - 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()) + public void testFailOpen_ResumesDrainingOnReady() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setFailureModeAllow(true) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + + 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()) { + // Respond with headers AND negative window update to block outbound body + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementDownstreamToSidestream(-65536) // Reduce window to 0 + .build()) + .build()); + extProcReceivedHeadersLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void 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 sentToBackend = new CopyOnWriteArrayList<>(); + final AtomicBoolean backendReady = new AtomicBoolean(true); + final AtomicReference> backendListenerRef = new AtomicReference<>(); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(100); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) {} + + @Override + public void onHalfClose() {} + + @Override + public void onCancel() {} + }; + }) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + ClientInterceptor backendInterceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + ClientCall delegateCall = next.newCall(method, callOptions); + return new SimpleForwardingClientCall(delegateCall) { + @Override + public void start(ClientCall.Listener responseListener, Metadata headers) { + backendListenerRef.set((ClientCall.Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + try { + InputStream is = (InputStream) message; + byte[] bytes = com.google.common.io.ByteStreams.toByteArray(is); + String str = new String(bytes, StandardCharsets.UTF_8); + sentToBackend.add(str); + } catch (Exception e) { + throw new RuntimeException(e); + } + super.sendMessage(message); + } + + @Override + public boolean isReady() { + return backendReady.get(); + } + }; + } + }; + Channel interceptedChannel = + ClientInterceptors.intercept(dataPlaneChannel, backendInterceptor); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + interceptedChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + + assertThat(extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // 1. Backend not ready + backendReady.set(false); + + // 2. Send msg-1, msg-2 (buffered in pendingRequestBodyMessages) + proxyCall.sendMessage("msg-1"); + proxyCall.sendMessage("msg-2"); + + assertThat(sentToBackend).isEmpty(); + + // 3. Trigger fail-open while backend is NOT ready + responseObserverRef.get().onError(Status.UNAVAILABLE.asException()); + + // Verify still nothing sent + assertThat(sentToBackend).isEmpty(); + + // 4. Make backend ready and trigger onReady + backendReady.set(true); + backendListenerRef.get().onReady(); + + // Verify messages are drained + assertThat(sentToBackend).containsExactly("msg-1", "msg-2").inOrder(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 16: Inbound Backpressure (request(n) / pendingRequests) --- + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityTrue_whenExtProcBusy_thenAppRequestsBuffered() + throws Exception { + 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()) + .setObservabilityMode(true) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); @@ -8825,15 +8965,11 @@ public void givenExtProcStreamCompleted_whenAppRequestsMessages_thenRequestsForw @Override @SuppressWarnings("unchecked") public StreamObserver process( - final StreamObserver responseObserver) { + StreamObserver responseObserver) { ((ServerCallStreamObserver) responseObserver).request(100); return new StreamObserver() { @Override public void onNext(ProcessingRequest request) { - if (request.hasRequestHeaders()) { - // Immediately complete the stream from server side - responseObserver.onCompleted(); - } } @Override @@ -8851,23 +8987,62 @@ public void onCompleted() { .directExecutor() .build().start()); + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .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) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); + final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); + .addMethod(METHOD_SAY_HELLO, 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() { + responseObserver.onCompleted(); + } + }; + } })) .build()); - final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); ManagedChannel dataPlaneChannel = grpcCleanup.register( InProcessChannelBuilder.forName(dataPlaneServerName) .directExecutor() @@ -8887,97 +9062,78 @@ public void request(int numMessages) { }) .build()); - final CountDownLatch readyLatch = new CountDownLatch(1); CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(new ClientCall.Listener() { - @Override - public void onReady() { - readyLatch.countDown(); - } - }, new Metadata()); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - // Wait for sidecar stream completion - assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(proxyCall.isReady()).isTrue(); + // Wait for sidecar call to start + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + } + assertThat(sidecarListenerRef.get()).isNotNull(); - proxyCall.request(7); + // Sidecar is busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); - // Verify request forwarded immediately - assertThat(dataPlaneRequestCount.get()).isEqualTo(7); - // proxyCall.isReady() should remain true as sidecar is gone + proxyCall.request(5); + + // Verify data plane call NOT requested yet (due to observability mode and sidecar busy) + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + // After sidecar becomes ready, pending requests should be drained to data plane. + assertThat(dataPlaneRequestCount.get()).isEqualTo(5); assertThat(proxyCall.isReady()).isTrue(); proxyCall.cancel("Cleanup", null); channelManager.close(); } - // --- Category 16: Error Handling & Security --- - @Test - @SuppressWarnings("FutureReturnValueIgnored") - public void givenPendingData_whenImmediateResponseReceived_thenDeliversDataBeforeStatus() + @SuppressWarnings("unchecked") + public void givenRequestDrainActive_whenAppRequestsMessages_thenRequestsBuffered() throws Exception { - final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); - final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); - final List appEvents = Collections.synchronizedList(new ArrayList<>()); - final CountDownLatch finishLatch = new CountDownLatch(1); - final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); - final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); - final Metadata.Key immediateKey = - Metadata.Key.of("x-immediate-header", Metadata.ASCII_STRING_MARSHALLER); - final AtomicReference appTrailers = new AtomicReference<>(); + 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() + .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; + // External Processor Server 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) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (request.hasResponseHeaders()) { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse(ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) - .build()) - .setDetails("Immediate Auth Failure") - .setHeaders( - io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation.newBuilder() - .addSetHeaders( - io.envoyproxy.envoy.config.core.v3.HeaderValueOption - .newBuilder() - .setHeader( - io.envoyproxy.envoy.config.core.v3.HeaderValue - .newBuilder() - .setKey("x-immediate-header") - .setValue("true") - .build()) - .build()) - .build()) - .build()) - .build()); - responseObserver.onCompleted(); - } - } - }); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestDrain(true) + .build()); + } } @Override @@ -8986,292 +9142,221 @@ public void onError(Throwable t) { @Override public void onCompleted() { - extProcCompletedLatch.countDown(); } }; } }; - - grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) - .addService(extProcImpl).directExecutor().build().start()); + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); }); - ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, channelManager); - ExternalProcessor proto = createBaseProto(extProcServerName) - .setProcessingMode(ProcessingMode.newBuilder() - .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) - .build()) - .build(); - ConfigOrError configOrError = - provider.parseFilterConfig(Any.pack(proto), filterContext); - ExternalProcessorFilterConfig filterConfig = configOrError.config; - - ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); - MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); - dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, (call, headers) -> { - call.sendHeaders(new Metadata()); - call.request(1); - return new ServerCall.Listener() { - @Override - public void onMessage(String message) { - call.sendMessage("server-response"); - call.close(Status.OK, new Metadata()); - } - }; - }) + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) .build()); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(dataPlaneRegistry) - .executor(Executors.newSingleThreadExecutor()) - .build().start()); - - ManagedChannel channel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); - Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( - channel, - Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); - - ClientCall call = - interceptedChannel.newCall( - METHOD_SAY_HELLO, - DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor())); - call.start(new ClientCall.Listener() { - @Override - public void onHeaders(Metadata headers) { - appEvents.add("HEADERS"); - } + 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()); - @Override - public void onMessage(String message) { - appEvents.add("MESSAGE"); - } + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - @Override - public void onClose(Status status, Metadata trailers) { - appEvents.add("CLOSE:" + status.getCode()); - appTrailers.set(trailers); - finishLatch.countDown(); - } - }, new Metadata()); + // Wait for drain to be processed + long startTime = System.currentTimeMillis(); + while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + } + assertThat(proxyCall.isReady()).isFalse(); - call.request(1); - call.sendMessage("request-body"); - call.halfClose(); + // App requests more messages + proxyCall.request(3); - assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(appEvents).containsExactly("HEADERS", "MESSAGE", "CLOSE:UNAUTHENTICATED"); - assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); - assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // Verify requests are buffered and not sent to data plane + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + // proxyCall.isReady() should remain false during drain + assertThat(proxyCall.isReady()).isFalse(); - sidecarResponseExecutor.shutdown(); + proxyCall.cancel("Cleanup", null); channelManager.close(); } - @Test - @SuppressWarnings("FutureReturnValueIgnored") - public void - givenStreamingCall_whenImmediateResponseReceivedDuringRequestStreaming_thenTerminatesCleanly() + @SuppressWarnings("unchecked") + public void givenBufferedRequests_whenExtProcStreamBecomesReady_thenDataPlaneDrained() throws Exception { - final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); - final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); - final List appEvents = Collections.synchronizedList(new ArrayList<>()); - final CountDownLatch finishLatch = new CountDownLatch(1); - final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); - final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); - final Metadata.Key immediateKey = - Metadata.Key.of("x-immediate-header", Metadata.ASCII_STRING_MARSHALLER); - final AtomicReference appTrailers = new AtomicReference<>(); - final AtomicInteger extProcRequestCount = new AtomicInteger(0); + 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()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; - ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = - new ExternalProcessorGrpc.ExternalProcessorImplBase() { + // External Processor Server + 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 StreamObserver process( - final StreamObserver responseObserver) { - ((ServerCallStreamObserver) responseObserver).request(100); - return new StreamObserver() { - @Override - public void onNext(ProcessingRequest request) { - sidecarResponseExecutor.submit(() -> { - synchronized (responseObserver) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (request.hasRequestBody()) { - int count = extProcRequestCount.incrementAndGet(); - if (count == 1) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestBody(BodyResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (count == 2) { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse(ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) - .build()) - .setDetails("Immediate Auth Failure") - .setHeaders( - io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation - .newBuilder() - .addSetHeaders( - io.envoyproxy.envoy.config.core.v3.HeaderValueOption - .newBuilder() - .setHeader( - io.envoyproxy.envoy.config.core.v3.HeaderValue - .newBuilder() - .setKey("x-immediate-header") - .setValue("true") - .build()) - .build()) - .build()) - .build()) - .build()); - responseObserver.onCompleted(); - } - } - } - }); - } + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } - @Override - public void onError(Throwable t) {} + @Override + public void onError(Throwable t) { + } - @Override - public void onCompleted() { - extProcCompletedLatch.countDown(); - } - }; + @Override + public void onCompleted() { } }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) - .addService(extProcImpl).directExecutor().build().start()); - + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); - }); - - ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, channelManager); - ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) - .setProcessingMode(ProcessingMode.newBuilder() - .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) - .build()) - .build(); - ConfigOrError configOrError = - provider.parseFilterConfig(Any.pack(proto), filterContext); - ExternalProcessorFilterConfig filterConfig = configOrError.config; + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .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) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } - ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); - MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); - dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { - call.sendHeaders(new Metadata()); - call.request(100); - return new ServerCall.Listener() { - @Override - public void onMessage(String message) { - call.sendMessage("server-response-" + message); - } + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); - @Override - public void onHalfClose() { - call.close(Status.OK, new Metadata()); - } - }; - }) + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) .build()); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(dataPlaneRegistry) - .executor(Executors.newSingleThreadExecutor()) - .build().start()); - - ManagedChannel channel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); - Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( - channel, - Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); - - ClientCall call = - interceptedChannel.newCall( - METHOD_BIDI_STREAMING, - DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor())); - - call.start(new ClientCall.Listener() { - @Override - public void onHeaders(Metadata headers) { - appEvents.add("HEADERS"); - } + 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()); - @Override - public void onMessage(String message) { - appEvents.add("MESSAGE:" + message); - } + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - @Override - public void onClose(Status status, Metadata trailers) { - appEvents.add("CLOSE:" + status.getCode()); - appTrailers.set(trailers); - finishLatch.countDown(); - } - }, new Metadata()); + // Wait for sidecar call to start + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + } + assertThat(sidecarListenerRef.get()).isNotNull(); - call.request(100); - - // 1. Send Message 1 (should succeed and be allowed) - call.sendMessage("msg1"); - - // 2. Send Message 2 (should trigger the delay and then ImmediateResponse on ext_proc) - call.sendMessage("msg2"); + // Sidecar is busy initially + sidecarReady.set(false); - // 3. Concurrent write of Message 3 (while ext_proc is sleeping) - try { - call.sendMessage("msg3"); - } catch (IllegalStateException e) { - appEvents.add("WRITE_FAILED"); - } + // Request from application + proxyCall.request(10); + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); - call.halfClose(); + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); - assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(appEvents).contains("CLOSE:UNAUTHENTICATED"); - assertThat(appEvents).doesNotContain("WRITE_FAILED"); - assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); - assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // Verify buffered request drained + assertThat(dataPlaneRequestCount.get()).isEqualTo(10); + assertThat(proxyCall.isReady()).isTrue(); - sidecarResponseExecutor.shutdown(); + proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenFailureModeAllowFalse_whenExtProcStreamFails_thenDataPlaneCallCancelled() + public void givenExtProcStreamCompleted_whenAppRequestsMessages_thenRequestsForwarded() throws Exception { ExternalProcessor proto = ExternalProcessor.newBuilder() .setGrpcService(GrpcService.newBuilder() @@ -9283,14 +9368,13 @@ public void givenFailureModeAllowFalse_whenExtProcStreamFails_thenDataPlaneCallC .build()) .build()) .build()) - .setFailureModeAllow(false) // Fail Closed .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // External Processor Server triggers error + // External Processor Server ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override @@ -9302,11 +9386,8 @@ public StreamObserver process( @Override public void onNext(ProcessingRequest request) { if (request.hasRequestHeaders()) { - // Fail the stream immediately on headers - responseObserver.onError( - Status.INTERNAL - .withDescription("Simulated sidecar failure") - .asRuntimeException()); + // Immediately complete the stream from server side + responseObserver.onCompleted(); } } @@ -9333,72 +9414,125 @@ public void onCompleted() { 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()); + + final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + 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()); - final AtomicReference closedStatus = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override - public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedLatch.countDown(); - } - }; - + final CountDownLatch readyLatch = new CountDownLatch(1); CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onReady() { + readyLatch.countDown(); + } + }, new Metadata()); - // Verify application receives INTERNAL due to sidecar failure - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); - assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); - - proxyCall.cancel("Cleanup", null); - channelManager.close(); + // Wait for sidecar stream completion + assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.request(7); + + // Verify request forwarded immediately + assertThat(dataPlaneRequestCount.get()).isEqualTo(7); + // proxyCall.isReady() should remain true as sidecar is gone + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); } + // --- Category 17: Error Handling & Security --- + @Test - @SuppressWarnings("unchecked") - public void givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen() + @SuppressWarnings("FutureReturnValueIgnored") + public void givenPendingData_whenImmediateResponseReceived_thenDeliversDataBeforeStatus() throws Exception { - 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()) - .setFailureModeAllow(true) // Fail Open - .build(); - ConfigOrError configOrError = - provider.parseFilterConfig(Any.pack(proto), filterContext); - assertThat(configOrError.errorDetail).isNull(); - ExternalProcessorFilterConfig filterConfig = configOrError.config; + final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + final List appEvents = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch finishLatch = new CountDownLatch(1); + final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); + final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); + final Metadata.Key immediateKey = + Metadata.Key.of("x-immediate-header", Metadata.ASCII_STRING_MARSHALLER); + final AtomicReference appTrailers = new AtomicReference<>(); - // External Processor Server 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) { - if (request.hasRequestHeaders()) { - new Thread(() -> { - synchronized (responseObserver) { - responseObserver.onError(Status.INTERNAL.asRuntimeException()); + sidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Immediate Auth Failure") + .setHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-immediate-header") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); } - }).start(); - } + } + }); } @Override @@ -9407,247 +9541,311 @@ public void onError(Throwable t) { @Override public void onCompleted() { + extProcCompletedLatch.countDown(); } }; } }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) - .addService(extProcImpl) - .directExecutor() - .build().start()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); }); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, channelManager); + ExternalProcessor proto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + ExternalProcessorFilterConfig filterConfig = configOrError.config; - final CountDownLatch dataPlaneLatch = new CountDownLatch(1); - final CountDownLatch headersReceivedLatch = new CountDownLatch(1); - final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); - ServerInterceptor dataPlaneInterceptor = new ServerInterceptor() { - @Override - public ServerCall.Listener interceptCall( - ServerCall call, Metadata headers, ServerCallHandler next) { - headersReceivedLatch.countDown(); - try { - resumeAsyncThreadLatch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return next.startCall(call, headers); - } - }; + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(1); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) { + call.sendMessage("server-response"); + call.close(Status.OK, new Metadata()); + } + }; + }) + .build()); - dataPlaneServiceRegistry.addService(ServerInterceptors.intercept( - ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); - dataPlaneLatch.countDown(); - })) - .build(), - dataPlaneInterceptor)); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + ManagedChannel channel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( + channel, + Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); - final AtomicReference statusRef = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { + ClientCall call = + interceptedChannel.newCall( + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor())); + call.start(new ClientCall.Listener() { @Override - public void onClose(Status status, Metadata trailers) { - statusRef.set(status); - closedLatch.countDown(); + public void onHeaders(Metadata headers) { + appEvents.add("HEADERS"); } - }; - - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); - ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); - // Trigger unary call. request(1) starts it. - proxyCall.request(1); - - // Wait for the async sidecar thread to enter activateCall() and block inside interceptCall - assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - // Now, while the async thread is blocked (and passThroughMode is still false), - // send a message and half-close. - proxyCall.sendMessage("test"); - proxyCall.halfClose(); + @Override + public void onMessage(String message) { + appEvents.add("MESSAGE"); + } - // Unblock the async thread - resumeAsyncThreadLatch.countDown(); + @Override + public void onClose(Status status, Metadata trailers) { + appEvents.add("CLOSE:" + status.getCode()); + appTrailers.set(trailers); + finishLatch.countDown(); + } + }, new Metadata()); - // Verify data plane call reached (failed open) - assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + call.request(1); + call.sendMessage("request-body"); + call.halfClose(); - // Verify client call completes successfully - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(statusRef.get().isOk()).isTrue(); + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appEvents).containsExactly("HEADERS", "MESSAGE", "CLOSE:UNAUTHENTICATED"); + assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + sidecarResponseExecutor.shutdown(); channelManager.close(); } + @Test - @SuppressWarnings("unchecked") - public void givenObservabilityMode_whenDataPlaneClosed_thenSidecarCloseIsDeferred() + @SuppressWarnings("FutureReturnValueIgnored") + public void + givenStreamingCall_whenImmediateResponseReceivedDuringRequestStreaming_thenTerminatesCleanly() throws Exception { - 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()) - .setObservabilityMode(true) - .setDeferredCloseTimeout( - com.google.protobuf.Duration.newBuilder().setSeconds(10).build()) - .build(); - ConfigOrError configOrError = - provider.parseFilterConfig(Any.pack(proto), filterContext); - assertThat(configOrError.errorDetail).isNull(); - ExternalProcessorFilterConfig filterConfig = configOrError.config; + final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + final List appEvents = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch finishLatch = new CountDownLatch(1); + final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); + final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); + final Metadata.Key immediateKey = + Metadata.Key.of("x-immediate-header", Metadata.ASCII_STRING_MARSHALLER); + final AtomicReference appTrailers = new AtomicReference<>(); + final AtomicInteger extProcRequestCount = new AtomicInteger(0); - // External Processor Server - final CountDownLatch sidecarCompletedLatch = 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() { + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onNext(ProcessingRequest request) { - } + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + sidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + int count = extProcRequestCount.incrementAndGet(); + if (count == 1) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (count == 2) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Immediate Auth Failure") + .setHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation + .newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-immediate-header") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + } + }); + } - @Override - public void onError(Throwable t) { - } + @Override + public void onError(Throwable t) {} - @Override - public void onCompleted() { - sidecarCompletedLatch.countDown(); + @Override + public void onCompleted() { + extProcCompletedLatch.countDown(); + } + }; } }; - } - }; - final io.grpc.Server extProcServer = - grpcCleanup.register( - InProcessServerBuilder.forName(extProcServerName) - .addService(extProcImpl) - .executor(fakeClock.getScheduledExecutorService()) - .build() - .start()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); }); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, channelManager); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + ExternalProcessorFilterConfig filterConfig = configOrError.config; - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); - try { - final CountDownLatch appCloseLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - appCloseLatch.countDown(); - } - }; - - CallOptions callOptions = - DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); - ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(100); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) { + call.sendMessage("server-response-" + message); + } - // Data plane closes immediately - proxyCall.halfClose(); - dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("test"); - responseObserver.onCompleted(); - })) - .build()); - proxyCall.request(1); + @Override + public void onHalfClose() { + call.close(Status.OK, new Metadata()); + } + }; + }) + .build()); - // Wait for app onClose - for (int i = 0; i < 1000 && appCloseLatch.getCount() > 0; i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); - } - assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); - // At this point, app received onClose, but sidecar should NOT be completed yet - assertThat(sidecarCompletedLatch.getCount()).isEqualTo(1); + ManagedChannel channel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( + channel, + Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); - // Fast forward time to trigger deferred close - fakeClock.forwardTime(10, TimeUnit.SECONDS); - - for (int i = 0; i < 100 && sidecarCompletedLatch.getCount() > 0; i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); + ClientCall call = + interceptedChannel.newCall( + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor())); + + call.start(new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + appEvents.add("HEADERS"); } - assertThat(sidecarCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - proxyCall.cancel("Cleanup", null); - } finally { - dataPlaneChannel.shutdownNow(); - extProcServer.shutdownNow(); - for (int i = 0; - i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); - i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); + + @Override + public void onMessage(String message) { + appEvents.add("MESSAGE:" + message); } - channelManager.close(); + + @Override + public void onClose(Status status, Metadata trailers) { + appEvents.add("CLOSE:" + status.getCode()); + appTrailers.set(trailers); + finishLatch.countDown(); + } + }, new Metadata()); + + call.request(100); + + // 1. Send Message 1 (should succeed and be allowed) + call.sendMessage("msg1"); + + // 2. Send Message 2 (should trigger the delay and then ImmediateResponse on ext_proc) + call.sendMessage("msg2"); + + // 3. Concurrent write of Message 3 (while ext_proc is sleeping) + try { + call.sendMessage("msg3"); + } catch (IllegalStateException e) { + appEvents.add("WRITE_FAILED"); } + + call.halfClose(); + + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appEvents).contains("CLOSE:UNAUTHENTICATED"); + assertThat(appEvents).doesNotContain("WRITE_FAILED"); + assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + sidecarResponseExecutor.shutdown(); + channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenUnsupportedCompressionInResponse_whenReceived_thenStreamErrored() + public void givenFailureModeAllowFalse_whenExtProcStreamFails_thenDataPlaneCallCancelled() throws Exception { - String uniqueExtProcServerName = - "extProc-compression-" + InProcessServerBuilder.generateName(); - String uniqueDataPlaneServerName = - "dataPlane-compression-" + InProcessServerBuilder.generateName(); ExternalProcessor proto = ExternalProcessor.newBuilder() .setGrpcService(GrpcService.newBuilder() .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() - .setTargetUri("in-process:///" + uniqueExtProcServerName) + .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() - .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .setFailureModeAllow(false) // Fail Closed .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // External Processor Server + // External Processor Server triggers error ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override @@ -9659,28 +9857,11 @@ public StreamObserver process( @Override public void onNext(ProcessingRequest request) { if (request.hasRequestHeaders()) { - synchronized (responseObserver) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } - } else if (request.hasRequestBody()) { - // Simulate sidecar sending compressed body mutation (unsupported) - synchronized (responseObserver) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestBody(BodyResponse.newBuilder() - .setResponse(CommonResponse.newBuilder() - .setBodyMutation(BodyMutation.newBuilder() - .setStreamedResponse(StreamedBodyResponse.newBuilder() - .setGrpcMessageCompressed(true) - .build()) - .build()) - .build()) - .build()) - .build()); - } + // Fail the stream immediately on headers + responseObserver.onError( + Status.INTERNAL + .withDescription("Simulated sidecar failure") + .asRuntimeException()); } } @@ -9690,57 +9871,25 @@ public void onError(Throwable t) { @Override public void onCompleted() { - new Thread(() -> { - synchronized (responseObserver) { - responseObserver.onCompleted(); - } - }).start(); } }; } }; - grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) .addService(extProcImpl) - .executor(fakeClock.getScheduledExecutorService()) + .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); - final CountDownLatch dataPlaneLatch = new CountDownLatch(1); - MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(uniqueRegistry) - .directExecutor() - .build().start()); - uniqueRegistry.addService(ServerInterceptors.intercept( - ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); - dataPlaneLatch.countDown(); - })) - .build(), - new ServerInterceptor() { - @Override - public ServerCall.Listener interceptCall( - ServerCall call, Metadata headers, ServerCallHandler next) { - return next.startCall(call, headers); - } - })); - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); final AtomicReference closedStatus = new AtomicReference<>(); final CountDownLatch closedLatch = new CountDownLatch(1); @@ -9752,27 +9901,12 @@ public void onClose(Status status, Metadata trailers) { } }; - CallOptions callOptions = - DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); proxyCall.start(appListener, new Metadata()); - // 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 - proxyCall.request(1); - proxyCall.sendMessage("test"); - proxyCall.halfClose(); - - // Verify application receives INTERNAL with correct description - for (int i = 0; i < 10000 && closedLatch.getCount() > 0; i++) { - fakeClock.forwardTime(1, TimeUnit.MILLISECONDS); - } + // Verify application receives INTERNAL due to sidecar failure assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); @@ -9783,27 +9917,19 @@ public void onClose(Status status, Metadata trailers) { @Test @SuppressWarnings("unchecked") - public void givenUnsupportedCompressionInResponseBody_whenReceived_thenStreamErrored() + public void givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen() throws Exception { - String uniqueExtProcServerName = - "extProc-resp-compression-" + InProcessServerBuilder.generateName(); - String uniqueDataPlaneServerName = - "dataPlane-resp-compression-" + InProcessServerBuilder.generateName(); ExternalProcessor proto = ExternalProcessor.newBuilder() .setGrpcService(GrpcService.newBuilder() .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() - .setTargetUri("in-process:///" + uniqueExtProcServerName) + .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() - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) - .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) - .build()) + .setFailureModeAllow(true) // Fail Open .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); @@ -9822,36 +9948,11 @@ public StreamObserver process( @Override public void onNext(ProcessingRequest request) { if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (request.hasRequestBody()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestBody(BodyResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (request.hasResponseHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setResponseHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } else if (request.hasResponseBody()) { - // Simulate sidecar sending compressed body mutation (unsupported) for response body - responseObserver.onNext(ProcessingResponse.newBuilder() - .setResponseBody(BodyResponse.newBuilder() - .setResponse(CommonResponse.newBuilder() - .setBodyMutation(BodyMutation.newBuilder() - .setStreamedResponse(StreamedBodyResponse.newBuilder() - .setGrpcMessageCompressed(true) - .build()) - .build()) - .build()) - .build()) - .build()); + new Thread(() -> { + synchronized (responseObserver) { + responseObserver.onError(Status.INTERNAL.asRuntimeException()); + } + }).start(); } } @@ -9861,46 +9962,61 @@ public void onError(Throwable t) { @Override public void onCompleted() { - responseObserver.onCompleted(); } }; } }; - grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); }); ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( filterConfig, channelManager, scheduler, FAKE_CONTEXT); - MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(uniqueRegistry) - .directExecutor() - .build().start()); - uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("Hello"); - responseObserver.onCompleted(); - })) - .build()); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + final CountDownLatch headersReceivedLatch = new CountDownLatch(1); + final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1); - ManagedChannel dataPlaneChannel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + ServerInterceptor dataPlaneInterceptor = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + headersReceivedLatch.countDown(); + try { + resumeAsyncThreadLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return next.startCall(call, headers); + } + }; - final AtomicReference closedStatus = new AtomicReference<>(); + dataPlaneServiceRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build(), + dataPlaneInterceptor)); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference statusRef = new AtomicReference<>(); final CountDownLatch closedLatch = new CountDownLatch(1); ClientCall.Listener appListener = new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); + @Override + public void onClose(Status status, Metadata trailers) { + statusRef.set(status); closedLatch.countDown(); } }; @@ -9910,39 +10026,47 @@ public void onCompleted() { interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); proxyCall.start(appListener, new Metadata()); + // Trigger unary call. request(1) starts it. proxyCall.request(1); + + // Wait for the async sidecar thread to enter activateCall() and block inside interceptCall + assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Now, while the async thread is blocked (and passThroughMode is still false), + // send a message and half-close. proxyCall.sendMessage("test"); proxyCall.halfClose(); - // Verify application receives INTERNAL with correct description + // Unblock the async thread + resumeAsyncThreadLatch.countDown(); + + // Verify data plane call reached (failed open) + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify client call completes successfully assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); - assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + assertThat(statusRef.get().isOk()).isTrue(); - proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenHeaderSendModeDefault_whenProcessing_thenFollowsDefaultBehavior() + public void givenObservabilityMode_whenDataPlaneClosed_thenSidecarCloseIsDeferred() throws Exception { - String uniqueExtProcServerName = InProcessServerBuilder.generateName(); - String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); ExternalProcessor proto = ExternalProcessor.newBuilder() .setGrpcService(GrpcService.newBuilder() .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() - .setTargetUri("in-process:///" + uniqueExtProcServerName) + .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.DEFAULT) - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.DEFAULT).build()) + .setObservabilityMode(true) + .setDeferredCloseTimeout( + com.google.protobuf.Duration.newBuilder().setSeconds(10).build()) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); @@ -9950,9 +10074,7 @@ public void givenHeaderSendModeDefault_whenProcessing_thenFollowsDefaultBehavior ExternalProcessorFilterConfig filterConfig = configOrError.config; // External Processor Server - final AtomicInteger sidecarRequestHeaderCount = new AtomicInteger(0); - final AtomicInteger sidecarResponseHeaderCount = new AtomicInteger(0); - final AtomicInteger sidecarResponseTrailerCount = new AtomicInteger(0); + final CountDownLatch sidecarCompletedLatch = new CountDownLatch(1); ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override @@ -9963,23 +10085,6 @@ public StreamObserver process( return new StreamObserver() { @Override public void onNext(ProcessingRequest request) { - if (request.hasRequestHeaders()) { - sidecarRequestHeaderCount.incrementAndGet(); - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder().build()) - .build()); - } else if (request.hasResponseHeaders()) { - sidecarResponseHeaderCount.incrementAndGet(); - responseObserver.onNext(ProcessingResponse.newBuilder() - .setResponseHeaders(HeadersResponse.newBuilder().build()) - .build()); - } else if (request.hasResponseTrailers()) { - sidecarResponseTrailerCount.incrementAndGet(); - responseObserver.onNext(ProcessingResponse.newBuilder() - .setResponseTrailers(TrailersResponse.newBuilder().build()) - .build()); - responseObserver.onCompleted(); - } } @Override @@ -9988,342 +10093,4813 @@ public void onError(Throwable t) { @Override public void onCompleted() { + sidecarCompletedLatch.countDown(); } }; } }; final io.grpc.Server extProcServer = grpcCleanup.register( - InProcessServerBuilder.forName(uniqueExtProcServerName) + InProcessServerBuilder.forName(extProcServerName) .addService(extProcImpl) .executor(fakeClock.getScheduledExecutorService()) .build() .start()); - CachedChannelManager channelManager = new CachedChannelManager(config -> { - return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); - }); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Data plane closes immediately + proxyCall.halfClose(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("test"); + responseObserver.onCompleted(); + })) + .build()); + proxyCall.request(1); + + // Wait for app onClose + for (int i = 0; i < 1000 && appCloseLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // At this point, app received onClose, but sidecar should NOT be completed yet + assertThat(sidecarCompletedLatch.getCount()).isEqualTo(1); + + // Fast forward time to trigger deferred close + fakeClock.forwardTime(10, TimeUnit.SECONDS); + + for (int i = 0; i < 100 && sidecarCompletedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(sidecarCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + channelManager.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenUnsupportedCompressionInResponse_whenReceived_thenStreamErrored() + throws Exception { + String uniqueExtProcServerName = + "extProc-compression-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-compression-" + 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()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + 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) { + if (request.hasRequestHeaders()) { + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } + } else if (request.hasRequestBody()) { + // Simulate sidecar sending compressed body mutation (unsupported) + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setGrpcMessageCompressed(true) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> { + synchronized (responseObserver) { + responseObserver.onCompleted(); + } + }).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(fakeClock.getScheduledExecutorService()) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // 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); + } + + // Trigger request body processing to hit the unsupported compression check + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives INTERNAL with correct description + for (int i = 0; i < 10000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.MILLISECONDS); + } + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenUnsupportedCompressionInResponseBody_whenReceived_thenStreamErrored() + throws Exception { + String uniqueExtProcServerName = + "extProc-resp-compression-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-resp-compression-" + 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()) + .setProcessingMode(ProcessingMode.newBuilder() + .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; + + // External Processor Server + 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) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseBody()) { + // Simulate sidecar sending compressed body mutation (unsupported) for + // response body + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setGrpcMessageCompressed(true) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @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); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.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 application receives INTERNAL with correct description + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenHeaderSendModeDefault_whenProcessing_thenFollowsDefaultBehavior() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = 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()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.DEFAULT).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final AtomicInteger sidecarRequestHeaderCount = new AtomicInteger(0); + final AtomicInteger sidecarResponseHeaderCount = new AtomicInteger(0); + final AtomicInteger sidecarResponseTrailerCount = new AtomicInteger(0); + 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) { + if (request.hasRequestHeaders()) { + sidecarRequestHeaderCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + sidecarResponseHeaderCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseTrailers()) { + sidecarResponseTrailerCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(fakeClock.getScheduledExecutorService()) + .build() + .start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + final io.grpc.Server dataPlaneServer = + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .executor(fakeClock.getScheduledExecutorService()) + .build() + .start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("test"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final CountDownLatch finishLatch = new CountDownLatch(1); + CallOptions callOptions = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + finishLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + for (int i = 0; i < 1000 && finishLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Defaults: Request headers SENT, Response headers SENT, Response trailers SKIPPED + assertThat(sidecarRequestHeaderCount.get()).isEqualTo(1); + assertThat(sidecarResponseHeaderCount.get()).isEqualTo(1); + assertThat(sidecarResponseTrailerCount.get()).isEqualTo(0); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + dataPlaneServer.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 + && (!dataPlaneChannel.isTerminated() + || !dataPlaneServer.isTerminated() + || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + 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 18: Immediate Response Handling --- + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponse_whenReceived_thenDataPlaneCallCancelled() + throws Exception { + 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()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + 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) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Custom security rejection") + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicBoolean dataPlaneStarted = new AtomicBoolean(false); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneStarted.set(true); + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Verify app listener notified with the correct status and details + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); + assertThat(closedStatus.get().getDescription()).isEqualTo("Custom security rejection"); + + // Data plane call should NOT have been started as sidecar rejected immediately on headers + assertThat(dataPlaneStarted.get()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseAndObservabilityTrue_whenReceived_thenImmediateResponseIgnored() + throws Exception { + 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()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server sends ImmediateResponse + 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) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Custom security rejection") + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .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 CountDownLatch closedLatch = new CountDownLatch(1); + final AtomicReference closedStatus = new AtomicReference<>(); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.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(); + + // In observability mode, the call should NOT be cancelled by the immediate response. + // It should proceed normally to the data plane and finish successfully (Status.OK). + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().isOk()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseDisabled_whenReceivedBeforeActivation_thenSidecarStreamErrored() + throws Exception { + 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()) + .setDisableImmediateResponse(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server sends immediate response despite being disabled + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(extProcServerName) + .addService(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() + .setImmediateResponse( + ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }) + .executor(fakeClock.getScheduledExecutorService()) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + for (int i = 0; i < 1000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + // Verify app listener notified with an error (not the sidecar's UNAUTHENTICATED) + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + channelManager.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseDisabled_whenReceivedAfterActivation_thenSidecarStreamErrored() + throws Exception { + 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()) + .setDisableImmediateResponse(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server sends request headers first (activating the call) + // and then schedules an immediate response (which is disabled) + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(extProcServerName) + .addService(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()) { + // 1. Send request headers response to activate the call + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + + // 2. Schedule the immediate response to be sent after 2 seconds + @SuppressWarnings("unused") + java.util.concurrent.ScheduledFuture unused = + fakeClock.getScheduledExecutorService().schedule(() -> { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse( + ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc + .v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .build()) + .build()); + }, 2, TimeUnit.SECONDS); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }) + .executor(fakeClock.getScheduledExecutorService()) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + for (int i = 0; i < 1000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + // 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 + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNIMPLEMENTED); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + channelManager.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseInTrailers_whenReceived_thenDataPlaneCallStatusIsOverridden() + throws Exception { + 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() + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + 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) { + if (request.hasRequestHeaders()) { + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } + } else if (request.hasResponseHeaders()) { + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } + } else if (request.hasResponseTrailers()) { + new Thread(() -> { + synchronized (responseObserver) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setImmediateResponse( + ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus + .newBuilder() + .setStatus(Status.DATA_LOSS.getCode().value()) + .build()) + .setDetails("Sidecar detected data loss") + .setHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation + .newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-sidecar-extra") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + }).start(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).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()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final AtomicReference closedTrailers = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedTrailers.set(trailers); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Request message to allow the call to complete + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives the OVERRIDDEN status and merged trailers + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.DATA_LOSS); + assertThat(closedStatus.get().getDescription()).isEqualTo("Sidecar detected data loss"); + assertThat( + closedTrailers + .get() + .get(Metadata.Key.of("x-sidecar-extra", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 19: Resource Management --- + + @Test + public void givenFilter_whenClosed_thenCachedChannelManagerIsClosed() throws Exception { + CachedChannelManager mockChannelManager = Mockito.mock(CachedChannelManager.class); + + ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, mockChannelManager); + + filter.close(); + + Mockito.verify(mockChannelManager).close(); + } + + // --- Category 20: Data plane rpc cancellation --- + + @Test + @SuppressWarnings("unchecked") + public void givenActiveRpc_whenDataPlaneCallCancelled_thenExtProcStreamIsErrored() + throws Exception { + 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()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch cancelLatch = 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) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + cancelLatch.countDown(); + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).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) -> { + // No-op + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation + for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { + fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Application cancels the RPC + proxyCall.cancel("User cancelled", null); + + // Verify sidecar stream also cancelled + assertThat(cancelLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + channelManager.close(); + } + + // --- Category 21: Flow Control when side stream is full --- + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityModeFalse_whenExtProcBusy_thenIsReadyReturnsFalse() + throws Exception { + 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()) + .setObservabilityMode(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final List extProcRequests = + 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 + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + extProcRequests.add(request); + 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(() -> { + synchronized (responseObserver) { + responseObserver.onCompleted(); + } + }).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicBoolean dataPlaneReady = 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 boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .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"); + responseObserver.onCompleted(); + })) + .build()); + + 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 boolean isReady() { + return dataPlaneReady.get() && super.isReady(); + } + }; + } + }) + .build()); + + CallOptions callOptions2 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions2, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation + assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(responseSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar becomes busy -> proxyCall becomes busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + // Sidecar becomes ready, but Data Plane is busy -> proxyCall is STILL ready because Normal Mode + sidecarReady.set(true); + dataPlaneReady.set(false); + assertThat(proxyCall.isReady()).isTrue(); + + assertThat(extProcRequests).isNotEmpty(); + for (ProcessingRequest request : extProcRequests) { + assertThat(request.getObservabilityMode()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityModeFalse_whenExtProcBusy_thenAppRequestsAreBuffered() + throws Exception { + 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() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .setObservabilityMode(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // 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) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + sidecarActionLatch.countDown(); + synchronized (responseObserver) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .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(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> { + synchronized (responseObserver) { + responseObserver.onCompleted(); + } + }).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); + 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) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .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 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 for headers + + // Wait for activation + assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + 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(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); + } + 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(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenResponseBodyModeNone_whenExtProcBusy_thenAppRequestsAreNotBuffered() + throws Exception { + 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() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .setObservabilityMode(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // 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) { + 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 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, but since we have queued messages + // (Mutated1) and transport is not ready, it will defer half-close. + assertThat(halfCloseCallCount.get()).isEqualTo(0); + + // 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, and then the deferred half-close is triggered. + assertThat(sendMessageCount.get()).isEqualTo(1); + 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(); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + // 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); - MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); - final io.grpc.Server dataPlaneServer = - grpcCleanup.register( - InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(uniqueRegistry) - .executor(fakeClock.getScheduledExecutorService()) - .build() - .start()); - uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - responseObserver.onNext("test"); - responseObserver.onCompleted(); - })) - .build()); + assertThat(responseBodyReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // receivedRequests contains Headers + ResponseBody + assertThat(receivedRequests).hasSize(2); - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + // 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); - try { - final CountDownLatch finishLatch = new CountDownLatch(1); - CallOptions callOptions = - DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); - ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - finishLatch.countDown(); - } - }, new Metadata()); - proxyCall.request(1); - proxyCall.sendMessage("test"); - proxyCall.halfClose(); + assertThat(requestBodyProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - 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(); - - // Defaults: Request headers SENT, Response headers SENT, Response trailers SKIPPED - assertThat(sidecarRequestHeaderCount.get()).isEqualTo(1); - assertThat(sidecarResponseHeaderCount.get()).isEqualTo(1); - assertThat(sidecarResponseTrailerCount.get()).isEqualTo(0); + // receivedRequests should only contain Headers, ResponseBody, and RequestBody (size = 3). + assertThat(receivedRequests).hasSize(3); - proxyCall.cancel("Cleanup", null); - } finally { - dataPlaneChannel.shutdownNow(); - dataPlaneServer.shutdownNow(); - extProcServer.shutdownNow(); - for (int i = 0; - i < 100 - && (!dataPlaneChannel.isTerminated() - || !dataPlaneServer.isTerminated() - || !extProcServer.isTerminated()); - i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); - } - channelManager.close(); - } + proxyCall.cancel("Cleanup", null); + channelManager.close(); } - // --- Category 17: Immediate Response Handling --- - @Test @SuppressWarnings("unchecked") - public void givenImmediateResponse_whenReceived_thenDataPlaneCallCancelled() - throws Exception { + 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("type.googleapis.com/envoy.extensions.grpc_service." - + "channel_credentials.insecure.v3.InsecureCredentials") + .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; - // External Processor Server - 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) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse(ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) - .build()) - .setDetails("Custom security rejection") - .build()) - .build()); - responseObserver.onCompleted(); - } - } + final CountDownLatch extProcUnilateralLatch = new CountDownLatch(1); + final CountDownLatch clientHalfCloseProcessedLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onError(Throwable t) { - } + 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 onCompleted() { + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) - .addService(extProcImpl) - .directExecutor() - .build().start()); - CachedChannelManager channelManager = new CachedChannelManager(config -> { - return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .directExecutor() - .build()); - }); + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); - final AtomicBoolean dataPlaneStarted = new AtomicBoolean(false); - dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") - .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - dataPlaneStarted.set(true); - responseObserver.onNext("Hello " + request); - responseObserver.onCompleted(); - })) - .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).directExecutor().build()); + 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()); - final AtomicReference closedStatus = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override - public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedLatch.countDown(); - } - }; - - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + 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(); - // Verify app listener notified with the correct status and details - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); - assertThat(closedStatus.get().getDescription()).isEqualTo("Custom security rejection"); - - // Data plane call should NOT have been started as sidecar rejected immediately on headers - assertThat(dataPlaneStarted.get()).isFalse(); - proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenImmediateResponseAndObservabilityTrue_whenReceived_thenImmediateResponseIgnored() - throws Exception { + public void testHalfClosePiggybacking() throws Exception { 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") + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) .build()) .build()) .build()) - .setObservabilityMode(true) + .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; - - // External Processor Server sends ImmediateResponse - ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; - extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { - @Override - @SuppressWarnings("unchecked") - public StreamObserver process( - final StreamObserver responseObserver) { - ((ServerCallStreamObserver) responseObserver).request(100); - return new StreamObserver() { + + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onNext(ProcessingRequest request) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse(ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) + 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()) - .setDetails("Custom security rejection") - .build()) - .build()); - responseObserver.onCompleted(); - } - } + .build()) + .build()); + } + } - @Override - public void onError(Throwable t) {} + @Override + public void onError(Throwable t) {} - @Override - public void onCompleted() {} + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .directExecutor() - .build()); + 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(); + .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()); - final CountDownLatch closedLatch = new CountDownLatch(1); - final AtomicReference closedStatus = new AtomicReference<>(); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override - public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedLatch.countDown(); - } - }; - - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + 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.request(1); - proxyCall.sendMessage("test"); proxyCall.halfClose(); - // In observability mode, the call should NOT be cancelled by the immediate response. - // It should proceed normally to the data plane and finish successfully (Status.OK). - assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedStatus.get().isOk()).isTrue(); - + // 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 givenImmediateResponseDisabled_whenReceivedBeforeActivation_thenSidecarStreamErrored() - throws Exception { + public void testPiggybackingOnRequestBody() throws Exception { 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") + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) .build()) .build()) .build()) - .setDisableImmediateResponse(true) + .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; - // External Processor Server sends immediate response despite being disabled - final io.grpc.Server extProcServer = - grpcCleanup.register( - InProcessServerBuilder.forName(extProcServerName) - .addService(new ExternalProcessorGrpc.ExternalProcessorImplBase() { + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override public StreamObserver process( final StreamObserver responseObserver) { @@ -10331,112 +14907,216 @@ public StreamObserver process( return new StreamObserver() { @Override public void onNext(ProcessingRequest request) { + receivedRequests.add(request); if (request.hasRequestHeaders()) { responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse( - ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .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 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()) - @Override - public void onCompleted() { - } - }; - } - }) - .executor(fakeClock.getScheduledExecutorService()) - .build().start()); + .isEqualTo(15); + assertThat(dummyRespBodyReq.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) - CachedChannelManager channelManager = new CachedChannelManager(config -> { - return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); - }); + .isEqualTo(0); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + // 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()) - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + .isEqualTo(0); + assertThat(msg2Req.getClientWindowUpdate().getWindowIncrementSidestreamToDownstream()) - try { - final AtomicReference closedStatus = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedLatch.countDown(); - } - }; - - CallOptions callOptions = - DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); - ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + .isEqualTo(20); - 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(); - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); - - proxyCall.cancel("Cleanup", null); - } finally { - dataPlaneChannel.shutdownNow(); - extProcServer.shutdownNow(); - for (int i = 0; - i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); - i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); - } - channelManager.close(); - } + proxyCall.cancel("Cleanup", null); + channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenImmediateResponseDisabled_whenReceivedAfterActivation_thenSidecarStreamErrored() - throws Exception { + public void testPiggybackingOnResponseBody() throws Exception { 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") + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) .build()) .build()) .build()) - .setDisableImmediateResponse(true) + .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; - // External Processor Server sends request headers first (activating the call) - // and then schedules an immediate response (which is disabled) - final io.grpc.Server extProcServer = - grpcCleanup.register( - InProcessServerBuilder.forName(extProcServerName) - .addService(new ExternalProcessorGrpc.ExternalProcessorImplBase() { + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override public StreamObserver process( final StreamObserver responseObserver) { @@ -10444,509 +15124,745 @@ public StreamObserver process( return new StreamObserver() { @Override public void onNext(ProcessingRequest request) { + receivedRequests.add(request); if (request.hasRequestHeaders()) { - // 1. Send request headers response to activate the call responseObserver.onNext(ProcessingResponse.newBuilder() .setRequestHeaders(HeadersResponse.newBuilder().build()) .build()); - - // 2. Schedule the immediate response to be sent after 2 seconds - @SuppressWarnings("unused") - java.util.concurrent.ScheduledFuture unused = - fakeClock.getScheduledExecutorService().schedule(() -> { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setImmediateResponse( - ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc - .v3.GrpcStatus.newBuilder() - .setStatus(Status.UNAUTHENTICATED.getCode().value()) - .build()) - .build()) - .build()); - }, 2, TimeUnit.SECONDS); + 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) { - } + public void onError(Throwable t) {} @Override public void onCompleted() { + responseObserver.onCompleted(); } }; } - }) - .executor(fakeClock.getScheduledExecutorService()) + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + 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) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); - try { - final AtomicReference closedStatus = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedLatch.countDown(); - } - }; - - CallOptions callOptions = - DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); - ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); - 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 - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNIMPLEMENTED); - - proxyCall.cancel("Cleanup", null); - } finally { - dataPlaneChannel.shutdownNow(); - extProcServer.shutdownNow(); - for (int i = 0; - i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); - i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(1); - } - channelManager.close(); + 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 givenImmediateResponseInTrailers_whenReceived_thenDataPlaneCallStatusIsOverridden() - throws Exception { + public void testClientWindowUpdateDeferredUntilRequestBodySendMessage() throws Exception { 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") + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) .build()) .build()) .build()) .setProcessingMode(ProcessingMode.newBuilder() - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND).build()) + .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; - // External Processor Server - ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; - extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { - @Override - @SuppressWarnings("unchecked") - public StreamObserver process( - final StreamObserver responseObserver) { - ((ServerCallStreamObserver) responseObserver).request(100); - return new StreamObserver() { + 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 void onNext(ProcessingRequest request) { - if (request.hasRequestHeaders()) { - synchronized (responseObserver) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } - } else if (request.hasResponseHeaders()) { - synchronized (responseObserver) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setResponseHeaders(HeadersResponse.newBuilder() - .setResponse(CommonResponse.newBuilder().build()) - .build()) - .build()); - } - } else if (request.hasResponseTrailers()) { - new Thread(() -> { - synchronized (responseObserver) { - responseObserver.onNext( - ProcessingResponse.newBuilder() - .setImmediateResponse( - ImmediateResponse.newBuilder() - .setGrpcStatus( - io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus - .newBuilder() - .setStatus(Status.DATA_LOSS.getCode().value()) - .build()) - .setDetails("Sidecar detected data loss") - .setHeaders( - io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation - .newBuilder() - .addSetHeaders( - io.envoyproxy.envoy.config.core.v3.HeaderValueOption - .newBuilder() - .setHeader( - io.envoyproxy.envoy.config.core.v3.HeaderValue - .newBuilder() - .setKey("x-sidecar-extra") - .setValue("true") - .build()) - .build()) - .build()) + 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()); - responseObserver.onCompleted(); + .build()) + .build()) + .build()); } - }).start(); - } - } + } - @Override - public void onError(Throwable t) { - } + @Override + public void onError(Throwable t) {} - @Override - public void onCompleted() { + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + 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(); + .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).directExecutor().build()); + 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()); - final AtomicReference closedStatus = new AtomicReference<>(); - final AtomicReference closedTrailers = new AtomicReference<>(); - final CountDownLatch closedLatch = new CountDownLatch(1); - ClientCall.Listener appListener = new ClientCall.Listener() { - @Override public void onClose(Status status, Metadata trailers) { - closedStatus.set(status); - closedTrailers.set(trailers); - closedLatch.countDown(); - } - }; - - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(appListener, new Metadata()); + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - // Request message to allow the call to complete - proxyCall.request(1); - proxyCall.sendMessage("test"); - proxyCall.halfClose(); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - // Verify application receives the OVERRIDDEN status and merged trailers - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.DATA_LOSS); - assertThat(closedStatus.get().getDescription()).isEqualTo("Sidecar detected data loss"); - assertThat( - closedTrailers - .get() - .get(Metadata.Key.of("x-sidecar-extra", Metadata.ASCII_STRING_MARSHALLER))) - .isEqualTo("true"); - - proxyCall.cancel("Cleanup", null); - channelManager.close(); - } + // Send 40k message to ext_proc. + String body40k = new String(new char[40000]).replace('\0', 'a'); - // --- Category 18: Resource Management --- + // Call sendMessage in a background thread to avoid blocking the main test execution thread + new Thread(() -> { + proxyCall.sendMessage(body40k); + sendMessageFinishedLatch.countDown(); + }).start(); - @Test - public void givenFilter_whenClosed_thenCachedChannelManagerIsClosed() throws Exception { - CachedChannelManager mockChannelManager = Mockito.mock(CachedChannelManager.class); - - ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, mockChannelManager); - - filter.close(); - - Mockito.verify(mockChannelManager).close(); - } + // 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()) - // --- Category 19: Data plane rpc cancellation --- + .isEqualTo(40000); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } @Test @SuppressWarnings("unchecked") - public void givenActiveRpc_whenDataPlaneCallCancelled_thenExtProcStreamIsErrored() - throws Exception { + public void testClientWindowUpdateDeferredUntilResponseBodyOnMessage() throws Exception { 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") + .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; - // External Processor Server - final CountDownLatch cancelLatch = 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) { - if (request.hasRequestHeaders()) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder().build()) - .build()); - } - } + 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 void onError(Throwable t) { - cancelLatch.countDown(); - } + 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 onCompleted() { + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); CachedChannelManager channelManager = new CachedChannelManager(config -> { return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + 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_SAY_HELLO, ServerCalls.asyncUnaryCall( - (request, responseObserver) -> { - // No-op + .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()); + 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); + } + } + } + }; - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - // Wait for activation - for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { - fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); - Thread.sleep(10); - } - assertThat(proxyCall.isReady()).isTrue(); + proxyCall.start(appListener, new Metadata()); + proxyCall.request(10); - // Application cancels the RPC - proxyCall.cancel("User cancelled", null); + // Call client to activate + proxyCall.sendMessage("Client Msg"); - // Verify sidecar stream also cancelled - assertThat(cancelLatch.await(5, TimeUnit.SECONDS)).isTrue(); - + 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(); } - // --- Category 20: Flow Control when side stream is full --- - @Test @SuppressWarnings("unchecked") - public void givenObservabilityModeFalse_whenExtProcBusy_thenIsReadyReturnsFalse() + public void testClientWindowUpdateSentImmediatelyOnSidestreamToUpstreamWindowExhaustion() throws Exception { - 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") + 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()) - .build()) - .build()) - .setObservabilityMode(false) - .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 extProcRequests = - new java.util.concurrent.CopyOnWriteArrayList<>(); - // Sidecar server - final CountDownLatch sidecarActionLatch = 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() { + 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 void onNext(ProcessingRequest request) { - extProcRequests.add(request); - new Thread(() -> { - if (request.hasRequestHeaders()) { - sidecarActionLatch.countDown(); - synchronized (responseObserver) { + 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(); } } - }).start(); - } - @Override - public void onError(Throwable t) { - } + @Override + public void onError(Throwable t) { + } - @Override - public void onCompleted() { - new Thread(() -> { - synchronized (responseObserver) { + @Override + public void onCompleted() { responseObserver.onCompleted(); } - }).start(); + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) .addService(extProcImpl) .directExecutor() .build().start()); - final AtomicBoolean sidecarReady = new AtomicBoolean(true); - final AtomicBoolean dataPlaneReady = 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 boolean isReady() { - return sidecarReady.get(); - } - }; - } - }) - .build()); + 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"); - responseObserver.onCompleted(); - })) - .build()); + 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) {} - 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 boolean isReady() { - return dataPlaneReady.get() && super.isReady(); - } - }; - } - }) + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) .build()); - CallOptions callOptions2 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + 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_SAY_HELLO, callOptions2, dataPlaneChannel); + interceptCall( + interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - // 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(proxyCall.isReady()).isTrue(); + // Send 10k message to ext_proc. + String body10k = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(body10k); - // Sidecar becomes busy -> proxyCall becomes busy - sidecarReady.set(false); - assertThat(proxyCall.isReady()).isFalse(); + 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(); - // Sidecar becomes ready, but Data Plane is busy -> proxyCall is STILL ready because Normal Mode - sidecarReady.set(true); - dataPlaneReady.set(false); - assertThat(proxyCall.isReady()).isTrue(); + // Wait for the window update to be received by ext_proc + assertThat(windowUpdateLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(extProcRequests).isNotEmpty(); - for (ProcessingRequest request : extProcRequests) { - assertThat(request.getObservabilityMode()).isFalse(); - } + assertThat(receivedRequests).hasSize(3); + assertThat(receivedRequests.get(2).hasClientWindowUpdate()).isTrue(); + assertThat(receivedRequests.get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + .isEqualTo(20000); proxyCall.cancel("Cleanup", null); channelManager.close(); @@ -10954,167 +15870,258 @@ public boolean isReady() { @Test @SuppressWarnings("unchecked") - public void givenObservabilityModeFalse_whenExtProcBusy_thenAppRequestsAreBuffered() + public void testClientWindowUpdateSentImmediatelyOnSidestreamToDownstreamWindowExhaustion() throws Exception { - 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") + 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()) - .build()) - .build()) - .setProcessingMode(ProcessingMode.newBuilder() - .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) - .build()) - .setObservabilityMode(false) - .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; - // Sidecar server - final CountDownLatch sidecarActionLatch = 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() { + 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 void onNext(ProcessingRequest request) { - new Thread(() -> { - if (request.hasRequestHeaders()) { - sidecarActionLatch.countDown(); - synchronized (responseObserver) { - responseObserver.onNext(ProcessingResponse.newBuilder() - .setRequestHeaders(HeadersResponse.newBuilder().build()) - .build()); + 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(); + } } } - }).start(); - } - @Override - public void onError(Throwable t) { - } + @Override + public void onError(Throwable t) { + } - @Override - public void onCompleted() { - new Thread(() -> { - synchronized (responseObserver) { + @Override + public void onCompleted() { responseObserver.onCompleted(); } - }).start(); + }; } }; - } - }; - grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) - .addService(extProcImpl) - .directExecutor() - .build().start()); - final AtomicBoolean sidecarReady = new AtomicBoolean(true); - final AtomicReference> sidecarListenerRef = - new AtomicReference<>(); - 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) { - sidecarListenerRef.set((Listener) responseListener); - super.start(responseListener, headers); - } + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); - @Override - public boolean isReady() { - return sidecarReady.get(); - } - }; - } - }) - .build()); - }); + 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"); - 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) {} - 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); - } - }; - } - }) + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) .build()); - CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + ClientCall proxyCall = - interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); - proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); - // 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); + 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(proxyCall.isReady()).isTrue(); + 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); - // Sidecar busy -> request(5) should be buffered - sidecarReady.set(false); - proxyCall.request(5); - assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(1).length()).isEqualTo(20000); - // Sidecar becomes ready -> buffered requests should be drained - sidecarReady.set(true); - sidecarListenerRef.get().onReady(); - - long startTime2 = System.currentTimeMillis(); - while (dataPlaneRequestCount.get() < 5 && System.currentTimeMillis() - startTime2 < 5000) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); - Thread.sleep(10); + windowUpdates.clear(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasClientWindowUpdate()) { + windowUpdates.add(req); + } } - assertThat(dataPlaneRequestCount.get()).isEqualTo(5); + // 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) --- + // --- Category 22: Streaming Completeness (Client & Bi-Di) --- @Test @SuppressWarnings({"unchecked", "FutureReturnValueIgnored"}) @@ -11142,7 +16149,7 @@ public void givenClientStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveM Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); - final CountDownLatch sidecarActionLatch = new CountDownLatch(6); + final CountDownLatch sidecarActionLatch = new CountDownLatch(5); final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); // External Processor Server ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; @@ -11178,8 +16185,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() @@ -11189,10 +16212,9 @@ public void onNext(ProcessingRequest request) { BodyMutation.newBuilder() .setStreamedResponse( StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8( + "MutatedRequest")) .setEndOfStream(true) - .setEndOfStreamWithoutMessage( - request.getRequestBody() - .getEndOfStreamWithoutMessage()) .build()) .build()) .build()) @@ -11342,6 +16364,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); final AtomicReference clientReceivedBody = new AtomicReference<>(); StreamObserver requestObserver = ClientCalls.asyncClientStreamingCall( interceptCall(interceptor, @@ -11356,6 +16379,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11372,7 +16396,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 +16447,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 +16483,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 +16510,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 +16672,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); StreamObserver bidiRequestObserver = ClientCalls.asyncBidiStreamingCall( interceptCall(interceptor, METHOD_BIDI_STREAMING, @@ -11643,6 +16686,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11659,7 +16703,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", @@ -11680,7 +16727,7 @@ public void onCompleted() { channelManager.close(); } - // --- Category 22: Header Forwarding --- + // --- Category 23: Header Forwarding --- @Test public void @@ -12204,7 +17251,7 @@ public void onClose(Status status, Metadata trailers) { channelManager.close(); } - // --- Category 23: Request Attributes --- + // --- Category 24: Request Attributes --- @Test public void parseFilterConfig_withUnrecognizedRequestAttribute_isIgnored() { @@ -12461,7 +17508,7 @@ public void onClose(Status status, Metadata trailers) { - // --- Category 24: Response Ordering Checks --- + // --- Category 25: Response Ordering Checks --- @Test public void givenOutOfOrderReqResponses_whenMessageArrivesBeforeHeaders_thenFails() @@ -13204,7 +18251,7 @@ public void onCompleted() { channelManager.close(); } - // --- Category 25: Header Response Status Checks --- + // --- Category 26: Header Response Status Checks --- @Test public void givenRequestHeadersResponse_whenStatusIsContinueAndReplace_thenFails() @@ -13749,7 +18796,7 @@ public ServerCall.Listener interceptCall( realScheduler.shutdown(); } - // --- Category 26: Call activation with failure mode allow on and off --- + // --- Category 27: Call activation with failure mode allow on and off --- @Test public void givenRequestHeaderModeSend_Fma_true_whenExtProcTerminates_thenCallIsActivated() @@ -14296,7 +19343,7 @@ public void onClose(Status status, Metadata trailers) { channelManager.close(); } - // --- Category 27: Request-Scoped Context Propagation --- + // --- Category 28: Request-Scoped Context Propagation --- @Test public void clientInterceptor_contextPropagatedToStartCall() throws Exception { @@ -14643,7 +19690,7 @@ public ClientCall interceptCall( } } - // --- Category 28: Header Option Value Spec Compliance and Validation --- + // --- Category 29: Header Option Value Spec Compliance and Validation --- @Test @SuppressWarnings("unchecked") @@ -15630,11 +20677,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