#![cfg(all(feature = "web", not(target_arch = "wasm32")))] // Native transport comparison test uses blocking socket deadlines. #![allow(clippy::disallowed_types, clippy::disallowed_methods)] mod common; use common::{FixtureOptions, FixtureServer, FixtureStore}; use makepad_asset_client::{ BaseUrl, HttpLimits, OwnedRequest, PlatformHttpTransport, TcpHttpTransport, Transport, TransportCompletion, TransportError, TransportId, TransportMethod, }; use makepad_asset_data::BlobId; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener}; use std::sync::mpsc; use std::thread::JoinHandle; use std::time::{Duration, Instant}; #[derive(Clone, Copy)] enum Backend { Tcp, Platform, } const BACKENDS: [Backend; 2] = [Backend::Tcp, Backend::Platform]; fn backend_name(backend: Backend) -> &'static str { match backend { Backend::Tcp => "tcp", Backend::Platform => "platform", } } fn transport_for(backend: Backend, addr: SocketAddr, max_body: u64) -> Box { match backend { Backend::Tcp => Box::new(TcpHttpTransport::with_limits( addr, HttpLimits::default_v1(), max_body, )), Backend::Platform => Box::new(PlatformHttpTransport::with_max_response_body(max_body)), } } fn request_for( backend: Backend, addr: SocketAddr, method: TransportMethod, body: &[u8], ) -> OwnedRequest { let target = match backend { Backend::Tcp => "/contract".to_string(), Backend::Platform => format!("http://{addr}/contract"), }; OwnedRequest::new(method, target).body(body.to_vec()) } fn one_shot_server( response: Vec, response_delay: Duration, ) -> (SocketAddr, mpsc::Receiver<(String, Vec)>, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap(); let (tx, rx) = mpsc::channel(); let join = std::thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(2); let (mut stream, _) = loop { match listener.accept() { Ok(pair) => break pair, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { if Instant::now() >= deadline { return; } std::thread::sleep(Duration::from_millis(2)); } Err(_) => return, } }; stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); let mut request = Vec::new(); let mut scratch = [0u8; 4096]; let head_end = loop { let Ok(read) = stream.read(&mut scratch) else { return }; if read == 0 { return; } request.extend_from_slice(&scratch[..read]); if let Some(at) = request.windows(4).position(|window| window == b"\r\n\r\n") { break at + 4; } }; let head = String::from_utf8_lossy(&request[..head_end]).to_string(); let content_length = head.lines().find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().ok()) .flatten() }).unwrap_or(0); while request.len() - head_end < content_length { let Ok(read) = stream.read(&mut scratch) else { return }; if read == 0 { return; } request.extend_from_slice(&scratch[..read]); } let _ = tx.send((head, request[head_end..head_end + content_length].to_vec())); if !response_delay.is_zero() { std::thread::sleep(response_delay); } let _ = stream.write_all(&response); let _ = stream.flush(); }); (addr, rx, join) } fn wait_for_dyn(transport: &mut dyn Transport, id: TransportId) -> TransportCompletion { let deadline = Instant::now() + Duration::from_secs(5); loop { let mut completions = Vec::new(); transport.poll(&mut completions); if let Some(completion) = completions.into_iter().find(|item| item.id == id) { return completion; } assert!(Instant::now() < deadline, "transport request did not complete"); std::thread::sleep(Duration::from_millis(5)); } } fn wait_for( transport: &mut PlatformHttpTransport, id: TransportId, ) -> TransportCompletion { let deadline = Instant::now() + Duration::from_secs(5); loop { let mut completions = Vec::new(); transport.poll(&mut completions); if let Some(completion) = completions.into_iter().find(|item| item.id == id) { return completion; } assert!(Instant::now() < deadline, "platform request did not complete"); std::thread::sleep(Duration::from_millis(5)); } } #[test] fn platform_transport_get_range_not_found_and_cancel() { let mut store = FixtureStore::default(); let revision = store.add_prop(7, "portable", None, "portable", b"0123456789".to_vec(), vec![]); let blob = store .assets .iter() .find(|asset| asset.revision == revision.revision) .unwrap() .manifest .files[0] .blob; let fixture = FixtureServer::start(store, FixtureOptions::default()); let base = BaseUrl::parse(format!("http://127.0.0.1:{}", fixture.data.addr.port())).unwrap(); let blob_target = makepad_asset_client::wire::path_blob(&blob); let blob_url = base.join(&blob_target).unwrap(); let mut transport = PlatformHttpTransport::new(); let get = transport.start(OwnedRequest::new(TransportMethod::Get, blob_url.clone())); let response = wait_for(&mut transport, get).result.unwrap(); assert_eq!(response.status, 200); assert_eq!(response.body, b"0123456789"); assert!(response.headers.iter().all(|(name, _)| *name == name.to_ascii_lowercase())); let range = transport.start( OwnedRequest::new(TransportMethod::Get, blob_url).header("Range", "bytes=4-"), ); let response = wait_for(&mut transport, range).result.unwrap(); assert_eq!(response.status, 206); assert_eq!(response.body, b"456789"); assert_eq!(response.header("content-range"), Some("bytes 4-9/10")); let missing = transport.start(OwnedRequest::new( TransportMethod::Get, base.join(&makepad_asset_client::wire::path_blob(&BlobId::from_bytes([0; 32]))) .unwrap(), )); assert_eq!(wait_for(&mut transport, missing).result.unwrap().status, 404); let cancelled = transport.start(OwnedRequest::new( TransportMethod::Get, base.join("/v1/missing").unwrap(), )); transport.cancel(cancelled); assert!(matches!( wait_for(&mut transport, cancelled).result, Err(TransportError::Cancelled) )); } #[test] fn adapters_refuse_redirects_bodyless_statuses_and_bad_framing() { let cases = [ b"HTTP/1.1 302 Found\r\nLocation: /elsewhere\r\nContent-Length: 0\r\n\r\n".to_vec(), b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".to_vec(), b"HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\n\r\n".to_vec(), b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n0\r\n\r\n".to_vec(), b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nok".to_vec(), ]; for backend in BACKENDS { for response in &cases { let (addr, _captured, server) = one_shot_server(response.clone(), Duration::ZERO); let mut transport = transport_for(backend, addr, 1024); let request = request_for(backend, addr, TransportMethod::Get, &[]); let id = transport.start(request); assert!( wait_for_dyn(&mut *transport, id).result.is_err(), "{} accepted a refused response", backend_name(backend) ); server.join().unwrap(); } } } #[test] fn adapters_enforce_body_limit_and_support_head_post_put() { for backend in BACKENDS { let (addr, _captured, server) = one_shot_server( b"HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n0123456789abcdef".to_vec(), Duration::ZERO, ); let mut transport = transport_for(backend, addr, 8); let request = request_for(backend, addr, TransportMethod::Get, &[]); let id = transport.start(request); assert!( matches!( wait_for_dyn(&mut *transport, id).result, Err(TransportError::OverBudget { what: "response body", .. }) ), "{} did not report the body cap", backend_name(backend) ); server.join().unwrap(); for (method, body) in [ (TransportMethod::Head, &b""[..]), (TransportMethod::Post, &b"post-body"[..]), (TransportMethod::Put, &b"put-body"[..]), ] { let response = if matches!(method, TransportMethod::Head) { b"HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\n".to_vec() } else { b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec() }; let (addr, captured, server) = one_shot_server(response, Duration::ZERO); let mut transport = transport_for(backend, addr, 1024); let request = request_for(backend, addr, method, body); let id = transport.start(request); let response = wait_for_dyn(&mut *transport, id).result.unwrap_or_else(|error| { panic!("{} {method:?} failed: {error}", backend_name(backend)) }); if matches!(method, TransportMethod::Head) { assert!(response.body.is_empty()); } else { assert_eq!(response.body, b"ok"); } let (head, captured_body) = captured.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(head.starts_with(method_name(method))); assert_eq!(captured_body, body); server.join().unwrap(); } } } fn method_name(method: TransportMethod) -> &'static str { match method { TransportMethod::Get => "GET ", TransportMethod::Head => "HEAD ", TransportMethod::Post => "POST ", TransportMethod::Put => "PUT ", TransportMethod::Delete => "DELETE ", } } #[test] fn cancellation_race_has_exactly_one_terminal_completion() { for backend in BACKENDS { let (addr, _captured, server) = one_shot_server( b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec(), Duration::from_millis(100), ); let mut transport = transport_for(backend, addr, 1024); let request = request_for(backend, addr, TransportMethod::Get, &[]); let id = transport.start(request); transport.cancel(id); assert!(matches!( wait_for_dyn(&mut *transport, id).result, Err(TransportError::Cancelled) )); server.join().unwrap(); std::thread::sleep(Duration::from_millis(20)); let mut extra = Vec::new(); transport.poll(&mut extra); assert!( extra.iter().all(|completion| completion.id != id), "{} emitted a second terminal completion", backend_name(backend) ); } }