Skip to main content

tauri_plugin_syncular/
transport.rs

1//! The transport the plugin core OWNS — the same two-shape design as the FFI
2//! crate's transport (a native app has no host loop to invert transport into,
3//! unlike the conformance shim, so the Rust core drives the network itself).
4//!
5//! - `Null` (dependency-lean default): every network op fails loudly with
6//!   `transport.unavailable`; client-local commands (create/subscribe/mutate/
7//!   query/…) still run. Enough for the unit tests and an offline app.
8//! - `Native` (the `native-transport` feature): a real blocking HTTP (`ureq`)
9//!   plus WS (`tungstenite`) client with a reader thread buffering inbound
10//!   frames. This is what apps get so sync/segments/blobs/realtime all work.
11//!
12//! Kept deliberately parallel to `rust/crates/ffi/src/transport.rs`; the only
13//! difference is that this one carries no `EventQueue` (the plugin core derives
14//! events from client state after each drain, so the transport just buffers raw
15//! inbound frames).
16
17use std::sync::{Arc, Mutex};
18
19use syncular_client::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
20
21/// One inbound realtime frame buffered for the client's `on_realtime_*`.
22pub enum Inbound {
23    Text(String),
24    Binary(Vec<u8>),
25}
26
27/// The shared inbound buffer the WS reader thread fills and the command path
28/// drains after each command.
29pub struct InboundBuffer {
30    frames: Mutex<Vec<Inbound>>,
31    notify: Option<Arc<dyn Fn() + Send + Sync>>,
32}
33
34impl Default for InboundBuffer {
35    fn default() -> Self {
36        Self {
37            frames: Mutex::new(Vec::new()),
38            notify: None,
39        }
40    }
41}
42
43impl InboundBuffer {
44    pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
45        Self {
46            frames: Mutex::new(Vec::new()),
47            notify: Some(notify),
48        }
49    }
50
51    pub fn push(&self, frame: Inbound) {
52        self.frames.lock().expect("inbound lock").push(frame);
53        if let Some(notify) = &self.notify {
54            notify();
55        }
56    }
57    fn take(&self) -> Vec<Inbound> {
58        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
59    }
60}
61
62pub enum HostTransport {
63    /// No network: client-local commands only (dependency-lean default).
64    Null {
65        signed_urls: bool,
66        inbound: std::sync::Arc<InboundBuffer>,
67    },
68    #[cfg(feature = "native-transport")]
69    Native(native::NativeTransport),
70}
71
72impl HostTransport {
73    /// Build the transport from the plugin config. `{}` (or no `baseUrl`) →
74    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
75    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
76        Self::from_config_with_notify(config, None)
77    }
78
79    pub fn from_config_with_notify(
80        config: &serde_json::Value,
81        notify: Option<Arc<dyn Fn() + Send + Sync>>,
82    ) -> Result<Self, String> {
83        #[cfg(feature = "native-transport")]
84        {
85            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
86                return Ok(HostTransport::Native(native::NativeTransport::new(
87                    base_url, config, notify,
88                )?));
89            }
90        }
91        #[cfg(not(feature = "native-transport"))]
92        {
93            if config.get("baseUrl").is_some() {
94                return Err("this build has no native transport (enable the \
95                    native-transport feature on tauri-plugin-syncular)"
96                    .to_owned());
97            }
98        }
99        Ok(HostTransport::Null {
100            signed_urls: false,
101            inbound: Arc::new(match notify {
102                Some(notify) => InboundBuffer::with_notify(notify),
103                None => InboundBuffer::default(),
104            }),
105        })
106    }
107
108    pub fn set_signed_urls(&mut self, value: bool) {
109        match self {
110            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
111            #[cfg(feature = "native-transport")]
112            HostTransport::Native(t) => t.signed_urls = value,
113        }
114    }
115
116    /// Replace the extra request headers at runtime (rotating auth — RFC 0002
117    /// §2.3). HTTP requests use the new set from the next call; the WS
118    /// handshake reads headers at connect, so a live realtime socket keeps
119    /// its old set until it reconnects. A `Null` transport accepts and
120    /// ignores the set (it never sends requests).
121    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
122        match self {
123            HostTransport::Null { .. } => drop(headers),
124            #[cfg(feature = "native-transport")]
125            HostTransport::Native(t) => t.set_headers(headers),
126        }
127    }
128
129    /// Drain the inbound realtime frames buffered since the last call.
130    pub fn take_inbound(&mut self) -> Vec<Inbound> {
131        match self {
132            HostTransport::Null { inbound, .. } => inbound.take(),
133            #[cfg(feature = "native-transport")]
134            HostTransport::Native(t) => t.inbound.take(),
135        }
136    }
137
138    /// Release the socket/reader thread. Idempotent.
139    pub fn shutdown(&mut self) {
140        match self {
141            HostTransport::Null { .. } => {}
142            #[cfg(feature = "native-transport")]
143            HostTransport::Native(t) => t.shutdown(),
144        }
145    }
146}
147
148fn unavailable(op: &str) -> TransportError {
149    TransportError::new(
150        "transport.unavailable",
151        format!("{op} needs the native transport (enable the native-transport feature)"),
152    )
153}
154
155#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
156impl Transport for HostTransport {
157    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
158        match self {
159            HostTransport::Null { .. } => Err(unavailable("sync")),
160            #[cfg(feature = "native-transport")]
161            HostTransport::Native(t) => t.sync(request),
162        }
163    }
164
165    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
166        match self {
167            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
168            #[cfg(feature = "native-transport")]
169            HostTransport::Native(t) => t.realtime_sync(request),
170        }
171    }
172
173    fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
174        match self {
175            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
176            #[cfg(feature = "native-transport")]
177            HostTransport::Native(t) => t.download_segment(request),
178        }
179    }
180
181    fn supports_url_fetch(&self) -> bool {
182        match self {
183            HostTransport::Null { signed_urls, .. } => *signed_urls,
184            #[cfg(feature = "native-transport")]
185            HostTransport::Native(t) => t.signed_urls,
186        }
187    }
188
189    fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
190        match self {
191            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
192            #[cfg(feature = "native-transport")]
193            HostTransport::Native(t) => t.fetch_url(url),
194        }
195    }
196
197    fn blob_upload(
198        &mut self,
199        blob_id: &str,
200        bytes: &[u8],
201        media_type: Option<&str>,
202    ) -> Result<(), TransportError> {
203        match self {
204            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
205            #[cfg(feature = "native-transport")]
206            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
207        }
208    }
209
210    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
211        match self {
212            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
213            #[cfg(feature = "native-transport")]
214            HostTransport::Native(t) => t.blob_download(blob_id),
215        }
216    }
217
218    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
219        match self {
220            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
221            #[cfg(feature = "native-transport")]
222            HostTransport::Native(t) => t.fetch_blob_url(url),
223        }
224    }
225
226    fn blob_upload_grant(
227        &mut self,
228        blob_id: &str,
229        byte_length: u64,
230        media_type: Option<&str>,
231    ) -> Result<BlobUploadGrant, TransportError> {
232        match self {
233            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
234            #[cfg(feature = "native-transport")]
235            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
236        }
237    }
238
239    fn realtime_connect(&mut self) -> Result<(), TransportError> {
240        match self {
241            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
242            #[cfg(feature = "native-transport")]
243            HostTransport::Native(t) => t.realtime_connect(),
244        }
245    }
246
247    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
248        match self {
249            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
250            #[cfg(feature = "native-transport")]
251            HostTransport::Native(t) => t.realtime_send(text),
252        }
253    }
254
255    fn realtime_close(&mut self) -> Result<(), TransportError> {
256        match self {
257            HostTransport::Null { .. } => Ok(()),
258            #[cfg(feature = "native-transport")]
259            HostTransport::Native(t) => t.realtime_close(),
260        }
261    }
262}
263
264#[cfg(feature = "native-transport")]
265mod native {
266    //! The real native HTTP + WS transport (mirrors the FFI crate's `native`
267    //! module: `ureq` blocking HTTP, `tungstenite` sync WS, a reader thread
268    //! buffering inbound frames — matching the client's synchronous API).
269
270    use std::io::Read;
271    use std::net::TcpStream;
272    use std::sync::atomic::{AtomicBool, Ordering};
273    use std::sync::{Arc, Condvar, Mutex};
274    use std::thread::JoinHandle;
275    use std::time::Duration;
276
277    use tungstenite::stream::MaybeTlsStream;
278    use tungstenite::{Message, WebSocket};
279
280    use super::{Inbound, InboundBuffer};
281    use syncular_client::{
282        BlobDownload, RealtimeRound, RoundInbound, SegmentRequest, Transport, TransportError,
283    };
284
285    type Ws = WebSocket<MaybeTlsStream<TcpStream>>;
286
287    // §8.7 round-over-socket framing — kept byte-for-byte parallel with
288    // `rust/crates/ffi/src/transport.rs`'s `native` module (see that file for
289    // the full commentary). The transport-agnostic tag demux + reassembly
290    // lives in `syncular_client::RealtimeRound`, shared by both crates; only
291    // the WS send/read plumbing is mirrored here (the crates are in separate
292    // cargo workspaces and cannot share a private module).
293    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
294    const READ_TIMEOUT: Duration = Duration::from_millis(50);
295
296    /// The §8.7 round rendezvous shared between the reader thread and
297    /// `realtime_sync`. See the FFI crate's `RoundChannel` for details.
298    #[derive(Default)]
299    pub(super) struct RoundChannel {
300        state: Mutex<RoundState>,
301        ready: Condvar,
302    }
303
304    #[derive(Default)]
305    struct RoundState {
306        round: RealtimeRound,
307        outcome: Option<Result<Vec<u8>, TransportError>>,
308    }
309
310    impl RoundChannel {
311        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
312            let mut state = self.state.lock().expect("round lock");
313            state.outcome = None;
314            state.round.begin(request)
315        }
316
317        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
318            let mut state = self.state.lock().expect("round lock");
319            match state.round.route_binary(frame) {
320                Ok(RoundInbound::Delta(body)) => Some(body),
321                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
322                Ok(RoundInbound::RoundComplete(bytes)) => {
323                    state.outcome = Some(Ok(bytes));
324                    self.ready.notify_all();
325                    None
326                }
327                Err(error) => {
328                    state.outcome = Some(Err(error));
329                    self.ready.notify_all();
330                    None
331                }
332            }
333        }
334
335        fn fail_in_flight(&self, error: TransportError) {
336            let mut state = self.state.lock().expect("round lock");
337            if state.round.in_flight() && state.outcome.is_none() {
338                state.round.abort();
339                state.outcome = Some(Err(error));
340                self.ready.notify_all();
341            }
342        }
343
344        fn wait(&self) -> Result<Vec<u8>, TransportError> {
345            let mut state = self.state.lock().expect("round lock");
346            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
347            while state.outcome.is_none() {
348                let now = std::time::Instant::now();
349                if now >= deadline {
350                    state.round.abort();
351                    return Err(TransportError::new(
352                        "sync.transport_failed",
353                        "realtime sync round timed out (§8.7)",
354                    ));
355                }
356                let (guard, _timeout) = self
357                    .ready
358                    .wait_timeout(state, deadline - now)
359                    .expect("round wait");
360                state = guard;
361            }
362            state.outcome.take().expect("outcome present")
363        }
364    }
365
366    fn is_would_block(e: &tungstenite::Error) -> bool {
367        matches!(
368            e,
369            tungstenite::Error::Io(io) if matches!(
370                io.kind(),
371                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
372            )
373        )
374    }
375
376    fn set_read_timeout(ws: &mut Ws, timeout: Option<Duration>) {
377        match ws.get_mut() {
378            MaybeTlsStream::Plain(s) => {
379                let _ = s.set_read_timeout(timeout);
380            }
381            MaybeTlsStream::Rustls(s) => {
382                let _ = s.get_ref().set_read_timeout(timeout);
383            }
384            _ => {}
385        }
386    }
387
388    pub struct NativeTransport {
389        base_url: String,
390        ws_url: String,
391        headers: Vec<(String, String)>,
392        agent: ureq::Agent,
393        pub signed_urls: bool,
394        pub inbound: Arc<InboundBuffer>,
395        socket: Option<Arc<Mutex<Ws>>>,
396        reader: Option<JoinHandle<()>>,
397        reader_stop: Arc<AtomicBool>,
398        round: Arc<RoundChannel>,
399    }
400
401    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
402        TransportError::new("transport.failed", format!("{op}: {e}"))
403    }
404
405    fn derive_ws_url(base_url: &str) -> String {
406        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
407            format!("wss://{rest}")
408        } else if let Some(rest) = base_url.strip_prefix("http://") {
409            format!("ws://{rest}")
410        } else {
411            base_url.to_owned()
412        };
413        let trimmed = ws.trim_end_matches('/');
414        format!("{trimmed}/realtime")
415    }
416
417    impl NativeTransport {
418        pub fn new(
419            base_url: &str,
420            config: &serde_json::Value,
421            notify: Option<Arc<dyn Fn() + Send + Sync>>,
422        ) -> Result<Self, String> {
423            let mut headers = Vec::new();
424            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
425                for (k, v) in map {
426                    if let Some(s) = v.as_str() {
427                        headers.push((k.clone(), s.to_owned()));
428                    }
429                }
430            }
431            let ws_url = config
432                .get("wsUrl")
433                .and_then(|v| v.as_str())
434                .map(str::to_owned)
435                .unwrap_or_else(|| derive_ws_url(base_url));
436            Ok(NativeTransport {
437                base_url: base_url.trim_end_matches('/').to_owned(),
438                ws_url,
439                headers,
440                agent: ureq::AgentBuilder::new().build(),
441                signed_urls: false,
442                inbound: Arc::new(match notify {
443                    Some(notify) => InboundBuffer::with_notify(notify),
444                    None => InboundBuffer::default(),
445                }),
446                socket: None,
447                reader: None,
448                reader_stop: Arc::new(AtomicBool::new(false)),
449                round: Arc::new(RoundChannel::default()),
450            })
451        }
452
453        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
454            let url = format!("{}{}", self.base_url, path);
455            // SSP2 requests carry their own media type (SPEC 1.1); a stock
456            // server answers 415 to anything else.
457            let mut req = self
458                .agent
459                .post(&url)
460                .set("content-type", "application/vnd.syncular.sync.v2");
461            for (k, v) in &self.headers {
462                req = req.set(k, v);
463            }
464            let resp = req.send_bytes(body).map_err(|e| http_err("POST", e))?;
465            read_body(resp)
466        }
467
468        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
469            let mut req = self.agent.get(url);
470            if with_headers {
471                for (k, v) in &self.headers {
472                    req = req.set(k, v);
473                }
474            }
475            let resp = req.call().map_err(|e| http_err("GET", e))?;
476            read_body(resp)
477        }
478
479        /// Replace the per-request headers (see `HostTransport::set_headers`).
480        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
481            self.headers = headers;
482        }
483
484        pub fn shutdown(&mut self) {
485            self.reader_stop.store(true, Ordering::SeqCst);
486            self.round.fail_in_flight(TransportError::new(
487                "sync.transport_failed",
488                "realtime disconnected mid-round (§8.7)",
489            ));
490            if let Some(socket) = &self.socket {
491                if let Ok(mut ws) = socket.lock() {
492                    let _ = ws.close(None);
493                    let _ = ws.flush();
494                }
495            }
496            if let Some(handle) = self.reader.take() {
497                let _ = handle.join();
498            }
499            self.socket = None;
500        }
501    }
502
503    fn read_body(resp: ureq::Response) -> Result<Vec<u8>, TransportError> {
504        let mut buf = Vec::new();
505        resp.into_reader()
506            .read_to_end(&mut buf)
507            .map_err(|e| http_err("read", e))?;
508        Ok(buf)
509    }
510
511    impl Transport for NativeTransport {
512        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
513            self.post_sync("/sync", request)
514        }
515
516        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
517            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
518            // the connected socket, block for the reassembled response stream.
519            // No socket connected → the round rides HTTP (same rule as the TS
520            // client: `POST /sync` when the socket is absent).
521            let Some(socket) = self.socket.clone() else {
522                return self.post_sync("/sync", request);
523            };
524            let framed = self.round.begin(request)?;
525            let send = {
526                let mut ws = socket
527                    .lock()
528                    .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
529                ws.send(Message::Binary(framed))
530                    .map_err(|e| http_err("ws round send", &e))
531                    .and_then(|()| ws.flush().map_err(|e| http_err("ws round flush", &e)))
532            };
533            if let Err(e) = send {
534                self.round.fail_in_flight(e);
535            }
536            self.round.wait()
537        }
538
539        fn download_segment(
540            &mut self,
541            request: &SegmentRequest,
542        ) -> Result<Vec<u8>, TransportError> {
543            let id = request
544                .segment_id
545                .strip_prefix("sha256:")
546                .unwrap_or(&request.segment_id);
547            let url = format!("{}/segments/{}", self.base_url, id);
548            self.get_bytes(&url, true)
549        }
550
551        fn supports_url_fetch(&self) -> bool {
552            self.signed_urls
553        }
554
555        fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
556            self.get_bytes(url, false)
557        }
558
559        fn blob_upload(
560            &mut self,
561            blob_id: &str,
562            bytes: &[u8],
563            media_type: Option<&str>,
564        ) -> Result<(), TransportError> {
565            let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
566            let url = format!("{}/blobs/{}", self.base_url, id);
567            let mut req = self.agent.put(&url).set(
568                "content-type",
569                media_type.unwrap_or("application/octet-stream"),
570            );
571            for (k, v) in &self.headers {
572                req = req.set(k, v);
573            }
574            req.send_bytes(bytes).map_err(|e| http_err("PUT blob", e))?;
575            Ok(())
576        }
577
578        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
579            let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
580            let url = format!("{}/blobs/{}", self.base_url, id);
581            let mut req = self.agent.get(&url);
582            for (k, v) in &self.headers {
583                req = req.set(k, v);
584            }
585            let resp = req.call().map_err(|e| {
586                let e = http_err("GET blob", e);
587                if e.code == "transport.failed" {
588                    TransportError::new("blob.not_found", e.message)
589                } else {
590                    e
591                }
592            })?;
593            // 5.9.5 always-issue: a JSON body carries a presigned `url`; an
594            // octet-stream body is inline bytes. (Mirrors the FFI transport.)
595            let is_json = resp
596                .header("content-type")
597                .is_some_and(|ct| ct.contains("application/json"));
598            let body = read_body(resp)?;
599            if is_json {
600                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
601                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
602                        return Ok(BlobDownload::Url {
603                            url: u.to_owned(),
604                            url_expires_at_ms: parsed
605                                .get("urlExpiresAtMs")
606                                .and_then(|v| v.as_i64()),
607                        });
608                    }
609                }
610            }
611            Ok(BlobDownload::Bytes(body))
612        }
613
614        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
615            // 5.9.5: the URL is the entire grant — no host credentials.
616            self.get_bytes(url, false)
617        }
618
619        fn realtime_connect(&mut self) -> Result<(), TransportError> {
620            if self.socket.is_some() {
621                return Ok(());
622            }
623            // Build via `IntoClientRequest` so tungstenite fills the mandatory
624            // WS handshake headers (a hand-built `http::Request` would omit
625            // them); then layer the configured auth/actor headers on top.
626            use tungstenite::client::IntoClientRequest;
627            let mut request = self
628                .ws_url
629                .as_str()
630                .into_client_request()
631                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
632            {
633                let out = request.headers_mut();
634                for (k, v) in &self.headers {
635                    if let (Ok(name), Ok(value)) = (
636                        tungstenite::http::HeaderName::try_from(k.as_str()),
637                        tungstenite::http::HeaderValue::try_from(v.as_str()),
638                    ) {
639                        out.insert(name, value);
640                    }
641                }
642            }
643            let (mut ws, _resp) = tungstenite::connect(request)
644                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
645            set_read_timeout(&mut ws, Some(READ_TIMEOUT));
646            let socket = Arc::new(Mutex::new(ws));
647            self.socket = Some(Arc::clone(&socket));
648            self.reader_stop.store(false, Ordering::SeqCst);
649            let inbound = Arc::clone(&self.inbound);
650            let stop = Arc::clone(&self.reader_stop);
651            let reader_socket = Arc::clone(&socket);
652            let round = Arc::clone(&self.round);
653            self.reader = Some(std::thread::spawn(move || loop {
654                if stop.load(Ordering::SeqCst) {
655                    break;
656                }
657                let msg = {
658                    let mut ws = match reader_socket.lock() {
659                        Ok(ws) => ws,
660                        Err(_) => break,
661                    };
662                    ws.read()
663                };
664                match msg {
665                    Ok(Message::Text(text)) => inbound.push(Inbound::Text(text)),
666                    Ok(Message::Binary(bytes)) => {
667                        // §8.7 tag demux: `0x01` round chunk → round channel;
668                        // `0x00` delta → inbound (tag stripped, a bare SSP2
669                        // response the client applies like a pull, §8.2).
670                        if let Some(delta) = round.route_binary(&bytes) {
671                            inbound.push(Inbound::Binary(delta));
672                        }
673                    }
674                    Err(e) if is_would_block(&e) => continue,
675                    Ok(Message::Close(_)) | Err(_) => {
676                        round.fail_in_flight(TransportError::new(
677                            "sync.transport_failed",
678                            "realtime disconnected mid-round (§8.7)",
679                        ));
680                        break;
681                    }
682                    Ok(_) => {}
683                }
684            }));
685            Ok(())
686        }
687
688        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
689            let Some(socket) = &self.socket else {
690                return Err(TransportError::new(
691                    "transport.failed",
692                    "realtime not connected",
693                ));
694            };
695            let mut ws = socket
696                .lock()
697                .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
698            ws.send(Message::Text(text.to_owned()))
699                .map_err(|e| http_err("ws send", e))?;
700            ws.flush().map_err(|e| http_err("ws flush", e))?;
701            Ok(())
702        }
703
704        fn realtime_close(&mut self) -> Result<(), TransportError> {
705            self.shutdown();
706            Ok(())
707        }
708    }
709}