Перейти к основному содержимому

Кадры: где кончается пакет

TCP отдаёт непрерывный поток байт. Отправитель мог вызвать Write три раза подряд, а получатель увидит эти байты одним куском, двумя или пятью - границ вызовов протокол не сохраняет. Чтобы понять, где кончается один пакет и начинается следующий, перед телом пакета нужна длина. Это и есть кадр: служебная обвязка вокруг пакета, которая делает границу видимой на приёмной стороне - в протоколе она описана на странице Packet format. McProtoNet.Transport.Framing собирает и разбирает эту обвязку.

Кадр без сжатия​

Без сжатия кадр - это длина пакета как VarInt, за которой идёт сам пакет: VarInt-идентификатор и тело. PacketWriteExtensions пишет его в две строки:

writer.WriteVarInt(packet.Length);
writer.Write(packet);

Читающая сторона идёт в обратном порядке: сначала VarInt длины, байт за байтом, затем ровно столько байт тела, сколько эта длина назвала - ни байтом больше.

Кадр со сжатием​

Когда у PacketStreamReader/PacketStreamWriter включён CompressionThreshold, после длины кадра появляется второй VarInt. Пакет короче порога идёт как есть, и этот VarInt - просто 0. Пакет не короче порога сжимается целиком (идентификатор и тело вместе) через libdeflate, и тогда второй VarInt несёт несжатый размер - он нужен, чтобы выделить буфер под распаковку заранее:

writer.WriteVarInt(compressedLength + uncompressedSize.GetVarIntLength());
writer.WriteVarInt(uncompressedSize);
writer.Write(rented.AsSpan(0, compressedLength));

Первый VarInt здесь - длина всего, что после него: размер несжатого значения плюс сжатые байты. Читающая сторона смотрит на второй VarInt: 0 или отрицательное значит «пакет не сжат, дальше его сырые байты», положительное - «дальше zlib, распаковать в буфер именно такого размера». Если после распаковки байт вышло больше или меньше заявленного, кадр считается повреждённым.

Кто этим занимается​

PacketWriteExtensions - статический класс с кирпичами кадра: одни и те же методы WritePacket работают поверх IBufferWriter<byte>, PipeWriter и обычного Stream, синхронно и асинхронно. StreamingConnection собирает из них кадры пачками, по одному буферизованному чтению на пачку, в PacketBatch

  • про это разбор в «Соединении без клиента». Когда включён шифр, PacketStreamWriter собирает кадр целиком во вспомогательном буфере из пула, перед тем как прогнать его через PacketCipher
  • шифру нужен весь кадр сразу, кусками его не отдать.

Транспорт про содержимое пакета не знает ничего: кадр несёт номер пакета и байты, а что это за пакет и какие у него поля - решает пакетный слой.

Ограничения и ошибки​

Длина кадра - от 1 до 32 МиБ. Ноль, отрицательное значение или превышение потолка - InvalidDataException от ThrowHelper.ThrowInvalidFrameLength. VarInt длины не может занимать больше пяти байт - если занимает, это ThrowVarIntTooLong. С несжатым размером та же проверка потолка: если он выходит за 32 МиБ, кадр отклоняется до попытки выделить буфер. Провал распаковки или несовпадение итогового размера с заявленным несжатым размером - тоже InvalidDataException, с разными сообщениями (ThrowDecompressFailed, ThrowDecompressSizeMismatch).

Обрыв соединения посреди кадра тоже даёт EndOfStreamException, без пустого пакета на этом месте - полная таблица «что произошло → какое исключение» в «Отмене, ошибках, закрытии».

PacketStreamReader и PacketStreamWriter не читают и не пишут два кадра одновременно - параллельный ReadPacketAsync/WritePacketAsync поверх незавершённого первого получает InvalidOperationException, как и везде в транспорте (там же). По той же причине Cipher, CompressionThreshold и AutoFlush у писателя меняются только между кадрами - попытка сменить их посреди чтения или записи тоже бросает InvalidOperationException. AutoFlush включён по умолчанию: если его выключить, кадры копятся в буфере потока до FlushAsync. ConnectionAbortedException к этому слою отношения не имеет - это исключение уровня соединения, разобрано в «Потоке пакетов».

Где это трогает приложение напрямую​

Обычно кадры не видно: соединение прячет их за IncomingPacket/OutgoingPacket. Но когда MinecraftConnection не нужен - например, для рукопожатия до логина или для короткого протокольного разговора без буферизации - PacketStreamReader и PacketStreamWriter дают тот же формат кадра напрямую поверх любого Stream, по одному пакету за вызов. Тело пакета - окно в блок из пула, которым владеет сам пакет, - пакет освобождают, когда тело больше не нужно («Кто владеет телом пакета»).

Дальше​