Disallow exiting from VM tests (#2640)

As suggested in #2577

Tests exiting early with a hard exit is an infrequent but persistent
source of confusion. Use `IOOverrides` to treat any call to `exit` as a
test failure instead of a VM exit.
diff --git a/pkgs/test/CHANGELOG.md b/pkgs/test/CHANGELOG.md
index 66d75f8..0cfc58a 100644
--- a/pkgs/test/CHANGELOG.md
+++ b/pkgs/test/CHANGELOG.md
@@ -10,6 +10,7 @@
   independent from any OS specific configuration. For instance a wide skip of
   all tests with OS `'windows'` would previously still run browser tests on
   windows, but will now skip all tests including browser tests.
+* Treat calls to `exit` as test failures in VM tests.
 
 ## 1.31.1
 
diff --git a/pkgs/test/test/runner/subprocess_crash_test.dart b/pkgs/test/test/runner/subprocess_crash_test.dart
index e98f634..b6e40df 100644
--- a/pkgs/test/test/runner/subprocess_crash_test.dart
+++ b/pkgs/test/test/runner/subprocess_crash_test.dart
@@ -13,16 +13,16 @@
 void main() {
   setUpAll(precompileTestExecutable);
 
-  test('gracefully handles an early test suite exit', () async {
+  test('gracefully handles a non-exit crash', () async {
     await d.file('test.dart', '''
-      import 'dart:io';
+      import 'dart:ffi';
 
       import 'package:test/test.dart';
 
       void main() {
         test('runs', () {});
-        test('exits', () {
-          exit(0);
+        test('crashes', () {
+          Pointer.fromAddress(0).cast<Long>()[0] = 0;
         });
       }''').create();
 
@@ -30,7 +30,7 @@
     expect(
       test.stdout,
       containsInOrder([
-        '+1: [VM, Exe] exits - did not complete [E]',
+        '+1: [VM, Exe] crashes - did not complete [E]',
         '+1: Some tests failed.',
       ]),
     );
diff --git a/pkgs/test/test/runner/vm/runner_test.dart b/pkgs/test/test/runner/vm/runner_test.dart
new file mode 100644
index 0000000..7f795b6
--- /dev/null
+++ b/pkgs/test/test/runner/vm/runner_test.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2026, 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')
+library;
+
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import '../../io.dart';
+
+void main() {
+  setUpAll(precompileTestExecutable);
+
+  group('fails gracefully if a test file calls exit(0)', () {
+    setUp(() async {
+      await d.file('test.dart', '''
+import 'dart:io';
+import 'package:test/test.dart';
+
+void main() {
+  test('exits', () {
+    exit(0);
+  });
+}
+''').create();
+    });
+
+    test('in a VM test', () async {
+      var test = await runTest(['test.dart']);
+
+      expect(test.stdout, containsInOrder(['exit(0) was called.']));
+      await test.shouldExit(1);
+    });
+
+    test('in a native test', () async {
+      var test = await runTest(['--compiler', 'exe', 'test.dart']);
+
+      expect(test.stdout, containsInOrder(['exit(0) was called.']));
+      await test.shouldExit(1);
+    });
+  });
+}
diff --git a/pkgs/test_core/CHANGELOG.md b/pkgs/test_core/CHANGELOG.md
index b0e5659..608cc4b 100644
--- a/pkgs/test_core/CHANGELOG.md
+++ b/pkgs/test_core/CHANGELOG.md
@@ -1,6 +1,7 @@
 ## 0.6.19-wip
 
 * Support using the OS platform selector to configure browser tests.
+* Treat calls to `exit` as test failures in VM tests.
 
 ## 0.6.18
 
diff --git a/pkgs/test_core/lib/src/bootstrap/vm.dart b/pkgs/test_core/lib/src/bootstrap/vm.dart
index a4eebe6..034a0ab 100644
--- a/pkgs/test_core/lib/src/bootstrap/vm.dart
+++ b/pkgs/test_core/lib/src/bootstrap/vm.dart
@@ -9,6 +9,7 @@
 
 import 'package:stream_channel/isolate_channel.dart';
 import 'package:stream_channel/stream_channel.dart';
+import 'package:test_api/hooks.dart';
 
 import '../runner/plugin/remote_platform_helpers.dart';
 import '../runner/plugin/shared_platform_helpers.dart';
@@ -19,7 +20,12 @@
     IsolateChannel<Object?>.connectSend(sendPort),
   );
   var testControlChannel = platformChannel.virtualChannel()
-    ..pipe(serializeSuite(getMain));
+    ..pipe(
+      IOOverrides.runWithIOOverrides(
+        () => serializeSuite(getMain),
+        _EarlyExitIOOverrides(),
+      ),
+    );
   platformChannel.sink.add(testControlChannel.id);
 
   platformChannel.stream.forEach((message) {
@@ -43,7 +49,12 @@
   var socket = await Socket.connect(args[0], int.parse(args[1]));
   var platformChannel = MultiChannel<Object?>(jsonSocketStreamChannel(socket));
   var testControlChannel = platformChannel.virtualChannel()
-    ..pipe(serializeSuite(getMain));
+    ..pipe(
+      IOOverrides.runWithIOOverrides(
+        () => serializeSuite(getMain),
+        _EarlyExitIOOverrides(),
+      ),
+    );
   platformChannel.sink.add(testControlChannel.id);
 
   unawaited(
@@ -54,3 +65,10 @@
     }),
   );
 }
+
+final class _EarlyExitIOOverrides extends IOOverrides {
+  @override
+  Never exit(int code) {
+    throw TestFailure('exit($code) was called.');
+  }
+}