Skip to content

Commit f2c0c02

Browse files
committed
xds: Fix half-close race condition in ext-proc client interceptor
Resolve a protocol violation bug in ExternalProcessorClientInterceptor and fix a related data race in its fail-open unit tests: 1. Interceptor Bug Fix (Incorrect Frame Order): When the external processor stream fails (extProcStreamState is FAILED/completed) but the interceptor has not yet transitioned to pass-through mode, a call to sendMessage() is buffered, but a call to halfClose() immediately executed super.halfClose() because it only checked stream completion. This sent the halfClose frame to the backend server before the buffered message was drained, violating the gRPC protocol and causing the backend to drop the message. Fixed by modifying halfClose() to only execute immediately if passThroughMode is true. If passThroughMode is false and the stream is completed/failed, halfClose is buffered (pendingHalfClose = true) and deferred until the draining task drains the buffered messages and triggers it. 2. Test Fix (Deterministic Race Simulation): Redesigned givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen in ExternalProcessorClientInterceptorTest to deterministically simulate the interleaving that caused the bug. Used a custom ServerInterceptor on the backend server to suspend the async onError thread inside activateLine() (midway through fail-open processing) while the client main thread calls sendMessage() and halfClose(). This forces the race condition and verifies that the message is no longer dropped.
1 parent 8730fa9 commit f2c0c02

2 files changed

Lines changed: 70 additions & 65 deletions

File tree

xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ private void proceedWithHalfClose() {
841841
@Override
842842
public void halfClose() {
843843
clientHalfCloseStartNanos = System.nanoTime();
844-
if (passThroughMode.get() || extProcStreamState.get().isCompleted()) {
844+
if (passThroughMode.get()) {
845845
if (requestSideClosed.compareAndSet(false, true)) {
846846
proceedWithHalfClose();
847847
}
@@ -850,6 +850,10 @@ public void halfClose() {
850850

851851
pendingHalfClose.set(true);
852852

853+
if (extProcStreamState.get().isCompleted()) {
854+
return;
855+
}
856+
853857
if (extProcStreamState.get().isDraining()) {
854858
return;
855859
}
@@ -867,8 +871,6 @@ public void halfClose() {
867871
.setEndOfStreamWithoutMessage(true)
868872
.build())
869873
.build());
870-
871-
// Defer super.halfClose() until ext-proc response signals end_of_stream.
872874
}
873875

874876
@Override

xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java

Lines changed: 65 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8451,75 +8451,78 @@ public void onCompleted() {
84518451
.directExecutor()
84528452
.build().start());
84538453

8454-
ExecutorService extProcChannelExecutor = Executors.newSingleThreadExecutor();
8455-
try {
8456-
CachedChannelManager channelManager = new CachedChannelManager(config -> {
8457-
return grpcCleanup.register(
8458-
InProcessChannelBuilder.forName(extProcServerName)
8459-
.executor(extProcChannelExecutor)
8460-
.build());
8461-
});
8454+
CachedChannelManager channelManager = new CachedChannelManager(config -> {
8455+
return grpcCleanup.register(
8456+
InProcessChannelBuilder.forName(extProcServerName).directExecutor().build());
8457+
});
84628458

8463-
ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor(
8464-
filterConfig, channelManager, scheduler, FAKE_CONTEXT);
8465-
8466-
final CountDownLatch dataPlaneLatch = new CountDownLatch(1);
8467-
final CountDownLatch headersReceivedLatch = new CountDownLatch(1);
8468-
ServerInterceptor dataPlaneInterceptor = new ServerInterceptor() {
8469-
@Override
8470-
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
8471-
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
8472-
headersReceivedLatch.countDown();
8473-
return next.startCall(call, headers);
8474-
}
8475-
};
8476-
dataPlaneServiceRegistry.addService(ServerInterceptors.intercept(
8477-
ServerServiceDefinition.builder("test.TestService")
8478-
.addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall(
8479-
(request, responseObserver) -> {
8480-
responseObserver.onNext("Hello " + request);
8481-
responseObserver.onCompleted();
8482-
dataPlaneLatch.countDown();
8483-
}))
8484-
.build(),
8485-
dataPlaneInterceptor));
8459+
ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor(
8460+
filterConfig, channelManager, scheduler, FAKE_CONTEXT);
84868461

8487-
ManagedChannel dataPlaneChannel = grpcCleanup.register(
8488-
InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build());
8462+
final CountDownLatch dataPlaneLatch = new CountDownLatch(1);
8463+
final CountDownLatch headersReceivedLatch = new CountDownLatch(1);
8464+
final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1);
84898465

8490-
final CountDownLatch closedLatch = new CountDownLatch(1);
8491-
ClientCall.Listener<String> appListener = new ClientCall.Listener<String>() {
8492-
@Override
8493-
public void onClose(Status status, Metadata trailers) {
8494-
closedLatch.countDown();
8466+
ServerInterceptor dataPlaneInterceptor = new ServerInterceptor() {
8467+
@Override
8468+
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
8469+
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
8470+
headersReceivedLatch.countDown();
8471+
try {
8472+
resumeAsyncThreadLatch.await();
8473+
} catch (InterruptedException e) {
8474+
Thread.currentThread().interrupt();
84958475
}
8496-
};
8497-
8498-
CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor());
8499-
ClientCall<String, String> proxyCall =
8500-
interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel);
8501-
proxyCall.start(appListener, new Metadata());
8476+
return next.startCall(call, headers);
8477+
}
8478+
};
85028479

8503-
// Send message and half-close to trigger unary call reaching server
8504-
proxyCall.request(1);
8505-
assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue();
8506-
8507-
// Wait for the onError execution on extProcChannelExecutor to finish completely
8508-
final CountDownLatch executorSyncLatch = new CountDownLatch(1);
8509-
extProcChannelExecutor.execute(executorSyncLatch::countDown);
8510-
assertThat(executorSyncLatch.await(5, TimeUnit.SECONDS)).isTrue();
8480+
dataPlaneServiceRegistry.addService(ServerInterceptors.intercept(
8481+
ServerServiceDefinition.builder("test.TestService")
8482+
.addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall(
8483+
(request, responseObserver) -> {
8484+
responseObserver.onNext("Hello " + request);
8485+
responseObserver.onCompleted();
8486+
dataPlaneLatch.countDown();
8487+
}))
8488+
.build(),
8489+
dataPlaneInterceptor));
85118490

8512-
proxyCall.sendMessage("test");
8513-
proxyCall.halfClose();
8491+
ManagedChannel dataPlaneChannel = grpcCleanup.register(
8492+
InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build());
85148493

8515-
// Verify data plane call reached (failed open)
8516-
assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue();
8517-
8518-
proxyCall.cancel("Cleanup", null);
8519-
channelManager.close();
8520-
} finally {
8521-
extProcChannelExecutor.shutdown();
8522-
}
8494+
final CountDownLatch closedLatch = new CountDownLatch(1);
8495+
ClientCall.Listener<String> appListener = new ClientCall.Listener<String>() {
8496+
@Override
8497+
public void onClose(Status status, Metadata trailers) {
8498+
closedLatch.countDown();
8499+
}
8500+
};
8501+
8502+
CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor());
8503+
ClientCall<String, String> proxyCall =
8504+
interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel);
8505+
proxyCall.start(appListener, new Metadata());
8506+
8507+
// Trigger unary call. request(1) starts it.
8508+
proxyCall.request(1);
8509+
8510+
// Wait for the async sidecar thread to enter activateCall() and block inside interceptCall
8511+
assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue();
8512+
8513+
// Now, while the async thread is blocked (and passThroughMode is still false),
8514+
// send a message and half-close.
8515+
proxyCall.sendMessage("test");
8516+
proxyCall.halfClose();
8517+
8518+
// Unblock the async thread
8519+
resumeAsyncThreadLatch.countDown();
8520+
8521+
// Verify data plane call reached (failed open)
8522+
assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue();
8523+
8524+
proxyCall.cancel("Cleanup", null);
8525+
channelManager.close();
85238526
}
85248527

85258528
@Test

0 commit comments

Comments
 (0)