[Rust Frontend] Expose gRPC port in Python vllm serve (#59659)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Alec Flowers <aflowers@nvidia.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Alec committed
Oct 2, 2026 at 01:14 UTC
9d2a6f52b264c20b5816c5a094b191bfbcadd74b
11 files changed
+320
-64
rust/README.md
+21
@@ -43,6 +43,27 @@ For example:
43
VLLM_USE_RUST_FRONTEND=1 vllm serve Qwen/Qwen3-0.6B
44
```
45
46
+To also expose the Rust frontend's gRPC inference and control services, add
47
+`--grpc-port`. The HTTP listener remains on `--port`:
48
+
49
+```bash
50
+VLLM_USE_RUST_FRONTEND=1 vllm serve Qwen/Qwen3-0.6B \
51
+ --host 127.0.0.1 --port 8000 --grpc-port 50051
52
+```
53
+
54
+The gRPC listener binds on `--host` like HTTP, or on `127.0.0.1` when HTTP uses
55
+`--uds`. When HTTP uses TCP, its port and the gRPC port must differ unless
56
+requesting an automatically assigned port with `0`.
57
+
58
+This also works with Python-supervised hybrid data parallelism. It requires a
59
+frontend process and cannot be combined with `--headless` or
60
+`--data-parallel-multi-port-external-lb`.
61
+
62
+The separate `vllm serve MODEL --grpc --port 50051` command launches the Python
63
+SMG `VllmEngine` gRPC server in place of HTTP. The Rust listener exposes the
64
+`vllm.Inference` and `vllm.Control` services alongside HTTP. These are different
65
+gRPC APIs; `--grpc` and `--grpc-port` are mutually exclusive.
66
+
67
### RL weight synchronization
68
69
With `VLLM_SERVER_DEV_MODE=1`, the Rust frontend supports the HTTP weight-transfer
rust/src/cmd/src/cli.rs
+24
-7
@@ -268,11 +268,6 @@ pub struct SharedRuntimeArgs {
268
#[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)]
269
#[serde(default)]
270
pub max_logprobs: Option<i32>,
271
- /// TCP port for the gRPC Inference service. When not set, no gRPC server is
272
- /// started.
273
- #[arg(long)]
274
- #[serde(default)]
275
- pub grpc_port: Option<u16>,
271
/// Maximum time to wait for active requests to drain during shutdown.
272
#[arg(long, default_value_t = 0)]
273
#[serde(default)]
@@ -505,6 +500,7 @@ impl SharedRuntimeArgs {
500
fn into_bootstrapped_config(
501
self,
502
listen_fd: i32,
503
+ grpc_listen_fd: Option<i32>,
504
input_address: String,
505
output_address: String,
506
coordinator_address: Option<String>,
@@ -539,6 +535,7 @@ impl SharedRuntimeArgs {
535
generation_config: self.generation_config,
536
served_model_name: self.served_model_name,
537
listener_mode: HttpListenerMode::InheritedFd { fd: listen_fd },
538
+ grpc_listener_mode: grpc_listen_fd.map(|fd| HttpListenerMode::InheritedFd { fd }),
539
tool_call_parser: self.tool_call_parser,
540
reasoning_parser: self.reasoning_parser,
541
tool_strict_level: self.tool_strict_level,
@@ -555,7 +552,6 @@ impl SharedRuntimeArgs {
552
tls,
553
api_keys: self.api_key,
554
disable_log_stats: self.disable_log_stats,
558
- grpc_port: self.grpc_port,
555
shutdown_timeout,
556
// The engine is launched and supervised by another process.
557
manages_engine: false,
@@ -569,6 +565,7 @@ impl SharedRuntimeArgs {
565
fn into_managed_config(
566
self,
567
listener_mode: HttpListenerMode,
568
+ grpc_listener_mode: Option<HttpListenerMode>,
569
handshake_address: String,
570
advertised_host: String,
571
engine_count: usize,
@@ -600,6 +597,7 @@ impl SharedRuntimeArgs {
597
generation_config: self.generation_config,
598
served_model_name: self.served_model_name,
599
listener_mode,
600
+ grpc_listener_mode,
601
tool_call_parser: self.tool_call_parser,
602
reasoning_parser: self.reasoning_parser,
603
tool_strict_level: self.tool_strict_level,
@@ -616,7 +614,6 @@ impl SharedRuntimeArgs {
614
tls,
615
api_keys: self.api_key,
616
disable_log_stats: self.disable_log_stats,
619
- grpc_port: self.grpc_port,
617
shutdown_timeout,
618
manages_engine,
619
keep_alive_timeout,
@@ -696,6 +693,10 @@ pub struct FrontendArgs {
693
/// supervisor.
694
#[arg(long)]
695
pub listen_fd: i32,
696
+ /// Inherited gRPC listening socket file descriptor passed by the Python
697
+ /// supervisor. When not set, no gRPC server is started.
698
+ #[arg(long)]
699
+ pub grpc_listen_fd: Option<i32>,
700
/// Frontend input ROUTER socket address that the Python engines will
701
/// connect to.
702
#[arg(long)]
@@ -731,6 +732,7 @@ impl FrontendArgs {
732
let data_parallel_size = self.data_parallel_size.unwrap_or(self.engine_count);
733
self.runtime.into_bootstrapped_config(
734
self.listen_fd,
735
+ self.grpc_listen_fd,
736
self.input_address,
737
self.output_address,
738
self.coordinator_address,
@@ -760,6 +762,10 @@ pub struct ServeArgs {
762
/// Unix domain socket path. If set, host and port arguments are ignored.
763
#[arg(long)]
764
pub uds: Option<String>,
765
+ /// TCP port for the gRPC Inference and Control services. When not set, no
766
+ /// gRPC server is started.
767
+ #[arg(long)]
768
+ pub grpc_port: Option<u16>,
769
770
/// Flag to print debug information about CLI argument parsing and exit.
771
#[educe(Debug(ignore))]
@@ -789,9 +795,20 @@ impl ServeArgs {
795
port: self.port,
796
},
797
};
798
+ // gRPC follows the HTTP TCP host. With a Unix socket it defaults to IPv4
799
+ // loopback rather than all interfaces, so the side-car is never
800
+ // accidentally network-exposed.
801
+ let grpc_listener_mode = self.grpc_port.map(|port| HttpListenerMode::BindTcp {
802
+ host: match self.uds {
803
+ Some(_) => "127.0.0.1".to_string(),
804
+ None => self.host.clone(),
805
+ },
806
+ port,
807
+ });
808
809
self.runtime.clone().into_managed_config(
810
listener_mode,
811
+ grpc_listener_mode,
812
handshake_address,
813
self.managed_engine.handshake_host.clone(),
814
self.managed_engine.data_parallel_size,
rust/src/cmd/src/cli/tests.rs
+71
-6
@@ -174,6 +174,7 @@ fn serve_args_forward_python_flags_with_separator() {
174
host: "127.0.0.1",
175
port: 8000,
176
uds: None,
177
+ grpc_port: None,
178
runtime: SharedRuntimeArgs {
179
model: "Qwen/Qwen3-0.6B",
180
revision: None,
@@ -188,7 +189,6 @@ fn serve_args_forward_python_flags_with_separator() {
189
renderer: Auto,
190
language_model_only: false,
191
max_logprobs: None,
191
- grpc_port: None,
192
shutdown_timeout: 0,
193
http_timeout_keep_alive: None,
194
chat_template: None,
@@ -1007,6 +1007,7 @@ fn frontend_args_accept_json() {
1007
command: Frontend(
1008
FrontendArgs {
1009
listen_fd: 3,
1010
+ grpc_listen_fd: None,
1011
input_address: "ipc:///tmp/input.sock",
1012
output_address: "ipc:///tmp/output.sock",
1013
coordinator_address: Some(
@@ -1029,7 +1030,6 @@ fn frontend_args_accept_json() {
1030
renderer: Auto,
1031
language_model_only: false,
1032
max_logprobs: None,
1032
- grpc_port: None,
1033
shutdown_timeout: 0,
1034
http_timeout_keep_alive: None,
1035
chat_template: None,
@@ -1687,6 +1687,7 @@ fn serve_args_accept_handshake_aliases() {
1687
host: "127.0.0.1",
1688
port: 8000,
1689
uds: None,
1690
+ grpc_port: None,
1691
runtime: SharedRuntimeArgs {
1692
model: "Qwen/Qwen3-0.6B",
1693
revision: None,
@@ -1701,7 +1702,6 @@ fn serve_args_accept_handshake_aliases() {
1702
renderer: Auto,
1703
language_model_only: false,
1704
max_logprobs: None,
1704
- grpc_port: None,
1705
shutdown_timeout: 0,
1706
http_timeout_keep_alive: None,
1707
chat_template: None,
@@ -1854,6 +1854,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1854
host: "127.0.0.1",
1855
port: 8000,
1856
},
1857
+ grpc_listener_mode: None,
1858
tool_call_parser: Auto,
1859
reasoning_parser: Auto,
1860
tool_strict_level: Auto,
@@ -1887,7 +1888,6 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1888
tls: None,
1889
api_keys: [],
1890
disable_log_stats: false,
1890
- grpc_port: None,
1891
shutdown_timeout: 0ns,
1892
manages_engine: true,
1893
keep_alive_timeout: 5s,
@@ -1949,6 +1949,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1949
host: "127.0.0.1",
1950
port: 8000,
1951
},
1952
+ grpc_listener_mode: None,
1953
tool_call_parser: Auto,
1954
reasoning_parser: Auto,
1955
tool_strict_level: Auto,
@@ -1982,7 +1983,6 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1983
tls: None,
1984
api_keys: [],
1985
disable_log_stats: false,
1985
- grpc_port: None,
1986
shutdown_timeout: 0ns,
1987
manages_engine: true,
1988
keep_alive_timeout: 5s,
@@ -2088,6 +2088,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
2088
listener_mode: InheritedFd {
2089
fd: 3,
2090
},
2091
+ grpc_listener_mode: None,
2092
tool_call_parser: None,
2093
reasoning_parser: None,
2094
tool_strict_level: Auto,
@@ -2121,7 +2122,6 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
2122
tls: None,
2123
api_keys: [],
2124
disable_log_stats: false,
2124
- grpc_port: None,
2125
shutdown_timeout: 0ns,
2126
manages_engine: false,
2127
keep_alive_timeout: 5s,
@@ -2139,6 +2139,8 @@ fn serve_frontend_config_uses_unix_listener_when_uds_is_present() {
2139
"Qwen/Qwen3-0.6B",
2140
"--uds",
2141
"/tmp/vllm.sock",
2142
+ "--grpc-port",
2143
+ "50051",
2144
])
2145
.unwrap();
2146
@@ -2153,6 +2155,69 @@ fn serve_frontend_config_uses_unix_listener_when_uds_is_present() {
2155
path: "/tmp/vllm.sock".to_string(),
2156
}
2157
);
2158
+ assert_eq!(
2159
+ config.grpc_listener_mode,
2160
+ Some(HttpListenerMode::BindTcp {
2161
+ host: "127.0.0.1".to_string(),
2162
+ port: 50051,
2163
+ })
2164
+ );
2165
+}
2166
+
2167
+#[test]
2168
+fn serve_frontend_config_binds_grpc_on_http_host() {
2169
+ let cli = Cli::try_parse_from([
2170
+ "vllm-rs",
2171
+ "serve",
2172
+ "Qwen/Qwen3-0.6B",
2173
+ "--host",
2174
+ "0.0.0.0",
2175
+ "--grpc-port",
2176
+ "50051",
2177
+ ])
2178
+ .unwrap();
2179
+
2180
+ let Command::Serve(args) = cli.command else {
2181
+ panic!("expected serve args");
2182
+ };
2183
+ let config = args.to_frontend_config("tcp://127.0.0.1:29550".to_string());
2184
+
2185
+ assert_eq!(
2186
+ config.grpc_listener_mode,
2187
+ Some(HttpListenerMode::BindTcp {
2188
+ host: "0.0.0.0".to_string(),
2189
+ port: 50051,
2190
+ })
2191
+ );
2192
+}
2193
+
2194
+#[test]
2195
+fn frontend_config_adopts_inherited_grpc_listener() {
2196
+ let cli = Cli::try_parse_from([
2197
+ "vllm-rs",
2198
+ "frontend",
2199
+ "--listen-fd",
2200
+ "3",
2201
+ "--grpc-listen-fd",
2202
+ "4",
2203
+ "--input-address",
2204
+ "ipc:///tmp/input.sock",
2205
+ "--output-address",
2206
+ "ipc:///tmp/output.sock",
2207
+ "--args-json",
2208
+ r#"{"model_tag":"Qwen/Qwen3-0.6B","grpc_port":50051}"#,
2209
+ ])
2210
+ .unwrap();
2211
+
2212
+ let Command::Frontend(args) = cli.command else {
2213
+ panic!("expected frontend args");
2214
+ };
2215
+ let config = args.into_config();
2216
+
2217
+ assert_eq!(
2218
+ config.grpc_listener_mode,
2219
+ Some(HttpListenerMode::InheritedFd { fd: 4 })
2220
+ );
2221
}
2222
2223
#[test]
rust/src/server/examples/external_engine_openai_qwen.rs
+1
-1
@@ -69,6 +69,7 @@ async fn main() -> Result<()> {
69
host: "127.0.0.1".to_string(),
70
port,
71
},
72
+ grpc_listener_mode: None,
73
tool_call_parser: ParserSelection::Auto,
74
reasoning_parser: ParserSelection::Auto,
75
tool_strict_level: ToolStrictLevel::Auto,
@@ -85,7 +86,6 @@ async fn main() -> Result<()> {
86
tls: None,
87
api_keys: Vec::new(),
88
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),
rust/src/server/src/config.rs
+3
-4
@@ -23,7 +23,7 @@ use vllm_text::backend::hf::HfOverrides;
23
/// when keep-alive is disabled (`0`).
24
pub const DEFAULT_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
25
26
-/// How the HTTP server obtains its listening socket.
26
+/// How the HTTP or gRPC server obtains its listening socket.
27
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28
pub enum HttpListenerMode {
29
/// Bind a fresh TCP listener on the given host/port.
@@ -229,6 +229,8 @@ pub struct Config {
229
pub served_model_name: Vec<String>,
230
/// HTTP listener setup.
231
pub listener_mode: HttpListenerMode,
232
+ /// gRPC listener setup. When `None`, no gRPC server is started.
233
+ pub grpc_listener_mode: Option<HttpListenerMode>,
234
/// Tool-call parser selection.
235
pub tool_call_parser: ParserSelection,
236
/// Reasoning parser selection.
@@ -272,9 +274,6 @@ pub struct Config {
274
/// engine-reported scheduler stats and request lifecycle events are not
275
/// exported.
276
pub disable_log_stats: bool,
275
- /// TCP port for the gRPC Inference service. When `None`, no gRPC server is
276
- /// started.
277
- pub grpc_port: Option<u16>,
277
/// Maximum time to wait for active HTTP/gRPC requests to drain on shutdown.
278
pub shutdown_timeout: Duration,
279
/// Whether the caller manages the engine process and shuts it down when
rust/src/server/src/lib.rs
+4
-37
@@ -37,7 +37,6 @@ use hyper_util::rt::{TokioIo, TokioTimer};
37
use hyper_util::server::graceful::GracefulShutdown;
38
use hyper_util::service::TowerToHyperService;
39
pub use render::{RenderConfig, serve_render};
40
-use tokio::net::TcpListener;
40
use tokio::time::{Instant, sleep_until};
41
use tokio_util::sync::CancellationToken;
42
use tonic::transport::Server as TonicServer;
@@ -74,17 +73,6 @@ fn effective_served_model_names(model: &str, served_model_name: &[String]) -> Ve
73
}
74
}
75
77
-/// Choose the gRPC listener host. It follows the HTTP TCP host when there is
78
-/// one; otherwise (unix socket or inherited fd) it defaults to IPv4 loopback
79
-/// rather than all interfaces, so the side-car is never accidentally
80
-/// network-exposed.
81
-fn grpc_bind_host(listener_mode: &HttpListenerMode) -> &str {
82
- match listener_mode {
83
- HttpListenerMode::BindTcp { host, .. } => host.as_str(),
84
- HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "127.0.0.1",
85
- }
86
-}
87
-
76
/// Build the shared application state for one configured model and one engine
77
/// client, including any static LoRA adapters from `--lora-modules`.
78
async fn build_state(config: &Config) -> Result<Arc<AppState>> {
@@ -229,13 +217,11 @@ where
217
// Optionally bind the gRPC Inference server on a separate port. Bind
218
// synchronously here so bind errors (port in use, permission denied, ...)
219
// surface before serving rather than being deferred until shutdown.
232
- let grpc_setup = if let Some(grpc_port) = config.grpc_port {
233
- let grpc_host = grpc_bind_host(&config.listener_mode);
234
- let grpc_listener = TcpListener::bind((grpc_host, grpc_port))
220
+ let grpc_setup = if let Some(grpc_listener_mode) = &config.grpc_listener_mode {
221
+ let grpc_listener = Listener::bind(grpc_listener_mode)
222
.await
236
- .with_context(|| format!("failed to bind gRPC listener on {grpc_host}:{grpc_port}"))?;
237
- let addr = grpc_listener.local_addr()?;
238
- let grpc_listener = Listener::Tcp(grpc_listener);
223
+ .with_context(|| format!("failed to bind gRPC listener for {grpc_listener_mode:?}"))?;
224
+ let addr = grpc_listener.local_addr_display()?;
225
// gRPC reuses the HTTP TLS config (same SslContext) plus ALPN h2.
226
let grpc_tls = config
227
.tls
@@ -479,23 +465,4 @@ mod tests {
465
served_names
466
);
467
}
482
-
483
- #[test]
484
- fn grpc_bind_host_follows_http_tcp_host() {
485
- let mode = HttpListenerMode::BindTcp {
486
- host: "0.0.0.0".to_string(),
487
- port: 8000,
488
- };
489
- assert_eq!(grpc_bind_host(&mode), "0.0.0.0");
490
- }
491
-
492
- #[test]
493
- fn grpc_bind_host_defaults_to_loopback_without_tcp_host() {
494
- let unix = HttpListenerMode::BindUnix {
495
- path: "/tmp/vllm.sock".to_string(),
496
- };
497
- let inherited = HttpListenerMode::InheritedFd { fd: 3 };
498
- assert_eq!(grpc_bind_host(&unix), "127.0.0.1");
499
- assert_eq!(grpc_bind_host(&inherited), "127.0.0.1");
500
- }
468
}
tests/entrypoints/launchers/api_server/test_api_server_process_manager.py
+53
@@ -536,3 +536,56 @@ def test_rust_frontend_uses_config_model_as_model_tag(monkeypatch, caplog, tmp_p
536
sock.close()
537
538
assert '"model_tag": "org/model"' in caplog.text
539
+
540
+
541
+def test_rust_frontend_inherits_grpc_listener(monkeypatch):
542
+ """The Python-bound gRPC socket is passed to Rust like the HTTP one."""
543
+ import subprocess as subprocess_mod
544
+
545
+ from vllm.entrypoints.launchers.cli_args import make_arg_parser
546
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
547
+ from vllm.v1.utils import RustFrontendProcessManager
548
+
549
+ args = make_arg_parser(FlexibleArgumentParser()).parse_args(
550
+ ["--model", "org/model", "--grpc-port", "50051"]
551
+ )
552
+
553
+ class _FakeProc:
554
+ pid = 4321
555
+ returncode = 0
556
+
557
+ def poll(self):
558
+ return 0
559
+
560
+ def wait(self, timeout=None):
561
+ return 0
562
+
563
+ popen_calls = []
564
+
565
+ def fake_popen(cmd, **kwargs):
566
+ popen_calls.append((cmd, kwargs))
567
+ return _FakeProc()
568
+
569
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
570
+ grpc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
571
+ try:
572
+ monkeypatch.setattr(subprocess_mod, "Popen", fake_popen)
573
+ RustFrontendProcessManager(
574
+ binary_path="/nonexistent/vllm-rs",
575
+ sock=sock,
576
+ args=args,
577
+ input_address="ipc:///tmp/in",
578
+ output_address="ipc:///tmp/out",
579
+ engine_start_index=0,
580
+ engine_count=1,
581
+ data_parallel_size=1,
582
+ grpc_sock=grpc_sock,
583
+ )
584
+ [(cmd, kwargs)] = popen_calls
585
+ grpc_fd = grpc_sock.fileno()
586
+ flag_index = cmd.index("--grpc-listen-fd")
587
+ assert cmd[flag_index + 1] == str(grpc_fd)
588
+ assert list(kwargs["pass_fds"]) == [sock.fileno(), grpc_fd]
589
+ finally:
590
+ sock.close()
591
+ grpc_sock.close()
tests/entrypoints/launchers/test_cli_args.py
+83
-6
@@ -28,7 +28,7 @@ assert CHATML_JINJA_PATH.exists()
28
29
def _build_vllm_parsers():
30
vllm_parser = FlexibleArgumentParser()
31
- subparsers = vllm_parser.add_subparsers()
31
+ subparsers = vllm_parser.add_subparsers(dest="subparser")
32
serve_parser = subparsers.add_parser("serve")
33
make_arg_parser(serve_parser)
34
return {"vllm": vllm_parser, "vllm serve": serve_parser}
@@ -173,6 +173,75 @@ def test_multiple_valid_inputs(serve_parser):
173
174
175
### Tests for serve argument validation that run prior to loading
176
+@pytest.mark.parametrize(
177
+ "port, extra_args",
178
+ [
179
+ (50051, []),
180
+ (0, ["--port", "0"]),
181
+ (50051, ["--port", "50051", "--uds", "/tmp/vllm.sock"]),
182
+ ],
183
+)
184
+def test_rust_grpc_port_accepts_frontend_launch(
185
+ vllm_parser, monkeypatch, port, extra_args
186
+):
187
+ monkeypatch.setenv("VLLM_USE_RUST_FRONTEND", "1")
188
+ args = vllm_parser.parse_args(
189
+ [
190
+ "serve",
191
+ "--grpc-port",
192
+ str(port),
193
+ "--data-parallel-size",
194
+ "8",
195
+ "--data-parallel-size-local",
196
+ "4",
197
+ "--data-parallel-start-rank",
198
+ "4",
199
+ "--data-parallel-hybrid-lb",
200
+ *extra_args,
201
+ ]
202
+ )
203
+ validate_parsed_serve_args(args)
204
+ assert args.grpc_port == port
205
+
206
+
207
+@pytest.mark.parametrize(
208
+ "rust_enabled, extra_args, port",
209
+ [
210
+ (False, [], 50051),
211
+ (False, ["--grpc"], 50051),
212
+ (True, ["--grpc"], 50051),
213
+ (True, ["--headless"], 50051),
214
+ (True, ["--api-server-count", "0"], 50051),
215
+ (True, ["--api-server-count", "-1"], 50051),
216
+ (True, ["--data-parallel-multi-port-external-lb"], 50051),
217
+ (True, ["--port", "50051"], 50051),
218
+ (True, [], -1),
219
+ (True, [], 65536),
220
+ ],
221
+)
222
+def test_rust_grpc_port_rejects_incompatible_launch(
223
+ vllm_parser, monkeypatch, rust_enabled, extra_args, port
224
+):
225
+ monkeypatch.setenv("VLLM_USE_RUST_FRONTEND", "1" if rust_enabled else "0")
226
+ args = vllm_parser.parse_args(["serve", "--grpc-port", str(port), *extra_args])
227
+ error = (
228
+ "--grpc and --grpc-port are mutually exclusive"
229
+ if "--grpc" in extra_args
230
+ else "--grpc-port"
231
+ )
232
+ with pytest.raises(ValueError, match=error):
233
+ validate_parsed_serve_args(args)
234
+
235
+
236
+def test_rust_grpc_port_requires_serve_subcommand(monkeypatch):
237
+ """Direct Python render/API entrypoints must reject a Rust-only listener."""
238
+ monkeypatch.setenv("VLLM_USE_RUST_FRONTEND", "1")
239
+ parser = make_arg_parser(FlexibleArgumentParser())
240
+ args = parser.parse_args(["--grpc-port", "50051"])
241
+ with pytest.raises(ValueError, match="--grpc-port requires"):
242
+ validate_parsed_serve_args(args)
243
+
244
+
245
def test_enable_auto_choice_passes_without_tool_call_parser(serve_parser):
246
"""Ensure validation fails if tool choice is enabled with no call parser."""
247
# If we enable-auto-tool-choice, explode with no tool-call-parser
@@ -265,13 +334,21 @@ def launch_render_parser():
334
return _build_launch_render_parser()
335
336
268
-def test_launch_render_validates_serve_args(launch_render_parser):
337
+@pytest.mark.parametrize(
338
+ "cli_args, error",
339
+ [
340
+ (["--enable-auto-tool-choice"], TypeError),
341
+ (["--grpc-port", "50051"], ValueError),
342
+ ],
343
+)
344
+def test_launch_render_validates_serve_args(
345
+ launch_render_parser, monkeypatch, cli_args, error
346
+):
347
"""`vllm launch render` reuses the serve parser, so it gets the serve checks"""
270
- args = launch_render_parser.parse_args(
271
- args=["launch", "render", "--enable-auto-tool-choice"]
272
- )
348
+ monkeypatch.setenv("VLLM_USE_RUST_FRONTEND", "1")
349
+ args = launch_render_parser.parse_args(args=["launch", "render", *cli_args])
350
assert args.subparser == "launch"
274
- with pytest.raises(TypeError):
351
+ with pytest.raises(error):
352
validate_parsed_serve_args(args)
353
354
vllm/entrypoints/cli/serve.py
+9
@@ -17,6 +17,7 @@ from vllm.entrypoints.launchers.cli_args import (
17
validate_parsed_serve_args,
18
)
19
from vllm.entrypoints.launchers.dp_supervisor import run_dp_supervisor
20
+from vllm.entrypoints.launchers.launcher import create_server_socket
21
from vllm.entrypoints.serve.utils.api_utils import VLLM_SUBCMD_PARSER_EPILOG
22
from vllm.logger import init_logger
23
from vllm.reasoning import ReasoningParserManager
@@ -295,6 +296,13 @@ def run_multi_api_server(args: argparse.Namespace):
296
signal.signal(signal.SIGINT, signal_handler)
297
298
listen_address, sock = setup_server(args, reuse_port=num_api_servers > 1)
299
+ # `--grpc-port` is only accepted for the Rust frontend, which inherits this
300
+ # listener like the HTTP one. gRPC follows the HTTP TCP host, or IPv4
301
+ # loopback when HTTP uses a Unix socket.
302
+ grpc_sock = None
303
+ if args.grpc_port is not None:
304
+ grpc_host = "127.0.0.1" if args.uds else (args.host or "")
305
+ grpc_sock = create_server_socket((grpc_host, args.grpc_port), reuse_port=False)
306
307
engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
308
propagate_flash_late_interaction(args, engine_args)
@@ -355,6 +363,7 @@ def run_multi_api_server(args: argparse.Namespace):
363
api_server_manager = RustFrontendProcessManager(
364
binary_path=rust_frontend_path,
365
sock=sock,
366
+ grpc_sock=grpc_sock,
367
args=args,
368
input_address=addresses.inputs[0],
369
output_address=addresses.outputs[0],
vllm/entrypoints/launchers/cli_args.py
+43
-2
@@ -284,6 +284,11 @@ class FrontendArgs(BaseFrontendArgs):
284
"""Host name."""
285
port: int = 8000
286
"""Port number."""
287
+ grpc_port: int | None = None
288
+ """Enable the Rust frontend's additional gRPC Inference and Control services
289
+ on this port. Requires `VLLM_USE_RUST_FRONTEND=1 vllm serve`; HTTP remains on
290
+ `--port`. Binds on `--host`, or 127.0.0.1 with `--uds`. Cannot be combined
291
+ with the Python gRPC server's `--grpc` flag."""
292
data_parallel_supervisor_port: int = 9256
293
"""HTTP port for aggregated health endpoints in multi-port external LB
294
mode."""
@@ -451,8 +456,10 @@ def make_arg_parser(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
456
"--grpc",
457
action="store_true",
458
default=False,
454
- help="Launch a gRPC server instead of the HTTP OpenAI-compatible "
455
- "server. Requires: pip install vllm[grpc].",
459
+ help="Launch the Python gRPC (SMG VllmEngine) server on --port instead "
460
+ "of the HTTP OpenAI-compatible server. Requires: pip install vllm[grpc]. "
461
+ "Cannot be combined with --grpc-port, which enables the Rust "
462
+ "frontend's gRPC services.",
463
)
464
parser = FrontendArgs.add_cli_args(parser)
465
parser = AsyncEngineArgs.add_cli_args(parser)
@@ -460,6 +467,38 @@ def make_arg_parser(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
467
return parser
468
469
470
+def validate_grpc_port_arg(args: argparse.Namespace) -> None:
471
+ """Validate the Rust frontend's optional gRPC listener."""
472
+ if getattr(args, "grpc_port", None) is None:
473
+ return
474
+
475
+ if args.grpc:
476
+ raise ValueError(
477
+ "--grpc and --grpc-port are mutually exclusive. Use --grpc "
478
+ "--port for the Python gRPC server, or VLLM_USE_RUST_FRONTEND=1 "
479
+ "with --grpc-port for the Rust frontend."
480
+ )
481
+ if not envs.VLLM_USE_RUST_FRONTEND or getattr(args, "subparser", None) != "serve":
482
+ raise ValueError("--grpc-port requires VLLM_USE_RUST_FRONTEND=1 vllm serve")
483
+ if args.headless or (
484
+ args.api_server_count is not None and args.api_server_count <= 0
485
+ ):
486
+ raise ValueError(
487
+ "--grpc-port requires a Rust frontend; remove --headless "
488
+ "and non-positive --api-server-count"
489
+ )
490
+ if args.data_parallel_multi_port_external_lb:
491
+ raise ValueError(
492
+ "--grpc-port is incompatible with "
493
+ "--data-parallel-multi-port-external-lb: "
494
+ "its frontends would share the same gRPC port"
495
+ )
496
+ if not 0 <= args.grpc_port <= 65535:
497
+ raise ValueError("--grpc-port must be between 0 and 65535")
498
+ if args.grpc_port != 0 and args.grpc_port == args.port and not args.uds:
499
+ raise ValueError("--grpc-port must differ from --port when HTTP uses TCP")
500
+
501
+
502
def validate_parsed_serve_args(args: argparse.Namespace):
503
"""Quick checks for model serve args that raise prior to loading."""
504
# `vllm launch <component>` builds its parser with make_arg_parser too (see
@@ -478,6 +517,8 @@ def validate_parsed_serve_args(args: argparse.Namespace):
517
"the checkpoint's output format."
518
)
519
520
+ validate_grpc_port_arg(args)
521
+
522
# Enable auto tool needs a tool call parser to be valid
523
if args.enable_auto_tool_choice and not args.tool_call_parser:
524
raise TypeError("Error: --enable-auto-tool-choice requires --tool-call-parser")
vllm/v1/utils.py
+8
-1
@@ -354,6 +354,7 @@ class RustFrontendProcessManager:
354
engine_count: int,
355
data_parallel_size: int,
356
stats_update_address: str | None = None,
357
+ grpc_sock: Any | None = None,
358
):
359
import os
360
import subprocess
@@ -377,6 +378,12 @@ class RustFrontendProcessManager:
378
"--data-parallel-size",
379
str(data_parallel_size),
380
]
381
+ pass_fds = [fd]
382
+ if grpc_sock is not None:
383
+ grpc_fd = grpc_sock.fileno()
384
+ os.set_inheritable(grpc_fd, True)
385
+ cmd.extend(["--grpc-listen-fd", str(grpc_fd)])
386
+ pass_fds.append(grpc_fd)
387
if stats_update_address is not None:
388
cmd.extend(["--coordinator-address", stats_update_address])
389
from vllm.entrypoints.serve.utils.api_utils import jsonify_non_default_args
@@ -418,7 +425,7 @@ class RustFrontendProcessManager:
425
426
redacted_json = json.dumps(redact_sensitive_args(args_dict), sort_keys=True)
427
logger.info("Launching Rust frontend: %s", " ".join(cmd[:-1] + [redacted_json]))
421
- self._proc = subprocess.Popen(cmd, pass_fds=(fd,))
428
+ self._proc = subprocess.Popen(cmd, pass_fds=pass_fds)
429
430
# Create a process wrapper with a sentinel fd for monitoring
431
self.processes: list[_SubprocessWrapper] = [