diff --git a/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java index 0bc79e5ec5d..04830b29fa7 100644 --- a/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java @@ -60,9 +60,11 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.internal.DelayedClientCall; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.SerializingExecutor; import io.grpc.stub.ClientCallStreamObserver; import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.MetadataUtils; import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterConfig; import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.internal.extproc.DataPlaneCallState; @@ -77,6 +79,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 +92,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. @@ -117,7 +121,7 @@ final class ExternalProcessorClientInterceptor implements ClientInterceptor { } static synchronized void initMetricInstruments() { - if (io.grpc.internal.GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { + if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { if (clientHeadersDuration == null) { MetricInstrumentRegistry registry = MetricInstrumentRegistry.getDefaultRegistry(); @@ -186,11 +190,6 @@ ExternalProcessorFilterConfig getFilterConfig() { return filterConfig; } - @VisibleForTesting - ManagedChannel getExtProcChannel() { - return extProcChannel; - } - @Override @SuppressWarnings("unchecked") public ClientCall interceptCall( @@ -229,10 +228,9 @@ public ClientCall interceptCall( } } extProcStub = extProcStub.withInterceptors( - io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(extraHeaders)); + MetadataUtils.newAttachHeadersInterceptor(extraHeaders)); } - // The filter chain is preceded by RawMessageClientInterceptor, so ReqT and RespT are // InputStream. MethodDescriptor rawMethod = @@ -278,11 +276,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 +289,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 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 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; @@ -312,6 +358,7 @@ private static class DataPlaneClientCall new AtomicReference<>(ExtProcStreamState.ACTIVE); final AtomicBoolean passThroughMode = new AtomicBoolean(false); final AtomicBoolean requestSideClosed = new AtomicBoolean(false); + final AtomicBoolean appHalfClosed = new AtomicBoolean(false); final AtomicBoolean isProcessingTrailers = new AtomicBoolean(false); final AtomicBoolean pendingHalfClose = new AtomicBoolean(false); final AtomicBoolean bodyMessageSentToExtProc = new AtomicBoolean(false); @@ -344,15 +391,13 @@ protected DataPlaneClientCall( this.backendService = checkNotNull(backendService, "backendService"); } - - - private void activateCall() { + private boolean activateCall() { if ((extProcStreamState.get() == ExtProcStreamState.FAILED && !config.getFailureModeAllow() && !config.getObservabilityMode()) || !dataPlaneCallState.compareAndSet( DataPlaneCallState.IDLE, DataPlaneCallState.ACTIVE)) { - return; + return false; } if (clientHeadersStartNanos > 0) { long durationNanos = System.nanoTime() - clientHeadersStartNanos; @@ -365,6 +410,7 @@ private void activateCall() { } drainPendingRequests(); onReadyNotify(); + return true; } private void recordDuration(DoubleHistogramMetricInstrument instrument, long durationNanos) { @@ -412,8 +458,6 @@ private boolean validateCompressionSupport(BodyResponse bodyResponse) { return true; } - - @Override public void start(Listener responseListener, Metadata headers) { this.callContext = Context.current(); @@ -440,6 +484,26 @@ public void onNext(ProcessingResponse response) { return; } + if (response.hasServerWindowUpdate()) { + ProcessingResponse.ServerWindowUpdate update = response.getServerWindowUpdate(); + boolean wasReady; + synchronized (streamLock) { + wasReady = isReady(); + 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 @@ -668,21 +732,96 @@ 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) + .setInitialWindowUpstreamToSidestream(DEFAULT_INITIAL_WINDOW_SIZE) + .setInitialWindowSidestreamToDownstream(DEFAULT_INITIAL_WINDOW_SIZE) + .build()) + .build(); + flowControlInitSent = true; } extProcClientCallRequestObserver.onNext(requestToSend); } } + // Note: This method not only modifies the builder, but has the side effect of modifying + // the window update bookkeeping. + @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(); + } } } @@ -731,7 +870,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; @@ -739,10 +910,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 @@ -756,11 +925,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 @@ -769,16 +941,34 @@ 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) { + // 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 @@ -813,29 +1003,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.compareAndSet(true, false)) { + halfClose(); } } @@ -850,7 +1069,9 @@ private void proceedWithHalfClose() { @Override public void halfClose() { - clientHalfCloseStartNanos = System.nanoTime(); + if (appHalfClosed.compareAndSet(false, true)) { + clientHalfCloseStartNanos = System.nanoTime(); + } if (passThroughMode.get()) { if (requestSideClosed.compareAndSet(false, true)) { proceedWithHalfClose(); @@ -858,13 +1079,13 @@ public void halfClose() { return; } - pendingHalfClose.set(true); - if (extProcStreamState.get().isCompleted()) { if (passThroughMode.get()) { if (requestSideClosed.compareAndSet(false, true)) { proceedWithHalfClose(); } + } else { + pendingHalfClose.set(true); } return; } @@ -881,6 +1102,8 @@ public void halfClose() { if (requestSideClosed.compareAndSet(false, true)) { proceedWithHalfClose(); } + } else { + pendingHalfClose.set(true); } return; } @@ -893,11 +1116,19 @@ public void halfClose() { } // Mode is GRPC - sendToExtProc(ProcessingRequest.newBuilder() - .setRequestBody(HttpBody.newBuilder() - .setEndOfStreamWithoutMessage(true) - .build()) - .build()); + synchronized (streamLock) { + if (!pendingRequestBodyMessages.isEmpty()) { + pendingHalfClose.set(true); + return; + } + + ProcessingRequest.Builder builder = ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()); + mergeAccumulatedWindowUpdates(builder); + sendToExtProc(builder.build()); + } } private void cancelDownstream(@Nullable String message, @Nullable Throwable cause) { @@ -926,11 +1157,31 @@ private void handleRequestBodyResponse(BodyResponse bodyResponse) { if (mutation.hasStreamedResponse()) { StreamedBodyResponse streamed = mutation.getStreamedResponse(); if (!streamed.getEndOfStreamWithoutMessage()) { - super.sendMessage(new KnownLengthInputStream(streamed.getBody())); + 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); + } } } } @@ -943,7 +1194,104 @@ private void handleResponseBodyResponse( BodyMutation mutation = bodyResponse.getResponse().getBodyMutation(); if (mutation.hasStreamedResponse()) { StreamedBodyResponse streamed = mutation.getStreamedResponse(); - listener.onExternalBody(streamed.getBody()); + 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(); + } + }); + } + } + + // Used to immediately flush any mutated response chunks that we already received and buffered + // before the stream failed, ensuring the application receives them in the correct order + 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(); + } } } } @@ -977,23 +1325,59 @@ 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 (appHalfClosed.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); } } } private void handleFailOpen(DataPlaneListener listener) { - activateCall(); - drainPendingRequests(); + if (!activateCall()) { + drainPendingRequests(); + } listener.unblockAfterStreamComplete(); closeExtProcStream(); } @@ -1060,6 +1444,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; @@ -1097,8 +1483,7 @@ void setImmediateResponse(Status status, Metadata trailers) { @Override public void onReady() { - dataPlaneClientCall.drainPendingRequests(); - onReadyNotify(); + dataPlaneClientCall.onReady(); } @Override @@ -1116,7 +1501,7 @@ public void onHeaders(Metadata headers) { return; } - if (dataPlaneClientCall.getPassThroughMode().get() + if (dataPlaneClientCall.getPassThroughMode().get() || dataPlaneClientCall.getExtProcStreamState().get().isCompleted() || !sendResponseHeaders) { proceedWithHeaders(headers); @@ -1138,7 +1523,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; @@ -1157,34 +1542,61 @@ 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); + // TODO: Consider having separate classes handling normal mode and observability mode + 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(); } } @@ -1243,7 +1655,7 @@ void onReadyNotify() { void proceedWithHeaders() { if (savedHeaders != null) { proceedWithHeaders(savedHeaders); - synchronized (savedMessages) { + synchronized (dataPlaneClientCall.streamLock) { savedHeaders = null; if (!dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { InputStream msg; @@ -1297,13 +1709,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; @@ -1373,6 +1789,7 @@ private void triggerCloseHandshake() { } } + @GuardedBy("dataPlaneClientCall.streamLock") private void sendResponseBodyToExtProc( @Nullable ByteString bodyByteString, boolean endOfStream) { if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() @@ -1388,9 +1805,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 2e8761214a3..0701b670b5d 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,112 +9142,4595 @@ 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) + 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() + .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_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for drain to be processed + long startTime = System.currentTimeMillis(); + while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + } + assertThat(proxyCall.isReady()).isFalse(); + + // App requests more messages + proxyCall.request(3); + + // 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()) .build()) + .setObservabilityMode(true) .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); - - 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()); + // 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()); } - }; - }) - .build()); + } - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(dataPlaneRegistry) - .executor(Executors.newSingleThreadExecutor()) + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() .build().start()); - ManagedChannel channel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); - Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( - channel, - Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); + 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); + } - 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"); - } + @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 " + request); + responseObserver.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_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for sidecar call to start + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + } + assertThat(sidecarListenerRef.get()).isNotNull(); + + // Sidecar is busy initially + sidecarReady.set(false); + + // Request from application + proxyCall.request(10); + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + // 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()) + .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()) { + // Immediately complete the stream from server side + 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); + + 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() + .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 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()); + + // 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("FutureReturnValueIgnored") + public void givenPendingData_whenImmediateResponseReceived_thenDeliversDataBeforeStatus() + 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<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @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.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(); + } + } + }); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + extProcCompletedLatch.countDown(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).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); + + 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()); + + 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"); + } + + @Override + public void onMessage(String message) { + appEvents.add("MESSAGE"); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appEvents.add("CLOSE:" + status.getCode()); + appTrailers.set(trailers); + finishLatch.countDown(); + } + }, new Metadata()); + + call.request(1); + call.sendMessage("request-body"); + call.halfClose(); + + 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("FutureReturnValueIgnored") + public void + givenStreamingCall_whenImmediateResponseReceivedDuringRequestStreaming_thenTerminatesCleanly() + 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); + + 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) { + 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 onCompleted() { + extProcCompletedLatch.countDown(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); + + 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; + + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + + 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); + } + + @Override + public void onHalfClose() { + call.close(Status.OK, new Metadata()); + } + }; + }) + .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"); + } + + @Override + public void onMessage(String message) { + appEvents.add("MESSAGE:" + message); + } + + @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 givenFailureModeAllowFalse_whenExtProcStreamFails_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()) + .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 + 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()) { + // Fail the stream immediately on headers + responseObserver.onError( + Status.INTERNAL + .withDescription("Simulated sidecar failure") + .asRuntimeException()); + } + } + + @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); + + 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 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(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen() + 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; + + // 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()); + } + }).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); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + final CountDownLatch headersReceivedLatch = new CountDownLatch(1); + final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1); + + 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); + } + }; + + 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) { + statusRef.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()); + + // 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(); + + // 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(statusRef.get().isOk()).isTrue(); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityMode_whenDataPlaneClosed_thenSidecarCloseIsDeferred() + 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; + + // 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() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + sidecarCompletedLatch.countDown(); + } + }; + } + }; + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .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 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(firstRequest.getFlowControlInit().getInitialWindowUpstreamToSidestream()) + .isEqualTo(65536); + assertThat(firstRequest.getFlowControlInit().getInitialWindowSidestreamToDownstream()) + .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 testUpstreamToSidestreamFlowControl_DrainsPartiallyOnPartialWindowReplenishment() + 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 (70k)) + final CountDownLatch thirtykLatch = new CountDownLatch(1); + final CountDownLatch twentykLatch = new CountDownLatch(1); + final CountDownLatch tenkLatch = 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(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(originalBody) + .build()) + .build()) + .build()) + .build()) + .build()); + if (originalBody.size() == 30000) { + thirtykLatch.countDown(); + } else if (originalBody.size() == 20000) { + twentykLatch.countDown(); + } else if (originalBody.size() == 10000) { + tenkLatch.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 messagesLatchDummyAnd70k = new CountDownLatch(2); + final CountDownLatch messagesLatchWith30kAnd20k = new CountDownLatch(4); + final CountDownLatch messagesLatchAll = new CountDownLatch(5); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + messagesLatchDummyAnd70k.countDown(); + messagesLatchWith30kAnd20k.countDown(); + messagesLatchAll.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'); + String largeMessage20k = new String(new char[20000]).replace('\0', 'c'); + String largeMessage10k = new String(new char[10000]).replace('\0', 'd'); + + // 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(messagesLatchDummyAnd70k.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessages).hasSize(2); + + // Upstream sends 30k, 20k, 10k response chunks. Since the window is negative, the filter + // must block/buffer these chunks. + upstreamResponseObserver.onNext(largeMessage30k); + upstreamResponseObserver.onNext(largeMessage20k); + upstreamResponseObserver.onNext(largeMessage10k); + + // Wait a brief period and verify that none of these chunks have been sent + // to the ext_proc server + assertThat(thirtykLatch.getCount()).isEqualTo(1); + assertThat(twentykLatch.getCount()).isEqualTo(1); + assertThat(tenkLatch.getCount()).isEqualTo(1); + assertThat(appReceivedMessages).hasSize(2); + + // Sidecar server sends a ServerWindowUpdate of 40k to the filter. + // Window becomes -4464 + 40000 = 35536. + // This allows draining: + // - 30k chunk: window becomes 35536 - 30000 = 5536. + // - 20k chunk: window becomes 5536 - 20000 = -14464. + // - 10k chunk: window is <= 0, so it remains buffered. + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementUpstreamToSidestream(40000) + .build()) + .build()); + + // Verify 30k and 20k chunks are now successfully delivered to the client application + // (total 4 messages) + assertThat(messagesLatchWith30kAnd20k.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessages).hasSize(4); + assertThat(appReceivedMessages.get(2)).isEqualTo(largeMessage30k); + assertThat(appReceivedMessages.get(3)).isEqualTo(largeMessage20k); + + // Verify they reached ext_proc + assertThat(thirtykLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(twentykLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List bodies = new ArrayList<>(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasResponseBody()) { + bodies.add(req.getResponseBody().getBody()); + } + } + assertThat(bodies).containsExactly( + ByteString.copyFromUtf8("Dummy for headers"), + ByteString.copyFromUtf8(largeMessage70k), + ByteString.copyFromUtf8(largeMessage30k), + ByteString.copyFromUtf8(largeMessage20k) + ).inOrder(); + + // Verify 10k chunk is still blocked + assertThat(tenkLatch.getCount()).isEqualTo(1); + + // Sidecar server sends another ServerWindowUpdate of 20k. + // Window becomes -14464 + 20000 = 5536. + // This allows draining the 10k chunk (window becomes 5536 - 10000 = -4464). + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementUpstreamToSidestream(20000) + .build()) + .build()); + + // Verify 10k chunk is now successfully delivered (total 5 messages) + assertThat(messagesLatchAll.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessages).hasSize(5); + assertThat(appReceivedMessages.get(4)).isEqualTo(largeMessage10k); + + // Verify it reached ext_proc + assertThat(tenkLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List finalBodies = new ArrayList<>(); + for (ProcessingRequest req : receivedRequests) { + if (req.hasResponseBody()) { + finalBodies.add(req.getResponseBody().getBody()); + } + } + assertThat(finalBodies).containsExactly( + ByteString.copyFromUtf8("Dummy for headers"), + ByteString.copyFromUtf8(largeMessage70k), + ByteString.copyFromUtf8(largeMessage30k), + ByteString.copyFromUtf8(largeMessage20k), + ByteString.copyFromUtf8(largeMessage10k) + ).inOrder(); + + 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) { - appEvents.add("MESSAGE"); - } - - @Override - public void onClose(Status status, Metadata trailers) { - appEvents.add("CLOSE:" + status.getCode()); - appTrailers.set(trailers); - finishLatch.countDown(); + receivedResponses.add(message); } }, new Metadata()); - call.request(1); - call.sendMessage("request-body"); - call.halfClose(); + // Wait for the headers handshake to complete and activate the call + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).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(); + 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("FutureReturnValueIgnored") - public void - givenStreamingCall_whenImmediateResponseReceivedDuringRequestStreaming_thenTerminatesCleanly() + @SuppressWarnings("unchecked") + public void testSidestreamToDownstreamFlowControl_DrainingAndSendingWindowUpdates() 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(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() { @@ -9102,56 +13741,59 @@ public StreamObserver process( 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()) + receivedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().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()) + 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()); - } 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()) + 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()); - responseObserver.onCompleted(); - } - } + secondBodyResponseLatch.countDown(); } - }); + } } @Override @@ -9159,1171 +13801,1551 @@ public void onError(Throwable t) {} @Override public void onCompleted() { - extProcCompletedLatch.countDown(); + 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()); - }); + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); - 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; + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); - 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_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); - } + 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 onHalfClose() { - call.close(Status.OK, new Metadata()); - } - }; - }) - .build()); + @Override + public void onError(Throwable t) {} - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(dataPlaneRegistry) - .executor(Executors.newSingleThreadExecutor()) - .build().start()); + @Override + public void onCompleted() {} + }; + } + })).build()); - ManagedChannel channel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); - Channel interceptedChannel = io.grpc.ClientInterceptors.interceptForward( - channel, - Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); - 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"); - } + 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) { - appEvents.add("MESSAGE:" + message); - } - - @Override - public void onClose(Status status, Metadata trailers) { - appEvents.add("CLOSE:" + status.getCode()); - appTrailers.set(trailers); - finishLatch.countDown(); + receivedResponses.add(message); } }, 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"); + // 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); - call.halfClose(); + proxyCall.request(1); + assertThat(receivedResponses).containsExactly(resp1, resp2); - 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(); + 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 givenFailureModeAllowFalse_whenExtProcStreamFails_thenDataPlaneCallCancelled() - throws Exception { + public void testThresholdBasedWindowUpdates() 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()) - .setFailureModeAllow(false) // Fail Closed + .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 triggers error - 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()) { - // Fail the stream immediately on headers - responseObserver.onError( - Status.INTERNAL - .withDescription("Simulated sidecar failure") - .asRuntimeException()); - } - } + final List receivedRequests = new CopyOnWriteArrayList<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List> observers = new ArrayList<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onError(Throwable t) { - } + 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 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); + 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()); - 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_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(); - // 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(); } @Test @SuppressWarnings("unchecked") - public void givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen() + public void testWindowUpdateWithheldWhenUpstreamCapacityExistsAndBelowThreshold() 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()) + 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()) - .setFailureModeAllow(true) // Fail Open .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); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onNext(ProcessingRequest request) { - if (request.hasRequestHeaders()) { - new Thread(() -> { - synchronized (responseObserver) { - responseObserver.onError(Status.INTERNAL.asRuntimeException()); + 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(); } - }).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) + + 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); - final CountDownLatch headersReceivedLatch = new CountDownLatch(1); - final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1); - - 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); - } - }; + 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) {} - 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)); + @Override + public void onError(Throwable t) {} - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + @Override + public void onCompleted() { + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + } + }; + } + })) + .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) { - statusRef.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()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); - // Trigger unary call. request(1) starts it. - proxyCall.request(1); + ClientCall proxyCall = + interceptCall( + interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - // Wait for the async sidecar thread to enter activateCall() and block inside interceptCall - assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - // Now, while the async thread is blocked (and passThroughMode is still false), - // send a message and half-close. - proxyCall.sendMessage("test"); - proxyCall.halfClose(); + // Send 10k message to ext_proc. + String body10k = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(body10k); - // Unblock the async thread - resumeAsyncThreadLatch.countDown(); + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // Verify data plane call reached (failed open) - assertThat(dataPlaneLatch.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); - // Verify client call completes successfully - assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(statusRef.get().isOk()).isTrue(); - + proxyCall.cancel("Cleanup", null); channelManager.close(); } @Test @SuppressWarnings("unchecked") - public void givenObservabilityMode_whenDataPlaneClosed_thenSidecarCloseIsDeferred() + public void testWindowUpdateWithheldWhenDownstreamCapacityExistsAndBelowThreshold() 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") + 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()) - .build()) - .setObservabilityMode(true) - .setDeferredCloseTimeout( - com.google.protobuf.Duration.newBuilder().setSeconds(10).build()) - .build(); + .build(); ConfigOrError configOrError = provider.parseFilterConfig(Any.pack(proto), filterContext); assertThat(configOrError.errorDetail).isNull(); ExternalProcessorFilterConfig filterConfig = configOrError.config; - // 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() { - @Override - public void onNext(ProcessingRequest request) { - } + 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 void onError(Throwable t) { - } + 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 onCompleted() { - sidecarCompletedLatch.countDown(); + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; } }; - } - }; - final io.grpc.Server extProcServer = - grpcCleanup.register( - InProcessServerBuilder.forName(extProcServerName) - .addService(extProcImpl) - .executor(fakeClock.getScheduledExecutorService()) - .build() - .start()); - CachedChannelManager channelManager = new CachedChannelManager(config -> { - return grpcCleanup.register( - InProcessChannelBuilder.forName(extProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); - }); + 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()); + }); - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(dataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) + 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()); - 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()); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); - // 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); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); - // Wait for app onClose - for (int i = 0; i < 1000 && appCloseLatch.getCount() > 0; i++) { - fakeClock.forwardTime(1, TimeUnit.SECONDS); + final List receivedResponses = Collections.synchronizedList(new ArrayList<>()); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + receivedResponses.add(message); } - assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + }, new Metadata()); - // At this point, app received onClose, but sidecar should NOT be completed yet - assertThat(sidecarCompletedLatch.getCount()).isEqualTo(1); + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // 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(); - } + 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 givenUnsupportedCompressionInResponse_whenReceived_thenStreamErrored() + public void testWindowUpdateWithheldOnUpstreamWindowExhaustionWithZeroIncrement() 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") + 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()) - .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()); - } - } - } + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; - @Override - public void onError(Throwable t) { - } + 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 void onCompleted() { - new Thread(() -> { - synchronized (responseObserver) { + 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(); } - }).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()); - }); + 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 CountDownLatch dataPlaneLatch = new CountDownLatch(1); - MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); - grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) - .fallbackHandlerRegistry(uniqueRegistry) - .directExecutor() - .build().start()); - uniqueRegistry.addService(ServerInterceptors.intercept( + 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) -> { - 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); - } - })); + .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(uniqueDataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) + 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()); - 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()); + interceptCall( + interceptor, + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - // 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); - } + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); - // Trigger request body processing to hit the unsupported compression check + // 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); - 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"); - + // 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 givenUnsupportedCompressionInResponseBody_whenReceived_thenStreamErrored() + public void testWindowUpdateWithheldOnDownstreamWindowExhaustionWithZeroIncrement() 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") + 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()) - .build()) - .setProcessingMode(ProcessingMode.newBuilder() - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) - .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) - .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() { + 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 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) + 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()) - .build()) - .build()) - .build()); - } - } + .build()); + requestBodyProcessedLatch.countDown(); + } + } - @Override - public void onError(Throwable t) { - } + @Override + public void onError(Throwable t) {} - @Override - public void onCompleted() { - responseObserver.onCompleted(); + @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()); - }); + 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()); + }); - 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()); + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); - ManagedChannel dataPlaneChannel = - grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_BIDI_STREAMING, + ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + final 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()); - 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_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); - proxyCall.request(1); - proxyCall.sendMessage("test"); - proxyCall.halfClose(); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // 1. Manually trigger onMessage on the client listener. + // Since proxyCall.request() was never called, downstreamRequestsPending is 0. + // So the mutated body from ext_proc will be queued in pendingMutatedResponseBodies, + // and accumulated downstream increment (id) will remain 0. + String responseMsg = new String(new char[10000]).replace('\0', 'y'); + InputStream responseStream = METHOD_BIDI_STREAMING.streamResponse(responseMsg); + dataPlaneListenerRef.get().onMessage(responseStream); + + assertThat(responseBodyReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // receivedRequests contains Headers + ResponseBody + assertThat(receivedRequests).hasSize(2); + + // 2. Now send a request body of size 10000. + // It will be sent to ext_proc, mutated to 20000 bytes (below threshold), and delivered + // immediately to raw call. + // During trySendAccumulatedWindowUpdates(), RHS evaluates to False (since id = 0, wd <= 0, but + // id > 0 (2nd) is False). + // So no window update is sent. + String requestMsg = new String(new char[10000]).replace('\0', 'a'); + proxyCall.sendMessage(requestMsg); + + assertThat(requestBodyProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // receivedRequests should only contain Headers, ResponseBody, and RequestBody (size = 3). + assertThat(receivedRequests).hasSize(3); - // 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(); + public void testExtProcUnilateralHalfClose_PreventsDuplicateHalfClose() throws Exception { 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") + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) .build()) .build()) .build()) .setProcessingMode(ProcessingMode.newBuilder() - .setRequestHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) - .setResponseHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) - .setResponseTrailerMode(ProcessingMode.HeaderSendMode.DEFAULT).build()) + .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 - 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(); - } - } + 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 onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; } + }; + + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build() + .start()); + + CachedChannelManager channelManager = + new CachedChannelManager( + config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = + new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicInteger halfCloseCallCount = new AtomicInteger(0); + final CountDownLatch serverResponseLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService( + ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_CLIENT_STREAMING, + ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + new Thread( + () -> { + try { + serverResponseLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + responseObserver.onNext("Response"); + responseObserver.onCompleted(); + }) + .start(); + } + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void halfClose() { + halfCloseCallCount.incrementAndGet(); + super.halfClose(); + } + }; + } + }) + .directExecutor() + .build()); + + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); - @Override - public void onCompleted() { - } - }; - } - }; - final io.grpc.Server extProcServer = - grpcCleanup.register( - InProcessServerBuilder.forName(uniqueExtProcServerName) - .addService(extProcImpl) - .executor(fakeClock.getScheduledExecutorService()) - .build() - .start()); + // Send Message 1. This will trigger the unilateral half-close from mock ext_proc. + proxyCall.sendMessage("Message 1"); - CachedChannelManager channelManager = new CachedChannelManager(config -> { - return grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueExtProcServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); - }); + assertThat(extProcUnilateralLatch.await(5, TimeUnit.SECONDS)).isTrue(); - ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( - filterConfig, channelManager, scheduler, FAKE_CONTEXT); + // Verify that the transport halfClose was called once due to unilateral half-close + assertThat(halfCloseCallCount.get()).isEqualTo(1); - 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()); + // 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(); - ManagedChannel dataPlaneChannel = grpcCleanup.register( - InProcessChannelBuilder.forName(uniqueDataPlaneServerName) - .executor(fakeClock.getScheduledExecutorService()) - .build()); + assertThat(clientHalfCloseProcessedLatch.await(5, TimeUnit.SECONDS)).isTrue(); - 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(); + // Verify that the transport halfClose count remains 1 + assertThat(halfCloseCallCount.get()).isEqualTo(1); - 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); + // Let the server complete now + serverResponseLatch.countDown(); - 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 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()) + .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 - 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 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(); + .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 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_BIDI_STREAMING, DEFAULT_CALL_OPTIONS, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); + + proxyCall.sendMessage("Last Message"); + + // Verify 12 bytes are accumulated but no standalone update is sent. + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + + proxyCall.halfClose(); + + // Verify halfClose sends EOF request piggybacking the accumulated 12 bytes update. + assertThat(filterClientRequests(receivedRequests)).hasSize(3); + assertThat(filterClientRequests(receivedRequests).get(2).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getRequestBody().getEndOfStreamWithoutMessage()) + + .isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2).hasClientWindowUpdate()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2) + .getClientWindowUpdate().getWindowIncrementSidestreamToUpstream()) + + .isEqualTo(12); - // 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 testNoDuplicateHalfCloseSentToExtProc() 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(); - } - } + .setTypeUrl(INSECURE_CREDENTIALS_TYPE_URL) + .build()) + .build()) + .build()) + .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; - @Override - public void onError(Throwable t) {} + final List receivedRequests = new CopyOnWriteArrayList<>(); + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch extProcReceivedHeadersLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { @Override - public void onCompleted() {} + 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()); + extProcReceivedHeadersLatch.countDown(); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder().build()) + .build()); + } + } + + @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 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.request(1); - proxyCall.sendMessage("test"); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(extProcReceivedHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(filterClientRequests(receivedRequests)).hasSize(1); + + // 1. Send Message 1 (size 65536) to exhaust the initial window (65536) + String largeString = new String(new char[65536]); + proxyCall.sendMessage(largeString); + + // Verify Message 1 is sent to ext-proc + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + assertThat(filterClientRequests(receivedRequests).get(1).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(1).getRequestBody().getBody().size()) + .isEqualTo(65536); + + // 2. Send Message 2 (size 1). This should be buffered because window is now 0. + proxyCall.sendMessage("a"); + + // Verify Message 2 is NOT sent yet (still only 2 requests received) + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + + // 3. Call halfClose(). This should be deferred because queue is not empty. 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 is NOT sent yet (still only 2 requests received) + assertThat(filterClientRequests(receivedRequests)).hasSize(2); + + // 4. Send ServerWindowUpdate to increment window by 100 + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementDownstreamToSidestream(100) + .build()) + .build()); + + // Draining should trigger: + // - Message 2 (size 1) is sent. + // - EOF request is sent (since queue became empty and halfClose was pending). + // Total requests should now be 4: headers, body 1, body 2, eof. + assertThat(filterClientRequests(receivedRequests)).hasSize(4); + + // Verify Message 2 content + assertThat(filterClientRequests(receivedRequests).get(2).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(2).getRequestBody().getBody() + .toStringUtf8()).isEqualTo("a"); + + // Verify EOF request content + assertThat(filterClientRequests(receivedRequests).get(3).hasRequestBody()).isTrue(); + assertThat(filterClientRequests(receivedRequests).get(3).getRequestBody() + .getEndOfStreamWithoutMessage()).isTrue(); + + // 5. Send another ServerWindowUpdate (redundant) + responseObserverRef.get().onNext(ProcessingResponse.newBuilder() + .setServerWindowUpdate(ProcessingResponse.ServerWindowUpdate.newBuilder() + .setWindowIncrementDownstreamToSidestream(100) + .build()) + .build()); + + // Verify that NO duplicate EOF request is sent. + // Total requests should remain 4. + assertThat(filterClientRequests(receivedRequests)).hasSize(4); + 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 +15353,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 +15570,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 +16316,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 +16595,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 +16631,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 +16658,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 +16810,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); final AtomicReference clientReceivedBody = new AtomicReference<>(); StreamObserver requestObserver = ClientCalls.asyncClientStreamingCall( interceptCall(interceptor, @@ -11356,6 +16825,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11372,7 +16842,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 +16893,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 +16929,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 +16956,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 +17118,7 @@ public void onHeaders(Metadata headers) { } }); + final AtomicReference errorRef = new AtomicReference<>(); StreamObserver bidiRequestObserver = ClientCalls.asyncBidiStreamingCall( interceptCall(interceptor, METHOD_BIDI_STREAMING, @@ -11643,6 +17132,7 @@ public void onNext(String value) { @Override public void onError(Throwable t) { + errorRef.set(t); finishLatch.countDown(); } @@ -11659,7 +17149,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 +17173,7 @@ public void onCompleted() { channelManager.close(); } - // --- Category 22: Header Forwarding --- + // --- Category 23: Header Forwarding --- @Test public void @@ -12204,7 +17697,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 +17954,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 +18697,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 +19242,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 +19789,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 { @@ -14649,7 +20142,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") @@ -15636,11 +21129,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));