| // Copyright (c) 2022, 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. | |
| // TODO(https://github.com/dart-lang/sdk/issues/48161) investigate whether we | |
| // can delete this file or not. | |
| import 'dart:async'; | |
| import 'dart:io' as io; | |
| import 'package:collection/collection.dart'; | |
| import 'package:devtools_shared/devtools_shared.dart'; | |
| import 'package:vm_service/vm_service.dart'; | |
| class MemoryProfile { | |
| MemoryProfile(this.service, String profileFilename, this._verboseMode) { | |
| onConnectionClosed.listen(_handleConnectionStop); | |
| service!.onEvent('Service').listen(handleServiceEvent); | |
| _jsonFile = MemoryJsonFile.create(profileFilename); | |
| _hookUpEvents(); | |
| } | |
| late MemoryJsonFile _jsonFile; | |
| final bool _verboseMode; | |
| void _hookUpEvents() async { | |
| final streamIds = [ | |
| EventStreams.kExtension, | |
| EventStreams.kGC, | |
| EventStreams.kIsolate, | |
| EventStreams.kLogging, | |
| EventStreams.kStderr, | |
| EventStreams.kStdout, | |
| // TODO(Kenzi): Collect timeline data too. | |
| // EventStreams.kTimeline, | |
| EventStreams.kVM, | |
| EventStreams.kService, | |
| ]; | |
| await Future.wait(streamIds.map((String id) async { | |
| try { | |
| await service!.streamListen(id); | |
| } catch (e) { | |
| if (id.endsWith('Logging')) { | |
| // Don't complain about '_Logging' or 'Logging' events (new VMs don't | |
| // have the private names, and older ones don't have the public ones). | |
| } else { | |
| print("Service client stream not supported: '$id'\n $e"); | |
| } | |
| } | |
| })); | |
| } | |
| bool get hasConnection => service != null; | |
| void handleServiceEvent(Event e) { | |
| if (e.kind == EventKind.kServiceRegistered) { | |
| final serviceName = e.service!; | |
| _registeredMethodsForService | |
| .putIfAbsent(serviceName, () => []) | |
| .add(e.method!); | |
| } | |
| if (e.kind == EventKind.kServiceUnregistered) { | |
| final serviceName = e.service!; | |
| _registeredMethodsForService.remove(serviceName); | |
| } | |
| } | |
| late IsolateRef _selectedIsolate; | |
| Future<Response?> getAdbMemoryInfo() async { | |
| return await callService( | |
| flutterMemory.service, | |
| isolateId: _selectedIsolate.id, | |
| ); | |
| } | |
| /// Call a service that is registered by exactly one client. | |
| Future<Response?> callService( | |
| String name, { | |
| String? isolateId, | |
| Map<String, dynamic>? args, | |
| }) async { | |
| final registered = _registeredMethodsForService[name] ?? const []; | |
| if (registered.isEmpty) { | |
| throw Exception('There are no registered methods for service "$name"'); | |
| } | |
| return service!.callMethod( | |
| registered.first, | |
| isolateId: isolateId, | |
| args: args, | |
| ); | |
| } | |
| Map<String, List<String>> get registeredMethodsForService => | |
| _registeredMethodsForService; | |
| final _registeredMethodsForService = <String, List<String>>{}; | |
| static const Duration updateDelay = Duration(milliseconds: 500); | |
| VmService? service; | |
| late Timer _pollingTimer; | |
| /// Polled VM current RSS. | |
| int? processRss; | |
| final Map<String, List<HeapSpace>> isolateHeaps = <String, List<HeapSpace>>{}; | |
| final List<HeapSample> samples = <HeapSample>[]; | |
| AdbMemoryInfo? adbMemoryInfo; | |
| EventSample eventSample = EventSample.empty(); | |
| RasterCache? rasterCache; | |
| late int heapMax; | |
| Stream<void> get onConnectionClosed => _connectionClosedController.stream; | |
| final _connectionClosedController = StreamController<void>.broadcast(); | |
| void _handleConnectionStop(dynamic event) { | |
| // TODO(terry): Gracefully handle connection loss. | |
| } | |
| // TODO(terry): Investigate moving code from this point through end of class to devtools_shared. | |
| void startPolling() { | |
| _pollingTimer = Timer(updateDelay, _pollMemory); | |
| service!.onGCEvent.listen(_handleGCEvent); | |
| } | |
| void _handleGCEvent(Event event) { | |
| //final bool ignore = event.json['reason'] == 'compact'; | |
| final json = event.json!; | |
| final List<HeapSpace> heaps = <HeapSpace>[ | |
| HeapSpace.parse(json['new'])!, | |
| HeapSpace.parse(json['old'])! | |
| ]; | |
| _updateGCEvent(event.isolate!.id!, heaps); | |
| // TODO(terry): expose when GC occurred as markers in memory timeline. | |
| } | |
| void stopPolling() { | |
| _pollingTimer.cancel(); | |
| service = null; | |
| } | |
| Future<void> _pollMemory() async { | |
| final service = this.service!; | |
| final VM vm = await service.getVM(); | |
| // TODO(terry): Need to handle a possible Sentinel being returned. | |
| final List<Isolate?> isolates = | |
| await Future.wait(vm.isolates!.map((IsolateRef ref) async { | |
| try { | |
| return await service.getIsolate(ref.id!); | |
| } catch (e) { | |
| // TODO(terry): Seem to sometimes get a sentinel not sure how? VM issue? | |
| // Unhandled Exception: type 'Sentinel' is not a subtype of type 'FutureOr<Isolate>' | |
| print('Error [MEMORY_PROTOCOL]: $e'); | |
| return Future<Isolate?>.value(); | |
| } | |
| })); | |
| // Polls for current Android meminfo using: | |
| // > adb shell dumpsys meminfo -d <package_name> | |
| final isolate = isolates[0]!; | |
| _selectedIsolate = IsolateRef( | |
| id: isolate.id, | |
| name: isolate.name, | |
| number: isolate.number, | |
| isSystemIsolate: isolate.isSystemIsolate, | |
| ); | |
| if (hasConnection && vm.operatingSystem == 'android') { | |
| // Poll ADB meminfo | |
| adbMemoryInfo = await _fetchAdbInfo(); | |
| } else { | |
| // TODO(terry): TBD alternative for iOS memory info - all values zero. | |
| adbMemoryInfo = AdbMemoryInfo.empty(); | |
| } | |
| // Query the engine's rasterCache estimate. | |
| rasterCache = await _fetchRasterCacheInfo(_selectedIsolate); | |
| // TODO(terry): There are no user interactions. However, might be nice to | |
| // record VM GC's on the timeline. | |
| eventSample = EventSample.empty(); | |
| // Polls for current RSS size. | |
| _update(vm, isolates); | |
| _pollingTimer = Timer(updateDelay, _pollMemory); | |
| } | |
| /// Poll ADB meminfo | |
| Future<AdbMemoryInfo?> _fetchAdbInfo() async { | |
| final adbMemInfo = await getAdbMemoryInfo(); | |
| if (adbMemInfo?.json != null) { | |
| return AdbMemoryInfo.fromJsonInKB(adbMemInfo!.json!); | |
| } | |
| return null; | |
| } | |
| /// Poll Flutter engine's Raster Cache metrics. | |
| /// @returns engine's rasterCache estimates or null. | |
| Future<RasterCache?> _fetchRasterCacheInfo(IsolateRef selectedIsolate) async { | |
| final response = await getRasterCacheMetrics(selectedIsolate); | |
| return RasterCache.parse(response?.json); | |
| } | |
| /// @returns view id of selected isolate's 'FlutterView'. | |
| /// @throws Exception if no 'FlutterView'. | |
| Future<String?> getFlutterViewId(IsolateRef selectedIsolate) async { | |
| final flutterViewListResponse = await service!.callServiceExtension( | |
| flutterListViews, | |
| isolateId: selectedIsolate.id, | |
| ); | |
| final List<dynamic> views = | |
| flutterViewListResponse.json!['views'].cast<Map<String, dynamic>>(); | |
| // Each isolate should only have one FlutterView. | |
| final flutterView = views.firstWhereOrNull( | |
| (view) => view['type'] == 'FlutterView', | |
| ); | |
| if (flutterView == null) { | |
| final message = | |
| 'No Flutter Views to query: ${flutterViewListResponse.json}'; | |
| print('ERROR: $message'); | |
| throw Exception(message); | |
| } | |
| final String flutterViewId = flutterView['id']!; | |
| return flutterViewId; | |
| } | |
| /// Flutter engine returns estimate how much memory is used by layer/picture raster | |
| /// cache entries in bytes. | |
| /// | |
| /// Call to returns JSON payload 'EstimateRasterCacheMemory' with two entries: | |
| /// layerBytes - layer raster cache entries in bytes | |
| /// pictureBytes - picture raster cache entries in bytes | |
| Future<Response?> getRasterCacheMetrics(IsolateRef selectedIsolate) async { | |
| final viewId = await getFlutterViewId(selectedIsolate); | |
| return await service!.callServiceExtension( | |
| flutterEngineRasterCache, | |
| args: {'viewId': viewId}, | |
| isolateId: selectedIsolate.id, | |
| ); | |
| } | |
| void _update(VM vm, List<Isolate?> isolates) { | |
| processRss = vm.json!['_currentRSS']; | |
| isolateHeaps.clear(); | |
| for (Isolate? isolate in isolates) { | |
| if (isolate != null) { | |
| isolateHeaps[isolate.id!] = getHeaps(isolate); | |
| } | |
| } | |
| _recalculate(); | |
| } | |
| void _updateGCEvent(String id, List<HeapSpace> heaps) { | |
| isolateHeaps[id] = heaps; | |
| _recalculate(true); | |
| } | |
| void _recalculate([bool fromGC = false]) { | |
| int total = 0; | |
| int used = 0; | |
| int capacity = 0; | |
| int external = 0; | |
| for (List<HeapSpace> heaps in isolateHeaps.values) { | |
| used += heaps.fold<int>(0, (i, heap) => i + heap.used!); | |
| capacity += heaps.fold<int>(0, (i, heap) => i + heap.capacity!); | |
| external += heaps.fold<int>(0, (i, heap) => i + heap.external!); | |
| capacity += external; | |
| total += | |
| heaps.fold<int>(0, (i, heap) => i + heap.capacity! + heap.external!); | |
| } | |
| heapMax = total; | |
| final time = DateTime.now().millisecondsSinceEpoch; | |
| final sample = HeapSample( | |
| time, | |
| processRss ?? -1, | |
| capacity, | |
| used, | |
| external, | |
| fromGC, | |
| adbMemoryInfo, | |
| eventSample, | |
| rasterCache, | |
| ); | |
| if (_verboseMode) { | |
| final timeCollected = _formatTime( | |
| DateTime.fromMillisecondsSinceEpoch(time), | |
| ); | |
| print(' Collected Sample: [$timeCollected] capacity=$capacity, ' | |
| 'ADB MemoryInfo total=${adbMemoryInfo!.total}${fromGC ? ' [GC]' : ''}'); | |
| } | |
| _jsonFile.writeSample(sample); | |
| } | |
| static List<HeapSpace> getHeaps(Isolate isolate) { | |
| final Map<String, dynamic> heaps = isolate.json!['_heaps']; | |
| final heapList = <HeapSpace>[]; | |
| for (final heapJson in heaps.values) { | |
| final heap = HeapSpace.parse(heapJson); | |
| if (heap != null) { | |
| heapList.add(heap); | |
| } | |
| } | |
| return heapList; | |
| } | |
| static String _formatTime(DateTime value) { | |
| String toStringLength(int value, int length) { | |
| final result = '$value'; | |
| assert(length >= result.length); | |
| return '0' * (length - result.length) + result; | |
| } | |
| return '${toStringLength(value.hour, 2)}:' | |
| '${toStringLength(value.minute, 2)}:' | |
| '${toStringLength(value.second, 2)}.' | |
| '${toStringLength(value.millisecond, 3)}'; | |
| } | |
| } | |
| class MemoryJsonFile { | |
| MemoryJsonFile.create(this._absoluteFileName) { | |
| _open(); | |
| } | |
| final String _absoluteFileName; | |
| late io.File _fs; | |
| late io.RandomAccessFile _raFile; | |
| bool _multipleSamples = false; | |
| void _open() { | |
| _fs = io.File(_absoluteFileName); | |
| _raFile = _fs.openSync(mode: io.FileMode.writeOnly); | |
| _populateJsonHeader(); | |
| } | |
| void _populateJsonHeader() { | |
| final payload = '${SamplesMemoryJson.header}${MemoryJson.trailer}'; | |
| _raFile.writeStringSync(payload); | |
| _raFile.flushSync(); | |
| } | |
| void _setPositionToWriteSample() { | |
| // Set the file position to the data array field contents - inside of []. | |
| final filePosition = _raFile.positionSync(); | |
| _raFile.setPositionSync(filePosition - MemoryJson.trailer.length); | |
| } | |
| void writeSample(HeapSample sample) { | |
| _setPositionToWriteSample(); | |
| String encodedSample; | |
| if (_multipleSamples) { | |
| encodedSample = SamplesMemoryJson().encodeAnother(sample); | |
| } else { | |
| encodedSample = SamplesMemoryJson().encode(sample); | |
| } | |
| _raFile.writeStringSync('$encodedSample${MemoryJson.trailer}'); | |
| _raFile.flushSync(); | |
| _multipleSamples = true; | |
| } | |
| } |