Skip to content

binder: Fix listener callbacks in two edge cases - #13139

Open
jdcormie wants to merge 2 commits into
grpc:masterfrom
jdcormie:binder-close-before-start
Open

jdcormie wants to merge 2 commits into
grpc:masterfrom
jdcormie:binder-close-before-start

Conversation

@jdcormie

Copy link
Copy Markdown
Member

See each commit description - will not squash.

@jdcormie
jdcormie requested a review from ejona86 October 26, 2026 19:05

@ejona86 ejona86 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ClientInbound is created and registered in ongoingCalls by
newStream() but only learns about its ClientStreamListener later, in
ClientStream.start().

Did you consider registering in ongoingCalls within ClientStream.start()? Most real transports do registration in the "transport thread" in start(), and newStream() does nothing more than allocations.

The approach taken here can be fine, but want to make sure you considered the alternative.

@jdcormie
jdcormie force-pushed the binder-close-before-start branch from 07b613f to 38c097c Compare October 27, 2026 04:41
@jdcormie jdcormie changed the title binder: Fix listener callbacks when a stream is closed before start() binder: Fix listener callbacks in two edge cases Oct 27, 2026
@jdcormie
jdcormie force-pushed the binder-close-before-start branch from 38c097c to c2b6f44 Compare October 27, 2026 04:51
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. start() now initializes the Inbound
with its listener and then registers the call under the transport
lock so, relative to shutdownInternal()'s snapshot of ongoingCalls,
there are only two possible outcomes: either shutdown ran first, in
which case the call is never registered and start() reports
closed(shutdownStatus, MISCARRIED) (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. That MISCARRIED close is
delivered under the Inbound lock alone, like every other close.
Because a closed stream may still legally be written to and
half-closed, the miscarried stream's Outbound is closed first so that
nothing can ever go on the wire 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 and gRPC core's paths honour it (ClientCallImpl arms Context
and deadline cancellation only after start(), see issue grpc#1343), 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.
Inbound.onTransportReady() unconditionally forwarded onReady() to the
listener, so flow control unblocking concurrently with a close could
invoke onReady() after closed(), which must be the listener's final
callback.

Only skip the listener, not the Outbound: a server stream's close may
still be sitting in its Outbound waiting for the transmit window to
open.
@jdcormie
jdcormie force-pushed the binder-close-before-start branch from c2b6f44 to c6cd8f5 Compare October 27, 2026 05:30
@jdcormie

Copy link
Copy Markdown
Member Author

Did you consider registering in ongoingCalls within ClientStream.start()? Most real transports do registration in the "transport thread" in start(), and newStream() does nothing more than allocations.

No but that's a good idea! And it seems at least one place in core assumes this. PTAL.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants