connectrpc/payload.rs
1//! Type-erased, lazily-decoded RPC message bodies.
2//!
3//! Interceptors run on every RPC and most of them never look inside the
4//! request or response message — they read the [`Spec`](crate::Spec),
5//! the headers, or the deadline and pass the call through. Decoding the
6//! message eagerly would tax every call to pay for the rare interceptor
7//! that inspects fields.
8//!
9//! [`Payload`] solves this by holding the wire bytes (always available,
10//! reference-counted) and decoding to a typed message on first access.
11//! Interceptors that want a typed message call [`Payload::message`]
12//! (owned, works for both proto and JSON wires) or [`Payload::view`]
13//! (zero-copy, proto only). Interceptors that want to *replace* the
14//! message call [`Payload::set_message`]; the dispatch path re-encodes
15//! the replacement on the way out.
16//!
17//! [`AnyMessage`] is the object-safe surface that lets a `Payload` cache
18//! a decoded message without knowing its concrete type. It has a blanket
19//! impl over every protobuf message, so user code never implements it
20//! directly.
21
22use std::any::Any;
23use std::fmt;
24use std::sync::OnceLock;
25
26use buffa::Message;
27use buffa::view::{MessageView, OwnedView};
28use bytes::Bytes;
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use crate::codec::{CodecFormat, decode_json, decode_proto, encode_json, encode_proto};
33use crate::error::ConnectError;
34
35/// Object-safe, type-erased RPC message.
36///
37/// `AnyMessage` is what a [`Payload`] caches once it has decoded its wire
38/// bytes — a `Box<dyn AnyMessage>` that can be downcast back to the
39/// concrete request/response type and re-encoded for the wire if an
40/// interceptor swaps it.
41///
42/// You will almost never implement this trait directly. A blanket
43/// implementation covers every type that is `Message + Serialize`, which
44/// includes every owned message the code generator emits. A manual impl
45/// must uphold a round-trip invariant: bytes returned by
46/// [`encode`](AnyMessage::encode)`(format)` must decode back to an
47/// equivalent value in that same format — the dispatch path relies on it
48/// when re-encoding a replacement set via [`Payload::set_message`].
49/// Violating it does not panic or error — [`Payload::encoded`] silently
50/// produces wrong-shape bytes — so a manual impl must be tested for the
51/// round-trip explicitly.
52pub trait AnyMessage: Send + Sync + 'static {
53 /// Borrow the message as `dyn Any` for downcasting.
54 fn as_any(&self) -> &dyn Any;
55 /// Mutably borrow the message as `dyn Any` for downcasting.
56 fn as_any_mut(&mut self) -> &mut dyn Any;
57 /// Convert the boxed message into a `Box<dyn Any>` for owned downcasting.
58 ///
59 /// [`Payload::take_message`] uses this to move the cached decode out
60 /// of the `Payload` and into the handler without a clone.
61 fn into_any(self: Box<Self>) -> Box<dyn Any>;
62 /// Serialize the message to wire bytes in the given format.
63 ///
64 /// # Errors
65 ///
66 /// Returns an error if encoding fails. Proto encoding is infallible
67 /// for valid messages; JSON encoding can fail on non-UTF-8 `bytes`
68 /// fields and similar serde edge cases.
69 fn encode(&self, format: CodecFormat) -> Result<Bytes, ConnectError>;
70 /// The concrete type's name, for diagnostics. The default uses
71 /// [`std::any::type_name`].
72 fn type_name(&self) -> &'static str {
73 std::any::type_name::<Self>()
74 }
75}
76
77impl<T> AnyMessage for T
78where
79 // Message already requires Send + Sync as supertraits.
80 T: Message + Serialize + 'static,
81{
82 fn as_any(&self) -> &dyn Any {
83 self
84 }
85 fn as_any_mut(&mut self) -> &mut dyn Any {
86 self
87 }
88 fn into_any(self: Box<Self>) -> Box<dyn Any> {
89 self
90 }
91 fn encode(&self, format: CodecFormat) -> Result<Bytes, ConnectError> {
92 match format {
93 CodecFormat::Proto => encode_proto(self),
94 CodecFormat::Json => encode_json(self),
95 }
96 }
97}
98
99/// A lazily-decoded, replaceable RPC message body.
100///
101/// A `Payload` always holds the wire-encoded body bytes ([`Bytes`], so
102/// clones are reference-counted) and the [`CodecFormat`] they came in.
103/// Typed access happens on demand:
104///
105/// - [`message`](Payload::message) — decode once into an owned message,
106/// cache it, return a borrow. Works for both `Proto` and `Json` wires.
107/// - [`view`](Payload::view) — zero-copy view borrowing the wire bytes
108/// directly. `Proto` wires only; returns an error for `Json`.
109/// - [`set_message`](Payload::set_message) — replace the body. The
110/// replacement takes priority for all subsequent reads, and the
111/// dispatch path re-encodes it on the way out.
112/// - [`encoded`](Payload::encoded) — the wire bytes that will actually be
113/// sent: either the original `bytes` or the re-encoded replacement.
114///
115/// `Payload` is normally constructed by the dispatch path and received
116/// by user code through [`UnaryRequest`](crate::UnaryRequest) and
117/// [`UnaryResponse`](crate::UnaryResponse). [`Payload::new`] is `pub`
118/// so test fixtures and custom transports can build one directly.
119///
120/// `message` borrows the cached owned decode; `view` returns a fresh
121/// self-contained [`OwnedView`] (a [`Bytes`] refcount bump, not a copy)
122/// because a zero-copy view cannot be stored in the type-erased cache.
123///
124/// `Payload` is intentionally not `Clone`: a clone would either drop the
125/// decode cache (surprising) or duplicate it (defeating the laziness).
126/// Pass it by reference, or move it through the call chain.
127pub struct Payload {
128 bytes: Bytes,
129 format: CodecFormat,
130 decoded: OnceLock<Box<dyn AnyMessage>>,
131 replaced: Option<Box<dyn AnyMessage>>,
132}
133
134impl Payload {
135 /// Wrap wire bytes in a `Payload`. No decoding happens until a typed
136 /// accessor is called.
137 pub fn new(bytes: Bytes, format: CodecFormat) -> Self {
138 Self {
139 bytes,
140 format,
141 decoded: OnceLock::new(),
142 replaced: None,
143 }
144 }
145
146 /// The original wire bytes the peer sent, **ignoring** any
147 /// replacement set with [`set_message`](Payload::set_message). For
148 /// the bytes the dispatch path will actually send downstream, use
149 /// [`encoded()`](Payload::encoded).
150 pub fn bytes(&self) -> &Bytes {
151 &self.bytes
152 }
153
154 /// The codec format the wire bytes are encoded in.
155 pub fn format(&self) -> CodecFormat {
156 self.format
157 }
158
159 /// Decode and cache the body as an owned `M`, returning a borrow.
160 ///
161 /// The decode runs at most once per `Payload`; subsequent calls
162 /// return the cached value. If a replacement has been set with
163 /// [`set_message`](Payload::set_message), that is returned instead.
164 ///
165 /// # Errors
166 ///
167 /// - [`invalid_argument`](ConnectError::invalid_argument) if the wire
168 /// bytes fail to decode as `M` — peer-supplied data, not a server
169 /// bug.
170 /// - [`internal`](ConnectError::internal) if a replacement set with
171 /// [`set_message`](Payload::set_message) is not an `M`, or if a
172 /// prior `message::<N>()` cached a different type than `M` — both
173 /// are server-side programming errors (interceptors and handlers
174 /// for the same RPC must agree on the message types), and the cache
175 /// holds whichever type decoded first.
176 pub fn message<M>(&self) -> Result<&M, ConnectError>
177 where
178 M: Message + Serialize + DeserializeOwned + 'static,
179 {
180 if let Some(replaced) = &self.replaced {
181 return replaced.as_any().downcast_ref::<M>().ok_or_else(|| {
182 ConnectError::internal(format!(
183 "payload replacement is a {}, not a {}",
184 replaced.type_name(),
185 std::any::type_name::<M>()
186 ))
187 });
188 }
189 // `get_or_try_init` is unstable, so probe-then-set. Two threads
190 // racing decode the same bytes; only one `set` wins and the loser
191 // discards its copy. With the same `M` (the normal case), the loser
192 // still returns the winner's cached value. With *different* `M`
193 // (a caller-side type bug), only the winner's type is cached and
194 // the other caller gets the wrong-type error below.
195 if self.decoded.get().is_none() {
196 let m: M = match self.format {
197 CodecFormat::Proto => decode_proto(&self.bytes)?,
198 CodecFormat::Json => decode_json(&self.bytes)?,
199 };
200 let _ = self.decoded.set(Box::new(m));
201 }
202 // The `set` above (or a concurrent winner's) guarantees the cell is
203 // now populated. The downcast can still miss if a prior call cached
204 // a different `M`; that's a caller-side type mismatch, not a panic.
205 let cached = self.decoded.get().expect("decoded cell populated above");
206 cached.as_any().downcast_ref::<M>().ok_or_else(|| {
207 ConnectError::internal(format!(
208 "payload was previously decoded as a {}, not a {}",
209 cached.type_name(),
210 std::any::type_name::<M>()
211 ))
212 })
213 }
214
215 /// Decode the body into an owned `M`, consuming `self`.
216 ///
217 /// If the body was already decoded — an interceptor called
218 /// [`message`](Payload::message) — and the cached value is an `M`,
219 /// it is moved out: no second decode, no clone. If a replacement was
220 /// set with [`set_message`](Payload::set_message), it is moved out
221 /// instead. Otherwise the wire bytes are decoded fresh.
222 ///
223 /// The dispatch path uses this to hand the request to the handler
224 /// without re-decoding bytes an interceptor already decoded. Because
225 /// it consumes the `Payload`, it must be the last access — call
226 /// [`message`](Payload::message) (which caches a borrow) for repeated
227 /// reads.
228 ///
229 /// # Errors
230 ///
231 /// The same error contract as [`message`](Payload::message), with one
232 /// behavioral difference worth noting: a wrong-typed cache that an
233 /// interceptor created is now an error *for the handler*. Before
234 /// `take_message`, the handler decoded the wire bytes independently
235 /// and never saw the interceptor's cache, so an interceptor that
236 /// decoded as the wrong `M` failed silently. Surfacing it loudly is
237 /// the intent — interceptors and handlers for the same RPC must agree
238 /// on the message types.
239 ///
240 /// - [`invalid_argument`](ConnectError::invalid_argument) if there is
241 /// no cache and the wire bytes fail to decode as `M` — peer-supplied
242 /// data, not a server bug.
243 /// - [`internal`](ConnectError::internal) if a cached decode or a
244 /// replacement set with [`set_message`](Payload::set_message) is not
245 /// an `M` — a server-side type bug.
246 pub fn take_message<M>(self) -> Result<M, ConnectError>
247 where
248 // Unlike `message()`, this never *populates* the cache, so it
249 // does not need `M: Serialize` (the bound `message()` carries to
250 // box `M` as `dyn AnyMessage`). It only reads the cache or
251 // decodes fresh.
252 M: Message + DeserializeOwned + 'static,
253 {
254 if let Some(replaced) = self.replaced {
255 let type_name = replaced.type_name();
256 return replaced
257 .into_any()
258 .downcast::<M>()
259 .map(|b| *b)
260 .map_err(|_| {
261 ConnectError::internal(format!(
262 "payload replacement is a {}, not a {}",
263 type_name,
264 std::any::type_name::<M>()
265 ))
266 });
267 }
268 if let Some(cached) = self.decoded.into_inner() {
269 let type_name = cached.type_name();
270 return cached.into_any().downcast::<M>().map(|b| *b).map_err(|_| {
271 ConnectError::internal(format!(
272 "payload was previously decoded as a {}, not a {}",
273 type_name,
274 std::any::type_name::<M>()
275 ))
276 });
277 }
278 match self.format {
279 CodecFormat::Proto => decode_proto(&self.bytes),
280 CodecFormat::Json => decode_json(&self.bytes),
281 }
282 }
283
284 /// Decode the body as a zero-copy [`OwnedView`].
285 ///
286 /// Borrows directly from the wire bytes — no copy, no allocation
287 /// beyond the [`Bytes`] refcount bump. If a replacement has been set
288 /// with [`set_message`](Payload::set_message), it is encoded to proto
289 /// (regardless of [`format`](Payload::format)) and decoded as a view
290 /// — note this re-encodes on **every** call (unlike
291 /// [`message`](Payload::message), there is no cache for views). Hold
292 /// onto the returned `OwnedView` rather than re-fetching in a loop.
293 ///
294 /// # Errors
295 ///
296 /// - [`internal`](ConnectError::internal) for JSON-encoded wires:
297 /// JSON cannot back a zero-copy proto view. This is a server-side
298 /// programming error and escapes to the peer as a 500 if uncaught —
299 /// branch on [`format()`](Payload::format) and call
300 /// [`message()`](Payload::message) for JSON wires instead.
301 /// - [`invalid_argument`](ConnectError::invalid_argument) if the wire
302 /// bytes fail to decode as `V` — peer-supplied data, not a server
303 /// bug.
304 /// - [`internal`](ConnectError::internal) if a replacement set with
305 /// [`set_message`](Payload::set_message) fails to re-encode or
306 /// decode as `V` — server-supplied data, so the asymmetry with the
307 /// wire-bytes case is intentional.
308 pub fn view<V>(&self) -> Result<OwnedView<V>, ConnectError>
309 where
310 V: MessageView<'static>,
311 {
312 if let Some(replaced) = &self.replaced {
313 let bytes = replaced.encode(CodecFormat::Proto)?;
314 return OwnedView::decode(bytes).map_err(|e| {
315 ConnectError::internal(format!("failed to decode replacement as view: {e}"))
316 });
317 }
318 if self.format != CodecFormat::Proto {
319 return Err(ConnectError::internal(
320 "Payload::view requires a proto-encoded wire; use Payload::message for JSON",
321 ));
322 }
323 OwnedView::decode(self.bytes.clone()).map_err(|e| {
324 ConnectError::invalid_argument(format!("failed to decode payload as view: {e}"))
325 })
326 }
327
328 /// Replace the body with a new message.
329 ///
330 /// Subsequent [`message`](Payload::message) and
331 /// [`view`](Payload::view) calls return the replacement, and
332 /// [`encoded`](Payload::encoded) re-encodes it for the wire.
333 pub fn set_message<M>(&mut self, message: M)
334 where
335 M: AnyMessage,
336 {
337 self.replaced = Some(Box::new(message));
338 // Drop the prior decode cache so the original message doesn't pin
339 // memory for the Payload's lifetime. `replaced` is checked first,
340 // so a stale cache would never be visible — this is purely a
341 // memory concern.
342 if self.decoded.get().is_some() {
343 self.decoded = OnceLock::new();
344 }
345 }
346
347 /// The wire bytes the dispatch path should actually send.
348 ///
349 /// Returns the original `bytes` (a cheap [`Bytes`] clone) unless a
350 /// replacement was set with [`set_message`](Payload::set_message),
351 /// in which case the replacement is re-encoded in the original
352 /// [`format`](Payload::format).
353 ///
354 /// # Errors
355 ///
356 /// Returns an error if re-encoding a replacement fails.
357 pub fn encoded(&self) -> Result<Bytes, ConnectError> {
358 match &self.replaced {
359 Some(r) => r.encode(self.format),
360 None => Ok(self.bytes.clone()),
361 }
362 }
363}
364
365impl fmt::Debug for Payload {
366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367 f.debug_struct("Payload")
368 .field("len", &self.bytes.len())
369 .field("format", &self.format)
370 .field("decoded", &self.decoded.get().is_some())
371 .field("replaced", &self.replaced.is_some())
372 .finish()
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use buffa_types::google::protobuf::__buffa::view::StringValueView;
380 use buffa_types::google::protobuf::StringValue;
381
382 fn proto_payload(value: &str) -> Payload {
383 let msg = StringValue {
384 value: value.into(),
385 ..Default::default()
386 };
387 Payload::new(encode_proto(&msg).unwrap(), CodecFormat::Proto)
388 }
389
390 #[test]
391 fn message_decodes_and_caches() {
392 let p = proto_payload("hello");
393 let m1: &StringValue = p.message().unwrap();
394 assert_eq!(m1.value, "hello");
395 // Second call returns the same cached value (same address).
396 let m2: &StringValue = p.message().unwrap();
397 assert!(std::ptr::eq(m1, m2), "second call should hit the cache");
398 }
399
400 #[test]
401 fn message_decodes_json() {
402 let bytes = encode_json(&StringValue {
403 value: "json".into(),
404 ..Default::default()
405 })
406 .unwrap();
407 let p = Payload::new(bytes, CodecFormat::Json);
408 let m: &StringValue = p.message().unwrap();
409 assert_eq!(m.value, "json");
410 }
411
412 #[test]
413 fn view_zero_copy_proto() {
414 let p = proto_payload("zero copy");
415 let v = p.view::<StringValueView>().unwrap();
416 assert_eq!(v.value, "zero copy");
417 // Borrows the payload's bytes — same backing storage.
418 let value_ptr = v.value.as_ptr() as usize;
419 let bytes_range =
420 p.bytes().as_ptr() as usize..p.bytes().as_ptr() as usize + p.bytes().len();
421 assert!(
422 bytes_range.contains(&value_ptr),
423 "view should borrow from the payload's wire bytes"
424 );
425 }
426
427 #[test]
428 fn view_errors_on_json() {
429 let bytes = encode_json(&StringValue {
430 value: "x".into(),
431 ..Default::default()
432 })
433 .unwrap();
434 let p = Payload::new(bytes, CodecFormat::Json);
435 let err = p.view::<StringValueView>().unwrap_err();
436 assert!(
437 err.message
438 .as_deref()
439 .unwrap_or_default()
440 .contains("requires a proto-encoded wire"),
441 "{err:?}"
442 );
443 }
444
445 #[test]
446 fn set_message_round_trips() {
447 let mut p = proto_payload("before");
448 p.set_message(StringValue {
449 value: "after".into(),
450 ..Default::default()
451 });
452 // message() returns the replacement.
453 let m: &StringValue = p.message().unwrap();
454 assert_eq!(m.value, "after");
455 // view() re-encodes the replacement and views it.
456 let v = p.view::<StringValueView>().unwrap();
457 assert_eq!(v.value, "after");
458 // encoded() re-encodes for the original format.
459 let encoded = p.encoded().unwrap();
460 let rt: StringValue = decode_proto(&encoded).unwrap();
461 assert_eq!(rt.value, "after");
462 // bytes() is unchanged.
463 let orig: StringValue = decode_proto(p.bytes()).unwrap();
464 assert_eq!(orig.value, "before");
465 }
466
467 #[test]
468 fn set_message_round_trips_json_format() {
469 let bytes = encode_json(&StringValue {
470 value: "before".into(),
471 ..Default::default()
472 })
473 .unwrap();
474 let mut p = Payload::new(bytes, CodecFormat::Json);
475 p.set_message(StringValue {
476 value: "after".into(),
477 ..Default::default()
478 });
479 // encoded() re-encodes in the original (JSON) format.
480 let encoded = p.encoded().unwrap();
481 let rt: StringValue = decode_json(&encoded).unwrap();
482 assert_eq!(rt.value, "after");
483 }
484
485 #[test]
486 fn encoded_without_replacement_returns_original() {
487 let p = proto_payload("x");
488 // Same backing storage — refcounted Bytes clone, not a copy.
489 assert!(std::ptr::eq(
490 p.encoded().unwrap().as_ptr(),
491 p.bytes().as_ptr()
492 ));
493 }
494
495 #[test]
496 fn message_wrong_type_errors() {
497 use buffa_types::google::protobuf::Int32Value;
498 let p = proto_payload("x");
499 // Cache as StringValue.
500 let _: &StringValue = p.message().unwrap();
501 // Now ask for a different type — downcast fails. The error names
502 // both types so the bug is locatable.
503 let err = p.message::<Int32Value>().unwrap_err();
504 let msg = err.message.as_deref().unwrap_or_default();
505 assert!(msg.contains("previously decoded as a"), "{err:?}");
506 assert!(msg.contains("StringValue"), "{err:?}");
507 assert!(msg.contains("Int32Value"), "{err:?}");
508 }
509
510 #[test]
511 fn message_decode_error_is_invalid_argument() {
512 use crate::ErrorCode;
513 // Bytes that cannot decode as a StringValue — peer-supplied data,
514 // so the error code blames the peer, not the server.
515 let p = Payload::new(Bytes::from_static(&[0xff, 0xff, 0xff]), CodecFormat::Proto);
516 let err = p.message::<StringValue>().unwrap_err();
517 assert_eq!(err.code, ErrorCode::InvalidArgument, "{err:?}");
518 }
519
520 #[test]
521 fn message_replacement_wrong_type_errors() {
522 use buffa_types::google::protobuf::Int32Value;
523 let mut p = proto_payload("x");
524 p.set_message(Int32Value {
525 value: 7,
526 ..Default::default()
527 });
528 // The replacement path has a distinct error message from the
529 // post-decode wrong-type path covered by message_wrong_type_errors.
530 let err = p.message::<StringValue>().unwrap_err();
531 let msg = err.message.as_deref().unwrap_or_default();
532 assert!(msg.contains("replacement is a"), "{err:?}");
533 assert!(msg.contains("Int32Value"), "{err:?}");
534 assert!(msg.contains("StringValue"), "{err:?}");
535 }
536
537 #[test]
538 fn view_replaced_json_format_payload() {
539 // A replacement is always re-encoded to proto for view(), so a
540 // JSON-format payload with a replacement still views successfully.
541 let bytes = encode_json(&StringValue {
542 value: "before".into(),
543 ..Default::default()
544 })
545 .unwrap();
546 let mut p = Payload::new(bytes, CodecFormat::Json);
547 p.set_message(StringValue {
548 value: "after".into(),
549 ..Default::default()
550 });
551 let v = p.view::<StringValueView>().unwrap();
552 assert_eq!(v.value, "after");
553 }
554
555 #[test]
556 fn set_message_twice_supersedes() {
557 let mut p = proto_payload("original");
558 p.set_message(StringValue {
559 value: "first".into(),
560 ..Default::default()
561 });
562 p.set_message(StringValue {
563 value: "second".into(),
564 ..Default::default()
565 });
566 let m: &StringValue = p.message().unwrap();
567 assert_eq!(m.value, "second");
568 }
569
570 #[test]
571 fn take_message_decodes_fresh_when_no_cache() {
572 let p = proto_payload("fresh");
573 let m: StringValue = p.take_message().unwrap();
574 assert_eq!(m.value, "fresh");
575 }
576
577 #[test]
578 fn take_message_reuses_cache() {
579 let p = proto_payload("cached");
580 // Populate the cache (an interceptor would do this).
581 let _ = p.message::<StringValue>().unwrap();
582 // `take_message` reads the cache, not the wire bytes. The
583 // no-second-decode property has no observable proof in safe Rust
584 // (the cached value and a fresh decode are bitwise identical), so
585 // it's pinned indirectly by `take_message_returns_replacement` —
586 // if `take_message` decoded the bytes there, it would never see
587 // the replacement. The wrong-type test below pins the other
588 // direction (the cache, not the bytes, is the source of truth).
589 let m: StringValue = p.take_message().unwrap();
590 assert_eq!(m.value, "cached");
591 }
592
593 #[test]
594 fn take_message_returns_replacement() {
595 // Build a payload whose wire bytes are *garbage* — not a valid
596 // proto. If `take_message` decoded the bytes instead of moving
597 // the replacement out, this would error.
598 let mut p = Payload::new(Bytes::from_static(&[0xff, 0xff, 0xff]), CodecFormat::Proto);
599 p.set_message(StringValue {
600 value: "replaced".into(),
601 ..Default::default()
602 });
603 let m: StringValue = p.take_message().unwrap();
604 assert_eq!(m.value, "replaced");
605 }
606
607 #[test]
608 fn take_message_wrong_cached_type_errors() {
609 use buffa_types::google::protobuf::Int32Value;
610 let p = proto_payload("x");
611 // An interceptor cached the wrong type for this route. Before
612 // `take_message`, the handler would silently re-decode and never
613 // notice. Now the bug is loud.
614 let _: &StringValue = p.message().unwrap();
615 let err = p.take_message::<Int32Value>().unwrap_err();
616 let msg = err.message.as_deref().unwrap_or_default();
617 assert!(msg.contains("previously decoded as a"), "{err:?}");
618 assert!(msg.contains("StringValue"), "{err:?}");
619 assert!(msg.contains("Int32Value"), "{err:?}");
620 }
621
622 #[test]
623 fn take_message_wrong_replacement_type_errors() {
624 use buffa_types::google::protobuf::Int32Value;
625 let mut p = proto_payload("x");
626 p.set_message(Int32Value {
627 value: 7,
628 ..Default::default()
629 });
630 let err = p.take_message::<StringValue>().unwrap_err();
631 let msg = err.message.as_deref().unwrap_or_default();
632 assert!(msg.contains("replacement is a"), "{err:?}");
633 }
634
635 #[test]
636 fn take_message_decode_error_is_invalid_argument() {
637 use crate::ErrorCode;
638 let p = Payload::new(Bytes::from_static(&[0xff, 0xff, 0xff]), CodecFormat::Proto);
639 let err = p.take_message::<StringValue>().unwrap_err();
640 assert_eq!(err.code, ErrorCode::InvalidArgument, "{err:?}");
641 }
642
643 #[test]
644 fn payload_debug_redacts_body() {
645 let p = proto_payload("secret");
646 let dbg = format!("{p:?}");
647 assert!(!dbg.contains("secret"), "Debug must not leak body: {dbg}");
648 assert!(dbg.contains("Proto"), "{dbg}");
649 }
650
651 /// `Payload` and `Box<dyn AnyMessage>` cross task boundaries inside
652 /// the dispatch path; assert the auto-trait bounds hold.
653 #[test]
654 fn payload_is_send_sync() {
655 fn assert_send_sync<T: Send + Sync>() {}
656 assert_send_sync::<Payload>();
657 assert_send_sync::<Box<dyn AnyMessage>>();
658 }
659
660 /// Hammer the `OnceLock` probe-then-set race from multiple threads.
661 /// Same `M` everywhere: every thread must succeed and all returned
662 /// borrows must alias the same cache slot.
663 #[test]
664 fn message_concurrent_same_type() {
665 let p = proto_payload("race");
666 std::thread::scope(|s| {
667 let handles: Vec<_> = (0..16)
668 .map(|_| {
669 let p = &p;
670 // Return the cache slot's address as an integer so the
671 // closure stays `Send` (raw pointers are not).
672 s.spawn(move || p.message::<StringValue>().unwrap() as *const _ as usize)
673 })
674 .collect();
675 let addrs: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
676 assert!(
677 addrs.iter().all(|&a| a == addrs[0]),
678 "all callers should observe the same cached value"
679 );
680 });
681 }
682}