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