| // 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 'dart:convert' show LineSplitter, json, utf8; |
| import 'dart:io' as io; |
| |
| import 'package:logging/logging.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:shelf/shelf.dart'; |
| import 'package:shelf/shelf_io.dart' as shelf_io; |
| import 'package:shelf_static/shelf_static.dart'; |
| |
| import '../framework/browser.dart'; |
| import '../framework/task_result.dart'; |
| import '../framework/utils.dart'; |
| |
| /// 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, |
| }); |
| |
| Future<TaskResult> runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { |
| // Reduce logging level. Otherwise, package:webkit_inspection_protocol is way too spammy. |
| Logger.root.level = Level.INFO; |
| final String macrobenchmarksDirectory = path.join( |
| flutterDirectory.path, |
| 'dev', |
| 'benchmarks', |
| 'macrobenchmarks', |
| ); |
| return inDirectory(macrobenchmarksDirectory, () async { |
| await flutter('clean'); |
| // 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<void> ddcAppReady = Completer<void>(); |
| flutterRunProcess = await startFlutter( |
| 'run', |
| options: <String>[ |
| '-d', |
| 'chrome', |
| '--web-port', |
| '$benchmarksAppPort', |
| '--web-browser-debug-port', |
| '$chromeDebugPort', |
| '--web-launch-url', |
| 'http://localhost:$benchmarksAppPort/index.html', |
| '--debug', |
| '--web-run-headless', |
| '--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', |
| '--web-browser-flag=--disable-renderer-backgrounding', |
| '--web-browser-flag=--headless=new', |
| '--web-browser-flag=--no-sandbox', |
| '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true', |
| if (benchmarkOptions.withHotReload) |
| '--web-experimental-hot-reload' |
| else |
| '--no-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: <String>[ |
| 'web', |
| '--no-tree-shake-icons', // local engine builds are frequently out of sync with the Dart Kernel version |
| if (benchmarkOptions.useWasm) ...<String>['--wasm', '--no-strip-wasm'], |
| '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true', |
| '--profile', |
| '--no-web-resources-cdn', |
| '-t', |
| 'lib/web_benchmarks.dart', |
| ], |
| ); |
| } |
| final Completer<List<Map<String, dynamic>>> profileData = |
| Completer<List<Map<String, dynamic>>>(); |
| final List<Map<String, dynamic>> collectedProfiles = <Map<String, dynamic>>[]; |
| List<String>? benchmarks; |
| late Iterator<String> benchmarkIterator; |
| |
| // This future fixes a race condition between the web-page loading and |
| // asking to run a benchmark, and us connecting to Chrome's DevTools port. |
| // Sometime one wins. Other times, the other wins. |
| Future<Chrome>? whenChromeIsReady; |
| Chrome? chrome; |
| late io.HttpServer server; |
| Cascade cascade = Cascade(); |
| List<Map<String, dynamic>>? latestPerformanceTrace; |
| final Map<String, List<String>> requestHeaders = <String, List<String>>{ |
| 'Access-Control-Allow-Headers': <String>[ |
| 'Accept', |
| 'Access-Control-Allow-Headers', |
| 'Access-Control-Allow-Methods', |
| 'Access-Control-Allow-Origin', |
| 'Content-Type', |
| 'Origin', |
| ], |
| 'Access-Control-Allow-Methods': <String>['Post'], |
| 'Access-Control-Allow-Origin': <String>['http://localhost:$benchmarksAppPort'], |
| }; |
| |
| 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<String, dynamic> profile = json.decode(requestContents) as Map<String, dynamic>; |
| 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()); |
| } |
| |
| // 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'] ??= <dynamic>[]; // using dynamic for consistency with JSON |
| (profile['scoreKeys'] as List<dynamic>).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<String, dynamic> errorDetails = |
| json.decode(requestContents) as Map<String, dynamic>; |
| 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<dynamic>).cast<String>(); |
| 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 { |
| shelf_io.serveRequests(server, cascade.handler); |
| |
| final String dartToolDirectory = path.join('$macrobenchmarksDirectory/.dart_tool'); |
| final String userDataDir = io.Directory( |
| dartToolDirectory, |
| ).createTempSync('flutter_chrome_user_data.').path; |
| |
| // TODO(yjbanov): temporarily disables headful Chrome until we get |
| // devicelab hardware that is able to run it. Our current |
| // GCE VMs can only run in headless mode. |
| // See: https://github.com/flutter/flutter/issues/50164 |
| final bool isUncalibratedSmokeTest = io.Platform.environment['CALIBRATED'] != 'true'; |
| // 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:$appServingPort/index.html$urlParams', |
| userDataDirectory: userDataDir, |
| headless: isUncalibratedSmokeTest, |
| debugPort: chromeDebugPort, |
| enableWasmGC: benchmarkOptions.useWasm, |
| ); |
| |
| print('Launching Chrome.'); |
| |
| 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<String, dynamic> taskResult = <String, dynamic>{}; |
| final List<String> benchmarkScoreKeys = <String>[]; |
| final List<Map<String, dynamic>> profiles = await profileData.future; |
| |
| print('Received profile data'); |
| for (final Map<String, dynamic> profile in profiles) { |
| final String benchmarkName = profile['name'] as String; |
| if (benchmarkName.isEmpty) { |
| throw 'Benchmark name is empty'; |
| } |
| |
| final String webRendererName; |
| if (benchmarkOptions.useWasm) { |
| webRendererName = benchmarkOptions.forceSingleThreadedSkwasm ? 'skwasm_st' : 'skwasm'; |
| } else { |
| webRendererName = 'canvaskit'; |
| } |
| final String namespace = '$benchmarkName.$webRendererName'; |
| final List<String> scoreKeys = List<String>.from(profile['scoreKeys'] as List<dynamic>); |
| if (scoreKeys.isEmpty) { |
| throw 'No score keys in benchmark "$benchmarkName"'; |
| } |
| for (final String scoreKey in scoreKeys) { |
| if (scoreKey.isEmpty) { |
| throw 'Score key is empty in benchmark "$benchmarkName". ' |
| 'Received [${scoreKeys.join(', ')}]'; |
| } |
| benchmarkScoreKeys.add('$namespace.$scoreKey'); |
| } |
| |
| for (final String key in profile.keys) { |
| if (key == 'name' || key == 'scoreKeys') { |
| continue; |
| } |
| taskResult['$namespace.$key'] = profile[key]; |
| } |
| } |
| return TaskResult.success(taskResult, benchmarkScoreKeys: benchmarkScoreKeys); |
| } 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; |
| }, |
| ); |
| } |
| } |
| }); |
| } |
| |
| Handler createBuildDirectoryHandler(String buildDirectoryPath) { |
| final Handler childHandler = createStaticHandler(buildDirectoryPath); |
| return (Request request) async { |
| final Response response = await childHandler(request); |
| final String? mimeType = response.mimeType; |
| |
| // Provide COOP/COEP headers so that the browser loads the page as |
| // crossOriginIsolated. This will make sure that we get high-resolution |
| // timers for our benchmark measurements. |
| if (mimeType == 'text/html' || mimeType == 'text/javascript') { |
| return response.change( |
| headers: <String, String>{ |
| 'Cross-Origin-Opener-Policy': 'same-origin', |
| 'Cross-Origin-Embedder-Policy': 'require-corp', |
| }, |
| ); |
| } else { |
| return response; |
| } |
| }; |
| } |