dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.3k stars 1.59k forks source link

Async try-catch will not break on exception where runZoned will #40594

Open filiph opened 4 years ago

filiph commented 4 years ago

I'm not sure that this is a bug. But even if it's not, I'd like to understand the following behavior so that I can explain it to users who might struggle with it. This has had pretty big influence on my productivity with Dart when debugging.

I have a project which sends error information using Sentry (an error monitoring service). Something like this:

Future<void> update() async {
  try {
    await _update();
  } catch (e, s) {
    _sendErrorToSentry(...);
    rethrow;
  }
}

I noticed some time ago that, in some instances, running a test in debug mode won't break on exception, and will instead break on the rethrow of that error. This means, most frustratingly, I can't inspect the variables at the time of the actual error. Which leads to print-debugging and similar hacks.

I recently made a change that went from the above to this:

Future<void> update() async {
  return runZoned(_update, onError: (Object e, StackTrace s) {
    _sendErrorToSentry(...);
    throw e;
  });
}

After this, I get breaks on the actual exception. This makes debugging much better.

I would like to understand why try-catch sometimes doesn't break-on-exception where I'd expect it to. Should we recommend runZoned for this reason?

Reproduction

I tried to make a minimal reproduction. For a more real-life repro, I can also make a branch of the full project.

example/runner.dart:
import 'dart:async';

import 'package:my_package/try_catch.dart';

const useZoned = false;

Future<void> main(List<String> args) {
  if (useZoned) {
    return withRunZoned();
  } else {
    return withTryCatch();
  }
}
lib/try_catch.dart:
import 'dart:async';

Future<int> withTryCatch() async {
  print('hi from withTryCatch');

  try {
    await foo();
  } catch (e, s) {
    print('caught in async try-catch: $e $s');
    rethrow;
  }

  return 42;
}

Future<int> withRunZoned() async {
  print('hi from withRunZoned');

  await runZoned(foo, onError: (e, s) {
    print('caught in runZoned: $e $s');
    throw e;
  });

  return 42;
}

Future<void> foo() async {
  await Future.delayed(const Duration(milliseconds: 200));
  throw FormatException(r'¯\_(ツ)_/¯');
}
test/repro_test.dart:
import 'package:test/test.dart';
import 'package:my_package/try_catch.dart';

void main() {
  test('withRunZoned', () async {
    expect(() async => withRunZoned(), returnsNormally);
  });

  test('withTryCatch', () async {
    expect(() async => withTryCatch(), returnsNormally);
  });
}
Instructions
  1. In your favorite IDE w/ Dart support, first debug-run example/runner.dart as is. This will break on the rethrow, and won't show the actual place where the error occured.
  2. Now, change line 5 in that file to const useZoned = true; and debug-run again. This time, the debugger stops at the throw inside foo(), and we are able to inspect variables.
  3. (optional) Try to run test/repro_test.dart. Both tests will pass, despite us rethrowing the error. And the print statements in the catch / onError blocks won't be executed.

Screencast

try-catch-runZoned 2020-02-11 15_57_05

Versions

Dart VM version: 2.7.1 (Thu Jan 23 13:02:26 2020 +0100) on "macos_x64"

MacOS

filiph commented 4 years ago

(Never mind on the test case. I think the correct test would expect(..., completion(returnsNormally)). If I do that, the test correctly fails. It still doesn't execute the print statements, but that seems like a distant issue to this one. So, please disregard the test. The issue with debugging, a.k.a. Instruction 1 and 2, still stands.)