Rationalise all our exception handling.

- Create a FlutterErrorDetails struct-like class that describes an

  exception along with more details that aren't in the exception, like

  where it was caught and what was going on when it was caught.



- Provide a FlutterError static API for handling these objects:



  - FlutterError.onError which is called whenever Flutter catches an

    error.



  - FlutterError.reportError() which handles an error.



  - FlutterError.dumpErrorToConsole() which is the default behavior

    for onError.



- Removes all the existing exception handler callbacks.



- Replaces all the existing places that described exceptions using

  debugPrint with calls to FlutterError.reportError().



- Extend lockState() to also catch exceptions, so that we catch

  exceptions that happen during finalizers.



- Make the test framework catch errors and treat them as failures.



- Provide a mechanism to override this behavior in the test framework.



- Make the tests that used to depend on the exception handler

  callbacks use this new mechanism.



- Make pump() also support the phase argument.



- Improve some tests using these new features.



Fixes #2356, #2988, #2985, #2220.
diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart
index d71c401..b1d9ee56 100644
--- a/packages/flutter/lib/src/gestures/binding.dart
+++ b/packages/flutter/lib/src/gestures/binding.dart
@@ -16,8 +16,6 @@
 import 'hit_test.dart';
 import 'pointer_router.dart';
 
-typedef void GesturerExceptionHandler(PointerEvent event, HitTestTarget target, dynamic exception, StackTrace stack);
-
 /// A binding for the gesture subsystem.
 abstract class Gesturer extends BindingBase implements HitTestTarget, HitTestable {
 
@@ -82,14 +80,6 @@
     result.add(new HitTestEntry(this));
   }
 
-  /// This callback is invoked whenever an exception is caught by the Gesturer
-  /// binding. The 'event' argument is the pointer event that was being routed.
-  /// The 'target' argument is the class whose handleEvent function threw the
-  /// exception. The 'exception' argument contains the object that was thrown,
-  /// and the 'stack' argument contains the stack trace. If no handler is
-  /// registered, then the information will be printed to the console instead.
-  GesturerExceptionHandler debugGesturerExceptionHandler;
-
   /// Dispatch the given event to the path of the given hit test result
   void dispatchEvent(PointerEvent event, HitTestResult result) {
     assert(result != null);
@@ -97,20 +87,20 @@
       try {
         entry.target.handleEvent(event, entry);
       } catch (exception, stack) {
-        if (debugGesturerExceptionHandler != null) {
-          debugGesturerExceptionHandler(event, entry.target, exception, stack);
-        } else {
-          debugPrint('-- EXCEPTION CAUGHT BY GESTURE LIBRARY ---------------------------------');
-          debugPrint('The following exception was raised while dispatching a pointer event:');
-          debugPrint('$exception');
-          debugPrint('Event:');
-          debugPrint('$event');
-          debugPrint('Target:');
-          debugPrint('${entry.target}');
-          debugPrint('Stack trace:');
-          debugPrint('$stack');
-          debugPrint('------------------------------------------------------------------------');
-        }
+        FlutterError.reportError(new FlutterErrorDetailsForPointerEventDispatcher(
+          exception: exception,
+          stack: stack,
+          library: 'gesture library',
+          context: 'while dispatching a pointer event',
+          event: event,
+          hitTestEntry: entry,
+          informationCollector: (StringBuffer information) {
+            information.writeln('Event:');
+            information.writeln('  $event');
+            information.writeln('Target:');
+            information.write('  ${entry.target}');
+          }
+        ));
       }
     }
   }
@@ -125,3 +115,44 @@
     }
   }
 }
+
+/// Variant of [FlutterErrorDetails] with extra fields for the gesture
+/// library's binding's pointer event dispatcher ([Gesturer.dispatchEvent]).
+///
+/// See also [FlutterErrorDetailsForPointerRouter], which is also used by the
+/// gesture library.
+class FlutterErrorDetailsForPointerEventDispatcher extends FlutterErrorDetails {
+  /// Creates a [FlutterErrorDetailsForPointerEventDispatcher] object with the given
+  /// arguments setting the object's properties.
+  ///
+  /// The gesture library calls this constructor when catching an exception
+  /// that will subsequently be reported using [FlutterError.onError].
+  const FlutterErrorDetailsForPointerEventDispatcher({
+    dynamic exception,
+    StackTrace stack,
+    String library,
+    String context,
+    this.event,
+    this.hitTestEntry,
+    FlutterInformationCollector informationCollector,
+    bool silent
+  }) : super(
+    exception: exception,
+    stack: stack,
+    library: library,
+    context: context,
+    informationCollector: informationCollector,
+    silent: silent
+  );
+
+  /// The pointer event that was being routed when the exception was raised.
+  final PointerEvent event;
+
+  /// The hit test result entry for the object whose handleEvent method threw
+  /// the exception.
+  /// 
+  /// The target object itself is given by the [HitTestEntry.target] property of
+  /// the hitTestEntry object.
+  final HitTestEntry hitTestEntry;
+}
+
diff --git a/packages/flutter/lib/src/gestures/pointer_router.dart b/packages/flutter/lib/src/gestures/pointer_router.dart
index 5985187..b791b55 100644
--- a/packages/flutter/lib/src/gestures/pointer_router.dart
+++ b/packages/flutter/lib/src/gestures/pointer_router.dart
@@ -11,8 +11,6 @@
 /// A callback that receives a [PointerEvent]
 typedef void PointerRoute(PointerEvent event);
 
-typedef void PointerExceptionHandler(PointerRouter source, PointerEvent event, PointerRoute route, dynamic exception, StackTrace stack);
-
 /// A routing table for [PointerEvent] events.
 class PointerRouter {
   final Map<int, LinkedHashSet<PointerRoute>> _routeMap = new Map<int, LinkedHashSet<PointerRoute>>();
@@ -40,16 +38,6 @@
       _routeMap.remove(pointer);
   }
 
-  /// This callback is invoked whenever an exception is caught by the pointer
-  /// router. The 'source' argument is the [PointerRouter] object that caught
-  /// the exception. The 'event' argument is the pointer event that was being
-  /// routed. The 'route' argument is the callback that threw the exception. The
-  /// 'exception' argument contains the object that was thrown, and the 'stack'
-  /// argument contains the stack trace. If no handler is registered, then the
-  /// human-readable parts of this information (the exception, event, and stack
-  /// trace) will be printed to the console instead.
-  PointerExceptionHandler debugPointerExceptionHandler;
-
   /// Calls the routes registered for this pointer event.
   ///
   /// Routes are called in the order in which they were added to the
@@ -64,19 +52,64 @@
       try {
         route(event);
       } catch (exception, stack) {
-        if (debugPointerExceptionHandler != null) {
-          debugPointerExceptionHandler(this, event, route, exception, stack);
-        } else {
-          debugPrint('-- EXCEPTION CAUGHT BY GESTURE LIBRARY ---------------------------------');
-          debugPrint('The following exception was raised while routing a pointer event:');
-          debugPrint('$exception');
-          debugPrint('Event:');
-          debugPrint('$event');
-          debugPrint('Stack trace:');
-          debugPrint('$stack');
-          debugPrint('------------------------------------------------------------------------');
-        }
+        FlutterError.reportError(new FlutterErrorDetailsForPointerRouter(
+          exception: exception,
+          stack: stack,
+          library: 'gesture library',
+          context: 'while routing a pointer event',
+          router: this,
+          route: route,
+          event: event,
+          informationCollector: (StringBuffer information) {
+            information.writeln('Event:');
+            information.write('  $event');
+          }
+        ));
       }
     }
   }
 }
+
+/// Variant of [FlutterErrorDetails] with extra fields for the gestures
+/// library's pointer router ([PointerRouter]).
+///
+/// See also [FlutterErrorDetailsForPointerEventDispatcher], which is also used
+/// by the gestures library.
+class FlutterErrorDetailsForPointerRouter extends FlutterErrorDetails {
+  /// Creates a [FlutterErrorDetailsForPointerRouter] object with the given
+  /// arguments setting the object's properties.
+  ///
+  /// The gestures library calls this constructor when catching an exception
+  /// that will subsequently be reported using [FlutterError.onError].
+  const FlutterErrorDetailsForPointerRouter({
+    dynamic exception,
+    StackTrace stack,
+    String library,
+    String context,
+    this.router,
+    this.route,
+    this.event,
+    FlutterInformationCollector informationCollector,
+    bool silent
+  }) : super(
+    exception: exception,
+    stack: stack,
+    library: library,
+    context: context,
+    informationCollector: informationCollector,
+    silent: silent
+  );
+
+  /// The pointer router that caught the exception.
+  /// 
+  /// In a typical application, this is the value of [Gesturer.pointerRouter] on
+  /// the binding ([Gesturer.instance]).
+  final PointerRouter router;
+
+  /// The callback that threw the exception.
+  final PointerRoute route;
+
+  /// The pointer event that was being routed when the exception was raised.
+  final PointerEvent event;
+}
+
diff --git a/packages/flutter/lib/src/http/mojo_client.dart b/packages/flutter/lib/src/http/mojo_client.dart
index af1d8f0..2fb4df9 100644
--- a/packages/flutter/lib/src/http/mojo_client.dart
+++ b/packages/flutter/lib/src/http/mojo_client.dart
@@ -149,14 +149,14 @@
       ByteData data = await mojo.DataPipeDrainer.drainHandle(response.body);
       Uint8List bodyBytes = new Uint8List.view(data.buffer);
       return new Response(bodyBytes: bodyBytes, statusCode: response.statusCode);
-    } catch (exception) {
-      assert(() {
-        debugPrint('-- EXCEPTION CAUGHT BY NETWORKING HTTP LIBRARY -------------------------');
-        debugPrint('An exception was raised while sending bytes to the Mojo network library:');
-        debugPrint('$exception');
-        debugPrint('------------------------------------------------------------------------');
-        return true;
-      });
+    } catch (exception, stack) {
+      FlutterError.reportError(new FlutterErrorDetails(
+        exception: exception,
+        stack: stack,
+        library: 'networking HTTP library',
+        context: 'while sending bytes to the Mojo network library',
+        silent: true
+      ));
       return new Response(statusCode: 500);
     } finally {
       loader.close();
diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart
index e20837a..bc6c347 100644
--- a/packages/flutter/lib/src/rendering/box.dart
+++ b/packages/flutter/lib/src/rendering/box.dart
@@ -795,10 +795,13 @@
   void performLayout() {
     assert(() {
       if (!sizedByParent) {
-        debugPrint('$runtimeType needs to either override performLayout() to\n'
-          'set size and lay out children, or, set sizedByParent to true\n'
-          'so that performResize() sizes the render object.');
-        assert(sizedByParent);
+        throw new FlutterError(
+          '$runtimeType did not implement performLayout().\n'
+          'RenderBox subclasses need to either override performLayout() to '
+          'set a size and lay out any children, or, set sizedByParent to true '
+          'so that performResize() sizes the render object.'
+        );
+        return true;
       }
       return true;
     });
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index 77dc0fe..f38fc66 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -394,16 +394,6 @@
 typedef void LayoutCallback(Constraints constraints);
 typedef double ExtentCallback(Constraints constraints);
 
-typedef void RenderingExceptionHandler(RenderObject source, String method, dynamic exception, StackTrace stack);
-/// This callback is invoked whenever an exception is caught by the rendering
-/// system. The 'source' argument is the [RenderObject] object that caught the
-/// exception. The 'method' argument is the method in which the exception
-/// occurred; it will be one of 'performResize', 'performLayout, or 'paint'. The
-/// 'exception' argument contains the object that was thrown, and the 'stack'
-/// argument contains the stack trace. If no handler is registered, then the
-/// information will be printed to the console instead.
-RenderingExceptionHandler debugRenderingExceptionHandler;
-
 class _SemanticsGeometry {
   _SemanticsGeometry() : transform = new Matrix4.identity();
   _SemanticsGeometry.withClipFrom(_SemanticsGeometry other) {
@@ -889,49 +879,45 @@
   void visitChildren(RenderObjectVisitor visitor) { }
 
   dynamic debugCreator;
-  static int _debugPrintedExceptionCount = 0;
   void _debugReportException(String method, dynamic exception, StackTrace stack) {
-    try {
-      if (debugRenderingExceptionHandler != null) {
-        debugRenderingExceptionHandler(this, method, exception, stack);
-      } else {
-        _debugPrintedExceptionCount += 1;
-        if (_debugPrintedExceptionCount == 1) {
-          debugPrint('-- EXCEPTION CAUGHT BY RENDERING LIBRARY -------------------------------');
-          debugPrint('The following exception was raised during $method():');
-          debugPrint('$exception');
-          debugPrint('The following RenderObject was being processed when the exception was fired:\n${this}');
-          if (debugCreator != null)
-            debugPrint('This RenderObject had the following creator:\n$debugCreator');
-          int depth = 0;
-          List<String> descendants = <String>[];
-          const int maxDepth = 5;
-          void visitor(RenderObject child) {
-            depth += 1;
+    FlutterError.reportError(new FlutterErrorDetailsForRendering(
+      exception: exception,
+      stack: stack,
+      library: 'rendering library',
+      context: 'during $method()',
+      renderObject: this,
+      informationCollector: (StringBuffer information) {
+        information.writeln('The following RenderObject was being processed when the exception was fired:\n${this}');
+        if (debugCreator != null)
+          information.writeln('This RenderObject had the following creator:\n$debugCreator');
+        List<String> descendants = <String>[];
+        const int maxDepth = 5;
+        int depth = 0;
+        const int maxLines = 30;
+        int lines = 0;
+        void visitor(RenderObject child) {
+          if (lines < maxLines) {
             descendants.add('${"  " * depth}$child');
+            depth += 1;
             if (depth < maxDepth)
               child.visitChildren(visitor);
             depth -= 1;
+          } else if (lines == maxLines) {
+            descendants.add('  ...(descendants list truncated after $lines lines)');
           }
-          visitChildren(visitor);
-          if (descendants.length > 1) {
-            debugPrint('This RenderObject had the following descendants (showing up to depth $maxDepth):');
-          } else if (descendants.length == 1) {
-            debugPrint('This RenderObject had the following child:');
-          } else {
-            debugPrint('This RenderObject has no descendants.');
-          }
-          descendants.forEach(debugPrint);
-          debugPrint('Stack trace:');
-          debugPrint('$stack');
-          debugPrint('------------------------------------------------------------------------');
-        } else {
-          debugPrint('Another exception was raised: ${exception.toString().split("\n")[0]}');
+          lines += 1;
         }
+        visitChildren(visitor);
+        if (lines > 1) {
+          information.writeln('This RenderObject had the following descendants (showing up to depth $maxDepth):');
+        } else if (descendants.length == 1) {
+          information.writeln('This RenderObject had the following child:');
+        } else {
+          information.writeln('This RenderObject has no descendants.');
+        }
+        information.writeAll(descendants, '\n');
       }
-    } catch (exception) {
-      debugPrint('(exception during exception handler: $exception)');
-    }
+    ));
   }
 
   bool _debugDoingThisResize = false;
@@ -2153,3 +2139,32 @@
     return result;
   }
 }
+
+/// Variant of [FlutterErrorDetails] with extra fields for the rendering
+/// library.
+class FlutterErrorDetailsForRendering extends FlutterErrorDetails {
+  /// Creates a [FlutterErrorDetailsForRendering] object with the given
+  /// arguments setting the object's properties.
+  ///
+  /// The rendering library calls this constructor when catching an exception
+  /// that will subsequently be reported using [FlutterError.onError].
+  const FlutterErrorDetailsForRendering({
+    dynamic exception,
+    StackTrace stack,
+    String library,
+    String context,
+    this.renderObject,
+    FlutterInformationCollector informationCollector,
+    bool silent
+  }) : super(
+    exception: exception,
+    stack: stack,
+    library: library,
+    context: context,
+    informationCollector: informationCollector,
+    silent: silent
+  );
+
+  /// The RenderObject that was being processed when the exception was caught.
+  final RenderObject renderObject;
+}
diff --git a/packages/flutter/lib/src/scheduler/scheduler.dart b/packages/flutter/lib/src/scheduler/scheduler.dart
index 998c4a0..361a2e9 100644
--- a/packages/flutter/lib/src/scheduler/scheduler.dart
+++ b/packages/flutter/lib/src/scheduler/scheduler.dart
@@ -24,16 +24,8 @@
 /// common time base.
 typedef void FrameCallback(Duration timeStamp);
 
-typedef void SchedulerExceptionHandler(dynamic exception, StackTrace stack);
-
 typedef bool SchedulingStrategy({ int priority, Scheduler scheduler });
 
-/// This callback is invoked whenever an exception is caught by the scheduler.
-/// The 'exception' argument contains the object that was thrown, and the
-/// 'stack' argument contains the stack trace. If the callback is set, it is
-/// invoked instead of printing the information to the console.
-SchedulerExceptionHandler debugSchedulerExceptionHandler;
-
 /// An entry in the scheduler's priority queue.
 ///
 /// Combines the task and its priority.
@@ -277,16 +269,12 @@
     try {
       callback(timeStamp);
     } catch (exception, stack) {
-      if (debugSchedulerExceptionHandler != null) {
-        debugSchedulerExceptionHandler(exception, stack);
-      } else {
-        debugPrint('-- EXCEPTION CAUGHT BY SCHEDULER LIBRARY -------------------------------');
-        debugPrint('An exception was raised during a scheduler callback:');
-        debugPrint('$exception');
-        debugPrint('Stack trace:');
-        debugPrint('$stack');
-        debugPrint('------------------------------------------------------------------------');
-      }
+      FlutterError.reportError(new FlutterErrorDetails(
+        exception: exception,
+        stack: stack,
+        library: 'scheduler library',
+        context: 'during a scheduler callback'
+      ));
     }
   }
 
diff --git a/packages/flutter/lib/src/services/assertions.dart b/packages/flutter/lib/src/services/assertions.dart
index 080f356..d312ebb 100644
--- a/packages/flutter/lib/src/services/assertions.dart
+++ b/packages/flutter/lib/src/services/assertions.dart
@@ -2,6 +2,79 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'print.dart';
+
+/// Signature for [FlutterError.onException] handler.
+typedef void FlutterExceptionHandler(FlutterErrorDetails details);
+
+/// Signature for [FlutterErrorDetails.informationCollector] callback.
+///
+/// The text written to the information argument may contain newlines but should
+/// not end with a newline.
+typedef void FlutterInformationCollector(StringBuffer information);
+
+/// Class for information provided to [FlutterExceptionHandler] callbacks.
+///
+/// See [FlutterError.onError].
+class FlutterErrorDetails {
+  /// Creates a [FlutterErrorDetails] object with the given arguments setting
+  /// the object's properties.
+  ///
+  /// The framework calls this constructor when catching an exception that will
+  /// subsequently be reported using [FlutterError.onError].
+  const FlutterErrorDetails({
+    this.exception,
+    this.stack,
+    this.library: 'Flutter framework',
+    this.context,
+    this.informationCollector,
+    this.silent: false
+  });
+
+  /// The exception. Often this will be an [AssertionError], maybe specifically
+  /// a [FlutterError]. However, this could be any value at all.
+  final dynamic exception;
+
+  /// The stack trace from where the [exception] was thrown (as opposed to where
+  /// it was caught).
+  ///
+  /// StackTrace objects are opaque except for their [toString] function. A
+  /// stack trace is not expected to be machine-readable.
+  final StackTrace stack;
+
+  /// A human-readable brief name describing the library that caught the error
+  /// message. This is used by the default error handler in the header dumped to
+  /// the console.
+  final String library;
+
+  /// A human-readable description of where the error was caught (as opposed to
+  /// where it was thrown).
+  final String context;
+
+  /// A callback which, when invoked with a [StringBuffer] will write to that buffer
+  /// information that could help with debugging the problem.
+  ///
+  /// Information collector callbacks can be expensive, so the generated information
+  /// should be cached, rather than the callback being invoked multiple times.
+  final FlutterInformationCollector informationCollector;
+
+  /// Whether this error should be ignored by the default error reporting
+  /// behavior in release mode.
+  ///
+  /// If this is false, the default, then the default error handler will always
+  /// dump this error to the console.
+  ///
+  /// If this is true, then the default error handler would only dump this error
+  /// to the console in checked mode. In release mode, the error is ignored.
+  ///
+  /// This is used by certain exception handlers that catch errors that could be
+  /// triggered by environmental conditions (as opposed to logic errors). For
+  /// example, the HTTP library sets this flag so as to not report every 404
+  /// error to the console on end-user devices, while still allowing a custom
+  /// error handler to see the errors even in release builds.
+  final bool silent;
+}
+
 /// Error class used to report Flutter-specific assertion failures and
 /// contract violations.
 class FlutterError extends AssertionError {
@@ -17,14 +90,16 @@
 
   /// The message associated with this error.
   ///
-  /// The message may have newlines in it. The first line should be a
-  /// terse description of the error, e.g. "Incorrect GlobalKey usage"
-  /// or "setState() or markNeedsBuild() called during build".
-  /// Subsequent lines can then contain more information. In some
-  /// cases, when a FlutterError is reported to the user, only the
-  /// first line is included. For example, Flutter will typically only
-  /// fully report the first exception at runtime, displaying only the
-  /// first line of subsequent errors.
+  /// The message may have newlines in it. The first line should be a terse
+  /// description of the error, e.g. "Incorrect GlobalKey usage" or "setState()
+  /// or markNeedsBuild() called during build". Subsequent lines should contain
+  /// substantial additional information, ideally sufficient to develop a
+  /// correct solution to the problem.
+  ///
+  /// In some cases, when a FlutterError is reported to the user, only the first
+  /// line is included. For example, Flutter will typically only fully report
+  /// the first exception at runtime, displaying only the first line of
+  /// subsequent errors.
   ///
   /// All sentences in the error should be correctly punctuated (i.e.,
   /// do end the error message with a period).
@@ -32,4 +107,70 @@
 
   @override
   String toString() => message;
+
+  /// Called whenever the Flutter framework catches an error.
+  ///
+  /// The default behavior is to invoke [dumpErrorToConsole].
+  ///
+  /// You can set this to your own function to override this default behavior.
+  /// For example, you could report all errors to your server.
+  ///
+  /// If the error handler throws an exception, it will not be caught by the
+  /// Flutter framework.
+  ///
+  /// Set this to null to silently catch and ignore errors. This is not
+  /// recommended.
+  static FlutterExceptionHandler onError = dumpErrorToConsole;
+
+  static int _errorCount = 0;
+
+  /// Prints the given exception details to the console.
+  ///
+  /// The first time this is called, it dumps a very verbose message to the
+  /// console using [debugPrint].
+  ///
+  /// Subsequent calls only dump the first line of the exception.
+  ///
+  /// This is the default behavior for the [onError] handler.
+  static void dumpErrorToConsole(FlutterErrorDetails details) {
+    assert(details != null);
+    assert(details.exception != null);
+    bool reportError = !details.silent;
+    assert(() {
+      // In checked mode, we ignore the "silent" flag.
+      reportError = true;
+      return true;
+    });
+    if (!reportError)
+      return;
+    if (_errorCount == 0) {
+      final String header = '-- EXCEPTION CAUGHT BY ${details.library} '.toUpperCase();
+      const String footer = '------------------------------------------------------------------------';
+      debugPrint('$header${"-" * (footer.length - header.length)}');
+      debugPrint('The following exception was raised${ details.context != null ? " ${details.context}" : ""}:');
+      debugPrint('${details.exception}');
+      if (details.informationCollector != null) {
+        StringBuffer information = new StringBuffer();
+        details.informationCollector(information);
+        debugPrint(information.toString());
+      }
+      if (details.stack != null) {
+        debugPrint('Stack trace:');
+        debugPrint('${details.stack}$footer');
+      } else {
+        debugPrint(footer);
+      }
+    } else {
+      debugPrint('Another exception was raised: ${details.exception.toString().split("\n")[0]}');
+    }
+    _errorCount += 1;
+  }
+
+  /// Calls [onError] with the given details, unless it is null.
+  static void reportError(FlutterErrorDetails details) {
+    assert(details != null);
+    assert(details.exception != null);
+    if (onError != null)
+      onError(details);
+  }
 }
diff --git a/packages/flutter/lib/src/services/fetch.dart b/packages/flutter/lib/src/services/fetch.dart
index bf2f7fc..b4d2a78 100644
--- a/packages/flutter/lib/src/services/fetch.dart
+++ b/packages/flutter/lib/src/services/fetch.dart
@@ -8,8 +8,8 @@
 import 'package:mojo/mojo/url_response.mojom.dart' as mojom;
 import 'package:mojo_services/mojo/url_loader.mojom.dart' as mojom;
 
+import 'assertions.dart';
 import '../http/mojo_client.dart';
-import 'print.dart';
 
 export 'package:mojo/mojo/url_response.mojom.dart' show UrlResponse;
 
@@ -27,14 +27,17 @@
         message.writeln('Protocol error: ${response.statusCode} ${response.statusLine ?? "<no server message>"}');
       if (response.url != request.url)
         message.writeln('Final URL after redirects was: ${response.url}');
-      throw message;
+      throw message; // this is not a FlutterError, because it's a real error, not an assertion
     }
     return response;
-  } catch (exception) {
-    debugPrint('-- EXCEPTION CAUGHT BY NETWORKING HTTP LIBRARY -------------------------');
-    debugPrint('An exception was raised while sending bytes to the Mojo network library:');
-    debugPrint('$exception');
-    debugPrint('------------------------------------------------------------------------');
+  } catch (exception, stack) {
+    FlutterError.reportError(new FlutterErrorDetails(
+      exception: exception,
+      stack: stack,
+      library: 'fetch service',
+      context: 'while sending bytes to the Mojo network library',
+      silent: true
+    ));
     return null;
   } finally {
     loader.close();
diff --git a/packages/flutter/lib/src/services/image_resource.dart b/packages/flutter/lib/src/services/image_resource.dart
index cb11644..8537c8d 100644
--- a/packages/flutter/lib/src/services/image_resource.dart
+++ b/packages/flutter/lib/src/services/image_resource.dart
@@ -5,7 +5,7 @@
 import 'dart:async';
 import 'dart:ui' as ui show Image;
 
-import 'print.dart';
+import 'assertions.dart';
 
 /// A [ui.Image] object with its corresponding scale.
 ///
@@ -61,7 +61,7 @@
     _futureImage.then(
       _handleImageLoaded,
       onError: (dynamic exception, dynamic stack) {
-        _handleImageError('Failed to load image:', exception, stack);
+        _handleImageError('while loading an image', exception, stack);
       }
     );
   }
@@ -86,7 +86,7 @@
       try {
         listener(_image);
       } catch (exception, stack) {
-        _handleImageError('The following exception was thrown by a synchronously-invoked image listener:', exception, stack);
+        _handleImageError('by a synchronously-invoked image listener', exception, stack);
       }
     }
   }
@@ -109,18 +109,18 @@
       try {
         listener(_image);
       } catch (exception, stack) {
-        _handleImageError('The following exception was thrown by an image listener:', exception, stack);
+        _handleImageError('by an image listener', exception, stack);
       }
     }
   }
 
-  void _handleImageError(String message, dynamic exception, dynamic stack) {
-    debugPrint('-- EXCEPTION CAUGHT BY SERVICES LIBRARY --------------------------------');
-    debugPrint(message);
-    debugPrint('$exception');
-    debugPrint('Stack trace:');
-    debugPrint('$stack');
-    debugPrint('------------------------------------------------------------------------');
+  void _handleImageError(String context, dynamic exception, dynamic stack) {
+    FlutterError.reportError(new FlutterErrorDetails(
+      exception: exception,
+      stack: stack,
+      library: 'image resource service',
+      context: context
+    ));
   }
 
   @override
diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart
index d07a3e4..a97ad99 100644
--- a/packages/flutter/lib/src/widgets/binding.dart
+++ b/packages/flutter/lib/src/widgets/binding.dart
@@ -170,7 +170,7 @@
       } else {
         element.update(this);
       }
-    }, building: true);
+    }, building: true, context: 'while attaching root widget to rendering tree');
     return element;
   }
 
diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart
index 61ff0ac..d73507c 100644
--- a/packages/flutter/lib/src/widgets/framework.dart
+++ b/packages/flutter/lib/src/widgets/framework.dart
@@ -9,6 +9,7 @@
 import 'debug.dart';
 
 import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
 
 export 'dart:ui' show hashValues, hashList;
 export 'package:flutter/rendering.dart' show RenderObject, RenderBox, debugPrint;
@@ -695,17 +696,17 @@
   bool _debugBuilding = false;
   BuildableElement _debugCurrentBuildTarget;
 
-  /// Establishes a scope in which widget build functions can run.
+  /// Establishes a scope in which calls to [State.setState] are forbidden.
   ///
-  /// Inside a build scope, widget build functions are allowed to run, but
-  /// State.setState() is forbidden. This mechanism prevents build functions
-  /// from transitively requiring other build functions to run, potentially
-  /// causing infinite loops.
+  /// This mechanism prevents build functions from transitively requiring other
+  /// build functions to run, potentially causing infinite loops.
   ///
-  /// After unwinding the last build scope on the stack, the framework verifies
-  /// that each global key is used at most once and notifies listeners about
-  /// changes to global keys.
-  void lockState(void callback(), { bool building: false }) {
+  /// If the building argument is true, then this is a build scope. Build scopes
+  /// cannot be nested.
+  ///
+  /// The context argument is used to describe the scope in case an exception is
+  /// caught while invoking the callback.
+  void lockState(void callback(), { bool building: false, String context }) {
     assert(_debugStateLockLevel >= 0);
     assert(() {
       if (building) {
@@ -718,6 +719,8 @@
     });
     try {
       callback();
+    } catch (e, stack) {
+      _debugReportException(context, e, stack);
     } finally {
       assert(() {
         _debugStateLockLevel -= 1;
@@ -766,18 +769,25 @@
       }
       assert(!_dirtyElements.any((BuildableElement element) => element.dirty));
       _dirtyElements.clear();
-    }, building: true);
+    }, building: true, context: 'while rebuilding dirty elements');
     assert(_dirtyElements.isEmpty);
     Timeline.finishSync();
   }
 
   /// Complete the element build pass by unmounting any elements that are no
   /// longer active.
+  ///
   /// This is called by beginFrame().
+  ///
+  /// In checked mode, this also verifies that each global key is used at most
+  /// once.
+  ///
+  /// After the current call stack unwinds, a microtask that notifies listeners
+  /// about changes to global keys will run.
   void finalizeTree() {
     lockState(() {
       _inactiveElements._unmountAll();
-    });
+    }, context: 'while finalizing the widget tree');
     assert(GlobalKey._debugCheckForDuplicates);
     scheduleMicrotask(GlobalKey._notifyListeners);
   }
@@ -2126,24 +2136,11 @@
   }
 }
 
-typedef void WidgetsExceptionHandler(String context, dynamic exception, StackTrace stack);
-/// This callback is invoked whenever an exception is caught by the widget
-/// system. The 'context' argument is a description of what was happening when
-/// the exception occurred, and may include additional details such as
-/// descriptions of the objects involved. The 'exception' argument contains the
-/// object that was thrown, and the 'stack' argument contains the stack trace.
-/// If no callback is set, then a default behavior consisting of dumping the
-/// context, exception, and stack trace to the console is used instead.
-WidgetsExceptionHandler debugWidgetsExceptionHandler;
 void _debugReportException(String context, dynamic exception, StackTrace stack) {
-  if (debugWidgetsExceptionHandler != null) {
-    debugWidgetsExceptionHandler(context, exception, stack);
-  } else {
-    debugPrint('-- EXCEPTION CAUGHT BY WIDGETS LIBRARY ---------------------------------');
-    debugPrint('Exception caught while $context');
-    debugPrint('$exception');
-    debugPrint('Stack trace:');
-    debugPrint('$stack');
-    debugPrint('------------------------------------------------------------------------');
-  }
+  FlutterError.reportError(new FlutterErrorDetails(
+    exception: exception,
+    stack: stack,
+    library: 'widgets library',
+    context: context
+  ));
 }
diff --git a/packages/flutter/lib/src/widgets/mixed_viewport.dart b/packages/flutter/lib/src/widgets/mixed_viewport.dart
index a28449e..cef35fa 100644
--- a/packages/flutter/lib/src/widgets/mixed_viewport.dart
+++ b/packages/flutter/lib/src/widgets/mixed_viewport.dart
@@ -254,7 +254,7 @@
     }
     owner.lockState(() {
       _doLayout(constraints);
-    }, building: true);
+    }, building: true, context: 'during $runtimeType layout');
   }
 
   void postLayout() {
diff --git a/packages/flutter/lib/src/widgets/virtual_viewport.dart b/packages/flutter/lib/src/widgets/virtual_viewport.dart
index 204a9a2..eca2c0c 100644
--- a/packages/flutter/lib/src/widgets/virtual_viewport.dart
+++ b/packages/flutter/lib/src/widgets/virtual_viewport.dart
@@ -157,7 +157,7 @@
     assert(startOffsetBase != null);
     assert(startOffsetLimit != null);
     _updatePaintOffset();
-    owner.lockState(_materializeChildren, building: true);
+    owner.lockState(_materializeChildren, building: true, context: 'during $runtimeType layout');
   }
 
   void _materializeChildren() {
diff --git a/packages/flutter/test/widget/build_scope_test.dart b/packages/flutter/test/widget/build_scope_test.dart
index 12e35cb..2693fa5 100644
--- a/packages/flutter/test/widget/build_scope_test.dart
+++ b/packages/flutter/test/widget/build_scope_test.dart
@@ -3,7 +3,6 @@
 // found in the LICENSE file.
 
 import 'package:flutter_test/flutter_test.dart';
-import 'package:flutter/scheduler.dart';
 import 'package:flutter/widgets.dart';
 import 'package:test/test.dart';
 
@@ -73,33 +72,12 @@
 
   @override
   void dispose() {
-    setState(() {});
+    setState(() { /* This is invalid behavior. */ });
     super.dispose();
   }
 }
 
 void main() {
-  dynamic cachedException;
-
-  // ** WARNING **
-  // THIS TEST OVERRIDES THE NORMAL EXCEPTION HANDLING
-  // AND DOES NOT REPORT EXCEPTIONS FROM THE FRAMEWORK
-
-  setUp(() {
-    assert(cachedException == null);
-    debugWidgetsExceptionHandler = (String context, dynamic exception, StackTrace stack) {
-      cachedException = exception;
-    };
-    debugSchedulerExceptionHandler = (dynamic exception, StackTrace stack) { throw exception; };
-  });
-
-  tearDown(() {
-    assert(cachedException == null);
-    cachedException = null;
-    debugWidgetsExceptionHandler = null;
-    debugSchedulerExceptionHandler = null;
-  });
-
   test('Legal times for setState', () {
     testWidgets((WidgetTester tester) {
       GlobalKey flipKey = new GlobalKey();
@@ -129,21 +107,18 @@
 
   test('Setting parent state during build is forbidden', () {
     testWidgets((WidgetTester tester) {
-      expect(cachedException, isNull);
       tester.pumpWidget(new BadWidgetParent());
-      expect(cachedException, isNotNull);
-      cachedException = null;
+      expect(tester.takeException(), isNotNull);
       tester.pumpWidget(new Container());
-      expect(cachedException, isNull);
     });
   });
 
   test('Setting state during dispose is forbidden', () {
     testWidgets((WidgetTester tester) {
       tester.pumpWidget(new BadDisposeWidget());
-      expect(() {
-        tester.pumpWidget(new Container());
-      }, throws);
+      expect(tester.takeException(), isNull);
+      tester.pumpWidget(new Container());
+      expect(tester.takeException(), isNotNull);
     });
   });
 }
diff --git a/packages/flutter/test/widget/image_test.dart b/packages/flutter/test/widget/image_test.dart
index a3f0d80..176f14f 100644
--- a/packages/flutter/test/widget/image_test.dart
+++ b/packages/flutter/test/widget/image_test.dart
@@ -112,18 +112,16 @@
           child: new AsyncImage(
             provider: imageProvider1
           )
-        )
+        ),
+        null,
+        EnginePhase.layout
       );
       RenderImage renderImage = key.currentContext.findRenderObject();
       expect(renderImage.image, isNull);
 
-      // An exception will be thrown when we try to draw the image.  Catch it.
-      RenderingExceptionHandler originalRenderingExceptionHandler = debugRenderingExceptionHandler;
-      debugRenderingExceptionHandler = (_, __, ___, ____) => null;
       imageProvider1.complete();
-      tester.pump();
-      tester.pump();
-      debugRenderingExceptionHandler = originalRenderingExceptionHandler;
+      tester.async.flushMicrotasks(); // resolve the future from the image provider
+      tester.pump(null, EnginePhase.layout);
 
       renderImage = key.currentContext.findRenderObject();
       expect(renderImage.image, isNotNull);
@@ -135,7 +133,9 @@
           child: new AsyncImage(
             provider: imageProvider2
           )
-        )
+        ),
+        null,
+        EnginePhase.layout
       );
 
       renderImage = key.currentContext.findRenderObject();
@@ -152,18 +152,16 @@
         new AsyncImage(
             key: key,
             provider: imageProvider1
-        )
+        ),
+        null,
+        EnginePhase.layout
       );
       RenderImage renderImage = key.currentContext.findRenderObject();
       expect(renderImage.image, isNull);
 
-      // An exception will be thrown when we try to draw the image.  Catch it.
-      RenderingExceptionHandler originalRenderingExceptionHandler = debugRenderingExceptionHandler;
-      debugRenderingExceptionHandler = (_, __, ___, ____) => null;
       imageProvider1.complete();
-      tester.pump();
-      tester.pump();
-      debugRenderingExceptionHandler = originalRenderingExceptionHandler;
+      tester.async.flushMicrotasks(); // resolve the future from the image provider
+      tester.pump(null, EnginePhase.layout);
 
       renderImage = key.currentContext.findRenderObject();
       expect(renderImage.image, isNotNull);
@@ -173,7 +171,9 @@
         new AsyncImage(
           key: key,
           provider: imageProvider2
-        )
+        ),
+        null,
+        EnginePhase.layout
       );
 
       renderImage = key.currentContext.findRenderObject();
diff --git a/packages/flutter/test/widget/parent_data_test.dart b/packages/flutter/test/widget/parent_data_test.dart
index 491ad45..c12c5dd 100644
--- a/packages/flutter/test/widget/parent_data_test.dart
+++ b/packages/flutter/test/widget/parent_data_test.dart
@@ -48,20 +48,6 @@
 final TestParentData kNonPositioned = new TestParentData();
 
 void main() {
-  dynamic cachedException;
-
-  setUp(() {
-    assert(cachedException == null);
-    debugWidgetsExceptionHandler = (String context, dynamic exception, StackTrace stack) {
-      cachedException = exception;
-    };
-  });
-
-  tearDown(() {
-    cachedException = null;
-    debugWidgetsExceptionHandler = null;
-  });
-
   test('ParentDataWidget control test', () {
     testWidgets((WidgetTester tester) {
 
@@ -259,8 +245,6 @@
 
   test('ParentDataWidget conflicting data', () {
     testWidgets((WidgetTester tester) {
-      expect(cachedException, isNull);
-
       tester.pumpWidget(
         new Stack(
           children: <Widget>[
@@ -276,14 +260,11 @@
           ]
         )
       );
-
-      expect(cachedException, isNotNull);
-      cachedException = null;
+      expect(tester.takeException(), isNotNull);
 
       tester.pumpWidget(new Stack());
 
       checkTree(tester, <TestParentData>[]);
-      expect(cachedException, isNull);
 
       tester.pumpWidget(
         new Container(
@@ -298,9 +279,7 @@
           )
         )
       );
-
-      expect(cachedException, isNotNull);
-      cachedException = null;
+      expect(tester.takeException(), isNotNull);
 
       tester.pumpWidget(
         new Stack()
diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart
index 60cce29..2b24355 100644
--- a/packages/flutter_test/lib/src/widget_tester.dart
+++ b/packages/flutter_test/lib/src/widget_tester.dart
@@ -4,11 +4,12 @@
 
 import 'dart:ui' as ui show window;
 
-import 'package:quiver/testing/async.dart';
-import 'package:quiver/time.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:flutter/widgets.dart';
+import 'package:quiver/testing/async.dart';
+import 'package:quiver/time.dart';
 
 import 'instrumentation.dart';
 
@@ -89,6 +90,21 @@
   /// The supplied EnginePhase is the final phase reached during the pump pass;
   /// if not supplied, the whole pass is executed.
   void pumpWidget(Widget widget, [ Duration duration, EnginePhase phase ]) {
+    runApp(widget);
+    pump(duration, phase);
+  }
+
+  /// Triggers a frame sequence (build/layout/paint/etc),
+  /// then flushes microtasks.
+  ///
+  /// If duration is set, then advances the clock by that much first.
+  /// Doing this flushes microtasks.
+  ///
+  /// The supplied EnginePhase is the final phase reached during the pump pass;
+  /// if not supplied, the whole pass is executed.
+  void pump([ Duration duration, EnginePhase phase ]) {
+    if (duration != null)
+      async.elapse(duration);
     if (binding is _SteppedWidgetFlutterBinding) {
       // Some tests call WidgetFlutterBinding.ensureInitialized() manually, so
       // we can't actually be sure we have a stepped binding.
@@ -98,8 +114,10 @@
       // Can't step to a given phase in that case
       assert(phase == null);
     }
-    runApp(widget);
-    pump(duration);
+    binding.handleBeginFrame(new Duration(
+      milliseconds: clock.now().millisecondsSinceEpoch)
+    );
+    async.flushMicrotasks();
   }
 
   /// Artificially calls dispatchLocaleChanged on the Widget binding,
@@ -110,46 +128,72 @@
     async.flushMicrotasks();
   }
 
-  /// Triggers a frame sequence (build/layout/paint/etc),
-  /// then flushes microtasks.
-  ///
-  /// If duration is set, then advances the clock by that much first.
-  /// Doing this flushes microtasks.
-  void pump([ Duration duration ]) {
-    if (duration != null)
-      async.elapse(duration);
-    binding.handleBeginFrame(new Duration(
-      milliseconds: clock.now().millisecondsSinceEpoch)
-    );
-    async.flushMicrotasks();
-  }
-
   @override
   void dispatchEvent(PointerEvent event, HitTestResult result) {
     super.dispatchEvent(event, result);
     async.flushMicrotasks();
   }
+
+  /// Returns the exception most recently caught by the Flutter framework.
+  ///
+  /// Call this if you expect an exception during a test. If an exception is
+  /// thrown and this is not called, then the exception is rethrown when
+  /// the [testWidgets] call completes.
+  ///
+  /// If two exceptions are thrown in a row without the first one being
+  /// acknowledged with a call to this method, then when the second exception is
+  /// thrown, they are both dumped to the console and then the second is
+  /// rethrown from the exception handler. This will likely result in the
+  /// framework entering a highly unstable state and everything collapsing.
+  ///
+  /// It's safe to call this when there's no pending exception; it will return
+  /// null in that case.
+  dynamic takeException() {
+    dynamic result = _pendingException;
+    _pendingException = null;
+    return result;
+  }
+  dynamic _pendingException;
 }
 
 void testWidgets(callback(WidgetTester tester)) {
   new FakeAsync().run((FakeAsync async) {
-    WidgetTester tester = new WidgetTester._(async);
-    runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state.
-    callback(tester);
-    runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets.
-    async.flushMicrotasks();
-    assert(() {
-      "An animation is still running even after the widget tree was disposed.";
-      return Scheduler.instance.transientCallbackCount == 0;
-    });
-    assert(() {
-      "A Timer is still running even after the widget tree was disposed.";
-      return async.periodicTimerCount == 0;
-    });
-    assert(() {
-      "A Timer is still running even after the widget tree was disposed.";
-      return async.nonPeriodicTimerCount == 0;
-    });
-    assert(async.microtaskCount == 0); // Shouldn't be possible.
+    FlutterExceptionHandler oldHandler = FlutterError.onError;
+    try {
+      WidgetTester tester = new WidgetTester._(async);
+      FlutterError.onError = (FlutterErrorDetails details) {
+        if (tester._pendingException != null) {
+          FlutterError.dumpErrorToConsole(tester._pendingException);
+          FlutterError.dumpErrorToConsole(details.exception);
+          tester._pendingException = 'An uncaught exception was thrown.';
+          throw details.exception;
+        }
+        tester._pendingException = details;
+      };
+      runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state.
+      callback(tester);
+      runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets.
+      async.flushMicrotasks();
+      assert(() {
+        "An animation is still running even after the widget tree was disposed.";
+        return Scheduler.instance.transientCallbackCount == 0;
+      });
+      assert(() {
+        "A Timer is still running even after the widget tree was disposed.";
+        return async.periodicTimerCount == 0;
+      });
+      assert(() {
+        "A Timer is still running even after the widget tree was disposed.";
+        return async.nonPeriodicTimerCount == 0;
+      });
+      assert(async.microtaskCount == 0); // Shouldn't be possible.
+      assert(() {
+        if (tester._pendingException != null)
+          FlutterError.dumpErrorToConsole(tester._pendingException);
+        return tester._pendingException == null;
+      });
+    } finally {
+      FlutterError.onError = oldHandler;
+    }
   });
 }