[Rust Frontend][gRPC] Add abort control RPC (#49255)

Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Connor Carpenter <connorc@nvidia.com>

Connor Carpenter committed Jul 22, 2026 at 04:31 UTC c79ad3ae213249760bc360b50eb733af1bf1f715
5 files changed +177 -29
rust/proto/vllm_grpc.proto
+13
@@ -14,6 +14,10 @@ service Generate {
14 rpc GenerateStream (GenerateRequest) returns (stream GenerateResponse) {}
15 }
16
17 +service Control {
18 + rpc Abort (AbortRequest) returns (AbortResponse) {}
19 +}
20 +
21 // ======================================================================================
22 // Generate Request
23 // ======================================================================================
@@ -201,3 +205,12 @@ message TokenIds {
205 repeated uint32 ids = 1;
206 }
207
208 +// ======================================================================================
209 +// Control
210 +// ======================================================================================
211 +
212 +message AbortRequest {
213 + repeated string request_ids = 1;
214 +}
215 +
216 +message AbortResponse {}
rust/src/server/src/grpc/health.rs
+6 -11
@@ -8,7 +8,7 @@ use tonic_health::ServingStatus;
8 use tonic_health::server::HealthReporter;
9 use tracing::{info, warn};
10
11 -use super::GenerateGrpcService;
11 +use super::{ControlGrpcService, GenerateGrpcService};
12
13 pub(crate) async fn monitor_health(
14 mut health_reporter: HealthReporter,
@@ -16,21 +16,18 @@ pub(crate) async fn monitor_health(
16 shutdown: CancellationToken,
17 ) {
18 let generate_service = GenerateGrpcService::NAME;
19 + let control_service = ControlGrpcService::NAME;
20 let status = ServingStatus::NotServing;
21 let health_event_first = tokio::select! {
22 result = engine_health.wait_for(|healthy| !*healthy) => {
23 match result {
24 Ok(_) => warn!(
24 - generate_service,
25 - overall_service = true,
25 status = ?status,
26 reason = "engine_unhealthy",
27 "marking gRPC health services as not serving"
28 ),
29 Err(error) => warn!(
30 %error,
32 - generate_service,
33 - overall_service = true,
31 status = ?status,
32 reason = "health_channel_closed",
33 "engine health channel closed; marking gRPC health services as not serving"
@@ -40,8 +37,6 @@ pub(crate) async fn monitor_health(
37 }
38 _ = shutdown.cancelled() => {
39 info!(
43 - generate_service,
44 - overall_service = true,
40 status = ?status,
41 reason = "server_shutdown",
42 "server shutting down; marking gRPC health services as not serving"
@@ -51,20 +46,20 @@ pub(crate) async fn monitor_health(
46 };
47
48 health_reporter.set_not_serving::<GenerateGrpcService>().await;
54 - // Generate is currently the only engine-backed gRPC service, so overall
55 - // server health intentionally mirrors it.
49 + health_reporter.set_not_serving::<ControlGrpcService>().await;
50 + // Both gRPC services use the same engine client, so overall server health
51 + // mirrors their shared engine health.
52 health_reporter.set_service_status("", status).await;
53
54 if health_event_first {
55 shutdown.cancelled().await;
56 info!(
61 - generate_service,
62 - overall_service = true,
57 reason = "server_shutdown",
58 "server shutting down; closing gRPC health watches"
59 );
60 }
61
62 health_reporter.clear_service_status(generate_service).await;
63 + health_reporter.clear_service_status(control_service).await;
64 health_reporter.clear_service_status("").await;
65 }
rust/src/server/src/grpc/mod.rs
+32
@@ -26,8 +26,10 @@ pub mod pb {
26 }
27
28 pub(crate) use health::monitor_health;
29 +pub use pb::control_server::ControlServer;
30 pub use pb::generate_server::GenerateServer;
31
32 +pub(crate) type ControlGrpcService = ControlServer<ControlServiceImpl>;
33 pub(crate) type GenerateGrpcService = GenerateServer<GenerateServiceImpl>;
34
35 #[cfg(test)]
@@ -44,6 +46,36 @@ impl GenerateServiceImpl {
46 }
47 }
48
49 +/// gRPC control service backed by the shared application state.
50 +pub struct ControlServiceImpl {
51 + state: Arc<AppState>,
52 +}
53 +
54 +impl ControlServiceImpl {
55 + pub fn new(state: Arc<AppState>) -> Self {
56 + Self { state }
57 + }
58 +}
59 +
60 +#[tonic::async_trait]
61 +impl pb::control_server::Control for ControlServiceImpl {
62 + async fn abort(
63 + &self,
64 + request: Request<pb::AbortRequest>,
65 + ) -> Result<Response<pb::AbortResponse>, Status> {
66 + let request_ids = request.into_inner().request_ids;
67 + if request_ids.is_empty() {
68 + return Ok(Response::new(pb::AbortResponse {}));
69 + }
70 + self.state
71 + .chat
72 + .abort(&request_ids)
73 + .await
74 + .map_err(|error| Status::internal(error.to_report_string()))?;
75 + Ok(Response::new(pb::AbortResponse {}))
76 + }
77 +}
78 +
79 #[tonic::async_trait]
80 impl pb::generate_server::Generate for GenerateServiceImpl {
81 type GenerateStreamStream =
rust/src/server/src/grpc/tests.rs
+122 -18
@@ -38,8 +38,9 @@ use vllm_tokenizer::test_utils::TestTokenizer;
38 use zeromq::prelude::{SocketRecv, SocketSend};
39 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
40
41 +use super::pb::control_client::ControlClient;
42 use super::pb::generate_client::GenerateClient;
42 -use super::{GenerateServer, GenerateServiceImpl, pb};
43 +use super::{ControlServer, ControlServiceImpl, GenerateServer, GenerateServiceImpl, pb};
44 use crate::listener::{Listener, MaybeTlsListener};
45 use crate::state::AppState;
46 use crate::tls;
@@ -153,10 +154,6 @@ async fn recv_engine_message(dealer: &mut DealerSocket) -> Vec<bytes::Bytes> {
154 dealer.recv().await.expect("recv engine message").into_vec()
155 }
156
156 -fn test_llm(client: EngineCoreClient) -> Llm {
157 - Llm::new(client).with_request_id_randomization(false)
158 -}
159 -
157 #[derive(Clone, Debug)]
158 struct FakeTextBackend;
159
@@ -206,6 +203,7 @@ async fn setup_grpc_service(
203 output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
204 ) -> (
205 GenerateServer<GenerateServiceImpl>,
206 + ControlServer<ControlServiceImpl>,
207 tokio::sync::watch::Receiver<bool>,
208 MockEngineTask,
209 ) {
@@ -243,12 +241,13 @@ async fn setup_grpc_service(
241 let engine_health = client.subscribe_health();
242
243 let chat = ChatLlm::from_shared_backend(
246 - test_llm(client),
244 + Llm::new(client),
245 Arc::new(FakeTextBackend) as Arc<dyn ChatTextBackend>,
246 );
247 let state = Arc::new(AppState::new(vec!["test-model".to_string()], chat));
248 (
251 - GenerateServer::new(GenerateServiceImpl::new(state)),
249 + GenerateServer::new(GenerateServiceImpl::new(state.clone())),
250 + ControlServer::new(ControlServiceImpl::new(state)),
251 engine_health,
252 engine_task,
253 )
@@ -264,9 +263,11 @@ async fn grpc_test_server(
263 tokio::task::JoinHandle<()>,
264 MockEngineTask,
265 ) {
267 - let (svc, engine_health, engine_task) = setup_grpc_service(engine_id, output_specs).await;
266 + let (generate_service, control_service, engine_health, engine_task) =
267 + setup_grpc_service(engine_id, output_specs).await;
268 let (channel, server_task) = start_grpc_test_server(
269 - svc,
269 + generate_service,
270 + control_service,
271 engine_health,
272 tokio_util::sync::CancellationToken::new(),
273 )
@@ -276,11 +277,13 @@ async fn grpc_test_server(
277
278 async fn start_grpc_test_server(
279 generate_service: GenerateServer<GenerateServiceImpl>,
280 + control_service: ControlServer<ControlServiceImpl>,
281 engine_health: tokio::sync::watch::Receiver<bool>,
282 shutdown: tokio_util::sync::CancellationToken,
283 ) -> (Channel, tokio::task::JoinHandle<()>) {
284 let (health_reporter, health_service) = health_reporter();
285 health_reporter.set_serving::<GenerateServer<GenerateServiceImpl>>().await;
286 + health_reporter.set_serving::<ControlServer<ControlServiceImpl>>().await;
287
288 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener");
289 let addr = listener.local_addr().expect("local addr");
@@ -289,6 +292,7 @@ async fn start_grpc_test_server(
292 let incoming = MaybeTlsListener::plain(Listener::Tcp(listener));
293 let server = TonicServer::builder()
294 .add_service(health_service)
295 + .add_service(control_service)
296 .add_service(generate_service)
297 .serve_with_incoming_shutdown(incoming, shutdown.clone().cancelled_owned());
298 let health_monitor =
@@ -319,7 +323,8 @@ async fn grpc_tls_test_server(
323 certs: &TestCerts,
324 cert_reqs: i32,
325 ) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) {
322 - let (svc, _engine_health, engine_task) = setup_grpc_service(engine_id, output_specs).await;
326 + let (generate_service, control_service, _engine_health, engine_task) =
327 + setup_grpc_service(engine_id, output_specs).await;
328 let context = tls::build_grpc_server_config(&server_tls(certs, cert_reqs))
329 .expect("build grpc tls config");
330
@@ -329,7 +334,8 @@ async fn grpc_tls_test_server(
334 let server_task = tokio::spawn(async move {
335 let incoming = MaybeTlsListener::tls(Listener::Tcp(listener), context);
336 TonicServer::builder()
332 - .add_service(svc)
337 + .add_service(control_service)
338 + .add_service(generate_service)
339 .serve_with_incoming(incoming)
340 .await
341 .expect("grpc tls server");
@@ -409,7 +415,7 @@ async fn grpc_server_with_keepalive(
415 engine_id: impl Into<EngineId>,
416 keepalive: Option<Duration>,
417 ) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) {
412 - let (svc, _engine_health, engine_task) =
418 + let (generate_service, control_service, _engine_health, engine_task) =
419 setup_grpc_service(engine_id, default_stream_output_specs()).await;
420
421 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener");
@@ -425,7 +431,8 @@ async fn grpc_server_with_keepalive(
431 let server_task = tokio::spawn(async move {
432 let incoming = MaybeTlsListener::plain(Listener::Tcp(listener));
433 builder
428 - .add_service(svc)
434 + .add_service(control_service)
435 + .add_service(generate_service)
436 .serve_with_incoming(incoming)
437 .await
438 .expect("grpc server");
@@ -1073,14 +1080,106 @@ async fn grpc_without_keepalive_keeps_unresponsive_connection_open() {
1080 server_task.abort();
1081 }
1082
1083 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1084 +#[serial]
1085 +async fn control_abort_resolves_external_id_and_empty_is_noop() {
1086 + let (generate_service, control_service, engine_health, engine_task) =
1087 + setup_grpc_service(b"engine-grpc-abort-active", vec![(vec![b'h' as u32], None)]).await;
1088 + let (channel, server_task) = start_grpc_test_server(
1089 + generate_service,
1090 + control_service,
1091 + engine_health,
1092 + tokio_util::sync::CancellationToken::new(),
1093 + )
1094 + .await;
1095 + let mut generate_client = GenerateClient::new(channel.clone());
1096 + let mut control_client = ControlClient::new(channel);
1097 + let request_id = "test-abort-active";
1098 +
1099 + let mut stream = generate_client
1100 + .generate_stream(pb::GenerateRequest {
1101 + request_id: request_id.to_string(),
1102 + model: "test-model".to_string(),
1103 + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())),
1104 + stopping: Some(pb::StoppingCriteria {
1105 + max_new_tokens: 10,
1106 + ..Default::default()
1107 + }),
1108 + ..Default::default()
1109 + })
1110 + .await
1111 + .expect("start generation")
1112 + .into_inner();
1113 +
1114 + loop {
1115 + let response = tokio::time::timeout(Duration::from_secs(2), stream.message())
1116 + .await
1117 + .expect("timed out waiting for active generation output")
1118 + .expect("read active generation output")
1119 + .expect("generation ended before producing output");
1120 + if let Some(output) = response.outputs {
1121 + assert!(
1122 + output.finish_info.is_none(),
1123 + "generation finished before abort behavior was exercised"
1124 + );
1125 + break;
1126 + }
1127 + }
1128 +
1129 + control_client
1130 + .abort(pb::AbortRequest::default())
1131 + .await
1132 + .expect("empty abort should be a no-op");
1133 + assert!(
1134 + tokio::time::timeout(Duration::from_millis(100), stream.message())
1135 + .await
1136 + .is_err(),
1137 + "empty abort unexpectedly ended the active generation"
1138 + );
1139 +
1140 + control_client
1141 + .abort(pb::AbortRequest {
1142 + request_ids: vec![
1143 + request_id.to_string(),
1144 + request_id.to_string(),
1145 + "unknown".to_string(),
1146 + ],
1147 + })
1148 + .await
1149 + .expect("abort active generation");
1150 +
1151 + let finish_reason = loop {
1152 + let response = tokio::time::timeout(Duration::from_secs(2), stream.message())
1153 + .await
1154 + .expect("timed out waiting for aborted generation")
1155 + .expect("read aborted generation")
1156 + .expect("generation ended without an aborted response");
1157 + if let Some(finish_info) = response.outputs.and_then(|output| output.finish_info) {
1158 + break finish_info.finish_reason;
1159 + }
1160 + };
1161 + assert_eq!(finish_reason, pb::finish_info::FinishReason::Aborted as i32);
1162 +
1163 + control_client
1164 + .abort(pb::AbortRequest {
1165 + request_ids: vec![request_id.to_string()],
1166 + })
1167 + .await
1168 + .expect("repeated abort should be idempotent");
1169 +
1170 + engine_task.await.expect("mock engine task");
1171 + server_task.abort();
1172 +}
1173 +
1174 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1175 #[serial]
1176 async fn grpc_health_transitions_to_not_serving_when_engine_becomes_unhealthy() {
1079 - let (generate_service, _connected_engine_health, _engine_task) =
1177 + let (generate_service, control_service, _connected_engine_health, _engine_task) =
1178 setup_grpc_service(b"engine-grpc-health-failure", default_stream_output_specs()).await;
1179 let (engine_health_tx, engine_health) = tokio::sync::watch::channel(true);
1180 let (channel, server_task) = start_grpc_test_server(
1181 generate_service,
1182 + control_service,
1183 engine_health,
1184 tokio_util::sync::CancellationToken::new(),
1185 )
@@ -1088,7 +1187,7 @@ async fn grpc_health_transitions_to_not_serving_when_engine_becomes_unhealthy()
1187 let mut health_client = HealthClient::new(channel);
1188
1189 let mut health_streams = Vec::new();
1091 - for service in ["vllm.Generate", ""] {
1190 + for service in ["vllm.Generate", "vllm.Control", ""] {
1191 let service_label = if service.is_empty() {
1192 "overall"
1193 } else {
@@ -1143,14 +1242,19 @@ async fn grpc_health_transitions_to_not_serving_when_engine_becomes_unhealthy()
1242 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1243 #[serial]
1244 async fn grpc_health_watch_closes_on_graceful_shutdown() {
1146 - let (generate_service, engine_health, _engine_task) = setup_grpc_service(
1245 + let (generate_service, control_service, engine_health, _engine_task) = setup_grpc_service(
1246 b"engine-grpc-health-shutdown",
1247 default_stream_output_specs(),
1248 )
1249 .await;
1250 let shutdown = tokio_util::sync::CancellationToken::new();
1152 - let (channel, server_task) =
1153 - start_grpc_test_server(generate_service, engine_health, shutdown.clone()).await;
1251 + let (channel, server_task) = start_grpc_test_server(
1252 + generate_service,
1253 + control_service,
1254 + engine_health,
1255 + shutdown.clone(),
1256 + )
1257 + .await;
1258 let mut health_client = HealthClient::new(channel);
1259 let mut stream = health_client
1260 .watch(HealthCheckRequest {
rust/src/server/src/lib.rs
+4
@@ -207,6 +207,9 @@ where
207 let (health_reporter, health_service) = health_reporter();
208 let engine_health = state.engine_core_client().subscribe_health();
209 health_reporter.set_serving::<grpc::GenerateGrpcService>().await;
210 + health_reporter.set_serving::<grpc::ControlGrpcService>().await;
211 + let control_service =
212 + grpc::ControlGrpcService::new(grpc::ControlServiceImpl::new(state.clone()));
213 let generate_service =
214 grpc::GenerateGrpcService::new(grpc::GenerateServiceImpl::new(state.clone()));
215 let svc = TonicServer::builder()
@@ -214,6 +217,7 @@ where
217 .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
218 .layer(middleware::request_runtime_layer(state.clone()))
219 .add_service(health_service)
220 + .add_service(control_service)
221 .add_service(generate_service);
222 info!(%addr, tls = grpc_tls.is_some(), "starting gRPC server");
223 Some((grpc_listener, svc, grpc_tls, health_reporter, engine_health))