Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/modules/meeting/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,14 @@ async def get_room(
@router.get(
"/{room_code}/participants",
status_code=status.HTTP_200_OK,
summary="Get live active participants and lobby waiting list (Host only)",
summary="Get live active participants and lobby waiting list",
)
async def get_live_state(
room_code: str,
current_user: User = Depends(get_current_user),
service: MeetingService = Depends(get_meeting_service),
) -> JSONResponse:
state = await service.get_live_state(host=current_user, room_code=room_code)
state = await service.get_live_state(user=current_user, room_code=room_code)
return JSONResponse(
content={
"status": "success",
Expand Down
16 changes: 9 additions & 7 deletions app/modules/meeting/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,16 @@ async def get_room_details(self, room_code: str) -> Room:

return room

async def get_live_state(self, host: User, room_code: str) -> dict:
"""Fetch active participant and waiting lobby details. Host only."""
async def get_live_state(self, user: User, room_code: str) -> dict:
"""Fetch active participant and waiting lobby details."""
room = self.repo.get_room_by_code(room_code)
if not room:
raise NotFoundException(message="Room not found.")

if room.host_id != host.id:
raise ForbiddenException(
message="Only the host can view live room state payload."
)
is_host = room.host_id == user.id

active = await self.state.get_participants(room_code)
lobby = await self.state.get_lobby(room_code)
lobby = await self.state.get_lobby(room_code) if is_host else {}

return {"active": active, "lobby": lobby}

Expand Down Expand Up @@ -507,6 +504,11 @@ async def admit_user(self, host: User, room_code: str, target_user_id: str) -> N
if not was_in_lobby:
raise BadRequestException(message="User is not in the lobby.")

cm = get_connection_manager()
await cm.send_to_user(
room_code, target_user_id, {"type": "admitted", "room_code": room_code}
)
Comment on lines +507 to +510
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate ConnectionManager and inspect send_to_user signature + how user connections are keyed.
fd -t f 'connection_manager*.py' app/services
rg -nP -C3 '\bdef\s+send_to_user\s*\(' --type=py
rg -nP -C3 '\bclass\s+ConnectionManager\b' --type=py
# Look at how users are registered (to confirm the key used by send_to_user)
rg -nP -C3 '\b(connect|register|add_user)\s*\(' app/services/connection_manager.py 2>/dev/null || \
  rg -nP -C3 '\b(connect|register|add_user)\s*\('  --type=py -g 'connection_manager*'

Repository: Brints/spoken-api

Length of output: 1427


🏁 Script executed:

# Get full implementation of send_to_user method
sed -n '94,130p' app/services/connection_manager.py

Repository: Brints/spoken-api

Length of output: 1458


🏁 Script executed:

# Check how connections are stored (look for self._* data structures)
rg -nP 'self\._.*=' app/services/connection_manager.py | head -20

Repository: Brints/spoken-api

Length of output: 174


🏁 Script executed:

# Look at the admission flow context around line 502 in service.py
sed -n '495,520p' app/modules/meeting/service.py

Repository: Brints/spoken-api

Length of output: 1155


🏁 Script executed:

# Check if there's any error handling around the send_to_user call
rg -nP -A10 'admit_from_lobby' app/modules/meeting/service.py | grep -A10 'send_to_user'

Repository: Brints/spoken-api

Length of output: 273


🏁 Script executed:

# Look at the _listen_to_redis implementation to see how unicast messages are routed
sed -n '140,200p' app/services/connection_manager.py

Repository: Brints/spoken-api

Length of output: 2459


🏁 Script executed:

# Search for where unicast messages are handled specifically
rg -nP -A15 'unicast' app/services/connection_manager.py

Repository: Brints/spoken-api

Length of output: 1729


🏁 Script executed:

# Check how WebSocket connections are stored/indexed to understand keying
sed -n '30,70p' app/services/connection_manager.py

Repository: Brints/spoken-api

Length of output: 1739


Verify ConnectionManager.send_to_user signature and connection lookup semantics.

The new payload correctly enables client routing per the PR objective. The implementation confirms:

  1. Signature match: send_to_user(room_code: str, target_user_id: str, message: dict) is correctly called with positional arguments in the right order.

  2. Connection keying: Connections are consistently keyed by user_id in active_connections[room_code][user_id]. The listener retrieves the target WebSocket using the same key. However, if the admitted user's WebSocket is not yet established or has disconnected, the unicast message will silently drop with only a log warning—no error is propagated back to the caller.

  3. State mutation risk: The user is admitted in state on line 502 before the send attempt on lines 508–510. If the send fails (or silently drops), the user is still marked admitted in Redis but never notified. Consider wrapping the send in a try/catch and logging the error, or implement a notification retry mechanism so the user can reconnect and receive the admitted signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/modules/meeting/service.py` around lines 507 - 510, The admitted user is
marked in state before attempting ConnectionManager.send_to_user, which can
silently drop if the target WebSocket isn't present; update the flow around
get_connection_manager().send_to_user to handle failures: call send_to_user
inside a try/except, log detailed errors from the exception, and if delivery
fails either revert the admission state in Redis (undo the admit mutation) or
enqueue a notification retry so the admitted user will receive the signal when
they reconnect; reference ConnectionManager.send_to_user,
get_connection_manager, and the active_connections lookup to implement the error
handling/retry or rollback logic.


async def end_room(self, host: User, room_code: str) -> Room:
"""Host forcibly ends the meeting for everyone."""
room = self.repo.get_room_by_code(room_code)
Expand Down
7 changes: 5 additions & 2 deletions tests/meeting/test_meeting_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ async def test_host_gets_live_state(
assert "lobby" in body["data"]

@pytest.mark.asyncio
async def test_non_host_cannot_get_live_state(
async def test_non_host_gets_live_state_without_lobby(
self, client: httpx.AsyncClient, db_session: Session
) -> None:
_seed_user(db_session, email="host@example.com")
Expand All @@ -729,7 +729,10 @@ async def test_non_host_cannot_get_live_state(
f"/api/v1/meetings/{room_code}/participants",
headers=_auth_headers(other_token),
)
assert resp.status_code == 403
assert resp.status_code == 200
body = resp.json()
assert "active" in body["data"]
assert body["data"]["lobby"] == {}


# ---------------------------------------------------------------------------
Expand Down
18 changes: 11 additions & 7 deletions tests/meeting/test_meeting_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,24 +238,28 @@ async def test_returns_active_and_lobby(self) -> None:
state.get_participants.return_value = {"u1": {"status": "connected"}}
state.get_lobby.return_value = {"u2": {"display_name": "Guest"}}

result = await svc.get_live_state(host=host, room_code="ABCDEF123456")
result = await svc.get_live_state(user=host, room_code="ABCDEF123456")

assert "active" in result
assert "lobby" in result
assert len(result["active"]) == 1
assert len(result["lobby"]) == 1

@pytest.mark.asyncio
async def test_raises_forbidden_for_non_host(self) -> None:
svc, repo, _state = _build_service()
async def test_non_host_receives_empty_lobby(self) -> None:
svc, repo, state = _build_service()
host = _make_user()
other_user = _make_user(email="other@example.com")
room = _make_room(host_id=host.id)

repo.get_room_by_code.return_value = room
state.get_participants.return_value = {"u1": {"status": "connected"}}
state.get_lobby.return_value = {"u2": {"display_name": "Guest"}}

with pytest.raises(ForbiddenException, match="Only the host"):
await svc.get_live_state(host=other_user, room_code="ABCDEF123456")
result = await svc.get_live_state(user=other_user, room_code="ABCDEF123456")

assert "active" in result
assert result["lobby"] == {}
assert len(result["active"]) == 1

@pytest.mark.asyncio
async def test_raises_not_found_for_missing_room(self) -> None:
Expand All @@ -264,7 +268,7 @@ async def test_raises_not_found_for_missing_room(self) -> None:
repo.get_room_by_code.return_value = None

with pytest.raises(NotFoundException):
await svc.get_live_state(host=host, room_code="INVALID")
await svc.get_live_state(user=host, room_code="INVALID")


# ---------------------------------------------------------------------------
Expand Down
Loading