Use spawnHybrid for the stub server (#700)

Move the in-process stub server from `test/io/utils.dart` to
`test/stub_server.dart` as a `hybridMain`. This will allow spawning the
server from the browser tests as well.

The browser tests still do not work against this server. Separate out
the change in how the server is started from the (potential) other
changes with fixes.

Update tests to use the new pattern - they call `startServer` and wait
for the `serverUrl`. No explicit teardown is needed because the
`spawnHybrid` call gets an implicit `addTearDown` which kills the
isolate.

Remove `test/io/utils.dart` entirely. Inline the definition of
`throwsSocketException` at the single use site.
Make the `message` argument to the other copy of `throwsClientException`
optional and use it in place of the version that had been in
`test/io/utils.dart`.
diff --git a/pubspec.yaml b/pubspec.yaml
index 2689e46..0500d42 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,4 +16,5 @@
   fake_async: ^1.2.0
   lints: ^1.0.0
   shelf: ^1.1.0
+  stream_channel: ^2.1.0
   test: ^1.16.0
diff --git a/test/io/client_test.dart b/test/io/client_test.dart
index d856efb..2115e1c 100644
--- a/test/io/client_test.dart
+++ b/test/io/client_test.dart
@@ -10,12 +10,13 @@
 import 'package:http/io_client.dart' as http_io;
 import 'package:test/test.dart';
 
-import 'utils.dart';
+import '../utils.dart';
 
 void main() {
-  setUp(startServer);
-
-  tearDown(stopServer);
+  late Uri serverUrl;
+  setUpAll(() async {
+    serverUrl = await startServer();
+  });
 
   test('#send a StreamedRequest', () async {
     var client = http.Client();
@@ -101,7 +102,7 @@
     request.headers[HttpHeaders.contentTypeHeader] =
         'application/json; charset=utf-8';
 
-    expect(client.send(request), throwsSocketException);
+    expect(client.send(request), throwsA(isA<SocketException>()));
 
     request.sink.add('{"hello": "world"}'.codeUnits);
     request.sink.close();
diff --git a/test/io/http_test.dart b/test/io/http_test.dart
index 58b1d86..7bdf7da 100644
--- a/test/io/http_test.dart
+++ b/test/io/http_test.dart
@@ -7,14 +7,15 @@
 import 'package:http/http.dart' as http;
 import 'package:test/test.dart';
 
-import 'utils.dart';
+import '../utils.dart';
 
 void main() {
+  late Uri serverUrl;
+  setUpAll(() async {
+    serverUrl = await startServer();
+  });
+
   group('http.', () {
-    setUp(startServer);
-
-    tearDown(stopServer);
-
     test('head', () async {
       var response = await http.head(serverUrl);
       expect(response.statusCode, equals(200));
@@ -408,7 +409,7 @@
     });
 
     test('read throws an error for a 4** status code', () {
-      expect(http.read(serverUrl.resolve('/error')), throwsClientException);
+      expect(http.read(serverUrl.resolve('/error')), throwsClientException());
     });
 
     test('readBytes', () async {
@@ -434,7 +435,7 @@
 
     test('readBytes throws an error for a 4** status code', () {
       expect(
-          http.readBytes(serverUrl.resolve('/error')), throwsClientException);
+          http.readBytes(serverUrl.resolve('/error')), throwsClientException());
     });
   });
 }
diff --git a/test/io/multipart_test.dart b/test/io/multipart_test.dart
index f2333f5..070ea5f 100644
--- a/test/io/multipart_test.dart
+++ b/test/io/multipart_test.dart
@@ -10,7 +10,7 @@
 import 'package:path/path.dart' as path;
 import 'package:test/test.dart';
 
-import 'utils.dart';
+import '../utils.dart';
 
 void main() {
   late Directory tempDir;
diff --git a/test/io/request_test.dart b/test/io/request_test.dart
index 97555f9..80a7f24 100644
--- a/test/io/request_test.dart
+++ b/test/io/request_test.dart
@@ -7,12 +7,13 @@
 import 'package:http/http.dart' as http;
 import 'package:test/test.dart';
 
-import 'utils.dart';
+import '../utils.dart';
 
 void main() {
-  setUp(startServer);
-
-  tearDown(stopServer);
+  late Uri serverUrl;
+  setUpAll(() async {
+    serverUrl = await startServer();
+  });
 
   test('send happy case', () async {
     final request = http.Request('GET', serverUrl)
diff --git a/test/io/streamed_request_test.dart b/test/io/streamed_request_test.dart
index 9dd5d3e..d820068 100644
--- a/test/io/streamed_request_test.dart
+++ b/test/io/streamed_request_test.dart
@@ -9,12 +9,13 @@
 import 'package:http/http.dart' as http;
 import 'package:test/test.dart';
 
-import 'utils.dart';
+import '../utils.dart';
 
 void main() {
-  setUp(startServer);
-
-  tearDown(stopServer);
+  late Uri serverUrl;
+  setUpAll(() async {
+    serverUrl = await startServer();
+  });
 
   group('contentLength', () {
     test('controls the Content-Length header', () async {
diff --git a/test/io/utils.dart b/test/io/utils.dart
deleted file mode 100644
index d2208fb..0000000
--- a/test/io/utils.dart
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (c) 2014, 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.
-
-import 'dart:async';
-import 'dart:convert';
-import 'dart:io';
-
-import 'package:http/http.dart';
-import 'package:http/src/utils.dart';
-import 'package:test/test.dart';
-
-export '../utils.dart';
-
-/// The current server instance.
-HttpServer? _server;
-
-/// The URL for the current server instance.
-Uri get serverUrl => Uri.parse('http://localhost:${_server!.port}');
-
-/// Starts a new HTTP server.
-Future<void> startServer() async {
-  _server = (await HttpServer.bind('localhost', 0))
-    ..listen((request) async {
-      var path = request.uri.path;
-      var response = request.response;
-
-      if (path == '/error') {
-        response
-          ..statusCode = 400
-          ..contentLength = 0;
-        unawaited(response.close());
-        return;
-      }
-
-      if (path == '/loop') {
-        var n = int.parse(request.uri.query);
-        response
-          ..statusCode = 302
-          ..headers
-              .set('location', serverUrl.resolve('/loop?${n + 1}').toString())
-          ..contentLength = 0;
-        unawaited(response.close());
-        return;
-      }
-
-      if (path == '/redirect') {
-        response
-          ..statusCode = 302
-          ..headers.set('location', serverUrl.resolve('/').toString())
-          ..contentLength = 0;
-        unawaited(response.close());
-        return;
-      }
-
-      if (path == '/no-content-length') {
-        response
-          ..statusCode = 200
-          ..contentLength = -1
-          ..write('body');
-        unawaited(response.close());
-        return;
-      }
-
-      var requestBodyBytes = await ByteStream(request).toBytes();
-      var encodingName = request.uri.queryParameters['response-encoding'];
-      var outputEncoding = encodingName == null
-          ? ascii
-          : requiredEncodingForCharset(encodingName);
-
-      response.headers.contentType =
-          ContentType('application', 'json', charset: outputEncoding.name);
-      response.headers.set('single', 'value');
-
-      dynamic requestBody;
-      if (requestBodyBytes.isEmpty) {
-        requestBody = null;
-      } else if (request.headers.contentType?.charset != null) {
-        var encoding =
-            requiredEncodingForCharset(request.headers.contentType!.charset!);
-        requestBody = encoding.decode(requestBodyBytes);
-      } else {
-        requestBody = requestBodyBytes;
-      }
-
-      final headers = <String, List<String>>{};
-
-      request.headers.forEach((name, values) {
-        // These headers are automatically generated by dart:io, so we don't
-        // want to test them here.
-        if (name == 'cookie' || name == 'host') return;
-
-        headers[name] = values;
-      });
-
-      var content = <String, dynamic>{
-        'method': request.method,
-        'path': request.uri.path,
-        if (requestBody != null) 'body': requestBody,
-        'headers': headers,
-      };
-
-      var body = json.encode(content);
-      response
-        ..contentLength = body.length
-        ..write(body);
-      unawaited(response.close());
-    });
-}
-
-/// Stops the current HTTP server.
-void stopServer() {
-  if (_server != null) {
-    _server!.close();
-    _server = null;
-  }
-}
-
-/// A matcher for functions that throw HttpException.
-Matcher get throwsClientException =>
-    throwsA(const TypeMatcher<ClientException>());
-
-/// A matcher for functions that throw SocketException.
-final Matcher throwsSocketException =
-    throwsA(const TypeMatcher<SocketException>());
diff --git a/test/stub_server.dart b/test/stub_server.dart
new file mode 100644
index 0000000..a53f77d
--- /dev/null
+++ b/test/stub_server.dart
@@ -0,0 +1,100 @@
+// Copyright (c) 2022, 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.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http/http.dart';
+import 'package:http/src/utils.dart';
+import 'package:stream_channel/stream_channel.dart';
+
+void hybridMain(StreamChannel<dynamic> channel) async {
+  final server = await HttpServer.bind('localhost', 0);
+  final url = Uri.http('localhost:${server.port}', '');
+  server.listen((request) async {
+    var path = request.uri.path;
+    var response = request.response;
+
+    if (path == '/error') {
+      response
+        ..statusCode = 400
+        ..contentLength = 0;
+      unawaited(response.close());
+      return;
+    }
+
+    if (path == '/loop') {
+      var n = int.parse(request.uri.query);
+      response
+        ..statusCode = 302
+        ..headers.set('location', url.resolve('/loop?${n + 1}').toString())
+        ..contentLength = 0;
+      unawaited(response.close());
+      return;
+    }
+
+    if (path == '/redirect') {
+      response
+        ..statusCode = 302
+        ..headers.set('location', url.resolve('/').toString())
+        ..contentLength = 0;
+      unawaited(response.close());
+      return;
+    }
+
+    if (path == '/no-content-length') {
+      response
+        ..statusCode = 200
+        ..contentLength = -1
+        ..write('body');
+      unawaited(response.close());
+      return;
+    }
+
+    var requestBodyBytes = await ByteStream(request).toBytes();
+    var encodingName = request.uri.queryParameters['response-encoding'];
+    var outputEncoding =
+        encodingName == null ? ascii : requiredEncodingForCharset(encodingName);
+
+    response.headers.contentType =
+        ContentType('application', 'json', charset: outputEncoding.name);
+    response.headers.set('single', 'value');
+
+    dynamic requestBody;
+    if (requestBodyBytes.isEmpty) {
+      requestBody = null;
+    } else if (request.headers.contentType?.charset != null) {
+      var encoding =
+          requiredEncodingForCharset(request.headers.contentType!.charset!);
+      requestBody = encoding.decode(requestBodyBytes);
+    } else {
+      requestBody = requestBodyBytes;
+    }
+
+    final headers = <String, List<String>>{};
+
+    request.headers.forEach((name, values) {
+      // These headers are automatically generated by dart:io, so we don't
+      // want to test them here.
+      if (name == 'cookie' || name == 'host') return;
+
+      headers[name] = values;
+    });
+
+    var content = <String, dynamic>{
+      'method': request.method,
+      'path': request.uri.path,
+      if (requestBody != null) 'body': requestBody,
+      'headers': headers,
+    };
+
+    var body = json.encode(content);
+    response
+      ..contentLength = body.length
+      ..write(body);
+    unawaited(response.close());
+  });
+  channel.sink.add(server.port);
+}
diff --git a/test/utils.dart b/test/utils.dart
index e3bf411..575df1a 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -110,5 +110,19 @@
 /// [http.ClientException] with the given [message].
 ///
 /// [message] can be a String or a [Matcher].
-Matcher throwsClientException(String message) => throwsA(
-    isA<http.ClientException>().having((e) => e.message, 'message', message));
+Matcher throwsClientException([String? message]) {
+  var exception = isA<http.ClientException>();
+  if (message != null) {
+    exception = exception.having((e) => e.message, 'message', message);
+  }
+  return throwsA(exception);
+}
+
+/// Spawn an isolate in the test runner with an http server.
+///
+/// The server isolate will be killed on teardown.
+Future<Uri> startServer() async {
+  final channel = spawnHybridUri(Uri(path: '/test/stub_server.dart'));
+  final port = await channel.stream.first as int;
+  return Uri.http('localhost:$port', '');
+}