Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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,
    })
}