diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index bab9e5d27ce..42e12420709 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:meta/meta.dart'; import 'package:process/process.dart'; @@ -48,6 +50,12 @@ class IOSCoreDeviceLauncher { final FileSystem _fileSystem; final LLDB _lldb; + /// Contains a stream that devicectl sends logs to. + final coreDeviceLogForwarder = IOSCoreDeviceLogForwarder(); + + /// Contains a stream that LLDB sends logs to. + final lldbLogForwarder = LLDBLogForwarder(); + /// Install and launch the app on the device with `devicectl` ([_coreDeviceControl]) /// and do not attach a debugger. This is generally only used for release mode. Future launchAppWithoutDebugger({ @@ -57,12 +65,10 @@ class IOSCoreDeviceLauncher { required List launchArguments, }) async { // Install app to device - final bool installSuccess = await _coreDeviceControl.installApp( - deviceId: deviceId, - bundlePath: bundlePath, - ); - if (!installSuccess) { - return installSuccess; + final (bool installStatus, IOSCoreDeviceInstallResult? installResult) = await _coreDeviceControl + .installApp(deviceId: deviceId, bundlePath: bundlePath); + if (!installStatus) { + return false; } // Launch app to device @@ -90,34 +96,47 @@ class IOSCoreDeviceLauncher { required List launchArguments, }) async { // Install app to device - final bool installSuccess = await _coreDeviceControl.installApp( - deviceId: deviceId, - bundlePath: bundlePath, - ); - if (!installSuccess) { - return installSuccess; + final (bool installStatus, IOSCoreDeviceInstallResult? installResult) = await _coreDeviceControl + .installApp(deviceId: deviceId, bundlePath: bundlePath); + final String? installationURL = installResult?.installationURL; + if (!installStatus || installationURL == null) { + return false; } // Launch app on device, but start it stopped so it will wait until the debugger is attached before starting. - final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp( + final bool launchResult = await _coreDeviceControl.launchAppAndStreamLogs( + coreDeviceLogForwarder: coreDeviceLogForwarder, deviceId: deviceId, bundleId: bundleId, launchArguments: launchArguments, startStopped: true, ); - if (launchResult == null || launchResult.outcome != 'success') { - return false; + if (!launchResult) { + return launchResult; } - final IOSCoreDeviceRunningProcess? launchedProcess = launchResult.process; + // Find the process that was launched using the installationURL. + final List processes = await _coreDeviceControl + .getRunningProcesses(deviceId: deviceId); + final IOSCoreDeviceRunningProcess? launchedProcess = processes + .where( + (IOSCoreDeviceRunningProcess process) => + process.executable != null && process.executable!.contains(installationURL), + ) + .firstOrNull; + final int? processId = launchedProcess?.processIdentifier; if (launchedProcess == null || processId == null) { return false; } // Start LLDB and attach to the device process. - final bool attachStatus = await _lldb.attachAndStart(deviceId, processId); + final bool attachStatus = await _lldb.attachAndStart( + deviceId: deviceId, + appProcessId: processId, + lldbLogForwarder: lldbLogForwarder, + ); // If it fails to attach with lldb, kill the launched process so it doesn't stay hanging. if (!attachStatus) { @@ -220,6 +239,9 @@ class IOSCoreDeviceLauncher { processToStop = processId; } + // Then kill the attached launch process first so it doesn't process any additional logs when you terminate the app + await Future.wait([coreDeviceLogForwarder.exit(), lldbLogForwarder.exit()]); + if (processToStop == null) { return false; } @@ -230,6 +252,35 @@ class IOSCoreDeviceLauncher { } } +/// This class is used to forward logs from devicectl to any active listeners. +class IOSCoreDeviceLogForwarder { + /// The `devicectl` process that launched the app and is streaming the logs. + Process? launchProcess; + + final _streamController = StreamController.broadcast(); + Stream get logLines => _streamController.stream; + + /// Whether or not a `devicectl` launch process is running. + bool get isRunning => launchProcess != null; + + void addLog(String log) { + if (!_streamController.isClosed) { + _streamController.add(log); + } + } + + /// Kill [launchProcess] if available and set it to null. + Future exit() async { + final bool success = (launchProcess == null) || launchProcess!.kill(); + launchProcess = null; + if (_streamController.hasListener) { + // Tell listeners the process died. + await _streamController.close(); + } + return success; + } +} + /// A wrapper around the `devicectl` command line tool. /// /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices @@ -258,6 +309,14 @@ class IOSCoreDeviceControl { /// run the command. static const _minimumTimeoutInSeconds = 5; + /// A list of log patterns to ignore. + static final _ignorePatterns = [ + RegExp(r'\[PreviewsAgentExecutorLibrary\].*'), + RegExp(r'\[UIKit App Config\].*'), + RegExp(r'\[UIFocus\].*'), + RegExp(r'Dart execution mode: .*'), + ]; + /// Executes `devicectl` command to get list of devices. The command will /// likely complete before [timeout] is reached. If [timeout] is reached, /// the command will be stopped as a failure. @@ -436,10 +495,13 @@ class IOSCoreDeviceControl { return false; } - Future installApp({required String deviceId, required String bundlePath}) async { + Future<(bool, IOSCoreDeviceInstallResult?)> installApp({ + required String deviceId, + required String bundlePath, + }) async { if (!_xcode.isDevicectlInstalled) { - _logger.printError('devicectl is not installed.'); - return false; + _logger.printTrace('devicectl is not installed.'); + return (false, null); } final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); @@ -464,20 +526,24 @@ class IOSCoreDeviceControl { final String stringOutput = output.readAsStringSync(); try { - final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; - if (decodeResult is Map && decodeResult['outcome'] == 'success') { - return true; + final Object? decodedJson = json.decode(stringOutput); + if (decodedJson is Map) { + final result = IOSCoreDeviceInstallResult.fromJson(decodedJson); + if (result.outcome != null) { + final success = result.outcome == 'success'; + return (success, result); + } } - _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); - return false; + _logger.printTrace('devicectl returned unexpected JSON response: $stringOutput'); + return (false, null); } on FormatException { // We failed to parse the devicectl output, or it returned junk. - _logger.printError('devicectl returned non-JSON response: $stringOutput'); - return false; + _logger.printTrace('devicectl returned non-JSON response: $stringOutput'); + return (false, null); } } on ProcessException catch (err) { - _logger.printError('Error executing devicectl: $err'); - return false; + _logger.printTrace('Error executing devicectl: $err'); + return (false, null); } finally { tempDirectory.deleteSync(recursive: true); } @@ -532,6 +598,43 @@ class IOSCoreDeviceControl { } } + /// Launches the app on the device. + /// + /// If [startStopped] is true, the app will be launched and paused, waiting + /// for a debugger to attach. + /// + /// If [attachToConsole] is true, attaches the application to the console and waits for the app + /// to terminate. + List _launchAppCommand({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + bool startStopped = false, + bool attachToConsole = false, + File? outputFile, + }) { + return [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + if (startStopped) '--start-stopped', + if (attachToConsole) ...[ + '--console', + '--environment-variables', + // OS_ACTIVITY_DT_MODE needs to be set to get NSLog and os_log output + // See https://github.com/llvm/llvm-project/blob/19b43e1757b4fd3d0f188cf8a08e9febb0dbec2f/lldb/source/Plugins/Platform/MacOSX/PlatformDarwin.cpp#L1227-L1233 + '{"OS_ACTIVITY_DT_MODE": "enable"}', + ], + if (outputFile != null) ...['--json-output', outputFile.path], + bundleId, + if (launchArguments.isNotEmpty) ...launchArguments, + ]; + } + /// Launches the app on the device. /// /// If [startStopped] is true, the app will be launched and paused, waiting @@ -548,23 +651,15 @@ class IOSCoreDeviceControl { } final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); - final File output = tempDirectory.childFile('launch_results.json'); - output.createSync(); + final File output = tempDirectory.childFile('launch_results.json')..createSync(); - final command = [ - ..._xcode.xcrunCommand(), - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - if (startStopped) '--start-stopped', - bundleId, - if (launchArguments.isNotEmpty) ...launchArguments, - '--json-output', - output.path, - ]; + final List command = _launchAppCommand( + bundleId: bundleId, + deviceId: deviceId, + launchArguments: launchArguments, + startStopped: startStopped, + outputFile: output, + ); try { await _processUtils.run(command, throwOnError: true); @@ -592,6 +687,92 @@ class IOSCoreDeviceControl { } } + /// Launches the app on the device, streams the logs, and stays attached until the app terminates. + /// + /// If [startStopped] is true, the app will be launched and paused, waiting + /// for a debugger to attach. + Future launchAppAndStreamLogs({ + required IOSCoreDeviceLogForwarder coreDeviceLogForwarder, + required String deviceId, + required String bundleId, + List launchArguments = const [], + bool startStopped = false, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printTrace('devicectl is not installed.'); + return false; + } + if (coreDeviceLogForwarder.isRunning) { + _logger.printTrace( + 'A launch process is already running. It must be stopped before starting a new one.', + ); + return false; + } + + final launchCompleter = Completer(); + final List command = _launchAppCommand( + bundleId: bundleId, + deviceId: deviceId, + launchArguments: launchArguments, + startStopped: startStopped, + attachToConsole: true, + ); + + try { + final Process launchProcess = await _processUtils.start(command); + coreDeviceLogForwarder.launchProcess = launchProcess; + + final StreamSubscription stdoutSubscription = launchProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + if (launchCompleter.isCompleted && !_ignoreLog(line)) { + coreDeviceLogForwarder.addLog(line); + } else { + _logger.printTrace(line); + } + + if (line.contains('Waiting for the application to terminate')) { + launchCompleter.complete(true); + } + }); + + final StreamSubscription stderrSubscription = launchProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + if (launchCompleter.isCompleted && !_ignoreLog(line)) { + coreDeviceLogForwarder.addLog(line); + } else { + _logger.printTrace(line); + } + }); + + unawaited( + launchProcess.exitCode + .then((int status) async { + _logger.printTrace('lldb exited with code $status'); + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + }) + .whenComplete(() async { + await coreDeviceLogForwarder.exit(); + if (!launchCompleter.isCompleted) { + launchCompleter.complete(false); + } + }), + ); + return launchCompleter.future; + } on ProcessException catch (err) { + _logger.printTrace('Error executing devicectl: $err'); + return false; + } + } + + bool _ignoreLog(String log) { + return _ignorePatterns.any((Pattern pattern) => log.contains(pattern)); + } + /// Terminate the [processId] on the device using `devicectl`. Future terminateProcess({required String deviceId, required int processId}) async { if (!_xcode.isDevicectlInstalled) { @@ -644,6 +825,65 @@ class IOSCoreDeviceControl { tempDirectory.deleteSync(recursive: true); } } + + Future> _listRunningProcesses({required String deviceId}) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printTrace('devicectl is not installed.'); + return []; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('core_device_process_list.json')..createSync(); + + final command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + + final String stringOutput = output.readAsStringSync(); + + try { + if (json.decode(stringOutput) case { + 'result': {'runningProcesses': final List decodedProcesses}, + }) { + return decodedProcesses; + } + _logger.printTrace('devicectl returned unexpected JSON response: $stringOutput'); + return []; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printTrace('devicectl returned non-JSON response: $stringOutput'); + return []; + } + } on ProcessException catch (err) { + _logger.printTrace('Error executing devicectl: $err'); + return []; + } on FileSystemException catch (err) { + _logger.printTrace('Error reading output file: $err'); + return []; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + Future> getRunningProcesses({required String deviceId}) async { + final List processesData = await _listRunningProcesses(deviceId: deviceId); + return [ + for (final Object? processObject in processesData) + if (processObject is Map) + IOSCoreDeviceRunningProcess.fromJson(processObject), + ]; + } } class IOSCoreDevice { @@ -1188,3 +1428,64 @@ class IOSCoreDeviceRunningProcess { final String? executable; final int? processIdentifier; } + +class IOSCoreDeviceInstallResult { + IOSCoreDeviceInstallResult._({ + required this.outcome, + required this.bundleID, + required this.databaseUUID, + required this.installationURL, + required this.launchServicesIdentifier, + }); + + /// Parse JSON from `devicectl device install app --device --json-output`. + /// + /// Example: + /// { + /// "info" : { + /// ... + /// "outcome" : "success", + /// ... + /// }, + /// "result" : { + /// ... + /// "installedApplications" : [ + /// { + /// "bundleID" : "com.example.app", + /// "databaseSequenceNumber" : 1324, + /// "databaseUUID" : "DF123456-1234-4C46-B3F2-EF7D18596C3D", + /// "installationURL" : "file:////private/var/containers/Bundle/Application/D12EFD3B-4567-890E-B1F2-23456DAA789A/Runner.app/", + /// "launchServicesIdentifier" : "unknown", + /// "options" : { + /// } + /// } + /// ] + /// } + /// } + factory IOSCoreDeviceInstallResult.fromJson(Map data) { + String? outcome; + final Object? info = data['info']; + if (info is Map) { + outcome = info['outcome'] as String?; + } + + final Map? installedApp = switch (data['result']) { + {'installedApplications': [final Map app, ...]} => app, + _ => null, + }; + + return IOSCoreDeviceInstallResult._( + outcome: outcome, + bundleID: installedApp?['bundleID'] as String?, + databaseUUID: installedApp?['databaseUUID'] as String?, + installationURL: installedApp?['installationURL'] as String?, + launchServicesIdentifier: installedApp?['launchServicesIdentifier'] as String?, + ); + } + + final String? outcome; + final String? bundleID; + final String? databaseUUID; + final String? installationURL; + final String? launchServicesIdentifier; +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index a6dd75ee5a9..bbcca01aae9 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +/// @docImport '../resident_runner.dart'; +library; + import 'dart:async'; import 'package:meta/meta.dart'; @@ -28,6 +31,7 @@ import '../device_vm_service_discovery_for_attach.dart'; import '../features.dart'; import '../globals.dart' as globals; import '../macos/xcdevice.dart'; +import '../macos/xcode.dart'; import '../mdns_discovery.dart'; import '../project.dart'; import '../protocol_discovery.dart'; @@ -37,6 +41,7 @@ import 'core_devices.dart'; import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; +import 'lldb.dart'; import 'mac.dart'; import 'xcode_build_settings.dart'; import 'xcode_debug.dart'; @@ -414,8 +419,11 @@ class IOSDevice extends Device { int installationResult; try { if (isCoreDevice) { - installationResult = - await _coreDeviceControl.installApp(deviceId: id, bundlePath: bundle.path) ? 0 : 1; + final (bool installSuccess, _) = await _coreDeviceControl.installApp( + deviceId: id, + bundlePath: bundle.path, + ); + installationResult = installSuccess ? 0 : 1; } else { installationResult = await _iosDeploy.installApp( deviceId: id, @@ -964,7 +972,7 @@ class IOSDevice extends Device { // Release mode // Install app to device - final bool installSuccess = await _coreDeviceControl.installApp( + final (bool installSuccess, _) = await _coreDeviceControl.installApp( deviceId: id, bundlePath: package.deviceBundlePath, ); @@ -991,6 +999,14 @@ class IOSDevice extends Device { final Version? xcodeVersion = globals.xcode?.currentVersion; final bool lldbFeatureEnabled = featureFlags.isLLDBDebuggingEnabled; if (xcodeVersion != null && xcodeVersion.major >= 26 && lldbFeatureEnabled) { + final DeviceLogReader deviceLogReader = getLogReader( + app: package, + usingCISystem: debuggingOptions.usingCISystem, + ); + if (deviceLogReader is IOSDeviceLogReader) { + await deviceLogReader.listenToCoreDeviceLauncher(_coreDeviceLauncher); + } + final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger( deviceId: id, bundlePath: package.deviceBundlePath, @@ -1138,6 +1154,7 @@ class IOSDevice extends Device { app: app, iMobileDevice: _iMobileDevice, usingCISystem: usingCISystem, + xcode: globals.xcode, ), ); } @@ -1291,8 +1308,21 @@ String decodeSyslog(String line) { } } +/// Listens to multiple logging sources to get the logs from the physical iOS device. +/// +/// Potential logging sources include: +/// * `idevicesyslog` +/// * `ios-deploy` +/// * `devicectl` and `lldb` +/// * Unified Logging (Dart VM) +/// +/// Not all logging sources work on all devices. See [logSources] for limitations. +/// +/// Logs are added to the [linesController] and consumed through the [logLines] stream by +/// [FlutterDevice.startEchoingDeviceLog]. class IOSDeviceLogReader extends DeviceLogReader { IOSDeviceLogReader._( + this._xcode, this._iMobileDevice, this._majorSdkVersion, this._deviceId, @@ -1313,10 +1343,12 @@ class IOSDeviceLogReader extends DeviceLogReader { required IOSDevice device, IOSApp? app, required IMobileDevice iMobileDevice, + required Xcode? xcode, bool usingCISystem = false, }) { final String appName = app?.name?.replaceAll('.app', '') ?? ''; return IOSDeviceLogReader._( + xcode, iMobileDevice, device.majorSdkVersion, device.id, @@ -1331,6 +1363,7 @@ class IOSDeviceLogReader extends DeviceLogReader { /// Create an [IOSDeviceLogReader] for testing. factory IOSDeviceLogReader.test({ required IMobileDevice iMobileDevice, + required Xcode? xcode, bool useSyslog = true, bool usingCISystem = false, int? majorSdkVersion, @@ -1339,6 +1372,7 @@ class IOSDeviceLogReader extends DeviceLogReader { }) { final int sdkVersion = majorSdkVersion ?? (useSyslog ? 12 : 13); return IOSDeviceLogReader._( + xcode, iMobileDevice, sdkVersion, '1234', @@ -1357,6 +1391,7 @@ class IOSDeviceLogReader extends DeviceLogReader { final bool _isWirelesslyConnected; final bool _isCoreDevice; final IMobileDevice _iMobileDevice; + final Xcode? _xcode; final bool _usingCISystem; // Matches a syslog line from the runner. @@ -1448,6 +1483,15 @@ class IOSDeviceLogReader extends DeviceLogReader { @override Stream get logLines => linesController.stream; + final _coreDeviceLoggingSource = CoreDeviceLoggingSource(); + Future listenToCoreDeviceLauncher(IOSCoreDeviceLauncher launcher) async { + if (!useCoreDeviceLogging) { + return; + } + _coreDeviceLoggingSource.coreDeviceLauncher = launcher; + await _coreDeviceLoggingSource.listenToLogs(addToLinesController, linesController); + } + FlutterVmService? _connectedVmService; @override @@ -1469,6 +1513,15 @@ class IOSDeviceLogReader extends DeviceLogReader { // Also, `idevicesyslog` does not work with iOS 17 wireless devices, so use the // Dart VM for wireless devices. if (_isCoreDevice) { + // `idevicesyslog` stopped working with at least Xcode 26 (may have been before). + // Instead, use logging from `devicectl` and `lldb`. + final Version? xcodeVersion = _xcode?.currentVersion; + if (xcodeVersion != null && xcodeVersion.major >= 26) { + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.devicectlAndLldb, + fallbackSource: IOSDeviceLogSource.unifiedLogging, + ); + } if (_isWirelesslyConnected) { return _IOSDeviceLogSources(primarySource: IOSDeviceLogSource.unifiedLogging); } @@ -1536,6 +1589,13 @@ class IOSDeviceLogReader extends DeviceLogReader { logSources.fallbackSource == IOSDeviceLogSource.iosDeploy; } + /// Whether `devicectl` and `lldb` are used as the primary or fallback source for device logs. + @visibleForTesting + bool get useCoreDeviceLogging { + return logSources.primarySource == IOSDeviceLogSource.devicectlAndLldb || + logSources.fallbackSource == IOSDeviceLogSource.devicectlAndLldb; + } + /// Listen to Dart VM for logs on iOS 13 or greater. Future _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async { if (!useUnifiedLogging) { @@ -1671,6 +1731,7 @@ class IOSDeviceLogReader extends DeviceLogReader { } idevicesyslogProcess?.kill(); _iosDeployDebugger?.detach(); + _coreDeviceLoggingSource.dispose(); } } @@ -1683,6 +1744,9 @@ enum IOSDeviceLogSource { /// Gets logs from the Dart VM Service. unifiedLogging, + + /// Gets logs from `devicectl` and `lldb` + devicectlAndLldb, } class _IOSDeviceLogSources { @@ -1692,6 +1756,62 @@ class _IOSDeviceLogSources { final IOSDeviceLogSource? fallbackSource; } +@visibleForTesting +class CoreDeviceLoggingSource { + IOSCoreDeviceLauncher? coreDeviceLauncher; + final _loggingSubscriptions = >[]; + + Future listenToLogs( + void Function(String, IOSDeviceLogSource) onLogMessage, + StreamController linesController, + ) async { + final IOSCoreDeviceLogForwarder? debugger = coreDeviceLauncher?.coreDeviceLogForwarder; + if (debugger != null) { + _loggingSubscriptions.add( + debugger.logLines.listen( + (String line) => + onLogMessage(_debuggerLineHandler(line), IOSDeviceLogSource.devicectlAndLldb), + onError: linesController.addError, + onDone: linesController.close, + cancelOnError: true, + ), + ); + } + + final LLDBLogForwarder? lldbLogForwarder = coreDeviceLauncher?.lldbLogForwarder; + if (lldbLogForwarder != null) { + _loggingSubscriptions.add( + lldbLogForwarder.logLines.listen( + (String line) => + onLogMessage(_debuggerLineHandler(line), IOSDeviceLogSource.devicectlAndLldb), + onError: linesController.addError, + onDone: linesController.close, + cancelOnError: true, + ), + ); + } + } + + // Logging from native code/Flutter engine is prefixed by timestamp and process metadata: + // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching. + // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching. + // + // Logging from the dart code has no prefixing metadata. + final _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)'); + + // Strip off the logging metadata (leave the category), or just echo the line. + String _debuggerLineHandler(String line) => + _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line; + + void dispose() { + for (final StreamSubscription loggingSubscription in _loggingSubscriptions) { + loggingSubscription.cancel(); + } + coreDeviceLauncher?.lldbLogForwarder.exit(); + coreDeviceLauncher?.coreDeviceLogForwarder.exit(); + } +} + /// A [DevicePortForwarder] specialized for iOS usage with iproxy. class IOSDevicePortForwarder extends DevicePortForwarder { /// Create a new [IOSDevicePortForwarder]. diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 025876ecec4..8da71ee17d0 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -29,6 +29,9 @@ class LLDB { /// Whether or not a LLDB process is running. bool get isRunning => _lldbProcess != null; + /// Whether or not the LLDB process has attached and resumed the application process. + var _isAttached = false; + /// The process id of the application running on the iOS device. int? get appProcessId => _lldbProcess?.appProcessId; @@ -49,6 +52,9 @@ class LLDB { /// Example: Breakpoint 1: no locations (pending). static final _breakpointPattern = RegExp(r'Breakpoint (\d+)*:'); + /// A list of log patterns to ignore. + static final _ignorePatterns = [RegExp(r'\d+ location added to breakpoint \d+')]; + /// Breakpoint script required for JIT on iOS. /// /// This should match the "handle_new_rx_page" function in [IosProject._lldbPythonHelperTemplate]. @@ -75,7 +81,14 @@ return False /// Starts an LLDB process and inputs commands to start debugging the [appProcessId]. /// This will start a debugserver on the device, which is required for JIT. - Future attachAndStart(String deviceId, int appProcessId) async { + /// + /// After attaching and starting the app process, forwards logs to [lldbLogForwarder]. + /// This may include crash logs. + Future attachAndStart({ + required String deviceId, + required int appProcessId, + required LLDBLogForwarder lldbLogForwarder, + }) async { Timer? timer; try { timer = Timer(const Duration(minutes: 1), () { @@ -90,7 +103,10 @@ return False ); }); - final bool start = await _startLLDB(appProcessId); + final bool start = await _startLLDB( + appProcessId: appProcessId, + lldbLogForwarder: lldbLogForwarder, + ); if (!start) { return false; } @@ -98,6 +114,7 @@ return False await _setBreakpoint(); await _attachToAppProcess(appProcessId); await _resumeProcess(); + _isAttached = true; } on _LLDBError catch (e) { _logger.printTrace('lldb failed with error: ${e.message}'); exit(); @@ -113,7 +130,10 @@ return False /// Streams `stdout` and `stderr`. When receiving a log from `stdout`, check /// if it matches the pattern [_logCompleter] is waiting for. If a log is sent /// to `stderr`, complete with an error and stop the process. - Future _startLLDB(int appProcessId) async { + Future _startLLDB({ + required int appProcessId, + required LLDBLogForwarder lldbLogForwarder, + }) async { if (_lldbProcess != null) { _logger.printTrace( 'An LLDB process is already running. It must be stopped before starting a new one.', @@ -131,16 +151,29 @@ return False .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { - _logger.printTrace('[lldb]: $line'); - _logCompleter?.checkForMatch(line); + if (_isAttached && !_ignoreLog(line)) { + // Only forwards logs after LLDB is attached. All logs before then are part of the + // attach process. + + lldbLogForwarder.addLog(line); + } else { + _logger.printTrace('[lldb]: $line'); + _logCompleter?.checkForMatch(line); + } }); final StreamSubscription stderrSubscription = _lldbProcess!.stderr .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { - _logger.printTrace('[lldb]: $line'); _monitorError(line); + if (_isAttached && !_ignoreLog(line)) { + // Only forwards logs after LLDB is attached. All logs before then are part of the + // attach process. + lldbLogForwarder.addLog(line); + } else { + _logger.printTrace('[lldb]: $line'); + } }); unawaited( @@ -166,6 +199,7 @@ return False final bool success = (_lldbProcess == null) || _lldbProcess!.kill(); _lldbProcess = null; _logCompleter = null; + _isAttached = false; return success; } @@ -258,6 +292,10 @@ return False exit(); } } + + bool _ignoreLog(String log) { + return _ignorePatterns.any((Pattern pattern) => log.contains(pattern)); + } } class _LLDBError implements Exception { @@ -334,3 +372,23 @@ class _LLDBProcess { return _stdinWriteFuture; } } + +/// This class is used to forward logs from LLDB to any active listeners. +class LLDBLogForwarder { + final _streamController = StreamController.broadcast(); + Stream get logLines => _streamController.stream; + + void addLog(String log) { + if (!_streamController.isClosed) { + _streamController.add(log); + } + } + + Future exit() async { + if (_streamController.hasListener) { + // Tell listeners the process died. + await _streamController.close(); + } + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart index 80434cac57e..9ae1921c4ac 100644 --- a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -2,6 +2,9 @@ // 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:io' as io; + import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/base/error_handling_io.dart'; @@ -172,14 +175,27 @@ void main() { group('launchAppWithLLDBDebugger', () { testWithoutContext('succeeds', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), launchResult: IOSCoreDeviceLaunchResult.fromJson(const { 'info': {'outcome': 'success'}, 'result': { 'process': {'processIdentifier': 123}, }, }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], ); - final processManager = FakeProcessManager.any(); final logger = BufferLogger.test(); final processUtils = ProcessUtils(processManager: processManager, logger: logger); @@ -205,7 +221,72 @@ void main() { }); testWithoutContext('fails on install', () async { - final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(installSuccess: false); + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], + installSuccess: false, + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on missing installationURL', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'failure'}, + }), + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], + ); final processManager = FakeProcessManager.any(); final logger = BufferLogger.test(); @@ -233,12 +314,27 @@ void main() { testWithoutContext('fails on launch', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), launchResult: IOSCoreDeviceLaunchResult.fromJson(const { - 'info': {'outcome': 'failed'}, + 'info': {'outcome': 'success'}, 'result': { 'process': {'processIdentifier': 123}, }, }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], + launchSuccess: false, ); final processManager = FakeProcessManager.any(); @@ -265,38 +361,23 @@ void main() { expect(fakeLLDB.attemptedToAttach, isFalse); }); - testWithoutContext('fails on null launch result', () async { - final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); - - final processManager = FakeProcessManager.any(); - final logger = BufferLogger.test(); - final processUtils = ProcessUtils(processManager: processManager, logger: logger); - final fakeLLDB = FakeLLDB(); - final launcher = IOSCoreDeviceLauncher( - coreDeviceControl: fakeCoreDeviceControl, - logger: logger, - xcodeDebug: FakeXcodeDebug(), - fileSystem: MemoryFileSystem.test(), - processUtils: processUtils, - lldb: fakeLLDB, - ); - - final bool result = await launcher.launchAppWithLLDBDebugger( - deviceId: 'device-id', - bundlePath: 'bundle-path', - bundleId: 'bundle-id', - launchArguments: [], - ); - - expect(result, isFalse); - expect(fakeLLDB.attemptedToAttach, isFalse); - }); - - testWithoutContext('fails on null launched process', () async { + testWithoutContext('fails on missing launched process', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), launchResult: IOSCoreDeviceLaunchResult.fromJson(const { 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, }), + runningProcesses: [], ); final processManager = FakeProcessManager.any(); @@ -325,10 +406,23 @@ void main() { testWithoutContext('fails on null launched process id', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), launchResult: IOSCoreDeviceLaunchResult.fromJson(const { 'info': {'outcome': 'success'}, - 'result': {'process': {}}, + 'result': { + 'process': {'processIdentifier': 123}, + }, }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const {'executable': '/asdf'}), + ], ); final processManager = FakeProcessManager.any(); @@ -357,14 +451,27 @@ void main() { testWithoutContext('fails on lldb attach', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), launchResult: IOSCoreDeviceLaunchResult.fromJson(const { 'info': {'outcome': 'success'}, 'result': { 'process': {'processIdentifier': 123}, }, }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], ); - final processManager = FakeProcessManager.any(); final logger = BufferLogger.test(); final processUtils = ProcessUtils(processManager: processManager, logger: logger); @@ -637,6 +744,49 @@ void main() { }); }); + group('IOSCoreDeviceLogForwarder', () { + testWithoutContext('addLog', () async { + const expectedLog = 'hello world'; + final expectedLogCompleter = Completer(); + final logForwarder = IOSCoreDeviceLogForwarder(); + logForwarder.logLines.listen((String line) { + expect(line, expectedLog); + expectedLogCompleter.complete(); + }); + logForwarder.addLog(expectedLog); + await expectedLogCompleter.future; + }); + + testWithoutContext('exit', () async { + final exitCompleter = Completer(); + final logForwarder = IOSCoreDeviceLogForwarder(); + final lldbProcess = FakeProcess(); + logForwarder.launchProcess = lldbProcess; + logForwarder.logLines.listen((String line) => line).onDone(() { + exitCompleter.complete(); + }); + await logForwarder.exit(); + await exitCompleter.future; + expect(logForwarder.isRunning, isFalse); + expect(lldbProcess.signals, contains(io.ProcessSignal.sigterm)); + }); + + testWithoutContext('addLog after exit', () async { + final exitCompleter = Completer(); + final logForwarder = IOSCoreDeviceLogForwarder(); + final lldbProcess = FakeProcess(); + logForwarder.launchProcess = lldbProcess; + logForwarder.logLines.listen((String line) => line).onDone(() { + exitCompleter.complete(); + }); + await logForwarder.exit(); + await exitCompleter.future; + expect(logForwarder.isRunning, isFalse); + expect(lldbProcess.signals, contains(io.ProcessSignal.sigterm)); + logForwarder.addLog('hello world'); + }); + }); + group('Xcode prior to Core Device Control/Xcode 15', () { late BufferLogger logger; late FakeProcessManager fakeProcessManager; @@ -671,13 +821,14 @@ void main() { }); testWithoutContext('fails to install app', () async { - final bool status = await deviceControl.installApp( + final (bool status, IOSCoreDeviceInstallResult? result) = await deviceControl.installApp( deviceId: 'device-id', bundlePath: '/path/to/bundle', ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl is not installed.')); + expect(logger.traceText, contains('devicectl is not installed.')); expect(status, isFalse); + expect(result, isNull); }); testWithoutContext('fails to launch app', () async { @@ -788,7 +939,7 @@ void main() { ), ); - final bool status = await deviceControl.installApp( + final (bool status, IOSCoreDeviceInstallResult? result) = await deviceControl.installApp( deviceId: deviceId, bundlePath: bundlePath, ); @@ -872,14 +1023,14 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo ), ); - final bool status = await deviceControl.installApp( + final (bool status, IOSCoreDeviceInstallResult? result) = await deviceControl.installApp( deviceId: deviceId, bundlePath: bundlePath, ); expect(fakeProcessManager, hasNoRemainingExpectations); expect( - logger.errorText, + logger.traceText, contains('ERROR: Could not obtain access to one or more requested file system'), ); expect(tempFile, isNot(exists)); @@ -916,13 +1067,13 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo ), ); - final bool status = await deviceControl.installApp( + final (bool status, IOSCoreDeviceInstallResult? result) = await deviceControl.installApp( deviceId: deviceId, bundlePath: bundlePath, ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(logger.traceText, contains('devicectl returned unexpected JSON response')); expect(tempFile, isNot(exists)); expect(status, false); }); @@ -955,13 +1106,13 @@ invalid JSON ), ); - final bool status = await deviceControl.installApp( + final (bool status, IOSCoreDeviceInstallResult? result) = await deviceControl.installApp( deviceId: deviceId, bundlePath: bundlePath, ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(logger.traceText, contains('devicectl returned non-JSON response')); expect(tempFile, isNot(exists)); expect(status, false); }); @@ -1207,7 +1358,7 @@ invalid JSON }); }); - group('launch app', () { + group('launchApp', () { const deviceId = 'device-id'; const bundleId = 'com.example.flutterApp'; @@ -1276,9 +1427,9 @@ invalid JSON 'launch', '--device', deviceId, - bundleId, '--json-output', tempFile.path, + bundleId, ], onRun: (_) { expect(tempFile, exists); @@ -1366,11 +1517,11 @@ invalid JSON 'launch', '--device', deviceId, + '--json-output', + tempFile.path, bundleId, '--arg1', '--arg2', - '--json-output', - tempFile.path, ], onRun: (_) { expect(tempFile, exists); @@ -1392,7 +1543,7 @@ invalid JSON expect(result!.outcome, 'success'); }); - testWithoutContext('devicectl fails install with an error', () async { + testWithoutContext('devicectl fails launch with an error', () async { const deviceControlOutput = ''' { "error" : { @@ -1441,9 +1592,9 @@ invalid JSON 'launch', '--device', deviceId, - bundleId, '--json-output', tempFile.path, + bundleId, ], onRun: (_) { expect(tempFile, exists); @@ -1469,7 +1620,7 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus expect(result, isNull); }); - testWithoutContext('devicectl fails install without an error', () async { + testWithoutContext('devicectl fails launch without an error', () async { const deviceControlOutput = ''' { "error" : { @@ -1518,9 +1669,9 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus 'launch', '--device', deviceId, - bundleId, '--json-output', tempFile.path, + bundleId, ], onRun: (_) { expect(tempFile, exists); @@ -1559,9 +1710,9 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus 'launch', '--device', deviceId, - bundleId, '--json-output', tempFile.path, + bundleId, ], onRun: (_) { expect(tempFile, exists); @@ -1598,9 +1749,9 @@ invalid JSON 'launch', '--device', deviceId, - bundleId, '--json-output', tempFile.path, + bundleId, ], onRun: (_) { expect(tempFile, exists); @@ -1621,6 +1772,191 @@ invalid JSON }); }); + group('launchAppAndStreamLogs', () { + const deviceId = 'device-id'; + const bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful launch without launch args', () async { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--start-stopped', + '--console', + '--environment-variables', + '{"OS_ACTIVITY_DT_MODE": "enable"}', + bundleId, + ], + stdout: ''' +10:04:12 Acquired tunnel connection to device. +10:04:12 Enabling developer disk image services. +10:04:12 Acquired usage assertion. +Launched application with com.example.my_app bundle identifier. +Waiting for the application to terminate... +''', + ), + ); + + final bool result = await deviceControl.launchAppAndStreamLogs( + deviceId: deviceId, + bundleId: bundleId, + coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), + startStopped: true, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(result, isTrue); + }); + + testWithoutContext('Successful launch with launch args', () async { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--start-stopped', + '--console', + '--environment-variables', + '{"OS_ACTIVITY_DT_MODE": "enable"}', + bundleId, + '--arg1', + '--arg2', + ], + stdout: ''' +10:04:12 Acquired tunnel connection to device. +10:04:12 Enabling developer disk image services. +10:04:12 Acquired usage assertion. +Launched application with com.example.my_app bundle identifier. +Waiting for the application to terminate... +''', + ), + ); + + final bool result = await deviceControl.launchAppAndStreamLogs( + deviceId: deviceId, + bundleId: bundleId, + coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), + startStopped: true, + launchArguments: ['--arg1', '--arg2'], + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(result, isTrue); + }); + + testWithoutContext('Successful stream logs', () async { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--start-stopped', + '--console', + '--environment-variables', + '{"OS_ACTIVITY_DT_MODE": "enable"}', + bundleId, + ], + stdout: ''' +10:04:12 Acquired tunnel connection to device. +10:04:12 Enabling developer disk image services. +10:04:12 Acquired usage assertion. +This log happens before the application is launched and should not be sent to FakeIOSCoreDeviceLogForwarder +Launched application with com.example.my_app bundle identifier. +Waiting for the application to terminate... +[PreviewsAgentExecutorLibrary] This log happens after the application is launched but matches an ignore pattern and should be skipped +This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder +''', + ), + ); + final logForwarder = FakeIOSCoreDeviceLogForwarder(); + final bool result = await deviceControl.launchAppAndStreamLogs( + deviceId: deviceId, + bundleId: bundleId, + coreDeviceLogForwarder: logForwarder, + startStopped: true, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(logForwarder.logs.length, 1); + expect( + logForwarder.logs, + contains( + 'This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder', + ), + ); + expect( + logger.traceText, + contains(''' +10:04:12 Acquired tunnel connection to device. +10:04:12 Enabling developer disk image services. +10:04:12 Acquired usage assertion. +This log happens before the application is launched and should not be sent to FakeIOSCoreDeviceLogForwarder +Launched application with com.example.my_app bundle identifier. +Waiting for the application to terminate... +[PreviewsAgentExecutorLibrary] This log happens after the application is launched but matches an ignore pattern and should be skipped +'''), + ); + expect(result, isTrue); + }); + + testWithoutContext('devicectl fails launch with an error', () async { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--start-stopped', + '--console', + '--environment-variables', + '{"OS_ACTIVITY_DT_MODE": "enable"}', + bundleId, + ], + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''', + ), + ); + + final bool result = await deviceControl.launchAppAndStreamLogs( + deviceId: deviceId, + bundleId: bundleId, + coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), + startStopped: true, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(result, isFalse); + }); + }); + group('terminate app', () { const deviceId = 'device-id'; const processId = 1234; @@ -3166,25 +3502,265 @@ invalid JSON }); }); }); + + group('list running processes', () { + const deviceId = 'device-id'; + + testWithoutContext('All sections parsed', () async { + const deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "processes", + "--device", + "00008112-0006112A3C03401E", + "--json-output", + "./process.json" + ], + "commandType" : "devicectl.device.info.processes", + "environment" : { + "TERM" : "xterm-256color" + }, + "jsonVersion" : 2, + "outcome" : "success", + "version" : "477.29" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "runningProcesses" : [ + { + "executable" : "file:///sbin/launchd", + "processIdentifier" : 1 + }, + { + "processIdentifier" : 961 + }, + { + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1050 + } + ] + } +} + +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_process_list.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final List processes = await deviceControl.getRunningProcesses( + deviceId: deviceId, + ); + expect(processes.length, 3); + + expect(processes[0].processIdentifier, isNotNull); + expect(processes[0].executable, isNotNull); + expect(processes[1].processIdentifier, isNotNull); + expect(processes[1].executable, isNull); + expect(processes[2].processIdentifier, 1050); + expect( + processes[2].executable, + 'file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner', + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('fails because of unexpected JSON', () async { + const deviceControlOutput = ''' +{"valid": "but wrong"} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_process_list.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final List processes = await deviceControl.getRunningProcesses( + deviceId: deviceId, + ); + expect(processes.length, 0); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(logger.traceText, contains('devicectl returned unexpected JSON response')); + }); + + testWithoutContext('fails because of invalid JSON', () async { + const deviceControlOutput = ''' +invalid JSON +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_process_list.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final List processes = await deviceControl.getRunningProcesses( + deviceId: deviceId, + ); + expect(processes.length, 0); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(logger.traceText, contains('devicectl returned non-JSON response')); + }); + + testWithoutContext('fails when devicectl fails', () async { + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_process_list.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + tempFile.path, + ], + stderr: 'something went wrong', + exitCode: 1, + ), + ); + + final List processes = await deviceControl.getRunningProcesses( + deviceId: deviceId, + ); + expect(processes.length, 0); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(logger.traceText, contains('something went wrong')); + }); + + testWithoutContext('fails when missing output', () async { + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_process_list.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + deviceId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + tempFile.deleteSync(); + }, + ), + ); + + final List processes = await deviceControl.getRunningProcesses( + deviceId: deviceId, + ); + expect(processes.length, 0); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(logger.traceText, contains('Error reading output file')); + }); + }); }); } class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { FakeIOSCoreDeviceControl({ this.installSuccess = true, + this.installResult, + this.launchSuccess = true, this.launchResult, this.terminateSuccess = true, + this.runningProcesses = const [], }); bool installSuccess; IOSCoreDeviceLaunchResult? launchResult; + bool launchSuccess; + IOSCoreDeviceInstallResult? installResult; bool terminateSuccess; int? processTerminated; + List runningProcesses; bool get terminateProcessCalled => processTerminated != null; @override - Future installApp({required String deviceId, required String bundlePath}) async { - return installSuccess; + Future<(bool, IOSCoreDeviceInstallResult?)> installApp({ + required String deviceId, + required String bundlePath, + }) async { + if (installResult != null) { + return (installSuccess, installResult); + } + final result = IOSCoreDeviceInstallResult.fromJson({ + 'info': {'outcome': installSuccess ? 'success' : 'failure'}, + }); + return (installSuccess, result); } @override @@ -3197,11 +3773,27 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { return launchResult; } + @override + Future launchAppAndStreamLogs({ + required IOSCoreDeviceLogForwarder coreDeviceLogForwarder, + required String deviceId, + required String bundleId, + List launchArguments = const [], + bool startStopped = false, + }) async { + return launchSuccess; + } + @override Future terminateProcess({required String deviceId, required int processId}) async { processTerminated = processId; return terminateSuccess; } + + @override + Future> getRunningProcesses({required String deviceId}) async { + return runningProcesses; + } } class FakeXcodeDebug extends Fake implements XcodeDebug { @@ -3289,7 +3881,11 @@ class FakeLLDB extends Fake implements LLDB { } @override - Future attachAndStart(String deviceId, int processId) async { + Future attachAndStart({ + required String deviceId, + required int appProcessId, + required LLDBLogForwarder lldbLogForwarder, + }) async { attemptedToAttach = true; return attachSuccess; } @@ -3367,3 +3963,21 @@ class FakeIosProject extends Fake implements IosProject { } class FakeTemplateRenderer extends Fake implements TemplateRenderer {} + +class FakeIOSCoreDeviceLogForwarder extends Fake implements IOSCoreDeviceLogForwarder { + List logs = []; + @override + Process? launchProcess; + + @override + bool get isRunning => false; + @override + Future exit() async { + return true; + } + + @override + void addLog(String log) { + logs.add(log); + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index f09b38040af..f4537307c45 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -27,6 +27,7 @@ import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:test/fake.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -520,6 +521,7 @@ void main() { device: device, app: appPackage, iMobileDevice: IMobileDevice.test(processManager: FakeProcessManager.any()), + xcode: FakeXcode(), ); logReader.idevicesyslogProcess = process; return logReader; @@ -1131,3 +1133,5 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} class FakeAnalytics extends Fake implements Analytics {} + +class FakeXcode extends Fake implements Xcode {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart index 9d62ad5307e..6f92b939603 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart @@ -398,8 +398,14 @@ class FakeXcodeDebug extends Fake implements XcodeDebug {} class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { @override - Future installApp({required String deviceId, required String bundlePath}) async { - return true; + Future<(bool, IOSCoreDeviceInstallResult?)> installApp({ + required String deviceId, + required String bundlePath, + }) async { + final result = IOSCoreDeviceInstallResult.fromJson({ + 'info': {'outcome': 'success'}, + }); + return (true, result); } @override diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart index 2bfb858f62a..c79c30f75fe 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart @@ -7,12 +7,16 @@ import 'dart:async'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/async_guard.dart'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/version.dart' as base; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; +import 'package:flutter_tools/src/ios/lldb.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:flutter_tools/src/vmservice.dart'; import 'package:test/fake.dart'; import 'package:vm_service/vm_service.dart'; @@ -74,6 +78,7 @@ Runner(UIKit)[297] : E is for enpitsu" cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); final List lines = await logReader.logLines.toList(); @@ -103,6 +108,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); final List lines = await logReader.logLines.toList(); @@ -136,6 +142,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); final List lines = await logReader.logLines.toList(); @@ -186,6 +193,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); await logReader.provideVmService(vmService); @@ -238,6 +246,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); await logReader.provideVmService(vmService); @@ -275,6 +284,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), useSyslog: false, ); final iosDeployDebugger = FakeIOSDeployDebugger(); @@ -299,6 +309,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), useSyslog: false, ); final streamComplete = Completer(); @@ -318,6 +329,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), useSyslog: false, ); final iosDeployDebugger = FakeIOSDeployDebugger(); @@ -342,6 +354,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), useSyslog: false, ); Object? exception; @@ -372,6 +385,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 17, isCoreDevice: true, ); @@ -379,6 +393,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isTrue); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.idevicesyslog); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }); @@ -391,6 +406,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 17, isCoreDevice: true, isWirelesslyConnected: true, @@ -399,6 +415,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isFalse); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.unifiedLogging); expect(logReader.logSources.fallbackSource, isNull); }); @@ -411,12 +428,14 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 12, ); expect(logReader.useSyslogLogging, isTrue); expect(logReader.useUnifiedLogging, isFalse); expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.idevicesyslog); expect(logReader.logSources.fallbackSource, isNull); }); @@ -431,12 +450,14 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 13, ); expect(logReader.useSyslogLogging, isFalse); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }, @@ -452,6 +473,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 13, ); @@ -477,6 +499,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isFalse); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.unifiedLogging); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.iosDeploy); }, @@ -492,6 +515,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 13, ); @@ -521,6 +545,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isFalse); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }, @@ -534,6 +559,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 16, ); @@ -544,6 +570,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isFalse); expect(logReader.useUnifiedLogging, isTrue); expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }); @@ -556,6 +583,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -563,10 +591,32 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt expect(logReader.useSyslogLogging, isTrue); expect(logReader.useUnifiedLogging, isFalse); expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useCoreDeviceLogging, isFalse); expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); }); + testWithoutContext('for CoreDevice and Xcode 26', () { + final logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + xcode: FakeXcode(version: base.Version(26, 0, 0)), + majorSdkVersion: 17, + isCoreDevice: true, + ); + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.useCoreDeviceLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.devicectlAndLldb); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); + }); + group('when useSyslogLogging', () { testWithoutContext('is true syslog sends flutter messages to stream', () async { processManager.addCommand( @@ -588,6 +638,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -609,6 +660,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 16, ); @@ -633,6 +685,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 16, ); @@ -658,6 +711,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 12, ); @@ -714,6 +768,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), ); await logReader.provideVmService(vmService); @@ -765,6 +820,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 12, ); await logReader.provideVmService(vmService); @@ -778,6 +834,66 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt }); }); + group('when useCoreDeviceLogging', () { + testWithoutContext('is true devicectl and lldb sends messages to stream', () async { + final logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + xcode: FakeXcode(version: base.Version(26, 0, 0)), + majorSdkVersion: 17, + isCoreDevice: true, + ); + + final coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); + await logReader.listenToCoreDeviceLauncher(coreDeviceLauncher); + + const deviceCtlLog = 'A log from devicectl\n'; + const lldbLog = 'A log from LLDB\n'; + + coreDeviceLauncher.coreDeviceLogForwarder.addLog(deviceCtlLog); + coreDeviceLauncher.lldbLogForwarder.addLog(lldbLog); + + expect(logReader.useCoreDeviceLogging, isTrue); + await expectLater( + logReader.logLines, + emitsInAnyOrder([equals(deviceCtlLog), equals(lldbLog)]), + ); + }); + + testWithoutContext('is false devicectl and lldb do not sends messages to stream', () async { + final logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + xcode: FakeXcode(version: base.Version(16, 0, 0)), + majorSdkVersion: 17, + isCoreDevice: true, + ); + + final coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); + await logReader.listenToCoreDeviceLauncher(coreDeviceLauncher); + + const deviceCtlLog = 'A log from devicectl\n'; + const lldbLog = 'A log from LLDB\n'; + + coreDeviceLauncher.coreDeviceLogForwarder.addLog(deviceCtlLog); + coreDeviceLauncher.lldbLogForwarder.addLog(lldbLog); + + expect(logReader.useCoreDeviceLogging, isFalse); + await expectLater(logReader.logLines, neverEmits(deviceCtlLog)); + + expect(logReader.useCoreDeviceLogging, isFalse); + await expectLater(logReader.logLines, neverEmits(lldbLog)); + }); + }); + group('and when to exclude logs:', () { testWithoutContext( 'all primary messages are included except if fallback sent flutter message first', @@ -789,6 +905,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -838,6 +955,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), majorSdkVersion: 12, ); @@ -885,6 +1003,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -948,6 +1067,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -1006,6 +1126,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), + xcode: FakeXcode(), usingCISystem: true, majorSdkVersion: 16, ); @@ -1046,3 +1167,21 @@ class FakeIOSDeployDebugger extends Fake implements IOSDeployDebugger { detached = true; } } + +@override +class FakeXcode extends Fake implements Xcode { + FakeXcode({this.version}); + + base.Version? version; + + @override + base.Version? get currentVersion => version ?? base.Version(16, 0, 0); +} + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { + @override + final coreDeviceLogForwarder = IOSCoreDeviceLogForwarder(); + + @override + final lldbLogForwarder = LLDBLogForwarder(); +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index 9e7a498510c..a5e2591b45d 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -1375,8 +1375,14 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { List? get argumentsUsedForLaunch => _launchArguments; @override - Future installApp({required String deviceId, required String bundlePath}) async { - return installSuccess; + Future<(bool, IOSCoreDeviceInstallResult?)> installApp({ + required String deviceId, + required String bundlePath, + }) async { + final result = IOSCoreDeviceInstallResult.fromJson({ + 'info': {'outcome': installSuccess ? 'success' : 'failure'}, + }); + return (installSuccess, result); } @override diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 8404fa2c987..17c01753f9f 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -22,6 +22,7 @@ import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; +import 'package:flutter_tools/src/ios/lldb.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; @@ -176,7 +177,7 @@ void main() { }, ); - testWithoutContext( + testUsingContext( 'IOSDevice.startApp twice in a row where ios-deploy fails the first time', () async { final logger = BufferLogger.test(); @@ -428,7 +429,7 @@ void main() { overrides: {MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery()}, ); - testWithoutContext( + testUsingContext( 'IOSDevice.startApp retries when ios-deploy loses connection the first time in CI', () async { final logger = BufferLogger.test(); @@ -489,7 +490,7 @@ void main() { }, ); - testWithoutContext( + testUsingContext( 'IOSDevice.startApp does not retry when ios-deploy loses connection if not in CI', () async { final logger = BufferLogger.test(); @@ -861,15 +862,21 @@ void main() { uncompressedBundle: bundleLocation, applicationPackage: bundleLocation, ); - final deviceLogReader = FakeDeviceLogReader(); + final DeviceLogReader deviceLogReader = IOSDeviceLogReader.test( + iMobileDevice: FakeIMobileDevice(), + xcode: FakeXcode(currentVersion: Version(26, 0, 0)), + isCoreDevice: true, + ); device.portForwarder = const NoOpDevicePortForwarder(); device.setLogReader(iosApp, deviceLogReader); // Start writing messages to the log reader. Timer.run(() { - deviceLogReader.addLine('Foo'); - deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + fakeLauncher.coreDeviceLogForwarder.addLog('Foo'); + fakeLauncher.coreDeviceLogForwarder.addLog( + 'The Dart VM service is listening on http://127.0.0.1:456', + ); }); final LaunchResult launchResult = await device.startApp( @@ -880,6 +887,7 @@ void main() { ); expect(launchResult.started, true); + expect(launchResult.hasVmService, true); expect(fakeLauncher.launchedWithLLDB, true); expect(fakeLauncher.launchedWithXcode, false); expect(fakeAnalytics.sentEvents, [ @@ -1702,6 +1710,12 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { Completer? xcodeCompleter; + @override + final coreDeviceLogForwarder = IOSCoreDeviceLogForwarder(); + + @override + final lldbLogForwarder = LLDBLogForwarder(); + @override Future launchAppWithLLDBDebugger({ required String deviceId, @@ -1744,3 +1758,5 @@ class FakeAnalytics extends Fake implements Analytics { sentEvents.add(event); } } + +class FakeIMobileDevice extends Fake implements IMobileDevice {} diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart index 3756f58d6de..562f2c03a39 100644 --- a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -19,7 +19,7 @@ import '../../src/fake_process_manager.dart'; void main() { testWithoutContext('attachAndStart fails if lldb fails', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; final processCompleter = Completer(); final lldbCommand = FakeLLDBCommand( @@ -38,7 +38,11 @@ void main() { final processUtils = ProcessUtils(processManager: processManager, logger: logger); final lldb = LLDB(logger: logger, processUtils: processUtils); - final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ); expect(success, isFalse); expect(lldb.isRunning, isFalse); expect(lldb.appProcessId, isNull); @@ -48,7 +52,7 @@ void main() { testWithoutContext('attachAndStart returns true on success', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; const breakpointId = 123; final breakPointCompleter = Completer>(); @@ -79,7 +83,7 @@ void main() { final lldb = LLDB(logger: logger, processUtils: processUtils); const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - const processAttachMatcher = 'device process attach --pid $appappProcessId'; + const processAttachMatcher = 'device process attach --pid $appProcessId'; const processResumedMatcher = 'process continue'; final expectedInputs = [ 'device select $deviceId', @@ -114,14 +118,18 @@ Target 0: (Runner) stopped. ); } if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process $appappProcessId resuming\n')); + processResumedCompleted.complete(utf8.encode('Process $appProcessId resuming\n')); } }); - final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ); expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appappProcessId); + expect(lldb.appProcessId, appProcessId); expect(expectedInputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); @@ -129,7 +137,7 @@ Target 0: (Runner) stopped. testWithoutContext('attachAndStart returns false when stderr during log waiter', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -168,7 +176,11 @@ Target 0: (Runner) stopped. } }); - final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ); expect(success, isFalse); expect(lldb.isRunning, isFalse); expect(lldb.appProcessId, isNull); @@ -179,7 +191,7 @@ Target 0: (Runner) stopped. testWithoutContext('attachAndStart returns false when stderr not during log waiter', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -214,7 +226,11 @@ Target 0: (Runner) stopped. errorCompleter.complete(utf8.encode(errorText)); }); - final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ); expect(success, isFalse); expect(lldb.isRunning, isFalse); expect(lldb.appProcessId, isNull); @@ -225,7 +241,7 @@ Target 0: (Runner) stopped. testWithoutContext('attachAndStart prints warning if takes too long', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; final stdinController = StreamController>(); @@ -255,7 +271,11 @@ Target 0: (Runner) stopped. }); await FakeAsync().run((FakeAsync time) { - lldb.attachAndStart(deviceId, appappProcessId); + lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ); time.elapse(const Duration(minutes: 2)); time.flushMicrotasks(); return completer.future; @@ -267,9 +287,106 @@ Target 0: (Runner) stopped. ); }); + testWithoutContext('attachAndStart streams logs to LLDBLogForwarder', () async { + const deviceId = '123'; + const appProcessId = 5678; + const breakpointId = 123; + + final breakPointCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final processResumedCompleted = Completer>(); + final logAfterAttachCompleter = Completer>(); + + final stdoutStream = Stream>.fromFutures([ + breakPointCompleter.future, + processAttachCompleter.future, + processResumedCompleted.future, + logAfterAttachCompleter.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + const processAttachMatcher = 'device process attach --pid $appProcessId'; + const processResumedMatcher = 'process continue'; + final expectedInputs = [ + 'device select $deviceId', + breakPointMatcher, + 'breakpoint command add --script-type python $breakpointId', + processAttachMatcher, + processResumedMatcher, + ]; + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + expectedInputs.remove(line); + if (line == breakPointMatcher) { + breakPointCompleter.complete( + utf8.encode('Breakpoint $breakpointId: no locations (pending).\n'), + ); + } + if (line == processAttachMatcher) { + processAttachCompleter.complete( + utf8.encode(''' +Process 568 stopped +* thread #1, stop reason = signal SIGSTOP + frame #0: 0x0000000102c7b240 dyld`_dyld_start +dyld`_dyld_start: +-> 0x102c7b240 <+0>: mov x0, sp + 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 + 0x102c7b248 <+8>: mov x29, #0x0 ; =0 + 0x102c7b24c <+12>: mov x30, #0x0 ; =0 +Target 0: (Runner) stopped. +'''), + ); + } + if (line == processResumedMatcher) { + processResumedCompleted.complete(utf8.encode('Process $appProcessId resuming\n')); + } + }); + + const ignoreLog = '1 location added to breakpoint 1'; + const expectedForwardedLog = 'Some random log from LLDB'; + final lldbLogForwarder = FakeLLDBLogForwarder(expectedLog: expectedForwardedLog); + + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: lldbLogForwarder, + ); + + logAfterAttachCompleter.complete(utf8.encode('$ignoreLog\n$expectedForwardedLog\n')); + await lldbLogForwarder.expectedLogCompleter.future; + + expect(success, isTrue); + expect(lldb.isRunning, isTrue); + expect(lldb.appProcessId, appProcessId); + expect(expectedInputs, isEmpty); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, isEmpty); + expect(lldbLogForwarder.logs.length, 1); + expect(lldbLogForwarder.logs, contains(expectedForwardedLog)); + }); + testWithoutContext('exit returns true and kills process', () async { const deviceId = '123'; - const appappProcessId = 5678; + const appProcessId = 5678; final stdinController = StreamController>(); @@ -298,9 +415,16 @@ Target 0: (Runner) stopped. } }); - unawaited(lldb.attachAndStart(deviceId, appappProcessId)); + unawaited( + lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + ), + ); await lldbStarted.future; + expect(lldb.isRunning, isTrue); final bool exitStatus = lldb.exit(); expect(exitStatus, isTrue); expect(lldb.isRunning, isFalse); @@ -320,6 +444,41 @@ Target 0: (Runner) stopped. expect(lldb.isRunning, isFalse); expect(lldb.appProcessId, isNull); }); + + group('LLDBLogForwarder', () { + testWithoutContext('addLog', () async { + const expectedLog = 'hello world'; + final expectedLogCompleter = Completer(); + final lldbLogForwarder = LLDBLogForwarder(); + lldbLogForwarder.logLines.listen((String line) { + expect(line, expectedLog); + expectedLogCompleter.complete(); + }); + lldbLogForwarder.addLog(expectedLog); + await expectedLogCompleter.future; + }); + + testWithoutContext('exit', () async { + final exitCompleter = Completer(); + final lldbLogForwarder = LLDBLogForwarder(); + lldbLogForwarder.logLines.listen((String line) => line).onDone(() { + exitCompleter.complete(); + }); + await lldbLogForwarder.exit(); + await exitCompleter.future; + }); + + testWithoutContext('addLog after exit', () async { + final exitCompleter = Completer(); + final lldbLogForwarder = LLDBLogForwarder(); + lldbLogForwarder.logLines.listen((String line) => line).onDone(() { + exitCompleter.complete(); + }); + await lldbLogForwarder.exit(); + await exitCompleter.future; + lldbLogForwarder.addLog('hello world'); + }); + }); } class FakeLLDBProcessManager extends Fake implements ProcessManager { @@ -500,3 +659,21 @@ class FakeLLDBCommand { expect(command, matchers); } } + +class FakeLLDBLogForwarder extends Fake implements LLDBLogForwarder { + FakeLLDBLogForwarder({this.expectedLog}); + + final expectedLogCompleter = Completer(); + + final String? expectedLog; + + final logs = []; + + @override + void addLog(String log) { + logs.add(log); + if (log == expectedLog) { + expectedLogCompleter.complete(); + } + } +}