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.go — LookupSessionId 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
- Run 3 signaling nodes behind a load balancer (
balance leastconn)
- Connect participant A to node 1, participant B to node 2 (different source IPs / no sticky sessions)
- Both participants join the same Talk room and start a call
- 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
Bug Description
When running
nextcloud-spreed-signalingin 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.go—LookupSessionIdperforms 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
RegisterRoomListenerandSetRoomSessioninsideSubscribeRoomEventson node B. At that moment the session exists logically but is not yet registered inroomSessionToSessionid, so node B respondscodes.NotFoundimmediately.Timeline of the race:
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 afterSetRoomSessioncompletes.Secondary Bug
errors.Is(err, ErrNoSuchRoomSession)in the server handler comparesserver.ErrNoSuchRoomSession(returned byhub.GetSessionIdByRoomSessionId) againstgrpc.ErrNoSuchRoomSession(defined ingrpc/client.go). These are two distinct sentinel errors created witherrors.New(), soerrors.Isalways returnsfalse. The handler falls through toreturn nil, err, causing gRPC to wrap it ascodes.Unknowninstead ofcodes.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:Also add
"time"to the import block and remove the now-unused"errors"import.How to Reproduce
balance leastconn)Log evidence on the receiving node (node B):
Environment
nextcloud-spreed-signalingv2.1.1 (also confirmed on master9cecc92)nats://loopbackbalance leastconn(no source stickiness)