[Feature][Rust Frontend] Add Shutdown control RPC (#59316)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Julien Darve <jdarve@NVIDIA.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

JulienDarve committed Sep 30, 2026 at 05:49 UTC f28a5081629377c36cd219a35070555b72279109
10 files changed +129 -9
docs/usage/security.md
+1 -1
@@ -375,7 +375,7 @@ An attacker who can reach the gRPC port can:
375 1. **Run arbitrary inference** via the `Generate` and `GenerateStream` RPCs without any credentials
376 2. **Mutate engine state** by pausing generation, sleeping the engine, or initiating configured RL weight updates through the `Control` service
377 3. **Consume GPU and compute resources** by submitting unbounded generation requests
378 -4. **Cause Denial of Service** by exploiting bugs in the gRPC interface that can crash vLLM.
378 +4. **Stop a managed engine** through `Control.Shutdown`, or cause denial of service by exploiting bugs in the gRPC interface.
379
380 ### Recommendations
381
rust/proto/README.md
+1 -1
@@ -16,7 +16,7 @@ the schemas on pull requests, including on forks.
16
17 ```toml
18 [dependencies]
19 -vllm-proto = "0.3"
19 +vllm-proto = "0.4"
20 ```
21
22 For example, import `vllm_proto::inference_client::InferenceClient` or
rust/proto/control.proto
+8
@@ -9,6 +9,10 @@ service Control {
9 rpc GetServerInfo (GetServerInfoRequest) returns (ServerInfo) {}
10 rpc GetModelInfo (GetModelInfoRequest) returns (ModelInfo) {}
11
12 + // Request managed-engine shutdown using the configured shutdown timeout.
13 + // Returns FAILED_PRECONDITION when this server does not manage the engine process.
14 + rpc Shutdown (ShutdownRequest) returns (ShutdownResponse) {}
15 +
16 // Request lifecycle.
17 rpc Abort (AbortRequest) returns (AbortResponse) {}
18
@@ -86,6 +90,10 @@ message ModelInfo {
90 string tool_call_parser = 25;
91 }
92
93 +message ShutdownRequest {}
94 +// Acknowledges acceptance, not completion; shutdown may close the connection first.
95 +message ShutdownResponse {}
96 +
97 message AbortRequest {
98 repeated string request_ids = 1;
99 }
rust/src/cmd/src/cli.rs
+6
@@ -557,6 +557,8 @@ impl SharedRuntimeArgs {
557 disable_log_stats: self.disable_log_stats,
558 grpc_port: self.grpc_port,
559 shutdown_timeout,
560 + // The engine is launched and supervised by another process.
561 + manages_engine: false,
562 keep_alive_timeout,
563 profiler,
564 }
@@ -572,6 +574,7 @@ impl SharedRuntimeArgs {
574 engine_count: usize,
575 local_input_address: Option<String>,
576 local_output_address: Option<String>,
577 + manages_engine: bool,
578 ) -> Config {
579 let ready_timeout = self.ready_timeout();
580 let shutdown_timeout = self.shutdown_timeout();
@@ -615,6 +618,7 @@ impl SharedRuntimeArgs {
618 disable_log_stats: self.disable_log_stats,
619 grpc_port: self.grpc_port,
620 shutdown_timeout,
621 + manages_engine,
622 keep_alive_timeout,
623 profiler,
624 }
@@ -793,6 +797,8 @@ impl ServeArgs {
797 self.managed_engine.data_parallel_size,
798 local_input_address,
799 local_output_address,
800 + // `--data-parallel-size-local 0` runs the frontend without a local engine.
801 + self.managed_engine.data_parallel_size_local != Some(0),
802 )
803 }
804
rust/src/cmd/src/cli/tests.rs
+26
@@ -1889,6 +1889,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1889 disable_log_stats: false,
1890 grpc_port: None,
1891 shutdown_timeout: 0ns,
1892 + manages_engine: true,
1893 keep_alive_timeout: 5s,
1894 profiler: None,
1895 }
@@ -1983,6 +1984,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1984 disable_log_stats: false,
1985 grpc_port: None,
1986 shutdown_timeout: 0ns,
1987 + manages_engine: true,
1988 keep_alive_timeout: 5s,
1989 profiler: None,
1990 }
@@ -2011,6 +2013,29 @@ fn frontend_args_reject_legacy_handshake_flags() {
2013 assert!(error.to_string().contains("--handshake-address"));
2014 }
2015
2016 +#[test]
2017 +fn serve_frontend_config_does_not_manage_engine_without_local_engines() {
2018 + let cli = Cli::try_parse_from([
2019 + "vllm-rs",
2020 + "serve",
2021 + "Qwen/Qwen3-0.6B",
2022 + "--data-parallel-address",
2023 + "10.99.48.128",
2024 + "--data-parallel-size",
2025 + "2",
2026 + "--data-parallel-size-local",
2027 + "0",
2028 + ])
2029 + .unwrap();
2030 +
2031 + let Command::Serve(args) = cli.command else {
2032 + panic!("expected serve args");
2033 + };
2034 + let config = args.to_frontend_config("tcp://10.99.48.128:29550".to_string());
2035 +
2036 + assert!(!config.manages_engine);
2037 +}
2038 +
2039 #[test]
2040 fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present() {
2041 let cli = Cli::try_parse_from([
@@ -2098,6 +2123,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
2123 disable_log_stats: false,
2124 grpc_port: None,
2125 shutdown_timeout: 0ns,
2126 + manages_engine: false,
2127 keep_alive_timeout: 5s,
2128 profiler: None,
2129 }
rust/src/server/examples/external_engine_openai_qwen.rs
+1
@@ -87,6 +87,7 @@ async fn main() -> Result<()> {
87 disable_log_stats: false,
88 grpc_port: None,
89 shutdown_timeout: Duration::ZERO,
90 + manages_engine: false,
91 keep_alive_timeout: Duration::from_secs(5),
92 profiler: None,
93 };
rust/src/server/src/config.rs
+3
@@ -277,6 +277,9 @@ pub struct Config {
277 pub grpc_port: Option<u16>,
278 /// Maximum time to wait for active HTTP/gRPC requests to drain on shutdown.
279 pub shutdown_timeout: Duration,
280 + /// Whether the caller manages the engine process and shuts it down when
281 + /// the shutdown token is cancelled. Enables the gRPC `Control.Shutdown` RPC.
282 + pub manages_engine: bool,
283 /// Maximum idle time on a keep-alive HTTP connection before the server
284 /// closes it (`VLLM_HTTP_TIMEOUT_KEEP_ALIVE`, default 5s).
285 pub keep_alive_timeout: Duration,
rust/src/server/src/grpc/control.rs
+20
@@ -6,6 +6,7 @@ use std::sync::Arc;
6 use serde_json::Value as JsonValue;
7 use thiserror_ext::AsReport as _;
8 use tokio::sync::Mutex;
9 +use tokio_util::sync::CancellationToken;
10 use tonic::{Code, Request, Response, Status};
11 use vllm_engine_core_client::EngineCoreClient;
12 use vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse;
@@ -23,6 +24,7 @@ pub(crate) type ControlGrpcService = ControlServer<ControlServiceImpl>;
24 pub struct ControlServiceImpl {
25 state: Arc<AppState>,
26 rl_lock: Mutex<()>,
27 + engine_shutdown: Option<CancellationToken>,
28 }
29
30 impl ControlServiceImpl {
@@ -30,9 +32,15 @@ impl ControlServiceImpl {
32 Self {
33 state,
34 rl_lock: Mutex::new(()),
35 + engine_shutdown: None,
36 }
37 }
38
39 + pub fn with_engine_shutdown(mut self, shutdown: Option<CancellationToken>) -> Self {
40 + self.engine_shutdown = shutdown;
41 + self
42 + }
43 +
44 fn ready(&self) -> &EngineCoreReadyResponse {
45 self.state.engine_core_client().ready_response()
46 }
@@ -179,6 +187,18 @@ fn list_loras_status(error: LoraDisabledError) -> Status {
187
188 #[tonic::async_trait]
189 impl pb::control_server::Control for ControlServiceImpl {
190 + async fn shutdown(
191 + &self,
192 + _request: Request<pb::ShutdownRequest>,
193 + ) -> Result<Response<pb::ShutdownResponse>, Status> {
194 + let shutdown = self.engine_shutdown.as_ref().ok_or_else(|| {
195 + Status::failed_precondition("this frontend does not manage the engine process")
196 + })?;
197 + tracing::info!("engine shutdown requested via gRPC");
198 + shutdown.cancel();
199 + Ok(Response::new(pb::ShutdownResponse {}))
200 + }
201 +
202 async fn get_server_info(
203 &self,
204 _request: Request<pb::GetServerInfoRequest>,
rust/src/server/src/grpc/tests.rs
+58 -4
@@ -366,6 +366,7 @@ where
366 engine_id,
367 default_ready_response(),
368 backend,
369 + None,
370 move |dealer, push| {
371 boxed_test_future(async move {
372 let add = recv_engine_message(dealer).await;
@@ -387,6 +388,7 @@ async fn setup_grpc_service_with_engine_script<F>(
388 engine_id: impl Into<EngineId>,
389 ready: EngineCoreReadyResponse,
390 backend: Arc<dyn ChatTextBackend>,
391 + engine_shutdown: Option<tokio_util::sync::CancellationToken>,
392 script: F,
393 ) -> (
394 InferenceServer<InferenceServiceImpl>,
@@ -425,7 +427,7 @@ where
427 (
428 InferenceServer::new(InferenceServiceImpl::new(state.clone()))
429 .max_decoding_message_size(crate::DEFAULT_REQUEST_BODY_LIMIT_BYTES),
428 - ControlServer::new(ControlServiceImpl::new(state)),
430 + ControlServer::new(ControlServiceImpl::new(state).with_engine_shutdown(engine_shutdown)),
431 engine_health,
432 engine_task,
433 )
@@ -1920,6 +1922,7 @@ async fn control_reports_server_and_model_info() {
1922 b"engine-grpc-info".to_vec(),
1923 ready,
1924 Arc::new(FakeTextBackend),
1925 + None,
1926 |_, _| boxed_test_future(async {}),
1927 )
1928 .await;
@@ -1987,6 +1990,7 @@ async fn control_lora_lifecycle_selects_adapter_for_generation() {
1990 b"engine-grpc-lora".to_vec(),
1991 ready,
1992 Arc::new(FakeTextBackend),
1993 + None,
1994 |dealer, push| {
1995 boxed_test_future(async move {
1996 reply_utility_bool(dealer, push, "add_lora", true).await;
@@ -2111,6 +2115,7 @@ async fn control_list_loras_requires_lora_enabled_engine() {
2115 b"engine-grpc-lora-disabled".to_vec(),
2116 default_ready_response(),
2117 Arc::new(FakeTextBackend),
2118 + None,
2119 |_, _| boxed_test_future(async move {}),
2120 )
2121 .await;
@@ -2143,6 +2148,7 @@ async fn control_forwards_weight_update_without_pause_guard() {
2148 b"engine-grpc-rl".to_vec(),
2149 ready,
2150 Arc::new(FakeTextBackend),
2151 + None,
2152 |dealer, push| {
2153 boxed_test_future(async move {
2154 let frames = recv_engine_message(dealer).await;
@@ -2392,9 +2398,9 @@ async fn grpc_health_transitions_to_not_serving_when_engine_becomes_unhealthy()
2398
2399 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2400 #[serial]
2395 -async fn grpc_health_watch_closes_on_graceful_shutdown() {
2401 +async fn grpc_shutdown_requires_managed_engine() {
2402 let (inference_service, control_service, engine_health, _engine_task) = setup_grpc_service(
2397 - b"engine-grpc-health-shutdown",
2403 + b"engine-grpc-external-shutdown",
2404 default_stream_output_specs(),
2405 )
2406 .await;
@@ -2406,6 +2412,47 @@ async fn grpc_health_watch_closes_on_graceful_shutdown() {
2412 shutdown.clone(),
2413 )
2414 .await;
2415 + let mut control_client = ControlClient::new(channel.clone());
2416 +
2417 + let status = control_client
2418 + .shutdown(pb::ShutdownRequest {})
2419 + .await
2420 + .expect_err("external engine shutdown must be rejected");
2421 + assert_eq!(status.code(), tonic::Code::FailedPrecondition);
2422 + assert!(!shutdown.is_cancelled());
2423 +
2424 + let health = HealthClient::new(channel)
2425 + .check(HealthCheckRequest {
2426 + service: "vllm.Inference".to_string(),
2427 + })
2428 + .await
2429 + .expect("server remains healthy after rejected shutdown")
2430 + .into_inner();
2431 + assert_eq!(health.status, HealthServingStatus::Serving as i32);
2432 + server_task.abort();
2433 +}
2434 +
2435 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2436 +#[serial]
2437 +async fn grpc_shutdown_closes_health_watch_and_server() {
2438 + let shutdown = tokio_util::sync::CancellationToken::new();
2439 + let (inference_service, control_service, engine_health, _engine_task) =
2440 + setup_grpc_service_with_engine_script(
2441 + b"engine-grpc-health-shutdown",
2442 + default_ready_response(),
2443 + Arc::new(FakeTextBackend),
2444 + Some(shutdown.clone()),
2445 + |_, _| boxed_test_future(async {}),
2446 + )
2447 + .await;
2448 + let (channel, server_task) = start_grpc_test_server(
2449 + inference_service,
2450 + control_service,
2451 + engine_health,
2452 + shutdown.child_token(),
2453 + )
2454 + .await;
2455 + let mut control_client = ControlClient::new(channel.clone());
2456 let mut health_client = HealthClient::new(channel);
2457 let mut stream = health_client
2458 .watch(HealthCheckRequest {
@@ -2426,7 +2473,14 @@ async fn grpc_health_watch_closes_on_graceful_shutdown() {
2473 "unexpected initial health status for vllm.Inference"
2474 );
2475
2429 - shutdown.cancel();
2476 + control_client
2477 + .shutdown(pb::ShutdownRequest {})
2478 + .await
2479 + .expect("accept managed engine shutdown");
2480 + assert!(
2481 + shutdown.is_cancelled(),
2482 + "engine owner must receive shutdown"
2483 + );
2484
2485 let update = tokio::time::timeout(Duration::from_secs(2), stream.message())
2486 .await
rust/src/server/src/lib.rs
+5 -3
@@ -247,9 +247,11 @@ where
247 let engine_health = state.engine_core_client().subscribe_health();
248 health_reporter.set_serving::<grpc::InferenceGrpcService>().await;
249 health_reporter.set_serving::<grpc::ControlGrpcService>().await;
250 - let control_service =
251 - grpc::ControlGrpcService::new(grpc::ControlServiceImpl::new(state.clone()))
252 - .max_decoding_message_size(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
250 + let control_service = grpc::ControlGrpcService::new(
251 + grpc::ControlServiceImpl::new(state.clone())
252 + .with_engine_shutdown(config.manages_engine.then(|| shutdown.clone())),
253 + )
254 + .max_decoding_message_size(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
255 let inference_service =
256 grpc::InferenceGrpcService::new(grpc::InferenceServiceImpl::new(state.clone()))
257 .max_decoding_message_size(DEFAULT_REQUEST_BODY_LIMIT_BYTES);