diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index 60d98b3c7..2d0b4bf1d 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -2329,44 +2329,70 @@ mod tests { serve_recorded(answers).0 } + /// Reads a whole request from `stream`, body too: answering and closing while a POST's body + /// is still arriving resets the connection, and the client sees that instead of the answer. + #[cfg(not(target_arch = "wasm32"))] + fn read_request(stream: &mut std::net::TcpStream) -> String { + use std::io::Read; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); + let mut request = Vec::new(); + let mut buf = [0u8; 2048]; + while let Ok(n) = stream.read(&mut buf) { + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + let Some(head_end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue }; + let head = String::from_utf8_lossy(&request[..head_end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= head_end + 4 + length { + break; + } + } + String::from_utf8_lossy(&request).into_owned() + } + #[cfg(not(target_arch = "wasm32"))] fn serve_recorded(answers: Vec>) -> (String, std::sync::mpsc::Receiver) { - use std::io::{Read, Write}; + use std::io::Write; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let (sent, received) = std::sync::mpsc::channel(); std::thread::spawn(move || { for answer in answers { let Ok((mut stream, _)) = listener.accept() else { return }; - // The whole request, body too, before answering: answering and closing while a - // POST's body is still arriving resets the connection, and the client sees that - // instead of the answer. - let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); - let mut request = Vec::new(); - let mut buf = [0u8; 2048]; - while let Ok(n) = stream.read(&mut buf) { - if n == 0 { - break; - } - request.extend_from_slice(&buf[..n]); - let Some(head_end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue }; - let head = String::from_utf8_lossy(&request[..head_end]).to_ascii_lowercase(); - let length = head - .lines() - .find_map(|line| line.strip_prefix("content-length:")) - .and_then(|v| v.trim().parse::().ok()) - .unwrap_or(0); - if request.len() >= head_end + 4 + length { - break; - } - } - let _ = sent.send(String::from_utf8_lossy(&request).into_owned()); + let _ = sent.send(read_request(&mut stream)); let _ = stream.write_all(&answer); } }); (format!("http://{addr}"), received) } + /// Serves every connection to the returned address with what `answer` gives for its request + /// path, sending each request to the receiver. It answers until the test binary exits, so its + /// thread and listener live that long. + #[cfg(not(target_arch = "wasm32"))] + fn serve_by_path(answer: impl Fn(&str) -> Vec + Send + 'static) -> (String, std::sync::mpsc::Receiver) { + use std::io::Write; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (sent, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let request = read_request(&mut stream); + let path = request.split_whitespace().nth(1).unwrap_or("").to_string(); + let _ = sent.send(request); + let _ = stream.write_all(&answer(&path)); + } + }); + (format!("http://{addr}"), received) + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_headers_follow_same_origin_redirects() { @@ -2621,13 +2647,18 @@ mod tests { let tilejson = serde_json::json!({"tiles": ["./tile/{z}/{x}/{y}.pbf"]}); let answer = |body: String| format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).into_bytes(); - let (base, asked) = serve_recorded(vec![ - answer(style.to_string()), - answer(tilejson.to_string()), - answer("{}".into()), - answer("{}".into()), - answer("{}".into()), - ]); + // Answered by path, for as many connections as come: a test running at once may clear the + // shared cache, and resolving then fetches the style again in the background, so the + // requests are neither five nor in a fixed order. + let (base, asked) = serve_by_path(move |path| { + if path.starts_with("/style.json") { + answer(style.to_string()) + } else if path.starts_with("/tiles.json") { + answer(tilejson.to_string()) + } else { + answer("{}".into()) + } + }); let binding = logins::activate(&base, "derived-style", Method::Header("X-Service-Key".into()), "derived-style-secret", true).unwrap(); let url = logins::bind(&binding, &format!("{base}/style.json")).unwrap(); @@ -2678,7 +2709,11 @@ mod tests { ..Default::default() }; native::get(&native::agent(), &info.request(0.0, 0.0, 10.0).unwrap(), &[]).unwrap(); - for request in asked.iter().take(5) { + // Every request sent so far, background fetches included. Each of the test's own requests + // was sent on before its answer came back, so all of them are among these. + let requests: Vec = asked.try_iter().collect(); + assert!(requests.len() >= 5); + for request in &requests { assert!(request.to_ascii_lowercase().contains("x-service-key: derived-style-secret\r\n")); assert!(!request.contains("rustgis-login:")); }