Skip to content

fix(llc)!: prevent external mutation of ClientState collections#2686

Open
xsahil03x wants to merge 1 commit into
v10.0.0from
fix/unmodifiable-channels-map
Open

fix(llc)!: prevent external mutation of ClientState collections#2686
xsahil03x wants to merge 1 commit into
v10.0.0from
fix/unmodifiable-channels-map

Conversation

@xsahil03x
Copy link
Copy Markdown
Member

@xsahil03x xsahil03x commented May 26, 2026

Closes FLU-500

Summary

The collection getters on ClientStatechannels, users, activeLiveLocations — returned live internal references. Callers could mutate the cache directly and silently bypass channelsStream / usersStream / activeLiveLocationsStream. Wrap them in unmodifiable maps/lists at write time so both the synchronous getter and the stream emit immutable values, and lock down the cache-mutation surface with @internal.

What changed

Read-side

  • state.channels, state.users, state.activeLiveLocations now return unmodifiable maps/lists. Calling .clear(), .remove(...), [k] = v, etc. throws UnsupportedError.
  • The wrap happens once at write time inside the setter / cache-mutation method, so the getter is zero-allocation. Subscribers to channelsStream / usersStream / activeLiveLocationsStream also receive unmodifiable values.
  • BehaviorSubject seeds are unmodifiable too — subscribers connecting before any write still see immutable values.

Write-side hardening

  • All BehaviorSubject.add(...) calls in ClientState switched to safeAdd(...) (the existing close-safe extension). Emissions after close are no-ops instead of throwing.
  • removeChannel early-returns when the channel isn't cached and emits a freshly-built map, so .distinct() subscribers correctly observe the change. Previously channels = channels..remove(cid) re-emitted the same reference and missed the change for .distinct().
  • dispose() closes _channelsController before iterating the cached channels and disposing them. Each Channel.dispose() calls removeChannel(cid) which safeAdd-no-ops on the closed controller, so teardown produces zero subscriber emissions (vs. N progressively-shrinking maps previously).

@internal audit
The cache-mutation API surface on ClientState is now marked @internal. App flows (connectUser, client.queryChannels, client.startLiveLocationSharing, etc.) populate the cache through these methods and are unaffected.

  • Setters: channels=, currentUser=, activeLiveLocations=, totalUnreadCount=, blockedUserIds=
  • Methods: updateUsers, updateUser, addChannels, removeChannel

ChannelClientState is left out of this PR — the same pattern can be applied later when threads/typing events become a priority.

Tests

8 tests under ClientState mutation guards:

  • state.channels returns an unmodifiable view
  • state.users returns an unmodifiable view
  • state.activeLiveLocations returns an unmodifiable view
  • removeChannel emits a fresh map so .distinct() subscribers see the change
  • initial seeded values are unmodifiable (before any write)
  • channelsStream emits unmodifiable maps
  • usersStream emits unmodifiable maps
  • activeLiveLocationsStream emits unmodifiable lists

Full LLC suite passes (1389/1389) on the rebased branch.

Breaking

fix(llc)! — code that was mutating these collections directly (state.channels.remove(...), state.users.clear(), etc.) will now throw UnsupportedError at runtime. Migration: go through the existing addChannels / removeChannel / updateUser / updateUsers methods (now @internal, but callable by SDK code; apps populate the cache via API methods that hit these internally).

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 37187cfa-ea80-40cf-9af4-7d91eecbec78

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/unmodifiable-channels-map

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.

@xsahil03x xsahil03x marked this pull request as draft May 26, 2026 11:53
@xsahil03x xsahil03x force-pushed the fix/unmodifiable-channels-map branch from c7fbb3d to 44adb3b Compare May 26, 2026 14:43
@xsahil03x xsahil03x marked this pull request as ready for review May 26, 2026 14:46
@xsahil03x xsahil03x force-pushed the fix/unmodifiable-channels-map branch 2 times, most recently from 3da78f3 to d03f858 Compare May 26, 2026 15:00
@xsahil03x xsahil03x changed the title fix(llc)!: prevent external mutation of cached state collections fix(llc)!: prevent external mutation of ClientState collections May 26, 2026
`ClientState`'s `channels`, `users`, and `activeLiveLocations` getters
returned live internal references, so callers could mutate the cache
and silently bypass `channelsStream` / `usersStream` /
`activeLiveLocationsStream`. Wrap them in unmodifiable maps/lists at
write time so both the synchronous getter and the stream emit
immutable values without per-read allocation. Initial `BehaviorSubject`
seeds are unmodifiable too — subscribers connecting before any write
still get the same contract.

`removeChannel` now early-returns when the channel isn't cached and
emits a freshly-built map so `.distinct()` subscribers observe the
change. `dispose()` closes the channels controller first, then iterates
remaining channels and disposes them — `Channel.dispose()`'s
`removeChannel` call no-ops on the closed controller, so teardown
produces zero subscriber emissions instead of N progressively-shrinking
maps. All controller writes use `safeAdd` (no-ops on close).

Marks the cache-mutation surface as `@internal`:
- Setters: `channels=`, `currentUser=`, `activeLiveLocations=`,
  `totalUnreadCount=`, `blockedUserIds=`
- Methods: `updateUsers`, `updateUser`, `addChannels`, `removeChannel`

App flows (`connectUser`, `client.queryChannels`, etc.) populate the
cache through these methods and are unaffected.

Tests cover: read-side unmodifiability, initial-seed unmodifiability,
stream-side unmodifiability, and the fresh-map emission from
`removeChannel`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xsahil03x xsahil03x force-pushed the fix/unmodifiable-channels-map branch from d03f858 to c4d2860 Compare May 26, 2026 15:06
@codecov
Copy link
Copy Markdown

codecov Bot commented May 26, 2026

Codecov Report

❌ Patch coverage is 81.48148% with 5 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v10.0.0@427609a). Learn more about missing BASE report.

Files with missing lines Patch % Lines
packages/stream_chat/lib/src/client/client.dart 81.48% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             v10.0.0    #2686   +/-   ##
==========================================
  Coverage           ?   66.98%           
==========================================
  Files              ?      407           
  Lines              ?    24364           
  Branches           ?        0           
==========================================
  Hits               ?    16321           
  Misses             ?     8043           
  Partials           ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

1 participant