Skip to main content

syncular/
transport.rs

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