diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index 42d8b34e8af..db8565bd98e 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -95,6 +95,7 @@ class IOSCoreDeviceLauncher { required String bundlePath, required String bundleId, required List launchArguments, + required ShutdownHooks shutdownHooks, }) async { // Install app to device final (bool installStatus, IOSCoreDeviceInstallResult? installResult) = await _coreDeviceControl @@ -111,6 +112,7 @@ class IOSCoreDeviceLauncher { bundleId: bundleId, launchArguments: launchArguments, startStopped: true, + shutdownHooks: shutdownHooks, ); if (!launchResult) { @@ -335,8 +337,6 @@ class IOSCoreDeviceControl { 'Failed to execute code (error: EXC_BAD_ACCESS, debugger assist: not detected)', ]; - static const kCoreDeviceLaunchCompleteLog = 'Waiting for the application to terminate'; - /// 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. @@ -653,22 +653,19 @@ class IOSCoreDeviceControl { /// If [attachToConsole] is true, attaches the application to the console and waits for the app /// to terminate. /// - /// When [jsonOutputFile] is provided, devicectl will write a JSON file with the command results - /// after the command has completed. This will not have the results when using [attachToConsole] - /// until the process has exited. - /// - /// When [logOutputFile] is provided, devicectl will write all logging otherwise passed to - /// stdout/stderr to the file. It will also continue to stream the logs to stdout/stderr. + /// If [interactiveMode] is true, runs the process in interactive mode (via script) to convince + /// devicectl it has a terminal attached in order to redirect stdout. List _launchAppCommand({ required String deviceId, required String bundleId, List launchArguments = const [], bool startStopped = false, bool attachToConsole = false, - File? jsonOutputFile, - File? logOutputFile, + File? outputFile, + bool interactiveMode = false, }) { return [ + if (interactiveMode) ...['script', '-t', '0', '/dev/null'], ..._xcode.xcrunCommand(), 'devicectl', 'device', @@ -684,8 +681,7 @@ class IOSCoreDeviceControl { // See https://github.com/llvm/llvm-project/blob/19b43e1757b4fd3d0f188cf8a08e9febb0dbec2f/lldb/source/Plugins/Platform/MacOSX/PlatformDarwin.cpp#L1227-L1233 '{"OS_ACTIVITY_DT_MODE": "enable"}', ], - if (jsonOutputFile != null) ...['--json-output', jsonOutputFile.path], - if (logOutputFile != null) ...['--log-output', logOutputFile.path], + if (outputFile != null) ...['--json-output', outputFile.path], bundleId, if (launchArguments.isNotEmpty) ...launchArguments, ]; @@ -714,7 +710,7 @@ class IOSCoreDeviceControl { deviceId: deviceId, launchArguments: launchArguments, startStopped: startStopped, - jsonOutputFile: output, + outputFile: output, ); try { @@ -751,6 +747,7 @@ class IOSCoreDeviceControl { required IOSCoreDeviceLogForwarder coreDeviceLogForwarder, required String deviceId, required String bundleId, + required ShutdownHooks shutdownHooks, List launchArguments = const [], bool startStopped = false, }) async { @@ -765,9 +762,6 @@ class IOSCoreDeviceControl { return false; } - final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); - final File output = tempDirectory.childFile('launch_log.txt')..createSync(); - final launchCompleter = Completer(); final List command = _launchAppCommand( bundleId: bundleId, @@ -775,9 +769,9 @@ class IOSCoreDeviceControl { launchArguments: launchArguments, startStopped: startStopped, attachToConsole: true, - logOutputFile: output, + interactiveMode: true, ); - Timer? timer; + try { final Process launchProcess = await _processUtils.start(command); coreDeviceLogForwarder.launchProcess = launchProcess; @@ -785,13 +779,16 @@ class IOSCoreDeviceControl { final StreamSubscription stdoutSubscription = launchProcess.stdout .transform(utf8LineDecoder) .listen((String line) { + if (line.trim().isEmpty) { + return; + } if (launchCompleter.isCompleted && !_ignoreLog(line)) { coreDeviceLogForwarder.addLog(line); } else { _logger.printTrace(line); } - if (!launchCompleter.isCompleted && line.contains(kCoreDeviceLaunchCompleteLog)) { + if (line.contains('Waiting for the application to terminate')) { launchCompleter.complete(true); } }); @@ -799,6 +796,9 @@ class IOSCoreDeviceControl { final StreamSubscription stderrSubscription = launchProcess.stderr .transform(utf8LineDecoder) .listen((String line) { + if (line.trim().isEmpty) { + return; + } if (launchCompleter.isCompleted && !_ignoreLog(line)) { coreDeviceLogForwarder.addLog(line); } else { @@ -821,26 +821,14 @@ class IOSCoreDeviceControl { }), ); - // Sometimes devicectl launch logs don't stream to stdout. - // As a workaround, we also use the log output file to check if it has finished launching. - timer = Timer.periodic(const Duration(seconds: 1), (timer) async { - if (await output.exists()) { - final String contents = await output.readAsString(); - if (!launchCompleter.isCompleted && contents.contains(kCoreDeviceLaunchCompleteLog)) { - launchCompleter.complete(true); - } - } - }); - - // Do not return the launchCompleter.future directly, otherwise, the timer will be canceled - // prematurely. - final bool status = await launchCompleter.future; - return status; + // devicectl is running in an interactive shell. + // Signal script child jobs to exit and exit the shell. + // See https://linux.die.net/Bash-Beginners-Guide/sect_12_01.html#sect_12_01_01_02. + shutdownHooks.addShutdownHook(() => launchProcess.kill()); + return launchCompleter.future; } on ProcessException catch (err) { _logger.printTrace('Error executing devicectl: $err'); return false; - } finally { - timer?.cancel(); } } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 69e2d51afc5..1d3ae6e775f 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -1055,6 +1055,7 @@ class IOSDevice extends Device { bundlePath: package.deviceBundlePath, bundleId: package.id, launchArguments: launchArguments, + shutdownHooks: globals.shutdownHooks, ); // If it succeeds to launch with LLDB, return, otherwise continue on to 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 78ff3c3c0e0..fa672cb320a 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 @@ -55,6 +55,8 @@ class LocalFileSystemFake extends Fake implements LocalFileSystem { var _disposed = false; } +final _interactiveModeArgs = ['script', '-t', '0', '/dev/null']; + void main() { late MemoryFileSystem fileSystem; @@ -214,6 +216,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isTrue); @@ -263,6 +266,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -306,6 +310,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -355,6 +360,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -398,6 +404,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -443,6 +450,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -490,6 +498,7 @@ void main() { bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], + shutdownHooks: FakeShutdownHooks(), ); expect(result, isFalse); @@ -1778,8 +1787,9 @@ invalid JSON testWithoutContext('Successful launch without launch args', () async { fakeProcessManager.addCommand( - const FakeCommand( + FakeCommand( command: [ + ..._interactiveModeArgs, 'xcrun', 'devicectl', 'device', @@ -1791,8 +1801,6 @@ invalid JSON '--console', '--environment-variables', '{"OS_ACTIVITY_DT_MODE": "enable"}', - '--log-output', - '/.tmp_rand0/core_devices.rand0/launch_log.txt', bundleId, ], stdout: ''' @@ -1805,22 +1813,26 @@ Waiting for the application to terminate... ), ); + final shutdownHooks = FakeShutdownHooks(); final bool result = await deviceControl.launchAppAndStreamLogs( deviceId: deviceId, bundleId: bundleId, coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), startStopped: true, + shutdownHooks: shutdownHooks, ); expect(fakeProcessManager, hasNoRemainingExpectations); + expect(shutdownHooks.registeredHooks.length, 1); expect(logger.errorText, isEmpty); expect(result, isTrue); }); testWithoutContext('Successful launch with launch args', () async { fakeProcessManager.addCommand( - const FakeCommand( + FakeCommand( command: [ + ..._interactiveModeArgs, 'xcrun', 'devicectl', 'device', @@ -1832,8 +1844,6 @@ Waiting for the application to terminate... '--console', '--environment-variables', '{"OS_ACTIVITY_DT_MODE": "enable"}', - '--log-output', - '/.tmp_rand0/core_devices.rand0/launch_log.txt', bundleId, '--arg1', '--arg2', @@ -1847,24 +1857,27 @@ Waiting for the application to terminate... ''', ), ); - + final shutdownHooks = FakeShutdownHooks(); final bool result = await deviceControl.launchAppAndStreamLogs( deviceId: deviceId, bundleId: bundleId, coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), startStopped: true, launchArguments: ['--arg1', '--arg2'], + shutdownHooks: shutdownHooks, ); expect(fakeProcessManager, hasNoRemainingExpectations); + expect(shutdownHooks.registeredHooks.length, 1); expect(logger.errorText, isEmpty); expect(result, isTrue); }); testWithoutContext('Successful stream logs', () async { fakeProcessManager.addCommand( - const FakeCommand( + FakeCommand( command: [ + ..._interactiveModeArgs, 'xcrun', 'devicectl', 'device', @@ -1876,8 +1889,6 @@ Waiting for the application to terminate... '--console', '--environment-variables', '{"OS_ACTIVITY_DT_MODE": "enable"}', - '--log-output', - '/.tmp_rand0/core_devices.rand0/launch_log.txt', bundleId, ], stdout: ''' @@ -1896,14 +1907,17 @@ This log happens after the application is launched and should be sent to FakeIOS ), ); final logForwarder = FakeIOSCoreDeviceLogForwarder(); + final shutdownHooks = FakeShutdownHooks(); final bool result = await deviceControl.launchAppAndStreamLogs( deviceId: deviceId, bundleId: bundleId, coreDeviceLogForwarder: logForwarder, startStopped: true, + shutdownHooks: shutdownHooks, ); expect(fakeProcessManager, hasNoRemainingExpectations); + expect(shutdownHooks.registeredHooks.length, 1); expect(logger.errorText, isEmpty); expect(logForwarder.logs.length, 3); expect( @@ -1932,8 +1946,9 @@ Waiting for the application to terminate... testWithoutContext('devicectl fails launch with an error', () async { fakeProcessManager.addCommand( - const FakeCommand( + FakeCommand( command: [ + ..._interactiveModeArgs, 'xcrun', 'devicectl', 'device', @@ -1945,8 +1960,6 @@ Waiting for the application to terminate... '--console', '--environment-variables', '{"OS_ACTIVITY_DT_MODE": "enable"}', - '--log-output', - '/.tmp_rand0/core_devices.rand0/launch_log.txt', bundleId, ], exitCode: 1, @@ -1957,66 +1970,20 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus ''', ), ); - + final shutdownHooks = FakeShutdownHooks(); final bool result = await deviceControl.launchAppAndStreamLogs( deviceId: deviceId, bundleId: bundleId, coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), startStopped: true, + shutdownHooks: shutdownHooks, ); expect(fakeProcessManager, hasNoRemainingExpectations); + expect(shutdownHooks.registeredHooks.length, 1); expect(logger.errorText, isEmpty); expect(result, isFalse); }); - - testWithoutContext('Successful launch with output in log file', () async { - final Completer launchCompleter = Completer(); - fakeProcessManager.addCommand( - FakeCommand( - command: const [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - '--start-stopped', - '--console', - '--environment-variables', - '{"OS_ACTIVITY_DT_MODE": "enable"}', - '--log-output', - '/.tmp_rand0/core_devices.rand0/launch_log.txt', - bundleId, - ], - onRun: (command) { - fileSystem.file('/.tmp_rand0/core_devices.rand0/launch_log.txt') - ..createSync(recursive: true) - ..writeAsStringSync(''' -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... -'''); - }, - completer: launchCompleter, - ), - ); - - final bool result = await deviceControl.launchAppAndStreamLogs( - deviceId: deviceId, - bundleId: bundleId, - coreDeviceLogForwarder: FakeIOSCoreDeviceLogForwarder(), - startStopped: true, - ); - launchCompleter.complete(); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, isEmpty); - expect(result, isTrue); - }); }); group('terminate app', () { @@ -3883,6 +3850,7 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { required IOSCoreDeviceLogForwarder coreDeviceLogForwarder, required String deviceId, required String bundleId, + required ShutdownHooks shutdownHooks, List launchArguments = const [], bool startStopped = false, }) async { @@ -4086,3 +4054,23 @@ class FakeIOSCoreDeviceLogForwarder extends Fake implements IOSCoreDeviceLogForw logs.add(log); } } + +/// A [ShutdownHooks] implementation that does not actually execute any hooks. +class FakeShutdownHooks extends Fake implements ShutdownHooks { + @override + bool get isShuttingDown => _isShuttingDown; + var _isShuttingDown = false; + + @override + final registeredHooks = []; + + @override + void addShutdownHook(ShutdownHook shutdownHook) { + registeredHooks.add(shutdownHook); + } + + @override + Future runShutdownHooks(Logger logger) async { + _isShuttingDown = true; + } +} 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 2891370016d..a3a2980730a 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 @@ -12,6 +12,7 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/os.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -1715,6 +1716,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { required String bundlePath, required String bundleId, required List launchArguments, + required ShutdownHooks shutdownHooks, }) async { return true; } 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 816fe0a5452..50b3598724e 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 @@ -11,6 +11,7 @@ import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/template.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; @@ -1802,6 +1803,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { required String bundlePath, required String bundleId, required List launchArguments, + required ShutdownHooks shutdownHooks, }) async { launchedWithLLDB = true; return lldbLaunchResult;