Skip to content

Commit fb64539

Browse files
authored
client: restore Send on stream message() futures with concrete view types (#215)
Fixes #214. **Before** (0.8.0 — fails to compile with any concrete generated view type): ```rust tokio::spawn(async move { while let Some(msg) = stream.message().await? { /* … */ } }); // error: implementation of `MessageView` is not general enough ``` **After**: the same code compiles and runs; verified live with a spawned consumer on a multi-threaded runtime across server-stream, mid-stream-error, and bidi shapes. ## Root cause The 0.8.0 client-stream change bounded both `message()` methods with `RespView::Owned: HasMessageView<View<'static> = RespView>`. Projecting through the GAT inside an `async fn`'s where-clause trips rustc's coroutine-witness auto-trait check on monomorphization — the future is `Send` when the message type is a generic parameter but loses `Send` with any concrete generated type, exactly as isolated in the issue (whose report was excellent — the generic-vs-concrete observation pointed straight at the fix). ## The fix Reshape the bound as an output type parameter: ```rust pub async fn message<M>(&mut self) -> Result<Option<StreamMessage<M>>, ConnectError> where RespView: MessageView<'static, Owned = M>, M: HasMessageView<View<'static> = RespView>, ``` `M` is uniquely determined by `RespView` (the impl blocks already carry `RespView: MessageView<'static>`), so the bound is logically equivalent and `M` is inferred at every call site — the entire workspace compiles with zero call-site changes. The regression pin in `tests/streaming` asserts `Send` through async-block wrappers deliberately: the bare `message()` future is `Send` even with the buggy bound — the failure only manifests in the *enclosing* coroutine's witness, i.e. exactly the `tokio::spawn(async move { .. })` shape users write. Verified fail-before (4 errors) / pass-after, and covered by concrete-shape probes including the issue's message-typed-`oneof`. ## Semver note `cargo semver-checks` flags one lint: `message` gains a generic type parameter (classified major). The formal break surface is explicit turbofish on a method that previously had no generic parameters — code that cannot meaningfully exist against 0.8.0. Weighed against the real regression (the streaming client being unusable in `Send` tasks), this ships as **0.8.1**. Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
1 parent ed5d957 commit fb64539

3 files changed

Lines changed: 46 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixed
2+
body: |-
3+
Client stream `message()` futures are `Send` again with concrete generated view types ([#214]). 0.8.0's bound shape on `ServerStream::message`/`BidiStream::message` (projecting `RespView::Owned` in the where-clause) tripped rustc's coroutine-witness auto-trait check on monomorphization, so a server-stream could not be consumed inside `tokio::spawn`; the bound is reshaped as an output type parameter (`RespView: MessageView<'static, Owned = M>`), which is equivalent for every caller and restores `Send`. Call sites are unaffected — `M` is inferred.
4+
5+
[#214]: https://github.com/connectrpc/connect-rust/issues/214
6+
time: 2026-07-02T09:00:31.44759722-07:00

connectrpc/src/client/mod.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2401,11 +2401,15 @@ where
24012401
/// matching grpc-go's treatment of each case. A Trailers-Only response
24022402
/// carrying `grpc-status: 0` in the headers (empty body) is a clean
24032403
/// end.
2404-
pub async fn message(
2405-
&mut self,
2406-
) -> Result<Option<crate::StreamMessage<RespView::Owned>>, ConnectError>
2404+
pub async fn message<M>(&mut self) -> Result<Option<crate::StreamMessage<M>>, ConnectError>
24072405
where
2408-
RespView::Owned: HasMessageView<View<'static> = RespView>,
2406+
// `M` is an output parameter pinned to `RespView`'s owned message —
2407+
// spelled this way round (rather than bounding `RespView::Owned`
2408+
// directly) so the future stays `Send` for concrete generated view
2409+
// types: projecting through the GAT in the bound trips rustc's
2410+
// coroutine-witness auto-trait check (#214).
2411+
RespView: MessageView<'static, Owned = M>,
2412+
M: HasMessageView<View<'static> = RespView>,
24092413
{
24102414
// The outcome is immutable once reported: replay the terminal
24112415
// record without re-entering the body.
@@ -3139,11 +3143,12 @@ where
31393143
/// server error carried in the termination metadata is returned as
31403144
/// `Err`, sticky across calls — see [`ServerStream::message()`] for the
31413145
/// full contract.
3142-
pub async fn message(
3143-
&mut self,
3144-
) -> Result<Option<crate::StreamMessage<RespView::Owned>>, ConnectError>
3146+
pub async fn message<M>(&mut self) -> Result<Option<crate::StreamMessage<M>>, ConnectError>
31453147
where
3146-
RespView::Owned: HasMessageView<View<'static> = RespView>,
3148+
// Same output-parameter shape as `ServerStream::message` — see the
3149+
// bound comment there (#214).
3150+
RespView: MessageView<'static, Owned = M>,
3151+
M: HasMessageView<View<'static> = RespView>,
31473152
{
31483153
// If we already failed during construction or first await, return that.
31493154
if let Some(ref err) = self.construct_err {

tests/streaming/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@ mod tests {
2121

2222
const MSG_DELAY: Duration = Duration::from_millis(100);
2323

24+
/// Regression pin for issue #214: the `message()` futures must be `Send`
25+
/// with CONCRETE generated view types, not just generic parameters — the
26+
/// pre-fix bound shape (projecting `RespView::Owned` in the where-clause)
27+
/// compiled generically but lost `Send` on monomorphization via rustc's
28+
/// coroutine-witness auto-trait check. Compile-time-only assertion; never
29+
/// called.
30+
#[allow(dead_code)]
31+
fn stream_message_futures_are_send<B>(
32+
mut server_stream: connectrpc::client::ServerStream<B, EchoResponseView<'static>>,
33+
mut bidi: connectrpc::client::BidiStream<B, EchoRequest, EchoResponseView<'static>>,
34+
) where
35+
B: connectrpc::http_body::Body<Data = bytes::Bytes> + Send + Unpin + 'static,
36+
B::Error: std::fmt::Display,
37+
{
38+
fn assert_send<T: Send>(_: T) {}
39+
// The async-block wrappers are load-bearing: asserting the bare
40+
// `message()` future is Send passes even with the buggy bound —
41+
// the failure only manifests in the ENCLOSING coroutine's witness,
42+
// i.e. exactly the `tokio::spawn(async move { .. })` shape users hit.
43+
assert_send(async move {
44+
let _ = server_stream.message().await;
45+
});
46+
assert_send(async move {
47+
let _ = bidi.message().await;
48+
});
49+
}
50+
2451
/// Test echo service that echoes messages back with configurable delays.
2552
struct TestEchoService;
2653

0 commit comments

Comments
 (0)