use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use arcature::prelude::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const HANG_GUARD: Duration = Duration::from_secs(10);
struct RunningApp {
addr: SocketAddr,
shutdown: tokio::sync::oneshot::Sender<()>,
join: tokio::task::JoinHandle<()>,
}
impl RunningApp {
async fn start(app: Application<()>) -> RunningApp {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral listener");
let addr = listener.local_addr().expect("read bound address");
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let join = tokio::spawn(async move {
app.serve_with_shutdown(listener, async {
let _ = shutdown_rx.await;
})
.await
.expect("application served without engine error");
});
RunningApp {
addr,
shutdown: shutdown_tx,
join,
}
}
fn addr(&self) -> SocketAddr {
self.addr
}
async fn stop(self) {
self.shutdown
.send(())
.expect("server task still alive to receive shutdown");
tokio::time::timeout(HANG_GUARD, self.join)
.await
.expect("server did not hang on shutdown")
.expect("server task did not panic");
}
}
async fn http_get(addr: SocketAddr, path: &str) -> String {
let mut stream = tokio::time::timeout(HANG_GUARD, TcpStream::connect(addr))
.await
.expect("connect did not hang")
.expect("connect succeeds");
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
stream
.write_all(request.as_bytes())
.await
.expect("write request");
let mut buffer = Vec::new();
tokio::time::timeout(HANG_GUARD, stream.read_to_end(&mut buffer))
.await
.expect("read did not hang")
.expect("read succeeds");
String::from_utf8_lossy(&buffer).into_owned()
}
fn header<'a>(response: &'a str, name: &str) -> Option<&'a str> {
let lower = name.to_ascii_lowercase();
for line in response.split("\r\n") {
if let Some((k, v)) = line.split_once(": ")
&& k.to_ascii_lowercase() == lower
{
return Some(v.trim());
}
}
None
}
async fn post_routing_marker(
req: arcature::axum::extract::Request,
next: arcature::axum::middleware::Next,
) -> arcature::axum::response::Response {
let mut response = next.run(req).await;
response.headers_mut().insert(
"x-post-routing-ran",
"yes".parse().expect("valid header value"),
);
response
}
#[tokio::test]
async fn pre_routing_short_circuit_skips_handler() {
let handler_called = Arc::new(AtomicUsize::new(0));
let h_counter = handler_called.clone();
let app = Application::new()
.routes(Routes::new().route(
"/",
get(move || {
let c = h_counter.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
"handler ran"
}
}),
))
.proxy(|_req| ProxyAction::ShortCircuit {
status: StatusCode::SERVICE_UNAVAILABLE,
response: None,
})
.build();
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/").await;
assert!(response.starts_with("HTTP/1.1 503"), "short-circuit 503");
assert_eq!(
handler_called.load(Ordering::SeqCst),
0,
"handler must not run when proxy short-circuits (pre-routing skips routing)"
);
server.stop().await;
}
#[tokio::test]
async fn continue_proxy_runs_handler_exactly_once() {
let handler_called = Arc::new(AtomicUsize::new(0));
let h_counter = handler_called.clone();
let app = Application::new()
.routes(Routes::new().route(
"/",
get(move || {
let c = h_counter.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
"ok"
}
}),
))
.proxy(|_req| ProxyAction::continue_default())
.build();
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/").await;
assert!(response.starts_with("HTTP/1.1 200 OK"));
assert_eq!(
handler_called.load(Ordering::SeqCst),
1,
"handler must run exactly once when proxy returns Continue"
);
server.stop().await;
}
#[tokio::test]
async fn pre_routing_short_circuit_skips_post_routing_layer() {
let app_continue = Application::new()
.routes(
Routes::new()
.route("/", get(|| async { "ok" }))
.layer(arcature::axum::middleware::from_fn(post_routing_marker)),
)
.proxy(|_req| ProxyAction::continue_default())
.build();
let server = RunningApp::start(app_continue).await;
let response = http_get(server.addr(), "/").await;
assert!(response.starts_with("HTTP/1.1 200 OK"));
assert_eq!(
header(&response, "x-post-routing-ran"),
Some("yes"),
"post-routing layer must run when proxy continues (marker present)"
);
server.stop().await;
let app_short = Application::new()
.routes(
Routes::new()
.route("/", get(|| async { "ok" }))
.layer(arcature::axum::middleware::from_fn(post_routing_marker)),
)
.proxy(|_req| ProxyAction::ShortCircuit {
status: StatusCode::SERVICE_UNAVAILABLE,
response: None,
})
.build();
let server = RunningApp::start(app_short).await;
let response = http_get(server.addr(), "/").await;
assert!(response.starts_with("HTTP/1.1 503"), "short-circuit 503");
assert_eq!(
header(&response, "x-post-routing-ran"),
None,
"post-routing layer must NOT run when proxy short-circuits (marker absent — pre-routing is a separate zone)"
);
server.stop().await;
}