Skip to content

Commit c1c1b41

Browse files
fix(server): handle graceful process shutdown (#294)
* fix(server): handle graceful process shutdown Signed-off-by: nachiketb <nachiketb@nvidia.com> * refactor(server): isolate platform shutdown signals Signed-off-by: nachiketb <nachiketb@nvidia.com> * refactor(server): await shutdown without spawning Signed-off-by: nachiketb <nachiketb@nvidia.com> --------- Signed-off-by: nachiketb <nachiketb@nvidia.com>
1 parent 5fad270 commit c1c1b41

5 files changed

Lines changed: 212 additions & 33 deletions

File tree

crates/switchyard-py/src/server_bindings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ impl PyServer {
4646
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
4747
backlog: DEFAULT_LISTEN_BACKLOG,
4848
dry_run: false,
49+
shutdown_timeout: Duration::from_secs(2),
4950
tls: None,
5051
},
5152
)

crates/switchyard-server/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ export API_KEY="..."
5858
cargo run -p switchyard-server -- --config routes.toml
5959
```
6060

61+
Ctrl+C and Unix `SIGTERM` stop new connections and allow active requests to drain for up to
62+
`--shutdown-timeout` (30 seconds by default) before they are terminated.
63+
6164
The server logs exactly one structured terminal event per LLM request: successful responses at
6265
`INFO`, 4xx responses at `WARN`, and 5xx responses at `ERROR`. Set
6366
`RUST_LOG=switchyard_server=debug,libsy=debug` to include routing decisions and nested failure

crates/switchyard-server/src/cli.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use std::path::PathBuf;
99
use clap::Parser;
1010
use switchyard_server::config::load_server_state;
1111
use switchyard_server::{
12-
DEFAULT_LISTEN_BACKLOG, ServerError, ServerResult, ServerRunOptions, ServerState, TlsOptions,
13-
run_server,
12+
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT, DEFAULT_LISTEN_BACKLOG, ServerError, ServerResult,
13+
ServerRunOptions, ServerState, TlsOptions, run_server,
1414
};
1515

1616
const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
@@ -40,6 +40,10 @@ pub(crate) struct ServerArgs {
4040
#[arg(long, default_value_t = DEFAULT_LISTEN_BACKLOG)]
4141
backlog: u32,
4242

43+
/// Maximum time active requests may drain during shutdown.
44+
#[arg(long, default_value_t = humantime::Duration::from(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT))]
45+
shutdown_timeout: humantime::Duration,
46+
4347
/// Validate the algorithm and client configuration without binding a socket.
4448
#[arg(long)]
4549
dry_run: bool,
@@ -85,6 +89,7 @@ impl ServerArgs {
8589
addr: SocketAddr::new(self.host, self.port),
8690
backlog: self.backlog,
8791
dry_run: self.dry_run,
92+
shutdown_timeout: self.shutdown_timeout.into(),
8893
tls,
8994
};
9095
Ok((state, options))

crates/switchyard-server/src/lib.rs

Lines changed: 149 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod metrics;
88
mod observability;
99
mod response;
1010
mod routing_log;
11+
mod shutdown;
1112
mod sse;
1213
mod stats;
1314
mod usage_metrics;
@@ -50,6 +51,9 @@ pub use observability::{flush_observability, initialize_observability};
5051
/// Default TCP listen backlog used by the Rust server.
5152
pub const DEFAULT_LISTEN_BACKLOG: u32 = 65_535;
5253

54+
/// Default time allowed for active requests to finish during shutdown.
55+
pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
56+
5357
/// Maximum buffered JSON request size accepted by the LLM endpoints.
5458
pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
5559

@@ -214,6 +218,8 @@ pub struct ServerRunOptions {
214218
pub backlog: u32,
215219
/// Validate runtime construction without binding a socket.
216220
pub dry_run: bool,
221+
/// Maximum time active requests may drain after shutdown begins.
222+
pub shutdown_timeout: Duration,
217223
/// TLS certificate configuration, when HTTPS is enabled.
218224
pub tls: Option<TlsOptions>,
219225
}
@@ -242,7 +248,7 @@ pub async fn run_server(state: ServerState, options: ServerRunOptions) -> Server
242248

243249
let server = BoundServer::bind(state, options)?;
244250
println!("{}", server.startup_banner(std::io::stdout().is_terminal()));
245-
server.serve(shutdown_signal()).await
251+
server.serve(shutdown::signal()).await
246252
}
247253

248254
/// A configured server with its listening socket already bound.
@@ -276,10 +282,11 @@ impl BoundServer {
276282
self,
277283
shutdown: impl Future<Output = ()> + Send + 'static,
278284
) -> ServerResult<()> {
285+
let shutdown_timeout = self.options.shutdown_timeout;
279286
if let Some(tls) = self.options.tls {
280-
serve_tls(self.listener, self.router, tls, shutdown).await
287+
serve_tls(self.listener, self.router, tls, shutdown_timeout, shutdown).await
281288
} else {
282-
serve(self.listener, self.router, shutdown).await
289+
serve(self.listener, self.router, shutdown_timeout, shutdown).await
283290
}
284291
}
285292

@@ -292,6 +299,7 @@ async fn serve_tls(
292299
listener: TcpListener,
293300
router: Router,
294301
tls: TlsOptions,
302+
shutdown_timeout: Duration,
295303
shutdown: impl Future<Output = ()> + Send + 'static,
296304
) -> ServerResult<()> {
297305
if let Err(error) = rustls::crypto::aws_lc_rs::default_provider().install_default() {
@@ -301,32 +309,49 @@ async fn serve_tls(
301309
let config = RustlsConfig::from_pem_file(tls.cert, tls.key)
302310
.await
303311
.map_err(server_io_error)?;
304-
let handle = axum_server::Handle::new();
305-
306-
let shutdown_handle = handle.clone();
307-
tokio::spawn(async move {
308-
shutdown.await;
309-
shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
310-
});
311-
312312
let std_listener = listener.into_std().map_err(server_io_error)?;
313-
axum_server::from_tcp_rustls(std_listener, config)
314-
.map_err(server_io_error)?
315-
.handle(handle)
316-
.serve(router.into_make_service())
317-
.await
318-
.map_err(server_io_error)
313+
let server = axum_server::from_tcp_rustls(std_listener, config).map_err(server_io_error)?;
314+
let handle = axum_server::Handle::new();
315+
let server = server
316+
.handle(handle.clone())
317+
.serve(router.into_make_service());
318+
serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
319319
}
320320

321321
async fn serve(
322322
listener: TcpListener,
323323
router: Router,
324+
shutdown_timeout: Duration,
324325
shutdown: impl Future<Output = ()> + Send + 'static,
325326
) -> ServerResult<()> {
326-
axum::serve(listener, router)
327-
.with_graceful_shutdown(shutdown)
328-
.await
329-
.map_err(server_io_error)
327+
let std_listener = listener.into_std().map_err(server_io_error)?;
328+
let server = axum_server::from_tcp(std_listener).map_err(server_io_error)?;
329+
let handle = axum_server::Handle::new();
330+
let server = server
331+
.handle(handle.clone())
332+
.serve(router.into_make_service());
333+
serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
334+
}
335+
336+
/// Runs the server until it exits or shutdown begins, then drains active requests.
337+
async fn serve_until_shutdown(
338+
server: impl Future<Output = std::io::Result<()>>,
339+
handle: axum_server::Handle<SocketAddr>,
340+
timeout: Duration,
341+
shutdown: impl Future<Output = ()> + Send + 'static,
342+
) -> ServerResult<()> {
343+
tokio::pin!(server);
344+
tokio::select! {
345+
result = &mut server => result.map_err(server_io_error),
346+
_ = shutdown => {
347+
tracing::info!(
348+
?timeout,
349+
"shutdown signal received; draining active requests"
350+
);
351+
handle.graceful_shutdown(Some(timeout));
352+
server.await.map_err(server_io_error)
353+
}
354+
}
330355
}
331356

332357
/// Ingress timestamp for one request, taken before any body is read.
@@ -411,16 +436,6 @@ fn server_io_error(error: std::io::Error) -> ServerError {
411436
ServerError::new(error.to_string())
412437
}
413438

414-
async fn shutdown_signal() {
415-
if let Err(error) = tokio::signal::ctrl_c().await {
416-
tracing::warn!(
417-
error = %error,
418-
"ctrl-c shutdown signal unavailable; continuing without shutdown trigger"
419-
);
420-
std::future::pending::<()>().await;
421-
}
422-
}
423-
424439
async fn openai_chat_completions(
425440
State(state): State<ServerState>,
426441
Extension(started): Extension<RequestStart>,
@@ -1079,8 +1094,111 @@ fn endpoint_listing(has_routing_log: bool) -> String {
10791094

10801095
#[cfg(test)]
10811096
mod tests {
1097+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1098+
use tokio::sync::{Notify, oneshot};
1099+
10821100
use super::*;
10831101

1102+
#[derive(Clone)]
1103+
struct ShutdownTestState {
1104+
started: Arc<Notify>,
1105+
release: Arc<Notify>,
1106+
}
1107+
1108+
struct ShutdownTestServer {
1109+
state: ShutdownTestState,
1110+
shutdown: oneshot::Sender<()>,
1111+
server: task::JoinHandle<ServerResult<()>>,
1112+
request: task::JoinHandle<std::io::Result<Vec<u8>>>,
1113+
}
1114+
1115+
async fn blocked_request(State(state): State<ShutdownTestState>) -> &'static str {
1116+
state.started.notify_one();
1117+
state.release.notified().await;
1118+
"done"
1119+
}
1120+
1121+
async fn raw_request(addr: SocketAddr) -> std::io::Result<Vec<u8>> {
1122+
let mut stream = tokio::net::TcpStream::connect(addr).await?;
1123+
stream
1124+
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
1125+
.await?;
1126+
let mut response = Vec::new();
1127+
stream.read_to_end(&mut response).await?;
1128+
Ok(response)
1129+
}
1130+
1131+
fn shutdown_test_server(shutdown_timeout: Duration) -> ShutdownTestServer {
1132+
let state = ShutdownTestState {
1133+
started: Arc::new(Notify::new()),
1134+
release: Arc::new(Notify::new()),
1135+
};
1136+
let router = Router::new()
1137+
.route("/", get(blocked_request))
1138+
.with_state(state.clone());
1139+
let listener = bind_tcp_listener("127.0.0.1:0".parse().expect("valid address"), 16)
1140+
.expect("listener binds");
1141+
let addr = listener.local_addr().expect("listener has an address");
1142+
let (shutdown, shutdown_receiver) = oneshot::channel();
1143+
let server = tokio::spawn(serve(listener, router, shutdown_timeout, async move {
1144+
let _ = shutdown_receiver.await;
1145+
}));
1146+
let request = tokio::spawn(raw_request(addr));
1147+
ShutdownTestServer {
1148+
state,
1149+
shutdown,
1150+
server,
1151+
request,
1152+
}
1153+
}
1154+
1155+
// Active requests may finish within the grace period, while stuck requests are bounded.
1156+
#[tokio::test]
1157+
async fn shutdown_drains_until_configured_deadline() {
1158+
let ShutdownTestServer {
1159+
state,
1160+
shutdown,
1161+
mut server,
1162+
request,
1163+
} = shutdown_test_server(Duration::from_secs(1));
1164+
state.started.notified().await;
1165+
shutdown.send(()).expect("server receives shutdown");
1166+
assert!(
1167+
tokio::time::timeout(Duration::from_millis(25), &mut server)
1168+
.await
1169+
.is_err(),
1170+
"server must wait for the active request"
1171+
);
1172+
state.release.notify_one();
1173+
tokio::time::timeout(Duration::from_secs(1), server)
1174+
.await
1175+
.expect("server stops after request drains")
1176+
.expect("server task completes")
1177+
.expect("server exits cleanly");
1178+
let response = request
1179+
.await
1180+
.expect("request task completes")
1181+
.expect("request succeeds");
1182+
assert!(response.windows(8).any(|part| part == b"200 OK\r\n"));
1183+
assert!(response.ends_with(b"done"));
1184+
1185+
let ShutdownTestServer {
1186+
state,
1187+
shutdown,
1188+
server,
1189+
request,
1190+
} = shutdown_test_server(Duration::from_millis(25));
1191+
state.started.notified().await;
1192+
shutdown.send(()).expect("server receives shutdown");
1193+
tokio::time::timeout(Duration::from_secs(1), server)
1194+
.await
1195+
.expect("shutdown deadline is enforced")
1196+
.expect("server task completes")
1197+
.expect("server exits cleanly");
1198+
state.release.notify_one();
1199+
request.abort();
1200+
}
1201+
10841202
// Terminal request severity follows HTTP status instead of error-path bookkeeping.
10851203
#[test]
10861204
fn request_log_level_follows_http_status() {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Platform-specific process shutdown signals.
5+
6+
/// Waits for the platform's normal process termination signal.
7+
pub(crate) async fn signal() {
8+
platform::signal().await;
9+
}
10+
11+
async fn ctrl_c() {
12+
if let Err(error) = tokio::signal::ctrl_c().await {
13+
tracing::warn!(
14+
error = %error,
15+
"ctrl-c shutdown signal unavailable; continuing without shutdown trigger"
16+
);
17+
std::future::pending::<()>().await;
18+
}
19+
}
20+
21+
#[cfg(unix)]
22+
mod platform {
23+
pub(super) async fn signal() {
24+
tokio::select! {
25+
_ = super::ctrl_c() => {},
26+
_ = terminate() => {},
27+
}
28+
}
29+
30+
async fn terminate() {
31+
let mut signal =
32+
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
33+
Ok(signal) => signal,
34+
Err(error) => {
35+
tracing::warn!(
36+
error = %error,
37+
"SIGTERM shutdown signal unavailable; continuing without SIGTERM trigger"
38+
);
39+
std::future::pending::<()>().await;
40+
return;
41+
}
42+
};
43+
signal.recv().await;
44+
}
45+
}
46+
47+
#[cfg(not(unix))]
48+
mod platform {
49+
pub(super) async fn signal() {
50+
super::ctrl_c().await;
51+
}
52+
}

0 commit comments

Comments
 (0)