Skip to content

refactor(grpc): Use gRPC metadata instead of tonic metadata#2629

Merged
arjan-bal merged 15 commits into
hyperium:masterfrom
arjan-bal:use-grpc-metadata
May 7, 2026
Merged

refactor(grpc): Use gRPC metadata instead of tonic metadata#2629
arjan-bal merged 15 commits into
hyperium:masterfrom
arjan-bal:use-grpc-metadata

Conversation

@arjan-bal
Copy link
Copy Markdown
Collaborator

@arjan-bal arjan-bal commented May 5, 2026

This PR migrates the gRPC crates to use the gRPC MetadataMap introduced in #2567, replacing the dependency on tonic types.

This PR also includes the following fixes:

  • Explicit failure on invalid binary metadata: Converting http::HeaderMap to the gRPC MetadataMap now fails explicitly when encountering invalid base64-encoded binary metadata, rather than silently dropping the values. The tonic transport now returns an INTERNAL status in these cases to ensure consistency with other gRPC implementations.
  • Stream cancellation: The HTTP/2 stream is now cancelled whenever message or header decoding fails, or when the application drops the RecvStream.

@arjan-bal arjan-bal requested a review from dfawley May 5, 2026 20:31
Comment thread grpc/src/client/transport/tonic/mod.rs Outdated
Comment on lines 205 to 214
let trailers = match md.map(TryInto::try_into) {
Some(Err(e)) => Trailers::new(Err(StatusError::new(
StatusCodeError::Internal,
format!("failed to parse metadata: {e}"),
))),
Some(Ok(metadata)) => Trailers::new(status_res).with_metadata(metadata),
None => Trailers::new(status_res),
};

ClientResponseStreamItem::Trailers(trailers)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can this just call trailers_from_status to do all of this part?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that removed the redundant error handling. Updated.

Comment thread grpc/src/client/transport/tonic/mod.rs Outdated
Comment on lines +290 to +293
// TODO: in this case, tonic believes the stream is
// still running, but our parsing failed -- do we need
// to terminate the request stream now even though the
// Streaming is dropped?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's answer this before merging this PR if we can.

Copy link
Copy Markdown
Collaborator Author

@arjan-bal arjan-bal May 6, 2026

Choose a reason for hiding this comment

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

I initially assumed that either Tonic, Hyper, or h2 would automatically close the underlying HTTP/2 stream when the client drops the inbound stream. However, this is not the case.

I verified this by modifying the route_chat client in Tonic's routeguide example and running a Go gRPC server to log all HTTP/2 frames:

Modified route_chat client
async fn run_route_chat(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
    let start = time::Instant::now();

    let outbound = async_stream::stream! {
        let mut interval = time::interval(Duration::from_secs(1));

        loop {
            let time = interval.tick().await;
            let elapsed = time.duration_since(start);
            let note = RouteNote {
                location: Some(Point {
                    latitude: 409146138 + elapsed.as_secs() as i32,
                    longitude: -746188906,
                }),
                message: format!("at {elapsed:?}"),
            };
            println!("Sending message: {:?}", note.clone());

            yield note;
        }
    };

    let response = client.route_chat(Request::new(outbound)).await?;
    let mut inbound = response.into_inner();
    
    // Drop the inbound stream immediately
    drop(inbound);

    tokio::time::sleep(std::time::Duration::from_secs(60)).await;

    Ok(())
}

Despite dropping inbound, the outbound stream continued to be polled by Hyper/h2, and the Go server continued logging incoming DATA frames.


I updated the Tonic transport to explicitly close the outbound stream when it fails to decode response messages or headers on the inbound stream. With this change, the Go server receives a RST_STREAM frame with code CANCELLED once decoding fails on the client.

To implement this without introducing a mutex or spawning an additional background task, I used the take_until stream combinator from the futures crate.

Update: Also ensured dropping TonicRecvStream results in stream cancellation.

@dfawley dfawley assigned arjan-bal and unassigned dfawley May 5, 2026
@arjan-bal arjan-bal assigned dfawley and unassigned arjan-bal May 6, 2026
Comment thread grpc/src/client/transport/tonic/mod.rs Outdated
// stream to yield `None`, which tells Tonic to cancel the stream.
let stop_notify_clone = stop_notify.clone();
let request_stream = ReceiverStream::new(req_rx)
.take_until(Box::pin(async move { stop_notify_clone.notified().await }));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would this work?

.take_until(Box::pin(stop_notify_clone.notified()));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Because notified() takes &self, the async block was originally needed to take ownership of the Notify instance and ensure it lived long enough. Tokio's notified_owned() method handles this by taking ownership directly, which cleanly removes the need for the async wrapper. I've updated the code to use it, and since Box::pin was also unnecessary, I've removed that as well.

Comment thread grpc/src/client/transport/tonic/mod.rs Outdated

impl Drop for TonicRecvStream {
fn drop(&mut self) {
if let Some(notify) = self.stop_notify.take() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I believe this take is unnecessary, but it's probably not significant so feel free to not change it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, removed the take.

@dfawley dfawley assigned arjan-bal and unassigned dfawley May 6, 2026
@arjan-bal arjan-bal merged commit 6b885b0 into hyperium:master May 7, 2026
21 checks passed
@arjan-bal arjan-bal deleted the use-grpc-metadata branch May 7, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants