Skip to content

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

Merged
xsahil03x merged 6 commits into
v10.0.0from
fix/unmodifiable-channels-map
May 28, 2026
Merged

fix(llc)!: prevent external mutation of ClientState collections#2686
xsahil03x merged 6 commits 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.
  • Initial seeds are unmodifiable too — subscribers connecting before any write still see immutable values.

Write-side hardening

  • All write calls in ClientState go through 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.

Extracted wrapper module (post-review)

The unmodifiable-wrap discipline lives in two @internal wrapper classes (in lib/src/core/util/immutable_collection_subjects.dart):

  • ImmutableMapBehaviorSubject<K, V>
  • ImmutableListBehaviorSubject<E>

Both wrap a BehaviorSubject<UnmodifiableMapView/ListView>, extend StreamView<T>, and implement Sink<T> — so they behave like a regular broadcast stream + sink. Callers pass plain Map<K, V> / List<E> to add / safeAdd; the unmodifiable wrap happens inside, structurally impossible to forget or bypass via the wrapper's public surface.

The file also exports two public typedefs to keep the "Immutable" naming consistent:

  • ImmutableMap<K, V> = UnmodifiableMapView<K, V>
  • ImmutableList<E> = UnmodifiableListView<E>

The three collection controllers in ClientState now use the wrappers instead of the inline BehaviorSubject<...>.seeded(.unmodifiable(...)) pattern.

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

Plus 18 unit tests for the wrappers in immutable_collection_subjects_test.dart — covering both factory constructors, add/safeAdd wrap behavior, add throws after close, safeAdd no-op after close, subscribers receive unmodifiable values, and IS-A Stream/Sink.

Full LLC suite passes (1407/1407) 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: 3e844af6-1d39-4ac6-ac2b-17ef3831980b

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 87.71930% with 7 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 ⚠️
...b/src/core/util/immutable_collection_subjects.dart 93.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             v10.0.0    #2686   +/-   ##
==========================================
  Coverage           ?   67.02%           
==========================================
  Files              ?      408           
  Lines              ?    24394           
  Branches           ?        0           
==========================================
  Hits               ?    16349           
  Misses             ?     8045           
  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.

Comment thread packages/stream_chat/lib/src/client/client.dart
Comment thread packages/stream_chat/lib/src/client/client.dart Outdated
Replace the inline `BehaviorSubject<UnmodifiableMapView/ListView<...>>` +
manual wrap pattern in `ClientState` with two reusable wrapper classes:
`ImmutableMapBehaviorSubject<K, V>` and `ImmutableListBehaviorSubject<E>`.

The wrappers extend `StreamView<T>` and implement `ValueStream<T>` +
`Sink<T>`, so callers use them like any rxdart subject — `.listen(...)`,
`.value`, `.add(...)`, `.safeAdd(...)`. The unmodifiable wrap happens
inside `add` and `safeAdd`, so callers pass plain `Map<K, V>` / `List<E>`
without having to remember `UnmodifiableMapView(...)` at every write site.
The wrap is structurally impossible to forget or bypass through the
wrapper's public surface.

This addresses the review comment about extracting the discipline into a
reusable shape; the same wrappers can later be applied to
`ChannelClientState`'s collection controllers without re-establishing the
discipline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/stream_chat/lib/src/core/util/immutable_collection_subjects.dart Outdated
xsahil03x and others added 4 commits May 28, 2026 12:03
…jects

Per review feedback: implementing rxdart's `ValueStream<T>` couples the
SDK to a third-party interface we don't control. If rxdart adds an
abstract member at any version bump, our build breaks.

Drop `implements ValueStream<T>` from both `ImmutableMapBehaviorSubject`
and `ImmutableListBehaviorSubject`. Remove the seven forwarding getters
that existed only to satisfy that interface (`valueOrNull`, `hasValue`,
`error`, `errorOrNull`, `hasError`, `stackTrace`, `lastEventOrNull`) —
none are used in `client.dart`.

Kept: `extends StreamView<T>` and `implements Sink<T>` (both `dart:async`,
stable stdlib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ects

The previous commit dropped `hasValue`/`valueOrNull` along with the
`ValueStream` interface, which left the non-seeded constructor untested.
Add two tests per wrapper:

- reading `value` before the first add throws (BehaviorSubject contract)
- the first add becomes the current `value`, wrapped unmodifiable

Uses `throwsA(isA<Error>())` instead of pinning to rxdart's
`ValueStreamError` so the test stays decoupled from rxdart internals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce `ImmutableMap<K, V>` and `ImmutableList<E>` typedefs over
`dart:collection`'s `UnmodifiableMapView` / `UnmodifiableListView`. The
wrapper classes and tests now use the aliases throughout, keeping the
"immutable" naming consistent with `ImmutableMapBehaviorSubject` and
`ImmutableListBehaviorSubject`.

Purely a rename — no behavior change. `UnmodifiableMapView` /
`UnmodifiableListView` remain the underlying runtime types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Mention "broadcast stream — multiple subscribers can listen at once"
  at the class-doc level for both wrappers (was implicit before).
- Constructor docs: "Creates an [ClassName] ..." → "Creates a subject
  ..." (Effective Dart "AVOID redundancy with surrounding context").
- "See [StreamController.broadcast] for ..." → "... follow
  [StreamController.broadcast] semantics" — declarative instead of a
  bare cross-reference.
- "the wrap is handled here so it cannot be forgotten or bypassed" →
  "wrapping is automatic and unbypassable" — tighter, less impl-leaky.
- Drop `@internal` from `ImmutableMap` / `ImmutableList` typedefs so the
  aliases are usable from SDK code without the warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xsahil03x xsahil03x merged commit 2501f53 into v10.0.0 May 28, 2026
11 checks passed
@xsahil03x xsahil03x deleted the fix/unmodifiable-channels-map branch May 28, 2026 10:24
@xsahil03x xsahil03x mentioned this pull request May 28, 2026
6 tasks
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.

2 participants