sgl-router: experimental Rust HTTP router for SGLang worker pools (#25851)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-05-25 15:34:05 +08:00
committed by GitHub
co-authored by Claude Opus 4.7
parent aae04b1241
commit 6e8fe176be
131 changed files with 28623 additions and 55 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,437 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Minimal axum mock of an SGLang HTTP worker for routing tests.
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Json;
use bytes::Bytes;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
/// Headers captured from the most recent inbound request.
#[derive(Default)]
pub struct CapturedHeaders {
pub seen: HashSet<String>, // names (kept for backwards compat)
pub headers: HashMap<String, String>, // name -> value (last write wins)
pub last_body: Option<Bytes>,
}
#[derive(Clone)]
#[allow(dead_code)] // Only used by some test files; mock_worker is shared.
pub struct MockWorkerState {
pub captured: Arc<Mutex<CapturedHeaders>>,
pub stream_chunks: Arc<Vec<&'static str>>,
}
/// A running mock SGLang worker. Shuts down on Drop via the oneshot sender.
pub struct MockWorker {
pub url: String,
// Used in header_forwarding_test; not every test file reads captured headers.
#[allow(dead_code)]
pub captured: Arc<Mutex<CapturedHeaders>>,
_shutdown: oneshot::Sender<()>,
}
impl MockWorker {
/// Bind to a random port on 127.0.0.1 and start serving.
///
/// `stream_chunks` are the raw SSE bytes returned when a streaming
/// chat-completion request arrives.
#[allow(dead_code)] // Only used by some test files.
pub async fn start(stream_chunks: Vec<&'static str>) -> Self {
let captured = Arc::new(Mutex::new(CapturedHeaders::default()));
let state = MockWorkerState {
captured: captured.clone(),
stream_chunks: Arc::new(stream_chunks),
};
// /server_info advertises served_model_name="tiny" so the
// worker-manager introspect step resolves model_ids for the
// "tiny" model the tests register a tokenizer + policy under.
let app = axum::Router::new()
.route("/v1/chat/completions", post(chat))
.route("/server_info", get(serve_tiny_server_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = rx.await;
})
.await
.unwrap();
});
Self {
url,
captured,
_shutdown: tx,
}
}
/// Bind to a random port and start a worker that accepts the request,
/// sleeps for `delay`, then returns `200 OK` with an empty JSON object.
/// Used to test router behaviour when the upstream wedges after accepting
/// the TCP connection but before sending response headers.
#[allow(dead_code)]
pub async fn start_hanging(delay: Duration) -> Self {
let captured = Arc::new(Mutex::new(CapturedHeaders::default()));
#[derive(Clone)]
struct HangState {
captured: Arc<Mutex<CapturedHeaders>>,
delay: Duration,
}
async fn hang_handler(
State(s): State<HangState>,
headers: HeaderMap,
body: Bytes,
) -> Response<Body> {
{
let mut g = s.captured.lock().unwrap();
g.last_body = Some(body.clone());
for (k, v) in headers.iter() {
g.seen.insert(k.as_str().to_string());
if let Ok(val) = v.to_str() {
g.headers.insert(k.as_str().to_string(), val.to_string());
}
}
}
tokio::time::sleep(s.delay).await;
let mut r = Response::new(Body::from("{}"));
*r.status_mut() = StatusCode::OK;
r.headers_mut().insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
r
}
let state = HangState {
captured: captured.clone(),
delay,
};
let app = axum::Router::new()
.route("/v1/chat/completions", post(hang_handler))
.route("/server_info", get(serve_tiny_server_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = rx.await;
})
.await
.unwrap();
});
Self {
url,
captured,
_shutdown: tx,
}
}
/// Bind to a random port and start a worker that streams `chunks` with a
/// fixed `delay` between each chunk. Used to test that load guards survive
/// the full body lifetime for streaming responses.
#[allow(dead_code)]
pub async fn start_slow_stream(chunks: Vec<&'static str>, delay: Duration) -> Self {
let captured = Arc::new(Mutex::new(CapturedHeaders::default()));
#[derive(Clone)]
struct SlowState {
captured: Arc<Mutex<CapturedHeaders>>,
chunks: Arc<Vec<&'static str>>,
delay: Duration,
}
async fn slow_chat(
State(s): State<SlowState>,
headers: HeaderMap,
body: Bytes,
) -> Response<Body> {
{
let mut g = s.captured.lock().unwrap();
g.last_body = Some(body.clone());
for (k, v) in headers.iter() {
g.seen.insert(k.as_str().to_string());
if let Ok(val) = v.to_str() {
g.headers.insert(k.as_str().to_string(), val.to_string());
}
}
}
let chunks = s.chunks.clone();
let delay = s.delay;
// Stream chunks via a channel, sleeping between each send.
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
for chunk in chunks.iter() {
tokio::time::sleep(delay).await;
if tx.send(Ok(Bytes::from(*chunk))).await.is_err() {
break;
}
}
});
let body = Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx));
let mut r = Response::new(body);
*r.status_mut() = StatusCode::OK;
r.headers_mut().insert(
HeaderName::from_static("content-type"),
"text/event-stream".parse().unwrap(),
);
r
}
let state = SlowState {
captured: captured.clone(),
chunks: Arc::new(chunks),
delay,
};
let app = axum::Router::new()
.route("/v1/chat/completions", post(slow_chat))
.route("/server_info", get(serve_tiny_server_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = rx.await;
})
.await
.unwrap();
});
Self {
url,
captured,
_shutdown: tx,
}
}
/// Bind to a raw TCP listener and start a worker that writes a status
/// line + headers with a large declared `Content-Length`, then writes
/// only `partial_body_bytes` of body before closing the connection.
///
/// Used to test router behaviour when the upstream replies with a status
/// but drops the connection mid-body. We can't build this with axum
/// directly (it owns the response lifecycle); raw TCP gives us frame-level
/// control to short-write the body and close.
///
/// NOTE: unlike the axum-based variants, this helper does NOT serve
/// `/server_info` (one-shot raw-TCP accept, no path routing). Callers
/// that wire this through `spawn_discovery` will see introspect fail
/// with empty `model_ids`. All current callers inject the worker via
/// `registry.add()` directly, which bypasses introspect.
#[allow(dead_code)]
pub async fn start_returning_partial_body(
status: StatusCode,
partial_body_bytes: &'static [u8],
) -> Self {
let captured = Arc::new(Mutex::new(CapturedHeaders::default()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let (tx, mut rx) = oneshot::channel::<()>();
tokio::spawn(async move {
// Accept one connection (or exit on shutdown).
tokio::select! {
_ = &mut rx => (),
accept = listener.accept() => {
let (mut sock, _) = match accept {
Ok(v) => v,
Err(_) => return,
};
// Drain the request bytes until we see end-of-headers
// (`\r\n\r\n`). We deliberately do NOT fully consume the
// request body — the router has already sent it before
// awaiting our response, and we want to write the
// truncated response promptly.
let mut buf = [0u8; 4096];
let mut acc: Vec<u8> = Vec::new();
while !acc.windows(4).any(|w| w == b"\r\n\r\n") {
let n = match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
acc.extend_from_slice(&buf[..n]);
if acc.len() > 64 * 1024 {
// Defensive: don't loop forever if the request
// never produces a header terminator.
break;
}
}
// Write a response with a Content-Length larger than the
// bytes we will actually write, then drop the socket
// before the body completes.
let declared_len = partial_body_bytes.len() + 1024;
let head = format!(
"HTTP/1.1 {status_u16} {phrase}\r\n\
content-type: application/json\r\n\
content-length: {declared_len}\r\n\
connection: close\r\n\
\r\n",
status_u16 = status.as_u16(),
phrase = status.canonical_reason().unwrap_or("OK"),
);
if sock.write_all(head.as_bytes()).await.is_err() {
return;
}
if sock.write_all(partial_body_bytes).await.is_err() {
return;
}
// Flush, then drop — the client should see content-length
// mismatch as a transport-level body read failure.
let _ = sock.flush().await;
drop(sock);
}
}
});
Self {
url,
captured,
_shutdown: tx,
}
}
/// Bind to a random port and start a worker that ALWAYS returns the given
/// HTTP status code and JSON body with `Content-Type: application/json`.
/// Used to test router behaviour when the upstream returns an error.
#[allow(dead_code)]
pub async fn start_returning_error(status: StatusCode, body: Value) -> Self {
let captured = Arc::new(Mutex::new(CapturedHeaders::default()));
let body_arc = Arc::new(body.to_string());
#[derive(Clone)]
struct ErrorState {
captured: Arc<Mutex<CapturedHeaders>>,
body_str: Arc<String>,
status: StatusCode,
}
async fn error_handler(
State(s): State<ErrorState>,
headers: HeaderMap,
body: Bytes,
) -> Response<Body> {
{
let mut g = s.captured.lock().unwrap();
g.last_body = Some(body);
for (k, v) in headers.iter() {
g.seen.insert(k.as_str().to_string());
if let Ok(val) = v.to_str() {
g.headers.insert(k.as_str().to_string(), val.to_string());
}
}
}
let mut r = Response::new(Body::from(s.body_str.as_ref().clone()));
*r.status_mut() = s.status;
r.headers_mut().insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
r
}
let state = ErrorState {
captured: captured.clone(),
body_str: body_arc,
status,
};
let app = axum::Router::new()
.route("/v1/chat/completions", post(error_handler))
.route("/server_info", get(serve_tiny_server_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = rx.await;
})
.await
.unwrap();
});
Self {
url,
captured,
_shutdown: tx,
}
}
}
/// Stateless `/server_info` handler shared by every axum-based
/// `MockWorker::start_*` variant. Advertising `served_model_name="tiny"`
/// lets the worker manager's introspect step resolve `model_ids` for any
/// variant that flows through `spawn_discovery`, instead of burning 3 ×
/// `SERVER_INFO_TIMEOUT` of retries before registering with empty
/// `model_ids`. Adding it unconditionally is cheaper than tracking which
/// variants do or don't get introspected.
#[allow(dead_code)] // shared across all axum variants
async fn serve_tiny_server_info() -> Json<Value> {
Json(serde_json::json!({"served_model_name": "tiny"}))
}
#[allow(dead_code)] // Used by `MockWorker::start`, only some test files need it.
async fn chat(State(s): State<MockWorkerState>, headers: HeaderMap, body: Bytes) -> Response<Body> {
{
let mut g = s.captured.lock().unwrap();
g.last_body = Some(body.clone());
for (k, v) in headers.iter() {
g.seen.insert(k.as_str().to_string());
if let Ok(val) = v.to_str() {
g.headers.insert(k.as_str().to_string(), val.to_string());
}
}
}
let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let streaming = v.get("stream").and_then(|x| x.as_bool()).unwrap_or(false);
if streaming {
let chunks: Vec<_> = s
.stream_chunks
.iter()
.map(|c| Ok::<_, std::io::Error>(Bytes::from(*c)))
.collect();
let body = Body::from_stream(futures::stream::iter(chunks));
let mut r = Response::new(body);
*r.status_mut() = StatusCode::OK;
r.headers_mut().insert(
HeaderName::from_static("content-type"),
"text/event-stream".parse().unwrap(),
);
return r;
}
let resp = serde_json::json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"model": v["model"].as_str().unwrap_or("unknown"),
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}]
});
Json(resp).into_response()
}
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Shared test harness re-exports.
pub mod mock_worker;
pub mod streaming;
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! SSE parsing and body-collection helpers for integration tests.
use bytes::Bytes;
/// Parse an SSE stream's `data: …` payloads (one per event).
#[allow(dead_code)]
pub fn parse_sse_data(raw: &[u8]) -> Vec<String> {
let s = std::str::from_utf8(raw).unwrap_or("");
s.lines()
.filter_map(|l| l.strip_prefix("data: "))
.map(|l| l.to_string())
.collect()
}
/// Collect an axum Body to bytes in tests.
#[allow(dead_code)]
pub async fn collect_body(body: axum::body::Body) -> Bytes {
use http_body_util::BodyExt;
body.collect().await.unwrap().to_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
/// Ported from SMG tests/api/streaming_tests.rs::test_sse_format_parsing.
/// Verifies that parse_sse_data:
/// 1. Extracts standard `data: …` lines.
/// 2. Silently ignores SSE `event: …` type fields (not data lines).
/// 3. Silently ignores SSE `: …` comment lines.
/// 4. Correctly parses `[DONE]` sentinel.
///
/// These edge-cases matter because SGLang workers may emit `event: message`
/// fields in their SSE frames. A parser that accidentally leaks those into
/// the payload list would cause clients to fail on JSON-parse.
#[test]
fn parse_sse_data_extracts_data_lines_only() {
// Basic: three data lines including the [DONE] sentinel.
let basic =
b"data: {\"text\":\"Hello\"}\n\ndata: {\"text\":\" world\"}\n\ndata: [DONE]\n\n";
let events = parse_sse_data(basic);
assert_eq!(events.len(), 3, "expected 3 data events, got: {events:?}");
assert_eq!(events[0], "{\"text\":\"Hello\"}");
assert_eq!(events[1], "{\"text\":\" world\"}");
assert_eq!(events[2], "[DONE]");
// Mixed: event: type field + comment line — neither must appear in output.
let mixed = b"event: message\ndata: {\"test\":true}\n\n: comment line\ndata: [DONE]\n\n";
let events = parse_sse_data(mixed);
assert_eq!(
events.len(),
2,
"event: and : comment lines must be ignored; got: {events:?}"
);
assert_eq!(events[0], "{\"test\":true}");
assert_eq!(events[1], "[DONE]");
}
}
@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use axum::body::Body;
use axum::http::Request;
use sgl_router::config::*;
use sgl_router::discovery::{spawn_discovery, ModelId};
use sgl_router::policies::factory::build_registry_with_defaults as build_policy_registry;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::manager;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
#[tokio::test]
async fn failover_when_one_worker_dies() {
// Three mock workers. Each advertises served_model_name = "tiny" on
// /server_info, so the worker manager's introspect step resolves the
// registry's model_ids without us having to hand-declare them here.
let w1 = crate::common::mock_worker::MockWorker::start(vec![]).await;
let w2 = crate::common::mock_worker::MockWorker::start(vec![]).await;
let w3 = crate::common::mock_worker::MockWorker::start(vec![]).await;
let cfg = Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: Some(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(1).unwrap(), // open after first failure
cool_down_secs: 30,
}),
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec![w1.url.clone(), w2.url.clone(), w3.url.clone()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
let policies = Arc::new(build_policy_registry(&cfg).unwrap());
let (event_rx, _disc) = spawn_discovery(&cfg).await.unwrap();
let _mgr = tokio::spawn(manager::run_with_config(
event_rx,
registry.clone(),
Some(Arc::new(cfg.clone())),
None,
None,
));
// Poll for the registry to converge — `register_one` introspect is
// a per-task spawn (manager.rs:127), so order of registration is
// non-deterministic under load. Cap the wait so a real hang surfaces
// instead of becoming a flake.
let converged = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if registry.workers_for(&ModelId("tiny".into())).len() == 3 {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
converged.is_ok(),
"registry should contain all 3 workers after discovery; have {}",
registry.workers_for(&ModelId("tiny".into())).len()
);
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
let ctx = Arc::new(AppContext::new(
cfg,
tokenizers,
proxy,
registry.clone(),
policies,
));
ctx.mark_ready();
let app = build_router(ctx);
// Kill w2 by dropping its handle, then poll until its socket
// actually refuses connections. Without this, the first request
// routed to w2 can race against the listener's graceful shutdown
// and succeed, masking the failover assertion below.
let w2_url = w2.url.clone();
drop(w2);
let host_port = w2_url.trim_start_matches("http://");
let down = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if tokio::net::TcpStream::connect(host_port).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(down.is_ok(), "w2 socket never went down");
// Send 6 requests; round-robin would route 2 to w2 → connection refused →
// breaker opens (threshold=1); subsequent round-robin picks rotate among
// the 2 healthy workers (#1 and #3) because healthy_workers_for filters out w2.
let mut errs = 0usize;
let mut oks = 0usize;
for i in 0..6 {
let body = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": format!("hi {i}")}],
}))
.unwrap();
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
if res.status().is_success() {
oks += 1;
} else {
errs += 1;
}
}
// We expect exactly 1 error — the first call routed to w2 fails and opens
// its breaker; subsequent round-robin picks rotate among the 2 healthy
// workers since registry.healthy_workers_for filters out the open breaker.
assert_eq!(errs, 1, "exactly the first w2 pick should error");
assert_eq!(oks, 5, "remaining 5 picks should succeed via filtered RR");
}
@@ -0,0 +1,230 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Pins the contract that `axum::serve(...).with_graceful_shutdown(...)` —
//! exactly as wired in `src/main.rs` — drains every in-flight streaming
//! request through the **real** `build_router(ctx)` stack before the
//! server future resolves. A k8s SIGTERM must not truncate streaming
//! completions.
//!
//! Why route the test through the real router (chat handler + proxy +
//! SSE pump) rather than a synthetic `Router::new().route(...)`: a
//! truncation regression could live in `forward_streaming_to`'s
//! `bytes_stream_to_body` completion hook, in `chat::chat_completions`'
//! guards, or in the SSE pump's `tx.send().await` race — all of which
//! would be silently skipped by a synthetic-handler test.
use bytes::Bytes;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const TEST_TIMEOUT: Duration = Duration::from_secs(15);
fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
let cfg = Config {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
registry
.add(WorkerSpec {
id: WorkerId("w1".into()),
url: worker_url.to_string(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
})
.expect("test worker accepted");
let policies = Arc::new(build_registry_with_defaults(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap());
let ctx = AppContext::new(cfg, tokenizers, proxy, registry, policies);
ctx.mark_ready();
Arc::new(ctx)
}
/// Streaming chat-completions body the worker hands back chunk-by-chunk.
/// One ~60 ms delay per chunk × 8 chunks ≈ ~480 ms per request, long
/// enough that we can race in ~100 concurrent clients and trigger
/// shutdown while every stream is still mid-flight.
const SLOW_CHUNKS: &[&str] = &[
"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"c\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"d\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"e\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"f\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"g\"}}]}\n\n",
"data: [DONE]\n\n",
];
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shutdown_drains_100_inflight_streaming_chat_completions() {
// 1. Spin up a slow streaming worker.
let worker = crate::common::mock_worker::MockWorker::start_slow_stream(
SLOW_CHUNKS.to_vec(),
Duration::from_millis(60),
)
.await;
let ctx = build_ctx_with_worker(&worker.url);
// 2. Serve the REAL `build_router(ctx)` on a random port with the
// `with_graceful_shutdown` wiring main.rs uses.
let app = build_router(ctx);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{addr}/v1/chat/completions");
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
.expect("axum::serve cleanly resolves on shutdown");
});
// 3. Fire 100 concurrent streaming clients.
const N: usize = 100;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap();
let body = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hi"}],
"stream": true,
}))
.unwrap();
let mut handles = Vec::with_capacity(N);
for i in 0..N {
let c = client.clone();
let u = url.clone();
let b = body.clone();
handles.push(tokio::spawn(async move {
let resp = c
.post(&u)
.header("content-type", "application/json")
.body(b)
.send()
.await
.map_err(|e| format!("client {i} send: {e}"))?;
if !resp.status().is_success() {
return Err(format!("client {i} non-2xx: {}", resp.status()));
}
let bytes: Bytes = resp
.bytes()
.await
.map_err(|e| format!("client {i} body: {e}"))?;
Ok::<Bytes, String>(bytes)
}));
}
// 4. Let every request grab a connection and start receiving data.
// 100 ms is past the first chunk delay (60 ms) for every stream
// but well before the last chunk fires.
tokio::time::sleep(Duration::from_millis(100)).await;
// 5. Trigger shutdown. axum stops accepting new connections but
// MUST drain the 100 already-attached streams.
let started = Instant::now();
shutdown_tx.send(()).unwrap();
// 6. Every in-flight request must complete with a `[DONE]` terminator
// — proving the stream was NOT truncated by shutdown.
let mut bytes_total: usize = 0;
let mut done_count: usize = 0;
for h in handles {
let result = h
.await
.expect("client task panicked")
.expect("client completed");
bytes_total += result.len();
let body_str = String::from_utf8_lossy(&result);
if body_str.contains("data: [DONE]") {
done_count += 1;
}
}
// Server task must exit cleanly once all 100 in-flight requests drained.
server.await.expect("server task joins after shutdown");
let elapsed = started.elapsed();
assert_eq!(
done_count, N,
"all {N} streams must terminate with `data: [DONE]` during graceful shutdown (got {done_count})"
);
assert!(
bytes_total > 0,
"expected non-zero body bytes across {N} clients"
);
// Drain MUST have taken at least ~400 ms (7 remaining chunks * 60ms).
// A shorter wait implies the streams were truncated.
assert!(
elapsed >= Duration::from_millis(300),
"graceful shutdown returned too fast ({elapsed:?}) — likely truncated streams"
);
}
#[tokio::test]
async fn shutdown_with_no_inflight_returns_promptly() {
// Complement of the load test: when nothing is in flight, the
// shutdown future resolves quickly. Catches a regression where the
// server might hang waiting on an idle connection pool.
let worker = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx_with_worker(&worker.url);
let app = build_router(ctx);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
.unwrap();
});
let started = Instant::now();
shutdown_tx.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("server resolves within 2s when idle")
.expect("server task joined cleanly");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"idle shutdown took too long: {elapsed:?}"
);
}
@@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use axum::body::Body;
use axum::http::Request;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults as build_policy_registry;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
#[tokio::test]
async fn forwards_whitelisted_headers_strips_others() {
let worker = crate::common::mock_worker::MockWorker::start(vec![]).await;
let cfg = Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
let _ = registry.add(WorkerSpec {
id: WorkerId("w1".into()),
url: worker.url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
});
let policies = Arc::new(build_policy_registry(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
let app = build_router(Arc::new(AppContext::new(
cfg, tokenizers, proxy, registry, policies,
)));
let body = serde_json::to_vec(&serde_json::json!({
"model":"tiny","messages":[{"role":"user","content":"hi"}]
}))
.unwrap();
// Use a spoofed content-length that differs from the real body length so we
// can distinguish "inbound value forwarded" from "reqwest auto-computed it".
let spoofed_content_length = "99999";
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.header("authorization", "Bearer test")
.header("x-request-id", "abc-123")
.header("x-sgl-route-key", "k1")
.header("cookie", "should-not-forward=true")
.header("host", "example.com")
.header("content-length", spoofed_content_length)
.header("transfer-encoding", "chunked")
.body(Body::from(body))
.unwrap();
app.oneshot(req).await.unwrap();
let seen = worker.captured.lock().unwrap();
// Whitelisted headers are forwarded with their inbound VALUES intact —
// a regression that mangles, uppercases, or drops the value (e.g.,
// forwarding the name but not the value) must fail this assertion.
assert_eq!(
seen.headers.get("authorization").map(String::as_str),
Some("Bearer test"),
"authorization must be forwarded with its inbound value verbatim",
);
assert_eq!(
seen.headers.get("x-request-id").map(String::as_str),
Some("abc-123"),
"x-request-id must be forwarded with its inbound value verbatim",
);
assert_eq!(
seen.headers.get("x-sgl-route-key").map(String::as_str),
Some("k1"),
"x-sgl-route-key must be forwarded with its inbound value verbatim",
);
// Cookie must be stripped.
assert!(!seen.seen.contains("cookie"));
// transfer-encoding is hop-by-hop and must not be forwarded (reqwest does not
// re-add it for a regular body, so absence check is reliable here).
assert!(
!seen.seen.contains("transfer-encoding"),
"transfer-encoding is hop-by-hop and must be stripped"
);
// content-length: the inbound spoofed value must not reach the upstream.
// reqwest may auto-compute its own content-length for the outbound body,
// so we assert value-inequality rather than absence.
assert_ne!(
seen.headers.get("content-length").map(|s| s.as_str()),
Some(spoofed_content_length),
"router must not forward the inbound content-length value to upstream"
);
// Host: the inbound value must not reach the upstream.
let captured_host: Option<&String> = seen.headers.get("host");
assert_ne!(
captured_host,
Some(&"example.com".to_string()),
"router must not forward the inbound Host header to upstream"
);
}
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Full HTTP proxy integration tests.
//!
//! Each submodule spins up the router via `build_router(AppContext)` and
//! drives real requests through a `common::mock_worker::MockWorker`
//! backend. For component-scope tests that don't need the router, see
//! `tests/component/`.
mod common;
mod chat_routing;
mod failover;
mod graceful_shutdown;
mod header_forwarding;
mod pd_bootstrap_injection;
mod pd_pool_isolation;
mod timeout;
@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! PD-disagg bootstrap-room injection + dual-dispatch — end-to-end
//! at the HTTP layer using MockWorkers.
//!
//! Asserts the router-side contract for SGLang disagg-prefill HTTP mode:
//!
//! * Every PD-mode `/v1/chat/completions` request fans out to BOTH a
//! prefill and a decode worker (the prefill is `tokio::spawn`'d in
//! the background; the decode is awaited for the client response).
//! * Both bodies carry the SAME flat top-level fields:
//! - `bootstrap_host` = the chosen prefill worker's host
//! - `bootstrap_port` = the chosen prefill worker's bootstrap port
//! - `bootstrap_room` = a random u64 in `[0, i64::MAX]` (63-bit)
//! * Plain-mode requests do NOT carry any `bootstrap_*` field — the
//! injection step is gated on `worker.mode() == Prefill`.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use bytes::Bytes;
use serde_json::{json, Value};
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
fn config() -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
fn build_ctx(specs: Vec<WorkerSpec>) -> Arc<AppContext> {
let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
for s in specs {
let _ = registry.add(s);
}
let policies = Arc::new(build_registry_with_defaults(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
fn chat_request() -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap(),
))
.unwrap()
}
/// Pattern-B dispatch: prefill is `tokio::spawn`'d as a detached task
/// so the client response can return as soon as decode is reachable —
/// the prefill body is captured *eventually* but may not be present
/// when the handler returns. Poll with a short bound rather than
/// sleeping a fixed duration.
async fn await_captured_body(
mock: &crate::common::mock_worker::MockWorker,
timeout: Duration,
label: &str,
) -> Bytes {
let start = std::time::Instant::now();
loop {
// Release the `std::sync::Mutex` guard before the sleep.await
// (clippy: await_holding_lock).
let captured = mock.captured.lock().unwrap().last_body.clone();
if let Some(b) = captured {
return b;
}
if start.elapsed() > timeout {
panic!("{label}: no request body captured within {timeout:?}");
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
fn parse_body(b: &Bytes) -> Value {
serde_json::from_slice(b).expect("body must be valid JSON")
}
/// Helper: extract bootstrap_host as &str.
fn bootstrap_host(v: &Value) -> Option<&str> {
v.get("bootstrap_host").and_then(|x| x.as_str())
}
/// Helper: extract bootstrap_port as u16.
fn bootstrap_port(v: &Value) -> Option<u16> {
v.get("bootstrap_port")
.and_then(|x| x.as_u64())
.map(|p| p as u16)
}
/// Helper: extract bootstrap_room as u64.
fn bootstrap_room(v: &Value) -> Option<u64> {
v.get("bootstrap_room").and_then(|x| x.as_u64())
}
/// PD-mode chat fans out to BOTH prefill and decode with identical
/// bootstrap fields injected into both bodies.
#[tokio::test]
async fn pd_mode_chat_injects_bootstrap_fields_into_both_bodies() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8997),
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "decode side should 200");
let prefill_body = await_captured_body(&prefill, Duration::from_secs(2), "prefill").await;
let decode_body = await_captured_body(&decode, Duration::from_secs(2), "decode").await;
let pj = parse_body(&prefill_body);
let dj = parse_body(&decode_body);
// Same bootstrap_room on both sides (one room minted per request).
let p_room = bootstrap_room(&pj).expect("prefill body missing bootstrap_room");
let d_room = bootstrap_room(&dj).expect("decode body missing bootstrap_room");
assert_eq!(
p_room, d_room,
"prefill and decode must share the same bootstrap_room"
);
// Room must be in [0, i64::MAX]: the SGLang prefill stores it as
// i64 internally, so values with the top bit set wrap negative.
assert!(
p_room <= i64::MAX as u64,
"bootstrap_room {p_room} exceeds 63-bit range; SGLang would mis-store as negative i64",
);
// bootstrap_host on both sides == prefill worker's hostname
// (MockWorker binds to 127.0.0.1).
assert_eq!(bootstrap_host(&pj), Some("127.0.0.1"));
assert_eq!(bootstrap_host(&dj), Some("127.0.0.1"));
// bootstrap_port on both sides == prefill's configured bootstrap_port.
assert_eq!(bootstrap_port(&pj), Some(8997));
assert_eq!(bootstrap_port(&dj), Some(8997));
}
/// Plain-mode (non-PD) requests do NOT carry any `bootstrap_*` field.
/// The injection step is gated on `worker.mode() == Prefill`; plain
/// workers serve the chat route directly without disagg bootstrapping.
#[tokio::test]
async fn plain_mode_chat_does_not_inject_bootstrap_fields() {
let plain = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![WorkerSpec {
id: WorkerId("w1".into()),
url: plain.url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
}]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = await_captured_body(&plain, Duration::from_secs(2), "plain").await;
let v = parse_body(&body);
assert!(
v.get("bootstrap_room").is_none(),
"plain-mode request must not carry bootstrap_room; got {v}"
);
assert!(
v.get("bootstrap_host").is_none(),
"plain-mode request must not carry bootstrap_host; got {v}"
);
assert!(
v.get("bootstrap_port").is_none(),
"plain-mode request must not carry bootstrap_port; got {v}"
);
}
/// PD-mode with multiple prefill workers + different `bootstrap_port`
/// values: the bootstrap_port injected MUST match the actually-chosen
/// prefill (not e.g. the first registered or a global config value).
#[tokio::test]
async fn pd_mode_bootstrap_port_matches_chosen_prefill_worker() {
let prefill_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let prefill_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("pA".into()),
url: prefill_a.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(11111),
},
WorkerSpec {
id: WorkerId("pB".into()),
url: prefill_b.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(22222),
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
// Fire enough requests to ensure round-robin hits both prefill workers.
for _ in 0..6 {
let res = app.clone().oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
// Wait until both prefill workers have captured at least one body.
let body_a = await_captured_body(&prefill_a, Duration::from_secs(2), "prefill_a").await;
let body_b = await_captured_body(&prefill_b, Duration::from_secs(2), "prefill_b").await;
let va = parse_body(&body_a);
let vb = parse_body(&body_b);
// Each prefill must see its OWN bootstrap_port — never the other's.
assert_eq!(
bootstrap_port(&va),
Some(11111),
"prefill_a body should carry its own bootstrap_port"
);
assert_eq!(
bootstrap_port(&vb),
Some(22222),
"prefill_b body should carry its own bootstrap_port"
);
}
/// Pin Pattern B's "prefill failure is invisible to the client"
/// contract: when the spawned prefill task gets a 5xx (or any other
/// upstream error), the decode response still reaches the client
/// unmodified. The router intentionally does not wire fail-fast here —
/// the decode side will eventually hang on `bootstrap_room` and time
/// out, but the chat handler itself doesn't propagate the prefill
/// error. Matches llm-d / aibrix behaviour.
#[tokio::test]
async fn pd_mode_prefill_5xx_does_not_poison_decode_response() {
let prefill = crate::common::mock_worker::MockWorker::start_returning_error(
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": "simulated prefill failure"}),
)
.await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8997),
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
// Client must see decode's 200 — the failing prefill is invisible.
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"decode response should reach the client even when prefill returned 5xx",
);
// Decode received its body (proves dual dispatch fired despite
// the prefill failure).
let decode_body = await_captured_body(&decode, Duration::from_secs(2), "decode").await;
let v = parse_body(&decode_body);
assert_eq!(bootstrap_port(&v), Some(8997));
// Prefill also received its body — it just returned 5xx. The
// bootstrap fields are present so the engine WOULD have honoured
// the bootstrap_room if the mock had succeeded.
let prefill_body = await_captured_body(&prefill, Duration::from_secs(2), "prefill").await;
let pv = parse_body(&prefill_body);
assert_eq!(bootstrap_port(&pv), Some(8997));
}
@@ -0,0 +1,425 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! PD pool isolation — end-to-end at the HTTP layer using MockWorker.
//!
//! Drives the chat handler with:
//!
//! * A model whose registered workers are all `WorkerMode::Decode`. The
//! handler dispatches **prefill** traffic (chat-completions is the
//! prefill phase of a PD request), so it must return 503 with
//! `no_prefill_workers_available`.
//! * A model with no workers at all → 503 `no_healthy_workers`
//! (existing code path; pinned here so a future PD wiring change
//! doesn't silently swap codes).
//! * A PD-disagg model with both pools healthy → request flows to the
//! prefill worker (smoke; the decode worker MUST NOT be selected for
//! the chat route).
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
fn config() -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
fn build_ctx(specs: Vec<WorkerSpec>) -> Arc<AppContext> {
let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
for s in specs {
let _ = registry.add(s);
}
let policies = Arc::new(build_registry_with_defaults(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
fn chat_request() -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap(),
))
.unwrap()
}
/// Gap closer #1: PD mode with only decode workers → 503 with
/// `no_prefill_workers_available`. The chat route is a prefill
/// dispatch, so a decode-only pool means partial failure.
#[tokio::test]
async fn pd_mode_decode_only_returns_no_prefill_workers_available() {
let worker = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![WorkerSpec {
id: WorkerId("d1".into()),
url: worker.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
}]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
res.headers().get("x-router-error-code").unwrap(),
"no_prefill_workers_available",
);
let body = res.into_body().collect().await.unwrap().to_bytes();
let body_str = String::from_utf8_lossy(&body);
assert!(
body_str.contains("\"code\":\"no_prefill_workers_available\""),
"body: {body_str}"
);
}
/// Pin the existing-code-path branch: no workers at all → 503 with
/// `no_healthy_workers`. Ensures the new PD code path didn't swap the
/// code for the "model has zero workers" case.
#[tokio::test]
async fn no_workers_returns_no_healthy_workers() {
let ctx = build_ctx(vec![]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
res.headers().get("x-router-error-code").unwrap(),
"no_healthy_workers",
);
}
/// PD-disagg deployment with both pools healthy → chat dispatch fans
/// out to BOTH the prefill and the decode worker (Pattern B: prefill
/// in a detached task, decode awaited for the client response). Both
/// receive the same bootstrap-injected body so the SGLang engine can
/// match KV transfers via `bootstrap_room`. Pool *isolation* — the
/// guarantee that the policy's prefill candidate set excludes decode
/// workers — is exercised at the resolver layer
/// (`policies::registry::tests::pd_resolution_returns_distinct_pools`).
/// Here we only assert the HTTP-layer wiring of the dual dispatch.
#[tokio::test]
async fn pd_mode_chat_dispatch_fans_to_both_prefill_and_decode() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8997),
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
// Fire a single request; both prefill (spawn-and-forget) and
// decode (awaited) must receive a body with the injected
// bootstrap fields. The decode body is what the client sees on
// the response.
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"decode response status should reach the client",
);
// Decode receives its body synchronously (we awaited it), so it's
// guaranteed captured by the time the response returned. Scope
// the lock guard to this block so it doesn't span the `.await`
// below (clippy: await_holding_lock).
{
let decode_seen = decode.captured.lock().unwrap();
assert!(
decode_seen.last_body.is_some(),
"decode worker must receive the bootstrap-injected request body in PD mode",
);
}
// Prefill is detached; poll briefly until its capture lands. The
// prefill task races the HTTP response back to the client. The
// local binding releases the `std::sync::Mutex` guard before the
// `.await` — holding a sync mutex across an await would let one
// task pin the lock while another tries to acquire it.
let prefill_body = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let captured = prefill.captured.lock().unwrap().last_body.clone();
if let Some(b) = captured {
return b;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("prefill MUST eventually receive its body via the detached task");
assert!(!prefill_body.is_empty());
}
/// Task C: PD-mode chat request carries an `x-sgl-decode-url` header
/// pointing at the host-affinity decode peer. With two prefill workers
/// on different hosts and a decode worker on each, the affinity helper
/// MUST pick the decode peer co-located with the chosen prefill.
///
/// Round-robin will select prefill workers deterministically (alphabetic
/// dashmap order is not guaranteed; the test fires several requests so
/// at least one lands on each prefill, and asserts the per-host pairing
/// holds across all of them).
#[tokio::test]
async fn pd_mode_chat_dispatch_sets_decode_affinity_header() {
use std::collections::HashSet;
let prefill_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let prefill_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
// MockWorker URLs always bind to `127.0.0.1`, so every worker
// shares the same host string and the affinity helper's
// same-host branch is moot here — the helper still returns a
// decode peer via the load-tiebreak fallback. The unit tests in
// `policies::registry::tests::decoder_picks_same_host_when_available`
// carry the real burden of pinning the host-affinity rules; this
// integration test only asserts the wiring is in place (the
// `x-sgl-decode-url` header IS set on PD requests, and the
// value is one of the registered decode worker URLs).
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill_a.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
WorkerSpec {
id: WorkerId("p2".into()),
url: prefill_b.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode_a.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
WorkerSpec {
id: WorkerId("d2".into()),
url: decode_b.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
// Fire 4 requests; both prefill workers see traffic via round-robin.
for _ in 0..4 {
let res = app.clone().oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
// Every request that hit a prefill mock MUST carry the decode-hint
// header. The header value MUST be one of the two registered
// decode worker URLs.
let decode_urls: HashSet<String> = [decode_a.url.clone(), decode_b.url.clone()]
.into_iter()
.collect();
for (label, p) in [("prefill_a", &prefill_a), ("prefill_b", &prefill_b)] {
let g = p.captured.lock().unwrap();
if g.last_body.is_none() {
// This prefill didn't receive a request — round-robin's
// dashmap iteration is non-deterministic, so one side may
// skip in a 4-request fire. Continue.
continue;
}
let hdr = g.headers.get("x-sgl-decode-url").unwrap_or_else(|| {
panic!(
"{label} did not receive an x-sgl-decode-url header. headers: {:?}",
g.headers
)
});
assert!(
decode_urls.contains(hdr),
"{label} got decode hint {hdr}, expected one of {decode_urls:?}",
);
}
}
/// Task C: plain-mode (non-PD) request does NOT carry the
/// `x-sgl-decode-url` header. Pin: the affinity step is gated on
/// `worker.mode() == Prefill` so plain workers are not asked to
/// bootstrap nonexistent decode peers.
#[tokio::test]
async fn plain_mode_chat_dispatch_omits_decode_affinity_header() {
let plain = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![WorkerSpec {
id: WorkerId("w1".into()),
url: plain.url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
}]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let g = plain.captured.lock().unwrap();
assert!(
!g.headers.contains_key("x-sgl-decode-url"),
"plain-mode worker must not receive a decode-affinity header. headers: {:?}",
g.headers,
);
}
/// Task C: PD-mode prefill request with NO decode workers → 503
/// `no_decode_workers_available`. Pin: failure mode is loud and
/// distinct from the existing `no_prefill_workers_available` path.
#[tokio::test]
async fn pd_mode_prefill_only_returns_no_decode_workers_available() {
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
}]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
res.headers().get("x-router-error-code").unwrap(),
"no_decode_workers_available",
);
}
/// PD-mode chat response carries `x-sgl-decode-url` so external tests
/// can observe decode affinity end-to-end (without sniffing the proxy
/// hop into the upstream prefill worker). Mirrors the request-side
/// behavior asserted by `pd_mode_chat_dispatch_sets_decode_affinity_header`.
#[tokio::test]
async fn pd_mode_chat_response_carries_decode_affinity_header() {
use std::collections::HashSet;
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
let decode_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![
WorkerSpec {
id: WorkerId("p1".into()),
url: prefill.url.clone(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
WorkerSpec {
id: WorkerId("d1".into()),
url: decode_a.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
WorkerSpec {
id: WorkerId("d2".into()),
url: decode_b.url.clone(),
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let decode_urls: HashSet<String> = [decode_a.url.clone(), decode_b.url.clone()]
.into_iter()
.collect();
let hdr = res
.headers()
.get("x-sgl-decode-url")
.unwrap_or_else(|| {
panic!(
"PD-mode chat response did not carry x-sgl-decode-url; headers: {:?}",
res.headers(),
)
})
.to_str()
.unwrap()
.to_owned();
assert!(
decode_urls.contains(&hdr),
"response carried decode hint {hdr}, expected one of {decode_urls:?}",
);
}
/// Plain-mode chat response does NOT carry `x-sgl-decode-url`. Pin: the
/// response-side mirror is gated on PD-mode dispatch.
#[tokio::test]
async fn plain_mode_chat_response_omits_decode_affinity_header() {
let plain = crate::common::mock_worker::MockWorker::start(vec![]).await;
let ctx = build_ctx(vec![WorkerSpec {
id: WorkerId("w1".into()),
url: plain.url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
}]);
let app = build_router(ctx);
let res = app.oneshot(chat_request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(
!res.headers().contains_key("x-sgl-decode-url"),
"plain-mode chat response must not carry x-sgl-decode-url; headers: {:?}",
res.headers(),
);
}
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Tests that the router does not wedge indefinitely when an upstream
//! worker accepts the TCP connection but never sends response headers.
//!
//! Without a configured `.timeout(...)` on the reqwest client, a stalled
//! backend hangs the axum handler future forever and the test harness
//! would just timeout. We assert here that the router returns a fast,
//! clean 502 (`upstream_timeout`) instead.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ObservabilityConfig,
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults as build_policy_registry;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
fn config(_worker_url: &str) -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
#[tokio::test]
async fn non_streaming_request_times_out_when_worker_hangs() {
// Worker accepts and then sleeps for 5s; router timeout is 200ms.
let worker =
crate::common::mock_worker::MockWorker::start_hanging(Duration::from_secs(5)).await;
let cfg = config(&worker.url);
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
let _ = registry.add(WorkerSpec {
id: WorkerId("w1".into()),
url: worker.url.clone(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
});
let policies = Arc::new(build_policy_registry(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_millis(200)).unwrap());
let ctx = Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies));
let app = build_router(ctx);
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hi"}],
"stream": false
}))
.unwrap(),
))
.unwrap();
let started = std::time::Instant::now();
// Outer guard so a regression doesn't wedge CI forever.
let res = tokio::time::timeout(Duration::from_secs(2), app.oneshot(req))
.await
.expect("router must return within 2s when proxy timeout is 200ms")
.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"router must short-circuit on upstream timeout; elapsed {elapsed:?}"
);
assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
res.headers().get("x-router-error-code").unwrap(),
"upstream_timeout"
);
let bytes = res.into_body().collect().await.unwrap().to_bytes();
let body_str = String::from_utf8_lossy(&bytes);
assert!(
body_str.contains("\"code\":\"upstream_timeout\""),
"body: {body_str}"
);
// No leak of worker URL or reqwest source chain to the client.
assert!(
!body_str.contains(&worker.url),
"worker URL must not leak in client-visible body: {body_str}"
);
}