Prhub

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

原始 PR 作者 zcxGGmu 合并时间 2026-08-04 04:50 文件变更 4 提交数 2 评论 2 代码增减 +80 / -8

执行摘要

拒绝空 gRPC stop 字符串,修复解码器 panic 致进程中止

关联 Issue #50725 指出,gRPC Generate / GenerateStream 请求的 stopping.stop_strings 含空字符串时会 panic 文本解码路径;由于 workspace release 配置 panic = "abort",panic 会直接终止整个服务进程,影响所有并发请求。REST 路径已有 validate_stop 返回 400,但 gRPC 路径没有等价校验,且解码器存在两个独立 panic 位点,只修一个不够。

值得精读。核心看点:njhill 关于“第三份拷贝”的评论揭示了校验逻辑分散的坏味道,最终采用“共享校验点 + 底层防御”的分层设计,既保证入口统一又不依赖每个调用方自觉。对于处理外部输入的服务端组件,这种同时在上层校验和底层防 panic 的思路值得借鉴。

讨论亮点

njhill 在 review 中指出了设计层面的关键问题:这是同一规则的第三份拷贝(REST validate_stop、gRPC convert.rs、现在新增的 request.rs 校验),而真正的隐患在使用点未设防——TextDecodeOptions 是纯公开字段结构体,任何新入口都可能绕过校验;解码器有两个独立 panic 位点,在 panic = "abort" 下都会终止进程。他建议无论如何都要加固 decoded.rssaturating_sublen == 0 守卫),并考虑把校验放在共享关口。作者 zcxGGmu 随后按建议调整:将校验移入 TextRequest::validate() 共享校验点,移除 gRPC 本地重复校验,并加固了解码器。最终 njhill 批准通过。

实现拆解

实现分四步完成:

  1. 新增错误类型:在 rust/src/text/src/error.rs 中添加 EmptyStopString 变体,错误消息为 "text request{request_id}stop strings cannot be empty",并将其纳入 Error::is_request_validation_error() 匹配,以便上层映射为 InvalidArgument

  2. 共享校验点:在 rust/src/text/src/request.rsTextRequest::validate() 中,检查 decode_options.stop_strings 是否包含空字符串(is_some_and(|stops| stops.iter().any(String::is_empty))),命中则返回 EmptyStopString。所有文本请求(包括 gRPC 转换产物)都会经过此校验,形成统一关口。

  3. 解码器防御加固:在 rust/src/text/src/output/decoded.rs 中,缓冲长度计算由 ... - 1 改为 ... .saturating_sub(1),防止空 stop 字符串导致下溢;matches_stop_string 中对长度为 0 的 stop 字符串直接跳过,避免 windows(0) panic。

  4. 回归测试:在 request.rs 增加 validate_rejects_empty_stop_string_at_shared_chokepoint 单测;在 decoded.rs 增加 stream_empty_stop_string_does_not_underflow_bufferempty_stop_string_is_ignored_by_matcher 两个单测;在 grpc/tests.rs 增加 unary_generate_empty_stop_string_returns_invalid_argument 端到端 unary gRPC 回归测试,验证返回 InvalidArgument 且消息包含 "stop strings cannot be empty"

文件 模块 状态 重要度
rust/src/text/src/output/decoded.rs 文本解码 modified 7.19
rust/src/text/src/request.rs 请求校验 modified 6.93
rust/src/server/src/grpc/tests.rs gRPC 服务 modified 6.18
rust/src/text/src/error.rs 错误处理 modified 4.02

关键符号

TextRequest::validate matches_stop_string decoded_text_event_stream Error::is_request_validation_error

关键源码片段

rust/src/text/src/output/decoded.rs core-logic

文本解码核心路径,修复两个 panic 位点(缓冲下溢和 windows(0)),是本次修复的根本层。

/// 判断输出文本中是否命中 stop 字符串列表中的某一个。
///
/// 返回 `(stop 列表索引, 命中的起始字节偏移)`。当多个 stop 字符串
/// 在同一段新生成文本中命中时,选择最先结束的那个,平局按列表顺序。
fn matches_stop_string(stops: &[String], output: &str, new_bytes: usize) -> Option<(usize, usize)> {
    // 按字节比较,避免 UTF-8 边界问题
    let output = output.as_bytes();
    let next_off = (output.len() + 1) - new_bytes;
    stops
        .iter()
        .map(|ss| (ss.as_bytes(), ss.len(), next_off.saturating_sub(ss.len())))
        .enumerate()
        .filter_map(|(ss_idx, (ss, len, start_off))| {
            // 关键防御:长度为 0 的 stop 字符串无法匹配,直接跳过;
            // 否则后续 windows(0) 会 panic(在 release 下 panic = abort
            // 会终止整个服务进程)。
            if len == 0 {
                return None;
            }
            output[start_off..]
                .windows(len)
                .position(|w| w == ss)
                .map(|pos| (ss_idx, start_off + pos, start_off + pos + len))
        })
        // min_by_key 保留第一个最小值,因此平局时回退到 stop 列表顺序。
        .min_by_key(|&(_, _, end)| end)
        .map(|(ss_idx, start, _)| (ss_idx, start))
}
rust/src/text/src/request.rs core-logic

新增共享校验点 TextRequest::validate(),将空 stop 字符串校验集中到所有文本请求的必经关口。

impl TextRequest {
    /// 在 tokenization 或 request lowering 之前校验最小不变量。
    pub fn validate(&self) -> Result<()> {
        if matches!(&self.prompt, Prompt::TokenIds(ids) if ids.is_empty()) {
            return Err(Error::EmptyPromptTokenIds {
                request_id: self.request_id.clone(),
            });
        }
        // 共享校验点:所有文本请求(包括 gRPC 转换产物)都会经过这里,
        // 空 stop 字符串在此被拒绝,避免进入解码器触发 panic。
        if self
            .decode_options
            .stop_strings
            .as_ref()
            .is_some_and(|stops| stops.iter().any(String::is_empty))
        {
            return Err(Error::EmptyStopString {
                request_id: self.request_id.clone(),
            });
        }
        Ok(())
    }
}

评论区精华

校验层级与解码器防御 设计

njhill 指出这是同一规则的第三份拷贝(REST validate_stop、gRPC convert.rs、request.rs),而真正问题在使用点未设防;解码器有两个独立 panic 位点,任何未来生产者忘记校验都会在 panic=abort 下终止进程。建议无论如何加固 decoded.rs(saturating_sub 和 len==0 guard),并考虑共享校验点。

结论:作者按建议将校验移入 TextRequest::validate() 共享校验点,移除 gRPC 本地重复校验,并在 decoded.rs 中采用 saturating_sub 和 len==0 guard 加固。njhill 最终批准。 · 已解决

风险与影响

风险点:

1) decoded.rs 是文本解码热路径,改动可能影响正常 stop 字符串行为,但 saturating_sub 仅在空 stop 场景下与原先相差 1,len == 0 守卫只是跳过空串,正常路径不变;
2) 新增 EmptyStopString 错误变体后,需要确认所有调用方都通过 is_request_validation_error 正确映射为 InvalidArgument,目前 gRPC 路径已覆盖,但未来其他入口可能遗漏;
3) 行为变更:此前可通过 gRPC 发送空 stop 字符串(虽然会崩溃),现在将被直接拒绝,对依赖该行为的客户端是 breaking change,但属于合理修复。

影响范围:Rust 前端(VLLM_USE_RUST_FRONTEND=1)且启用 grpc_port 的 gRPC 路径。修复后恶意或误用请求不再导致进程级 abort,服务稳定性显著提升;共享校验点也惠及未来新增入口。对 OpenAI REST 路径无影响(已有校验),对 Python 前端无影响。团队层面,该 PR 确立了文本请求校验应集中在 TextRequest::validate() 的模式,并强调了底层解码器对不可信输入必须防御。

文本解码热路径变更 新错误类型需全面映射 行为变更(拒绝空 stop 字符串)

关联 Issue

#50725 [Bug]: System crash, Rust front end panic (process abort) from an empty stop string via the gRPC Generate API

完整报告

参与讨论