openlang

стриминговый декодер: лишние поля в последующих записях и "malformed at offset N" на валидных байтах, только при маленьком размере чанка

всем привет, пишу на Rust небольшой декодер для клиентского формата логов запросов (байт count, затем столько пар ключ/значение, каждая из байта длины и самих байтов). байты приходят из сокета произвольными кусками, поэтому в main лежит Vec<u8>, я добавляю в него каждый чанк, вызываю next_record, пока не получу Incomplete, и потом дренирую то, что парсер сообщил как использованное.

если скормить одним куском, все три записи печатаются нормально. если передать первым аргументом размер чанка 7 или 10, ломается двумя способами: в записи иногда появляются лишние поля (ключ из записи, которая так и не дочиталась, оказывается внутри следующей), а в других случаях я получаю 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 выбрасывает байты недочитанной записи, и следующий вызов начинает с середины записи. Это один баг с двумя симптомами: устаревшие ключи или случайный байт, прочитанный как 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 локальный, то при неудачной попытке он дропается на раннем return, и восстанавливать нужно только 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. Если нужны стабильные числа, ведите накопительный счётчик удалённых байт.

    Переведено с английскогоПоказать оригинал
  3. Ссылка на ответ

    Мне кажется, высокий max_fields также объясняет, почему у alden лишние поля на одних размерах чанка и malformed на других. Сдвинутый байт проваливает проверку счётчика, только если он выше лимита. Иначе он проходит как счётчик, парсер читает дальше и обычно заканчивает на Incomplete, так что рассинхрон остаётся тихим. Malformed появляется, только когда мусор случайно не проходит проверку или ломает UTF-8.

    Для 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 как правдоподобный счётчик. потом парсер читает дальше, упирается в конец буфера и возвращает Incomplete, так что ничего подозрительного не видно. только байт выше 64 (или битый UTF-8-срез) даёт Malformed. это объясняло бы, почему два симптома приходятся на разные размеры чанка. это один рассинхрон, который падает громко или тихо в зависимости от того, какой байт прочитали неверно. это подходит лучше, чем моя догадка про "два бага".

    проверку с размером чанка 1 запускаю так (прогон одним куском — это просто размер чанка больше файла):

    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 вообще не были бы общим состоянием в парсере. или лишнее возвращаемое значение просто переносит ту же бухгалтерию в другое место?

    Переведено с английскогоПоказать оригинал
  5. Ссылка на ответ

    было бы чище, если бы next_record возвращал потреблённые байты вместе с Record

    Я бы сказал, что да. Это всё равно учёт, но он в одном месте. Сделайте разбор функцией от &[u8] с локальным курсором, который начинается с 0, и пусть она возвращает (Record, usize). Тогда неполная запись откатывается бесплатно, потому что ничего не сохранялось. reset_pos тоже уходит.

    Я предполагаю, что настоящий код читает из сокета, который может закрыться посреди записи. Если так, проверяйте остаток байтов в buf на EOF и сообщайте о них как об обрезанных. Сейчас всё просто тихо заканчивается, как и при том тихом рассинхроне.

    Для диффа я бы ещё печатал смещение в потоке для начала каждой записи, как в #6. Тогда первая отличающаяся строка указывает на байт, а не только на номер записи.

    Переведено с английскогоПоказать оригинал
  6. Ссылка на ответ

    чтобы main вызывал Vec::drain с этим числом

    Думаю, так чище, но со смещениями есть один подвох. При локальном курсоре, начинающемся с 0, offset внутри Malformed считается относительно переданного вами среза, а не буфера и не потока. Так что main должен прибавить к нему накопленное число удалённых байт перед печатью. А если вы к тому же делаете drain один раз после внутреннего цикла, нужно учесть и байты, потреблённые предыдущими записями в том же проходе. Иначе числа снова неверные, просто по-другому.

    Проще всего делать drain сразу после каждого Ok, внутри цикла:

    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> по значению,…

    Ответы 0Последняя активность rust
  2. except ValueError никогда не срабатывает вокруг gather(return_exceptions=True), а потом TypeError на ValueError

    Перед созвонами с клиентами я запускаю небольшой скрипт, который оценивает сайт каждого клиента по трём метрикам, с весами дл…

    Ответы 5Последняя активность python
  3. `result.map_error` в каждой строке цепочки use

    use вместе с result.try - моя любимая часть Gleam, и я начинаю на неё немного злиться. Каждый шаг вызывает другой модуль, у к…

    Ответы 1Последняя активность gleam