Skip to content

fix(grpc): keep connection alive on client send half-close#670

Merged
leafaar merged 3 commits intomasterfrom
fix-recv-tx-closing
Feb 4, 2026
Merged

fix(grpc): keep connection alive on client send half-close#670
leafaar merged 3 commits intomasterfrom
fix-recv-tx-closing

Conversation

@leafaar
Copy link
Contributor

@leafaar leafaar commented Feb 4, 2026

the ping patch #662 (91709fd, str-300: do no skip ping logic even if the channel is full) removed ping_client_tx, which was a clone of client_tx that lived inside the ping task. it was removed because pings no longer needed to signal disconnect explicitly, the new .send().await approach just breaks on error.

removing that clone had a side effect. before the patch there were two senders for the client_rx channel: ping_client_tx in the ping task, and incoming_client_tx in the incoming message task. an mpsc channel only closes when all senders are dropped. when a client half-closed its send stream (normal in grpc, it just means "i have nothing more to send"), the incoming message task would receive Ok(None), break, and drop incoming_client_tx. but ping_client_tx was still alive in the ping task, so client_rx stayed open and client_loop kept running. the client kept receiving data. this is correct for grpc bidirectional streaming, half-closing one direction should not tear down the other.

after the patch, incoming_client_tx is the only sender. when the incoming task breaks on Ok(None), all senders are gone, client_rx.recv() returns None, and client_loop treats that as a disconnect. the entire connection is torn down because the client closed its send half.

this breaks any client that sends its initial filter and then drops the send side of the stream, which is a common pattern. most simple clients send one SubscribeRequest and just read. in grpc, a client half-close is not a disconnect, it just means the client is done sending. the server should keep streaming.

the fix is in the Ok(None) handler of the incoming message task. instead of breaking immediately (which drops the sender and cascades into a full disconnect), the task now awaits incoming_cancellation_token.cancelled(). this keeps incoming_client_tx alive so client_loop continues running until an actual disconnect condition happens like server shutdown or stream error.

the ping patch (`91709fd`, `str-300: do no skip ping logic even if
the channel is full`) removed `ping_client_tx`, which was a clone
of `client_tx` that lived inside the ping task. it was removed
because pings no longer needed to signal disconnect explicitly,
the new `.send().await` approach just breaks on error.

removing that clone had a side effect. before the patch there were
two senders for the `client_rx` channel: `ping_client_tx` in the
ping task, and `incoming_client_tx` in the incoming message task.
an `mpsc` channel only closes when all senders are dropped. when a
client half-closed its send stream (normal in grpc, it just means
"i have nothing more to send"), the incoming message task would
receive `Ok(None)`, break, and drop `incoming_client_tx`. but
`ping_client_tx` was still alive in the ping task, so `client_rx`
stayed open and `client_loop` kept running. the client kept
receiving data. this is correct for grpc bidirectional streaming,
half-closing one direction should not tear down the other.

after the patch, `incoming_client_tx` is the only sender. when the
incoming task breaks on `Ok(None)`, all senders are gone,
`client_rx.recv()` returns `None`, and `client_loop` treats that
as a disconnect. the entire connection is torn down because the
client closed its send half.

this breaks any client that sends its initial filter and then drops
the send side of the stream, which is a common pattern. most
simple clients send one `SubscribeRequest` and just read. in grpc,
a client half-close is not a disconnect, it just means the client
is done sending. the server should keep streaming.

the fix is in the `Ok(None)` handler of the incoming message task.
instead of breaking immediately (which drops the sender and
cascades into a full disconnect), the task now awaits
`incoming_cancellation_token.cancelled()`. this keeps
`incoming_client_tx` alive so `client_loop` continues running
until an actual disconnect condition happens like server shutdown
or stream error.
@leafaar leafaar requested a review from lvboudre February 4, 2026 14:16
@leafaar leafaar merged commit ed49e07 into master Feb 4, 2026
3 of 5 checks passed
@leafaar leafaar deleted the fix-recv-tx-closing branch February 4, 2026 16:47
WilfredAlmeida pushed a commit that referenced this pull request Feb 5, 2026
* fix(grpc): keep connection alive on client send half-close

the ping patch (`91709fd`, `str-300: do no skip ping logic even if
the channel is full`) removed `ping_client_tx`, which was a clone
of `client_tx` that lived inside the ping task. it was removed
because pings no longer needed to signal disconnect explicitly,
the new `.send().await` approach just breaks on error.

removing that clone had a side effect. before the patch there were
two senders for the `client_rx` channel: `ping_client_tx` in the
ping task, and `incoming_client_tx` in the incoming message task.
an `mpsc` channel only closes when all senders are dropped. when a
client half-closed its send stream (normal in grpc, it just means
"i have nothing more to send"), the incoming message task would
receive `Ok(None)`, break, and drop `incoming_client_tx`. but
`ping_client_tx` was still alive in the ping task, so `client_rx`
stayed open and `client_loop` kept running. the client kept
receiving data. this is correct for grpc bidirectional streaming,
half-closing one direction should not tear down the other.

after the patch, `incoming_client_tx` is the only sender. when the
incoming task breaks on `Ok(None)`, all senders are gone,
`client_rx.recv()` returns `None`, and `client_loop` treats that
as a disconnect. the entire connection is torn down because the
client closed its send half.

this breaks any client that sends its initial filter and then drops
the send side of the stream, which is a common pattern. most
simple clients send one `SubscribeRequest` and just read. in grpc,
a client half-close is not a disconnect, it just means the client
is done sending. the server should keep streaming.

the fix is in the `Ok(None)` handler of the incoming message task.
instead of breaking immediately (which drops the sender and
cascades into a full disconnect), the task now awaits
`incoming_cancellation_token.cancelled()`. this keeps
`incoming_client_tx` alive so `client_loop` continues running
until an actual disconnect condition happens like server shutdown
or stream error.

* chore: update changelog

* chore: add comments
WilfredAlmeida pushed a commit that referenced this pull request Feb 5, 2026
* fix(grpc): keep connection alive on client send half-close

the ping patch (`91709fd`, `str-300: do no skip ping logic even if
the channel is full`) removed `ping_client_tx`, which was a clone
of `client_tx` that lived inside the ping task. it was removed
because pings no longer needed to signal disconnect explicitly,
the new `.send().await` approach just breaks on error.

removing that clone had a side effect. before the patch there were
two senders for the `client_rx` channel: `ping_client_tx` in the
ping task, and `incoming_client_tx` in the incoming message task.
an `mpsc` channel only closes when all senders are dropped. when a
client half-closed its send stream (normal in grpc, it just means
"i have nothing more to send"), the incoming message task would
receive `Ok(None)`, break, and drop `incoming_client_tx`. but
`ping_client_tx` was still alive in the ping task, so `client_rx`
stayed open and `client_loop` kept running. the client kept
receiving data. this is correct for grpc bidirectional streaming,
half-closing one direction should not tear down the other.

after the patch, `incoming_client_tx` is the only sender. when the
incoming task breaks on `Ok(None)`, all senders are gone,
`client_rx.recv()` returns `None`, and `client_loop` treats that
as a disconnect. the entire connection is torn down because the
client closed its send half.

this breaks any client that sends its initial filter and then drops
the send side of the stream, which is a common pattern. most
simple clients send one `SubscribeRequest` and just read. in grpc,
a client half-close is not a disconnect, it just means the client
is done sending. the server should keep streaming.

the fix is in the `Ok(None)` handler of the incoming message task.
instead of breaking immediately (which drops the sender and
cascades into a full disconnect), the task now awaits
`incoming_cancellation_token.cancelled()`. this keeps
`incoming_client_tx` alive so `client_loop` continues running
until an actual disconnect condition happens like server shutdown
or stream error.

* chore: update changelog

* chore: add comments
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