Skip to content

Conversation

nuno-vieira
Copy link
Member

@nuno-vieira nuno-vieira commented Aug 18, 2025

🔗 Issue Links

https://linear.app/stream/issue/IOS-1079/fix-livestreamchannelcontroller-not-reconnecting-when-connection-is

🎯 Goal

Fix LivestreamChannelController not reconnecting when the connection is dropped.

🛠 Implementation

Sync operation was not tracking livestream controllers.

🧪 Manual Testing Notes

One way to test this is by creating a breakpoint while debugging, and wait for a couple of minutes.
When doing so, sending messages and receiving messages should still work.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

Summary by CodeRabbit

  • New Features

    • Controllers can start and stop watching livestream channels; active livestreams are tracked and automatically re-watched after reconnects.
  • Bug Fixes

    • Fixed livestream channels not reconnecting after a dropped connection.
  • Tests

    • Added unit and integration tests covering start/stop watching, tracking in sync repository, and retry behavior for livestream watch operations.
  • Documentation

    • Changelog updated with an entry noting the livestream reconnection fix.

@nuno-vieira nuno-vieira requested a review from a team as a code owner August 18, 2025 15:30
Copy link

coderabbitai bot commented Aug 18, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Adds start/stop watching lifecycle APIs to LivestreamChannelController, tracks active livestream controllers in SyncRepository, updates WatchChannelOperation to use LivestreamChannelController and re-enqueue watch operations on reconnect, updates tests and test tooling, and documents the fix in CHANGELOG.md.

Changes

Cohort / File(s) Summary of changes
Docs
CHANGELOG.md
Added StreamChat > Fixed entry referencing PR #3782 about LivestreamChannelController reconnection.
Livestream controller APIs
Sources/.../Controllers/ChannelController/LivestreamChannelController.swift
Added startWatching(isInRecoveryMode:completion:) and stopWatching(completion:) with CID validation, SyncRepository tracking calls, updater coordination, and main-thread completions.
Sync operation (watch)
Sources/.../Repositories/SyncOperations.swift
WatchChannelOperation initializer changed to accept LivestreamChannelController; uses startWatching(isInRecoveryMode:) with retry logic and updates context.watchedAndSynchedChannelIds on success.
Sync repository tracking
Sources/.../Repositories/SyncRepository.swift
Added activeLivestreamControllers: ThreadSafeWeakCollection<LivestreamChannelController>, startTrackingLivestreamController, stopTrackingLivestreamController; removeAllTracked clears livestream entries; syncLocalState enqueues watch operations for tracked livestream controllers.
Test tooling (mocks & spies)
TestTools/.../Workers/ChannelUpdater_Mock.swift
TestTools/.../Spy/LivestreamChannelController_Spy.swift
Mock records isInRecoveryMode via @Atomic var startWatching_isInRecoveryMode; spy subclass added to inject startWatching errors and record calls.
Controller tests
Tests/.../Controllers/ChannelController/LivestreamChannelController_Tests.swift
Added tests for start/stop watching behavior, error propagation, and SyncRepository tracking during synchronize/start/stop.
Repository tests
Tests/.../Repositories/SyncRepository_Tests.swift
Tests/.../Repositories/SyncOperations_Tests.swift
Added tests for SyncRepository livestream tracking and WatchChannelOperation with LivestreamChannelController (including retry behavior). Note: the SyncOperations tests block appears duplicated in the patch.
Xcode project
StreamChat.xcodeproj/project.pbxproj
Added LivestreamChannelController_Spy.swift to the project build; removed several Thread DTO file references from the project file.

Sequence Diagram(s)

sequenceDiagram
  participant UI as App/UI
  participant LCC as LivestreamChannelController
  participant SR as SyncRepository
  participant CU as ChannelUpdater

  UI->>LCC: startWatching(isInRecoveryMode, completion)
  alt CID missing
    LCC-->>UI: completion(ChannelNotCreatedYet)
  else CID present
    LCC->>SR: startTrackingLivestreamController(self)
    LCC->>CU: startWatching(cid, isInRecoveryMode)
    CU-->>LCC: result (success/error)
    LCC-->>UI: completion(result) on main
  end
Loading
sequenceDiagram
  participant Net as Network
  participant SR as SyncRepository
  participant OpQ as Sync Ops Queue
  participant LCC as LivestreamChannelController
  participant CU as ChannelUpdater

  Net-->>SR: Reconnected
  SR->>OpQ: enqueue WatchChannelOperation for each tracked LCC
  OpQ->>LCC: WatchChannelOperation -> startWatching(isInRecoveryMode=false)
  LCC->>CU: startWatching(cid, false)
  CU-->>LCC: result
  alt success
    LCC->>OpQ: mark complete, add cid to watchedAndSynchedChannelIds
  else failure
    OpQ->>OpQ: retry WatchChannelOperation
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective (issue) Addressed Explanation
Fix LivestreamChannelController not reconnecting when connection is dropped (IOS-1079)

Out-of-scope changes

Code Change Explanation
Removal of Thread DTO references from Xcode project (StreamChat.xcodeproj/project.pbxproj) Project-file cleanup of Thread DTO references is unrelated to the reconnection objective.
Addition of LivestreamChannelController_Spy.swift to project file (StreamChat.xcodeproj/project.pbxproj) Adding the spy to the build is test scaffolding and not required by the reconnection fix itself.

Possibly related PRs

Suggested labels

🌐 SDK: StreamChat (LLC), ✅ Feature, 🟢 QAed

Suggested reviewers

  • martinmitrevski
  • laevandus

Poem

I twitch my ears at streams that drop,
I start, I stop, then hop back on top.
I track each watcher, reconnect with care,
A rabbit's hop brings watchers there. 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5762194 and 9869c22.

📒 Files selected for processing (1)
  • CHANGELOG.md (1 hunks)
✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/livestream-controller-membership-updates

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@Stream-SDK-Bot
Copy link
Collaborator

Stream-SDK-Bot commented Aug 18, 2025

SDK Size

title develop branch diff status
StreamChat 8.04 MB 8.04 MB +1 KB 🟢
StreamChatUI 4.86 MB 4.86 MB 0 KB 🟢

Copy link

Public Interface

 public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate, AppStateObserverDelegate  
-   public func loadPreviousMessages(before messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func startWatching(isInRecoveryMode: Bool,completion: ((Error?) -> Void)? = nil)
-   public func loadNextMessages(after messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func stopWatching(completion: ((Error?) -> Void)? = nil)
-   public func loadPageAroundMessageId(_ messageId: MessageId,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadPreviousMessages(before messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil)
+   public func loadNextMessages(after messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func createNewMessage(messageId: MessageId? = nil,text: String,pinning: MessagePinning? = nil,isSilent: Bool = false,attachments: [AnyAttachmentPayload] = [],mentionedUserIds: [UserId] = [],quotedMessageId: MessageId? = nil,skipPush: Bool = false,skipEnrichUrl: Bool = false,restrictedVisibility: [UserId] = [],location: NewLocationInfo? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Result<MessageId, Error>) -> Void)? = nil)
+   public func loadPageAroundMessageId(_ messageId: MessageId,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func deleteMessage(messageId: MessageId,hard: Bool = false,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil)
-   public func loadReactions(for messageId: MessageId,limit: Int = 25,offset: Int = 0,completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void)
+   public func createNewMessage(messageId: MessageId? = nil,text: String,pinning: MessagePinning? = nil,isSilent: Bool = false,attachments: [AnyAttachmentPayload] = [],mentionedUserIds: [UserId] = [],quotedMessageId: MessageId? = nil,skipPush: Bool = false,skipEnrichUrl: Bool = false,restrictedVisibility: [UserId] = [],location: NewLocationInfo? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Result<MessageId, Error>) -> Void)? = nil)
-   public func flag(messageId: MessageId,reason: String? = nil,extraData: [String: RawJSON]? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func deleteMessage(messageId: MessageId,hard: Bool = false,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func unflag(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadReactions(for messageId: MessageId,limit: Int = 25,offset: Int = 0,completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void)
-   public func addReaction(_ type: MessageReactionType,to messageId: MessageId,score: Int = 1,enforceUnique: Bool = false,skipPush: Bool = false,pushEmojiCode: String? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Error?) -> Void)? = nil)
+   public func flag(messageId: MessageId,reason: String? = nil,extraData: [String: RawJSON]? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func deleteReaction(_ type: MessageReactionType,from messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func unflag(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func pin(messageId: MessageId,pinning: MessagePinning = .noExpiration,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func addReaction(_ type: MessageReactionType,to messageId: MessageId,score: Int = 1,enforceUnique: Bool = false,skipPush: Bool = false,pushEmojiCode: String? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Error?) -> Void)? = nil)
-   public func unpin(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func deleteReaction(_ type: MessageReactionType,from messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func loadPinnedMessages(pageSize: Int = .messagesPageSize,sorting: [Sorting<PinnedMessagesSortingKey>] = [],pagination: PinnedMessagesPagination? = nil,completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void)
+   public func pin(messageId: MessageId,pinning: MessagePinning = .noExpiration,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func currentCooldownTime()-> Int
+   public func unpin(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func enableSlowMode(cooldownDuration: Int,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadPinnedMessages(pageSize: Int = .messagesPageSize,sorting: [Sorting<PinnedMessagesSortingKey>] = [],pagination: PinnedMessagesPagination? = nil,completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void)
-   public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil)
+   public func currentCooldownTime()-> Int
-   public func pause()
+   public func enableSlowMode(cooldownDuration: Int,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func resume(completion: (@MainActor(Error?) -> Void)? = nil)
+   public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil)
-   public func eventsController(_ controller: EventsController,didReceiveEvent event: Event)
+   public func pause()
-   public func applicationDidReceiveMemoryWarning()
+   public func resume(completion: (@MainActor(Error?) -> Void)? = nil)
-   public func applicationDidMoveToForeground()
+   public func eventsController(_ controller: EventsController,didReceiveEvent event: Event)
+   public func applicationDidReceiveMemoryWarning()
+   public func applicationDidMoveToForeground()

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (9)
Sources/StreamChat/Repositories/SyncOperations.swift (1)

165-186: Add a cid presence guard to avoid noisy retries when channel isn’t created yet.

Currently, if cid is nil, startWatching will fail and the op will retry up to the max. Guarding early reduces logs and unnecessary retries.

Apply this diff:

 init(livestreamController: LivestreamChannelController, context: SyncContext, recovery: Bool) {
   super.init(maxRetries: syncOperationsMaximumRetries) { [weak livestreamController] _, done in
-    guard let controller = livestreamController else {
+    guard let controller = livestreamController else {
       done(.continue)
       return
     }
 
-    let cidString = (controller.cid?.rawValue ?? "unknown")
+    guard let cid = controller.cid else {
+      done(.continue)
+      return
+    }
+    let cidString = cid.rawValue
     log.info("Watching active channel \(cidString)", subsystems: .offlineSupport)
     controller.startWatching(isInRecoveryMode: recovery) { error in
-      if let cid = controller.cid, error == nil {
+      if error == nil {
         log.info("Successfully watched active channel \(cidString)", subsystems: .offlineSupport)
         context.watchedAndSynchedChannelIds.insert(cid)
         done(.continue)
       } else {
         let errorMessage = error?.localizedDescription ?? "missing cid"
         log.error("Failed watching active channel \(cidString): \(errorMessage)", subsystems: .offlineSupport)
         done(.retry)
       }
     }
   }
 }
Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (2)

230-249: Clarify docstring and keep the main-thread completion behavior.

API shape looks good. Recommend reflecting the isInRecoveryMode parameter in the documentation for easier discoverability.

Apply this doc tweak:

-    /// Start watching a channel
-    ///
-    /// - Parameter completion: Called when the API call is finished. Called with `Error` if the remote update fails.
+    /// Starts watching the channel.
+    ///
+    /// - Parameters:
+    ///   - isInRecoveryMode: When true, the request is routed via the recovery client to align with reconnection flows.
+    ///   - completion: Called on the main thread when the API call finishes. Receives an `Error` if the remote update fails.

251-270: Docs: mirror behavior and side effects.

The implementation also stops tracking in the sync repository. It’s worth stating this explicitly in the docs to set expectations.

Apply this doc tweak:

-    /// Stop watching a channel
-    ///
-    /// - Parameter completion: Called when the API call is finished. Called with `Error` if the remote update fails.
+    /// Stops watching the channel.
+    ///
+    /// Also removes this controller from the SyncRepository’s active livestream controllers.
+    ///
+    /// - Parameter completion: Called on the main thread when the API call finishes. Receives an `Error` if the remote update fails.

Additionally, consider proactively untracking on deinit to keep the collection tidy (it’s weak, so this is optional):

// in deinit
client.syncRepository.stopTrackingLivestreamController(self)
Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift (4)

2274-2287: Avoid flakiness: ThreadSafeWeakCollection writes are async — assert with eventual consistency

ThreadSafeWeakCollection.add/remove use queue.async barriers. Immediate reads can race in tests. Prefer AssertAsync to wait until the mutation is observed.

Apply this diff:

-        XCTAssert(controller.client === client)
-        XCTAssert(client.syncRepository.activeLivestreamControllers.count == 1)
-        XCTAssert(client.syncRepository.activeLivestreamControllers.allObjects.first === controller)
+        XCTAssert(controller.client === client)
+        AssertAsync.willBeEqual(client.syncRepository.activeLivestreamControllers.count, 1)
+        AssertAsync.willBeTrue(client.syncRepository.activeLivestreamControllers.contains(controller))

2289-2303: Same flakiness concern for startWatching tracking assertions

Use AssertAsync to avoid racing the async barrier write in ThreadSafeWeakCollection.

Apply this diff:

-        XCTAssert(controller.client === client)
-        XCTAssert(client.syncRepository.activeLivestreamControllers.count == 1)
-        XCTAssert(client.syncRepository.activeLivestreamControllers.allObjects.first === controller)
+        XCTAssert(controller.client === client)
+        AssertAsync.willBeEqual(client.syncRepository.activeLivestreamControllers.count, 1)
+        AssertAsync.willBeTrue(client.syncRepository.activeLivestreamControllers.contains(controller))

2304-2317: Use eventual assertions for both add and remove tracking paths

Both startTracking and stopTracking are async; assert with eventual consistency for stability. Also, prefer XCTAssertTrue over “== true” for readability.

Apply this diff:

-        controller.synchronize()
-        XCTAssert(client.syncRepository.activeLivestreamControllers.count == 1)
+        controller.synchronize()
+        AssertAsync.willBeEqual(client.syncRepository.activeLivestreamControllers.count, 1)

-        controller.stopWatching()
-        XCTAssert(client.syncRepository.activeLivestreamControllers.allObjects.isEmpty == true)
+        controller.stopWatching()
+        AssertAsync.willBeTrue(client.syncRepository.activeLivestreamControllers.allObjects.isEmpty)

Optionally, you can also assert via the collection API:

AssertAsync.willBeTrue(client.syncRepository.activeLivestreamControllers.contains(controller) == false)

2085-2269: Consider adding a negative-path test for missing cid

Small gap: startWatching/stopWatching guard on cid and complete with ClientError.ChannelNotCreatedYet(). Adding a test ensures error propagation remains stable.

Would you like me to add a test like:

  • test_startWatching_withoutCid_callsCompletionWithChannelNotCreatedYet
  • test_stopWatching_withoutCid_callsCompletionWithChannelNotCreatedYet
Sources/StreamChat/Repositories/SyncRepository.swift (2)

201-210: Potential behavior nuance: tracking on synchronize() means “rewatch” even if never explicitly watched

LivestreamChannelController.synchronize() invokes startTrackingLivestreamController(self). This means any synchronized livestream controller will be re-watched after a reconnect, even if startWatching was never called. If this is the intended parity with ChatChannelController, all good; otherwise, consider tracking only when the controller is actively “watching” (startWatching), and untracking on stopWatching.


40-45: Note on eventual consistency of ThreadSafeWeakCollection in production paths

ThreadSafeWeakCollection.add/remove use async barriers. If a syncLocalState run happens immediately after a tracking call on another thread, the newly tracked object might miss that cycle. This matches the existing design for other trackers; just calling it out for awareness. If you want stricter guarantees here, a sync write or an explicit barrier drain would be needed, but that’s a broader pattern change.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 81a1d10 and 1749f02.

📒 Files selected for processing (7)
  • CHANGELOG.md (1 hunks)
  • Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (1 hunks)
  • Sources/StreamChat/Repositories/SyncOperations.swift (1 hunks)
  • Sources/StreamChat/Repositories/SyncRepository.swift (5 hunks)
  • TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift (3 hunks)
  • Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift (1 hunks)
  • Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (5)
Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (6)
Sources/StreamChat/Repositories/SyncRepository.swift (2)
  • startTrackingLivestreamController (94-97)
  • stopTrackingLivestreamController (99-101)
Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift (1)
  • channelQuery (48-52)
TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift (2)
  • startWatching (527-532)
  • stopWatching (534-538)
Sources/StreamChat/Controllers/ChannelController/ChannelController.swift (2)
  • startWatching (1430-1445)
  • stopWatching (1464-1479)
Sources/StreamChat/Workers/ChannelUpdater.swift (4)
  • startWatching (621-631)
  • startWatching (1019-1025)
  • stopWatching (640-644)
  • stopWatching (1027-1033)
Sources/StreamChat/StateLayer/Chat.swift (1)
  • stopWatching (118-121)
Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift (1)
Sources/StreamChat/Repositories/SyncRepository.swift (5)
  • startTrackingLivestreamController (94-97)
  • stopTrackingLivestreamController (99-101)
  • startTrackingChannelController (85-88)
  • startTrackingChannelListController (112-115)
  • removeAllTracked (121-127)
Sources/StreamChat/Repositories/SyncOperations.swift (2)
Sources/StreamChat/Utils/Logger/Logger.swift (1)
  • info (323-338)
Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (1)
  • startWatching (233-249)
Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift (3)
TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift (3)
  • startWatching (527-532)
  • cleanUp (161-300)
  • stopWatching (534-538)
Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (3)
  • startWatching (233-249)
  • stopWatching (254-270)
  • synchronize (215-228)
Sources/StreamChat/Workers/ChannelUpdater.swift (4)
  • startWatching (621-631)
  • startWatching (1019-1025)
  • stopWatching (640-644)
  • stopWatching (1027-1033)
Sources/StreamChat/Repositories/SyncRepository.swift (1)
Sources/StreamChat/Utils/ThreadSafeWeakCollection.swift (4)
  • contains (45-51)
  • add (27-31)
  • remove (33-37)
  • removeAllObjects (39-43)
⏰ 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). (2)
  • GitHub Check: Build Test App and Frameworks
  • GitHub Check: Metrics
🔇 Additional comments (13)
CHANGELOG.md (1)

6-8: CHANGELOG entry reads well and matches the PR intent.

The new “Fixed” entry under StreamChat clearly states the reconnection fix and links to the PR. No issues spotted.

TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift (3)

120-123: Good addition to capture recovery mode flag in tests.

Recording startWatching_isInRecoveryMode under @Atomic is appropriate and thread-safe for assertions.


261-263: Cleanup covers the new test hook.

Resetting startWatching_isInRecoveryMode in cleanUp() prevents test cross-contamination.


526-532: Override correctly records parameters and forwards completion.

The mock’s startWatching override captures cid, isInRecoveryMode, stores the completion, and triggers the canned result. This enables precise test control.

Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift (1)

446-503: Solid coverage for livestream controller tracking lifecycle.

The tests verify add, idempotency, remove, and inclusion in removeAllTracked(). This guards the new SyncRepository APIs against regressions.

Sources/StreamChat/Repositories/SyncOperations.swift (1)

141-163: Summary discrepancy: legacy ChatChannelController watch path still present.

AI summary claims the previous recoverWatchedChannel(recovery:) path was removed, but this initializer still uses it. If intentional (to keep channel controller recovery alongside livestream), consider updating the summary. If not, remove this initializer to avoid divergent paths.

Would you like me to scan SyncRepository for call sites to confirm both initializers are used as intended?

Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift (1)

222-223: Tracking livestream controllers during synchronize is correct.

Ensures the repository is aware of active livestream controllers before network work kicks off.

Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift (2)

2087-2161: Start watching tests validate both normal and recovery flows — looks good

The tests correctly assert the updater receives cid and the isInRecoveryMode flag, and that errors are surfaced. The API surface aligns with ChannelUpdater and the delegate call sites.


2198-2269: Stop watching tests cover success and failure — looks good

The tests correctly validate that stopWatching calls through to the updater and propagates errors.

Sources/StreamChat/Repositories/SyncRepository.swift (4)

43-45: Tracking livestream controllers — aligns with existing tracking pattern

Adding activeLivestreamControllers mirrors existing active* trackers and uses a weak, thread-safe collection. This is the right choice to avoid retention and enable re-watch orchestration.


94-101: start/stop tracking APIs are consistent and idempotent — good

Guarding with contains before add matches other trackers and keeps operations stable.


121-127: Include livestream controllers in cleanup — good

removeAllTracked now clears activeLivestreamControllers too; prevents stale state across sessions.


201-210: Re-watch wiring includes livestream controllers — confirm non-recovery path is intended

Appending WatchChannelOperation(livestreamController:…, recovery: false) mirrors channel controllers’ behavior. Given this is part of the “Background mode operations” (post-recovery), using a regular request is likely correct.

Action: please confirm WatchChannelOperation for livestreams switches to recovery requests only in the “Recovery mode operations” stage (if applicable), and that it does not create duplicate watch attempts when both a Chat and a Livestream controller point to the same cid.

@Stream-SDK-Bot
Copy link
Collaborator

SDK Performance

target metric benchmark branch performance status
MessageList Hitches total duration 10 ms 8.35 ms 16.5% 🔼 🟢
Duration 2.6 s 2.55 s 1.92% 🔼 🟢
Hitch time ratio 4 ms per s 3.28 ms per s 18.0% 🔼 🟢
Frame rate 75 fps 78.49 fps 4.65% 🔼 🟢
Number of hitches 1 0.8 20.0% 🔼 🟢

Copy link

1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@Stream-SDK-Bot
Copy link
Collaborator

Stream-SDK-Bot commented Aug 18, 2025

SDK Size

title develop branch diff status
StreamChat 8.04 MB 8.04 MB 0 KB 🟢
StreamChatUI 4.86 MB 4.86 MB 0 KB 🟢

Copy link

Public Interface

 public class LivestreamChannelController: DataStoreProvider, EventsControllerDelegate, AppStateObserverDelegate  
-   public func loadPreviousMessages(before messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func startWatching(isInRecoveryMode: Bool,completion: ((Error?) -> Void)? = nil)
-   public func loadNextMessages(after messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func stopWatching(completion: ((Error?) -> Void)? = nil)
-   public func loadPageAroundMessageId(_ messageId: MessageId,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadPreviousMessages(before messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil)
+   public func loadNextMessages(after messageId: MessageId? = nil,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func createNewMessage(messageId: MessageId? = nil,text: String,pinning: MessagePinning? = nil,isSilent: Bool = false,attachments: [AnyAttachmentPayload] = [],mentionedUserIds: [UserId] = [],quotedMessageId: MessageId? = nil,skipPush: Bool = false,skipEnrichUrl: Bool = false,restrictedVisibility: [UserId] = [],location: NewLocationInfo? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Result<MessageId, Error>) -> Void)? = nil)
+   public func loadPageAroundMessageId(_ messageId: MessageId,limit: Int? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func deleteMessage(messageId: MessageId,hard: Bool = false,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadFirstPage(_ completion: (@MainActor(_ error: Error?) -> Void)? = nil)
-   public func loadReactions(for messageId: MessageId,limit: Int = 25,offset: Int = 0,completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void)
+   public func createNewMessage(messageId: MessageId? = nil,text: String,pinning: MessagePinning? = nil,isSilent: Bool = false,attachments: [AnyAttachmentPayload] = [],mentionedUserIds: [UserId] = [],quotedMessageId: MessageId? = nil,skipPush: Bool = false,skipEnrichUrl: Bool = false,restrictedVisibility: [UserId] = [],location: NewLocationInfo? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Result<MessageId, Error>) -> Void)? = nil)
-   public func flag(messageId: MessageId,reason: String? = nil,extraData: [String: RawJSON]? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func deleteMessage(messageId: MessageId,hard: Bool = false,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func unflag(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadReactions(for messageId: MessageId,limit: Int = 25,offset: Int = 0,completion: @escaping @MainActor(Result<[ChatMessageReaction], Error>) -> Void)
-   public func addReaction(_ type: MessageReactionType,to messageId: MessageId,score: Int = 1,enforceUnique: Bool = false,skipPush: Bool = false,pushEmojiCode: String? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Error?) -> Void)? = nil)
+   public func flag(messageId: MessageId,reason: String? = nil,extraData: [String: RawJSON]? = nil,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func deleteReaction(_ type: MessageReactionType,from messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func unflag(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func pin(messageId: MessageId,pinning: MessagePinning = .noExpiration,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func addReaction(_ type: MessageReactionType,to messageId: MessageId,score: Int = 1,enforceUnique: Bool = false,skipPush: Bool = false,pushEmojiCode: String? = nil,extraData: [String: RawJSON] = [:],completion: (@MainActor(Error?) -> Void)? = nil)
-   public func unpin(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func deleteReaction(_ type: MessageReactionType,from messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func loadPinnedMessages(pageSize: Int = .messagesPageSize,sorting: [Sorting<PinnedMessagesSortingKey>] = [],pagination: PinnedMessagesPagination? = nil,completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void)
+   public func pin(messageId: MessageId,pinning: MessagePinning = .noExpiration,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func currentCooldownTime()-> Int
+   public func unpin(messageId: MessageId,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func enableSlowMode(cooldownDuration: Int,completion: (@MainActor(Error?) -> Void)? = nil)
+   public func loadPinnedMessages(pageSize: Int = .messagesPageSize,sorting: [Sorting<PinnedMessagesSortingKey>] = [],pagination: PinnedMessagesPagination? = nil,completion: @escaping @MainActor(Result<[ChatMessage], Error>) -> Void)
-   public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil)
+   public func currentCooldownTime()-> Int
-   public func pause()
+   public func enableSlowMode(cooldownDuration: Int,completion: (@MainActor(Error?) -> Void)? = nil)
-   public func resume(completion: (@MainActor(Error?) -> Void)? = nil)
+   public func disableSlowMode(completion: (@MainActor(Error?) -> Void)? = nil)
-   public func eventsController(_ controller: EventsController,didReceiveEvent event: Event)
+   public func pause()
-   public func applicationDidReceiveMemoryWarning()
+   public func resume(completion: (@MainActor(Error?) -> Void)? = nil)
-   public func applicationDidMoveToForeground()
+   public func eventsController(_ controller: EventsController,didReceiveEvent event: Event)
+   public func applicationDidReceiveMemoryWarning()
+   public func applicationDidMoveToForeground()

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
TestTools/StreamChatTestTools/SpyPattern/Spy/LivestreamChannelController_Spy.swift (2)

9-11: Capture and assert the isInRecoveryMode flag in the spy

Right now, the spy doesn’t expose whether startWatching was called with recovery=true/false, so tests can’t assert parameter forwarding. Capture it for stronger test guarantees.

Apply this diff:

@@
-    var startWatchingError: Error?
-    let spyState = SpyState()
+    var startWatchingError: Error?
+    let spyState = SpyState()
+    // Captures the last flag passed to `startWatching` for assertions
+    var startWatching_isInRecoveryMode: Bool?
@@
     override func startWatching(isInRecoveryMode: Bool, completion: ((Error?) -> Void)? = nil) {
         record()
+        startWatching_isInRecoveryMode = isInRecoveryMode
         completion?(startWatchingError)
     }

Also applies to: 16-19


12-14: Make CID injectable for targeted test scenarios (backward compatible)

Accept an optional cid to simplify deterministic assertions (e.g., when coordinating with pre-seeded DB state). Default keeps current behavior.

-    init(client: ChatClient_Mock) {
-        super.init(channelQuery: .init(cid: .unique), client: client)
-    }
+    init(client: ChatClient_Mock, cid: ChannelId = .unique) {
+        super.init(channelQuery: .init(cid: cid), client: client)
+    }
Tests/StreamChatTests/Repositories/SyncOperations_Tests.swift (2)

164-176: Assert parameter forwarding of isInRecoveryMode in success path

Now that the spy can capture the flag, assert it to ensure WatchChannelOperation forwards the recovery parameter correctly.

         XCTAssertEqual(context.watchedAndSynchedChannelIds.count, 1)
         XCTAssertTrue(context.watchedAndSynchedChannelIds.contains(controller.cid!))
         XCTAssertCall("startWatching(isInRecoveryMode:completion:)", on: controller)
+        XCTAssertEqual(controller.startWatching_isInRecoveryMode, true)

178-189: Also assert the isInRecoveryMode flag in failure/retry path and add a recovery-failure test

  • Validate the forwarded flag when recovery=false.
  • Consider adding a dedicated test for recovery=true + failure to assert there’s no retry.

Apply this diff to strengthen the existing test:

         XCTAssertEqual(context.watchedAndSynchedChannelIds.count, 0)
         XCTAssertCall("startWatching(isInRecoveryMode:completion:)", on: controller, times: 3)
+        XCTAssertEqual(controller.startWatching_isInRecoveryMode, false)

Optionally add the following new test to cover the recovery=true failure path (no retries expected). I can open a follow-up PR if helpful:

func test_WatchChannelOperation_livestreamController_startWatchingFailure_inRecovery_shouldNotRetry() {
    let context = SyncContext(lastSyncAt: .init())
    let controller = LivestreamChannelController_Spy(client: client)
    controller.startWatchingError = ClientError("Watch failed")

    let operation = WatchChannelOperation(livestreamController: controller, context: context, recovery: true)

    operation.startAndWaitForCompletion()

    XCTAssertEqual(context.watchedAndSynchedChannelIds.count, 0)
    XCTAssertCall("startWatching(isInRecoveryMode:completion:)", on: controller, times: 1)
    XCTAssertEqual(controller.startWatching_isInRecoveryMode, true)
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1749f02 and 5762194.

📒 Files selected for processing (3)
  • StreamChat.xcodeproj/project.pbxproj (4 hunks)
  • TestTools/StreamChatTestTools/SpyPattern/Spy/LivestreamChannelController_Spy.swift (1 hunks)
  • Tests/StreamChatTests/Repositories/SyncOperations_Tests.swift (1 hunks)
⏰ 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: Metrics
🔇 Additional comments (7)
StreamChat.xcodeproj/project.pbxproj (6)

1442-1442: LivestreamChannelController_Spy.swift build file entry looks correct

PBXBuildFile entry is well-formed and references the matching PBXFileReference ID.


4309-4309: PBXFileReference for LivestreamChannelController_Spy.swift is correct

Reference is consistent (sourcecode.swift, path-only) and will resolve under the Spy group.


8475-8475: Added spy to the Spy group — good organization

The file is grouped alongside other test doubles, keeping test utilities discoverable.


4310-4312: File references are correct—DTO files still exist
The ThreadDTO.swift, ThreadParticipantDTO.swift, and ThreadReadDTO.swift files are present on disk under Sources/StreamChat/Database/DTOs/. Keeping their PBXFileReference entries is appropriate; no removals are needed.

Likely an incorrect or invalid review comment.


1443-1445: Thread DTO build-file entries are valid; files still exist

The PBXBuildFile entries for ThreadDTO.swift, ThreadParticipantDTO.swift, and ThreadReadDTO.swift should remain, as these files are present under Sources/StreamChat/Database/DTOs. No removals are needed in StreamChat.xcodeproj/project.pbxproj. Instead, please update the AI summary to stop claiming these DTO files were deleted.

• No changes required in project.pbxproj
• Update the summary to reflect that Thread DTO files remain in the codebase

Likely an incorrect or invalid review comment.


11490-11490: No targets include LivestreamChannelController_Spy.swift

Confirmed via PBXSourcesBuildPhase mapping that LivestreamChannelController_Spy.swift isn’t added to any production target (or any target). No action needed here.

Tests/StreamChatTests/Repositories/SyncOperations_Tests.swift (1)

145-162: Good coverage: verifies the operation doesn’t retain the controller

This effectively guards against accidental strong captures inside WatchChannelOperation. No changes needed.

@nuno-vieira nuno-vieira merged commit 83fa8c5 into develop Aug 19, 2025
4 of 6 checks passed
@nuno-vieira nuno-vieira deleted the fix/livestream-controller-membership-updates branch August 19, 2025 11:15
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants