Skip to content

Commit ae4f4bf

Browse files
committed
binder: Deliver closed() to streams started after shutdown
BinderClientTransport.newStream() registered a new ClientInbound in ongoingCalls right away but the Inbound only learned about its ClientStreamListener later, in ClientStream.start(). If the transport terminated in between (binderDied(), onUnbound(), a peer's SHUTDOWN_TRANSPORT or shutdownNow(), all of which close ongoing calls asynchronously), closeAbnormal() found an UNINITIALIZED Inbound with no listener or StatsTraceContext to report to. start() then attached the listener to an Inbound that was already closed and the RPC hung forever with its tracers never closed. Registering at newStream() was also a leak waiting to happen: gRPC core can abandon a ClientStream it got from newStream() without ever calling start() or cancel() on it (DelayedStream.setStream() silently drops the real stream if the DelayedStream was cancelled in the meantime). Such a stream stayed in ongoingCalls and counted as in-use forever, so a graceful shutdown() of that transport could never terminate. Make newStream() do allocations only and move registration into start(), as OkHttp and Netty do. The new BinderClientTransport .startStream() helper holds the transport lock across both registration and Inbound.init() so, relative to shutdownInternal()'s snapshot of ongoingCalls, there are only two possible outcomes: either shutdown ran first, in which case start() reports closed(shutdownStatus, MISCARRIED) without registering anything (mirroring OkHttp's streamReadyToStart() and allowing a transparent retry on a fresh transport), or we registered an Inbound that already has a listener for the eventual close to be delivered to. The listener callback itself runs with no locks held. The miscarried stream's Outbound is put into CLOSED so that the writeMessage()/halfClose() ClientCalls issues right after start() can't send anything for a call the server must never see. Two behaviour changes follow, both consistent with OkHttp/Netty: - transportInUse(true) is reported at start() rather than newStream(). - A graceful shutdown() between newStream() and start() no longer waits for the unstarted stream, whose start() then miscarries. Since nothing can reach an Inbound that was never registered, the only way a client Inbound can still be UNINITIALIZED when cancel() runs is if cancel() came before start(). ClientStream#cancel says that's not allowed, gRPC core's paths honour it (ClientCallImpl arms Context and deadline cancellation only after start(), see issue #1343) and other transports NPE if it happens, so cancel() now throws IllegalStateException instead of silently closing a stream nobody is listening to. A cancel() after a miscarried start() remains a legal no-op. unregisterInbound() only decrements the in-use count for an Inbound that was actually registered.
1 parent 15d64a4 commit ae4f4bf

9 files changed

Lines changed: 586 additions & 40 deletions

File tree

‎binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java‎

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
import io.grpc.binder.InboundParcelablePolicy;
5050
import io.grpc.binder.SecurityPolicy;
5151
import io.grpc.internal.ClientStream;
52+
import io.grpc.internal.ClientStreamListener;
53+
import io.grpc.internal.ClientStreamListener.RpcProgress;
5254
import io.grpc.internal.ClientTransportFactory.ClientTransportOptions;
5355
import io.grpc.internal.ConnectionClientTransport;
5456
import io.grpc.internal.FailingClientStream;
@@ -251,6 +253,7 @@ public synchronized ClientStream newStream(
251253
tracers);
252254
}
253255

256+
// Just allocations here. The new call is registered when it's start()ed, in startStream().
254257
int callId = latestCallId++;
255258
if (latestCallId == LAST_CALL_ID) {
256259
latestCallId = FIRST_CALL_ID;
@@ -260,15 +263,6 @@ public synchronized ClientStream newStream(
260263
Inbound.ClientInbound inbound =
261264
new Inbound.ClientInbound(
262265
this, attributes, callId, GrpcUtil.shouldBeCountedForInUse(callOptions));
263-
if (ongoingCalls.putIfAbsent(callId, inbound) != null) {
264-
Status failure = Status.INTERNAL.withDescription("Clashing call IDs");
265-
shutdownInternal(failure, true);
266-
return newFailingClientStream(failure, attributes, headers, tracers);
267-
}
268-
269-
if (inbound.countsForInUse() && numInUseStreams.getAndIncrement() == 0) {
270-
clientTransportListener.transportInUse(true);
271-
}
272266
Outbound.ClientOutbound outbound =
273267
new Outbound.ClientOutbound(this, callId, method, headers, statsTraceContext);
274268
if (method.getType().clientSendsOneMessage()) {
@@ -278,12 +272,95 @@ public synchronized ClientStream newStream(
278272
}
279273
}
280274

275+
/**
276+
* Registers a call allocated by {@link #newStream} and connects it to its listener.
277+
*
278+
* <p>If this transport has already shut down, the call isn't registered at all and 'listener' is
279+
* instead told that the stream was closed with {@link RpcProgress#MISCARRIED}, which is accurate:
280+
* nothing has been sent on its behalf yet, so the server never saw it and the RPC can be
281+
* transparently retried on another transport.
282+
*
283+
* <p>Takes the transport lock, so must not be called with any Inbound or Outbound lock held.
284+
*
285+
* @return true iff the call was registered and {@code inbound} initialized, in which case the
286+
* caller may proceed to use the stream normally. False iff the stream is now closed and
287+
* 'listener' has already been told so, in which case the caller must not call it again.
288+
*/
289+
boolean startStream(
290+
Inbound.ClientInbound inbound,
291+
Outbound.ClientOutbound outbound,
292+
ClientStreamListener listener) {
293+
Status failure = registerCallAndInitInbound(inbound, outbound, listener);
294+
if (failure != null) {
295+
// Nobody else can reach a never-registered Inbound so we can finish up without our own lock,
296+
// like every other close. MISCARRIED promises the server never saw this call, yet a closed
297+
// stream may still be written to and half-closed (the listener's state has no bearing on
298+
// what a caller may do; see ClientCall). So first make sure nothing can ever go on the wire
299+
// for this call, then tell the listener.
300+
synchronized (outbound) {
301+
outbound.close();
302+
}
303+
synchronized (inbound) {
304+
inbound.closeMiscarried(failure);
305+
}
306+
return false;
307+
}
308+
return true;
309+
}
310+
311+
/**
312+
* Registers 'inbound' as an ongoing call, unless we've shut down, and either way initializes it
313+
* with its Outbound and listener.
314+
*
315+
* @return null iff 'inbound' is now registered, otherwise the reason it couldn't be
316+
*/
317+
@Nullable
318+
private synchronized Status registerCallAndInitInbound(
319+
Inbound.ClientInbound inbound,
320+
Outbound.ClientOutbound outbound,
321+
ClientStreamListener listener) {
322+
Status streamFailure = null; // Why 'inbound' can't start, if it can't.
323+
Status transportFailure = null; // Why this whole transport must terminate, if it must.
324+
boolean nowInUse = false;
325+
// Holding our lock across both registration and init() means that shutdownInternal() either
326+
// runs before us (and the stream miscarries) or after us (and finds an Inbound with a listener
327+
// to report the close to). Holding the Inbound's lock too means nothing can touch it in
328+
// between, once it's visible in ongoingCalls.
329+
synchronized (inbound) {
330+
if (isShutdown()) {
331+
streamFailure = shutdownStatus;
332+
} else {
333+
Inbound<?, ?> existing = ongoingCalls.putIfAbsent(inbound.callId, inbound);
334+
if (existing == inbound) {
335+
throw new IllegalStateException("start() called more than once");
336+
} else if (existing != null) {
337+
transportFailure = Status.INTERNAL.withDescription("Clashing call IDs");
338+
streamFailure = transportFailure;
339+
} else {
340+
nowInUse = inbound.countsForInUse() && numInUseStreams.getAndIncrement() == 0;
341+
}
342+
}
343+
inbound.init(outbound, listener);
344+
}
345+
// Callbacks into gRPC (via clientTransportListener) happen under our lock, as they always
346+
// have, but not under the Inbound's.
347+
if (transportFailure != null) {
348+
shutdownInternal(transportFailure, true);
349+
} else if (nowInUse) {
350+
clientTransportListener.transportInUse(true);
351+
}
352+
return streamFailure;
353+
}
354+
281355
@Override
282-
protected void unregisterInbound(Inbound<?, ?> inbound) {
283-
if (inbound.countsForInUse() && numInUseStreams.decrementAndGet() == 0) {
356+
protected boolean unregisterInbound(Inbound<?, ?> inbound) {
357+
boolean removed = super.unregisterInbound(inbound);
358+
// Only ever registered Inbounds counted towards in-use, and forced termination may have already
359+
// unregistered everything (see notifyTerminated() for the corresponding transportInUse(false)).
360+
if (removed && inbound.countsForInUse() && numInUseStreams.decrementAndGet() == 0) {
284361
clientTransportListener.transportInUse(false);
285362
}
286-
super.unregisterInbound(inbound);
363+
return removed;
287364
}
288365

289366
@Override

‎binder/src/main/java/io/grpc/binder/internal/BinderTransport.java‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,21 @@ protected synchronized void sendPing(int id) throws StatusException {
396396
}
397397
}
398398

399-
protected void unregisterInbound(Inbound<?, ?> inbound) {
400-
unregisterCall(inbound.callId);
399+
/**
400+
* Removes 'inbound' from the set of ongoing calls, if it's there.
401+
*
402+
* @return true iff 'inbound' was registered (and now isn't)
403+
*/
404+
protected boolean unregisterInbound(Inbound<?, ?> inbound) {
405+
return unregisterCall(inbound.callId);
401406
}
402407

403-
final void unregisterCall(int callId) {
408+
/**
409+
* Removes the call identified by 'callId' from the set of ongoing calls, if it's there.
410+
*
411+
* @return true iff 'callId' was registered (and now isn't)
412+
*/
413+
final boolean unregisterCall(int callId) {
404414
boolean removed = (ongoingCalls.remove(callId) != null);
405415
if (removed && ongoingCalls.isEmpty()) {
406416
// If inState(SHUTDOWN), we'd need to terminate (as the last call to finish). But we can't
@@ -416,6 +426,7 @@ final void unregisterCall(int callId) {
416426
}
417427
});
418428
}
429+
return removed;
419430
}
420431

421432
final void sendTransaction(int callId, ParcelHolder parcel) throws StatusException {

‎binder/src/main/java/io/grpc/binder/internal/Inbound.java‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,11 @@ private Inbound(T transport, Attributes attributes, int callId) {
154154

155155
@GuardedBy("this")
156156
final void init(Outbound outbound, L listener) {
157+
checkState(isUninitialized(), "already initialized");
157158
this.outbound = outbound;
158159
this.statsTraceContext = outbound.getStatsTraceContext();
159160
this.listener = listener;
160-
if (!isClosed()) {
161-
onDeliveryState(State.INITIALIZED);
162-
}
161+
onDeliveryState(State.INITIALIZED);
163162
}
164163

165164
final void unregister() {
@@ -184,6 +183,12 @@ protected final boolean isClosed() {
184183
return deliveryState == State.CLOSED;
185184
}
186185

186+
/** Returns true iff our delivery state is still {@link State#UNINITIALIZED}. */
187+
@GuardedBy("this")
188+
final boolean isUninitialized() {
189+
return deliveryState == State.UNINITIALIZED;
190+
}
191+
187192
@GuardedBy("this")
188193
private final boolean messageAvailable() {
189194
return firstMessage != null || nextCompleteMessageEnd > 0;
@@ -616,6 +621,34 @@ protected void deliverSuffix() {
616621
protected void deliverCloseAbnormal(Status status) {
617622
listener.closed(status, RpcProgress.PROCESSED, new Metadata());
618623
}
624+
625+
/**
626+
* Handles {@link io.grpc.internal.ClientStream#cancel}, which "may only be called after
627+
* start()".
628+
*
629+
* <p>Nothing can reach us before we're registered, which happens at start(), so still being
630+
* {@link State#UNINITIALIZED} here can only mean that contract was broken.
631+
*/
632+
@GuardedBy("this")
633+
void cancel(Status status) {
634+
checkState(!isUninitialized(), "cancel() before start()");
635+
closeOnCancel(status);
636+
}
637+
638+
/**
639+
* Closes a stream that couldn't be registered because its transport had already shut down.
640+
*
641+
* <p>Nothing has been sent on the stream's behalf, so this is reported as {@link
642+
* RpcProgress#MISCARRIED}. And since the stream was never registered, there's no out-of-band
643+
* close to send and nothing to unregister().
644+
*/
645+
@GuardedBy("this")
646+
void closeMiscarried(Status status) {
647+
checkState(!isClosed(), "already closed");
648+
onDeliveryState(State.CLOSED);
649+
statsTraceContext.streamClosed(status);
650+
listener.closed(status, RpcProgress.MISCARRIED, new Metadata());
651+
}
619652
}
620653

621654
// ======================================

‎binder/src/main/java/io/grpc/binder/internal/MultiMessageClientStream.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ final class MultiMessageClientStream implements ClientStream {
5959

6060
@Override
6161
public void start(ClientStreamListener listener) {
62-
synchronized (inbound) {
63-
inbound.init(outbound, listener);
62+
if (!inbound.transport.startStream(inbound, outbound, listener)) {
63+
return; // Already closed as MISCARRIED and 'listener' has been told.
6464
}
6565
if (outbound.isReady()) {
6666
listener.onReady();
@@ -120,7 +120,7 @@ public void halfClose() {
120120
@Override
121121
public void cancel(Status status) {
122122
synchronized (inbound) {
123-
inbound.closeOnCancel(status);
123+
inbound.cancel(status);
124124
}
125125
}
126126

‎binder/src/main/java/io/grpc/binder/internal/Outbound.java‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,24 @@ final void send() throws StatusException {
211211
sendInternal();
212212
} catch (StatusException se) {
213213
// Ensure we don't send anything else and rethrow.
214-
onOutboundState(State.CLOSED);
214+
close();
215215
throw se;
216216
}
217217
}
218218
}
219219

220+
/**
221+
* Moves to {@link State#CLOSED} so that nothing (more) is ever sent.
222+
*
223+
* <p>Anything subsequently handed to this Outbound is accepted but never sent. Besides the send
224+
* failure case, this is for a stream that closed before anything was sent on its behalf, where
225+
* sending anything now would be for a call the server must never hear about.
226+
*/
227+
@GuardedBy("this")
228+
final void close() {
229+
onOutboundState(State.CLOSED);
230+
}
231+
220232
@GuardedBy("this")
221233
@SuppressWarnings("fallthrough")
222234
protected final void sendInternal() throws StatusException {

‎binder/src/main/java/io/grpc/binder/internal/SingleMessageClientStream.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ final class SingleMessageClientStream implements ClientStream {
6363

6464
@Override
6565
public void start(ClientStreamListener listener) {
66-
synchronized (inbound) {
67-
inbound.init(outbound, listener);
66+
if (!inbound.transport.startStream(inbound, outbound, listener)) {
67+
return; // Already closed as MISCARRIED and 'listener' has been told.
6868
}
6969
if (outbound.isReady()) {
7070
listener.onReady();
@@ -114,7 +114,7 @@ public void halfClose() {
114114
@Override
115115
public void cancel(Status status) {
116116
synchronized (inbound) {
117-
inbound.closeOnCancel(status);
117+
inbound.cancel(status);
118118
}
119119
}
120120

0 commit comments

Comments
 (0)