Skip to main content

syncular_client/
realtime_round.rs

1//! Transport-agnostic §8.7 sync-round-over-socket framing.
2//!
3//! The realtime channel carries one-byte channel-tagged binary messages
4//! (§8.7): tag `0x00` is a standalone SSP2 response (a delta, §8.2), tag
5//! `0x01` is a chunk of the in-flight round's byte stream. This module owns
6//! the tag demux and the scanner-driven response reassembly — the protocol
7//! logic — leaving the WS send/read plumbing to each native transport. It
8//! is deliberately free of any WS dependency so it lives in the lean client
9//! crate and both native transports (FFI + Tauri plugin) share it via their
10//! existing `syncular-client` dependency (the honest single-source, since
11//! the crates are in different cargo workspaces and cannot share a private
12//! module directly).
13//!
14//! It mirrors the reference host reassembly in
15//! `packages/conformance/src/drivers/rust-client.ts` (`#routeBinary` /
16//! `#realtimeRound`), which is the reference client's socket-round path:
17//! tag the request `0x01`, reassemble `0x01` response chunks to `END`,
18//! reject bytes past `END`, and route `0x00` deltas + text frames to the
19//! inbound queue for the command path to apply (§8.2). Deltas MUST NOT
20//! arrive mid-round per server discipline (§8.7 interleaving), but the
21//! honest client posture is tolerate-and-queue — matching the TS host,
22//! which routes a stray delta to the inbound lane rather than failing.
23
24use crate::{MessageStreamScanner, TransportError};
25
26/// §8.7 channel tags. A closed registry per wire version.
27pub const REALTIME_TAG_DELTA: u8 = 0x00;
28pub const REALTIME_TAG_ROUND: u8 = 0x01;
29
30/// What a native transport should do with one inbound binary frame that the
31/// round demux classified.
32#[derive(Debug)]
33pub enum RoundInbound {
34    /// A `0x00` delta payload (tag stripped) to hand to the client as an
35    /// inbound realtime binary frame (queued for the command path, §8.2).
36    Delta(Vec<u8>),
37    /// A `0x01` round chunk that did not yet complete the response — nothing
38    /// for the caller to do but keep reading.
39    RoundProgress,
40    /// The round's response is fully reassembled: the complete SSP2 response
41    /// envelope bytes, ready to return from `realtime_sync`.
42    RoundComplete(Vec<u8>),
43    /// A `0x01` round chunk arrived with no round in flight, or an unknown
44    /// tag: tolerated and ignored (§8.7 forward-compat).
45    Ignored,
46}
47
48/// The per-connection round state: at most one round in flight (§8.7). A
49/// native transport creates one of these per socket and drives it from its
50/// reader loop (`route_binary`) and its `realtime_sync` call
51/// (`begin`/`finish`).
52#[derive(Default)]
53pub struct RealtimeRound {
54    /// The active round's response scanner, `Some` between `begin` and the
55    /// response `END` (or a failure). Its presence is the "one round in
56    /// flight" flag.
57    scanner: Option<MessageStreamScanner>,
58}
59
60impl RealtimeRound {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// True while a round is in flight (request sent, response not yet at
66    /// `END`). The client-side enforcement of §8.7's "one round in flight".
67    pub fn in_flight(&self) -> bool {
68        self.scanner.is_some()
69    }
70
71    /// Frame the request for the socket: a `0x01` tag byte followed by the
72    /// whole request envelope. Chunk boundaries are arbitrary (§8.7), so a
73    /// single chunk carrying the entire request is legal and simplest; the
74    /// request is already bounded (bulk rides segments over HTTP, §5.7), so
75    /// there is nothing to gain by splitting it. Marks the round in flight.
76    ///
77    /// Returns an error if a round is already in flight (client-side §8.7
78    /// one-in-flight enforcement — the caller must not pipeline).
79    pub fn begin(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
80        if self.scanner.is_some() {
81            return Err(TransportError::new(
82                "sync.transport_failed",
83                "a realtime sync round is already in flight (§8.7 one round per connection)",
84            ));
85        }
86        self.scanner = Some(MessageStreamScanner::new());
87        let mut framed = Vec::with_capacity(request.len() + 1);
88        framed.push(REALTIME_TAG_ROUND);
89        framed.extend_from_slice(request);
90        Ok(framed)
91    }
92
93    /// Route one inbound binary frame (tag byte + payload) while a round may
94    /// be in flight. `0x01` chunks feed the response scanner; `0x00` deltas
95    /// are surfaced for the inbound queue; unknown tags are ignored.
96    ///
97    /// On a scanner error (bad envelope header) or bytes past `END`, the
98    /// round is failed and the error returned — the caller wakes the pending
99    /// `realtime_sync` with it and (per §8.7) the connection is unusable.
100    pub fn route_binary(&mut self, frame: &[u8]) -> Result<RoundInbound, TransportError> {
101        if frame.is_empty() {
102            return Ok(RoundInbound::Ignored);
103        }
104        let tag = frame[0];
105        let body = &frame[1..];
106        match tag {
107            REALTIME_TAG_ROUND => {
108                let Some(scanner) = self.scanner.as_mut() else {
109                    // A round chunk with no round in flight: tolerated and
110                    // ignored (mirrors the TS host's `round === undefined`
111                    // early return).
112                    return Ok(RoundInbound::Ignored);
113                };
114                match scanner.push(body) {
115                    Ok(None) => Ok(RoundInbound::RoundProgress),
116                    Ok(Some(done)) => {
117                        self.scanner = None;
118                        if done.excess > 0 {
119                            return Err(TransportError::new(
120                                "sync.transport_failed",
121                                "realtime round response has bytes past END (§8.7)",
122                            ));
123                        }
124                        Ok(RoundInbound::RoundComplete(done.message))
125                    }
126                    Err(error) => {
127                        self.scanner = None;
128                        Err(TransportError::new(
129                            "sync.transport_failed",
130                            format!("realtime round response decode error: {}", error.detail),
131                        ))
132                    }
133                }
134            }
135            REALTIME_TAG_DELTA => Ok(RoundInbound::Delta(body.to_vec())),
136            // Unknown tag: tolerated and ignored (§8.7 closed registry,
137            // forward-compat mirror of §8.1's unknown-event rule).
138            _ => Ok(RoundInbound::Ignored),
139        }
140    }
141
142    /// Abandon any in-flight round (socket dropped mid-round). Clears the
143    /// in-flight flag so the connection can be re-established.
144    pub fn abort(&mut self) {
145        self.scanner = None;
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use ssp2::model::{Frame, Message, MsgKind};
153    use ssp2::{encode_message, MessageStreamScanner as _Scanner};
154
155    fn response_bytes() -> Vec<u8> {
156        // A minimal but real response envelope: RESP_HEADER + END.
157        let message = Message {
158            msg_kind: MsgKind::Response,
159            frames: vec![Frame::RespHeader {
160                required_schema_version: None,
161                latest_schema_version: None,
162            }],
163        };
164        encode_message(&message)
165    }
166
167    fn tagged(tag: u8, body: &[u8]) -> Vec<u8> {
168        let mut v = vec![tag];
169        v.extend_from_slice(body);
170        v
171    }
172
173    #[test]
174    fn begin_frames_request_with_round_tag_and_marks_in_flight() {
175        let mut round = RealtimeRound::new();
176        assert!(!round.in_flight());
177        let framed = round.begin(&[0xde, 0xad]).unwrap();
178        assert_eq!(framed, vec![REALTIME_TAG_ROUND, 0xde, 0xad]);
179        assert!(round.in_flight());
180    }
181
182    #[test]
183    fn second_begin_while_in_flight_is_rejected() {
184        let mut round = RealtimeRound::new();
185        round.begin(&[0x01]).unwrap();
186        let err = round.begin(&[0x02]).unwrap_err();
187        assert_eq!(err.code, "sync.transport_failed");
188        assert!(err.message.contains("one round"));
189    }
190
191    #[test]
192    fn single_chunk_response_completes_and_clears_in_flight() {
193        let response = response_bytes();
194        let mut round = RealtimeRound::new();
195        round.begin(&[0x00]).unwrap();
196        let frame = tagged(REALTIME_TAG_ROUND, &response);
197        match round.route_binary(&frame).unwrap() {
198            RoundInbound::RoundComplete(bytes) => assert_eq!(bytes, response),
199            _ => panic!("expected RoundComplete"),
200        }
201        assert!(!round.in_flight(), "round clears after END");
202    }
203
204    #[test]
205    fn chunked_response_reassembles_across_arbitrary_boundaries() {
206        let response = response_bytes();
207        for split in 1..response.len() {
208            let mut round = RealtimeRound::new();
209            round.begin(&[0x00]).unwrap();
210            let first = tagged(REALTIME_TAG_ROUND, &response[..split]);
211            assert!(matches!(
212                round.route_binary(&first).unwrap(),
213                RoundInbound::RoundProgress
214            ));
215            let second = tagged(REALTIME_TAG_ROUND, &response[split..]);
216            match round.route_binary(&second).unwrap() {
217                RoundInbound::RoundComplete(bytes) => {
218                    assert_eq!(bytes, response, "split {split}")
219                }
220                _ => panic!("split {split}: expected RoundComplete"),
221            }
222        }
223    }
224
225    #[test]
226    fn delta_during_round_is_queued_not_applied_to_round() {
227        let response = response_bytes();
228        let mut round = RealtimeRound::new();
229        round.begin(&[0x00]).unwrap();
230        // A stray delta arrives mid-round (server discipline forbids it, but
231        // the client tolerates-and-queues): it is surfaced as a Delta, and
232        // the round stays in flight.
233        let delta = tagged(REALTIME_TAG_DELTA, &[0xaa, 0xbb]);
234        match round.route_binary(&delta).unwrap() {
235            RoundInbound::Delta(body) => assert_eq!(body, vec![0xaa, 0xbb]),
236            _ => panic!("expected Delta"),
237        }
238        assert!(round.in_flight(), "delta does not end the round");
239        // The round still completes normally afterwards.
240        let frame = tagged(REALTIME_TAG_ROUND, &response);
241        assert!(matches!(
242            round.route_binary(&frame).unwrap(),
243            RoundInbound::RoundComplete(_)
244        ));
245    }
246
247    #[test]
248    fn bytes_past_end_fail_the_round() {
249        let mut response = response_bytes();
250        response.extend_from_slice(&[0xff, 0xff]);
251        let mut round = RealtimeRound::new();
252        round.begin(&[0x00]).unwrap();
253        let frame = tagged(REALTIME_TAG_ROUND, &response);
254        let err = round.route_binary(&frame).unwrap_err();
255        assert_eq!(err.code, "sync.transport_failed");
256        assert!(err.message.contains("past END"));
257        assert!(!round.in_flight());
258    }
259
260    #[test]
261    fn round_chunk_with_no_round_in_flight_is_ignored() {
262        let mut round = RealtimeRound::new();
263        let frame = tagged(REALTIME_TAG_ROUND, &[0x01, 0x02]);
264        assert!(matches!(
265            round.route_binary(&frame).unwrap(),
266            RoundInbound::Ignored
267        ));
268    }
269
270    #[test]
271    fn unknown_tag_is_ignored() {
272        let mut round = RealtimeRound::new();
273        round.begin(&[0x00]).unwrap();
274        let frame = tagged(0x7f, &[0x01]);
275        assert!(matches!(
276            round.route_binary(&frame).unwrap(),
277            RoundInbound::Ignored
278        ));
279        assert!(round.in_flight());
280    }
281
282    // Silence the unused-import lint for the aliased scanner (kept to document
283    // the reassembly primitive the round drives).
284    #[allow(dead_code)]
285    fn _uses_scanner(_: _Scanner) {}
286}