Skip to main content

reddb_wire/redwire/
builder.rs

1//! Server-side frame construction discipline.
2//!
3//! Every server-emitted frame today is built by stitching together
4//! [`Frame::new`] + [`Frame::with_stream`] + [`Frame::with_flags`] at
5//! the call site. That spreads four invariants across every dispatch
6//! path:
7//!
8//!   1. Correlation-id propagation — each response must echo the
9//!      request frame's id (or `0` for unsolicited frames).
10//!   2. `MORE_FRAMES` sequencing — only the *last* frame of a multi-
11//!      frame reply may clear the flag.
12//!   3. `MAX_FRAME_SIZE` enforcement — the codec checks on decode but
13//!      a producer happily encodes oversized frames the peer will
14//!      reject anyway.
15//!   4. Compression policy — callers either opt in by setting
16//!      `Flags::COMPRESSED` or do not, and the codec silently falls
17//!      back to plaintext on incompressible input.
18//!
19//! [`FrameBuilder`] owns those invariants. The acceptance test for
20//! this module is the deletion test: deleting `builder.rs` forces
21//! frame-construction discipline back to inline `Frame::new` calls
22//! at every dispatch site.
23//!
24//! ```ignore
25//! use reddb_wire::redwire::{FrameBuilder, MessageKind};
26//!
27//! let frame = FrameBuilder::reply_to(request.correlation_id)
28//!     .kind(MessageKind::Result)
29//!     .payload(body)
30//!     .stream_id(42)
31//!     .more_frames(false)
32//!     .build()?;
33//! ```
34//!
35//! The builder is engine-free — it only depends on [`Frame`],
36//! [`MessageKind`], [`Flags`], and the size constants from this
37//! crate. Server dispatch (auth, session, listener) constructs
38//! frames through the builder; the codec stays focused on bytes.
39
40use super::frame::{Flags, Frame, MessageKind, FRAME_HEADER_SIZE, MAX_FRAME_SIZE};
41
42/// Errors surfaced at `build()` time so they fail at construction
43/// rather than at encode time, where the call site has already lost
44/// the context to recover.
45///
46/// Distinct from [`crate::redwire::codec::FrameError`], which covers
47/// decode-side framing errors. The split keeps producer-side
48/// invariants (this) separate from consumer-side parsing (that).
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum BuildError {
51    /// `kind()` was never called — a frame has no default kind so
52    /// the builder refuses to guess.
53    KindMissing,
54    /// The encoded frame would exceed [`MAX_FRAME_SIZE`].
55    PayloadTooLarge { encoded_len: usize, max: u32 },
56    /// Catalog cross-check failed: the requested flag set isn't in
57    /// `MessageKind::allowed_flags()` for the chosen kind.
58    FlagsNotAllowedForKind { kind: MessageKind, flags: u8 },
59}
60
61impl std::fmt::Display for BuildError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::KindMissing => write!(f, "FrameBuilder: kind() must be called before build()"),
65            Self::PayloadTooLarge { encoded_len, max } => write!(
66                f,
67                "FrameBuilder: encoded frame size {encoded_len} exceeds MAX_FRAME_SIZE ({max})"
68            ),
69            Self::FlagsNotAllowedForKind { kind, flags } => write!(
70                f,
71                "FrameBuilder: flag bits 0x{flags:02x} not allowed on kind {kind:?}"
72            ),
73        }
74    }
75}
76
77impl std::error::Error for BuildError {}
78
79/// Compression intent recorded by `compress(true|false)`. The
80/// builder defers the actual zstd call to the codec but tracks
81/// whether the caller asked for compression so it can drop the
82/// `COMPRESSED` flag if the payload is provably incompressible
83/// (see [`FrameBuilder::build`]).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Compress {
86    No,
87    Yes,
88}
89
90/// Builder for RedWire [`Frame`]s.
91///
92/// Construct with [`FrameBuilder::request`] for client requests,
93/// [`FrameBuilder::reply_to`] for response frames (carries the
94/// request's correlation id), or [`FrameBuilder::unsolicited`] for
95/// server-initiated frames (correlation id `0`). All other fields are
96/// optional.
97#[derive(Debug, Clone)]
98pub struct FrameBuilder {
99    kind: Option<MessageKind>,
100    correlation_id: u64,
101    stream_id: u16,
102    payload: Vec<u8>,
103    flags: Flags,
104    compress: Compress,
105    /// `true` when the caller has *not* yet declared this is the
106    /// last frame of a multi-frame reply — i.e. `MORE_FRAMES` is
107    /// set. Defaults to `false` (single-frame reply).
108    more_frames: bool,
109}
110
111impl FrameBuilder {
112    /// Client request frame. Carries the caller's new correlation id
113    /// so the server can echo it in its reply.
114    pub fn request(correlation_id: u64) -> Self {
115        Self::with_correlation(correlation_id)
116    }
117
118    /// Reply to a request frame. Echoes the caller's correlation id
119    /// so the client can pair the response with the request.
120    pub fn reply_to(correlation_id: u64) -> Self {
121        Self::with_correlation(correlation_id)
122    }
123
124    /// Server-initiated frame with no request to echo (correlation
125    /// id `0`). Used for notices, unsolicited Bye, etc.
126    pub fn unsolicited() -> Self {
127        Self::with_correlation(0)
128    }
129
130    fn with_correlation(correlation_id: u64) -> Self {
131        Self {
132            kind: None,
133            correlation_id,
134            stream_id: 0,
135            payload: Vec::new(),
136            flags: Flags::empty(),
137            compress: Compress::No,
138            more_frames: false,
139        }
140    }
141
142    pub fn kind(mut self, kind: MessageKind) -> Self {
143        self.kind = Some(kind);
144        self
145    }
146
147    pub fn payload(mut self, payload: Vec<u8>) -> Self {
148        self.payload = payload;
149        self
150    }
151
152    pub fn stream_id(mut self, stream_id: u16) -> Self {
153        self.stream_id = stream_id;
154        self
155    }
156
157    /// Replace the flag set wholesale. Most callers should prefer
158    /// [`Self::more_frames`] / [`Self::compress`] over poking flags
159    /// directly — this exists for the Cancel / Compress / Notice
160    /// control frames that carry caller-defined bits.
161    pub fn flags(mut self, flags: Flags) -> Self {
162        self.flags = flags;
163        self
164    }
165
166    /// Mark this frame as part of a multi-frame reply. Pass `false`
167    /// (the default) on the *last* frame of the burst — the
168    /// `MORE_FRAMES` last-frame invariant is enforced at build()
169    /// time by the flag bit.
170    pub fn more_frames(mut self, more: bool) -> Self {
171        self.more_frames = more;
172        self
173    }
174
175    /// Request that the encoder zstd-compress the payload. The
176    /// codec falls back to plaintext + cleared flag if the payload
177    /// is incompressible (see [`Self::build`]).
178    pub fn compress(mut self, yes: bool) -> Self {
179        self.compress = if yes { Compress::Yes } else { Compress::No };
180        self
181    }
182
183    /// Finalize the frame.
184    ///
185    /// Enforces:
186    ///   * `kind()` was set (otherwise [`BuildError::KindMissing`]).
187    ///   * Plaintext encoded size <= [`MAX_FRAME_SIZE`] (otherwise
188    ///     [`BuildError::PayloadTooLarge`]) — checked against the
189    ///     plaintext payload, since the wire form after compression
190    ///     can only shrink.
191    ///   * `MORE_FRAMES` flag mirrors the `more_frames(bool)` call.
192    ///   * `COMPRESSED` flag is set only when compression was
193    ///     requested *and* the payload looks compressible. A trivial
194    ///     incompressibility heuristic ("the payload is empty or
195    ///     too short for zstd to reduce") drops the flag here so
196    ///     the encoded bytes match the flag.
197    pub fn build(self) -> Result<Frame, BuildError> {
198        let kind = self.kind.ok_or(BuildError::KindMissing)?;
199        let encoded_len = FRAME_HEADER_SIZE + self.payload.len();
200        if encoded_len > MAX_FRAME_SIZE as usize {
201            return Err(BuildError::PayloadTooLarge {
202                encoded_len,
203                max: MAX_FRAME_SIZE,
204            });
205        }
206
207        if !kind.permits_flags(self.flags) {
208            return Err(BuildError::FlagsNotAllowedForKind {
209                kind,
210                flags: self.flags.bits(),
211            });
212        }
213
214        let mut flags = self.flags;
215        if self.more_frames {
216            flags = flags.insert(Flags::MORE_FRAMES);
217        } else {
218            // Clear MORE_FRAMES on the last frame of a burst. Stays
219            // a no-op when the caller never set it.
220            flags = Flags::from_bits(flags.bits() & !Flags::MORE_FRAMES.bits());
221        }
222
223        let compressed = match self.compress {
224            Compress::No => false,
225            Compress::Yes => is_payload_compressible(&self.payload),
226        };
227        if compressed {
228            flags = flags.insert(Flags::COMPRESSED);
229        } else {
230            flags = Flags::from_bits(flags.bits() & !Flags::COMPRESSED.bits());
231        }
232
233        // Catalog cross-check: the chosen kind's allowed_flags() is
234        // the producer-side mirror of the codec's decode-time check.
235        // Catches misframed builds (e.g. COMPRESSED on Hello) before
236        // they reach the wire.
237        if !kind.permits_flags(flags) {
238            return Err(BuildError::FlagsNotAllowedForKind {
239                kind,
240                flags: flags.bits(),
241            });
242        }
243
244        Ok(Frame {
245            kind,
246            flags,
247            stream_id: self.stream_id,
248            correlation_id: self.correlation_id,
249            payload: self.payload,
250        })
251    }
252}
253
254pub fn build_reply_frame(
255    correlation_id: u64,
256    kind: MessageKind,
257    payload: Vec<u8>,
258) -> Result<Frame, BuildError> {
259    FrameBuilder::reply_to(correlation_id)
260        .kind(kind)
261        .payload(payload)
262        .build()
263}
264
265pub fn build_error_frame(correlation_id: u64, message: &str) -> Result<Frame, BuildError> {
266    build_reply_frame(
267        correlation_id,
268        MessageKind::Error,
269        message.as_bytes().to_vec(),
270    )
271}
272
273pub fn build_error_frame_lossy(correlation_id: u64, message: &str) -> Frame {
274    build_error_frame(correlation_id, message).unwrap_or_else(|_| {
275        Frame::new(
276            MessageKind::Error,
277            correlation_id,
278            b"redwire error frame too large".to_vec(),
279        )
280    })
281}
282
283pub fn build_dispatch_reply_frame(
284    correlation_id: u64,
285    kind: MessageKind,
286    payload: Vec<u8>,
287) -> Frame {
288    build_reply_frame(correlation_id, kind, payload)
289        .unwrap_or_else(|err| build_error_frame_lossy(correlation_id, &err.to_string()))
290}
291
292/// Adapt an older 5-byte handler envelope into a RedWire reply frame.
293///
294/// Some in-process fast paths return `[u32 length][u8 kind][body...]`.
295/// RedWire uses the same message-kind discriminator for `kind`, but owns the
296/// outer frame header. This helper keeps that compatibility bridge in the wire
297/// crate instead of letting runtime code parse kind bytes directly.
298pub fn rewrap_length_prefixed_handler_response(raw_bytes: &[u8], correlation_id: u64) -> Frame {
299    if raw_bytes.len() < 5 {
300        return build_error_frame_lossy(
301            correlation_id,
302            "fast-path handler returned a truncated frame",
303        );
304    }
305    let kind = MessageKind::from_u8(raw_bytes[4]).unwrap_or(MessageKind::Error);
306    build_dispatch_reply_frame(correlation_id, kind, raw_bytes[5..].to_vec())
307}
308
309/// Borrow a legacy payload while constructing only its RedWire metadata. The
310/// caller can admit the exact encoded allocation before making its one copy.
311pub struct BorrowedLegacyReply<'a> {
312    frame: Frame,
313    payload: &'a [u8],
314}
315
316impl<'a> BorrowedLegacyReply<'a> {
317    pub fn new(raw: &'a [u8], correlation_id: u64) -> Result<Self, super::codec::FrameError> {
318        use super::codec::FrameError;
319        if raw.len() < 5 {
320            return Err(FrameError::Truncated);
321        }
322        let payload = &raw[5..];
323        let length = payload
324            .len()
325            .checked_add(FRAME_HEADER_SIZE)
326            .filter(|length| *length <= MAX_FRAME_SIZE as usize)
327            .ok_or(FrameError::InvalidLength(u32::MAX))?;
328        let kind = MessageKind::from_u8(raw[4]).unwrap_or(MessageKind::Error);
329        let frame = build_reply_frame(correlation_id, kind, Vec::new())
330            .expect("empty uncompressed reply is valid for every kind");
331        assert_eq!(length, FRAME_HEADER_SIZE + payload.len());
332        Ok(Self { frame, payload })
333    }
334
335    pub fn encoded_len(&self) -> usize {
336        FRAME_HEADER_SIZE + self.payload.len()
337    }
338
339    pub fn encode_into(&self, buffer: &mut Vec<u8>) {
340        super::codec::encode_uncompressed_payload_into(&self.frame, self.payload, buffer);
341    }
342}
343
344pub fn build_query_frame(correlation_id: u64, sql: &str) -> Result<Frame, BuildError> {
345    build_request_frame(correlation_id, MessageKind::Query, sql.as_bytes().to_vec())
346}
347
348pub fn build_query_with_params_frame(
349    correlation_id: u64,
350    payload: Vec<u8>,
351) -> Result<Frame, BuildError> {
352    build_request_frame(correlation_id, MessageKind::QueryWithParams, payload)
353}
354
355pub fn build_bulk_insert_frame(correlation_id: u64, payload: Vec<u8>) -> Result<Frame, BuildError> {
356    build_request_frame(correlation_id, MessageKind::BulkInsert, payload)
357}
358
359pub fn build_get_frame(correlation_id: u64, payload: Vec<u8>) -> Result<Frame, BuildError> {
360    build_request_frame(correlation_id, MessageKind::Get, payload)
361}
362
363pub fn build_delete_frame(correlation_id: u64, payload: Vec<u8>) -> Result<Frame, BuildError> {
364    build_request_frame(correlation_id, MessageKind::Delete, payload)
365}
366
367pub fn build_bulk_insert_binary_frame(
368    correlation_id: u64,
369    payload: Vec<u8>,
370) -> Result<Frame, BuildError> {
371    build_request_frame(correlation_id, MessageKind::BulkInsertBinary, payload)
372}
373
374pub fn build_ping_frame(correlation_id: u64) -> Result<Frame, BuildError> {
375    build_request_frame(correlation_id, MessageKind::Ping, Vec::new())
376}
377
378pub fn build_bye_frame(correlation_id: u64) -> Result<Frame, BuildError> {
379    build_request_frame(correlation_id, MessageKind::Bye, Vec::new())
380}
381
382pub fn build_request_frame(
383    correlation_id: u64,
384    kind: MessageKind,
385    payload: Vec<u8>,
386) -> Result<Frame, BuildError> {
387    FrameBuilder::request(correlation_id)
388        .kind(kind)
389        .payload(payload)
390        .build()
391}
392
393/// Cheap heuristic: zstd's frame header is ~12 bytes, so a payload
394/// has to clear that bar to even potentially shrink. The codec also
395/// catches truly pathological cases at encode time and falls back to
396/// plaintext, but this lets the builder report the cleared flag
397/// before encoding.
398fn is_payload_compressible(payload: &[u8]) -> bool {
399    payload.len() > 32
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn borrowed_legacy_reply_matches_existing_bridge_without_payload_allocation() {
408        for kind in [crate::legacy::MSG_RESULT, crate::legacy::MSG_ERROR, 255] {
409            for size in [0, 1, 4096, 192 * 1024] {
410                let raw = crate::legacy::build_legacy_frame(kind, &vec![b'x'; size]);
411                let reply = BorrowedLegacyReply::new(&raw, 123).unwrap();
412                assert_eq!(reply.payload.as_ptr(), raw[5..].as_ptr());
413                assert_eq!(reply.frame.payload.capacity(), 0);
414                let mut bytes = Vec::with_capacity(reply.encoded_len());
415                reply.encode_into(&mut bytes);
416                assert_eq!(bytes.len(), bytes.capacity());
417                assert_eq!(
418                    bytes,
419                    super::super::codec::encode_frame(&rewrap_length_prefixed_handler_response(
420                        &raw, 123
421                    ))
422                );
423            }
424        }
425        assert!(BorrowedLegacyReply::new(&[0; 4], 1).is_err());
426        let oversized = vec![0; MAX_FRAME_SIZE as usize];
427        assert!(BorrowedLegacyReply::new(&oversized, 1).is_err());
428    }
429
430    #[test]
431    fn reply_to_propagates_correlation_id() {
432        // Mirrors the dispatch site: every response frame must echo
433        // the request's correlation id so the client pairs them up.
434        let frame = FrameBuilder::reply_to(0xABCD)
435            .kind(MessageKind::Result)
436            .payload(b"ok".to_vec())
437            .build()
438            .expect("build");
439        assert_eq!(frame.correlation_id, 0xABCD);
440        assert_eq!(frame.kind, MessageKind::Result);
441        assert_eq!(frame.payload, b"ok");
442    }
443
444    #[test]
445    fn request_builders_choose_client_message_kinds() {
446        let query = build_query_frame(1, "select 1").expect("query");
447        assert_eq!(query.kind, MessageKind::Query);
448        assert_eq!(query.correlation_id, 1);
449        assert_eq!(query.payload, b"select 1");
450
451        let ping = build_ping_frame(2).expect("ping");
452        assert_eq!(ping.kind, MessageKind::Ping);
453        assert!(ping.payload.is_empty());
454
455        let bye = build_bye_frame(3).expect("bye");
456        assert_eq!(bye.kind, MessageKind::Bye);
457        assert!(bye.payload.is_empty());
458    }
459
460    #[test]
461    fn unsolicited_uses_zero_correlation() {
462        let frame = FrameBuilder::unsolicited()
463            .kind(MessageKind::Notice)
464            .payload(b"server-side notice".to_vec())
465            .build()
466            .expect("build");
467        assert_eq!(frame.correlation_id, 0);
468    }
469
470    #[test]
471    fn missing_kind_rejected() {
472        let err = FrameBuilder::reply_to(1).build().unwrap_err();
473        assert_eq!(err, BuildError::KindMissing);
474    }
475
476    #[test]
477    fn more_frames_last_frame_clears_the_flag() {
478        // The MORE_FRAMES last-frame invariant: only intermediate
479        // frames in a burst carry the flag. The last frame must
480        // clear it, otherwise the client keeps waiting for more.
481        let middle = FrameBuilder::reply_to(7)
482            .kind(MessageKind::Result)
483            .payload(vec![0; 8])
484            .more_frames(true)
485            .build()
486            .expect("build middle");
487        assert!(
488            middle.flags.contains(Flags::MORE_FRAMES),
489            "middle frame must carry MORE_FRAMES"
490        );
491
492        let last = FrameBuilder::reply_to(7)
493            .kind(MessageKind::Result)
494            .payload(vec![0; 8])
495            .more_frames(false)
496            .build()
497            .expect("build last");
498        assert!(
499            !last.flags.contains(Flags::MORE_FRAMES),
500            "last frame must clear MORE_FRAMES"
501        );
502    }
503
504    #[test]
505    fn more_frames_default_is_last_frame() {
506        // A single-frame reply (the common case) is implicitly the
507        // last frame — callers shouldn't have to remember to clear
508        // the flag.
509        let frame = FrameBuilder::reply_to(1)
510            .kind(MessageKind::Pong)
511            .build()
512            .expect("build");
513        assert!(!frame.flags.contains(Flags::MORE_FRAMES));
514    }
515
516    #[test]
517    fn payload_at_max_size_accepted() {
518        let payload = vec![0u8; (MAX_FRAME_SIZE as usize) - FRAME_HEADER_SIZE];
519        let frame = FrameBuilder::reply_to(1)
520            .kind(MessageKind::Result)
521            .payload(payload)
522            .build()
523            .expect("build at limit");
524        assert_eq!(frame.encoded_len(), MAX_FRAME_SIZE);
525    }
526
527    #[test]
528    fn payload_over_max_size_rejected() {
529        // MAX_FRAME_SIZE is the on-wire upper bound. The builder
530        // refuses oversize plaintext rather than letting the encoder
531        // produce a frame the peer will reject anyway.
532        let oversize = (MAX_FRAME_SIZE as usize) - FRAME_HEADER_SIZE + 1;
533        let payload = vec![0u8; oversize];
534        let err = FrameBuilder::reply_to(1)
535            .kind(MessageKind::Result)
536            .payload(payload)
537            .build()
538            .unwrap_err();
539        match err {
540            BuildError::PayloadTooLarge { encoded_len, max } => {
541                assert_eq!(max, MAX_FRAME_SIZE);
542                assert_eq!(encoded_len, MAX_FRAME_SIZE as usize + 1);
543            }
544            other => panic!("expected PayloadTooLarge, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn compression_fallback_drops_flag_for_incompressible_payload() {
550        // Compression was requested, but the payload is too short
551        // for zstd to actually reduce. The builder drops the flag
552        // so the encoded bytes match — otherwise the wire form
553        // claims COMPRESSED but the body is plaintext.
554        let frame = FrameBuilder::reply_to(1)
555            .kind(MessageKind::Result)
556            .payload(b"tiny".to_vec())
557            .compress(true)
558            .build()
559            .expect("build");
560        assert!(
561            !frame.flags.contains(Flags::COMPRESSED),
562            "incompressible payload must not carry COMPRESSED"
563        );
564    }
565
566    #[test]
567    fn compression_kept_for_compressible_payload() {
568        let payload = b"abcabcabc".repeat(16);
569        let frame = FrameBuilder::reply_to(1)
570            .kind(MessageKind::Result)
571            .payload(payload)
572            .compress(true)
573            .build()
574            .expect("build");
575        assert!(frame.flags.contains(Flags::COMPRESSED));
576    }
577
578    #[test]
579    fn flags_not_allowed_for_kind_rejected_at_build() {
580        // Hello is a handshake kind — the catalog forbids COMPRESSED.
581        // The builder mirrors the codec's decode-time check so a
582        // misframed build fails before the bytes ever hit the wire.
583        let err = FrameBuilder::reply_to(1)
584            .kind(MessageKind::Hello)
585            .flags(Flags::COMPRESSED)
586            .build()
587            .unwrap_err();
588        match err {
589            BuildError::FlagsNotAllowedForKind { kind, flags } => {
590                assert_eq!(kind, MessageKind::Hello);
591                assert_eq!(flags, Flags::COMPRESSED.bits());
592            }
593            other => panic!("expected FlagsNotAllowedForKind, got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn stream_id_propagates() {
599        let frame = FrameBuilder::reply_to(1)
600            .kind(MessageKind::Result)
601            .stream_id(0xBEEF)
602            .build()
603            .expect("build");
604        assert_eq!(frame.stream_id, 0xBEEF);
605    }
606
607    #[test]
608    fn generic_reply_builders_pin_server_frame_contracts() {
609        let reply = build_reply_frame(7, MessageKind::Pong, b"ok".to_vec()).expect("reply frame");
610        assert_eq!(reply.correlation_id, 7);
611        assert_eq!(reply.kind, MessageKind::Pong);
612        assert_eq!(reply.payload, b"ok");
613
614        let err = build_error_frame(8, "bad request").expect("error frame");
615        assert_eq!(err.kind, MessageKind::Error);
616        assert_eq!(err.correlation_id, 8);
617        assert_eq!(err.payload, b"bad request");
618
619        let dispatch = build_dispatch_reply_frame(9, MessageKind::Result, b"rows".to_vec());
620        assert_eq!(dispatch.kind, MessageKind::Result);
621        assert_eq!(dispatch.correlation_id, 9);
622    }
623
624    #[test]
625    fn rewraps_length_prefixed_handler_response() {
626        let raw = [4u8, 0, 0, 0, MessageKind::BulkOk as u8, b'o', b'k'];
627        let frame = rewrap_length_prefixed_handler_response(&raw, 12);
628        assert_eq!(frame.correlation_id, 12);
629        assert_eq!(frame.kind, MessageKind::BulkOk);
630        assert_eq!(frame.payload, b"ok");
631
632        let truncated = rewrap_length_prefixed_handler_response(&raw[..4], 13);
633        assert_eq!(truncated.kind, MessageKind::Error);
634        assert_eq!(truncated.correlation_id, 13);
635        assert_eq!(
636            truncated.payload,
637            b"fast-path handler returned a truncated frame"
638        );
639    }
640
641    #[test]
642    fn lossy_error_builder_never_panics_on_oversized_payload() {
643        let too_large = "x".repeat(MAX_FRAME_SIZE as usize);
644        let err = build_error_frame_lossy(11, &too_large);
645        assert_eq!(err.kind, MessageKind::Error);
646        assert_eq!(err.correlation_id, 11);
647        assert_eq!(err.payload, b"redwire error frame too large");
648    }
649}