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:
co-authored by
Claude Opus 4.7
parent
aae04b1241
commit
6e8fe176be
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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user