Skip to content

fix(ios): add NSLock to nativeAcceptHandledCallIds and 10s REST timeout to handleNativeAccept - #7198

Merged
diegolmello merged 1 commit into
feat.voip-lib-newfrom
fix/voip-ios-nslock-timeout
Apr 22, 2026
Merged

fix(ios): add NSLock to nativeAcceptHandledCallIds and 10s REST timeout to handleNativeAccept#7198
diegolmello merged 1 commit into
feat.voip-lib-newfrom
fix/voip-ios-nslock-timeout

Conversation

@diegolmello

@diegolmello diegolmello commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

H1 fix: Add NSLock (nativeAcceptLock) to synchronize all reads/writes of nativeAcceptHandledCallIds across:

  • handleNativeAccept (CallKit answer path)
  • clearNativeAcceptDedupe (called from DDP listener, timeout handler, call observer)
  • CXCallObserver callbacks

H2 fix: Add a 10-second DispatchWorkItem timeout guard in handleNativeAccept. If the REST callback (api.fetch) hasn't fired within 10 seconds, the work item calls finishAccept(false). The work item cancels itself when the REST response arrives first.

Changes

  • ios/Libraries/VoipService.swift:
    • Added nativeAcceptLock = NSLock() to serialize nativeAcceptHandledCallIds access
    • Wrapped the check+insert in handleNativeAccept with nativeAcceptLock.lock/unlock
    • Wrapped nativeAcceptHandledCallIds.remove(callId) in clearNativeAcceptDedupe with lock
    • Added 10s timeout DispatchWorkItem that checks if callId is still tracked before calling finishAccept(false)
    • finishAccept captures [weak timeoutWorkItem] to cancel the timeout on REST completion

Testing

  • iOS build: xcodebuild compiles without errors (Watch App / JitsiWebRTC failures are pre-existing, unrelated to this change)
  • Logic: timeout fires after 10s if REST hangs, cancels when REST completes; lock serializes concurrent access

Merge Order

This is PR 1 of 6. Merge in this order:

  1. PR-2: fix(ios) DDP cleanup — independent ← already merged
  2. PR-1: fix(ios) NSLock + timeout ← MERGE NEXT
  3. PR-3: feat(android) VoipCallService — independent
  4. PR-4: fix(android) service integration — MUST merge after PR-3
  5. PR-5: fix(both) null guard — independent, merge before PR-6
  6. PR-6: chore(ts) cleanup — merge last (shares file with PR-5)

Summary by CodeRabbit

  • Bug Fixes
    • Improved concurrent call handling reliability by adding thread-safe serialization to prevent race conditions.
    • Added automatic 10-second timeout for calls to ensure proper cleanup if calls hang or don't complete.

…ut to handleNativeAccept

- Add NSLock (`nativeAcceptLock`) to synchronize all reads/writes of
  `nativeAcceptHandledCallIds` across: handleNativeAccept (CallKit path),
  clearNativeAcceptDedupe (called from DDP listener, timeout handler,
  call observer), and CXCallObserver callbacks.
- Add 10-second DispatchWorkItem timeout in handleNativeAccept: if the
  REST callback hasn't fired within 10s, call finishAccept(false).
  The work item cancels itself when the REST response arrives first.

Fixes: H1 (data race on nativeAcceptHandledCallIds), H2 (no REST timeout)
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Added thread-safety mechanisms to prevent concurrent access races in VoIP call deduplication by introducing an NSLock, and implemented a 10-second timeout that automatically fails incomplete call acceptance operations.

Changes

Cohort / File(s) Summary
VoIP Service Thread Safety
ios/Libraries/VoipService.swift
Added nativeAcceptLock to serialize access to nativeAcceptHandledCallIds. Wrapped dedupe mutations and reads with the lock. Introduced 10-second timeout with DispatchWorkItem that calls finishAccept(false) for incomplete operations and cancels via weak reference.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Suggested labels

type: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding NSLock for thread-safe access to nativeAcceptHandledCallIds and implementing a 10-second timeout mechanism in handleNativeAccept.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@diegolmello

Copy link
Copy Markdown
Member Author

Code Review — PR #7198

Files Reviewed: ios/Libraries/VoipService.swift
Issues: 0 CRITICAL/HIGH, 1 LOW observation


[LOW] Capture of finishAccept in timeout workItem closure

The timeout workItem closure at line 461-470 captures finishAccept by reference:

let timeoutWorkItem = DispatchWorkItem { [weak payload] in
    ...
    if isStillTracked {
        finishAccept(false)  // ← captures `finishAccept` variable by reference
    }
}
let finishAccept: (Bool) -> Void = { [weak timeoutWorkItem] success in
    ...
}

Swift closures capture variables by reference (not by value), so when the workItem fires 10 seconds later, finishAccept will resolve to whichever closure was assigned to that variable. This is intentional and correct. However, a reviewer should be aware that the capture chain is: finishAccept variable → closure body → stopDDPClientInternal, storeInitialEvents, clearNativeAcceptDedupe, notification posts. All of these are safe to call from main thread (where the timeout fires via DispatchQueue.main.asyncAfter).

No action required — this is a correct design.


Verification Against Acceptance Criteria

Criterion Status Notes
nativeAcceptHandledCallIds reads/writes synchronized via NSLock Lock wraps: check+insert in handleNativeAccept (line 449-456), remove in clearNativeAcceptDedupe (line 284-287)
handleNativeAccept times out after 10s if REST callback hasn't fired DispatchWorkItem with 10s delay at line 471; fires finishAccept(false) if callId still tracked
Timeout cancels correctly when REST completes before 10s finishAccept captures [weak timeoutWorkItem] and calls timeoutWorkItem?.cancel() at line 474
No regression in existing call accept flow finishAccept logic unchanged; lock is additive; timeout path mirrors existing failure path

Positive Observations

  1. Lock is correctly scoped. nativeAcceptLock covers all 3 access sites exactly, no more and no less.
  2. Double-check pattern prevents race. Checking contains before insert inside the lock ensures only one path proceeds.
  3. Timeout re-checks deduplication slot. The timeout workItem re-checks nativeAcceptHandledCallIds before calling finishAccept(false), preventing double-trigger (timeout fires and then REST fires).
  4. Weak capture on payload. [weak payload] in the timeout workItem prevents retain cycle if the call is cleaned up before 10s.
  5. Timeout fires on main thread. DispatchQueue.main.asyncAfter ensures all state mutations (finishAccept, notifications) happen on main, consistent with the rest of the code.
  6. Matches Android pattern. VoipNotification.handleAcceptAction already has a 10s timeout (line 276: timeoutHandler.postDelayed(postedTimeout, 10_000L)), so this aligns the iOS behavior.

Verdict

LGTM — All 4 acceptance criteria satisfied. No blocking issues. The LOW observation is informational only.

@diegolmello
diegolmello had a problem deploying to official_android_build April 22, 2026 00:24 — with GitHub Actions Failure
@diegolmello
diegolmello had a problem deploying to experimental_android_build April 22, 2026 00:24 — with GitHub Actions Failure
@diegolmello
diegolmello had a problem deploying to experimental_ios_build April 22, 2026 00:24 — with GitHub Actions Failure

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ios/Libraries/VoipService.swift (1)

460-546: ⚠️ Potential issue | 🔴 Critical

Make native accept completion single-shot with atomic deduplication check.

The timeout closure (line 461) can call finishAccept(false), then a late REST callback (line 541) can still call finishAccept(true), posting both VoipAcceptFailed and VoipAcceptSucceeded notifications for the same call. The timeout closure also references finishAccept before its declaration (line 468 refs line 473). Move the deduplication check into finishAccept as an atomic remove-and-proceed guard: only the first completion (timeout or REST) should proceed; others should no-op.

🐛 Proposed fix
-        // 10-second timeout guard: if REST hasn't completed by then, call finishAccept(false).
-        let timeoutWorkItem = DispatchWorkItem { [weak payload] in
-            guard let payload else { return }
-            // Check the callId is still tracked (not already cleaned up).
-            nativeAcceptLock.lock()
-            let isStillTracked = nativeAcceptHandledCallIds.contains(payload.callId)
-            nativeAcceptLock.unlock()
-            if isStillTracked {
-                finishAccept(false)
-            }
-        }
-        DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutWorkItem)
-
-        let finishAccept: (Bool) -> Void = { [weak timeoutWorkItem] success in
-            timeoutWorkItem?.cancel()
+        var timeoutWorkItem: DispatchWorkItem?
+
+        let finishAccept: (Bool) -> Void = { success in
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            nativeAcceptLock.lock()
+            let shouldFinish = nativeAcceptHandledCallIds.remove(payload.callId) != nil
+            nativeAcceptLock.unlock()
+            guard shouldFinish else {
+                return
+            }
+
             stopDDPClientInternal(callId: payload.callId)
             if success {
                 storeInitialEvents(payload)
-                clearNativeAcceptDedupe(for: payload.callId)
                 NotificationCenter.default.post(
                     name: NSNotification.Name("VoipAcceptSucceeded"),
                     object: nil,
                     userInfo: payload.toDictionary()
                 )
             } else {
-                clearNativeAcceptDedupe(for: payload.callId)
                 RNCallKeep.endCall(withUUID: payload.callId, reason: 6)
                 let failedPayload = VoipPayload(
                     callId: payload.callId,
@@
                 )
             }
         }
+
+        // 10-second timeout guard: if REST hasn't completed by then, call finishAccept(false).
+        timeoutWorkItem = DispatchWorkItem {
+            finishAccept(false)
+        }
+        if let timeoutWorkItem {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutWorkItem)
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/VoipService.swift` around lines 460 - 546, The timeoutClosure
(timeoutWorkItem) can race with the REST callback causing finishAccept to run
twice and post both VoipAcceptFailed and VoipAcceptSucceeded; also
timeoutWorkItem captures finishAccept before it's declared. Fix by moving the
dedupe atomic check and remove into finishAccept: have
finishAccept(callSucceeded:) first acquire nativeAcceptLock, check and remove
payload.callId from nativeAcceptHandledCallIds (no-op and return if not
present), then proceed to cancel timeoutWorkItem, call stopDDPClientInternal,
clearNativeAcceptDedupe(for:), and post the appropriate notification; keep
timeoutWorkItem only calling finishAccept(false) and the REST callback calling
finishAccept(true) so only the first caller proceeds. Ensure you reference and
update nativeAcceptHandledCallIds, nativeAcceptLock, timeoutWorkItem,
finishAccept, stopDDPClientInternal, and clearNativeAcceptDedupe in the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@ios/Libraries/VoipService.swift`:
- Around line 460-546: The timeoutClosure (timeoutWorkItem) can race with the
REST callback causing finishAccept to run twice and post both VoipAcceptFailed
and VoipAcceptSucceeded; also timeoutWorkItem captures finishAccept before it's
declared. Fix by moving the dedupe atomic check and remove into finishAccept:
have finishAccept(callSucceeded:) first acquire nativeAcceptLock, check and
remove payload.callId from nativeAcceptHandledCallIds (no-op and return if not
present), then proceed to cancel timeoutWorkItem, call stopDDPClientInternal,
clearNativeAcceptDedupe(for:), and post the appropriate notification; keep
timeoutWorkItem only calling finishAccept(false) and the REST callback calling
finishAccept(true) so only the first caller proceeds. Ensure you reference and
update nativeAcceptHandledCallIds, nativeAcceptLock, timeoutWorkItem,
finishAccept, stopDDPClientInternal, and clearNativeAcceptDedupe in the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f6218167-3325-42ee-9e0c-f80914bb3f2e

📥 Commits

Reviewing files that changed from the base of the PR and between d30c291 and 797b53d.

📒 Files selected for processing (1)
  • ios/Libraries/VoipService.swift
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: ESLint and Test / run-eslint-and-test
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate

Applied to files:

  • ios/Libraries/VoipService.swift
🔇 Additional comments (3)
ios/Libraries/VoipService.swift (3)

39-41: LGTM — scoped lock for the native accept dedupe set.

This is a focused synchronization primitive for the new cross-context nativeAcceptHandledCallIds access.


285-287: LGTM — removal is now synchronized.

This closes the obvious race between cleanup paths and handleNativeAccept.


449-456: LGTM — check-and-insert is atomic now.

Serializing this block prevents duplicate accepts from concurrent CXCallObserver callbacks.

@diegolmello
diegolmello merged commit d10c764 into feat.voip-lib-new Apr 22, 2026
5 of 10 checks passed
@diegolmello
diegolmello deleted the fix/voip-ios-nslock-timeout branch April 22, 2026 14:12
diegolmello added a commit that referenced this pull request Apr 22, 2026
…/Decline (#7215)

* merge feat.voip-lib

* feat(voip): enhance call handling with UUID mapping and event listeners

* Base call UI

* feat(voip): integrate Zustand for call state management and enhance CallView UI

* feat(voip): add simulateCall function for mock call handling in UI development

* refactor(CallView): update button handlers and improve UI responsiveness

* Add pause-shape-unfilled icon

* Base CallHeader

* toggleFocus

* collapse buttons

* Header components

* Hide header when no call

* Timer

* Add use memo

* Add voice call item on sidebar

* cleanup

* Temp use @rocket.chat/media-signaling from .tgz

* cleanup

* Check module and permissions to enable voip

* Refactor stop method to use optional chaining for media signal listeners

* voip push first test

* Add VoIP call handling with pending call management

- Implemented VoIP push notification handling in index.js, including storing call info for later processing.
- Added CallKeep event handlers for answering and ending calls from a cold start.
- Introduced a new CallIdUUID module to convert call IDs to deterministic UUIDs for compatibility with CallKit.
- Created a pending call store to manage incoming calls when the app is not fully initialized.
- Updated deep linking actions to include VoIP call handling.
- Enhanced MediaSessionInstance to process pending calls and manage call states effectively.

* Remove pending store and create getInitialEvents on app/index

* Attempt to make iOS calls work from cold state

* lint and format

* Patch callkeep ios

* Temp send iOS voip push token on gcm

* Temp fix require cycle

* chore: format code and fix lint issues [skip ci]

* CallIDUUID module on android and voip push

* Add setCallUUID on useCallStore to persist calls accepted on native Android

* remove callkeep from notification

* Android Incoming Call UI POC

* Refactor VoIP handling: Migrate VoIP-related classes to a new package structure, removing deprecated modules and consolidating functionality. Update imports in MainApplication and NotificationIntentHandler to reflect changes. This cleanup enhances code organization and prepares for future VoIP feature enhancements.

* Remove VoipForegroundService

* cleanup and use caller instead of callerName

* Cleanup and make iOS build again

* Refactor VoIP handling: Remove unused event emissions for call answered and declined, switch from SharedPreferences to in-memory storage for pending VoIP call data, and update method signatures for better clarity. This cleanup enhances performance and prepares for future VoIP feature improvements.

* Refactor VoIP handling: Introduce a new VoipPayload class to encapsulate call data, streamline notification processing, and enhance method signatures across the VoIP module. This update improves code clarity and prepares for future feature enhancements.

* Migrate react-native-voip-push-notifications to VoipModule

* Refactor VoIP module: Update package structure by moving VoipTurboPackage to the main package and removing the obsolete NativeVoipSpec class. Adjust imports in MainApplication and VoipModule to reflect these changes, enhancing code organization and maintainability.

* Unify emitters

* Move CallKeep listeners from MediaSessionInstance to getInitialEvents

* Clear callkeep on endcall

* Unify getInitialEvents logic

* getInitialEvents -> MediaCallEvents

* chore: format code and fix lint issues [skip ci]

* feat(Android): Add full screen incoming call (#6977)

* feat: Update call UI (#6990)

* feat: Handle audio routing, e.g., Bluetooth headset vs. internal speaker switching (#6992)

* fix: empty space when not on call (#6993)

* feat: Dialpad (#7000)

* action: organized translations

* feat: start call (#7024)

* chore: format code and fix lint issues

* feat: Pre flight (#7038)

* action: organized translations

* feat: Receive voip push notifications from backend (#7045)

* feat: Refactor media session handling and improve disconnect logic (#7065)

* feat: Control incoming call from native (#7066)

* feat: Voice message blocks (#7057)

* feat: native accept success event (#7068)

* feat(voip): call waiting, busy detection, and videoconf blocking (#7077)

* action: organized translations

* feat(voip): tap-to-hide call controls with animations (#7078)

* feat(voip): navigate to call DM from message button and header (#7082)

* feat(voip): tablet and landscape layout (#7110)

* chore: develop into feat.voip-lib-new (RN 81 + Expo 54 + reanimated 4 + true-sheet + iOS 26) (#7114)

* chore: format code and fix lint issues

* feat(voip): android landscape layout for IncomingCallActivity (#7116)

* Update agents files

* feat(voip): Support a11y (#7106)

* Fix content cutting on iOS on some edge cases

* pods

* Ignore .worktrees on jest

* chore: Merge develop into feat.voip-lib-new (#7129)

* fix(voip): show CallKit UI when call is active in background (#7128)

* chore: Update media-signaling to 0.2.0 (#7153)

* feat(voip): migrate iOS accept/reject from DDP to REST (#7124)

* Fix icons

* feat(voip): migrate Android accept/reject from DDP to REST (#7127)

* test(voip): integration tests for CallView pipeline (#7161)

* feat(voip): display video conf provider as subtitle (#7160)

* fix(voip): CallView button grid and correct landscape/dialpad layouts (#7164)

* fix(voip): prevent stale MMKV cache on Android first-install accept

MMKVKeyManager.initialize ran in MainApplication.onCreate before the JS
engine started and opened the default MMKV file via the Tencent 1.2 JAR
when it was still empty. Tencent caches instances per-ID in a singleton
registry, so that empty-state view was held for the rest of the process.
JS later wrote credentials through react-native-mmkv (MMKV Core 2.0),
which has its own separate registry. When a VoIP push arrived,
Ejson.getMMKV() got the cached empty Tencent instance and reported
"No userId found in MMKV for server". Closing and reopening the app
cleared the cache, which is why only the very first call after install
failed.

Drop the open/verify block — the encryption key is already cached from
SecureKeystore, so no MMKV handle is needed here. The first Tencent
instance is now created inside Ejson.getMMKV() after JS has written,
so it scans the file fresh.

* fix(voip): prevent duplicate ringtone on Android incoming call (#7158)

* fix(voip): set explicit snaps for NewMediaCall bottom sheet (#7165)

* Update app/lib/services/voip/MediaSessionStore.ts

Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>

* fix: make startVoipFork reactive to permissions-changed (#7151)

* fix(android): remove MediaProjectionService from merged manifest (#7190)

* fix(voip): Phone account creation (#7170)

* feat: add Enable Mobile Ringing toggle in user preferences (#7155)

* fix(voip): ship blockers for PushKit, licensing, outbound calls, push tokens (#7167)

* fix(android): Play Store mic discoverability, safer FCM logs, avatar auth via headers (#7171)

* fix(ios): serialize VoipService bridge statics (#7169)

* fix(voip): Android DDP thread safety and VoipPayload bundle parity (#7168)

* chore(voip): dead-code and hygiene sweep (#7174)

* refactor(voip): decouple navigateToCallRoom from Redux and backfill REST/connect tests (#7176)

* test(voip): tighten ringing endCall assertion and add VideoConf VoIP-lock saga coverage (#7177)

* fix(ios): harden VoIP DDP WebSocket client on receive failures and TLS (#7173)

* refactor(voip): MediaCallEvents Redux adapters and resetVoipState (#7178)

* refactor(voip): decouple peer autocomplete from Redux; simplify NewMediaCall (#7175)

* fix(ios): add NS_SWIFT_NAME to Challenge.runChallenge for Swift 6.2 compatibility

Swift 6.2 (Xcode 26.x / macos-26 runner) auto-renames the Objective-C
method runChallenge:didReceiveChallenge:completionHandler: to
run(_:didReceive:completionHandler:) when imported into Swift.

Add NS_SWIFT_NAME to explicitly pin the Swift import name, preventing
the compiler from applying its heuristics. This keeps the existing
Swift call site in DDPClient.swift working without changes.

* fix(ios): cancel old URLSession/webSocketTask before reconnecting in DDPClient.connect (#7197)

* fix(ios): add NSLock to nativeAcceptHandledCallIds and 10s REST timeout to handleNativeAccept (#7198)

* feat(android): create VoipCallService with FOREGROUND_SERVICE_MICROPHONE (#7199)

* fix(android): start VoipCallService on accept, stop on hangup/timeout, install end-call listener (#7200)

* fix(voip): enable DM nav for users with SIP extension (#7203)

* fix(android): handle null VoiceConnection in answerIncomingCall, notify JS (#7201)

* fix(voip): resolve closure capture ordering in handleNativeAccept (#7209)

* fix(android): integrate VoIP modules with SSL-pinned OkHttpClient (#7208)

* fix(push): gate id and voipToken behind server version checks, fix VideoConf caller extra (#7210)

* fix(voip): remove sensitive data from production logs (#7207)

* fix(android): remove isRunning guard + add double-tap guard on Accept/Decline

- VoipCallService: remove if (!isRunning) guard, call startForeground unconditionally
  (idempotent on Android, fixes Android 14+ foreground service requirement)
- IncomingCallActivity: add AtomicBoolean guard on handleAccept/handleDecline
  to prevent double-tap from triggering multiple service starts

---------

Co-authored-by: diegolmello <diegolmello@users.noreply.github.com>
Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant