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
- User A calls User B (ringing flow)
- User B receives incoming call notification
- User B taps "Accept" button
- 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:
- Save call type/ID before calling
accept()
- After
accept() succeeds, check if SDK locally rejected (race condition)
- If rejected, create a fresh
Call object with makeCall() + get()
- 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.
Bug Report: Race Condition in
accept()Causes Local Rejection of Incoming CallsSummary
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.0stream_video_flutter: ^1.4.0Platform
Steps to Reproduce
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.dartin the_handleCoordinatorCallAcceptedmethod (lines 735-754):The problem: In
accept(),lifecycleCallAccepted()is called AFTER the HTTP response:Timeline of the race:
Suggested Fix
Move
lifecycleCallAccepted()to BEFORE the HTTP request inaccept():Alternatively, the check in
_handleCoordinatorCallAcceptedcould 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:
accept()accept()succeeds, check if SDK locally rejected (race condition)Callobject withmakeCall()+get()Logs
Impact
Related Issues
This may be related to other multi-device synchronization issues mentioned in the changelog.