diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 58415c06b313b..4d1e5db082e28 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -39,6 +39,24 @@ import 'xcode_build_settings.dart'; import 'xcode_debug.dart'; import 'xcodeproj.dart'; +const String kJITCrashFailureMessage = + 'Crash occurred when compiling unknown function in unoptimized JIT mode in unknown pass'; + +@visibleForTesting +String jITCrashFailureInstructions(String deviceVersion) => ''' +════════════════════════════════════════════════════════════════════════════════ +A change to iOS has caused a temporary break in Flutter's debug mode on +physical devices. +See https://github.com/flutter/flutter/issues/163984 for details. + +In the meantime, we recommend these temporary workarounds: + +* When developing with a physical device, use one running iOS 18.3 or lower. +* Use a simulator for development rather than a physical device. +* If you must use a device updated to $deviceVersion, use Flutter's release or + profile mode via --release or --profile flags. +════════════════════════════════════════════════════════════════════════════════'''; + class IOSDevices extends PollingDeviceDiscovery { IOSDevices({ required Platform platform, @@ -594,6 +612,7 @@ class IOSDevice extends Device { debuggingOptions: debuggingOptions, packageId: packageId, vmServiceDiscovery: vmServiceDiscovery, + package: package, ); } else if (isWirelesslyConnected) { // Wait for the Dart VM url to be discovered via logs (from `ios-deploy`) @@ -702,6 +721,7 @@ class IOSDevice extends Device { required String packageId, required DebuggingOptions debuggingOptions, ProtocolDiscovery? vmServiceDiscovery, + IOSApp? package, }) async { Timer? maxWaitForCI; final Completer cancelCompleter = Completer(); @@ -743,6 +763,11 @@ class IOSDevice extends Device { }); } + final StreamSubscription? errorListener = await _interceptErrorsFromLogs( + package, + debuggingOptions: debuggingOptions, + ); + final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( packageId, this, @@ -771,9 +796,40 @@ class IOSDevice extends Device { } } maxWaitForCI?.cancel(); + await errorListener?.cancel(); return localUri; } + /// Listen to device logs for crash on iOS 18.4+ due to JIT restriction. If + /// found, give guided error and throw tool exit. Returns null and does not + /// listen if device is less than iOS 18.4. + Future?> _interceptErrorsFromLogs( + IOSApp? package, { + required DebuggingOptions debuggingOptions, + }) async { + // Currently only checking for kJITCrashFailureMessage, which only should + // be checked on iOS 18.4+. + if (sdkVersion == null || sdkVersion! < Version(18, 4, null)) { + return null; + } + final DeviceLogReader deviceLogReader = getLogReader( + app: package, + usingCISystem: debuggingOptions.usingCISystem, + ); + + final Stream logStream = deviceLogReader.logLines; + + final String deviceSdkVersion = await sdkNameAndVersion; + + final StreamSubscription errorListener = logStream.listen((String line) { + if (line.contains(kJITCrashFailureMessage)) { + throwToolExit(jITCrashFailureInstructions(deviceSdkVersion)); + } + }); + + return errorListener; + } + ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({ required IOSApp package, required Directory bundle, 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 485c3a75f51af..3a6ebb571ac88 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 @@ -1101,6 +1101,75 @@ void main() { MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(returnsNull: true), }, ); + + testUsingContext( + 'IOSDevice.startApp prints guided message when iOS 18.4 crashes due to JIT', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory + .childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + sdkVersion: '18.4', + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ), + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine(kJITCrashFailureMessage); + }); + + final Completer completer = Completer(); + // device.startApp() asynchronously calls throwToolExit, so we + // catch it in a zone. + unawaited( + runZoned?>( + () { + unawaited( + device.startApp( + iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ), + ); + return null; + }, + onError: (Object error, StackTrace stack) { + expect(error.toString(), contains(jITCrashFailureInstructions('iOS 18.4'))); + completer.complete(); + }, + ), + ); + await completer.future; + }, + ); }); }); }