Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Possible memory leak on StreamProvider.autoDispose #193

Closed
fpabl0 opened this issue Oct 21, 2020 · 26 comments · Fixed by #194
Closed

Possible memory leak on StreamProvider.autoDispose #193

fpabl0 opened this issue Oct 21, 2020 · 26 comments · Fixed by #194
Labels
bug Something isn't working

Comments

@fpabl0
Copy link

fpabl0 commented Oct 21, 2020

Describe the bug
I am trying to replace StreamBuilders in favor of StreamProvider.autoDispose, however they don't behave equally (don't know if I am misunderstanding StreamProvider, if so, then sorry). Using watch(streamProvider) does not unsubscribe from the stream when the ui is destroyed as a consequence, every time I returned to that screen a new subscription is done with disposing the previous one. That's not the case with the StreamBuilder.

To Reproduce

I have this method in a class that return a stream:

final waypointsRepoProvider = Provider<WaypointsRepository>(
  (ref) => WaypointsFirestoreRepository(),
);

class WaypointsFirestoreRepository implements WaypointsRepository {
  @override
  Stream<List<WaypointModel>> getWaypointsStream() {
    final route = FirebaseFirestore.instance
            .collection("drivers")
            .doc("J5AIHrG0CH3kOpJUpwCq")
            .collection("route");
    return route.snapshots().map<List<WaypointModel>>((snapshot) {
      print("here"); // this message is the key to see if the stream has still listeners when the ui is destroyed
      return snapshot.docs.map((el) {
        return WaypointModel(
          position: el.get("position"),
          address: el.get("address"),
          completed: el.get("completed"),
        );
      }).toList();
    });
  }
}

With StreamBuilder (when user goes to other screen, if data changes "here" is not displayed, because the stream subscription has been closed by the streambuilder):

  Widget build(BuildContext context, ScopedReader watch) {
    final repo = watch(waypointsRepoProvider);
    return StreamBuilder(
      stream: repo.getWaypointsStream(),
      builder: (context, snapshot) => Container(
        child: snapshot.data != null
            ? Text("${snapshot.data[0]?.address}")
            : Text("no data"),
      ),
    );
 }

With StreamProvider (when user goes to other screen, "here" message still triggers when data changes, and you can get a lot of them at once, if you returned to the ui that use the stream many times):

final waypointsStreamProvider = StreamProvider.autoDispose<List<WaypointModel>>((ref) {
  final repo = ref.watch(waypointsRepoProvider);
  return repo.getWaypointsStream();
});
  Widget build(BuildContext context, ScopedReader watch) {
    final waypoints = watch(waypointsStreamProvider);
    return waypoints.maybeWhen(
      data: (w) => Container(child: Text("${w[0].address}")),
      orElse: () => Container(child: Text("no data")),
    );
 }

Expected behavior
I expect that the subscriptions are totally disposed when the ui that use the streamProvider is destroyed.

@fpabl0 fpabl0 added bug Something isn't working needs triage labels Oct 21, 2020
@rrousselGit rrousselGit added question Further information is requested and removed needs triage labels Oct 21, 2020
@rrousselGit
Copy link
Owner

Could you share fully executable examples with step by steps way to reproduce the problem?

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Yes, of course, I have created a github repository with an example with the problem:
https://github.com/fpabl0/riverpod_streamprovider_test

@rrousselGit rrousselGit removed the question Further information is requested label Oct 22, 2020
@rrousselGit
Copy link
Owner

Thanks!

I'm able to reproduce the issue. I will investigate

@rrousselGit
Copy link
Owner

I think that's a bug related to broadcast streams:

void main() {
  final stream = Stream.periodic(Duration(seconds: 5), (n) {
    print('$n');
    return n;
  });

  final sub = stream.asBroadcastStream().listen((value) {
    print('value $value');
  });

  print('cancel');
  sub.cancel();
}

This will print cancel then keep printing an incrementing number (but the "value" print does not appear)

@rrousselGit
Copy link
Owner

rrousselGit commented Oct 22, 2020

Interesting case. thanks for the report! Learned something new today

TL;DR, the bug was, Riverpod does:

StreamSubscription sub;
Stream publicStream;

initState() {
  publicStream = stream.asBroadcastStream();
  sub = publicStream.listen(...);
}

dispose() {
  sub.cancel();
}

But with this, while the subscriptions are closed, the broadcast stream never closes

The fix appears to be:

StreamSubscription sub;
Stream publicStream;
StreamController controller;

initState() {
  controller = StreamController.broadcast();
  publicStream = controller.stream;
  sub = stream.listen(controller.add, onError: controller.addError, onDone: controller.close);
}

dispose() {
  sub.cancel();
  controller.close();
}

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

void main() {
  final stream = Stream.periodic(Duration(seconds: 5), (n) {
    print('$n');
    return n;
  });

  final sub = stream.asBroadcastStream().listen((value) {
    print('value $value');
  });

  print('cancel');
  sub.cancel();
}

Hmm but in that case the value printed is because of the stream generator, the case in the example is this:

void main() async {
  final stream = Stream.periodic(Duration(seconds: 1), (n) {
    print('$n');
    return n;
  });

  final sub = stream.asBroadcastStream().map((event) {
    print("here");
    return event;
  }).listen((value) {
    print('value $value');
  });

  print('cancel');
  sub.cancel();

  await Future.delayed(Duration(seconds: 5));
}

You will see the numbers printed, but not "here" message, because the subscription was cancelled.

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Interesting case. thanks for the report! Learned something new today

TL;DR, the bug was, Riverpod does:

StreamSubscription sub;
Stream publicStream;

initState() {
  publicStream = stream.asBroadcastStream();
  sub = publicStream.listen(...);
}

dispose() {
  sub.cancel();
}

But with this, while the subscriptions are closed, the broadcast stream never closes

The fix appears to be:

StreamSubscription sub;
Stream publicStream;
StreamController controller;

initState() {
  controller = StreamController.broadcast();
  publicStream = controller.stream;
  sub = publicStream.listen(controller.add, onError: controller.addError, onDone: controller.close);
}

dispose() {
  sub.cancel();
  controller.close();
}

Thanks to you for this amazing library.
Hmm yes, I didn't know StreamProvider creates internally a broadcast stream, but that should be the solution, although I think the sub.cancel is not needed because controller.close() will dispose all the attached subscriptions?

@rrousselGit
Copy link
Owner

Yes but that is not what happen here.

The code is not stream.asBroadcast().map() but stream.map().asBroadcast

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Yes but that is not what happen here.

The code is not stream.asBroadcast().map() but stream.map().asBroadcast

Yep, I see, I didn't realize StreamProvider creates internally a broadcast stream.

@rrousselGit
Copy link
Owner

Yeah

The broadcast stream is needed for watch(streamProvider.stream).listen(...)

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Yeah

The broadcast stream is needed for watch(streamProvider.stream).listen(...)

Oh, I see, so that should be the solution, closing the StreamController in the dispose method.

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Hi @rrousselGit , sorry for comment on this closed issue, I just want to share that I have just realized that the broadcast stream can be closed using asBroadcastStream method too:

void main() async {
  final stream = Stream.periodic(Duration(seconds: 1), (n) {
    print('$n');
    return n;
  });

  final broadcastStream = stream.asBroadcastStream(
    onCancel: (sub) {
      print("don't have listeners now, so close the underlying subscription");
      sub.cancel();
    },
  );

  final sub1 = broadcastStream.listen((value) {
    print('value sub1 -> $value');
  });

  final sub2 = broadcastStream.listen((value) {
    print('value sub2 -> $value');
  });

  print('cancelling sub1');
  sub1.cancel();
  print('cancelling sub2');
  sub2.cancel();

  await Future.delayed(Duration(seconds: 5));
}

@rrousselGit
Copy link
Owner

Indeed

But in this case it makes more sense to have a StreamController

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

@rrousselGit version 0.12.0 solves this issue?

@rrousselGit
Copy link
Owner

Yes it does

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

Yes it does

Hmm I have updated the example to that version, however I am still getting the same problem. Am I missing anything?

@rrousselGit
Copy link
Owner

Ah my bad, I fixed it for StreamProvider but not autoDispose (which had the issue with context.refresh/ref.watch too)

@rrousselGit
Copy link
Owner

I'll deploy another fix later today, it's a small change

@fpabl0
Copy link
Author

fpabl0 commented Oct 22, 2020

I'll deploy another fix later today, it's a small change

Perfect, thank you so much 😄

@muhajirdev
Copy link

Hi @rrousselGit @fpabl0 ,

final userProvider = StreamProvider((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

final firebaseClassData = StreamProvider.autoDispose((ref) {
  final userStream = ref.watch(userProvider.last);

  final result = userStream.asStream().switchMap((user) {
    print('got user $user');
    return FirebaseFirestore.instance
        .collection('users')
        .doc(user.uid)
        .collection('courses')
        .snapshots()
        .map((snapshots) => snapshots.docs);
  }).map((event) {
    print('snapshot is $event');
    return event;
  });

  print('result is $result');

  return result;
});

final classListProvider =
    FutureProvider.autoDispose<List<ClassData>>((ref) async {
  final prismicClass = await ref.watch(allClassesProvider.future);
  print('got prismicClass data $prismicClass');
  final userClassData = await ref.watch(firebaseClassData.last);

  final result = prismicClass.results
      .map((result) => getClassData(result, userClassData))
      .toList();

  return result;
});

So my code looks like this. For some reason if I see the debug console on my vscode. It seems like firebaseClassData keeps re running again and again.

The logs looks like this

flutter: snapshot is [Instance of 'QueryDocumentSnapshot']
flutter: got prismicClass data Instance of 'PrismicClassData'
flutter: result is Instance of '_MapStream<List<QueryDocumentSnapshot>, List<QueryDocumentSnapshot>>'
flutter: got user User(displayName: null, email: xxx@gmail.com, emailVerified: true, isAnonymous: false, metadata: UserMetadata(creationTime: 2020-12-07 12:34:41.919, lastSignInTime: 2020-12-11 18:02:38.788), phoneNumber: null, photoURL: null, providerData, [UserInfo(displayName: Muhammad Muhajir, email: xxx@gmail.com, phoneNumber: null, photoURL: https://lh4.googleusercontent.com/-4wjF-SdZsNw/AAAAAAAAAAI/AAAAAAAABkI/s96-c/photo.jpg, providerId: google.com, uid: 116894881093999019464), UserInfo(displayName: null, email: xxx@gmail.com, phoneNumber: null, photoURL: null, providerId: apple.com, uid: 000306.cfd3ad86aa7c4344a0d5930ea8fe6999.1019)], refreshToken: , tenantId: null,  hZPCJNyk93aR1AYoAwimTyKVl9c2)
flutter: snapshot is [Instance of 'QueryDocumentSnapshot']
flutter: got prismicClass data Instance of 'PrismicClassData'
flutter: result is Instance of '_MapStream<List<QueryDocumentSnapshot>, List<QueryDocumentSnapshot>>'
flutter: got user User(displayName: null: xxx@gmail.com, emailVerified: true, isAnonymous: false, metadata: UserMetadata(creationTime: 2020-12-07 12:34:41.919, lastSignInTime: 2020-12-11 18:02:38.788), phoneNumber: null, photoURL: null, providerData, [UserInfo(displayName: Muhammad Muhajir, email: xxx@gmail.com, phoneNumber: null, photoURL: https://lh4.googleusercontent.com/-4wjF-SdZsNw/AAAAAAAAAAI/AAAAAAAABkI/s96-c/photo.jpg, providerId: google.com, uid: 116894881093999019464), UserInfo(displayName: null, email: xxx@gmail.com, phoneNumber: null, photoURL: null, providerId: apple.com, uid: 000306.cfd3ad86aa7c4344a0d5930ea8fe6999.1019)], refreshToken: , tenantId: null,  hZPCJNyk93aR1AYoAwimTyKVl9c2)
flutter: snapshot is [Instance of 'QueryDocumentSnapshot']
flutter: got prismicClass data Instance of 'PrismicClassData'
flutter: result is Instance of '_MapStream<List<QueryDocumentSnapshot>, List<QueryDocumentSnapshot>>'
flutter: got user User(displayName: null: xxx@gmail.com, emailVerified: true, isAnonymous: false, metadata: UserMetadata(creationTime: 2020-12-07 12:34:41.919, lastSignInTime: 2020-12-11 18:02:38.788), phoneNumber: null, photoURL: null, providerData, [UserInfo(displayName: Muhammad Muhajir, email: xxx@gmail.com, phoneNumber: null, photoURL: https://lh4.googleusercontent.com/-4wjF-SdZsNw/AAAAAAAAAAI/AAAAAAAABkI/s96-c/photo.jpg, providerId: google.com, uid: 116894881093999019464), UserInfo(displayName: null, email: xxx@gmail.com, phoneNumber: null, photoURL: null, providerId: apple.com, uid: 000306.cfd3ad86aa7c4344a0d5930ea8fe6999.1019)], refreshToken: , tenantId: null,  hZPCJNyk93aR1AYoAwimTyKVl9c2)
flutter: snapshot is [Instance of 'QueryDocumentSnapshot

But, if I change final firebaseClassData = StreamProvider.autoDispose((ref) { into final firebaseClassData = StreamProvider((ref) {

(removing autoDispose). Things seems to be working okay. Is this is a bug or I missed something ?

@rrousselGit
Copy link
Owner

It is likely that for a few frames, your firebaseClassData is no-longer listened and therefore destroyed

@muhajirdev
Copy link

muhajirdev commented Dec 11, 2020

Hi @rrousselGit . Thanks for the quick reply

So I tried to reproduce this issue without firebase

final testProvider = StreamProvider.autoDispose((ref) async* {
  ref.onDispose(() {
    print('testProvider disposed');
  });

  yield '1';
  yield '2';
  yield '3';
  yield '4';
});

final someFutureProvider = FutureProvider((ref) async {
  print('future is called');
  return 'something';
});

final test2Provider = FutureProvider.autoDispose((ref) async {
  final somefuture = await ref.watch(someFutureProvider.future);
  print('somefuture is $somefuture');
  final a = await ref.watch(testProvider.last);
  print('a is $a');

  return 'c';
});

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(
      child: Consumer(
        builder: (context, watch, child) {
          final u = watch(test2Provider);
          return u.when(
              data: (a) => Text(
                    "anything",
                    style: TextStyle(color: Colors.white),
                  ),
              loading: () => Text(
                    'a',
                    style: TextStyle(color: Colors.white),
                  ),
              error: (e, s) => Text(
                    'b',
                    style: TextStyle(color: Colors.white),
                  ));
        },
      ),
    ));


  }}

try to switch

 final somefuture = await ref.watch(someFutureProvider.future);
  print('somefuture is $somefuture');
  final a = await ref.watch(testProvider.last);
  print('a is $a');

with

final a = await ref.watch(testProvider.last);
print('a is $a');
final somefuture = await ref.watch(someFutureProvider.future);
print('somefuture is $somefuture');

what I found is, if I watch futureProvider first, then I'l' got infinite log like this

flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed
flutter: somefuture is something
flutter: a is 1
flutter: testProvider disposed

But if I switch the order, it will be okay

here's the log if I watch StreamProvider first

flutter: a is 1
flutter: future is called
flutter: somefuture is something
flutter: a is 4
flutter: somefuture is something

Any clue? is this different issue or same issue?

@muhajirdev
Copy link

It looks like the StreamProvider is dispossed and then recreated

@muhajirdev
Copy link

Btw what I am trying to accomplish here is to combine data from two sources. From firestore (stream) and from an API (future).

Am i doing it the right way?

@rrousselGit
Copy link
Owner

It may be a bug, I'll look at it later. Can you create a new issue?

@muhajirdev
Copy link

I opened new issue @rrousselGit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants