Skip to content

Race condition in accept() causes local rejection of incoming calls #1254

Description

@RazvanTamazlicariu

Bug Report: Race Condition in accept() Causes Local Rejection of Incoming Calls

Summary

When accepting an incoming call, a race condition between the HTTP response and WebSocket event can cause the SDK to incorrectly reject the call locally, thinking it was "accepted on another device."

SDK Version

  • stream_video: ^1.4.0
  • stream_video_flutter: ^1.4.0

Platform

  • iOS (also affects Android)
  • Flutter 3.x

Steps to Reproduce

  1. User A calls User B (ringing flow)
  2. User B receives incoming call notification
  3. User B taps "Accept" button
  4. Call is immediately disconnected with reason "userRespondedElsewhere"

Expected Behavior

Call should connect successfully after acceptance.

Actual Behavior

Call is rejected locally ~50-100ms after accept() returns success. The call status transitions:

  • Incoming{acceptedByMe: false}Incoming{acceptedByMe: true}Disconnected{reason: Cancelled}

Root Cause Analysis

The race condition occurs in lib/src/call/call.dart in the _handleCoordinatorCallAccepted method (lines 735-754):

Future<void> _handleCoordinatorCallAccepted(StreamCallAcceptedEvent event) async {
  final currentUserId = _stateManager.callState.currentUserId;
  final status = state.value.status;

  if (event.acceptedByUserId == currentUserId &&
      status is CallStatusIncoming &&
      !status.acceptedByMe) {  // <-- This check fails due to race!
    _logger.i(() => '[onCoordinatorEvent] call accepted on another device, '
        'rejecting locally with userRespondedElsewhere');
    await reject(reason: CallRejectReason.userRespondedElsewhere());
    return;
  }
  // ...
}

The problem: In accept(), lifecycleCallAccepted() is called AFTER the HTTP response:

Future<Result<None>> accept() async {
  // ... validation ...
  
  final result = await _coordinatorClient.acceptCall(cid: state.callCid);
  if (result is Success<None>) {
    _stateManager.lifecycleCallAccepted();  // <-- Sets acceptedByMe = true
  }
  return result;
}

Timeline of the race:

T+0ms    User taps Accept → accept() is called
T+1ms    HTTP POST /call/{type}/{id}/accept sent to server
T+50ms   Server receives request, broadcasts "call.accepted" WebSocket event
T+55ms   WebSocket event arrives at client
T+56ms   _handleCoordinatorCallAccepted() runs
         → Checks: status.acceptedByMe → FALSE (HTTP hasn't returned yet!)
         → SDK concludes: "accepted on another device"
         → Calls reject(reason: userRespondedElsewhere) → Sends REJECT to server!
T+60ms   HTTP response finally arrives
         → lifecycleCallAccepted() is called (too late - call already rejected)

Suggested Fix

Move lifecycleCallAccepted() to BEFORE the HTTP request in accept():

Future<Result<None>> accept() async {
  final state = this.state.value;
  _logger.i(() => '[accept] state: $state');

  final status = state.status;
  if (status is! CallStatusIncoming || status.acceptedByMe) {
    _logger.w(() => '[accept] rejected (invalid status): $status');
    return Result.error('invalid status: $status');
  }

  // ... outgoing call handling ...

  _session?.trace('call.accept', null);
  
  // FIX: Set acceptedByMe = true BEFORE the HTTP request
  // This prevents the race condition where the WebSocket event
  // arrives before the HTTP response.
  _stateManager.lifecycleCallAccepted();
  
  final result = await _coordinatorClient.acceptCall(cid: state.callCid);
  
  // If the HTTP request fails, we should revert the state
  if (result is! Success<None>) {
    // Optionally revert acceptedByMe or handle failure
    _logger.e(() => '[accept] HTTP request failed after local state update');
  }

  return result;
}

Alternatively, the check in _handleCoordinatorCallAccepted could be made more lenient by tracking that an accept is "in progress" and not rejecting during that window.

Workaround (Current)

We implemented a workaround in our app:

  1. Save call type/ID before calling accept()
  2. After accept() succeeds, check if SDK locally rejected (race condition)
  3. If rejected, create a fresh Call object with makeCall() + get()
  4. Join directly without re-accepting (server already has our accept)
Future<void> _handleAccept() async {
  final callType = widget.call.type;
  final callId = widget.call.id;
  
  final acceptResult = await widget.call.accept();
  if (!acceptResult.isSuccess) {
    await _joinWithFreshCall(callType, callId);
    return;
  }
  
  await Future.delayed(Duration(milliseconds: 50));
  
  if (widget.call.state.valueOrNull?.status?.isDisconnected ?? false) {
    // Race condition! Server has accept, SDK rejected locally.
    await _joinWithFreshCall(callType, callId);
    return;
  }
  
  await widget.call.join();
}

Future<void> _joinWithFreshCall(StreamCallType callType, String callId) async {
  final freshCall = video.makeCall(callType: callType, id: callId);
  await freshCall.get();  // Sync with server
  await freshCall.join(); // Join directly - server knows we accepted
}

Logs

21:47:53.437 - User tapped Accept
21:47:53.438 - accept() sending HTTP request
21:47:53.506 - CoordinatorCallAcceptedEvent received (WebSocket)
21:47:53.507 - _handleCoordinatorCallAccepted: acceptedByMe = false
21:47:53.508 - SDK: "call accepted on another device, rejecting locally"
21:47:53.509 - reject(userRespondedElsewhere) called
21:47:53.510 - accept() HTTP response received (too late)
21:47:53.577 - Status: Disconnected{reason: Cancelled{byUserId: 323}}

Impact

  • High: Affects all incoming calls in ringing flow
  • Users cannot reliably accept calls
  • Timing-dependent (worse on slow networks where HTTP takes longer)

Related Issues

This may be related to other multi-device synchronization issues mentioned in the changelog.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions