Files
boxmaker/crates/inferproxy/tests/forward.rs
T
kyleandClaude Opus 5.5 a15c428ea6 tests: every temporary path is removed by a guard; no leftovers in /tmp
About 10,000 test leftovers had built up in titan's /tmp. loopd's Home
and FakeServer, its fake broker's socket, bxctl's temp sockets, fake
loopd and command homes, inferproxy's socket, and a few brokerd and
toolkit tests created uniquely named directories or sockets and never
removed them, or removed them by hand at the end, which a failing test
skips. Each is now a guard removed on drop (bxctl gains
tests/support/temp.rs). Two races also left gatewayd homes behind: the
test Running only signalled its server to stop, and two Stage structs
declared the home first, so it was removed while the server could still
write into it. Running's drop now waits for the thread (with a 5-second
limit), and every home is declared last.

Checked: make gate twice, then the gate and three rounds of gatewayd and
loopd tests; no new entry in /tmp; 944 tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:29:32 -07:00

224 lines
6.9 KiB
Rust

//! End-to-end tests for the forwarder. Do not edit: these define the required behaviour.
//!
//! Each test starts a small TCP server as the upstream, runs `serve` on a Unix socket in a
//! temporary directory, and talks to it as a client would.
use inferproxy::{Limits, serve};
use std::io::{Read, Write};
use std::net::{Shutdown, TcpListener};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
use std::time::Duration;
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A socket path in a fresh directory of its own, removed when the value is dropped (tip T44).
struct TempSocket {
dir: PathBuf,
path: PathBuf,
}
impl std::ops::Deref for TempSocket {
type Target = PathBuf;
fn deref(&self) -> &PathBuf {
&self.path
}
}
impl AsRef<std::path::Path> for TempSocket {
fn as_ref(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempSocket {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn socket_path() -> TempSocket {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("inferproxy-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("infer.sock");
TempSocket { dir, path }
}
/// An upstream that reads until the client stops sending, then answers with what it read,
/// upper-cased, and closes. This is the shape of one HTTP exchange with `Connection: close`.
fn shouting_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
thread::spawn(move || {
let mut got = Vec::new();
stream.read_to_end(&mut got).unwrap();
stream.write_all(&got.to_ascii_uppercase()).unwrap();
});
}
});
addr
}
/// An upstream that accepts and then holds every connection open without reading or writing.
fn silent_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
thread::spawn(move || {
let mut held = Vec::new();
for stream in listener.incoming() {
held.push(stream.unwrap());
}
});
addr
}
fn start(upstream: String, limits: Limits) -> TempSocket {
let path = socket_path();
let listener = UnixListener::bind(&path).unwrap();
thread::spawn(move || serve(listener, upstream, limits));
path
}
fn exchange(path: &PathBuf, request: &[u8]) -> Vec<u8> {
let mut s = UnixStream::connect(path).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(request).unwrap();
s.shutdown(Shutdown::Write).unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).unwrap();
got
}
/// True if the proxy closed the connection without sending anything.
fn was_refused(path: &PathBuf) -> bool {
let mut s = UnixStream::connect(path).unwrap();
s.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let mut buf = [0u8; 1];
matches!(s.read(&mut buf), Ok(0))
}
#[test]
fn forwards_both_ways_and_passes_the_half_close_on() {
let path = start(shouting_upstream(), Limits::default());
assert_eq!(exchange(&path, b"hello, box"), b"HELLO, BOX");
}
#[test]
fn forwards_more_than_one_buffer() {
let path = start(shouting_upstream(), Limits::default());
let request: Vec<u8> = (0..300_000u32).map(|i| b'a' + (i % 26) as u8).collect();
assert_eq!(exchange(&path, &request), request.to_ascii_uppercase());
}
#[test]
fn serves_connections_one_after_another() {
let path = start(
shouting_upstream(),
Limits {
max_connections: 1,
burst: 100,
per_second: 100,
},
);
for i in 0..5 {
let msg = format!("request {i}");
assert_eq!(
exchange(&path, msg.as_bytes()),
msg.to_ascii_uppercase().as_bytes()
);
}
}
#[test]
fn refuses_connections_over_the_open_limit() {
let limits = Limits {
max_connections: 2,
burst: 100,
per_second: 100,
};
let path = start(silent_upstream(), limits);
let _a = UnixStream::connect(&path).unwrap();
let _b = UnixStream::connect(&path).unwrap();
thread::sleep(Duration::from_millis(200)); // let the proxy take both
assert!(
was_refused(&path),
"a third open connection must be closed at once"
);
}
#[test]
fn refuses_connections_over_the_rate_limit() {
let limits = Limits {
max_connections: 100,
burst: 3,
per_second: 1,
};
let path = start(shouting_upstream(), limits);
for i in 0..3 {
assert_eq!(
exchange(&path, b"x"),
b"X",
"connection {i} is within the burst"
);
}
assert!(
was_refused(&path),
"the fourth connection in the same instant must be refused"
);
}
#[test]
fn an_unreachable_upstream_closes_the_client() {
// Port 1 on localhost refuses connections.
let path = start("127.0.0.1:1".to_string(), Limits::default());
assert!(was_refused(&path));
}
/// An upstream that answers at once and closes, whether or not the client has finished sending.
/// This is what `llama-server` does, and what a server that dies mid-answer looks like.
fn answer_and_close_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut first = [0u8; 1];
let _ = stream.read(&mut first);
stream.write_all(b"answer").unwrap();
// dropping `stream` closes it
}
});
addr
}
#[test]
fn the_upstreams_close_reaches_a_client_that_is_still_sending() {
// The HTTP client in loopd never half-closes: it keeps its sending side open until it drops
// the connection. When the server closes, the proxy must close towards the client at once,
// or the client only learns of a dead server from its own timeout.
let path = start(answer_and_close_upstream(), Limits::default());
let mut s = UnixStream::connect(&path).unwrap();
s.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
s.write_all(b"request").unwrap();
let started = std::time::Instant::now();
let mut got = Vec::new();
let result = s.read_to_end(&mut got);
assert!(
result.is_ok(),
"the read must end with EOF, not a timeout: {result:?}"
);
assert_eq!(got, b"answer");
assert!(
started.elapsed() < Duration::from_millis(1000),
"EOF took {:?}",
started.elapsed()
);
}