fix: учёт связи курсанта по сокетам и фазе ДДС

This commit is contained in:
kaifarikman 2026-09-27 18:14:51 +03:00
commit 57c3ff5a5c
10 changed files with 420 additions and 42 deletions

View file

@ -113,6 +113,7 @@ class ActiveSessionOut(BaseModel):
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
def _out(session) -> SessionOut:
@ -206,11 +207,8 @@ def _signals(
подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
соединения не сразу считался потерей связи курсанта.
`live=False` — снимок собран из checkpoint (сессию не держит хаб этого
процесса: рестарт до переподключения сокета или чужой узел в кластере).
Такой объект живёт один запрос и выбрасывается: писать в него
`socket_last_seen_at` бессмысленно — на следующем опросе присутствие
неизвестно снова. Честнее не утверждать «на связи», чем соврать.
`live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
присутствие неизвестно. Чтение реестра не меняет состояние занятия.
"""
settings = get_settings()
signals: list[Signal] = []
@ -226,16 +224,14 @@ def _signals(
))
if live:
connected = (
hub.station_connected(state.session_id) if state.exercise is Exercise.DDS
hub.station_connected(state.session_id) if state.dds_phase
else hub.trainee_connected(state.session_id)
)
if connected:
state.socket_last_seen_at = now
offline_seconds = (
(now - state.socket_last_seen_at).total_seconds()
if state.socket_last_seen_at is not None else 0
(now - (state.socket_last_seen_at or state.started_at)).total_seconds()
if (state.socket_last_seen_at or state.started_at) is not None else 0
)
if offline_seconds >= settings.signal_offline_window_seconds:
if not connected and offline_seconds >= settings.signal_offline_window_seconds:
signals.append(Signal(
kind="offline", severity="violated",
text=f"Курсант не на связи {int(offline_seconds)} с",
@ -256,9 +252,8 @@ async def active(
state.session_id: state
for state in hub.active_sessions(who.login)
}
#: Снимки без живой записи в хабе этого узла — рестарт до переподключения
#: сокета либо чужой узел в кластере. Присутствие сокета для них здесь
#: не проверяется: объект живёт один запрос, а не хаб (см. `_signals`).
#: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
#: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
checkpoint_only: set[UUID] = set()
if db is not None:
rows = (
@ -293,7 +288,7 @@ async def active(
for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds()))
if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else []
card = state.desk.active
status_log = card.status_log if card is not None else []
@ -327,6 +322,7 @@ async def active(
dds_statuses=latest_statuses,
dds_snapshot=station,
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
presence_known=state.session_id not in checkpoint_only,
))
return result

View file

@ -14,7 +14,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
BgStart,
CallEnded,
@ -390,9 +390,14 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
)
if entered is None:
return
_who, state = entered
who, state = entered
with hub.trainee(session_id) as queue:
async with hub.trainee_socket(
session_id, station=False, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing

View file

@ -104,6 +104,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
dds_service=identity.service or event.dds_service,
attempt=identity.attempt,
criteria=event.criteria,
socket_last_seen_at=now_utc(),
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
CallEndReason,
ErrorEvent,
@ -59,9 +59,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None:
return
_who, state = entered
who, state = entered
with hub.station(session_id) as queue:
async with hub.trainee_socket(
session_id, station=True, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
sender = asyncio.create_task(pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется: