Skip to content

client: add BidiStream::into_split for independently owned halves - #228

Merged
iainmcgin merged 1 commit into
mainfrom
iain/bidi-into-split
Jul 20, 2026
Merged

client: add BidiStream::into_split for independently owned halves#228
iainmcgin merged 1 commit into
mainfrom
iain/bidi-into-split

Conversation

@iainmcgin

@iainmcgin iainmcgin commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds BidiStream::into_split, splitting a bidirectional stream into independently owned halves so the two sides can be driven from separate tasks — true full duplex:

let (mut send, mut recv) = client.running_sum().await?.into_split();
let reader = tokio::spawn(async move {
    while let Some(reply) = recv.message().await? { /* ... */ }
    Ok::<_, connectrpc::ConnectError>(())
});
for req in requests {
    send.send(req).await?;
}
send.close_send();
reader.await.expect("reader task")?;

Builds on #227, now merged, and rebased onto main; additive — no existing API changes, no codegen changes (generated bidi methods still return BidiStream).

Design

BidiStream was already two disjoint sides internally; the split is a plain destructure with no locking added:

  • BidiSendHalf<Req> owns send/close_send (and only needs Req bounds — looser than the combined impl). It carries its own copy of the whole-call deadline. Dropping it ends the request body cleanly; the RPC continues until the receive half finishes.
  • BidiRecvHalf<B, RespView> owns message/headers/trailers/error and the lazy response-initialization state machine. The abort-on-drop of in-flight initialization tasks (client: make BidiStream receive initialization cancellation-safe #221) moves here from BidiStream's Drop — which also frees BidiStream of its Drop impl so into_split can destructure. Dropping this half cancels the RPC, exactly as dropping a whole BidiStream did; a code comment records that the send-before-recv field order is load-bearing for the drop sequence.
  • BidiStream keeps its full API via thin delegation, and into_split sits in an unbounded impl (a pure move needs no bounds). No reunite — documented on both halves.
  • Docs state the HTTP/2 requirement for interleaved use prominently on into_split itself (an HTTP/1.1 caller doing response-dependent sends would deadlock), and steer users toward spawned tasks over naming the halves' body type parameter.

Testing

  • Three new e2e tests over a real gRPC/h2 connection: full-duplex ping-pong with the halves owned by different tasks (each echo received before the next send), dropping the send half ends the stream cleanly, dropping the receive half fails subsequent sends instead of hanging.
  • All 562 all-features unit tests pass unchanged through the delegating methods, including the client: make BidiStream receive initialization cancellation-safe #221 cancellation-safety tests.
  • Live socket drive: split ping-pong over h2 completes in ~50 ms alongside the existing client-stream probes.
  • Conformance client suites re-run: Connect 2580/2580; gRPC shows only the known environment-flaky Timeouts cases (including unary cases this diff does not touch).
  • cargo semver-checks vs 0.8.1: 196/196 (additive). Net non-test size: +177 lines.

@iainmcgin
iainmcgin force-pushed the iain/bidi-into-split branch from bec1838 to 8950ea0 Compare July 17, 2026 22:27
@iainmcgin

Copy link
Copy Markdown
Collaborator Author

[claude code] Restacked onto the current #227 tip (f948aba), which picked up that PR review round. Rebase was clean — no conflicts with the doc and re-export changes there, despite both touching client/mod.rs. Branch is now 8950ea0; task lint and the full workspace suite (56 suites, 0 failures) are green on the restacked branch.

This is now targeted at the 0.9.0 release alongside #227, so it needs to land right after its parent.

Base automatically changed from iain/client-stream-async to main July 20, 2026 19:06
Splits a bidirectional stream into BidiSendHalf (send/close_send, Req
bounds only, its own copy of the call deadline) and BidiRecvHalf
(message/headers/trailers/error plus the lazy response-initialization
state machine), so the two sides can be driven from separate tasks -
true full duplex. The split is a plain destructure: BidiStream was
already two disjoint sides internally, and no locking is added.

The abort-on-drop of in-flight initialization tasks moves from
BidiStream's Drop to BidiRecvHalf's Drop, which also frees BidiStream
of its Drop impl so into_split can destructure; the send-before-recv
field order preserves the drop sequence and is documented as
load-bearing. Dropping the send half ends the request body cleanly
while the RPC continues; dropping the receive half cancels the RPC,
matching the behavior of dropping a whole BidiStream. BidiStream keeps
its full API via thin delegation, so the change is purely additive,
and generated code is unchanged.

Docs state the HTTP/2 requirement for interleaved use on into_split
itself, since a response-dependent send loop deadlocks on HTTP/1.1.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin
iainmcgin force-pushed the iain/bidi-into-split branch from 8950ea0 to 5c24c84 Compare July 20, 2026 19:12
@iainmcgin
iainmcgin marked this pull request as ready for review July 20, 2026 19:12
@iainmcgin
iainmcgin requested a review from lovesegfault July 20, 2026 19:13
@iainmcgin
iainmcgin enabled auto-merge July 20, 2026 19:13
@iainmcgin
iainmcgin added this pull request to the merge queue Jul 20, 2026
Merged via the queue into main with commit 3c711f5 Jul 20, 2026
14 checks passed
@iainmcgin
iainmcgin deleted the iain/bidi-into-split branch July 20, 2026 20:16
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