Skip to main content

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}