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    /// Network view for one I/O executor. The mutable owner keeps the reader
122    /// and inbound queue; no SQLite state or new socket is created.
123    pub fn fork_round(&self) -> Self {
124        match self {
125            Self::Null {
126                signed_urls,
127                inbound,
128            } => Self::Null {
129                signed_urls: *signed_urls,
130                inbound: inbound.clone(),
131            },
132            #[cfg(feature = "native-transport")]
133            Self::Native(t) => Self::Native(t.fork_round()),
134        }
135    }
136
137    pub fn set_signed_urls(&mut self, value: bool) {
138        match self {
139            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
140            #[cfg(feature = "native-transport")]
141            HostTransport::Native(t) => t.signed_urls = value,
142        }
143    }
144
145    /// Replace auth/application headers for subsequent HTTP requests and the
146    /// next realtime connection. A live socket retains its handshake headers.
147    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
148        match self {
149            HostTransport::Null { .. } => drop(headers),
150            #[cfg(feature = "native-transport")]
151            HostTransport::Native(t) => t.set_headers(headers),
152        }
153    }
154
155    /// Drain the inbound realtime frames buffered since the last call.
156    pub fn take_inbound(&mut self) -> Vec<Inbound> {
157        match self {
158            HostTransport::Null { inbound, .. } => inbound.take(),
159            #[cfg(feature = "native-transport")]
160            HostTransport::Native(t) => t.inbound.take(),
161        }
162    }
163
164    /// Release the socket/reader thread. Idempotent.
165    pub fn shutdown(&mut self) {
166        match self {
167            HostTransport::Null { .. } => {}
168            #[cfg(feature = "native-transport")]
169            HostTransport::Native(t) => t.shutdown(),
170        }
171    }
172}
173
174fn unavailable(op: &str) -> TransportError {
175    TransportError::new(
176        "transport.unavailable",
177        format!("{op} needs the native transport (build with --features native-transport)"),
178    )
179}
180
181// Without the native transport the `Null` arm ignores every request payload;
182// the params are only consumed by the feature-gated `Native` arm.
183#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
184impl Transport for HostTransport {
185    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
186        match self {
187            HostTransport::Null { .. } => Err(unavailable("sync")),
188            #[cfg(feature = "native-transport")]
189            HostTransport::Native(t) => t.sync(request),
190        }
191    }
192
193    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
194        match self {
195            HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
196            #[cfg(feature = "native-transport")]
197            HostTransport::Native(t) => t.remote_operation(request),
198        }
199    }
200
201    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
202        match self {
203            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
204            #[cfg(feature = "native-transport")]
205            HostTransport::Native(t) => t.realtime_sync(request),
206        }
207    }
208
209    fn download_segment(
210        &mut self,
211        request: &SegmentRequest,
212        on_progress: &mut dyn FnMut(u64),
213    ) -> Result<Vec<u8>, TransportError> {
214        match self {
215            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
216            #[cfg(feature = "native-transport")]
217            HostTransport::Native(t) => t.download_segment(request, on_progress),
218        }
219    }
220
221    fn supports_url_fetch(&self) -> bool {
222        match self {
223            HostTransport::Null { signed_urls, .. } => *signed_urls,
224            #[cfg(feature = "native-transport")]
225            HostTransport::Native(t) => t.signed_urls,
226        }
227    }
228
229    fn fetch_url(
230        &mut self,
231        url: &str,
232        on_progress: &mut dyn FnMut(u64),
233    ) -> Result<Vec<u8>, TransportError> {
234        match self {
235            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
236            #[cfg(feature = "native-transport")]
237            HostTransport::Native(t) => t.fetch_url(url, on_progress),
238        }
239    }
240
241    fn blob_upload(
242        &mut self,
243        blob_id: &str,
244        bytes: &[u8],
245        media_type: Option<&str>,
246    ) -> Result<(), TransportError> {
247        match self {
248            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
249            #[cfg(feature = "native-transport")]
250            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
251        }
252    }
253
254    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
255        match self {
256            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
257            #[cfg(feature = "native-transport")]
258            HostTransport::Native(t) => t.blob_download(blob_id),
259        }
260    }
261
262    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
263        match self {
264            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
265            #[cfg(feature = "native-transport")]
266            HostTransport::Native(t) => t.fetch_blob_url(url),
267        }
268    }
269
270    fn blob_upload_grant(
271        &mut self,
272        blob_id: &str,
273        byte_length: u64,
274        media_type: Option<&str>,
275    ) -> Result<BlobUploadGrant, TransportError> {
276        match self {
277            // No grant available ⇒ the client streams through the direct
278            // upload endpoint (§5.9.3 capability, not fallback).
279            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
280            #[cfg(feature = "native-transport")]
281            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
282        }
283    }
284
285    fn blob_put_url(
286        &mut self,
287        url: &str,
288        bytes: &[u8],
289        media_type: Option<&str>,
290    ) -> Result<(), TransportError> {
291        match self {
292            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
293            #[cfg(feature = "native-transport")]
294            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
295        }
296    }
297
298    fn realtime_connect(&mut self) -> Result<(), TransportError> {
299        match self {
300            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
301            #[cfg(feature = "native-transport")]
302            HostTransport::Native(t) => {
303                t.set_realtime_client_id(None);
304                t.realtime_connect()
305            }
306        }
307    }
308
309    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
310        match self {
311            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
312            #[cfg(feature = "native-transport")]
313            HostTransport::Native(t) => {
314                t.set_realtime_client_id(Some(client_id));
315                t.realtime_connect()
316            }
317        }
318    }
319
320    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
321        match self {
322            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
323            #[cfg(feature = "native-transport")]
324            HostTransport::Native(t) => t.realtime_send(text),
325        }
326    }
327
328    fn realtime_close(&mut self) -> Result<(), TransportError> {
329        match self {
330            HostTransport::Null { .. } => Ok(()),
331            #[cfg(feature = "native-transport")]
332            HostTransport::Native(t) => t.realtime_close(),
333        }
334    }
335}
336
337#[cfg(feature = "native-transport")]
338mod native {
339    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
340    //! async runtime — matches the client's synchronous API); WS via
341    //! `tungstenite`, with a reader thread pushing inbound frames into the
342    //! shared `InboundBuffer`.
343    //!
344    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
345    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
346    //! not needed here — the native app authenticates via configured headers),
347    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
348    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
349
350    use std::net::TcpStream;
351    use std::sync::atomic::{AtomicBool, Ordering};
352    use std::sync::{mpsc, Arc, Condvar, Mutex};
353    use std::thread::JoinHandle;
354    use std::time::Duration;
355
356    use polling::{Event, Events, Poller};
357    use tungstenite::stream::MaybeTlsStream;
358    use tungstenite::Message;
359
360    use super::{Inbound, InboundBuffer};
361    use crate::{
362        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
363        TransportError,
364    };
365
366    // Unregister before the cloned socket handle is closed, including on unwind.
367    struct SocketRegistration {
368        poller: Arc<Poller>,
369        stream: TcpStream,
370    }
371
372    impl Drop for SocketRegistration {
373        fn drop(&mut self) {
374            let _ = self.poller.delete(&self.stream);
375        }
376    }
377
378    #[derive(Clone)]
379    struct SocketSender {
380        queue: mpsc::SyncSender<Outgoing>,
381        poller: Arc<Poller>,
382    }
383
384    struct Outgoing {
385        message: Message,
386        completed: mpsc::Sender<Result<(), TransportError>>,
387    }
388
389    /// How long a single response round waits before giving up (§8.7 rounds
390    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
391    /// surfaces as a transport failure rather than hanging the caller forever.
392    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
393    /// The §8.7 round rendezvous shared between the reader thread (which
394    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
395    /// `realtime_sync` (which begins the round, sends the request, and blocks
396    /// here for the reassembled response). The transport-agnostic framing
397    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
398    /// the Tauri plugin); this struct is just the thread rendezvous.
399    #[derive(Default)]
400    pub(super) struct RoundChannel {
401        state: Mutex<RoundState>,
402        ready: Condvar,
403    }
404
405    #[derive(Default)]
406    struct RoundState {
407        round: RealtimeRound,
408        /// The completed round outcome, taken by `realtime_sync` once set.
409        outcome: Option<Result<Vec<u8>, TransportError>>,
410    }
411
412    impl RoundChannel {
413        /// Begin a round: frame the request (`0x01` tag + envelope) for the
414        /// socket and mark it in flight. Errors if one is already in flight
415        /// (§8.7 one-in-flight, enforced client-side).
416        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
417            let mut state = self.state.lock().expect("round lock");
418            state.outcome = None;
419            state.round.begin(request)
420        }
421
422        /// Route one inbound binary frame from the reader thread. Returns the
423        /// delta payload to enqueue on the inbound buffer, if any; a completed
424        /// or failed round is stored and the waiting `realtime_sync` woken.
425        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
426            let mut state = self.state.lock().expect("round lock");
427            match state.round.route_binary(frame) {
428                Ok(RoundInbound::Delta(body)) => Some(body),
429                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
430                Ok(RoundInbound::RoundComplete(bytes)) => {
431                    state.outcome = Some(Ok(bytes));
432                    self.ready.notify_all();
433                    None
434                }
435                Err(error) => {
436                    state.outcome = Some(Err(error));
437                    self.ready.notify_all();
438                    None
439                }
440            }
441        }
442
443        /// Fail any in-flight round (socket dropped) and wake the waiter.
444        fn fail_in_flight(&self, error: TransportError) {
445            let mut state = self.state.lock().expect("round lock");
446            if state.round.in_flight() && state.outcome.is_none() {
447                state.round.abort();
448                state.outcome = Some(Err(error));
449                self.ready.notify_all();
450            }
451        }
452
453        /// Block until the round completes, fails, or `ROUND_TIMEOUT` elapses.
454        fn wait(&self) -> Result<Vec<u8>, TransportError> {
455            let mut state = self.state.lock().expect("round lock");
456            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
457            while state.outcome.is_none() {
458                let now = std::time::Instant::now();
459                if now >= deadline {
460                    state.round.abort();
461                    return Err(TransportError::new(
462                        "sync.transport_failed",
463                        "realtime sync round timed out (§8.7)",
464                    ));
465                }
466                let (guard, _timeout) = self
467                    .ready
468                    .wait_timeout(state, deadline - now)
469                    .expect("round wait");
470                state = guard;
471            }
472            state.outcome.take().expect("outcome present")
473        }
474    }
475
476    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
477        TransportError::new("transport.failed", format!("{op}: {e}"))
478    }
479
480    fn segment_error(
481        path: Option<&str>,
482        http_status: Option<u16>,
483        cause: impl std::fmt::Display,
484    ) -> TransportError {
485        let mut details = serde_json::json!({ "causeMessage": cause.to_string() });
486        if let Some(path) = path {
487            details["path"] = path.into();
488        }
489        if let Some(status) = http_status {
490            details["httpStatus"] = status.into();
491        }
492        let mut error = TransportError::new("sync.transport_failed", "segment transfer failed");
493        error.details = Some(details);
494        error
495    }
496
497    /// Nonblocking I/O needs a readiness notification before it can continue.
498    fn is_would_block(e: &tungstenite::Error) -> bool {
499        matches!(
500            e,
501            tungstenite::Error::Io(io) if matches!(
502                io.kind(),
503                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
504            )
505        )
506    }
507
508    pub struct NativeTransport {
509        base_url: String,
510        ws_url: String,
511        /// Extra request headers (auth, actor/project ids) as (name, value).
512        headers: Vec<(String, String)>,
513        agent: ureq::Agent,
514        pub signed_urls: bool,
515        pub inbound: Arc<InboundBuffer>,
516        /// One I/O thread owns the socket; callers wait for their write to flush.
517        outgoing: Option<Box<SocketSender>>,
518        reader: Option<JoinHandle<()>>,
519        reader_stop: Arc<AtomicBool>,
520        /// §8.7 round rendezvous, shared with the reader thread.
521        round: Arc<RoundChannel>,
522        realtime_client_id: Option<String>,
523    }
524
525    fn derive_ws_url(base_url: &str) -> String {
526        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
527        // realtime endpoint sits alongside /sync under the mount (§8.7).
528        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
529            format!("wss://{rest}")
530        } else if let Some(rest) = base_url.strip_prefix("http://") {
531            format!("ws://{rest}")
532        } else {
533            base_url.to_owned()
534        };
535        let trimmed = ws.trim_end_matches('/');
536        format!("{trimmed}/realtime")
537    }
538
539    impl NativeTransport {
540        pub fn fork_round(&self) -> Self {
541            Self {
542                base_url: self.base_url.clone(),
543                ws_url: self.ws_url.clone(),
544                headers: self.headers.clone(),
545                agent: self.agent.clone(),
546                signed_urls: self.signed_urls,
547                inbound: self.inbound.clone(),
548                outgoing: self.outgoing.clone(),
549                reader: None,
550                reader_stop: self.reader_stop.clone(),
551                round: self.round.clone(),
552                realtime_client_id: self.realtime_client_id.clone(),
553            }
554        }
555
556        pub fn new(
557            base_url: &str,
558            config: &serde_json::Value,
559            notify: Option<Arc<dyn Fn() + Send + Sync>>,
560        ) -> Result<Self, String> {
561            let mut headers = Vec::new();
562            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
563                for (k, v) in map {
564                    if let Some(s) = v.as_str() {
565                        headers.push((k.clone(), s.to_owned()));
566                    }
567                }
568            }
569            let ws_url = config
570                .get("wsUrl")
571                .and_then(|v| v.as_str())
572                .map(str::to_owned)
573                .unwrap_or_else(|| derive_ws_url(base_url));
574            Ok(NativeTransport {
575                base_url: base_url.trim_end_matches('/').to_owned(),
576                ws_url,
577                headers,
578                agent: ureq::Agent::new_with_defaults(),
579                signed_urls: false,
580                inbound: Arc::new(match notify {
581                    Some(notify) => InboundBuffer::with_notify(notify),
582                    None => InboundBuffer::default(),
583                }),
584                outgoing: None,
585                reader: None,
586                reader_stop: Arc::new(AtomicBool::new(false)),
587                round: Arc::new(RoundChannel::default()),
588                realtime_client_id: None,
589            })
590        }
591
592        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
593            let url = format!("{}{}", self.base_url, path);
594            // SSP2 requests carry their own media type (SPEC 1.1); a stock
595            // server answers 415 to anything else.
596            let mut req = self
597                .agent
598                .post(&url)
599                .header("content-type", "application/vnd.syncular.sync.v2");
600            for (k, v) in &self.headers {
601                req = req.header(k.as_str(), v.as_str());
602            }
603            let resp = req.send(body).map_err(|e| http_err("POST", e))?;
604            read_body(resp, None)
605        }
606
607        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
608            let url = format!("{}/operations", self.base_url);
609            let mut req = self.agent.post(&url).header(
610                "content-type",
611                "application/vnd.syncular.operations.v1+json",
612            );
613            for (key, value) in &self.headers {
614                req = req.header(key.as_str(), value.as_str());
615            }
616            let response = req
617                .send(body)
618                .map_err(|error| http_err("POST operation", error))?;
619            read_body(response, None)
620        }
621
622        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
623            let mut req = self.agent.get(url);
624            if with_headers {
625                for (k, v) in &self.headers {
626                    req = req.header(k.as_str(), v.as_str());
627                }
628            }
629            let resp = req.call().map_err(|e| http_err("GET", e))?;
630            read_body(resp, None)
631        }
632
633        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
634            self.headers = headers;
635        }
636
637        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
638            self.realtime_client_id = client_id.map(str::to_owned);
639        }
640
641        pub fn shutdown(&mut self) {
642            self.reader_stop.store(true, Ordering::SeqCst);
643            // Wake any `realtime_sync` blocked on a round: the socket is going
644            // away, so the round can never complete (§8.7 mid-round drop).
645            self.round.fail_in_flight(TransportError::new(
646                "sync.transport_failed",
647                "realtime disconnected mid-round (§8.7)",
648            ));
649            if let Some(outgoing) = &self.outgoing {
650                let _ = outgoing.poller.notify();
651            }
652            if let Some(handle) = self.reader.take() {
653                let _ = handle.join();
654            }
655            self.outgoing = None;
656        }
657
658        fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
659            let result = (|| {
660                let Some(outgoing) = &self.outgoing else {
661                    return Err(TransportError::new(
662                        "transport.failed",
663                        "realtime not connected",
664                    ));
665                };
666                let (completed, result) = mpsc::channel();
667                outgoing
668                    .queue
669                    .try_send(Outgoing { message, completed })
670                    .map_err(|_| {
671                        TransportError::new("transport.failed", "realtime send queue unavailable")
672                    })?;
673                outgoing
674                    .poller
675                    .notify()
676                    .map_err(|e| http_err("ws wake", e))?;
677                result.recv_timeout(ROUND_TIMEOUT).map_err(|_| {
678                    TransportError::new("transport.failed", "realtime send did not complete")
679                })?
680            })();
681            // A failed/timed-out write must not remain eligible for delivery
682            // behind a later command on the same connection.
683            if result.is_err() {
684                self.shutdown();
685            }
686            result
687        }
688    }
689
690    fn read_body(
691        resp: ureq::http::Response<ureq::Body>,
692        mut progress: Option<&mut dyn FnMut(u64)>,
693    ) -> Result<Vec<u8>, TransportError> {
694        use std::io::Read;
695        let mut reader = resp.into_body().into_with_config().reader();
696        let mut bytes = Vec::new();
697        let mut chunk = [0u8; 64 * 1024];
698        let mut reported = 0;
699        loop {
700            let n = reader.read(&mut chunk).map_err(|e| http_err("read", e))?;
701            if n == 0 {
702                break;
703            }
704            bytes.extend_from_slice(&chunk[..n]);
705            if bytes.len() - reported >= 64 * 1024 {
706                if let Some(callback) = progress.as_mut() {
707                    callback(bytes.len() as u64);
708                }
709                reported = bytes.len();
710            }
711        }
712        if bytes.len() != reported {
713            if let Some(callback) = progress {
714                callback(bytes.len() as u64);
715            }
716        }
717        Ok(bytes)
718    }
719
720    impl Transport for NativeTransport {
721        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
722            self.post_sync("/sync", request)
723        }
724
725        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
726            self.post_operation(request)
727        }
728
729        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
730            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
731            // the connected socket and block for the reassembled response
732            // stream (the reader thread demuxes `0x01` chunks to END, routing
733            // any `0x00` delta / text that interleaves to the inbound queue).
734            // When no socket is connected this is the client's "not connected"
735            // path — the caller (client core) only calls `realtime_sync` while
736            // `realtime_connected`, and connect established the socket; a
737            // missing socket here means the round rides HTTP instead (same
738            // rule as the TS client: `POST /sync` when the socket is absent).
739            if self.outgoing.is_none() {
740                return self.post_sync("/sync", request);
741            }
742            let framed = self.round.begin(request)?;
743            if let Err(error) = self.send_message(Message::Binary(framed.into())) {
744                self.round.fail_in_flight(error);
745            }
746            self.round.wait()
747        }
748
749        fn download_segment(
750            &mut self,
751            request: &SegmentRequest,
752            on_progress: &mut dyn FnMut(u64),
753        ) -> Result<Vec<u8>, TransportError> {
754            // The FULL content address (`sha256:<hex>`) is the path param —
755            // the reference server keys its segment store by it (§5.1) and
756            // answers `sync.not_found` to a bare hex id. The requested-scopes
757            // header carries what the pull round granted; the server
758            // re-authorizes the download against it (§5.5) and answers
759            // `sync.forbidden` when it is missing.
760            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
761            let parsed = url::Url::parse(&url).map_err(|error| segment_error(None, None, error))?;
762            let mut req = self
763                .agent
764                .get(&url)
765                .header("x-syncular-scopes", &request.requested_scopes_json);
766            for (k, v) in &self.headers {
767                req = req.header(k.as_str(), v.as_str());
768            }
769            let resp = req.call().map_err(|error| {
770                let status = match &error {
771                    ureq::Error::StatusCode(status) => Some(*status),
772                    _ => None,
773                };
774                segment_error(Some(parsed.path()), status, error)
775            })?;
776            let status = resp.status().as_u16();
777            read_body(resp, Some(on_progress))
778                .map_err(|error| segment_error(Some(parsed.path()), Some(status), error.message))
779        }
780
781        fn supports_url_fetch(&self) -> bool {
782            self.signed_urls
783        }
784
785        fn fetch_url(
786            &mut self,
787            url: &str,
788            on_progress: &mut dyn FnMut(u64),
789        ) -> Result<Vec<u8>, TransportError> {
790            // §5.4: the URL is the entire grant — no host credentials attached.
791            let parsed = url::Url::parse(url).map_err(|error| segment_error(None, None, error))?;
792            let resp = self.agent.get(url).call().map_err(|error| {
793                let status = match &error {
794                    ureq::Error::StatusCode(status) => Some(*status),
795                    _ => None,
796                };
797                segment_error(Some(parsed.path()), status, error)
798            })?;
799            let status = resp.status().as_u16();
800            read_body(resp, Some(on_progress))
801                .map_err(|error| segment_error(Some(parsed.path()), Some(status), error.message))
802        }
803
804        fn blob_upload(
805            &mut self,
806            blob_id: &str,
807            bytes: &[u8],
808            media_type: Option<&str>,
809        ) -> Result<(), TransportError> {
810            // Full `sha256:<hex>` id in the path — the reference server's
811            // isBlobId check rejects a bare hex id (§5.9.1).
812            let url = format!("{}/blobs/{}", self.base_url, blob_id);
813            let mut req = self.agent.put(&url).header(
814                "content-type",
815                media_type.unwrap_or("application/octet-stream"),
816            );
817            for (k, v) in &self.headers {
818                req = req.header(k.as_str(), v.as_str());
819            }
820            req.send(bytes).map_err(|e| http_err("PUT blob", e))?;
821            Ok(())
822        }
823
824        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
825            // Full `sha256:<hex>` id in the path — the reference server's
826            // isBlobId check rejects a bare hex id (§5.9.1).
827            let url = format!("{}/blobs/{}", self.base_url, blob_id);
828            let mut req = self.agent.get(&url);
829            for (k, v) in &self.headers {
830                req = req.header(k.as_str(), v.as_str());
831            }
832            let resp = req.call().map_err(|e| {
833                let e = http_err("GET blob", e);
834                // Preserve a blob.* semantics hint (§5.9.5) for the caller.
835                if e.code == "transport.failed" {
836                    TransportError::new("blob.not_found", e.message)
837                } else {
838                    e
839                }
840            })?;
841            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
842            // octet-stream body is inline bytes.
843            let is_json = resp
844                .headers()
845                .get(ureq::http::header::CONTENT_TYPE)
846                .and_then(|value| value.to_str().ok())
847                .is_some_and(|value| value.contains("application/json"));
848            let body = read_body(resp, None)?;
849            if is_json {
850                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
851                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
852                        return Ok(BlobDownload::Url {
853                            url: u.to_owned(),
854                            url_expires_at_ms: parsed
855                                .get("urlExpiresAtMs")
856                                .and_then(|v| v.as_i64()),
857                        });
858                    }
859                }
860            }
861            Ok(BlobDownload::Bytes(body))
862        }
863
864        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
865            // §5.9.5: the URL is the entire grant — no host credentials.
866            self.get_bytes(url, false)
867        }
868
869        fn blob_upload_grant(
870            &mut self,
871            blob_id: &str,
872            byte_length: u64,
873            media_type: Option<&str>,
874        ) -> Result<BlobUploadGrant, TransportError> {
875            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
876            let mut req = self
877                .agent
878                .post(&url)
879                .header("content-type", "application/json");
880            for (k, v) in &self.headers {
881                req = req.header(k.as_str(), v.as_str());
882            }
883            let body = serde_json::json!({
884                "byteLength": byte_length,
885                "mediaType": media_type,
886            });
887            let resp = req
888                .send(body.to_string())
889                .map_err(|e| http_err("POST upload-grant", e))?;
890            let grant_body = read_body(resp, None)?;
891            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
892                .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
893            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
894                return Ok(BlobUploadGrant::Url {
895                    url: u.to_owned(),
896                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
897                });
898            }
899            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
900                return Ok(BlobUploadGrant::Present);
901            }
902            Ok(BlobUploadGrant::None)
903        }
904
905        fn blob_put_url(
906            &mut self,
907            url: &str,
908            bytes: &[u8],
909            media_type: Option<&str>,
910        ) -> Result<(), TransportError> {
911            // §5.9.3: the presigned URL is the entire grant — no host auth.
912            let req = self.agent.put(url).header(
913                "content-type",
914                media_type.unwrap_or("application/octet-stream"),
915            );
916            req.send(bytes).map_err(|e| http_err("PUT blob url", e))?;
917            Ok(())
918        }
919
920        fn realtime_connect(&mut self) -> Result<(), TransportError> {
921            if self.outgoing.is_some() {
922                if self.reader_stop.load(Ordering::SeqCst)
923                    || self
924                        .reader
925                        .as_ref()
926                        .is_some_and(|reader| reader.is_finished())
927                {
928                    self.shutdown();
929                } else {
930                    return Ok(());
931                }
932            }
933            // Build the client request VIA `IntoClientRequest` so tungstenite
934            // fills the mandatory handshake headers (Host / Connection /
935            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
936            // hand-built `http::Request` is taken as-is and would omit them.
937            // Then layer the configured auth/actor headers on top.
938            use tungstenite::client::IntoClientRequest;
939            let mut url = url::Url::parse(&self.ws_url)
940                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
941            if let Some(client_id) = self.realtime_client_id.as_deref() {
942                let retained_query: Vec<(String, String)> = url
943                    .query_pairs()
944                    .filter(|(key, _)| key != "clientId")
945                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
946                    .collect();
947                url.set_query(None);
948                url.query_pairs_mut()
949                    .extend_pairs(retained_query)
950                    .append_pair("clientId", client_id);
951            }
952            let mut request = url
953                .as_str()
954                .into_client_request()
955                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
956            {
957                let out = request.headers_mut();
958                for (k, v) in &self.headers {
959                    if let (Ok(name), Ok(value)) = (
960                        tungstenite::http::HeaderName::try_from(k.as_str()),
961                        tungstenite::http::HeaderValue::try_from(v.as_str()),
962                    ) {
963                        out.insert(name, value);
964                    }
965                }
966            }
967            let (mut ws, _resp) = tungstenite::connect(request)
968                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
969            let stream = match ws.get_mut() {
970                MaybeTlsStream::Plain(stream) => stream.try_clone(),
971                MaybeTlsStream::Rustls(stream) => stream.get_ref().try_clone(),
972                _ => {
973                    return Err(TransportError::new(
974                        "transport.failed",
975                        "unsupported websocket stream",
976                    ))
977                }
978            }
979            .map_err(|e| http_err("ws clone", e))?;
980            stream
981                .set_nonblocking(true)
982                .map_err(|e| http_err("ws nonblocking", e))?;
983            let poller = Arc::new(Poller::new().map_err(|e| http_err("ws poller", e))?);
984            // SAFETY: SocketRegistration owns this handle and unregisters it before drop.
985            unsafe { poller.add(&stream, Event::readable(0)) }
986                .map_err(|e| http_err("ws register", e))?;
987            let registration = SocketRegistration {
988                poller: Arc::clone(&poller),
989                stream,
990            };
991            let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
992            self.outgoing = Some(Box::new(SocketSender {
993                queue: outgoing,
994                poller,
995            }));
996            self.reader_stop = Arc::new(AtomicBool::new(false));
997            self.round = Arc::new(RoundChannel::default());
998            let inbound = Arc::clone(&self.inbound);
999            let stop = Arc::clone(&self.reader_stop);
1000            let round = Arc::clone(&self.round);
1001            self.reader = Some(std::thread::spawn(move || {
1002                let mut events = Events::new();
1003                let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
1004                let failure = 'io: loop {
1005                    if stop.load(Ordering::SeqCst) {
1006                        let _ = ws.close(None);
1007                        let _ = ws.flush();
1008                        break TransportError::new(
1009                            "sync.transport_failed",
1010                            "realtime disconnected mid-round (§8.7)",
1011                        );
1012                    }
1013                    if pending.is_none() {
1014                        if let Ok(outgoing) = queued.try_recv() {
1015                            pending = Some(outgoing.completed);
1016                            // WouldBlock retains the frame in tungstenite's write buffer.
1017                            if let Err(error) = ws.write(outgoing.message) {
1018                                if !is_would_block(&error) {
1019                                    break http_err("ws write", error);
1020                                }
1021                            }
1022                        }
1023                    }
1024                    // Bound receive work so a continuously readable peer cannot starve sends.
1025                    let mut drained = false;
1026                    for _ in 0..64 {
1027                        match ws.read() {
1028                            Ok(Message::Text(text)) => {
1029                                inbound.push(Inbound::Text(text.to_string()))
1030                            }
1031                            Ok(Message::Binary(bytes)) => {
1032                                if let Some(delta) = round.route_binary(&bytes) {
1033                                    inbound.push(Inbound::Binary(delta));
1034                                }
1035                            }
1036                            Err(error) if is_would_block(&error) => {
1037                                drained = true;
1038                                break;
1039                            }
1040                            Ok(Message::Close(_)) => {
1041                                let _ = ws.flush();
1042                                break 'io TransportError::new(
1043                                    "sync.transport_failed",
1044                                    "realtime disconnected mid-round (§8.7)",
1045                                );
1046                            }
1047                            Err(_) => {
1048                                break 'io TransportError::new(
1049                                    "sync.transport_failed",
1050                                    "realtime disconnected mid-round (§8.7)",
1051                                );
1052                            }
1053                            Ok(_) => {}
1054                        }
1055                    }
1056                    let interest = match ws.flush() {
1057                        Ok(()) => {
1058                            if let Some(completed) = pending.take() {
1059                                let _ = completed.send(Ok(()));
1060                            }
1061                            Event::readable(0)
1062                        }
1063                        Err(error) if is_would_block(&error) => Event::all(0),
1064                        Err(error) => break http_err("ws flush", error),
1065                    };
1066                    if !drained {
1067                        continue;
1068                    }
1069                    if let Err(error) = registration.poller.modify(&registration.stream, interest) {
1070                        break http_err("ws rearm", error);
1071                    }
1072                    events.clear();
1073                    if let Err(error) = registration.poller.wait(&mut events, None) {
1074                        if error.kind() != std::io::ErrorKind::Interrupted {
1075                            break http_err("ws wait", error);
1076                        }
1077                    }
1078                };
1079                if let Some(completed) = pending {
1080                    let _ = completed.send(Err(failure.clone()));
1081                }
1082                while let Ok(outgoing) = queued.try_recv() {
1083                    let _ = outgoing.completed.send(Err(failure.clone()));
1084                }
1085                round.fail_in_flight(failure);
1086            }));
1087            Ok(())
1088        }
1089
1090        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1091            self.send_message(Message::Text(text.to_owned().into()))
1092        }
1093
1094        fn realtime_close(&mut self) -> Result<(), TransportError> {
1095            self.shutdown();
1096            Ok(())
1097        }
1098    }
1099}
1100
1101#[cfg(all(test, feature = "native-transport"))]
1102mod tests {
1103    use super::*;
1104    use std::io::{BufRead, BufReader, Write};
1105
1106    #[test]
1107    fn detached_round_uses_the_owned_socket_and_shutdown_releases_it() {
1108        use ssp2::model::{Frame, Message, MsgKind};
1109        use std::sync::mpsc::channel;
1110        for cancelled in [false, true] {
1111            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1112            let base = format!("http://{}", listener.local_addr().unwrap());
1113            let (entered_tx, entered_rx) = channel();
1114            let (release_tx, release_rx) = channel();
1115            let server = std::thread::spawn(move || {
1116                let (socket, _) = listener.accept().unwrap();
1117                let mut socket = tungstenite::accept(socket).unwrap();
1118                let request = socket.read().unwrap().into_data();
1119                assert_eq!(request[0], crate::REALTIME_TAG_ROUND);
1120                assert_eq!(
1121                    ssp2::decode_message(&request[1..]).unwrap().msg_kind,
1122                    MsgKind::Request
1123                );
1124                entered_tx.send(()).unwrap();
1125                release_rx.recv().unwrap();
1126                if !cancelled {
1127                    let response = ssp2::encode_message(&Message {
1128                        wire_version: ssp2::decode::WIRE_VERSION,
1129                        msg_kind: MsgKind::Response,
1130                        frames: vec![Frame::RespHeader {
1131                            required_schema_version: None,
1132                            latest_schema_version: None,
1133                            log_epoch: Some("epoch".into()),
1134                            reset_required: Some(true),
1135                        }],
1136                    });
1137                    let mut chunk = vec![crate::REALTIME_TAG_ROUND];
1138                    chunk.extend(response);
1139                    socket
1140                        .send(tungstenite::Message::Binary(chunk.into()))
1141                        .unwrap();
1142                }
1143            });
1144            let mut owner =
1145                HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
1146            owner.realtime_connect_for_client("detached-round").unwrap();
1147            let mut detached = owner.fork_round();
1148            let network = std::thread::spawn(move || {
1149                let request = ssp2::encode_message(&Message {
1150                    wire_version: ssp2::decode::WIRE_VERSION,
1151                    msg_kind: MsgKind::Request,
1152                    frames: vec![
1153                        Frame::ReqHeader {
1154                            client_id: "detached-round".into(),
1155                            schema_version: 1,
1156                            log_epoch: None,
1157                        },
1158                        Frame::PullHeader {
1159                            limit_commits: 0,
1160                            limit_snapshot_rows: 0,
1161                            max_snapshot_pages: 0,
1162                            accept: 3,
1163                        },
1164                    ],
1165                });
1166                detached.realtime_sync(&request)
1167            });
1168            entered_rx.recv().unwrap();
1169            owner.set_headers(vec![("authorization".into(), "Bearer rotated".into())]);
1170            assert!(owner.take_inbound().is_empty());
1171            if cancelled {
1172                owner.shutdown();
1173            }
1174            release_tx.send(()).unwrap();
1175            let response = network.join().unwrap();
1176            if cancelled {
1177                assert!(response.is_err());
1178            } else {
1179                assert_eq!(
1180                    ssp2::decode_message(&response.unwrap()).unwrap().msg_kind,
1181                    MsgKind::Response
1182                );
1183                owner.shutdown();
1184            }
1185            server.join().unwrap();
1186        }
1187    }
1188
1189    #[test]
1190    fn segment_http_failures_have_named_codes_and_request_details() {
1191        for signed in [false, true] {
1192            for body_failure in [false, true] {
1193                let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1194                let base = format!("http://{}", listener.local_addr().unwrap());
1195                let server = std::thread::spawn(move || {
1196                    let (mut socket, _) = listener.accept().unwrap();
1197                    let mut reader = BufReader::new(socket.try_clone().unwrap());
1198                    loop {
1199                        let mut line = String::new();
1200                        reader.read_line(&mut line).unwrap();
1201                        if line == "\r\n" {
1202                            break;
1203                        }
1204                        assert!(!line.is_empty());
1205                    }
1206                    socket.write_all(if body_failure {
1207                        b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nshort"
1208                    } else {
1209                        b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1210                    }).unwrap();
1211                });
1212                let mut transport =
1213                    HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
1214                let mut progress = |_| {};
1215                let error = if signed {
1216                    transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
1217                } else {
1218                    transport.download_segment(
1219                        &SegmentRequest {
1220                            segment_id: "sha256:test".into(),
1221                            table: "tasks".into(),
1222                            requested_scopes_json: "{}".into(),
1223                        },
1224                        &mut progress,
1225                    )
1226                }
1227                .unwrap_err();
1228                assert_eq!(error.code, "sync.transport_failed");
1229                assert_eq!(error.message, "segment transfer failed");
1230                let details = error.details.unwrap();
1231                assert_eq!(
1232                    details["path"],
1233                    if signed {
1234                        "/signed"
1235                    } else {
1236                        "/segments/sha256:test"
1237                    }
1238                );
1239                assert_eq!(details["httpStatus"], if body_failure { 200 } else { 403 });
1240                server.join().unwrap();
1241            }
1242        }
1243    }
1244
1245    #[test]
1246    fn refused_segment_connection_has_a_named_code_without_an_invented_status() {
1247        for signed in [false, true] {
1248            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1249            let base = format!("http://{}", listener.local_addr().unwrap());
1250            drop(listener);
1251            let mut transport =
1252                HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
1253            let mut progress = |_| {};
1254            let error = if signed {
1255                transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
1256            } else {
1257                transport.download_segment(
1258                    &SegmentRequest {
1259                        segment_id: "sha256:test".into(),
1260                        table: "tasks".into(),
1261                        requested_scopes_json: "{}".into(),
1262                    },
1263                    &mut progress,
1264                )
1265            }
1266            .unwrap_err();
1267            assert_eq!(error.code, "sync.transport_failed");
1268            assert!(error.details.as_ref().unwrap().get("httpStatus").is_none());
1269        }
1270    }
1271
1272    #[test]
1273    fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
1274        for signed in [false, true] {
1275            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1276            let base = format!("http://{}", listener.local_addr().unwrap());
1277            let server = std::thread::spawn(move || {
1278                let (mut socket, _) = listener.accept().unwrap();
1279                let mut reader = BufReader::new(socket.try_clone().unwrap());
1280                let mut headers = String::new();
1281                loop {
1282                    let mut line = String::new();
1283                    reader.read_line(&mut line).unwrap();
1284                    if line == "\r\n" {
1285                        break;
1286                    }
1287                    assert!(!line.is_empty());
1288                    headers.push_str(&line.to_ascii_lowercase());
1289                }
1290                assert_eq!(headers.contains("authorization: bearer test"), !signed);
1291                assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
1292                let bytes = vec![7; 128 * 1024 + 17];
1293                write!(
1294                    socket,
1295                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1296                    bytes.len()
1297                )
1298                .unwrap();
1299                socket.write_all(&bytes).unwrap();
1300            });
1301            let mut transport = HostTransport::from_config(
1302                &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
1303            )
1304            .unwrap();
1305            let mut updates = Vec::new();
1306            let mut progress = |bytes| updates.push(bytes);
1307            let bytes = if signed {
1308                transport
1309                    .fetch_url(&format!("{base}/signed"), &mut progress)
1310                    .unwrap()
1311            } else {
1312                transport
1313                    .download_segment(
1314                        &SegmentRequest {
1315                            segment_id: "sha256:test".into(),
1316                            table: "tasks".into(),
1317                            requested_scopes_json: "{}".into(),
1318                        },
1319                        &mut progress,
1320                    )
1321                    .unwrap()
1322            };
1323            assert_eq!(bytes.len(), 128 * 1024 + 17);
1324            assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
1325            assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
1326            assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
1327            server.join().unwrap();
1328        }
1329    }
1330}