diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index efda6de35f..dcabe91249 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -645,7 +645,7 @@ class HotRunner extends ResidentRunner { ); 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 @@ class HotRunner extends ResidentRunner { // 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> breakpointAndExceptionRemoval = >[ @@ -667,12 +667,22 @@ class HotRunner extends ResidentRunner { 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 bcc9d2f80c..0e62273964 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart @@ -239,6 +239,14 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakeUnpausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.toJson(), @@ -785,6 +793,14 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakeUnpausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.toJson(), @@ -851,19 +867,29 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakePausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), + FakeVmServiceRequest( + method: 'removeBreakpoint', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'breakpointId': 'test-breakpoint', + }, + ), + FakeVmServiceRequest( + method: 'resume', + args: {'isolateId': fakeUnpausedIsolate.id}, + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.toJson(), ), - const FakeVmServiceRequest( - method: 'setIsolatePauseMode', - args: {'isolateId': '1', 'exceptionPauseMode': 'None'}, - ), - const FakeVmServiceRequest( - method: 'removeBreakpoint', - args: {'isolateId': '1', 'breakpointId': 'test-breakpoint'}, - ), - const FakeVmServiceRequest(method: 'resume', args: {'isolateId': '1'}), listViews, const FakeVmServiceRequest( method: 'streamListen', @@ -913,6 +939,14 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakeUnpausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.toJson(), @@ -940,6 +974,14 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakeUnpausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.toJson(), @@ -967,6 +1009,14 @@ void main() { args: {'isolateId': fakeUnpausedIsolate.id}, jsonResponse: fakeUnpausedIsolate.toJson(), ), + FakeVmServiceRequest( + method: 'setIsolatePauseMode', + args: { + 'isolateId': fakeUnpausedIsolate.id, + 'exceptionPauseMode': vm_service.ExceptionPauseMode.kNone, + }, + jsonResponse: vm_service.Success().toJson(), + ), FakeVmServiceRequest( method: 'getVM', jsonResponse: vm_service.VM.parse({})!.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 0000000000..181ada6af0 --- /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 childIsolatePausedCompleter = Completer(); + 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 0000000000..df057e747f --- /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 778bfe49fc..0dd6ab3356 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 @@ abstract final class FlutterTestDriver { final Completer isolateStarted = Completer(); _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.