mirror of https://github.com/grpc/grpc-java.git
core: Use SyncContext for InProcessTransport listener callbacks to avoid deadlocks
Fixes deadlocks caused by client and server listeners being called in a synchronized block Also support unary calls returning null values Fixes #3084
This commit is contained in:
parent
c0790283ec
commit
74137b0978
|
|
@ -40,6 +40,7 @@ import io.grpc.MethodDescriptor;
|
||||||
import io.grpc.SecurityLevel;
|
import io.grpc.SecurityLevel;
|
||||||
import io.grpc.ServerStreamTracer;
|
import io.grpc.ServerStreamTracer;
|
||||||
import io.grpc.Status;
|
import io.grpc.Status;
|
||||||
|
import io.grpc.SynchronizationContext;
|
||||||
import io.grpc.internal.ClientStream;
|
import io.grpc.internal.ClientStream;
|
||||||
import io.grpc.internal.ClientStreamListener;
|
import io.grpc.internal.ClientStreamListener;
|
||||||
import io.grpc.internal.ClientStreamListener.RpcProgress;
|
import io.grpc.internal.ClientStreamListener.RpcProgress;
|
||||||
|
|
@ -106,6 +107,18 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
private List<ServerStreamTracer.Factory> serverStreamTracerFactories;
|
private List<ServerStreamTracer.Factory> serverStreamTracerFactories;
|
||||||
private final Attributes attributes;
|
private final Attributes attributes;
|
||||||
|
|
||||||
|
private Thread.UncaughtExceptionHandler uncaughtExceptionHandler =
|
||||||
|
new Thread.UncaughtExceptionHandler() {
|
||||||
|
@Override
|
||||||
|
public void uncaughtException(Thread t, Throwable e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
throw new Error(e);
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@GuardedBy("this")
|
@GuardedBy("this")
|
||||||
private final InUseStateAggregator<InProcessStream> inUseState =
|
private final InUseStateAggregator<InProcessStream> inUseState =
|
||||||
new InUseStateAggregator<InProcessStream>() {
|
new InUseStateAggregator<InProcessStream>() {
|
||||||
|
|
@ -407,8 +420,10 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
|
|
||||||
private class InProcessServerStream implements ServerStream {
|
private class InProcessServerStream implements ServerStream {
|
||||||
final StatsTraceContext statsTraceCtx;
|
final StatsTraceContext statsTraceCtx;
|
||||||
@GuardedBy("this")
|
// All callbacks must run in syncContext to avoid possibility of deadlock in direct executors
|
||||||
private ClientStreamListener clientStreamListener;
|
private ClientStreamListener clientStreamListener;
|
||||||
|
private final SynchronizationContext syncContext =
|
||||||
|
new SynchronizationContext(uncaughtExceptionHandler);
|
||||||
@GuardedBy("this")
|
@GuardedBy("this")
|
||||||
private int clientRequested;
|
private int clientRequested;
|
||||||
@GuardedBy("this")
|
@GuardedBy("this")
|
||||||
|
|
@ -444,10 +459,11 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
if (onReady) {
|
if (onReady) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
clientStreamListener.onReady();
|
syncContext.executeLater(() -> clientStreamListener.onReady());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method is the only reason we have to synchronize field accesses.
|
// This method is the only reason we have to synchronize field accesses.
|
||||||
|
|
@ -456,28 +472,36 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
*
|
*
|
||||||
* @return whether onReady should be called on the server
|
* @return whether onReady should be called on the server
|
||||||
*/
|
*/
|
||||||
private synchronized boolean clientRequested(int numMessages) {
|
private boolean clientRequested(int numMessages) {
|
||||||
|
boolean previouslyReady;
|
||||||
|
boolean nowReady;
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
boolean previouslyReady = clientRequested > 0;
|
|
||||||
|
previouslyReady = clientRequested > 0;
|
||||||
clientRequested += numMessages;
|
clientRequested += numMessages;
|
||||||
while (clientRequested > 0 && !clientReceiveQueue.isEmpty()) {
|
while (clientRequested > 0 && !clientReceiveQueue.isEmpty()) {
|
||||||
clientRequested--;
|
clientRequested--;
|
||||||
clientStreamListener.messagesAvailable(clientReceiveQueue.poll());
|
StreamListener.MessageProducer producer = clientReceiveQueue.poll();
|
||||||
}
|
syncContext.executeLater(() -> clientStreamListener.messagesAvailable(producer));
|
||||||
// Attempt being reentrant-safe
|
|
||||||
if (closed) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientReceiveQueue.isEmpty() && clientNotifyStatus != null) {
|
if (clientReceiveQueue.isEmpty() && clientNotifyStatus != null) {
|
||||||
closed = true;
|
closed = true;
|
||||||
clientStream.statsTraceCtx.clientInboundTrailers(clientNotifyTrailers);
|
clientStream.statsTraceCtx.clientInboundTrailers(clientNotifyTrailers);
|
||||||
clientStream.statsTraceCtx.streamClosed(clientNotifyStatus);
|
clientStream.statsTraceCtx.streamClosed(clientNotifyStatus);
|
||||||
clientStreamListener.closed(
|
Status notifyStatus = this.clientNotifyStatus;
|
||||||
clientNotifyStatus, RpcProgress.PROCESSED, clientNotifyTrailers);
|
Metadata notifyTrailers = this.clientNotifyTrailers;
|
||||||
|
syncContext.executeLater(() ->
|
||||||
|
clientStreamListener.closed(notifyStatus, RpcProgress.PROCESSED, notifyTrailers));
|
||||||
}
|
}
|
||||||
boolean nowReady = clientRequested > 0;
|
|
||||||
|
nowReady = clientRequested > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncContext.drain();
|
||||||
return !previouslyReady && nowReady;
|
return !previouslyReady && nowReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -486,7 +510,8 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void writeMessage(InputStream message) {
|
public void writeMessage(InputStream message) {
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -498,12 +523,15 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
StreamListener.MessageProducer producer = new SingleMessageProducer(message);
|
StreamListener.MessageProducer producer = new SingleMessageProducer(message);
|
||||||
if (clientRequested > 0) {
|
if (clientRequested > 0) {
|
||||||
clientRequested--;
|
clientRequested--;
|
||||||
clientStreamListener.messagesAvailable(producer);
|
syncContext.executeLater(() -> clientStreamListener.messagesAvailable(producer));
|
||||||
} else {
|
} else {
|
||||||
clientReceiveQueue.add(producer);
|
clientReceiveQueue.add(producer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncContext.drain();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void flush() {}
|
public void flush() {}
|
||||||
|
|
||||||
|
|
@ -540,8 +568,9 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
}
|
}
|
||||||
|
|
||||||
clientStream.statsTraceCtx.clientInboundHeaders();
|
clientStream.statsTraceCtx.clientInboundHeaders();
|
||||||
clientStreamListener.headersRead(headers);
|
syncContext.executeLater(() -> clientStreamListener.headersRead(headers));
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -585,13 +614,14 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
closed = true;
|
closed = true;
|
||||||
clientStream.statsTraceCtx.clientInboundTrailers(trailers);
|
clientStream.statsTraceCtx.clientInboundTrailers(trailers);
|
||||||
clientStream.statsTraceCtx.streamClosed(clientStatus);
|
clientStream.statsTraceCtx.streamClosed(clientStatus);
|
||||||
clientStreamListener.closed(clientStatus, RpcProgress.PROCESSED, trailers);
|
syncContext.executeLater(
|
||||||
|
() -> clientStreamListener.closed(clientStatus, RpcProgress.PROCESSED, trailers));
|
||||||
} else {
|
} else {
|
||||||
clientNotifyStatus = clientStatus;
|
clientNotifyStatus = clientStatus;
|
||||||
clientNotifyTrailers = trailers;
|
clientNotifyTrailers = trailers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
streamClosed();
|
streamClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,7 +634,8 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
streamClosed();
|
streamClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized boolean internalCancel(Status clientStatus) {
|
private boolean internalCancel(Status clientStatus) {
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -621,7 +652,11 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clientStream.statsTraceCtx.streamClosed(clientStatus);
|
clientStream.statsTraceCtx.streamClosed(clientStatus);
|
||||||
clientStreamListener.closed(clientStatus, RpcProgress.PROCESSED, new Metadata());
|
syncContext.executeLater(
|
||||||
|
() ->
|
||||||
|
clientStreamListener.closed(clientStatus, RpcProgress.PROCESSED, new Metadata()));
|
||||||
|
}
|
||||||
|
syncContext.drain();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -662,8 +697,10 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
private class InProcessClientStream implements ClientStream {
|
private class InProcessClientStream implements ClientStream {
|
||||||
final StatsTraceContext statsTraceCtx;
|
final StatsTraceContext statsTraceCtx;
|
||||||
final CallOptions callOptions;
|
final CallOptions callOptions;
|
||||||
@GuardedBy("this")
|
// All callbacks must run in syncContext to avoid possibility of deadlock in direct executors
|
||||||
private ServerStreamListener serverStreamListener;
|
private ServerStreamListener serverStreamListener;
|
||||||
|
private final SynchronizationContext syncContext =
|
||||||
|
new SynchronizationContext(uncaughtExceptionHandler);
|
||||||
@GuardedBy("this")
|
@GuardedBy("this")
|
||||||
private int serverRequested;
|
private int serverRequested;
|
||||||
@GuardedBy("this")
|
@GuardedBy("this")
|
||||||
|
|
@ -693,9 +730,10 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
if (onReady) {
|
if (onReady) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
serverStreamListener.onReady();
|
syncContext.executeLater(() -> serverStreamListener.onReady());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -705,21 +743,29 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
*
|
*
|
||||||
* @return whether onReady should be called on the server
|
* @return whether onReady should be called on the server
|
||||||
*/
|
*/
|
||||||
private synchronized boolean serverRequested(int numMessages) {
|
private boolean serverRequested(int numMessages) {
|
||||||
|
boolean previouslyReady;
|
||||||
|
boolean nowReady;
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
boolean previouslyReady = serverRequested > 0;
|
previouslyReady = serverRequested > 0;
|
||||||
serverRequested += numMessages;
|
serverRequested += numMessages;
|
||||||
|
|
||||||
while (serverRequested > 0 && !serverReceiveQueue.isEmpty()) {
|
while (serverRequested > 0 && !serverReceiveQueue.isEmpty()) {
|
||||||
serverRequested--;
|
serverRequested--;
|
||||||
serverStreamListener.messagesAvailable(serverReceiveQueue.poll());
|
StreamListener.MessageProducer producer = serverReceiveQueue.poll();
|
||||||
|
syncContext.executeLater(() -> serverStreamListener.messagesAvailable(producer));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverReceiveQueue.isEmpty() && serverNotifyHalfClose) {
|
if (serverReceiveQueue.isEmpty() && serverNotifyHalfClose) {
|
||||||
serverNotifyHalfClose = false;
|
serverNotifyHalfClose = false;
|
||||||
serverStreamListener.halfClosed();
|
syncContext.executeLater(() -> serverStreamListener.halfClosed());
|
||||||
}
|
}
|
||||||
boolean nowReady = serverRequested > 0;
|
nowReady = serverRequested > 0;
|
||||||
|
}
|
||||||
|
syncContext.drain();
|
||||||
return !previouslyReady && nowReady;
|
return !previouslyReady && nowReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -728,7 +774,8 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void writeMessage(InputStream message) {
|
public void writeMessage(InputStream message) {
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -740,11 +787,13 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
StreamListener.MessageProducer producer = new SingleMessageProducer(message);
|
StreamListener.MessageProducer producer = new SingleMessageProducer(message);
|
||||||
if (serverRequested > 0) {
|
if (serverRequested > 0) {
|
||||||
serverRequested--;
|
serverRequested--;
|
||||||
serverStreamListener.messagesAvailable(producer);
|
syncContext.executeLater(() -> serverStreamListener.messagesAvailable(producer));
|
||||||
} else {
|
} else {
|
||||||
serverReceiveQueue.add(producer);
|
serverReceiveQueue.add(producer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void flush() {}
|
public void flush() {}
|
||||||
|
|
@ -768,8 +817,9 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
streamClosed();
|
streamClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized boolean internalCancel(
|
private boolean internalCancel(
|
||||||
Status serverListenerStatus, Status serverTracerStatus) {
|
Status serverListenerStatus, Status serverTracerStatus) {
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -787,21 +837,26 @@ final class InProcessTransport implements ServerTransport, ConnectionClientTrans
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverStream.statsTraceCtx.streamClosed(serverTracerStatus);
|
serverStream.statsTraceCtx.streamClosed(serverTracerStatus);
|
||||||
serverStreamListener.closed(serverListenerStatus);
|
syncContext.executeLater(() -> serverStreamListener.closed(serverListenerStatus));
|
||||||
|
}
|
||||||
|
syncContext.drain();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void halfClose() {
|
public void halfClose() {
|
||||||
|
synchronized (this) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (serverReceiveQueue.isEmpty()) {
|
if (serverReceiveQueue.isEmpty()) {
|
||||||
serverStreamListener.halfClosed();
|
syncContext.executeLater(() -> serverStreamListener.halfClosed());
|
||||||
} else {
|
} else {
|
||||||
serverNotifyHalfClose = true;
|
serverNotifyHalfClose = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncContext.drain();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setMessageCompression(boolean enable) {}
|
public void setMessageCompression(boolean enable) {}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ import org.mockito.ArgumentMatchers;
|
||||||
* Not intended to provide a high code coverage or to test every major usecase.
|
* Not intended to provide a high code coverage or to test every major usecase.
|
||||||
*
|
*
|
||||||
* directExecutor() makes it easier to have deterministic tests.
|
* directExecutor() makes it easier to have deterministic tests.
|
||||||
* However, if your implementation uses another thread and uses streaming it is better to use
|
|
||||||
* the default executor, to avoid hitting bug #3084.
|
|
||||||
*
|
*
|
||||||
* <p>For more unit test examples see {@link io.grpc.examples.routeguide.RouteGuideClientTest} and
|
* <p>For more unit test examples see {@link io.grpc.examples.routeguide.RouteGuideClientTest} and
|
||||||
* {@link io.grpc.examples.routeguide.RouteGuideServerTest}.
|
* {@link io.grpc.examples.routeguide.RouteGuideServerTest}.
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import org.junit.runners.JUnit4;
|
||||||
* Not intended to provide a high code coverage or to test every major usecase.
|
* Not intended to provide a high code coverage or to test every major usecase.
|
||||||
*
|
*
|
||||||
* directExecutor() makes it easier to have deterministic tests.
|
* directExecutor() makes it easier to have deterministic tests.
|
||||||
* However, if your implementation uses another thread and uses streaming it is better to use
|
|
||||||
* the default executor, to avoid hitting bug #3084.
|
|
||||||
*
|
*
|
||||||
* <p>For more unit test examples see {@link io.grpc.examples.routeguide.RouteGuideClientTest} and
|
* <p>For more unit test examples see {@link io.grpc.examples.routeguide.RouteGuideClientTest} and
|
||||||
* {@link io.grpc.examples.routeguide.RouteGuideServerTest}.
|
* {@link io.grpc.examples.routeguide.RouteGuideServerTest}.
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,6 @@ import org.mockito.ArgumentCaptor;
|
||||||
* Not intended to provide a high code coverage or to test every major usecase.
|
* Not intended to provide a high code coverage or to test every major usecase.
|
||||||
*
|
*
|
||||||
* directExecutor() makes it easier to have deterministic tests.
|
* directExecutor() makes it easier to have deterministic tests.
|
||||||
* However, if your implementation uses another thread and uses streaming it is better to use
|
|
||||||
* the default executor, to avoid hitting bug #3084.
|
|
||||||
*
|
*
|
||||||
* <p>For basic unit test examples see {@link io.grpc.examples.helloworld.HelloWorldClientTest} and
|
* <p>For basic unit test examples see {@link io.grpc.examples.helloworld.HelloWorldClientTest} and
|
||||||
* {@link io.grpc.examples.helloworld.HelloWorldServerTest}.
|
* {@link io.grpc.examples.helloworld.HelloWorldServerTest}.
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@ import org.mockito.ArgumentCaptor;
|
||||||
* Not intended to provide a high code coverage or to test every major usecase.
|
* Not intended to provide a high code coverage or to test every major usecase.
|
||||||
*
|
*
|
||||||
* directExecutor() makes it easier to have deterministic tests.
|
* directExecutor() makes it easier to have deterministic tests.
|
||||||
* However, if your implementation uses another thread and uses streaming it is better to use
|
|
||||||
* the default executor, to avoid hitting bug #3084.
|
|
||||||
*
|
*
|
||||||
* <p>For basic unit test examples see {@link io.grpc.examples.helloworld.HelloWorldClientTest} and
|
* <p>For basic unit test examples see {@link io.grpc.examples.helloworld.HelloWorldClientTest} and
|
||||||
* {@link io.grpc.examples.helloworld.HelloWorldServerTest}.
|
* {@link io.grpc.examples.helloworld.HelloWorldServerTest}.
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,7 @@ public final class ClientCalls {
|
||||||
private static final class UnaryStreamToFuture<RespT> extends StartableListener<RespT> {
|
private static final class UnaryStreamToFuture<RespT> extends StartableListener<RespT> {
|
||||||
private final GrpcFuture<RespT> responseFuture;
|
private final GrpcFuture<RespT> responseFuture;
|
||||||
private RespT value;
|
private RespT value;
|
||||||
|
private boolean isValueReceived = false;
|
||||||
|
|
||||||
// Non private to avoid synthetic class
|
// Non private to avoid synthetic class
|
||||||
UnaryStreamToFuture(GrpcFuture<RespT> responseFuture) {
|
UnaryStreamToFuture(GrpcFuture<RespT> responseFuture) {
|
||||||
|
|
@ -521,17 +522,18 @@ public final class ClientCalls {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onMessage(RespT value) {
|
public void onMessage(RespT value) {
|
||||||
if (this.value != null) {
|
if (this.isValueReceived) {
|
||||||
throw Status.INTERNAL.withDescription("More than one value received for unary call")
|
throw Status.INTERNAL.withDescription("More than one value received for unary call")
|
||||||
.asRuntimeException();
|
.asRuntimeException();
|
||||||
}
|
}
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
this.isValueReceived = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClose(Status status, Metadata trailers) {
|
public void onClose(Status status, Metadata trailers) {
|
||||||
if (status.isOk()) {
|
if (status.isOk()) {
|
||||||
if (value == null) {
|
if (!isValueReceived) {
|
||||||
// No value received so mark the future as an error
|
// No value received so mark the future as an error
|
||||||
responseFuture.setException(
|
responseFuture.setException(
|
||||||
Status.INTERNAL.withDescription("No value received for unary call")
|
Status.INTERNAL.withDescription("No value received for unary call")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue