diff --git a/lib/src/backend/closed_exception.dart b/lib/src/backend/closed_exception.dart
new file mode 100644
index 0000000000000000000000000000000000000000..0c9dcca2e5bc70eb0643fdc55eb227e139219cee
--- /dev/null
+++ b/lib/src/backend/closed_exception.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library unittest.backend.closed_exception;
+
+/// An exception thrown by various front-end methods when the test framework has
+/// been closed and a test must shut down as soon as possible.
+class ClosedException implements Exception {
+  ClosedException();
+
+  String toString() => "This test has been closed.";
+}
diff --git a/lib/src/backend/invoker.dart b/lib/src/backend/invoker.dart
index 228b90e64b46583dd4eb7f5382b8425b5122d05c..057e704918cc1e64276066992b48eb151c815aa0 100644
--- a/lib/src/backend/invoker.dart
+++ b/lib/src/backend/invoker.dart
@@ -10,6 +10,7 @@ import 'package:stack_trace/stack_trace.dart';
 
 import '../frontend/expect.dart';
 import '../utils.dart';
+import 'closed_exception.dart';
 import 'live_test.dart';
 import 'live_test_controller.dart';
 import 'metadata.dart';
@@ -55,6 +56,14 @@ class Invoker {
   LiveTest get liveTest => _controller.liveTest;
   LiveTestController _controller;
 
+  /// Whether the test has been closed.
+  ///
+  /// Once the test is closed, [expect] and [expectAsync] will throw
+  /// [ClosedException]s whenever accessed to help the test stop executing as
+  /// soon as possible.
+  bool get closed => _closed;
+  bool _closed = false;
+
   /// The test being run.
   LocalTest get _test => liveTest.test as LocalTest;
 
@@ -76,7 +85,9 @@ class Invoker {
   }
 
   Invoker._(Suite suite, LocalTest test) {
-    _controller = new LiveTestController(suite, test, _onRun);
+    _controller = new LiveTestController(suite, test, _onRun, () {
+      _closed = true;
+    });
   }
 
   /// Tells the invoker that there's a callback running that it should wait for
@@ -87,7 +98,10 @@ class Invoker {
   /// that only successful tests wait for outstanding callbacks; as soon as a
   /// test experiences an error, any further calls to [addOutstandingCallback]
   /// or [removeOutstandingCallback] will do nothing.
+  ///
+  /// Throws a [ClosedException] if this test has been closed.
   void addOutstandingCallback() {
+    if (closed) throw new ClosedException();
     _outstandingCallbacks++;
   }
 
@@ -162,10 +176,6 @@ class Invoker {
         new Future(_test._body)
             .then((_) => removeOutstandingCallback());
 
-        // Explicitly handle an error here so that we can return the [Future].
-        // If a [Future] returned from an error zone would throw an error
-        // through the zone boundary, it instead never completes, and we want to
-        // avoid that.
         _completer.future.then((_) {
           if (_test._tearDown == null) return null;
           return new Future.sync(_test._tearDown);
diff --git a/lib/src/backend/live_test.dart b/lib/src/backend/live_test.dart
index 4b1697d76446b25824283cbe032b53af7b05c6f6..4cc917a5a4af3efb1dbcd30c8b87256600c23484 100644
--- a/lib/src/backend/live_test.dart
+++ b/lib/src/backend/live_test.dart
@@ -111,10 +111,16 @@ abstract class LiveTest {
   /// Once [close] is called, [onComplete] will complete if it hasn't already
   /// and [onStateChange] and [onError] will close immediately. This means that,
   /// if the test was running at the time [close] is called, it will never emit
-  /// a [Status.complete] state-change event.
+  /// a [Status.complete] state-change event. Once a test is closed, [expect]
+  /// and [expectAsync] will throw a [ClosedException] to help the test
+  /// terminate as quickly as possible.
   ///
   /// This doesn't automatically happen after the test completes because there
   /// may be more asynchronous work going on in the background that could
   /// produce new errors.
+  ///
+  /// Returns a [Future] that completes once all resources are released *and*
+  /// the test has completed. This allows the caller to wait until the test's
+  /// tear-down logic has run.
   Future close();
 }
diff --git a/lib/src/backend/live_test_controller.dart b/lib/src/backend/live_test_controller.dart
index f84b1ce8a1a5fe9ab9ff37292bec2bfaabe6ac03..71731b6f5cf8a31feac0517f63a4e92c9254983d 100644
--- a/lib/src/backend/live_test_controller.dart
+++ b/lib/src/backend/live_test_controller.dart
@@ -110,15 +110,16 @@ class LiveTestController {
   ///
   /// [test] is the test being run; [suite] is the suite that contains it.
   ///
-  /// [onRun] is a function that will be called from [LiveTest.run]. It should
-  /// start the test running. The controller takes care of ensuring that
+  /// [onRun] is a function that's called from [LiveTest.run]. It should start
+  /// the test running. The controller takes care of ensuring that
   /// [LiveTest.run] isn't called more than once and that [LiveTest.onComplete]
   /// is returned.
   ///
-  /// If [onClose] is passed, it's called the first [LiveTest.close] is called.
-  /// It should clean up any resources that have been allocated for the test. It
-  /// may return a [Future].
-  LiveTestController(this._suite, this._test, void onRun(), {onClose()})
+  /// [onClose] is a function that's called the first time [LiveTest.close] is
+  /// called. It should clean up any resources that have been allocated for the
+  /// test and ensure that the test finishes quickly if it's still running. It
+  /// will only be called if [onRun] has been called first.
+  LiveTestController(this._suite, this._test, void onRun(), void onClose())
       : _onRun = onRun,
         _onClose = onClose {
     _liveTest = new _LiveTest(this);
@@ -130,6 +131,8 @@ class LiveTestController {
   /// [LiveTest.onError]. [stackTrace] is automatically converted into a [Chain]
   /// if it's not one already.
   void addError(error, StackTrace stackTrace) {
+    if (_isClosed) return;
+
     var asyncError = new AsyncError(error, new Chain.forTrace(stackTrace));
     _errors.add(asyncError);
     _onErrorController.add(asyncError);
@@ -141,7 +144,9 @@ class LiveTestController {
   /// [LiveTest.state] and emits the new state via [LiveTest.onStateChanged]. If
   /// it's not different, this does nothing.
   void setState(State newState) {
+    if (_isClosed) return;
     if (_state == newState) return;
+
     _state = newState;
     _onStateChangeController.add(newState);
   }
@@ -163,12 +168,17 @@ class LiveTestController {
 
   /// A wrapper for [_onClose] that ensures that all controllers are closed.
   Future _close() {
-    if (_isClosed) return new Future.value();
+    if (_isClosed) return completer.future;
+
     _onStateChangeController.close();
     _onErrorController.close();
-    if (!completer.isCompleted) completer.complete();
 
-    if (_onClose != null) return new Future.sync(_onClose);
-    return new Future.value();
+    if (_runCalled) {
+      _onClose();
+    } else {
+      completer.complete();
+    }
+
+    return completer.future;
   }
 }
diff --git a/lib/src/executable.dart b/lib/src/executable.dart
index fe403b2a1f26423aed50fcc52059e55c28de2aba..b70a5066a2a6c3824fb12ff26cdb206c2dbe1154 100644
--- a/lib/src/executable.dart
+++ b/lib/src/executable.dart
@@ -25,6 +25,16 @@ import 'utils.dart';
 /// The argument parser used to parse the executable arguments.
 final _parser = new ArgParser(allowTrailingOptions: true);
 
+/// A merged stream of all signals that tell the test runner to shut down
+/// gracefully.
+///
+/// Signals will only be captured as long as this has an active subscription.
+/// Otherwise, they'll be handled by Dart's default signal handler, which
+/// terminates the program immediately.
+final _signals = mergeStreams([
+  ProcessSignal.SIGTERM.watch(), ProcessSignal.SIGINT.watch()
+]);
+
 void main(List<String> args) {
   _parser.addFlag("help", abbr: "h", negatable: false,
       help: "Shows this usage information.");
@@ -65,6 +75,15 @@ void main(List<String> args) {
   var platforms = options["platform"].map(TestPlatform.find);
   var loader = new Loader(platforms,
       packageRoot: options["package-root"], color: color);
+
+  var signalSubscription;
+  var closed = false;
+  signalSubscription = _signals.listen((_) {
+    signalSubscription.cancel();
+    closed = true;
+    loader.close();
+  });
+
   new Future.sync(() {
     var paths = options.rest;
     if (paths.isEmpty) {
@@ -82,6 +101,7 @@ void main(List<String> args) {
       throw new LoadException(path, 'Does not exist.');
     }));
   }).then((suites) {
+    if (closed) return null;
     suites = flatten(suites);
 
     var pattern;
@@ -116,10 +136,30 @@ void main(List<String> args) {
     }
 
     var reporter = new CompactReporter(flatten(suites), color: color);
+
+    // Override the signal handler to close [reporter]. [loader] will still be
+    // closed in the [whenComplete] below.
+    signalSubscription.onData((_) {
+      signalSubscription.cancel();
+      closed = true;
+
+      // Wait a bit to print this message, since printing it eagerly looks weird
+      // if the tests then finish immediately.
+      var timer = new Timer(new Duration(seconds: 1), () {
+        print("Waiting for current test to finish.");
+        print("Press Control-C again to terminate immediately.");
+      });
+
+      reporter.close().then((_) => timer.cancel());
+    });
+
     return reporter.run().then((success) {
       exitCode = success ? 0 : 1;
-    }).whenComplete(() => reporter.close());
-  }).catchError((error, stackTrace) {
+    }).whenComplete(() {
+      signalSubscription.cancel();
+      return reporter.close();
+    });
+  }).whenComplete(signalSubscription.cancel).catchError((error, stackTrace) {
     if (error is LoadException) {
       stderr.writeln(error.toString(color: color));
 
diff --git a/lib/src/frontend/expect.dart b/lib/src/frontend/expect.dart
index 71a141694f42ba7f0a04415ca4f34651430672c0..d39986b40231df2599403430c43909ff703e40e5 100644
--- a/lib/src/frontend/expect.dart
+++ b/lib/src/frontend/expect.dart
@@ -6,6 +6,9 @@ library test.frontend.expect;
 
 import 'package:matcher/matcher.dart';
 
+import '../backend/closed_exception.dart';
+import '../backend/invoker.dart';
+
 /// An exception thrown when a test assertion fails.
 class TestFailure {
   final String message;
@@ -36,6 +39,8 @@ typedef String ErrorFormatter(
 /// [verbose] should be specified as `true`.
 void expect(actual, matcher,
     {String reason, bool verbose: false, ErrorFormatter formatter}) {
+  if (Invoker.current.closed) throw new ClosedException();
+
   matcher = wrapMatcher(matcher);
   var matchState = {};
   try {
diff --git a/lib/src/runner/browser/compiler_pool.dart b/lib/src/runner/browser/compiler_pool.dart
index e5ba0565fe9e48b7124973a2fe34974ebbef4b6c..a424151af93fda56f221f1d07a48e93a325f7f07 100644
--- a/lib/src/runner/browser/compiler_pool.dart
+++ b/lib/src/runner/browser/compiler_pool.dart
@@ -35,6 +35,12 @@ class CompilerPool {
   /// emitted once they become visible.
   final _compilers = new Queue<_Compiler>();
 
+  /// Whether [close] has been called.
+  bool get _closed => _closeCompleter != null;
+
+  /// The completer for the [Future] returned by [close].
+  Completer _closeCompleter;
+
   /// Creates a compiler pool that runs up to [parallel] instances of `dart2js`
   /// at once.
   ///
@@ -55,6 +61,8 @@ class CompilerPool {
   /// *and* all its output has been printed to the command line.
   Future compile(String dartPath, String jsPath, {String packageRoot}) {
     return _pool.withResource(() {
+      if (_closed) return null;
+
       return withTempDir((dir) {
         var wrapperPath = p.join(dir, "runInBrowser.dart");
         new File(wrapperPath).writeAsStringSync('''
@@ -103,12 +111,18 @@ void main(_) {
       compiler.process.stdout.listen(stdout.add).asFuture(),
       compiler.process.stderr.listen(stderr.add).asFuture(),
       compiler.process.exitCode.then((exitCode) {
-        if (exitCode == 0) return;
+        if (exitCode == 0 || _closed) return;
         throw new LoadException(compiler.path, "dart2js failed.");
       })
-    ]).then(compiler.onDoneCompleter.complete)
-        .catchError(compiler.onDoneCompleter.completeError)
-        .then((_) {
+    ]).then((_) {
+      if (_closed) return;
+      compiler.onDoneCompleter.complete();
+    }).catchError((error, stackTrace) {
+      if (_closed) return;
+      compiler.onDoneCompleter.completeError(error, stackTrace);
+    }).then((_) {
+      if (_closed) return;
+
       _compilers.removeFirst();
       if (_compilers.isEmpty) return;
 
@@ -119,6 +133,24 @@ void main(_) {
       Timer.run(() => _showProcess(next));
     });
   }
+
+  /// Closes the compiler pool.
+  ///
+  /// This kills all currently-running compilers and ensures that no more will
+  /// be started. It returns a [Future] that completes once all the compilers
+  /// have been killed and all resources released.
+  Future close() {
+    if (_closed) return _closeCompleter.future;
+    _closeCompleter = new Completer();
+
+    return Future.wait(_compilers.map((compiler) {
+      compiler.process.kill();
+      return compiler.process.exitCode.then(compiler.onDoneCompleter.complete);
+    })).then((_) {
+      _compilers.clear();
+      _closeCompleter.complete();
+    }).catchError(_closeCompleter.completeError);
+  }
 }
 
 /// A running instance of `dart2js`.
diff --git a/lib/src/runner/browser/iframe_listener.dart b/lib/src/runner/browser/iframe_listener.dart
index 6cef9f62b2022d1a69f25605e3b95a8bb37ca1d7..cdc9edad9321aad065e5434f6b7aee5d99377deb 100644
--- a/lib/src/runner/browser/iframe_listener.dart
+++ b/lib/src/runner/browser/iframe_listener.dart
@@ -143,6 +143,11 @@ class IframeListener {
   void _runTest(Test test, MultiChannel channel) {
     var liveTest = test.load(_suite);
 
+    channel.stream.listen((message) {
+      assert(message['command'] == 'close');
+      liveTest.close();
+    });
+
     liveTest.onStateChange.listen((state) {
       channel.sink.add({
         "type": "state-change",
diff --git a/lib/src/runner/browser/iframe_test.dart b/lib/src/runner/browser/iframe_test.dart
index 6b663ab1999163750a977b951fee532e8f9666b0..6b799419c5ba8458639c86dae4c79d83180fee85 100644
--- a/lib/src/runner/browser/iframe_test.dart
+++ b/lib/src/runner/browser/iframe_test.dart
@@ -25,10 +25,11 @@ class IframeTest implements Test {
 
   LiveTest load(Suite suite) {
     var controller;
+    var testChannel;
     controller = new LiveTestController(suite, this, () {
       controller.setState(const State(Status.running, Result.success));
 
-      var testChannel = _channel.virtualChannel();
+      testChannel = _channel.virtualChannel();
       _channel.sink.add({
         'command': 'run',
         'channel': testChannel.id
@@ -50,6 +51,12 @@ class IframeTest implements Test {
           controller.completer.complete();
         }
       });
+    }, () {
+      // Ignore all future messages from the test and complete it immediately.
+      // We don't need to tell it to run its tear-down because there's nothing a
+      // browser test needs to clean up on the file system anyway.
+      testChannel.sink.close();
+      if (!controller.completer.isCompleted) controller.completer.complete();
     });
     return controller.liveTest;
   }
diff --git a/lib/src/runner/browser/server.dart b/lib/src/runner/browser/server.dart
index 9730e3c2a276ca36808cccd00ca5caabc535146f..f39466ed31cf6d138a34c50677557076b023cd7a 100644
--- a/lib/src/runner/browser/server.dart
+++ b/lib/src/runner/browser/server.dart
@@ -64,6 +64,12 @@ class BrowserServer {
   /// This is `null` until a suite is loaded.
   Chrome _browser;
 
+  /// Whether [close] has been called.
+  bool get _closed => _closeCompleter != null;
+
+  /// The completer for the [Future] returned by [close].
+  Completer _closeCompleter;
+
   /// A future that will complete to the [BrowserManager] for [_browser].
   ///
   /// The first time this is called, it will start both the browser and the
@@ -92,7 +98,7 @@ class BrowserServer {
   Completer<BrowserManager> _browserManagerCompleter;
 
   BrowserServer._(this._packageRoot, bool color)
-      : _compiledDir = Directory.systemTemp.createTempSync('test_').path,
+      : _compiledDir = createTempDir(),
         _compilers = new CompilerPool(color: color);
 
   /// Starts the underlying server.
@@ -114,8 +120,12 @@ class BrowserServer {
   /// This will start a browser to load the suite if one isn't already running.
   Future<Suite> loadSuite(String path) {
     return _compileSuite(path).then((dir) {
+      if (_closed) return null;
+
       // TODO(nweiz): Don't start the browser until all the suites are compiled.
       return _browserManager.then((browserManager) {
+        if (_closed) return null;
+
         // Add a trailing slash because at least on Chrome, the iframe's
         // window.location.href will do so automatically, and if that differs
         // from the original URL communication will fail.
@@ -135,6 +145,8 @@ class BrowserServer {
     return _compilers.compile(dartPath, jsPath,
             packageRoot: packageRootFor(dartPath, _packageRoot))
         .then((_) {
+      if (_closed) return null;
+
       // TODO(nweiz): support user-authored HTML files.
       new File(p.join(dir, "index.html")).writeAsStringSync('''
 <!DOCTYPE html>
@@ -154,10 +166,18 @@ class BrowserServer {
   /// Returns a [Future] that completes once the server is closed and its
   /// resources have been fully released.
   Future close() {
-    new Directory(_compiledDir).deleteSync(recursive: true);
-    return _server.close().then((_) {
+    if (_closeCompleter != null) return _closeCompleter.future;
+    _closeCompleter = new Completer();
+
+    return Future.wait([
+      _server.close(),
+      _compilers.close()
+    ]).then((_) {
       if (_browserManagerCompleter == null) return null;
       return _browserManager.then((_) => _browser.close());
-    });
+    }).then((_) {
+      new Directory(_compiledDir).deleteSync(recursive: true);
+      _closeCompleter.complete();
+    }).catchError(_closeCompleter.completeError);
   }
 }
diff --git a/lib/src/runner/engine.dart b/lib/src/runner/engine.dart
index 9d3c1bba30b60ea9d7af75f46898073211edb1f3..355686b0e01a5b66f3ac61edd748e9bc7c10c821 100644
--- a/lib/src/runner/engine.dart
+++ b/lib/src/runner/engine.dart
@@ -23,6 +23,9 @@ class Engine {
   /// Whether [run] has been called yet.
   var _runCalled = false;
 
+  /// Whether [close] has been called.
+  var _closed = false;
+
   /// An unmodifiable list of tests to run.
   ///
   /// These are [LiveTest]s, representing the in-progress state of each test.
@@ -54,6 +57,7 @@ class Engine {
     _runCalled = true;
 
     return Future.forEach(liveTests, (liveTest) {
+      if (_closed) return new Future.value();
       _onTestStartedController.add(liveTest);
 
       // First, schedule a microtask to ensure that [onTestStarted] fires before
@@ -67,6 +71,12 @@ class Engine {
 
   /// Signals that the caller is done paying attention to test results and the
   /// engine should release any resources it has allocated.
-  Future close() =>
-      Future.wait(liveTests.map((liveTest) => liveTest.close()));
+  ///
+  /// Any actively-running tests are also closed. VM tests are allowed to finish
+  /// running so that any modifications they've made to the filesystem can be
+  /// cleaned up.
+  Future close() {
+    _closed = true;
+    return Future.wait(liveTests.map((liveTest) => liveTest.close()));
+  }
 }
diff --git a/lib/src/runner/reporter/compact.dart b/lib/src/runner/reporter/compact.dart
index 42b1eb755a3e5a1232b0b9aa2ba07c4872c25fdc..b24136aff21960904527057631e5fa2ee3dd55ff 100644
--- a/lib/src/runner/reporter/compact.dart
+++ b/lib/src/runner/reporter/compact.dart
@@ -51,6 +51,9 @@ class CompactReporter {
   /// The set of tests that have completed and been marked as failing or error.
   final _failed = new Set<LiveTest>();
 
+  /// Whether [close] has been called.
+  bool _closed = false;
+
   /// The size of [_passed] last time a progress notification was printed.
   int _lastProgressPassed;
 
@@ -60,6 +63,9 @@ class CompactReporter {
   /// The message printed for the last progress notification.
   String _lastProgressMessage;
 
+  // Whether a newline has been printed since the last progress line.
+  var _printedNewline = true;
+
   /// Creates a [ConsoleReporter] that will run all tests in [suites].
   ///
   /// If [color] is `true`, this will use terminal colors; if it's `false`, it
@@ -72,10 +78,10 @@ class CompactReporter {
         _green = color ? '\u001b[32m' : '',
         _red = color ? '\u001b[31m' : '',
         _noColor = color ? '\u001b[0m' : '' {
-    // Whether a newline has been printed since the last progress line.
-    var printedNewline = false;
     _engine.onTestStarted.listen((liveTest) {
       _progressLine(_description(liveTest));
+      _printedNewline = false;
+
       liveTest.onStateChange.listen((state) {
         if (state.status != Status.complete) return;
         if (state.result == Result.success) {
@@ -85,15 +91,15 @@ class CompactReporter {
           _failed.add(liveTest);
         }
         _progressLine(_description(liveTest));
-        printedNewline = false;
+        _printedNewline = false;
       });
 
       liveTest.onError.listen((error) {
         if (liveTest.state.status != Status.complete) return;
 
         _progressLine(_description(liveTest));
-        if (!printedNewline) print('');
-        printedNewline = true;
+        if (!_printedNewline) print('');
+        _printedNewline = true;
 
         print(indent(error.error.toString()));
         print(indent(terseChain(error.stackTrace).toString()));
@@ -101,8 +107,8 @@ class CompactReporter {
 
       liveTest.onPrint.listen((line) {
         _progressLine(_description(liveTest));
-        if (!printedNewline) print('');
-        printedNewline = true;
+        if (!_printedNewline) print('');
+        _printedNewline = true;
 
         print(line);
       });
@@ -126,6 +132,8 @@ class CompactReporter {
 
     _stopwatch.start();
     return _engine.run().then((success) {
+      if (_closed) return false;
+
       if (success) {
         _progressLine("All tests passed!");
         print('');
@@ -140,7 +148,12 @@ class CompactReporter {
 
   /// Signals that the caller is done with any test output and the reporter
   /// should release any resources it has allocated.
-  Future close() => _engine.close();
+  Future close() {
+    if (!_printedNewline) print("");
+    _printedNewline = true;
+    _closed = true;
+    return _engine.close();
+  }
 
   /// Prints a line representing the current state of the tests.
   ///
diff --git a/lib/src/runner/vm/isolate_listener.dart b/lib/src/runner/vm/isolate_listener.dart
index c0e7c09c7fcf2f6fbb797479816a0134cf7b86d1..46c05f2592c70c8e09abdd8a61609a80ec76da1e 100644
--- a/lib/src/runner/vm/isolate_listener.dart
+++ b/lib/src/runner/vm/isolate_listener.dart
@@ -97,6 +97,15 @@ class IsolateListener {
   void _runTest(Test test, SendPort sendPort) {
     var liveTest = test.load(_suite);
 
+    var receivePort = new ReceivePort();
+    sendPort.send({"type": "started", "reply": receivePort.sendPort});
+
+    receivePort.listen((message) {
+      assert(message['command'] == 'close');
+      receivePort.close();
+      liveTest.close();
+    });
+
     liveTest.onStateChange.listen((state) {
       sendPort.send({
         "type": "state-change",
diff --git a/lib/src/runner/vm/isolate_test.dart b/lib/src/runner/vm/isolate_test.dart
index ef73869f4a6a9d8e63d525d06a9f38814faa5dcb..61a74cc30efae9e8491efda489a67a439ef50e35 100644
--- a/lib/src/runner/vm/isolate_test.dart
+++ b/lib/src/runner/vm/isolate_test.dart
@@ -4,6 +4,7 @@
 
 library test.runner.vm.isolate_test;
 
+import 'dart:async';
 import 'dart:isolate';
 
 import '../../backend/live_test.dart';
@@ -26,11 +27,18 @@ class IsolateTest implements Test {
 
   /// Loads a single runnable instance of this test.
   LiveTest load(Suite suite) {
-    var receivePort;
     var controller;
+
+    // We get a new send port for communicating with the live test, since
+    // [_sendPort] is only for communicating with the non-live test. This will
+    // be non-null once the test starts running.
+    var sendPortCompleter;
+
+    var receivePort;
     controller = new LiveTestController(suite, this, () {
       controller.setState(const State(Status.running, Result.success));
 
+      sendPortCompleter = new Completer();
       receivePort = new ReceivePort();
       _sendPort.send({
         'command': 'run',
@@ -38,7 +46,9 @@ class IsolateTest implements Test {
       });
 
       receivePort.listen((message) {
-        if (message['type'] == 'error') {
+        if (message['type'] == 'started') {
+          sendPortCompleter.complete(message['reply']);
+        } else if (message['type'] == 'error') {
           var asyncError = RemoteException.deserialize(message['error']);
           controller.addError(asyncError.error, asyncError.stackTrace);
         } else if (message['type'] == 'state-change') {
@@ -53,8 +63,21 @@ class IsolateTest implements Test {
           controller.completer.complete();
         }
       });
-    }, onClose: () {
-      if (receivePort != null) receivePort.close();
+    }, () {
+      // If the test has finished running, just disconnect the receive port. The
+      // Dart process won't terminate if there are any live receive ports open.
+      if (controller.completer.isCompleted) {
+        receivePort.close();
+        return;
+      }
+
+      // If the test is still running, send it a message telling it to shut down
+      // ASAP. This causes the [Invoker] to eagerly throw exceptions whenever
+      // the test touches it.
+      sendPortCompleter.future.then((sendPort) {
+        sendPort.send({'command': 'close'});
+        return controller.completer.future;
+      }).then((_) => receivePort.close());
     });
     return controller.liveTest;
   }
diff --git a/lib/src/util/dart.dart b/lib/src/util/dart.dart
index 378c0350b97f329ce0ff7b23a8086b1b17350233..c62bacbfd2f188dee6d2196fe71d6082bcccf1ae 100644
--- a/lib/src/util/dart.dart
+++ b/lib/src/util/dart.dart
@@ -28,7 +28,7 @@ import 'remote_exception.dart';
 /// [String] or a [Uri].
 Future<Isolate> runInIsolate(String code, message, {packageRoot}) {
   // TODO(nweiz): load code from a local server rather than from a file.
-  var dir = Directory.systemTemp.createTempSync().path;
+  var dir = createTempDir();
   var dartPath = p.join(dir, 'runInIsolate.dart');
   new File(dartPath).writeAsStringSync(code);
   var port = new ReceivePort();
diff --git a/lib/src/util/io.dart b/lib/src/util/io.dart
index 39c336ac84ddff329fcecdc6fee2f9932b7110f5..fa12ae6d1eb205677ad246c964a0a8fd36930873 100644
--- a/lib/src/util/io.dart
+++ b/lib/src/util/io.dart
@@ -26,6 +26,15 @@ final OperatingSystem currentOS = (() {
   throw new UnsupportedError('Unsupported operating system "$name".');
 })();
 
+/// The root directory below which to nest temporary directories created by the
+/// test runner.
+///
+/// This is configurable so that the test code can validate that the runner
+/// cleans up after itself fully.
+final _tempDir = Platform.environment.containsKey("_UNITTEST_TEMP_DIR")
+    ? Platform.environment["_UNITTEST_TEMP_DIR"]
+    : Directory.systemTemp.path;
+
 /// The path to the `lib` directory of the `test` package.
 String libDir({String packageRoot}) {
   var pathToIo = libraryPath(#test.util.io, packageRoot: packageRoot);
@@ -52,6 +61,10 @@ bool get canUseSpecialChars =>
     Platform.operatingSystem != 'windows' &&
     Platform.environment["_UNITTEST_USE_COLOR"] != "false";
 
+/// Creates a temporary directory and returns its path.
+String createTempDir() =>
+    new Directory(_tempDir).createTempSync('dart_test_').path;
+
 /// Creates a temporary directory and passes its path to [fn].
 ///
 /// Once the [Future] returned by [fn] completes, the temporary directory and
@@ -62,11 +75,9 @@ bool get canUseSpecialChars =>
 /// [fn] completes to.
 Future withTempDir(Future fn(String path)) {
   return new Future.sync(() {
-    // TODO(nweiz): Empirically test whether sync or async functions perform
-    // better here when starting a bunch of isolates.
-    var tempDir = Directory.systemTemp.createTempSync('test_');
-    return new Future.sync(() => fn(tempDir.path))
-        .whenComplete(() => tempDir.deleteSync(recursive: true));
+    var tempDir = createTempDir();
+    return new Future.sync(() => fn(tempDir))
+        .whenComplete(() => new Directory(tempDir).deleteSync(recursive: true));
   });
 }
 
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 97997d7752521a1e30d13ec6997ad4e04f4a19d2..7d1094edd970080d7e04b8690353efb78633cad2 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -16,6 +16,9 @@ import 'backend/operating_system.dart';
 /// The return type should only ever by [Future] or void.
 typedef AsyncFunction();
 
+/// A typedef for a zero-argument callback function.
+typedef void Callback();
+
 /// A regular expression to match the exception prefix that some exceptions'
 /// [Object.toString] values contain.
 final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): ');
@@ -141,3 +144,38 @@ String truncate(String text, int maxLength) {
   }
   return '...$result';
 }
+
+/// Merges [streams] into a single stream that emits events from all sources.
+Stream mergeStreams(Iterable<Stream> streamIter) {
+  var streams = streamIter.toList();
+
+  var subscriptions = new Set();
+  var controller;
+  controller = new StreamController(sync: true, onListen: () {
+    for (var stream in streams) {
+      var subscription;
+      subscription = stream.listen(
+          controller.add,
+          onError: controller.addError,
+          onDone: () {
+        subscriptions.remove(subscription);
+        if (subscriptions.isEmpty) controller.close();
+      });
+      subscriptions.add(subscription);
+    }
+  }, onPause: () {
+    for (var subscription in subscriptions) {
+      subscription.pause();
+    }
+  }, onResume: () {
+    for (var subscription in subscriptions) {
+      subscription.resume();
+    }
+  }, onCancel: () {
+    for (var subscription in subscriptions) {
+      subscription.cancel();
+    }
+  });
+
+  return controller.stream;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 577cd58d9e151fa6f76e27b998dc08ab1a640328..b26c9d3f3bb891e703a394e0fe7e3082b2d40d0f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 0.12.0-beta.4
+version: 0.12.0-dev
 author: Dart Team <misc@dartlang.org>
 description: A library for writing dart unit tests.
 homepage: https://github.com/dart-lang/test
diff --git a/test/io.dart b/test/io.dart
index 9dccdaef5772d4ed5f3c1430ca33f7ea67b2d504..68b6b52e5b39c5ff78fb101d594c9073b9d3b635 100644
--- a/test/io.dart
+++ b/test/io.dart
@@ -4,6 +4,7 @@
 
 library test.test.io;
 
+import 'dart:async';
 import 'dart:io';
 
 import 'package:path/path.dart' as p;
@@ -37,3 +38,28 @@ ProcessResult runDart(List<String> args, {String workingDirectory,
   return Process.runSync(Platform.executable, allArgs,
       workingDirectory: workingDirectory, environment: environment);
 }
+
+/// Starts the test executable with the package root set properly.
+Future<Process> startUnittest(List<String> args, {String workingDirectory,
+    Map<String, String> environment}) {
+  var allArgs = [
+    p.absolute(p.join(packageDir, 'bin/test.dart')),
+    "--package-root=${p.join(packageDir, 'packages')}"
+  ]..addAll(args);
+
+  if (environment == null) environment = {};
+  environment.putIfAbsent("_UNITTEST_USE_COLOR", () => "false");
+
+  return startDart(allArgs, workingDirectory: workingDirectory,
+      environment: environment);
+}
+
+/// Starts Dart.
+Future<Process> startDart(List<String> args, {String workingDirectory,
+    Map<String, String> environment}) {
+  var allArgs = Platform.executableArguments.toList()..addAll(args);
+
+  // TODO(nweiz): Use ScheduledProcess once it's compatible.
+  return Process.start(Platform.executable, allArgs,
+      workingDirectory: workingDirectory, environment: environment);
+}
diff --git a/test/runner/browser/compact_reporter_test.dart b/test/runner/browser/compact_reporter_test.dart
index 24045229d8b888f1d3146c6c7be51d7e9fb4e02f..94b748d2aa7019fc23d58c7735db3648c859dd9b 100644
--- a/test/runner/browser/compact_reporter_test.dart
+++ b/test/runner/browser/compact_reporter_test.dart
@@ -7,6 +7,7 @@
 import 'dart:io';
 
 import 'package:path/path.dart' as p;
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../../io.dart';
@@ -15,7 +16,7 @@ String _sandbox;
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {
diff --git a/test/runner/browser/compiler_pool_test.dart b/test/runner/browser/compiler_pool_test.dart
index ed28f052e133d35d36bcf3f56d21ab6fd15ddafe..6b05a3a15d22e01639d0acd39c171533fdf90b8b 100644
--- a/test/runner/browser/compiler_pool_test.dart
+++ b/test/runner/browser/compiler_pool_test.dart
@@ -8,6 +8,7 @@ import 'dart:io';
 
 import 'package:path/path.dart' as p;
 import 'package:test/src/util/exit_codes.dart' as exit_codes;
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../../io.dart';
@@ -16,7 +17,7 @@ String _sandbox;
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {
diff --git a/test/runner/browser/loader_test.dart b/test/runner/browser/loader_test.dart
index 4d0228e771094992063a5c5d7ad02a17c18d2ae8..e1a7d6657eb97474a45f444f1476a19c3d3dc38d 100644
--- a/test/runner/browser/loader_test.dart
+++ b/test/runner/browser/loader_test.dart
@@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
 import 'package:test/src/backend/state.dart';
 import 'package:test/src/backend/test_platform.dart';
 import 'package:test/src/runner/loader.dart';
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../../io.dart';
@@ -34,7 +35,7 @@ void main() {
   setUp(() {
     _loader = new Loader([TestPlatform.chrome],
         packageRoot: p.join(packageDir, 'packages'));
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
     /// TODO(nweiz): Use scheduled_test for this once it's compatible with this
     /// version of test.
     new File(p.join(_sandbox, 'a_test.dart')).writeAsStringSync(_tests);
diff --git a/test/runner/browser/runner_test.dart b/test/runner/browser/runner_test.dart
index abe5ac4da5e06f56e21b6a64fdb5e8e26f0a81f6..aea05886d3756e828653b590adb81018a3dcd6fa 100644
--- a/test/runner/browser/runner_test.dart
+++ b/test/runner/browser/runner_test.dart
@@ -8,6 +8,7 @@ import 'dart:io';
 
 import 'package:path/path.dart' as p;
 import 'package:test/src/util/exit_codes.dart' as exit_codes;
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../../io.dart';
@@ -26,7 +27,7 @@ void main() {
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {
diff --git a/test/runner/loader_test.dart b/test/runner/loader_test.dart
index 9be28d77ff7ebf23b264a90dd39dece8e810ba73..f56bc94b4a9a1871c743f4c6814ed7780baac477 100644
--- a/test/runner/loader_test.dart
+++ b/test/runner/loader_test.dart
@@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
 import 'package:test/src/backend/state.dart';
 import 'package:test/src/backend/test_platform.dart';
 import 'package:test/src/runner/loader.dart';
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../io.dart';
@@ -34,7 +35,7 @@ void main() {
   setUp(() {
     _loader = new Loader([TestPlatform.vm],
         packageRoot: p.join(packageDir, 'packages'));
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {
diff --git a/test/runner/parse_metadata_test.dart b/test/runner/parse_metadata_test.dart
index e65b62a2ead0ca479c2b0703aabae233158a46eb..72c2b4cfe76d530b18d3f1da4482876fb91949f7 100644
--- a/test/runner/parse_metadata_test.dart
+++ b/test/runner/parse_metadata_test.dart
@@ -11,13 +11,14 @@ import 'package:test/test.dart';
 import 'package:test/src/backend/platform_selector.dart';
 import 'package:test/src/backend/test_platform.dart';
 import 'package:test/src/runner/parse_metadata.dart';
+import 'package:test/src/util/io.dart';
 
 String _sandbox;
 String _path;
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
     _path = p.join(_sandbox, "test.dart");
   });
 
diff --git a/test/runner/runner_test.dart b/test/runner/runner_test.dart
index af67c03590988164b0ae24d3a417721437cb0eab..c1c003967d822199c5fa38a075fcddec572a2c22 100644
--- a/test/runner/runner_test.dart
+++ b/test/runner/runner_test.dart
@@ -8,6 +8,7 @@ import 'dart:io';
 
 import 'package:path/path.dart' as p;
 import 'package:test/src/util/exit_codes.dart' as exit_codes;
+import 'package:test/src/util/io.dart';
 import 'package:test/test.dart';
 
 import '../io.dart';
@@ -51,7 +52,7 @@ Usage: pub run test:test [files or directories...]
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {
diff --git a/test/runner/signal_test.dart b/test/runner/signal_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..d0e7520a49be2dc6a1aa7d373786249419360eb5
--- /dev/null
+++ b/test/runner/signal_test.dart
@@ -0,0 +1,277 @@
+// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn("vm")
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test/src/util/io.dart';
+
+import '../io.dart';
+
+String _sandbox;
+
+String get _tempDir => p.join(_sandbox, "tmp");
+
+final _lines = UTF8.decoder.fuse(const LineSplitter());
+
+// This test is inherently prone to race conditions. If it fails, it will likely
+// do so flakily, but if it succeeds, it will succeed consistently. The tests
+// represent a best effort to kill the test runner at certain times during its
+// execution.
+void main() {
+  setUp(() {
+    _sandbox = createTempDir();
+  });
+
+  tearDown(() {
+    new Directory(_sandbox).deleteSync(recursive: true);
+  });
+
+  group("during loading,", () {
+    test("cleans up if killed while loading a VM test", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+void main() {
+  print("in test.dart");
+  // Spin for a long time so the test is probably killed while still loading.
+  for (var i = 0; i < 100000000; i++) {}
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).first.then((line) {
+          expect(line, equals("in test.dart"));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+
+    test("cleans up if killed while loading a browser test", () {
+      new File(p.join(_sandbox, "test.dart"))
+          .writeAsStringSync("void main() {}");
+
+      return _startUnittest(["-p", "chrome", "test.dart"]).then((process) {
+        return _lines.bind(process.stdout).first.then((line) {
+          expect(line, equals("Compiling test.dart..."));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+
+    test("exits immediately if ^C is sent twice", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+void main() {
+  print("in test.dart");
+  while (true) {}
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).first.then((line) {
+          expect(line, equals("in test.dart"));
+          process.kill();
+
+          // TODO(nweiz): Sending two signals in close succession can cause the
+          // second one to be ignored, so we wait a bit before the second
+          // one. Remove this hack when issue 23047 is fixed.
+          return new Future.delayed(new Duration(seconds: 1));
+        }).then((_) {
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+  });
+
+  group("during test running", () {
+    test("waits for a VM test to finish running", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'dart:async';
+import 'dart:io';
+
+import 'package:test/test.dart';
+
+void main() {
+  tearDown(() => new File("output").writeAsStringSync("ran teardown"));
+
+  test("test", () {
+    print("running test");
+    return new Future.delayed(new Duration(seconds: 1));
+  });
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).skip(2).first.then((line) {
+          expect(line, equals("running test"));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new File(p.join(_sandbox, "output")).readAsStringSync(),
+              equals("ran teardown"));
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+
+    test("kills a browser test immediately", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+void main() {
+  test("test", () {
+    print("running test");
+
+    // Allow an event loop to pass so the preceding print can be handled.
+    return new Future(() {
+      // Loop forever so that if the test isn't stopped while running, it never
+      // stops.
+      while (true) {}
+    });
+  });
+}
+""");
+
+      return _startUnittest(["-p", "chrome", "test.dart"]).then((process) {
+        return _lines.bind(process.stdout).skip(3).first.then((line) {
+          expect(line, equals("running test"));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+
+    test("kills a VM test immediately if ^C is sent twice", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'package:test/test.dart';
+
+void main() {
+  test("test", () {
+    print("running test");
+    while (true) {}
+  });
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).skip(2).first.then((line) {
+          expect(line, equals("running test"));
+          process.kill();
+
+          // TODO(nweiz): Sending two signals in close succession can cause the
+          // second one to be ignored, so we wait a bit before the second
+          // one. Remove this hack when issue 23047 is fixed.
+          return new Future.delayed(new Duration(seconds: 1));
+        }).then((_) {
+          process.kill();
+          return process.exitCode;
+        });
+      });
+    });
+
+    test("causes expect() to always throw an error immediately", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'dart:async';
+import 'dart:io';
+
+import 'package:test/test.dart';
+
+void main() {
+  var expectThrewError = false;
+
+  tearDown(() {
+    new File("output").writeAsStringSync(expectThrewError.toString());
+  });
+
+  test("test", () {
+    print("running test");
+
+    return new Future.delayed(new Duration(seconds: 1)).then((_) {
+      try {
+        expect(true, isTrue);
+      } catch (_) {
+        expectThrewError = true;
+      }
+    });
+  });
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).skip(2).first.then((line) {
+          expect(line, equals("running test"));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new File(p.join(_sandbox, "output")).readAsStringSync(),
+              equals("true"));
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+
+    test("causes expectAsync() to always throw an error immediately", () {
+      new File(p.join(_sandbox, "test.dart")).writeAsStringSync("""
+import 'dart:async';
+import 'dart:io';
+
+import 'package:test/test.dart';
+
+void main() {
+  var expectAsyncThrewError = false;
+
+  tearDown(() {
+    new File("output").writeAsStringSync(expectAsyncThrewError.toString());
+  });
+
+  test("test", () {
+    print("running test");
+
+    return new Future.delayed(new Duration(seconds: 1)).then((_) {
+      try {
+        expectAsync(() {});
+      } catch (_) {
+        expectAsyncThrewError = true;
+      }
+    });
+  });
+}
+""");
+
+      return _startUnittest(["test.dart"]).then((process) {
+        return _lines.bind(process.stdout).skip(2).first.then((line) {
+          expect(line, equals("running test"));
+          process.kill();
+          return process.exitCode;
+        }).then((_) {
+          expect(new File(p.join(_sandbox, "output")).readAsStringSync(),
+              equals("true"));
+          expect(new Directory(_tempDir).listSync(), isEmpty);
+        });
+      });
+    });
+  });
+}
+
+Future<Process> _startUnittest(List<String> args) {
+  new Directory(_tempDir).create();
+  return startUnittest(args, workingDirectory: _sandbox,
+      environment: {"_UNITTEST_TEMP_DIR": _tempDir});
+}
diff --git a/test/runner/test_on_test.dart b/test/runner/test_on_test.dart
index 91b69d33a1bd579b660f3674dd2d56f015e4ea6c..632f94c27afa6a3e276e8586ae48bb8b09005425 100644
--- a/test/runner/test_on_test.dart
+++ b/test/runner/test_on_test.dart
@@ -18,7 +18,7 @@ final _otherOS = Platform.isWindows ? "mac-os" : "windows";
 
 void main() {
   setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('test_').path;
+    _sandbox = createTempDir();
   });
 
   tearDown(() {