Отмена, ошибки, закрытие
Соединение можно закончить тремя способами: отменить операцию через
CancellationToken, вызвать Abort с причиной или вызвать DisposeAsync. Все
три закрывают соединение, но по-разному ведут себя для вызова, который в этот
момент ждёт байты, и оставляют разный след в CloseReason. Ниже - про
MinecraftConnection
и MinecraftClient; у
StreamingConnection
те же правила, с одной добавкой в конце страницы.
Три способа
Токен передаётся в ReadPacketAsync, WritePacketAsync и SendAsync. Дёшево
обходится ровно один случай: токен отменён ещё до вызова. Тогда проверка на
входе бросает OperationCanceledException, соединение при этом остаётся живым.
Как только вызов начался, отмена закрывает соединение целиком - даже если из
сокета ещё не пришло ни байта. Почему так - в следующем разделе.
Abort(reason) можно вызвать из любого потока и в любой момент, не ожидая
ничего в ответ: чтение или запись, которые в этот момент занимали сокет,
проваливаются с указанной причиной, а CloseReason её запоминает. Это тот
инструмент, которым код приложения сообщает библиотеке, что соединение испортил
протокол, а не транспорт - например, сервер прислал пакет, какого не должно быть
в этой фазе.
DisposeAsync использует тот же Abort, но без причины, и добавляет к нему
ожидание; порядок этих шагов описан в «Потоке пакетов». На уровне
MinecraftClient к этому добавляется свой гейт отправки: SendAsync и
SendRawAsync занимают его на время вызова, и DisposeAsync клиента ждёт
освобождения гейта тем же бюджетом в пять секунд, а затем закрывает соединение в
любом случае - даже если гейт так и не освободился.
Почему отмена начатого чтения рвёт соединение
Начатое чтение уже сидит внутри читателя кадров, и снаружи не видно, что именно оно успело взять: часть кадра - длина или кусок тела - может быть вынута из потока и осесть в буфере. Отдать её обратно нельзя, а значит, нельзя и вернуть соединение к границе кадра для следующего вызова: кадр разорван так же, как при обрыве сети или ошибке кодирования.
Поэтому соединение не различает «отменённое чтение» и «сбойное чтение» - оба
закрывают соединение. Вызывающий, чей токен сработал, получает
OperationCanceledException; любой другой вызов, начатый позже или ждавший
рядом, получает
ConnectionAbortedException
с этим же исключением внутри. Для кода приложения это значит: токен, переданный
в ReadPacketsAsync, работает как выключатель всего цикла чтения, а не как
способ отменить текущий вызов и получить следующий пакет как ни в чём не бывало.
Как узнать причину
CloseReason - null, пока соединение открыто, и null же после чистого конца
потока. Во всех остальных случаях там лежит исключение: либо то, что передали в
Abort, либо первый сбой, который поймал собственный читатель или писатель
соединения. Completion - задача, которая завершается в момент закрытия и
никогда не падает: код может ждать её без try/catch, чтобы узнать факт
закрытия, а затем прочитать CloseReason и узнать причину.
Первый сбой потока долетает до вызвавшего его кода своим собственным типом и в
этот же момент оседает в CloseReason. Все последующие вызовы - и того же
метода, и любых других членов соединения - не трогают мёртвый сокет ещё раз, а
сразу бросают ConnectionAbortedException с той же причиной внутри: второй
читатель или писатель, подоспевший к уже мёртвому соединению, видит настоящую
причину, а не случайный обрывок ошибки. Исключение из этого правила -
InvalidOperationException не от ObjectDisposedException: параллельное чтение
или вызов после ToStreaming - это ошибка вызывающего кода, которая не дошла до
потока, и соединение из-за неё не закрывается.
Что произошло → какое исключение
| Что произошло | Исключение |
|---|---|
| Сервер закрыл поток чисто, между кадрами | EndOfStreamException |
| Поток оборвался посреди кадра | EndOfStreamException |
| Закрытие своей стороной, когда вызов ждал байты | ConnectionAbortedException |
Вызов после того, как DisposeAsync завершился | ObjectDisposedException |
Второй параллельный ReadPacketAsync на связи | InvalidOperationException |
| Битый кадр (длина, varint, размер распаковки) | InvalidDataException |
Первые два случая читатель не различает - оба приходят как чистый
EndOfStreamException. Строка про битый кадр - не закрытие соединения по
причине снаружи, а находка самого читателя: она тоже осядет в CloseReason, но
это ошибка данных, а не обрыв связи.
Порядок закрытия в коде приложения
await using var client = new MinecraftClient(options);
await client.ConnectAsync(token);
try
{
await foreach (var packet in client.ReadPacketsAsync(token))
Handle(packet);
}
catch (EndOfStreamException)
{
// штатный конец сеанса, сервер закрыл поток сам
}
catch (ConnectionAbortedException ex)
{
Log(ex.InnerException); // причина уже внутри
}
Отдельно звать Abort или DisposeAsync тут не нужно - await using берёт это
на себя: гейт отправки дождётся своего бюджета, буферы вернутся в пул. Явный
Abort нужен, только когда соединение закрывает код, не связанный с циклом
чтения - например, другая задача, заметившая, что сервер ведёт себя не по
протоколу.
Потоковый путь
StreamingConnection, полученный через ToStreaming, наследует общее: Abort
из любого потока, CloseReason, Completion, то же запоминание первой ошибки:
она оседает в CloseReason, и её же получают все следующие вызовы. Разница в
трёх местах.
Главное отличие - отмена. Отменённый собственным токеном ReadBatchAsync
соединение не закрывает: буфер цел, граница кадра не потеряна, читать можно
дальше. Закрывает соединение только отмена FlushAsync после того, как байты
уже пошли в поток.
Рядом с Abort у него есть CompleteAsync - чистое завершение, которое
досылает накопленное в буфере отправки, и после которого CloseReason остаётся
null. А его DisposeAsync не использует пятисекундный бюджет: он ждёт
Completion целиком, а несброшенные в поток байты просто отбрасывает.
Дальше
- Поток пакетов - те же правила для одного кадра
- Соединение без клиента -
MinecraftConnectionи потоковый путь безMinecraftClientсверху - Исключения - что бросает пакетный слой поверх транспорта