Skip to content

fix(grpc): LookupSessionId race condition causes cross-node session lookup to fail #1261

Description

@oboeglen

Bug Description

When running nextcloud-spreed-signaling in a multi-node cluster, participants connected to different signaling nodes cannot see each other in a Talk video call. Both participants remain stuck on "Waiting for others to join the call" indefinitely.

Root Cause

There is a race condition in grpc/server.goLookupSessionId performs a single, one-shot lookup with no retry mechanism.

When node A queries node B for a room session ID, the lookup can arrive during the brief window between RegisterRoomListener and SetRoomSession inside SubscribeRoomEvents on node B. At that moment the session exists logically but is not yet registered in roomSessionToSessionid, so node B responds codes.NotFound immediately.

Timeline of the race:

Node A: LookupSessionId(roomSessionId=X) ──────────────────────┐
                                                                 ↓
Node B: RegisterRoomListener(roomId, session)    ← session not yet in map
Node B: GetSessionIdByRoomSessionId(X)           → ErrNoSuchRoomSession
Node B: returns codes.NotFound                   ← too early!
Node B: SetRoomSession(session, X)               ← session registered (too late)

Node A retries the lookup (from roomsessions_builtin.go) but since all retries hit node B after the same pattern (each retry triggers a fresh gRPC call that resolves instantly), it never catches the brief window after SetRoomSession completes.

Secondary Bug

errors.Is(err, ErrNoSuchRoomSession) in the server handler compares server.ErrNoSuchRoomSession (returned by hub.GetSessionIdByRoomSessionId) against grpc.ErrNoSuchRoomSession (defined in grpc/client.go). These are two distinct sentinel errors created with errors.New(), so errors.Is always returns false. The handler falls through to return nil, err, causing gRPC to wrap it as codes.Unknown instead of codes.NotFound.

Proposed Fix

Add a short retry loop (10 × 50ms = 500ms max) in the gRPC server handler before returning NotFound. This covers the registration window without meaningfully impacting performance for genuine not-found sessions.

grpc/server.go:

func (s *Server) LookupSessionId(ctx context.Context, request *LookupSessionIdRequest) (*LookupSessionIdReply, error) {
    statsGrpcServerCalls.WithLabelValues("LookupSessionId").Inc()
    s.logger.Printf("Lookup session id for room session id %s", request.RoomSessionId)

    // Retry with short delays to handle the race condition where a session is
    // concurrently being registered on this node (gap between RegisterRoomListener
    // and SetRoomSession in SubscribeRoomEvents). Without retries, the lookup
    // returns "not found" during the brief registration window, causing cross-node
    // call participants to never see each other.
    const maxRetries = 10
    const retryInterval = 50 * time.Millisecond
    var sid api.PublicSessionId
    var err error
    for i := 0; i < maxRetries; i++ {
        sid, err = s.hub.GetSessionIdByRoomSessionId(api.RoomSessionId(request.RoomSessionId))
        if err == nil {
            break
        }
        if i < maxRetries-1 {
            select {
            case <-ctx.Done():
                return nil, status.Error(codes.Canceled, "context canceled")
            case <-time.After(retryInterval):
            }
        }
    }

    if err != nil {
        return nil, status.Error(codes.NotFound, "no such room session id")
    }

    if sid != "" && request.DisconnectReason != "" {
        s.hub.DisconnectSessionByRoomSessionId(sid, api.RoomSessionId(request.RoomSessionId), request.DisconnectReason)
    }
    return &LookupSessionIdReply{
        SessionId: string(sid),
    }, nil
}

Also add "time" to the import block and remove the now-unused "errors" import.

How to Reproduce

  1. Run 3 signaling nodes behind a load balancer (balance leastconn)
  2. Connect participant A to node 1, participant B to node 2 (different source IPs / no sticky sessions)
  3. Both participants join the same Talk room and start a call
  4. Both see "Waiting for others to join the call" — they never see each other

Log evidence on the receiving node (node B):

server.go:160: Lookup session id for room session id <X>
# → returns codes.Unknown desc = "unknown room session id" (no retry)

# Milliseconds later:
clientsession.go:520: Session <Y> joined room <roomId> with room session id <X>
# → now registered, but the lookup already failed

Environment

  • nextcloud-spreed-signaling v2.1.1 (also confirmed on master 9cecc92)
  • 3-node cluster, gRPC configured, nats://loopback
  • HAProxy balance leastconn (no source stickiness)
  • Nextcloud Talk v23.0.6

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions