[CP-stable][ Hot Restart ] Fix possible hang due to unhandled exception in UI isolates on hot restart (#166064)
This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request)
Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request.
### Issue Link:
https://github.com/flutter/flutter/issues/161466
### Changelog Description:
[flutter/161466](https://github.com/flutter/flutter/issues/161466): Fixed issue where hot restart could hang indefinitely if "Pause on Unhandled Exceptions" was enabled and a call to `Isolate.run` had not completed.
### Impact Description:
Hot restart (and the Dart-Code extension) could end up in a bad state where hot restart never completes and interacting with the application via the Dart-Code extension doesn't work as expected. The application becomes unresponsive and must be fully restarted to continue development.
`Isolate.run` is used to load license files in the background, meaning that users don't need to explicitly be spawning isolates to encounter this issue.
### Workaround:
Is there a workaround for this issue?
Explicitly disable "Pause on Unhandled Exceptions", which is typically enabled by default.
### Risk:
What is the risk level of this cherry-pick?
### Test Coverage:
Are you confident that your fix is well-tested by automated tests?
### Validation Steps:
What are the steps to validate that this fix works?
1. Create a Flutter project with the following `main.dart`:
```dart
import 'dart:async';
import 'dart:developer';
import 'dart:isolate';
import 'package:flutter/material.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized().platformDispatcher.onError = (Object error, StackTrace? stack) {
return true;
};
runApp(
const Center(
child: Text(
'Hello, world!',
key: Key('title'),
textDirection: TextDirection.ltr,
),
),
);
Isolate.run(() {
print('COMPUTING');
debugger();
});
}
```
2. Run the application in debug mode and perform a hot restart once `COMPUTING` appears on `stdout`. Hot restart should complete successfully.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 599b13b..dbee234 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@
- [flutter/165166](https://github.com/flutter/flutter/pull/165166) - Impeller, All platforms, Text that is scaled over 48x renders incorrectly.
- [flutter/163627](https://github.com/flutter/flutter/pull/163627) - Fix issue where placeholder types in ARB localizations weren't used for type inference, causing a possible type mismatch with the placeholder field defined in the template.
- [flutter/165166](https://github.com/flutter/flutter/pull/165166) - Update CI configurations and tests to use Xcode 16 and iOS 18 simulator.
+- [flutter/161466](https://github.com/flutter/flutter/pull/161466) - Hot restart can hang on all platforms if "Pause on Unhandled Exceptions" is enabled by the debugger and a call to `compute` or `Isolate.run` has not completed.
### [3.29.2](https://github.com/flutter/flutter/releases/tag/3.29.2)
diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart
index 011c6e5..e463502 100644
--- a/packages/flutter_tools/lib/src/run_hot.dart
+++ b/packages/flutter_tools/lib/src/run_hot.dart
@@ -645,7 +645,7 @@
);
operations.add(
reloadIsolate.then((vm_service.Isolate? isolate) async {
- if ((isolate != null) && isPauseEvent(isolate.pauseEvent!.kind!)) {
+ if (isolate != null) {
// The embedder requires that the isolate is unpaused, because the
// runInView method requires interaction with dart engine APIs that
// are not thread-safe, and thus must be run on the same thread that
@@ -655,7 +655,7 @@
// or in a frequently called method) or an exception. Instead, all
// breakpoints are first disabled and exception pause mode set to
// None, and then the isolate resumed.
- // These settings to not need restoring as Hot Restart results in
+ // These settings do not need restoring as Hot Restart results in
// new isolates, which will be configured by the editor as they are
// started.
final List<Future<void>> breakpointAndExceptionRemoval = <Future<void>>[
@@ -667,12 +667,22 @@
device.vmService!.service.removeBreakpoint(isolate.id!, breakpoint.id!),
];
await Future.wait(breakpointAndExceptionRemoval);
- await device.vmService!.service.resume(view.uiIsolate!.id!);
+ if (isPauseEvent(isolate.pauseEvent!.kind!)) {
+ await device.vmService!.service.resume(view.uiIsolate!.id!);
+ }
}
}),
);
}
+ // Wait for the UI isolates to have their breakpoints removed and exception pause mode
+ // cleared while also ensuring the isolate's are no longer paused. If we don't clear
+ // the exception pause mode before we start killing child isolates, it's possible that
+ // any UI isolate waiting on a result from a child isolate could throw an unhandled
+ // exception and re-pause the isolate, causing hot restart to hang.
+ await Future.wait(operations);
+ operations.clear();
+
// The engine handles killing and recreating isolates that it has spawned
// ("uiIsolates"). The isolates that were spawned from these uiIsolates
// will not be restarted, and so they must be manually killed.
diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
index 2f49b42..2f4dd0a 100644
--- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
@@ -241,6 +241,14 @@
jsonResponse: fakeUnpausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
@@ -840,6 +848,14 @@
jsonResponse: fakeUnpausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
@@ -916,18 +932,28 @@
jsonResponse: fakePausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
+ method: 'removeBreakpoint',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'breakpointId': 'test-breakpoint',
+ },
+ ),
+ FakeVmServiceRequest(
+ method: 'resume',
+ args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
- const FakeVmServiceRequest(
- method: 'setIsolatePauseMode',
- args: <String, String>{'isolateId': '1', 'exceptionPauseMode': 'None'},
- ),
- const FakeVmServiceRequest(
- method: 'removeBreakpoint',
- args: <String, String>{'isolateId': '1', 'breakpointId': 'test-breakpoint'},
- ),
- const FakeVmServiceRequest(method: 'resume', args: <String, String>{'isolateId': '1'}),
listViews,
const FakeVmServiceRequest(
method: 'streamListen',
@@ -978,6 +1004,14 @@
jsonResponse: fakeUnpausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
@@ -1005,6 +1039,14 @@
jsonResponse: fakeUnpausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
@@ -1032,6 +1074,14 @@
jsonResponse: fakeUnpausedIsolate.toJson(),
),
FakeVmServiceRequest(
+ method: 'setIsolatePauseMode',
+ args: <String, Object?>{
+ 'isolateId': fakeUnpausedIsolate.id,
+ 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
+ },
+ jsonResponse: vm_service.Success().toJson(),
+ ),
+ FakeVmServiceRequest(
method: 'getVM',
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
),
diff --git a/packages/flutter_tools/test/integration.shard/hot_restart_with_unhandled_exception_test.dart b/packages/flutter_tools/test/integration.shard/hot_restart_with_unhandled_exception_test.dart
new file mode 100644
index 0000000..181ada6
--- /dev/null
+++ b/packages/flutter_tools/test/integration.shard/hot_restart_with_unhandled_exception_test.dart
@@ -0,0 +1,63 @@
+// Copyright 2014 The Flutter Authors. 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 'package:file/file.dart';
+import 'package:vm_service/vm_service.dart';
+import 'package:vm_service/vm_service_io.dart';
+
+import '../src/common.dart';
+import 'test_data/hot_restart_with_paused_child_isolate_project.dart';
+import 'test_driver.dart';
+import 'test_utils.dart';
+
+void main() {
+ late Directory tempDir;
+ final HotRestartWithPausedChildIsolateProject project = HotRestartWithPausedChildIsolateProject();
+ late FlutterRunTestDriver flutter;
+
+ setUp(() async {
+ tempDir = createResolvedTempDirectorySync('hot_restart_test.');
+ await project.setUpIn(tempDir);
+ flutter = FlutterRunTestDriver(tempDir);
+ });
+
+ tearDown(() async {
+ await flutter.stop();
+ tryToDelete(tempDir);
+ });
+
+ // Possible regression test for https://github.com/flutter/flutter/issues/161466
+ testWithoutContext("Hot restart doesn't hang when an unhandled exception is "
+ 'thrown in the UI isolate', () async {
+ await flutter.run(withDebugger: true, startPaused: true, pauseOnExceptions: true);
+ final VmService vmService = await vmServiceConnectUri(flutter.vmServiceWsUri.toString());
+ final Isolate root = await flutter.getFlutterIsolate();
+
+ // The UI isolate has already started paused. Setup a listener for the
+ // child isolate that will spawn when the isolate resumes. Resume the
+ // spawned child which will pause on start, and then wait for it to execute
+ // the `debugger()` call.
+ final Completer<void> childIsolatePausedCompleter = Completer<void>();
+ vmService.onDebugEvent.listen((Event event) async {
+ if (event.kind == EventKind.kPauseStart) {
+ await vmService.resume(event.isolate!.id!);
+ } else if (event.kind == EventKind.kPauseBreakpoint) {
+ if (!childIsolatePausedCompleter.isCompleted) {
+ await vmService.streamCancel(EventStreams.kDebug);
+ childIsolatePausedCompleter.complete();
+ }
+ }
+ });
+ await vmService.streamListen(EventStreams.kDebug);
+
+ await vmService.resume(root.id!);
+ await childIsolatePausedCompleter.future;
+
+ // This call will fail to return if the UI isolate pauses on an unhandled
+ // exception due to the isolate spawned by `Isolate.run` not completing.
+ await flutter.hotRestart();
+ });
+}
diff --git a/packages/flutter_tools/test/integration.shard/test_data/hot_restart_with_paused_child_isolate_project.dart b/packages/flutter_tools/test/integration.shard/test_data/hot_restart_with_paused_child_isolate_project.dart
new file mode 100644
index 0000000..df057e7
--- /dev/null
+++ b/packages/flutter_tools/test/integration.shard/test_data/hot_restart_with_paused_child_isolate_project.dart
@@ -0,0 +1,50 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'project.dart';
+
+// Reproduction case from
+// https://github.com/flutter/flutter/issues/161466#issuecomment-2743309718.
+class HotRestartWithPausedChildIsolateProject extends Project {
+ @override
+ final String pubspec = '''
+ name: test
+ environment:
+ sdk: ^3.7.0-0
+
+ dependencies:
+ flutter:
+ sdk: flutter
+ ''';
+
+ @override
+ final String main = r'''
+ import 'dart:async';
+ import 'dart:developer';
+ import 'dart:isolate';
+
+ import 'package:flutter/material.dart';
+
+ void main() {
+ WidgetsFlutterBinding.ensureInitialized().platformDispatcher.onError = (Object error, StackTrace? stack) {
+ print('HERE');
+ return true;
+ };
+ runApp(
+ const Center(
+ child: Text(
+ 'Hello, world!',
+ key: Key('title'),
+ textDirection: TextDirection.ltr,
+ ),
+ ),
+ );
+
+ Isolate.run(() {
+ print('COMPUTING');
+ debugger();
+ });
+ }
+ ''';
+}
diff --git a/packages/flutter_tools/test/integration.shard/test_driver.dart b/packages/flutter_tools/test/integration.shard/test_driver.dart
index 778bfe4..0dd6ab3 100644
--- a/packages/flutter_tools/test/integration.shard/test_driver.dart
+++ b/packages/flutter_tools/test/integration.shard/test_driver.dart
@@ -148,7 +148,9 @@
final Completer<void> isolateStarted = Completer<void>();
_vmService!.onIsolateEvent.listen((Event event) {
if (event.kind == EventKind.kIsolateStart) {
- isolateStarted.complete();
+ if (!isolateStarted.isCompleted) {
+ isolateStarted.complete();
+ }
} else if (event.kind == EventKind.kIsolateExit && event.isolate?.id == _flutterIsolateId) {
// Hot restarts cause all the isolates to exit, so we need to refresh
// our idea of what the Flutter isolate ID is.