blob: be82d9fd94071fda907be3f23a1c02e270fbc779 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Utilities for downloading and locally caching symbol files.
library symbolizer.symbols;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:symbolizer/model.dart';
import 'package:symbolizer/ndk.dart';
final _log = Logger('symbols');
/// Local cache of symbol files downloaded from Cloud Storage bucket.
class SymbolsCache {
final Ndk _ndk;
/// Local path at which this cache is located.
final String _path;
/// Number of entries in the cache after which we will start trying to
/// evict all entries which were not touched for longer than
/// [evictionThreshold].
final int _sizeThreshold;
/// Threshold past which an unused entry in the cache is considered evictable.
final Duration _evictionThreshold;
/// Map describing when symbols for the given [EngineBuild] were used
/// last time.
final Map<String, int> _lastUsedTimestamp = {};
/// Cache mapping Build-Id's values to corresponding engine builds.
final Map<String, EngineBuild> _buildIdCache = {};
/// Pending downloads by [EngineBuild].
final Map<String, Future<String>> _downloads = {};
/// Constructs cache at the given [path], which is assumed to be a directory.
/// If destination does not exist it will be created.
SymbolsCache({
@required Ndk ndk,
@required String path,
int sizeThreshold = 20,
Duration evictionThreshold = const Duration(minutes: 5),
}) : _ndk = ndk,
_path = path,
_sizeThreshold = sizeThreshold,
_evictionThreshold = evictionThreshold {
if (!Directory(path).existsSync()) {
Directory(path).createSync();
}
_loadState();
}
/// If necessary download symbols for the given [build] and return path to
/// the folder containing them.
Future<String> get(EngineBuild build) => _get(build, '', _downloadSymbols);
/// If necessary download engine binary (libflutter.so or Flutter) for the
/// given build and return path to the binary itself.
Future<String> getEngineBinary(EngineBuild build) async {
final dir = await _get(build, 'libflutter', _downloadEngine);
return p.join(dir, p.basename(_libflutterPath(build)));
}
/// Download an artifact from Cloud Storage using the given [downloader] and
/// cache result in the path using the given [suffix].
Future<String> _get(
EngineBuild build,
String suffix,
Future<void> Function(Directory, EngineBuild) downloader,
) {
final cacheDir = _cacheDirectoryFor(build, suffix: suffix);
if (!_downloads.containsKey(cacheDir)) {
_downloads[cacheDir] = _getImpl(cacheDir, build, downloader);
_downloads[cacheDir].then((_) => _downloads.remove(cacheDir));
}
return _downloads[cacheDir];
}
/// Given the [engineHash] and [EngineVariant] which specifies OS and
/// architecture look at symbols for all possible build modes and
/// find the one matching the given [buildId].
///
/// Currently only used for Android symbols because on iOS LC_UUID
/// is unreliable.
Future<EngineBuild> findVariantByBuildId({
@required String engineHash,
@required EngineVariant variant,
@required String buildId,
}) async {
_log.info('looking for ${buildId} among ${variant} engines');
if (variant.os != 'android') {
throw ArgumentError(
'LC_UUID is unreliable on iOS and can not be used for mode matching');
}
// Check if we already have a match in the Build-Id cache.
if (_buildIdCache.containsKey(buildId)) {
final build = _buildIdCache[buildId];
if (build.variant.os == variant.os &&
build.variant.arch == variant.arch) {
return build;
}
}
for (var potentialVariant in EngineVariant.allModesFor(variant)) {
final engineBuild =
EngineBuild(engineHash: engineHash, variant: potentialVariant);
final dir = await get(engineBuild);
final thisBuildId = await _ndk.getBuildId(p.join(dir, 'libflutter.so'));
_buildIdCache[thisBuildId] = engineBuild;
if (thisBuildId == buildId) {
return engineBuild;
}
}
throw 'Failed to find build with matching buildId (${buildId})';
}
File get _cacheStateFile => File(p.join(_path, 'cache.json'));
void _loadState() {
if (!_cacheStateFile.existsSync()) return;
final Map<String, dynamic> cacheState =
jsonDecode(_cacheStateFile.readAsStringSync());
final timestamps = cacheState['lastUsedTimestamp'] as List<dynamic>;
_lastUsedTimestamp.clear();
for (var i = 0; i < timestamps.length; i += 2) {
_lastUsedTimestamp[timestamps[i]] = timestamps[i + 1];
}
_buildIdCache.addAll((cacheState['buildIdCache'] as Map<String, dynamic>)
.map((key, value) => MapEntry(key, EngineBuild.fromJson(value))));
}
void _saveState() {
_cacheStateFile.writeAsStringSync(jsonEncode({
'lastUsedTimestamp':
_lastUsedTimestamp.entries.expand((e) => [e.key, e.value]).toList(),
'buildIdCache': _buildIdCache
}));
}
String _cacheDirectoryFor(EngineBuild build, {String suffix = ''}) =>
'symbols-cache/${build.engineHash}-${build.variant.toArtifactPath()}'
'${suffix.isNotEmpty ? '-' : ''}${suffix}';
Future<String> _getImpl(String targetDir, EngineBuild build,
Future<void> Function(Directory, EngineBuild) downloader) async {
final dir = Directory(targetDir);
if (dir.existsSync()) {
_touch(dir.path);
return dir.path;
}
// Make sure we have some space.
_evictOldEntriesIfNecessary();
_log.info('downloading ${targetDir} for ${build}');
// Download symbols into a temporary directory, once we successfully
// unpack them we will rename this directory.
final tempDir = await Directory.systemTemp.createTemp();
try {
await downloader(tempDir, build);
// Now move the directory into the cache.
final renamed = await tempDir.rename(dir.path);
if (build.variant.os == 'android') {
// Fetch Build-Id from the library and add it to the cache.
final buildId =
await _ndk.getBuildId(p.join(renamed.path, 'libflutter.so'));
_buildIdCache[buildId] = build;
}
_touch(renamed.path);
return renamed.path;
} finally {
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
}
}
Future<void> _downloadSymbols(Directory tempDir, EngineBuild build) async {
final symbolsFile =
build.variant.os == 'ios' ? 'Flutter.dSYM.zip' : 'symbols.zip';
await _run('gsutil', [
'cp',
'gs://flutter_infra/flutter/${build.engineHash}/${build.variant.toArtifactPath()}/${symbolsFile}',
p.join(tempDir.path, symbolsFile)
]);
await _run('unzip', [symbolsFile], workingDirectory: tempDir.path);
// Delete downloaded ZIP file.
await File(p.join(tempDir.path, symbolsFile)).delete();
}
Future<void> _downloadEngine(Directory tempDir, EngineBuild build) async {
final artifactsFile = 'artifacts.zip';
await _run('gsutil', [
'cp',
'gs://flutter_infra/flutter/${build.engineHash}/${build.variant.toArtifactPath()}/${artifactsFile}',
p.join(tempDir.path, artifactsFile)
]);
final nestedZip =
build.variant.os == 'ios' ? 'Flutter.framework.zip' : 'flutter.jar';
await _run('unzip', [artifactsFile, nestedZip],
workingDirectory: tempDir.path);
final libraryPath = _libflutterPath(build);
await _run('unzip', [nestedZip, libraryPath],
workingDirectory: tempDir.path);
if (p.dirname(libraryPath) != '.') {
await File(p.join(tempDir.path, libraryPath))
.rename(p.join(tempDir.path, p.basename(libraryPath)));
await Directory(p.join(tempDir.path, p.dirname(libraryPath)))
.delete(recursive: true);
}
// Delete downloaded ZIP file.
await File(p.join(tempDir.path, artifactsFile)).delete();
await File(p.join(tempDir.path, nestedZip)).delete();
}
static String _libflutterPath(EngineBuild build) {
switch (build.variant.os) {
case 'ios':
return 'Flutter';
case 'android':
switch (build.variant.arch) {
case 'arm64':
return 'lib/arm64-v8a/libflutter.so';
case 'arm':
return 'lib/armeabi-v7a/libflutter.so';
}
break;
}
throw 'Unsupported combination of architecture and OS: ${build.variant}';
}
Future<void> _run(String executable, List<String> args,
{String workingDirectory}) async {
final result =
await Process.run(executable, args, workingDirectory: workingDirectory);
if (result.exitCode != 0) {
throw 'Failed to run ${executable} ${args.join(' ')} '
'(exit code ${result.exitCode}): ${result.stdout} ${result.stderr}';
}
}
void _touch(String path) {
_lastUsedTimestamp[path] = DateTime.now().millisecondsSinceEpoch;
_saveState();
}
/// If the cache is too big then evict all entries outside of the given
/// interval.
void _evictOldEntriesIfNecessary() {
if (_lastUsedTimestamp.length < _sizeThreshold) {
return;
}
final fiveMinutesAgo =
DateTime.now().subtract(_evictionThreshold).millisecondsSinceEpoch;
for (var path in _lastUsedTimestamp.entries
.where((e) => e.value < fiveMinutesAgo)
.map((e) => e.key)
.toList()) {
final dir = Directory(path);
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
_lastUsedTimestamp.remove(path);
}
_saveState();
}
}
extension on EngineVariant {
String toArtifactPath() {
final modeSuffix = (mode == 'debug') ? '' : '-${mode}';
if (os == 'ios') {
return '${os}${modeSuffix}';
} else {
return '${os}-${arch}${modeSuffix}';
}
}
}