Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Fixed watchers not being removed from `ChannelClientState.watchers` on `user.watching.stop`.
- Fixed `Channel.name`/`image`/`extraData` setters throwing after a *failed* initialization; they now only throw once the channel is successfully initialized.
- Fixed `Channel.initialized` staying errored after a failed init; it now reflects a subsequent successful (re)initialization.
- Fixed a `StateError` (`Cannot add new events after calling close`) thrown when the client is disposed while a reconnect recovery is still in flight.

## 10.2.0

Expand Down
5 changes: 4 additions & 1 deletion packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,14 @@ class StreamChatClient {

/// Method called to add a new event to the [_eventController].
void handleEvent(Event event) {
// Ignore events that arrive after the client has been disposed.
if (_eventController.isClosed) return;

if (event.type == EventType.healthCheck) {
return _handleHealthCheckEvent(event);
}
state.updateUser(event.user);
return _eventController.add(event);
return _eventController.safeAdd(event);
}

void _onConnectionStatusChanged(
Expand Down
79 changes: 79 additions & 0 deletions packages/stream_chat/test/src/client/client_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars

import 'dart:async';

import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/stream_chat.dart';
Expand Down Expand Up @@ -5423,6 +5425,83 @@ void main() {
});
});

group('dispose during reconnect recovery', () {
const apiKey = 'test-api-key';
final user = User(id: 'test-user-id');
final token = Token.development(user.id).rawValue;

late FakeChatApi api;
late FakeWebSocket ws;
late StreamChatClient client;
var disposed = false;

setUpAll(() {
registerFallbackValue(const PaginationParams());
registerFallbackValue(Filter.equal('cid', ''));
});

setUp(() {
api = FakeChatApi();
ws = FakeWebSocket();
disposed = false;
});

// The test disposes the client itself; avoid disposing it a second time.
tearDown(() async {
if (!disposed) await client.dispose();
});

// Disposing the client while a reconnect is still recovering must complete
// cleanly: recovery work that finishes after disposal is discarded, never
// surfacing as an error.
test('disposing mid-recovery does not surface a late recovery event', () async {
// Keep the recovery's channel query pending so the client is still
// mid-recovery at the moment it is disposed.
final pendingQuery = Completer<QueryChannelsResponse>();
when(
() => api.channel.queryChannels(
filter: any(named: 'filter'),
sort: any(named: 'sort'),
state: any(named: 'state'),
watch: any(named: 'watch'),
presence: any(named: 'presence'),
memberLimit: any(named: 'memberLimit'),
messageLimit: any(named: 'messageLimit'),
paginationParams: any(named: 'paginationParams'),
),
).thenAnswer((_) => pendingQuery.future);

client = StreamChatClient(apiKey, chatApi: api, ws: ws);
await client.connectUser(user, token);
await delay(300);

// Track a channel so reconnecting triggers channel recovery, which then
// blocks on the pending query above.
final channel = Channel.fromState(
client,
ChannelState(channel: ChannelModel(cid: 'messaging:c1')),
);
client.state.addChannels({'messaging:c1': channel});

// Drop then restore the connection to start a reconnect recovery.
ws.connectionStatus = ConnectionStatus.disconnected;
await delay(100);
ws.connectionStatus = ConnectionStatus.connected;
await delay(100);

// Dispose while the recovery is still in flight.
await client.dispose();
disposed = true;

// Let the now-orphaned recovery finish. Its trailing work must be
// discarded silently instead of thrown as an unhandled async error.
pendingQuery.complete(QueryChannelsResponse()..channels = []);
await delay(300);

expect(client.wsConnectionStatus, ConnectionStatus.disconnected);
});
Comment on lines +5457 to +5502

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -a 'client_test.dart|client.dart' . | sed 's#^\./##' | head -100

echo
echo "Find references to _queryChannelsOnlineImpl and stubbed test"
rg -n "_queryChannelsOnlineImpl|disposing mid-recovery|queryChannels\(|addChannels|submitForDelivery|saveChannelQueries" packages -S || true

echo
echo "Show test lines around comment"
sed -n '5430,5515p' packages/stream_chat/test/src/client/client_test.dart 2>/dev/null || true

Repository: GetStream/stream-chat-flutter

Length of output: 20656


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -a 'client_test.dart|client.dart' . | sed 's#^\./##' | head -100

echo
echo "Find references to _queryChannelsOnlineImpl and stubbed test"
rg -n "_queryChannelsOnlineImpl|disposing mid-recovery|queryChannels\(

Repository: GetStream/stream-chat-flutter

Length of output: 981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect _queryChannelsOnlineImpl"
sed -n '860,970p' packages/stream_chat/lib/src/client/client.dart

echo
echo "Inspect fake persistence submitForDelivery state"
sed -n '170,215p' packages/stream_chat/test/src/fakes.dart

echo
echo "Inspect fake queryChannels implementation"
sed -n '340,430p' packages/stream_chat/test/src/fakes.dart

echo
echo "Behavioural/static verifier: check whether empty query returns before side effects"
python3 - <<'PY'
from pathlib import Path
text = Path('packages/stream_chat/lib/src/client/client.dart').read_text()
start = text.index('Future<QueryChannelsResult> _queryChannelsOnlineImpl')
end = text.index('\n  `@Deprecated`(', start)
method = text[start:end]
print('_queryChannelsOnlineImpl length:', len(method))
for label in [
    'final result = await _chatApi.channel.queryChannels',
    'if (result.channels.isEmpty)',
    'await channelDeliveryReporter.submitForDelivery',
    'await chatPersistenceClient?.saveChannelQueries',
    'this.state.addChannels',
]:
    idx = method.find(label)
    print(f'{label}: found={idx != -1!s}')
print('non-empty result path: addChannels inside result.channels.isNotEmpty block:', 'result.channels.isNotEmpty' in method)
PY

Repository: GetStream/stream-chat-flutter

Length of output: 5300


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search fake query channel implementation"
rg -n "class FakeChatApi|fake.*queryChannels|queryChannels.*Completer|QueryChannelsResponse" packages/stream_chat/test/src/fakes.dart packages/stream_chat/test/src -S || true

echo
echo "Show _queryChannelsOnlineImpl early exit region with line numbers"
nl -ba packages/stream_chat/lib/src/client/client.dart | sed -n '866,978p'

echo
echo "Show related recovery stub setup lines"
nl -ba packages/stream_chat/lib/src/client/client.dart | sed -n '2898,2918p'
nl -ba packages/stream_chat/test/src/client/client_test.dart | sed -n '5240,5305p'

Repository: GetStream/stream-chat-flutter

Length of output: 3023


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "FakeChatApi body"
sed -n '104,145p' packages/stream_chat/test/src/fakes.dart

echo
echo "Relevant recovery stub and disposal test"
sed -n '5240,5265p' packages/stream_chat/lib/src/client/client.dart
sed -n '5457,5505p' packages/stream_chat/test/src/client/client_test.dart

Repository: GetStream/stream-chat-flutter

Length of output: 2963


Cover non-empty channel recovery after disposal

Recovering with channels = [] triggers the early return in _queryChannelsOnlineImpl, so it does not exercise the post-disposal paths for state.addChannels, channelDeliveryReporter.submitForDelivery, or chatPersistenceClient?.saveChannelQueries. Add a companion case where the pending query resolves with one or more channels after client.dispose().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat/test/src/client/client_test.dart` around lines 5457 -
5502, Add a companion test near “disposing mid-recovery does not surface a late
recovery event” that completes the pending channel query after client.dispose()
with at least one channel. Ensure the non-empty recovery path executes
state.addChannels, channelDeliveryReporter.submitForDelivery, and
chatPersistenceClient?.saveChannelQueries, then verify the post-disposal
completion is discarded silently without an unhandled async error.

});

group('WS events', () {
late StreamChatClient client;

Expand Down
Loading