fix(llc)!: prevent external mutation of ClientState collections#2686
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
c7fbb3d to
44adb3b
Compare
3da78f3 to
d03f858
Compare
`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>
d03f858 to
c4d2860
Compare
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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>
…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>
Closes FLU-500
Summary
The collection getters on
ClientState—channels,users,activeLiveLocations— returned live internal references. Callers could mutate the cache directly and silently bypasschannelsStream/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.activeLiveLocationsnow return unmodifiable maps/lists. Calling.clear(),.remove(...),[k] = v, etc. throwsUnsupportedError.channelsStream/usersStream/activeLiveLocationsStreamalso receive unmodifiable values.Write-side hardening
ClientStatego throughsafeAdd(...)(the existing close-safe extension). Emissions after close are no-ops instead of throwing.removeChannelearly-returns when the channel isn't cached and emits a freshly-built map, so.distinct()subscribers correctly observe the change. Previouslychannels = channels..remove(cid)re-emitted the same reference and missed the change for.distinct().dispose()closes_channelsControllerbefore iterating the cached channels and disposing them. EachChannel.dispose()callsremoveChannel(cid)whichsafeAdd-no-ops on the closed controller, so teardown produces zero subscriber emissions (vs. N progressively-shrinking maps previously).@internalauditThe cache-mutation API surface on
ClientStateis now marked@internal. App flows (connectUser,client.queryChannels,client.startLiveLocationSharing, etc.) populate the cache through these methods and are unaffected.channels=,currentUser=,activeLiveLocations=,totalUnreadCount=,blockedUserIds=updateUsers,updateUser,addChannels,removeChannelChannelClientStateis 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
@internalwrapper classes (inlib/src/core/util/immutable_collection_subjects.dart):ImmutableMapBehaviorSubject<K, V>ImmutableListBehaviorSubject<E>Both wrap a
BehaviorSubject<UnmodifiableMapView/ListView>, extendStreamView<T>, and implementSink<T>— so they behave like a regular broadcast stream + sink. Callers pass plainMap<K, V>/List<E>toadd/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
ClientStatenow use the wrappers instead of the inlineBehaviorSubject<...>.seeded(.unmodifiable(...))pattern.Tests
8 tests under
ClientState mutation guards:state.channelsreturns an unmodifiable viewstate.usersreturns an unmodifiable viewstate.activeLiveLocationsreturns an unmodifiable viewremoveChannelemits a fresh map so.distinct()subscribers see the changechannelsStreamemits unmodifiable mapsusersStreamemits unmodifiable mapsactiveLiveLocationsStreamemits unmodifiable listsPlus 18 unit tests for the wrappers in
immutable_collection_subjects_test.dart— covering both factory constructors,add/safeAddwrap behavior,addthrows after close,safeAddno-op after close, subscribers receive unmodifiable values, and IS-AStream/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 throwUnsupportedErrorat runtime. Migration: go through the existingaddChannels/removeChannel/updateUser/updateUsersmethods (now@internal, but callable by SDK code; apps populate the cache via API methods that hit these internally).🤖 Generated with Claude Code