[Bugfix][Frontend] Reject empty gRPC stop strings (#50746)

Signed-off-by: zq <zhouquan1511@163.com> Co-authored-by: zq <zhouquan1511@163.com>

zcxGGmu committed Aug 4, 2026 at 04:50 UTC c4e9f09de74aa1bf885af8cafd949c3c4dca67db
4 files changed +80 -8
rust/src/server/src/grpc/tests.rs
+26
@@ -650,6 +650,32 @@ async fn unary_generate_min_tokens_above_max_tokens_returns_invalid_argument() {
650 server_task.abort();
651 }
652
653 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
654 +#[serial]
655 +async fn unary_generate_empty_stop_string_returns_invalid_argument() {
656 + let (mut client, server_task, _engine_task) =
657 + grpc_test_server(b"engine-grpc-empty-stop", default_stream_output_specs()).await;
658 +
659 + let status = client
660 + .generate(pb::GenerateRequest {
661 + request_id: "test-empty-stop".to_string(),
662 + model: "test-model".to_string(),
663 + prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())),
664 + stopping: Some(pb::StoppingCriteria {
665 + stop_strings: vec!["".to_string()],
666 + ..Default::default()
667 + }),
668 + ..Default::default()
669 + })
670 + .await
671 + .expect_err("should fail when stop_strings contains an empty string");
672 +
673 + assert_eq!(status.code(), tonic::Code::InvalidArgument);
674 + assert!(status.message().contains("stop strings cannot be empty"));
675 +
676 + server_task.abort();
677 +}
678 +
679 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
680 #[serial]
681 async fn unary_generate_invalid_sampling_params_returns_invalid_argument() {
rust/src/text/src/error.rs
+3
@@ -15,6 +15,8 @@ pub enum Error {
15 Tokenizer(String),
16 #[error("text request `{request_id}` must contain at least one prompt token ID")]
17 EmptyPromptTokenIds { request_id: String },
18 + #[error("text request `{request_id}` stop strings cannot be empty")]
19 + EmptyStopString { request_id: String },
20 #[error(
21 "this model's maximum context length is {max_model_len} tokens, \
22 but the prompt contains {prompt_len} input tokens"
@@ -51,6 +53,7 @@ impl Error {
53 match self {
54 Self::PromptTooLong { .. }
55 | Self::EmptyPromptTokenIds { .. }
56 + | Self::EmptyStopString { .. }
57 | Self::Logprobs(_)
58 | Self::TokenIds(_)
59 | Self::SamplingParams(_)
rust/src/text/src/output/decoded.rs
+23 -8
@@ -126,14 +126,12 @@ pub async fn decoded_text_event_stream(
126 // when streaming the outputs.
127 match decode_options.include_stop_str_in_output {
128 true => 0,
129 - false => {
130 - decode_options
131 - .stop_strings
132 - .as_ref()
133 - .and_then(|stops| stops.iter().map(|ss| ss.len()).max())
134 - .unwrap_or(1)
135 - - 1
136 - }
129 + false => decode_options
130 + .stop_strings
131 + .as_ref()
132 + .and_then(|stops| stops.iter().map(|ss| ss.len()).max())
133 + .unwrap_or(1)
134 + .saturating_sub(1),
135 },
136 );
137 decoder = Some(dec);
@@ -321,6 +319,9 @@ fn matches_stop_string(stops: &[String], output: &str, new_bytes: usize) -> Opti
319 .map(|ss| (ss.as_bytes(), ss.len(), next_off.saturating_sub(ss.len())))
320 .enumerate()
321 .filter_map(|(ss_idx, (ss, len, start_off))| {
322 + if len == 0 {
323 + return None;
324 + }
325 output[start_off..]
326 .windows(len)
327 .position(|w| w == ss)
@@ -459,6 +460,13 @@ mod tests {
460 assert_eq!(output.finish_reason, FinishReason::Length);
461 }
462
463 + #[tokio::test]
464 + async fn stream_empty_stop_string_does_not_underflow_buffer() {
465 + let output = run_to_completion(ascii_tokens("hello"), opts(&[""], 0)).await;
466 + assert_eq!(output.text, "hello");
467 + assert_eq!(output.finish_reason, FinishReason::Length);
468 + }
469 +
470 #[tokio::test]
471 async fn stream_stop_string_multi_char() {
472 let output = run_to_completion(ascii_tokens("say hello world"), opts(&["lo"], 0)).await;
@@ -636,6 +644,13 @@ mod tests {
644 assert_eq!(result, None);
645 }
646
647 + #[test]
648 + fn empty_stop_string_is_ignored_by_matcher() {
649 + let stops = vec!["".to_string(), "world".to_string()];
650 + let result = matches_stop_string(&stops, "hello", 1);
651 + assert_eq!(result, None);
652 + }
653 +
654 #[test]
655 fn stop_string_multibyte_utf8() {
656 let stops = vec!["世界".to_string()];
rust/src/text/src/request.rs
+28
@@ -225,6 +225,34 @@ impl TextRequest {
225 request_id: self.request_id.clone(),
226 });
227 }
228 + if self
229 + .decode_options
230 + .stop_strings
231 + .as_ref()
232 + .is_some_and(|stops| stops.iter().any(String::is_empty))
233 + {
234 + return Err(Error::EmptyStopString {
235 + request_id: self.request_id.clone(),
236 + });
237 + }
238 Ok(())
239 }
240 }
241 +
242 +#[cfg(test)]
243 +mod tests {
244 + use super::*;
245 +
246 + #[test]
247 + fn validate_rejects_empty_stop_string_at_shared_chokepoint() {
248 + let mut request = TextRequest::for_test();
249 + request.request_id = "req-empty-stop".to_string();
250 + request.decode_options.stop_strings = Some(vec!["valid".to_string(), String::new()]);
251 +
252 + let err = request.validate().expect_err("empty stop strings should be rejected");
253 +
254 + assert!(err.is_request_validation_error());
255 + assert!(err.to_string().contains("stop strings cannot be empty"));
256 + assert!(err.to_string().contains("req-empty-stop"));
257 + }
258 +}