src/testserv.rs
//! **A real local HTTP/1.1 server that can be told to die mid-stream.**
//!
//! Hand-rolled on `std::net::TcpListener` — about 120 lines — rather than pulled from a
//! crate, for one reason: the behaviour under test is *a transfer that stops halfway*,
//! and no HTTP framework offers "write 40 % of the body and then drop the socket" as a
//! first-class knob. A test that cannot cause the failure cannot prove the recovery.
//!
//! It answers exactly the three shapes the resume path has to survive:
//!
//! | [`Behaviour`] | what the client sees |
//! |---|---|
//! | [`Behaviour::Truncate`] | `200`/`206` with a full `Content-Length`, then the socket dies after `n` body bytes |
//! | [`Behaviour::Honour`] | a correct `206 Partial Content` with `Content-Range` |
//! | [`Behaviour::IgnoreRange`] | `200` with the WHOLE body, `Range` header ignored — the case that corrupts a naive appender |
//!
//! Every request's `Range` header is recorded, so a test can assert the client *asked*
//! to resume rather than inferring it from a byte count that a lucky refetch would also
//! produce.
#![allow(dead_code)] // test support — not every test uses every knob
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
/// What the server does with one request.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Behaviour {
/// Answer correctly (`206` when a `Range` was sent, else `200`) but cut the
/// connection after writing `n` bytes of the body it promised.
Truncate(usize),
/// Answer correctly and completely.
Honour,
/// Answer `200` with the whole body **even though a `Range` was requested** — the
/// behaviour `dlFile` handles at `bundle.go:270-272` and the one that silently
/// corrupts a client that appends unconditionally.
IgnoreRange,
}
/// One served request, as the server saw it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Seen {
/// The raw `Range:` header value, e.g. `bytes=4096-`. `None` when absent.
pub range: Option<String>,
/// The status the server answered with.
pub status: u16,
/// Body bytes actually written before the connection closed.
pub wrote: usize,
}
/// A one-URL HTTP server on 127.0.0.1, scripted per request.
pub struct TestServer {
pub url: String,
seen: Arc<Mutex<Vec<Seen>>>,
stop: Arc<Mutex<bool>>,
handle: Option<gatling::background::Job<()>>,
}
impl TestServer {
/// Serve `body`, taking one [`Behaviour`] from `script` per request. When the script
/// runs dry every further request is [`Behaviour::Honour`].
pub fn start(body: Vec<u8>, script: Vec<Behaviour>) -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind a test listener");
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(Mutex::new(false));
let (s2, st2) = (Arc::clone(&seen), Arc::clone(&stop));
// ROOT-LAW-#0: the accept loop goes through gatling's ONE sanctioned spawn.
let handle = gatling::background::Job::spawn(move || {
let mut script: VecDeque<Behaviour> = script.into();
for conn in listener.incoming() {
if *st2.lock().unwrap() {
return;
}
let Ok(conn) = conn else { continue };
let b = script.pop_front().unwrap_or(Behaviour::Honour);
if let Some(rec) = serve_one(conn, &body, b) {
s2.lock().unwrap().push(rec);
}
}
});
TestServer {
url: format!("http://127.0.0.1:{port}/artifact.bin"),
seen,
stop,
handle: Some(handle),
}
}
/// Every request served so far, in order.
pub fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
/// Total body bytes the server wrote across every request — the wire-side answer to
/// "did the client refetch from zero", independent of anything the client reports.
pub fn bytes_written(&self) -> usize {
self.seen().iter().map(|s| s.wrote).sum()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
*self.stop.lock().unwrap() = true;
// Unblock the accept loop with one throwaway connection.
if let Ok(host) = self
.url
.trim_start_matches("http://")
.split('/')
.next()
.ok_or(())
{
let _ = TcpStream::connect(host);
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
fn serve_one(mut conn: TcpStream, body: &[u8], behaviour: Behaviour) -> Option<Seen> {
let mut reader = BufReader::new(conn.try_clone().ok()?);
let mut request_line = String::new();
if reader.read_line(&mut request_line).ok()? == 0 {
return None;
}
let mut range: Option<String> = None;
loop {
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
if let Some(v) = line
.split_once(':')
.filter(|(k, _)| k.eq_ignore_ascii_case("range"))
.map(|(_, v)| v.trim().to_string())
{
range = Some(v);
}
}
// What slice of the body this response covers.
let start = match (&range, behaviour) {
(Some(r), Behaviour::Truncate(_) | Behaviour::Honour) => r
.trim_start_matches("bytes=")
.split('-')
.next()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0),
_ => 0,
};
let start = start.min(body.len());
let slice = &body[start..];
let partial = start > 0 && !matches!(behaviour, Behaviour::IgnoreRange);
let status: u16 = if partial { 206 } else { 200 };
let mut head = format!(
"HTTP/1.1 {status} {}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n",
if partial { "Partial Content" } else { "OK" },
slice.len()
);
if partial {
head.push_str(&format!(
"Content-Range: bytes {start}-{}/{}\r\n",
body.len().saturating_sub(1),
body.len()
));
}
head.push_str("Connection: close\r\n\r\n");
conn.write_all(head.as_bytes()).ok()?;
// The body — truncated on purpose when the script says so. The `Content-Length`
// above still promises the whole slice, so the client sees the stream die short,
// which is exactly what a dropped link looks like.
let write_n = match behaviour {
Behaviour::Truncate(n) => n.min(slice.len()),
_ => slice.len(),
};
let wrote = match conn.write_all(&slice[..write_n]) {
Ok(()) => write_n,
Err(_) => 0,
};
let _ = conn.flush();
// **Close BOTH handles, and do not wait for the client.**
//
// This cost an eight-minute hang before it was written down. `reader` holds a
// `try_clone` of the same socket, so dropping `conn` alone leaves the fd open; the
// client is still waiting for the `Content-Length` bytes we deliberately did not
// send, and a drain here waits for a client that is waiting for us. Two sleepers,
// one socket. Dropping both halves sends the FIN that makes the truncation look
// like the dropped link it is standing in for.
drop(conn);
drop(reader);
Some(Seen {
range,
status,
wrote,
})
}