[router] Speak cleartext h2c on both edges: serve it inbound, forward it outbound (#39006)
Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kangyan Zhou
Claude Opus 5
parent
c1f5b4736a
commit
afde31a2f5
@@ -0,0 +1,116 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Inbound cleartext-HTTP/2 (h2c) listener tests for the router's own server.
|
||||
//!
|
||||
//! Where `h2c_forward.rs` proves the router's *outbound* client speaks h2c to
|
||||
//! an HTTP/2-only worker, these prove the *inbound* side: the router's
|
||||
//! `axum::serve` listener accepts an h2c prior-knowledge client on the same
|
||||
//! cleartext port it serves HTTP/1.1 on, auto-negotiating per connection.
|
||||
//!
|
||||
//! Protocol negotiation happens at the connection layer, below the tower
|
||||
//! service — so unlike the `oneshot` tests in `chat_routing.rs`, these must
|
||||
//! drive a real socket via `axum::serve` (mirroring `main.rs`).
|
||||
//!
|
||||
//! What actually compiles the listener's h2 path is `hyper-util/http2`, reached
|
||||
//! through `server-auto`, and several dependencies enable it. So these tests do
|
||||
//! NOT fail if axum's own `http2` feature is dropped — verified by removing it.
|
||||
//! Their job is the property an operator depends on, whatever the feature graph
|
||||
//! happens to look like: one cleartext port serving both h2c prior-knowledge
|
||||
//! and HTTP/1.1. That is what would break if a dependency change silently took
|
||||
//! `hyper-util/http2` away.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sgl_router::config::PolicyKind;
|
||||
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 axum::http::Version;
|
||||
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn build_ctx() -> Arc<AppContext> {
|
||||
// Reuse the shared fixture rather than restating the whole `Config`: this
|
||||
// test is about the listener, not the routing policy, and a local literal
|
||||
// would be one more site to edit every time `ModelConfig` gains a field.
|
||||
let mut cfg = crate::common::cache_aware_fixture::config();
|
||||
cfg.model.policy = PolicyKind::RoundRobin;
|
||||
cfg.model.cache_aware = None;
|
||||
|
||||
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 proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap());
|
||||
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
|
||||
}
|
||||
|
||||
/// Spawn the real router (`build_router`) behind `axum::serve` on an ephemeral
|
||||
/// port — the exact serve path used in `main.rs`. `/healthz` returns 200
|
||||
/// unconditionally, so no worker is needed. Returns the base URL; the accept
|
||||
/// loop is dropped when the test runtime shuts down.
|
||||
async fn spawn_router() -> String {
|
||||
let ctx = build_ctx();
|
||||
ctx.mark_ready();
|
||||
let app = build_router(ctx);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_accepts_h2c_prior_knowledge() {
|
||||
let base = spawn_router().await;
|
||||
|
||||
// `http2_prior_knowledge()` sends the HTTP/2 connection preface directly
|
||||
// over cleartext (no ALPN, no h1 upgrade) — the same way a service-mesh
|
||||
// sidecar dials h2c. The listener must recognize the preface and serve h2.
|
||||
let client = reqwest::Client::builder()
|
||||
.http2_prior_knowledge()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/healthz"))
|
||||
.send()
|
||||
.await
|
||||
.expect("h2c prior-knowledge client must reach the router listener");
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.version(),
|
||||
Version::HTTP_2,
|
||||
"listener must negotiate HTTP/2 for an h2c prior-knowledge client",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_still_accepts_http1() {
|
||||
let base = spawn_router().await;
|
||||
|
||||
// The default reqwest client speaks HTTP/1.1 over cleartext. Enabling h2c
|
||||
// must not break existing HTTP/1.1 callers (load balancers, probes, curl)
|
||||
// — the auto builder serves both on the same port.
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/healthz"))
|
||||
.send()
|
||||
.await
|
||||
.expect("HTTP/1.1 client must still reach the router listener");
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.version(),
|
||||
Version::HTTP_11,
|
||||
"an HTTP/1.1 client must keep negotiating HTTP/1.1 on the same port",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user