Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#18,724 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
3/5
Tempo stimato
1-2 giorni
Idoneità per principianti
72/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
dart, firebase, flutter
Ambito
mobile-dev

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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.

Lingua principale
Dart
Stelle
9.3k
Fork
4.1k
Merge medio
1g 14h
PR unite (30g)
39

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di firebase/flutterfire

Tutte le issue di firebase/flutterfire

Issue simili

Altre issue su Dart

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.