Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

[cloud_firestore]: a snapshots() listener cancelled while it registers is never removed natively

Đang mở
#18,724 1 bình luận 0 reaction 0 người được giao Xem trên GitHub

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
Công nghệ
dart, firebase, flutter
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ả

Needs Attention platform: android platform: ios platform: macos plugin: cloud_firestore reproduced type: bug
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:

  1. They await a pigeon call (documentReferenceSnapshot / querySnapshot). That call registers an EventChannel natively and returns its observer id.
  2. They subscribe to that event channel and store the subscription in snapshotStreamSubscription. The event channel's listen is what makes the native StreamHandler.onListen call addSnapshotListener: Android DocumentSnapshotsStreamHandler / QuerySnapshotsStreamHandler, iOS FLTDocumentSnapshotStreamHandler / 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 listen goes 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 StreamBuilder swapping 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.dart L121-158
  • cloud_firestore_platform_interface/lib/src/method_channel/method_channel_query.dart L165-199
  • MethodChannelFirebaseFirestore.snapshotsInSync() (method_channel_firestore.dart L191-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_target for the document.
  • It never sends a remove_target for 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_target 77 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

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của firebase/flutterfire

Tất cả issue của firebase/flutterfire

Issue tương tự

Thêm issue về Dart

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.