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

Отмена, ошибки, закрытие

Соединение можно закончить тремя способами: отменить операцию через 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 целиком, а несброшенные в поток байты просто отбрасывает.

Дальше