Dart - Detect non-completing futures

Viewed 64

I have a Dart console app that is calling into a third-party library.

When my console app calls the third-party library the call to the method returns however my CLI app then 'hangs' for 10 seconds or so before finally shutting down.

I suspect that the library has some type of resource that it has created but has not closed/completed.

My best guess is that it is a non-completed future.

So I'm looking for ways to detect resources that haven't been freed.

My first port of call would be looking for a technique to detect futures that haven't been completed but solutions for other resource types would be useful.

I'm currently using a runZoneGuarded, passing in a ZoneSpecification to hook calls.

Edit: with some experimentation, I've found I can detect timers and cancel them. In a simple experiment, I've found that a non-cancelled timer will cause the app to hang. If I cancel the timers (during my checkLeaks method) the app will shut down, however, this isn't enough in my real-world app so I'm still looking for ways to detect other resources.

Here is the experimental code I have:

#! /usr/bin/env dcli

import 'dart:async';

import 'package:dcli/dcli.dart';
import 'package:onepub/src/pub/global_packages.dart';
import 'package:onepub/src/pub/system_cache.dart';
import 'package:onepub/src/version/version.g.dart';
import 'package:pub_semver/pub_semver.dart';

void main(List<String> arguments) async {
  print(orange('OnePub version: $packageVersion '));

  print('');

  print(globals);

  // await globals.repairActivatedPackages();

  await runZonedGuarded(() async {
    Timer(Duration(seconds: 20), () => print('timer done'));
    unawaited(Future.delayed(Duration(seconds: 20)));
    var completer = Completer();
    unawaited(
        Future.delayed(Duration(seconds: 20), () => completer.complete()));

    //   await globals.activateHosted(
    //     'dcli_unit_tester',
    //     VersionConstraint.any,
    //     null, // all executables
    //     overwriteBinStubs: true,
    //     url: null, // hostedUrl,
    //   );
    print('end activate');
  }, (error, stackTrace) {
    print('Uncaught error: $error');
  }, zoneSpecification: buildZoneSpec());

  print('end');

  checkLeaks();

  // await entrypoint(arguments, CommandSet.ONEPUB, 'onepub');
}

late final SystemCache cache = SystemCache(isOffline: false);
GlobalPackages? _globals;
GlobalPackages get globals => _globals ??= GlobalPackages(cache);

List<void Function()> actions = [];
List<Source<Timer>> timers = [];

int testCounter = 0;
int timerCount = 0;
int periodicCallbacksCount = 0;
int microtasksCount = 0;

ZoneSpecification buildZoneSpec() {
  return ZoneSpecification(
    createTimer: (source, parent, zone, duration, f) {
      timerCount += 1;
      final result = parent.createTimer(zone, duration, f);
      timers.add(Source(result));
      return result;
    },
    createPeriodicTimer: (source, parent, zone, period, f) {
      periodicCallbacksCount += 1;
      final result = parent.createPeriodicTimer(zone, period, f);
      timers.add(Source(result));
      return result;
    },
    scheduleMicrotask: (source, parent, zone, f) {
      microtasksCount += 1;
      actions.add(f);
      final result = parent.scheduleMicrotask(zone, f);
      return result;
    },
  );
}

void checkLeaks() {
  print(actions.length);
  print(timers.length);

  print('testCounter $testCounter');
  print('timerCount $timerCount');
  print('periodicCallbacksCount $periodicCallbacksCount');
  print('microtasksCount $microtasksCount');

  for (var timer in timers) {
    if (timer.source.isActive) {
      print('Active Timer: ${timer.st}');
      timer.source.cancel();
    }
  }
}

class Source<T> {
  Source(this.source) {
    st = StackTrace.current;
  }
  T source;
  late StackTrace st;
}

I'm my real-world testing I can see that I do have hanging timers caused by HTTP connections. As I originally guessed this does seem to point to some other problem with the HTTP connections not being closed down correctly.

Active Timer: #0      new Source (file:///home/bsutton/git/onepub/onepub/bin/onepub.dart:105:21)
#1      buildZoneSpec.<anonymous closure> (file:///home/bsutton/git/onepub/onepub/bin/onepub.dart:68:18)
#2      _CustomZone.createTimer (dart:async/zone.dart:1388:19)
#3      new Timer (dart:async/timer.dart:54:10)
#4      _HttpClientConnection.startTimer (dart:_http/http_impl.dart:2320:18)
#5      _ConnectionTarget.returnConnection (dart:_http/http_impl.dart:2381:16)
#6      _HttpClient._returnConnection (dart:_http/http_impl.dart:2800:41)
#7      _HttpClientConnection.send.<anonymous closure>.<anonymous closure>.<anonymous closure> (dart:_http/http_impl.dart:2171:25)
#8      _rootRunUnary (dart:async/zone.dart:1434:47)
1 Answers

In general, it's impossible to find things that doesn't happen.

There is no way to find all futures in the program.

With a zone, you might be able to intercept all the callbacks being "registered" in the zone, but you can't know which of them must be called. A future can have both value handlers and an error handlers, and at most one of them will ever be called. So, just because a callback on a future isn't called, it doesn't mean the future didn't complete.

A future most likely won't keep the isolate alive, though. An incompleted future will just be garbage collected if nothing important is hanging on to it.

The most likely culprits for keeping an isolate alive are timers and receive ports. (The VM internal implementation of timers, and I/O, and sockets, all use receive ports, so it's really just the ports.)

Again, there is no way to find all open ports programmatically. You need a debugger with memory inspection tools for that.

I'd recommend using the developer tools to look for instances of ReceivePort or RawReceivePort that are not being garbage collected, and see whether they are still alive.


Also be careful with runZonedGuarded.

Since runZonedGuarded introduces a new error zone (because it introduces an uncaught error handler in the new zone), an error future created inside the zone will not be seen to complete outside the zone.

That means that the code:

await runZonedGuarded(() async {

will not work if the body throws. The error of the future is handled by the zone instead of the await, so the await just sees a future which never completes.

Related