Skip to content

Add dispose() method to ChatClient for resource cleanup#477

Open
AndyTWF wants to merge 1 commit intomainfrom
dispose-method
Open

Add dispose() method to ChatClient for resource cleanup#477
AndyTWF wants to merge 1 commit intomainfrom
dispose-method

Conversation

@AndyTWF
Copy link
Copy Markdown
Contributor

@AndyTWF AndyTWF commented Feb 2, 2026

Summary

Implements the dispose() method on ChatClient to allow explicit disposal of the chat client and release of all associated resources (CHA-CL1 spec item).

PR to fix spec: ably/specification#422

Changes

  • Add dispose() to ChatClientProtocol and ChatClient implementation
  • Add dispose() to DefaultRooms that releases all rooms concurrently
  • Add dispose() to room features that have internal state:
    • DefaultMessages - cleans up subscription point continuations
    • DefaultTyping - invalidates typing timers via TypingTimerManager
    • DefaultOccupancy - cleans up internal state
    • DefaultConnection - removes event listeners
    • DefaultMessageReactions - no-op (no internal state)
  • Add clientDisposed error case with error code 40014 (resourceDisposed)
  • Call feature dispose() methods during room release

Behavior

  • After disposal, attempting to get a room throws an error
  • The dispose() method is idempotent - calling it multiple times has no additional effect
  • Rooms are released concurrently during disposal

Test Plan

Added DisposeTests.swift with tests covering:

  • Client disposal prevents getting rooms
  • Idempotent disposal behavior
  • Room release during disposal

Summary by CodeRabbit

  • New Features

    • Added an async dispose() to the client to gracefully release resources; idempotent and prevents further operations with a clear error.
  • Behavior Changes

    • Disposal now cleans up rooms, message/typing/occupancy state, timers, and event listeners to avoid leaks and stale state.
  • Tests

    • New tests validating disposal behavior, idempotency, and resource cleanup.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 2, 2026

Walkthrough

Adds an explicit async disposal API: dispose() on ChatClient that marks the client disposed and cascades cleanup to Rooms, each Room’s features (Messages, Typing, Occupancy, Reactions), and the connection; introduces a new clientDisposed error case.

Changes

Cohort / File(s) Summary
Client Disposal
Sources/AblyChat/ChatClient.swift
Added func dispose() async to ChatClientProtocol and ChatClient; added private isDisposed; idempotent disposal that disposes rooms and connection.
Connection Cleanup
Sources/AblyChat/DefaultConnection.swift
Added eventListeners, isDisposed, enhanced onStatusChange to track listeners, and internal dispose() to cancel timers and unregister tracked ARTEventListeners (does not close underlying realtime).
Rooms Management
Sources/AblyChat/Rooms.swift
Added private isDisposed, get(named:options:) now throws clientDisposed if disposed, and internal idempotent dispose() that concurrently releases tracked rooms and clears state.
Room release changes
Sources/AblyChat/Room.swift
DefaultRoom.release now calls dispose() on typing, occupancy, and messages during release.
Feature disposals
Sources/AblyChat/DefaultMessages.swift, Sources/AblyChat/DefaultOccupancy.swift, Sources/AblyChat/DefaultTyping.swift, Sources/AblyChat/DefaultMessageReactions.swift
Added internal dispose() implementations: Messages removes channel state listener and clears subscription points; Occupancy clears cached occupancy; Typing delegates to timer manager dispose; Reactions adds a no-op dispose.
Typing timer manager
Sources/AblyChat/TypingTimerManager.swift
Added dispose() to TypingTimerManager to cancel heartbeat and all per-user typing timers; added dispose() requirement to TypingTimerManagerProtocol.
Error handling
Sources/AblyChat/InternalError.swift
Added clientDisposed enum case mapped to public resourceDisposed code (40014) and integrated into error-to-ErrorInfo/statusCode mappings.
Mocks & Example
Example/AblyChatExample/Mocks/MockClients.swift
Added no-op async dispose() to MockChatClient to satisfy protocol/lifecycle expectations.
Tests
Tests/AblyChatTests/DisposeTests.swift
New tests verifying disposal: prevents get() after dispose, releases all rooms once, idempotency, clears rooms map, and TypingTimerManager timer cleanup.
Repo metadata
.gitignore
Added .claude/settings.local.json to ignore list.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Client as ChatClient
    participant Rooms as Rooms
    participant Room as Room
    participant Features as RoomFeatures
    participant Conn as DefaultConnection

    App->>Client: dispose() async
    activate Client
    Client->>Rooms: dispose()
    activate Rooms
    Rooms->>Rooms: set isDisposed = true
    Rooms->>Room: release() (concurrent) for each tracked room
    activate Room
    Room->>Features: dispose()
    activate Features
    Features->>Features: cancel timers / remove listeners / clear caches
    deactivate Features
    Room->>Room: releaseChannel()
    deactivate Room
    Rooms->>Rooms: clear roomStates
    deactivate Rooms
    Client->>Conn: dispose()
    activate Conn
    Conn->>Conn: cancel timers
    Conn->>Conn: unregister eventListeners
    deactivate Conn
    Client->>Client: mark isDisposed
    deactivate Client
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibbled through listeners at dawn’s first light,
Timers hushed softly, each race stopped polite,
Rooms tucked away, subscriptions all cleared,
One gentle dispose — no surprises appeared,
The rabbit winked: all systems sleep tight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main change: adding a dispose() method to ChatClient for resource cleanup, which is the primary objective of the PR.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dispose-method

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.

@github-actions github-actions bot temporarily deployed to staging/pull/477/AblyChat February 2, 2026 19:59 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/477/AblyChat February 2, 2026 20:02 Inactive
Copy link
Copy Markdown

@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: 2

🤖 Fix all issues with AI agents
In `@Sources/AblyChat/DefaultConnection.swift`:
- Around line 7-12: The DefaultConnection class mutates eventListeners and
isDisposed and accesses timerManager from an Ably callback that runs off the
main thread, causing a data race; mark the class DefaultConnection with
`@MainActor` to isolate its mutable state and, inside the Ably
realtime.connection.on callback, explicitly hop to the main actor before
referencing self (or its properties) so all accesses to eventListeners,
isDisposed, and timerManager occur on the main actor.

In `@Sources/AblyChat/TypingTimerManager.swift`:
- Around line 89-99: The dispose() method lacks the required spec annotation;
update the method comment above internal func dispose() to include the
appropriate `@spec` tag (e.g., // `@spec` CHA-RL3g) to reference the Chat SDK
features spec for disposal behavior, ensuring you add the annotation alongside
the existing descriptive comment and keep references to cancelHeartbeatTimer(),
whoIsTypingTimers, and timer.cancelTimer() to make the behavior traceable.

AndyTWF added a commit to ably/docs that referenced this pull request Feb 2, 2026
Add new section documenting ChatClient.dispose() method for JavaScript,
React, Swift, Kotlin, and Jetpack Compose based on:
- ably/ably-chat-swift#477
- ably/ably-chat-kotlin#188
@github-actions github-actions bot temporarily deployed to staging/pull/477/AblyChat February 2, 2026 20:12 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/477/AblyChat February 2, 2026 20:19 Inactive
@AndyTWF
Copy link
Copy Markdown
Contributor Author

AndyTWF commented Feb 2, 2026

Addressed CodeRabbit's review comments:

  1. Added @MainActor to DefaultConnection class
  2. Wrapped Ably callback with Task { @MainActor in } for thread safety
  3. Added spec annotation to TypingTimerManager.dispose()

@github-actions github-actions bot temporarily deployed to staging/pull/477/AblyChat February 2, 2026 23:31 Inactive
@AndyTWF
Copy link
Copy Markdown
Contributor Author

AndyTWF commented Feb 3, 2026

(CI failing is unrelated to this change that we need to look into)

Implements CHA-CL1 spec: adds dispose() method that releases all resources
held by the ChatClient instance.

- ChatClient.dispose() releases all rooms (CHA-CL1a) then disposes connection (CHA-CL1b)
- DefaultRooms.dispose() releases all managed rooms concurrently
- DefaultConnection.dispose() removes all event listeners
- All dispose methods are idempotent
- Added MockChatClient.dispose() for example app compatibility
- Mark DefaultConnection as @mainactor and wrap Ably callback for thread safety
Copy link
Copy Markdown

@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: 2

🧹 Nitpick comments (1)
Tests/AblyChatTests/DisposeTests.swift (1)

84-90: Strengthen this test to prove concurrent room release, not just eventual release.

At Line 84 onward, signaling completion immediately can still pass if dispose() releases rooms serially. Add an assertion that both release() calls were started before completing either operation.

Suggested test hardening
         // When: dispose() is called
         async let disposeTask: Void = rooms.dispose()
+        await Task.yield()
+        `#expect`(room1.releaseCallCount == 1)
+        `#expect`(room2.releaseCallCount == 1)

         // Allow the release operations to complete
         room1ReleaseOperation.complete()
         room2ReleaseOperation.complete()

         await disposeTask

Also applies to: 92-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/AblyChatTests/DisposeTests.swift` around lines 84 - 90, The test
currently completes room1ReleaseOperation and room2ReleaseOperation immediately
which doesn't prove concurrent release; modify the mock release implementations
used by rooms.dispose() (the mocked release() calls represented by
room1ReleaseOperation and room2ReleaseOperation) to set a "started" flag or
fulfill an expectation when each release() begins, then assert both "started"
signals have occurred before calling complete() on either operation, and only
then complete both operations and await disposeTask; apply the same change to
the second occurrence (the block around lines 92-95) so both tests verify that
both release() calls were initiated prior to either finishing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AblyChat/DefaultConnection.swift`:
- Around line 61-64: The branch that cancels the transient timer and calls
callback(statusChange) can still allow execution to continue and emit the same
change again; in the methods touching isTimerRunning/currentState (referencing
isTimerRunning, currentState, timerManager.cancelTimer(), and
callback(statusChange)) cancel the timer and then immediately return/exit the
function (or otherwise short-circuit further handling) so the status change is
emitted only once; apply the same pattern to the other symmetric block that
covers lines 76-82 to prevent duplicate emissions.

In `@Tests/AblyChatTests/DisposeTests.swift`:
- Around line 20-22: The predicate closure passed to stream.first inside the
releaseImplementation closure must be annotated `@Sendable` to satisfy `@MainActor`
operator closure rules; update the closure passed to AsyncSequence.first (the
call in releaseImplementation) so the predicate is declared as an `@Sendable`
closure (e.g., annotate the parameter/closure) to match the pattern used
elsewhere (see releaseImplementation and the stream.first call).

---

Nitpick comments:
In `@Tests/AblyChatTests/DisposeTests.swift`:
- Around line 84-90: The test currently completes room1ReleaseOperation and
room2ReleaseOperation immediately which doesn't prove concurrent release; modify
the mock release implementations used by rooms.dispose() (the mocked release()
calls represented by room1ReleaseOperation and room2ReleaseOperation) to set a
"started" flag or fulfill an expectation when each release() begins, then assert
both "started" signals have occurred before calling complete() on either
operation, and only then complete both operations and await disposeTask; apply
the same change to the second occurrence (the block around lines 92-95) so both
tests verify that both release() calls were initiated prior to either finishing.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a6b725e and a97b5aa.

📒 Files selected for processing (13)
  • .gitignore
  • Example/AblyChatExample/Mocks/MockClients.swift
  • Sources/AblyChat/ChatClient.swift
  • Sources/AblyChat/DefaultConnection.swift
  • Sources/AblyChat/DefaultMessageReactions.swift
  • Sources/AblyChat/DefaultMessages.swift
  • Sources/AblyChat/DefaultOccupancy.swift
  • Sources/AblyChat/DefaultTyping.swift
  • Sources/AblyChat/InternalError.swift
  • Sources/AblyChat/Room.swift
  • Sources/AblyChat/Rooms.swift
  • Sources/AblyChat/TypingTimerManager.swift
  • Tests/AblyChatTests/DisposeTests.swift
✅ Files skipped from review due to trivial changes (1)
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sources/AblyChat/Room.swift
  • Sources/AblyChat/Rooms.swift
  • Sources/AblyChat/TypingTimerManager.swift

Comment on lines +61 to 64
if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed {
self.timerManager.cancelTimer()
callback(statusChange)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent duplicate status-change emission while transient timer is active.

When the branch at Line 61 emits callback(statusChange), execution continues and can emit again at Line 81 because isTimerRunning was captured before cancellation (Line 52). This can produce duplicate events for a single state transition.

💡 Proposed fix
                 if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed {
                     self.timerManager.cancelTimer()
                     callback(statusChange)
+                    return
                 }

Also applies to: 76-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AblyChat/DefaultConnection.swift` around lines 61 - 64, The branch
that cancels the transient timer and calls callback(statusChange) can still
allow execution to continue and emit the same change again; in the methods
touching isTimerRunning/currentState (referencing isTimerRunning, currentState,
timerManager.cancelTimer(), and callback(statusChange)) cancel the timer and
then immediately return/exit the function (or otherwise short-circuit further
handling) so the status change is emitted only once; apply the same pattern to
the other symmetric block that covers lines 76-82 to prevent duplicate
emissions.

Comment on lines +20 to +22
releaseImplementation = { @Sendable () async in
await (stream.first { _ in true })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n Tests/AblyChatTests/DisposeTests.swift | head -30 | tail -15

Repository: ably/ably-chat-swift

Length of output: 611


🏁 Script executed:

# Let's see a broader view around lines 20-22
sed -n '15,30p' Tests/AblyChatTests/DisposeTests.swift

Repository: ably/ably-chat-swift

Length of output: 507


🏁 Script executed:

# Let's also check the file structure to understand the `@MainActor` context
sed -n '1,50p' Tests/AblyChatTests/DisposeTests.swift

Repository: ably/ably-chat-swift

Length of output: 2011


🏁 Script executed:

# Verify the syntax is valid by checking if similar `@Sendable` annotations appear elsewhere in the codebase
rg -t swift '@Sendable.*in\s+' Tests/ --max-count=5

Repository: ably/ably-chat-swift

Length of output: 3290


🏁 Script executed:

# Check if there are other uses of AsyncSequence.first in the codebase to see patterns
rg -t swift '\.first\s*{' --max-count=10 -A 1

Repository: ably/ably-chat-swift

Length of output: 149


Add @Sendable annotation to the AsyncSequence.first predicate closure.

At line 21, the predicate closure passed to stream.first is missing the @Sendable annotation. This violates the coding guideline requiring operator closures in @MainActor contexts to be marked @Sendable to avoid data race warnings. The codebase consistently uses this pattern elsewhere (e.g., DefaultRoomsTests.swift, IntegrationTests.swift).

Suggested fix
             releaseImplementation = { `@Sendable` () async in
-                await (stream.first { _ in true })
+                await (stream.first { `@Sendable` _ in true })
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
releaseImplementation = { @Sendable () async in
await (stream.first { _ in true })
}
releaseImplementation = { `@Sendable` () async in
await (stream.first { `@Sendable` _ in true })
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/AblyChatTests/DisposeTests.swift` around lines 20 - 22, The predicate
closure passed to stream.first inside the releaseImplementation closure must be
annotated `@Sendable` to satisfy `@MainActor` operator closure rules; update the
closure passed to AsyncSequence.first (the call in releaseImplementation) so the
predicate is declared as an `@Sendable` closure (e.g., annotate the
parameter/closure) to match the pattern used elsewhere (see
releaseImplementation and the stream.first call).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant