[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
+}