generated from mononoki/pyproj
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""События менеджера, окно восстановления и гонки с отложенной очисткой."""
|
||
|
||
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)
|