Skip to content

Commit cf56914

Browse files
[CP-stable][ Hot Restart ] Fix possible hang due to unhandled exception in UI isolates on hot restart (flutter#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: flutter#161466 ### Changelog Description: [flutter/161466](flutter#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.
1 parent dd7ad71 commit cf56914

File tree

6 files changed

+188
-12
lines changed

6 files changed

+188
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ INTERNAL NOTE
3434
- [flutter/165166](https://github.com/flutter/flutter/pull/165166) - Impeller, All platforms, Text that is scaled over 48x renders incorrectly.
3535
- [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.
3636
- [flutter/165166](https://github.com/flutter/flutter/pull/165166) - Update CI configurations and tests to use Xcode 16 and iOS 18 simulator.
37+
- [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.
3738

3839
### [3.29.2](https://github.com/flutter/flutter/releases/tag/3.29.2)
3940

packages/flutter_tools/lib/src/run_hot.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ class HotRunner extends ResidentRunner {
645645
);
646646
operations.add(
647647
reloadIsolate.then((vm_service.Isolate? isolate) async {
648-
if ((isolate != null) && isPauseEvent(isolate.pauseEvent!.kind!)) {
648+
if (isolate != null) {
649649
// The embedder requires that the isolate is unpaused, because the
650650
// runInView method requires interaction with dart engine APIs that
651651
// are not thread-safe, and thus must be run on the same thread that
@@ -655,7 +655,7 @@ class HotRunner extends ResidentRunner {
655655
// or in a frequently called method) or an exception. Instead, all
656656
// breakpoints are first disabled and exception pause mode set to
657657
// None, and then the isolate resumed.
658-
// These settings to not need restoring as Hot Restart results in
658+
// These settings do not need restoring as Hot Restart results in
659659
// new isolates, which will be configured by the editor as they are
660660
// started.
661661
final List<Future<void>> breakpointAndExceptionRemoval = <Future<void>>[
@@ -667,12 +667,22 @@ class HotRunner extends ResidentRunner {
667667
device.vmService!.service.removeBreakpoint(isolate.id!, breakpoint.id!),
668668
];
669669
await Future.wait(breakpointAndExceptionRemoval);
670-
await device.vmService!.service.resume(view.uiIsolate!.id!);
670+
if (isPauseEvent(isolate.pauseEvent!.kind!)) {
671+
await device.vmService!.service.resume(view.uiIsolate!.id!);
672+
}
671673
}
672674
}),
673675
);
674676
}
675677

678+
// Wait for the UI isolates to have their breakpoints removed and exception pause mode
679+
// cleared while also ensuring the isolate's are no longer paused. If we don't clear
680+
// the exception pause mode before we start killing child isolates, it's possible that
681+
// any UI isolate waiting on a result from a child isolate could throw an unhandled
682+
// exception and re-pause the isolate, causing hot restart to hang.
683+
await Future.wait(operations);
684+
operations.clear();
685+
676686
// The engine handles killing and recreating isolates that it has spawned
677687
// ("uiIsolates"). The isolates that were spawned from these uiIsolates
678688
// will not be restarted, and so they must be manually killed.

packages/flutter_tools/test/general.shard/resident_runner_test.dart

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ void main() {
240240
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
241241
jsonResponse: fakeUnpausedIsolate.toJson(),
242242
),
243+
FakeVmServiceRequest(
244+
method: 'setIsolatePauseMode',
245+
args: <String, Object?>{
246+
'isolateId': fakeUnpausedIsolate.id,
247+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
248+
},
249+
jsonResponse: vm_service.Success().toJson(),
250+
),
243251
FakeVmServiceRequest(
244252
method: 'getVM',
245253
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
@@ -839,6 +847,14 @@ void main() {
839847
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
840848
jsonResponse: fakeUnpausedIsolate.toJson(),
841849
),
850+
FakeVmServiceRequest(
851+
method: 'setIsolatePauseMode',
852+
args: <String, Object?>{
853+
'isolateId': fakeUnpausedIsolate.id,
854+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
855+
},
856+
jsonResponse: vm_service.Success().toJson(),
857+
),
842858
FakeVmServiceRequest(
843859
method: 'getVM',
844860
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
@@ -916,18 +932,28 @@ void main() {
916932
jsonResponse: fakePausedIsolate.toJson(),
917933
),
918934
FakeVmServiceRequest(
919-
method: 'getVM',
920-
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
921-
),
922-
const FakeVmServiceRequest(
923935
method: 'setIsolatePauseMode',
924-
args: <String, String>{'isolateId': '1', 'exceptionPauseMode': 'None'},
936+
args: <String, Object?>{
937+
'isolateId': fakeUnpausedIsolate.id,
938+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
939+
},
940+
jsonResponse: vm_service.Success().toJson(),
925941
),
926-
const FakeVmServiceRequest(
942+
FakeVmServiceRequest(
927943
method: 'removeBreakpoint',
928-
args: <String, String>{'isolateId': '1', 'breakpointId': 'test-breakpoint'},
944+
args: <String, Object?>{
945+
'isolateId': fakeUnpausedIsolate.id,
946+
'breakpointId': 'test-breakpoint',
947+
},
948+
),
949+
FakeVmServiceRequest(
950+
method: 'resume',
951+
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
952+
),
953+
FakeVmServiceRequest(
954+
method: 'getVM',
955+
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
929956
),
930-
const FakeVmServiceRequest(method: 'resume', args: <String, String>{'isolateId': '1'}),
931957
listViews,
932958
const FakeVmServiceRequest(
933959
method: 'streamListen',
@@ -977,6 +1003,14 @@ void main() {
9771003
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
9781004
jsonResponse: fakeUnpausedIsolate.toJson(),
9791005
),
1006+
FakeVmServiceRequest(
1007+
method: 'setIsolatePauseMode',
1008+
args: <String, Object?>{
1009+
'isolateId': fakeUnpausedIsolate.id,
1010+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
1011+
},
1012+
jsonResponse: vm_service.Success().toJson(),
1013+
),
9801014
FakeVmServiceRequest(
9811015
method: 'getVM',
9821016
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
@@ -1004,6 +1038,14 @@ void main() {
10041038
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
10051039
jsonResponse: fakeUnpausedIsolate.toJson(),
10061040
),
1041+
FakeVmServiceRequest(
1042+
method: 'setIsolatePauseMode',
1043+
args: <String, Object?>{
1044+
'isolateId': fakeUnpausedIsolate.id,
1045+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
1046+
},
1047+
jsonResponse: vm_service.Success().toJson(),
1048+
),
10071049
FakeVmServiceRequest(
10081050
method: 'getVM',
10091051
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
@@ -1031,6 +1073,14 @@ void main() {
10311073
args: <String, Object?>{'isolateId': fakeUnpausedIsolate.id},
10321074
jsonResponse: fakeUnpausedIsolate.toJson(),
10331075
),
1076+
FakeVmServiceRequest(
1077+
method: 'setIsolatePauseMode',
1078+
args: <String, Object?>{
1079+
'isolateId': fakeUnpausedIsolate.id,
1080+
'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone,
1081+
},
1082+
jsonResponse: vm_service.Success().toJson(),
1083+
),
10341084
FakeVmServiceRequest(
10351085
method: 'getVM',
10361086
jsonResponse: vm_service.VM.parse(<String, Object>{})!.toJson(),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:file/file.dart';
8+
import 'package:vm_service/vm_service.dart';
9+
import 'package:vm_service/vm_service_io.dart';
10+
11+
import '../src/common.dart';
12+
import 'test_data/hot_restart_with_paused_child_isolate_project.dart';
13+
import 'test_driver.dart';
14+
import 'test_utils.dart';
15+
16+
void main() {
17+
late Directory tempDir;
18+
final HotRestartWithPausedChildIsolateProject project = HotRestartWithPausedChildIsolateProject();
19+
late FlutterRunTestDriver flutter;
20+
21+
setUp(() async {
22+
tempDir = createResolvedTempDirectorySync('hot_restart_test.');
23+
await project.setUpIn(tempDir);
24+
flutter = FlutterRunTestDriver(tempDir);
25+
});
26+
27+
tearDown(() async {
28+
await flutter.stop();
29+
tryToDelete(tempDir);
30+
});
31+
32+
// Possible regression test for https://github.com/flutter/flutter/issues/161466
33+
testWithoutContext("Hot restart doesn't hang when an unhandled exception is "
34+
'thrown in the UI isolate', () async {
35+
await flutter.run(withDebugger: true, startPaused: true, pauseOnExceptions: true);
36+
final VmService vmService = await vmServiceConnectUri(flutter.vmServiceWsUri.toString());
37+
final Isolate root = await flutter.getFlutterIsolate();
38+
39+
// The UI isolate has already started paused. Setup a listener for the
40+
// child isolate that will spawn when the isolate resumes. Resume the
41+
// spawned child which will pause on start, and then wait for it to execute
42+
// the `debugger()` call.
43+
final Completer<void> childIsolatePausedCompleter = Completer<void>();
44+
vmService.onDebugEvent.listen((Event event) async {
45+
if (event.kind == EventKind.kPauseStart) {
46+
await vmService.resume(event.isolate!.id!);
47+
} else if (event.kind == EventKind.kPauseBreakpoint) {
48+
if (!childIsolatePausedCompleter.isCompleted) {
49+
await vmService.streamCancel(EventStreams.kDebug);
50+
childIsolatePausedCompleter.complete();
51+
}
52+
}
53+
});
54+
await vmService.streamListen(EventStreams.kDebug);
55+
56+
await vmService.resume(root.id!);
57+
await childIsolatePausedCompleter.future;
58+
59+
// This call will fail to return if the UI isolate pauses on an unhandled
60+
// exception due to the isolate spawned by `Isolate.run` not completing.
61+
await flutter.hotRestart();
62+
});
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'project.dart';
6+
7+
// Reproduction case from
8+
// https://github.com/flutter/flutter/issues/161466#issuecomment-2743309718.
9+
class HotRestartWithPausedChildIsolateProject extends Project {
10+
@override
11+
final String pubspec = '''
12+
name: test
13+
environment:
14+
sdk: ^3.7.0-0
15+
16+
dependencies:
17+
flutter:
18+
sdk: flutter
19+
''';
20+
21+
@override
22+
final String main = r'''
23+
import 'dart:async';
24+
import 'dart:developer';
25+
import 'dart:isolate';
26+
27+
import 'package:flutter/material.dart';
28+
29+
void main() {
30+
WidgetsFlutterBinding.ensureInitialized().platformDispatcher.onError = (Object error, StackTrace? stack) {
31+
print('HERE');
32+
return true;
33+
};
34+
runApp(
35+
const Center(
36+
child: Text(
37+
'Hello, world!',
38+
key: Key('title'),
39+
textDirection: TextDirection.ltr,
40+
),
41+
),
42+
);
43+
44+
Isolate.run(() {
45+
print('COMPUTING');
46+
debugger();
47+
});
48+
}
49+
''';
50+
}

packages/flutter_tools/test/integration.shard/test_driver.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ abstract final class FlutterTestDriver {
148148
final Completer<void> isolateStarted = Completer<void>();
149149
_vmService!.onIsolateEvent.listen((Event event) {
150150
if (event.kind == EventKind.kIsolateStart) {
151-
isolateStarted.complete();
151+
if (!isolateStarted.isCompleted) {
152+
isolateStarted.complete();
153+
}
152154
} else if (event.kind == EventKind.kIsolateExit && event.isolate?.id == _flutterIsolateId) {
153155
// Hot restarts cause all the isolates to exit, so we need to refresh
154156
// our idea of what the Flutter isolate ID is.

0 commit comments

Comments
 (0)