net: «неожиданный» фрагмент сбрасывает передачу, а не весь пакет

Source.NET в ReadSubChannelData при фрагменте вне диапазона или нулевой
длине делает data.Return() и выходит — это штатная ситуация для
опоздавшего/дублирующего фрагмента. Наш код возвращал NetError, из-за чего
терялись все остальные сообщения пакета, а с ними и signon-блок.
This commit is contained in:
Niko Marmeladkov 2026-10-12 14:35:28 +03:00
parent 459fe6b0bc
commit fb48358208
2 changed files with 67 additions and 8 deletions

View file

@ -380,10 +380,17 @@ impl NetChan {
}
if start_fragment + num_fragments > stream.num_fragments {
return Err(NetError::Protocol(format!(
"фрагмент вне диапазона: {start_fragment}+{num_fragments}>{}",
stream.num_fragments
)));
// Source.NET (ReadSubChannelData) просто возвращает управление:
// это штатная ситуация для опоздавшего/дублирующего фрагмента.
// Ошибкой считать нельзя — иначе один старый фрагмент уронит
// разбор всех остальных сообщений пакета.
tracing::trace!(
start_fragment,
num_fragments,
num_fragments_total = stream.num_fragments,
"фрагмент вне диапазона, ждём повтор"
);
return Ok(());
}
if start_fragment + num_fragments == stream.num_fragments {
// Последний фрагмент — укорачиваем до реального размера.
@ -393,10 +400,17 @@ impl NetChan {
}
}
if length == 0 || offset + length > stream.bytes {
return Err(NetError::Protocol(format!(
"некорректный фрагмент: offset={offset} length={length} bytes={}",
stream.bytes
)));
// Source.NET делает data.Return() и уходит: передача отбрасывается
// целиком, ждём её с начала. Ошибка здесь означала бы потерю всех
// прочих сообщений пакета.
tracing::trace!(
offset,
length,
bytes = stream.bytes,
"некорректный фрагмент, передача отброшена"
);
stream.reset();
return Ok(());
}
for i in 0..length {

View file

@ -709,3 +709,48 @@ public enum PacketFlag {
`MAX_FRAGMENTS_PER_PACKET_BITS = 5`, `NETMSG_TYPE_BITS = 6`,
`IN_RELIABLE_STATE_BITS = 16`, `SubChannel.MAX_BITS = 4` (совпало с дизасмом),
порядок `net.SetConVar` → `net.SignonState(CONNECTED, -1)`.
### 13.13. Полнота эталонного захвата: send tables в нём отсутствуют
Проверка непрерывности `seq` в `real_client.pcap`:
| сторона | netchan-пакетов | пропусков | потеряно |
|---|---|---|---|
| сервер | 2718 | 164 | **2576** |
| клиент | 3204 | 82 | 201 |
Основная потеря — в начале сессии (пакеты #63…#130, по 30+ подряд), то есть
именно там, где сервер шлёт send tables, baselines и string tables.
**Вывод:** отсутствие send tables в эталоне — дефект захвата (трафик шёл через
SOCKS-прокси), а не дефект парсера. Проверено: собранные 582 блока разбираются
до конца, первый блок (`#34`, 305 байт) содержит `SV_Print` (баннер ClassicBox)
и `SV_ServerInfo` (proto=24, count=2, ded=1, maxcl=48, tick=0.030303,
`dir=garrysmod`, `map=gm_bigcity_improved_rp`) — ровно тот блок, что лежит в
фикстуре `tests/fixtures/classicbox_signon_block.bin`.
Многофрагментных (multi-fragment) передач в читаемом виде в захвате нет: все
собранные блоки меньше `FRAGMENT_SIZE`. Значит дальнейшую верификацию этой ветки
проводить не на чем — но её раскладка теперь известна из исходника и совпадает с
нашей реализацией.
### 13.14. Устойчивость разбора фрагментов
`ReadSubChannelData` в Source.NET при «неожиданном» фрагменте (за пределами
диапазона, нулевой длине, выходе за `bytes`) **не считает это ошибкой**: делает
`data.Return()` и выходит, передача будет прислана заново. Наш код возвращал
`NetError`, что сбрасывало разбор всех остальных сообщений пакета — достаточно
одного опоздавшего фрагмента, чтобы потерять весь signon-блок. Исправлено на
отбрасывание передачи (`stream.reset()`) с `trace`-логом.
### 13.15. Распределение флагов в эталоне
| флаги | пакетов | состав |
|---|---|---|
| 0x20 / 0xa0 / 0x60 | 2187 | Challenge (+ биты добивки 0x40/0x80) |
| 0x21 / 0x61 / 0xa1 | 527 | Reliable\|Challenge (+ добивка) |
| 0xb0, 0xa7, 0xbf, 0xd3 | 4 | единичные Split/Choked/Compressed/Encrypted |
То есть правка `CHOKED = 1<<4` затрагивала 3 пакета из 2718 — редко, но каждый
из них раньше давал рассинхрон на 8 бит. Биты 6–7 (0x40/0x80) — добивка, их
значения игнорируются.