[Rust Frontend] Bound recursive argument parsers (#54303)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 2, 2026 at 05:45 UTC 3439bad37e68ba9755a46f4f6b44a4aeaf1f60a9
4 files changed +139 -3
rust/src/parser/src/tool/minimax_m3.rs
+21 -1
@@ -12,6 +12,7 @@ use super::parameters::{ParamElement, ParamInput, ToolSchemas};
12 use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
13 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
14 use crate::tool::Tool;
15 +use crate::utils::recursion::ParserRecursionGuard;
16
17 const NAMESPACE: &str = "]<]minimax[>[";
18 const TOOL_CALL_START: &str = "]<]minimax[>[<tool_call>";
@@ -279,6 +280,7 @@ fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<(String, ParamInput
280 /// Parse a MiniMax M3 parameter element.
281 fn parameter_element(input: &mut &str) -> ModalResult<ParamElement> {
282 let name = open_element_tag(input)?.to_string();
283 + let _guard = ParserRecursionGuard::enter()?;
284 let value = element_body(input, &name)?;
285 close_element_tag(input, &name)?;
286 Ok(ParamElement { name, value })
@@ -389,10 +391,11 @@ mod tests {
391
392 use super::{
393 ELEMENT_END_START, ELEMENT_START, INVOKE_END, INVOKE_START, MinimaxM3ToolParser,
392 - TOOL_CALL_END, TOOL_CALL_START, ToolParser,
394 + TOOL_CALL_END, TOOL_CALL_START, ToolParser, parse_invoke_params,
395 };
396 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
397 use crate::tool::{Tool, ToolParserEvent, ToolParserTestExt as _};
398 + use crate::utils::recursion::MAX_PARSER_RECURSION_DEPTH;
399
400 fn element(name: &str, body: &str) -> String {
401 format!("{ELEMENT_START}{name}>{body}{ELEMENT_END_START}{name}>")
@@ -508,6 +511,23 @@ mod tests {
511 .join("")
512 }
513
514 + fn nested_element(depth: usize) -> String {
515 + format!(
516 + "{}leaf{}",
517 + format!("{ELEMENT_START}node>").repeat(depth),
518 + format!("{ELEMENT_END_START}node>").repeat(depth)
519 + )
520 + }
521 +
522 + #[test]
523 + fn minimax_m3_recursion_limit_accepts_boundary_and_rejects_next_depth() {
524 + assert!(parse_invoke_params(&nested_element(MAX_PARSER_RECURSION_DEPTH)).is_ok());
525 + assert!(matches!(
526 + parse_invoke_params(&nested_element(MAX_PARSER_RECURSION_DEPTH + 1)),
527 + Err(winnow::error::ErrMode::Cut(_))
528 + ));
529 + }
530 +
531 #[test]
532 fn minimax_m3_parse_complete_without_tool_call_keeps_text() {
533 let mut parser = MinimaxM3ToolParser::new(&m3_test_tools());
rust/src/parser/src/unified/gemma4.rs
+20 -2
@@ -14,6 +14,7 @@ use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
14 use crate::reasoning::last_reasoning_boundary;
15 use crate::tool::{Tool, ToolCallDelta};
16 use crate::unified::parsing_failed;
17 +use crate::utils::recursion::ParserRecursionGuard;
18 use crate::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_text_len_mul};
19
20 const REASONING_START: &str = "<|channel>thought\n";
@@ -442,12 +443,16 @@ fn gemma4_string<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
443
444 /// Parse a nested Gemma4 object.
445 fn gemma4_object(input: &mut &str) -> ModalResult<Map<String, Value>> {
445 - delimited(literal("{"), gemma4_args, literal("}")).parse_next(input)
446 + literal("{").parse_next(input)?;
447 + let _guard = ParserRecursionGuard::enter()?;
448 + terminated(gemma4_args, literal("}")).parse_next(input)
449 }
450
451 /// Parse a Gemma4 array value.
452 fn gemma4_array_value(input: &mut &str) -> ModalResult<Vec<Value>> {
450 - delimited(literal("["), gemma4_array_content, literal("]")).parse_next(input)
453 + literal("[").parse_next(input)?;
454 + let _guard = ParserRecursionGuard::enter()?;
455 + terminated(gemma4_array_content, literal("]")).parse_next(input)
456 }
457
458 /// Parse Gemma4 array content.
@@ -519,6 +524,7 @@ mod tests {
524 };
525 use crate::tool::Tool;
526 use crate::unified::{UnifiedParserError, UnifiedParserEvent, parsing_failed};
527 + use crate::utils::recursion::MAX_PARSER_RECURSION_DEPTH;
528
529 const CHANNEL_START_ID: u32 = 256;
530 const CHANNEL_END_ID: u32 = 257;
@@ -720,6 +726,18 @@ mod tests {
726 assert_eq!(Value::Array(parsed), json!([42, true, 114.514]));
727 }
728
729 + #[test]
730 + fn gemma4_parser_rejects_excessive_recursion_and_recovers_input() {
731 + let depth = MAX_PARSER_RECURSION_DEPTH * 2;
732 + let nested_value = format!("{}0{}", "[".repeat(depth), "]".repeat(depth));
733 + let input = format!("<|tool_call>call:set{{value:{nested_value}}}<tool_call|>");
734 + let mut parser = test_parser();
735 +
736 + let error = parser.parse_chunk(&input).unwrap_err();
737 + assert!(error.to_report_string().contains("parser recursion limit exceeded"));
738 + assert_eq!(parser.reset(), input);
739 + }
740 +
741 #[test]
742 fn gemma4_parse_complete_extracts_single_tool_call() {
743 let mut parser = test_parser();
rust/src/parser/src/utils.rs
+2
@@ -9,6 +9,8 @@ use winnow::stream::{FindSlice, Offset, Partial, Stream};
9
10 use crate::tool::{Result, ToolParserError};
11
12 +pub(crate) mod recursion;
13 +
14 /// Return the byte length of the longest proper prefix of `token` that is also
15 /// a suffix of `buffer`.
16 ///
rust/src/parser/src/utils/recursion.rs new
+96
@@ -0,0 +1,96 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Recursion-depth tracking for synchronous parsers.
5 +
6 +use std::cell::Cell;
7 +use std::marker::PhantomData;
8 +use std::rc::Rc;
9 +
10 +use winnow::error::{ContextError, ErrMode, ModalResult, StrContext};
11 +
12 +/// Maximum number of recursive parser frames active on one thread.
13 +pub(crate) const MAX_PARSER_RECURSION_DEPTH: usize = 128;
14 +
15 +thread_local! {
16 + static PARSER_RECURSION_DEPTH: Cell<usize> = const { Cell::new(0) };
17 +}
18 +
19 +/// Bounds recursive parser calls on the current thread.
20 +///
21 +/// Keep the guard alive for the duration of one recursive parser frame. Its
22 +/// thread-local depth is restored on return, parse errors, and unwinding.
23 +pub(crate) struct ParserRecursionGuard {
24 + previous_depth: usize,
25 + // Dropping on another thread would restore the wrong TLS counter.
26 + _not_send: PhantomData<Rc<()>>,
27 +}
28 +
29 +impl ParserRecursionGuard {
30 + /// Enters one recursive parser frame.
31 + ///
32 + /// Returns a cut error when the current thread has reached the recursion
33 + /// limit. The returned guard must stay alive for the full recursive call;
34 + /// dropping it restores the preceding depth.
35 + pub(crate) fn enter() -> ModalResult<Self> {
36 + PARSER_RECURSION_DEPTH.with(|depth| {
37 + let previous_depth = depth.get();
38 + if previous_depth >= MAX_PARSER_RECURSION_DEPTH {
39 + let mut error = ContextError::new();
40 + error.push(StrContext::Label("parser recursion limit exceeded"));
41 + return Err(ErrMode::Cut(error));
42 + }
43 +
44 + depth.set(previous_depth + 1);
45 + Ok(Self {
46 + previous_depth,
47 + _not_send: PhantomData,
48 + })
49 + })
50 + }
51 +}
52 +
53 +impl Drop for ParserRecursionGuard {
54 + fn drop(&mut self) {
55 + PARSER_RECURSION_DEPTH.with(|depth| depth.set(self.previous_depth));
56 + }
57 +}
58 +
59 +#[cfg(test)]
60 +mod tests {
61 + use std::panic::{AssertUnwindSafe, catch_unwind};
62 +
63 + use winnow::error::ErrMode;
64 +
65 + use super::{MAX_PARSER_RECURSION_DEPTH, ParserRecursionGuard};
66 +
67 + fn enter_recursively(depth: usize) -> winnow::error::ModalResult<()> {
68 + if depth == 0 {
69 + return Ok(());
70 + }
71 +
72 + let _guard = ParserRecursionGuard::enter()?;
73 + enter_recursively(depth - 1)
74 + }
75 +
76 + #[test]
77 + fn enforces_limit_and_restores_depth() {
78 + assert!(enter_recursively(MAX_PARSER_RECURSION_DEPTH).is_ok());
79 + assert!(matches!(
80 + enter_recursively(MAX_PARSER_RECURSION_DEPTH + 1),
81 + Err(ErrMode::Cut(_))
82 + ));
83 + assert!(enter_recursively(MAX_PARSER_RECURSION_DEPTH).is_ok());
84 + }
85 +
86 + #[test]
87 + fn restores_depth_after_unwind() {
88 + let result = catch_unwind(AssertUnwindSafe(|| {
89 + let _guard = ParserRecursionGuard::enter().unwrap();
90 + panic!("test unwind");
91 + }));
92 +
93 + assert!(result.is_err());
94 + assert!(enter_recursively(MAX_PARSER_RECURSION_DEPTH).is_ok());
95 + }
96 +}