generated from mononoki/pyproj
[+!] all but small
some small modifications, tests added
This commit is contained in:
176
tests/README.md
176
tests/README.md
@@ -1,70 +1,150 @@
|
||||
# Клиент и тесты сервера
|
||||
# Клиент и тесты SIRMENT/core
|
||||
|
||||
`src/.test/client_test.py` используется и для ручного запуска, и в интеграционных
|
||||
тестах. Он ожидает `token_ask`, отвечает `token_answer`, ожидает `handshake_ask`,
|
||||
отвечает `handshake_answer` и подтверждает состояние `air` ответом на heartbeat.
|
||||
Затем ручной запуск отправляет десять пакетов телеметрии с heartbeat между ними.
|
||||
Каждое ожидание ответа имеет таймаут; импорт файла не запускает клиент.
|
||||
Общий клиент `examples/client.py` используется для ручного запуска и настоящих
|
||||
TCP-интеграционных тестов. Fixture загружает его по пути относительно
|
||||
репозитория, без запуска CLI при импорте. Папка `src/.test` игнорируется Git и
|
||||
не нужна для выполнения тестов из чистого клона.
|
||||
|
||||
После запуска сервера:
|
||||
## Установка и запуск
|
||||
|
||||
```bat
|
||||
src\.test\start_client.bat rover --count 10 --interval 1
|
||||
Нужен Python 3.11 и зависимости тестов:
|
||||
|
||||
```sh
|
||||
python -m pip install -r req.txt
|
||||
python -m pip install -r req-dev.txt
|
||||
python -m pytest -q --cov=src --cov-branch --cov-report=term-missing --cov-report=xml
|
||||
```
|
||||
|
||||
Для запроса восстановления укажите старые значения из вывода клиента:
|
||||
Для окружения sites на Windows, из корня репозитория:
|
||||
|
||||
```bat
|
||||
src\.test\start_client.bat rover --old-token 123 --old-upf 456
|
||||
D:\_Programs\anaconda\envs\sites\python.exe -m pip install -r req-dev.txt
|
||||
D:\_Programs\anaconda\envs\sites\python.exe -m pytest -q --cov=src --cov-branch --cov-report=term-missing --cov-report=xml
|
||||
tests\start_tests.bat
|
||||
```
|
||||
|
||||
Сейчас сервер не сообщает результат восстановления отдельным ответом.
|
||||
Клиент сообщает только факт отправки запроса; heartbeat подтверждает новый
|
||||
handshake, но сам по себе не доказывает успешное восстановление.
|
||||
|
||||
Запуск всех тестов из корня `server`:
|
||||
Батник переходит в корень репозитория независимо от текущей папки. Без аргументов
|
||||
он запускает весь набор с покрытием; с аргументами передаёт их pytest:
|
||||
|
||||
```bat
|
||||
D:\_Programs\anaconda\envs\sites\python.exe -m pytest -q
|
||||
tests\start_tests.bat tests/test_router.py -q
|
||||
```
|
||||
|
||||
Также можно запустить `tests/start_tests.bat`. Только проверки самого клиента:
|
||||
Отдельные группы:
|
||||
|
||||
```bat
|
||||
tests\start_tests.bat tests/test_client_protocol.py -q
|
||||
```sh
|
||||
python -m pytest tests/test_client_protocol.py -q
|
||||
python -m pytest tests/test_session.py tests/test_session_manager.py tests/test_router.py -q
|
||||
python -m pytest tests/test_transport_boundary.py -q
|
||||
python -m pytest tests/test_entrypoint.py -q --cov=src --cov-branch
|
||||
```
|
||||
|
||||
Интеграционные тесты проверяют подключение, метаданные портов, телеметрию,
|
||||
heartbeat, немедленное закрытие транспорта и сохранение отключённой сессии,
|
||||
отключение после пяти команд в неправильном состоянии протокола,
|
||||
перенос данных и маршрутов в обе стороны, отказ для активной/отсутствующей
|
||||
сессии и неверного `upf`, а также однократность восстановления.
|
||||
Новые регрессионные проверки намеренно не помечены `xfail` и не отключены.
|
||||
С текущими ошибками реализации весь набор возвращает ненулевой код. После
|
||||
исправления соответствующей ошибки тот же тест должен пройти без изменения
|
||||
ожидаемого поведения. Результаты и пробелы проверки — в [REPORT.md](REPORT.md).
|
||||
|
||||
Дополнительные проверки:
|
||||
## Ручной клиент
|
||||
|
||||
- `test_session.py`: все этапы подключения, истечение и продление дедлайна,
|
||||
остановка воркера, ошибки последовательности и повторный handshake.
|
||||
- `test_session_manager.py`: обработка событий, истечение окна восстановления,
|
||||
однократность переноса и безопасность старой отложенной очистки.
|
||||
- `test_router.py`: перенос ссылок в обе стороны, несвязанные маршруты,
|
||||
окончательное удаление связей и данные отправителя при пересылке.
|
||||
- `test_source_validation.py`: синтаксис исходников и docstring каждого теста.
|
||||
После запуска сервера через `python src/main.py`:
|
||||
|
||||
В `test_client_run.py` также проверяются настоящие TCP-таймауты в состояниях
|
||||
`new/token/ready/air` и сохранение соединения после продления heartbeat.
|
||||
Краткое описание каждой проверки находится в docstring её функции.
|
||||
```sh
|
||||
python examples/client.py rover --host 127.0.0.1 --port 9000 --count 10 --interval 1
|
||||
python examples/client.py rover --old-token 123 --old-upf 456
|
||||
```
|
||||
|
||||
Интеграционные тесты запускают настоящий TCP-обработчик, сессии и роутер на
|
||||
свободном локальном порту. Маршруты задаются явно внутри теста: правила API/GUI
|
||||
не предполагаются. Изолированные проверки менеджера используют наблюдатель
|
||||
вызовов роутера; настоящий роутер дополнительно проверяется отдельно.
|
||||
На Windows: `examples\start_client.bat rover --count 10 --interval 1`.
|
||||
Клиент ждёт `token_ask`, отвечает `token_answer`, ждёт `handshake_ask`, отвечает
|
||||
`handshake_answer` и подтверждает состояние `air` ответом на heartbeat.
|
||||
Ручной запуск отправляет телеметрию с heartbeat между пакетами. Ожидания ответов
|
||||
ограничены таймаутом. Сервер пока не сообщает результат восстановления отдельным
|
||||
пакетом; клиент сообщает только факт отправки запроса.
|
||||
|
||||
В сценариях с таймерами подменяются только часы и ожидания времени в памяти.
|
||||
Тест явно продвигает время и освобождает нужное ожидание, поэтому реальные
|
||||
паузы в 5/60 секунд не нужны. Методы сессии, её воркер, обработчики менеджера
|
||||
и маршрутизация не подменяются. Неопределённые имена часов не добавляются
|
||||
тестами: такие ошибки остаются видимыми. Фоновые задачи завершаются или
|
||||
отменяются при выходе из каждого сценария; ошибки воркеров не скрываются.
|
||||
## Что проверяется
|
||||
|
||||
`fixtures/client_run_expected.json` оставлен как исторический эталон старого
|
||||
сценария. Новые тесты проверяют ответы протокола и состояние сервера, а не логи.
|
||||
| Файл | Сценарии |
|
||||
| --- | --- |
|
||||
| `test_client_protocol.py` | Порядок handshake клиента, буферизация данных до heartbeat, таймауты и ошибочные ответы |
|
||||
| `test_client_run.py` | Настоящий TCP, метаданные портов, телеметрия, heartbeat, EOF, таймауты всех этапов, восстановление в обе стороны, отказ переноса и однократность |
|
||||
| `test_session.py` | Дедлайны 60/5 секунд, собственный watcher, переходы протокола, retry handshake, последние данные, запрет новых отправок в stop/migrated, обработка данных во время фоновой отправки |
|
||||
| `test_session_manager.py` | События, срок восстановления 60 секунд, истечение окна, повторный close после удаления, ошибка writer.wait_closed через настоящий Connection.close, безопасность старой очистки после реконекта |
|
||||
| `test_router.py` | Исходящие/входящие связи, самоссылки, несвязанные узлы, удаление ссылок и данные отправителя |
|
||||
| `test_transport_boundary.py` | Отключение/очистка плохих пакетов, лимит неверных команд, status True/False/Other, auth робота, произвольные JSON-данные без автокаста, heartbeat, TCP-фрейминг, watcher/EOF, фоновые сетевые ошибки |
|
||||
| `test_entrypoint.py` | Настоящие main.py/TCP.run в отдельном процессе, handshake, heartbeat и завершение процесса |
|
||||
| `test_source_validation.py` | Синтаксис src/examples и краткие docstring каждого теста |
|
||||
|
||||
Некорректные клиенты проверяются одновременно с исправным соединением:
|
||||
закрытие одного транспорта не должно мешать heartbeat и телеметрии другого.
|
||||
Локальная ошибка разбора пакета допускается, если транспорт закрывается,
|
||||
запись и маршруты удаляются после окна восстановления, а другой клиент работает.
|
||||
Ожидаемое исключение разрешается только для проверенного обработчика плохого
|
||||
клиента; ошибки остальных задач продолжают проваливать тест.
|
||||
|
||||
`connection.payload.token` хранится в метаданных `auth` как токен робота.
|
||||
Отсутствующий/null токен не мешает подключению; whitelist роутера пока не
|
||||
реализован. Это поле отдельно от UID/upf восстановления в `token_answer`.
|
||||
По согласованному протоколу отказ handshake означает только JSON `false`:
|
||||
`0`, `null`, пустые строка/массив/объект означают успех. Тесты не требуют boolean.
|
||||
Для status оставлены три представителя: True, False и Other = 0. Значение 0
|
||||
отличает буквальный false от ложного значения Python без перебора всех типов.
|
||||
|
||||
Payload команд, читающих поля объекта, проверяется на безопасное отклонение,
|
||||
если его нельзя разобрать. Содержимое heartbeat не используется, поэтому
|
||||
необъектное значение разрешено. Для heartbeat без поля payload допустимы
|
||||
обработка или локальное отключение с очисткой. Полезная нагрузка данных
|
||||
сохраняется и маршрутизируется без преобразования JSON-значения в словарь.
|
||||
|
||||
## Метод проверки и завершение задач
|
||||
|
||||
Интеграционные тесты запускают настоящие TCP-обработчик, Session, SessionManager
|
||||
и Router на локальном порту, выбранном ОС. Моки сервера не используются.
|
||||
Маршруты задаются явно в тестах: правила будущих API/GUI не предполагаются.
|
||||
Изолированные тесты менеджера используют наблюдатель вызовов роутера, а сам
|
||||
Router проверяется отдельно.
|
||||
|
||||
В проверках таймаутов подменяются только монотонные часы и ожидания времени в
|
||||
памяти. Тест освобождает выбранный `sleep` явно; реальные паузы в 5/60 секунд
|
||||
не нужны. Основные методы сервера не подменяются. Ошибки транспорта вводятся
|
||||
локально на одном реальном connection. Искусственная передача Python set в
|
||||
сериализацию исключена: она не воспроизводит текущий путь данных из JSON.
|
||||
|
||||
Гонка watcher/TCP-finally проверяет одно закрытие и одну очистку и допускает
|
||||
как ожидание очистки вызывающей корутиной, так и отдельную задачу очистки.
|
||||
Ошибка закрытия воспроизводится на `writer.wait_closed` и проходит через
|
||||
настоящий `Connection.close`; удаление записи и маршрутов должно состояться.
|
||||
Уже существующий тест старой очистки после реконекта сохранён.
|
||||
|
||||
Фоновые сетевые исключения должны быть видны с исходным исключением; стандартное
|
||||
логирование asyncio, включая `Task exception was never retrieved`, допускается.
|
||||
Тест не требует немедленного disconnect от одной ошибки отдельной задачи:
|
||||
закрытие проверяется через настоящий EOF. При разрыве во время отправки
|
||||
допускаются завершение с сетевой ошибкой или отмена задачи; транспорт закрывается
|
||||
один раз, другое соединение работает. Отмена уже запланированных отправок при
|
||||
смене состояния не вводится как отдельное обязательное правило.
|
||||
|
||||
Фикстуры и контекст сервера завершают или отменяют созданные задачи, закрывают
|
||||
клиентов и listener и проверяют исключения задач. Ожидания ограничены временем;
|
||||
ошибка не превращается в зависший тест.
|
||||
|
||||
## Точка входа и покрытие дочернего процесса
|
||||
|
||||
В текущем `main.py` адрес/порт заданы напрямую, поэтому процессный тест запускает
|
||||
`tests/entrypoint_runner.py`, который выполняет настоящий файл через `runpy`.
|
||||
Обёртка перенаправляет только адрес и порт вызова `asyncio.start_server` на
|
||||
`127.0.0.1:0` и вызывает оригинальную функцию. Код main.py, TCP.run, handshake,
|
||||
сессий, маршрутизации и фоновых отправок не заменяется.
|
||||
|
||||
Процесс запускается из другой текущей папки. После проверки клиент закрывается,
|
||||
а команда по stdin отменяет главную корутину. Это позволяет завершить процесс
|
||||
штатно и сохранить покрытие, включая Windows. При зависании есть принудительное
|
||||
завершение. Тест не доказывает настраиваемость порта самим production CLI:
|
||||
такого интерфейса пока нет.
|
||||
|
||||
`pytest-cov` и `coverage` находятся в `req-dev.txt`. `.coveragerc` включает
|
||||
покрытие ветвей и `patch = subprocess`, чтобы учитывать main.py/TCP.run из
|
||||
дочернего процесса. Это [рекомендованный механизм pytest-cov 7](https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html).
|
||||
Порог 100% не установлен; ошибки поведения не скрываются высоким процентом.
|
||||
`coverage.xml` и `.coverage*` — генерируемые, игнорируемые Git результаты.
|
||||
|
||||
`fixtures/client_run_expected.json` сохранён как исторический эталон старого
|
||||
сценария. Нынешние тесты проверяют протокол и состояние сервера, а не этот лог.
|
||||
|
||||
119
tests/REPORT.md
Normal file
119
tests/REPORT.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Проверка SIRMENT/core — 7 октября 2026
|
||||
|
||||
Изменены тесты и Markdown. Исходники `src` при этой доработке не редактировались;
|
||||
сохранённые изменения автора проекта оставлены на месте.
|
||||
|
||||
## Уточнённые ожидания
|
||||
|
||||
Тесты проверяют согласованное поведение системы:
|
||||
|
||||
- Ошибка разбора входного пакета может завершать обработчик этого клиента.
|
||||
Проверяется закрытие транспорта, сохранение записи на окно восстановления,
|
||||
последующее удаление записи/маршрутов и работа другого TCP-соединения.
|
||||
Разрешается только проверенное исключение конкретного плохого клиента;
|
||||
остальные ошибки не скрываются.
|
||||
- Отсутствующая команда отключает сразу. Неверная команда до handshake
|
||||
проверяется на установленном лимите из пяти логических ошибок.
|
||||
- Отказ handshake означает только буквальный JSON `false`. Остальные значения
|
||||
`status`, включая `0`, `null`, пустые строку/массив/объект, должны разрешать
|
||||
продолжение. Проверяются три представителя: True, False и Other = 0.
|
||||
Требование boolean и перебор всех ложных значений удалены.
|
||||
- Полезная нагрузка данных сохраняется и пересылается как исходное JSON-значение,
|
||||
без преобразования в словарь. Содержимое heartbeat не используется.
|
||||
- `connection.payload.token` хранится как `auth` робота; отсутствие/null и
|
||||
строковый токен проверяются отдельно от UID/upf восстановления.
|
||||
Будущая whitelist-проверка не вводится.
|
||||
- Фоновые отправки продолжают работать отдельными задачами. Проверяется, что
|
||||
ожидание отправки не блокирует обработку данных.
|
||||
- Сетевая ошибка фоновой отправки должна быть видна с исходным исключением.
|
||||
Стандартное логирование asyncio допустимо. Немедленное отключение от одной
|
||||
ошибки отдельной задачи не требуется; закрытие проверяется через настоящий EOF.
|
||||
- При разрыве во время отправки допустимы сетевая ошибка или отмена задачи.
|
||||
Проверяются однократное закрытие и работа другого соединения.
|
||||
- Ошибка закрытия вводится в `writer.wait_closed` настоящего `Connection.close`,
|
||||
чтобы проверить защиту на уровне транспорта и последующую очистку менеджером.
|
||||
|
||||
Удалены искусственная сериализация Python set, обязательная отмена уже
|
||||
запланированной отправки при смене состояния, запрет сообщения
|
||||
`Task exception was never retrieved` и требование нулевого числа исключений
|
||||
при корректном отключении некорректного клиента. Реальные TCP-интеграционные
|
||||
тесты сохранены; мок сервера не используется.
|
||||
|
||||
## Последний полный прогон
|
||||
|
||||
Среда: Windows, Python 3.11.15 из Anaconda `sites`, pytest 9.1.1,
|
||||
pytest-cov 7.1.0, coverage 7.16.2.
|
||||
|
||||
```sh
|
||||
python -m pytest -q --cov=src --cov-branch --cov-report=term-missing --cov-report=xml
|
||||
```
|
||||
|
||||
**106 сценариев: 105 passed, 1 failed.**
|
||||
|
||||
| Причина | Падений | Что происходит |
|
||||
| --- | ---: | --- |
|
||||
| test_handshake_status_follows_protocol[other] | 1 | Other = 0 должен разрешать handshake; текущий if not status запускает retry, и ожидание heartbeat_answer завершается TimeoutError |
|
||||
|
||||
True проходит полный handshake и heartbeat. False сохраняет ready, вызывает
|
||||
повторный handshake и допускает последующий успешный ответ True. Значение 0
|
||||
выбрано единственным Other: оно отличает буквальный false от ложности Python.
|
||||
|
||||
Сохранённые исправления автора проекта в Connection.close и Session.send
|
||||
проверены: проходят обработка ошибки writer.wait_closed с последующей очисткой,
|
||||
запрет новых отправок в stop/migrated, handshake, heartbeat и фоновые отправки.
|
||||
|
||||
Проходят также изоляция/очистка некорректных пакетов, TCP-фрейминг,
|
||||
гонка watcher/finally, восстановление, видимость фоновых сетевых ошибок,
|
||||
разрыв во время отправки и настоящий main.py/TCP.run в отдельном процессе.
|
||||
Тесты не имеют xfail или пропусков. Исходники src не исправлялись тестами.
|
||||
|
||||
## Покрытие последнего прогона
|
||||
|
||||
pytest-cov с учётом ветвей показывает **99%**:
|
||||
|
||||
- строки: **192 из 193**, 99,48%;
|
||||
- ветви: **55 из 56**, 98,21%.
|
||||
|
||||
Дочерний процесс включён в покрытие: main.py и TCP.run выполнены.
|
||||
Единственный непокрытый путь — отсутствующий UID в SessionManager.__getitem__,
|
||||
строка 63. Отдельный тест простого getter ради процента не добавлен.
|
||||
|
||||
Высокий процент не отменяет оставшееся расхождение status: один тест падает.
|
||||
Порог 100% и исключения ради повышения процента не вводились.
|
||||
|
||||
## Воспроизводимость и ограничения
|
||||
|
||||
Общий клиент находится в отслеживаемом `examples/client.py`; fixture не зависит
|
||||
от игнорируемого `src/.test`. Перенос ранее проверен чистым локальным Git-клоном
|
||||
снимка рабочих файлов: клиент доступен без этой папки. Новая редакция тестов
|
||||
проверена в рабочем дереве; повторный клон этого снимка не запускался.
|
||||
|
||||
Процессный тест выполняет настоящий main.py через `tests/entrypoint_runner.py`.
|
||||
Обёртка меняет только адрес/порт настоящего asyncio.start_server на
|
||||
`127.0.0.1:0`, запускает собственный stdin-контроль отмены главной корутины
|
||||
и позволяет сохранить покрытие дочернего процесса. Production CLI настройки
|
||||
порта и обработка SIGTERM/CTRL+C самим main.py не проверяются.
|
||||
|
||||
Сохранены проверки реконекта: перенос данных и динамических метаданных,
|
||||
маршруты в обе стороны, отдельный handshake, неверный upf,
|
||||
активная/отсутствующая/перенесённая сессия, однократность и безопасность старой
|
||||
очистки после восстановления.
|
||||
|
||||
Оставшиеся ограничения:
|
||||
|
||||
- Сроки 60 секунд до первого heartbeat, 5 секунд после heartbeat и 60 секунд
|
||||
восстановления проверяются управляемыми часами, без настоящих длинных пауз.
|
||||
- Проверяется выбранное пересечение watcher/TCP-finally, но не длительный
|
||||
стресс множества соединений, переполнение буферов и задержки event loop.
|
||||
- Не охвачены все поля метаданных/типы UID и upf, повреждённый UTF-8,
|
||||
предельная длина пакетов и исчерпание ресурсов.
|
||||
- Не проверена доступная внешнему JSON-клиенту ошибка сериализации:
|
||||
искусственный Python set не является таким сценарием.
|
||||
- Linux/Gitea/Docker в этой среде не запускались.
|
||||
- GUI/API и автоматическое создание маршрутов, авторизация по whitelist,
|
||||
полноценное переключение диалектов, WebSocket/sio и сохранение состояния
|
||||
после перезапуска ещё не реализованы и не предполагаются тестами.
|
||||
- Отдельного ответа о результате восстановления пока нет; правило объединения
|
||||
старых и новых статических метаданных и назначение com ещё не закреплены.
|
||||
|
||||
Команды запуска и методика описаны в [README.md](README.md).
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
@pytest.fixture(scope="session")
|
||||
def client_module():
|
||||
"""Загружает общий тестовый клиент без запуска его командного интерфейса."""
|
||||
path = Path(__file__).parents[1] / "src" / ".test" / "client_test.py"
|
||||
path = Path(__file__).parents[1] / "examples" / "client.py"
|
||||
spec = importlib.util.spec_from_file_location("artex_test_client", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
@@ -67,6 +67,7 @@ class Timing:
|
||||
def __init__(self):
|
||||
self.clock = Clock()
|
||||
self.tasks = []
|
||||
self.expected_errors = []
|
||||
self.queues = {}
|
||||
self.waiting = {}
|
||||
|
||||
@@ -84,6 +85,11 @@ class Timing:
|
||||
self.tasks.append(task)
|
||||
return task
|
||||
|
||||
def accept_task_error(self, task, error):
|
||||
assert task in self.tasks and task.done() and not task.cancelled()
|
||||
assert task.exception() is error
|
||||
self.expected_errors.append(error)
|
||||
|
||||
async def sleeping(self, label, task=None):
|
||||
async with asyncio.timeout(1):
|
||||
while True:
|
||||
@@ -106,7 +112,8 @@ class Timing:
|
||||
if exc_type is None:
|
||||
for result in results:
|
||||
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
|
||||
raise result
|
||||
if not any(result is expected for expected in self.expected_errors):
|
||||
raise result
|
||||
|
||||
|
||||
class ConnectionRecorder:
|
||||
|
||||
41
tests/entrypoint_runner.py
Normal file
41
tests/entrypoint_runner.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Execute the real entrypoint with a private TCP binding and graceful test shutdown.
|
||||
|
||||
Only asyncio.start_server's binding arguments are redirected. Its real server,
|
||||
TCP.run, handler, manager, router, protocol and background sends all run normally.
|
||||
The stdin task cancels main just as an external stop would, allowing coverage's
|
||||
atexit hook to save child measurements on Windows as well as Linux.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
|
||||
async def stop_from_stdin(main_task):
|
||||
await asyncio.to_thread(sys.stdin.buffer.readline)
|
||||
main_task.cancel()
|
||||
|
||||
|
||||
def run():
|
||||
main = Path(sys.argv[1]).resolve()
|
||||
original_start_server = asyncio.start_server
|
||||
|
||||
async def start_server(callback, host=None, port=None, **kwargs):
|
||||
server = await original_start_server(callback, "127.0.0.1", 0, **kwargs)
|
||||
address = server.sockets[0].getsockname()
|
||||
asyncio.create_task(stop_from_stdin(asyncio.current_task()))
|
||||
print(f"TCP listening {address[0]}:{address[1]}", flush=True)
|
||||
return server
|
||||
|
||||
asyncio.start_server = start_server
|
||||
sys.path.insert(0, str(main.parent))
|
||||
sys.argv = [str(main)]
|
||||
try:
|
||||
runpy.run_path(str(main), run_name="__main__")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -10,7 +10,7 @@ if not exist "%PYTHON%" (
|
||||
)
|
||||
|
||||
if "%~1"=="" (
|
||||
"%PYTHON%" -m pytest "%~dp0."
|
||||
"%PYTHON%" -m pytest "%~dp0." --cov=src --cov-branch --cov-report=term-missing --cov-report=xml
|
||||
) else (
|
||||
"%PYTHON%" -m pytest %*
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Integration checks using the same client as src/.test/start_client.bat."""
|
||||
"""Integration checks using the same client as examples/client.py."""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
@@ -12,9 +12,13 @@ class RunningServer:
|
||||
self.clients = []
|
||||
self.handlers = []
|
||||
self.watchers = []
|
||||
self.records = []
|
||||
self.errors = []
|
||||
self.expected_errors = []
|
||||
self.peer_errors = {}
|
||||
|
||||
async def __aenter__(self):
|
||||
self.initial_tasks = asyncio.all_tasks()
|
||||
# Lazy imports keep client-only tests runnable if the server has an error.
|
||||
from router import Router
|
||||
from session import Sessions
|
||||
@@ -22,11 +26,14 @@ class RunningServer:
|
||||
|
||||
errors = self.errors
|
||||
watchers = self.watchers
|
||||
records = self.records
|
||||
peer_errors = self.peer_errors
|
||||
|
||||
class ObservedSessions(Sessions):
|
||||
async def new(self, connection):
|
||||
uid = await super().new(connection)
|
||||
watchers.append(self[uid].watcher_task)
|
||||
records.append(self[uid])
|
||||
return uid
|
||||
|
||||
async def feed(self, uid, packet):
|
||||
@@ -36,6 +43,7 @@ class RunningServer:
|
||||
# Preserve the original error before the handler enters
|
||||
# its delayed cleanup and closes the client's transport.
|
||||
errors.append(error)
|
||||
peer_errors.setdefault(asyncio.current_task(), []).append(error)
|
||||
raise
|
||||
|
||||
self.router = Router()
|
||||
@@ -55,6 +63,17 @@ class RunningServer:
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
self.errors.append(task.exception())
|
||||
|
||||
def accept_peer_rejection(self, task, error):
|
||||
# Accept only the checked exception from this particular client handler.
|
||||
assert task in self.handlers and task.done() and not task.cancelled()
|
||||
assert task.exception() is error or any(error is item for item in self.peer_errors.get(task, []))
|
||||
self.expected_errors.append(error)
|
||||
|
||||
def assert_no_unexpected_errors(self):
|
||||
for error in self.errors:
|
||||
if not any(error is expected for expected in self.expected_errors):
|
||||
raise error
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
@@ -63,17 +82,23 @@ class RunningServer:
|
||||
)
|
||||
# Stop pending recovery-window waits after assertions, without modifying
|
||||
# the production timer or waiting 60 seconds for each test connection.
|
||||
tasks = self.handlers + self.watchers
|
||||
tasks = set(self.handlers + self.watchers)
|
||||
tasks.update(asyncio.all_tasks() - self.initial_tasks)
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
# A cancelled handler can enter finally and start the recovery wait.
|
||||
# Let it close its transport, then cancel that new wait as well.
|
||||
await asyncio.sleep(0)
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 1)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
|
||||
self.errors.append(result)
|
||||
if exc_type is None and self.errors:
|
||||
raise self.errors[0]
|
||||
if exc_type is None:
|
||||
self.assert_no_unexpected_errors()
|
||||
for result in close_results:
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
50
tests/test_entrypoint.py
Normal file
50
tests/test_entrypoint.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Проверка настоящего main.py и TCP.run() в отдельном процессе."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_main_process_handshake_and_heartbeat(client_module, tmp_path):
|
||||
"""Запускает main.py на порту ОС из другого cwd, проверяет протокол и завершает процесс."""
|
||||
async def scenario():
|
||||
main = Path(__file__).parents[1] / "src" / "main.py"
|
||||
runner = Path(__file__).with_name("entrypoint_runner.py")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-B", "-u", str(runner), str(main),
|
||||
cwd=tmp_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
client = None
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), 4)
|
||||
if not line:
|
||||
diagnostic = await asyncio.wait_for(process.stderr.read(), 1)
|
||||
raise AssertionError("main.py не запустил TCP:\n" + diagnostic.decode(errors="replace"))
|
||||
assert line.startswith(b"TCP listening "), line.decode(errors="replace")
|
||||
port = int(line.decode().strip().rsplit(":", 1)[1])
|
||||
assert 0 < port < 65536
|
||||
client = client_module.Client("process-rover", port=port, timeout=2)
|
||||
await client.connect()
|
||||
await client.telemetry()
|
||||
assert await client.heartbeat() == {"command": "heartbeat_answer"}
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
if process.returncode is None:
|
||||
try:
|
||||
process.stdin.write(b"stop\n")
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), 4)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
stdout, stderr = await process.communicate()
|
||||
assert process.returncode is not None
|
||||
assert process.returncode == 0, stderr.decode(errors="replace")
|
||||
asyncio.run(asyncio.wait_for(scenario(), 12))
|
||||
@@ -227,13 +227,50 @@ def test_handshake_retry_limit_requests_disconnect(make_session, timing):
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_stopped_session_does_not_send_packets(make_session, timing):
|
||||
"""Проверяет, что после stop новые отправки не поступают закрытому соединению."""
|
||||
@pytest.mark.parametrize("state", ["stop", "migrated"])
|
||||
def test_stopped_session_does_not_send_packets(make_session, timing, state):
|
||||
"""Проверяет запрет новых отправок в stop и migrated."""
|
||||
async def scenario():
|
||||
async with timing:
|
||||
case = make_session()
|
||||
await case.session.stop()
|
||||
case.session.state = state
|
||||
case.session.send({"command": "telemetry", "payload": {}})
|
||||
await asyncio.sleep(0)
|
||||
assert case.connection.sent == []
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_background_send_does_not_block_processing_data(make_session, timing, monkeypatch):
|
||||
"""Проверяет обработку новых данных, пока фоновая сетевая отправка ожидает завершения."""
|
||||
async def scenario():
|
||||
async with timing:
|
||||
case = make_session()
|
||||
case.session.state = "air"
|
||||
started, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||
original_send = case.connection.send
|
||||
|
||||
async def pending_send(packet):
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
await original_send(packet)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(case.connection, "send", pending_send)
|
||||
packet = {"command": "telemetry", "payload": {"battery": 87}}
|
||||
try:
|
||||
case.session.send(packet)
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
assert case.connection.sent == []
|
||||
event = await asyncio.wait_for(case.session.feed({
|
||||
"command": "status", "payload": {"ready": True},
|
||||
}), 1)
|
||||
assert event == {"type": "data", "data": "status"}
|
||||
assert case.session.last_data == {"status": {"ready": True}}
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.wait_for(finished.wait(), 1)
|
||||
assert case.connection.sent == [packet]
|
||||
run(scenario)
|
||||
|
||||
@@ -191,3 +191,66 @@ def test_old_cleanup_cannot_delete_recovered_session(make_manager, timing):
|
||||
assert restored.last_data == {"telemetry": {"battery": 87}}
|
||||
assert new_uid in case.router.routes
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_close_after_record_was_deleted_is_safe(make_manager, timing):
|
||||
"""Проверяет повторный close после истечения окна и удаления UID."""
|
||||
async def scenario():
|
||||
async with timing:
|
||||
case = make_manager()
|
||||
uid, connection = await new_session(case)
|
||||
task = timing.spawn(case.manager.close(uid))
|
||||
request = await timing.sleeping("manager")
|
||||
timing.clock.advance(60)
|
||||
await finish_wait(request)
|
||||
await task
|
||||
await asyncio.wait_for(case.manager.close(uid), 1)
|
||||
assert connection.close_count == 1
|
||||
assert case.router.removed == [uid]
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_transport_close_error_does_not_prevent_expiry(make_manager, timing):
|
||||
"""Проверяет очистку сессии/маршрутов при ConnectionResetError из writer.wait_closed."""
|
||||
from transport.tcp.connection import Connection
|
||||
|
||||
async def scenario():
|
||||
async with timing:
|
||||
case = make_manager()
|
||||
failure = ConnectionResetError("close regression")
|
||||
|
||||
class BrokenWriter:
|
||||
close_count = 0
|
||||
|
||||
def close(self):
|
||||
self.close_count += 1
|
||||
|
||||
async def wait_closed(self):
|
||||
raise failure
|
||||
|
||||
writer = BrokenWriter()
|
||||
connection = Connection(None, writer)
|
||||
uid = await case.manager.new(connection)
|
||||
case.manager[uid].state = "air"
|
||||
case.router.add(uid)
|
||||
task = asyncio.create_task(case.manager.close(uid))
|
||||
try:
|
||||
request = await timing.sleeping("manager")
|
||||
assert request.delay == 60
|
||||
assert case.manager[uid].state == "stop"
|
||||
timing.clock.advance(60)
|
||||
try:
|
||||
await finish_wait(request)
|
||||
except ConnectionResetError as error:
|
||||
assert error is failure
|
||||
result = (await asyncio.gather(task, return_exceptions=True))[0]
|
||||
assert result is None or result is failure
|
||||
assert uid not in case.manager.sessions
|
||||
assert uid not in case.router.routes
|
||||
assert writer.close_count == 1
|
||||
assert case.router.removed == [uid]
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
run(scenario)
|
||||
|
||||
@@ -7,7 +7,9 @@ from pathlib import Path
|
||||
def test_all_python_sources_compile():
|
||||
"""Проверяет синтаксис всех серверных модулей и клиента без запуска и записи pyc."""
|
||||
errors = []
|
||||
for path in sorted((Path(__file__).parents[1] / "src").rglob("*.py")):
|
||||
root = Path(__file__).parents[1]
|
||||
paths = list((root / "src").rglob("*.py")) + list((root / "examples").rglob("*.py"))
|
||||
for path in sorted(paths):
|
||||
try:
|
||||
compile(path.read_text(encoding="utf-8"), str(path), "exec")
|
||||
except SyntaxError as error:
|
||||
|
||||
396
tests/test_transport_boundary.py
Normal file
396
tests/test_transport_boundary.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Настоящий TCP: некорректные пакеты, фрейминг и фоновые отправки."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_client_run import RunningServer
|
||||
|
||||
|
||||
def run(scenario):
|
||||
asyncio.run(asyncio.wait_for(scenario(), 5))
|
||||
|
||||
|
||||
async def open_at_state(server, state="new", connection_payload=None):
|
||||
count = len(server.records)
|
||||
client = server.client_module.Client(port=server.port, timeout=1)
|
||||
server.clients.append(client)
|
||||
client.reader, client.writer = await asyncio.open_connection("127.0.0.1", server.port)
|
||||
async with asyncio.timeout(1):
|
||||
while len(server.records) == count:
|
||||
await asyncio.sleep(0)
|
||||
session = server.records[-1]
|
||||
if state in {"token", "ready", "air"}:
|
||||
await client.send("connection", connection_payload if connection_payload is not None else {
|
||||
"name": "raw-client", "type": "test", "in": [], "out": [],
|
||||
})
|
||||
token = await client.recv()
|
||||
assert token["command"] == "token_ask"
|
||||
client.uid, client.upf = token["payload"], token["upf"]
|
||||
if state in {"ready", "air"}:
|
||||
await client.send("token_answer", {"token": None, "upf": None})
|
||||
assert await client.recv() == {"command": "handshake_ask"}
|
||||
if state == "air":
|
||||
await client.send("handshake_answer", {"status": True})
|
||||
await client.heartbeat()
|
||||
assert session.state == state
|
||||
return client, session
|
||||
|
||||
|
||||
async def assert_bad_peer_isolated(
|
||||
server, bad, session, healthy, timing, rejection_type=None,
|
||||
rejection_key=None, allowed_responses=(), background_start=None,
|
||||
):
|
||||
handler = server.handlers[-1]
|
||||
raw = await asyncio.wait_for(bad.reader.read(), 1)
|
||||
responses = [json.loads(line) for line in raw.splitlines()]
|
||||
assert responses == list(allowed_responses[:len(responses)])
|
||||
assert session.state == "stop"
|
||||
assert session.uid in server.sessions.sessions
|
||||
if background_start is not None:
|
||||
# token_answer queues handshake_ask before inspecting its payload.
|
||||
# Closing this peer may make that already queued send fail normally.
|
||||
tasks = timing.tasks[background_start:]
|
||||
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 1)
|
||||
for task, result in zip(tasks, results):
|
||||
if isinstance(result, BaseException):
|
||||
assert isinstance(result, ConnectionError)
|
||||
timing.accept_task_error(task, result)
|
||||
await healthy.telemetry(50, 90)
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
assert server.sessions[healthy.uid].last_data["telemetry"] == {"temperature": 50, "battery": 90}
|
||||
cleanup = await timing.sleeping("manager")
|
||||
assert cleanup.delay == 60
|
||||
timing.clock.advance(60)
|
||||
cleanup.release()
|
||||
results = await asyncio.wait_for(asyncio.gather(
|
||||
cleanup.task, handler, return_exceptions=True,
|
||||
), 1)
|
||||
errors = [result for result in results if isinstance(result, BaseException)]
|
||||
errors.extend(server.peer_errors.get(handler, []))
|
||||
for error in errors:
|
||||
assert rejection_type is not None and isinstance(error, rejection_type)
|
||||
if rejection_key is not None:
|
||||
assert error.args == (rejection_key,)
|
||||
server.accept_peer_rejection(handler, error)
|
||||
assert session.uid not in server.sessions.sessions
|
||||
assert session.uid not in server.router.session_routes
|
||||
assert all(session.uid not in routes for routes in server.router.session_routes.values())
|
||||
server.assert_no_unexpected_errors()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,repetitions,rejection_type", [
|
||||
(b"{broken\n", 1, json.JSONDecodeError),
|
||||
(b"null\n", 1, AttributeError),
|
||||
(b"[]\n", 1, AttributeError),
|
||||
(b"{}\n", 1, None),
|
||||
(b'{"command":[],"payload":{}}\n', 5, None),
|
||||
], ids=["broken-json", "null", "array", "missing-command", "command-error-limit"])
|
||||
@pytest.mark.usefixtures("make_manager")
|
||||
def test_invalid_packet_disconnects_only_its_peer(client_module, timing, raw, repetitions, rejection_type):
|
||||
"""Проверяет изоляцию и очистку плохого пакета; неверная команда достигает лимита ошибок."""
|
||||
async def scenario():
|
||||
async with timing, RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
bad, session = await open_at_state(server)
|
||||
bad.writer.write(raw * repetitions)
|
||||
await bad.writer.drain()
|
||||
await assert_bad_peer_isolated(server, bad, session, healthy, timing, rejection_type)
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state,command", [("new", "connection"), ("token", "token_answer"),
|
||||
("ready", "handshake_answer")])
|
||||
@pytest.mark.parametrize("payload_case", ["missing", "null", "array", "string", "number", "boolean"])
|
||||
@pytest.mark.usefixtures("make_manager")
|
||||
def test_invalid_control_payload_disconnects_only_its_peer(client_module, timing, state, command, payload_case):
|
||||
"""Проверяет отключение и очистку, когда управляющий payload нельзя разобрать как объект."""
|
||||
async def scenario():
|
||||
async with timing, RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
bad, session = await open_at_state(server, state)
|
||||
packet = {"command": command}
|
||||
if payload_case != "missing":
|
||||
packet["payload"] = {"null": None, "array": [], "string": "bad", "number": 1, "boolean": True}[payload_case]
|
||||
background_start = len(timing.tasks)
|
||||
bad.writer.write((json.dumps(packet) + "\n").encode())
|
||||
await bad.writer.drain()
|
||||
await assert_bad_peer_isolated(
|
||||
server, bad, session, healthy, timing, AttributeError,
|
||||
allowed_responses=({"command": "handshake_ask"},) if state == "token" else (),
|
||||
background_start=background_start,
|
||||
)
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [
|
||||
pytest.param(True, id="true"),
|
||||
pytest.param(False, id="false"),
|
||||
])
|
||||
@pytest.mark.usefixtures("make_manager")
|
||||
def test_handshake_status_follows_protocol(client_module, timing, status):
|
||||
"""Проверяет True/Other как успех и False как ожидание повторного handshake; Other — 0."""
|
||||
async def scenario():
|
||||
async with timing, RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
client, session = await open_at_state(server, "ready")
|
||||
await client.send("handshake_answer", {"status": status})
|
||||
if status is False:
|
||||
retry = await timing.sleeping("session", server.handlers[-1])
|
||||
assert session.state == "ready"
|
||||
assert session.uid not in server.router.session_routes
|
||||
assert retry.delay > 0
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
retry.release()
|
||||
assert await client.recv() == {"command": "handshake_ask"}
|
||||
assert session.state == "ready"
|
||||
await client.send("handshake_answer", {"status": True})
|
||||
assert await client.heartbeat() == {"command": "heartbeat_answer"}
|
||||
assert session.state == "air"
|
||||
assert session.uid in server.router.session_routes
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_case", ["missing", "null", "robot-token"])
|
||||
def test_connection_preserves_robot_auth_token(client_module, token_case):
|
||||
"""Проверяет хранение auth без whitelist-проверки и независимо от UID/upf восстановления."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
payload = {"name": "auth-client", "type": "test", "in": [], "out": []}
|
||||
expected = "robot-auth-token" if token_case == "robot-token" else None
|
||||
if token_case != "missing":
|
||||
payload["token"] = expected
|
||||
client, session = await open_at_state(server, "air", payload)
|
||||
assert session.static_meta["auth"] == expected
|
||||
assert client.uid == session.uid and client.upf == session.upf
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, [], "unused", 1, True])
|
||||
def test_heartbeat_does_not_require_an_object_payload(client_module, payload):
|
||||
"""Проверяет ответ и продление heartbeat, который не использует содержимое payload."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
client = await server.client("heartbeat-payload")
|
||||
healthy = await server.client("healthy")
|
||||
session = server.sessions[client.uid]
|
||||
deadline = session.timeout
|
||||
await client.send("heartbeat_ask", payload)
|
||||
assert await client.recv() == {"command": "heartbeat_answer"}
|
||||
assert session.state == "air" and session.timeout >= deadline
|
||||
assert session.last_data == {}
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("make_manager")
|
||||
def test_missing_heartbeat_payload_is_isolated_or_accepted(client_module, timing):
|
||||
"""Проверяет heartbeat без payload: обработку либо локальное отключение с очисткой."""
|
||||
async def scenario():
|
||||
async with timing, RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
client, session = await open_at_state(server, "air")
|
||||
client.writer.write(b'{"command":"heartbeat_ask"}\n')
|
||||
await client.writer.drain()
|
||||
raw = await asyncio.wait_for(client.reader.readline(), 1)
|
||||
if raw:
|
||||
assert json.loads(raw) == {"command": "heartbeat_answer"}
|
||||
assert session.state == "air"
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
else:
|
||||
await assert_bad_peer_isolated(
|
||||
server, client, session, healthy, timing, KeyError, "payload",
|
||||
)
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, [1, {"ok": True}], "telemetry", 42, False])
|
||||
def test_data_payload_is_forwarded_without_dictionary_conversion(client_module, payload):
|
||||
"""Проверяет сохранение и маршрутизацию JSON-значений payload без преобразования в словарь."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
sender = await server.client("sender")
|
||||
receiver = await server.client("receiver")
|
||||
server.router.session_routes[sender.uid].add(receiver.uid)
|
||||
await sender.send("telemetry", payload)
|
||||
await sender.heartbeat()
|
||||
assert await receiver.recv() == {"command": "telemetry", "payload": payload}
|
||||
assert server.sessions[sender.uid].last_data == {"telemetry": payload}
|
||||
assert await receiver.heartbeat() == {"command": "heartbeat_answer"}
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_json_packet_split_between_tcp_writes(client_module):
|
||||
"""Проверяет сборку одного JSON-пакета из нескольких записей до завершающего LF."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
client = await server.client("fragmented")
|
||||
session = server.sessions[client.uid]
|
||||
data = b'{"command":"telemetry","payload":{"battery":42}}\n'
|
||||
for fragment in (data[:8], data[8:30], data[30:-1]):
|
||||
client.writer.write(fragment)
|
||||
await client.writer.drain()
|
||||
# A response to a second client proves the loop had a chance to read fragments.
|
||||
peer = await server.client("peer")
|
||||
await peer.heartbeat()
|
||||
assert "telemetry" not in session.last_data
|
||||
client.writer.write(data[-1:])
|
||||
await client.writer.drain()
|
||||
await client.heartbeat()
|
||||
assert session.last_data["telemetry"] == {"battery": 42}
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_several_json_packets_in_one_tcp_write(client_module):
|
||||
"""Проверяет порядок нескольких JSON-строк, отправленных одной TCP-записью."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
client = await server.client("coalesced")
|
||||
packets = [
|
||||
{"command": "telemetry", "payload": {"battery": 43}},
|
||||
{"command": "status", "payload": {"ok": True}},
|
||||
{"command": "telemetry", "payload": {"battery": 42}},
|
||||
{"command": "heartbeat_ask", "payload": {}},
|
||||
]
|
||||
client.writer.write(b"".join((json.dumps(p) + "\n").encode() for p in packets))
|
||||
await client.writer.drain()
|
||||
assert await client.recv() == {"command": "heartbeat_answer"}
|
||||
assert server.sessions[client.uid].last_data == {"telemetry": {"battery": 42}, "status": {"ok": True}}
|
||||
run(scenario)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("make_manager")
|
||||
def test_watcher_and_tcp_finally_schedule_cleanup_once(client_module, timing, monkeypatch):
|
||||
"""Проверяет гонку watcher/EOF: одно закрытие транспорта и один таймер очистки."""
|
||||
async def scenario():
|
||||
async with timing, RunningServer(client_module) as server:
|
||||
client = await server.client("racing")
|
||||
session = server.sessions[client.uid]
|
||||
calls, removed = [], []
|
||||
close_requests = []
|
||||
close_started, finish_close = asyncio.Event(), asyncio.Event()
|
||||
original_close = session.connection.close
|
||||
original_remove = server.router.remove
|
||||
original_manager_close = server.sessions.close
|
||||
|
||||
async def counted_close():
|
||||
calls.append(session.uid)
|
||||
close_started.set()
|
||||
await finish_close.wait()
|
||||
await original_close()
|
||||
|
||||
async def observed_manager_close(uid):
|
||||
close_requests.append(uid)
|
||||
await original_manager_close(uid)
|
||||
|
||||
def counted_remove(uid):
|
||||
removed.append(uid)
|
||||
original_remove(uid)
|
||||
|
||||
monkeypatch.setattr(session.connection, "close", counted_close)
|
||||
monkeypatch.setattr(server.router, "remove", counted_remove)
|
||||
monkeypatch.setattr(server.sessions, "close", observed_manager_close)
|
||||
poll = await timing.sleeping("session", session.watcher_task)
|
||||
timing.clock.advance(session.timeout - timing.clock())
|
||||
poll.release()
|
||||
try:
|
||||
await asyncio.wait_for(close_started.wait(), 1)
|
||||
# Hold the watcher's close open while the real handler reaches finally.
|
||||
client.writer.write_eof()
|
||||
await asyncio.wait_for(server.handlers[0], 1)
|
||||
assert close_requests == [session.uid, session.uid]
|
||||
finally:
|
||||
finish_close.set()
|
||||
assert await asyncio.wait_for(client.reader.read(), 1) == b""
|
||||
cleanup = await timing.sleeping("manager")
|
||||
assert calls == [session.uid]
|
||||
await asyncio.sleep(0)
|
||||
assert timing.queues["manager"].empty()
|
||||
timing.clock.advance(60)
|
||||
cleanup.release()
|
||||
await asyncio.wait_for(cleanup.task, 1)
|
||||
await asyncio.wait_for(asyncio.gather(session.watcher_task, *server.handlers), 1)
|
||||
assert removed == [session.uid]
|
||||
assert session.uid not in server.sessions.sessions
|
||||
run(scenario)
|
||||
|
||||
|
||||
async def assert_send_error_observed(caplog, failure):
|
||||
async with asyncio.timeout(1):
|
||||
while not any(record.exc_info and record.exc_info[1] is failure for record in caplog.records):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def test_background_send_failure_is_observable_and_isolated(client_module, monkeypatch, caplog):
|
||||
"""Проверяет видимость фонового ConnectionResetError, закрытие по EOF и работу другого клиента."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
bad = await server.client("broken-send")
|
||||
session = server.sessions[bad.uid]
|
||||
failure = ConnectionResetError("send regression")
|
||||
raised = asyncio.Event()
|
||||
|
||||
async def failing_send(packet):
|
||||
raised.set()
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(session.connection, "send", failing_send)
|
||||
session.send({"command": "telemetry", "payload": {"battery": 87}})
|
||||
await asyncio.wait_for(raised.wait(), 1)
|
||||
await assert_send_error_observed(caplog, failure)
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
# A send exception in its own task need not close the reader task.
|
||||
# The actual TCP EOF must still close the session normally.
|
||||
await server.disconnect(bad)
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
run(scenario)
|
||||
|
||||
|
||||
def test_disconnect_while_background_send_is_waiting(client_module, monkeypatch, caplog):
|
||||
"""Проверяет разрыв во время фоновой отправки и наблюдаемость ошибки без повторного close."""
|
||||
async def scenario():
|
||||
async with RunningServer(client_module) as server:
|
||||
healthy = await server.client("healthy")
|
||||
client = await server.client("pending-send")
|
||||
session = server.sessions[client.uid]
|
||||
started, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||
original_send = session.connection.send
|
||||
original_close = session.connection.close
|
||||
closes = []
|
||||
failures = []
|
||||
|
||||
async def pending_send(packet):
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
await original_send(packet)
|
||||
except ConnectionError as error:
|
||||
failures.append(error)
|
||||
raise
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
async def counted_close():
|
||||
closes.append(session.uid)
|
||||
await original_close()
|
||||
|
||||
monkeypatch.setattr(session.connection, "send", pending_send)
|
||||
monkeypatch.setattr(session.connection, "close", counted_close)
|
||||
session.send({"command": "telemetry", "payload": {}})
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
await server.disconnect(client)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.wait_for(finished.wait(), 1)
|
||||
await asyncio.sleep(0)
|
||||
assert closes == [session.uid]
|
||||
assert await healthy.heartbeat() == {"command": "heartbeat_answer"}
|
||||
for failure in failures:
|
||||
await assert_send_error_observed(caplog, failure)
|
||||
run(scenario)
|
||||
Reference in New Issue
Block a user