openlang

流式解码器,后面的记录里多出了字段,合法字节还报 "malformed at offset N",只在 chunk 很小时出现

大家好,我在用 Rust 给客户的请求日志格式写一个小解码器(先是一个计数字节,然后是这么多个键值对,每个都是一个长度字节加上对应的字节)。字节从 socket 里以任意大小的块到达,所以 main 里维护一个 Vec<u8>,每来一块就追加,反复调用 next_record 直到返回 Incomplete,再把解析器说它用掉的部分 drain 掉。

整个数据作为一块喂进去,三条记录都能正常打印。把 chunk 大小作为第一个参数设成 7 或 10,就会以两种方式出错。一种是某条记录里冒出多余的字段(一条从没解析完的记录里的某个 key 出现在了下一条记录里),另一种是在单块运行能通过的字节上报 malformed at offset N。这两种现象似乎不是在同样的 chunk 大小下出现的,所以我分不清是一个 bug 还是两个。

我一直怀疑是 drain 的问题,但改了它只是换了一种垃圾输出。我还怀疑过在循环里一直持有 &buf(能编译通过,所以我觉得不太可能)。我把 Incomplete 当成“不是错误,等着就行”,循环也是这么处理的,所以我不明白还缺了什么。

fn take<'a>(&mut self, buf: &'a [u8], n: usize) -> Result<&'a [u8], ParseError> {
    if self.pos + n > buf.len() {
        return Err(ParseError::Incomplete);
    }
    let s = &buf[self.pos..self.pos + n];
    self.pos += n;
    Ok(s)
}

fn take_str(&mut self, buf: &[u8]) -> Result<String, ParseError> {
    let len = self.take(buf, 1)?[0] as usize;
    let raw = self.take(buf, len)?;
    std::str::from_utf8(raw)
        .map(|s| s.to_string())
        .map_err(|_| ParseError::Malformed { offset: self.pos - len })
}

pub fn next_record(&mut self, buf: &[u8]) -> Result<Record, ParseError> {
    let count = self.take(buf, 1)?[0] as usize;
    if count == 0 || count > self.max_fields {
        return Err(ParseError::Malformed { offset: self.pos - 1 });
    }
    for _ in 0..count {
        let k = self.take_str(buf)?;
        let v = self.take_str(buf)?;
        self.scratch.push((k, v));
    }
    Ok(Record { fields: std::mem::take(&mut self.scratch) })
}

main 里的循环如下(Parser 里有 pos、scratch: Vec<(String, String)> 和 max_fields)。

for piece in data.chunks(chunk) {
    buf.extend_from_slice(piece);
    loop {
        match parser.next_record(&buf) {
            Ok(rec) => { seen += 1; println!("record {}: {:?}", seen, rec.fields); }
            Err(ParseError::Incomplete) => break,
            Err(ParseError::Malformed { offset }) => {
                eprintln!("malformed at offset {}", offset);
                return;
            }
        }
    }
    buf.drain(..parser.pos());
    parser.reset_pos();
}

我到底漏了什么?

从英语翻译查看原文

后续讨论 9

顺着彼此的依据读下去
  1. 固定链接

    我把 Incomplete 当成"不是错误,等着就行"

    确实是这样,但到那时你的解析器已经往前走了。pos 已经前移,scratch 里存着目前解析出的字段。然后你的 drain 把这些半条记录的字节扔掉了,下一次调用就从记录中间开始。这是同一个 bug 的两种表现,要么是残留的 key,要么是把某个随机字节当成了 count。

    包一层,让失败的尝试回滚。

    pub fn next_record(&mut self, buf: &[u8]) -> Result<Record, ParseError> {
        let start = self.pos;
        let r = self.parse_record(buf);
        if matches!(r, Err(ParseError::Incomplete)) {
            self.pos = start;
            self.scratch.clear();
        }
        r
    }

    把你现在的函数改名为 parse_record。顺便试试块大小 1,这是最快的验证方式。

    从英语翻译查看原文
  2. 固定链接

    pos 已经前进,scratch 里存着目前已解析的字段。

    pos 这一半才是真正的修复。scratch 那一半之所以需要处理,只是因为这个字段是共享状态。如果 Vec 是局部的,失败的尝试在提前返回时就会把它丢掉,只需要恢复 pos:

    fn parse_record(&mut self, buf: &[u8]) -> Result<Record, ParseError> {
        let mut fields = Vec::new();
        let count = self.take(buf, 1)?[0] as usize;
        // ... count check as before ...
        for _ in 0..count {
            let k = self.take_str(buf)?;
            let v = self.take_str(buf)?;
            fields.push((k, v));
        }
        Ok(Record { fields })
    }

    给 alden 一个提醒。drain 之后,pos 是从剩余缓冲区的开头算起的,不是从流的开头算。所以即使是同一个坏字节,“malformed at offset N” 的数字在不同块大小下也会不一样。我以前有一晚上就是在追一个偏移量,结果它只是相对于上一次 drain 的。想要稳定的数字,就自己累计已经 drain 掉的字节总数。

    从英语翻译查看原文
  3. 固定链接

    我觉得 max_fields 设得高,也解释了为什么 alden 在某些块大小下看到多余字段,在另一些下看到 malformed。错位的字节只有超过上限,才会过不了 count 检查。否则它会被当成一个 count,解析器接着往下读,通常以 Incomplete 收尾,所以失步是悄无声息的。只有这个垃圾值恰好触发检查,或者 UTF-8 校验失败时,才会变成 Malformed。

    做 diff 的时候,记录每条记录的起点,按流来计数,而不是按缓冲区:

    eprintln!("record {} starts at {}", seen + 1, drained + start);

    这里 drained 是目前已经移除的字节总数,start 是调用前的 pos。

    从英语翻译查看原文
  4. 固定链接

    谢谢 brin40,没错,max_fields 是设得高(我记得是 64,得去查一下那个常量)。客户给的样例日志每条记录从没超过 6 或 7 个字段,所以 64 就是“比任何真实情况都宽裕”。

    猜你解析器里的 max_fields 设得比较高?

    如果我理解对了,任何小于 64 的游离字节都能通过 count == 0 || count > self.max_fields 这个检查,被当成一个看起来合理的 count。解析器接着往下读,缓冲区读完了,返回 Incomplete,所以看起来一切正常。只有大于 64 的字节(或者一段坏的 UTF-8)才会给出 Malformed。这就能解释为什么两种症状落在不同的块大小上。这是同一个失步,根据哪个字节被读错,表现为报错或者悄无声息。这比我“两个 bug”的猜测更合理。

    块大小为 1 的检查我是这么跑的(整块运行就是把块大小设成比文件还大):

    cargo run -- 1 > one.txt 2>&1
    cargo run -- 100000 > all.txt 2>&1
    diff one.txt all.txt

    加上 #2 里的回滚之后,diff 应该是空的。如果不是,说明第一个 bug 后面还藏着第二个。

    还有一个问题,因为我一开始就是怀疑 drain 的问题。会不会让 next_record 把它消耗的字节数和 Record 一起返回更干净,这样 main 就用这个数调用 Vec::drain?那 pos 和 reset_pos 在解析器里就根本不再是共享状态了。还是说多一个返回值只是把同样的记账挪到了别处?

    从英语翻译查看原文
  5. 固定链接

    会不会让 next_record 把它消耗的字节数和 Record 一起返回更干净

    我觉得会。记账还是要做,但只集中在一个地方。把解析做成以 &[u8] 为输入的函数,用一个从 0 开始的局部游标,返回 (Record, usize)。这样 Incomplete 自然就回滚了,因为什么都没存下来。reset_pos 也就不需要了。

    我假设你实际用的是会在记录中途关闭的 socket。如果是这样,在 EOF 时要检查 buf 里有没有剩余字节,有的话就报告为被截断。现在这种情况会悄无声息地结束,和前面那个无声的失步一样。

    做 diff 的时候,我还会像 #6 那样打印每条记录起点在流中的偏移量。这样第一处不同的行指向的是一个字节,而不只是一个记录编号。

    从英语翻译查看原文
  6. 固定链接

    这样 main 就用这个数调用 Vec::drain

    我觉得是更干净,但偏移量上有一个坑。用从 0 开始的局部游标时,Malformed 里的 offset 是相对于你传入的切片的,既不是相对于缓冲区,也不是相对于流。所以 main 在打印前得把累计已 drain 的总数加上去。如果你还是在内层循环之后只 drain 一次,那还得加上同一轮里之前那些记录消耗的字节数。不然数字又会错,只是换了一种错法。

    最简单的办法是在循环里,每次 Ok 之后立刻 drain:

    Ok((rec, used)) => {
        buf.drain(..used);
        drained += used;
        // ...
    }
    Err(ParseError::Malformed { offset }) => {
        eprintln!("malformed at offset {}", drained + offset);
        return;
    }

    这样 buf 始终从一条记录的边界开始,drained + offset 就是真正的流偏移量。我以前写过一个带长度前缀的解码器,把消耗的字节数累加起来,在一轮结束时才 drain。报错的偏移量刚好差了一条记录的长度,我花了太久才看明白原因。

    另外,你的 diff 里 Malformed 那一行是通过 eprintln! 输出的,所以要用 2>&1 确认它在文件里的位置是对的。

    从英语翻译查看原文
  7. 固定链接

    与其手动试 7、10 和 1,我会在测试里把每个块大小都循环一遍。这样能找到最小的会出问题的大小,而不只是碰巧试到的那两个。我猜 decode_all 得返回记录而不是直接打印,不过这改动很小。

    #[test]
    fn same_for_every_chunk_size() {
        let want = decode_all(DATA, DATA.len());
        for n in 1..=DATA.len() {
            assert_eq!(decode_all(DATA, n), want, "chunk size {n}");
        }
    }
    从英语翻译查看原文

同一个问题的其他角度

  1. `String::from_utf8` 失败时会把字节还给你

    消耗型 API 得决定失败时那个值怎么处理。String::from_utf8 按值接收 Vec<u8>,失败时由错误持有它。 它会打印 bad byte at 2, 4 bytes back。没有拷贝,调用方在一个值里同时拿回缓冲区和出错的偏移。m…

    回复 0最近活动 rust
  2. except ValueError 在 gather(return_exceptions=True) 外面永远不执行,然后 ValueError 引发 TypeError

    我在客户回访前跑一个小脚本,根据三项指标给每个客户的网站打分,每个客户的权重从他们的分析接口获取。获取是通过 asyncio.gather 做的,并且设了 return_exceptions=True,所以一个客户出问题不会取消其他的,对吧?响应为空…

    回复 5最近活动 python
  3. use 链里每一行都挂着 `result.map_error`

    use 配 result.try 是 Gleam 里我最喜欢的部分,但我开始有点烦它了。每一步调的是不同的模块,每个模块有自己的错误类型,结果每一行末尾都得挂一个 result.map_error。 把构造器当函数传确实能让它短一点。但每行有一半都是…

    回复 1最近活动 gleam