[cloud_firestore]: a snapshots() listener cancelled while it registers is never removed natively
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 72/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Lĩnh vực
- mobile-dev
Hướng nghiên cứu
Start with cloud_firestore_platform_interface/lib/src/method_channel/method_channel_document_reference.dart and method_channel_query.dart, then compare snapshotsInSync() in method_channel_firestore.dart. Run the supplied platform-interface Flutter regression test and verify that cancelling during the in-flight pigeon call does not send an event-channel listen, while normal cancellation still removes the native listener.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Is there an existing issue for this?
- I have searched the existing issues.
Which plugins are affected?
Other: cloud_firestore (cloud_firestore_platform_interface)
Which platforms are affected?
Android, iOS, macOS. The race is in the Dart method-channel implementation; the native stream handlers were checked on Android and iOS.
Description
MethodChannelDocumentReference.snapshots() and MethodChannelQuery.snapshots() attach a listener in two steps inside the broadcast controller's onListen:
- They
awaita pigeon call (documentReferenceSnapshot/querySnapshot). That call registers anEventChannelnatively and returns its observer id. - They subscribe to that event channel and store the subscription in
snapshotStreamSubscription. The event channel'slistenis what makes the nativeStreamHandler.onListencalladdSnapshotListener: AndroidDocumentSnapshotsStreamHandler/QuerySnapshotsStreamHandler, iOSFLTDocumentSnapshotStreamHandler/FLTQuerySnapshotStreamHandler.
onCancel only runs snapshotStreamSubscription?.cancel().
If the Dart subscription is cancelled while step 1 is still in flight, snapshotStreamSubscription is still null, so the cancel does nothing. When the pigeon call returns, onListen carries on and subscribes anyway:
- the event channel's
listengoes out, and the native listener attaches; - nothing ever sends
cancel, so the listener stays registered for the life of the process; - every change to its target is delivered, and billed as a read, into a controller that has no listeners.
Expected: a subscription cancelled before its listener has finished registering never attaches a native listener, or detaches it as soon as it does.
Actual: the native listener attaches after the cancel and is never removed.
The window is one pigeon round trip, so any code that cancels a snapshots() subscription soon after listening can hit it:
- a widget disposed right after it subscribes;
- a
StreamBuilderswapping streams; - a listener re-subscribed twice in quick succession.
Cancelling in the same turn (stream.listen(...).cancel()) always lands inside the window.
The code is identical in 8.0.7 and on main at ff6da47:
cloud_firestore_platform_interface/lib/src/method_channel/method_channel_document_reference.dartL121-158cloud_firestore_platform_interface/lib/src/method_channel/method_channel_query.dartL165-199MethodChannelFirebaseFirestore.snapshotsInSync()(method_channel_firestore.dartL191-207) has the same shape.
A possible fix: skip the event-channel subscribe when the listen was cancelled during the await. A generation counter, rather than a bool, also covers the broadcast controller being re-listened while the first await is still out:
StreamSubscription<dynamic>? snapshotStreamSubscription;
var listenGeneration = 0;
controller = StreamController<DocumentSnapshotPlatform>.broadcast(
onListen: () async {
final generation = ++listenGeneration;
final observerId = await MethodChannelFirebaseFirestore.pigeonChannel
.documentReferenceSnapshot(/* … */);
// Cancelled (or re-listened) while the native side was registering.
if (generation != listenGeneration) return;
snapshotStreamSubscription = /* … */.listen(/* … */);
},
onCancel: () {
listenGeneration++;
snapshotStreamSubscription?.cancel();
snapshotStreamSubscription = null;
},
);
Reproducing the issue
This is a self-contained test against the platform interface, with no Firebase app and no device. It mocks the pigeon call and the observer's event channel, and passes on 8.0.7: the event channel receives listen after the cancel, and never cancel.
import 'dart:async';
import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart';
import 'package:cloud_firestore_platform_interface/src/method_channel/method_channel_document_reference.dart';
import 'package:cloud_firestore_platform_interface/src/method_channel/method_channel_firestore.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
test('a snapshots() listener cancelled while it registers stays attached', () async {
const observerId = 'observer-1';
// The native registration (documentReferenceSnapshot), held in flight.
final reply = Completer<ByteData?>();
messenger.setMockMessageHandler(
'dev.flutter.pigeon.cloud_firestore_platform_interface.'
'FirebaseFirestoreHostApi.documentReferenceSnapshot',
(_) => reply.future,
);
// The observer's event channel: 'listen' is what calls addSnapshotListener
// natively, 'cancel' is what removes it.
final calls = <String>[];
messenger.setMockMethodCallHandler(
const MethodChannel(
'plugins.flutter.io/firebase_firestore/document/$observerId',
StandardMethodCodec(PigeonCodec()),
),
(call) async {
calls.add(call.method);
return null;
},
);
final reference = MethodChannelDocumentReference(
MethodChannelFirebaseFirestore(),
'users/alice',
FirestorePigeonFirebaseApp(
appName: '[DEFAULT]',
databaseURL: '(default)',
settings: InternalFirebaseSettings(ignoreUndefinedProperties: false),
),
);
final subscription = reference
.snapshots(listenSource: ListenSource.defaultSource)
.listen((_) {});
// Cancelled while documentReferenceSnapshot is still in flight.
await subscription.cancel();
reply.complete(
FirebaseFirestoreHostApi.pigeonChannelCodec.encodeMessage(<Object?>[
observerId,
]),
);
await Future<void>.delayed(Duration.zero);
// The native listener is attached after the cancel...
expect(calls, ['listen']);
// ...and nothing will ever send 'cancel' to remove it.
});
}
It also reproduces on a device. On an Android emulator (Firestore Android SDK 26.6.0, via cloud_firestore 6.9.0), with FirebaseFirestore.setLoggingEnabled(true), we ran FirebaseFirestore.instance.doc(path).snapshots().listen((_) {}).cancel() in one turn, on a document nothing else listens to:
- The watch stream sends
add_targetfor the document. - It never sends a
remove_targetfor that target. We watched for 142 s while the stream stayed healthy; the target stays live until the process dies. - For comparison, with the cancel held until the listener's first event (our workaround, below), the same call sends its
remove_target77 ms after the first snapshot.
Firebase Core version
4.14.0
Flutter Version
3.47.4
Relevant Log Output
# Unguarded: listen + cancel in one turn. target 304 is added and never removed.
22:18:14.696 I/Firestore: (26.6.0) [WatchStream]: (316b86b) Stream sending: # com.google.firestore.v1.ListenRequest
add_target { documents { documents: "projects/<project>/databases/(default)/documents/<collection>/<doc-a>" } resume_token: "" target_id: 304 }
22:18:14.724 I/Firestore: (26.6.0) [WatchStream]: Stream received: target_change { target_change_type: ADD target_ids: 304 }
22:18:14.741 I/Firestore: (26.6.0) [WatchStream]: Stream received: document_change { ... target_ids: 304 }
22:18:14.746 I/Firestore: (26.6.0) [WatchStream]: Stream received: target_change { target_change_type: CURRENT target_ids: 304 }
# ... no remove_target for 304 through 22:20:36 (142 s), stream healthy (global target_change heartbeats) ...
# Workaround (cancel held until the first event): target 306 is removed once it answers.
22:20:06.060 I/Firestore: (26.6.0) [WatchStream]: Stream sending: ListenRequest add_target { documents { documents: ".../<collection>/<doc-b>" } resume_token: "" target_id: 306 }
22:20:06.086 I/Firestore: (26.6.0) [WatchStream]: Stream received: target_change { target_change_type: ADD target_ids: 306 }
22:20:06.108 I/Firestore: (26.6.0) [WatchStream]: Stream received: document_change { ... target_ids: 306 }
22:20:06.110 I/Firestore: (26.6.0) [WatchStream]: Stream received: target_change { target_change_type: CURRENT target_ids: 306 }
22:20:06.137 I/Firestore: (26.6.0) [WatchStream]: Stream sending: ListenRequest remove_target: 306
22:20:06.192 I/Firestore: (26.6.0) [WatchStream]: Stream received: target_change { target_change_type: REMOVE target_ids: 306 }
Flutter dependencies
cloud_firestore 6.9.0, cloud_firestore_platform_interface 8.0.7, firebase_core 4.14.0. The bug is in the Dart method-channel layer, so no other dependency is involved.
Additional context and comments
We work around it in our app by holding a cancel that lands before the stream's first event, and carrying it out on that event. Any delivery proves snapshotStreamSubscription has been assigned, so from then on the cancel reaches the native side. The native listener still attaches briefly, though, and every app using snapshots() is exposed, so a fix here would be much better.
- Ngôn ngữ chính
- Dart
- Star
- 9.3k
- Fork
- 4.1k
- Merge trung bình
- 1 ngày 14 giờ
- Pull request đã merge (30 ngày)
- 39
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của firebase/flutterfire
-
Needs Attention platform: windows plugin: cloud_firestore reproduced type: bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
firebase/flutterfire#18735 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
blocked: customer-response platform: web plugin: cloud_firestore type: bug
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
firebase/flutterfire#18700 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Needs Attention platform: ios plugin: messaging reproduced type: bug
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 74/100
firebase/flutterfire#18699 · 4 bình luận · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
blocked: flutter platform: windows plugin: storage reproduced type: bug
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 25/100
firebase/flutterfire#18664 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[core] Decouple platform_interface from Flutter for pure-Dart webCó thể đã có người làm @Lyokone đã nhận 13 ngày trước. Đang mởNeeds Attention platform: all platform: web plugin: core type: enhancement
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 35/100
firebase/flutterfire#18645 · 2 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của firebase/flutterfire
Issue tương tự
-
Smart charging: USB charger re-assert is starved during BLE scans, so the tablet never dischargesĐang mởbug ready-for-agent
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 86/100
decentespresso/decaid#931 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 92/100
flame-engine/flame#4067 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
mrgnhnt96/zonai#37 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 88/100
google/skills_lint.dart#58 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[BUG] attempt to index nil valueĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
nvim-flutter/flutter-tools.nvim#557 ·
Maintainer thường phản hồi trong vòng 1 ngày