Files
core/tests/test_session_manager.py
FoxVoland c16ed7915a [+!] all but small
some small modifications, tests added
2026-10-07 22:42:46 +03:00

257 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""События менеджера, окно восстановления и гонки с отложенной очисткой."""
import asyncio
from copy import deepcopy
import pytest
def run(scenario):
asyncio.run(asyncio.wait_for(scenario(), 3))
async def new_session(case, state="air"):
connection = case.connection()
uid = await case.manager.new(connection)
case.manager[uid].state = state
if state == "air":
case.router.add(uid)
return uid, connection
async def finish_wait(request, allow_cancel=False):
request.release()
try:
await asyncio.wait_for(request.task, 1)
except asyncio.CancelledError:
if not allow_cancel:
raise
def test_new_connections_have_distinct_live_uids(make_manager, timing):
"""Проверяет разные UID одновременно существующих соединений."""
async def scenario():
async with timing:
case = make_manager()
first, _ = await new_session(case)
second, _ = await new_session(case)
assert first != second
assert len(case.manager.sessions) == 2
run(scenario)
def test_connect_and_data_events_reach_router(make_manager, timing):
"""Проверяет регистрацию connect и передачу UID/команды data роутеру."""
async def scenario():
async with timing:
case = make_manager()
uid, _ = await new_session(case, "ready")
await case.manager.process(uid, {"type": "connect", "uid": uid})
assert uid in case.router.routes
await case.manager.process(uid, {"type": "data", "data": "telemetry"})
assert case.router.data == [(uid, "telemetry")]
run(scenario)
def test_disconnect_event_stops_transport_and_retains_record(make_manager, timing):
"""Проверяет немедленное закрытие транспорта по событию disconnect и начало окна восстановления."""
async def scenario():
async with timing:
case = make_manager()
uid, connection = await new_session(case)
task = timing.spawn(case.manager.process(uid, {"type": "disconnect"}))
await asyncio.sleep(0)
assert connection.close_count == 1, "Менеджер не обработал событие disconnect"
assert case.manager[uid].state == "stop"
assert uid in case.manager.sessions
request = await timing.sleeping("manager")
assert request.delay == 60
timing.clock.advance(60)
await finish_wait(request)
await asyncio.wait_for(task, 1)
# If cleanup is detached, wait for its observable effect, not an arbitrary pause.
async with asyncio.timeout(1):
while uid in case.manager.sessions:
await asyncio.sleep(0)
assert uid not in case.router.routes
run(scenario)
def test_close_preserves_session_until_recovery_window_expires(make_manager, timing):
"""Проверяет сохранение сессии на 60 секунд и удаление записи/маршрутов после окна."""
async def scenario():
async with timing:
case = make_manager()
uid, connection = await new_session(case)
case.manager[uid].last_data = {"telemetry": {"battery": 87}}
task = timing.spawn(case.manager.close(uid))
request = await timing.sleeping("manager")
assert request.delay == 60
assert connection.close_count == 1
assert case.manager[uid].state == "stop"
assert case.manager[uid].last_data == {"telemetry": {"battery": 87}}
assert uid in case.router.routes
timing.clock.advance(60)
await finish_wait(request)
await task
assert uid not in case.manager.sessions
assert uid not in case.router.routes
run(scenario)
@pytest.mark.parametrize("reason", ["active", "wrong_upf", "missing", "migrated"])
def test_ineligible_recovery_does_not_move_data(make_manager, timing, reason):
"""Проверяет отказ восстановления активной, отсутствующей, перенесённой сессии и неверного upf."""
async def scenario():
async with timing:
case = make_manager()
old_uid, _ = await new_session(case)
old = case.manager[old_uid]
old.state = "air" if reason == "active" else ("migrated" if reason == "migrated" else "stop")
old.last_data = {"telemetry": {"battery": 87}}
new_uid, _ = await new_session(case, "ready")
await case.manager.process(new_uid, {
"type": "reconnect",
"uid": -1 if reason == "missing" else old_uid,
"upf": old.upf + 1 if reason == "wrong_upf" else old.upf,
})
assert case.router.moves == []
assert case.manager[new_uid].last_data == {}
assert old.last_data == {"telemetry": {"battery": 87}}
assert case.manager[new_uid].state == "ready"
run(scenario)
def test_successful_recovery_preserves_data_and_is_consumed_once(make_manager, timing):
"""Проверяет перенос динамических данных ровно один раз и сохранение собственного handshake."""
async def scenario():
async with timing:
case = make_manager()
old_uid, _ = await new_session(case)
old = case.manager[old_uid]
old.state = "stop"
old.last_data = {"telemetry": {"battery": 87}}
old.dynamic_meta = {"active_in": {"control"}, "active_out": {"telemetry"}}
expected_data = deepcopy(old.last_data)
expected_meta = deepcopy(old.dynamic_meta)
new_uid, _ = await new_session(case, "ready")
event = {"type": "reconnect", "uid": old_uid, "upf": old.upf}
await case.manager.process(new_uid, event)
assert case.manager[new_uid].last_data == expected_data
assert case.manager[new_uid].dynamic_meta == expected_meta
assert case.manager[new_uid].state == "ready"
assert case.router.moves == [(old_uid, new_uid)]
repeated_uid, _ = await new_session(case, "ready")
await case.manager.process(repeated_uid, event)
assert case.manager[repeated_uid].last_data == {}
assert case.router.moves == [(old_uid, new_uid)]
assert case.manager[new_uid].last_data == expected_data
run(scenario)
def test_expired_session_cannot_be_recovered(make_manager, timing):
"""Проверяет отказ восстановления после удаления старой сессии по 60-секундному таймеру."""
async def scenario():
async with timing:
case = make_manager()
old_uid, _ = await new_session(case)
old_upf = case.manager[old_uid].upf
task = timing.spawn(case.manager.close(old_uid))
request = await timing.sleeping("manager")
timing.clock.advance(60)
await finish_wait(request)
await task
new_uid, _ = await new_session(case, "ready")
await case.manager.process(new_uid, {"type": "reconnect", "uid": old_uid, "upf": old_upf})
assert case.router.moves == []
assert case.manager[new_uid].last_data == {}
run(scenario)
def test_old_cleanup_cannot_delete_recovered_session(make_manager, timing):
"""Проверяет, что старый таймер после восстановления не удаляет новую запись и её данные."""
async def scenario():
async with timing:
case = make_manager()
old_uid, _ = await new_session(case)
old = case.manager[old_uid]
old.last_data = {"telemetry": {"battery": 87}}
cleanup = timing.spawn(case.manager.close(old_uid))
request = await timing.sleeping("manager")
new_uid, _ = await new_session(case, "ready")
restored = case.manager[new_uid]
await case.manager.process(new_uid, {"type": "reconnect", "uid": old_uid, "upf": old.upf})
assert restored.last_data == {"telemetry": {"battery": 87}}
timing.clock.advance(60)
await finish_wait(request, allow_cancel=True)
if not cleanup.cancelled():
await cleanup
assert case.manager[new_uid] is restored
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)