Skip to content

Commit 47d35a2

Browse files
authored
Fixed hot reload/restart crashes after closing browser tab on web-server device (flutter#177026)
This PR fixes crashes during hot reload/restart operations on web-server devices that occur when browser tabs are closed. Previously, the VM service would throw "Bad state: No element" errors when attempting operations with no connected clients. The fix introduces graceful handling on both sides: DWDS now catches NoClientsAvailableException in its hot reload/restart operations and returns structured JSON responses with a noClientsAvailable boolean flag instead of throwing exceptions. Flutter Tools reads this flag via a helper method _checkNoClientsAvailable() and handles the scenario gracefully by displaying "Recompile complete. No client connected." while preserving the DWDS connection to automatically support browser reconnections without requiring a full restart of the Flutter tools. Fixes: flutter#174791 Changes in DWDS (Parent PR): dart-lang/webdev#2699 Follow up bug: dart-lang/sdk#61757
1 parent 542705c commit 47d35a2

File tree

5 files changed

+133
-22
lines changed

5 files changed

+133
-22
lines changed

packages/flutter_tools/lib/src/isolated/resident_web_runner.dart

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ const kExitMessage =
8888
'instance in Chrome.\nThis can happen if the websocket connection used by the '
8989
'web tooling is unable to correctly establish a connection, for example due to a firewall.';
9090

91+
const kNoClientConnectedMessage = 'Recompile complete. No client connected.';
92+
9193
class ResidentWebRunner extends ResidentRunner {
9294
ResidentWebRunner(
9395
FlutterDevice device, {
@@ -404,6 +406,13 @@ class ResidentWebRunner extends ResidentRunner {
404406
);
405407
}
406408

409+
/// Handles the no clients available scenario gracefully.
410+
OperationResult _handleNoClientsAvailable(Status status) {
411+
status.stop();
412+
_logger.printStatus(kNoClientConnectedMessage);
413+
return OperationResult.ok;
414+
}
415+
407416
@override
408417
Future<OperationResult> restart({
409418
bool fullRestart = false,
@@ -487,9 +496,7 @@ class ResidentWebRunner extends ResidentRunner {
487496
}
488497

489498
if (_connectionResult == null) {
490-
status.stop();
491-
_logger.printStatus('Recompile complete. No client connected..');
492-
return OperationResult.ok;
499+
return _handleNoClientsAvailable(status);
493500
}
494501

495502
// Both will be null when not assigned.
@@ -504,13 +511,51 @@ class ResidentWebRunner extends ResidentRunner {
504511
// it. Otherwise, default to calling "hotRestart" without a namespace.
505512
final String hotRestartMethod =
506513
_registeredMethodsForService['hotRestart'] ?? 'hotRestart';
507-
await _vmService.service.callMethod(hotRestartMethod);
514+
515+
try {
516+
await _vmService.service.callMethod(hotRestartMethod);
517+
} on vmservice.RPCError catch (e) {
518+
// DWDS throws an RPC error with kIsolateCannotReload code when there are no
519+
// browser clients currently connected during a hot restart operation.
520+
521+
// TODO(61757): Remove this temporary workaround once vm_service is fixed.
522+
// There's a bug in vm_service where it re-encodes RPCErrors as kServerError
523+
// instead of preserving the original error code. Until that's fixed, we need
524+
// to check for both kIsolateCannotReload and kServerError for this method.
525+
if (e.callingMethod == hotRestartMethod &&
526+
(e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code ||
527+
e.code == vmservice.RPCErrorKind.kServerError.code)) {
528+
return _handleNoClientsAvailable(status);
529+
}
530+
// Re-throw other RPC errors
531+
rethrow;
532+
}
508533
} else {
509534
final DateTime reloadStart = _systemClock.now();
510535
final vmservice.VM vm = await _vmService.service.getVM();
511-
final vmservice.ReloadReport report = await _vmService.service.reloadSources(
512-
vm.isolates!.first.id!,
513-
);
536+
final String hotReloadMethod =
537+
_registeredMethodsForService['reloadSources'] ?? 'reloadSources';
538+
539+
// Check if there are any isolates available
540+
if (vm.isolates == null || vm.isolates!.isEmpty) {
541+
_logger.printTrace('No isolates available for hot reload');
542+
return _handleNoClientsAvailable(status);
543+
}
544+
545+
vmservice.ReloadReport report;
546+
try {
547+
report = await _vmService.service.reloadSources(vm.isolates!.first.id!);
548+
} on vmservice.RPCError catch (e) {
549+
// DWDS throws an RPC error with kIsolateCannotReload code when there are no
550+
// browser clients currently connected during a hot reload operation.
551+
if (e.callingMethod == hotReloadMethod &&
552+
e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code) {
553+
return _handleNoClientsAvailable(status);
554+
}
555+
// Re-throw other RPC errors
556+
rethrow;
557+
}
558+
514559
reloadDuration = _systemClock.now().difference(reloadStart);
515560
final contents = ReloadReportContents.fromReloadReport(report);
516561
final bool success = contents.success ?? false;

packages/flutter_tools/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies:
1414
archive: 3.6.1
1515
args: 2.7.0
1616
dds: 5.0.3
17-
dwds: 26.0.0
17+
dwds: 26.1.0
1818
code_builder: 4.11.0
1919
collection: 1.19.1
2020
completion: 1.0.2
@@ -127,4 +127,4 @@ dev_dependencies:
127127
dartdoc:
128128
# Exclude this package from the hosted API docs.
129129
nodoc: true
130-
# PUBSPEC CHECKSUM: 848sij
130+
# PUBSPEC CHECKSUM: d3nerf

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ name: my_app
963963
expect(debugConnectionInfo, isNotNull);
964964

965965
final OperationResult result = await residentWebRunner.restart();
966-
expect(logger.statusText, contains('Recompile complete. No client connected.'));
966+
expect(logger.statusText, contains(kNoClientConnectedMessage));
967967
expect(result.code, 0);
968968
},
969969
overrides: <Type, Generator>{
@@ -1166,7 +1166,7 @@ name: my_app
11661166

11671167
expect(result.code, 0);
11681168
expect(result.isOk, isTrue);
1169-
expect(logger.statusText, contains('Recompile complete. No client connected.'));
1169+
expect(logger.statusText, contains(kNoClientConnectedMessage));
11701170
},
11711171
overrides: <Type, Generator>{
11721172
Analytics: () => fakeAnalytics,

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ library;
88
import 'dart:async';
99

1010
import 'package:file/file.dart';
11-
11+
import 'package:flutter_tools/src/isolated/resident_web_runner.dart' show kNoClientConnectedMessage;
1212
import '../src/common.dart';
1313
import 'test_data/hot_reload_project.dart';
1414
import 'test_data/websocket_dwds_test_common.dart';
@@ -54,7 +54,7 @@ void testAll({List<String> additionalCommandArgs = const <String>[]}) {
5454

5555
try {
5656
// Test hot reload functionality
57-
debugPrint('Step 6: Testing hot reload with WebSocket connection...');
57+
debugPrint('Testing hot reload with WebSocket connection...');
5858
await flutter.hotReload().timeout(
5959
hotReloadTimeout,
6060
onTimeout: () {
@@ -80,5 +80,71 @@ void testAll({List<String> additionalCommandArgs = const <String>[]}) {
8080
},
8181
skip: !platform.isMacOS, // Skip on non-macOS platforms where Chrome paths may differ
8282
);
83+
84+
testWithoutContext(
85+
'hot reload gracefully handles closed browser (no clients available)',
86+
() async {
87+
debugPrint('Starting test for no clients available scenario...');
88+
89+
// Set up WebSocket connection
90+
final WebSocketDwdsTestSetup setup = await WebSocketDwdsTestUtils.setupWebSocketConnection(
91+
flutter,
92+
additionalCommandArgs: additionalCommandArgs,
93+
);
94+
95+
try {
96+
// First, verify hot reload works with browser connected
97+
debugPrint('Verifying initial hot reload with browser connected...');
98+
await flutter.hotReload().timeout(
99+
hotReloadTimeout,
100+
onTimeout: () {
101+
throw Exception('Initial hot reload timed out');
102+
},
103+
);
104+
105+
await Future<void>.delayed(const Duration(seconds: 1));
106+
final initialOutput = setup.stdout.toString();
107+
expect(initialOutput, contains('Reloaded'), reason: 'Initial hot reload should succeed');
108+
debugPrint('✓ Initial hot reload succeeded');
109+
110+
// Close the browser to simulate no clients available
111+
debugPrint('Closing browser to simulate no clients available...');
112+
setup.chromeProcess.kill();
113+
await setup.chromeProcess.exitCode;
114+
debugPrint('✓ Browser closed');
115+
116+
// Give DWDS time to detect the disconnection
117+
await Future<void>.delayed(const Duration(seconds: 2));
118+
119+
// Attempt hot reload with no browser connected
120+
debugPrint('Attempting hot reload with no browser connected...');
121+
await flutter.hotReload().timeout(
122+
hotReloadTimeout,
123+
onTimeout: () {
124+
throw Exception('Hot reload with no clients timed out');
125+
},
126+
);
127+
128+
// Give some time for logs to capture
129+
await Future<void>.delayed(const Duration(seconds: 2));
130+
131+
final output = setup.stdout.toString();
132+
133+
// Verify the graceful handling message
134+
expect(
135+
output,
136+
contains(kNoClientConnectedMessage),
137+
reason: 'Should show no client connected message',
138+
);
139+
140+
debugPrint('✓ Hot reload handled no clients gracefully');
141+
debugPrint('✓ Test completed: Verified graceful handling when browser is closed');
142+
} finally {
143+
// Note: Chrome process is already killed in the test, so just cancel subscription
144+
await setup.subscription.cancel();
145+
}
146+
},
147+
skip: !platform.isMacOS, // Skip on non-macOS platforms where Chrome paths may differ
148+
);
83149
});
84150
}

packages/flutter_tools/test/integration.shard/test_data/websocket_dwds_test_common.dart

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class WebSocketDwdsTestUtils {
5151
required List<String> additionalCommandArgs,
5252
WebSocketDwdsTestConfig config = const WebSocketDwdsTestConfig(),
5353
}) async {
54-
debugPrint('Step 1: Starting WebSocket DWDS connection setup...');
54+
debugPrint('Starting WebSocket DWDS connection setup...');
5555

5656
// Set up listening for app output before starting
5757
final stdout = StringBuffer();
@@ -72,15 +72,15 @@ class WebSocketDwdsTestUtils {
7272

7373
io.Process? chromeProcess;
7474
try {
75-
// Step 1: Start Flutter app with web-server device (will wait for debug connection)
76-
debugPrint('Step 2: Starting Flutter app with web-server device...');
75+
// Start Flutter app with web-server device (will wait for debug connection)
76+
debugPrint('Starting Flutter app with web-server device...');
7777
final Future<void> appStartFuture = runFlutterWithWebServerDevice(
7878
flutter,
7979
additionalCommandArgs: [...additionalCommandArgs, '--no-web-resources-cdn'],
8080
);
8181

82-
// Step 2: Wait for DWDS debug URL to be available
83-
debugPrint('Step 3: Waiting for DWDS debug service URL...');
82+
// Wait for DWDS debug URL to be available
83+
debugPrint('Waiting for DWDS debug service URL...');
8484
final String debugUrl = await sawDebugUrl.future.timeout(
8585
config.debugUrlTimeout,
8686
onTimeout: () {
@@ -89,13 +89,13 @@ class WebSocketDwdsTestUtils {
8989
);
9090
debugPrint('✓ DWDS debug service available at: $debugUrl');
9191

92-
// Step 3: Launch headless Chrome to connect to DWDS
93-
debugPrint('Step 4: Launching headless Chrome to connect to DWDS...');
92+
// Launch headless Chrome to connect to DWDS
93+
debugPrint('Launching headless Chrome to connect to DWDS...');
9494
chromeProcess = await launchHeadlessChrome(debugUrl);
9595
debugPrint('✓ Headless Chrome launched and connecting to DWDS');
9696

97-
// Step 4: Wait for app to start (Chrome connection established)
98-
debugPrint('Step 5: Waiting for Flutter app to start after Chrome connection...');
97+
// Wait for app to start (Chrome connection established)
98+
debugPrint('Waiting for Flutter app to start after Chrome connection...');
9999
await appStartFuture.timeout(
100100
config.appStartTimeout,
101101
onTimeout: () {

0 commit comments

Comments
 (0)