grpc_webnext_client/tonic_service.rs
1//! tonic interop: drive **generated** client stubs over the tunnel.
2//!
3//! The h2ts path is real HTTP/2, so nothing here translates a protocol — it hands
4//! tonic a [`tower_service::Service`] that moves request bytes out and response
5//! bytes, headers and trailers back. tonic keeps doing what it already does well
6//! (codec, framing, status, compression, interceptors); this crate keeps doing what
7//! tonic cannot do in a browser (dial, tunnel, reconnect). A stub generated by
8//! `tonic-prost-build` runs unmodified:
9//!
10//! ```no_run
11//! # mod pb { pub mod greeter_client { pub struct GreeterClient<T>(T);
12//! # impl<T> GreeterClient<T> { pub fn new(inner: T) -> Self { Self(inner) } } } }
13//! # fn demo(client: grpc_webnext_client::Client) {
14//! use pb::greeter_client::GreeterClient;
15//!
16//! let mut greeter = GreeterClient::new(client.into_tonic());
17//! # let _ = greeter;
18//! # }
19//! ```
20//!
21//! ## The `Send` bound, honestly — and no, it does not mean threads
22//!
23//! tonic's generated stubs require `T::ResponseBody: Send + 'static`, and the engine
24//! underneath is `!Send` on purpose — that is what a browser is, and asserting
25//! otherwise with `unsafe impl` would be a lie the compiler can no longer check.
26//! [`SendWrapper`] is the middle path: it carries the value across the bound and
27//! records the thread that made it, so a value that really does move threads
28//! **panics** at the boundary instead of racing. Natively, keep the client on a
29//! `LocalSet`, which is where a `!Send` client belongs anyway.
30//!
31//! `Send` is a compile-time marker, not a runtime: requiring it links nothing and
32//! spawns nothing. This feature brings **no threading** — a release build of the stub
33//! path has zero atomic instructions, no shared memory, and no spawn symbols, and
34//! `tokio` resolves with `sync` alone, so `tokio::spawn` (behind `rt`) is not even
35//! compiled in. Plain `wasm32-unknown-unknown`, no `+atomics`, is the supported
36//! configuration. Under wasm threads the rule is one client per worker: moving one
37//! across workers panics at the wrapper, deliberately, rather than racing on `Rc`.
38//!
39//! ## Deadlines
40//!
41//! `grpc-timeout` on the outgoing request is also **enforced locally**, covering the
42//! whole call — opening it and every frame of the response body. This is a
43//! deliberate difference from tonic-over-`Channel`, where `Request::set_timeout`
44//! sets the header and only an `Endpoint::timeout` layer enforces anything. The
45//! header is a request to the far end, and a peer that ignores it leaves a tab
46//! waiting forever; see [`CallOptions::timeout`](crate::CallOptions::timeout), which
47//! makes the same promise on the native API.
48
49use std::future::Future;
50use std::pin::Pin;
51use std::task::{Context, Poll};
52use std::time::Duration;
53
54use bytes::Bytes;
55use futures::stream::{LocalBoxStream, StreamExt};
56use h2ts_client::{RequestBody, Trailers};
57use http::{HeaderMap, HeaderName, HeaderValue};
58use http_body::{Body, Frame};
59use send_wrapper::SendWrapper;
60
61use crate::client::Client;
62
63/// A [`Client`] wearing tonic's transport interface, so generated stubs can drive it.
64///
65/// Cheap to clone, and clones share one tunnel — the same contract [`Client`] has,
66/// which is what makes this a stand-in for `tonic::transport::Channel`. Build one
67/// with [`Client::into_tonic`].
68#[derive(Clone)]
69pub struct TonicService {
70 client: Client,
71}
72
73impl std::fmt::Debug for TonicService {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("TonicService").finish_non_exhaustive()
76 }
77}
78
79impl TonicService {
80 pub fn new(client: Client) -> TonicService {
81 TonicService { client }
82 }
83
84 /// The channel underneath, for the things tonic has no vocabulary for —
85 /// [`Client::state`], [`Client::state_changes`], [`Client::is_closed`].
86 pub fn client(&self) -> &Client {
87 &self.client
88 }
89}
90
91impl Client {
92 /// Wrap this channel as a tonic transport, for generated client stubs.
93 pub fn into_tonic(self) -> TonicService {
94 TonicService::new(self)
95 }
96}
97
98impl tower_service::Service<http::Request<tonic::body::Body>> for TonicService {
99 type Response = http::Response<ResponseBody>;
100 /// `tonic::Status` rather than this crate's, because tonic downcasts it back out
101 /// of the boxed error — so a deadline arrives at the caller as DEADLINE_EXCEEDED
102 /// instead of decaying to UNKNOWN with the text in the message.
103 type Error = tonic::Status;
104 /// Deliberately **not** `Send`, and deliberately not wrapped. tonic bounds the
105 /// response *body* with `Send`, never the future — it awaits this inline inside
106 /// `Grpc::streaming`, on whatever executor is polling, which here is one thread.
107 /// Wrapping it would assert something no caller asks for and add a second place a
108 /// cross-thread move could panic, so `ResponseBody` stays the single seam.
109 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
110
111 /// Always ready: there is nothing to reserve. The tunnel is dialed by the call
112 /// itself and shared by every other, so readiness here would be reporting on a
113 /// resource this service does not own.
114 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
115 Poll::Ready(Ok(()))
116 }
117
118 fn call(&mut self, request: http::Request<tonic::body::Body>) -> Self::Future {
119 let client = self.client.clone();
120 Box::pin(send(client, request))
121 }
122}
123
124/// Issue one request and return the response headers plus a body tonic can read.
125async fn send(
126 client: Client,
127 request: http::Request<tonic::body::Body>,
128) -> Result<http::Response<ResponseBody>, tonic::Status> {
129 let (parts, body) = request.into_parts();
130 let path = parts.uri.path().to_string();
131 let timeout = grpc_timeout(&parts.headers);
132 let headers = to_wire_headers(&parts.headers)?;
133 let body = RequestBody::stream(request_chunks(body));
134
135 // The deadline covers the whole call, so the timer that bounds the open is the
136 // same one handed to the body afterwards — `select` returns the loser untouched,
137 // so it carries its *remaining* time rather than restarting per phase. It is
138 // created once and never rechecked against a clock, because
139 // `std::time::Instant::now()` panics on `wasm32-unknown-unknown`.
140 let (response, deadline) = match timeout {
141 None => (client.send(&path, headers, body).await?, None),
142 Some(timeout) => {
143 use futures::future::{select, Either};
144 let mut timer = futures_timer::Delay::new(timeout);
145 let opened = {
146 let open = client.send(&path, headers, body);
147 futures::pin_mut!(open);
148 match select(open, &mut timer).await {
149 Either::Left((response, _)) => Some(response),
150 Either::Right(((), _)) => None,
151 }
152 };
153 match opened {
154 Some(response) => (response?, Some(timer)),
155 None => return Err(tonic::Status::deadline_exceeded("deadline exceeded")),
156 }
157 }
158 };
159
160 let mut builder = http::Response::builder().status(response.status);
161 // `raw_headers` rather than the collapsed map: gRPC metadata is multi-valued, and
162 // a `HashMap<String, String>` keeps only one of a repeated key.
163 for header in &response.raw_headers {
164 // Pseudo-headers are HTTP/2's, not HTTP's — `:status` is already the status.
165 if header.name.starts_with(':') {
166 continue;
167 }
168 builder = builder.header(&header.name, &header.value);
169 }
170 let (body, trailers) = response.into_parts();
171 builder
172 .body(ResponseBody::new(body.boxed_local(), trailers, deadline))
173 .map_err(|e| tonic::Status::internal(format!("malformed response headers: {e}")))
174}
175
176/// The request body as chunks h2ts can upload.
177///
178/// tonic has already framed each message (`[compressed flag | u32 len | bytes]`), so
179/// this is a byte pass-through and not a re-encode. Request trailers are dropped: a
180/// gRPC *client* never sends any, and half-close is the end of the stream.
181fn request_chunks(body: tonic::body::Body) -> impl futures::Stream<Item = Vec<u8>> {
182 futures::stream::unfold(Box::pin(body), |mut body| async move {
183 loop {
184 let frame = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await;
185 match frame {
186 Some(Ok(frame)) => match frame.into_data() {
187 Ok(data) => return Some((data.to_vec(), body)),
188 Err(_trailers) => continue,
189 },
190 // Ending the body here half-closes the stream mid-message, which the
191 // server reports as a truncated request — the honest outcome, since
192 // there is no way to signal "this body failed" over HTTP/2 except by
193 // resetting, and that would lose the server's own status.
194 Some(Err(_)) | None => return None,
195 }
196 }
197 })
198}
199
200/// The request's headers, as h2ts wants them.
201///
202/// tonic has already set `content-type`, `te`, `user-agent` and the compression
203/// headers, and sanitized the connection-specific ones HTTP/2 forbids, so this is a
204/// conversion and not a policy.
205fn to_wire_headers(headers: &HeaderMap) -> Result<Vec<(String, String)>, tonic::Status> {
206 headers
207 .iter()
208 .map(|(name, value)| {
209 // gRPC restricts ASCII metadata to printable ASCII and requires `-bin`
210 // values to be base64, so anything else is already off-spec — and h2ts
211 // header values are `String`. Refusing beats sending mojibake.
212 let value = value.to_str().map_err(|_| {
213 tonic::Status::internal(format!(
214 "metadata value for `{name}` is not valid ASCII; \
215 binary metadata must use a `-bin` key"
216 ))
217 })?;
218 Ok((name.as_str().to_string(), value.to_string()))
219 })
220 .collect()
221}
222
223/// Parse `grpc-timeout` (a positive integer plus a unit) into a duration. Anything
224/// unparseable is `None`: a malformed header means the call is unbounded locally, not
225/// that it fails, since the value is the peer's to enforce in the first place.
226fn grpc_timeout(headers: &HeaderMap) -> Option<Duration> {
227 let raw = headers.get("grpc-timeout")?.to_str().ok()?;
228 let (digits, unit) = raw.split_at_checked(raw.len().checked_sub(1)?)?;
229 let value: u64 = digits.parse().ok()?;
230 Some(match unit {
231 "n" => Duration::from_nanos(value),
232 "u" => Duration::from_micros(value),
233 "m" => Duration::from_millis(value),
234 "S" => Duration::from_secs(value),
235 "M" => Duration::from_secs(value.checked_mul(60)?),
236 "H" => Duration::from_secs(value.checked_mul(3600)?),
237 _ => return None,
238 })
239}
240
241fn to_header_map(headers: std::collections::HashMap<String, String>) -> HeaderMap {
242 let mut map = HeaderMap::with_capacity(headers.len());
243 for (name, value) in headers {
244 // A header the `http` crate rejects cannot be represented; dropping it beats
245 // failing the call, since the status itself is what tonic reads out of here
246 // and a malformed *other* trailer should not bury it.
247 if let (Ok(name), Ok(value)) =
248 (HeaderName::try_from(name), HeaderValue::try_from(value))
249 {
250 map.append(name, value);
251 }
252 }
253 map
254}
255
256/// The response body, as an [`http_body::Body`] tonic can decode.
257///
258/// Data frames pass through untouched; the terminal trailers become the final frame,
259/// which is where tonic reads `grpc-status` from. Backpressure is preserved for free:
260/// the window is replenished only as this is polled, so tonic's own consumption rate
261/// is what throttles the server.
262pub struct ResponseBody(SendWrapper<Inner>);
263
264struct Inner {
265 body: LocalBoxStream<'static, Result<Vec<u8>, h2ts_client::H2Error>>,
266 trailers: Trailers,
267 /// The call's deadline, still running from before the response opened.
268 deadline: Option<futures_timer::Delay>,
269 ended: bool,
270}
271
272impl std::fmt::Debug for ResponseBody {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.debug_struct("ResponseBody").field("ended", &self.0.ended).finish_non_exhaustive()
275 }
276}
277
278impl ResponseBody {
279 fn new(
280 body: LocalBoxStream<'static, Result<Vec<u8>, h2ts_client::H2Error>>,
281 trailers: Trailers,
282 deadline: Option<futures_timer::Delay>,
283 ) -> ResponseBody {
284 ResponseBody(SendWrapper::new(Inner { body, trailers, deadline, ended: false }))
285 }
286}
287
288impl Body for ResponseBody {
289 type Data = Bytes;
290 type Error = tonic::Status;
291
292 fn poll_frame(
293 self: Pin<&mut Self>,
294 cx: &mut Context<'_>,
295 ) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
296 // Deref through the wrapper: this is the thread check, so a body that
297 // travelled panics here rather than touching `Rc`s from two threads.
298 let inner = &mut *self.get_mut().0;
299 if inner.ended {
300 return Poll::Ready(None);
301 }
302 // The deadline is checked before the body, because a server that sends
303 // headers promptly and then stalls is exactly the case it exists for.
304 if let Some(timer) = inner.deadline.as_mut() {
305 if Pin::new(timer).poll(cx).is_ready() {
306 inner.ended = true;
307 return Poll::Ready(Some(Err(tonic::Status::deadline_exceeded(
308 "deadline exceeded",
309 ))));
310 }
311 }
312 match inner.body.poll_next_unpin(cx) {
313 Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(Frame::data(Bytes::from(chunk))))),
314 Poll::Ready(Some(Err(e))) => {
315 inner.ended = true;
316 Poll::Ready(Some(Err(tonic::Status::unavailable(format!(
317 "stream failed: {e}"
318 )))))
319 }
320 Poll::Ready(None) => {
321 inner.ended = true;
322 // The status lives in the trailers. A body that ends without any is a
323 // protocol violation, and tonic says so — better it than a guess here.
324 match inner.trailers.get() {
325 Some(trailers) => {
326 Poll::Ready(Some(Ok(Frame::trailers(to_header_map(trailers)))))
327 }
328 None => Poll::Ready(None),
329 }
330 }
331 Poll::Pending => Poll::Pending,
332 }
333 }
334
335 fn is_end_stream(&self) -> bool {
336 self.0.ended
337 }
338}
339
340impl From<crate::Status> for tonic::Status {
341 fn from(status: crate::Status) -> tonic::Status {
342 let mut headers = HeaderMap::new();
343 for (name, value) in status.metadata.to_headers() {
344 if let (Ok(name), Ok(value)) =
345 (HeaderName::try_from(name), HeaderValue::try_from(value))
346 {
347 headers.append(name, value);
348 }
349 }
350 tonic::Status::with_metadata(
351 tonic::Code::from_i32(status.code as i32),
352 status.message,
353 tonic::metadata::MetadataMap::from_headers(headers),
354 )
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
363 let mut map = HeaderMap::new();
364 for (name, value) in pairs {
365 map.append(
366 HeaderName::try_from(*name).unwrap(),
367 HeaderValue::try_from(*value).unwrap(),
368 );
369 }
370 map
371 }
372
373 #[test]
374 fn every_grpc_timeout_unit_is_understood() {
375 // tonic picks the most precise unit that fits in 8 digits, so `n` and `u` are
376 // the ones it actually emits — but a peer may send any of them, and a unit we
377 // silently misread would be a deadline off by three orders of magnitude.
378 for (raw, expected) in [
379 ("100n", Duration::from_nanos(100)),
380 ("100u", Duration::from_micros(100)),
381 ("100m", Duration::from_millis(100)),
382 ("2S", Duration::from_secs(2)),
383 ("2M", Duration::from_secs(120)),
384 ("2H", Duration::from_secs(7200)),
385 ] {
386 assert_eq!(grpc_timeout(&headers(&[("grpc-timeout", raw)])), Some(expected), "{raw}");
387 }
388 }
389
390 #[test]
391 fn a_missing_or_malformed_timeout_leaves_the_call_unbounded() {
392 // Unbounded rather than failed: the header is advisory to begin with, so a
393 // value this client cannot read is the peer's problem, not the call's.
394 assert_eq!(grpc_timeout(&headers(&[])), None);
395 for raw in ["", "m", "100", "100x", "-1S", "abcS"] {
396 assert_eq!(grpc_timeout(&headers(&[("grpc-timeout", raw)])), None, "{raw:?}");
397 }
398 }
399
400 #[test]
401 fn tonics_own_encoding_round_trips() {
402 // tonic writes `Request::set_timeout` as microseconds up to 8 digits; the pair
403 // has to agree or every deadline set through a generated stub is wrong.
404 assert_eq!(
405 grpc_timeout(&headers(&[("grpc-timeout", "500000u")])),
406 Some(Duration::from_millis(500))
407 );
408 }
409
410 #[test]
411 fn headers_convert_and_binary_metadata_survives_as_base64() {
412 let mut metadata = tonic::metadata::MetadataMap::new();
413 metadata.insert("x-request-id", "abc-123".parse().unwrap());
414 metadata.insert_bin(
415 "x-trace-bin",
416 tonic::metadata::MetadataValue::from_bytes(&[0, 1, 250]),
417 );
418 let wire = to_wire_headers(&metadata.into_headers()).unwrap();
419
420 assert!(wire.contains(&("x-request-id".to_string(), "abc-123".to_string())));
421 // tonic base64s a `-bin` value on the way in, so it is already ASCII here —
422 // this crate must not encode it a second time.
423 let (_, encoded) = wire.iter().find(|(k, _)| k == "x-trace-bin").expect("the -bin key");
424 use base64::Engine as _;
425 assert_eq!(
426 base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded).unwrap(),
427 vec![0, 1, 250]
428 );
429 }
430
431 #[test]
432 fn a_non_ascii_metadata_value_is_refused_rather_than_mangled() {
433 let mut map = HeaderMap::new();
434 map.append("x-bad", HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap());
435 let error = to_wire_headers(&map).expect_err("not representable on the wire");
436 assert_eq!(error.code(), tonic::Code::Internal);
437 assert!(error.message().contains("x-bad"), "unhelpful message: {}", error.message());
438 }
439
440 #[test]
441 fn a_status_carries_its_code_message_and_metadata_into_tonic() {
442 let mut metadata = crate::Metadata::new();
443 metadata.insert("x-detail", "quota-exhausted");
444 metadata.insert_bin("x-detail-bin", vec![0, 1, 250]);
445 let status = tonic::Status::from(crate::Status {
446 code: crate::Code::FailedPrecondition,
447 message: "no".into(),
448 metadata,
449 });
450
451 assert_eq!(status.code(), tonic::Code::FailedPrecondition);
452 assert_eq!(status.message(), "no");
453 assert_eq!(status.metadata().get("x-detail").unwrap(), "quota-exhausted");
454 assert_eq!(
455 status.metadata().get_bin("x-detail-bin").unwrap().to_bytes().unwrap().as_ref(),
456 &[0, 1, 250]
457 );
458 }
459}