Тело переписано. Первая редакция описывала механизм неверно — разбор ошибки в комментарии ниже. Здесь — то, что подтверждено по коду.
Сдавшись подключаться, клиент обязан разбудить вызывающего NotConnectedException. Иногда вместо этого прилетает OperationCanceledException — и вызывающий вправе прочитать это как «операцию отменили снаружи», хотя её никто не отменял.
Симптом
Failed TestPermanentlyFailingOnConnectedHandlerStops [438 ms]
Assert.IsInstanceOfType failed. 'value' expression: 'connectError'.
Giving up must unblock the waiting caller with NotConnectedException,
got: OperationCanceledException.
Механизм
XrplClient.Connect — это две операции, а не одна (IXrplClient.cs:787):
await connection.Connect(cancellationToken);
await SetNetworkId(); // шлёт server_info
Путь сдачи OnConnectHandlerFailedAsync зовёт requestManager.RejectAllWithCancellation() (connection.cs:2121), а тот отклоняет все запросы в полёте с OperationCanceledException("Connection was intentionally closed.") (RequestManager.cs).
Дальше исход решает, кто успел:
| порядок |
что получает вызывающий |
цикл опроса в WaitForConnectionAsync заметил сдачу |
NotConnectedException — правильно |
сокет на мгновение был открыт, connection.Connect() вернулся успешно, управление дошло до SetNetworkId() — и его server_info отклонили отменой |
OperationCanceledException — неправильно |
То есть спор идёт не между двумя способами разбудить ожидающего, а между «ожидающий заметил сдачу» и «вызывающий проскочил дальше и получил отменённый запрос».
Чего в этом механизме нет
ConnectionManager к этому сценарию отношения не имеет, хотя выглядит подходящим подозреваемым:
WaitForConnectionAsync (connection.cs:1001) — цикл опроса с интервалом 100 мс, а не ожидание обещания. Он сам бросает NotConnectedException, увидев _permanentlyDisconnected или исчерпанные попытки;
connectionManager.AwaitConnection() зовётся ровно в одном месте — connection.cs:1116 — и только если попытка уже в полёте (State() == WebSocketState.Connecting). При первом Connect() этого нет;
- ветки отмены в самом цикле опроса завязаны на
cancellationToken, а Connect() в тесте вызван без токена, то есть с CancellationToken.None.
Почему это важно за пределами теста
OperationCanceledException означает «операцию отменили». Потребитель, который ловит его, чтобы отличить отмену от отказа — например, не логировать отмену как ошибку и не показывать её пользователю, — при исчерпании попыток подключения молча проглотит настоящий отказ. Два исключения означают разные вещи, и подменять одно другим по стечению обстоятельств нельзя.
Наблюдаемость
Флак виден только на CI:
След, за которым стоит проследить
В #114 в OnConnectHandlerFailedAsync добавлен StopMessageProcessor() (connection.cs:2120) — он блокирующе ждёт задачу читателя до двух секунд и стоит прямо перед RejectAllWithCancellation(), то есть внутри окна этой гонки. Гонка существовала и до него; правка могла изменить, кто её выигрывает.
Что делать
Не поднимать таймаут и не перезапускать джобу. Тип исключения должен определяться причиной остановки, а не тем, кто первым добежал:
- сдался цикл подключения →
NotConnectedException, независимо от того, на какой стадии Connect() это застало;
- запрос отменён потому, что клиент сдавался, а не потому, что отмену запросили снаружи → вызывающий не должен видеть это как отмену.
Тест верный и трогать его не нужно — он расхождение и обнаружил.
Сдавшись подключаться, клиент обязан разбудить вызывающего
NotConnectedException. Иногда вместо этого прилетаетOperationCanceledException— и вызывающий вправе прочитать это как «операцию отменили снаружи», хотя её никто не отменял.Симптом
Механизм
XrplClient.Connect— это две операции, а не одна (IXrplClient.cs:787):Путь сдачи
OnConnectHandlerFailedAsyncзовётrequestManager.RejectAllWithCancellation()(connection.cs:2121), а тот отклоняет все запросы в полёте сOperationCanceledException("Connection was intentionally closed.")(RequestManager.cs).Дальше исход решает, кто успел:
WaitForConnectionAsyncзаметил сдачуNotConnectedException— правильноconnection.Connect()вернулся успешно, управление дошло доSetNetworkId()— и егоserver_infoотклонили отменойOperationCanceledException— неправильноТо есть спор идёт не между двумя способами разбудить ожидающего, а между «ожидающий заметил сдачу» и «вызывающий проскочил дальше и получил отменённый запрос».
Чего в этом механизме нет
ConnectionManagerк этому сценарию отношения не имеет, хотя выглядит подходящим подозреваемым:WaitForConnectionAsync(connection.cs:1001) — цикл опроса с интервалом 100 мс, а не ожидание обещания. Он сам бросаетNotConnectedException, увидев_permanentlyDisconnectedили исчерпанные попытки;connectionManager.AwaitConnection()зовётся ровно в одном месте —connection.cs:1116— и только если попытка уже в полёте (State() == WebSocketState.Connecting). При первомConnect()этого нет;cancellationToken, аConnect()в тесте вызван без токена, то есть сCancellationToken.None.Почему это важно за пределами теста
OperationCanceledExceptionозначает «операцию отменили». Потребитель, который ловит его, чтобы отличить отмену от отказа — например, не логировать отмену как ошибку и не показывать её пользователю, — при исчерпании попыток подключения молча проглотит настоящий отказ. Два исключения означают разные вещи, и подменять одно другим по стечению обстоятельств нельзя.Наблюдаемость
Флак виден только на CI:
TestU(1137 тестов, параллельно по классам) тоже зелёный.След, за которым стоит проследить
В #114 в
OnConnectHandlerFailedAsyncдобавленStopMessageProcessor()(connection.cs:2120) — он блокирующе ждёт задачу читателя до двух секунд и стоит прямо передRejectAllWithCancellation(), то есть внутри окна этой гонки. Гонка существовала и до него; правка могла изменить, кто её выигрывает.Что делать
Не поднимать таймаут и не перезапускать джобу. Тип исключения должен определяться причиной остановки, а не тем, кто первым добежал:
NotConnectedException, независимо от того, на какой стадииConnect()это застало;Тест верный и трогать его не нужно — он расхождение и обнаружил.