diff --git a/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart index 23f6e3d1b5e..c8e6a8dcc5d 100644 --- a/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart +++ b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart @@ -7,6 +7,7 @@ import 'dart:convert' show json; import 'dart:js_interop'; import 'dart:math' as math; +import 'package:args/args.dart'; import 'package:web/web.dart' as web; import 'src/web/bench_build_image.dart'; @@ -81,9 +82,28 @@ final Map benchmarks = { BenchImageDecoding.benchmarkName: () => BenchImageDecoding(), }; -final LocalBenchmarkServerClient _client = LocalBenchmarkServerClient(); +late final LocalBenchmarkServerClient _client; + +Future main(List args) async { + final ArgParser parser = + ArgParser()..addOption( + 'port', + abbr: 'p', + help: + 'The port of the local benchmark server used that implements the ' + 'API required for orchestrating macrobenchmarks.', + ); + final ArgResults argResults = parser.parse(args); + Uri serverOrigin; + if (argResults.wasParsed('port')) { + final int port = int.parse(argResults['port'] as String); + serverOrigin = Uri.http('localhost:$port'); + } else { + serverOrigin = Uri.base; + } + + _client = LocalBenchmarkServerClient(serverOrigin); -Future main() async { // Check if the benchmark server wants us to run a specific benchmark. final String nextBenchmark = await _client.requestNextBenchmark(); @@ -96,6 +116,14 @@ Future main() async { web.window.location.reload(); } +/// Shared entrypoint used for DDC, which runs the macrobenchmarks server on a +/// separate port. +// TODO(markzipan): Use `main` in `'web_benchmarks.dart` when Flutter Web supports the `--dart-entrypoint-args` flag. +// ignore: unreachable_from_main +Future sharedMain(List args) { + return main(args); +} + Future _runBenchmark(String benchmarkName) async { final RecorderFactory? recorderFactory = benchmarks[benchmarkName]; @@ -310,9 +338,15 @@ class TimeseriesVisualization { /// implement a manual fallback. This allows debugging benchmarks using plain /// `flutter run`. class LocalBenchmarkServerClient { + LocalBenchmarkServerClient(this.serverOrigin); + /// This value is returned by [requestNextBenchmark]. static const String kManualFallback = '__manual_fallback__'; + /// The origin (e.g., http://localhost:1234) of the benchmark server that + /// hosts the macrobenchmarking API. + final Uri serverOrigin; + /// Whether we fell back to manual mode. /// /// This happens when you run benchmarks using plain `flutter run` rather than @@ -320,13 +354,20 @@ class LocalBenchmarkServerClient { /// provides API for automatically picking the next benchmark to run. bool isInManualMode = false; + Map get headers => { + 'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept', + 'Access-Control-Allow-Methods': 'Post', + 'Access-Control-Allow-Origin': serverOrigin.path, + }; + /// Asks the local server for the name of the next benchmark to run. /// /// Returns [kManualFallback] if local server is not available (uses 404 as a /// signal). Future requestNextBenchmark() async { final web.XMLHttpRequest request = await _requestXhr( - '/next-benchmark', + serverOrigin.resolve('next-benchmark'), + requestHeaders: headers, method: 'POST', mimeType: 'application/json', sendData: json.encode(benchmarks.keys.toList()), @@ -358,7 +399,8 @@ class LocalBenchmarkServerClient { Future startPerformanceTracing(String benchmarkName) async { _checkNotManualMode(); await _requestXhr( - '/start-performance-tracing?label=$benchmarkName', + serverOrigin.resolve('start-performance-tracing?label=$benchmarkName'), + requestHeaders: headers, method: 'POST', mimeType: 'application/json', ); @@ -367,7 +409,12 @@ class LocalBenchmarkServerClient { /// Stops the performance tracing session started by [startPerformanceTracing]. Future stopPerformanceTracing() async { _checkNotManualMode(); - await _requestXhr('/stop-performance-tracing', method: 'POST', mimeType: 'application/json'); + await _requestXhr( + serverOrigin.resolve('stop-performance-tracing'), + requestHeaders: headers, + method: 'POST', + mimeType: 'application/json', + ); } /// Sends the profile data collected by the benchmark to the local benchmark @@ -375,7 +422,8 @@ class LocalBenchmarkServerClient { Future sendProfileData(Profile profile) async { _checkNotManualMode(); final web.XMLHttpRequest request = await _requestXhr( - '/profile-data', + serverOrigin.resolve('profile-data'), + requestHeaders: headers, method: 'POST', mimeType: 'application/json', sendData: json.encode(profile.toJson()), @@ -394,7 +442,8 @@ class LocalBenchmarkServerClient { Future reportError(dynamic error, StackTrace stackTrace) async { _checkNotManualMode(); await _requestXhr( - '/on-error', + serverOrigin.resolve('on-error'), + requestHeaders: headers, method: 'POST', mimeType: 'application/json', sendData: json.encode({'error': '$error', 'stackTrace': '$stackTrace'}), @@ -405,7 +454,8 @@ class LocalBenchmarkServerClient { Future printToConsole(String report) async { _checkNotManualMode(); await _requestXhr( - '/print-to-console', + serverOrigin.resolve('print-to-console'), + requestHeaders: headers, method: 'POST', mimeType: 'text/plain', sendData: report, @@ -415,7 +465,7 @@ class LocalBenchmarkServerClient { /// This is the same as calling [html.HttpRequest.request] but it doesn't /// crash on 404, which we use to detect `flutter run`. Future _requestXhr( - String url, { + Uri url, { String? method, bool? withCredentials, String? responseType, @@ -427,7 +477,7 @@ class LocalBenchmarkServerClient { final web.XMLHttpRequest xhr = web.XMLHttpRequest(); method ??= 'GET'; - xhr.open(method, url, true); + xhr.open(method, '$url', true); if (withCredentials != null) { xhr.withCredentials = withCredentials; diff --git a/dev/benchmarks/macrobenchmarks/lib/web_benchmarks_ddc.dart b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks_ddc.dart new file mode 100644 index 00000000000..874d9ce9d3c --- /dev/null +++ b/dev/benchmarks/macrobenchmarks/lib/web_benchmarks_ddc.dart @@ -0,0 +1,18 @@ +// 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 'web_benchmarks.dart'; + +/// An entrypoint used by DDC for running macrobenchmarks. +/// +/// DDC runs macrobenchmarks via 'flutter run', which hosts files from its own +/// local server. As a result, the macrobenchmarking orchestration server needs +/// to be hosted on a separate port. We split the entrypoint here because we +/// can't pass command line args to Dart apps on Flutter Web. +/// +// TODO(markzipan): Use `main` in `'web_benchmarks.dart` when Flutter Web supports the `--dart-entrypoint-args` flag. +Future main() async { + // This is hard-coded and must be the same as `benchmarkServerPort` in `flutter/dev/devicelab/lib/tasks/web_benchmarks.dart`. + await sharedMain(['--port', '9999']); +} diff --git a/dev/benchmarks/macrobenchmarks/pubspec.yaml b/dev/benchmarks/macrobenchmarks/pubspec.yaml index 340ecc4a4c7..c2b3113ae40 100644 --- a/dev/benchmarks/macrobenchmarks/pubspec.yaml +++ b/dev/benchmarks/macrobenchmarks/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: # flutter update-packages --force-upgrade flutter_gallery_assets: 1.0.2 + args: 2.7.0 web: 1.1.1 async: 2.13.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -52,7 +53,6 @@ dev_dependencies: _fe_analyzer_shared: 82.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" analyzer: 7.4.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" - args: 2.7.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" cli_config: 0.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" convert: 3.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" coverage: 1.12.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" diff --git a/dev/devicelab/lib/framework/browser.dart b/dev/devicelab/lib/framework/browser.dart index 4219b4448e9..1d08316cee0 100644 --- a/dev/devicelab/lib/framework/browser.dart +++ b/dev/devicelab/lib/framework/browser.dart @@ -74,7 +74,7 @@ class Chrome { }); } - /// Launches Chrome with the give [options]. + /// Launches Chrome with the given [options]. /// /// The [onError] callback is called with an error message when the Chrome /// process encounters an error. In particular, [onError] is called when the @@ -125,7 +125,28 @@ class Chrome { WipConnection? debugConnection; if (withDebugging) { - debugConnection = await _connectToChromeDebugPort(chromeProcess, options.debugPort!); + debugConnection = await _connectToChromeDebugPort(options.debugPort!); + } + + return Chrome._(chromeProcess, onError, debugConnection); + } + + /// Connects to an existing Chrome process with the given [options]. + /// + /// The [onError] callback is called with an error message when the Chrome + /// process encounters an error. In particular, [onError] is called when the + /// Chrome process exits prematurely, i.e. before [stop] is called. + static Future connect( + io.Process chromeProcess, + ChromeOptions options, { + String? workingDirectory, + required ChromeErrorCallback onError, + }) async { + final bool withDebugging = options.debugPort != null; + + WipConnection? debugConnection; + if (withDebugging) { + debugConnection = await _connectToChromeDebugPort(options.debugPort!); } return Chrome._(chromeProcess, onError, debugConnection); @@ -260,7 +281,7 @@ String _findSystemChromeExecutable() { } /// Waits for Chrome to print DevTools URI and connects to it. -Future _connectToChromeDebugPort(io.Process chromeProcess, int port) async { +Future _connectToChromeDebugPort(int port) async { final Uri devtoolsUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')); print('Connecting to DevTools: $devtoolsUri'); final ChromeConnection chromeConnection = ChromeConnection('localhost', port); diff --git a/dev/devicelab/lib/tasks/web_benchmarks.dart b/dev/devicelab/lib/tasks/web_benchmarks.dart index c226692fc66..5ae69e4467d 100644 --- a/dev/devicelab/lib/tasks/web_benchmarks.dart +++ b/dev/devicelab/lib/tasks/web_benchmarks.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert' show json; +import 'dart:convert' show LineSplitter, json, utf8; import 'dart:io' as io; import 'package:logging/logging.dart'; @@ -16,10 +16,16 @@ import '../framework/browser.dart'; import '../framework/task_result.dart'; import '../framework/utils.dart'; -/// The port number used by the local benchmark server. +/// The port at which the local benchmark server is served. +/// This is hard-coded and must be the same as the port used for DDC's benchmark at `flutter/dev/benchmarks/macrobenchmarks/lib/web_benchmarks_ddc.dart`. const int benchmarkServerPort = 9999; + +/// The port at which Chrome listens for a debug connection. const int chromeDebugPort = 10000; +/// The port at which the benchmark's app is being served. +const int benchmarksAppPort = 10001; + typedef WebBenchmarkOptions = ({bool useWasm, bool forceSingleThreadedSkwasm, bool useDdc, bool withHotReload}); @@ -34,21 +40,69 @@ Future runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { ); return inDirectory(macrobenchmarksDirectory, () async { await flutter('clean'); - await evalFlutter( - 'build', - options: [ - 'web', - '--no-tree-shake-icons', // local engine builds are frequently out of sync with the Dart Kernel version - if (benchmarkOptions.useWasm) ...['--wasm', '--no-strip-wasm'], - '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true', - if (benchmarkOptions.useDdc) '--debug' else '--profile', - if (benchmarkOptions.useDdc && benchmarkOptions.withHotReload) - '--extra-front-end-options=--dartdevc-canary,--dartdevc-module-format=ddc', - '--no-web-resources-cdn', - '-t', - 'lib/web_benchmarks.dart', - ], - ); + // DDC runs the benchmarks suite with 'flutter run', attaching to its + // Chrome instance instead of starting a new one. + io.Process? flutterRunProcess; + if (benchmarkOptions.useDdc) { + final Completer ddcAppReady = Completer(); + flutterRunProcess = await startFlutter( + 'run', + options: [ + '-d', + 'chrome', + '--web-port', + '$benchmarksAppPort', + '--web-browser-debug-port', + '$chromeDebugPort', + '--web-launch-url', + 'http://localhost:$benchmarksAppPort/index.html', + '--debug', + '--no-web-enable-expression-evaluation', + '--web-browser-flag=--disable-popup-blocking', + '--web-browser-flag=--bwsi', + '--web-browser-flag=--no-first-run', + '--web-browser-flag=--no-default-browser-check', + '--web-browser-flag=--disable-default-apps', + '--web-browser-flag=--disable-translate', + '--web-browser-flag=--disable-background-timer-throttling', + '--web-browser-flag=--disable-backgrounding-occluded-windows', + '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true', + if (benchmarkOptions.withHotReload) '--web-experimental-hot-reload', + '--no-web-resources-cdn', + 'lib/web_benchmarks_ddc.dart', + ], + ); + flutterRunProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + if (line.startsWith('This app is linked to the debug service')) { + ddcAppReady.complete(); + } + print('[CHROME STDOUT]: $line'); + }); + flutterRunProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + print('[CHROME STDERR]: $line'); + }); + // Wait for the app to load in DDC's Chrome instance before trying to + // connect the debugger. + await ddcAppReady.future; + } else { + await evalFlutter( + 'build', + options: [ + 'web', + '--no-tree-shake-icons', // local engine builds are frequently out of sync with the Dart Kernel version + if (benchmarkOptions.useWasm) ...['--wasm', '--no-strip-wasm'], + '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true', + '--profile', + '--no-web-resources-cdn', + '-t', + 'lib/web_benchmarks.dart', + ], + ); + } final Completer>> profileData = Completer>>(); final List> collectedProfiles = >[]; @@ -63,83 +117,102 @@ Future runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { late io.HttpServer server; Cascade cascade = Cascade(); List>? latestPerformanceTrace; - cascade = cascade - .add((Request request) async { - try { - chrome ??= await whenChromeIsReady; - if (request.requestedUri.path.endsWith('/profile-data')) { - final Map profile = - json.decode(await request.readAsString()) as Map; - final String benchmarkName = profile['name'] as String; - if (benchmarkName != benchmarkIterator.current) { - profileData.completeError( - Exception( - 'Browser returned benchmark results from a wrong benchmark.\n' - 'Requested to run benchmark ${benchmarkIterator.current}, but ' - 'got results for $benchmarkName.', - ), - ); - unawaited(server.close()); - } + final Map> requestHeaders = >{ + 'Access-Control-Allow-Headers': [ + 'Accept', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Origin', + 'Content-Type', + 'Origin', + ], + 'Access-Control-Allow-Methods': ['Post'], + 'Access-Control-Allow-Origin': ['http://localhost:$benchmarksAppPort'], + }; - // Trace data is null when the benchmark is not frame-based, such as RawRecorder. - if (latestPerformanceTrace != null) { - final BlinkTraceSummary traceSummary = - BlinkTraceSummary.fromJson(latestPerformanceTrace!)!; - profile['totalUiFrame.average'] = - traceSummary.averageTotalUIFrameTime.inMicroseconds; - profile['scoreKeys'] ??= []; // using dynamic for consistency with JSON - (profile['scoreKeys'] as List).add('totalUiFrame.average'); - latestPerformanceTrace = null; - } - collectedProfiles.add(profile); - return Response.ok('Profile received'); - } else if (request.requestedUri.path.endsWith('/start-performance-tracing')) { - latestPerformanceTrace = null; - await chrome!.beginRecordingPerformance( - request.requestedUri.queryParameters['label']!, - ); - return Response.ok('Started performance tracing'); - } else if (request.requestedUri.path.endsWith('/stop-performance-tracing')) { - latestPerformanceTrace = await chrome!.endRecordingPerformance(); - return Response.ok('Stopped performance tracing'); - } else if (request.requestedUri.path.endsWith('/on-error')) { - final Map errorDetails = - json.decode(await request.readAsString()) as Map; - unawaited(server.close()); - // Keep the stack trace as a string. It's thrown in the browser, not this Dart VM. - profileData.completeError('${errorDetails['error']}\n${errorDetails['stackTrace']}'); - return Response.ok(''); - } else if (request.requestedUri.path.endsWith('/next-benchmark')) { - if (benchmarks == null) { - benchmarks = - (json.decode(await request.readAsString()) as List).cast(); - benchmarkIterator = benchmarks!.iterator; - } - if (benchmarkIterator.moveNext()) { - final String nextBenchmark = benchmarkIterator.current; - print('Launching benchmark "$nextBenchmark"'); - return Response.ok(nextBenchmark); - } else { - profileData.complete(collectedProfiles); - return Response.notFound('Finished running benchmarks.'); - } - } else if (request.requestedUri.path.endsWith('/print-to-console')) { - // A passthrough used by - // `dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart` - // to print information. - final String message = await request.readAsString(); - print('[APP] $message'); - return Response.ok('Reported.'); - } else { - return Response.notFound('This request is not handled by the profile-data handler.'); - } - } catch (error, stackTrace) { - profileData.completeError(error, stackTrace); - return Response.internalServerError(body: '$error'); + cascade = cascade.add((Request request) async { + final String requestContents = await request.readAsString(); + try { + chrome ??= await whenChromeIsReady; + if (request.method == 'OPTIONS') { + return Response.ok('', headers: requestHeaders); + } + if (request.requestedUri.path.endsWith('/profile-data')) { + final Map profile = json.decode(requestContents) as Map; + final String benchmarkName = profile['name'] as String; + if (benchmarkName != benchmarkIterator.current) { + profileData.completeError( + Exception( + 'Browser returned benchmark results from a wrong benchmark.\n' + 'Requested to run benchmark ${benchmarkIterator.current}, but ' + 'got results for $benchmarkName.', + ), + ); + unawaited(server.close()); } - }) - .add(createBuildDirectoryHandler(path.join(macrobenchmarksDirectory, 'build', 'web'))); + + // Trace data is null when the benchmark is not frame-based, such as RawRecorder. + if (latestPerformanceTrace != null) { + final BlinkTraceSummary traceSummary = + BlinkTraceSummary.fromJson(latestPerformanceTrace!)!; + profile['totalUiFrame.average'] = traceSummary.averageTotalUIFrameTime.inMicroseconds; + profile['scoreKeys'] ??= []; // using dynamic for consistency with JSON + (profile['scoreKeys'] as List).add('totalUiFrame.average'); + latestPerformanceTrace = null; + } + collectedProfiles.add(profile); + return Response.ok('Profile received', headers: requestHeaders); + } else if (request.requestedUri.path.endsWith('/start-performance-tracing')) { + latestPerformanceTrace = null; + await chrome!.beginRecordingPerformance(request.requestedUri.queryParameters['label']!); + return Response.ok('Started performance tracing', headers: requestHeaders); + } else if (request.requestedUri.path.endsWith('/stop-performance-tracing')) { + latestPerformanceTrace = await chrome!.endRecordingPerformance(); + return Response.ok('Stopped performance tracing', headers: requestHeaders); + } else if (request.requestedUri.path.endsWith('/on-error')) { + final Map errorDetails = + json.decode(requestContents) as Map; + unawaited(server.close()); + // Keep the stack trace as a string. It's thrown in the browser, not this Dart VM. + profileData.completeError('${errorDetails['error']}\n${errorDetails['stackTrace']}'); + return Response.ok('', headers: requestHeaders); + } else if (request.requestedUri.path.endsWith('/next-benchmark')) { + if (benchmarks == null) { + benchmarks = (json.decode(requestContents) as List).cast(); + benchmarkIterator = benchmarks!.iterator; + } + if (benchmarkIterator.moveNext()) { + final String nextBenchmark = benchmarkIterator.current; + print('Launching benchmark "$nextBenchmark"'); + return Response.ok(nextBenchmark, headers: requestHeaders); + } else { + profileData.complete(collectedProfiles); + return Response.notFound('Finished running benchmarks.', headers: requestHeaders); + } + } else if (request.requestedUri.path.endsWith('/print-to-console')) { + // A passthrough used by + // `dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart` + // to print information. + final String message = requestContents; + print('[APP] $message'); + return Response.ok('Reported.', headers: requestHeaders); + } else { + return Response.notFound( + 'This request is not handled by the profile-data handler.', + headers: requestHeaders, + ); + } + } catch (error, stackTrace) { + profileData.completeError(error, stackTrace); + return Response.internalServerError(body: '$error', headers: requestHeaders); + } + }); + // Macrobenchmarks using 'flutter build' serve files from their local build directory alongside the orchestration logic. + if (!benchmarkOptions.useDdc) { + cascade = cascade.add( + createBuildDirectoryHandler(path.join(macrobenchmarksDirectory, 'build', 'web')), + ); + } server = await io.HttpServer.bind('localhost', benchmarkServerPort); try { @@ -157,8 +230,10 @@ Future runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { // final bool isUncalibratedSmokeTest = // io.Platform.environment['UNCALIBRATED_SMOKE_TEST'] == 'true'; final String urlParams = benchmarkOptions.forceSingleThreadedSkwasm ? '?force_st=true' : ''; + // DDC apps are served from a different port from the orchestration server. + final int appServingPort = benchmarkOptions.useDdc ? benchmarksAppPort : benchmarkServerPort; final ChromeOptions options = ChromeOptions( - url: 'http://localhost:$benchmarkServerPort/index.html$urlParams', + url: 'http://localhost:$appServingPort/index.html$urlParams', userDataDirectory: userDataDir, headless: isUncalibratedSmokeTest, debugPort: chromeDebugPort, @@ -166,13 +241,26 @@ Future runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { ); print('Launching Chrome.'); - whenChromeIsReady = Chrome.launch( - options, - onError: (String error) { - profileData.completeError(Exception(error)); - }, - workingDirectory: cwd, - ); + + if (benchmarkOptions.useDdc) { + // DDC reuses the existing Chrome connection spawned via 'flutter run'. + whenChromeIsReady = Chrome.connect( + flutterRunProcess!, + options, + onError: (String error) { + profileData.completeError(Exception(error)); + }, + workingDirectory: cwd, + ); + } else { + whenChromeIsReady = Chrome.launch( + options, + onError: (String error) { + profileData.completeError(Exception(error)); + }, + workingDirectory: cwd, + ); + } print('Waiting for the benchmark to report benchmark profile.'); final Map taskResult = {}; @@ -216,6 +304,22 @@ Future runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { } finally { unawaited(server.close()); chrome?.stop(); + if (flutterRunProcess != null) { + // Sending a SIGINT/SIGTERM to the process here isn't reliable because [process] is + // the shell (flutter is a shell script) and doesn't pass the signal on. + // Sending a `q` is an instruction to quit using the console runner. + flutterRunProcess.stdin.write('q'); + await flutterRunProcess.stdin.flush(); + // Give the process a couple of seconds to exit and run shutdown hooks + // before sending kill signal. + await flutterRunProcess.exitCode.timeout( + const Duration(seconds: 2), + onTimeout: () { + flutterRunProcess!.kill(io.ProcessSignal.sigint); + return 0; + }, + ); + } } }); }