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}