fix(ios): harden VoIP DDP WebSocket client on receive failures and TLS#7173
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds URLSession-based TLS challenge delegation (forwarding to a new Changes
Sequence Diagram(s)sequenceDiagram
participant Client as DDPClient
participant URLSession as URLSession
participant Delegate as DDPClientURLSessionChallengeDelegate
participant Challenge as Challenge
participant WebSocket as WebSocketTask
Client->>URLSession: connect(with: Delegate)
URLSession->>Delegate: encounter TLS auth challenge
Delegate->>Challenge: runChallenge(session, challenge, completionHandler)
Challenge-->>Delegate: disposition, credential
Delegate-->>URLSession: call completionHandler(disposition, credential)
URLSession->>WebSocket: establish websocket
WebSocket->>Client: receive(message/result)
Client->>Client: handleWebSocketReceiveResult(result, for: task)
alt success
Client->>Client: handleMessage(string)
Client->>WebSocket: re-arm receive
else failure
Client->>Client: disconnectOnStateQueue(failPending: true)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested labels
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
ios/Libraries/DDPClient.swift (1)
192-214:⚠️ Potential issue | 🟡 MinorSend-path parity with receive-failure teardown.
The PR intentionally defers this (noted in "future work"), but flagging for visibility:
send(...)at Line 204 logs send errors in DEBUG and simply invokescompletion(false)— it does not tear down the session the wayhandleWebSocketReceiveResultnow does on failure. Net effect: a send-side failure on a broken socket leaves the client in the same "non-reading connected shell" state this PR set out to eliminate on the receive side, until the receive loop also trips. Consider tracking a follow-up to route send errors throughdisconnect()(or a shared teardown helper) for symmetry.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 192 - 214, The send(_ dict:completion:) path currently logs send errors and returns completion(false) but does not perform the same teardown as handleWebSocketReceiveResult, leaving the client in a broken-but-not-teardown state; update send(_:completion:) to route send failures into the common teardown (call disconnect() or the shared teardown helper used by handleWebSocketReceiveResult), ensuring the webSocketTask/session is cleaned up and any state flags are reset before invoking completion(false) so send-side failures mirror receive-side failures.
🧹 Nitpick comments (4)
ios/RocketChatRNTests/DDPClientReceiveFailureTests.swift (1)
8-27: Test correctness looks good; one hardening note.The ordering is safe —
testing_applyReceiveResultandtesting_readConnectionStateboth enqueue async onto the serialstateQueue, so the assertion block is guaranteed to run after the receive-failure handling.testing_installWebSocketSyncruns synchronously so the task reference is in place before the failure is applied.Minor: the injected
URLSessionis never invalidated by the test itself — it relies ondisconnect()to callinvalidateAndCancel. If a future change skipped that on a certain path, the ephemeral session would leak past the test. Consider adding adefer { session.invalidateAndCancel() }as belt-and-suspenders cleanup.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/RocketChatRNTests/DDPClientReceiveFailureTests.swift` around lines 8 - 27, In testReceiveFailureClearsConnectionAndSession add explicit cleanup to the ephemeral URLSession used by the test by calling session.invalidateAndCancel() in a defer right after the session is created; this ensures the injected session is always invalidated even if future changes to DDPClient.disconnect() skip invalidateAndCancel, and keeps the existing use of testing_installWebSocketSync, testing_applyReceiveResult and testing_readConnectionState unchanged.ios/Libraries/DDPClient.swift (2)
432-457: DEBUG test hooks: one small gap.
testing_installWebSocketSyncreplacesurlSessionandwebSocketTaskbut does not touchurlSessionChallengeDelegate. If a priorconnect(...)installed a challenge delegate, it will linger across the test install (the new injected session doesn't have one anyway, so behaviour is unaffected). More importantly, after the simulated failure triggersdisconnect(), the test'surlSessionIsNilassertion passes, but there's no assertion thaturlSessionChallengeDelegatewas also cleared — even thoughdisconnectOnStateQueuenow clears it on Line 174. Consider extendingtesting_readConnectionState(or adding a second helper) to also report whether the challenge delegate was released, so the test locks in the full teardown contract the PR is establishing.Also:
testing_installWebSocketSyncusesstateQueue.sync, which would deadlock if ever invoked from insidestateQueue. Fine for the current test caller (main thread), but worth an assertion or doc comment so future usage doesn't foot-gun.♻️ Suggested extension
- func testing_readConnectionState(_ completion: `@escaping` (_ isConnected: Bool, _ webSocketTaskIsNil: Bool, _ urlSessionIsNil: Bool) -> Void) { + func testing_readConnectionState(_ completion: `@escaping` (_ isConnected: Bool, _ webSocketTaskIsNil: Bool, _ urlSessionIsNil: Bool, _ challengeDelegateIsNil: Bool) -> Void) { stateQueue.async { - completion(self.isConnected, self.webSocketTask == nil, self.urlSession == nil) + completion(self.isConnected, self.webSocketTask == nil, self.urlSession == nil, self.urlSessionChallengeDelegate == nil) } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 432 - 457, testing_readConnectionState currently doesn't report whether urlSessionChallengeDelegate was cleared; update it to include a Bool indicating whether urlSessionChallengeDelegate == nil so tests can assert full teardown (reference testing_readConnectionState and urlSessionChallengeDelegate). Also update testing_installWebSocketSync to avoid potential deadlock when called from stateQueue by replacing stateQueue.sync with stateQueue.async or by asserting caller is not on stateQueue (reference testing_installWebSocketSync and stateQueue); keep behavior of replacing urlSession and webSocketTask and setting isConnected.
306-313: Nit: nested[weak self]capture is redundant.The inner
stateQueue.asyncclosure already runs only if the outerself?.dereference succeeded, so the inner[weak self]+guard let selfis redundant. Capturing self strongly inside the async block is safe (the block is short-lived and stateQueue doesn't outlive the client for the lifetime of the receive callback).♻️ Optional simplification
private func listenForMessages(task: URLSessionWebSocketTask) { task.receive { [weak self] result in - self?.stateQueue.async { [weak self] in - guard let self else { return } - self.handleWebSocketReceiveResult(result, for: task) + self?.stateQueue.async { + self?.handleWebSocketReceiveResult(result, for: task) } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 306 - 313, The nested `[weak self]` capture inside listenForMessages is redundant; change the outer receive closure to early-return if self is nil (guard let self = self else { return }) and then call stateQueue.async with a normal closure that uses self strongly to invoke handleWebSocketReceiveResult(result, for: task). Remove the inner `[weak self]` capture and the inner `guard let self` block so the async closure simply calls self.handleWebSocketReceiveResult(...).ios/Libraries/SSLPinning.h (1)
13-14: Minor: consider importing<Foundation/NSURLSession.h>directly.Forward-declaring
NSURLSession/NSURLAuthenticationChallengewhile usingNSURLSessionAuthChallengeDisposition(an enum that cannot be forward-declared) andNSURLCredential(not forward-declared) relies on a transitive import via<React/RCTHTTPRequestHandler.h>. An explicit#import <Foundation/NSURLSession.h>(or@import Foundation;) would make the header self-contained and robust to upstream RN header refactors.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/SSLPinning.h` around lines 13 - 14, The header currently forward-declares NSURLSession and NSURLAuthenticationChallenge but uses NSURLSessionAuthChallengeDisposition and NSURLCredential so make the header self-contained by adding an explicit import of Foundation (e.g. `#import` <Foundation/NSURLSession.h> or `@import` Foundation;) at the top of SSLPinning.h so references to NSURLSession, NSURLAuthenticationChallenge, NSURLSessionAuthChallengeDisposition, and NSURLCredential are resolved without relying on transitive imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ios/Libraries/SSLPinning.h`:
- Around line 13-21: The header Challenge declaration is using
NSURLSessionAuthChallengeDisposition and NSURLCredential without including their
Foundation definitions; update SSLPinning.h to be self-contained by adding an
explicit import of the Foundation session types (e.g. `#import`
<Foundation/NSURLSession.h>) and/or forward-declaring NSURLCredential (e.g.
`@class` NSURLCredential) so the
+runChallenge:didReceiveChallenge:completionHandler: signature is fully resolved
without relying on transitive imports from RCTHTTPRequestHandler.
In `@ios/RocketChatRNTests/DDPClientReceiveFailureTests.swift`:
- Around line 4-6: The test DDPClientReceiveFailureTests in
DDPClientReceiveFailureTests.swift is not included in an Xcode unit-test target,
so CI won't run it; update the test target wiring in the project to include this
file (add the DDPClientReceiveFailureTests test class to the appropriate unit
test target) so CI verifies the receive-failure teardown behavior, or
alternatively block merging until that wiring is added and ensure the DEBUG
helper APIs referenced by DDPClientReceiveFailureTests remain in sync with the
test; locate the test class name DDPClientReceiveFailureTests and any DEBUG
helper symbols used in the test to adjust the test target membership or gating
accordingly.
---
Outside diff comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 192-214: The send(_ dict:completion:) path currently logs send
errors and returns completion(false) but does not perform the same teardown as
handleWebSocketReceiveResult, leaving the client in a broken-but-not-teardown
state; update send(_:completion:) to route send failures into the common
teardown (call disconnect() or the shared teardown helper used by
handleWebSocketReceiveResult), ensuring the webSocketTask/session is cleaned up
and any state flags are reset before invoking completion(false) so send-side
failures mirror receive-side failures.
---
Nitpick comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 432-457: testing_readConnectionState currently doesn't report
whether urlSessionChallengeDelegate was cleared; update it to include a Bool
indicating whether urlSessionChallengeDelegate == nil so tests can assert full
teardown (reference testing_readConnectionState and
urlSessionChallengeDelegate). Also update testing_installWebSocketSync to avoid
potential deadlock when called from stateQueue by replacing stateQueue.sync with
stateQueue.async or by asserting caller is not on stateQueue (reference
testing_installWebSocketSync and stateQueue); keep behavior of replacing
urlSession and webSocketTask and setting isConnected.
- Around line 306-313: The nested `[weak self]` capture inside listenForMessages
is redundant; change the outer receive closure to early-return if self is nil
(guard let self = self else { return }) and then call stateQueue.async with a
normal closure that uses self strongly to invoke
handleWebSocketReceiveResult(result, for: task). Remove the inner `[weak self]`
capture and the inner `guard let self` block so the async closure simply calls
self.handleWebSocketReceiveResult(...).
In `@ios/Libraries/SSLPinning.h`:
- Around line 13-14: The header currently forward-declares NSURLSession and
NSURLAuthenticationChallenge but uses NSURLSessionAuthChallengeDisposition and
NSURLCredential so make the header self-contained by adding an explicit import
of Foundation (e.g. `#import` <Foundation/NSURLSession.h> or `@import` Foundation;)
at the top of SSLPinning.h so references to NSURLSession,
NSURLAuthenticationChallenge, NSURLSessionAuthChallengeDisposition, and
NSURLCredential are resolved without relying on transitive imports.
In `@ios/RocketChatRNTests/DDPClientReceiveFailureTests.swift`:
- Around line 8-27: In testReceiveFailureClearsConnectionAndSession add explicit
cleanup to the ephemeral URLSession used by the test by calling
session.invalidateAndCancel() in a defer right after the session is created;
this ensures the injected session is always invalidated even if future changes
to DDPClient.disconnect() skip invalidateAndCancel, and keeps the existing use
of testing_installWebSocketSync, testing_applyReceiveResult and
testing_readConnectionState unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d6ad14ce-0006-4c1a-9dc4-9394e1c0a7cd
📒 Files selected for processing (4)
ios/Libraries/DDPClient.swiftios/Libraries/SSLPinning.hios/RocketChatRN-Bridging-Header.hios/RocketChatRNTests/DDPClientReceiveFailureTests.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 (2)
📓 Common learnings
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
📚 Learning: 2026-03-05T06:06:19.755Z
Learnt from: divyanshu-patil
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6957
File: ios/RCTWatchModule.mm:19-24
Timestamp: 2026-03-05T06:06:19.755Z
Learning: In the Rocket.Chat React Native iOS app, `WCSession` (WatchConnectivity) is activated with its delegate in `ios/RocketChat Watch App/Loaders/WatchSession.swift`, not in `RCTWatchModule.mm`. Since `WCSession.defaultSession` is a singleton, activating it once in `WatchSession.swift` is sufficient; `RCTWatchModule.mm` does not need to re-activate or re-set the delegate.
Applied to files:
ios/RocketChatRNTests/DDPClientReceiveFailureTests.swift
🔇 Additional comments (4)
ios/Libraries/DDPClient.swift (4)
151-175: Disconnect ordering fix is correct.The
getSpecific(key: stateQueueKey)check reliably routes on-queue callers through the synchronous path (so teardown cannot be preempted by other queued work likelistenForMessagesrescheduling), while off-queue callers serialize safely viaasync.disconnectOnStateQueue()cleans up state, callbacks, task, session, and the new challenge delegate in the right order.One observation:
webSocketTask?.cancel(with: .normalClosure, reason: nil)is still invoked even when we reached here from a receive failure (connection already dead). That's harmless —URLSessionWebSocketTask.cancelis safe on a failed task — but worth being aware of when reading logs.
315-334: Receive-failure teardown is correct.Identity check via
task === webSocketTaskcleanly drops stale callbacks from a previously canceled task. Because this runs onstateQueue, thedisconnect()call on failure dispatches through the synchronous on-queue path in Line 152, so teardown completes before any later-queued work executes — exactly the invariant described in the PR objectives.One consideration: on
.successwith.data(...)(thedefault:branch at Line 323), binary frames are silently dropped. DDP is JSON-text so this is fine in practice, but a single DEBUG log line would aid future diagnosis if the server ever sends an unexpected binary frame.
367-383: Default-branch routing change looks good; pre-existing no-op above.
(json["collection"] as? String) != nilcorrectly gates forwarding oncollectionbeing a string, matching the PR description.Unrelated pre-existing smell in the
"changed" / "added" / "removed"branch at Line 368-372:message["collection"] = collectionreassigns the same string back onto the dictionary, which is a no-op. Not introduced by this PR, but worth cleaning up next time you're in this file.
411-430: Delegate wiring is correct; manual TLS verification is optional.The implementation correctly covers both challenge paths: session-level delegates (typically server-trust) and task-level delegates (typically client-cert) both forward to
Challenge.runChallenge(...), reusing the existing pinning/client-cert flow as intended. The code comment on line 18 explicitly acknowledges this design. URLSession manages delegate callback serialization per session, so no reentrancy issues arise from synchronous challenge handling.If integration testing reveals the need for visibility into TLS challenge flow (e.g., on pinned hosts), temporarily adding logging to the delegate methods is a reasonable debugging step—but it's not required for correctness.
ea0a890 to
ef1bdb5
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
ios/Libraries/DDPClient.swift (2)
362-367: Redundant re-assignment ofcollectioninto the copied dictionary.
messageis a value-type copy ofjson, somessage["collection"]already holds the same string. Thevar message = json+message["collection"] = collectiondance does nothing beyond forwardingjsonitself. Matches the simplification already done in thedefaultbranch below.♻️ Proposed change
case "changed", "added", "removed": - if let collection = json["collection"] as? String { - var message = json - message["collection"] = collection - onCollectionMessage?(message) + if (json["collection"] as? String) != nil { + onCollectionMessage?(json) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 362 - 367, Redundant copy-and-reassign: in the switch handling case "changed","added","removed" inside DDPClient.swift, remove the unnecessary var message = json and message["collection"] = collection lines and instead call onCollectionMessage?(json) directly (same behavior as the default branch); update the branch that references onCollectionMessage to pass json unchanged to avoid the no-op reassignment.
30-71: Add adeinitthat invalidates theURLSessionto prevent leaks ifDDPClientis released without callingdisconnect().
URLSessioncreated with a delegate retains that delegate strongly untilinvalidateAndCancel()orfinishTasksAndInvalidate()is called. Per Apple's official documentation, not invalidating leaks the session (and its delegate) for the app's lifetime. Whiledisconnect()handles this, any code path that drops aDDPClientreference without callingdisconnect()will silently leak theURLSessionand its live WebSocket task. A smalldeinitcloses this gap defensively:♻️ Proposed change
init() { stateQueue.setSpecific(key: stateQueueKey, value: ()) } + + deinit { + // Ensure the URLSession (which strongly retains its delegate) is always invalidated, + // even if disconnect() was never explicitly invoked by the owner. + webSocketTask?.cancel(with: .normalClosure, reason: nil) + urlSession?.invalidateAndCancel() + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 30 - 71, Add a deinit to DDPClient that defensively cleans up the live WebSocket and URLSession if the instance is released without calling disconnect(); specifically, implement deinit { webSocketTask?.cancel(with: .goingAway, reason: nil); urlSession?.invalidateAndCancel(); webSocketTask = nil; urlSession = nil } so the URLSession delegate created in connect and any running webSocketTask are released and do not leak if disconnect() is not invoked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 362-367: Redundant copy-and-reassign: in the switch handling case
"changed","added","removed" inside DDPClient.swift, remove the unnecessary var
message = json and message["collection"] = collection lines and instead call
onCollectionMessage?(json) directly (same behavior as the default branch);
update the branch that references onCollectionMessage to pass json unchanged to
avoid the no-op reassignment.
- Around line 30-71: Add a deinit to DDPClient that defensively cleans up the
live WebSocket and URLSession if the instance is released without calling
disconnect(); specifically, implement deinit { webSocketTask?.cancel(with:
.goingAway, reason: nil); urlSession?.invalidateAndCancel(); webSocketTask =
nil; urlSession = nil } so the URLSession delegate created in connect and any
running webSocketTask are released and do not leak if disconnect() is not
invoked.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 770aff75-3438-47cd-a3af-ff6b820a51f4
📒 Files selected for processing (4)
ios/Libraries/DDPClient.swiftios/Libraries/SSLPinning.hios/RocketChatRN-Bridging-Header.hios/RocketChatRNTests/DDPClientReceiveFailureTests.swift
✅ Files skipped from review due to trivial changes (1)
- ios/RocketChatRN-Bridging-Header.h
🚧 Files skipped from review as they are similar to previous changes (1)
- ios/RocketChatRNTests/DDPClientReceiveFailureTests.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)
📓 Common learnings
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
🔇 Additional comments (1)
ios/Libraries/SSLPinning.h (1)
13-21: Header still relies on transitive imports forNSURLSessionAuthChallengeDisposition/NSURLCredential.The
Challengesignature usesNSURLSessionAuthChallengeDispositionandNSURLCredentialdirectly, but the header only imports<React/RCTHTTPRequestHandler.h>. It resolves today through transitive inclusion, which is fragile to upstream React Native header reshuffles. Making the header self-contained (e.g.,#import <Foundation/NSURLSession.h>and/or@class NSURLCredential;) keeps it robust.♻️ Proposed change
`#import` <React/RCTHTTPRequestHandler.h> +#import <Foundation/NSURLSession.h> NS_ASSUME_NONNULL_BEGIN `@class` NSURLSession; `@class` NSURLAuthenticationChallenge; +@class NSURLCredential;
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 157-165: disconnectOnStateQueue currently clears pendingCallbacks
and queued method calls without invoking them, causing in-flight
connect/login/subscribe/callMethod to hang; update disconnectOnStateQueue to
iterate pendingCallbacks and invoke each callback delivering a failure (e.g.,
false or an error) before calling pendingCallbacks.removeAll() and
clearQueuedMethodCalls(), then nil out connectedCallback and
onCollectionMessage. Apply the same change in the receive-failure teardown path
(the code around lines 323-327) so that any pending callbacks are drained with a
failure result before clearing state.
- Around line 314-321: After calling handleMessage within the case .success
branch, ensure the WebSocket task hasn't been replaced or canceled by callbacks
(e.g., disconnect()) by comparing the local task parameter to the current
webSocketTask; only call listenForMessages(task:) if task === webSocketTask.
Update the case .success(let message) -> .string(let text) path in the receive
loop to perform this identity check after handleMessage(text) and before
re-arming the receive with listenForMessages(task: task).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8b8fea08-911c-4ae5-9e5f-8fabbc9981f0
📒 Files selected for processing (1)
ios/Libraries/DDPClient.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
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
🔇 Additional comments (2)
ios/Libraries/DDPClient.swift (2)
46-50: TLS challenge delegate wiring looks good.The WebSocket session now routes authentication challenges through the shared
Challengepath.Also applies to: 406-415
374-377: Collection routing guard looks good.The default branch now only forwards collection-style payloads when
collectionis actually a string.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
ios/Libraries/DDPClient.swift (1)
28-30:⚠️ Potential issue | 🟠 MajorKeep
deiniton the state queue before tearing down.
disconnectOnStateQueue()still mutates state-queue-confined fields, butdeinitcan run from any thread. Please hop synchronously when not already onstateQueueto avoid racing URLSession callbacks or queued client operations.Proposed fix
deinit { - disconnectOnStateQueue() + if DispatchQueue.getSpecific(key: stateQueueKey) != nil { + disconnectOnStateQueue() + } else { + stateQueue.sync { + disconnectOnStateQueue() + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ios/Libraries/DDPClient.swift` around lines 28 - 30, deinit currently calls disconnectOnStateQueue() from an arbitrary thread and must instead ensure it runs on stateQueue synchronously; update deinit to check whether the current execution is already on stateQueue (using the queue's specific key or equivalent check used elsewhere in the class) and if so call disconnectOnStateQueue() directly, otherwise call stateQueue.sync { disconnectOnStateQueue() } so all state-queue-confined fields are mutated only while on stateQueue and avoid races with URLSession callbacks or queued operations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 167-183: The teardown drains pendingCallbacks, queuedMethodCalls
and clears connectedCallback but send-failure paths in login, subscribe and
callMethod may later call those same completions again; fix by guarding each
send-failure block to only invoke the completion if its corresponding entry
still exists (e.g., check pendingCallbacks[someId] != nil for
login/subscribe/callMethod, or that the queuedMethodCalls element still remains)
and remove the entry before calling, and similarly guard connectedCallback
before calling it; update the send-failure handlers referenced in login,
subscribe, and callMethod to perform this existence check before invoking the
stored completion.
---
Duplicate comments:
In `@ios/Libraries/DDPClient.swift`:
- Around line 28-30: deinit currently calls disconnectOnStateQueue() from an
arbitrary thread and must instead ensure it runs on stateQueue synchronously;
update deinit to check whether the current execution is already on stateQueue
(using the queue's specific key or equivalent check used elsewhere in the class)
and if so call disconnectOnStateQueue() directly, otherwise call stateQueue.sync
{ disconnectOnStateQueue() } so all state-queue-confined fields are mutated only
while on stateQueue and avoid races with URLSession callbacks or queued
operations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e78e0fb7-2dea-4467-b1ce-ec9aa8245ec4
📒 Files selected for processing (1)
ios/Libraries/DDPClient.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
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
🔇 Additional comments (3)
ios/Libraries/DDPClient.swift (3)
50-50: LGTM — TLS challenge routing is centralized.Creating the DDP
URLSessionwith a delegate and forwarding challenges throughChallenge.runChallengematches the app’s existing SSL/client-certificate path.Also applies to: 419-428
315-343: LGTM — receive failures now tear down safely.The current-task guards and state-queue dispatch address stale receive callbacks, and the failure path now routes through centralized cleanup.
377-390: LGTM — collection routing is stricter without mutating payloads.Forwarding only messages with a string
collectionkeeps malformed DDP payloads out of the VoIP handler while preserving the original message dictionary.
PR 3a from VoIP split plan: call disconnect on WebSocket receive errors, reuse existing Challenge TLS handling via URLSession delegate, remove dead default-branch capture, and add a DEBUG-only XCTest source plus bridging for Challenge. Disconnect is synchronous when already on the client queue so cleanup finishes before further queued work. Made-with: Cursor
Drop the retained urlSessionChallengeDelegate ivar (URLSession already retains its delegate) and collapse the duplicated task-level challenge method since URLSessionDelegate's session-level method handles all challenges when implemented.
Remove DDPClientReceiveFailureTests.swift and the DEBUG-only testing_* extension on DDPClient. The test file was not wired into an Xcode unit test target so it never ran; wire-up will land in a follow-up PR.
- Fail in-flight connect/login/subscribe/callMethod callbacks on receive-failure teardown so they never hang; queued method calls also complete with false. - Re-check task === webSocketTask after handleMessage before re-arming receive, so a handler-triggered disconnect does not revive a stale task. - Drop the no-op var message = json; message["collection"] = collection dance in the changed/added/removed branch; forward json directly. - Add a deinit that invalidates the URLSession to prevent leaks if a DDPClient is released without calling disconnect(). - Make SSLPinning.h self-contained by importing Foundation/NSURLSession instead of relying on transitive React imports.
Remove the failPending flag. The completion contract is that every callback passed in is called exactly once; silently dropping on user-initiated disconnect() or deinit broke that. Always drain, and route deinit through disconnectOnStateQueue() so task/session cancel and callback fail-out stay in one place.
…llbacks Teardown now drains pendingCallbacks, but an in-flight send can resolve afterward and invoke the same login/subscribe/callMethod completion a second time. Guard each fallback on removeValue returning non-nil so the fallback is a no-op when the drain already fired the completion.
5de9518 to
a1494c4
Compare
…DDPClient Previously disconnectOnStateQueue() discarded the URLError from a receive failure and passed a synthetic "disconnected" payload to all callbacks. Callers could not distinguish a TLS negotiation failure from a clean disconnect. Now passes the optional Error through so pending callbacks receive ["error": ["reason": "disconnected", "underlying": <localizedDescription>]] when a real error caused the teardown.
…/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>
Proposed changes
This pull request hardens the native iOS DDP WebSocket client used for VoIP media-signal monitoring.
URLSessionWebSocketTask.receivecompletes with an error, the client now performs a full teardown (invalidate session, clear task, clear callbacks) instead of leaving a "connected" shell that no longer reads messages.URLSessionis created with a small delegate that forwards authentication challenges to the existingChallengeimplementation inSSLPinning.mm—the same path already used by React Native networking swizzles—so WebSocket handshakes honor the app's client-certificate flow without introducing a second pinning stack.disconnect()runs cleanup synchronously when it is already invoked from the client's internal serial queue, so failure handling cannot enqueue snapshot work ahead of an asynchronous disconnect that has not finished yet.defaultbranch in DDP JSON handling still forwards collection-style payloads only whencollectionis a string, matching the shape used elsewhere; a redundant unused binding was removed.urlSessionChallengeDelegateivar (URLSessionalready retains its delegate) and collapsed the duplicatedURLSessionTaskDelegatechallenge method — the session-levelURLSessionDelegatemethod handles all challenges once implemented.Challengeis declared inSSLPinning.hand imported throughRocketChatRN-Bridging-Header.hso Swift can call the existing Objective-C entry point.Unit tests for receive-failure teardown will land in a follow-up PR once the Xcode unit test target wiring is in place.
Issue(s)
How to test or reproduce
Screenshots
N/A (native networking only).
Types of changes
Checklist
Further comments
Deferred items (test target wiring, send-path parity, manual TLS checklist) are recorded under iOS
DDPClient— WebSocket client hardening in the VoIP reviewfuture.mdfollow-up list for separate tickets.Summary by CodeRabbit
Bug Fixes
New Features