openlang

스트리밍 디코더: 청크 크기가 작을 때만 뒤쪽 레코드에 엉뚱한 필드가 섞이고, 유효한 바이트에서 "malformed at offset N"이 나요

안녕하세요, 클라이언트의 요청 로그 포맷(count 바이트 하나, 그다음 그 개수만큼의 key/value 쌍, 각 쌍은 길이 바이트 하나와 바이트들)을 읽는 작은 디코더를 Rust로 짜고 있어요. 바이트는 소켓에서 제멋대로 된 청크로 들어와서, main이 Vec<u8>을 들고 있다가 청크가 올 때마다 붙이고, Incomplete가 나올 때까지 next_record를 부르고, 파서가 썼다고 하는 만큼 drain해요.

한 청크로 넣으면 레코드 세 개가 다 잘 찍혀요. 그런데 첫 번째 인자로 청크 크기를 7이나 10으로 주면 두 가지로 이상해져요. 하나는 레코드에 필드가 더 붙어서 나오는 거예요(끝나지 않은 레코드의 key가 다음 레코드 안에 나타나요). 다른 하나는 한 청크로 돌릴 때는 받아들여지는 바이트에서 malformed at offset N이 나오는 거고요. 두 증상이 같은 청크 크기에서 나오는 것 같지는 않아서, 버그가 하나인지 둘인지 모르겠어요.

계속 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이 반쯤 읽은 레코드의 바이트를 버리니까, 다음 호출은 레코드 중간에서 시작해요. 증상이 두 가지일 뿐 버그는 하나예요. 남아 있는 key가 보이거나, 아무 바이트나 카운트로 읽히거나요.

    실패한 시도는 롤백되게 감싸세요.

    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이 로컬이면 실패한 시도는 early return에서 그냥 drop되니까 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"의 숫자가 chunk 크기마다 달라져요. 저도 예전에 마지막 drain 기준 상대값일 뿐인 offset을 쫓느라 저녁 한나절을 쓴 적이 있어요. 숫자를 안정적으로 보고 싶으면 drain한 바이트의 누적 합계를 따로 들고 계세요.

    영어에서 번역원문 보기
  3. 고유 링크

    파서에서 max_fields가 꽤 높게 잡혀 있는 것 같은데요? chunk 크기 1로 돌려서 한 번에 넣은 실행과 출력을 diff해 보세요.

    영어에서 번역원문 보기
  4. 고유 링크

    max_fields가 높다는 점이 alden 님이 어떤 chunk 크기에서는 엉뚱한 필드를 보고 다른 크기에서는 malformed를 보는 이유도 설명해 줄 것 같아요. 어긋난 바이트는 한도를 넘을 때만 count 검사에서 걸려요. 그렇지 않으면 count로 통과하고, 파서는 계속 읽다가 보통 Incomplete로 끝나서 어긋남이 조용히 지나가요. 쓰레기 값이 우연히 검사에 걸리거나 UTF-8이 깨질 때만 Malformed가 되는 거죠.

    diff를 할 때는 각 레코드가 어디서 시작하는지를 버퍼 기준이 아니라 스트림 기준으로 찍어 보세요.

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

    여기서 drained는 지금까지 제거한 바이트의 누적 합계이고, start는 호출 전의 pos예요.

    영어에서 번역원문 보기
  5. 고유 링크

    brin40 님 고마워요. 맞아요, max_fields가 높아요(64일 거예요, 상수는 확인해 봐야 해요). 클라이언트의 샘플 로그는 레코드당 필드가 6~7개를 넘은 적이 없어서, 64는 그냥 "실제 값보다 넉넉히 위"로 잡은 거였어요.

    파서에서 max_fields가 꽤 높게 잡혀 있는 것 같은데요?

    제가 이해한 게 맞다면, 64 미만의 어긋난 바이트는 count == 0 || count > self.max_fields 검사를 그럴듯한 count로 통과해요. 그다음 파서가 계속 읽다가 버퍼가 바닥나서 Incomplete를 반환하니까 아무 문제도 없어 보이고요. 64보다 큰 바이트(또는 UTF-8이 깨진 슬라이스)일 때만 Malformed가 나와요. 그러면 두 증상이 서로 다른 chunk 크기에서 나오는 게 설명돼요. 잘못 읽은 바이트가 뭐냐에 따라 시끄럽게 실패하거나 조용히 실패하는, 하나의 어긋남이라는 거죠. "버그 두 개" 추측보다 이쪽이 더 맞아 보여요.

    chunk 크기 1 확인은 이렇게 돌리고 있어요(한 번에 넣는 실행은 파일보다 큰 chunk 크기로 하는 거예요).

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

    #2의 롤백을 넣으면 diff가 비어 있어야 해요. 안 비어 있으면 첫 번째 버그 뒤에 두 번째 버그가 숨어 있는 거고요.

    질문이 하나 있어요. drain부터 의심하기 시작했던 거라서요.

    next_record가 Record와 함께 소비한 바이트 수를 반환하는 게 더 깔끔할까요? 그래서 main이 그 개수로 Vec::drain을 호출하게요. 그러면 pos와 reset_pos가 파서 안의 공유 상태가 아예 아니게 될 텐데요. 아니면 반환값을 하나 더 늘리는 건 같은 장부 관리를 다른 곳으로 옮기는 것뿐일까요?

    영어에서 번역원문 보기
  6. 고유 링크

    next_record가 Record와 함께 소비한 바이트 수를 반환하는 게 더 깔끔할까요

    저는 그렇다고 봐요. 여전히 장부 관리이긴 한데, 한 곳에 모여요. 파싱을 &[u8]를 받는 함수로 만들고 0에서 시작하는 로컬 커서를 쓰면서 (Record, usize)를 반환하게 하세요. 그러면 아무것도 저장하지 않았으니까 Incomplete 롤백이 공짜로 돼요. reset_pos도 없어지고요.

    실제로는 소켓에서 읽고, 그 소켓이 레코드 중간에 닫힐 수 있다고 가정할게요. 그렇다면 EOF에서 buf에 남은 바이트가 있는지 확인해서 잘렸다고 보고하세요. 지금은 그게 조용히 끝나요. 조용한 어긋남과 똑같이요.

    diff를 할 때는 #6처럼 각 레코드 시작의 스트림 offset도 찍어 보세요. 그러면 처음으로 달라지는 줄이 레코드 번호만이 아니라 바이트를 가리켜요.

    영어에서 번역원문 보기
  7. 고유 링크

    그래서 main이 그 개수로 Vec::drain을 호출하게요

    깔끔해진다고 생각하는데, offset에 관해서는 걸리는 점이 하나 있어요. 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이 진짜 스트림 offset이 돼요. 저도 예전에 길이 접두사 디코더에서 소비한 개수를 합산해 두었다가 패스가 끝날 때 drain한 적이 있어요. 에러 offset이 정확히 레코드 하나 길이만큼 어긋났는데, 이유를 알아내는 데 너무 오래 걸렸어요.

    그리고 diff에서 Malformed 줄은 eprintln!을 거치니까, 2>&1로 파일의 제대로 된 위치에 들어가는지 확인해 보세요.

    영어에서 번역원문 보기
  8. 고유 링크

    7, 10, 1을 손으로 하나씩 해 보는 대신 테스트에서 chunk 크기를 전부 돌려 보겠어요. 그러면 우연히 깨지는 두 개가 아니라 깨지는 가장 작은 크기를 찾을 수 있어요. 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…

    답글 0최근 활동 rust
  2. except ValueError가 gather(return_exceptions=True) 주변에서 실행되지 않고, ValueError 때문에 TypeError가 나요

    클라이언트 체크인 전에 작은 스크립트를 돌려서 각 클라이언트 사이트를 세 가지 지표로 점수를 매기는데, 클라이언트별 가중치는 해당 클라이언트의 analytics 엔드포인트에서 가져와요. 가져오는 부분은 return_excep…

    답글 5최근 활동 python
  3. use 체인 줄마다 붙는 `result.map_error`

    use와 result.try 조합은 Gleam에서 제일 좋아하는 부분인데, 요즘 조금 미워지기 시작했어요. 단계마다 다른 모듈을 부르고 모듈마다 자기 오류 타입이 있으니, 줄마다 result.map_error를 달고 다녀요.…

    답글 1최근 활동 gleam