1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:async'; |
6 | import 'dart:convert' show json; |
7 | import 'dart:io' as io; |
8 | |
9 | import 'package:logging/logging.dart' ; |
10 | import 'package:path/path.dart' as path; |
11 | import 'package:shelf/shelf.dart' ; |
12 | import 'package:shelf/shelf_io.dart' as shelf_io; |
13 | import 'package:shelf_static/shelf_static.dart' ; |
14 | |
15 | import '../framework/browser.dart'; |
16 | import '../framework/task_result.dart'; |
17 | import '../framework/utils.dart'; |
18 | |
19 | /// The port number used by the local benchmark server. |
20 | const int benchmarkServerPort = 9999; |
21 | const int chromeDebugPort = 10000; |
22 | |
23 | typedef WebBenchmarkOptions = ({bool useWasm, bool forceSingleThreadedSkwasm}); |
24 | |
25 | Future<TaskResult> runWebBenchmark(WebBenchmarkOptions benchmarkOptions) async { |
26 | // Reduce logging level. Otherwise, package:webkit_inspection_protocol is way too spammy. |
27 | Logger.root.level = Level.INFO; |
28 | final String macrobenchmarksDirectory = path.join( |
29 | flutterDirectory.path, |
30 | 'dev' , |
31 | 'benchmarks' , |
32 | 'macrobenchmarks' , |
33 | ); |
34 | return inDirectory(macrobenchmarksDirectory, () async { |
35 | await flutter('clean' ); |
36 | await evalFlutter( |
37 | 'build' , |
38 | options: <String>[ |
39 | 'web' , |
40 | '--no-tree-shake-icons' , // local engine builds are frequently out of sync with the Dart Kernel version |
41 | if (benchmarkOptions.useWasm) ...<String>['--wasm' , '--no-strip-wasm' ], |
42 | '--dart-define=FLUTTER_WEB_ENABLE_PROFILING=true' , |
43 | '--profile' , |
44 | '--no-web-resources-cdn' , |
45 | '-t' , |
46 | 'lib/web_benchmarks.dart' , |
47 | ], |
48 | ); |
49 | final Completer<List<Map<String, dynamic>>> profileData = |
50 | Completer<List<Map<String, dynamic>>>(); |
51 | final List<Map<String, dynamic>> collectedProfiles = <Map<String, dynamic>>[]; |
52 | List<String>? benchmarks; |
53 | late Iterator<String> benchmarkIterator; |
54 | |
55 | // This future fixes a race condition between the web-page loading and |
56 | // asking to run a benchmark, and us connecting to Chrome's DevTools port. |
57 | // Sometime one wins. Other times, the other wins. |
58 | Future<Chrome>? whenChromeIsReady; |
59 | Chrome? chrome; |
60 | late io.HttpServer server; |
61 | Cascade cascade = Cascade(); |
62 | List<Map<String, dynamic>>? latestPerformanceTrace; |
63 | cascade = cascade |
64 | .add((Request request) async { |
65 | try { |
66 | chrome ??= await whenChromeIsReady; |
67 | if (request.requestedUri.path.endsWith('/profile-data' )) { |
68 | final Map<String, dynamic> profile = |
69 | json.decode(await request.readAsString()) as Map<String, dynamic>; |
70 | final String benchmarkName = profile['name' ] as String; |
71 | if (benchmarkName != benchmarkIterator.current) { |
72 | profileData.completeError( |
73 | Exception( |
74 | 'Browser returned benchmark results from a wrong benchmark.\n' |
75 | 'Requested to run benchmark ${benchmarkIterator.current}, but ' |
76 | 'got results for $benchmarkName.' , |
77 | ), |
78 | ); |
79 | unawaited(server.close()); |
80 | } |
81 | |
82 | // Trace data is null when the benchmark is not frame-based, such as RawRecorder. |
83 | if (latestPerformanceTrace != null) { |
84 | final BlinkTraceSummary traceSummary = |
85 | BlinkTraceSummary.fromJson(latestPerformanceTrace!)!; |
86 | profile['totalUiFrame.average' ] = |
87 | traceSummary.averageTotalUIFrameTime.inMicroseconds; |
88 | profile['scoreKeys' ] ??= <dynamic>[]; // using dynamic for consistency with JSON |
89 | (profile['scoreKeys' ] as List<dynamic>).add('totalUiFrame.average' ); |
90 | latestPerformanceTrace = null; |
91 | } |
92 | collectedProfiles.add(profile); |
93 | return Response.ok('Profile received' ); |
94 | } else if (request.requestedUri.path.endsWith('/start-performance-tracing' )) { |
95 | latestPerformanceTrace = null; |
96 | await chrome!.beginRecordingPerformance( |
97 | request.requestedUri.queryParameters['label' ]!, |
98 | ); |
99 | return Response.ok('Started performance tracing' ); |
100 | } else if (request.requestedUri.path.endsWith('/stop-performance-tracing' )) { |
101 | latestPerformanceTrace = await chrome!.endRecordingPerformance(); |
102 | return Response.ok('Stopped performance tracing' ); |
103 | } else if (request.requestedUri.path.endsWith('/on-error' )) { |
104 | final Map<String, dynamic> errorDetails = |
105 | json.decode(await request.readAsString()) as Map<String, dynamic>; |
106 | unawaited(server.close()); |
107 | // Keep the stack trace as a string. It's thrown in the browser, not this Dart VM. |
108 | profileData.completeError(' ${errorDetails['error' ]}\n ${errorDetails['stackTrace' ]}' ); |
109 | return Response.ok('' ); |
110 | } else if (request.requestedUri.path.endsWith('/next-benchmark' )) { |
111 | if (benchmarks == null) { |
112 | benchmarks = |
113 | (json.decode(await request.readAsString()) as List<dynamic>).cast<String>(); |
114 | benchmarkIterator = benchmarks!.iterator; |
115 | } |
116 | if (benchmarkIterator.moveNext()) { |
117 | final String nextBenchmark = benchmarkIterator.current; |
118 | print('Launching benchmark " $nextBenchmark"' ); |
119 | return Response.ok(nextBenchmark); |
120 | } else { |
121 | profileData.complete(collectedProfiles); |
122 | return Response.notFound('Finished running benchmarks.' ); |
123 | } |
124 | } else if (request.requestedUri.path.endsWith('/print-to-console' )) { |
125 | // A passthrough used by |
126 | // `dev/benchmarks/macrobenchmarks/lib/web_benchmarks.dart` |
127 | // to print information. |
128 | final String message = await request.readAsString(); |
129 | print('[APP] $message' ); |
130 | return Response.ok('Reported.' ); |
131 | } else { |
132 | return Response.notFound('This request is not handled by the profile-data handler.' ); |
133 | } |
134 | } catch (error, stackTrace) { |
135 | profileData.completeError(error, stackTrace); |
136 | return Response.internalServerError(body: ' $error' ); |
137 | } |
138 | }) |
139 | .add(createBuildDirectoryHandler(path.join(macrobenchmarksDirectory, 'build' , 'web' ))); |
140 | |
141 | server = await io.HttpServer.bind('localhost' , benchmarkServerPort); |
142 | try { |
143 | shelf_io.serveRequests(server, cascade.handler); |
144 | |
145 | final String dartToolDirectory = path.join(' $macrobenchmarksDirectory/.dart_tool' ); |
146 | final String userDataDir = |
147 | io.Directory(dartToolDirectory).createTempSync('flutter_chrome_user_data.' ).path; |
148 | |
149 | // TODO(yjbanov): temporarily disables headful Chrome until we get |
150 | // devicelab hardware that is able to run it. Our current |
151 | // GCE VMs can only run in headless mode. |
152 | // See: https://github.com/flutter/flutter/issues/50164 |
153 | final bool isUncalibratedSmokeTest = io.Platform.environment['CALIBRATED' ] != 'true' ; |
154 | // final bool isUncalibratedSmokeTest = |
155 | // io.Platform.environment['UNCALIBRATED_SMOKE_TEST'] == 'true'; |
156 | final String urlParams = benchmarkOptions.forceSingleThreadedSkwasm ? '?force_st=true' : '' ; |
157 | final ChromeOptions options = ChromeOptions( |
158 | url: 'http://localhost:$benchmarkServerPort/index.html$urlParams', |
159 | userDataDirectory: userDataDir, |
160 | headless: isUncalibratedSmokeTest, |
161 | debugPort: chromeDebugPort, |
162 | enableWasmGC: benchmarkOptions.useWasm, |
163 | ); |
164 | |
165 | print('Launching Chrome.'); |
166 | whenChromeIsReady = Chrome.launch( |
167 | options, |
168 | onError: (String error) { |
169 | profileData.completeError(Exception(error)); |
170 | }, |
171 | workingDirectory: cwd, |
172 | ); |
173 | |
174 | print('Waiting for the benchmark to report benchmark profile.'); |
175 | final Map<String, dynamic> taskResult = <String, dynamic>{}; |
176 | final List<String> benchmarkScoreKeys = <String>[]; |
177 | final List<Map<String, dynamic>> profiles = await profileData.future; |
178 | |
179 | print('Received profile data'); |
180 | for (final Map<String, dynamic> profile in profiles) { |
181 | final String benchmarkName = profile['name'] as String; |
182 | if (benchmarkName.isEmpty) { |
183 | throw 'Benchmark name is empty'; |
184 | } |
185 | |
186 | final String webRendererName; |
187 | if (benchmarkOptions.useWasm) { |
188 | webRendererName = benchmarkOptions.forceSingleThreadedSkwasm ? 'skwasm_st' : 'skwasm'; |
189 | } else { |
190 | webRendererName = 'canvaskit'; |
191 | } |
192 | final String namespace = '$benchmarkName.$webRendererName'; |
193 | final List<String> scoreKeys = List<String>.from(profile['scoreKeys'] as List<dynamic>); |
194 | if (scoreKeys.isEmpty) { |
195 | throw 'No score keys in benchmark "$benchmarkName"'; |
196 | } |
197 | for (final String scoreKey in scoreKeys) { |
198 | if (scoreKey.isEmpty) { |
199 | throw 'Score key is empty in benchmark "$benchmarkName". ' |
200 | 'Received [${scoreKeys.join(', ')}]'; |
201 | } |
202 | benchmarkScoreKeys.add('$namespace.$scoreKey'); |
203 | } |
204 | |
205 | for (final String key in profile.keys) { |
206 | if (key == 'name' || key == 'scoreKeys') { |
207 | continue; |
208 | } |
209 | taskResult['$namespace.$key'] = profile[key]; |
210 | } |
211 | } |
212 | return TaskResult.success(taskResult, benchmarkScoreKeys: benchmarkScoreKeys); |
213 | } finally { |
214 | unawaited(server.close()); |
215 | chrome?.stop(); |
216 | } |
217 | }); |
218 | } |
219 | |
220 | Handler createBuildDirectoryHandler(String buildDirectoryPath) { |
221 | final Handler childHandler = createStaticHandler(buildDirectoryPath); |
222 | return (Request request) async { |
223 | final Response response = await childHandler(request); |
224 | final String? mimeType = response.mimeType; |
225 | |
226 | // Provide COOP/COEP headers so that the browser loads the page as |
227 | // crossOriginIsolated. This will make sure that we get high-resolution |
228 | // timers for our benchmark measurements. |
229 | if (mimeType == 'text/html' || mimeType == 'text/javascript') { |
230 | return response.change( |
231 | headers: <String, String>{ |
232 | 'Cross-Origin-Opener-Policy': 'same-origin', |
233 | 'Cross-Origin-Embedder-Policy': 'require-corp', |
234 | }, |
235 | ); |
236 | } else { |
237 | return response; |
238 | } |
239 | }; |
240 | } |
241 | |