refactor: общий вход в сокет занятия и выполнение команды с fencing вместо копий в четырёх каналах

This commit is contained in:
gglamer 2026-09-27 07:56:12 +00:00
commit 36a9862a94
12 changed files with 602 additions and 282 deletions

View file

@ -27,6 +27,7 @@ from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
from app.domain.timers import TimerCode
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.session.access import can_access
from app.session.checkpoint import load_state
from app.session.finish import override_score, scoring_scenario
from app.session.hub import hub
@ -328,16 +329,23 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
return _out(session)
def _require_access(who, lesson, what: str = "session") -> None:
"""Отказ по правилу `can_access`: курсанту — 403, чужому преподавателю
занятие «не существует» — 404, владелец не раскрывается."""
if can_access(who, lesson):
return
if who.role is Role.TRAINEE:
raise HTTPException(status_code=403, detail=f"not_your_{what}")
raise HTTPException(status_code=404, detail=f"{what}_not_found")
@router.get("/{session_id}", response_model=SessionOut)
async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
who = require(request)
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
return _out(session)
@ -358,10 +366,7 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, state)
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = scoring_scenario(state)
@ -417,10 +422,7 @@ async def _report_data(
state = None
scenario = None
if state is not None and scenario is not None:
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
_require_access(who, state)
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
@ -443,10 +445,7 @@ async def _report_data(
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
@ -516,20 +515,13 @@ async def recording(session_id: UUID, request: Request, db: AsyncSession = Depen
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = state.trainee_id
owner_login = state.owner_login
ended = state.ended
lesson, ended = state, state.ended
else:
row = await repo.get_session(db, session_id)
if row is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = row.trainee_id
owner_login = row.owner_login
ended = row.ended_at is not None
if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
raise HTTPException(status_code=403, detail="not_your_recording")
if who.role is Role.INSTRUCTOR and owner_login != who.login:
raise HTTPException(status_code=404, detail="recording_not_found")
lesson, ended = row, row.ended_at is not None
_require_access(who, lesson, "recording")
if not ended:
raise HTTPException(status_code=409, detail="recording_not_ready")
path = recording_path(session_id)
@ -562,8 +554,7 @@ async def override(
)
state = hub.get(session_id)
if state is not None:
if state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
@ -577,8 +568,9 @@ async def override(
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None or session.owner_login != who.login:
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")

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.auth import principal_of, websocket_origin_allowed
from app.api.ws.session import pump, run_command, session_socket
from app.domain.events import (
BgStart,
CallEnded,
@ -43,7 +43,7 @@ from app.domain.roles import Role
from app.scenarios import store
from app.session.dds import prepare_handoff_queue
from app.session.finish import end_session, refresh_archived_report, release_score
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.hub import hub
from app.session.state import now_utc
from app.session.store import (
HintRecorded,
@ -381,66 +381,16 @@ def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) ->
state.voice.speak(scenario.first_line, state.persona.mood)
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
item = await queue.get()
# Бинарь — звук звонящего, без обёртки JSON (docs/arch/CONTRACT.md).
if isinstance(item, bytes):
await ws.send_bytes(item)
else:
await ws.send_text(item.model_dump_json())
if (isinstance(item, ErrorEvent) and item.code is ErrorKind.INTERNAL
and item.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _reject(ws: WebSocket, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
)
await ws.close()
@router.websocket("/ws/call/{session_id}")
async def call(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
who = principal_of(ws)
if who is None or who.role not in (Role.TRAINEE, Role.INSTRUCTOR):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(
ErrorEvent(
code=ErrorKind.SESSION_NOT_FOUND, message="Занятие ещё не запущено преподавателем"
).model_dump_json()
)
await ws.close()
return
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(
code=ErrorKind.SESSION_NOT_FOUND, message="Занятие ещё не запущено преподавателем"
).model_dump_json()
)
await ws.close()
return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
await _reject(ws, "Занятие не назначено этому обучающемуся")
entered = await session_socket(
ws, session_id, (Role.TRAINEE, Role.INSTRUCTOR),
not_found="Занятие ещё не запущено преподавателем",
)
if entered is None:
return
_who, state = entered
with hub.trainee(session_id) as queue:
if state.exercise is Exercise.CARD:
@ -476,7 +426,7 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state, initial_statement=False)
writer = asyncio.create_task(_pump(ws, queue))
writer = asyncio.create_task(pump(ws, queue))
try:
while True:
message = await ws.receive()
@ -509,8 +459,12 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
async with hub.operation(session_id):
await _handle(session_id, state, event)
# `_command_id` у `kio.patch` подтверждается эхом правки, а не
# `CommandAck`: его нет в событиях курсанта, повторы не отсеиваются.
if not await run_command(
ws, session_id, lambda: _handle(session_id, state, event), sender=writer,
):
return
except WebSocketDisconnect:
return
finally:

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import principal_of, websocket_origin_allowed
from app.api.ws.session import close_fenced, run_command, session_socket
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.db.repo import SessionNodeConflict
@ -47,7 +47,8 @@ from app.domain.roles import Role
from app.domain.timers import TimerCode
from app.scenarios import store
from app.session.dds import prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.access import can_access
from app.session.hub import hub
from app.session.finish import end_session, override_score
from app.session.state import SessionState, now_utc
from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
@ -424,29 +425,14 @@ async def _command(session_id: UUID, event, who) -> None:
)
async def _reject(ws: WebSocket, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
)
await ws.close()
@router.websocket("/ws/control/{session_id}")
async def control(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# Пульт преподавателя: управление занятием доступно только ему.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR,):
await _reject(ws, "Недостаточно прав для этого экрана")
# Пульт преподавателя: управление занятием доступно только ему. Занятия
# до `scenario.start` ещё нет — доступ проверяется на каждой команде.
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR,), require_state=False)
if entered is None:
return
who, _state = entered
# Чтение живёт дольше тика опроса: wait_for отменял бы его, а отмена после
# того, как receive уже забрал кадр, теряет команду или websocket.disconnect.
# Команда обрабатывается здесь, одной операцией хранилища.
@ -458,10 +444,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
done, _ = await asyncio.wait({read}, timeout=_FENCE_POLL_SECONDS)
if not done:
if hub.is_lease_fenced(session_id):
await ws.send_text(ErrorEvent(
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
).model_dump_json())
await ws.close(code=1012)
await close_fenced(ws)
return
continue
payload, read = read.result(), None
@ -482,30 +465,16 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message="Для нового занятия создайте новый номер сессии",
))
continue
elif existing_state is None or existing_state.owner_login != who.login:
# Запуск пишется своей транзакцией в `store.open`: до неё занятия нет.
await _command(session_id, event, who)
continue
if existing_state is None or not can_access(who, existing_state):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Это занятие принадлежит другому преподавателю",
))
continue
if event.type == "scenario.start":
# Запуск пишется своей транзакцией в `store.open`: до неё занятия нет.
await _command(session_id, event, who)
continue
try:
async with hub.operation(session_id):
await _command(session_id, event, who)
except Exception:
if hub.is_lease_fenced(session_id):
await ws.send_text(ErrorEvent(
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
).model_dump_json())
await ws.close(code=1012)
return
raise
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
if not await run_command(ws, session_id, lambda: _command(session_id, event, who)):
return
except WebSocketDisconnect:
return

View file

@ -15,24 +15,13 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.domain.events import ErrorEvent, ErrorKind
from app.api.auth import principal_of, websocket_origin_allowed
from app.api.ws.session import pump, session_socket
from app.domain.roles import Role
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.hub import hub
router = APIRouter()
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
and event.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _wait_for_disconnect(ws: WebSocket) -> None:
"""Единственное назначение — дождаться разрыва. Содержимое кадров
не читается и никуда не передаётся."""
@ -42,54 +31,21 @@ async def _wait_for_disconnect(ws: WebSocket) -> None:
return
async def _reject(ws: WebSocket, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
)
await ws.close()
@router.websocket("/ws/observe/{session_id}")
async def observe(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# Наблюдение за чужим занятием — не для обучающегося.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR, Role.ADMIN):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
# Live state is process-local, so authorize against the owner snapshot on
# the state itself. Instructors may observe only their own sessions;
# administrators retain the cross-owner diagnostic view.
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
# Наблюдение за чужим занятием — не для обучающегося. Преподаватель видит
# только свои занятия, администратор — любые, для диагностики.
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.ADMIN))
if entered is None:
return
_who, state = entered
# Снимок при подключении обязателен: монитор в классе включают посреди
# занятия, и он должен показать текущее состояние, а не ждать событий.
await ws.send_text(state.snapshot().model_dump_json())
with hub.observer(session_id) as queue:
sender = asyncio.create_task(_pump(ws, queue))
sender = asyncio.create_task(pump(ws, queue))
try:
await _wait_for_disconnect(ws)
except WebSocketDisconnect:

View file

@ -0,0 +1,160 @@
"""Общее для сокетов занятия: вход, доставка очереди хаба и выполнение команды.
Лестница входа одна для всех каналов, чтобы коды закрытия и тексты отказов
не расходились: 1008 — чужой origin, 1012 — занятие ушло другому узлу ещё
до accept, `ErrorEvent` и close — нет прав, нет занятия, не назначено.
Команда выполняется одной операцией хаба: сверка `(node_id, epoch)` остаётся
в её commit перед каждой мутацией, здесь только место вызова.
"""
import asyncio
import logging
from collections.abc import Awaitable, Callable, Collection
from uuid import UUID
from fastapi import WebSocket
from pydantic import BaseModel
from app.api.auth import Principal, principal_of, websocket_origin_allowed
from app.domain.events import CommandAck, ErrorEvent, ErrorKind
from app.domain.roles import Role
from app.session.access import can_access
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import MAX_STATION_COMMANDS, SessionState
log = logging.getLogger(__name__)
NOT_STARTED = "Занятие не запущено"
FORBIDDEN = "Недостаточно прав для этого экрана"
NOT_ASSIGNED = "Занятие не назначено этому обучающемуся"
#: Сколько ждать, пока `pump` доставит событие fencing сам. Очередь могла
#: переполниться и выпасть из подписки — тогда сокет закрывается здесь.
_FENCE_DELIVERY_SECONDS = 2.0
def _fenced_event() -> ErrorEvent:
return ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
def _is_fenced_event(item) -> bool:
return (isinstance(item, ErrorEvent) and item.code is ErrorKind.INTERNAL
and item.message == LEASE_FENCED_MESSAGE)
async def _refuse(ws: WebSocket, code: ErrorKind, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(ErrorEvent(code=code, message=message).model_dump_json())
await ws.close()
async def session_socket(
ws: WebSocket,
session_id: UUID,
roles: Collection[Role],
*,
require_state: bool = True,
not_found: str = NOT_STARTED,
) -> tuple[Principal, SessionState | None] | None:
"""Принципал и живое занятие — или `None`, если сокет уже закрыт.
`require_state=False` — для пульта: занятие создаёт его же `scenario.start`,
поэтому вход занятие не смотрит, а доступ проверяется на каждой команде.
"""
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return None
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return None
await ws.accept()
who = principal_of(ws)
if who is None or who.role not in roles:
await _refuse(ws, ErrorKind.FORBIDDEN, FORBIDDEN)
return None
if not require_state:
return who, None
state = hub.get(session_id)
if state is None:
await _refuse(ws, ErrorKind.SESSION_NOT_FOUND, not_found)
return None
if not can_access(who, state):
# Чужому преподавателю занятие «не существует»: отказ не выдаёт владельца.
if who.role is Role.TRAINEE:
await _refuse(ws, ErrorKind.FORBIDDEN, NOT_ASSIGNED)
else:
await _refuse(ws, ErrorKind.SESSION_NOT_FOUND, not_found)
return None
return who, state
async def pump(ws: WebSocket, queue: asyncio.Queue) -> None:
"""Очередь хаба в сокет; событие fencing закрывает его с 1012."""
while True:
item = await queue.get()
# Бинарь — звук звонящего, без обёртки JSON (docs/arch/CONTRACT.md).
if isinstance(item, bytes):
await ws.send_bytes(item)
continue
await ws.send_text(item.model_dump_json())
if _is_fenced_event(item):
await ws.close(code=1012)
return
async def close_fenced(ws: WebSocket, sender: asyncio.Task | None = None) -> None:
"""Структурное событие fencing и 1012 вместо безымянного 1006.
С очередью событие уже разослал `hub.fence` — его доставляет `pump`.
Без очереди (пульт) сообщение уходит прямо в сокет.
"""
if sender is not None:
done, _ = await asyncio.wait({sender}, timeout=_FENCE_DELIVERY_SECONDS)
if done:
return
sender.cancel()
await ws.send_text(_fenced_event().model_dump_json())
await ws.close(code=1012)
async def run_command(
ws: WebSocket,
session_id: UUID,
handler: Callable[[], Awaitable[None]],
*,
command_id: UUID | None = None,
ack: Callable[[BaseModel], None] | None = None,
sender: asyncio.Task | None = None,
) -> bool:
"""Одна команда — одна операция хаба. `False` — сокет закрыт fencing.
С `command_id` повтор уже зафиксированной команды только подтверждается:
id входит в тот же снимок, что и переход, а `CommandAck` копится в
операции и уходит после её commit.
"""
state = hub.get(session_id)
if command_id is not None and state is not None:
if str(command_id) in state.processed_station_commands:
if ack is not None:
ack(CommandAck(command_id=command_id))
return True
try:
async with hub.operation(session_id):
await handler()
if command_id is not None and state is not None:
state.processed_station_commands.append(str(command_id))
del state.processed_station_commands[:-MAX_STATION_COMMANDS]
if ack is not None:
ack(CommandAck(command_id=command_id))
except Exception:
if not hub.is_lease_fenced(session_id):
raise
log.info("закрытие WebSocket после fencing занятия %s", session_id)
await close_fenced(ws, sender)
return False
if hub.is_lease_fenced(session_id):
await close_fenced(ws, sender)
return False
return True

View file

@ -7,21 +7,19 @@
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
в отчёте и становится сорванным выездом с конкретной причиной.
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
операция хранилища и рассылка итога.
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
Отсев повторов и операция хранилища — в `run_command`.
"""
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import principal_of, websocket_origin_allowed
from app.api.ws.session import pump, run_command, session_socket
from app.domain.events import (
CallEndReason,
CommandAck,
ErrorEvent,
ErrorKind,
StationState,
@ -29,9 +27,8 @@ from app.domain.events import (
)
from app.domain.roles import Role
from app.session.finish import end_session
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter()
_adapter = TypeAdapter(StationToServer)
@ -56,61 +53,16 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
and event.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _reject(ws: WebSocket, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
)
await ws.close()
@router.websocket("/ws/station/{session_id}")
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR, Role.TRAINEE):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
await _reject(ws, "Занятие не назначено этому обучающемуся")
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None:
return
_who, state = entered
with hub.station(session_id) as queue:
sender = asyncio.create_task(_pump(ws, queue))
sender = asyncio.create_task(pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется:
# диспетчер садится за АРМ, когда вызов уже идёт.
@ -137,32 +89,13 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
message="Некорректный идентификатор команды"),
)
continue
if command_id is not None and str(command_id) in state.processed_station_commands:
# The checkpoint already proves this exact command committed.
# Re-ack it without rerunning its business transition.
hub.to_station(session_id, CommandAck(command_id=command_id))
continue
async with hub.operation(session_id):
await _handle(session_id, state, event)
if command_id is not None:
# ID команды входит в тот же снимок, что и её переход:
# подтверждение уходит только после их общего commit.
state.processed_station_commands.append(str(command_id))
del state.processed_station_commands[:-512]
hub.to_station(session_id, CommandAck(command_id=command_id))
if not await run_command(
ws, session_id, lambda: _handle(session_id, state, event),
command_id=command_id, sender=sender,
ack=lambda item: hub.to_station(session_id, item),
):
return
except WebSocketDisconnect:
return
except Exception: # noqa: BLE001 — failed durable transition may fence the owner
if not state.lease_fenced:
raise
log.info(
"закрытие станционного WebSocket после fencing занятия %s",
session_id,
)
# `hub.operation` broadcasts a structured fence event before
# propagating the failed database write. Let the sender deliver
# that event and close with 1012 instead of an opaque 1006.
await asyncio.gather(sender, return_exceptions=True)
return
finally:
sender.cancel()