Stream logs from devicectl and lldb (#173724)

When debugging with `devicectl` and `lldb` (exclusive to Xcode 26+),
stream logs from both processes instead of using `idevicesyslog`, which
does not work with Xcode 26.

Fixes https://github.com/flutter/flutter/issues/173365.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Victoria Ashworth 2025-08-25 14:09:21 -05:00 committed by GitHub
parent b7e2e2a69d
commit 91c2300a78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1575 additions and 134 deletions

View File

@ -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<bool> launchAppWithoutDebugger({
@ -57,12 +65,10 @@ class IOSCoreDeviceLauncher {
required List<String> 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<String> 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<IOSCoreDeviceRunningProcess> 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<String>.broadcast();
Stream<String> 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<bool> 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 = <Pattern>[
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<bool> 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<String, Object?>)['info'];
if (decodeResult is Map<String, Object?> && decodeResult['outcome'] == 'success') {
return true;
final Object? decodedJson = json.decode(stringOutput);
if (decodedJson is Map<String, Object?>) {
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<String> _launchAppCommand({
required String deviceId,
required String bundleId,
List<String> launchArguments = const <String>[],
bool startStopped = false,
bool attachToConsole = false,
File? outputFile,
}) {
return <String>[
..._xcode.xcrunCommand(),
'devicectl',
'device',
'process',
'launch',
'--device',
deviceId,
if (startStopped) '--start-stopped',
if (attachToConsole) ...<String>[
'--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) ...<String>['--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 = <String>[
..._xcode.xcrunCommand(),
'devicectl',
'device',
'process',
'launch',
'--device',
deviceId,
if (startStopped) '--start-stopped',
bundleId,
if (launchArguments.isNotEmpty) ...launchArguments,
'--json-output',
output.path,
];
final List<String> 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<bool> launchAppAndStreamLogs({
required IOSCoreDeviceLogForwarder coreDeviceLogForwarder,
required String deviceId,
required String bundleId,
List<String> launchArguments = const <String>[],
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<bool>();
final List<String> 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<String> stdoutSubscription = launchProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(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<String> stderrSubscription = launchProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(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<bool> terminateProcess({required String deviceId, required int processId}) async {
if (!_xcode.isDevicectlInstalled) {
@ -644,6 +825,65 @@ class IOSCoreDeviceControl {
tempDirectory.deleteSync(recursive: true);
}
}
Future<List<Object?>> _listRunningProcesses({required String deviceId}) async {
if (!_xcode.isDevicectlInstalled) {
_logger.printTrace('devicectl is not installed.');
return <Object?>[];
}
final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.');
final File output = tempDirectory.childFile('core_device_process_list.json')..createSync();
final command = <String>[
..._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 <String, Object?>{
'result': <String, Object?>{'runningProcesses': final List<Object?> decodedProcesses},
}) {
return decodedProcesses;
}
_logger.printTrace('devicectl returned unexpected JSON response: $stringOutput');
return <Object?>[];
} on FormatException {
// We failed to parse the devicectl output, or it returned junk.
_logger.printTrace('devicectl returned non-JSON response: $stringOutput');
return <Object?>[];
}
} on ProcessException catch (err) {
_logger.printTrace('Error executing devicectl: $err');
return <Object?>[];
} on FileSystemException catch (err) {
_logger.printTrace('Error reading output file: $err');
return <Object?>[];
} finally {
tempDirectory.deleteSync(recursive: true);
}
}
Future<List<IOSCoreDeviceRunningProcess>> getRunningProcesses({required String deviceId}) async {
final List<Object?> processesData = await _listRunningProcesses(deviceId: deviceId);
return <IOSCoreDeviceRunningProcess>[
for (final Object? processObject in processesData)
if (processObject is Map<String, Object?>)
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 <uuid|ecid|udid|name> <path> --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<String, Object?> data) {
String? outcome;
final Object? info = data['info'];
if (info is Map<String, Object?>) {
outcome = info['outcome'] as String?;
}
final Map<String, Object?>? installedApp = switch (data['result']) {
{'installedApplications': [final Map<String, Object?> 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;
}

View File

@ -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<String> get logLines => linesController.stream;
final _coreDeviceLoggingSource = CoreDeviceLoggingSource();
Future<void> 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<void> _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 = <StreamSubscription<void>>[];
Future<void> listenToLogs(
void Function(String, IOSDeviceLogSource) onLogMessage,
StreamController<String> 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<void> 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].

View File

@ -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 = <Pattern>[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<bool> attachAndStart(String deviceId, int appProcessId) async {
///
/// After attaching and starting the app process, forwards logs to [lldbLogForwarder].
/// This may include crash logs.
Future<bool> 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<bool> _startLLDB(int appProcessId) async {
Future<bool> _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<String>(utf8.decoder)
.transform<String>(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<String> stderrSubscription = _lldbProcess!.stderr
.transform<String>(utf8.decoder)
.transform<String>(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<String>.broadcast();
Stream<String> get logLines => _streamController.stream;
void addLog(String log) {
if (!_streamController.isClosed) {
_streamController.add(log);
}
}
Future<bool> exit() async {
if (_streamController.hasListener) {
// Tell listeners the process died.
await _streamController.close();
}
return true;
}
}

View File

@ -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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{
'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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{
'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: <String>[],
);
expect(result, isFalse);
expect(fakeLLDB.attemptedToAttach, isFalse);
});
testWithoutContext('fails on missing installationURL', () async {
final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(
installResult: IOSCoreDeviceInstallResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'failure'},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{
'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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'failed'},
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{
'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: <String>[],
);
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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{'process': <String, Object?>{}},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{'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 <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'installedApplications': [
<String, Object?>{'installationURL': '/asdf'},
],
},
}),
launchResult: IOSCoreDeviceLaunchResult.fromJson(const <String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
'result': <String, Object?>{
'process': <String, Object?>{'processIdentifier': 123},
},
}),
runningProcesses: [
IOSCoreDeviceRunningProcess.fromJson(const <String, Object?>{
'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<void>();
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<void>();
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<void>();
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 couldnt be opened because it doesnt 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 couldnt be opened because it doesnt 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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'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: <String>[
'xcrun',
'devicectl',
'device',
'info',
'processes',
'--device',
deviceId,
'--json-output',
tempFile.path,
],
onRun: (_) {
expect(tempFile, exists);
tempFile.writeAsStringSync(deviceControlOutput);
},
),
);
final List<IOSCoreDeviceRunningProcess> 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: <String>[
'xcrun',
'devicectl',
'device',
'info',
'processes',
'--device',
deviceId,
'--json-output',
tempFile.path,
],
onRun: (_) {
expect(tempFile, exists);
tempFile.writeAsStringSync(deviceControlOutput);
},
),
);
final List<IOSCoreDeviceRunningProcess> 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: <String>[
'xcrun',
'devicectl',
'device',
'info',
'processes',
'--device',
deviceId,
'--json-output',
tempFile.path,
],
onRun: (_) {
expect(tempFile, exists);
tempFile.writeAsStringSync(deviceControlOutput);
},
),
);
final List<IOSCoreDeviceRunningProcess> 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: <String>[
'xcrun',
'devicectl',
'device',
'info',
'processes',
'--device',
deviceId,
'--json-output',
tempFile.path,
],
stderr: 'something went wrong',
exitCode: 1,
),
);
final List<IOSCoreDeviceRunningProcess> 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: <String>[
'xcrun',
'devicectl',
'device',
'info',
'processes',
'--device',
deviceId,
'--json-output',
tempFile.path,
],
onRun: (_) {
tempFile.deleteSync();
},
),
);
final List<IOSCoreDeviceRunningProcess> 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 <IOSCoreDeviceRunningProcess>[],
});
bool installSuccess;
IOSCoreDeviceLaunchResult? launchResult;
bool launchSuccess;
IOSCoreDeviceInstallResult? installResult;
bool terminateSuccess;
int? processTerminated;
List<IOSCoreDeviceRunningProcess> runningProcesses;
bool get terminateProcessCalled => processTerminated != null;
@override
Future<bool> 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(<String, Object?>{
'info': <String, Object?>{'outcome': installSuccess ? 'success' : 'failure'},
});
return (installSuccess, result);
}
@override
@ -3197,11 +3773,27 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {
return launchResult;
}
@override
Future<bool> launchAppAndStreamLogs({
required IOSCoreDeviceLogForwarder coreDeviceLogForwarder,
required String deviceId,
required String bundleId,
List<String> launchArguments = const <String>[],
bool startStopped = false,
}) async {
return launchSuccess;
}
@override
Future<bool> terminateProcess({required String deviceId, required int processId}) async {
processTerminated = processId;
return terminateSuccess;
}
@override
Future<List<IOSCoreDeviceRunningProcess>> 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<bool> attachAndStart(String deviceId, int processId) async {
Future<bool> 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<String> logs = [];
@override
Process? launchProcess;
@override
bool get isRunning => false;
@override
Future<bool> exit() async {
return true;
}
@override
void addLog(String log) {
logs.add(log);
}
}

View File

@ -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 {}

View File

@ -398,8 +398,14 @@ class FakeXcodeDebug extends Fake implements XcodeDebug {}
class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {
@override
Future<bool> 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(<String, Object?>{
'info': <String, Object?>{'outcome': 'success'},
});
return (true, result);
}
@override

View File

@ -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] <Notice>: E is for enpitsu"
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
final List<String> lines = await logReader.logLines.toList();
@ -103,6 +108,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
final List<String> lines = await logReader.logLines.toList();
@ -136,6 +142,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
final List<String> lines = await logReader.logLines.toList();
@ -186,6 +193,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
await logReader.provideVmService(vmService);
@ -238,6 +246,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
await logReader.provideVmService(vmService);
@ -275,6 +284,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
useSyslog: false,
);
final iosDeployDebugger = FakeIOSDeployDebugger();
@ -299,6 +309,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
useSyslog: false,
);
final streamComplete = Completer<void>();
@ -318,6 +329,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
useSyslog: false,
);
final iosDeployDebugger = FakeIOSDeployDebugger();
@ -342,6 +354,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
useSyslog: false,
);
Object? exception;
@ -372,6 +385,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 17,
isCoreDevice: true,
);
@ -379,6 +393,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 17,
isCoreDevice: true,
isWirelesslyConnected: true,
@ -399,6 +415,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: 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] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 13,
);
@ -477,6 +499,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 13,
);
@ -521,6 +545,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 16,
);
@ -544,6 +570,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
usingCISystem: true,
majorSdkVersion: 16,
);
@ -563,10 +591,32 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
usingCISystem: true,
majorSdkVersion: 16,
);
@ -609,6 +660,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 16,
);
@ -633,6 +685,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 16,
);
@ -658,6 +711,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 12,
);
@ -714,6 +768,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
);
await logReader.provideVmService(vmService);
@ -765,6 +820,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 12,
);
await logReader.provideVmService(vmService);
@ -778,6 +834,66 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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(<Matcher>[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] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
usingCISystem: true,
majorSdkVersion: 16,
);
@ -838,6 +955,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
majorSdkVersion: 12,
);
@ -885,6 +1003,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
usingCISystem: true,
majorSdkVersion: 16,
);
@ -948,6 +1067,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
cache: fakeCache,
logger: logger,
),
xcode: FakeXcode(),
usingCISystem: true,
majorSdkVersion: 16,
);
@ -1006,6 +1126,7 @@ Runner(libsystem_asl.dylib)[297] <Notice>: 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();
}

View File

@ -1375,8 +1375,14 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {
List<String>? get argumentsUsedForLaunch => _launchArguments;
@override
Future<bool> 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(<String, Object?>{
'info': <String, Object?>{'outcome': installSuccess ? 'success' : 'failure'},
});
return (installSuccess, result);
}
@override

View File

@ -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: <Type, Generator>{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<void>? xcodeCompleter;
@override
final coreDeviceLogForwarder = IOSCoreDeviceLogForwarder();
@override
final lldbLogForwarder = LLDBLogForwarder();
@override
Future<bool> launchAppWithLLDBDebugger({
required String deviceId,
@ -1744,3 +1758,5 @@ class FakeAnalytics extends Fake implements Analytics {
sentEvents.add(event);
}
}
class FakeIMobileDevice extends Fake implements IMobileDevice {}

View File

@ -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<void>();
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<List<int>>();
@ -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<List<int>>();
final errorCompleter = Completer<List<int>>();
@ -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<List<int>>();
final errorCompleter = Completer<List<int>>();
@ -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<List<int>>();
@ -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<List<int>>();
final processAttachCompleter = Completer<List<int>>();
final processResumedCompleted = Completer<List<int>>();
final logAfterAttachCompleter = Completer<List<int>>();
final stdoutStream = Stream<List<int>>.fromFutures([
breakPointCompleter.future,
processAttachCompleter.future,
processResumedCompleted.future,
logAfterAttachCompleter.future,
]);
final stdinController = StreamController<List<int>>();
final processCompleter = Completer<void>();
final lldbCommand = FakeLLDBCommand(
command: const <String>['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<String>(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<List<int>>();
@ -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<void>();
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<void>();
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<void>();
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<void>();
final String? expectedLog;
final logs = <String>[];
@override
void addLog(String log) {
logs.add(log);
if (log == expectedLog) {
expectedLogCompleter.complete();
}
}
}