Skip to content

fix(llc): update watcher count from realtime events and ignore events after dispose - #2898

Merged
VelikovPetar merged 4 commits into
v9from
port/master-to-v9/FLU-619_watcher-count-realtime-events
Aug 14, 2026
Merged

fix(llc): update watcher count from realtime events and ignore events after dispose#2898
VelikovPetar merged 4 commits into
v9from
port/master-to-v9/FLU-619_watcher-count-realtime-events

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-619, FLU-658

Github Issue: #

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Description of the pull request

Backports two llc fixes from master to v9. They are independent; each is a separate commit.


1. Watcher count / watchers from realtime events — FLU-619

Backport of master commit 84c6a24 (originally #2837).

Why

ChannelClientState.watcherCount and ChannelClientState.watchers never reflected realtime events during a session:

  • The server's watcher_count on user.watching.start / user.watching.stop / message.new was dropped into Event.extraData, so the count stayed frozen at the last full channel.watch() / queryChannels snapshot.
  • Watchers were never removed from watchers on user.watching.stop — the additive keyed merge in updateChannelState (needed for watcher pagination) can only upsert, so the departing watcher lingered until the next full query.

iOS and Android already trust the server-provided watcher_count per event, so Flutter was the outlier.

Changes

  • Add Event.watcherCount, mapping the server watcher_count field (promoted to a first-class field instead of extraData).
  • Apply the count on user.watching.start, user.watching.stop, and message.new.
  • Remove the departing watcher from ChannelClientState.watchers on user.watching.stop (assign _channelState directly, bypassing the additive merge).

notification.message_new / notification.thread_message_new are intentionally excluded: the backend fetches the watcher count only after the notification fan-out, so these events structurally carry watcher_count: 0.

Port notes

Manual port, not a clean cherry-pick:

  • Formatting — master formats at 120 cols; v9 uses the 80-col default, so every touched hunk needed rewrapping.
  • Event.deletedForMe — a master-only field adjacent to the ported hunks in event.dart; not brought over.
  • _listenMessageNew — master extracted this handler's body into addNewMessage, which does not exist on v9. The extracted body was verified byte-for-byte identical to v9's inline body, so the watcher-count block was appended at the equivalent point (after submitForDelivery).
  • event.g.dart — regenerated with json_serializable pinned to 6.9.5. The currently-resolving 6.14.1 emits Dart 3.10 null-aware map elements ('key': ?instance.x), which v9's sdk: ^3.6.2 rejects. Output matches v9's existing if (instance.x case final value?) style. Note separately: this means melos run generate:dart is currently broken on v9 with an up-to-date toolchain, independent of this PR.

Performance note

The message.new handler's extra updateChannelState call is O(1), not a message-list walk: copyWith(watcherCount:) preserves the messages list reference, and merge short-circuits on identical(other, this). It does add one extra channelStateStream emission, in a path that already emits multiple times per message (updateMessage, then the unreadCount setter). Same shape and cost as on master.


2. Ignore events dispatched after the client is disposed — FLU-658

Backport of master commit 4ba19d7 (originally #2855).

Why

StreamChatClient.handleEvent could throw StateError: Cannot add new events after calling close when the client was disposed while a reconnect recovery was still in flight.

_onConnectionStatusChanged is async. On an offline→online transition it awaits a recovery pipeline (sync / queryChannelsOnline) and only then emits the trailing connectionRecovered event, driven by a fire-and-forget subscription. If dispose() closes _eventController while that continuation is suspended on an await, the trailing handleEvent adds to a closed controller — surfacing as an unhandled async error.

Changes

Guard handleEvent against a closed controller (early return on isClosed, plus safeAdd).

Safety analysis on v9

Verified directly against the v9 sources rather than inherited from the master PR:

  • _eventController.close() has a single call site, inside dispose().
  • disconnectUser() closes the WS and resets state/credentials/persistence but leaves the event controller open, so the logout→login reuse path is unaffected — the guard cannot swallow events on a live or reused client.
  • _eventController is a non-late final initialized inline and never reassigned, so isClosed is terminal for the client's lifetime.
  • Post-close() the broadcast stream is already complete, so no subscriber could have received these events; the guard removes the throw, not a delivery.

Semantic equivalence with master, despite a controller-type divergence: master's _eventController is a master-only EventController<Event>, v9's is a PublishSubject<Event> — but both are rxdart Subject subclasses backed by a broadcast StreamController, so isClosed is the same inherited implementation (Subject.isClosed => _controller.isClosed) and safeAdd resolves to the same StreamControllerX extension on both. The controllers differ only in where the poll-vote→poll-answer normalization happens (master overrides EventController.add; v9 maps it in the eventStream getter), which is orthogonal to the guard.

Behaviour change worth naming (identical placement on master): because the guard precedes the health-check branch, a health.check arriving after dispose no longer runs _handleHealthCheckEvent. That is correct — disconnectUser() has by then reset the connection-id manager and closed the persistence connection, so those writes would target torn-down objects.

Port notes

The client.dart hunk applies cleanly (v9's handleEvent is byte-identical to master's pre-fix version). Two adaptations:

  • v9's client.dart lacked import 'package:stream_chat/src/core/util/extension.dart' (master has it), so safeAdd did not resolve until it was added. This is the only line beyond master's diff.
  • The test group is appended at the end of main(). Master inserts it before group('WS events'), which does not exist in v9's client_test.dart. import 'dart:async' was also added for Completer.

Tests

FLU-619 — new Watching Events group in channel_test.dart: start/stop update the count, stop removes the watcher, a null count preserves the existing value, message.new updates the count, message.new without a count preserves it, and notification.message_new does not overwrite it.

Beyond the master commit, event_test.dart's existing parse / serialize / copyWith tests and test/fixtures/event.json were extended to cover watcher_count, following this repo's convention of extending existing serialization tests when adding model fields.

FLU-658 — new dispose during reconnect recovery group in client_test.dart, disposing the client while a recovery is suspended on a held-open queryChannels, then resuming it. Verified as a genuine regression test: with the guard reverted it fails with exactly Bad state: Cannot add new events after calling close; with the guard it passes.

Verification

  • melos run format — clean (0 changed).
  • melos run analyzestream_chat: No issues found.
  • Full stream_chat suite: 1309 pass, 2 skipped, 0 fail.

Screenshots / Videos

No UI changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 Plus

Run ID: dc5cd265-d867-4846-96eb-01cbd807bb86

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

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VelikovPetar VelikovPetar changed the title fix(llc): update watcher count and watchers from realtime events fix(llc): update watcher count from realtime events and ignore events after dispose Aug 14, 2026
@VelikovPetar
VelikovPetar marked this pull request as ready for review August 14, 2026 15:07
@VelikovPetar
VelikovPetar enabled auto-merge (squash) August 14, 2026 15:42
@VelikovPetar
VelikovPetar merged commit d3afd64 into v9 Aug 14, 2026
22 checks passed
@VelikovPetar
VelikovPetar deleted the port/master-to-v9/FLU-619_watcher-count-realtime-events branch August 14, 2026 15:50
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (v9@fc3942c). Learn more about missing BASE report.

Additional details and impacted files
@@          Coverage Diff          @@
##             v9    #2898   +/-   ##
=====================================
  Coverage      ?   67.39%           
=====================================
  Files         ?      431           
  Lines         ?    27417           
  Branches      ?        0           
=====================================
  Hits          ?    18477           
  Misses        ?     8940           
  Partials      ?        0           

☔ View full report in Codecov by Harness.
📢 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.

2 participants