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