Skip to main content

syncular_client/
native_transport.rs

1//! Shared native host transport for every Rust-backed Syncular binding.
2//!
3//! Two shapes behind one `HostTransport`:
4//!
5//! - `Null` (the dependency-lean default build): every network op fails loudly
6//!   with `transport.unavailable`. Client-local commands (create, subscribe,
7//!   mutate, readRows, conflicts, …) still run — enough for the C smoke test
8//!   and pure-logic tests, with zero HTTP/WS dependency compiled in.
9//! - `Native` (the `native-transport` feature): a real HTTP + WS client the
10//!   core drives itself, because a native app has no host loop to invert
11//!   transport into (unlike the conformance shim). `ureq` for blocking HTTP,
12//!   `tungstenite` for the WS socket; a reader thread buffers inbound frames.
13//!
14//! Inbound realtime frames land in a shared queue the host drains after each
15//! command. An optional wake callback lets mailbox-driven hosts react without
16//! polling. The socket implementation is deliberately singular: fairness and
17//! wire fixes therefore reach FFI, Tauri, and future Rust hosts together.
18
19use std::sync::{Arc, Mutex};
20
21use crate::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
22
23/// One inbound realtime frame buffered for the client's `on_realtime_*`.
24pub enum Inbound {
25    Text(String),
26    Binary(Vec<u8>),
27}
28
29/// The shared inbound buffer the WS reader thread fills and the command path
30/// drains. The optional callback carries no data; it only wakes an owning
31/// mailbox so the host can drain the buffer promptly.
32pub struct InboundBuffer {
33    frames: Mutex<Vec<Inbound>>,
34    notify: Option<Arc<dyn Fn() + Send + Sync>>,
35}
36
37impl Default for InboundBuffer {
38    fn default() -> Self {
39        Self {
40            frames: Mutex::new(Vec::new()),
41            notify: None,
42        }
43    }
44}
45
46impl InboundBuffer {
47    pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
48        Self {
49            frames: Mutex::new(Vec::new()),
50            notify: Some(notify),
51        }
52    }
53
54    pub fn push(&self, frame: Inbound) {
55        self.frames.lock().expect("inbound lock").push(frame);
56        if let Some(notify) = &self.notify {
57            notify();
58        }
59    }
60    fn take(&self) -> Vec<Inbound> {
61        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
62    }
63}
64
65pub enum HostTransport {
66    /// No network: client-local commands only (dependency-lean default).
67    Null {
68        signed_urls: bool,
69        inbound: Arc<InboundBuffer>,
70    },
71    #[cfg(feature = "native-transport")]
72    Native(native::NativeTransport),
73}
74
75impl HostTransport {
76    /// Build the transport from the `new` config. `{}` (or no `baseUrl`) →
77    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
78    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
79        Self::new_from_config(config)
80    }
81
82    /// [`Self::from_config`] for hosts outside this crate (the bench driver)
83    /// that own their inbound-frame drain and need no FFI event queue.
84    pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
85        Self::from_config_with_notify(config, None)
86    }
87
88    /// Build a transport whose realtime reader wakes a mailbox/event loop.
89    /// The callback is invoked after a frame is buffered and never carries
90    /// protocol data itself.
91    pub fn from_config_with_notify(
92        config: &serde_json::Value,
93        notify: Option<Arc<dyn Fn() + Send + Sync>>,
94    ) -> Result<Self, String> {
95        #[cfg(feature = "native-transport")]
96        {
97            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
98                return Ok(HostTransport::Native(native::NativeTransport::new(
99                    base_url, config, notify,
100                )?));
101            }
102        }
103        #[cfg(not(feature = "native-transport"))]
104        {
105            if config.get("baseUrl").is_some() {
106                return Err(
107                    "this build has no native transport (rebuild with --features native-transport)"
108                        .to_owned(),
109                );
110            }
111        }
112        Ok(HostTransport::Null {
113            signed_urls: false,
114            inbound: Arc::new(match notify {
115                Some(notify) => InboundBuffer::with_notify(notify),
116                None => InboundBuffer::default(),
117            }),
118        })
119    }
120
121    pub fn set_signed_urls(&mut self, value: bool) {
122        match self {
123            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
124            #[cfg(feature = "native-transport")]
125            HostTransport::Native(t) => t.signed_urls = value,
126        }
127    }
128
129    /// Replace auth/application headers for subsequent HTTP requests and the
130    /// next realtime connection. A live socket retains its handshake headers.
131    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
132        match self {
133            HostTransport::Null { .. } => drop(headers),
134            #[cfg(feature = "native-transport")]
135            HostTransport::Native(t) => t.set_headers(headers),
136        }
137    }
138
139    /// Drain the inbound realtime frames buffered since the last call.
140    pub fn take_inbound(&mut self) -> Vec<Inbound> {
141        match self {
142            HostTransport::Null { inbound, .. } => inbound.take(),
143            #[cfg(feature = "native-transport")]
144            HostTransport::Native(t) => t.inbound.take(),
145        }
146    }
147
148    /// Release the socket/reader thread. Idempotent.
149    pub fn shutdown(&mut self) {
150        match self {
151            HostTransport::Null { .. } => {}
152            #[cfg(feature = "native-transport")]
153            HostTransport::Native(t) => t.shutdown(),
154        }
155    }
156}
157
158fn unavailable(op: &str) -> TransportError {
159    TransportError::new(
160        "transport.unavailable",
161        format!("{op} needs the native transport (build with --features native-transport)"),
162    )
163}
164
165// Without the native transport the `Null` arm ignores every request payload;
166// the params are only consumed by the feature-gated `Native` arm.
167#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
168impl Transport for HostTransport {
169    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
170        match self {
171            HostTransport::Null { .. } => Err(unavailable("sync")),
172            #[cfg(feature = "native-transport")]
173            HostTransport::Native(t) => t.sync(request),
174        }
175    }
176
177    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
178        match self {
179            HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
180            #[cfg(feature = "native-transport")]
181            HostTransport::Native(t) => t.remote_operation(request),
182        }
183    }
184
185    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
186        match self {
187            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
188            #[cfg(feature = "native-transport")]
189            HostTransport::Native(t) => t.realtime_sync(request),
190        }
191    }
192
193    fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
194        match self {
195            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
196            #[cfg(feature = "native-transport")]
197            HostTransport::Native(t) => t.download_segment(request),
198        }
199    }
200
201    fn supports_url_fetch(&self) -> bool {
202        match self {
203            HostTransport::Null { signed_urls, .. } => *signed_urls,
204            #[cfg(feature = "native-transport")]
205            HostTransport::Native(t) => t.signed_urls,
206        }
207    }
208
209    fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
210        match self {
211            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
212            #[cfg(feature = "native-transport")]
213            HostTransport::Native(t) => t.fetch_url(url),
214        }
215    }
216
217    fn blob_upload(
218        &mut self,
219        blob_id: &str,
220        bytes: &[u8],
221        media_type: Option<&str>,
222    ) -> Result<(), TransportError> {
223        match self {
224            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
225            #[cfg(feature = "native-transport")]
226            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
227        }
228    }
229
230    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
231        match self {
232            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
233            #[cfg(feature = "native-transport")]
234            HostTransport::Native(t) => t.blob_download(blob_id),
235        }
236    }
237
238    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
239        match self {
240            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
241            #[cfg(feature = "native-transport")]
242            HostTransport::Native(t) => t.fetch_blob_url(url),
243        }
244    }
245
246    fn blob_upload_grant(
247        &mut self,
248        blob_id: &str,
249        byte_length: u64,
250        media_type: Option<&str>,
251    ) -> Result<BlobUploadGrant, TransportError> {
252        match self {
253            // No grant available ⇒ the client streams through the direct
254            // upload endpoint (§5.9.3 capability, not fallback).
255            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
256            #[cfg(feature = "native-transport")]
257            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
258        }
259    }
260
261    fn blob_put_url(
262        &mut self,
263        url: &str,
264        bytes: &[u8],
265        media_type: Option<&str>,
266    ) -> Result<(), TransportError> {
267        match self {
268            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
269            #[cfg(feature = "native-transport")]
270            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
271        }
272    }
273
274    fn realtime_connect(&mut self) -> Result<(), TransportError> {
275        match self {
276            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
277            #[cfg(feature = "native-transport")]
278            HostTransport::Native(t) => {
279                t.set_realtime_client_id(None);
280                t.realtime_connect()
281            }
282        }
283    }
284
285    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
286        match self {
287            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
288            #[cfg(feature = "native-transport")]
289            HostTransport::Native(t) => {
290                t.set_realtime_client_id(Some(client_id));
291                t.realtime_connect()
292            }
293        }
294    }
295
296    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
297        match self {
298            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
299            #[cfg(feature = "native-transport")]
300            HostTransport::Native(t) => t.realtime_send(text),
301        }
302    }
303
304    fn realtime_close(&mut self) -> Result<(), TransportError> {
305        match self {
306            HostTransport::Null { .. } => Ok(()),
307            #[cfg(feature = "native-transport")]
308            HostTransport::Native(t) => t.realtime_close(),
309        }
310    }
311}
312
313#[cfg(feature = "native-transport")]
314mod native {
315    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
316    //! async runtime — matches the client's synchronous API); WS via
317    //! `tungstenite`, with a reader thread pushing inbound frames into the
318    //! shared `InboundBuffer`.
319    //!
320    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
321    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
322    //! not needed here — the native app authenticates via configured headers),
323    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
324    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
325
326    use std::net::TcpStream;
327    use std::sync::atomic::{AtomicBool, Ordering};
328    use std::sync::{Arc, Condvar, Mutex};
329    use std::thread::JoinHandle;
330    use std::time::Duration;
331
332    use tungstenite::stream::MaybeTlsStream;
333    use tungstenite::{Message, WebSocket};
334
335    use super::{Inbound, InboundBuffer};
336    use crate::{
337        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
338        TransportError,
339    };
340
341    type Ws = WebSocket<MaybeTlsStream<TcpStream>>;
342
343    /// How long a single response round waits before giving up (§8.7 rounds
344    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
345    /// surfaces as a transport failure rather than hanging the caller forever.
346    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
347    /// The reader's per-iteration socket read timeout: bounds how long the
348    /// reader holds the socket lock across `ws.read()`, so `realtime_send`
349    /// (round request bytes + §8.2 acks) can interleave sends promptly. A
350    /// pending send waits out at most one read window, so this is the
351    /// worst-case send latency — keep it small (the wakeup churn on a quiet
352    /// socket is a few hundred cheap syscalls per second).
353    const READ_TIMEOUT: Duration = Duration::from_millis(5);
354    /// How long the reader parks OUTSIDE the socket lock after an empty read
355    /// window. Load-bearing for fairness, not just politeness: without it the
356    /// reader re-acquires the (unfair) mutex faster than a parked sender can
357    /// wake, and sends starve for seconds on a quiet socket (observed on
358    /// macOS: 30-150s per §8.7 round).
359    const READ_YIELD: Duration = Duration::from_micros(500);
360
361    /// The §8.7 round rendezvous shared between the reader thread (which
362    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
363    /// `realtime_sync` (which begins the round, sends the request, and blocks
364    /// here for the reassembled response). The transport-agnostic framing
365    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
366    /// the Tauri plugin); this struct is just the thread rendezvous.
367    #[derive(Default)]
368    pub(super) struct RoundChannel {
369        state: Mutex<RoundState>,
370        ready: Condvar,
371    }
372
373    #[derive(Default)]
374    struct RoundState {
375        round: RealtimeRound,
376        /// The completed round outcome, taken by `realtime_sync` once set.
377        outcome: Option<Result<Vec<u8>, TransportError>>,
378    }
379
380    impl RoundChannel {
381        /// Begin a round: frame the request (`0x01` tag + envelope) for the
382        /// socket and mark it in flight. Errors if one is already in flight
383        /// (§8.7 one-in-flight, enforced client-side).
384        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
385            let mut state = self.state.lock().expect("round lock");
386            state.outcome = None;
387            state.round.begin(request)
388        }
389
390        /// Route one inbound binary frame from the reader thread. Returns the
391        /// delta payload to enqueue on the inbound buffer, if any; a completed
392        /// or failed round is stored and the waiting `realtime_sync` woken.
393        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
394            let mut state = self.state.lock().expect("round lock");
395            match state.round.route_binary(frame) {
396                Ok(RoundInbound::Delta(body)) => Some(body),
397                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
398                Ok(RoundInbound::RoundComplete(bytes)) => {
399                    state.outcome = Some(Ok(bytes));
400                    self.ready.notify_all();
401                    None
402                }
403                Err(error) => {
404                    state.outcome = Some(Err(error));
405                    self.ready.notify_all();
406                    None
407                }
408            }
409        }
410
411        /// Fail any in-flight round (socket dropped) and wake the waiter.
412        fn fail_in_flight(&self, error: TransportError) {
413            let mut state = self.state.lock().expect("round lock");
414            if state.round.in_flight() && state.outcome.is_none() {
415                state.round.abort();
416                state.outcome = Some(Err(error));
417                self.ready.notify_all();
418            }
419        }
420
421        /// Block until the round completes, fails, or `ROUND_TIMEOUT` elapses.
422        fn wait(&self) -> Result<Vec<u8>, TransportError> {
423            let mut state = self.state.lock().expect("round lock");
424            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
425            while state.outcome.is_none() {
426                let now = std::time::Instant::now();
427                if now >= deadline {
428                    state.round.abort();
429                    return Err(TransportError::new(
430                        "sync.transport_failed",
431                        "realtime sync round timed out (§8.7)",
432                    ));
433                }
434                let (guard, _timeout) = self
435                    .ready
436                    .wait_timeout(state, deadline - now)
437                    .expect("round wait");
438                state = guard;
439            }
440            state.outcome.take().expect("outcome present")
441        }
442    }
443
444    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
445        TransportError::new("transport.failed", format!("{op}: {e}"))
446    }
447
448    /// A read error that is merely "no data within the timeout" — the reader
449    /// loops instead of tearing the socket down.
450    fn is_would_block(e: &tungstenite::Error) -> bool {
451        matches!(
452            e,
453            tungstenite::Error::Io(io) if matches!(
454                io.kind(),
455                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
456            )
457        )
458    }
459
460    /// Apply the reader's per-iteration read timeout to the live stream so
461    /// `ws.read()` yields the socket lock periodically (see `READ_TIMEOUT`).
462    fn set_read_timeout(ws: &mut Ws, timeout: Option<Duration>) {
463        match ws.get_mut() {
464            MaybeTlsStream::Plain(s) => {
465                let _ = s.set_read_timeout(timeout);
466            }
467            MaybeTlsStream::Rustls(s) => {
468                let _ = s.get_ref().set_read_timeout(timeout);
469            }
470            _ => {}
471        }
472    }
473
474    pub struct NativeTransport {
475        base_url: String,
476        ws_url: String,
477        /// Extra request headers (auth, actor/project ids) as (name, value).
478        headers: Vec<(String, String)>,
479        agent: ureq::Agent,
480        pub signed_urls: bool,
481        pub inbound: Arc<InboundBuffer>,
482        /// The live socket, shared with the reader thread for sends.
483        socket: Option<Arc<Mutex<Ws>>>,
484        reader: Option<JoinHandle<()>>,
485        reader_stop: Arc<AtomicBool>,
486        /// §8.7 round rendezvous, shared with the reader thread.
487        round: Arc<RoundChannel>,
488        realtime_client_id: Option<String>,
489    }
490
491    fn derive_ws_url(base_url: &str) -> String {
492        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
493        // realtime endpoint sits alongside /sync under the mount (§8.7).
494        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
495            format!("wss://{rest}")
496        } else if let Some(rest) = base_url.strip_prefix("http://") {
497            format!("ws://{rest}")
498        } else {
499            base_url.to_owned()
500        };
501        let trimmed = ws.trim_end_matches('/');
502        format!("{trimmed}/realtime")
503    }
504
505    impl NativeTransport {
506        pub fn new(
507            base_url: &str,
508            config: &serde_json::Value,
509            notify: Option<Arc<dyn Fn() + Send + Sync>>,
510        ) -> Result<Self, String> {
511            let mut headers = Vec::new();
512            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
513                for (k, v) in map {
514                    if let Some(s) = v.as_str() {
515                        headers.push((k.clone(), s.to_owned()));
516                    }
517                }
518            }
519            let ws_url = config
520                .get("wsUrl")
521                .and_then(|v| v.as_str())
522                .map(str::to_owned)
523                .unwrap_or_else(|| derive_ws_url(base_url));
524            Ok(NativeTransport {
525                base_url: base_url.trim_end_matches('/').to_owned(),
526                ws_url,
527                headers,
528                agent: ureq::Agent::new_with_defaults(),
529                signed_urls: false,
530                inbound: Arc::new(match notify {
531                    Some(notify) => InboundBuffer::with_notify(notify),
532                    None => InboundBuffer::default(),
533                }),
534                socket: None,
535                reader: None,
536                reader_stop: Arc::new(AtomicBool::new(false)),
537                round: Arc::new(RoundChannel::default()),
538                realtime_client_id: None,
539            })
540        }
541
542        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
543            let url = format!("{}{}", self.base_url, path);
544            // SSP2 requests carry their own media type (SPEC 1.1); a stock
545            // server answers 415 to anything else.
546            let mut req = self
547                .agent
548                .post(&url)
549                .header("content-type", "application/vnd.syncular.sync.v2");
550            for (k, v) in &self.headers {
551                req = req.header(k.as_str(), v.as_str());
552            }
553            let resp = req.send(body).map_err(|e| http_err("POST", e))?;
554            read_body(resp)
555        }
556
557        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
558            let url = format!("{}/operations", self.base_url);
559            let mut req = self.agent.post(&url).header(
560                "content-type",
561                "application/vnd.syncular.operations.v1+json",
562            );
563            for (key, value) in &self.headers {
564                req = req.header(key.as_str(), value.as_str());
565            }
566            let response = req
567                .send(body)
568                .map_err(|error| http_err("POST operation", error))?;
569            read_body(response)
570        }
571
572        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
573            let mut req = self.agent.get(url);
574            if with_headers {
575                for (k, v) in &self.headers {
576                    req = req.header(k.as_str(), v.as_str());
577                }
578            }
579            let resp = req.call().map_err(|e| http_err("GET", e))?;
580            read_body(resp)
581        }
582
583        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
584            self.headers = headers;
585        }
586
587        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
588            self.realtime_client_id = client_id.map(str::to_owned);
589        }
590
591        pub fn shutdown(&mut self) {
592            self.reader_stop.store(true, Ordering::SeqCst);
593            // Wake any `realtime_sync` blocked on a round: the socket is going
594            // away, so the round can never complete (§8.7 mid-round drop).
595            self.round.fail_in_flight(TransportError::new(
596                "sync.transport_failed",
597                "realtime disconnected mid-round (§8.7)",
598            ));
599            if let Some(socket) = &self.socket {
600                if let Ok(mut ws) = socket.lock() {
601                    let _ = ws.close(None);
602                    let _ = ws.flush();
603                }
604            }
605            if let Some(handle) = self.reader.take() {
606                let _ = handle.join();
607            }
608            self.socket = None;
609        }
610    }
611
612    fn read_body(resp: ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, TransportError> {
613        resp.into_body()
614            .into_with_config()
615            .read_to_vec()
616            .map_err(|e| http_err("read", e))
617    }
618
619    impl Transport for NativeTransport {
620        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
621            self.post_sync("/sync", request)
622        }
623
624        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
625            self.post_operation(request)
626        }
627
628        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
629            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
630            // the connected socket and block for the reassembled response
631            // stream (the reader thread demuxes `0x01` chunks to END, routing
632            // any `0x00` delta / text that interleaves to the inbound queue).
633            // When no socket is connected this is the client's "not connected"
634            // path — the caller (client core) only calls `realtime_sync` while
635            // `realtime_connected`, and connect established the socket; a
636            // missing socket here means the round rides HTTP instead (same
637            // rule as the TS client: `POST /sync` when the socket is absent).
638            let Some(socket) = self.socket.clone() else {
639                return self.post_sync("/sync", request);
640            };
641            let framed = self.round.begin(request)?;
642            // Send the whole request as one `0x01` chunk (boundaries are
643            // arbitrary, §8.7; the request is bounded — bulk rides segments).
644            let send = {
645                let mut ws = socket
646                    .lock()
647                    .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
648                ws.send(Message::Binary(framed.into()))
649                    .map_err(|e| http_err("ws round send", &e))
650                    .and_then(|()| ws.flush().map_err(|e| http_err("ws round flush", &e)))
651            };
652            if let Err(e) = send {
653                // Fail the started round so `wait` returns the send error, not
654                // a timeout.
655                self.round.fail_in_flight(e);
656            }
657            self.round.wait()
658        }
659
660        fn download_segment(
661            &mut self,
662            request: &SegmentRequest,
663        ) -> Result<Vec<u8>, TransportError> {
664            // The FULL content address (`sha256:<hex>`) is the path param —
665            // the reference server keys its segment store by it (§5.1) and
666            // answers `sync.not_found` to a bare hex id. The requested-scopes
667            // header carries what the pull round granted; the server
668            // re-authorizes the download against it (§5.5) and answers
669            // `sync.forbidden` when it is missing.
670            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
671            let mut req = self
672                .agent
673                .get(&url)
674                .header("x-syncular-scopes", &request.requested_scopes_json);
675            for (k, v) in &self.headers {
676                req = req.header(k.as_str(), v.as_str());
677            }
678            let resp = req.call().map_err(|e| http_err("GET segment", e))?;
679            read_body(resp)
680        }
681
682        fn supports_url_fetch(&self) -> bool {
683            self.signed_urls
684        }
685
686        fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
687            // §5.4: the URL is the entire grant — no host credentials attached.
688            self.get_bytes(url, false)
689        }
690
691        fn blob_upload(
692            &mut self,
693            blob_id: &str,
694            bytes: &[u8],
695            media_type: Option<&str>,
696        ) -> Result<(), TransportError> {
697            // Full `sha256:<hex>` id in the path — the reference server's
698            // isBlobId check rejects a bare hex id (§5.9.1).
699            let url = format!("{}/blobs/{}", self.base_url, blob_id);
700            let mut req = self.agent.put(&url).header(
701                "content-type",
702                media_type.unwrap_or("application/octet-stream"),
703            );
704            for (k, v) in &self.headers {
705                req = req.header(k.as_str(), v.as_str());
706            }
707            req.send(bytes).map_err(|e| http_err("PUT blob", e))?;
708            Ok(())
709        }
710
711        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
712            // Full `sha256:<hex>` id in the path — the reference server's
713            // isBlobId check rejects a bare hex id (§5.9.1).
714            let url = format!("{}/blobs/{}", self.base_url, blob_id);
715            let mut req = self.agent.get(&url);
716            for (k, v) in &self.headers {
717                req = req.header(k.as_str(), v.as_str());
718            }
719            let resp = req.call().map_err(|e| {
720                let e = http_err("GET blob", e);
721                // Preserve a blob.* semantics hint (§5.9.5) for the caller.
722                if e.code == "transport.failed" {
723                    TransportError::new("blob.not_found", e.message)
724                } else {
725                    e
726                }
727            })?;
728            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
729            // octet-stream body is inline bytes.
730            let is_json = resp
731                .headers()
732                .get(ureq::http::header::CONTENT_TYPE)
733                .and_then(|value| value.to_str().ok())
734                .is_some_and(|value| value.contains("application/json"));
735            let body = read_body(resp)?;
736            if is_json {
737                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
738                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
739                        return Ok(BlobDownload::Url {
740                            url: u.to_owned(),
741                            url_expires_at_ms: parsed
742                                .get("urlExpiresAtMs")
743                                .and_then(|v| v.as_i64()),
744                        });
745                    }
746                }
747            }
748            Ok(BlobDownload::Bytes(body))
749        }
750
751        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
752            // §5.9.5: the URL is the entire grant — no host credentials.
753            self.get_bytes(url, false)
754        }
755
756        fn blob_upload_grant(
757            &mut self,
758            blob_id: &str,
759            byte_length: u64,
760            media_type: Option<&str>,
761        ) -> Result<BlobUploadGrant, TransportError> {
762            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
763            let mut req = self
764                .agent
765                .post(&url)
766                .header("content-type", "application/json");
767            for (k, v) in &self.headers {
768                req = req.header(k.as_str(), v.as_str());
769            }
770            let body = serde_json::json!({
771                "byteLength": byte_length,
772                "mediaType": media_type,
773            });
774            let resp = req
775                .send(body.to_string())
776                .map_err(|e| http_err("POST upload-grant", e))?;
777            let grant_body = read_body(resp)?;
778            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
779                .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
780            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
781                return Ok(BlobUploadGrant::Url {
782                    url: u.to_owned(),
783                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
784                });
785            }
786            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
787                return Ok(BlobUploadGrant::Present);
788            }
789            Ok(BlobUploadGrant::None)
790        }
791
792        fn blob_put_url(
793            &mut self,
794            url: &str,
795            bytes: &[u8],
796            media_type: Option<&str>,
797        ) -> Result<(), TransportError> {
798            // §5.9.3: the presigned URL is the entire grant — no host auth.
799            let req = self.agent.put(url).header(
800                "content-type",
801                media_type.unwrap_or("application/octet-stream"),
802            );
803            req.send(bytes).map_err(|e| http_err("PUT blob url", e))?;
804            Ok(())
805        }
806
807        fn realtime_connect(&mut self) -> Result<(), TransportError> {
808            if self.socket.is_some() {
809                return Ok(());
810            }
811            // Build the client request VIA `IntoClientRequest` so tungstenite
812            // fills the mandatory handshake headers (Host / Connection /
813            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
814            // hand-built `http::Request` is taken as-is and would omit them.
815            // Then layer the configured auth/actor headers on top.
816            use tungstenite::client::IntoClientRequest;
817            let mut url = url::Url::parse(&self.ws_url)
818                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
819            if let Some(client_id) = self.realtime_client_id.as_deref() {
820                let retained_query: Vec<(String, String)> = url
821                    .query_pairs()
822                    .filter(|(key, _)| key != "clientId")
823                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
824                    .collect();
825                url.set_query(None);
826                url.query_pairs_mut()
827                    .extend_pairs(retained_query)
828                    .append_pair("clientId", client_id);
829            }
830            let mut request = url
831                .as_str()
832                .into_client_request()
833                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
834            {
835                let out = request.headers_mut();
836                for (k, v) in &self.headers {
837                    if let (Ok(name), Ok(value)) = (
838                        tungstenite::http::HeaderName::try_from(k.as_str()),
839                        tungstenite::http::HeaderValue::try_from(v.as_str()),
840                    ) {
841                        out.insert(name, value);
842                    }
843                }
844            }
845            let (mut ws, _resp) = tungstenite::connect(request)
846                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
847            // Bound how long the reader holds the socket lock across `ws.read()`
848            // so `realtime_sync` / ack sends can interleave promptly (§8.7 sends
849            // and reads share one socket).
850            set_read_timeout(&mut ws, Some(READ_TIMEOUT));
851            let socket = Arc::new(Mutex::new(ws));
852            self.socket = Some(Arc::clone(&socket));
853            // Reader thread: demux inbound binary frames by §8.7 channel tag —
854            // `0x01` round chunks feed the in-flight round (reassembled to END,
855            // handed back to the blocked `realtime_sync`); `0x00` deltas + text
856            // control frames go to the inbound buffer the command path drains.
857            self.reader_stop.store(false, Ordering::SeqCst);
858            let inbound = Arc::clone(&self.inbound);
859            let stop = Arc::clone(&self.reader_stop);
860            let reader_socket = Arc::clone(&socket);
861            let round = Arc::clone(&self.round);
862            self.reader = Some(std::thread::spawn(move || loop {
863                if stop.load(Ordering::SeqCst) {
864                    break;
865                }
866                let msg = {
867                    let mut ws = match reader_socket.lock() {
868                        Ok(ws) => ws,
869                        Err(_) => break,
870                    };
871                    ws.read()
872                };
873                match msg {
874                    Ok(Message::Text(text)) => inbound.push(Inbound::Text(text.to_string())),
875                    Ok(Message::Binary(bytes)) => {
876                        // §8.7 tag demux: round chunk → round channel; delta →
877                        // inbound (stripped of its tag, a bare SSP2 response
878                        // the client applies exactly like a pull, §8.2).
879                        if let Some(delta) = round.route_binary(&bytes) {
880                            inbound.push(Inbound::Binary(delta));
881                        }
882                    }
883                    // A read timeout is not a disconnect — loop and retry so a
884                    // quiet socket stays open. The yield sleep runs OUTSIDE
885                    // the socket lock so pending sends can interleave (see
886                    // `READ_YIELD` — senders starve without it).
887                    Err(e) if is_would_block(&e) => {
888                        std::thread::sleep(READ_YIELD);
889                        continue;
890                    }
891                    Ok(Message::Close(_)) | Err(_) => {
892                        // The socket is gone: fail any in-flight round so a
893                        // blocked `realtime_sync` wakes (§8.7 mid-round drop).
894                        round.fail_in_flight(TransportError::new(
895                            "sync.transport_failed",
896                            "realtime disconnected mid-round (§8.7)",
897                        ));
898                        break;
899                    }
900                    Ok(_) => {}
901                }
902            }));
903            Ok(())
904        }
905
906        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
907            let Some(socket) = &self.socket else {
908                return Err(TransportError::new(
909                    "transport.failed",
910                    "realtime not connected",
911                ));
912            };
913            let mut ws = socket
914                .lock()
915                .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
916            ws.send(Message::Text(text.to_owned().into()))
917                .map_err(|e| http_err("ws send", e))?;
918            ws.flush().map_err(|e| http_err("ws flush", e))?;
919            Ok(())
920        }
921
922        fn realtime_close(&mut self) -> Result<(), TransportError> {
923            self.shutdown();
924            Ok(())
925        }
926    }
927}