Repository: grpc/grpc-kotlin
Pull request #344 (2022-07-02 09:23:42)

Recover cancellation when close responses flow
When streaming call is cancelled it's handled in `sender` coroutine (lines 311-319). Both client call cancellation (line 316) and failure propagation (line 317) eventually reach the listener `onClose` callback (where `responses` flow is closed), but which one reaches the place first is racy. When failure propagation reaches the callback first, the flow is cancelled with cancellation exception (expected). But if the client call cancel reaches the callback first, the flow is cancelled with GRPC status exception (unexpected).

This change recovers the cancellation from GRPC status exception if it was the cause to make the behavior deterministic and aligned with expectations.

I don't know how to test this change reliably. I succeeded to reproduce the issue via slow emulator tests only with some chance over 100+ runs.

akandratovich: There is a failing test that asserts that flow fails with grpc error if request flow throws `CancellationException`. Is it part of the contract? The similar java test asserts on `RuntimeException`.
jamesward: That is a question for @lowasser 
lowasser: It seems reasonable to delete that test, I don't think it should be part of the contract.
akandratovich: I have "removed" the test.

Patch:
diff --git a/stub/src/main/java/io/grpc/kotlin/ClientCalls.kt b/stub/src/main/java/io/grpc/kotlin/ClientCalls.kt
index 6dcf0dc..3dce696 100644
--- a/stub/src/main/java/io/grpc/kotlin/ClientCalls.kt
+++ b/stub/src/main/java/io/grpc/kotlin/ClientCalls.kt
@@ -20,6 +20,7 @@ import io.grpc.CallOptions
 import io.grpc.ClientCall
 import io.grpc.MethodDescriptor
 import io.grpc.Status
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.cancel
@@ -292,9 +293,13 @@ object ClientCalls {
           }
 
           override fun onClose(status: Status, trailersMetadata: GrpcMetadata) {
-            responses.close(
-              cause = if (status.isOk) null else status.asException(trailersMetadata)
-            )
+            val cause =
+              when {
+                status.isOk -> null
+                status.cause is CancellationException -> status.cause
+                else -> status.asException(trailersMetadata)
+              }
+            responses.close(cause = cause)
           }
 
           override fun onReady() {


Test patch:
diff --git a/interop_testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.kt b/interop_testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.kt
index 9834674..c1757f5 100644
--- a/interop_testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.kt
+++ b/interop_testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.kt
@@ -662,31 +662,7 @@ abstract class AbstractInteropTest {
 
   @Test
   fun cancelAfterFirstResponse() {
-    val request = StreamingOutputCallRequest.newBuilder()
-      .addResponseParameters(
-        ResponseParameters.newBuilder()
-          .setSize(31415)
-      )
-      .setPayload(
-        Payload.newBuilder()
-          .setBody(ByteString.copyFrom(ByteArray(27182)))
-      )
-      .build()
-
-    runBlocking {
-      val ex = assertFailsWith<StatusException> {
-        stub
-          .fullDuplexCall(
-            flow {
-              emit(request)
-              throw CancellationException()
-            }
-          )
-          .collect()
-      }
-      assertThat(ex.status.code).isEqualTo(Status.Code.CANCELLED)
-    }
-    assertStatsTrace("grpc.testing.TestService/FullDuplexCall", Status.Code.CANCELLED)
+    // This test is not relevant to flow API.
   }
 
   @Test