use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use plecto_control::{Control, Host, Manifest, MemoryStore, ResolvedArtifact};
use plecto_host::test_support::{TestSigner, bound_sbom, filter_hello_component};
use plecto_server::serve_with_shutdown;
async fn spawn_upstream(delay: Duration) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(move |req: Request<Incoming>| async move {
if req.uri().path() == "/slow" {
tokio::time::sleep(delay).await;
}
Ok::<_, std::convert::Infallible>(
Response::builder()
.status(200)
.body(Full::new(Bytes::from_static(b"upstream-ok")))
.unwrap(),
)
}),
)
.await;
});
}
});
addr
}
fn control_for(upstream_addr: SocketAddr, extra: &str) -> Arc<Control> {
let component = filter_hello_component();
let signer = TestSigner::new().unwrap();
let component_signature = signer.sign(&component).unwrap();
let sbom = bound_sbom(&component);
let sbom_signature = signer.sign(&sbom).unwrap();
let mut store = MemoryStore::new();
let digest = store.insert(
"fh",
ResolvedArtifact {
component,
component_signature,
sbom,
sbom_signature,
},
);
let toml = format!(
r#"
[[filter]]
id = "fh"
source = "fh"
digest = "{digest}"
isolation = "trusted"
[[upstream]]
name = "echo"
addresses = ["{upstream_addr}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
[[route]]
filters = ["fh"]
upstream = "echo"
strip_prefix = "/api"
[route.match]
path_prefix = "/api"
{extra}
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
Arc::new(Control::load(host, &manifest, Box::new(store)).unwrap())
}
async fn spawn_proxy(
control: Arc<Control>,
) -> (
SocketAddr,
oneshot::Sender<()>,
JoinHandle<anyhow::Result<()>>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
let handle = tokio::spawn(serve_with_shutdown(control, listener, async move {
let _ = rx.await;
}));
(addr, tx, handle)
}
async fn free_addr() -> SocketAddr {
TcpListener::bind("127.0.0.1:0")
.await
.unwrap()
.local_addr()
.unwrap()
}
fn client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
async fn try_get(
client: &Client<HttpConnector, Empty<Bytes>>,
proxy: SocketAddr,
path: &str,
) -> anyhow::Result<(StatusCode, String)> {
let req = Request::builder()
.method("GET")
.uri(format!("http://{proxy}{path}"))
.body(Empty::<Bytes>::new())
.unwrap();
let resp = client.request(req).await?;
let (parts, body) = resp.into_parts();
let bytes = body.collect().await?.to_bytes();
Ok((parts.status, String::from_utf8_lossy(&bytes).into_owned()))
}
async fn wait_ready(client: &Client<HttpConnector, Empty<Bytes>>, proxy: SocketAddr) {
for _ in 0..100 {
if let Ok((status, _)) = try_get(client, proxy, "/api/__ready").await
&& status != StatusCode::SERVICE_UNAVAILABLE
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("upstream never became healthy within the readiness window");
}
#[tokio::test]
async fn graceful_shutdown_drains_inflight_and_stops_accepting() {
let upstream = spawn_upstream(Duration::from_millis(400)).await;
let control = control_for(upstream, "[listen.drain]\nwindow_ms = 5000\n");
let (proxy, shutdown, server) = spawn_proxy(control).await;
let client = client();
wait_ready(&client, proxy).await;
let inflight = {
let client = client.clone();
tokio::spawn(async move { try_get(&client, proxy, "/api/slow").await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
shutdown.send(()).unwrap();
let (status, body) = inflight
.await
.unwrap()
.expect("the in-flight request must complete during the drain window");
assert_eq!(status, StatusCode::OK, "drained response is the real one");
assert_eq!(body, "upstream-ok");
let served = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("serve must return once in-flight connections drained")
.unwrap();
served.expect("a drained shutdown is a clean (Ok) exit");
assert!(
tokio::net::TcpStream::connect(proxy).await.is_err(),
"the listener must be closed after shutdown"
);
}
#[tokio::test]
async fn drain_deadline_cuts_off_connections_that_outlive_it() {
let upstream = spawn_upstream(Duration::from_secs(10)).await;
let control = control_for(upstream, "[listen.drain]\nwindow_ms = 200\n");
let (proxy, shutdown, server) = spawn_proxy(control).await;
let client = client();
wait_ready(&client, proxy).await;
let inflight = {
let client = client.clone();
tokio::spawn(async move { try_get(&client, proxy, "/api/slow").await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
shutdown.send(()).unwrap();
let served = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("serve must return once the drain deadline expires")
.unwrap();
served.expect("a deadline-bounded shutdown is still a clean (Ok) exit");
assert!(
inflight.await.unwrap().is_err(),
"a request that outlives the drain deadline is cut, not answered"
);
}
#[tokio::test]
async fn idle_keepalive_connections_do_not_hold_the_drain_open() {
let upstream = spawn_upstream(Duration::ZERO).await;
let control = control_for(upstream, "[listen.drain]\nwindow_ms = 10000\n");
let (proxy, shutdown, server) = spawn_proxy(control).await;
let client = client();
wait_ready(&client, proxy).await;
let (status, _) = try_get(&client, proxy, "/api/hello").await.unwrap();
assert_eq!(status, StatusCode::OK);
shutdown.send(()).unwrap();
let served = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("idle keep-alive connections must be closed, not drained until the deadline")
.unwrap();
served.expect("shutdown with only idle connections is a clean (Ok) exit");
}
#[tokio::test]
async fn readyz_flips_to_503_at_the_signal_while_healthz_stays_200() {
let upstream = spawn_upstream(Duration::from_millis(800)).await;
let admin = free_addr().await;
let control = control_for(
upstream,
&format!("[observability]\nadmin_addr = \"{admin}\"\n\n[listen.drain]\nwindow_ms = 5000\n"),
);
let (proxy, shutdown, server) = spawn_proxy(control).await;
let client = client();
wait_ready(&client, proxy).await;
let (status, body) = try_get(&client, admin, "/readyz").await.unwrap();
assert_eq!(status, StatusCode::OK, "serving → ready");
assert_eq!(body, "ready\n");
let inflight = {
let client = client.clone();
tokio::spawn(async move { try_get(&client, proxy, "/api/slow").await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
shutdown.send(()).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let (status, body) = try_get(&client, admin, "/readyz").await.unwrap();
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"draining → not ready"
);
assert_eq!(body, "draining\n");
let (status, body) = try_get(&client, admin, "/healthz").await.unwrap();
assert_eq!(status, StatusCode::OK, "liveness holds through the drain");
assert_eq!(body, "ok\n");
let (status, _) = inflight
.await
.unwrap()
.expect("the in-flight request still completes during the drain window");
assert_eq!(status, StatusCode::OK);
tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("serve returns once drained")
.unwrap()
.expect("clean exit");
}
#[tokio::test]
async fn readiness_grace_keeps_accepting_before_the_drain_starts() {
let upstream = spawn_upstream(Duration::ZERO).await;
let admin = free_addr().await;
let control = control_for(
upstream,
&format!(
"[observability]\nadmin_addr = \"{admin}\"\n\n\
[listen.drain]\nreadiness_grace_ms = 600\nwindow_ms = 5000\n"
),
);
let (proxy, shutdown, server) = spawn_proxy(control).await;
let fresh = client();
let client = client();
wait_ready(&client, proxy).await;
let signalled = std::time::Instant::now();
shutdown.send(()).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let (status, _) = try_get(&client, admin, "/readyz").await.unwrap();
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
let (status, body) = try_get(&fresh, proxy, "/api/during-grace")
.await
.expect("a request during the readiness grace is accepted and served");
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "upstream-ok");
tokio::time::timeout(Duration::from_secs(3), server)
.await
.expect("serve returns after grace + drain")
.unwrap()
.expect("clean exit");
assert!(
signalled.elapsed() >= Duration::from_millis(600),
"serve must not return before the readiness grace has elapsed"
);
}