Skip to main content

ados_protocol/
state.rs

1//! Vehicle-state codec for the state socket (Contract B).
2//!
3//! The state is a JSON telemetry snapshot (attitude, position, GPS, battery,
4//! mode, armed, link stats). The producer broadcasts it as length-prefixed
5//! msgpack (v2): a 4-byte big-endian length followed by a versioned map body
6//! `{"v": <version>, "s": <state>}`. The `v` integer lets a reader reject a
7//! frame it does not understand rather than mis-decode it.
8//!
9//! This module also keeps the legacy v1 reader (newline-terminated JSON) so a
10//! stray v1 frame still decodes; the auto-detect reader picks the format per
11//! frame off the leading byte. The state is kept as an open
12//! [`serde_json::Value`] map rather than a fixed struct so new telemetry fields
13//! round-trip without a schema change.
14
15use std::io;
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use thiserror::Error;
20use tokio::io::{AsyncRead, AsyncReadExt};
21
22use crate::frame;
23
24/// Maximum v2 state frame payload. State snapshots are small (a few KiB); the
25/// cap is generous headroom and guards against a runaway producer.
26pub const STATE_V2_MAX_FRAME: usize = 1024 * 1024;
27
28/// The on-wire version integer carried in the v2 frame body's `v` field. Kept in
29/// lock-step with the contract registry (`state.v2`) — a test asserts they are
30/// equal so the wire and the registry can never silently diverge. A reader
31/// rejects any frame whose `v` does not match this.
32///
33/// **Exact-match, strict lockstep.** [`decode_v2`] skips a frame whose `v` is
34/// anything other than this, and the Python reader
35/// (`ados.core.ipc._decode_state_v2_body`) does the same; neither tolerates an
36/// unknown key. So EVERY change to the body shape bumps this, including a purely
37/// additive one — adding a field without bumping leaves two different payload
38/// shapes wearing the same version number, which no reader can detect. The
39/// bounded restart window a bump costs is the designed behaviour of lockstep; a
40/// silent version is a permanent ambiguity.
41///
42/// The constant tracks the BODY shape, not the framing generation the contract
43/// id is named for: 4 is the body whose not-reported readings (battery voltage,
44/// current, remaining and temperature, GPS eph/epv, RC rssi) are `null` rather
45/// than the MAVLink sentinel, still inside the length-prefixed-msgpack framing
46/// that `state.v2` names.
47pub const STATE_WIRE_VERSION: u16 = 4;
48
49#[derive(Debug, Error)]
50pub enum StateError {
51    #[error("json error: {0}")]
52    Json(#[from] serde_json::Error),
53    #[error("msgpack encode error: {0}")]
54    Encode(#[from] rmp_serde::encode::Error),
55    #[error("msgpack decode error: {0}")]
56    Decode(#[from] rmp_serde::decode::Error),
57    #[error("framing error: {0}")]
58    Frame(#[from] frame::FrameError),
59    #[error("state wire version mismatch: got {got}, expected {ours}")]
60    Version { got: u16, ours: u16 },
61}
62
63/// Encode a state snapshot in the v1 wire format: compact JSON terminated by a
64/// newline. Equivalent to Python `json.dumps(state).encode() + b"\n"`.
65pub fn encode_v1(state: &Value) -> Result<Vec<u8>, StateError> {
66    let mut buf = serde_json::to_vec(state)?;
67    buf.push(b'\n');
68    Ok(buf)
69}
70
71/// Decode one v1 line (with or without the trailing newline) into a state
72/// snapshot.
73pub fn decode_v1_line(line: &[u8]) -> Result<Value, StateError> {
74    let trimmed = line.strip_suffix(b"\n").unwrap_or(line);
75    Ok(serde_json::from_slice(trimmed)?)
76}
77
78/// The v2 frame body: a versioned msgpack map `{"v": <version>, "s": <state>}`.
79/// `to_vec_named` emits string map keys, byte-for-byte matching the Python
80/// producer's `msgpack.packb({"v": version, "s": state}, use_bin_type=True)`.
81/// This owned form is the decode target; the borrowed [`StateFrameV2Ref`] is the
82/// encode form, which avoids cloning the (potentially large — e.g. the full
83/// param blob) snapshot on the 10 Hz publish path.
84#[derive(Debug, Serialize, Deserialize)]
85struct StateFrameV2 {
86    #[serde(rename = "v")]
87    v: u16,
88    #[serde(rename = "s")]
89    s: Value,
90}
91
92/// Borrowed encode-side sibling of [`StateFrameV2`]. Serializes byte-identical
93/// output (`&Value` serializes the same as `Value`) without taking ownership.
94#[derive(Serialize)]
95struct StateFrameV2Ref<'a> {
96    #[serde(rename = "v")]
97    v: u16,
98    #[serde(rename = "s")]
99    s: &'a Value,
100}
101
102/// Encode a state snapshot as a complete v2 frame: a 4-byte big-endian length
103/// followed by the msgpack body `{"v": STATE_WIRE_VERSION, "s": state}`.
104pub fn encode_v2(state: &Value) -> Result<Vec<u8>, StateError> {
105    let body = rmp_serde::to_vec_named(&StateFrameV2Ref {
106        v: STATE_WIRE_VERSION,
107        s: state,
108    })?;
109    Ok(frame::encode_frame(&body, STATE_V2_MAX_FRAME)?)
110}
111
112/// Decode a v2 msgpack body (the frame payload, without the length prefix) into
113/// its inner state snapshot. Reads the version integer first and returns
114/// [`StateError::Version`] if it is not [`STATE_WIRE_VERSION`], so a newer /
115/// unknown frame is rejected rather than mis-read as the current shape.
116pub fn decode_v2(body: &[u8]) -> Result<Value, StateError> {
117    let frame: StateFrameV2 = rmp_serde::from_slice(body)?;
118    if frame.v != STATE_WIRE_VERSION {
119        return Err(StateError::Version {
120            got: frame.v,
121            ours: STATE_WIRE_VERSION,
122        });
123    }
124    Ok(frame.s)
125}
126
127/// One frame off the wire: a decoded snapshot, a single malformed-but-frame-
128/// aligned frame to skip without ending the connection, or a well-formed frame
129/// whose wire version this build does not speak.
130enum StateFrame {
131    Value(Value),
132    Skip,
133    VersionMismatch { got: u16 },
134}
135
136/// How many consecutive version-mismatched frames a reader skips before it
137/// gives up on the connection. The producer publishes at 10 Hz, so this is
138/// under a second of frames: long enough to ride out one stray frame, short
139/// enough that a producer/reader version skew surfaces as an error instead of a
140/// live connection that never yields a snapshot.
141pub const MAX_CONSECUTIVE_VERSION_MISMATCHES: u32 = 8;
142
143/// Process-wide count of state frames skipped for a wire-version mismatch.
144static VERSION_MISMATCH_SKIPS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
145
146/// Total state frames this process skipped because their wire version did not
147/// match [`STATE_WIRE_VERSION`], for status surfaces.
148pub fn version_mismatch_skips() -> u64 {
149    VERSION_MISMATCH_SKIPS.load(std::sync::atomic::Ordering::Relaxed)
150}
151
152/// Tracks consecutive version mismatches inside one read call. Returns the
153/// error that ends the read once the run reaches the cap.
154struct MismatchRun(u32);
155
156impl MismatchRun {
157    fn record(&mut self, got: u16) -> io::Result<()> {
158        VERSION_MISMATCH_SKIPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
159        self.0 += 1;
160        if self.0 < MAX_CONSECUTIVE_VERSION_MISMATCHES {
161            return Ok(());
162        }
163        // Logged once per give-up, so the caller's fixed reconnect pace is the
164        // rate limit.
165        tracing::warn!(
166            got,
167            ours = STATE_WIRE_VERSION,
168            skipped = self.0,
169            "state format mismatch: the producer speaks a different state wire version"
170        );
171        Err(io::Error::new(
172            io::ErrorKind::InvalidData,
173            format!(
174                "state format mismatch: producer sends state wire version {got}, \
175                 this reader expects {STATE_WIRE_VERSION}"
176            ),
177        ))
178    }
179}
180
181/// Validate a v2 frame's 4-byte big-endian length header and return the body
182/// length. A zero or over-cap length means the stream is unframable from here on
183/// (an unrecoverable error), not a skippable body.
184fn v2_body_len(header: [u8; frame::HEADER_SIZE]) -> io::Result<usize> {
185    let len = u32::from_be_bytes(header) as usize;
186    if len == 0 || len > STATE_V2_MAX_FRAME {
187        return Err(io::Error::new(
188            io::ErrorKind::InvalidData,
189            "state v2 frame length out of range",
190        ));
191    }
192    Ok(len)
193}
194
195/// Decode a complete frame body of the given wire kind. A malformed body is
196/// [`StateFrame::Skip`]; a well-formed v2 body with a foreign version is
197/// [`StateFrame::VersionMismatch`]. Sharing this with `v2_body_len` keeps the
198/// framing + decode logic identical across the async and blocking readers; only
199/// the byte pump differs between them.
200fn decode_state_body(is_v2: bool, body: &[u8]) -> StateFrame {
201    if is_v2 {
202        match decode_v2(body) {
203            Ok(v) => StateFrame::Value(v),
204            Err(StateError::Version { got, .. }) => StateFrame::VersionMismatch { got },
205            Err(_) => StateFrame::Skip,
206        }
207    } else {
208        decode_v1_line(body).map_or(StateFrame::Skip, StateFrame::Value)
209    }
210}
211
212/// Read exactly one state snapshot from `reader`, auto-detecting the wire format
213/// from the leading byte:
214///
215/// - `0x00` ⇒ **v2** length-prefixed msgpack. The 4-byte big-endian length's
216///   most-significant byte is always `0x00` for a snapshot far smaller than
217///   16 MiB, and `0x00` never begins valid JSON, so the two formats are mutually
218///   exclusive on the first byte and `0x00` is the positive signal for v2.
219/// - anything else ⇒ **v1** newline-terminated JSON.
220///
221/// Returns `Ok(Some(value))` on a decoded snapshot, `Ok(None)` on a clean EOF at
222/// a frame boundary (the caller reconnects), and `Err(e)` on an unrecoverable
223/// framing or IO error. A single malformed-but-frame-aligned frame (bad msgpack
224/// body or bad JSON line) is skipped internally and the next frame is read, so
225/// one bad snapshot never ends a hot connection. A run of
226/// [`MAX_CONSECUTIVE_VERSION_MISMATCHES`] frames in a wire version this build
227/// does not speak is an `InvalidData` error naming the "state format
228/// mismatch", so the caller reconnects and reports it instead of waiting
229/// forever on a connection that will never yield a snapshot.
230///
231/// This is the single reader every `state.sock` consumer uses. Keeping the wire
232/// detection in one place is what stops a producer and a consumer in the same
233/// build from silently disagreeing on the format (the failure that stalled the
234/// world-model capture: a v2 producer read v1-only, yielding no data and no
235/// error).
236pub async fn read_state_value<R>(reader: &mut R) -> io::Result<Option<Value>>
237where
238    R: AsyncRead + Unpin,
239{
240    read_state_value_into(reader, &mut Vec::new()).await
241}
242
243/// [`read_state_value`] reading each frame into the caller's `buf`, so a
244/// long-lived subscriber reuses one allocation for every snapshot instead of
245/// allocating a body per frame. The buffer's contents are scratch on return.
246pub async fn read_state_value_into<R>(
247    reader: &mut R,
248    buf: &mut Vec<u8>,
249) -> io::Result<Option<Value>>
250where
251    R: AsyncRead + Unpin,
252{
253    let mut mismatches = MismatchRun(0);
254    loop {
255        match read_state_frame_into(reader, buf).await? {
256            Some(StateFrame::Value(v)) => return Ok(Some(v)),
257            Some(StateFrame::Skip) => continue,
258            Some(StateFrame::VersionMismatch { got }) => mismatches.record(got)?,
259            None => return Ok(None),
260        }
261    }
262}
263
264/// Read one frame asynchronously into `buf`. `Ok(None)` = a clean EOF at a
265/// frame boundary.
266async fn read_state_frame_into<R>(
267    reader: &mut R,
268    buf: &mut Vec<u8>,
269) -> io::Result<Option<StateFrame>>
270where
271    R: AsyncRead + Unpin,
272{
273    let mut first = [0u8; 1];
274    match reader.read_exact(&mut first).await {
275        Ok(_) => {}
276        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
277        Err(e) => return Err(e),
278    }
279
280    buf.clear();
281    if first[0] == 0x00 {
282        let mut rest = [0u8; frame::HEADER_SIZE - 1];
283        reader.read_exact(&mut rest).await?;
284        let len = v2_body_len([first[0], rest[0], rest[1], rest[2]])?;
285        buf.resize(len, 0);
286        reader.read_exact(buf.as_mut_slice()).await?;
287        Ok(Some(decode_state_body(true, buf)))
288    } else {
289        buf.push(first[0]);
290        let mut byte = [0u8; 1];
291        loop {
292            match reader.read_exact(&mut byte).await {
293                Ok(_) => {}
294                // EOF mid-line: the connection ended without terminating the
295                // frame; treat it as a clean boundary EOF and reconnect.
296                Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
297                Err(e) => return Err(e),
298            }
299            if byte[0] == b'\n' {
300                break;
301            }
302            if buf.len() >= STATE_V2_MAX_FRAME {
303                return Err(io::Error::new(
304                    io::ErrorKind::InvalidData,
305                    "state v1 line exceeded the frame cap without a newline",
306                ));
307            }
308            buf.push(byte[0]);
309        }
310        Ok(Some(decode_state_body(false, buf)))
311    }
312}
313
314/// Blocking sibling of [`read_state_value`] for the sync heartbeat-enrichment
315/// path, which reads one snapshot under a socket read timeout rather than on a
316/// tokio task. Shares the exact framing + decode core (`v2_body_len` +
317/// `decode_state_body`); only the byte pump differs.
318pub fn read_state_value_blocking<R>(reader: &mut R) -> io::Result<Option<Value>>
319where
320    R: io::Read,
321{
322    let mut mismatches = MismatchRun(0);
323    loop {
324        match read_state_frame_blocking(reader)? {
325            Some(StateFrame::Value(v)) => return Ok(Some(v)),
326            Some(StateFrame::Skip) => continue,
327            Some(StateFrame::VersionMismatch { got }) => mismatches.record(got)?,
328            None => return Ok(None),
329        }
330    }
331}
332
333fn read_state_frame_blocking<R>(reader: &mut R) -> io::Result<Option<StateFrame>>
334where
335    R: io::Read,
336{
337    let mut first = [0u8; 1];
338    match reader.read_exact(&mut first) {
339        Ok(_) => {}
340        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
341        Err(e) => return Err(e),
342    }
343
344    if first[0] == 0x00 {
345        let mut rest = [0u8; frame::HEADER_SIZE - 1];
346        reader.read_exact(&mut rest)?;
347        let len = v2_body_len([first[0], rest[0], rest[1], rest[2]])?;
348        let mut body = vec![0u8; len];
349        reader.read_exact(&mut body)?;
350        Ok(Some(decode_state_body(true, &body)))
351    } else {
352        let mut line = vec![first[0]];
353        let mut byte = [0u8; 1];
354        loop {
355            match reader.read_exact(&mut byte) {
356                Ok(_) => {}
357                Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
358                Err(e) => return Err(e),
359            }
360            if byte[0] == b'\n' {
361                break;
362            }
363            if line.len() >= STATE_V2_MAX_FRAME {
364                return Err(io::Error::new(
365                    io::ErrorKind::InvalidData,
366                    "state v1 line exceeded the frame cap without a newline",
367                ));
368            }
369            line.push(byte[0]);
370        }
371        Ok(Some(decode_state_body(false, &line)))
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use serde_json::json;
379
380    fn sample() -> Value {
381        json!({
382            "armed": false,
383            "mode": "STABILIZE",
384            "battery": {"voltage": 16.4, "remaining": 87},
385            "gps": {"fix": 3, "sats": 14, "lat": 12.9716, "lon": 77.5946},
386            "attitude": {"roll": 0.01, "pitch": -0.02, "yaw": 1.57},
387            "link": {"rssi_dbm": -48, "valid_rx_packets_per_s": 630}
388        })
389    }
390
391    #[test]
392    fn v1_round_trip_with_newline() {
393        let state = sample();
394        let wire = encode_v1(&state).unwrap();
395        assert_eq!(*wire.last().unwrap(), b'\n');
396        let back = decode_v1_line(&wire).unwrap();
397        assert_eq!(state, back);
398    }
399
400    #[test]
401    fn v1_decode_tolerates_missing_newline() {
402        let state = sample();
403        let json = serde_json::to_vec(&state).unwrap();
404        assert_eq!(decode_v1_line(&json).unwrap(), state);
405    }
406
407    #[test]
408    fn v1_decode_rejects_malformed_json() {
409        assert!(decode_v1_line(b"{not json}\n").is_err());
410    }
411
412    #[test]
413    fn v2_round_trip() {
414        let state = sample();
415        let frame_bytes = encode_v2(&state).unwrap();
416        // Strip the 4-byte length prefix before decoding the body.
417        let body = &frame_bytes[frame::HEADER_SIZE..];
418        // The body is the versioned wrapper map {"v": 2, "s": <state>}; the raw
419        // deserialize proves the shape, and decode_v2 returns the inner state.
420        let raw: StateFrameV2 = rmp_serde::from_slice(body).unwrap();
421        assert_eq!(raw.v, STATE_WIRE_VERSION);
422        assert_eq!(raw.s, state);
423        let back = decode_v2(body).unwrap();
424        assert_eq!(state, back);
425    }
426
427    #[test]
428    fn version_matches_registry() {
429        // The wire version and the contract registry must never silently
430        // diverge; if the registry bumps state.v2, this const must move with it.
431        assert_eq!(
432            STATE_WIRE_VERSION,
433            crate::contracts::contract_version("state.v2").unwrap()
434        );
435    }
436
437    #[test]
438    fn decode_v2_rejects_a_future_version() {
439        // A frame tagged with a version this build does not speak is rejected,
440        // not mis-decoded as the current shape.
441        let body = rmp_serde::to_vec_named(&StateFrameV2Ref {
442            v: STATE_WIRE_VERSION + 1,
443            s: &sample(),
444        })
445        .unwrap();
446        match decode_v2(&body) {
447            Err(StateError::Version { got, ours }) => {
448                assert_eq!(got, STATE_WIRE_VERSION + 1);
449                assert_eq!(ours, STATE_WIRE_VERSION);
450            }
451            other => panic!("expected a Version error, got {other:?}"),
452        }
453    }
454
455    #[test]
456    fn v1_and_v2_preserve_the_same_fields() {
457        let state = sample();
458        let via_v1 = decode_v1_line(&encode_v1(&state).unwrap()).unwrap();
459        let frame_bytes = encode_v2(&state).unwrap();
460        let via_v2 = decode_v2(&frame_bytes[frame::HEADER_SIZE..]).unwrap();
461        assert_eq!(via_v1, via_v2);
462    }
463
464    #[test]
465    fn leading_byte_discriminates_the_two_wire_forms() {
466        // The auto-detect reader relies on these invariants: a small v2 frame
467        // always leads with 0x00 (its length's high byte), a v1 frame always
468        // leads with `{` (an object). 0x00 never begins valid JSON.
469        assert_eq!(encode_v2(&sample()).unwrap()[0], 0x00);
470        assert_eq!(encode_v1(&sample()).unwrap()[0], b'{');
471    }
472
473    #[tokio::test]
474    async fn read_state_value_decodes_v1_and_v2_identically() {
475        let state = sample();
476        let mut v1 = std::io::Cursor::new(encode_v1(&state).unwrap());
477        let mut v2 = std::io::Cursor::new(encode_v2(&state).unwrap());
478        let from_v1 = read_state_value(&mut v1).await.unwrap().unwrap();
479        let from_v2 = read_state_value(&mut v2).await.unwrap().unwrap();
480        assert_eq!(from_v1, state);
481        assert_eq!(from_v2, state);
482    }
483
484    #[tokio::test]
485    async fn read_state_value_reads_a_mixed_v1_v2_stream_in_order() {
486        // Auto-detect is per-frame, so a producer that switches encoding
487        // mid-stream is still read correctly.
488        let a = json!({"mode": "STABILIZE"});
489        let b = json!({"mode": "GUIDED"});
490        let mut wire = encode_v1(&a).unwrap();
491        wire.extend(encode_v2(&b).unwrap());
492        let mut r = std::io::Cursor::new(wire);
493        assert_eq!(read_state_value(&mut r).await.unwrap().unwrap(), a);
494        assert_eq!(read_state_value(&mut r).await.unwrap().unwrap(), b);
495        assert!(read_state_value(&mut r).await.unwrap().is_none());
496    }
497
498    #[tokio::test]
499    async fn read_state_value_into_reuses_one_buffer_across_frames() {
500        let a = json!({"mode": "STABILIZE", "pad": "x".repeat(512)});
501        let b = json!({"mode": "GUIDED"});
502        let mut wire = encode_v2(&a).unwrap();
503        wire.extend(encode_v1(&b).unwrap());
504        wire.extend(encode_v2(&b).unwrap());
505        let mut r = std::io::Cursor::new(wire);
506        let mut buf = Vec::new();
507        assert_eq!(
508            read_state_value_into(&mut r, &mut buf)
509                .await
510                .unwrap()
511                .unwrap(),
512            a
513        );
514        let grown = buf.capacity();
515        assert!(grown >= 512);
516        assert_eq!(
517            read_state_value_into(&mut r, &mut buf)
518                .await
519                .unwrap()
520                .unwrap(),
521            b
522        );
523        assert_eq!(
524            read_state_value_into(&mut r, &mut buf)
525                .await
526                .unwrap()
527                .unwrap(),
528            b
529        );
530        assert_eq!(
531            buf.capacity(),
532            grown,
533            "the smaller frames reused the allocation"
534        );
535        assert!(read_state_value_into(&mut r, &mut buf)
536            .await
537            .unwrap()
538            .is_none());
539    }
540
541    #[tokio::test]
542    async fn read_state_value_skips_a_malformed_frame_then_reads_the_next() {
543        let good = sample();
544        let mut wire = b"{ not json }\n".to_vec();
545        wire.extend(encode_v2(&good).unwrap());
546        let mut r = std::io::Cursor::new(wire);
547        // The one call skips the bad v1 line internally and returns the good v2.
548        assert_eq!(read_state_value(&mut r).await.unwrap().unwrap(), good);
549    }
550
551    #[tokio::test]
552    async fn read_state_value_returns_none_on_clean_eof() {
553        let mut r = std::io::Cursor::new(Vec::new());
554        assert!(read_state_value(&mut r).await.unwrap().is_none());
555    }
556
557    #[test]
558    fn read_state_value_blocking_decodes_both_wire_forms() {
559        let state = sample();
560        let mut v1 = std::io::Cursor::new(encode_v1(&state).unwrap());
561        let mut v2 = std::io::Cursor::new(encode_v2(&state).unwrap());
562        assert_eq!(read_state_value_blocking(&mut v1).unwrap().unwrap(), state);
563        assert_eq!(read_state_value_blocking(&mut v2).unwrap().unwrap(), state);
564    }
565
566    fn foreign_version_frame() -> Vec<u8> {
567        let body = rmp_serde::to_vec_named(&StateFrameV2Ref {
568            v: STATE_WIRE_VERSION + 1,
569            s: &sample(),
570        })
571        .unwrap();
572        frame::encode_frame(&body, STATE_V2_MAX_FRAME).unwrap()
573    }
574
575    #[tokio::test]
576    async fn a_stream_of_foreign_versions_is_an_error_not_a_silent_stall() {
577        // A producer on another state wire version: every frame is well formed
578        // and every frame is skipped. The reader must end the read with an
579        // error naming the mismatch instead of looping forever on a live
580        // connection that never yields a snapshot.
581        let mut wire = Vec::new();
582        for _ in 0..MAX_CONSECUTIVE_VERSION_MISMATCHES {
583            wire.extend(foreign_version_frame());
584        }
585        wire.extend(encode_v2(&sample()).unwrap());
586        let before = version_mismatch_skips();
587        let mut r = std::io::Cursor::new(wire.clone());
588        let err = read_state_value(&mut r).await.unwrap_err();
589        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
590        assert!(err.to_string().contains("state format mismatch"), "{err}");
591        assert!(version_mismatch_skips() >= before + MAX_CONSECUTIVE_VERSION_MISMATCHES as u64);
592
593        let mut r = std::io::Cursor::new(wire);
594        assert!(read_state_value_blocking(&mut r).is_err());
595    }
596
597    #[tokio::test]
598    async fn a_single_foreign_version_frame_is_skipped() {
599        let mut wire = foreign_version_frame();
600        wire.extend(encode_v2(&sample()).unwrap());
601        let mut r = std::io::Cursor::new(wire);
602        assert_eq!(read_state_value(&mut r).await.unwrap().unwrap(), sample());
603    }
604}