diff --git a/.ci.yaml b/.ci.yaml index bcb64e0f38..a371c275c2 100755 --- a/.ci.yaml +++ b/.ci.yaml @@ -2378,6 +2378,17 @@ targets: benchmark: "true" scheduler: luci + - name: Linux_android android_choreographer_do_frame_test + bringup: true + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab","android","linux"] + task_name: android_choreographer_do_frame_test + scheduler: luci + - name: Mac build_aar_module_test recipe: devicelab/devicelab_drone timeout: 60 diff --git a/TESTOWNERS b/TESTOWNERS index 5944196682..28bf447a58 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -71,6 +71,7 @@ /dev/devicelab/bin/tasks/opacity_peephole_fade_transition_text_perf__e2e_summary.dart @flar @flutter/engine /dev/devicelab/bin/tasks/opacity_peephole_col_of_alpha_savelayer_rows_perf__e2e_summary.dart @flar @flutter/engine /dev/devicelab/bin/tasks/opacity_peephole_grid_of_alpha_savelayers_perf__e2e_summary.dart @flar @flutter/engine +/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart @blasten @flutter/engine ## Windows Android DeviceLab tests /dev/devicelab/bin/tasks/basic_material_app_win__compile.dart @zanderso @flutter/tool diff --git a/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart b/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart new file mode 100644 index 0000000000..644af31b3d --- /dev/null +++ b/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart @@ -0,0 +1,10 @@ +// 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 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/android_choreographer_do_frame_test.dart'; + +Future main() async { + await task(androidChoreographerDoFrameTest()); +} diff --git a/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart b/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart new file mode 100644 index 0000000000..8226c0a920 --- /dev/null +++ b/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart @@ -0,0 +1,193 @@ +// 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'; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import '../framework/framework.dart'; +import '../framework/task_result.dart'; +import '../framework/utils.dart'; + +const List kSentinelStr = [ + '==== sentinel #1 ====', + '==== sentinel #2 ====', + '==== sentinel #3 ====', +]; + +// Regression test for https://github.com/flutter/flutter/issues/98973 +// This test ensures that Choreographer#doFrame finishes during application startup. +// This test fails if the application hangs during this period. +// https://ui.perfetto.dev/#!/?s=da6628c3a92456ae8fa3f345d0186e781da77e90fc8a64d073e9fee11d1e65 +TaskFunction androidChoreographerDoFrameTest({ + String? deviceIdOverride, + Map? environment, +}) { + final Directory tempDir = Directory.systemTemp + .createTempSync('flutter_devicelab_android_surface_recreation.'); + return () async { + try { + section('Create app'); + await inDirectory(tempDir, () async { + await flutter( + 'create', + options: [ + '--platforms', + 'android', + 'app', + ], + environment: environment, + ); + }); + + final File mainDart = File(path.join( + tempDir.absolute.path, + 'app', + 'lib', + 'main.dart', + )); + if (!mainDart.existsSync()) { + return TaskResult.failure('${mainDart.path} does not exist'); + } + + section('Patch lib/main.dart'); + await mainDart.writeAsString(''' +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + print('${kSentinelStr[0]}'); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + + print('${kSentinelStr[1]}'); + // If the Android UI thread is blocked, then this Future won't resolve. + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + + print('${kSentinelStr[2]}'); + runApp( + Container( + decoration: BoxDecoration( + color: const Color(0xff7c94b6), + ), + ), + ); +} +''', flush: true); + + Future runTestFor(String mode) async { + int nextCompleterIdx = 0; + final Map> sentinelCompleters = >{}; + for (final String sentinel in kSentinelStr) { + sentinelCompleters[sentinel] = Completer(); + } + + section('Flutter run (mode: $mode)'); + await inDirectory(path.join(tempDir.path, 'app'), () async { + final Process run = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + flutterCommandArgs('run', ['--$mode', '--verbose']), + ); + + int currSentinelIdx = 0; + final StreamSubscription stdout = run.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + + if (currSentinelIdx < sentinelCompleters.keys.length && + line.contains(sentinelCompleters.keys.elementAt(currSentinelIdx))) { + sentinelCompleters.values.elementAt(currSentinelIdx).complete(); + currSentinelIdx++; + print('stdout(MATCHED): $line'); + } else { + print('stdout: $line'); + } + + }); + + final StreamSubscription stderr = run.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('stderr: $line'); + }); + + final Completer exitCompleter = Completer(); + + unawaited(run.exitCode.then((int exitCode) { + exitCompleter.complete(); + })); + + section('Wait for sentinels (mode: $mode)'); + for (final Completer completer in sentinelCompleters.values) { + if (nextCompleterIdx == 0) { + // Don't time out because we don't know how long it would take to get the first log. + await Future.any( + >[ + completer.future, + exitCompleter.future, + ], + ); + } else { + try { + // Time out since this should not take 1s after the first log was received. + await Future.any( + >[ + completer.future.timeout(const Duration(seconds: 1)), + exitCompleter.future, + ], + ); + } on TimeoutException { + break; + } + } + if (exitCompleter.isCompleted) { + // The process exited. + break; + } + nextCompleterIdx++; + } + + section('Quit app (mode: $mode)'); + run.stdin.write('q'); + await exitCompleter.future; + + section('Stop listening to stdout and stderr (mode: $mode)'); + await stdout.cancel(); + await stderr.cancel(); + run.kill(); + }); + + if (nextCompleterIdx == sentinelCompleters.values.length) { + return TaskResult.success(null); + } + final String nextSentinel = sentinelCompleters.keys.elementAt(nextCompleterIdx); + return TaskResult.failure('Expected sentinel `$nextSentinel` in mode $mode'); + } + + final TaskResult debugResult = await runTestFor('debug'); + if (debugResult.failed) { + return debugResult; + } + + final TaskResult profileResult = await runTestFor('profile'); + if (profileResult.failed) { + return profileResult; + } + + final TaskResult releaseResult = await runTestFor('release'); + if (releaseResult.failed) { + return releaseResult; + } + + return TaskResult.success(null); + } finally { + rmTree(tempDir); + } + }; +}