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