Skip to content

Commit 9afd397

Browse files
[flutter_tools] Shut down Chromium cleanly using a command sent through the debug protocol (flutter#150645)
The previous approach of killing the Chromium parent process sometimes caused leaks of child processes on Windows. The Browser.close command in the debug protocol will tell Chromium to shut down all of its processes.
1 parent 00419bf commit 9afd397

File tree

2 files changed

+129
-39
lines changed

2 files changed

+129
-39
lines changed

packages/flutter_tools/lib/src/web/chrome.dart

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ class ChromiumLauncher {
415415
// connection is valid.
416416
if (!skipCheck) {
417417
try {
418-
await _getFirstTab(chrome);
418+
await chrome._validateChromeConnection();
419419
} on Exception catch (error, stackTrace) {
420420
_logger.printError('$error', stackTrace: stackTrace);
421421
await chrome.close();
@@ -427,40 +427,6 @@ class ChromiumLauncher {
427427
return chrome;
428428
}
429429

430-
/// Gets the first [chrome] tab.
431-
///
432-
/// Retries getting tabs from Chrome for a few seconds and retries finding
433-
/// the tab a few times. This reduces flakes caused by Chrome not returning
434-
/// correct output if the call was too close to the start.
435-
//
436-
// TODO(ianh): remove the timeouts here, they violate our style guide.
437-
// (We should just keep waiting forever, and print a warning when it's
438-
// taking too long.)
439-
Future<ChromeTab?> _getFirstTab(Chromium chrome) async {
440-
const Duration retryFor = Duration(seconds: 2);
441-
const int attempts = 5;
442-
443-
for (int i = 1; i <= attempts; i++) {
444-
try {
445-
final List<ChromeTab> tabs =
446-
await chrome.chromeConnection.getTabs(retryFor: retryFor);
447-
448-
if (tabs.isNotEmpty) {
449-
return tabs.first;
450-
}
451-
if (i == attempts) {
452-
return null;
453-
}
454-
} on ConnectionException catch (_) {
455-
if (i == attempts) {
456-
rethrow;
457-
}
458-
}
459-
await Future<void>.delayed(const Duration(milliseconds: 25));
460-
}
461-
return null;
462-
}
463-
464430
Future<Chromium> get connectedInstance => currentCompleter.future;
465431
}
466432

@@ -483,6 +449,7 @@ class Chromium {
483449
final ChromeConnection chromeConnection;
484450
final ChromiumLauncher _chromiumLauncher;
485451
final Logger _logger;
452+
bool _hasValidChromeConnection = false;
486453

487454
/// Resolves to browser's main process' exit code, when the browser exits.
488455
Future<int> get onExit async => _process.exitCode;
@@ -493,6 +460,41 @@ class Chromium {
493460
@visibleForTesting
494461
Process get process => _process;
495462

463+
/// Gets the first [chrome] tab in order to verify that the connection to
464+
/// the Chrome debug protocol is working properly.
465+
///
466+
/// Retries getting tabs from Chrome for a few seconds and retries finding
467+
/// the tab a few times. This reduces flakes caused by Chrome not returning
468+
/// correct output if the call was too close to the start.
469+
//
470+
// TODO(ianh): remove the timeouts here, they violate our style guide.
471+
// (We should just keep waiting forever, and print a warning when it's
472+
// taking too long.)
473+
Future<void> _validateChromeConnection() async {
474+
const Duration retryFor = Duration(seconds: 2);
475+
const int attempts = 5;
476+
477+
for (int i = 1; i <= attempts; i++) {
478+
try {
479+
final List<ChromeTab> tabs =
480+
await chromeConnection.getTabs(retryFor: retryFor);
481+
482+
if (tabs.isNotEmpty) {
483+
_hasValidChromeConnection = true;
484+
return;
485+
}
486+
if (i == attempts) {
487+
return;
488+
}
489+
} on ConnectionException catch (_) {
490+
if (i == attempts) {
491+
rethrow;
492+
}
493+
}
494+
await Future<void>.delayed(const Duration(milliseconds: 25));
495+
}
496+
}
497+
496498
/// Closes all connections to the browser and asks the browser to exit.
497499
Future<void> close() async {
498500
if (_logger.isVerbose) {
@@ -501,12 +503,31 @@ class Chromium {
501503
if (_chromiumLauncher.hasChromeInstance) {
502504
_chromiumLauncher.currentCompleter = Completer<Chromium>();
503505
}
506+
507+
// Send a command to shut down the browser cleanly.
508+
Duration sigtermDelay = Duration.zero;
509+
if (_hasValidChromeConnection) {
510+
final ChromeTab? tab = await chromeConnection.getTab(
511+
(_) => true, retryFor: const Duration(seconds: 1));
512+
if (tab != null) {
513+
final WipConnection wipConnection = await tab.connect();
514+
await wipConnection.sendCommand('Browser.close');
515+
await wipConnection.close();
516+
sigtermDelay = const Duration(seconds: 1);
517+
}
518+
}
504519
chromeConnection.close();
520+
_hasValidChromeConnection = false;
505521

506-
// Try to exit Chromium nicely using SIGTERM, before exiting it rudely using
507-
// SIGKILL. Wait no longer than 5 seconds for Chromium to exit before
508-
// falling back to SIGKILL, and then to a warning message.
509-
ProcessSignal.sigterm.kill(_process);
522+
// If the browser close command did not shut down the process, then try to
523+
// exit Chromium using SIGTERM.
524+
await _process.exitCode.timeout(sigtermDelay, onTimeout: () {
525+
ProcessSignal.sigterm.kill(_process);
526+
return 0;
527+
});
528+
// If the process still has not ended, then use SIGKILL. Wait up to 5
529+
// seconds for Chromium to exit before falling back to SIGKILL and then to
530+
// a warning message.
510531
await _process.exitCode.timeout(const Duration(seconds: 5), onTimeout: () {
511532
_logger.printWarning(
512533
'Failed to exit Chromium (pid: ${_process.pid}) using SIGTERM. Will try '

packages/flutter_tools/test/web.shard/chrome_test.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,26 @@ void main() {
800800
contains('<html> ...'),
801801
));
802802
});
803+
804+
test('Chromium close sends browser close command', () async {
805+
final BufferLogger logger = BufferLogger.test();
806+
final List<String> commands = <String>[];
807+
void onSendCommand(String cmd) { commands.add(cmd); }
808+
final FakeChromeConnectionWithTab chromeConnection = FakeChromeConnectionWithTab(onSendCommand: onSendCommand);
809+
final ChromiumLauncher chromiumLauncher = ChromiumLauncher(
810+
fileSystem: fileSystem,
811+
platform: platform,
812+
processManager: processManager,
813+
operatingSystemUtils: operatingSystemUtils,
814+
browserFinder: findChromeExecutable,
815+
logger: logger,
816+
);
817+
final FakeProcess process = FakeProcess();
818+
final Chromium chrome = Chromium(0, chromeConnection, chromiumLauncher: chromiumLauncher, process: process, logger: logger);
819+
expect(await chromiumLauncher.connect(chrome, false), equals(chrome));
820+
await chrome.close();
821+
expect(commands, contains('Browser.close'));
822+
});
803823
}
804824

805825
/// Fake chrome connection that fails to get tabs a few times.
@@ -834,3 +854,52 @@ class FakeChromeConnection extends Fake implements ChromeConnection {
834854
@override
835855
void close() {}
836856
}
857+
858+
typedef OnSendCommand = void Function(String);
859+
860+
/// Fake chrome connection that returns a tab.
861+
class FakeChromeConnectionWithTab extends Fake implements ChromeConnection {
862+
FakeChromeConnectionWithTab({OnSendCommand? onSendCommand})
863+
: _tab = FakeChromeTab(onSendCommand);
864+
865+
final FakeChromeTab _tab;
866+
867+
@override
868+
Future<ChromeTab?> getTab(bool Function(ChromeTab tab) accept, {Duration? retryFor}) async {
869+
return _tab;
870+
}
871+
872+
@override
873+
Future<List<ChromeTab>> getTabs({Duration? retryFor}) async {
874+
return <ChromeTab>[_tab];
875+
}
876+
877+
@override
878+
void close() {}
879+
}
880+
881+
class FakeChromeTab extends Fake implements ChromeTab {
882+
FakeChromeTab(this.onSendCommand);
883+
884+
OnSendCommand? onSendCommand;
885+
886+
@override
887+
Future<WipConnection> connect({Function? onError}) async {
888+
return FakeWipConnection(onSendCommand);
889+
}
890+
}
891+
892+
class FakeWipConnection extends Fake implements WipConnection {
893+
FakeWipConnection(this.onSendCommand);
894+
895+
OnSendCommand? onSendCommand;
896+
897+
@override
898+
Future<WipResponse> sendCommand(String method, [Map<String, dynamic>? params]) async {
899+
onSendCommand?.call(method);
900+
return WipResponse(<String, dynamic>{'id': 0, 'result': <String, dynamic>{}});
901+
}
902+
903+
@override
904+
Future<void> close() async {}
905+
}

0 commit comments

Comments
 (0)